From 98aa051a74ee072be2c44dcb1ffeed9bc573f15f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 1 Jul 2026 21:58:04 +0300 Subject: [PATCH] feat(nav): turntable orbit, selection/cursor pivot, Shift+MMB rotate (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework 3D view navigation: - Turntable / lock-horizon orbit: horizontal drag yaws about world +Z, vertical drag pitches about the horizontal camera-right axis, so the view never banks. Pitch is pole-clamped but the straight-down default view can still be tilted out of. - Orbit centre: the selection's centre when something is selected, otherwise the point under the cursor — no longer only the file centre. The camera revolves rigidly about the pivot, so it stays put on screen and the view doesn't jump. - Mouse scheme: Zoom = wheel, Pan = MMB, Rotate = Shift+MMB. Right-drag no longer orbits (right-click stays context menu / Enter). Floating viewports orbit with Shift+MMB too. Co-Authored-By: Claude Opus 4.8 --- src/app/update/mod.rs | 4 ++ src/app/update/viewport.rs | 99 +++++++++++-------------------- src/scene/mod.rs | 36 +++++++++++ src/scene/mspace.rs | 3 +- src/scene/pick/selection_state.rs | 4 ++ src/scene/view/camera.rs | 42 ++++++++----- 6 files changed, 106 insertions(+), 82 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6153a6d6f..5ba9ac5c8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1215,6 +1215,7 @@ impl OpenCADStudio { sel.right_press_time = None; sel.right_last_pos = None; sel.right_dragging = false; + sel.orbit_pivot = None; if !was_click { return Task::none(); } @@ -1257,6 +1258,9 @@ impl OpenCADStudio { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; + // End of a Shift+MMB orbit — drop the captured pivot so the next + // gesture recomputes it against the current selection. (#229) + sel.orbit_pivot = None; Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f73bb1765..eb3807e3e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -20,10 +20,6 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; -/// How long the right button must be held (ms) before a right-drag is treated -/// as an orbit. A press + release inside this window stays a click (context -/// menu / Enter), so quick right-clicks aren't lost to slight pointer jitter. -const RIGHT_HOLD_MS: u128 = 150; /// Pixel radius for grabbing a UCS-icon grip (origin dot or an axis tip). const UCS_GRIP_HIT_PX: f32 = 9.0; /// Pixel reach for hovering/clicking the icon body (origin, tips, or an arm). @@ -571,72 +567,44 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } } - if sel.right_down { - // Time-based click-vs-orbit split: a quick right-click - // (press + release within RIGHT_HOLD_MS) is always a click — - // it opens the context menu / fires Enter on release, even if - // the pointer drifted a little. Orbit only engages once the - // button has been *held* past the threshold and the pointer - // is actually moving, so hand jitter during a click never - // promotes it to an orbit drag. - if !sel.right_dragging { - let held = sel - .right_press_time - .map(|t| t.elapsed().as_millis() >= RIGHT_HOLD_MS) - .unwrap_or(false); - let moved = sel - .right_press_pos - .map(|press| { - let (dx, dy) = (p.x - press.x, p.y - press.y); - dx * dx + dy * dy > 4.0 - }) - .unwrap_or(false); - if held && moved { - sel.right_dragging = true; - sel.context_menu = None; - // An orbit restarts the right-click cycle. - sel.right_click_entered = false; - // Start the orbit from the current position so the - // view doesn't jump by the pre-threshold movement. - sel.right_last_pos = Some(p); - } - } - if sel.right_dragging { - if let Some(last) = sel.right_last_pos { - let (dx, dy) = (p.x - last.x, p.y - last.y); - if self.tabs[i].scene.active_viewport.is_some() { - // Update position before dropping the borrow. - sel.right_last_pos = Some(p); - drop(sel); - self.tabs[i].scene.orbit_active_viewport(dx, dy); - // Bump so the GPU re-uploads the viewport's - // re-culled wire set after the view rotates. - self.tabs[i].scene.camera_generation += 1; - return Task::none(); - } else if self.tabs[i].scene.current_layout == "Model" { - sel.right_last_pos = Some(p); - drop(sel); - self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy); - self.tabs[i].scene.camera_generation += 1; - return Task::none(); - } else { - // Paper sheet is top-locked: right-drag never - // orbits it (orbiting would corrupt the camera - // frame and skew subsequent pans). - sel.right_last_pos = Some(p); - return Task::none(); - } - } else { - sel.right_last_pos = Some(p); - } - } - } - 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); + // Shift+MMB drag orbits the model view instead of panning + // — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB + // scheme (#229). Floating viewports and paper keep the + // plain MMB pan. + if self.shift_down { + if self.tabs[i].scene.active_viewport.is_some() { + // Orbit the floating viewport's own model view. + drop(sel); + self.tabs[i].scene.orbit_active_viewport(dx, dy); + self.tabs[i].scene.camera_generation += 1; + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = + Some(p); + return Task::none(); + } else if self.tabs[i].scene.current_layout == "Model" { + if sel.orbit_pivot.is_none() { + // Selection centre when something is selected, + // otherwise the point under the cursor. (#229) + sel.orbit_pivot = self.tabs[i] + .scene + .orbit_pivot() + .or(Some(self.tabs[i].last_cursor_world.as_dvec3())); + } + let pivot = sel.orbit_pivot; + drop(sel); + self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot); + self.tabs[i].scene.camera_generation += 1; + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = + Some(p); + return Task::none(); + } + // Paper sheet is top-locked: Shift+MMB falls through + // to the plain pan below. + } // Pan scale uses the active tile's size (ortho size // is relative to viewport height), so a tiled pane // pans at the correct rate. @@ -1244,6 +1212,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { sel.right_click_entered = false; sel.middle_down = false; sel.middle_last_pos = None; + sel.orbit_pivot = None; sel.box_anchor = None; sel.box_anchor_world = None; sel.box_current = None; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 120917245..3bad1d708 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3370,6 +3370,42 @@ impl Scene { result } + /// The AABB centre of the current selection, in absolute world coordinates + /// (same space as `Camera::target`) — the point the 3D view orbits around + /// when something is selected. `None` when nothing is selected; the caller + /// then orbits about the point under the cursor. (#229) + pub fn orbit_pivot(&self) -> Option { + if self.selected.is_empty() { + return None; + } + let block = self.current_layout_block_handle(); + let wires = self.wires_for_block_culled(block, None, None, None, None); + let mut min = glam::DVec2::splat(f64::INFINITY); + let mut max = glam::DVec2::splat(f64::NEG_INFINITY); + let mut any = false; + for wire in &wires { + let Some(h) = Self::handle_from_wire_name(&wire.name) else { + continue; + }; + if !self.selected.contains(&h) { + continue; + } + for &[x, y, _] in &wire.points { + if x.is_finite() && y.is_finite() { + min = min.min(glam::DVec2::new(x as f64, y as f64)); + max = max.max(glam::DVec2::new(x as f64, y as f64)); + any = true; + } + } + } + if any { + let c = (min + max) * 0.5; + Some(glam::DVec3::new(c.x, c.y, 0.0)) + } else { + None + } + } + fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { let model_block = self.model_space_block_handle(); if model_block.is_null() { diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index e8fa8dc5c..f5688408c 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -220,7 +220,8 @@ impl Scene { Some(c) => c, None => return, }; - cam.orbit(delta_x, delta_y); + // Floating viewport orbits about its own target (no selection pivot). + cam.orbit(delta_x, delta_y, None); // yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p)) // `camera_for_viewport` reconstructs the rotation so that // `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)` diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 935f9e578..3ed9124d2 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -32,6 +32,10 @@ pub struct SelectionState { pub right_press_time: Option, pub right_dragging: bool, pub right_last_pos: Option, + /// World point the current orbit drag revolves around (selection or model + /// centre), captured when the drag starts so it stays fixed for the whole + /// gesture. `None` when no orbit is in progress. (#229) + pub orbit_pivot: Option, /// While a command is active, a right-click acts as Enter; the *next* /// consecutive right-click opens the context menu instead. This tracks /// whether the previous right-click already fired Enter. Reset by any diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 984d9b6fe..666b1f8b9 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -11,7 +11,7 @@ // // Coordinate convention: Z-up world space (same as the rest of OpenCADStudio). -use glam::{vec3, DVec3, Mat4, Quat, Vec3}; +use glam::{DVec3, Mat4, Quat, Vec3}; use iced::{Point, Rectangle}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -246,29 +246,39 @@ impl Camera { // ── Navigation ──────────────────────────────────────────────────────── /// Arcball orbit: drag delta (dx, dy) in screen pixels. - pub fn orbit(&mut self, delta_x: f32, delta_y: f32) { + /// Orbit the view by a screen drag, turntable-style: horizontal drag yaws + /// about world +Z, vertical drag pitches about the camera's (always + /// horizontal) right axis — so the horizon never banks. `pivot` is the world + /// point to revolve around (selection or model centre); `None` keeps the + /// current target as the centre. (#229) + pub fn orbit(&mut self, delta_x: f32, delta_y: f32, pivot: Option) { if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 { return; } - + let old_rot = self.rotation; let speed = 0.005_f32; - let angle = (delta_x * delta_x + delta_y * delta_y).sqrt() * speed; - - // Screen drag → rotation axis: right drag = rotate around cam_up (Y), - // down drag = rotate around cam_right (X). Negate so drag direction - // matches intuitive "grab and spin" arcball behaviour. - let screen_axis = vec3(-delta_y, -delta_x, 0.0).normalize_or_zero(); + let yaw = Quat::from_rotation_z(-delta_x * speed); + self.rotation = (yaw * self.rotation).normalize(); let cam_right = self.rotation * Vec3::X; - let cam_up = self.rotation * Vec3::Y; - let world_axis = (cam_right * screen_axis.x + cam_up * screen_axis.y).normalize_or_zero(); - - if world_axis.length_squared() < 1e-12 { - return; + let cur_z = (self.rotation * Vec3::Z).z; + let pitched = + (Quat::from_axis_angle(cam_right, -delta_y * speed) * self.rotation).normalize(); + let new_z = (pitched * Vec3::Z).z; + // Accept the pitch while the gaze stays clear of the poles, OR when it + // moves back AWAY from a pole — so the straight-down default view (z = 1) + // can still be tilted out of (a hard `< 0.9995` gate alone would freeze + // small drags there). Only a step deeper INTO a pole is rejected. + if new_z.abs() < 0.9995 || new_z.abs() < cur_z.abs() { + self.rotation = pitched; } - let delta_rot = Quat::from_axis_angle(world_axis, angle); - self.rotation = (delta_rot * self.rotation).normalize(); + // Revolve the target around the pivot by the same rotation delta so the + // view orbits the chosen centre instead of the fixed file centre (#229). + if let Some(p) = pivot { + let delta = self.rotation * old_rot.conjugate(); + self.target = p + (delta * (self.target - p).as_vec3()).as_dvec3(); + } // Sync legacy yaw/pitch for hit-test functions. self.sync_yaw_pitch();