scena 1.3.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use crate::diagnostics::LookupError;

use super::{FramingOptions, aabb_corners, validate_bounds};
use crate::scene::view_math::look_rotation;
use crate::scene::{PerspectiveCamera, Quat, Transform, Vec3};

#[derive(Debug, Clone, Copy)]
pub(super) struct ValidFramingOptions {
    pub(super) view_direction: Vec3,
    pub(super) up: Vec3,
    pub(super) fill: f32,
    pub(super) margin_px: f32,
    pub(super) viewport_width: u32,
    pub(super) viewport_height: u32,
    pub(super) tighten_depth_range: bool,
}

#[derive(Debug, Clone, Copy)]
pub(super) struct PerspectiveFit {
    pub(super) camera_transform: Transform,
    pub(super) target: Vec3,
    pub(super) distance: f32,
    pub(super) yaw_radians: f32,
    pub(super) pitch_radians: f32,
    pub(super) depth_radius: f32,
}

impl ValidFramingOptions {
    pub(super) fn new(options: FramingOptions) -> Result<Self, LookupError> {
        if options.viewport_width == 0 || options.viewport_height == 0 {
            return Err(LookupError::InvalidViewport {
                width: options.viewport_width,
                height: options.viewport_height,
            });
        }
        if !options.fill.is_finite() || options.fill <= 0.0 || options.fill > 1.0 {
            return Err(LookupError::InvalidFramingOption {
                field: "fill",
                reason: "fill must be finite and within 0 < fill <= 1",
            });
        }
        if !options.margin_px.is_finite() || options.margin_px < 0.0 {
            return Err(LookupError::InvalidFramingOption {
                field: "margin_px",
                reason: "margin_px must be finite and non-negative",
            });
        }
        let min_dimension = options.viewport_width.min(options.viewport_height) as f32;
        if options.margin_px * 2.0 >= min_dimension {
            return Err(LookupError::InvalidFramingOption {
                field: "margin_px",
                reason: "margin_px leaves no usable viewport",
            });
        }
        let view_direction =
            normalize(options.view_direction).ok_or(LookupError::InvalidFramingOption {
                field: "view_direction",
                reason: "view_direction must be finite and non-zero",
            })?;
        let up = normalize(options.up).ok_or(LookupError::InvalidFramingOption {
            field: "up",
            reason: "up vector must be finite and non-zero",
        })?;
        Ok(Self {
            view_direction,
            up,
            fill: options.fill,
            margin_px: options.margin_px,
            viewport_width: options.viewport_width,
            viewport_height: options.viewport_height,
            tighten_depth_range: options.tighten_depth_range,
        })
    }

    pub(super) fn aspect(self) -> f32 {
        self.viewport_width as f32 / self.viewport_height as f32
    }

    fn allowed_ndc_x(self) -> f32 {
        let usable = self.viewport_width as f32 - self.margin_px * 2.0;
        (usable / self.viewport_width as f32 * self.fill).max(0.001)
    }

    fn allowed_ndc_y(self) -> f32 {
        let usable = self.viewport_height as f32 - self.margin_px * 2.0;
        (usable / self.viewport_height as f32 * self.fill).max(0.001)
    }
}

