forked from 1b-agent/opencadstudio-upstream
A drawing opened with a twisted view made a TOP-face click keep the twist, so north ended up at the bottom. Add Camera::snap_to_face, which snaps to a canonical orientation — north (UCS +Y) up for top/bottom, world (UCS +Z) up for the side elevations — ignoring the current up-sense. Face clicks use it; edges/corners still preserve their up so they spin smoothly around the clicked feature. The Home button routes through the same path, giving a reliable reset for a twisted view. Co-Authored-By: Claude Opus 4.8 <[email protected]>
482 lines
22 KiB
Rust
482 lines
22 KiB
Rust
// Arcball orbit camera — quaternion-based rotation, no gimbal lock.
|
||
//
|
||
// The camera orbits around a `target` point using a unit quaternion (`rotation`)
|
||
// that maps the canonical "camera looks down -Z" pose to the current view.
|
||
//
|
||
// Pan: translates `target` in the view-plane (no rotation change).
|
||
// Orbit: updates `rotation` via arcball delta — converts screen drag delta
|
||
// to a rotation axis/angle, then pre-multiplies the current quaternion.
|
||
// Zoom: adjusts `distance` (exponential feel).
|
||
// Snap: directly assigns yaw+pitch encoded as a quaternion (for ViewCube).
|
||
//
|
||
// Coordinate convention: Z-up world space (same as the rest of OpenCADStudio).
|
||
|
||
use glam::{vec3, DVec3, Mat4, Quat, Vec3};
|
||
use iced::{Point, Rectangle};
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub enum Projection {
|
||
Orthographic,
|
||
Perspective,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct Camera {
|
||
/// World-space pivot point the camera orbits around. Kept in f64 so a far
|
||
/// pan (large offset-relative coordinate) doesn't lose precision in the
|
||
/// pivot itself — the eventual relative-to-eye render path needs an exact
|
||
/// eye, which derives from this target.
|
||
pub target: DVec3,
|
||
/// Arcball rotation: maps canonical pose to current orientation.
|
||
pub rotation: Quat,
|
||
/// Distance from eye to target.
|
||
pub distance: f32,
|
||
/// Vertical field of view in radians (perspective only).
|
||
pub fov_y: f32,
|
||
pub projection: Projection,
|
||
|
||
// --- Legacy yaw/pitch exposed only for ViewCube hit-test compatibility ---
|
||
// Kept in sync with `rotation` whenever orbit() or snap_angles() is called.
|
||
pub yaw: f32,
|
||
pub pitch: f32,
|
||
}
|
||
|
||
impl Default for Camera {
|
||
fn default() -> Self {
|
||
// Default: look straight down at the XY drawing plane (top view, Z-up).
|
||
// yaw = 0, pitch = PI/2 → eye is directly above target.
|
||
let yaw = 0.0_f32;
|
||
let pitch = std::f32::consts::FRAC_PI_2;
|
||
Self {
|
||
target: DVec3::ZERO,
|
||
rotation: yaw_pitch_to_quat(yaw, pitch, 0.0),
|
||
distance: 60.36,
|
||
fov_y: 45.0_f32.to_radians(),
|
||
projection: Projection::Orthographic,
|
||
yaw,
|
||
pitch,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub const OPENGL_TO_WGPU: Mat4 = glam::mat4(
|
||
glam::vec4(1.0, 0.0, 0.0, 0.0),
|
||
glam::vec4(0.0, 1.0, 0.0, 0.0),
|
||
glam::vec4(0.0, 0.0, 0.5, 0.0),
|
||
glam::vec4(0.0, 0.0, 0.5, 1.0),
|
||
);
|
||
|
||
impl Camera {
|
||
// ── Eye position ───────────────────────────────────────────────────────
|
||
|
||
/// Eye position in full f64 precision (world space). The whole pipeline is
|
||
/// relative-to-eye, so this is the canonical eye; direction-only callers
|
||
/// (view-matrix basis, ray casts) take `.as_vec3()`.
|
||
pub fn eye(&self) -> DVec3 {
|
||
let eye_dir = (self.rotation * Vec3::Z).as_dvec3();
|
||
self.target + eye_dir * self.distance as f64
|
||
}
|
||
|
||
/// Half-height of the orthographic frustum in world units.
|
||
pub fn ortho_size(&self) -> f32 {
|
||
self.distance * (self.fov_y * 0.5).tan()
|
||
}
|
||
|
||
// ── Projection matrices ────────────────────────────────────────────────
|
||
|
||
/// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5.
|
||
///
|
||
/// The draw-order depth bias shifts clip-z by ±`DRAW_ORDER_BIAS` (0.001).
|
||
/// The old range (`near = distance*0.001`, `far = distance*1000`) parked the
|
||
/// geometry at ndc-z ≈ 0.001 — right on the near plane — so a front-biased
|
||
/// entity landed exactly at z = 0 and got clipped the moment f32 rounding
|
||
/// (worse at high zoom) tipped it past the plane, making the drawing vanish.
|
||
/// A symmetric range gives the bias half the depth buffer of headroom on
|
||
/// each side; ortho permits a negative near.
|
||
fn ortho_depth_range(&self) -> (f32, f32) {
|
||
let r = (self.distance * 1000.0).max(1.0);
|
||
(self.distance - r, self.distance + r)
|
||
}
|
||
|
||
/// Relative-to-eye view-projection: identical projection, but the view
|
||
/// matrix carries rotation only (translation zeroed). Positions fed to it
|
||
/// must already be expressed relative to the eye (done per-vertex with
|
||
/// double-single precision in the shader), so the large eye translation
|
||
/// never enters the f32 matrix and large-coordinate jitter disappears.
|
||
pub fn view_proj_rte(&self, bounds: Rectangle) -> Mat4 {
|
||
let aspect = bounds.width / bounds.height;
|
||
let up_dir = self.rotation * Vec3::Y;
|
||
|
||
let mut view = Mat4::look_at_rh(self.eye().as_vec3(), self.target.as_vec3(), up_dir);
|
||
// Zero the translation column → pure rotation (world→view basis).
|
||
view.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0);
|
||
|
||
let proj = match self.projection {
|
||
Projection::Perspective => {
|
||
Mat4::perspective_rh(self.fov_y, aspect, self.distance * 0.001, self.distance * 1000.0)
|
||
}
|
||
Projection::Orthographic => {
|
||
let h = self.ortho_size();
|
||
let w = h * aspect;
|
||
let (near, far) = self.ortho_depth_range();
|
||
Mat4::orthographic_rh(-w, w, -h, h, near, far)
|
||
}
|
||
};
|
||
OPENGL_TO_WGPU * proj * view
|
||
}
|
||
|
||
/// Project a world point to screen pixels with full f64 precision: the
|
||
/// point is made eye-relative in f64 (small numbers near the view) before
|
||
/// the rotation-only projection, so it stays exact at large absolute
|
||
/// coordinates — the CPU equivalent of the GPU's relative-to-eye path.
|
||
/// Returns `None` for points at/behind the eye plane (w ≈ 0).
|
||
pub fn project(&self, p: glam::DVec3, bounds: Rectangle) -> Option<glam::Vec2> {
|
||
let rel = (p - self.eye()).as_vec3();
|
||
let clip = self.view_proj_rte(bounds) * rel.extend(1.0);
|
||
if clip.w.abs() < 1e-9 {
|
||
return None;
|
||
}
|
||
let ndc = clip.truncate() / clip.w;
|
||
Some(glam::vec2(
|
||
(ndc.x * 0.5 + 0.5) * bounds.width,
|
||
(0.5 - ndc.y * 0.5) * bounds.height,
|
||
))
|
||
}
|
||
|
||
/// Unproject a screen point onto an arbitrary world plane in f64. The ray
|
||
/// is built in eye-relative space (precise), intersected with the plane
|
||
/// expressed relative to the eye, then shifted back by the f64 eye — so the
|
||
/// returned world point keeps full precision at large absolute coordinates.
|
||
pub fn unproject_on_plane(
|
||
&self,
|
||
screen: Point,
|
||
bounds: Rectangle,
|
||
plane_normal: Vec3,
|
||
plane_point: glam::DVec3,
|
||
) -> glam::DVec3 {
|
||
let eye = self.eye();
|
||
let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0;
|
||
let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0;
|
||
let inv = self.view_proj_rte(bounds).inverse();
|
||
// Ray origin / direction in eye-relative space.
|
||
let (ray_origin, ray_dir) = match self.projection {
|
||
Projection::Perspective => {
|
||
let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
|
||
let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
|
||
(near_pt, (far_pt - near_pt).normalize())
|
||
}
|
||
Projection::Orthographic => {
|
||
let origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
|
||
let forward = self.rotation * Vec3::NEG_Z;
|
||
(origin, forward)
|
||
}
|
||
};
|
||
// Plane point relative to the eye (small) for a precise intersection.
|
||
let plane_rel = (plane_point - eye).as_vec3();
|
||
let denom = ray_dir.dot(plane_normal);
|
||
let rel_hit = if denom.abs() < 1e-6 {
|
||
plane_rel
|
||
} else {
|
||
let t = (plane_rel - ray_origin).dot(plane_normal) / denom;
|
||
ray_origin + ray_dir * t
|
||
};
|
||
eye + rel_hit.as_dvec3()
|
||
}
|
||
|
||
/// Eye position split into two f32 (high + low) emulating f64, for the
|
||
/// double-single relative-to-eye shaders. `high + low ≈ eye` to ~f64
|
||
/// precision; the shader subtracts these from each vertex's own high/low.
|
||
pub fn eye_high_low(&self) -> ([f32; 3], [f32; 3]) {
|
||
let e = self.eye();
|
||
let high = [e.x as f32, e.y as f32, e.z as f32];
|
||
let low = [
|
||
(e.x - high[0] as f64) as f32,
|
||
(e.y - high[1] as f64) as f32,
|
||
(e.z - high[2] as f64) as f32,
|
||
];
|
||
(high, low)
|
||
}
|
||
|
||
/// Project a screen point onto an arbitrary world-space plane.
|
||
///
|
||
/// The plane is defined by `plane_normal` (unit vector) and a `plane_point`
|
||
/// that lies on it. Returns the ray–plane intersection (falling back to
|
||
/// `plane_point` when nearly parallel), eye-relative in f64 so the cursor
|
||
/// stays precise at UTM-scale coordinates.
|
||
pub fn pick_on_plane(
|
||
&self,
|
||
screen: Point,
|
||
bounds: Rectangle,
|
||
plane_normal: Vec3,
|
||
plane_point: glam::DVec3,
|
||
) -> glam::DVec3 {
|
||
self.unproject_on_plane(screen, bounds, plane_normal, plane_point)
|
||
}
|
||
|
||
/// Project a screen point onto the plane through the orbit target.
|
||
pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> glam::DVec3 {
|
||
let forward = (self.target.as_vec3() - self.eye().as_vec3()).normalize_or(Vec3::NEG_Z);
|
||
self.unproject_on_plane(screen, bounds, forward, self.target)
|
||
}
|
||
|
||
|
||
// ── ViewCube rotation matrix ───────────────────────────────────────────
|
||
|
||
/// Returns the rotation matrix for the ViewCube.
|
||
///
|
||
/// The camera quaternion maps canonical pose (+Z eye) → current view.
|
||
/// The ViewCube needs the inverse so the cube stays world-aligned.
|
||
/// Inverse of a unit quaternion = its conjugate.
|
||
pub fn view_rotation_mat(&self) -> Mat4 {
|
||
Mat4::from_quat(self.rotation.conjugate())
|
||
}
|
||
|
||
/// The camera's roll — rotation about the view axis, in radians — the
|
||
/// inverse of the `roll` argument to [`yaw_pitch_to_quat`]. Recovered by
|
||
/// removing the yaw/pitch frame from the live rotation, so a saved view can
|
||
/// store its twist and round-trip it. Exact for a plan view (the twisted-
|
||
/// UCS case); approximate after a free 3D orbit.
|
||
pub fn roll(&self) -> f32 {
|
||
let q_yp = yaw_pitch_to_quat(self.yaw, self.pitch, 0.0);
|
||
let q_roll = q_yp.conjugate() * self.rotation;
|
||
// q_roll is (nominally) a rotation about Z; extract its angle.
|
||
2.0 * q_roll.z.atan2(q_roll.w)
|
||
}
|
||
|
||
// ── Navigation ────────────────────────────────────────────────────────
|
||
|
||
/// Arcball orbit: drag delta (dx, dy) in screen pixels.
|
||
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
|
||
if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 {
|
||
return;
|
||
}
|
||
|
||
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 cam_right = self.rotation * Vec3::X;
|
||
let cam_up = self.rotation * Vec3::Y;
|
||
let world_axis = (cam_right * screen_axis.x + cam_up * screen_axis.y).normalize_or_zero();
|
||
|
||
if world_axis.length_squared() < 1e-12 {
|
||
return;
|
||
}
|
||
|
||
let delta_rot = Quat::from_axis_angle(world_axis, angle);
|
||
self.rotation = (delta_rot * self.rotation).normalize();
|
||
|
||
// Sync legacy yaw/pitch for hit-test functions.
|
||
self.sync_yaw_pitch();
|
||
}
|
||
|
||
pub fn zoom(&mut self, delta: f32) {
|
||
self.distance = (self.distance * (1.0 - delta * 0.1)).max(0.001);
|
||
}
|
||
|
||
/// World-space offset from `target` to the point under `screen` on the
|
||
/// target plane. Computed in the camera frame (small numbers) and rotated
|
||
/// to world — it never touches the large absolute target, so it stays
|
||
/// precise at UTM-scale coordinates. For perspective this is the offset
|
||
/// evaluated at the target plane (the correct pivot for zoom-to-cursor).
|
||
fn cursor_offset_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 {
|
||
let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0;
|
||
let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0;
|
||
let aspect = bounds.width / bounds.height;
|
||
let half_h = self.ortho_size();
|
||
let half_w = half_h * aspect;
|
||
let cam_right = self.rotation * Vec3::X;
|
||
let cam_up = self.rotation * Vec3::Y;
|
||
cam_right * (ndc_x * half_w) + cam_up * (ndc_y * half_h)
|
||
}
|
||
|
||
pub fn zoom_about_point(&mut self, screen: Point, bounds: Rectangle, delta: f32) {
|
||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||
self.zoom(delta);
|
||
return;
|
||
}
|
||
|
||
// Keep the point under the cursor fixed by working with its offset
|
||
// *relative to target* before and after the zoom. Both offsets are
|
||
// small (camera-frame) numbers, so their difference is exact even at
|
||
// UTM coordinates — the old absolute view_proj.inverse() picks each
|
||
// carried ~0.5 m of f32 error that didn't cancel, making the whole
|
||
// scene jump on every zoom step.
|
||
let before = self.cursor_offset_on_target_plane(screen, bounds);
|
||
self.zoom(delta);
|
||
let after = self.cursor_offset_on_target_plane(screen, bounds);
|
||
self.target += (before - after).as_dvec3();
|
||
}
|
||
|
||
/// Pan so the world point under the cursor tracks it: screen pixels are
|
||
/// converted to world units via the ortho world-per-pixel scale of a
|
||
/// viewport `viewport_height` pixels tall. Used by tiled panes where the
|
||
/// pane height differs from the full canvas.
|
||
pub fn pan_screen(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
||
let wpp = if viewport_height > 0.0 {
|
||
(2.0 * self.ortho_size()) / viewport_height
|
||
} else {
|
||
0.0
|
||
};
|
||
let cam_right = self.rotation * Vec3::X;
|
||
let cam_up = self.rotation * Vec3::Y;
|
||
self.target -= (cam_right * delta_x * wpp).as_dvec3();
|
||
self.target += (cam_up * delta_y * wpp).as_dvec3();
|
||
}
|
||
|
||
pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3) {
|
||
self.target = ((min + max) * 0.5).as_dvec3();
|
||
let size = (max - min).length();
|
||
self.distance = size * 1.5;
|
||
}
|
||
|
||
// ── ViewCube snap ─────────────────────────────────────────────────────
|
||
|
||
/// 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, 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);
|
||
let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
|
||
(uz - new_eye * uz.dot(new_eye)).normalize()
|
||
} else {
|
||
(uy - new_eye * uy.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();
|
||
}
|
||
|
||
/// Snap to a canonical face view: looks along `eye_dir` with a fixed
|
||
/// upright orientation — north (UCS +Y) up for top/bottom, world (UCS +Z)
|
||
/// up for the side elevations. Unlike [`snap_to_direction`] this ignores
|
||
/// the current up-sense, so a face click always lands square and never
|
||
/// upside-down, even when the drawing opened with a twisted view.
|
||
pub fn snap_to_face(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
|
||
let new_eye = eye_dir.normalize_or(Vec3::Z);
|
||
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
|
||
let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
|
||
// Looking along the UCS Z axis (top/bottom) has no "world up" to use,
|
||
// so fall back to north (+Y); every side view uses world up.
|
||
let up_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz };
|
||
let projected = up_ref - new_eye * up_ref.dot(new_eye);
|
||
let new_up = projected.normalize_or(uy);
|
||
let new_right = new_up.cross(new_eye).normalize();
|
||
let mat = glam::Mat3::from_cols(new_right, new_up, new_eye);
|
||
self.rotation = Quat::from_mat3(&mat).normalize();
|
||
self.sync_yaw_pitch();
|
||
}
|
||
|
||
/// Jump to the default "home" view — a canonical top-down view (north up),
|
||
/// expressed in the active UCS. Doubles as a "reset" for a twisted view.
|
||
pub fn home_view(&mut self, ucs: glam::Mat4) {
|
||
let dir = ucs.transform_vector3(Vec3::Z);
|
||
self.snap_to_face(dir, ucs);
|
||
}
|
||
|
||
/// Roll the camera about its own view axis by `angle` radians. The gaze
|
||
/// direction is unchanged; only the up-sense twists.
|
||
pub fn roll_by(&mut self, angle: f32) {
|
||
self.rotation = (self.rotation * Quat::from_rotation_z(angle)).normalize();
|
||
self.sync_yaw_pitch();
|
||
}
|
||
|
||
/// Tip / spin the view 90° about a screen axis. `horizontal = false` tips
|
||
/// up/down (rotation about the camera's right axis); `true` spins
|
||
/// left/right (about the camera's up axis).
|
||
pub fn nudge_90(&mut self, horizontal: bool, positive: bool) {
|
||
let axis = if horizontal {
|
||
self.rotation * Vec3::Y
|
||
} else {
|
||
self.rotation * Vec3::X
|
||
};
|
||
let ang = if positive {
|
||
std::f32::consts::FRAC_PI_2
|
||
} else {
|
||
-std::f32::consts::FRAC_PI_2
|
||
};
|
||
let delta = Quat::from_axis_angle(axis, ang);
|
||
self.rotation = (delta * self.rotation).normalize();
|
||
self.sync_yaw_pitch();
|
||
}
|
||
|
||
// ── Internal helpers ───────────────────────────────────────────────────
|
||
|
||
/// Derive yaw and pitch from the current quaternion for the ViewCube
|
||
/// hit-test functions (hit_test / hover_id). These use yaw/pitch to
|
||
/// compute the same rotation matrix as the shader, so they must match.
|
||
fn sync_yaw_pitch(&mut self) {
|
||
// Eye direction in world space (canonical eye dir is +Z).
|
||
let eye_dir = self.rotation * Vec3::Z;
|
||
// pitch: angle above/below the XY plane.
|
||
self.pitch = eye_dir.z.clamp(-0.999, 0.999).asin();
|
||
// yaw: atan2(x, y) matches from_rotation_z(yaw) used in view_rotation_mat.
|
||
self.yaw = eye_dir.x.atan2(eye_dir.y);
|
||
}
|
||
}
|
||
|
||
// ── Free helpers ───────────────────────────────────────────────────────────
|
||
|
||
/// Build a rotation quaternion from yaw (rotation around Z) and pitch
|
||
/// (tilt toward Z). Matches the coordinate convention of the ViewCube
|
||
/// so snap angles continue to work unchanged.
|
||
///
|
||
/// Convention (Z-up, Y-forward):
|
||
/// 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, pitch and roll.
|
||
/// Positive yaw rotates the view direction clockwise when seen from above (Z-up).
|
||
/// 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);
|
||
let q_roll = Quat::from_rotation_z(roll);
|
||
(q_yaw * q_pitch * q_roll).normalize()
|
||
}
|