forked from 1b-agent/opencadstudio-upstream
fix(viewcube): snap to direction with nearest-canonical up axis
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
26bcd89358
commit
451239c4f9
4 files changed
+135
-68
No files matched your search
+12
-21
@@ -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
|
||||
|
||||
+60
-10
@@ -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()
|
||||
}
|
||||
+10
-9
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user