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

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

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

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

View File

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

View File

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

View File

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