Sprint 14~22 — egui 리본 UI + OcctKernel B-rep + 가로보/신축이음 + 선형 좌표 + USD 익스포트 + WASM + CI/CD + 테스트 4층

Sprint 14: egui TopBottomPanel 리본 + CollapsingHeader SidePanel (상부구조·추가부재·선형·프로젝트)
Sprint 15: IncrementalDb 전 Feature 타입 확장 (girder→7종), dirty-tracking 20 unit tests
Sprint 16: Gitea + GitHub Actions CI/CD (check/test/clippy/fmt + 멀티플랫폼 릴리스)
Sprint 17: AlignmentTransform + AlignmentScene — 선형 국소 프레임 → 세계 좌표 변환
Sprint 18: OcctKernel 교각(16각형 기둥+코핑) + 교대(흉벽+푸팅+날개벽) B-rep
Sprint 19: CrossBeamIR + ExpansionJointIR — IR/DSL/kernel/scene 전 계층, sweep_profile_flat_x
Sprint 20: 테스트 4층 — Layer1 insta 스냅샷(7종), Layer2 기하 불변량(19), Layer3 두-커널(7), Layer4 proptest(7) — 61 tests pass
Sprint 21: cimery-usd PureRustKernel 실제 기하 변환 + BridgeExporter 증분 캐시
Sprint 22: viewer wasm feature + wasm-bindgen/web-sys + GitHub Actions Cloudflare Pages 배포

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 08:18:06 +09:00
parent 81349c97d2
commit 1f9ca3a00f
37 changed files with 3569 additions and 259 deletions

View File

