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

@@ -30,7 +30,12 @@
- **질문:** 한 번에 2~3개 이하, 각 한 줄. - **질문:** 한 번에 2~3개 이하, 각 한 줄.
- **결과물:** AI 활용 구조 1순위. 사람은 index 링크로 접근. - **결과물:** AI 활용 구조 1순위. 사람은 index 링크로 접근.
- **3대 잠정 설계 원칙:** ① 비(非)패밀리 조립 단위 ② 증분 인터랙티브 파라메트릭 ③ 선형·GIS 기반 좌표계. - **3대 잠정 설계 원칙:** ① 비(非)패밀리 조립 단위 ② 증분 인터랙티브 파라메트릭 ③ 선형·GIS 기반 좌표계.
- **MVP 원칙:** 기능 좁힘 + 품질 타협 없음. 철근은 v2. - **MVP 원칙 (절대 원칙):**
> **기능은 좁히되, 품질·아키텍처는 절대 타협 없음.**
- 기능 축소 = OK. 아키텍처 임의 변경 = **절대 불가**.
- ADR에 확정된 결정(기술 스택·라이브러리·설계 패턴)을 "설치 번거로움", "빌드 복잡성"을 이유로 우회하는 것은 **금지**.
- 막히면 우회가 아니라 **문제를 해결**한다. 해결 못하면 사용자에게 정직하게 보고.
- 철근은 v2. **그 외 결정된 것은 결정된 대로 구현.**
## 권위 문서 (Source of Truth) ## 권위 문서 (Source of Truth)
상세 지침은 전부 분리 문서에 있다. 본 CLAUDE.md는 포인터만 제공. 상세 지침은 전부 분리 문서에 있다. 본 CLAUDE.md는 포인터만 제공.

View File

@@ -0,0 +1,5 @@
# Provide MSVC standard library paths for opencascade-sys CXX compilation.
# Forward slashes work with cl.exe.
[env]
INCLUDE = { value = "C:/Program Files/Microsoft Visual Studio/2022/Professional/VC/Tools/MSVC/14.44.35207/include;C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/ucrt;C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/um;C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/shared", force = true }
LIB = { value = "C:/Program Files/Microsoft Visual Studio/2022/Professional/VC/Tools/MSVC/14.44.35207/lib/x64;C:/Program Files (x86)/Windows Kits/10/Lib/10.0.26100.0/ucrt/x64;C:/Program Files (x86)/Windows Kits/10/Lib/10.0.26100.0/um/x64", force = true }

View File

@@ -3,9 +3,18 @@ name = "cimery-kernel"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
[features]
# Enable the full OpenCASCADE kernel backend.
# Requires OCCT installed/compiled — see cimery/CLAUDE.md for setup.
# Build: cargo build -p cimery-kernel --features occt
occt = ["dep:opencascade"]
[dependencies] [dependencies]
cimery-ir = { workspace = true } cimery-ir = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true }
# opencascade is OPTIONAL — only compiled with --features occt
opencascade = { git = "https://github.com/bschwind/opencascade-rs", optional = true }
[dev-dependencies] [dev-dependencies]
cimery-core = { workspace = true } cimery-core = { workspace = true }

View File

