// 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::camera::rh::proj::directx::{orthographic, perspective}; use glam::camera::rh::view::look_at_mat4; use glam::{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, /// Half-depth of the orthographic frustum in **world units**, sized from the /// drawing's extent (set by `fit_to_bounds`). The ortho near/far are placed /// at `distance ± depth_half_range`, so depth-buffer precision stays fixed /// as the user zooms. `0.0` means "unset" — the projection falls back to a /// distance-scaled range, whose precision collapses when zoomed out and /// makes coincident solids / meshes / wires flip draw order. /// /// Only a fallback now: when `model_bounds` is set, the near/far depth is /// derived from it per frame (see [`Camera::ortho_depth_range`]). pub depth_half_range: f32, /// Cached model AABB in world space, used to recompute the ortho near/far /// depth for the CURRENT eye direction every frame. A frozen scalar range /// (`depth_half_range`) is only correct for the orientation it was fitted /// in: a 3-D drawing fitted straight-down (top view) has a shallow depth /// span, but once orbited its width loads onto the eye axis and overruns /// that span, clipping the drawing (#473). Re-projecting the box each frame /// keeps near/far exactly as deep as the current view needs. `None` falls /// back to `depth_half_range`. pub model_bounds: Option<(DVec3, DVec3)>, } 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, depth_half_range: 0.0, model_bounds: None, } } } 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() } /// Change projection while keeping the fitted model's screen-space frame. /// /// `distance` is both the orthographic zoom scale and the perspective eye /// distance. Reusing it unchanged only preserves scale on the target plane; /// geometry closer to the eye can grow dramatically. Match the maximum /// projected extent of the model box instead, leaving target, rotation and /// field of view untouched. pub fn set_projection_preserving_frame( &mut self, projection: Projection, aspect: f32, ) { if self.projection == projection { return; } let Some((min, max)) = self.model_bounds else { self.projection = projection; return; }; let aspect = aspect.max(0.01); let tan_half_fov = (self.fov_y * 0.5).tan(); if !tan_half_fov.is_finite() || tan_half_fov <= 1e-6 { self.projection = projection; return; } let corners = self.bounds_in_view(min, max); let radius = |corner: &Vec3| corner.y.abs().max(corner.x.abs() / aspect); let max_radius = corners.iter().map(radius).fold(0.0_f32, f32::max); let min_z = corners .iter() .map(|corner| corner.z) .fold(f32::INFINITY, f32::min); let max_z = corners .iter() .map(|corner| corner.z) .fold(f32::NEG_INFINITY, f32::max); let depth_margin = ((max_z - min_z).abs() * 0.001).max(0.001); let near_safe = if max_z > 0.0 { (max_z / (1.0 - 0.001)) * 1.001 } else { 0.001 }; let far_safe = if min_z < 0.0 { (-min_z / (1000.0 - 1.0)) * 1.001 } else { 0.001 }; let depth_safe_distance = (max_z + depth_margin) .max(near_safe) .max(far_safe) .max(0.001); if !max_radius.is_finite() || max_radius <= 1e-6 { if projection == Projection::Perspective && depth_safe_distance.is_finite() { self.distance = self.distance.max(depth_safe_distance); } self.projection = projection; return; } // Extent is the model box's largest absolute NDC X/Y displacement. // When an existing perspective eye is already inside the box, its // projected envelope is undefined; fall back to its target-plane scale // so switching back to orthographic still recovers a usable view. let old_extent = match self.projection { Projection::Orthographic => max_radius / self.ortho_size().max(1e-6), Projection::Perspective => { let near = (self.distance * 0.001).max(1e-6); let mut extent = 0.0_f32; let mut all_in_front = true; for corner in &corners { let depth = self.distance - corner.z; if depth <= near { all_in_front = false; break; } extent = extent.max(radius(corner) / (depth * tan_half_fov)); } if all_in_front { extent } else { max_radius / (self.distance.max(0.001) * tan_half_fov) } } }; if !old_extent.is_finite() || old_extent <= 1e-6 { if projection == Projection::Perspective && depth_safe_distance.is_finite() { self.distance = self.distance.max(depth_safe_distance); } self.projection = projection; return; } match projection { Projection::Orthographic => { let distance = max_radius / (old_extent * tan_half_fov); if distance.is_finite() { self.distance = distance.max(0.001); } } Projection::Perspective => { // For every corner, radius / ((distance - z) * tan(fov/2)) // must be no larger than the old screen extent. The maximum // required distance gives the tightest perspective frame with // at least one corner touching the old envelope. let extent_at_depth = old_extent * tan_half_fov; let mut distance = f32::NEG_INFINITY; for corner in &corners { distance = distance.max(corner.z + radius(corner) / extent_at_depth); } // Keep the complete box ahead of the perspective near plane, // and its far plane, including corners whose X/Y radius does // not constrain framing. distance = distance.max(depth_safe_distance); if distance.is_finite() { self.distance = distance.max(0.001); } } } self.projection = projection; } // ── Projection matrices ──────────────────────────────────────────────── /// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5. /// /// A symmetric range gives draw-order offsets headroom on each side and /// keeps the target away from both clipping planes. The shared shader /// bounds those offsets near either plane; ortho permits a negative near. fn ortho_depth_range(&self) -> (f32, f32) { // Generous headroom based on the current screen size so that rotating any // geometry visible on screen in 3D never penetrates the near/far planes. let view_extent = (self.ortho_size() * 3.0).max(10.0); let r = if let Some((min, max)) = self.model_bounds { self.depth_extent_in_view(min, max).max(view_extent) } else if self.depth_half_range > 0.0 { self.depth_half_range.max(view_extent) } else { (self.distance * 1000.0).max(view_extent).max(10.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; // The basis, from the rotation alone. Zeroing the translation column // afterwards is not enough: `look_at` builds the basis out of // `target - eye`, and handing it two absolute positions cast to f32 // rounds both to the f32 grid first. At UTM that grid is half a metre // across, so the difference — and with it the whole view basis — // jitters as the camera moves, which is the shimmer a survey-placed // drawing showed. // // It never needed them. `eye` is `target + rotation·Z·distance`, so // the direction between the two is the rotation's own Z, a unit // vector that carries no coordinate at all. let mut view = look_at_mat4(Vec3::ZERO, -(self.rotation * Vec3::Z), up_dir); // Already zero, since the eye is the origin here; kept so the invariant // survives someone reworking the line above. view.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); let proj = match self.projection { Projection::Perspective => { perspective(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(); orthographic(-w, w, -h, h, near, far) } }; // The DirectX projection functions already produce WebGPU's [0, 1] // depth range. An OpenGL remap here would compress it to [0.5, 1]. 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 { 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 in f64, with the kernel intersection relative to the plane /// origin so rendering precision never becomes a geometry elevation. 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 as f64 / bounds.width as f64) * 2.0 - 1.0; let ndc_y = 1.0 - (screen.y as f64 / bounds.height as f64) * 2.0; let inv = self.view_proj_rte(bounds).as_dmat4().inverse(); // Ray origin / direction in eye-relative space. let (ray_origin, ray_dir) = match self.projection { Projection::Perspective => { let near_pt = inv.project_point3(DVec3::new(ndc_x, ndc_y, 0.0)); let far_pt = inv.project_point3(DVec3::new(ndc_x, ndc_y, 1.0)); (near_pt, (far_pt - near_pt).normalize()) } Projection::Orthographic => { let origin = inv.project_point3(DVec3::new(ndc_x, ndc_y, 0.0)); let forward = (self.rotation * Vec3::NEG_Z).as_dvec3(); (origin, forward) } }; cadkernel::space::plane::intersect_line_plane( (ray_origin - (plane_point - eye)).to_array(), ray_dir.to_array(), [0.0; 3], plane_normal.as_dvec3().to_array(), 1e-6, ) .map(|point| plane_point + DVec3::from_array(point)) .unwrap_or(plane_point) } /// 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.rotation * 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()) } /// Carry the camera through a rigid model-space transform. BEDIT uses this /// when a transient UCS is baked into block-local geometry: the contents /// retain their on-screen framing while their canonical coordinates change. pub fn apply_rigid_transform(&mut self, transform: &acadrust::types::Transform) { let point = acadrust::types::Vector3::new( self.target.x, self.target.y, self.target.z, ); let point = transform.apply(point); self.target = DVec3::new(point.x, point.y, point.z); let matrix = &transform.matrix.m; let rotation = glam::Mat3::from_cols( Vec3::new(matrix[0][0] as f32, matrix[1][0] as f32, matrix[2][0] as f32), Vec3::new(matrix[0][1] as f32, matrix[1][1] as f32, matrix[2][1] as f32), Vec3::new(matrix[0][2] as f32, matrix[1][2] as f32, matrix[2][2] as f32), ); self.rotation = (Quat::from_mat3(&rotation) * self.rotation).normalize(); self.sync_yaw_pitch(); if let Some((min, max)) = self.model_bounds { let corners = [ DVec3::new(min.x, min.y, min.z), DVec3::new(max.x, min.y, min.z), DVec3::new(min.x, max.y, min.z), DVec3::new(max.x, max.y, min.z), DVec3::new(min.x, min.y, max.z), DVec3::new(max.x, min.y, max.z), DVec3::new(min.x, max.y, max.z), DVec3::new(max.x, max.y, max.z), ]; let mut new_min = DVec3::splat(f64::INFINITY); let mut new_max = DVec3::splat(f64::NEG_INFINITY); for corner in corners { let transformed = transform.apply(acadrust::types::Vector3::new( corner.x, corner.y, corner.z, )); let transformed = DVec3::new(transformed.x, transformed.y, transformed.z); new_min = new_min.min(transformed); new_max = new_max.max(transformed); } self.model_bounds = Some((new_min, new_max)); } } /// The camera's rotation about its view axis. 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. /// Orbit the view by a screen drag, turntable-style: horizontal drag yaws /// about world +Z, vertical drag pitches about the camera's (always /// horizontal) right axis — so the horizon never banks. `pivot` is the world /// point to revolve around (selection or model centre); `None` keeps the /// current target as the centre. (#229) pub fn orbit(&mut self, delta_x: f32, delta_y: f32, pivot: Option) { if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 { return; } let old_rot = self.rotation; let speed = 0.005_f32; let yaw = Quat::from_rotation_z(-delta_x * speed); self.rotation = (yaw * self.rotation).normalize(); let cam_right = self.rotation * Vec3::X; self.rotation = (Quat::from_axis_angle(cam_right, -delta_y * speed) * self.rotation).normalize(); // Revolve the target around the pivot by the same rotation delta so the // view orbits the chosen centre instead of the fixed file centre (#229). // Do it in f64: casting `self.target - p` to f32 first quantizes the // offset to the drawing's extent scale (a large model at ANY origin, not // just UTM), so every orbit step nudged the whole view by a visible // fraction of a unit — the "jump" when starting a rotate on big files. if let Some(p) = pivot { let delta = self.rotation * old_rot.conjugate(); let delta = glam::DQuat::from_xyzw( delta.x as f64, delta.y as f64, delta.z as f64, delta.w as f64, ); self.target = p + delta * (self.target - p); } // 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(); } /// The 8 corners of `min..max`, expressed in camera space relative to the /// current target: `x`/`y` span the screen plane, `z` runs along the eye /// direction. The offset is taken in f64 before the cast, so a corner at /// UTM scale doesn't lose the difference to cancellation. fn bounds_in_view(&self, min: DVec3, max: DVec3) -> [Vec3; 8] { let inv = self.rotation.inverse(); let mut out = [Vec3::ZERO; 8]; for (i, slot) in out.iter_mut().enumerate() { let corner = DVec3::new( if i & 1 == 0 { min.x } else { max.x }, if i & 2 == 0 { min.y } else { max.y }, if i & 4 == 0 { min.z } else { max.z }, ); *slot = inv * (corner - self.target).as_vec3(); } out } /// Half-extent of `min..max` along the current eye direction (with the /// same 5% margin `ortho_depth_range` needs), measured from the target. /// This is exactly what `distance ± r` has to contain to avoid clipping. fn depth_extent_in_view(&self, min: DVec3, max: DVec3) -> f32 { let depth_r = self .bounds_in_view(min, max) .iter() .fold(0.0_f32, |m, c| m.max(c.z.abs())); let diag = (max - min).length() as f32; // Generous headroom (1.5x depth extent + 25% of model diagonal + minimum clearance) // so wide strokes, lineweights, camera orbit dynamics, and draw-order depth bias // never penetrate the near clipping plane in orthographic 3D view. (depth_r * 1.5 + diag * 0.25).max(10.0) } /// Fit the camera to `min..max` — pose and depth both. /// /// Zoom and clipping are sized from DIFFERENT axes on purpose. The zoom must /// frame what the view actually shows (the extent across the screen plane), /// while near/far must span what lies along the eye direction. Sizing both /// from the 3-D diagonal — as this did — makes one far-off Z drag the /// horizontal zoom out with it: a 140-unit drawing carrying a single entity /// 800 km below its plane zoomed out to 800 km and became a dot. The two /// agree on a flat drawing, which is why it went unnoticed. pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32) { self.fit_to_bounds_f64(min.as_dvec3(), max.as_dvec3(), aspect); } pub fn fit_to_bounds_f64(&mut self, min: DVec3, max: DVec3, aspect: f32) { self.target = (min + max) * 0.5; let corners = self.bounds_in_view(min, max); // Half-height the view needs so the box fits BOTH axes at the given // viewport aspect. The old circumscribed-radius rule fitted the box's // DIAGONAL into the height regardless of shape, which left a wide // drawing filling only a small central patch of the window (#364). let a = aspect.max(0.01); let half_h = corners .iter() .fold(0.0_f32, |m, c| m.max(c.y.abs()).max(c.x.abs() / a)) .max(1e-6); // `ortho_size` (the half-height) is `distance * tan(fov/2)`, so invert // that to get the distance which just contains `half_h`, plus a small // border margin. self.distance = (half_h / (self.fov_y * 0.5).tan() * 1.1).max(1e-3); self.fit_depth_to_bounds_f64(min, max); } /// Size only the near/far span to `min..max`, leaving the pose alone. /// /// Split out of [`fit_to_bounds`] for the camera restored from a file's /// saved view: that pose must not move, but its depth range still has to /// cover the model or geometry outside it is silently clipped away. pub fn fit_depth_to_bounds_f64(&mut self, min: DVec3, max: DVec3) { // Cache the box so `ortho_depth_range` re-derives the near/far depth for // the live eye direction every frame — orbiting off the fitted pose then // never clips the drawing (#473). Keeping it tied to the model (not to // `distance`) also holds depth-buffer precision constant across zoom, so // coincident solids / meshes / wires never flip draw order. self.model_bounds = Some((min, max)); // Also seed the scalar fallback for the fitted orientation, in case a // later reader consults it before the next projection. self.depth_half_range = self.depth_extent_in_view(min, max); } /// Drop a fitted depth range after the drawing changes. Keeping either the /// old box or its scalar fallback can clip newly-created geometry until the /// next Zoom Extents. The unset projection uses its conservative fallback /// until the scene refreshes the current model bounds. pub(crate) fn invalidate_model_bounds(&mut self) { self.model_bounds = None; self.depth_half_range = 0.0; } pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> { self.model_bounds } // ── 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. fn sync_yaw_pitch(&mut self) { let eye_dir = self.rotation * Vec3::Z; self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin(); self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 { 0.0 } else { 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 keeps the ViewCube faces aligned with the camera direction. 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() } #[cfg(test)] mod tests { use super::*; #[test] fn projection_uses_the_webgpu_depth_range_once() { let bounds = Rectangle::with_size(iced::Size::new(800., 600.)); for projection in [Projection::Orthographic, Projection::Perspective] { let camera = Camera { projection, rotation: Quat::IDENTITY, ..Camera::default() }; let (near, far) = match projection { Projection::Perspective => (camera.distance * 0.001, camera.distance * 1000.), Projection::Orthographic => camera.ortho_depth_range(), }; let matrix = camera.view_proj_rte(bounds); let depth = |distance: f32| { let clip = matrix * glam::vec4(0., 0., -distance, 1.); clip.z / clip.w }; assert!(depth(near).abs() < 1e-5, "{projection:?}: near must map to 0"); assert!( (depth(far) - 1.).abs() < 1e-5, "{projection:?}: far must map to 1" ); assert!( depth(near - (far - near) * 1e-7) < 0., "near clipping boundary moved" ); assert!(depth(far * 2.) > 1., "far clipping boundary moved"); if projection == Projection::Orthographic { assert!((depth(camera.distance) - 0.5).abs() < 1e-5); } } } /// A drawing 140 units wide carrying one entity 800 km below its plane must /// still zoom to the 140 units — the outlier belongs to the depth range, not /// to the zoom. (Sizing both from the 3-D diagonal made the drawing a dot.) #[test] fn a_far_off_plane_outlier_does_not_drag_the_zoom_out() { let flat_min = Vec3::new(-1200100.0, -800081.5, 0.0); let flat_max = Vec3::new(-1199960.0, -800000.0, 10.0); let mut flat = Camera::default(); flat.fit_to_bounds(flat_min, flat_max, 1.0); // Same drawing, plus the benchmark's entity 800 km down. let mut deep = Camera::default(); deep.fit_to_bounds(Vec3::new(flat_min.x, flat_min.y, -800017.5), flat_max, 1.0); // Top view: the outlier is along the eye direction, so the on-screen // framing must barely move. let ratio = deep.distance / flat.distance; assert!( (0.5..2.0).contains(&ratio), "zoom moved {ratio}x because of a depth-only outlier \ (flat={}, deep={})", flat.distance, deep.distance ); } /// …and that same outlier must be inside near/far, or it is clipped away. #[test] fn the_outlier_lands_inside_the_depth_range() { let min = Vec3::new(-1200100.0, -800081.5, -800017.5); let max = Vec3::new(-1199960.0, -800000.0, 10.0); let mut cam = Camera::default(); cam.fit_to_bounds(min, max, 1.0); let (near, far) = cam.ortho_depth_range(); // Depth of a point from the eye, along the view direction. Top view, so // the outlier at z=-800015 sits `distance + 800015`-ish away. let outlier = Vec3::new(-1200082.1, -800015.4, -800015.4); let local = cam.rotation.inverse() * (outlier.as_dvec3() - cam.target).as_vec3(); let depth = cam.distance - local.z; assert!( depth > near && depth < far, "outlier at depth {depth} is outside near/far ({near}, {far})" ); } /// A flat drawing must be unaffected: the 3-D diagonal and the screen-plane /// extent agree there, so the framing has to match the old behaviour. #[test] fn a_flat_drawing_frames_about_as_before() { let min = Vec3::new(-70.0, -40.0, 0.0); let max = Vec3::new(70.0, 40.0, 0.0); let mut cam = Camera::default(); cam.fit_to_bounds(min, max, 1.0); // Old rule: distance = 3-D diagonal * 1.5. let old = (max - min).length() * 1.5; let ratio = cam.distance / old; assert!( (0.75..1.25).contains(&ratio), "flat framing drifted {ratio}x from the old rule (was {old}, now {})", cam.distance ); // And the whole drawing still fits the half-height. let half_h = cam.ortho_size(); assert!( half_h >= 40.0, "half-height {half_h} no longer contains the drawing" ); } #[test] fn orbit_crosses_a_pole_without_stalling() { let mut cam = Camera::default(); for step in 0..200 { let before = cam.rotation; cam.orbit(0.0, 5.0, None); assert!( before.dot(cam.rotation).abs() < 0.999_999, "vertical orbit stalled at step {step}" ); } } #[test] fn invalidating_model_bounds_also_drops_the_stale_scalar_range() { let mut cam = Camera::default(); cam.fit_depth_to_bounds_f64(DVec3::splat(-1.0), DVec3::splat(1.0)); assert!(cam.model_bounds.is_some()); assert!(cam.depth_half_range > 0.0); cam.invalidate_model_bounds(); assert!(cam.model_bounds.is_none()); assert_eq!(cam.depth_half_range, 0.0); } } #[cfg(test)] mod rte_tests { use super::*; use iced::Rectangle; #[test] fn cursor_picking_preserves_the_requested_plane() { let bounds = Rectangle::new(Point::ORIGIN, iced::Size::new(800.0, 600.0)); for projection in [Projection::Orthographic, Projection::Perspective] { for distance in [0.01, 60.36, 71.08106, 100_000.0] { for origin in [DVec3::ZERO, DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5)] { let camera = Camera { target: origin, distance, projection, ..Camera::default() }; for cursor in [Point::new(400.0, 300.0), Point::new(530.0, 215.0)] { let point = camera.pick_on_target_plane(cursor, bounds); assert_eq!(point.z, origin.z, "{projection:?}, distance={distance}, {point:?}"); let normal = Vec3::new(0.2, -0.3, 1.0).normalize(); let point = camera.pick_on_plane(cursor, bounds, normal, origin); let residual = (point - origin).dot(normal.as_dvec3()); assert!(residual.abs() < 1e-9, "plane residual={residual}, {projection:?}, distance={distance}"); } } } } } #[test] fn target_plane_picking_is_translation_invariant() { let bounds = Rectangle::new(Point::ORIGIN, iced::Size::new(800.0, 600.0)); let cursor = Point::new(530.0, 215.0); for projection in [Projection::Orthographic, Projection::Perspective] { let camera = Camera { rotation: Quat::from_rotation_z(0.7) * Quat::from_rotation_x(0.4), projection, distance: 0.01, ..Camera::default() }; let offset = DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5); let translated = Camera { target: offset, ..camera.clone() }; let here = camera.pick_on_target_plane(cursor, bounds); let there = translated.pick_on_target_plane(cursor, bounds) - offset; assert!(here.distance(there) < 1e-9, "{projection:?}: {here:?} vs {there:?}"); } } /// The relative-to-eye view carries rotation and nothing else, so where the /// camera stands must not reach it at all. /// /// It used to. The basis came from `look_at(eye, target)` with both cast to /// f32, and at survey coordinates that grid is half a metre across — so the /// difference between them, and the whole basis, jittered as the camera /// moved. A drawing placed at UTM shimmered. #[test] fn the_view_basis_does_not_depend_on_where_the_camera_stands() { let bounds = Rectangle { x: 0.0, y: 0.0, width: 1600.0, height: 900.0, }; let mut at_origin = Camera::default(); at_origin.target = glam::DVec3::ZERO; let mut at_utm = at_origin.clone(); at_utm.target = glam::DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5); let here = at_origin.view_proj_rte(bounds); let there = at_utm.view_proj_rte(bounds); for (a, b) in here.to_cols_array().iter().zip(there.to_cols_array().iter()) { assert_eq!(a, b, "the camera's position reached the view matrix"); } } /// And it is the same basis as before, which the origin proves: there the /// old construction had no large coordinates to lose, so the two must /// agree exactly. Anything else would mean the refactor turned the view /// round rather than just keeping the coordinates out of it. #[test] fn the_basis_matches_what_two_positions_gave_where_they_were_exact() { for (yaw, pitch) in [(0.0, 1.2), (0.7, 0.3), (-2.1, -0.9)] { let mut camera = Camera::default(); camera.target = glam::DVec3::ZERO; camera.rotation = Quat::from_rotation_z(yaw) * Quat::from_rotation_x(pitch); let up_dir = camera.rotation * Vec3::Y; let mut old = look_at_mat4( camera.eye().as_vec3(), camera.target.as_vec3(), up_dir, ); old.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); let new = { let mut view = look_at_mat4(Vec3::ZERO, -(camera.rotation * Vec3::Z), up_dir); view.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); view }; for (a, b) in old.to_cols_array().iter().zip(new.to_cols_array().iter()) { assert!((a - b).abs() < 1e-6, "yaw {yaw} pitch {pitch}: {a} vs {b}"); } } } }