use std::collections::HashMap;
use web_time::Instant;
use crate::anim::SpringConfig;
use crate::event::{KeyModifiers, PointerButton};
use crate::scene::glam::Vec3;
use crate::scene::{Aabb, CameraControls, CameraState, Focus, Framing, Scene3DData};
use crate::tree::{El, Rect};
use super::UiState;
const MAX_SUBSTEP: f32 = 1.0 / 250.0;
const DT_CAP: f32 = 0.064;
const EPS_DISP: f32 = 1.0e-3;
const EPS_VEL: f32 = 1.0e-2;
const BOUNDS_EPSILON: f32 = 1.0e-3;
const POSE_SPRING: SpringConfig = SpringConfig::GENTLE;
const ORBIT_RAD_PER_PX: f32 = 0.005;
const ZOOM_PER_PX: f32 = 0.0015;
const ZOOM_DRAG_PER_PX: f32 = 0.005;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CameraDragMode {
Orbit,
Pan,
Zoom,
}
pub(crate) fn scheme_drag_mode(
controls: CameraControls,
button: PointerButton,
mods: KeyModifiers,
) -> Option<CameraDragMode> {
use CameraDragMode::{Orbit, Pan, Zoom};
use PointerButton::{Middle, Primary, Secondary};
match controls {
CameraControls::Orbit => match button {
Primary if mods.shift => Some(Pan),
Primary => Some(Orbit),
Secondary => Some(Pan),
_ => None,
},
CameraControls::Blender => match button {
Middle if mods.shift => Some(Pan),
Middle => Some(Orbit),
_ => None,
},
CameraControls::OnShape => match button {
Secondary => Some(Orbit),
Middle => Some(Pan),
_ => None,
},
CameraControls::Maya if mods.alt => match button {
Primary => Some(Orbit),
Middle => Some(Pan),
Secondary => Some(Zoom),
},
CameraControls::Maya => None,
}
}
#[derive(Clone, Debug)]
pub(crate) struct CameraDrag {
id: String,
mode: CameraDragMode,
last_x: f32,
last_y: f32,
}
#[derive(Clone, Debug)]
pub(crate) struct KeyedCamera {
pub current: CameraState,
pub goal: CameraState,
vel: [f32; 6],
last_bounds: Aabb,
last_focus: Option<Focus>,
last_step: Instant,
rect: Rect,
controls: CameraControls,
}
impl KeyedCamera {
fn channels(pose: CameraState) -> [f32; 6] {
[
pose.target.x,
pose.target.y,
pose.target.z,
pose.distance.max(1.0e-4).ln(),
pose.yaw,
pose.pitch,
]
}
fn from_channels(c: [f32; 6]) -> CameraState {
CameraState {
target: Vec3::new(c[0], c[1], c[2]),
distance: c[3].exp(),
yaw: c[4],
pitch: c[5],
}
}
fn step(&mut self, now: Instant) -> bool {
let dt = now
.saturating_duration_since(self.last_step)
.as_secs_f32()
.min(DT_CAP);
self.last_step = now;
if dt <= 0.0 {
return self.settled();
}
let cur = Self::channels(self.current);
let mut goal = Self::channels(self.goal);
goal[4] = cur[4] + shortest_angle(goal[4] - cur[4]);
let mut next = cur;
let mut settled = true;
for i in 0..6 {
let (c, v, s) = spring1(cur[i], self.vel[i], goal[i], POSE_SPRING, dt);
next[i] = c;
self.vel[i] = v;
settled &= s;
}
self.current = Self::from_channels(next);
settled
}
fn settled(&self) -> bool {
self.vel.iter().all(|v| v.abs() <= EPS_VEL)
&& Self::channels(self.current)
.iter()
.zip(Self::channels(self.goal))
.all(|(c, g)| (c - g).abs() <= EPS_DISP)
}
}
#[derive(Default)]
pub(crate) struct CameraStore {
cameras: HashMap<String, KeyedCamera>,
drag: Option<CameraDrag>,
hover_label_rects: Vec<Rect>,
}
impl UiState {
pub(crate) fn scene_camera(&self, id: &str) -> Option<CameraState> {
self.cameras.cameras.get(id).map(|c| c.current)
}
pub(crate) fn tick_scene_cameras(&mut self, root: &El, now: Instant) -> bool {
let mut raw: Vec<(&str, &crate::scene::SceneSpec)> = Vec::new();
collect_scene_nodes(root, &mut raw);
let nodes: Vec<(&str, Rect, &crate::scene::SceneSpec)> = raw
.into_iter()
.map(|(id, spec)| (id, self.rect(id), spec))
.collect();
self.cameras.hover_label_rects.clear();
let mut animating = false;
let mut seen: Vec<&str> = Vec::with_capacity(nodes.len());
for (id, rect, spec) in nodes {
if spec_has_hover_labels(spec) {
self.cameras.hover_label_rects.push(rect);
}
if spec.framing == Framing::Manual {
continue;
}
seen.push(id);
let content = Scene3DData::content_bounds(&spec.meshes, &spec.points, &spec.lines);
let entry = self
.cameras
.cameras
.entry(id.to_string())
.or_insert_with(|| {
let base = spec.camera.unwrap_or_default();
let init = match spec.focus {
Some(f) => base.focused(f),
None => base.fitted(content),
};
KeyedCamera {
current: init,
goal: init,
vel: [0.0; 6],
last_bounds: content,
last_focus: spec.focus,
last_step: now,
rect,
controls: spec.controls,
}
});
entry.rect = rect;
entry.controls = spec.controls;
if spec.focus != entry.last_focus {
if let Some(f) = spec.focus {
entry.goal = entry.current.focused(f);
}
entry.last_focus = spec.focus;
} else if spec.framing == Framing::Fit {
entry.goal = entry.goal.fitted(content);
} else if bounds_changed(entry.last_bounds, content) {
entry.goal.target = sphere_center(content);
entry.last_bounds = content;
}
if !entry.step(now) {
animating = true;
}
}
self.cameras
.cameras
.retain(|k, _| seen.contains(&k.as_str()));
animating
}
pub(crate) fn pointer_over_hover_scene(&self, x: f32, y: f32) -> bool {
self.cameras
.hover_label_rects
.iter()
.any(|r| r.contains(x, y))
}
pub(crate) fn scene_at(&self, x: f32, y: f32) -> Option<String> {
let mut found = None;
for (id, cam) in &self.cameras.cameras {
if cam.rect.contains(x, y) {
found = Some(id.clone());
}
}
found
}
pub(crate) fn scene_drag_mode(
&self,
id: &str,
button: PointerButton,
mods: KeyModifiers,
) -> Option<CameraDragMode> {
let controls = self.cameras.cameras.get(id)?.controls;
scheme_drag_mode(controls, button, mods)
}
pub(crate) fn begin_camera_drag(&mut self, id: String, mode: CameraDragMode, x: f32, y: f32) {
self.cameras.drag = Some(CameraDrag {
id,
mode,
last_x: x,
last_y: y,
});
}
pub(crate) fn camera_drag_active(&self) -> bool {
self.cameras.drag.is_some()
}
pub(crate) fn drag_camera_to(&mut self, x: f32, y: f32) -> bool {
let Some(drag) = self.cameras.drag.as_mut() else {
return false;
};
let dx = x - drag.last_x;
let dy = y - drag.last_y;
drag.last_x = x;
drag.last_y = y;
if dx == 0.0 && dy == 0.0 {
return false;
}
let (id, mode) = (drag.id.clone(), drag.mode);
let Some(cam) = self.cameras.cameras.get_mut(&id) else {
return false;
};
match mode {
CameraDragMode::Orbit => {
cam.current
.orbit(-dx * ORBIT_RAD_PER_PX, dy * ORBIT_RAD_PER_PX);
}
CameraDragMode::Pan => {
let (right, up) = camera_basis(&cam.current);
let half_h = (crate::scene::camera::DEFAULT_FOV_Y_RADIANS * 0.5).tan();
let world_per_px = 2.0 * cam.current.distance * half_h / cam.rect.h.max(1.0);
cam.current
.pan_by(right * (-dx * world_per_px) + up * (dy * world_per_px));
}
CameraDragMode::Zoom => {
cam.current.zoom_by((dy * ZOOM_DRAG_PER_PX).exp());
}
}
cam.goal = cam.current;
cam.vel = [0.0; 6];
true
}
pub(crate) fn end_camera_drag(&mut self) -> bool {
self.cameras.drag.take().is_some()
}
pub(crate) fn camera_wheel_zoom(&mut self, x: f32, y: f32, dy: f32) -> bool {
let Some(id) = self.scene_at(x, y) else {
return false;
};
let Some(cam) = self.cameras.cameras.get_mut(&id) else {
return false;
};
if dy.abs() <= f32::EPSILON {
return true;
}
cam.goal.zoom_by((dy * ZOOM_PER_PX).exp());
true
}
}
fn camera_basis(pose: &CameraState) -> (Vec3, Vec3) {
let forward = (pose.target - pose.eye()).normalize_or_zero();
let right = forward.cross(Vec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
(right, up)
}
fn collect_scene_nodes<'a>(n: &'a El, out: &mut Vec<(&'a str, &'a crate::scene::SceneSpec)>) {
if let Some(spec) = &n.scene_source {
out.push((n.computed_id.as_str(), spec));
}
for child in &n.children {
collect_scene_nodes(child, out);
}
}
fn spec_has_hover_labels(spec: &crate::scene::SceneSpec) -> bool {
spec.points.iter().any(|p| {
p.labels
.as_ref()
.is_some_and(|l| l.display == crate::scene::LabelDisplay::Hover)
})
}
fn sphere_center(b: Aabb) -> Vec3 {
if b.is_valid() { b.center() } else { Vec3::ZERO }
}
fn bounds_changed(a: Aabb, b: Aabb) -> bool {
match (a.is_valid(), b.is_valid()) {
(false, false) => false,
(true, true) => {
(a.center() - b.center()).length() > BOUNDS_EPSILON
|| (a.bounding_radius() - b.bounding_radius()).abs() > BOUNDS_EPSILON
}
_ => true,
}
}
fn shortest_angle(delta: f32) -> f32 {
use std::f32::consts::{PI, TAU};
let d = delta.rem_euclid(TAU);
if d > PI { d - TAU } else { d }
}
fn spring1(cur: f32, vel: f32, target: f32, cfg: SpringConfig, dt: f32) -> (f32, f32, bool) {
let n = (dt / MAX_SUBSTEP).ceil().max(1.0) as usize;
let h = dt / n as f32;
let (mut c, mut v) = (cur, vel);
for _ in 0..n {
let disp = c - target;
let force = -cfg.stiffness * disp - cfg.damping * v;
v += (force / cfg.mass) * h;
c += v * h;
}
if (c - target).abs() <= EPS_DISP && v.abs() <= EPS_VEL {
(target, 0.0, true)
} else {
(c, v, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn pose(target: Vec3, distance: f32) -> CameraState {
CameraState {
target,
distance,
yaw: 0.5,
pitch: 0.3,
}
}
fn keyed(current: CameraState, goal: CameraState, now: Instant) -> KeyedCamera {
KeyedCamera {
current,
goal,
vel: [0.0; 6],
last_bounds: Aabb::EMPTY,
last_focus: None,
last_step: now,
rect: Rect::new(0.0, 0.0, 0.0, 0.0),
controls: CameraControls::Orbit,
}
}
#[test]
fn spring_glides_then_settles() {
let start = Instant::now();
let mut cam = keyed(
pose(Vec3::ZERO, 5.0),
pose(Vec3::new(4.0, 0.0, 0.0), 5.0),
start,
);
let mut t = start;
t += Duration::from_millis(16);
cam.step(t);
let mid = cam.current.target.x;
assert!(mid > 0.0 && mid < 4.0, "should be gliding, got x={mid}");
let mut settled = false;
for _ in 0..600 {
t += Duration::from_millis(16);
if cam.step(t) {
settled = true;
break;
}
}
assert!(settled, "spring never settled");
assert!(
(cam.current.target.x - 4.0).abs() < 1e-2,
"x={}",
cam.current.target.x
);
}
#[test]
fn log_distance_interpolates_geometrically() {
let start = Instant::now();
let mut cam = keyed(pose(Vec3::ZERO, 1.0), pose(Vec3::ZERO, 100.0), start);
let mut t = start;
let mut crossed_at_arith = None;
for _ in 0..600 {
t += Duration::from_millis(16);
let settled = cam.step(t);
if crossed_at_arith.is_none() && cam.current.distance >= 10.0 {
crossed_at_arith = Some(cam.current.distance);
}
if settled {
break;
}
}
assert!(
(cam.current.distance - 100.0).abs() < 0.5,
"settled at {}",
cam.current.distance
);
let at = crossed_at_arith.expect("never reached 10");
assert!(
at < 50.0,
"log interp should pass 10 long before 50, got {at}"
);
}
#[test]
fn shortest_angle_takes_the_short_way() {
use std::f32::consts::PI;
let d = shortest_angle(350.0_f32.to_radians());
assert!((d - (-10.0_f32).to_radians()).abs() < 1e-4, "got {d}");
assert!(shortest_angle(0.5).abs() <= PI);
}
#[test]
fn auto_recenter_animates_on_data_change() {
use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
use crate::tree::chart3d;
let points = |c: f32| PointData {
points: vec![
ScenePoint {
position: Vec3::splat(c - 1.0),
color: [1.0; 4],
},
ScenePoint {
position: Vec3::splat(c + 1.0),
color: [1.0; 4],
},
],
};
let handle = PointsHandle::new(points(0.0)); let mut tree = chart3d(SceneSpec::new().points(handle.clone())); crate::layout::assign_ids(&mut tree);
let id = tree.computed_id.clone();
let mut ui = UiState::new();
let start = Instant::now();
ui.tick_scene_cameras(&tree, start);
let initial = ui.scene_camera(&id).expect("camera created").target;
assert!(
initial.length() < 1e-3,
"starts framed on origin, got {initial:?}"
);
handle.set(points(10.0));
let t1 = start + Duration::from_millis(16);
ui.tick_scene_cameras(&tree, t1);
let mid = ui.scene_camera(&id).unwrap().target;
assert!(mid.length() > 0.05, "target began gliding, got {mid:?}");
assert!(mid.x < 9.0, "must animate, not snap, got {mid:?}");
let mut t = t1;
for _ in 0..800 {
t += Duration::from_millis(16);
ui.tick_scene_cameras(&tree, t);
}
let end = ui.scene_camera(&id).unwrap().target;
assert!(
(end - Vec3::splat(10.0)).length() < 0.05,
"settled on the new centre, got {end:?}"
);
}
#[test]
fn drag_orbits_and_wheel_zooms() {
use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
use crate::tree::chart3d;
let handle = PointsHandle::new(PointData {
points: vec![
ScenePoint {
position: Vec3::splat(-1.0),
color: [1.0; 4],
},
ScenePoint {
position: Vec3::splat(1.0),
color: [1.0; 4],
},
],
});
let mut tree = chart3d(SceneSpec::new().points(handle));
let mut ui = UiState::new();
crate::layout::layout(&mut tree, &mut ui, Rect::new(0.0, 0.0, 200.0, 200.0));
let id = tree.computed_id.clone();
ui.tick_scene_cameras(&tree, Instant::now());
assert_eq!(ui.scene_at(100.0, 100.0).as_deref(), Some(id.as_str()));
assert!(ui.scene_at(-5.0, -5.0).is_none(), "outside the rect");
let yaw0 = ui.scene_camera(&id).unwrap().yaw;
ui.begin_camera_drag(id.clone(), CameraDragMode::Orbit, 100.0, 100.0);
assert!(ui.camera_drag_active());
assert!(ui.drag_camera_to(140.0, 100.0));
let yaw1 = ui.scene_camera(&id).unwrap().yaw;
assert!(
(yaw1 - yaw0).abs() > 0.1,
"drag should orbit: {yaw0} -> {yaw1}"
);
assert!(ui.end_camera_drag());
assert!(!ui.camera_drag_active());
let d0 = ui.scene_camera(&id).unwrap().distance;
assert!(ui.camera_wheel_zoom(100.0, 100.0, 60.0));
assert!(
!ui.camera_wheel_zoom(-5.0, -5.0, 60.0),
"wheel off-scene not consumed"
);
let mut t = Instant::now();
for _ in 0..400 {
t += Duration::from_millis(16);
ui.tick_scene_cameras(&tree, t);
}
let d1 = ui.scene_camera(&id).unwrap().distance;
assert!(
d1 > d0 + 0.01,
"wheel should zoom out (grow distance): {d0} -> {d1}"
);
}
#[test]
fn pan_tracks_cursor_one_to_one() {
use crate::scene::{PointData, PointsHandle, ScenePoint, SceneSpec};
use crate::tree::chart3d;
let handle = PointsHandle::new(PointData {
points: vec![
ScenePoint {
position: Vec3::splat(-1.0),
color: [1.0; 4],
},
ScenePoint {
position: Vec3::splat(1.0),
color: [1.0; 4],
},
],
});
let mut tree = chart3d(SceneSpec::new().points(handle));
let mut ui = UiState::new();
let viewport = Rect::new(0.0, 0.0, 300.0, 200.0);
crate::layout::layout(&mut tree, &mut ui, viewport);
let id = tree.computed_id.clone();
ui.tick_scene_cameras(&tree, Instant::now());
let cam0 = ui.scene_camera(&id).unwrap();
let world = cam0.target;
let view = Aabb::from_points([Vec3::splat(-1.0), Vec3::splat(1.0)]);
let s0 = cam0
.resolve(view)
.project_to_screen(world, viewport)
.unwrap();
ui.begin_camera_drag(id.clone(), CameraDragMode::Pan, 150.0, 100.0);
ui.drag_camera_to(210.0, 124.0);
let cam1 = ui.scene_camera(&id).unwrap();
let s1 = cam1
.resolve(view)
.project_to_screen(world, viewport)
.unwrap();
assert!(
(s1.x - s0.x - 60.0).abs() < 0.5,
"x moved {} (want 60)",
s1.x - s0.x
);
assert!(
(s1.y - s0.y - 24.0).abs() < 0.5,
"y moved {} (want 24)",
s1.y - s0.y
);
}
#[test]
fn nav_schemes_map_buttons() {
use CameraDragMode::{Orbit, Pan, Zoom};
use PointerButton::{Middle, Primary, Secondary};
let none = KeyModifiers::default();
let shift = KeyModifiers {
shift: true,
..Default::default()
};
let alt = KeyModifiers {
alt: true,
..Default::default()
};
let m = scheme_drag_mode;
assert_eq!(m(CameraControls::Orbit, Primary, none), Some(Orbit));
assert_eq!(m(CameraControls::Orbit, Primary, shift), Some(Pan));
assert_eq!(m(CameraControls::Orbit, Secondary, none), Some(Pan));
assert_eq!(m(CameraControls::Orbit, Middle, none), None);
assert_eq!(m(CameraControls::Blender, Middle, none), Some(Orbit));
assert_eq!(m(CameraControls::Blender, Middle, shift), Some(Pan));
assert_eq!(m(CameraControls::Blender, Primary, none), None);
assert_eq!(m(CameraControls::OnShape, Secondary, none), Some(Orbit));
assert_eq!(m(CameraControls::OnShape, Middle, none), Some(Pan));
assert_eq!(m(CameraControls::Maya, Primary, none), None);
assert_eq!(m(CameraControls::Maya, Primary, alt), Some(Orbit));
assert_eq!(m(CameraControls::Maya, Secondary, alt), Some(Zoom));
}
}