pub(super) fn perspective_fit(
    bounds: crate::Aabb,
    camera: PerspectiveCamera,
    options: ValidFramingOptions,
) -> Result<PerspectiveFit, LookupError> {
    validate_bounds(bounds)?;
    let base_target = bounds.center();
    let rotation = look_rotation(-options.view_direction, options.up);
    let inverse_rotation = rotation.inverse();

    let mut min = Vec3::splat(f32::INFINITY);
    let mut max = Vec3::splat(f32::NEG_INFINITY);
    for corner in aabb_corners(bounds) {
        let view = inverse_rotation * (corner - base_target);
        min = min.min(view);
        max = max.max(view);
    }

    let half_fov = camera.vertical_fov.radians() * 0.5;
    if !half_fov.is_finite() || half_fov <= 0.0 {
        return Err(LookupError::InvalidFramingOption {
            field: "vertical_fov",
            reason: "perspective camera vertical_fov must be positive",
        });
    }
    let focal = half_fov.tan().recip();

    let view_center = (min + max) * 0.5;
    let mut target = base_target + rotation * Vec3::new(view_center.x, view_center.y, 0.0);
    let mut solve = solve_perspective_distance(bounds, target, inverse_rotation, focal, options);
    for _ in 0..4 {
        let shift = Vec3::new(
            solve.center_ndc_x * solve.distance * options.aspect() / focal,
            solve.center_ndc_y * solve.distance / focal,
            0.0,
        );
        if shift.length_squared() <= 1e-8 {
            break;
        }
        target += rotation * shift;
        solve = solve_perspective_distance(bounds, target, inverse_rotation, focal, options);
    }
    let distance = solve.distance;
    let camera_transform = Transform {
        translation: target + options.view_direction * distance,
        rotation,
        scale: Vec3::ONE,
    };
    let (yaw_radians, pitch_radians) = orbit_angles_from_direction(options.view_direction);
    let depth_radius = (distance - solve.min_z)
        .max(distance + solve.max_z)
        .max(0.01);

    Ok(PerspectiveFit {
        camera_transform,
        target,
        distance,
        yaw_radians,
        pitch_radians,
        depth_radius,
    })
}

#[derive(Debug, Clone, Copy)]
struct PerspectiveDistanceSolve {
    distance: f32,
    min_z: f32,
    max_z: f32,
    center_ndc_x: f32,
    center_ndc_y: f32,
}

fn solve_perspective_distance(
    bounds: crate::Aabb,
    target: Vec3,
    inverse_rotation: Quat,
    focal: f32,
    options: ValidFramingOptions,
) -> PerspectiveDistanceSolve {
    let mut distance: f32 = 0.01;
    let mut min_z: f32 = 0.0;
    let mut max_z: f32 = 0.0;
    let mut views = Vec::with_capacity(8);
    for corner in aabb_corners(bounds) {
        let view = inverse_rotation * (corner - target);
        min_z = min_z.min(view.z);
        max_z = max_z.max(view.z);
        let x_distance =
            view.z + view.x.abs() * focal / (options.aspect() * options.allowed_ndc_x());
        let y_distance = view.z + view.y.abs() * focal / options.allowed_ndc_y();
        distance = distance.max(x_distance).max(y_distance);
        views.push(view);
    }
    distance = distance.max(0.01) * 1.001;

    let mut min_ndc_x = f32::INFINITY;
    let mut max_ndc_x = f32::NEG_INFINITY;
    let mut min_ndc_y = f32::INFINITY;
    let mut max_ndc_y = f32::NEG_INFINITY;
    for view in views {
        let depth = (distance - view.z).max(0.001);
        let ndc_x = view.x * focal / (options.aspect() * depth);
        let ndc_y = view.y * focal / depth;
        min_ndc_x = min_ndc_x.min(ndc_x);
        max_ndc_x = max_ndc_x.max(ndc_x);
        min_ndc_y = min_ndc_y.min(ndc_y);
        max_ndc_y = max_ndc_y.max(ndc_y);
    }

    PerspectiveDistanceSolve {
        distance,
        min_z,
        max_z,
        center_ndc_x: (min_ndc_x + max_ndc_x) * 0.5,
        center_ndc_y: (min_ndc_y + max_ndc_y) * 0.5,
    }
}

fn normalize(value: Vec3) -> Option<Vec3> {
    if !value.is_finite() {
        return None;
    }
    let length = value.length();
    (length > f32::EPSILON && length.is_finite()).then_some(value / length)
}

fn orbit_angles_from_direction(direction: Vec3) -> (f32, f32) {
    let direction = normalize(direction).unwrap_or(Vec3::new(0.0, 0.0, 1.0));
    let yaw = direction.x.atan2(direction.z);
    let pitch = direction.y.clamp(-1.0, 1.0).asin();
    (yaw, pitch)
}