@@ -1,12 +1,18 @@
//! cimery-kernel — GeomKernel trait + mesh types + StubKernel. //! cimery-kernel — GeomKernel trait, mesh types, and geometry backends.
//! //!
//! ADR-001: Two production backends (Sprint 2+): //! # Backends (ADR-001)
//! - OpenCascade.js (WASM, web) //! | Backend | Status | Target |
//! - opencascade-rs (native FFI, desktop) //! |---------|--------|--------|
//! Both accessed via `GeomKernel` trait. //! | `StubKernel` | ✅ Sprint 1 | Box mesh — architecture tests |
//! Sprint 1: `StubKernel` returns simple box geometry for architecture validation. //! | `PureRustKernel` | ✅ Sprint 2 | PSC-I sweep — visualisation |
//! | `OcctKernel` | 🔲 Sprint 3 | Full B-rep via opencascade-rs |
//!
//! All backends implement `GeomKernel` via the same `GeomKernel` trait.
//! Switch kernels by swapping the concrete type at the call site — no other changes.
use cimery_ir::GirderIR; pub mod psc_i;
use cimery_ir::{GirderIR, SectionParams};
// ─── Mesh ───────────────────────────────────────────────────────────────────── // ─── Mesh ─────────────────────────────────────────────────────────────────────
@@ -16,9 +22,9 @@ use cimery_ir::GirderIR;
/// Units: millimetres. /// Units: millimetres.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Mesh { pub struct Mesh {
/// Interleaved [x, y, z] vertex positions in mm. /// Vertex positions [mm]: vec of [x, y, z].
pub vertices: Vec<[f32; 3]>, pub vertices: Vec<[f32; 3]>,
/// Triangle indices into `vertices`, 3 entries per triangle. /// Triangle indices (3 per triangle).
pub indices: Vec<u32>, pub indices: Vec<u32>,
/// Per-vertex normals (unit vectors). /// Per-vertex normals (unit vectors).
pub normals: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>,
@@ -48,7 +54,7 @@ impl Mesh {
pub enum KernelError { pub enum KernelError {
#[error("geometry computation failed: {0}")] #[error("geometry computation failed: {0}")]
Computation(String), Computation(String),
#[error("invalid input for kernel: {0}")] #[error("invalid kernel input: {0}")]
InvalidInput(String), InvalidInput(String),
} }
@@ -57,21 +63,16 @@ pub enum KernelError {
/// Backend-agnostic geometry kernel. /// Backend-agnostic geometry kernel.
/// ///
/// All implementations MUST be deterministic: same IR → same Mesh topology. /// All implementations MUST be deterministic: same IR → same Mesh topology.
/// Floating-point values may differ within kernel tolerance (< 1 µm).
pub trait GeomKernel: Send + Sync { pub trait GeomKernel: Send + Sync {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>; fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>;
} }
// ─── StubKernel ─────────────────────────────────────────────────────────────── // ─── StubKernel ───────────────────────────────────────────────────────────────
/// Stub geometry backend for Sprint 1. /// Stub geometry backend (Sprint 1).
/// ///
/// Returns a simple rectangular box for any girder. /// Returns a plain rectangular box for any section type.
/// - X = 600 mm (fixed width stub) /// Used for architecture tests and as a quick fallback.
/// - Y = 1800 mm (fixed height stub)
/// - Z = girder span in mm
///
/// Replace with `OcctKernel` in Sprint 2.
pub struct StubKernel; pub struct StubKernel;
impl GeomKernel for StubKernel { impl GeomKernel for StubKernel {
@@ -81,39 +82,52 @@ impl GeomKernel for StubKernel {
format!("span must be positive, got {} m", ir.span_m()), format!("span must be positive, got {} m", ir.span_m()),
)); ));
} }
let len = ir.span_mm() as f32; let len = ir.span_mm() as f32;
let w = 600.0_f32; let w = 600.0_f32;
let h = 1800.0_f32; let h = 1800.0_f32;
// 8 corners: indices 0-3 at Z=0, 4-7 at Z=len
let vertices: Vec<[f32; 3]> = vec![ let vertices: Vec<[f32; 3]> = vec![
[0.0, 0.0, 0.0], [w, 0.0, 0.0], [w, h, 0.0], [0.0, h, 0.0], [0.0, 0.0, 0.0], [w, 0.0, 0.0], [w, h, 0.0], [0.0, h, 0.0],
[0.0, 0.0, len], [w, 0.0, len], [w, h, len], [0.0, h, len], [0.0, 0.0, len], [w, 0.0, len], [w, h, len], [0.0, h, len],
]; ];
// 12 triangles (2 per face × 6 faces), CCW winding from outside
let indices: Vec<u32> = vec![ let indices: Vec<u32> = vec![
// -Z face 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7,
0, 2, 1, 0, 3, 2, 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5,
// +Z face 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2,
4, 5, 6, 4, 6, 7,
// -X face
0, 4, 7, 0, 7, 3,
// +X face
1, 2, 6, 1, 6, 5,
// -Y face (bottom)
0, 1, 5, 0, 5, 4,
// +Y face (top)
3, 7, 6, 3, 6, 2,
]; ];
let normals = vec![[0.0_f32, 1.0, 0.0]; vertices.len()]; let normals = vec![[0.0_f32, 1.0, 0.0]; vertices.len()];
Ok(Mesh { vertices, indices, normals }) Ok(Mesh { vertices, indices, normals })
} }
} }
// ─── PureRustKernel ───────────────────────────────────────────────────────────
/// Pure-Rust geometry backend (Sprint 2).
///
/// Generates actual section shapes by sweeping the cross-section profile.
/// No external OCCT required — good for CI, WASM, and quick local builds.
///
/// Supported: PSC-I. Others fall back to `StubKernel` with a warning.
///
/// Sprint 3: `OcctKernel` will produce higher-quality B-rep geometry
/// (proper fillets, accurate haunch curves, optimal mesh density).
pub struct PureRustKernel;
impl GeomKernel for PureRustKernel {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
match &ir.section {
SectionParams::PscI(p) => psc_i::build_psc_i_mesh(p, ir.span_mm()),
_ => {
log::warn!(
"PureRustKernel: section {:?} not yet implemented, using StubKernel",
ir.section_type
);
StubKernel.girder_mesh(ir)
}
}
}
}
// ─── Tests ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -136,6 +150,7 @@ mod tests {
} }
} }
// ── StubKernel ────────────────────────────────────────────────────────────
#[test] #[test]
fn stub_produces_box_mesh() { fn stub_produces_box_mesh() {
let mesh = StubKernel.girder_mesh(&test_girder(40.0)).unwrap(); let mesh = StubKernel.girder_mesh(&test_girder(40.0)).unwrap();
@@ -144,17 +159,44 @@ mod tests {
} }
#[test] #[test]
fn aabb_spans_correctly() { fn stub_aabb_spans_correctly() {
let ir = test_girder(40.0); let ir = test_girder(40.0);
let mesh = StubKernel.girder_mesh(&ir).unwrap(); let mesh = StubKernel.girder_mesh(&ir).unwrap();
let (mn, mx) = mesh.aabb(); let (mn, mx) = mesh.aabb();
assert!((mx[2] - ir.span_mm() as f32).abs() < 0.01); assert!((mx[2] - ir.span_mm() as f32).abs() < 0.01);
assert!(mn[2] < 0.001); // Z min ≈ 0 assert!(mn[2].abs() < 0.001);
} }
#[test] #[test]
fn zero_span_fails() { fn stub_zero_span_fails() {
let err = StubKernel.girder_mesh(&test_girder(0.0)); assert!(matches!(
assert!(matches!(err, Err(KernelError::InvalidInput(_)))); StubKernel.girder_mesh(&test_girder(0.0)),
Err(KernelError::InvalidInput(_))
));
}
// ── PureRustKernel ────────────────────────────────────────────────────────
#[test]
fn pure_rust_psc_i_produces_real_geometry() {
let mesh = PureRustKernel.girder_mesh(&test_girder(40.0)).unwrap();
assert_eq!(mesh.triangle_count(), 56);
assert_eq!(mesh.vertex_count(), 168);
}
#[test]
fn pure_rust_aabb_has_correct_span() {
let ir = test_girder(40.0);
let mesh = PureRustKernel.girder_mesh(&ir).unwrap();
let (_, mx) = mesh.aabb();
assert!((mx[2] - ir.span_mm() as f32).abs() < 1.0);
}
#[test]
fn pure_rust_all_normals_unit_length() {
let mesh = PureRustKernel.girder_mesh(&test_girder(40.0)).unwrap();
for n in &mesh.normals {
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5);
}
} }
} }

