From 6ef248f76d2c44440dfc2978e185e7210db37781 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 10:33:10 +0900 Subject: [PATCH] feat(editor): add OrbitCamera with orbit, zoom, pan controls Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 + crates/voltex_editor/Cargo.toml | 1 + crates/voltex_editor/src/orbit_camera.rs | 166 +++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 crates/voltex_editor/src/orbit_camera.rs diff --git a/Cargo.lock b/Cargo.lock index 577443b..bf910cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2080,6 +2080,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "voltex_ai" version = "0.1.0" dependencies = [ + "voltex_ecs", "voltex_math", ] @@ -2106,6 +2107,7 @@ name = "voltex_editor" version = "0.1.0" dependencies = [ "bytemuck", + "voltex_math", "wgpu", ] diff --git a/crates/voltex_editor/Cargo.toml b/crates/voltex_editor/Cargo.toml index db3804d..d31f805 100644 --- a/crates/voltex_editor/Cargo.toml +++ b/crates/voltex_editor/Cargo.toml @@ -5,4 +5,5 @@ edition = "2021" [dependencies] bytemuck = { workspace = true } +voltex_math = { workspace = true } wgpu = { workspace = true } diff --git a/crates/voltex_editor/src/orbit_camera.rs b/crates/voltex_editor/src/orbit_camera.rs new file mode 100644 index 0000000..5d9b340 --- /dev/null +++ b/crates/voltex_editor/src/orbit_camera.rs @@ -0,0 +1,166 @@ +use voltex_math::{Vec3, Mat4}; +use std::f32::consts::PI; + +const PITCH_LIMIT: f32 = PI / 2.0 - 0.01; +const MIN_DISTANCE: f32 = 0.5; +const MAX_DISTANCE: f32 = 50.0; +const ORBIT_SENSITIVITY: f32 = 0.005; +const ZOOM_FACTOR: f32 = 0.1; +const PAN_SENSITIVITY: f32 = 0.01; + +pub struct OrbitCamera { + pub target: Vec3, + pub distance: f32, + pub yaw: f32, + pub pitch: f32, + pub fov_y: f32, + pub near: f32, + pub far: f32, +} + +impl OrbitCamera { + pub fn new() -> Self { + OrbitCamera { + target: Vec3::ZERO, + distance: 5.0, + yaw: 0.0, + pitch: 0.3, + fov_y: PI / 4.0, + near: 0.1, + far: 100.0, + } + } + + pub fn position(&self) -> Vec3 { + let cp = self.pitch.cos(); + let sp = self.pitch.sin(); + let cy = self.yaw.cos(); + let sy = self.yaw.sin(); + Vec3::new( + self.target.x + self.distance * cp * sy, + self.target.y + self.distance * sp, + self.target.z + self.distance * cp * cy, + ) + } + + pub fn orbit(&mut self, dx: f32, dy: f32) { + self.yaw += dx * ORBIT_SENSITIVITY; + self.pitch += dy * ORBIT_SENSITIVITY; + self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + + pub fn zoom(&mut self, delta: f32) { + self.distance *= 1.0 - delta * ZOOM_FACTOR; + self.distance = self.distance.clamp(MIN_DISTANCE, MAX_DISTANCE); + } + + pub fn pan(&mut self, dx: f32, dy: f32) { + let forward = (self.target - self.position()).normalize(); + let right = forward.cross(Vec3::Y); + let right = if right.length() < 1e-4 { Vec3::X } else { right.normalize() }; + let up = right.cross(forward).normalize(); + let offset_x = right * (-dx * PAN_SENSITIVITY * self.distance); + let offset_y = up * (dy * PAN_SENSITIVITY * self.distance); + self.target = self.target + offset_x + offset_y; + } + + pub fn view_matrix(&self) -> Mat4 { + Mat4::look_at(self.position(), self.target, Vec3::Y) + } + + pub fn projection_matrix(&self, aspect: f32) -> Mat4 { + Mat4::perspective(self.fov_y, aspect, self.near, self.far) + } + + pub fn view_projection(&self, aspect: f32) -> Mat4 { + self.projection_matrix(aspect).mul_mat4(&self.view_matrix()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_position() { + let cam = OrbitCamera::new(); + let pos = cam.position(); + assert!((pos.x).abs() < 1e-3); + assert!(pos.y > 0.0); + assert!(pos.z > 0.0); + } + + #[test] + fn test_orbit_changes_yaw_pitch() { + let mut cam = OrbitCamera::new(); + let old_yaw = cam.yaw; + let old_pitch = cam.pitch; + cam.orbit(100.0, 50.0); + assert!((cam.yaw - old_yaw - 100.0 * ORBIT_SENSITIVITY).abs() < 1e-6); + assert!((cam.pitch - old_pitch - 50.0 * ORBIT_SENSITIVITY).abs() < 1e-6); + } + + #[test] + fn test_pitch_clamped() { + let mut cam = OrbitCamera::new(); + cam.orbit(0.0, 100000.0); + assert!(cam.pitch <= PITCH_LIMIT); + cam.orbit(0.0, -200000.0); + assert!(cam.pitch >= -PITCH_LIMIT); + } + + #[test] + fn test_zoom_changes_distance() { + let mut cam = OrbitCamera::new(); + let d0 = cam.distance; + cam.zoom(1.0); + assert!(cam.distance < d0); + } + + #[test] + fn test_zoom_clamped() { + let mut cam = OrbitCamera::new(); + cam.zoom(1000.0); + assert!(cam.distance >= MIN_DISTANCE); + cam.zoom(-10000.0); + assert!(cam.distance <= MAX_DISTANCE); + } + + #[test] + fn test_pan_moves_target() { + let mut cam = OrbitCamera::new(); + let t0 = cam.target; + cam.pan(10.0, 0.0); + assert!((cam.target.x - t0.x).abs() > 1e-4 || (cam.target.z - t0.z).abs() > 1e-4); + } + + #[test] + fn test_view_matrix_not_zero() { + let cam = OrbitCamera::new(); + let v = cam.view_matrix(); + let sum: f32 = v.cols.iter().flat_map(|c| c.iter()).map(|x| x.abs()).sum(); + assert!(sum > 1.0); + } + + #[test] + fn test_projection_matrix() { + let cam = OrbitCamera::new(); + let p = cam.projection_matrix(16.0 / 9.0); + assert!(p.cols[0][0] > 0.0); + } + + #[test] + fn test_view_projection() { + let cam = OrbitCamera::new(); + let vp = cam.view_projection(1.0); + let v = cam.view_matrix(); + let p = cam.projection_matrix(1.0); + let expected = p.mul_mat4(&v); + for i in 0..4 { + for j in 0..4 { + assert!((vp.cols[i][j] - expected.cols[i][j]).abs() < 1e-4, + "mismatch at [{i}][{j}]: {} vs {}", vp.cols[i][j], expected.cols[i][j]); + } + } + } +}