fix(viewcube): deterministic horizon for obliques, generic opposite

Top Front Right/Left kept Y (north) as up when starting from Top,
tilting the base because orbit is turntable (never banks). Make
snap_to_direction deterministic: world +Z horizon except near +-Z
where north +Y is used, sign-preserved only for intentionally
inverted views. Expand opposite() from faces-only to all 26 regions
so already-there flip works for edges/corners.
This commit is contained in:
Karim Jerbi committed 2026-09-19 09:37:56 +01:00
1 parent 5ba72ec3e4
commit 4fdef48502
3 files changed
+50 -49

No files matched your search

+4 -3
View File
@@ -5511,9 +5511,10 @@ properties={:.1}ms picked={}",
}
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
// Faces snap to a canonical upright orientation (never upside
// down); edges/corners keep the current up-sense so they spin
// smoothly around the clicked feature.
// Faces and edges/corners all snap deterministically (world +Z
// horizon, north +Y for top/bottom) so a cube click is repeatable
// and matches the turntable orbit; snap_to_direction preserves an
// intentional upside-down sense by sign-flipping only.
let is_face = matches!(region, scene::CubeRegion::Face(_));
if self.tabs[i].scene.active_viewport.is_some() {
if is_face {
+27 -12
View File
@@ -142,24 +142,39 @@ impl CubeRegion {
/// 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.
/// / `snap_to_face` which derive a deterministic horizon (world +Z,
/// except top/bottom where north +Y is used) so every cube click
/// lands repeatably.
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)
}
pub fn opposite(self) -> CubeRegion {
match self {
CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM),
CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP),
CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK),
CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT),
CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT),
CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT),
other => other,
// Generic opposite: the region whose centroid is nearest to -self.
// Covers faces, edges and corners so "already there → flip" works
// for every cube click, not just faces.
let centroids = region_centroids();
let c = centroids[self.id()];
let neg = [-c[0], -c[1], -c[2]];
let mut best = self.id();
let mut best_d = f32::MAX;
for (i, cc) in centroids.iter().enumerate() {
let dx = cc[0] - neg[0];
let dy = cc[1] - neg[1];
let dz = cc[2] - neg[2];
let d = dx * dx + dy * dy + dz * dz;
if d < best_d {
best_d = d;
best = i;
}
}
if best < 6 {
CubeRegion::Face(best)
} else if best < 18 {
CubeRegion::Edge(best)
} else {
CubeRegion::Corner(best)
}
}
+19 -34
View File
@@ -645,44 +645,29 @@ impl Camera {
/// 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.
/// Deterministic horizon: when looking close to ±Z (top/bottom) the
/// up is north (+Y), otherwise world up (+Z), projected onto the
/// plane ⊥ `new_eye`. This matches the turntable `orbit` (which never
/// banks) so a cube corner always lands with the same roll and the
/// base never appears rotated. The sign is flipped only if the
/// current view is intentionally upside-down, preserving that sense.
pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
let new_eye = eye_dir.normalize_or(Vec3::Z);
let cur_up = self.rotation * Vec3::Y;
// Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap
// lands the view square to the user's coordinate system (in-plane roll
// included). Identity `ucs` reproduces the world-aligned snap.
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
let cardinals = [ux, -ux, uy, -uy, uz, -uz];
let mut best_score = f32::NEG_INFINITY;
let mut best_up = uz;
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);
// Deterministic horizon: top/bottom views (eye ≈ ±Z) use north (+Y)
// as up; every other direction uses world up (+Z). This keeps the
// cube's "Top Front Right" etc. repeatable and aligned with the
// turntable orbit (which never banks), and avoids the previous
// adaptive choice that picked the nearest cardinal to the current
// up — from a top view that was +Y, so an oblique top corner kept
// Y as up and tilted the base (roll) instead of keeping the horizon
// level. Preserve the sign only to keep an intentionally inverted
// (upside-down) view inverted.
let cur_up = self.rotation * Vec3::Y;
let raw_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz };
let up_ref = if cur_up.dot(raw_ref) < 0.0 { -raw_ref } else { raw_ref };
let projected = up_ref - new_eye * up_ref.dot(new_eye);
let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
(uz - new_eye * uz.dot(new_eye)).normalize()
} else {