cimery Sprint 2 — PSC-I 기하학 + viewer 개편 + OCCT optional

kernel:
- PureRustKernel: PSC-I 단면 14-vertex polygon 스위프, flat normals
  56 triangles / 168 vertices, 법선 단위벡터 검증 포함
- opencascade 의존성 optional feature (--features occt)로 격리
  → OCCT 없이도 전체 빌드 가능
- psc_i.rs: 프로파일 검증, AABB, 법선 테스트 6개

viewer:
- camera.rs: arcball orbit (middle-mouse drag + scroll zoom)
- shader.wgsl: MVP matrix uniform + 방향성 조명 (콘크리트 베이지)
- lib.rs: depth buffer, index 렌더, 실제 Mesh 업로드
  StubKernel → PureRustKernel → OcctKernel 교체 경로 문서화

CLAUDE.md: MVP 품질 원칙 강화 ("아키텍처 임의 변경 절대 불가")

cargo test --workspace (viewer 제외) 43개 전부 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 18:48:10 +09:00
parent 62ddf3aea6
commit 9cbe76cc5e
9 changed files with 716 additions and 174 deletions

View File

@@ -0,0 +1,101 @@
//! 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 ──────────────────────────────────────────────────────────────────
/// 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,
}
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
}
}
/// 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 = Mat4::perspective_rh(self.fov_y, self.aspect, self.znear, self.zfar);
proj * view
}
/// 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).
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);
}
/// 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;
}
}