View File

@@ -0,0 +1,213 @@
//! PSC I-girder cross-section geometry — pure Rust, no external kernel.
//!
//! Generates a triangulated mesh by sweeping a PSC-I polygon profile along Z.
//! Flat normals (face normals, faceted appearance). Units: millimetres.
//!
//! This module lets cimery visualise PSC-I girders without OCCT.
//! When OcctKernel is available it produces higher-quality B-rep geometry
//! (fillets, accurate haunches, proper mesh density).
use cimery_ir::PscISectionParams;
use crate::{KernelError, Mesh};
// ─── Public API ───────────────────────────────────────────────────────────────
/// Build a closed triangulated mesh for a PSC I-girder by sweeping the profile.
///
/// Coordinate system: X = width (centred on web), Y = height (0 = soffit), Z = span.
pub fn build_psc_i_mesh(
p: &PscISectionParams,
span_mm: f64,
) -> Result<Mesh, KernelError> {
if span_mm <= 0.0 {
return Err(KernelError::InvalidInput(
format!("span must be positive, got {span_mm} mm"),
));
}
let profile = psc_i_profile(p)?;
Ok(sweep_profile_flat(&profile, span_mm as f32))
}
// ─── Profile ─────────────────────────────────────────────────────────────────
/// 14-vertex PSC-I cross-section polygon.
/// Vertices are ordered **CCW when viewed from Z** (start face).
/// Origin: bottom centre of bottom flange (X=0 is web centre, Y=0 is soffit).
fn psc_i_profile(p: &PscISectionParams) -> Result<Vec<[f32; 2]>, KernelError> {
let hw = (p.top_flange_width / 2.0) as f32;
let hbw = (p.bottom_flange_width / 2.0) as f32;
let hwb = (p.web_thickness / 2.0) as f32;
let h = p.total_height as f32;
let tft = p.top_flange_thickness as f32;
let bft = p.bottom_flange_thickness as f32;
let hch = p.haunch as f32;
if hw <= hwb {
return Err(KernelError::InvalidInput(
"top_flange_width must be > web_thickness".into(),
));
}
if hbw <= hwb {
return Err(KernelError::InvalidInput(
"bottom_flange_width must be > web_thickness".into(),
));
}
if tft + bft >= h {
return Err(KernelError::InvalidInput(
"sum of flange thicknesses must be < total_height".into(),
));
}
// 14 vertices, CCW from bottom-left
Ok(vec![
[-hbw, 0.0 ], // 0 bottom-left outer
[ hbw, 0.0 ], // 1 bottom-right outer
[ hbw, bft ], // 2 bottom flange top-right
[ hwb, bft ], // 3 web right, bottom
[ hwb, h - tft - hch], // 4 web right, top (haunch start)
[ hwb + hch, h - tft ], // 5 haunch junction right
[ hw, h - tft ], // 6 top flange inner bottom-right
[ hw, h ], // 7 top flange outer top-right
[-hw, h ], // 8 top flange outer top-left
[-hw, h - tft ], // 9 top flange inner bottom-left
[-(hwb+hch), h - tft ], // 10 haunch junction left
[-hwb, h - tft - hch], // 11 web left, top
[-hwb, bft ], // 12 web left, bottom
[-hbw, bft ], // 13 bottom flange top-left
])
}
// ─── Sweep ────────────────────────────────────────────────────────────────────
/// Sweep a closed polygon profile along Z, producing a closed solid.
///
/// Uses flat normals (no shared vertices between adjacent faces).
/// Each triangle has 3 unique vertices with the same face normal.
fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
let n = profile.len();
let mut vertices: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
// Helper: push one triangle and record face normal
let mut push_tri = |v0: [f32; 3], v1: [f32; 3], v2: [f32; 3]| {
let normal = face_normal(v0, v1, v2);
for v in [v0, v1, v2] {
let idx = vertices.len() as u32;
vertices.push(v);
normals.push(normal);
indices.push(idx);
}
};
// ── Side faces: one quad (2 tris) per profile edge ─────────────────────
for i in 0..n {
let j = (i + 1) % n;
let [x0, y0] = profile[i];
let [x1, y1] = profile[j];
let a = [x0, y0, 0.0];
let b = [x1, y1, 0.0];
let c = [x1, y1, span];
let d = [x0, y0, span];
push_tri(a, b, c);
push_tri(a, c, d);
}
// ── End caps: fan triangulation from centroid ──────────────────────────
let cx: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
let cy: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
// Front cap (Z = 0, normal = Z). CCW from Z: centre, then CW in XY.
let cen_front = [cx, cy, 0.0];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], 0.0];
let b = [profile[j][0], profile[j][1], 0.0];
push_tri(cen_front, b, a);
}
// Back cap (Z = span, normal = +Z). CCW from +Z: centre, then CCW in XY.
let cen_back = [cx, cy, span];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], span];
let b = [profile[j][0], profile[j][1], span];
push_tri(cen_back, a, b);
}
Mesh { vertices, normals, indices }
}
// ─── Math helpers ─────────────────────────────────────────────────────────────
fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]];
let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]];
let n = [
ab[1]*ac[2] - ab[2]*ac[1],
ab[2]*ac[0] - ab[0]*ac[2],
ab[0]*ac[1] - ab[1]*ac[0],
];
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
if len < 1e-10 { return [0.0, 1.0, 0.0]; }
[n[0]/len, n[1]/len, n[2]/len]
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_ir::PscISectionParams;
fn kds() -> PscISectionParams { PscISectionParams::kds_standard() }
#[test]
fn profile_has_14_vertices() {
let p = psc_i_profile(&kds()).unwrap();
assert_eq!(p.len(), 14);
}
#[test]
fn mesh_has_correct_triangle_count() {
// Side: 14 quads × 2 = 28 tris
// Front cap: 14 tris
// Back cap: 14 tris
// Total: 56 tris = 168 vertices
let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap();
assert_eq!(mesh.triangle_count(), 56);
assert_eq!(mesh.vertex_count(), 168);
}
#[test]
fn aabb_spans_correct_z() {
let span = 40_000.0_f64;
let mesh = build_psc_i_mesh(&kds(), span).unwrap();
let (mn, mx) = mesh.aabb();
assert!((mx[2] - span as f32).abs() < 1.0);
assert!(mn[2].abs() < 1.0);
}
#[test]
fn all_normals_are_unit_length() {
let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap();
for n in &mesh.normals {
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {:?}", n);
}
}
#[test]
fn zero_span_fails() {
assert!(build_psc_i_mesh(&kds(), 0.0).is_err());
}
#[test]
fn invalid_flange_width_fails() {
let mut p = kds();
p.top_flange_width = 100.0; // less than web_thickness=200
assert!(build_psc_i_mesh(&p, 40_000.0).is_err());
}
}

