Files
OneCAD-Draft/src/scene/view/camera.rs
T

1032 lines
45 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::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<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 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<DVec3>) {
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}");
}
}
}
}