cimery Sprint 1 — Rust 워크스페이스 + 전 계층 파이프라인

8개 크레이트 구현, cargo test 32개 전부 통과:
- core: Mm/M 단위 newtype, UnitExt 리터럴, FeatureError
- ir: GirderIR + 전 단면 파라미터(PSC-I/U/SteelBox/PlateI) serde JSON
- dsl: Girder builder + 검증 (경간 범위·count·spacing)
- kernel: GeomKernel trait + StubKernel (box mesh, AABB)
- incremental: dirty-tracking IncrementalDb (salsa 업그레이드 경로 주석)
- evaluator: 상태 없는 IR→kernel 브리지
- usd: USDA 1.0 텍스트 익스포트 (CimeryBridgeAPI·GirderAPI schema)
- viewer: wgpu 22 + winit 0.30 컬러 삼각형 (Sprint 1 proof-of-concept)

Sprint 2 다음 단계:
- opencascade-rs로 StubKernel 교체 (실제 PSC-I sweep)
- viewer에서 Girder Mesh 렌더 + 카메라 orbit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 17:46:14 +09:00
parent 919855c1e8
commit 62ddf3aea6
24 changed files with 1779 additions and 3 deletions

3
cimery/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target/
Cargo.lock
.env

53
cimery/CLAUDE.md Normal file
View File

@@ -0,0 +1,53 @@
# cimery/CLAUDE.md
cimery 코드베이스 개발 지침. 최상위 CLAUDE.md + Output/guides/cimery-dev-guide.md의 상세판.
## 크레이트 구조
```
crates/
├── core/ 단위 타입(Mm·M), 에러, 도메인 열거형 ← 모두 의존
├── ir/ IR 구조체 + serde JSON 직렬화 ← dsl·kernel·usd 의존
├── dsl/ Girder 등 Feature 빌더 + 검증 ← ir 의존
├── kernel/ GeomKernel trait + StubKernel ← ir 의존
├── incremental/ 증분 계산 DB (dirty tracking → salsa) ← kernel·ir 의존
├── evaluator/ IR → kernel 연결 레이어 ← incremental·kernel 의존
├── viewer/ wgpu 뷰어 (독립, 선택적 kernel 의존) ← kernel 의존
└── usd/ USD 텍스트 익스포트 ← ir 의존
```
의존 방향: core → ir → { dsl, kernel, usd } → incremental → evaluator
## 개발 규칙
- **단위 강제:** 구조물 `Mm(f64)`, 선형 `M(f64)`. `UnitExt` trait로 리터럴 지원.
- **암묵 변환 금지:** `From<Mm> for M` 존재하나 반드시 명시적으로 호출.
- **결정론:** 같은 IR JSON → 같은 기하. 연산 순서 고정.
- **테스트 4층:** IR 스냅샷 (insta) · 기하 불변량 · 두 커널 cross-check · proptest.
- **StubKernel:** 실제 OCCT 전 기하 검증용. 박스 형태 반환. Sprint 1 전용.
- **cargo check 우선:** 먼저 컴파일되게 만들고, 최적화는 나중에.
## 빌드
```bash
# 전체 체크
cargo check --workspace
# 전체 테스트
cargo test --workspace
# 특정 크레이트
cargo test -p cimery-core
```
## 다음 스프린트 업그레이드 경로
- `incremental`: manual dirty tracking → salsa (크레이트가 안정화되면)
- `kernel`: StubKernel → opencascade-rs (데스크톱) / OpenCascade.js (웹)
- `viewer`: 삼각형 렌더 → Girder Mesh 렌더 → 전체 씬
- `dsl`: 순수 builder → `macro_rules!` 설탕 → proc-macro `#[param]`
## 참조 ADR
- ADR-001: 기술 스택 (Rust + Tauri/PWA, OCCT, wgpu)
- ADR-002: Feature DSL 아키텍처 (builder, IR, salsa, evaluator, 테스트 4층)
- ADR-003: 후속 결정 (Feature 카탈로그, LOD, UX)

50
cimery/Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[workspace]
members = [
"crates/core",
"crates/ir",
"crates/dsl",
"crates/kernel",
"crates/incremental",
"crates/evaluator",
"crates/viewer",
"crates/usd",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["kimminsung"]
license = "MIT OR Apache-2.0"
# ─── Shared dependencies ──────────────────────────────────────────────────────
[workspace.dependencies]
# Internal
cimery-core = { path = "crates/core" }
cimery-ir = { path = "crates/ir" }
cimery-dsl = { path = "crates/dsl" }
cimery-kernel = { path = "crates/kernel" }
cimery-incremental = { path = "crates/incremental" }
cimery-evaluator = { path = "crates/evaluator" }
cimery-usd = { path = "crates/usd" }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Error handling
thiserror = "1"
# ID generation
uuid = { version = "1", features = ["v4", "serde"] }
# Logging
log = "0.4"
env_logger = "0.11"
# ─── Profile tuning ───────────────────────────────────────────────────────────
[profile.dev]
opt-level = 1 # faster incremental builds; better perf for geometry ops
[profile.dev.build-override]
opt-level = 3 # fast proc-macro + bindgen compilation

View File

@@ -0,0 +1,11 @@
[package]
name = "cimery-core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -0,0 +1,140 @@
//! cimery-core — unit system, errors, domain enums.
//!
//! ADR-002 rule: all structural dimensions in `Mm`, alignment/road in `M`.
//! Conversions MUST be explicit via `From`/`Into`. Never implicit.
use serde::{Deserialize, Serialize};
// ─── Unit types ──────────────────────────────────────────────────────────────
/// Millimetres — all structural dimensions (flange thickness, web, height, spacing…).
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Mm(pub f64);
/// Metres — alignment stations, road offsets.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct M(pub f64);
impl Mm {
#[inline] pub fn value(self) -> f64 { self.0 }
#[inline] pub fn new(v: f64) -> Self { Self(v) }
}
impl M {
#[inline] pub fn value(self) -> f64 { self.0 }
#[inline] pub fn new(v: f64) -> Self { Self(v) }
}
// Explicit conversions only — boundary must be visible in code.
impl From<Mm> for M { fn from(mm: Mm) -> M { M(mm.0 / 1000.0) } }
impl From<M> for Mm { fn from(m: M) -> Mm { Mm(m.0 * 1000.0) } }
impl std::fmt::Display for Mm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} mm", self.0)
}
}
impl std::fmt::Display for M {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} m", self.0)
}
}
/// Literal helpers: `1800.0_f64.mm()`, `40.0_f64.m()`
pub trait UnitExt: Sized {
fn mm(self) -> Mm;
fn m(self) -> M;
}
macro_rules! impl_unit_ext {
($($t:ty),*) => {
$(impl UnitExt for $t {
fn mm(self) -> Mm { Mm(self as f64) }
fn m(self) -> M { M(self as f64) }
})*
};
}
impl_unit_ext!(f64, f32, i32, i64, u32, u64);
// ─── Errors ──────────────────────────────────────────────────────────────────
/// All Feature build/validation errors.
#[derive(Debug, thiserror::Error)]
pub enum FeatureError {
/// A field value violated a constraint.
#[error("{path}: {message}")]
Validation { path: String, message: String },
/// A required field was not supplied.
#[error("{path}: required field is missing")]
MissingField { path: String },
/// Geometry generation failed.
#[error("geometry error: {0}")]
Geometry(String),
}
impl FeatureError {
pub fn validation(path: impl Into<String>, msg: impl Into<String>) -> Self {
Self::Validation { path: path.into(), message: msg.into() }
}
pub fn missing(path: impl Into<String>) -> Self {
Self::MissingField { path: path.into() }
}
}
// ─── Domain enums ─────────────────────────────────────────────────────────────
/// Girder cross-section family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SectionType {
PscI,
PscU,
SteelBox,
SteelPlateI,
}
/// Structural material grade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MaterialGrade {
C40,
C50,
Ss400,
Sm490,
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_conversions_are_explicit() {
let mm = Mm(1000.0);
let m: M = mm.into();
assert!((m.value() - 1.0).abs() < f64::EPSILON);
let m2 = M(0.5);
let mm2: Mm = m2.into();
assert!((mm2.value() - 500.0).abs() < f64::EPSILON);
}
#[test]
fn unit_literals() {
let h = 1800.0.mm();
let sp = 40.0.m();
assert_eq!(h.value(), 1800.0);
assert_eq!(sp.value(), 40.0);
}
#[test]
fn serde_roundtrip() {
let mm: Mm = 1234.5.mm();
let json = serde_json::to_string(&mm).unwrap();
let mm2: Mm = serde_json::from_str(&json).unwrap();
assert!((mm.value() - mm2.value()).abs() < f64::EPSILON);
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "cimery-dsl"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-core = { workspace = true }
cimery-ir = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -0,0 +1,332 @@
//! Girder Feature builder.
use cimery_core::{FeatureError, MaterialGrade, Mm, M, SectionType, UnitExt};
use cimery_ir::{
FeatureId, GirderIR, PscISectionParams, SteelBoxParams, SectionParams,
};
// ─── Girder ───────────────────────────────────────────────────────────────────
/// A resolved Girder Feature — immutable after construction.
///
/// Build with [`GirderBuilder`]:
/// ```rust
/// use cimery_core::{MaterialGrade, UnitExt};
/// use cimery_dsl::Girder;
///
/// let g = Girder::builder()
/// .station_start(100.0.m())
/// .station_end(140.0.m())
/// .section_psc_i_default()
/// .count(5)
/// .spacing(2500.0.mm())
/// .material(MaterialGrade::C50)
/// .build()
/// .expect("valid girder");
///
/// assert!((g.ir.span_m() - 40.0).abs() < f64::EPSILON);
/// ```
#[derive(Debug)]
pub struct Girder {
pub ir: GirderIR,
}
impl Girder {
pub fn builder() -> GirderBuilder {
GirderBuilder::default()
}
}
// ─── GirderBuilder ────────────────────────────────────────────────────────────
/// Builder for a [`Girder`] Feature.
///
/// Every `set_*` method returns `Self` for chaining.
/// Call [`GirderBuilder::build`] to validate and produce a [`Girder`].
///
/// **Defaults (when not set):**
/// - `count` = 1
/// - `spacing` = 2500 mm
/// - `material` = C50
/// - `offset` = 0 mm
///
/// **Validation rules:**
/// - `station_start`, `station_end`, `section` are required.
/// - `station_end > station_start`.
/// - Span in practical range [5 m, 120 m].
/// - `count ≥ 1`; `spacing > 0` when `count > 1`.
#[derive(Default)]
pub struct GirderBuilder {
station_start: Option<M>,
station_end: Option<M>,
offset: Option<Mm>,
section: Option<SectionParams>,
section_type: Option<SectionType>,
count: Option<u32>,
spacing: Option<Mm>,
material: Option<MaterialGrade>,
}
impl GirderBuilder {
// ── Alignment ──────────────────────────────────────────────────────────
/// #[param(unit="m", range=0.0..=100_000.0)] station along alignment
pub fn station_start(mut self, v: M) -> Self {
self.station_start = Some(v); self
}
/// #[param(unit="m", range=0.0..=100_000.0)] station along alignment
pub fn station_end(mut self, v: M) -> Self {
self.station_end = Some(v); self
}
/// #[param(unit="mm", range=-10_000.0..=10_000.0, default=0.0)]
/// Lateral offset from alignment centreline. Positive = left.
pub fn offset(mut self, v: Mm) -> Self {
self.offset = Some(v); self
}
// ── Section ────────────────────────────────────────────────────────────
/// PSC I-girder section with explicit parameters.
pub fn section_psc_i(mut self, p: PscISectionParams) -> Self {
self.section = Some(SectionParams::PscI(p));
self.section_type = Some(SectionType::PscI);
self
}
/// PSC I-girder with KDS standard defaults (40 m span).
pub fn section_psc_i_default(self) -> Self {
self.section_psc_i(PscISectionParams::kds_standard())
}
/// Steel box girder.
pub fn section_steel_box(mut self, p: SteelBoxParams) -> Self {
self.section = Some(SectionParams::SteelBox(p));
self.section_type = Some(SectionType::SteelBox);
self
}
// ── Placement ──────────────────────────────────────────────────────────
/// #[param(unit="count", range=1..=20, default=1)]
pub fn count(mut self, n: u32) -> Self {
self.count = Some(n); self
}
/// #[param(unit="mm", range=1500.0..=4000.0, default=2500.0)]
/// Centre-to-centre girder spacing.
pub fn spacing(mut self, v: Mm) -> Self {
self.spacing = Some(v); self
}
/// #[param(enum=MaterialGrade, default=C50)]
pub fn material(mut self, m: MaterialGrade) -> Self {
self.material = Some(m); self
}
// ── Build ──────────────────────────────────────────────────────────────
/// Validate and produce a [`Girder`].
pub fn build(self) -> Result<Girder, FeatureError> {
// Required fields
let station_start = self.station_start
.ok_or_else(|| FeatureError::missing("girder.station_start"))?
.value();
let station_end = self.station_end
.ok_or_else(|| FeatureError::missing("girder.station_end"))?
.value();
let section = self.section
.ok_or_else(|| FeatureError::missing("girder.section"))?;
let section_type = self.section_type
.ok_or_else(|| FeatureError::missing("girder.section_type"))?;
// Span validation
if station_end <= station_start {
return Err(FeatureError::validation(
"girder.station_end",
format!(
"must be > station_start ({:.3} m); got {:.3} m",
station_start, station_end
),
));
}
let span = station_end - station_start;
if span < 5.0 {
return Err(FeatureError::validation(
"girder.span",
format!("span {:.1} m is below minimum 5 m", span),
));
}
if span > 120.0 {
return Err(FeatureError::validation(
"girder.span",
format!("span {:.1} m exceeds maximum 120 m", span),
));
}
// Count / spacing
let count = self.count.unwrap_or(1);
if count == 0 {
return Err(FeatureError::validation(
"girder.count",
"must be ≥ 1",
));
}
let spacing = self.spacing.unwrap_or(2500.0.mm()).value();
if count > 1 && spacing <= 0.0 {
return Err(FeatureError::validation(
"girder.spacing",
"spacing must be > 0 mm when count > 1",
));
}
Ok(Girder {
ir: GirderIR {
id: FeatureId::new(),
station_start,
station_end,
offset_from_alignment: self.offset.unwrap_or(0.0.mm()).value(),
section_type,
section,
count,
spacing,
material: self.material.unwrap_or(MaterialGrade::C50),
},
})
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, UnitExt};
use cimery_ir::SteelBoxParams;
fn psc_i_girder() -> Result<Girder, FeatureError> {
Girder::builder()
.station_start(100.0.m())
.station_end(140.0.m())
.section_psc_i_default()
.count(5)
.spacing(2500.0.mm())
.material(MaterialGrade::C50)
.build()
}
#[test]
fn build_valid_psc_i() {
let g = psc_i_girder().unwrap();
assert!((g.ir.span_m() - 40.0).abs() < f64::EPSILON);
assert_eq!(g.ir.count, 5);
assert_eq!(g.ir.material, MaterialGrade::C50);
}
#[test]
fn build_valid_steel_box() {
let g = Girder::builder()
.station_start(0.0.m())
.station_end(50.0.m())
.section_steel_box(SteelBoxParams {
total_height: 2000.0,
top_width: 3000.0,
bottom_width: 2000.0,
web_thickness: 18.0,
top_flange_thickness: 24.0,
bottom_flange_thickness: 20.0,
})
.build()
.unwrap();
assert!((g.ir.span_m() - 50.0).abs() < f64::EPSILON);
assert_eq!(g.ir.section_type, SectionType::SteelBox);
}
#[test]
fn missing_station_start_fails() {
let e = Girder::builder()
.station_end(40.0.m())
.section_psc_i_default()
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::MissingField { ref path } if path == "girder.station_start"));
}
#[test]
fn missing_section_fails() {
let e = Girder::builder()
.station_start(0.0.m())
.station_end(40.0.m())
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::MissingField { .. }));
}
#[test]
fn zero_span_fails() {
let e = Girder::builder()
.station_start(100.0.m())
.station_end(100.0.m())
.section_psc_i_default()
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::Validation { ref path, .. } if path == "girder.station_end"));
}
#[test]
fn span_too_short_fails() {
let e = Girder::builder()
.station_start(0.0.m())
.station_end(3.0.m()) // 3 m < 5 m minimum
.section_psc_i_default()
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::Validation { ref path, .. } if path == "girder.span"));
}
#[test]
fn span_too_long_fails() {
let e = Girder::builder()
.station_start(0.0.m())
.station_end(150.0.m()) // 150 m > 120 m maximum
.section_psc_i_default()
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::Validation { ref path, .. } if path == "girder.span"));
}
#[test]
fn zero_count_fails() {
let e = Girder::builder()
.station_start(0.0.m())
.station_end(40.0.m())
.section_psc_i_default()
.count(0)
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::Validation { ref path, .. } if path == "girder.count"));
}
#[test]
fn multi_girder_requires_spacing() {
let e = Girder::builder()
.station_start(0.0.m())
.station_end(40.0.m())
.section_psc_i_default()
.count(3)
.spacing(0.0.mm())
.build()
.unwrap_err();
assert!(matches!(e, FeatureError::Validation { ref path, .. } if path == "girder.spacing"));
}
#[test]
fn ir_serializes_to_json() {
let g = psc_i_girder().unwrap();
let json = serde_json::to_string_pretty(&g.ir).unwrap();
let ir2: cimery_ir::GirderIR = serde_json::from_str(&json).unwrap();
assert!((g.ir.span_m() - ir2.span_m()).abs() < f64::EPSILON);
assert_eq!(g.ir.count, ir2.count);
}
}

