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:
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