//! Arcball/orbit camera — Revit ViewCube style. //! //! Orbit with middle-mouse drag, zoom with scroll wheel. use glam::{Mat4, Vec3}; use bytemuck::{Pod, Zeroable}; // ─── GPU uniform ───────────────────────────────────────────────────────────── /// 64-byte view-projection matrix uploaded to the GPU once per frame. #[repr(C)] #[derive(Copy, Clone, Debug, Pod, Zeroable)] pub struct CameraUniform { pub view_proj: [[f32; 4]; 4], } impl CameraUniform { pub fn identity() -> Self { Self { view_proj: Mat4::IDENTITY.to_cols_array_2d() } } } // ─── Camera ────────────────────────────────────────────────────────────────── /// Standard axis-aligned views (Revit numpad convention). #[derive(Debug, Clone, Copy)] pub enum StandardView { Top, // Numpad 7 Front, // Numpad 1 Right, // Numpad 3 Left, // Numpad 4 / Ctrl+Numpad3 Iso, // Home } /// Camera projection mode. /// /// - `Perspective`: 원근 투영. 멀수록 작게 보임. 일반 3D 뷰. /// - `Orthographic`: 평행 투영. 거리 무관 실측 크기. 치수 검증·도면 스타일 뷰. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Projection { Perspective, Orthographic, } /// Orbit camera — spherical coordinates around a fixed target point. /// /// All distances in millimetres (scene units). pub struct Camera { /// Point the camera orbits around. pub target: Vec3, /// Distance from target [mm]. pub radius: f32, /// Horizontal rotation [radians]. pub yaw: f32, /// Vertical rotation [radians]. Clamped to avoid gimbal lock. pub pitch: f32, pub fov_y: f32, pub aspect: f32, pub znear: f32, pub zfar: f32, /// 투영 모드 (원근 / 평행). 토글 O. pub projection: Projection, } impl Camera { /// Default view looking at a 40 m PSC-I girder from a comfortable angle. pub fn default_for_girder(span_mm: f32) -> Self { Self { target: Vec3::new(300.0, 900.0, span_mm * 0.5), radius: span_mm * 1.5, yaw: std::f32::consts::FRAC_PI_4, // 45° pitch: 0.35, // ~20° fov_y: 60.0_f32.to_radians(), aspect: 16.0 / 9.0, znear: 10.0, // 10 mm zfar: 10_000_000.0, // 10 km projection: Projection::Perspective, } } /// Eye position derived from orbit parameters. pub fn eye(&self) -> Vec3 { let sin_p = self.pitch.sin(); let cos_p = self.pitch.cos(); let sin_y = self.yaw.sin(); let cos_y = self.yaw.cos(); self.target + Vec3::new( self.radius * cos_p * sin_y, self.radius * sin_p, self.radius * cos_p * cos_y, ) } /// View-projection matrix (right-handed, depth 0→1). pub fn view_proj(&self) -> Mat4 { let view = Mat4::look_at_rh(self.eye(), self.target, Vec3::Y); let proj = match self.projection { Projection::Perspective => { Mat4::perspective_rh(self.fov_y, self.aspect, self.znear, self.zfar) } Projection::Orthographic => { // Ortho 수직 반높이: 같은 radius·FOV 의 원근 뷰와 시각 스케일 일치 → // target 평면에서 보이던 크기를 그대로 유지한 채 평행 투영. let half_h = self.radius * (self.fov_y * 0.5).tan(); let half_w = half_h * self.aspect; Mat4::orthographic_rh(-half_w, half_w, -half_h, half_h, self.znear, self.zfar) } }; proj * view } /// Perspective ↔ Orthographic 토글. pub fn toggle_projection(&mut self) { self.projection = match self.projection { Projection::Perspective => Projection::Orthographic, Projection::Orthographic => Projection::Perspective, }; } /// Build GPU uniform from current state. pub fn to_uniform(&self) -> CameraUniform { CameraUniform { view_proj: self.view_proj().to_cols_array_2d() } } // ── Interaction ──────────────────────────────────────────────────────── /// Orbit by dragging (delta in pixels, scaled to radians). /// 사용자 요청: 회전 방향 반전 (yaw·pitch 양쪽 모두). pub fn orbit(&mut self, delta_x: f32, delta_y: f32) { self.yaw -= delta_x * 0.005; self.pitch = (self.pitch + delta_y * 0.005) .clamp(-std::f32::consts::FRAC_PI_2 + 0.05, std::f32::consts::FRAC_PI_2 - 0.05); } /// Zoom by scrolling (positive = closer, negative = farther). pub fn zoom(&mut self, delta: f32) { self.radius = (self.radius * (1.0 - delta * 0.1)).max(100.0); } /// Pan target by screen-space delta (Shift + middle mouse). /// /// Moves the orbit centre so the object stays under the cursor. pub fn pan(&mut self, delta_x: f32, delta_y: f32) { let eye = self.eye(); let view_dir = (self.target - eye).normalize(); let right = view_dir.cross(Vec3::Y).normalize(); let up = right.cross(view_dir).normalize(); let scale = self.radius * 0.001; // pan speed proportional to zoom self.target -= right * delta_x * scale; self.target += up * delta_y * scale; } /// Zoom-extents: fit the given bounding box into view. pub fn zoom_extents(&mut self, mn: [f32; 3], mx: [f32; 3]) { let c = Vec3::new( (mn[0] + mx[0]) * 0.5, (mn[1] + mx[1]) * 0.5, (mn[2] + mx[2]) * 0.5, ); let diag = Vec3::new(mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]).length(); self.target = c; self.radius = (diag * 0.75).max(1000.0); } /// Snap to a standard axis-aligned view (Revit numpad convention). pub fn set_standard_view(&mut self, view: StandardView) { match view { StandardView::Top => { self.yaw = 0.0; self.pitch = std::f32::consts::FRAC_PI_2 - 0.01; } StandardView::Front => { self.yaw = std::f32::consts::PI; self.pitch = 0.0; } StandardView::Right => { self.yaw = std::f32::consts::FRAC_PI_2; self.pitch = 0.0; } StandardView::Left => { self.yaw = -std::f32::consts::FRAC_PI_2; self.pitch = 0.0; } StandardView::Iso => { self.yaw = std::f32::consts::FRAC_PI_4; self.pitch = 0.35; } } } /// Update aspect ratio on window resize. pub fn resize(&mut self, width: u32, height: u32) { self.aspect = width as f32 / height.max(1) as f32; } }