Files
effectcraft/crates/render/src/three_d/camera.rs
T
Brandon Thomas 86b8e24417 Merge branch 'main' into worktree-agent-a15cecfec57d5290d
# Conflicts:
#	crates/effects/src/ocio.rs
#	crates/engine/src/commands/stubs.rs
#	docs/parity.md
2026-10-03 02:26:19 -04:00

536 lines
22 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.
//! Cameras: the comp's active camera (camera layers or the default 50 mm comp camera), the 3D
//! views of the viewer (Front/Left/Top/Back/Right/Bottom orthographic views and three custom
//! perspective views) and the camera-tool math (orbit, pan, dolly) shared by the engine commands.
//!
//! Conventions: world space is comp space (x right, y down, z into the screen). Camera space has
//! x right, y down and z forward (depth). The perspective projection maps camera space to comp
//! pixels as `x' = zoom·x/z + w/2`; orthographic views use `x' = zoom·x + w/2`.
use std::collections::BTreeMap;
use effectcraft_geom::{Mat4, Vec2, Vec3, vec2, vec3};
use effectcraft_project::{AutoOrient, Layer};
use serde::{Deserialize, Serialize};
use crate::EvalCtx;
/// Closest a perspective camera renders (camera-space z, pixels).
pub const NEAR: f64 = 1.0;
/// Depth of field settings of a camera.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Dof {
/// Distance from the eye to the plane in focus (pixels).
pub focus: f64,
/// Aperture diameter (pixels).
pub aperture: f64,
/// Blur Level (1.0 = 100%).
pub blur_level: f64,
/// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe.
#[serde(default)]
pub iris: super::bokeh::Iris,
/// Highlight Gain, Threshold and Saturation.
#[serde(default)]
pub highlight: super::bokeh::Highlight,
}
impl Dof {
/// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`.
pub fn coc(&self, z: f64) -> f64 {
if z <= NEAR || self.focus <= 0.0 {
return 0.0;
}
self.aperture * (z - self.focus).abs() / z * self.blur_level
}
}
/// An evaluated camera.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct CameraState {
/// World → camera space (rigid).
pub view: Mat4,
/// Eye position in world space.
pub eye: Vec3,
/// Perspective: distance from the eye to the image plane (pixels). Orthographic: pixels per
/// world unit.
pub zoom: f64,
pub ortho: bool,
pub dof: Option<Dof>,
}
impl CameraState {
/// World → comp pixels (homogeneous: divide by `w` for perspective cameras).
pub fn projection(&self, comp_w: f64, comp_h: f64) -> Mat4 {
if self.ortho {
let z = self.zoom;
Mat4([[z, 0.0, 0.0, comp_w * 0.5], [0.0, z, 0.0, comp_h * 0.5], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) * self.view
} else {
effectcraft_geom::camera_matrix(comp_w, comp_h, self.eye, self.view, self.zoom)
}
}
/// Camera-space depth of a world point.
pub fn depth(&self, p: Vec3) -> f64 {
self.view.apply(p).z
}
/// Unit view direction (world).
pub fn forward(&self) -> Vec3 {
let m = &self.view.0;
vec3(m[2][0], m[2][1], m[2][2])
}
pub fn right(&self) -> Vec3 {
let m = &self.view.0;
vec3(m[0][0], m[0][1], m[0][2])
}
pub fn down(&self) -> Vec3 {
let m = &self.view.0;
vec3(m[1][0], m[1][1], m[1][2])
}
/// Project a world point to comp pixels; None when behind a perspective camera.
pub fn project(&self, comp_w: f64, comp_h: f64, p: Vec3) -> Option<Vec2> {
let c = self.view.apply(p);
if self.ortho {
return Some(vec2(self.zoom * c.x + comp_w * 0.5, self.zoom * c.y + comp_h * 0.5));
}
if c.z < NEAR {
return None;
}
Some(vec2(self.zoom * c.x / c.z + comp_w * 0.5, self.zoom * c.y / c.z + comp_h * 0.5))
}
/// Direction from a shaded point towards the viewer.
pub fn to_viewer(&self, p: Vec3) -> Vec3 {
if self.ortho { -self.forward() } else { (self.eye - p).normalize() }
}
}
/// View matrix from an eye, a forward direction and a screen-down hint.
pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 {
let f = forward.normalize();
let f = if f.length() == 0.0 { vec3(0.0, 0.0, 1.0) } else { f };
let mut d = down_hint - f * down_hint.dot(f);
if d.length() < 1e-9 {
d = if f.y.abs() < 0.99 { vec3(0.0, 1.0, 0.0) } else { vec3(0.0, 0.0, -1.0) };
d = d - f * d.dot(f);
}
let d = d.normalize();
let r = d.cross(f).normalize();
Mat4([[r.x, r.y, r.z, 0.0], [d.x, d.y, d.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]) * Mat4::translate(-eye)
}
/// Orientation angles (degrees, AE order X→Y→Z with Z = 0) that point +z along `dir`.
pub fn orientation_for(dir: Vec3) -> [f64; 3] {
let d = dir.normalize();
let x = (-d.y).clamp(-1.0, 1.0).asin().to_degrees();
let y = d.x.atan2(d.z).to_degrees();
[norm360(x), norm360(y), 0.0]
}
fn norm360(a: f64) -> f64 {
let r = a.rem_euclid(360.0);
if (r - 360.0).abs() < 1e-9 { 0.0 } else { r }
}
/// AE's camera presets (35 mm film width 36 mm): (name, focal length mm).
pub const PRESETS: [(&str, f64); 9] =
[("15mm", 15.0), ("20mm", 20.0), ("24mm", 24.0), ("28mm", 28.0), ("35mm", 35.0), ("50mm", 50.0), ("80mm", 80.0), ("135mm", 135.0), ("200mm", 200.0)];
/// Film width the presets assume (mm, measured horizontally).
pub const FILM_SIZE_MM: f64 = 36.0;
/// Zoom (pixels) for a focal length on a comp `comp_w` pixels wide.
pub fn zoom_for_focal(comp_w: f64, focal_mm: f64) -> f64 {
comp_w * focal_mm / FILM_SIZE_MM
}
/// Focal length (mm) of a zoom value.
pub fn focal_for_zoom(comp_w: f64, zoom: f64) -> f64 {
zoom * FILM_SIZE_MM / comp_w.max(1.0)
}
/// Horizontal angle of view (degrees) of a zoom value.
pub fn angle_of_view(comp_w: f64, zoom: f64) -> f64 {
2.0 * (comp_w * 0.5 / zoom.max(1e-9)).atan().to_degrees()
}
/// Default aperture (pixels) of the presets: 25.3 mm at a 1920-pixel-wide comp, proportional.
pub fn default_aperture(comp_w: f64) -> f64 {
25.3 * comp_w / 1920.0
}
/// The comp camera used when there is no camera layer: the 50 mm preset at (w/2, h/2, −zoom)
/// looking at the comp centre, which shows the z = 0 plane at 100%.
pub fn default_camera(comp_w: f64, comp_h: f64) -> CameraState {
let zoom = effectcraft_geom::default_camera_zoom(comp_w);
let eye = vec3(comp_w / 2.0, comp_h / 2.0, -zoom);
CameraState { view: basis_view(eye, vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)), eye, zoom, ortho: false, dof: None }
}
/// Whether a camera layer is two-node (auto-orients towards its Point of Interest).
pub fn is_two_node(layer: &Layer) -> bool {
layer.auto_orient == AutoOrient::TowardsPointOfInterest && layer.transform().is_some_and(|t| t.get("poi").is_some())
}
/// Rotation of a camera/light layer from its Orientation and X/Y/Z Rotation (layer → parent).
pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 {
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
let o = ctx.v3(layer, tr, "orientation", [0.0; 3]);
let rx = ctx.f(layer, tr, "rotationX", 0.0);
let ry = ctx.f(layer, tr, "rotationY", 0.0);
let rz = ctx.f(layer, tr, "rotation", 0.0);
Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)
}
/// World matrix of a layer's parent chain (identity when unparented).
pub fn parent_world(ctx: &EvalCtx, layer: &Layer) -> Mat4 {
match layer.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.world_matrix(p),
None => Mat4::IDENTITY,
}
}
/// Rotation (local → parent) of a frame whose +z is `fwd` and whose +y is as close to
/// `down_hint` as possible.
pub fn basis_rotation(fwd: Vec3, down_hint: Vec3) -> Mat4 {
let m = basis_view(Vec3::ZERO, fwd, down_hint).0;
Mat4([[m[0][0], m[1][0], m[2][0], 0.0], [m[0][1], m[1][1], m[2][1], 0.0], [m[0][2], m[1][2], m[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]])
}
/// Local rotation (layer → parent) of a camera or light: auto-orient towards the Point of
/// Interest (two-node), then Orientation and X/Y/Z Rotation.
pub fn rig_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 {
let rot = layer_rotation(ctx, layer);
match layer.transform().filter(|_| is_two_node(layer)) {
Some(tr) => {
let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64);
let pos = Vec3::from(ctx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]));
let poi = Vec3::from(ctx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]));
basis_rotation(poi - pos, vec3(0.0, 1.0, 0.0)) * rot
}
None => rot,
}
}
// ---------------------------------------------------------------- auto-orient
/// Direction of travel of a layer's position (parent space) at the context time.
fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option<Vec3> {
let tr = layer.transform()?;
// Position (or its separated X/Y/Z dimensions) must be animated.
let moving = ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.keys.len() > 1 || p.has_expression()));
if !moving {
return None;
}
let dt = effectcraft_time::Tick::from_seconds_f64(0.005);
let a = Vec3::from(ctx.at(ctx.time - dt).v3(layer, tr, "position", [0.0; 3]));
let b = Vec3::from(ctx.at(ctx.time + dt).v3(layer, tr, "position", [0.0; 3]));
let d = b - a;
(d.length() > 1e-9).then(|| d.normalize())
}
/// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees.
pub fn auto_orient_2d(ctx: &EvalCtx, layer: &Layer) -> f64 {
if layer.auto_orient != AutoOrient::AlongPath {
return 0.0;
}
path_tangent(ctx, layer).map(|t| t.y.atan2(t.x).to_degrees()).unwrap_or(0.0)
}
/// Whether `id` is `layer` or one of its parents.
fn in_parent_chain(ctx: &EvalCtx, layer: &Layer, id: effectcraft_project::LayerId) -> bool {
let mut cur = Some(layer);
let mut guard = 0;
while let Some(l) = cur {
if l.id == id || guard > 64 {
return true;
}
guard += 1;
cur = l.parent.and_then(|p| ctx.comp.layer(p));
}
false
}
/// Auto-orient for 3D layers: the rotation that replaces Orientation (Along Path: the layer's x
/// axis follows the motion path; Towards Camera: the layer faces the active camera).
pub fn auto_orient_3d(ctx: &EvalCtx, layer: &Layer, pos: Vec3) -> Option<Mat4> {
match layer.auto_orient {
AutoOrient::AlongPath => {
let t = path_tangent(ctx, layer)?;
// x = tangent, y ≈ down, z = x × y.
let mut y = vec3(0.0, 1.0, 0.0) - t * t.y;
if y.length() < 1e-9 {
y = vec3(0.0, 0.0, 1.0) - t * t.z;
}
let y = y.normalize();
let z = t.cross(y);
Some(Mat4([[t.x, y.x, z.x, 0.0], [t.y, y.y, z.y, 0.0], [t.z, y.z, z.z, 0.0], [0.0, 0.0, 0.0, 1.0]]))
}
AutoOrient::TowardsCamera => {
// A camera parented (through any chain) to this layer would recurse.
if ctx.comp.active_camera(ctx.time).is_some_and(|c| in_parent_chain(ctx, c, layer.id)) {
return None;
}
let eye = active_camera(ctx).eye;
let e = parent_world(ctx, layer).inverse().unwrap_or(Mat4::IDENTITY).apply(eye);
let d = pos - e;
(d.length() > 1e-9).then(|| basis_rotation(d, vec3(0.0, 1.0, 0.0)))
}
_ => None,
}
}
/// (eye, forward, down) in world space of a camera or light layer.
pub fn layer_frame(ctx: &EvalCtx, layer: &Layer) -> (Vec3, Vec3, Vec3) {
let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64);
let Some(tr) = layer.transform() else { return (vec3(w / 2.0, h / 2.0, -1000.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)) };
let parent = parent_world(ctx, layer);
let pos = Vec3::from(ctx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]));
let local = rig_rotation(ctx, layer);
let eye = parent.apply(pos);
let fwd = parent.apply_vec(local.apply_vec(vec3(0.0, 0.0, 1.0))).normalize();
let down = parent.apply_vec(local.apply_vec(vec3(0.0, 1.0, 0.0))).normalize();
(eye, fwd, down)
}
/// Evaluate a camera layer.
pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
let w = ctx.comp.width as f64;
let (eye, fwd, down) = layer_frame(ctx, cam);
let opts = cam.props.sub("cameraOptions");
let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3);
let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof {
focus: ctx.f(cam, g, "focusDistance", zoom),
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
iris: super::bokeh::Iris {
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
rotation: ctx.f(cam, g, "irisRotation", 0.0),
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
},
highlight: super::bokeh::Highlight {
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
},
});
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
}
/// The comp's active camera at the context time (topmost active camera layer, else the default).
pub fn active_camera(ctx: &EvalCtx) -> CameraState {
match ctx.comp.active_camera(ctx.time) {
Some(cam) => layer_camera(ctx, cam),
None => default_camera(ctx.comp.width as f64, ctx.comp.height as f64),
}
}
// ---------------------------------------------------------------- 3D views
/// The viewer's 3D views (View ▸ Switch 3D View).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum View3D {
#[default]
ActiveCamera,
Front,
Left,
Top,
Back,
Right,
Bottom,
Custom1,
Custom2,
Custom3,
/// The comp's default camera (50 mm, looking at the comp centre), ignoring camera layers;
/// orbitable like the custom views.
Default,
}
impl View3D {
pub const ALL: [View3D; 11] = [
View3D::ActiveCamera,
View3D::Default,
View3D::Front,
View3D::Left,
View3D::Top,
View3D::Back,
View3D::Right,
View3D::Bottom,
View3D::Custom1,
View3D::Custom2,
View3D::Custom3,
];
pub fn label(self) -> &'static str {
match self {
View3D::ActiveCamera => "Active Camera",
View3D::Front => "Front",
View3D::Left => "Left",
View3D::Top => "Top",
View3D::Back => "Back",
View3D::Right => "Right",
View3D::Bottom => "Bottom",
View3D::Custom1 => "Custom View 1",
View3D::Custom2 => "Custom View 2",
View3D::Custom3 => "Custom View 3",
View3D::Default => "Default",
}
}
/// Stable id used by commands (`activeCamera`, `front`, `custom1`…).
pub fn id(self) -> &'static str {
match self {
View3D::ActiveCamera => "activeCamera",
View3D::Front => "front",
View3D::Left => "left",
View3D::Top => "top",
View3D::Back => "back",
View3D::Right => "right",
View3D::Bottom => "bottom",
View3D::Custom1 => "custom1",
View3D::Custom2 => "custom2",
View3D::Custom3 => "custom3",
View3D::Default => "default",
}
}
pub fn from_id(s: &str) -> Option<View3D> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
View3D::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(&k) || v.label().replace(' ', "").eq_ignore_ascii_case(&k))
}
pub fn is_ortho(self) -> bool {
matches!(self, View3D::Front | View3D::Left | View3D::Top | View3D::Back | View3D::Right | View3D::Bottom)
}
}
/// A view camera: eye, point of interest, screen-down hint, zoom (perspective) or scale (ortho).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ViewCam {
pub eye: [f64; 3],
pub poi: [f64; 3],
pub down: [f64; 3],
pub zoom: f64,
pub ortho: bool,
}
impl ViewCam {
pub fn state(&self) -> CameraState {
let eye = Vec3::from(self.eye);
let poi = Vec3::from(self.poi);
CameraState { view: basis_view(eye, poi - eye, Vec3::from(self.down)), eye, zoom: self.zoom, ortho: self.ortho, dof: None }
}
}
/// Default camera of a view for a `w`×`h` comp.
pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam {
let c = vec3(w / 2.0, h / 2.0, 0.0);
let zoom = effectcraft_geom::default_camera_zoom(w);
let far = 10_000.0;
let ortho = |fwd: Vec3, down: Vec3| ViewCam { eye: arr(c - fwd * far), poi: arr(c), down: arr(down), zoom: 0.5, ortho: true };
let custom = |yaw: f64, pitch: f64| {
// Orbit the default camera around the comp centre (yaw about y, then pitch).
let off = Mat4::rotate_y(yaw).apply_vec(Mat4::rotate_x(pitch).apply_vec(vec3(0.0, 0.0, -zoom * 1.6)));
ViewCam { eye: arr(c + off), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false }
};
match v {
View3D::ActiveCamera | View3D::Front => ortho(vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)),
View3D::Back => ortho(vec3(0.0, 0.0, -1.0), vec3(0.0, 1.0, 0.0)),
View3D::Left => ortho(vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0)),
View3D::Right => ortho(vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0)),
View3D::Top => ortho(vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0)),
View3D::Bottom => ortho(vec3(0.0, -1.0, 0.0), vec3(0.0, 0.0, 1.0)),
// Custom views look down at the comp from above-left, above and above-right.
View3D::Custom1 => custom(30.0, -20.0),
View3D::Custom2 => custom(0.0, -35.0),
View3D::Custom3 => custom(-30.0, -20.0),
View3D::Default => ViewCam { eye: arr(vec3(w / 2.0, h / 2.0, -zoom)), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false },
}
}
pub fn arr(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// Per-comp 3D view state of the viewer (current view + edited view cameras).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Views3D {
pub current: View3D,
/// The view before the last switch (View ▸ Switch to Last 3D View).
#[serde(default)]
pub last: View3D,
/// Edited view cameras (views not present use their defaults).
#[serde(default)]
pub cams: BTreeMap<View3D, ViewCam>,
}
impl Views3D {
pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam {
self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h))
}
/// Camera override for rendering: None for the active camera view.
pub fn override_camera(&self, w: f64, h: f64) -> Option<CameraState> {
(self.current != View3D::ActiveCamera).then(|| self.cam(self.current, w, h).state())
}
}
// ---------------------------------------------------------------- camera tools
/// A camera as edited by the tools: position, point of interest, zoom (two-node form).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rig {
pub eye: Vec3,
pub poi: Vec3,
pub zoom: f64,
pub ortho: bool,
}
impl Rig {
pub fn from_view(v: &ViewCam) -> Rig {
Rig { eye: Vec3::from(v.eye), poi: Vec3::from(v.poi), zoom: v.zoom, ortho: v.ortho }
}
fn frame(&self) -> (Vec3, Vec3, Vec3) {
let s =
CameraState { view: basis_view(self.eye, self.poi - self.eye, vec3(0.0, 1.0, 0.0)), eye: self.eye, zoom: self.zoom, ortho: self.ortho, dof: None };
(s.forward(), s.right(), s.down())
}
/// Orbit the eye around the point of interest: `yaw` about world Y, `pitch` about the
/// camera's right axis (degrees). Pitch is limited short of straight up/down.
pub fn orbit(&mut self, yaw: f64, pitch: f64) {
if self.ortho {
return;
}
let off = self.eye - self.poi;
let dist = off.length().max(1e-6);
let cur_pitch = (off.y / dist).clamp(-1.0, 1.0).asin().to_degrees();
let new_pitch = (cur_pitch + pitch).clamp(-89.0, 89.0);
let horiz = vec3(off.x, 0.0, off.z);
let hl = horiz.length();
let az = if hl > 1e-9 { off.x.atan2(-off.z).to_degrees() } else { 0.0 } + yaw;
let (sp, cp) = new_pitch.to_radians().sin_cos();
let (sa, ca) = az.to_radians().sin_cos();
self.eye = self.poi + vec3(sa * cp, sp, -ca * cp) * dist;
}
/// Pan in the image plane by (`dx`, `dy`) comp pixels (as seen at the point of interest).
pub fn pan(&mut self, dx: f64, dy: f64) {
let (_, r, d) = self.frame();
let k = if self.ortho { 1.0 / self.zoom.max(1e-9) } else { (self.poi - self.eye).length() / self.zoom.max(1e-9) };
let delta = r * (-dx * k) + d * (-dy * k);
self.eye += delta;
self.poi += delta;
}
/// Dolly along the view direction by `amount` pixels (positive = towards the POI). With
/// `with_poi` the point of interest travels too (camera layers: Track Z); otherwise the
/// eye stops short of the POI. Orthographic views scale instead.
pub fn dolly(&mut self, amount: f64, with_poi: bool) {
if self.ortho {
self.zoom = (self.zoom * (amount / 500.0).exp()).clamp(0.01, 100.0);
return;
}
let (f, _, _) = self.frame();
if with_poi {
self.eye += f * amount;
self.poi += f * amount;
} else {
let dist = (self.poi - self.eye).length();
let d = amount.min(dist - 1.0);
self.eye += f * d;
}
}
}