feat(nav): turntable orbit, selection/cursor pivot, Shift+MMB rotate (#229)
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 <noreply@anthropic.com>
This commit is contained in:
parent
c303900bd2
commit
98aa051a74
6 changed files with 100 additions and 76 deletions
|
|
@ -1215,6 +1215,7 @@ impl OpenCADStudio {
|
||||||
sel.right_press_time = None;
|
sel.right_press_time = None;
|
||||||
sel.right_last_pos = None;
|
sel.right_last_pos = None;
|
||||||
sel.right_dragging = false;
|
sel.right_dragging = false;
|
||||||
|
sel.orbit_pivot = None;
|
||||||
if !was_click {
|
if !was_click {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
|
@ -1257,6 +1258,9 @@ impl OpenCADStudio {
|
||||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||||
sel.middle_down = false;
|
sel.middle_down = false;
|
||||||
sel.middle_last_pos = None;
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,6 @@ use acadrust::{EntityType as AcadEntityType, Handle};
|
||||||
use iced::time::Instant;
|
use iced::time::Instant;
|
||||||
use iced::{mouse, Point, Task};
|
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).
|
/// Pixel radius for grabbing a UCS-icon grip (origin dot or an axis tip).
|
||||||
const UCS_GRIP_HIT_PX: f32 = 9.0;
|
const UCS_GRIP_HIT_PX: f32 = 9.0;
|
||||||
/// Pixel reach for hovering/clicking the icon body (origin, tips, or an arm).
|
/// 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<Message> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) =
|
let (mid_down, mid_last, vp_size) =
|
||||||
(sel.middle_down, sel.middle_last_pos, sel.vp_size);
|
(sel.middle_down, sel.middle_last_pos, sel.vp_size);
|
||||||
if mid_down {
|
if mid_down {
|
||||||
if let Some(last) = mid_last {
|
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);
|
||||||
|
// 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
|
// Pan scale uses the active tile's size (ortho size
|
||||||
// is relative to viewport height), so a tiled pane
|
// is relative to viewport height), so a tiled pane
|
||||||
// pans at the correct rate.
|
// pans at the correct rate.
|
||||||
|
|
@ -1244,6 +1212,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
sel.right_click_entered = false;
|
sel.right_click_entered = false;
|
||||||
sel.middle_down = false;
|
sel.middle_down = false;
|
||||||
sel.middle_last_pos = None;
|
sel.middle_last_pos = None;
|
||||||
|
sel.orbit_pivot = None;
|
||||||
sel.box_anchor = None;
|
sel.box_anchor = None;
|
||||||
sel.box_anchor_world = None;
|
sel.box_anchor_world = None;
|
||||||
sel.box_current = None;
|
sel.box_current = None;
|
||||||
|
|
|
||||||
|
|
@ -3370,6 +3370,42 @@ impl Scene {
|
||||||
result
|
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<glam::DVec3> {
|
||||||
|
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)> {
|
fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||||
let model_block = self.model_space_block_handle();
|
let model_block = self.model_space_block_handle();
|
||||||
if model_block.is_null() {
|
if model_block.is_null() {
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,8 @@ impl Scene {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return,
|
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))
|
// 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
|
// `camera_for_viewport` reconstructs the rotation so that
|
||||||
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ pub struct SelectionState {
|
||||||
pub right_press_time: Option<Instant>,
|
pub right_press_time: Option<Instant>,
|
||||||
pub right_dragging: bool,
|
pub right_dragging: bool,
|
||||||
pub right_last_pos: Option<Point>,
|
pub right_last_pos: Option<Point>,
|
||||||
|
/// 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<glam::DVec3>,
|
||||||
/// While a command is active, a right-click acts as Enter; the *next*
|
/// While a command is active, a right-click acts as Enter; the *next*
|
||||||
/// consecutive right-click opens the context menu instead. This tracks
|
/// consecutive right-click opens the context menu instead. This tracks
|
||||||
/// whether the previous right-click already fired Enter. Reset by any
|
/// whether the previous right-click already fired Enter. Reset by any
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//
|
//
|
||||||
// Coordinate convention: Z-up world space (same as the rest of OpenCADStudio).
|
// 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};
|
use iced::{Point, Rectangle};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
|
@ -246,29 +246,39 @@ impl Camera {
|
||||||
// ── Navigation ────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Arcball orbit: drag delta (dx, dy) in screen pixels.
|
/// 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<DVec3>) {
|
||||||
if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 {
|
if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let old_rot = self.rotation;
|
||||||
let speed = 0.005_f32;
|
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_right = self.rotation * Vec3::X;
|
||||||
let cam_up = self.rotation * Vec3::Y;
|
let cur_z = (self.rotation * Vec3::Z).z;
|
||||||
let world_axis = (cam_right * screen_axis.x + cam_up * screen_axis.y).normalize_or_zero();
|
let pitched =
|
||||||
|
(Quat::from_axis_angle(cam_right, -delta_y * speed) * self.rotation).normalize();
|
||||||
if world_axis.length_squared() < 1e-12 {
|
let new_z = (pitched * Vec3::Z).z;
|
||||||
return;
|
// 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);
|
// Revolve the target around the pivot by the same rotation delta so the
|
||||||
self.rotation = (delta_rot * self.rotation).normalize();
|
// 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.
|
// Sync legacy yaw/pitch for hit-test functions.
|
||||||
self.sync_yaw_pitch();
|
self.sync_yaw_pitch();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue