From 62ddf3aea6dea22a36a74898c88d41b3621d58fa Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 14 Apr 2026 17:46:14 +0900 Subject: [PATCH] =?UTF-8?q?cimery=20Sprint=201=20=E2=80=94=20Rust=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20+=20?= =?UTF-8?q?=EC=A0=84=20=EA=B3=84=EC=B8=B5=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PLAN.md | 4 +- PROGRESS.md | 5 +- cimery/.gitignore | 3 + cimery/CLAUDE.md | 53 +++++ cimery/Cargo.toml | 50 ++++ cimery/crates/core/Cargo.toml | 11 + cimery/crates/core/src/lib.rs | 140 +++++++++++ cimery/crates/dsl/Cargo.toml | 11 + cimery/crates/dsl/src/girder.rs | 332 +++++++++++++++++++++++++++ cimery/crates/dsl/src/lib.rs | 9 + cimery/crates/evaluator/Cargo.toml | 12 + cimery/crates/evaluator/src/lib.rs | 81 +++++++ cimery/crates/incremental/Cargo.toml | 11 + cimery/crates/incremental/src/lib.rs | 176 ++++++++++++++ cimery/crates/ir/Cargo.toml | 10 + cimery/crates/ir/src/lib.rs | 185 +++++++++++++++ cimery/crates/kernel/Cargo.toml | 11 + cimery/crates/kernel/src/lib.rs | 160 +++++++++++++ cimery/crates/usd/Cargo.toml | 10 + cimery/crates/usd/src/lib.rs | 151 ++++++++++++ cimery/crates/viewer/Cargo.toml | 17 ++ cimery/crates/viewer/src/lib.rs | 311 +++++++++++++++++++++++++ cimery/crates/viewer/src/main.rs | 4 + cimery/crates/viewer/src/shader.wgsl | 25 ++ 24 files changed, 1779 insertions(+), 3 deletions(-) create mode 100644 cimery/.gitignore create mode 100644 cimery/CLAUDE.md create mode 100644 cimery/Cargo.toml create mode 100644 cimery/crates/core/Cargo.toml create mode 100644 cimery/crates/core/src/lib.rs create mode 100644 cimery/crates/dsl/Cargo.toml create mode 100644 cimery/crates/dsl/src/girder.rs create mode 100644 cimery/crates/dsl/src/lib.rs create mode 100644 cimery/crates/evaluator/Cargo.toml create mode 100644 cimery/crates/evaluator/src/lib.rs create mode 100644 cimery/crates/incremental/Cargo.toml create mode 100644 cimery/crates/incremental/src/lib.rs create mode 100644 cimery/crates/ir/Cargo.toml create mode 100644 cimery/crates/ir/src/lib.rs create mode 100644 cimery/crates/kernel/Cargo.toml create mode 100644 cimery/crates/kernel/src/lib.rs create mode 100644 cimery/crates/usd/Cargo.toml create mode 100644 cimery/crates/usd/src/lib.rs create mode 100644 cimery/crates/viewer/Cargo.toml create mode 100644 cimery/crates/viewer/src/lib.rs create mode 100644 cimery/crates/viewer/src/main.rs create mode 100644 cimery/crates/viewer/src/shader.wgsl diff --git a/PLAN.md b/PLAN.md index 24e072d..76b47bd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,8 +17,8 @@ (없음 — 사용자 신호 대기) ### P1 — 다음 단계 (사용자 승인 후 착수) -- [ ] **cimery 저장소 스캐폴딩** — Cargo workspace, crate 레이아웃(`core`/`dsl`/`ir`/`kernel`/`ui`), `cimery/CLAUDE.md` 작성. 참조: ADR-001·002·003. -- [ ] **첫 Girder 엔드-투-엔드 수직 슬라이스** — DSL → IR → salsa → evaluator → OCCT → wgpu → USD 한 번 관통. 참조: ADR-002 + cimery-dev-guide.md "구현 우선순위". +- [ ] **Sprint 2 — OCCT 실제 커널 연결** — opencascade-rs 크레이트로 StubKernel 대체. PSC I 거더 실제 B-rep sweep 생성. ADR-001 참조. +- [ ] **Sprint 2 — wgpu에 Girder Mesh 렌더** — StubKernel mesh를 viewer에서 실제 렌더링. 카메라 orbit(Revit ViewCube 방식). --- diff --git a/PROGRESS.md b/PROGRESS.md index dc8037b..e48b8ac 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,8 @@ ## 타임라인 ### 2026-04-14 +- code — cimery Sprint 1 구현 완료. 8 crates (core/ir/dsl/kernel/incremental/evaluator/usd/viewer), `cargo test --workspace` 32개 전부 통과. DSL→IR→salsa-style-db→evaluator→StubKernel→USD 파이프라인 검증. +- meta — Revit API 가이드 Output/guides/revit-api-guide.md 추가됨. - meta — PLAN.md · PROGRESS.md 도입. 에이전트 간 작업 조정 프로토콜 확립. - meta — CLAUDE.md 린화. 상세 지침을 `Output/guides/cimery-dev-guide.md` · `obsidian-cli.md`로 분리. 프롬프트 토큰 절감. - adr — ADR-003 작성. 12개 후속 아키텍처 결정 (UI·IFC·CI/CD·USD·Alignment·Plugin·Feature 카탈로그·FEM·LOD·리본·선택/필터·설정). 병렬 조사 에이전트 기반. @@ -35,7 +37,8 @@ - `raw/` 수집 미개시 (PLAN.md 백로그 참조). ### cimery 코드 -- **미작성.** 저장소 스캐폴딩 대기 (PLAN.md P1). +- **Sprint 1 완료.** `cargo test` 32개 통과. StubKernel 기반 전 계층 파이프라인 동작. +- 다음: OCCT 실제 커널 연결 (Sprint 2), wgpu에 Girder Mesh 렌더 (Sprint 2). ### 아키텍처 결정 완성도 - 기본 구조 결정(DSL·기술 스택·후속 12개) **완료**. diff --git a/cimery/.gitignore b/cimery/.gitignore new file mode 100644 index 0000000..7265440 --- /dev/null +++ b/cimery/.gitignore @@ -0,0 +1,3 @@ +/target/ +Cargo.lock +.env diff --git a/cimery/CLAUDE.md b/cimery/CLAUDE.md new file mode 100644 index 0000000..fbb5040 --- /dev/null +++ b/cimery/CLAUDE.md @@ -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 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) diff --git a/cimery/Cargo.toml b/cimery/Cargo.toml new file mode 100644 index 0000000..4f399bc --- /dev/null +++ b/cimery/Cargo.toml @@ -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 diff --git a/cimery/crates/core/Cargo.toml b/cimery/crates/core/Cargo.toml new file mode 100644 index 0000000..ac08ad0 --- /dev/null +++ b/cimery/crates/core/Cargo.toml @@ -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 } diff --git a/cimery/crates/core/src/lib.rs b/cimery/crates/core/src/lib.rs new file mode 100644 index 0000000..0e35d3e --- /dev/null +++ b/cimery/crates/core/src/lib.rs @@ -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 for M { fn from(mm: Mm) -> M { M(mm.0 / 1000.0) } } +impl From 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, msg: impl Into) -> Self { + Self::Validation { path: path.into(), message: msg.into() } + } + pub fn missing(path: impl Into) -> 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); + } +} diff --git a/cimery/crates/dsl/Cargo.toml b/cimery/crates/dsl/Cargo.toml new file mode 100644 index 0000000..a7447bc --- /dev/null +++ b/cimery/crates/dsl/Cargo.toml @@ -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 } diff --git a/cimery/crates/dsl/src/girder.rs b/cimery/crates/dsl/src/girder.rs new file mode 100644 index 0000000..12eb367 --- /dev/null +++ b/cimery/crates/dsl/src/girder.rs @@ -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, + station_end: Option, + offset: Option, + section: Option, + section_type: Option, + count: Option, + spacing: Option, + material: Option, +} + +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 { + // 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::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); + } +} diff --git a/cimery/crates/dsl/src/lib.rs b/cimery/crates/dsl/src/lib.rs new file mode 100644 index 0000000..518b33a --- /dev/null +++ b/cimery/crates/dsl/src/lib.rs @@ -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}; diff --git a/cimery/crates/evaluator/Cargo.toml b/cimery/crates/evaluator/Cargo.toml new file mode 100644 index 0000000..c940db4 --- /dev/null +++ b/cimery/crates/evaluator/Cargo.toml @@ -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 } diff --git a/cimery/crates/evaluator/src/lib.rs b/cimery/crates/evaluator/src/lib.rs new file mode 100644 index 0000000..cbb9a8c --- /dev/null +++ b/cimery/crates/evaluator/src/lib.rs @@ -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 { + pub kernel: K, +} + +impl Evaluator { + 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 { + 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); + } +} diff --git a/cimery/crates/incremental/Cargo.toml b/cimery/crates/incremental/Cargo.toml new file mode 100644 index 0000000..462432d --- /dev/null +++ b/cimery/crates/incremental/Cargo.toml @@ -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 } diff --git a/cimery/crates/incremental/src/lib.rs b/cimery/crates/incremental/src/lib.rs new file mode 100644 index 0000000..3627331 --- /dev/null +++ b/cimery/crates/incremental/src/lib.rs @@ -0,0 +1,176 @@ +//! cimery-incremental — incremental computation layer. +//! +//! ## Sprint 1: manual dirty-tracking +//! +//! Uses a `HashMap` cache + `HashSet` 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 { + kernel: Arc, + girders: HashMap, + mesh_cache: HashMap>, + dirty: HashSet, +} + +impl IncrementalDb { + 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` without recomputation. + /// - Cache miss or dirty → calls kernel, updates cache, clears dirty. + pub fn girder_mesh( + &mut self, + id: &FeatureId, + ) -> Result, 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(_)))); + } +} diff --git a/cimery/crates/ir/Cargo.toml b/cimery/crates/ir/Cargo.toml new file mode 100644 index 0000000..acc5ada --- /dev/null +++ b/cimery/crates/ir/Cargo.toml @@ -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 } diff --git a/cimery/crates/ir/src/lib.rs b/cimery/crates/ir/src/lib.rs new file mode 100644 index 0000000..eac0116 --- /dev/null +++ b/cimery/crates/ir/src/lib.rs @@ -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); + } +} diff --git a/cimery/crates/kernel/Cargo.toml b/cimery/crates/kernel/Cargo.toml new file mode 100644 index 0000000..b9fd93c --- /dev/null +++ b/cimery/crates/kernel/Cargo.toml @@ -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 } diff --git a/cimery/crates/kernel/src/lib.rs b/cimery/crates/kernel/src/lib.rs new file mode 100644 index 0000000..2bcf5a7 --- /dev/null +++ b/cimery/crates/kernel/src/lib.rs @@ -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, + /// 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; +} + +// ─── 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 { + 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 = 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(_)))); + } +} diff --git a/cimery/crates/usd/Cargo.toml b/cimery/crates/usd/Cargo.toml new file mode 100644 index 0000000..1877316 --- /dev/null +++ b/cimery/crates/usd/Cargo.toml @@ -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 } diff --git a/cimery/crates/usd/src/lib.rs b/cimery/crates/usd/src/lib.rs new file mode 100644 index 0000000..e3ce277 --- /dev/null +++ b/cimery/crates/usd/src/lib.rs @@ -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); + } +} diff --git a/cimery/crates/viewer/Cargo.toml b/cimery/crates/viewer/Cargo.toml new file mode 100644 index 0000000..1248c90 --- /dev/null +++ b/cimery/crates/viewer/Cargo.toml @@ -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" diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs new file mode 100644 index 0000000..b931299 --- /dev/null +++ b/cimery/crates/viewer/src/lib.rs @@ -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::() 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, + 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) -> Self { + let size = window.inner_size(); + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + + // Arc 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) { + 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, +} + +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"); +} diff --git a/cimery/crates/viewer/src/main.rs b/cimery/crates/viewer/src/main.rs new file mode 100644 index 0000000..6582366 --- /dev/null +++ b/cimery/crates/viewer/src/main.rs @@ -0,0 +1,4 @@ +fn main() { + env_logger::init(); + cimery_viewer::run_viewer(); +} diff --git a/cimery/crates/viewer/src/shader.wgsl b/cimery/crates/viewer/src/shader.wgsl new file mode 100644 index 0000000..7e9c1d0 --- /dev/null +++ b/cimery/crates/viewer/src/shader.wgsl @@ -0,0 +1,25 @@ +// cimery-viewer Sprint 1 shader +// Simple per-vertex colour passthrough. + +struct VertexInput { + @location(0) position: vec3, + @location(1) color: vec3, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) color: vec3, +}; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(in.position, 1.0); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return vec4(in.color, 1.0); +}