molgfx-math 0.3.2

glam-based math foundation: cameras, projections, bounds, splines, transport frames.
Documentation
//! The camera: an eye, a target, an up hint and a projection.
//!
//! The full model→world→view→clip chain is composed here in one fixed order
//! with fixed associativity, so an identical camera and scene produce
//! bit-identical clip coordinates run after run.

use crate::aabb::{Aabb, BoundingSphere};
use crate::projection::Projection;
use crate::{Mat4, Vec3, Vec4};
use serde::{Deserialize, Serialize};

#[cfg(test)]
#[path = "camera_tests.rs"]
mod tests;

/// A look-at camera in world space (right-handed, +Y up, looking down −Z in
/// its own view space).
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
pub struct Camera {
    /// Eye position, world space.
    pub eye: Vec3,
    /// Point the camera looks at, world space.
    pub target: Vec3,
    /// Up hint; need not be orthogonal to the view direction.
    pub up: Vec3,
    /// The view-to-clip mapping.
    pub projection: Projection,
}

impl Camera {
    /// Builds a right-handed world-to-view look-at transform.
    #[must_use]
    pub fn look_at(eye: Vec3, target: Vec3, up: Vec3) -> Mat4 {
        glam::camera::rh::view::look_at_mat4(eye.into(), target.into(), up.into()).into()
    }

    /// Frames a world-space box tightly from the +Z axis with deterministic
    /// padding. This avoids the excess empty space introduced by first
    /// enclosing an anisotropic molecule in a sphere.
    #[must_use]
    pub fn framing_aabb(bound: &Aabb, aspect: f32) -> Self {
        if bound.is_empty() {
            return Self::framing(
                &BoundingSphere {
                    center: Vec3::ZERO,
                    radius: 1.0,
                },
                aspect,
            );
        }
        let fov_y = std::f32::consts::FRAC_PI_4;
        let half = bound.half_extents() * 1.12;
        let vertical_tangent = (fov_y * 0.5).tan();
        let horizontal_tangent = vertical_tangent * aspect.max(1e-3);
        // Look down the structure's shortest extent so its broadest face meets
        // the camera. Viewing along a long axis foreshortens an elongated
        // molecule — a duplex, a coiled coil, a fibre — into its least
        // informative silhouette. Ties resolve toward +Z, so a cube keeps the
        // conventional front view.
        let extents = [half.x, half.y, half.z];
        let mut depth_axis = 2usize;
        for axis in [1usize, 0usize] {
            if extents[axis] < extents[depth_axis] {
                depth_axis = axis;
            }
        }
        let first = (depth_axis + 1) % 3;
        let second = (depth_axis + 2) % 3;
        // The wider of the two remaining extents lies across the screen, which
        // is where a frame has the most room.
        let (horizontal_axis, vertical_axis) = if extents[first] >= extents[second] {
            (first, second)
        } else {
            (second, first)
        };
        let projected = (extents[vertical_axis] / vertical_tangent)
            .max(extents[horizontal_axis] / horizontal_tangent)
            .max(1.0);
        let center = bound.center();
        let unit = |axis: usize| match axis {
            0 => Vec3::X,
            1 => Vec3::Y,
            _ => Vec3::Z,
        };
        let eye = center + unit(depth_axis) * (extents[depth_axis] + projected);
        let up = unit(vertical_axis);
        let sphere = bound.bounding_sphere();
        let mut projection = Projection::Perspective {
            fov_y,
            aspect,
            near: 0.1,
            far: eye.distance(center) + sphere.radius * 2.0,
        };
        projection.fit_near_far(eye, &sphere);
        Self {
            eye,
            target: center,
            up,
            projection,
        }
    }

    /// A camera looking at `bound` from a distance that frames it fully,
    /// down the +Z axis with +Y up.
    #[must_use]
    pub fn framing(bound: &BoundingSphere, aspect: f32) -> Self {
        let fov_y = std::f32::consts::FRAC_PI_4;
        let radius = bound.radius.max(1.0);
        // Fit the sphere in both the vertical and horizontal fields of view.
        let half_min_fov = if aspect < 1.0 {
            (fov_y * 0.5).tan() * aspect
        } else {
            (fov_y * 0.5).tan()
        };
        let distance = radius / half_min_fov.clamp(1e-3, 1.0) * 1.2;
        let eye = bound.center + Vec3::new(0.0, 0.0, distance);
        let mut projection = Projection::Perspective {
            fov_y,
            aspect,
            near: 0.1,
            far: distance + radius * 2.0,
        };
        projection.fit_near_far(eye, bound);
        Self {
            eye,
            target: bound.center,
            up: Vec3::Y,
            projection,
        }
    }

    /// The view-from-world matrix.
    #[must_use]
    pub fn view(&self) -> Mat4 {
        glam::camera::rh::view::look_at_mat4(self.eye.into(), self.target.into(), self.up.into())
            .into()
    }

    /// The clip-from-world matrix: projection composed with view, in that
    /// order, always.
    #[must_use]
    pub fn view_proj(&self) -> Mat4 {
        self.projection.matrix() * self.view()
    }

    /// The six frustum planes of `view_proj`, as `(normal, d)` packed into
    /// `Vec4` with the inside satisfying `dot(n, p) + d >= 0`. Order: left,
    /// right, bottom, top, near, far.
    #[must_use]
    pub fn frustum_planes(&self) -> [Vec4; 6] {
        let m = self.view_proj();
        let row = |i: usize| m.row(i);
        let (r0, r1, r2, r3) = (row(0), row(1), row(2), row(3));
        let normalize = |p: Vec4| {
            let len = p.truncate().length();
            if len > 0.0 { p / len } else { p }
        };
        [
            normalize(r3 + r0), // left
            normalize(r3 - r0), // right
            normalize(r3 + r1), // bottom
            normalize(r3 - r1), // top
            // Reversed depth: clip z spans [0, w] with near at w and far at 0,
            // so z >= 0 is the far side and w - z >= 0 the near side.
            normalize(r3 - r2), // near
            normalize(r2),      // far
        ]
    }

    /// Distance from eye to target.
    #[must_use]
    pub fn focus_distance(&self) -> f32 {
        self.eye.distance(self.target)
    }
}