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 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 01:23:57 -04:00
1 parent 8188c7ed0a
commit c35fa82373
4 files changed
+230 -25

No files matched your search

+97 -3
View File
@@ -67,9 +67,70 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Vec<(Handle, Viewport)>> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
}
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Space> {
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());
}
}
+1
View File
@@ -206,6 +206,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
};
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)
+69 -9
View File
@@ -89,6 +89,8 @@ pub struct DocState {
pub saved: Arc<Drawing>,
pub revision: u64,
pub space: Space,
/// MSPACE: the layout viewport whose model space is being edited (None = paper space).
pub mspace: Option<Handle>,
pub views: Vec<(Space, View)>,
pub view_history: Vec<View>,
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<Handle> {
+63 -13
View File
@@ -78,7 +78,7 @@ pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
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<crate::gpu::GpuTarget>,
/// 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)
{