diff --git a/src/app/commands.rs b/src/app/commands.rs index 118b6c35f..90fb8fd77 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -901,11 +901,19 @@ impl H7CAD { } } + // ── MSPACE / PSPACE ─────────────────────────────────────────── + "MS"|"MSPACE" => { + return Task::done(Message::MspaceCommand); + } + "PSPACE" => { + return Task::done(Message::PspaceCommand); + } + // ── Plot / Page Setup ────────────────────────────────────────── "PRINT"|"PLOT"|"EXPORT" => { return Task::done(Message::PlotExport); } - "PAGESETUP"|"PS" => { + "PAGESETUP" => { if self.tabs[i].scene.current_layout == "Model" { self.command_line.push_error("PAGESETUP: switch to a paper space layout first."); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c123c5c1..45b482cdc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -224,6 +224,10 @@ pub enum Message { EnterViewport(acadrust::Handle), /// Exit MSPACE and return to paper-space editing (PSPACE). ExitViewport, + /// MS command: enter MSPACE for the first available viewport. + MspaceCommand, + /// PS command: exit MSPACE (PSPACE). + PspaceCommand, /// Switch to a named layout ("Model" or paper space layout name). LayoutSwitch(String), /// Create a new paper space layout. diff --git a/src/app/update.rs b/src/app/update.rs index 04f0f4176..231f22981 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -312,6 +312,9 @@ impl H7CAD { if let Some(r) = result { return self.apply_cmd_result(r); } + } else if self.tabs[i].scene.active_viewport.is_some() { + // ESC while in MSPACE → exit back to paper space. + return Task::done(Message::ExitViewport); } else { self.tabs[i].scene.deselect_all(); self.refresh_properties(); @@ -697,9 +700,10 @@ impl H7CAD { let (vw, vh) = vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; let cam = self.tabs[i].scene.camera.borrow(); - let raw = cam.pick_on_target_plane(p, bounds); + let raw_paper = cam.pick_on_target_plane(p, bounds); let vp_mat = cam.view_proj(bounds); drop(cam); + let raw = self.tabs[i].scene.paper_to_model(raw_paper); let edited_name = grip.handle.value().to_string(); let all_wires = self.tabs[i].scene.entity_wires(); @@ -738,9 +742,12 @@ impl H7CAD { let (vw, vh) = vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; let cam = self.tabs[i].scene.camera.borrow(); - let cursor_world = cam.pick_on_target_plane(p, bounds); + let cursor_paper = cam.pick_on_target_plane(p, bounds); let view_proj = cam.view_proj(bounds); drop(cam); + // In MSPACE, map paper-space cursor to model space so that + // command previews and snapping work in the correct coordinate space. + let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); let all_wires = self.tabs[i].scene.entity_wires(); let needs_tan = self.tabs[i] @@ -1075,7 +1082,7 @@ impl H7CAD { // ── Double-click: enter/exit MSPACE ─────────────────────── // Only when no command is running, no drag, and we're in paper space. if is_click - && !is_down2 + && is_down // ensures there was a matching left-press && self.tabs[i].active_cmd.is_none() && self.tabs[i].scene.current_layout != "Model" { @@ -1096,22 +1103,42 @@ impl H7CAD { if is_double { let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let all_wires = self.tabs[i].scene.entity_wires(); - let hit = scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) - .and_then(|s| Scene::handle_from_wire_name(s)); - if let Some(handle) = hit { - // Double-clicked on a user viewport → enter MSPACE. - if let Some(AcadEntityType::Viewport(vp)) = - self.tabs[i].scene.document.get_entity(handle) - { - if vp.id > 1 { - return Task::done(Message::EnterViewport(handle)); - } - } + // 1) Try direct wire hit — works when the border is clicked. + let hit_vp: Option = { + let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let all_wires = self.tabs[i].scene.entity_wires(); + scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) + .and_then(|s| Scene::handle_from_wire_name(s)) + .and_then(|h| { + if let Some(AcadEntityType::Viewport(vp)) = + self.tabs[i].scene.document.get_entity(h) + { + if vp.id > 1 { Some(h) } else { None } + } else { + None + } + }) + }; + + // 2) Geometric fallback: check if the cursor is inside any + // viewport's bounding rectangle in paper space. This handles + // double-clicks on model-entity content wires or empty areas. + let hit_vp = hit_vp.or_else(|| { + let paper_pt = self.tabs[i] + .scene + .camera + .borrow() + .pick_on_target_plane(p, bounds); + self.tabs[i] + .scene + .viewport_at_paper_point(paper_pt.x, paper_pt.y) + }); + + if let Some(handle) = hit_vp { + return Task::done(Message::EnterViewport(handle)); } else if self.tabs[i].scene.active_viewport.is_some() { - // Double-clicked on empty area while in MSPACE → exit. + // Double-clicked outside all viewports while in MSPACE → exit. return Task::done(Message::ExitViewport); } } @@ -1189,8 +1216,14 @@ impl H7CAD { let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; if self.tabs[i].scene.active_viewport.is_some() { - // In MSPACE: zoom the active viewport's model-space view. - self.tabs[i].scene.zoom_active_viewport(s); + // In MSPACE: zoom the active viewport's model-space view, + // keeping the model point under the cursor stationary. + let cursor_paper = cursor.map(|cp| { + let pt = self.tabs[i].scene.camera.borrow() + .pick_on_target_plane(cp, bounds); + glam::Vec2::new(pt.x, pt.y) + }); + self.tabs[i].scene.zoom_active_viewport(s, cursor_paper); } else { let mut cam = self.tabs[i].scene.camera.borrow_mut(); if let Some(cursor) = cursor { @@ -1658,6 +1691,29 @@ impl H7CAD { Task::none() } + Message::MspaceCommand => { + let i = self.active_tab; + if self.tabs[i].scene.current_layout == "Model" { + self.command_line.push_error("MS is only available in paper space layouts."); + return Task::none(); + } + if self.tabs[i].scene.active_viewport.is_some() { + // Already in MSPACE — nothing to do. + return Task::none(); + } + match self.tabs[i].scene.first_user_viewport() { + Some(handle) => Task::done(Message::EnterViewport(handle)), + None => { + self.command_line.push_error("No viewport found in this layout."); + Task::none() + } + } + } + + Message::PspaceCommand => { + Task::done(Message::ExitViewport) + } + Message::Undo => { self.undo_active_tab(); Task::none() } Message::Redo => { self.redo_active_tab(); Task::none() } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0ac683737..3c72915d2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -744,16 +744,20 @@ impl Scene { 1.0 }; if scale.abs() < 1e-12 { return; } - // Paper delta → model delta (divide by viewport scale). - vp.view_target.x -= (paper_delta.x / scale as f32) as f64; - vp.view_target.y -= (paper_delta.y / scale as f32) as f64; + // screen_delta_to_world returns the same delta that cam.pan() ADDS to its + // target, so we add it here too (dividing by viewport scale to convert from + // paper-space to model-space). Using -= would invert the drag direction. + vp.view_target.x += (paper_delta.x / scale as f32) as f64; + vp.view_target.y += (paper_delta.y / scale as f32) as f64; } } /// Zoom the active viewport's model-space view by `steps` notches. /// Positive = zoom in (increase detail), negative = zoom out. + /// `cursor_paper`: optional paper-space XY of the cursor; when supplied the + /// model point under the cursor is kept stationary (AutoCAD-style zoom). /// No-op when there is no active viewport. - pub fn zoom_active_viewport(&mut self, steps: f32) { + pub fn zoom_active_viewport(&mut self, steps: f32, cursor_paper: Option) { let vp_handle = match self.active_viewport { Some(h) => h, None => return, @@ -761,17 +765,83 @@ impl Scene { if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { - // Zoom in = shrink view_height → more of the model fits less area. + // Zoom in = shrink view_height → higher scale → objects appear larger. let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0); - let new_height = (vp.view_height * factor).max(1e-6); - vp.view_height = new_height; - // Keep custom_scale in sync. - if vp.view_height.abs() > 1e-9 { - vp.custom_scale = vp.height / vp.view_height; + + if let Some(cp) = cursor_paper { + // Compute the model-space point under the cursor before zoom. + let scale_before = if vp.custom_scale.abs() > 1e-9 { + vp.custom_scale as f32 + } else if vp.view_height.abs() > 1e-9 { + (vp.height / vp.view_height) as f32 + } else { + 1.0 + }; + let cx = vp.center.x as f32; + let cy = vp.center.y as f32; + let tx = vp.view_target.x as f32; + let ty = vp.view_target.y as f32; + let mx = (cp.x - cx) / scale_before + tx; + let my = (cp.y - cy) / scale_before + ty; + + // Apply zoom. + vp.view_height = (vp.view_height * factor).max(1e-6); + if vp.view_height.abs() > 1e-9 { + vp.custom_scale = vp.height / vp.view_height; + } + let scale_after = vp.custom_scale as f32; + + // Adjust view_target so the model point under cursor stays there. + let mx_after = (cp.x - cx) / scale_after + vp.view_target.x as f32; + let my_after = (cp.y - cy) / scale_after + vp.view_target.y as f32; + vp.view_target.x += (mx - mx_after) as f64; + vp.view_target.y += (my - my_after) as f64; + } else { + vp.view_height = (vp.view_height * factor).max(1e-6); + if vp.view_height.abs() > 1e-9 { + vp.custom_scale = vp.height / vp.view_height; + } } } } + /// Return the handle of the user viewport whose bounding rectangle contains + /// the given paper-space point, or `None` if no viewport matches. + pub fn viewport_at_paper_point(&self, px: f32, py: f32) -> Option { + let layout_block = self.current_layout_block_handle(); + self.document + .entities() + .find_map(|e| { + let EntityType::Viewport(vp) = e else { return None; }; + if vp.id <= 1 || vp.common.owner_handle != layout_block || !vp.status.is_on { + return None; + } + let hw = (vp.width / 2.0) as f32; + let hh = (vp.height / 2.0) as f32; + let cx = vp.center.x as f32; + let cy = vp.center.y as f32; + if px >= cx - hw && px <= cx + hw && py >= cy - hh && py <= cy + hh { + Some(vp.common.handle) + } else { + None + } + }) + } + + /// Return the handle of the first active user viewport in the current layout, + /// or `None` if there are none. Used by the MS command. + pub fn first_user_viewport(&self) -> Option { + let layout_block = self.current_layout_block_handle(); + self.document.entities().find_map(|e| { + let EntityType::Viewport(vp) = e else { return None; }; + if vp.id > 1 && vp.common.owner_handle == layout_block && vp.status.is_on { + Some(vp.common.handle) + } else { + None + } + }) + } + // ── Layout management ───────────────────────────────────────────────── /// Rename a paper-space layout. Updates the Layout object name in the document.