forked from 1b-agent/fieldcraft-upstream
A scene's `on_top` lines are drawn after every depth-tested layer, over the model, so a concentrated load at a point inside the part can be seen from any side (#78). `render_layers` takes a list of overlays (the setup's symbols and the selection highlight become separate layers), and draws every layer's labels. `Camera::length_per_pixel` is the model length a pixel spans at the target: the scale symbols of a fixed screen size are drawn at. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
606 lines
26 KiB
Rust
606 lines
26 KiB
Rust
//! An orbit camera about a target point, Z up, with perspective or orthographic projection.
|
||
//!
|
||
//! Adapted from SolidCraft's `crates/render/src/camera.rs` (MIT OR Apache-2.0), see
|
||
//! ATTRIBUTION.md.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::pick::Projector;
|
||
use crate::{Aabb3, Vec3};
|
||
|
||
/// Column-major 4×4 matrix.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Mat4(pub [[f64; 4]; 4]);
|
||
|
||
impl Mat4 {
|
||
pub fn mul(&self, o: &Mat4) -> Mat4 {
|
||
let mut r = [[0.0; 4]; 4];
|
||
for (c, col) in r.iter_mut().enumerate() {
|
||
for (rr, cell) in col.iter_mut().enumerate() {
|
||
*cell = (0..4).map(|k| self.0[k][rr] * o.0[c][k]).sum();
|
||
}
|
||
}
|
||
Mat4(r)
|
||
}
|
||
/// Transform a point; returns (x, y, z, w).
|
||
pub fn apply(&self, p: Vec3) -> [f64; 4] {
|
||
let v = [p.x, p.y, p.z, 1.0];
|
||
let mut out = [0.0; 4];
|
||
for (r, o) in out.iter_mut().enumerate() {
|
||
*o = (0..4).map(|c| self.0[c][r] * v[c]).sum();
|
||
}
|
||
out
|
||
}
|
||
pub fn to_f32(&self) -> [[f32; 4]; 4] {
|
||
self.0.map(|c| c.map(|x| x as f32))
|
||
}
|
||
}
|
||
|
||
/// Named view orientations.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum StandardView {
|
||
Front,
|
||
Back,
|
||
Top,
|
||
Bottom,
|
||
Left,
|
||
Right,
|
||
Iso,
|
||
}
|
||
|
||
impl StandardView {
|
||
pub const ALL: [StandardView; 7] = [
|
||
StandardView::Front,
|
||
StandardView::Back,
|
||
StandardView::Left,
|
||
StandardView::Right,
|
||
StandardView::Top,
|
||
StandardView::Bottom,
|
||
StandardView::Iso,
|
||
];
|
||
|
||
pub fn parse(s: &str) -> Option<StandardView> {
|
||
Some(match s.to_ascii_lowercase().as_str() {
|
||
"front" => StandardView::Front,
|
||
"back" => StandardView::Back,
|
||
"top" => StandardView::Top,
|
||
"bottom" => StandardView::Bottom,
|
||
"left" => StandardView::Left,
|
||
"right" => StandardView::Right,
|
||
"iso" | "isometric" => StandardView::Iso,
|
||
_ => return None,
|
||
})
|
||
}
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
StandardView::Front => "Front",
|
||
StandardView::Back => "Back",
|
||
StandardView::Top => "Top",
|
||
StandardView::Bottom => "Bottom",
|
||
StandardView::Left => "Left",
|
||
StandardView::Right => "Right",
|
||
StandardView::Iso => "Iso",
|
||
}
|
||
}
|
||
/// (yaw, pitch) in radians. Yaw 0 looks along +Y (front view); the iso view looks from the
|
||
/// front-right-top corner. Pitch is the elevation above the XY plane.
|
||
pub fn angles(self) -> (f64, f64) {
|
||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||
match self {
|
||
StandardView::Front => (0.0, 0.0),
|
||
StandardView::Back => (PI, 0.0),
|
||
StandardView::Right => (-FRAC_PI_2, 0.0),
|
||
StandardView::Left => (FRAC_PI_2, 0.0),
|
||
StandardView::Top => (0.0, FRAC_PI_2),
|
||
StandardView::Bottom => (0.0, -FRAC_PI_2),
|
||
// atan(1/√2): the elevation of the cube diagonal.
|
||
StandardView::Iso => (-FRAC_PI_4, 0.6154797086703874),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a camera looks from, in degrees as people and agents give it: `yaw` turns about Z (0
|
||
/// looks along +Y, the front view; −45 is the iso view's), `pitch` is the elevation above the XY
|
||
/// plane and `distance`, when given, the distance from the target (a zoom in orthographic views).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Orientation {
|
||
pub yaw: f64,
|
||
pub pitch: f64,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub distance: Option<f64>,
|
||
}
|
||
|
||
impl Orientation {
|
||
/// Closest and farthest camera distance.
|
||
pub const DISTANCE: (f64, f64) = (1e-3, 1e9);
|
||
|
||
/// The orientation of a standard view.
|
||
pub fn of(view: StandardView) -> Self {
|
||
let (yaw, pitch) = view.angles();
|
||
Orientation { yaw: yaw.to_degrees(), pitch: pitch.to_degrees(), distance: None }
|
||
}
|
||
|
||
/// A checked orientation: `None` when an angle or the distance is not finite or the distance
|
||
/// is not above 0. The yaw is wrapped into [−180, 180), the pitch clamped to ±90 and the
|
||
/// distance to [`Orientation::DISTANCE`].
|
||
pub fn checked(yaw: f64, pitch: f64, distance: Option<f64>) -> Option<Self> {
|
||
if !(yaw.is_finite() && pitch.is_finite()) || distance.is_some_and(|d| !(d.is_finite() && d > 0.0)) {
|
||
return None;
|
||
}
|
||
let yaw = (yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||
let (near, far) = Self::DISTANCE;
|
||
Some(Orientation { yaw, pitch: pitch.clamp(-90.0, 90.0), distance: distance.map(|d| d.clamp(near, far)) })
|
||
}
|
||
}
|
||
|
||
/// The whole view, as the `free` part of [`Camera::fit_in`].
|
||
pub const FULL_VIEW: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
|
||
|
||
/// The share of the free part's width and height a fit leaves empty on each side.
|
||
pub const FIT_MARGIN: f64 = 0.06;
|
||
|
||
/// `free` checked: finite, within the view, at least a tenth of it wide and high (else the whole
|
||
/// view).
|
||
fn checked_free(free: [f64; 4]) -> [f64; 4] {
|
||
let [x0, y0, x1, y1] = free.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { f64::NAN });
|
||
if x1 - x0 >= 0.1 && y1 - y0 >= 0.1 { [x0, y0, x1, y1] } else { FULL_VIEW }
|
||
}
|
||
|
||
/// Orbit camera around `target`, Z up.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Camera {
|
||
pub target: Vec3,
|
||
/// Rotation about Z; 0 = looking along +Y (from the front).
|
||
pub yaw: f64,
|
||
/// Elevation above the XY plane, in [−π/2, π/2].
|
||
pub pitch: f64,
|
||
/// Distance from target to eye.
|
||
pub distance: f64,
|
||
/// Vertical field of view (radians); 0 = orthographic.
|
||
pub fov: f64,
|
||
}
|
||
|
||
impl Default for Camera {
|
||
fn default() -> Self {
|
||
let (yaw, pitch) = StandardView::Iso.angles();
|
||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
|
||
}
|
||
}
|
||
|
||
impl Camera {
|
||
/// Direction from the target toward the eye.
|
||
pub fn back(&self) -> Vec3 {
|
||
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
|
||
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
|
||
}
|
||
pub fn eye(&self) -> Vec3 {
|
||
self.target + self.back() * self.distance
|
||
}
|
||
/// Camera basis: right, up, back (orthonormal).
|
||
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
|
||
let b = self.back();
|
||
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
|
||
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
|
||
(right, up, b)
|
||
}
|
||
pub fn view(&self) -> Mat4 {
|
||
let (r, u, b) = self.basis();
|
||
let e = self.eye();
|
||
Mat4([[r.x, u.x, b.x, 0.0], [r.y, u.y, b.y, 0.0], [r.z, u.z, b.z, 0.0], [-r.dot(e), -u.dot(e), -b.dot(e), 1.0]])
|
||
}
|
||
/// Half the visible height at the target distance.
|
||
pub fn half_height(&self) -> f64 {
|
||
if self.fov > 0.0 { self.distance * (self.fov * 0.5).tan() } else { self.distance * 0.4 }
|
||
}
|
||
/// Model length one pixel spans at the target, in a view `height_px` pixels high (the scale
|
||
/// symbols of a fixed screen size are drawn at); `None` for a size or camera that has none.
|
||
pub fn length_per_pixel(&self, height_px: f64) -> Option<f64> {
|
||
let l = 2.0 * self.half_height() / height_px;
|
||
(height_px > 0.0 && l.is_finite() && l > 0.0).then_some(l)
|
||
}
|
||
/// Near/far planes that keep a scene of radius `radius` around the target visible.
|
||
pub fn clip(&self, radius: f64) -> (f64, f64) {
|
||
let r = radius.max(1e-3);
|
||
if self.fov > 0.0 {
|
||
((self.distance - r * 2.0).max(self.distance * 1e-3).max(1e-3), self.distance + r * 2.0)
|
||
} else {
|
||
(self.distance - r * 4.0 - 1.0, self.distance + r * 4.0 + 1.0)
|
||
}
|
||
}
|
||
/// Projection to clip space (wgpu style, depth 0..1).
|
||
pub fn proj(&self, aspect: f64, near: f64, far: f64) -> Mat4 {
|
||
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
|
||
let (n, f) = (near, far.max(near + 1e-6));
|
||
if self.fov > 0.0 {
|
||
let t = 1.0 / (self.fov * 0.5).tan();
|
||
Mat4([[t / aspect, 0.0, 0.0, 0.0], [0.0, t, 0.0, 0.0], [0.0, 0.0, f / (n - f), -1.0], [0.0, 0.0, n * f / (n - f), 0.0]])
|
||
} else {
|
||
let h = self.half_height();
|
||
let w = h * aspect;
|
||
Mat4([[1.0 / w, 0.0, 0.0, 0.0], [0.0, 1.0 / h, 0.0, 0.0], [0.0, 0.0, 1.0 / (n - f), 0.0], [0.0, 0.0, n / (n - f), 1.0]])
|
||
}
|
||
}
|
||
pub fn view_proj(&self, aspect: f64, radius: f64) -> Mat4 {
|
||
let (n, f) = self.clip(radius);
|
||
self.proj(aspect, n, f).mul(&self.view())
|
||
}
|
||
/// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far).
|
||
/// For many points, a [`Projector`] computes the projection once.
|
||
pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> {
|
||
Projector::new(self, w, h, radius).screen(p).map(|[x, y, z]| (x, y, z))
|
||
}
|
||
/// Picking ray through pixel (x, y): origin and unit direction.
|
||
pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) {
|
||
let (r, u, b) = self.basis();
|
||
let hh = self.half_height();
|
||
let aspect = w / h.max(1.0);
|
||
let nx = (x / w.max(1.0)) * 2.0 - 1.0;
|
||
let ny = 1.0 - (y / h.max(1.0)) * 2.0;
|
||
if self.fov > 0.0 {
|
||
let d = (r * (nx * hh * aspect) + u * (ny * hh) - b * self.distance).normalized().unwrap_or(-b);
|
||
(self.eye(), d)
|
||
} else {
|
||
let o = self.target + r * (nx * hh * aspect) + u * (ny * hh) + b * (self.distance * 4.0);
|
||
(o, -b)
|
||
}
|
||
}
|
||
/// The point under pixel (x, y) on the plane through the target facing the eye: the anchor
|
||
/// that stays put when zooming at the cursor.
|
||
pub fn target_plane_point(&self, x: f64, y: f64, w: f64, h: f64) -> Option<Vec3> {
|
||
let (o, d) = self.ray(x, y, w, h);
|
||
let b = self.back();
|
||
let dn = d.dot(b);
|
||
if dn.abs() < 1e-12 {
|
||
return None;
|
||
}
|
||
let p = o + d * ((self.target - o).dot(b) / dn);
|
||
p.is_finite().then_some(p)
|
||
}
|
||
/// Zoom by `factor` (< 1 zooms in) toward a world point that stays put on screen.
|
||
pub fn zoom_at(&mut self, factor: f64, anchor: Option<Vec3>) {
|
||
let f = if factor.is_finite() { factor.clamp(0.05, 20.0) } else { 1.0 };
|
||
let nd = (self.distance * f).clamp(1e-3, 1e9);
|
||
if let Some(a) = anchor.filter(|a| a.is_finite()) {
|
||
self.target = a + (self.target - a) * (nd / self.distance);
|
||
}
|
||
self.distance = nd;
|
||
}
|
||
/// Pan by a screen delta in pixels for a viewport of height `h`.
|
||
pub fn pan(&mut self, dx: f64, dy: f64, h: f64) {
|
||
if !(dx.is_finite() && dy.is_finite()) {
|
||
return;
|
||
}
|
||
let (r, u, _) = self.basis();
|
||
let k = 2.0 * self.half_height() / h.max(1.0);
|
||
self.target = self.target - r * (dx * k) + u * (dy * k);
|
||
}
|
||
/// Orbit by pixel deltas. The model follows the cursor: dragging right turns the side
|
||
/// facing the viewer to the right.
|
||
pub fn orbit(&mut self, dx: f64, dy: f64) {
|
||
if !(dx.is_finite() && dy.is_finite()) {
|
||
return;
|
||
}
|
||
let lim = std::f64::consts::FRAC_PI_2 - 1e-4;
|
||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||
}
|
||
/// Orbit by pixel deltas about `pivot`, which keeps its place on screen: the eye turns
|
||
/// about the pivot and the target follows, so the pivot's view coordinates (and so its
|
||
/// screen position, in perspective and orthographic views) are the same after the turn.
|
||
pub fn orbit_about(&mut self, pivot: Vec3, dx: f64, dy: f64) {
|
||
if !pivot.is_finite() {
|
||
self.orbit(dx, dy);
|
||
return;
|
||
}
|
||
let (r, u, b) = self.basis();
|
||
let v = pivot - self.eye();
|
||
let (vx, vy, vz) = (v.dot(r), v.dot(u), v.dot(b));
|
||
self.orbit(dx, dy);
|
||
let (r, u, b) = self.basis();
|
||
let target = pivot - (r * vx + u * vy + b * vz) - b * self.distance;
|
||
if target.is_finite() {
|
||
self.target = target;
|
||
}
|
||
}
|
||
pub fn set_view(&mut self, v: StandardView) {
|
||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||
let (y, p) = v.angles();
|
||
self.yaw = y;
|
||
self.pitch = p.clamp(-lim, lim);
|
||
}
|
||
/// Turn to `o` (and move to its distance, when it has one); the pitch stays just short of the
|
||
/// poles, as an orbit's does.
|
||
pub fn orient(&mut self, o: &Orientation) {
|
||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||
if o.yaw.is_finite() && o.pitch.is_finite() {
|
||
self.yaw = o.yaw.to_radians();
|
||
self.pitch = o.pitch.to_radians().clamp(-lim, lim);
|
||
}
|
||
if let Some(d) = o.distance.filter(|d| d.is_finite() && *d > 0.0) {
|
||
self.distance = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1);
|
||
}
|
||
}
|
||
/// Frame a bounding box in the whole view of a square viewport (see [`Camera::fit_in`]).
|
||
pub fn fit(&mut self, b: &Aabb3) {
|
||
self.fit_in(b, 1.0, FULL_VIEW);
|
||
}
|
||
|
||
/// Frame a bounding box in part of the view, keeping the direction of view: the smallest
|
||
/// distance (zoom, in orthographic views) at which all eight corners of `b` project inside
|
||
/// `free` shrunk by [`FIT_MARGIN`] on each side, and the target moved across the view so the
|
||
/// box sits centred in it. `free` is `[x0, y0, x1, y1]` as fractions of the view's width and
|
||
/// height, y down (the part overlays leave free); `aspect` is the view's width over height.
|
||
///
|
||
/// The corners' projections are exact, for orthographic and perspective cameras alike, so
|
||
/// a long beam seen side on fills the width instead of a sphere around it the height. With
|
||
/// `s(z) = α·d − β·z` the half height of the view at depth `z` toward the eye (orthographic
|
||
/// α = 0.4, β = 0; perspective α = β = tan(fov/2)), a corner at `(x, z)` in view
|
||
/// coordinates (relative to the box centre) shows at `(x − t)/(aspect·s(z))` in normalized
|
||
/// device coordinates for a target offset `t`. Keeping every corner between the free part's
|
||
/// edges `a0 … a1` is linear in `(t, d)`: a pair of corners `i`, `j` needs
|
||
/// `d ≥ ((x_i − x_j)/aspect + β·(a1·z_i − a0·z_j)) / (α·(a1 − a0))`; the largest over all
|
||
/// pairs and both axes is the distance, and `t` is the middle of what is then allowed.
|
||
pub fn fit_in(&mut self, b: &Aabb3, aspect: f64, free: [f64; 4]) {
|
||
if b.is_empty() || !(b.min.is_finite() && b.max.is_finite()) {
|
||
return;
|
||
}
|
||
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect.min(1e6) } else { 1.0 };
|
||
let [x0, y0, x1, y1] = checked_free(free);
|
||
// The free part's edges in normalized device coordinates (y up), inside the margin.
|
||
let (mx, my) = ((x1 - x0) * FIT_MARGIN, (y1 - y0) * FIT_MARGIN);
|
||
let ax = (2.0 * (x0 + mx) - 1.0, 2.0 * (x1 - mx) - 1.0);
|
||
let ay = (1.0 - 2.0 * (y1 - my), 1.0 - 2.0 * (y0 + my));
|
||
let (r, u, back) = self.basis();
|
||
let c = b.center();
|
||
let corners = b.corners().map(|p| {
|
||
let p = p - c;
|
||
[p.dot(r), p.dot(u), p.dot(back)]
|
||
});
|
||
// The half height per unit of distance (`half_height`), and its change with depth.
|
||
let alpha = Camera { distance: 1.0, ..*self }.half_height();
|
||
let beta = if self.fov > 0.0 { alpha } else { 0.0 };
|
||
let size = (b.diagonal() * 0.5).max(1e-3);
|
||
// Nearest distance: a tiny box still has a size, and in perspective every corner is in
|
||
// front of the eye (an orthographic distance is only a zoom).
|
||
let mut d = size * 1e-3 / alpha;
|
||
if beta > 0.0 {
|
||
d = corners.iter().map(|q| q[2] + size * 1e-3).fold(d, f64::max);
|
||
}
|
||
for (axis, (a0, a1), k) in [(0, ax, aspect), (1, ay, 1.0)] {
|
||
for i in &corners {
|
||
for j in &corners {
|
||
let need = ((i[axis] - j[axis]) / k + beta * (a1 * i[2] - a0 * j[2])) / (alpha * (a1 - a0));
|
||
d = d.max(need);
|
||
}
|
||
}
|
||
}
|
||
let d = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1);
|
||
// The target offset along each axis: the middle of the offsets that keep every corner in.
|
||
let offset = |axis: usize, (a0, a1): (f64, f64), k: f64| {
|
||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||
for q in &corners {
|
||
let s = k * (alpha * d - beta * q[2]);
|
||
lo = lo.max(q[axis] - a1 * s);
|
||
hi = hi.min(q[axis] - a0 * s);
|
||
}
|
||
0.5 * (lo + hi)
|
||
};
|
||
let (tx, ty) = (offset(0, ax, aspect), offset(1, ay, 1.0));
|
||
let target = c + r * tx + u * ty;
|
||
if target.is_finite() && d.is_finite() {
|
||
self.target = target;
|
||
self.distance = d;
|
||
}
|
||
}
|
||
|
||
pub fn is_valid(&self) -> bool {
|
||
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn front_view_projects_target_to_centre() {
|
||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||
c.set_view(StandardView::Front);
|
||
let (x, y, z) = c.to_screen(c.target, 800.0, 600.0, 10.0).unwrap();
|
||
assert!((x - 400.0).abs() < 1e-6 && (y - 300.0).abs() < 1e-6 && (0.0..=1.0).contains(&z));
|
||
// Front view looks along +Y: +X is to the right, +Z up.
|
||
let (xr, _, _) = c.to_screen(c.target + Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||
let (_, yu, _) = c.to_screen(c.target + Vec3::Z, 800.0, 600.0, 10.0).unwrap();
|
||
assert!(xr > 400.0 && yu < 300.0);
|
||
let (o, d) = c.ray(400.0, 300.0, 800.0, 600.0);
|
||
assert!(d.dist(Vec3::Y) < 1e-9, "{d:?}");
|
||
assert!((o - c.target).cross(d).len() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_orbit_follows_the_cursor() {
|
||
let mut c = Camera { distance: 50.0, ..Default::default() };
|
||
c.set_view(StandardView::Front);
|
||
let near = Vec3::new(0.0, -1.0, 0.0);
|
||
let x0 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||
c.orbit(20.0, 0.0);
|
||
let x1 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
|
||
}
|
||
|
||
#[test]
|
||
fn top_view_and_perspective() {
|
||
let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() };
|
||
c.set_view(StandardView::Top);
|
||
let (xr, _, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||
let (_, yu, _) = c.to_screen(Vec3::Y, 800.0, 600.0, 10.0).unwrap();
|
||
assert!(xr > 400.0 && yu < 300.0, "{xr} {yu}");
|
||
let near = c.to_screen(Vec3::new(0.0, 0.0, 10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||
let far = c.to_screen(Vec3::new(0.0, 0.0, -10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||
assert!(near < far);
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_at_the_cursor_keeps_the_point_under_it() {
|
||
let mut c = Camera { distance: 100.0, ..Default::default() };
|
||
c.set_view(StandardView::Front);
|
||
let a = c.target_plane_point(600.0, 200.0, 800.0, 600.0).unwrap();
|
||
c.zoom_at(0.5, Some(a));
|
||
let (x, y, _) = c.to_screen(a, 800.0, 600.0, 10.0).unwrap();
|
||
assert!((x - 600.0).abs() < 1e-6 && (y - 200.0).abs() < 1e-6, "{x} {y}");
|
||
}
|
||
|
||
/// Orbiting about a pivot off the target keeps the pivot where it was on screen, in both
|
||
/// projections and from any view.
|
||
#[test]
|
||
fn orbit_about_keeps_the_pivot_on_screen() {
|
||
let pivot = Vec3::new(37.0, -12.0, 8.0);
|
||
for fov in [0.0, 0.7] {
|
||
for v in StandardView::ALL {
|
||
let mut c = Camera { target: Vec3::new(5.0, 3.0, -2.0), distance: 150.0, fov, ..Default::default() };
|
||
c.set_view(v);
|
||
let before = c.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||
for (dx, dy) in [(40.0, 0.0), (0.0, -25.0), (-60.0, 33.0)] {
|
||
c.orbit_about(pivot, dx, dy);
|
||
let after = c.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||
assert!(
|
||
(after.0 - before.0).abs() < 1e-6 && (after.1 - before.1).abs() < 1e-6,
|
||
"{} fov {fov}: {before:?} -> {after:?}",
|
||
v.label()
|
||
);
|
||
}
|
||
assert!(c.is_valid());
|
||
}
|
||
}
|
||
let mut c = Camera::default();
|
||
c.orbit_about(Vec3::new(f64::NAN, 0.0, 0.0), 10.0, 10.0);
|
||
assert!(c.is_valid() && c.target == Vec3::ZERO);
|
||
}
|
||
|
||
/// A wheel notch zooms by the same factor in orthographic and perspective views: the visible
|
||
/// height scales by it, and the point under the cursor stays put (#273).
|
||
#[test]
|
||
fn zoom_scales_the_view_alike_in_both_projections() {
|
||
for fov in [0.0, 0.7] {
|
||
let mut c = Camera { distance: 300.0, fov, ..Default::default() };
|
||
c.set_view(StandardView::Front);
|
||
let h0 = c.half_height();
|
||
let a = c.target_plane_point(650.0, 120.0, 800.0, 600.0).unwrap();
|
||
c.zoom_at(0.8, Some(a));
|
||
assert!((c.half_height() / h0 - 0.8).abs() < 1e-12, "fov {fov}");
|
||
let (x, y, _) = c.to_screen(a, 800.0, 600.0, 10.0).unwrap();
|
||
assert!((x - 650.0).abs() < 1e-6 && (y - 120.0).abs() < 1e-6, "fov {fov}: {x} {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fit_and_hostile() {
|
||
let mut c = Camera::default();
|
||
let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) };
|
||
c.fit(&b);
|
||
assert!(c.target.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-9);
|
||
c.zoom_at(f64::NAN, Some(Vec3::new(f64::NAN, 0.0, 0.0)));
|
||
c.orbit(1e9, -1e9);
|
||
c.orbit(f64::NAN, 0.0);
|
||
c.pan(f64::INFINITY, 0.0, 100.0);
|
||
assert!(c.is_valid());
|
||
c.fit(&Aabb3::EMPTY);
|
||
assert!(c.is_valid());
|
||
for v in StandardView::ALL {
|
||
assert_eq!(StandardView::parse(v.label()), Some(v));
|
||
}
|
||
}
|
||
|
||
/// Every corner of the box projects inside `free` (in pixels of a `w` × `h` view), and the
|
||
/// fit is tight: the corners reach the margin on one axis.
|
||
fn assert_framed(c: &Camera, b: &Aabb3, w: f64, h: f64, free: [f64; 4], what: &str) {
|
||
let [x0, y0, x1, y1] = free;
|
||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||
let proj = crate::pick::Projector::new(c, w, h, b.diagonal());
|
||
for (k, p) in b.corners().into_iter().enumerate() {
|
||
let [x, y, z] = proj.screen(p).unwrap();
|
||
assert!((0.0..=1.0).contains(&z), "{what}: corner {k} clipped (depth {z})");
|
||
let eps = 1e-6 * (w + h);
|
||
assert!(
|
||
x >= x0 * w - eps && x <= x1 * w + eps && y >= y0 * h - eps && y <= y1 * h + eps,
|
||
"{what}: corner {k} at ({x}, {y}) outside {free:?} of {w}×{h}"
|
||
);
|
||
lo = [lo[0].min(x), lo[1].min(y)];
|
||
hi = [hi[0].max(x), hi[1].max(y)];
|
||
}
|
||
// Tight: the box spans the free width or height less the margins.
|
||
let span = ((hi[0] - lo[0]) / ((x1 - x0) * w)).max((hi[1] - lo[1]) / ((y1 - y0) * h));
|
||
assert!((span - (1.0 - 2.0 * FIT_MARGIN)).abs() < 1e-6, "{what}: spans {span} of the free part");
|
||
}
|
||
|
||
#[test]
|
||
fn fit_in_frames_every_corner_in_the_free_part() {
|
||
let boxes = [
|
||
("long beam", Aabb3 { min: Vec3::new(0.0, -5.0, -10.0), max: Vec3::new(200.0, 5.0, 10.0) }),
|
||
("cube off the origin", Aabb3 { min: Vec3::new(100.0, 50.0, -20.0), max: Vec3::new(140.0, 90.0, 20.0) }),
|
||
("tall post", Aabb3 { min: Vec3::new(-2.0, -2.0, 0.0), max: Vec3::new(2.0, 2.0, 300.0) }),
|
||
("flat plate", Aabb3 { min: Vec3::ZERO, max: Vec3::new(120.0, 80.0, 0.0) }),
|
||
];
|
||
// Whole view; right of a legend and above the navigation bar; a narrow middle strip.
|
||
let frees = [FULL_VIEW, [0.22, 0.0, 0.85, 0.93], [0.3, 0.1, 0.7, 0.9]];
|
||
let sizes = [(1280.0, 800.0), (400.0, 1000.0), (800.0, 800.0), (2400.0, 600.0)];
|
||
for (name, b) in &boxes {
|
||
for v in StandardView::ALL {
|
||
for fov in [0.0, 0.7] {
|
||
for (w, h) in sizes {
|
||
for free in frees {
|
||
let mut c = Camera { fov, ..Default::default() };
|
||
c.set_view(v);
|
||
c.fit_in(b, w / h, free);
|
||
assert!(c.is_valid());
|
||
assert_framed(&c, b, w, h, free, &format!("{name}, {} view, fov {fov}, {w}×{h}, {free:?}", v.label()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fit_in_refuses_hostile_input() {
|
||
let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(10.0, 10.0, 10.0) };
|
||
let mut c = Camera::default();
|
||
c.fit_in(&b, f64::NAN, [f64::NAN, 0.0, 2.0, -1.0]);
|
||
assert!(c.is_valid());
|
||
let before = c;
|
||
c.fit_in(&Aabb3 { min: Vec3::new(f64::NEG_INFINITY, 0.0, 0.0), max: Vec3::ZERO }, 1.0, FULL_VIEW);
|
||
assert_eq!(c, before);
|
||
// A point: framed at a tiny distance, still valid.
|
||
c.fit_in(&Aabb3 { min: Vec3::ZERO, max: Vec3::ZERO }, 1.0, FULL_VIEW);
|
||
assert!(c.is_valid());
|
||
}
|
||
|
||
#[test]
|
||
fn orientation_in_degrees() {
|
||
// The iso view's angles, given in degrees, turn the camera as the iso view does.
|
||
let (mut a, mut b) = (Camera::default(), Camera::default());
|
||
a.set_view(StandardView::Front);
|
||
a.orient(&Orientation::of(StandardView::Iso));
|
||
b.set_view(StandardView::Iso);
|
||
assert!((a.yaw - b.yaw).abs() < 1e-12 && (a.pitch - b.pitch).abs() < 1e-12);
|
||
let o = Orientation::checked(-45.0, 35.264, Some(300.0)).unwrap();
|
||
a.orient(&o);
|
||
assert_eq!(a.distance, 300.0);
|
||
// Wrapped, clamped, refused.
|
||
let o = Orientation::checked(540.0, 1e6, Some(1e300)).unwrap();
|
||
assert_eq!((o.yaw, o.pitch, o.distance), (-180.0, 90.0, Some(Orientation::DISTANCE.1)));
|
||
a.orient(&o);
|
||
assert!(a.is_valid() && a.pitch < std::f64::consts::FRAC_PI_2, "{a:?}");
|
||
for (y, p, d) in
|
||
[(f64::NAN, 0.0, None), (0.0, f64::INFINITY, None), (0.0, 0.0, Some(0.0)), (0.0, 0.0, Some(-1.0)), (0.0, 0.0, Some(f64::NAN))]
|
||
{
|
||
assert_eq!(Orientation::checked(y, p, d), None);
|
||
}
|
||
// A hostile orientation built by hand leaves the camera as it was.
|
||
let before = a;
|
||
a.orient(&Orientation { yaw: f64::NAN, pitch: 0.0, distance: Some(f64::INFINITY) });
|
||
assert_eq!(a, before);
|
||
}
|
||
}
|