viewer: Pan + ZoomExtents + 표준 뷰 단축키

camera.rs:
- pan(dx, dy): Shift+중간버튼으로 target 이동 (화면 평면 이동)
- zoom_extents(mn, mx): E키로 전체 씬 맞춤
- set_standard_view(StandardView): 축 고정 뷰
- StandardView: Top(7) / Front(1) / Right(3) / Left(4) / Iso(Home)

lib.rs:
- shift_pressed 추적 (ModifiersChanged)
- Shift+중간버튼 드래그 → pan
- E → ZoomExtents
- 1/3/4/7/Home → 표준 뷰 스냅
- scene_mn/mx 저장 → zoom_extents에 전달

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 20:11:07 +09:00
parent 3359475879
commit 096fc133c4
2 changed files with 114 additions and 13 deletions

View File

@@ -22,6 +22,16 @@ impl CameraUniform {
// ─── Camera ────────────────────────────────────────────────────────────────── // ─── 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
}
/// Orbit camera — spherical coordinates around a fixed target point. /// Orbit camera — spherical coordinates around a fixed target point.
/// ///
/// All distances in millimetres (scene units). /// All distances in millimetres (scene units).
@@ -94,6 +104,42 @@ impl Camera {
self.radius = (self.radius * (1.0 - delta * 0.1)).max(100.0); 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. /// Update aspect ratio on window resize.
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
self.aspect = width as f32 / height.max(1) as f32; self.aspect = width as f32 / height.max(1) as f32;

View File

@@ -31,7 +31,7 @@ use cimery_kernel::OcctKernel;
#[cfg(not(feature = "occt"))] #[cfg(not(feature = "occt"))]
use cimery_kernel::PureRustKernel; use cimery_kernel::PureRustKernel;
use cimery_kernel::GeomKernel; use cimery_kernel::GeomKernel;
use camera::Camera; use camera::{Camera, StandardView};
use glam; use glam;
// ─── Vertex ─────────────────────────────────────────────────────────────────── // ─── Vertex ───────────────────────────────────────────────────────────────────
@@ -80,9 +80,13 @@ struct RenderState {
camera_bind_group: wgpu::BindGroup, camera_bind_group: wgpu::BindGroup,
// Depth // Depth
depth_view: wgpu::TextureView, depth_view: wgpu::TextureView,
// Mouse state // Mouse / keyboard state
mid_pressed: bool, mid_pressed: bool,
shift_pressed: bool,
last_mouse: winit::dpi::PhysicalPosition<f64>, last_mouse: winit::dpi::PhysicalPosition<f64>,
// Scene extents for ZoomExtents
scene_mn: [f32; 3],
scene_mx: [f32; 3],
} }
impl RenderState { impl RenderState {
@@ -271,6 +275,8 @@ impl RenderState {
cache: None, cache: None,
}); });
let (scene_mn, scene_mx) = bridge_scene::scene_extents();
RenderState { RenderState {
window, window,
device, device,
@@ -286,7 +292,10 @@ impl RenderState {
camera_bind_group, camera_bind_group,
depth_view, depth_view,
mid_pressed: false, mid_pressed: false,
shift_pressed: false,
last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 }, last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 },
scene_mn,
scene_mx,
} }
} }
@@ -416,16 +425,56 @@ impl ApplicationHandler for CimeryApp {
match event { match event {
// ── Exit ────────────────────────────────────────────────────────── // ── Exit ──────────────────────────────────────────────────────────
WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event: KeyEvent { // ── Shift modifier tracking ───────────────────────────────────────
physical_key: PhysicalKey::Code(KeyCode::Escape), .. WindowEvent::ModifiersChanged(mods) => {
}, .. state.shift_pressed = mods.state().shift_key();
} => event_loop.exit(), }
// ── Keyboard shortcuts ────────────────────────────────────────────
WindowEvent::KeyboardInput { event: KeyEvent { physical_key, state: key_state, .. }, .. }
if key_state == ElementState::Pressed =>
{
match physical_key {
PhysicalKey::Code(KeyCode::Escape) => event_loop.exit(),
// E → ZoomExtents (fit all)
PhysicalKey::Code(KeyCode::KeyE) => {
state.camera.zoom_extents(state.scene_mn, state.scene_mx);
state.update_camera();
}
// Numpad 7 / 7 → Top view
PhysicalKey::Code(KeyCode::Numpad7) | PhysicalKey::Code(KeyCode::Digit7) => {
state.camera.set_standard_view(StandardView::Top);
state.update_camera();
}
// Numpad 1 / 1 → Front view
PhysicalKey::Code(KeyCode::Numpad1) | PhysicalKey::Code(KeyCode::Digit1) => {
state.camera.set_standard_view(StandardView::Front);
state.update_camera();
}
// Numpad 3 / 3 → Right side view
PhysicalKey::Code(KeyCode::Numpad3) | PhysicalKey::Code(KeyCode::Digit3) => {
state.camera.set_standard_view(StandardView::Right);
state.update_camera();
}
// Numpad 4 / 4 → Left side view
PhysicalKey::Code(KeyCode::Numpad4) | PhysicalKey::Code(KeyCode::Digit4) => {
state.camera.set_standard_view(StandardView::Left);
state.update_camera();
}
// Home → Isometric (default view)
PhysicalKey::Code(KeyCode::Home) => {
state.camera.set_standard_view(StandardView::Iso);
state.update_camera();
}
_ => {}
}
}
// ── Resize ──────────────────────────────────────────────────────── // ── Resize ────────────────────────────────────────────────────────
WindowEvent::Resized(sz) => state.resize(sz), WindowEvent::Resized(sz) => state.resize(sz),
// ── Mouse orbit (middle button drag) ────────────────────────────── // ── Mouse orbit / pan (middle button drag) ────────────────────────
WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => { WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => {
state.mid_pressed = btn_state == ElementState::Pressed; state.mid_pressed = btn_state == ElementState::Pressed;
} }
@@ -433,7 +482,13 @@ impl ApplicationHandler for CimeryApp {
if state.mid_pressed { if state.mid_pressed {
let dx = (position.x - state.last_mouse.x) as f32; let dx = (position.x - state.last_mouse.x) as f32;
let dy = (position.y - state.last_mouse.y) as f32; let dy = (position.y - state.last_mouse.y) as f32;
if state.shift_pressed {
// Shift + Middle = Pan (translate target)
state.camera.pan(dx, dy);
} else {
// Middle = Orbit
state.camera.orbit(dx, dy); state.camera.orbit(dx, dy);
}
state.update_camera(); state.update_camera();
} }
state.last_mouse = position; state.last_mouse = position;