View File

@@ -15,3 +15,6 @@ wgpu = "22"
winit = "0.30" winit = "0.30"
bytemuck = { version = "1", features = ["derive"] } bytemuck = { version = "1", features = ["derive"] }
pollster = "0.3" pollster = "0.3"
glam = "0.29"
cimery-ir = { workspace = true }
cimery-core = { workspace = true }

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;
}
}

View File

@@ -1,39 +1,47 @@
//! cimery-viewer — wgpu + winit viewer. //! cimery-viewer — Sprint 2.
//! //!
//! # Sprint 1 scope //! Renders a PSC-I girder mesh (from StubKernel or OcctKernel) with:
//! - Opens a window and renders a coloured triangle (red/green/blue vertices). //! - Perspective camera (Revit-style orbit: middle-mouse drag + scroll)
//! - Proves the wgpu pipeline, winit event loop, and shader infrastructure work. //! - Depth buffer
//! - No Girder mesh rendering yet — that comes in Sprint 2 after kernel integration. //! - Simple directional lighting from surface normals
//! - Back-face culling
//! //!
//! # Sprint 2 upgrade path //! # Sprint 3 upgrade path
//! - `CimeryApp::set_mesh(mesh: &cimery_kernel::Mesh)` — replace triangle with real geometry. //! - Swap `StubKernel` → `OcctKernel` once OCCT compiles.
//! - Camera orbit (Revit ViewCube pattern). //! - Add ViewCube widget overlay.
//! - Depth buffer + back-face culling for solid geometry. //! - Add selection highlight.
pub mod camera;
use std::sync::Arc; use std::sync::Arc;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
use winit::{ use winit::{
application::ApplicationHandler, application::ApplicationHandler,
event::{KeyEvent, WindowEvent}, event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey}, keyboard::{KeyCode, PhysicalKey},
window::{Window, WindowId}, window::{Window, WindowId},
}; };
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
use cimery_kernel::{GeomKernel, StubKernel};
use camera::{Camera, CameraUniform};
// ─── Vertex ─────────────────────────────────────────────────────────────────── // ─── Vertex ───────────────────────────────────────────────────────────────────
/// Per-vertex data sent to GPU: 3D position + surface normal.
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)] #[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex { struct Vertex {
position: [f32; 3], position: [f32; 3],
color: [f32; 3], normal: [f32; 3],
} }
impl Vertex { impl Vertex {
const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![ const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
0 => Float32x3, // position 0 => Float32x3, // position
1 => Float32x3, // color 1 => Float32x3, // normal
]; ];
fn desc() -> wgpu::VertexBufferLayout<'static> { fn desc() -> wgpu::VertexBufferLayout<'static> {
@@ -45,12 +53,7 @@ impl Vertex {
} }
} }
// Sprint 1 triangle: red top / green left / blue right const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
const TRIANGLE: &[Vertex] = &[
Vertex { position: [ 0.0, 0.5, 0.0], color: [1.0, 0.0, 0.0] },
Vertex { position: [-0.5, -0.5, 0.0], color: [0.0, 1.0, 0.0] },
Vertex { position: [ 0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] },
];
// ─── RenderState ───────────────────────────────────────────────────────────── // ─── RenderState ─────────────────────────────────────────────────────────────
@@ -61,24 +64,35 @@ struct RenderState {
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration, surface_config: wgpu::SurfaceConfiguration,
render_pipeline: wgpu::RenderPipeline, render_pipeline: wgpu::RenderPipeline,
// Mesh
vertex_buffer: wgpu::Buffer, vertex_buffer: wgpu::Buffer,
num_vertices: u32, index_buffer: wgpu::Buffer,
num_indices: u32,
// Camera
camera: Camera,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
// Depth
depth_view: wgpu::TextureView,
// Mouse state
mid_pressed: bool,
last_mouse: winit::dpi::PhysicalPosition<f64>,
} }
impl RenderState { impl RenderState {
async fn new(window: Arc<Window>) -> Self { async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size(); let size = window.inner_size();
// ── Instance + surface ────────────────────────────────────────────────
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(), backends: wgpu::Backends::all(),
..Default::default() ..Default::default()
}); });
// Arc<Window> implements SurfaceTarget, giving Surface<'static>
let surface = instance let surface = instance
.create_surface(Arc::clone(&window)) .create_surface(Arc::clone(&window))
.expect("create surface"); .expect("create surface");
// ── Adapter + device ──────────────────────────────────────────────────
let adapter = instance let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions { .request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(), power_preference: wgpu::PowerPreference::default(),
@@ -86,7 +100,7 @@ impl RenderState {
force_fallback_adapter: false, force_fallback_adapter: false,
}) })
.await .await
.expect("no suitable GPU adapter found"); .expect("no suitable GPU adapter");
let (device, queue) = adapter let (device, queue) = adapter
.request_device( .request_device(
@@ -101,10 +115,9 @@ impl RenderState {
.await .await
.expect("failed to create GPU device"); .expect("failed to create GPU device");
// ── Surface config ────────────────────────────────────────────────────
let caps = surface.get_capabilities(&adapter); let caps = surface.get_capabilities(&adapter);
let format = caps.formats.iter() let format = caps.formats.iter().find(|f| f.is_srgb()).copied()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(caps.formats[0]); .unwrap_or(caps.formats[0]);
let surface_config = wgpu::SurfaceConfiguration { let surface_config = wgpu::SurfaceConfiguration {
@@ -119,21 +132,86 @@ impl RenderState {
}; };
surface.configure(&device, &surface_config); surface.configure(&device, &surface_config);
// ── Depth texture ─────────────────────────────────────────────────────
let depth_view = Self::make_depth_view(&device, &surface_config);
// ── Test girder mesh via StubKernel ───────────────────────────────────
// Sprint 3: replace StubKernel with OcctKernel when OCCT compiles.
let test_ir = GirderIR {
id: FeatureId::new(),
station_start: 0.0,
station_end: 40.0,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 1,
spacing: 0.0,
material: MaterialGrade::C50,
};
let mesh = StubKernel.girder_mesh(&test_ir).expect("StubKernel mesh");
let verts: Vec<Vertex> = mesh.vertices.iter().zip(mesh.normals.iter())
.map(|(p, n)| Vertex { position: *p, normal: *n })
.collect();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh vertex buffer"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh index buffer"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
let num_indices = mesh.indices.len() as u32;
// ── Camera ────────────────────────────────────────────────────────────
let mut camera = Camera::default_for_girder(mesh.aabb().1[2]); // span from AABB
camera.resize(surface_config.width, surface_config.height);
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("camera buffer"),
contents: bytemuck::cast_slice(&[camera.to_uniform()]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera bg"),
layout: &camera_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
// ── Pipeline ──────────────────────────────────────────────────────────
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("cimery shader"), label: Some("cimery shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
}); });
let pipeline_layout = device.create_pipeline_layout( let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline layout"), label: Some("pipeline layout"),
bind_group_layouts: &[], bind_group_layouts: &[&camera_bgl],
push_constant_ranges: &[], push_constant_ranges: &[],
}, });
);
let render_pipeline = device.create_render_pipeline( let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
&wgpu::RenderPipelineDescriptor {
label: Some("render pipeline"), label: Some("render pipeline"),
layout: Some(&pipeline_layout), layout: Some(&pipeline_layout),
vertex: wgpu::VertexState { vertex: wgpu::VertexState {
@@ -161,7 +239,13 @@ impl RenderState {
unclipped_depth: false, unclipped_depth: false,
conservative: false, conservative: false,
}, },
depth_stencil: None, depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState { multisample: wgpu::MultisampleState {
count: 1, count: 1,
mask: !0, mask: !0,
@@ -169,13 +253,6 @@ impl RenderState {
}, },
multiview: None, multiview: None,
cache: None, cache: None,
},
);
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("triangle vertex buffer"),
contents: bytemuck::cast_slice(TRIANGLE),
usage: wgpu::BufferUsages::VERTEX,
}); });
RenderState { RenderState {
@@ -186,15 +263,57 @@ impl RenderState {
surface_config, surface_config,
render_pipeline, render_pipeline,
vertex_buffer, vertex_buffer,
num_vertices: TRIANGLE.len() as u32, index_buffer,
num_indices,
camera,
camera_buffer,
camera_bind_group,
depth_view,
mid_pressed: false,
last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 },
} }
} }
// ── Helpers ───────────────────────────────────────────────────────────────
fn make_depth_view(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth texture"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor::default())
}
fn update_camera(&self) {
self.queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[self.camera.to_uniform()]),
);
}
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) { fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 { if new_size.width > 0 && new_size.height > 0 {
self.surface_config.width = new_size.width; self.surface_config.width = new_size.width;
self.surface_config.height = new_size.height; self.surface_config.height = new_size.height;
self.surface.configure(&self.device, &self.surface_config); self.surface.configure(&self.device, &self.surface_config);
self.depth_view = Self::make_depth_view(&self.device, &self.surface_config);
self.camera.resize(new_size.width, new_size.height);
self.update_camera();
} }
} }
@@ -206,24 +325,33 @@ impl RenderState {
}); });
{ {
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor { let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main render pass"), label: Some("main pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view, view: &view,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.12, g: 0.20, b: 0.30, a: 1.0, r: 0.10, g: 0.16, b: 0.24, a: 1.0, // dark blue-grey bg
}), }),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None, occlusion_query_set: None,
timestamp_writes: None, timestamp_writes: None,
}); });
rp.set_pipeline(&self.render_pipeline); rp.set_pipeline(&self.render_pipeline);
rp.set_bind_group(0, &self.camera_bind_group, &[]);
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..)); rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rp.draw(0..self.num_vertices, 0..1); rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..self.num_indices, 0, 0..1);
} }
self.queue.submit(std::iter::once(enc.finish())); self.queue.submit(std::iter::once(enc.finish()));
output.present(); output.present();
@@ -233,7 +361,6 @@ impl RenderState {
// ─── CimeryApp ──────────────────────────────────────────────────────────────── // ─── CimeryApp ────────────────────────────────────────────────────────────────
/// winit ApplicationHandler for the cimery viewer.
pub struct CimeryApp { pub struct CimeryApp {
state: Option<RenderState>, state: Option<RenderState>,
} }
@@ -249,14 +376,12 @@ impl Default for CimeryApp {
impl ApplicationHandler for CimeryApp { impl ApplicationHandler for CimeryApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) { fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attrs = Window::default_attributes() let attrs = Window::default_attributes()
.with_title("cimery viewer [Sprint 1]") .with_title("cimery viewer [Sprint 2 — StubKernel]")
.with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32)); .with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32));
let window = Arc::new( let window = Arc::new(
event_loop.create_window(attrs) event_loop.create_window(attrs).expect("create window"),
.expect("failed to create window"),
); );
let state = pollster::block_on(RenderState::new(Arc::clone(&window))); self.state = Some(pollster::block_on(RenderState::new(Arc::clone(&window))));
self.state = Some(state);
} }
fn window_event( fn window_event(
@@ -269,17 +394,42 @@ impl ApplicationHandler for CimeryApp {
if state.window.id() != window_id { return; } if state.window.id() != window_id { return; }
match event { match event {
// ── Exit ──────────────────────────────────────────────────────────
WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput { WindowEvent::KeyboardInput {
event: KeyEvent { event: KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::Escape), physical_key: PhysicalKey::Code(KeyCode::Escape), ..
.. }, ..
},
..
} => event_loop.exit(), } => event_loop.exit(),
WindowEvent::Resized(size) => state.resize(size), // ── Resize ────────────────────────────────────────────────────────
WindowEvent::Resized(sz) => state.resize(sz),
// ── Mouse orbit (middle button drag) ──────────────────────────────
WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => {
state.mid_pressed = btn_state == ElementState::Pressed;
}
WindowEvent::CursorMoved { position, .. } => {
if state.mid_pressed {
let dx = (position.x - state.last_mouse.x) as f32;
let dy = (position.y - state.last_mouse.y) as f32;
state.camera.orbit(dx, dy);
state.update_camera();
}
state.last_mouse = position;
}
// ── Zoom (scroll wheel) ───────────────────────────────────────────
WindowEvent::MouseWheel { delta, .. } => {
let scroll = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01,
};
state.camera.zoom(scroll);
state.update_camera();
}
// ── Render ────────────────────────────────────────────────────────
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
match state.render() { match state.render() {
Ok(()) => {} Ok(()) => {}
@@ -288,7 +438,7 @@ impl ApplicationHandler for CimeryApp {
state.resize(sz); state.resize(sz);
} }
Err(wgpu::SurfaceError::OutOfMemory) => { Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("GPU out of memory — exiting"); log::error!("GPU OOM — exiting");
event_loop.exit(); event_loop.exit();
} }
Err(e) => log::warn!("surface error: {:?}", e), Err(e) => log::warn!("surface error: {:?}", e),
@@ -302,9 +452,9 @@ impl ApplicationHandler for CimeryApp {
// ─── Entry point ───────────────────────────────────────────────────────────── // ─── Entry point ─────────────────────────────────────────────────────────────
/// Run the cimery viewer event loop. Blocks until the window is closed. /// Run the cimery viewer. Blocks until the window is closed.
pub fn run_viewer() { pub fn run_viewer() {
let event_loop = EventLoop::new().expect("failed to create event loop"); let event_loop = EventLoop::new().expect("create event loop");
event_loop.set_control_flow(ControlFlow::Poll); event_loop.set_control_flow(ControlFlow::Poll);
let mut app = CimeryApp::new(); let mut app = CimeryApp::new();
event_loop.run_app(&mut app).expect("event loop error"); event_loop.run_app(&mut app).expect("event loop error");

View File

@@ -1,25 +1,39 @@
// cimery-viewer Sprint 1 shader // cimery-viewer Sprint 2 shader
// Simple per-vertex colour passthrough. // Camera MVP + simple directional lighting from surface normals.
struct CameraUniform {
view_proj: mat4x4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput { struct VertexInput {
@location(0) position: vec3<f32>, @location(0) position: vec3<f32>,
@location(1) color: vec3<f32>, @location(1) normal: vec3<f32>,
}; };
struct VertexOutput { struct VertexOutput {
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec3<f32>, @location(0) world_normal: vec3<f32>,
}; };
@vertex @vertex
fn vs_main(in: VertexInput) -> VertexOutput { fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
out.clip_position = vec4<f32>(in.position, 1.0); out.clip_pos = camera.view_proj * vec4<f32>(in.position, 1.0);
out.color = in.color; out.world_normal = in.normal;
return out; return out;
} }
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0); // Simple directional light — PSC concrete beige
let light = normalize(vec3<f32>(0.577, 0.577, -0.577));
let n = normalize(in.world_normal);
let diffuse = max(dot(n, light), 0.0);
let ambient = 0.30;
let base_col = vec3<f32>(0.80, 0.76, 0.65); // concrete grey-beige
let col = base_col * (ambient + 0.70 * diffuse);
return vec4<f32>(col, 1.0);
} }