From 523ba74c1032bcd7d4de7631af335a7b03df847f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 31 Mar 2026 07:24:16 +0300 Subject: [PATCH] Phase 3: Active viewport MSPACE/PSPACE workflow - Add active_viewport: Option field to Scene - Add EnterViewport(Handle) and ExitViewport messages - Double-click on a user viewport (id > 1) enters MSPACE; double-click on empty area while in MSPACE exits to PSPACE - MSPACE middle-drag pans the viewport's model-space view (view_target) - MSPACE scroll zooms the viewport's view_height / custom_scale - Drawing commands inside MSPACE receive model-space coordinates via paper_to_model() coordinate transform - Active viewport rendered with yellow dashed border (vs. cyan solid) - Camera screen_delta_to_world helper for MSPACE pan conversion - LayoutSwitch clears active_viewport Co-Authored-By: Claude Sonnet 4.6 --- src/app/mod.rs | 10 ++++ src/app/update.rs | 98 +++++++++++++++++++++++++++++++++++---- src/scene/camera.rs | 10 ++++ src/scene/mod.rs | 110 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 215 insertions(+), 13 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 97696d248..4f360d044 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -61,6 +61,10 @@ pub(super) struct H7CAD { layout_context_menu: Option, /// Inline rename state: (original_name, current_edit_value). layout_rename_state: Option<(String, String)>, + /// Timestamp of the previous viewport left-click release (for double-click detection). + last_vp_click_time: Option, + /// Screen position of the previous viewport left-click release. + last_vp_click_pos: Option, } #[derive(Debug, Clone)] @@ -210,6 +214,10 @@ pub enum Message { PropColorPickerToggle, /// Toggle the full ACI colour palette expansion. PropColorPaletteToggle, + /// Enter the model-space editing mode inside the given viewport (MSPACE). + EnterViewport(acadrust::Handle), + /// Exit MSPACE and return to paper-space editing (PSPACE). + ExitViewport, /// Switch to a named layout ("Model" or paper space layout name). LayoutSwitch(String), /// Create a new paper space layout. @@ -261,6 +269,8 @@ impl H7CAD { clipboard_centroid: glam::Vec3::ZERO, layout_context_menu: None, layout_rename_state: None, + last_vp_click_time: None, + last_vp_click_pos: None, }; app.sync_ribbon_layers(); app diff --git a/src/app/update.rs b/src/app/update.rs index c912de284..5f14342ac 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -6,7 +6,7 @@ use crate::scene::object::GripApply; use crate::modules::ModuleEvent; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; -use acadrust::Handle; +use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::window; use iced::{mouse, Task}; @@ -670,10 +670,21 @@ impl H7CAD { } } - if sel.middle_down { - if let Some(last) = sel.middle_last_pos { + let (mid_down, mid_last, vp_size) = + (sel.middle_down, sel.middle_last_pos, sel.vp_size); + if mid_down { + if let Some(last) = mid_last { let (dx, dy) = (p.x - last.x, p.y - last.y); - self.tabs[i].scene.camera.borrow_mut().pan(dx, dy); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vp_size.0, height: vp_size.1 }; + // Drop `sel` before calling mutable scene methods. + drop(sel); + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i].scene.pan_active_viewport(dx, dy, bounds); + } else { + self.tabs[i].scene.camera.borrow_mut().pan(dx, dy); + } + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); + return Task::none(); } sel.middle_last_pos = Some(p); } @@ -873,7 +884,9 @@ impl H7CAD { let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj); let world_pt = { - let raw = self.tabs[i].scene.camera.borrow().pick_on_target_plane(p, bounds); + let raw_paper = self.tabs[i].scene.camera.borrow().pick_on_target_plane(p, bounds); + // Convert paper-space → model-space when inside a viewport. + let raw = self.tabs[i].scene.paper_to_model(raw_paper); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let all_wires = self.tabs[i].scene.entity_wires(); let needs_tan = self.tabs[i].active_cmd.as_ref() @@ -1059,6 +1072,51 @@ 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 + && self.tabs[i].active_cmd.is_none() + && self.tabs[i].scene.current_layout != "Model" + { + let now = Instant::now(); + let is_double = self + .last_vp_click_time + .map(|t| { + let dt = now.duration_since(t).as_millis(); + let last = self.last_vp_click_pos.unwrap_or(p); + let d = (p.x - last.x).hypot(p.y - last.y); + dt < 400 && d < 8.0 + }) + .unwrap_or(false); + + self.last_vp_click_time = Some(now); + self.last_vp_click_pos = Some(p); + + 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)); + } + } + } else if self.tabs[i].scene.active_viewport.is_some() { + // Double-clicked on empty area while in MSPACE → exit. + return Task::done(Message::ExitViewport); + } + } + } + Task::none() } @@ -1130,11 +1188,16 @@ impl H7CAD { let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; 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 mut cam = self.tabs[i].scene.camera.borrow_mut(); - if let Some(cursor) = cursor { - cam.zoom_about_point(cursor, bounds, s); + 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); } else { - cam.zoom(s); + let mut cam = self.tabs[i].scene.camera.borrow_mut(); + if let Some(cursor) = cursor { + cam.zoom_about_point(cursor, bounds, s); + } else { + cam.zoom(s); + } } Task::none() } @@ -1431,9 +1494,10 @@ impl H7CAD { Message::LayoutSwitch(name) => { let i = self.active_tab; let going_to_paper = name != "Model"; - // Cancel any pending rename/context-menu when switching layouts. + // Cancel any pending rename/context-menu and active viewport when switching. self.layout_rename_state = None; self.layout_context_menu = None; + self.tabs[i].scene.active_viewport = None; self.tabs[i].scene.current_layout = name; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.fit_all(); @@ -1559,6 +1623,20 @@ impl H7CAD { Task::none() } + Message::EnterViewport(handle) => { + let i = self.active_tab; + self.tabs[i].scene.active_viewport = Some(handle); + self.command_line.push_output("MSPACE — viewport entered. Middle-drag/scroll to navigate, double-click outside to exit."); + Task::none() + } + + Message::ExitViewport => { + let i = self.active_tab; + self.tabs[i].scene.active_viewport = None; + self.command_line.push_output("PSPACE"); + Task::none() + } + Message::Undo => { self.undo_active_tab(); Task::none() } Message::Redo => { self.redo_active_tab(); Task::none() } diff --git a/src/scene/camera.rs b/src/scene/camera.rs index 91a5e1648..36122cb39 100644 --- a/src/scene/camera.rs +++ b/src/scene/camera.rs @@ -201,6 +201,16 @@ impl Camera { self.target += cam_up * delta_y * speed; } + /// Returns the world-space translation that `pan(delta_x, delta_y)` would + /// apply to the camera target — without actually moving the camera. + /// Used by MSPACE to pan the viewport's model-space view independently. + pub fn screen_delta_to_world(&self, delta_x: f32, delta_y: f32, _bounds: Rectangle) -> Vec3 { + let speed = self.distance * 0.001; + let cam_right = self.rotation * Vec3::X; + let cam_up = self.rotation * Vec3::Y; + -(cam_right * delta_x * speed) + (cam_up * delta_y * speed) + } + pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3) { self.target = (min + max) * 0.5; let size = (max - min).length(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 579de63e1..4a53f1165 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -61,6 +61,10 @@ pub struct Scene { pub hatches: HashMap, /// GPU render data for solid meshes (truck Shell/Solid tessellation). pub meshes: HashMap, + /// The viewport that is currently "entered" (MSPACE mode). + /// `None` = paper space editing (PSPACE). Only meaningful when + /// `current_layout != "Model"`. + pub active_viewport: Option, } impl Scene { @@ -76,6 +80,7 @@ impl Scene { current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), + active_viewport: None, } } @@ -255,20 +260,32 @@ impl Scene { let sel = self.selected.contains(&h); if let EntityType::Viewport(vp) = e { + let is_active = self.active_viewport == Some(h); let color = if vp.id == 1 { + // Overall paper-space viewport — subtle grey. [0.40, 0.40, 0.40, 1.0] + } else if is_active { + // Active (entered) viewport — bright yellow. + [1.0, 0.90, 0.20, 1.0] } else { + // Normal user viewport — cyan. [0.0, 0.75, 0.75, 1.0] }; + // Active viewport gets a dashed border to visually indicate MSPACE. + let (pattern_length, pattern) = if is_active { + (1.5_f32, [0.8, -0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0_f32]) + } else { + (0.0_f32, [0.0f32; 8]) + }; return vec![tessellate::tessellate( &self.document, h, e, sel, color, - 0.0, - [0.0f32; 8], - 1.0, + pattern_length, + pattern, + 1.5, )]; } @@ -441,6 +458,93 @@ impl Scene { result } + // ── MSPACE helpers ─────────────────────────────────────────────────── + + /// Convert a **paper-space** world coordinate to **model-space** using the + /// geometry of the currently active viewport. Returns the input unchanged + /// when there is no active viewport. + pub fn paper_to_model(&self, paper_pt: glam::Vec3) -> glam::Vec3 { + let vp_handle = match self.active_viewport { + Some(h) => h, + None => return paper_pt, + }; + let vp = match self.document.get_entity(vp_handle) { + Some(acadrust::EntityType::Viewport(vp)) => vp, + _ => return paper_pt, + }; + let scale = 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 + }; + if scale.abs() < 1e-9 { + return paper_pt; + } + let tx = vp.view_target.x as f32; + let ty = vp.view_target.y as f32; + let pcx = vp.center.x as f32; + let pcy = vp.center.y as f32; + glam::Vec3::new( + (paper_pt.x - pcx) / scale + tx, + (paper_pt.y - pcy) / scale + ty, + paper_pt.z, + ) + } + + /// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels. + /// The delta is converted to model-space units using the camera and viewport scale. + /// No-op when there is no active viewport. + pub fn pan_active_viewport(&mut self, screen_dx: f32, screen_dy: f32, bounds: iced::Rectangle) { + let vp_handle = match self.active_viewport { + Some(h) => h, + None => return, + }; + // Convert screen pixels → paper-space delta using the camera. + let cam = self.camera.borrow(); + let paper_delta = cam.screen_delta_to_world(screen_dx, screen_dy, bounds); + drop(cam); + + if let Some(acadrust::EntityType::Viewport(vp)) = + self.document.get_entity_mut(vp_handle) + { + let scale = if vp.custom_scale.abs() > 1e-9 { + vp.custom_scale + } else if vp.view_height.abs() > 1e-9 { + vp.height / vp.view_height + } else { + 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; + } + } + + /// Zoom the active viewport's model-space view by `steps` notches. + /// Positive = zoom in (increase detail), negative = zoom out. + /// No-op when there is no active viewport. + pub fn zoom_active_viewport(&mut self, steps: f32) { + let vp_handle = match self.active_viewport { + Some(h) => h, + None => return, + }; + 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. + 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; + } + } + } + // ── Layout management ───────────────────────────────────────────────── /// Rename a paper-space layout. Updates the Layout object name in the document.