View File

@@ -0,0 +1,9 @@
//! cimery-dsl — fluent builder DSL for cimery features.
//!
//! # Design
//! Pure builder pattern, no proc macros (ADR-002 J).
//! Comments starting with `/// #[param(...)` are documentation hints
//! for a future macro-upgrade path and do not affect compilation.
pub mod girder;
pub use girder::{Girder, GirderBuilder};

View File

@@ -0,0 +1,12 @@
[package]
name = "cimery-evaluator"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-ir = { workspace = true }
cimery-kernel = { workspace = true }
cimery-incremental = { workspace = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -0,0 +1,81 @@
//! cimery-evaluator — IR → geometry kernel bridge.
//!
//! Stateless wrapper that calls a `GeomKernel` on demand.
//! For cached/incremental evaluation use [`cimery_incremental::IncrementalDb`].
use cimery_ir::GirderIR;
use cimery_kernel::{GeomKernel, KernelError, Mesh};
// ─── Evaluator ────────────────────────────────────────────────────────────────
/// Stateless bridge between IR and geometry kernel.
///
/// Use [`IncrementalDb`](cimery_incremental::IncrementalDb) for cached evaluation.
/// Use `Evaluator` when you need a one-shot compute without caching.
pub struct Evaluator<K: GeomKernel> {
pub kernel: K,
}
impl<K: GeomKernel> Evaluator<K> {
pub fn new(kernel: K) -> Self { Self { kernel } }
/// Compute the mesh for a Girder IR. No caching.
pub fn eval_girder(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
self.kernel.girder_mesh(ir)
}
// Future Sprint 2: eval_pier, eval_bearing, eval_deck, etc.
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
use cimery_kernel::StubKernel;
fn test_ir() -> GirderIR {
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,
}
}
#[test]
fn eval_with_stub_kernel() {
let eval = Evaluator::new(StubKernel);
let mesh = eval.eval_girder(&test_ir()).unwrap();
assert!(mesh.triangle_count() > 0);
assert!(mesh.vertex_count() > 0);
}
#[test]
fn eval_matches_direct_kernel_call() {
let ir = test_ir();
let eval = Evaluator::new(StubKernel);
let via_eval = eval.eval_girder(&ir).unwrap();
let via_direct = StubKernel.girder_mesh(&ir).unwrap();
assert_eq!(via_eval.triangle_count(), via_direct.triangle_count());
assert_eq!(via_eval.vertex_count(), via_direct.vertex_count());
}
#[test]
fn aabb_z_extent_equals_span() {
let ir = test_ir();
let eval = Evaluator::new(StubKernel);
let mesh = eval.eval_girder(&ir).unwrap();
let (mn, mx) = mesh.aabb();
let z_extent = mx[2] - mn[2];
assert!((z_extent - ir.span_mm() as f32).abs() < 0.1);
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "cimery-incremental"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-ir = { workspace = true }
cimery-kernel = { workspace = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -0,0 +1,176 @@
//! cimery-incremental — incremental computation layer.
//!
//! ## Sprint 1: manual dirty-tracking
//!
//! Uses a `HashMap` cache + `HashSet<FeatureId>` dirty set.
//! Query granularity: **Feature-level** (one dirty entry per Feature instance).
//!
//! ## Sprint 2 upgrade: salsa
//!
//! Will be replaced by [salsa](https://github.com/salsa-rs/salsa)-based queries
//! once the API is confirmed stable for both WASM (web) and native (desktop)
//! targets (ADR-002 D). Key design intent preserved:
//! - Feature unit = salsa query granularity.
//! - Lazy/reactive: only invalidated features recompute (ADR-002 B).
//! - Cache is keyed by `FeatureId`; invalidation is triggered by `set_*` calls.
use cimery_ir::{FeatureId, GirderIR};
use cimery_kernel::{GeomKernel, KernelError, Mesh};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
// ─── IncrementalDb ────────────────────────────────────────────────────────────
/// Incremental computation database.
///
/// Holds one geometry kernel and one cache per Feature type.
/// In Sprint 2 this becomes a salsa `Database` impl.
pub struct IncrementalDb<K: GeomKernel> {
kernel: Arc<K>,
girders: HashMap<FeatureId, GirderIR>,
mesh_cache: HashMap<FeatureId, Arc<Mesh>>,
dirty: HashSet<FeatureId>,
}
impl<K: GeomKernel> IncrementalDb<K> {
pub fn new(kernel: K) -> Self {
Self {
kernel: Arc::new(kernel),
girders: HashMap::new(),
mesh_cache: HashMap::new(),
dirty: HashSet::new(),
}
}
// ── Writers ────────────────────────────────────────────────────────────
/// Insert or update a Girder. Marks the feature dirty and evicts mesh cache.
pub fn set_girder(&mut self, ir: GirderIR) {
let id = ir.id;
self.girders.insert(id, ir);
self.mesh_cache.remove(&id);
self.dirty.insert(id);
}
// ── Readers ────────────────────────────────────────────────────────────
/// Query the mesh for a Girder.
///
/// - Cache hit (not dirty) → returns `Arc<Mesh>` without recomputation.
/// - Cache miss or dirty → calls kernel, updates cache, clears dirty.
pub fn girder_mesh(
&mut self,
id: &FeatureId,
) -> Result<Arc<Mesh>, KernelError> {
// Cache hit path (not dirty)
if !self.dirty.contains(id) {
if let Some(cached) = self.mesh_cache.get(id) {
return Ok(Arc::clone(cached));
}
}
// Compute path
let ir = self.girders.get(id).ok_or_else(|| {
KernelError::InvalidInput(format!("unknown FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.girder_mesh(ir)?);
self.mesh_cache.insert(*id, Arc::clone(&mesh));
self.dirty.remove(id);
Ok(mesh)
}
/// Raw IR lookup (no computation).
pub fn get_girder(&self, id: &FeatureId) -> Option<&GirderIR> {
self.girders.get(id)
}
// ── Status ─────────────────────────────────────────────────────────────
/// Number of Features awaiting recomputation.
pub fn dirty_count(&self) -> usize { self.dirty.len() }
/// Total number of stored Girder Features.
pub fn girder_count(&self) -> usize { self.girders.len() }
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
use cimery_kernel::StubKernel;
fn make_girder(station_start: f64, station_end: f64) -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start,
station_end,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 1,
spacing: 0.0,
material: MaterialGrade::C50,
}
}
#[test]
fn dirty_after_set() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
db.set_girder(ir);
assert_eq!(db.dirty_count(), 1);
assert_eq!(db.girder_count(), 1);
assert!(db.get_girder(&id).is_some());
}
#[test]
fn clean_after_compute() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
db.set_girder(ir);
db.girder_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
}
#[test]
fn cache_hit_on_second_call() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
db.set_girder(ir);
let m1 = db.girder_mesh(&id).unwrap();
let m2 = db.girder_mesh(&id).unwrap(); // must be same Arc
assert!(Arc::ptr_eq(&m1, &m2));
}
#[test]
fn invalidation_on_update() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
db.set_girder(ir.clone());
db.girder_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
// Update the same girder (longer span)
let mut ir2 = ir;
ir2.station_end = 50.0;
db.set_girder(ir2);
assert_eq!(db.dirty_count(), 1); // re-dirtied
}
#[test]
fn unknown_id_returns_error() {
let mut db = IncrementalDb::new(StubKernel);
let missing_id = FeatureId::new();
let err = db.girder_mesh(&missing_id);
assert!(matches!(err, Err(KernelError::InvalidInput(_))));
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "cimery-ir"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

185
cimery/crates/ir/src/lib.rs Normal file
View File

@@ -0,0 +1,185 @@
//! cimery-ir — deterministic intermediate representation.
//!
//! Rules:
//! - Structural values stored as raw `f64` in **mm**.
//! - Alignment values stored as raw `f64` in **m**.
//! - Fully serializable: JSON ↔ IR round-trip is lossless.
//! - Serde field ordering is deterministic (struct field declaration order).
use cimery_core::{MaterialGrade, SectionType};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ─── Feature identity ─────────────────────────────────────────────────────────
/// Stable identifier for a Feature instance across changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FeatureId(pub Uuid);
impl FeatureId {
pub fn new() -> Self { Self(Uuid::new_v4()) }
}
impl Default for FeatureId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for FeatureId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// ─── Girder IR ────────────────────────────────────────────────────────────────
/// Fully-resolved Girder specification ready for geometry kernel invocation.
///
/// All values are raw primitives — no unit types here (kernel doesn't need them).
/// Convention: linear = metres, structural = millimetres.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GirderIR {
pub id: FeatureId,
/// Station along alignment [m].
pub station_start: f64,
/// Station along alignment [m].
pub station_end: f64,
/// Lateral offset from alignment centreline [mm]. Positive = left.
pub offset_from_alignment: f64,
pub section_type: SectionType,
pub section: SectionParams,
/// Number of girders placed side by side.
pub count: u32,
/// Centre-to-centre spacing between girders [mm].
pub spacing: f64,
pub material: MaterialGrade,
}
impl GirderIR {
/// Span length in metres.
pub fn span_m(&self) -> f64 { self.station_end - self.station_start }
/// Span length in millimetres.
pub fn span_mm(&self) -> f64 { self.span_m() * 1_000.0 }
}
// ─── Section params ───────────────────────────────────────────────────────────
/// Cross-section geometry. Tag is the section type name.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum SectionParams {
PscI(PscISectionParams),
PscU(PscUSectionParams),
SteelBox(SteelBoxParams),
SteelPlateI(SteelPlateIParams),
}
/// PSC I-girder section dimensions [all mm].
///
/// #[param(unit="mm", range=1200..=3000, default=1800)] total_height
/// #[param(unit="mm", range=400..=800, default=600)] top_flange_width
/// #[param(unit="mm", range=100..=300, default=150)] top_flange_thickness
/// #[param(unit="mm", range=400..=900, default=700)] bottom_flange_width
/// #[param(unit="mm", range=120..=300, default=180)] bottom_flange_thickness
/// #[param(unit="mm", range=150..=350, default=200)] web_thickness
/// #[param(unit="mm", range=0..=100, default=50)] haunch
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PscISectionParams {
pub total_height: f64,
pub top_flange_width: f64,
pub top_flange_thickness: f64,
pub bottom_flange_width: f64,
pub bottom_flange_thickness:f64,
pub web_thickness: f64,
pub haunch: f64,
}
impl PscISectionParams {
/// Korean KDS standard I-girder, 40 m span.
pub fn kds_standard() -> Self {
Self {
total_height: 1800.0,
top_flange_width: 600.0,
top_flange_thickness: 150.0,
bottom_flange_width: 700.0,
bottom_flange_thickness: 180.0,
web_thickness: 200.0,
haunch: 50.0,
}
}
}
/// PSC U-girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PscUSectionParams {
pub total_height: f64,
pub top_width: f64,
pub bottom_width: f64,
pub web_thickness: f64,
pub flange_thickness: f64,
}
/// Steel box girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SteelBoxParams {
pub total_height: f64,
pub top_width: f64,
pub bottom_width: f64,
pub web_thickness: f64,
pub top_flange_thickness: f64,
pub bottom_flange_thickness:f64,
}
/// Steel plate I-girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SteelPlateIParams {
pub total_height: f64,
pub flange_width: f64,
pub flange_thickness: f64,
pub web_thickness: f64,
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
pub fn sample_girder() -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start: 100.0,
station_end: 140.0,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 5,
spacing: 2500.0,
material: MaterialGrade::C50,
}
}
#[test]
fn span_lengths() {
let g = sample_girder();
assert!((g.span_m() - 40.0).abs() < f64::EPSILON);
assert!((g.span_mm() - 40_000.0).abs() < f64::EPSILON);
}
#[test]
fn json_roundtrip() {
let g = sample_girder();
let json = serde_json::to_string_pretty(&g).unwrap();
let g2: GirderIR = serde_json::from_str(&json).unwrap();
assert!((g.span_m() - g2.span_m()).abs() < f64::EPSILON);
assert_eq!(g.count, g2.count);
assert_eq!(g.section_type, g2.section_type);
}
#[test]
fn feature_id_is_unique() {
let a = FeatureId::new();
let b = FeatureId::new();
assert_ne!(a, b);
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "cimery-kernel"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-ir = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -0,0 +1,160 @@
//! cimery-kernel — GeomKernel trait + mesh types + StubKernel.
//!
//! 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.
use cimery_ir::GirderIR;
// ─── Mesh ─────────────────────────────────────────────────────────────────────
/// Triangle mesh produced by a geometry kernel.
///
/// Coordinate convention: X = width, Y = height, Z = along-span axis.
/// Units: millimetres.
#[derive(Debug, Clone)]
pub struct Mesh {
/// Interleaved [x, y, z] vertex positions in mm.
pub vertices: Vec<[f32; 3]>,
/// Triangle indices into `vertices`, 3 entries per triangle.
pub indices: Vec<u32>,
/// Per-vertex normals (unit vectors).
pub normals: Vec<[f32; 3]>,
}
impl Mesh {
pub fn vertex_count(&self) -> usize { self.vertices.len() }
pub fn triangle_count(&self) -> usize { self.indices.len() / 3 }
/// Axis-aligned bounding box: ([min_x, min_y, min_z], [max_x, max_y, max_z])
pub fn aabb(&self) -> ([f32; 3], [f32; 3]) {
let mut mn = [f32::INFINITY; 3];
let mut mx = [f32::NEG_INFINITY; 3];
for v in &self.vertices {
for i in 0..3 {
if v[i] < mn[i] { mn[i] = v[i]; }
if v[i] > mx[i] { mx[i] = v[i]; }
}
}
(mn, mx)
}
}
// ─── Error ────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum KernelError {
#[error("geometry computation failed: {0}")]
Computation(String),
#[error("invalid input for kernel: {0}")]
InvalidInput(String),
}
// ─── Trait ────────────────────────────────────────────────────────────────────
/// 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.
///
/// 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.
pub struct StubKernel;
impl GeomKernel for StubKernel {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
if ir.span_m() <= 0.0 {
return Err(KernelError::InvalidInput(
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],
];
// 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,
];
let normals = vec![[0.0_f32, 1.0, 0.0]; vertices.len()];
Ok(Mesh { vertices, indices, normals })
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
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,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 1,
spacing: 0.0,
material: MaterialGrade::C50,
}
}
#[test]
fn stub_produces_box_mesh() {
let mesh = StubKernel.girder_mesh(&test_girder(40.0)).unwrap();
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.triangle_count(), 12);
}
#[test]
fn 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
}
#[test]
fn zero_span_fails() {
let err = StubKernel.girder_mesh(&test_girder(0.0));
assert!(matches!(err, Err(KernelError::InvalidInput(_))));
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "cimery-usd"
version.workspace = true
edition.workspace = true
[dependencies]
cimery-ir = { workspace = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -0,0 +1,151 @@
//! cimery-usd — USDA 1.0 text export.
//!
//! Sprint 1: stub geometry (box) — real B-rep export follows in Sprint 2
//! after `GeomKernel` backends produce STEP/BREP that can be tessellated.
//!
//! ADR-002 O / ADR-003 A4:
//! - USD is the *output format*, not a DSL.
//! - All cimery-specific concepts are captured as Applied API schemas
//! (`CimeryBridgeAPI`, `CimeryGirderAPI`) using the codeless USD schema approach.
//! - IFC alias double-tagging planned for Sprint 3 (AOUSD AECO spec alignment).
use cimery_ir::GirderIR;
// ─── Single girder export ─────────────────────────────────────────────────────
/// Export one [`GirderIR`] to a self-contained USDA 1.0 string.
///
/// Sprint 1: stub box geometry (600 × 1800 × span_mm).
/// The real geometry path is: IR → Evaluator → Mesh → USD Mesh prim.
pub fn girder_to_usda(ir: &GirderIR) -> String {
let id_str = safe_id(ir);
let pts = box_points(ir.span_mm() as f32);
let mut s = String::with_capacity(1024);
s.push_str(USDA_HEADER);
s.push_str("def Xform \"Bridge\" (\n");
s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n");
s.push_str(")\n{\n");
write_girder_prim(&mut s, ir, &id_str, &pts);
s.push_str("}\n");
s
}
/// Export multiple girders to a single USDA file under one Bridge prim.
pub fn girders_to_usda(girders: &[GirderIR]) -> String {
let mut s = String::with_capacity(girders.len() * 512 + 256);
s.push_str(USDA_HEADER);
s.push_str("def Xform \"Bridge\" (\n");
s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n");
s.push_str(")\n{\n");
for ir in girders {
let id_str = safe_id(ir);
let pts = box_points(ir.span_mm() as f32);
write_girder_prim(&mut s, ir, &id_str, &pts);
}
s.push_str("}\n");
s
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
fn safe_id(ir: &GirderIR) -> String {
ir.id.to_string().replace('-', "_")
}
fn box_points(span: f32) -> String {
format!(
"(0 0 0) (600 0 0) (600 1800 0) (0 1800 0) \
(0 0 {s}) (600 0 {s}) (600 1800 {s}) (0 1800 {s})",
s = span,
)
}
const USDA_HEADER: &str =
"#usda 1.0\n(\n metersPerUnit = 0.001\n upAxis = \"Y\"\n)\n\n";
fn write_girder_prim(s: &mut String, ir: &GirderIR, id_str: &str, pts: &str) {
s.push_str(&format!(" def Xform \"Girder_{}\" (\n", id_str));
s.push_str(" apiSchemas = [\"CimeryGirderAPI\"]\n");
s.push_str(" ) {\n");
s.push_str(&format!(
" custom float cimery:stationStart = {}\n", ir.station_start
));
s.push_str(&format!(
" custom float cimery:stationEnd = {}\n", ir.station_end
));
s.push_str(&format!(" custom int cimery:count = {}\n", ir.count));
s.push_str(&format!(
" custom token cimery:sectionType = \"{:?}\"\n", ir.section_type
));
s.push_str(&format!(
" custom token cimery:material = \"{:?}\"\n", ir.material
));
s.push('\n');
s.push_str(" def Mesh \"geometry\" {\n");
s.push_str(
" int[] faceVertexCounts = \
[3 3 3 3 3 3 3 3 3 3 3 3]\n"
);
s.push_str(
" int[] faceVertexIndices = \
[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]\n"
);
s.push_str(&format!(
" point3f[] points = [{}]\n", pts
));
s.push_str(" }\n");
s.push_str(" }\n");
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
fn sample() -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start: 100.0,
station_end: 140.0,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 5,
spacing: 2500.0,
material: MaterialGrade::C50,
}
}
#[test]
fn usda_header_present() {
let s = girder_to_usda(&sample());
assert!(s.starts_with("#usda 1.0"), "must start with USDA header");
}
#[test]
fn contains_bridge_api() {
let s = girder_to_usda(&sample());
assert!(s.contains("CimeryBridgeAPI"));
assert!(s.contains("CimeryGirderAPI"));
}
#[test]
fn contains_station_values() {
let s = girder_to_usda(&sample());
assert!(s.contains("100"), "should contain station_start");
assert!(s.contains("140"), "should contain station_end");
}
#[test]
fn multiple_girders() {
let girders = vec![sample(), sample()];
let s = girders_to_usda(&girders);
assert!(s.contains("CimeryBridgeAPI"));
// Two distinct prim blocks
assert_eq!(s.matches("CimeryGirderAPI").count(), 2);
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "cimery-viewer"
version.workspace = true
edition.workspace = true
[[bin]]
name = "cimery-viewer"
path = "src/main.rs"
[dependencies]
cimery-kernel = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
wgpu = "22"
winit = "0.30"
bytemuck = { version = "1", features = ["derive"] }
pollster = "0.3"

View File

@@ -0,0 +1,311 @@
//! cimery-viewer — wgpu + winit viewer.
//!
//! # 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.
//!
//! # 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.
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
use winit::{
application::ApplicationHandler,
event::{KeyEvent, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::{Window, WindowId},
};
use wgpu::util::DeviceExt;
// ─── Vertex ───────────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
position: [f32; 3],
color: [f32; 3],
}
impl Vertex {
const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
0 => Float32x3, // position
1 => Float32x3, // color
];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
// 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] },
];
// ─── 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,
}
impl RenderState {
async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
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");
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.expect("no suitable GPU adapter found");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("cimery device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
},
None,
)
.await
.expect("failed to create GPU device");
let caps = surface.get_capabilities(&adapter);
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,
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![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &surface_config);
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 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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("triangle vertex buffer"),
contents: bytemuck::cast_slice(TRIANGLE),
usage: wgpu::BufferUsages::VERTEX,
});
RenderState {
window,
device,
queue,
surface,
surface_config,
render_pipeline,
vertex_buffer,
num_vertices: TRIANGLE.len() as u32,
}
}
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);
}
}
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
let output = self.surface.get_current_texture()?;
let view = output.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("render encoder"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main render 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,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
rp.set_pipeline(&self.render_pipeline);
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rp.draw(0..self.num_vertices, 0..1);
}
self.queue.submit(std::iter::once(enc.finish()));
output.present();
Ok(())
}
}
// ─── CimeryApp ────────────────────────────────────────────────────────────────
/// winit ApplicationHandler for the cimery viewer.
pub struct CimeryApp {
state: Option<RenderState>,
}
impl CimeryApp {
pub fn new() -> Self { Self { state: None } }
}
impl Default for CimeryApp {
fn default() -> Self { Self::new() }
}
impl ApplicationHandler for CimeryApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attrs = Window::default_attributes()
.with_title("cimery viewer [Sprint 1]")
.with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32));
let window = Arc::new(
event_loop.create_window(attrs)
.expect("failed to create window"),
);
let state = pollster::block_on(RenderState::new(Arc::clone(&window)));
self.state = Some(state);
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else { return };
if state.window.id() != window_id { return; }
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event: KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::Escape),
..
},
..
} => event_loop.exit(),
WindowEvent::Resized(size) => state.resize(size),
WindowEvent::RedrawRequested => {
match state.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let sz = state.window.inner_size();
state.resize(sz);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("GPU out of memory — exiting");
event_loop.exit();
}
Err(e) => log::warn!("surface error: {:?}", e),
}
state.window.request_redraw();
}
_ => {}
}
}
}
// ─── Entry point ─────────────────────────────────────────────────────────────
/// Run the cimery viewer event loop. Blocks until the window is closed.
pub fn run_viewer() {
let event_loop = EventLoop::new().expect("failed to 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

@@ -0,0 +1,4 @@
fn main() {
env_logger::init();
cimery_viewer::run_viewer();
}

View File

@@ -0,0 +1,25 @@
// cimery-viewer Sprint 1 shader
// Simple per-vertex colour passthrough.
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: 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;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}