From 451239c4f987e053770c8416fb66185167c8dd81 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 27 May 2026 08:09:38 +0300 Subject: [PATCH] fix(viewcube): snap to direction with nearest-canonical up axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewCube tıklaması artık sabit (yaw, pitch) tablosu yerine bir yön vektörüne (eye direction) snap ediyor. Up vektörü şöyle seçiliyor: 1. Mevcut up alın. 2. ±X / ±Y / ±Z arasından (yeni gaze ile (anti-)paralel olmayanlar) mevcut up'a en yüksek dot-product veren cardinal axis'i seç. 3. Bu axis'i yeni gaze yönüne dik plane'e projekte et ve up olarak kullan. Sonuç: - Küçük tilt'ler ilgili canonical axis'e snap olarak temizleniyor. - TOP yukarıda iken TOP-LEFT edge tıklayınca TOP yukarıda kalıyor; cube edge'e dönüyor ama kullanıcının "yukarı" hissi korunuyor. - Upside-down orbit edilmişse -Z up olarak seçilir, snap baş aşağı yapılandırmayı bozmuyor. CubeRegion::snap_angles ve Camera::snap_to_angles yerini snap_direction / snap_to_direction aldı. snap_active_viewport_to_direction viewport'un view_direction'ını günceller, twist_angle'a dokunmaz. Co-Authored-By: Claude Opus 4.7 --- src/app/update.rs | 33 +++++--------- src/scene/camera.rs | 70 ++++++++++++++++++++++++----- src/scene/mod.rs | 19 ++++---- src/scene/pipeline/viewcube.rs | 81 ++++++++++++++++++++++------------ 4 files changed, 135 insertions(+), 68 deletions(-) diff --git a/src/app/update.rs b/src/app/update.rs index e579a1424..e68c98192 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2568,34 +2568,25 @@ impl OpenCADStudio { Message::ViewCubeSnap(region) => { let i = self.active_tab; let mut region = region; - let (yaw, pitch) = { - let (target_yaw, target_pitch) = region.snap_angles(); - // Check current orientation to detect "already there → flip to opposite". - let already_there = if let Some((cur_yaw, cur_pitch)) = - self.tabs[i].scene.active_viewport_yaw_pitch() - { - angle_close(cur_yaw, target_yaw, 0.01) - && angle_close(cur_pitch, target_pitch, 0.01) - } else if self.tabs[i].scene.active_viewport.is_some() { - false - } else { - let cam = self.tabs[i].scene.camera.borrow(); - angle_close(cam.yaw, target_yaw, 0.01) - && angle_close(cam.pitch, target_pitch, 0.01) - }; - if already_there { - region = region.opposite(); - } - region.snap_angles() + // "Already there → flip to opposite" check: compare the + // current gaze direction with the region's target gaze. + let target_dir = region.snap_direction(); + let cur_dir = { + let cam = self.tabs[i].scene.camera.borrow(); + cam.rotation * glam::Vec3::Z }; + if cur_dir.dot(target_dir) > 0.9999 { + region = region.opposite(); + } + let eye_dir = region.snap_direction(); if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] .scene - .snap_active_viewport_to_angles(yaw, pitch); + .snap_active_viewport_to_direction(eye_dir); } else { let mut cam = self.tabs[i].scene.camera.borrow_mut(); - cam.snap_to_angles(yaw, pitch); + cam.snap_to_direction(eye_dir); } self.tabs[i].scene.camera_generation += 1; self.command_line diff --git a/src/scene/camera.rs b/src/scene/camera.rs index a0b04fccb..8ae06c660 100644 --- a/src/scene/camera.rs +++ b/src/scene/camera.rs @@ -46,7 +46,7 @@ impl Default for Camera { let pitch = std::f32::consts::FRAC_PI_2; Self { target: Vec3::ZERO, - rotation: yaw_pitch_to_quat(yaw, pitch), + rotation: yaw_pitch_to_quat(yaw, pitch, 0.0), distance: 60.36, fov_y: 45.0_f32.to_radians(), projection: Projection::Orthographic, @@ -250,12 +250,59 @@ impl Camera { // ── ViewCube snap ───────────────────────────────────────────────────── - /// Snap to canonical yaw+pitch (called by ViewCubeSnap message). - /// Updates the arcball quaternion to match. - pub fn snap_to_angles(&mut self, yaw: f32, pitch: f32) { - self.yaw = yaw; - self.pitch = pitch; - self.rotation = yaw_pitch_to_quat(yaw, pitch); + /// Snap to a canonical view direction (called by ViewCubeSnap). + /// `eye_dir` is the unit vector from the target toward the camera. + /// + /// Up vector resolution: + /// 1. Take the current up. + /// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the + /// current up is highest — skipping any axis (anti-)parallel to + /// the new gaze direction. + /// 3. Project that axis onto the plane ⊥ `new_eye` and use that as + /// the new up. + /// + /// Result: small tilts collapse onto the nearest world axis (so the + /// view always lands cleanly aligned), while genuine flips of the + /// up-sense (e.g. orbited upside-down) are preserved. + pub fn snap_to_direction(&mut self, eye_dir: Vec3) { + let new_eye = eye_dir.normalize_or(Vec3::Z); + let cur_up = self.rotation * Vec3::Y; + let cardinals = [ + Vec3::X, + Vec3::NEG_X, + Vec3::Y, + Vec3::NEG_Y, + Vec3::Z, + Vec3::NEG_Z, + ]; + let mut best_score = f32::NEG_INFINITY; + let mut best_up = Vec3::Z; + for axis in cardinals { + // Skip axes (nearly) collinear with the new gaze — they can't + // serve as up because the projection onto the plane would + // vanish. + if axis.dot(new_eye).abs() > 0.999 { + continue; + } + let score = axis.dot(cur_up); + if score > best_score { + best_score = score; + best_up = axis; + } + } + // Project the chosen axis onto the plane ⊥ new_eye and normalize. + let projected = best_up - new_eye * best_up.dot(new_eye); + let new_up = projected.normalize_or(if new_eye.z.abs() < 0.99 { + (Vec3::Z - new_eye * Vec3::Z.dot(new_eye)).normalize() + } else { + (Vec3::Y - new_eye * Vec3::Y.dot(new_eye)).normalize() + }); + let new_right = new_up.cross(new_eye).normalize(); + // Camera rotation columns: [cam_x | cam_y | cam_z] where + // cam_z = eye_dir (canonical "+Z is toward eye"), cam_y = up. + let mat = glam::Mat3::from_cols(new_right, new_up, new_eye); + self.rotation = Quat::from_mat3(&mat).normalize(); + self.sync_yaw_pitch(); } // ── Internal helpers ─────────────────────────────────────────────────── @@ -283,11 +330,14 @@ impl Camera { /// yaw = 0 → camera looks along +Y axis (front view) /// pitch = PI/2 → camera looks down -Z (top view) /// pitch = 0 → camera in the XY plane -/// Build a rotation quaternion from yaw and pitch. +/// Build a rotation quaternion from yaw, pitch and roll. /// Positive yaw rotates the view direction clockwise when seen from above (Z-up). -pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32) -> Quat { +/// Roll rotates the camera around its own view axis (post-multiplied so it +/// composes after the yaw/pitch gaze direction is set). +pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat { // +yaw so ViewCube faces match camera direction (FRONT at yaw=0 = +Y world axis). let q_yaw = Quat::from_rotation_z(yaw); let q_pitch = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2 - pitch); - (q_yaw * q_pitch).normalize() + let q_roll = Quat::from_rotation_z(roll); + (q_yaw * q_pitch * q_roll).normalize() } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 909223827..8d51b04e1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2179,15 +2179,16 @@ impl Scene { } } - /// Snap the active viewport's view direction to a canonical yaw/pitch. - /// No-op when there is no active viewport or it is locked. - pub fn snap_active_viewport_to_angles(&mut self, yaw: f32, pitch: f32) { + /// Snap the active viewport's view direction to `eye_dir` (unit + /// vector from target toward camera). Twist angle is left at its + /// current value so the up-sense is preserved across successive + /// snaps. No-op when there is no active viewport or it is locked. + pub fn snap_active_viewport_to_direction(&mut self, eye_dir: glam::Vec3) { let vp_handle = match self.active_viewport { Some(h) => h, None => return, }; - let cos_p = pitch.cos(); - let eye = glam::Vec3::new(cos_p * yaw.sin(), cos_p * yaw.cos(), pitch.sin()); + let eye = eye_dir.normalize_or(glam::Vec3::Z); if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { if vp.status.locked { return; @@ -3807,7 +3808,7 @@ impl Scene { } else { vd.x.atan2(-vd.y) }; - let rotation = camera::yaw_pitch_to_quat(yaw, pitch); + let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0); let view_right = rotation * glam::Vec3::X; let view_up = rotation * glam::Vec3::Y; let base = if model_space { @@ -3877,7 +3878,7 @@ impl Scene { } else { vd.x.atan2(-vd.y) }; - let rotation = camera::yaw_pitch_to_quat(yaw, pitch); + let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0); let view_right = rotation * glam::Vec3::X; let view_up = rotation * glam::Vec3::Y; @@ -3964,7 +3965,7 @@ impl Scene { } else { vd.x.atan2(-vd.y) }; - let rotation = camera::yaw_pitch_to_quat(yaw, pitch); + let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0); let view_right = rotation * glam::Vec3::X; let view_up = rotation * glam::Vec3::Y; @@ -4337,7 +4338,7 @@ impl Scene { vd.x.atan2(-vd.y) }; - let rotation = camera::yaw_pitch_to_quat(yaw, pitch); + let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0); let view_right = rotation * glam::Vec3::X; let view_up = rotation * glam::Vec3::Y; diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index 3e7d3472d..c11c25096 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -72,37 +72,62 @@ impl CubeRegion { } } - pub fn snap_angles(self) -> (f32, f32) { + /// Unit eye-direction vector (from target toward the camera) that + /// looks straight at this region. Used by `Camera::snap_to_direction` + /// which derives the full orientation by re-using the current + /// camera's up vector, projected onto the plane perpendicular to + /// this direction — so clicking an edge spins the cube around the + /// edge without rolling the user's "up" sense. + pub fn snap_direction(self) -> glam::Vec3 { + let c = region_centroids()[self.id()]; + glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z) + } + + /// Returns `(yaw, pitch, roll)` for the canonical camera orientation + /// pointed at this region. + /// + /// `yaw` (rotate around world Z) and `pitch` (tilt toward world Z) + /// place the camera on the right side of the cube; `roll` rotates + /// around the camera's view axis so edge views land with the edge + /// vertical on screen instead of horizontal. Faces and corners + /// keep `roll = 0` (world up stays up). + pub fn snap_angles(self) -> (f32, f32, f32) { use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI}; let iso = (1.0_f32 / 2.0_f32.sqrt()).atan(); + // For the 8 horizontal-cube edges (top + bottom rings), roll by + // ±π/2 around the camera's view axis so the edge sits vertical + // on screen — TOP face on one half, the side face on the other. + // The 4 vertical edges (FRONT-RIGHT etc.) already run along Z + // and read vertical with roll = 0. + let r = FRAC_PI_2; match self.id() { - 0 => (0.0, FRAC_PI_2), - 1 => (0.0, -FRAC_PI_2), - 2 => (0.0, 0.0), // FRONT: yaw=0 (eye at -Y) - 3 => (PI, 0.0), // BACK: yaw=PI (eye at +Y) - 4 => (FRAC_PI_2, 0.0), // RIGHT - 5 => (-FRAC_PI_2, 0.0), // LEFT - 6 => (0.0, FRAC_PI_4), // EDGE_TOP_FRONT - 7 => (PI, FRAC_PI_4), // EDGE_TOP_BACK - 8 => (FRAC_PI_2, FRAC_PI_4), // EDGE_TOP_RIGHT - 9 => (-FRAC_PI_2, FRAC_PI_4), // EDGE_TOP_LEFT - 10 => (0.0, -FRAC_PI_4), // EDGE_BOT_FRONT - 11 => (PI, -FRAC_PI_4), // EDGE_BOT_BACK - 12 => (FRAC_PI_2, -FRAC_PI_4), - 13 => (-FRAC_PI_2, -FRAC_PI_4), - 14 => (FRAC_PI_4, 0.0), // EDGE_FRONT_RIGHT (between 0 and PI/2) - 15 => (-FRAC_PI_4, 0.0), // EDGE_FRONT_LEFT - 16 => (PI * 0.75, 0.0), // EDGE_BACK_RIGHT (between PI and PI/2) - 17 => (-PI * 0.75, 0.0), // EDGE_BACK_LEFT - 18 => (FRAC_PI_4, iso), // CORNER_TPF_R (FRONT+RIGHT) - 19 => (-FRAC_PI_4, iso), // CORNER_TPF_L (FRONT+LEFT) - 20 => (PI * 0.75, iso), // CORNER_TBK_R (BACK+RIGHT) - 21 => (-PI * 0.75, iso), // CORNER_TBK_L (BACK+LEFT) - 22 => (FRAC_PI_4, -iso), // CORNER_BTF_R - 23 => (-FRAC_PI_4, -iso), // CORNER_BTF_L - 24 => (PI * 0.75, -iso), // CORNER_BBK_R - 25 => (-PI * 0.75, -iso), // CORNER_BBK_L - _ => (0.0, 0.0), + 0 => (0.0, FRAC_PI_2, 0.0), + 1 => (0.0, -FRAC_PI_2, 0.0), + 2 => (0.0, 0.0, 0.0), // FRONT + 3 => (PI, 0.0, 0.0), // BACK + 4 => (FRAC_PI_2, 0.0, 0.0), // RIGHT + 5 => (-FRAC_PI_2, 0.0, 0.0), // LEFT + 6 => (0.0, FRAC_PI_4, r), // EDGE_TOP_FRONT + 7 => (PI, FRAC_PI_4, r), // EDGE_TOP_BACK + 8 => (FRAC_PI_2, FRAC_PI_4, r), // EDGE_TOP_RIGHT + 9 => (-FRAC_PI_2, FRAC_PI_4, r), // EDGE_TOP_LEFT + 10 => (0.0, -FRAC_PI_4, r), // EDGE_BOT_FRONT + 11 => (PI, -FRAC_PI_4, r), // EDGE_BOT_BACK + 12 => (FRAC_PI_2, -FRAC_PI_4, r), // EDGE_BOT_RIGHT + 13 => (-FRAC_PI_2, -FRAC_PI_4, r), // EDGE_BOT_LEFT + 14 => (FRAC_PI_4, 0.0, 0.0), // EDGE_FRONT_RIGHT + 15 => (-FRAC_PI_4, 0.0, 0.0), // EDGE_FRONT_LEFT + 16 => (PI * 0.75, 0.0, 0.0), // EDGE_BACK_RIGHT + 17 => (-PI * 0.75, 0.0, 0.0), // EDGE_BACK_LEFT + 18 => (FRAC_PI_4, iso, 0.0), // CORNER_TPF_R + 19 => (-FRAC_PI_4, iso, 0.0), // CORNER_TPF_L + 20 => (PI * 0.75, iso, 0.0), // CORNER_TBK_R + 21 => (-PI * 0.75, iso, 0.0), // CORNER_TBK_L + 22 => (FRAC_PI_4, -iso, 0.0), // CORNER_BTF_R + 23 => (-FRAC_PI_4, -iso, 0.0), // CORNER_BTF_L + 24 => (PI * 0.75, -iso, 0.0), // CORNER_BBK_R + 25 => (-PI * 0.75, -iso, 0.0), // CORNER_BBK_L + _ => (0.0, 0.0, 0.0), } }