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,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
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]
cimery-ir = { workspace = true }
thiserror = { workspace = true }
cimery-ir = { 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]
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+):
//! - OpenCascade.js (WASM, web)
//! - opencascade-rs (native FFI, desktop)
//! Both accessed via `GeomKernel` trait.
//! Sprint 1: `StubKernel` returns simple box geometry for architecture validation.
//! # Backends (ADR-001)
//! | Backend | Status | Target |
//! |---------|--------|--------|
//! | `StubKernel` | ✅ Sprint 1 | Box mesh — architecture tests |
//! | `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 ─────────────────────────────────────────────────────────────────────
@@ -16,9 +22,9 @@ use cimery_ir::GirderIR;
/// Units: millimetres.
#[derive(Debug, Clone)]
pub struct Mesh {
/// Interleaved [x, y, z] vertex positions in mm.
/// Vertex positions [mm]: vec of [x, y, z].
pub vertices: Vec<[f32; 3]>,
/// Triangle indices into `vertices`, 3 entries per triangle.
/// Triangle indices (3 per triangle).
pub indices: Vec<u32>,
/// Per-vertex normals (unit vectors).
pub normals: Vec<[f32; 3]>,
@@ -48,7 +54,7 @@ impl Mesh {
pub enum KernelError {
#[error("geometry computation failed: {0}")]
Computation(String),
#[error("invalid input for kernel: {0}")]
#[error("invalid kernel input: {0}")]
InvalidInput(String),
}
@@ -57,21 +63,16 @@ pub enum KernelError {
/// Backend-agnostic geometry kernel.
///
/// 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 {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>;
}
// ─── StubKernel ───────────────────────────────────────────────────────────────
/// Stub geometry backend for Sprint 1.
/// Stub geometry backend (Sprint 1).
///
/// Returns a simple rectangular box for any girder.
/// - X = 600 mm (fixed width stub)
/// - Y = 1800 mm (fixed height stub)
/// - Z = girder span in mm
///
/// Replace with `OcctKernel` in Sprint 2.
/// Returns a plain rectangular box for any section type.
/// Used for architecture tests and as a quick fallback.
pub struct StubKernel;
impl GeomKernel for StubKernel {
@@ -81,39 +82,52 @@ impl GeomKernel for StubKernel {
format!("span must be positive, got {} m", ir.span_m()),
));
}
let len = ir.span_mm() as f32;
let w = 600.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![
[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, 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],
];
// 12 triangles (2 per face × 6 faces), CCW winding from outside
let indices: Vec<u32> = vec![
// -Z face
0, 2, 1, 0, 3, 2,
// +Z face
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,
0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7,
0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5,
0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2,
];
let normals = vec![[0.0_f32, 1.0, 0.0]; vertices.len()];
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 ────────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -124,11 +138,11 @@ mod tests {
fn test_girder(span_m: f64) -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start: 0.0,
station_end: span_m,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
id: FeatureId::new(),
station_start: 0.0,
station_end: span_m,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 1,
spacing: 0.0,
@@ -136,6 +150,7 @@ mod tests {
}
}
// ── StubKernel ────────────────────────────────────────────────────────────
#[test]
fn stub_produces_box_mesh() {
let mesh = StubKernel.girder_mesh(&test_girder(40.0)).unwrap();
@@ -144,17 +159,44 @@ mod tests {
}
#[test]
fn aabb_spans_correctly() {
fn stub_aabb_spans_correctly() {
let ir = test_girder(40.0);
let mesh = StubKernel.girder_mesh(&ir).unwrap();
let (mn, mx) = mesh.aabb();
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]
fn zero_span_fails() {
let err = StubKernel.girder_mesh(&test_girder(0.0));
assert!(matches!(err, Err(KernelError::InvalidInput(_))));
fn stub_zero_span_fails() {
assert!(matches!(
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"
bytemuck = { version = "1", features = ["derive"] }
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
//! - Opens a window and renders a coloured triangle (red/green/blue vertices).
//! - Proves the wgpu pipeline, winit event loop, and shader infrastructure work.
//! - No Girder mesh rendering yet — that comes in Sprint 2 after kernel integration.
//! Renders a PSC-I girder mesh (from StubKernel or OcctKernel) with:
//! - Perspective camera (Revit-style orbit: middle-mouse drag + scroll)
//! - Depth buffer
//! - Simple directional lighting from surface normals
//! - Back-face culling
//!
//! # Sprint 2 upgrade path
//! - `CimeryApp::set_mesh(mesh: &cimery_kernel::Mesh)` — replace triangle with real geometry.
//! - Camera orbit (Revit ViewCube pattern).
//! - Depth buffer + back-face culling for solid geometry.
//! # Sprint 3 upgrade path
//! - Swap `StubKernel` → `OcctKernel` once OCCT compiles.
//! - Add ViewCube widget overlay.
//! - Add selection highlight.
pub mod camera;
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
use winit::{
application::ApplicationHandler,
event::{KeyEvent, WindowEvent},
event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::{Window, WindowId},
};
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 ───────────────────────────────────────────────────────────────────
/// Per-vertex data sent to GPU: 3D position + surface normal.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
position: [f32; 3],
color: [f32; 3],
normal: [f32; 3],
}
impl Vertex {
const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
0 => Float32x3, // position
1 => Float32x3, // color
1 => Float32x3, // normal
];
fn desc() -> wgpu::VertexBufferLayout<'static> {
@@ -45,40 +53,46 @@ impl Vertex {
}
}
// Sprint 1 triangle: red top / green left / blue right
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] },
];
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
// ─── RenderState ─────────────────────────────────────────────────────────────
struct RenderState {
window: Arc<Window>,
device: wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration,
render_pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
num_vertices: u32,
window: Arc<Window>,
device: wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration,
render_pipeline: wgpu::RenderPipeline,
// Mesh
vertex_buffer: wgpu::Buffer,
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 {
async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
// ── Instance + surface ────────────────────────────────────────────────
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
// Arc<Window> implements SurfaceTarget, giving Surface<'static>
let surface = instance
.create_surface(Arc::clone(&window))
.expect("create surface");
// ── Adapter + device ──────────────────────────────────────────────────
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
@@ -86,14 +100,14 @@ impl RenderState {
force_fallback_adapter: false,
})
.await
.expect("no suitable GPU adapter found");
.expect("no suitable GPU adapter");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("cimery device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
label: Some("cimery device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
},
None,
@@ -101,81 +115,144 @@ impl RenderState {
.await
.expect("failed to create GPU device");
// ── Surface config ────────────────────────────────────────────────────
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.iter()
.find(|f| f.is_srgb())
.copied()
let format = caps.formats.iter().find(|f| f.is_srgb()).copied()
.unwrap_or(caps.formats[0]);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: caps.present_modes[0],
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
width: size.width.max(1),
height: size.height.max(1),
present_mode: caps.present_modes[0],
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
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 {
label: Some("cimery shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline layout"),
bind_group_layouts: &[],
push_constant_ranges: &[],
},
);
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline layout"),
bind_group_layouts: &[&camera_bgl],
push_constant_ranges: &[],
});
let render_pipeline = device.create_render_pipeline(
&wgpu::RenderPipelineDescriptor {
label: Some("render pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
cache: None,
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("render pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
);
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("triangle vertex buffer"),
contents: bytemuck::cast_slice(TRIANGLE),
usage: wgpu::BufferUsages::VERTEX,
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
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 {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
cache: None,
});
RenderState {
@@ -186,15 +263,57 @@ impl RenderState {
surface_config,
render_pipeline,
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>) {
if new_size.width > 0 && new_size.height > 0 {
self.surface_config.width = new_size.width;
self.surface_config.height = new_size.height;
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 {
label: Some("main render pass"),
label: Some("main pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
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,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: 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,
timestamp_writes: None,
});
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.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()));
output.present();
@@ -233,7 +361,6 @@ impl RenderState {
// ─── CimeryApp ────────────────────────────────────────────────────────────────
/// winit ApplicationHandler for the cimery viewer.
pub struct CimeryApp {
state: Option<RenderState>,
}
@@ -248,38 +375,61 @@ impl Default for CimeryApp {
impl ApplicationHandler for CimeryApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attrs = Window::default_attributes()
.with_title("cimery viewer [Sprint 1]")
let attrs = Window::default_attributes()
.with_title("cimery viewer [Sprint 2 — StubKernel]")
.with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32));
let window = Arc::new(
event_loop.create_window(attrs)
.expect("failed to create window"),
event_loop.create_window(attrs).expect("create window"),
);
let state = pollster::block_on(RenderState::new(Arc::clone(&window)));
self.state = Some(state);
self.state = Some(pollster::block_on(RenderState::new(Arc::clone(&window))));
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
window_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else { return };
if state.window.id() != window_id { return; }
match event {
// ── Exit ──────────────────────────────────────────────────────────
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event: KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::Escape),
..
},
..
physical_key: PhysicalKey::Code(KeyCode::Escape), ..
}, ..
} => 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 => {
match state.render() {
Ok(()) => {}
@@ -288,7 +438,7 @@ impl ApplicationHandler for CimeryApp {
state.resize(sz);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("GPU out of memory — exiting");
log::error!("GPU OOM — exiting");
event_loop.exit();
}
Err(e) => log::warn!("surface error: {:?}", e),
@@ -302,9 +452,9 @@ impl ApplicationHandler for CimeryApp {
// ─── 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() {
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);
let mut app = CimeryApp::new();
event_loop.run_app(&mut app).expect("event loop error");

View File

@@ -1,25 +1,39 @@
// cimery-viewer Sprint 1 shader
// Simple per-vertex colour passthrough.
// cimery-viewer Sprint 2 shader
// Camera MVP + simple directional lighting from surface normals.
struct CameraUniform {
view_proj: mat4x4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>,
@location(1) normal: vec3<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec3<f32>,
@builtin(position) clip_pos: vec4<f32>,
@location(0) world_normal: vec3<f32>,
};
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(in.position, 1.0);
out.color = in.color;
out.clip_pos = camera.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal;
return out;
}
@fragment
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);
}