Phase 3: Active viewport MSPACE/PSPACE workflow
- Add active_viewport: Option<Handle> 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 <noreply@anthropic.com>
This commit is contained in:
parent
474b38f4fb
commit
523ba74c10
4 changed files with 215 additions and 13 deletions
|
|
@ -61,6 +61,10 @@ pub(super) struct H7CAD {
|
||||||
layout_context_menu: Option<String>,
|
layout_context_menu: Option<String>,
|
||||||
/// Inline rename state: (original_name, current_edit_value).
|
/// Inline rename state: (original_name, current_edit_value).
|
||||||
layout_rename_state: Option<(String, String)>,
|
layout_rename_state: Option<(String, String)>,
|
||||||
|
/// Timestamp of the previous viewport left-click release (for double-click detection).
|
||||||
|
last_vp_click_time: Option<Instant>,
|
||||||
|
/// Screen position of the previous viewport left-click release.
|
||||||
|
last_vp_click_pos: Option<Point>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -210,6 +214,10 @@ pub enum Message {
|
||||||
PropColorPickerToggle,
|
PropColorPickerToggle,
|
||||||
/// Toggle the full ACI colour palette expansion.
|
/// Toggle the full ACI colour palette expansion.
|
||||||
PropColorPaletteToggle,
|
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).
|
/// Switch to a named layout ("Model" or paper space layout name).
|
||||||
LayoutSwitch(String),
|
LayoutSwitch(String),
|
||||||
/// Create a new paper space layout.
|
/// Create a new paper space layout.
|
||||||
|
|
@ -261,6 +269,8 @@ impl H7CAD {
|
||||||
clipboard_centroid: glam::Vec3::ZERO,
|
clipboard_centroid: glam::Vec3::ZERO,
|
||||||
layout_context_menu: None,
|
layout_context_menu: None,
|
||||||
layout_rename_state: None,
|
layout_rename_state: None,
|
||||||
|
last_vp_click_time: None,
|
||||||
|
last_vp_click_pos: None,
|
||||||
};
|
};
|
||||||
app.sync_ribbon_layers();
|
app.sync_ribbon_layers();
|
||||||
app
|
app
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::scene::object::GripApply;
|
||||||
use crate::modules::ModuleEvent;
|
use crate::modules::ModuleEvent;
|
||||||
use crate::ui::PropertiesPanel;
|
use crate::ui::PropertiesPanel;
|
||||||
use acadrust::types::Color as AcadColor;
|
use acadrust::types::Color as AcadColor;
|
||||||
use acadrust::Handle;
|
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||||
use iced::time::Instant;
|
use iced::time::Instant;
|
||||||
use iced::window;
|
use iced::window;
|
||||||
use iced::{mouse, Task};
|
use iced::{mouse, Task};
|
||||||
|
|
@ -670,11 +670,22 @@ impl H7CAD {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sel.middle_down {
|
let (mid_down, mid_last, vp_size) =
|
||||||
if let Some(last) = sel.middle_last_pos {
|
(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);
|
let (dx, dy) = (p.x - last.x, p.y - last.y);
|
||||||
|
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.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);
|
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 tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj);
|
||||||
|
|
||||||
let world_pt = {
|
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 vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
|
||||||
let all_wires = self.tabs[i].scene.entity_wires();
|
let all_wires = self.tabs[i].scene.entity_wires();
|
||||||
let needs_tan = self.tabs[i].active_cmd.as_ref()
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1130,12 +1188,17 @@ impl H7CAD {
|
||||||
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
|
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
|
||||||
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
|
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 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);
|
||||||
|
} else {
|
||||||
let mut cam = self.tabs[i].scene.camera.borrow_mut();
|
let mut cam = self.tabs[i].scene.camera.borrow_mut();
|
||||||
if let Some(cursor) = cursor {
|
if let Some(cursor) = cursor {
|
||||||
cam.zoom_about_point(cursor, bounds, s);
|
cam.zoom_about_point(cursor, bounds, s);
|
||||||
} else {
|
} else {
|
||||||
cam.zoom(s);
|
cam.zoom(s);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1431,9 +1494,10 @@ impl H7CAD {
|
||||||
Message::LayoutSwitch(name) => {
|
Message::LayoutSwitch(name) => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let going_to_paper = name != "Model";
|
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_rename_state = None;
|
||||||
self.layout_context_menu = None;
|
self.layout_context_menu = None;
|
||||||
|
self.tabs[i].scene.active_viewport = None;
|
||||||
self.tabs[i].scene.current_layout = name;
|
self.tabs[i].scene.current_layout = name;
|
||||||
self.tabs[i].scene.deselect_all();
|
self.tabs[i].scene.deselect_all();
|
||||||
self.tabs[i].scene.fit_all();
|
self.tabs[i].scene.fit_all();
|
||||||
|
|
@ -1559,6 +1623,20 @@ impl H7CAD {
|
||||||
Task::none()
|
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::Undo => { self.undo_active_tab(); Task::none() }
|
||||||
Message::Redo => { self.redo_active_tab(); Task::none() }
|
Message::Redo => { self.redo_active_tab(); Task::none() }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,16 @@ impl Camera {
|
||||||
self.target += cam_up * delta_y * speed;
|
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) {
|
pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3) {
|
||||||
self.target = (min + max) * 0.5;
|
self.target = (min + max) * 0.5;
|
||||||
let size = (max - min).length();
|
let size = (max - min).length();
|
||||||
|
|
|
||||||
110
src/scene/mod.rs
110
src/scene/mod.rs
|
|
@ -61,6 +61,10 @@ pub struct Scene {
|
||||||
pub hatches: HashMap<Handle, HatchModel>,
|
pub hatches: HashMap<Handle, HatchModel>,
|
||||||
/// GPU render data for solid meshes (truck Shell/Solid tessellation).
|
/// GPU render data for solid meshes (truck Shell/Solid tessellation).
|
||||||
pub meshes: HashMap<Handle, MeshModel>,
|
pub meshes: HashMap<Handle, MeshModel>,
|
||||||
|
/// The viewport that is currently "entered" (MSPACE mode).
|
||||||
|
/// `None` = paper space editing (PSPACE). Only meaningful when
|
||||||
|
/// `current_layout != "Model"`.
|
||||||
|
pub active_viewport: Option<Handle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scene {
|
impl Scene {
|
||||||
|
|
@ -76,6 +80,7 @@ impl Scene {
|
||||||
current_layout: "Model".to_string(),
|
current_layout: "Model".to_string(),
|
||||||
hatches: HashMap::new(),
|
hatches: HashMap::new(),
|
||||||
meshes: HashMap::new(),
|
meshes: HashMap::new(),
|
||||||
|
active_viewport: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,20 +260,32 @@ impl Scene {
|
||||||
let sel = self.selected.contains(&h);
|
let sel = self.selected.contains(&h);
|
||||||
|
|
||||||
if let EntityType::Viewport(vp) = e {
|
if let EntityType::Viewport(vp) = e {
|
||||||
|
let is_active = self.active_viewport == Some(h);
|
||||||
let color = if vp.id == 1 {
|
let color = if vp.id == 1 {
|
||||||
|
// Overall paper-space viewport — subtle grey.
|
||||||
[0.40, 0.40, 0.40, 1.0]
|
[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 {
|
} else {
|
||||||
|
// Normal user viewport — cyan.
|
||||||
[0.0, 0.75, 0.75, 1.0]
|
[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(
|
return vec![tessellate::tessellate(
|
||||||
&self.document,
|
&self.document,
|
||||||
h,
|
h,
|
||||||
e,
|
e,
|
||||||
sel,
|
sel,
|
||||||
color,
|
color,
|
||||||
0.0,
|
pattern_length,
|
||||||
[0.0f32; 8],
|
pattern,
|
||||||
1.0,
|
1.5,
|
||||||
)];
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,6 +458,93 @@ impl Scene {
|
||||||
result
|
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 ─────────────────────────────────────────────────
|
// ── Layout management ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue