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:
4
PLAN.md
4
PLAN.md
@@ -17,8 +17,8 @@
|
|||||||
(없음 — 사용자 신호 대기)
|
(없음 — 사용자 신호 대기)
|
||||||
|
|
||||||
### P1 — 다음 단계 (사용자 승인 후 착수)
|
### P1 — 다음 단계 (사용자 승인 후 착수)
|
||||||
- [ ] **cimery 저장소 스캐폴딩** — Cargo workspace, crate 레이아웃(`core`/`dsl`/`ir`/`kernel`/`ui`), `cimery/CLAUDE.md` 작성. 참조: ADR-001·002·003.
|
- [ ] **Sprint 2 — OCCT 실제 커널 연결** — opencascade-rs 크레이트로 StubKernel 대체. PSC I 거더 실제 B-rep sweep 생성. ADR-001 참조.
|
||||||
- [ ] **첫 Girder 엔드-투-엔드 수직 슬라이스** — DSL → IR → salsa → evaluator → OCCT → wgpu → USD 한 번 관통. 참조: ADR-002 + cimery-dev-guide.md "구현 우선순위".
|
- [ ] **Sprint 2 — wgpu에 Girder Mesh 렌더** — StubKernel mesh를 viewer에서 실제 렌더링. 카메라 orbit(Revit ViewCube 방식).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
## 타임라인
|
## 타임라인
|
||||||
|
|
||||||
### 2026-04-14
|
### 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 — PLAN.md · PROGRESS.md 도입. 에이전트 간 작업 조정 프로토콜 확립.
|
||||||
- meta — CLAUDE.md 린화. 상세 지침을 `Output/guides/cimery-dev-guide.md` · `obsidian-cli.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·리본·선택/필터·설정). 병렬 조사 에이전트 기반.
|
- adr — ADR-003 작성. 12개 후속 아키텍처 결정 (UI·IFC·CI/CD·USD·Alignment·Plugin·Feature 카탈로그·FEM·LOD·리본·선택/필터·설정). 병렬 조사 에이전트 기반.
|
||||||
@@ -35,7 +37,8 @@
|
|||||||
- `raw/` 수집 미개시 (PLAN.md 백로그 참조).
|
- `raw/` 수집 미개시 (PLAN.md 백로그 참조).
|
||||||
|
|
||||||
### cimery 코드
|
### cimery 코드
|
||||||
- **미작성.** 저장소 스캐폴딩 대기 (PLAN.md P1).
|
- **Sprint 1 완료.** `cargo test` 32개 통과. StubKernel 기반 전 계층 파이프라인 동작.
|
||||||
|
- 다음: OCCT 실제 커널 연결 (Sprint 2), wgpu에 Girder Mesh 렌더 (Sprint 2).
|
||||||
|
|
||||||
### 아키텍처 결정 완성도
|
### 아키텍처 결정 완성도
|
||||||
- 기본 구조 결정(DSL·기술 스택·후속 12개) **완료**.
|
- 기본 구조 결정(DSL·기술 스택·후속 12개) **완료**.
|
||||||
|
|||||||
3
cimery/.gitignore
vendored
Normal file
3
cimery/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
.env
|
||||||
53
cimery/CLAUDE.md
Normal file
53
cimery/CLAUDE.md
Normal 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
50
cimery/Cargo.toml
Normal 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
|
||||||
11
cimery/crates/core/Cargo.toml
Normal file
11
cimery/crates/core/Cargo.toml
Normal 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 }
|
||||||
140
cimery/crates/core/src/lib.rs
Normal file
140
cimery/crates/core/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
cimery/crates/dsl/Cargo.toml
Normal file
11
cimery/crates/dsl/Cargo.toml
Normal 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 }
|
||||||
332
cimery/crates/dsl/src/girder.rs
Normal file
332
cimery/crates/dsl/src/girder.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
cimery/crates/dsl/src/lib.rs
Normal file
9
cimery/crates/dsl/src/lib.rs
Normal 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};
|
||||||
12
cimery/crates/evaluator/Cargo.toml
Normal file
12
cimery/crates/evaluator/Cargo.toml
Normal 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 }
|
||||||
81
cimery/crates/evaluator/src/lib.rs
Normal file
81
cimery/crates/evaluator/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
cimery/crates/incremental/Cargo.toml
Normal file
11
cimery/crates/incremental/Cargo.toml
Normal 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 }
|
||||||
176
cimery/crates/incremental/src/lib.rs
Normal file
176
cimery/crates/incremental/src/lib.rs
Normal 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(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
cimery/crates/ir/Cargo.toml
Normal file
10
cimery/crates/ir/Cargo.toml
Normal 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
185
cimery/crates/ir/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
cimery/crates/kernel/Cargo.toml
Normal file
11
cimery/crates/kernel/Cargo.toml
Normal 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 }
|
||||||
160
cimery/crates/kernel/src/lib.rs
Normal file
160
cimery/crates/kernel/src/lib.rs
Normal 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(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
cimery/crates/usd/Cargo.toml
Normal file
10
cimery/crates/usd/Cargo.toml
Normal 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 }
|
||||||
151
cimery/crates/usd/src/lib.rs
Normal file
151
cimery/crates/usd/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
cimery/crates/viewer/Cargo.toml
Normal file
17
cimery/crates/viewer/Cargo.toml
Normal 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"
|
||||||
311
cimery/crates/viewer/src/lib.rs
Normal file
311
cimery/crates/viewer/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
4
cimery/crates/viewer/src/main.rs
Normal file
4
cimery/crates/viewer/src/main.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
env_logger::init();
|
||||||
|
cimery_viewer::run_viewer();
|
||||||
|
}
|
||||||
25
cimery/crates/viewer/src/shader.wgsl
Normal file
25
cimery/crates/viewer/src/shader.wgsl
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user