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,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(_))));
}
}