fix(ucs): snap ViewCube face views square to the active UCS

Clicking a ViewCube face rotated the gaze into the UCS frame but derived
the up vector from world X/Y/Z, so the snapped view landed world-aligned
(most visibly Top, where the UCS shares world Z and only the in-plane
roll differs). snap_to_direction now takes the UCS basis and picks its up
from the UCS axes, so a face snap lands square to the user's coordinate
system. Identity UCS reproduces the world-aligned snap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-22 11:12:43 +03:00
commit 98142ee4af
2 changed files with 13 additions and 14 deletions

View file

@ -4351,7 +4351,7 @@ impl OpenCADStudio {
.snap_active_viewport_to_direction(eye_dir); .snap_active_viewport_to_direction(eye_dir);
} else { } else {
let mut cam = self.tabs[i].scene.camera.borrow_mut(); let mut cam = self.tabs[i].scene.camera.borrow_mut();
cam.snap_to_direction(eye_dir); cam.snap_to_direction(eye_dir, r_ucs);
} }
self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.camera_generation += 1;
self.command_line self.command_line

View file

@ -284,19 +284,18 @@ impl Camera {
/// Result: small tilts collapse onto the nearest world axis (so the /// Result: small tilts collapse onto the nearest world axis (so the
/// view always lands cleanly aligned), while genuine flips of the /// view always lands cleanly aligned), while genuine flips of the
/// up-sense (e.g. orbited upside-down) are preserved. /// up-sense (e.g. orbited upside-down) are preserved.
pub fn snap_to_direction(&mut self, eye_dir: Vec3) { pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
let new_eye = eye_dir.normalize_or(Vec3::Z); let new_eye = eye_dir.normalize_or(Vec3::Z);
let cur_up = self.rotation * Vec3::Y; let cur_up = self.rotation * Vec3::Y;
let cardinals = [ // Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap
Vec3::X, // lands the view square to the user's coordinate system (in-plane roll
Vec3::NEG_X, // included). Identity `ucs` reproduces the world-aligned snap.
Vec3::Y, let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
Vec3::NEG_Y, let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
Vec3::Z, let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
Vec3::NEG_Z, let cardinals = [ux, -ux, uy, -uy, uz, -uz];
];
let mut best_score = f32::NEG_INFINITY; let mut best_score = f32::NEG_INFINITY;
let mut best_up = Vec3::Z; let mut best_up = uz;
for axis in cardinals { for axis in cardinals {
// Skip axes (nearly) collinear with the new gaze — they can't // Skip axes (nearly) collinear with the new gaze — they can't
// serve as up because the projection onto the plane would // serve as up because the projection onto the plane would
@ -312,10 +311,10 @@ impl Camera {
} }
// Project the chosen axis onto the plane ⊥ new_eye and normalize. // Project the chosen axis onto the plane ⊥ new_eye and normalize.
let projected = best_up - new_eye * best_up.dot(new_eye); let projected = best_up - new_eye * best_up.dot(new_eye);
let new_up = projected.normalize_or(if new_eye.z.abs() < 0.99 { let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
(Vec3::Z - new_eye * Vec3::Z.dot(new_eye)).normalize() (uz - new_eye * uz.dot(new_eye)).normalize()
} else { } else {
(Vec3::Y - new_eye * Vec3::Y.dot(new_eye)).normalize() (uy - new_eye * uy.dot(new_eye)).normalize()
}); });
let new_right = new_up.cross(new_eye).normalize(); let new_right = new_up.cross(new_eye).normalize();
// Camera rotation columns: [cam_x | cam_y | cam_z] where // Camera rotation columns: [cam_x | cam_y | cam_z] where