@@ -1,11 +1,16 @@
//! cimery-incremental — incremental computation layer.
//!
//! ## Sprint 1: manual dirty-tracking
//! ## Sprint 8: manual dirty-tracking (all feature types)
//!
//! Uses a `HashMap` cache + `HashSet<FeatureId>` dirty set.
//! Query granularity: **Feature-level** (one dirty entry per Feature instance).
//! Covers all MVP feature types: Girder, DeckSlab, Bearing, Pier, Abutment.
//!
//! ## Sprint 2 upgrade: salsa
//! ## Sprint 15 upgrade: all feature types
//! Extended from Girder-only to full MVP feature set. Same dirty-tracking
//! pattern applied to every feature kind.
//!
//! ## Future 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)
@@ -14,7 +19,7 @@
//! - 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_ir::{AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR};
use cimery_kernel::{GeomKernel, KernelError, Mesh};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -23,59 +28,91 @@ use std::sync::Arc;
/// Incremental computation database.
///
/// Holds one geometry kernel and one cache per Feature type.
/// In Sprint 2 this becomes a salsa `Database` impl.
/// Holds one geometry kernel and per-kind caches for all MVP feature types.
/// In the future this becomes a salsa `Database` impl (ADR-002 D).
///
/// ## Invariants
/// - `dirty` tracks only IDs that have a corresponding IR entry.
/// - Removing a feature also removes its cache entry and dirty mark.
pub struct IncrementalDb<K: GeomKernel> {
kernel: Arc<K>,
kernel: Arc<K>,
// ── IR stores ─────────────────────────────────────────────────────────
girders: HashMap<FeatureId, GirderIR>,
mesh_cache: HashMap<FeatureId, Arc<Mesh>>,
dirty: HashSet<FeatureId>,
decks: HashMap<FeatureId, DeckSlabIR>,
bearings: HashMap<FeatureId, BearingIR>,
piers: HashMap<FeatureId, PierIR>,
abutments: HashMap<FeatureId, AbutmentIR>,
// ── Mesh caches ───────────────────────────────────────────────────────
girder_cache: HashMap<FeatureId, Arc<Mesh>>,
deck_cache: HashMap<FeatureId, Arc<Mesh>>,
bearing_cache: HashMap<FeatureId, Arc<Mesh>>,
pier_cache: HashMap<FeatureId, Arc<Mesh>>,
abutment_cache: HashMap<FeatureId, Arc<Mesh>>,
// ── Dirty sets (per kind) ─────────────────────────────────────────────
dirty_girder: HashSet<FeatureId>,
dirty_deck: HashSet<FeatureId>,
dirty_bearing: HashSet<FeatureId>,
dirty_pier: HashSet<FeatureId>,
dirty_abutment: 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(),
kernel: Arc::new(kernel),
girders: HashMap::new(),
decks: HashMap::new(),
bearings: HashMap::new(),
piers: HashMap::new(),
abutments: HashMap::new(),
girder_cache: HashMap::new(),
deck_cache: HashMap::new(),
bearing_cache: HashMap::new(),
pier_cache: HashMap::new(),
abutment_cache:HashMap::new(),
dirty_girder: HashSet::new(),
dirty_deck: HashSet::new(),
dirty_bearing: HashSet::new(),
dirty_pier: HashSet::new(),
dirty_abutment:HashSet::new(),
}
}
// ── Writers ────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────────
// Girder
// ──────────────────────────────────────────────────────────────────────────
/// Insert or update a Girder. Marks the feature dirty and evicts mesh cache.
/// Insert or update a Girder. Marks dirty, evicts 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);
self.girder_cache.remove(&id);
self.dirty_girder.insert(id);
}
// ── Readers ────────────────────────────────────────────────────────────
/// Remove a Girder and clear its cache/dirty state.
pub fn remove_girder(&mut self, id: &FeatureId) {
self.girders.remove(id);
self.girder_cache.remove(id);
self.dirty_girder.remove(id);
}
/// 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) {
/// Query mesh for a Girder (cache-first).
pub fn girder_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
if !self.dirty_girder.contains(id) {
if let Some(cached) = self.girder_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))
KernelError::InvalidInput(format!("unknown Girder FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.girder_mesh(ir)?);
self.mesh_cache.insert(*id, Arc::clone(&mesh));
self.dirty.remove(id);
self.girder_cache.insert(*id, Arc::clone(&mesh));
self.dirty_girder.remove(id);
Ok(mesh)
}
@@ -84,13 +121,185 @@ impl<K: GeomKernel> IncrementalDb<K> {
self.girders.get(id)
}
// ── Status ─────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────────
// DeckSlab
// ──────────────────────────────────────────────────────────────────────────
/// Number of Features awaiting recomputation.
pub fn dirty_count(&self) -> usize { self.dirty.len() }
pub fn set_deck_slab(&mut self, ir: DeckSlabIR) {
let id = ir.id;
self.decks.insert(id, ir);
self.deck_cache.remove(&id);
self.dirty_deck.insert(id);
}
/// Total number of stored Girder Features.
pub fn girder_count(&self) -> usize { self.girders.len() }
pub fn remove_deck_slab(&mut self, id: &FeatureId) {
self.decks.remove(id);
self.deck_cache.remove(id);
self.dirty_deck.remove(id);
}
pub fn deck_slab_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
if !self.dirty_deck.contains(id) {
if let Some(cached) = self.deck_cache.get(id) {
return Ok(Arc::clone(cached));
}
}
let ir = self.decks.get(id).ok_or_else(|| {
KernelError::InvalidInput(format!("unknown DeckSlab FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.deck_slab_mesh(ir)?);
self.deck_cache.insert(*id, Arc::clone(&mesh));
self.dirty_deck.remove(id);
Ok(mesh)
}
pub fn get_deck_slab(&self, id: &FeatureId) -> Option<&DeckSlabIR> {
self.decks.get(id)
}
// ──────────────────────────────────────────────────────────────────────────
// Bearing
// ──────────────────────────────────────────────────────────────────────────
pub fn set_bearing(&mut self, ir: BearingIR) {
let id = ir.id;
self.bearings.insert(id, ir);
self.bearing_cache.remove(&id);
self.dirty_bearing.insert(id);
}
pub fn remove_bearing(&mut self, id: &FeatureId) {
self.bearings.remove(id);
self.bearing_cache.remove(id);
self.dirty_bearing.remove(id);
}
pub fn bearing_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
if !self.dirty_bearing.contains(id) {
if let Some(cached) = self.bearing_cache.get(id) {
return Ok(Arc::clone(cached));
}
}
let ir = self.bearings.get(id).ok_or_else(|| {
KernelError::InvalidInput(format!("unknown Bearing FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.bearing_mesh(ir)?);
self.bearing_cache.insert(*id, Arc::clone(&mesh));
self.dirty_bearing.remove(id);
Ok(mesh)
}
pub fn get_bearing(&self, id: &FeatureId) -> Option<&BearingIR> {
self.bearings.get(id)
}
// ──────────────────────────────────────────────────────────────────────────
// Pier
// ──────────────────────────────────────────────────────────────────────────
pub fn set_pier(&mut self, ir: PierIR) {
let id = ir.id;
self.piers.insert(id, ir);
self.pier_cache.remove(&id);
self.dirty_pier.insert(id);
}
pub fn remove_pier(&mut self, id: &FeatureId) {
self.piers.remove(id);
self.pier_cache.remove(id);
self.dirty_pier.remove(id);
}
pub fn pier_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
if !self.dirty_pier.contains(id) {
if let Some(cached) = self.pier_cache.get(id) {
return Ok(Arc::clone(cached));
}
}
let ir = self.piers.get(id).ok_or_else(|| {
KernelError::InvalidInput(format!("unknown Pier FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.pier_mesh(ir)?);
self.pier_cache.insert(*id, Arc::clone(&mesh));
self.dirty_pier.remove(id);
Ok(mesh)
}
pub fn get_pier(&self, id: &FeatureId) -> Option<&PierIR> {
self.piers.get(id)
}
// ──────────────────────────────────────────────────────────────────────────
// Abutment
// ──────────────────────────────────────────────────────────────────────────
pub fn set_abutment(&mut self, ir: AbutmentIR) {
let id = ir.id;
self.abutments.insert(id, ir);
self.abutment_cache.remove(&id);
self.dirty_abutment.insert(id);
}
pub fn remove_abutment(&mut self, id: &FeatureId) {
self.abutments.remove(id);
self.abutment_cache.remove(id);
self.dirty_abutment.remove(id);
}
pub fn abutment_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
if !self.dirty_abutment.contains(id) {
if let Some(cached) = self.abutment_cache.get(id) {
return Ok(Arc::clone(cached));
}
}
let ir = self.abutments.get(id).ok_or_else(|| {
KernelError::InvalidInput(format!("unknown Abutment FeatureId: {}", id))
})?;
let mesh = Arc::new(self.kernel.abutment_mesh(ir)?);
self.abutment_cache.insert(*id, Arc::clone(&mesh));
self.dirty_abutment.remove(id);
Ok(mesh)
}
pub fn get_abutment(&self, id: &FeatureId) -> Option<&AbutmentIR> {
self.abutments.get(id)
}
// ──────────────────────────────────────────────────────────────────────────
// Status / diagnostics
// ──────────────────────────────────────────────────────────────────────────
/// Total features awaiting recomputation (all kinds).
pub fn dirty_count(&self) -> usize {
self.dirty_girder.len()
+ self.dirty_deck.len()
+ self.dirty_bearing.len()
+ self.dirty_pier.len()
+ self.dirty_abutment.len()
}
/// Dirty Girders only (kept for backward-compat).
pub fn dirty_girder_count(&self) -> usize { self.dirty_girder.len() }
pub fn girder_count(&self) -> usize { self.girders.len() }
pub fn deck_count(&self) -> usize { self.decks.len() }
pub fn bearing_count(&self) -> usize { self.bearings.len() }
pub fn pier_count(&self) -> usize { self.piers.len() }
pub fn abutment_count(&self) -> usize { self.abutments.len() }
/// Clear all caches (force full recompute on next access).
pub fn invalidate_all(&mut self) {
for id in self.girders.keys() { self.dirty_girder.insert(*id); }
for id in self.decks.keys() { self.dirty_deck.insert(*id); }
for id in self.bearings.keys() { self.dirty_bearing.insert(*id); }
for id in self.piers.keys() { self.dirty_pier.insert(*id); }
for id in self.abutments.keys() { self.dirty_abutment.insert(*id); }
self.girder_cache.clear();
self.deck_cache.clear();
self.bearing_cache.clear();
self.pier_cache.clear();
self.abutment_cache.clear();
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
@@ -98,26 +307,72 @@ impl<K: GeomKernel> IncrementalDb<K> {
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
use cimery_core::{AbutmentType, BearingType, MaterialGrade, PierType, ColumnShape, SectionType};
use cimery_ir::{
AbutmentIR, BearingIR, CapBeamIR, DeckSlabIR, FeatureId, GirderIR,
PierIR, PscISectionParams, SectionParams, WingWallIR,
};
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,
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,
count: 1, spacing: 0.0, material: MaterialGrade::C50,
}
}
fn make_deck() -> DeckSlabIR {
DeckSlabIR {
id: FeatureId::new(), station_start: 0.0, station_end: 40.0,
width_left: 6_000.0, width_right: 6_000.0,
thickness: 220.0, haunch_depth: 0.0, cross_slope: 2.0,
material: MaterialGrade::C40,
}
}
fn make_bearing() -> BearingIR {
BearingIR {
id: FeatureId::new(), station: 0.0,
bearing_type: BearingType::Elastomeric,
plan_length: 350.0, plan_width: 450.0,
total_height: 60.0, capacity_vertical: 1_500.0,
}
}
fn make_pier() -> PierIR {
PierIR {
id: FeatureId::new(), station: 20.0, skew_angle: 0.0,
pier_type: PierType::RoundColumn,
column_shape: ColumnShape::Circular,
column_count: 2, column_spacing: 3_000.0,
column_diameter: 1_500.0, column_depth: 0.0,
column_height: 8_000.0,
cap_beam: CapBeamIR {
length: 7_000.0, width: 1_500.0, depth: 1_200.0,
cantilever_left: 500.0, cantilever_right: 500.0,
},
material: MaterialGrade::C30,
}
}
fn make_abutment() -> AbutmentIR {
AbutmentIR {
id: FeatureId::new(), station: 0.0, skew_angle: 0.0,
abutment_type: AbutmentType::ReverseT,
breast_wall_height: 3_000.0, breast_wall_thickness: 800.0,
breast_wall_width: 12_000.0, footing_length: 4_000.0,
footing_width: 13_000.0, footing_thickness: 1_000.0,
wing_wall_left: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 },
wing_wall_right: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 },
material: MaterialGrade::C40,
}
}
// ── Girder (backward-compat tests) ────────────────────────────────────────
#[test]
fn dirty_after_set() {
fn dirty_after_set_girder() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
@@ -128,7 +383,7 @@ mod tests {
}
#[test]
fn clean_after_compute() {
fn clean_after_compute_girder() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
@@ -138,39 +393,122 @@ mod tests {
}
#[test]
fn cache_hit_on_second_call() {
fn cache_hit_girder() {
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
let m2 = db.girder_mesh(&id).unwrap();
assert!(Arc::ptr_eq(&m1, &m2));
}
#[test]
fn invalidation_on_update() {
fn invalidation_on_update_girder() {
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
assert_eq!(db.dirty_count(), 1);
}
#[test]
fn unknown_id_returns_error() {
fn unknown_id_girder_error() {
let mut db = IncrementalDb::new(StubKernel);
let missing_id = FeatureId::new();
let err = db.girder_mesh(&missing_id);
let err = db.girder_mesh(&FeatureId::new());
assert!(matches!(err, Err(KernelError::InvalidInput(_))));
}
// ── DeckSlab ───────────────────────────────────────────────────────────────
#[test]
fn deck_slab_dirty_compute_cache() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_deck();
let id = ir.id;
db.set_deck_slab(ir);
assert_eq!(db.dirty_count(), 1);
let m1 = db.deck_slab_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
let m2 = db.deck_slab_mesh(&id).unwrap();
assert!(Arc::ptr_eq(&m1, &m2));
}
// ── Bearing ────────────────────────────────────────────────────────────────
#[test]
fn bearing_dirty_compute_cache() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_bearing();
let id = ir.id;
db.set_bearing(ir);
assert_eq!(db.dirty_count(), 1);
db.bearing_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
}
// ── Pier ───────────────────────────────────────────────────────────────────
#[test]
fn pier_dirty_compute_cache() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_pier();
let id = ir.id;
db.set_pier(ir);
assert_eq!(db.dirty_count(), 1);
db.pier_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
}
// ── Abutment ───────────────────────────────────────────────────────────────
#[test]
fn abutment_dirty_compute_cache() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_abutment();
let id = ir.id;
db.set_abutment(ir);
assert_eq!(db.dirty_count(), 1);
db.abutment_mesh(&id).unwrap();
assert_eq!(db.dirty_count(), 0);
}
// ── Multi-feature dirty count ──────────────────────────────────────────────
#[test]
fn total_dirty_count_all_features() {
let mut db = IncrementalDb::new(StubKernel);
db.set_girder(make_girder(0.0, 40.0));
db.set_deck_slab(make_deck());
db.set_bearing(make_bearing());
db.set_pier(make_pier());
db.set_abutment(make_abutment());
assert_eq!(db.dirty_count(), 5);
}
#[test]
fn invalidate_all_re_dirties() {
let mut db = IncrementalDb::new(StubKernel);
let g = make_girder(0.0, 40.0);
let gid = g.id;
db.set_girder(g);
db.girder_mesh(&gid).unwrap();
assert_eq!(db.dirty_count(), 0);
db.invalidate_all();
assert_eq!(db.dirty_count(), 1);
}
// ── Remove feature ─────────────────────────────────────────────────────────
#[test]
fn remove_girder_clears_all() {
let mut db = IncrementalDb::new(StubKernel);
let ir = make_girder(0.0, 40.0);
let id = ir.id;
db.set_girder(ir);
db.remove_girder(&id);
assert_eq!(db.girder_count(), 0);
assert_eq!(db.dirty_count(), 0);
assert!(matches!(db.girder_mesh(&id), Err(KernelError::InvalidInput(_))));
}
}