Files
OneCAD-Draft/src/scene/view/camera.rs
T
Hakan SevenandClaude Opus 4.8 012ec3febc fix(viewcube): land face clicks upright, never upside-down
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 <noreply@anthropic.com>
2026-06-26 00:11:40 +03:00

482 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}