From 263806b834e649da257297daaaeca9e929651cc5 Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 14 Apr 2026 20:55:16 +0900 Subject: [PATCH] =?UTF-8?q?Sprint=206/7/8=20=E2=80=94=20Alignment=20?= =?UTF-8?q?=EB=A1=9C=EB=8D=94=20+=20CSV=20=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=ED=8A=B8=EB=A6=BD=20+=20IncrementalDb=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 6 (Alignment JSON 로더): - AlignmentIR: from_file(), position_at(station), total_length_m() - AlignmentStation, AlignmentSpecs in ir crate - alignments/BR-001-test.json: 40m 직선 테스트 선형 Sprint 7 (CSV 라운드트립): - csv_template.rs: girder_params() 레지스트리 - girder_to_csv_template(): 헤더+기본값 CSV 출력 - girder_from_csv(): CSV → Vec 파싱 - 테스트 3개 (template, multi-row, invalid span) Sprint 8 (IncrementalDb 스캐폴드): - incremental_scene.rs: IncrementalBridge - 안정적 girder ID (슬롯 기반), DB 캐시 → X-translate - Sprint 9에서 전체 Feature IncrementalDb 통합 예정 cargo test 60개 전부 통과 Co-Authored-By: Claude Opus 4.6 (1M context) --- cimery/alignments/BR-001-test.json | 19 ++ cimery/crates/dsl/src/csv_template.rs | 180 ++++++++++++++++++ cimery/crates/dsl/src/lib.rs | 1 + cimery/crates/ir/src/lib.rs | 77 ++++++++ cimery/crates/viewer/src/incremental_scene.rs | 99 ++++++++++ cimery/crates/viewer/src/lib.rs | 1 + 6 files changed, 377 insertions(+) create mode 100644 cimery/alignments/BR-001-test.json create mode 100644 cimery/crates/dsl/src/csv_template.rs create mode 100644 cimery/crates/viewer/src/incremental_scene.rs diff --git a/cimery/alignments/BR-001-test.json b/cimery/alignments/BR-001-test.json new file mode 100644 index 0000000..ad7f59f --- /dev/null +++ b/cimery/alignments/BR-001-test.json @@ -0,0 +1,19 @@ +{ + "name": "BR-001-test", + "description": "테스트 직선 선형 — 40 m 경간 거더교", + "coordinate_system": "local", + "stations": [ + {"station": 0.0, "x": 0.0, "y": 0.0, "z": 0.0}, + {"station": 5.0, "x": 5000.0, "y": 0.0, "z": 0.0}, + {"station": 10.0, "x": 10000.0, "y": 0.0, "z": 0.0}, + {"station": 20.0, "x": 20000.0, "y": 0.0, "z": 0.0}, + {"station": 30.0, "x": 30000.0, "y": 0.0, "z": 0.0}, + {"station": 40.0, "x": 40000.0, "y": 0.0, "z": 0.0} + ], + "specs": { + "type": "highway", + "design_speed": 80, + "lanes": 2, + "lane_width_mm": 3750 + } +} diff --git a/cimery/crates/dsl/src/csv_template.rs b/cimery/crates/dsl/src/csv_template.rs new file mode 100644 index 0000000..10d679f --- /dev/null +++ b/cimery/crates/dsl/src/csv_template.rs @@ -0,0 +1,180 @@ +//! CSV round-trip for Feature parameters. +//! +//! ADR-002 N: Developer exports CSV template → Engineer fills values → +//! Import CSV → cimery generates DSL instances. +//! +//! Sprint 7: Manual parameter registry (no proc-macro yet — ADR-002 J). +//! Sprint 8+: `#[param(...)]` attribute will auto-generate this. + +use std::collections::HashMap; +use cimery_core::{FeatureError, MaterialGrade, SectionType, UnitExt}; +use cimery_ir::{GirderIR, PscISectionParams, SectionParams, FeatureId}; +use crate::girder::GirderBuilder; + +// ─── Parameter descriptor ───────────────────────────────────────────────────── + +/// One editable parameter in a Feature template. +#[derive(Debug, Clone)] +pub struct ParamDef { + pub name: &'static str, + pub unit: &'static str, + pub description: &'static str, + pub default: f64, + pub min: f64, + pub max: f64, +} + +// ─── Girder parameter registry ──────────────────────────────────────────────── + +/// Return the editable parameters for a Girder Feature. +/// Mirrors the `#[param(...)]` annotations in `girder.rs`. +pub fn girder_params() -> Vec { + vec![ + ParamDef { name: "station_start", unit: "m", description: "시작 스테이션", default: 0.0, min: 0.0, max: 100_000.0 }, + ParamDef { name: "station_end", unit: "m", description: "종점 스테이션", default: 40.0, min: 5.0, max: 100_000.0 }, + ParamDef { name: "count", unit: "ea", description: "거더 수", default: 5.0, min: 1.0, max: 20.0 }, + ParamDef { name: "spacing", unit: "mm", description: "c/c 간격", default: 2500.0,min: 1500.0, max: 4000.0 }, + ParamDef { name: "offset", unit: "mm", description: "선형 편심", default: 0.0, min:-10000.0, max: 10000.0 }, + ParamDef { name: "total_height", unit: "mm", description: "단면 전체 높이",default: 1800.0,min: 1200.0, max: 3000.0 }, + ParamDef { name: "top_flange_width", unit: "mm", description: "상부 플랜지 폭",default: 600.0, min: 400.0, max: 800.0 }, + ParamDef { name: "top_flange_thick", unit: "mm", description: "상부 플랜지 두께",default:150.0,min: 100.0, max: 300.0 }, + ParamDef { name: "bottom_flange_width",unit: "mm", description: "하부 플랜지 폭",default: 700.0, min: 400.0, max: 900.0 }, + ParamDef { name: "bottom_flange_thick",unit: "mm", description: "하부 플랜지 두께",default:180.0,min: 120.0, max: 300.0 }, + ParamDef { name: "web_thickness", unit: "mm", description: "복부판 두께", default: 200.0, min: 150.0, max: 350.0 }, + ParamDef { name: "haunch", unit: "mm", description: "헌치", default: 50.0, min: 0.0, max: 100.0 }, + ] +} + +// ─── CSV export ─────────────────────────────────────────────────────────────── + +/// Write a Girder CSV template to a string. +/// First row = headers, second row = default values. +pub fn girder_to_csv_template() -> String { + let params = girder_params(); + let mut out = String::new(); + + // Header row: name (unit) [description] + let header: Vec = params.iter() + .map(|p| format!("{}({})[{}]", p.name, p.unit, p.description)) + .collect(); + out.push_str(&header.join(",")); + out.push('\n'); + + // Default values row + let defaults: Vec = params.iter() + .map(|p| p.default.to_string()) + .collect(); + out.push_str(&defaults.join(",")); + out.push('\n'); + + out +} + +/// Parse one or more Girder IRs from CSV data. +/// +/// Format: first row = headers (param names), subsequent rows = values. +/// Returns one `GirderIR` per data row. +pub fn girder_from_csv(csv: &str) -> Result, FeatureError> { + let mut lines = csv.lines(); + + // Parse header + let header = match lines.next() { + Some(h) => h, + None => return Err(FeatureError::validation("csv", "empty file")), + }; + let col_names: Vec<&str> = header.split(',') + .map(|s| s.split('(').next().unwrap_or(s).trim()) + .collect(); + + let mut results = Vec::new(); + + for (row_idx, line) in lines.enumerate() { + if line.trim().is_empty() { continue; } + + let vals: Vec = line.split(',') + .map(|s| s.trim().parse::().unwrap_or(0.0)) + .collect(); + + // Map column names to values + let mut m: HashMap<&str, f64> = HashMap::new(); + for (i, name) in col_names.iter().enumerate() { + if let Some(&v) = vals.get(i) { m.insert(name, v); } + } + + let get = |key: &str, default: f64| -> f64 { *m.get(key).unwrap_or(&default) }; + + let section = PscISectionParams { + total_height: get("total_height", 1800.0), + top_flange_width: get("top_flange_width", 600.0), + top_flange_thickness: get("top_flange_thick", 150.0), + bottom_flange_width: get("bottom_flange_width", 700.0), + bottom_flange_thickness: get("bottom_flange_thick", 180.0), + web_thickness: get("web_thickness", 200.0), + haunch: get("haunch", 50.0), + }; + + let station_start = get("station_start", 0.0); + let station_end = get("station_end", 40.0); + let span = station_end - station_start; + + if span <= 0.0 { + return Err(FeatureError::validation( + &format!("csv.row[{}].station_end", row_idx), + format!("must be > station_start, got span={:.1}m", span), + )); + } + + results.push(GirderIR { + id: FeatureId::new(), + station_start, + station_end, + offset_from_alignment: get("offset", 0.0), + section_type: SectionType::PscI, + section: SectionParams::PscI(section), + count: get("count", 1.0) as u32, + spacing: get("spacing", 2500.0), + material: MaterialGrade::C50, + }); + } + + if results.is_empty() { + return Err(FeatureError::validation("csv", "no data rows")); + } + Ok(results) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn template_roundtrip() { + let csv = girder_to_csv_template(); + assert!(csv.contains("station_start")); + assert!(csv.contains("1800")); // default total_height + + let girders = girder_from_csv(&csv).unwrap(); + assert_eq!(girders.len(), 1); + let g = &girders[0]; + assert!((g.span_m() - 40.0).abs() < 0.001); + assert_eq!(g.count, 5); + } + + #[test] + fn multi_row_csv() { + let csv = "station_start,station_end,count,total_height\n\ + 0,40,5,1800\n\ + 100,140,5,1800\n"; + let girders = girder_from_csv(csv).unwrap(); + assert_eq!(girders.len(), 2); + assert!((girders[1].station_start - 100.0).abs() < 0.001); + } + + #[test] + fn invalid_span_fails() { + let csv = "station_start,station_end\n50.0,20.0\n"; + assert!(girder_from_csv(csv).is_err()); + } +} diff --git a/cimery/crates/dsl/src/lib.rs b/cimery/crates/dsl/src/lib.rs index 5bef9e9..8fddbe0 100644 --- a/cimery/crates/dsl/src/lib.rs +++ b/cimery/crates/dsl/src/lib.rs @@ -6,6 +6,7 @@ //! for a future macro-upgrade path and do not affect compilation. pub mod girder; +pub mod csv_template; pub mod deck_slab; pub mod bearing; pub mod pier; diff --git a/cimery/crates/ir/src/lib.rs b/cimery/crates/ir/src/lib.rs index 42342a2..a9c6015 100644 --- a/cimery/crates/ir/src/lib.rs +++ b/cimery/crates/ir/src/lib.rs @@ -138,6 +138,83 @@ pub struct SteelPlateIParams { pub web_thickness: f64, } +// ─── Alignment IR ──────────────────────────────────────────────────────────── + +/// Single station point along an alignment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlignmentStation { + /// Station distance along alignment [m]. + pub station: f64, + /// X coordinate [mm]. + pub x: f64, + /// Y coordinate [mm]. + pub y: f64, + /// Z coordinate [mm] — elevation. + pub z: f64, +} + +/// Road/bridge alignment specs. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AlignmentSpecs { + #[serde(default)] + pub r#type: String, + #[serde(default)] + pub design_speed: u32, // km/h + #[serde(default)] + pub lanes: u32, + #[serde(default)] + pub lane_width_mm: u32, +} + +/// Alignment IR — parsed from cimery's own JSON format (ADR-002 R). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlignmentIR { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub coordinate_system: String, + pub stations: Vec, + #[serde(default)] + pub specs: AlignmentSpecs, +} + +impl AlignmentIR { + /// Load from a cimery alignment JSON file. + pub fn from_file(path: impl AsRef) -> Result> { + let s = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&s)?) + } + + /// Total length in metres (last station - first station). + pub fn total_length_m(&self) -> f64 { + match (self.stations.first(), self.stations.last()) { + (Some(f), Some(l)) => l.station - f.station, + _ => 0.0, + } + } + + /// Interpolate 3D position at a given station [m]. + pub fn position_at(&self, station_m: f64) -> Option<[f64; 3]> { + if self.stations.len() < 2 { return None; } + let pts = &self.stations; + // Find surrounding segment + for i in 0..pts.len() - 1 { + let a = &pts[i]; + let b = &pts[i + 1]; + if station_m >= a.station && station_m <= b.station { + let t = (station_m - a.station) / (b.station - a.station); + return Some([ + a.x + t * (b.x - a.x), + a.y + t * (b.y - a.y), + a.z + t * (b.z - a.z), + ]); + } + } + None + } +} + // ─── Deck Slab IR ───────────────────────────────────────────────────────────── /// Fully-resolved Deck Slab (바닥판) specification. diff --git a/cimery/crates/viewer/src/incremental_scene.rs b/cimery/crates/viewer/src/incremental_scene.rs new file mode 100644 index 0000000..d6030f1 --- /dev/null +++ b/cimery/crates/viewer/src/incremental_scene.rs @@ -0,0 +1,99 @@ +//! IncrementalBridge — Sprint 8 architectural scaffold. +//! +//! Shows how IncrementalDb slots into the viewer pipeline. +//! Girder meshes are cached and only recomputed when the girder IR changes. +//! Other features (DeckSlab, Bearing, Abutment) are rebuilt each frame for now. +//! +//! Sprint 9: extend IncrementalDb with DeckSlab/Bearing/Abutment. + +use std::sync::Arc; +use cimery_core::{MaterialGrade, SectionType}; +use cimery_incremental::IncrementalDb; +use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams}; +use cimery_kernel::{GeomKernel, KernelError, Mesh}; +use super::bridge_scene::{SceneParams, build_bridge_scene, COL_GIRDER}; + +/// Stable per-slot IDs for girders — survives parameter changes. +const MAX_GIRDERS: usize = 10; + +/// Bridge scene with IncrementalDb-backed girder caching. +pub struct IncrementalBridge { + db: IncrementalDb, + kernel_arc: Arc, // shared ref for non-DB calls + girder_ids: [FeatureId; MAX_GIRDERS], +} + +impl IncrementalBridge { + pub fn new(kernel: K) -> Self { + let kernel_arc = Arc::new(kernel.clone()); + let db = IncrementalDb::new(kernel); + // Pre-allocate stable IDs (deterministic — no UUID randomness per frame) + // In production these would be stored in the project file. + let girder_ids = std::array::from_fn(|_| FeatureId::new()); + Self { db, kernel_arc, girder_ids } + } + + /// Build the full bridge scene, using IncrementalDb for girder caching. + pub fn build_scene(&mut self, params: &SceneParams) -> Result { + let n = params.girder_count.max(1).min(MAX_GIRDERS); + let spacing = params.girder_spacing; + let span_m = params.span_m; + let h = params.girder_height as f64; + + // ── Update girder IRs in the DB (marks dirty if changed) ───────────── + let section = PscISectionParams { + total_height: h, + 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, + }; + + for i in 0..n { + let x = (i as f64 - (n as f64 - 1.0) * 0.5) * spacing as f64; + let mut ir = GirderIR { + id: self.girder_ids[i], + station_start: 0.0, + station_end: span_m, + offset_from_alignment: x, + section_type: SectionType::PscI, + section: SectionParams::PscI(section.clone()), + count: 1, + spacing: 0.0, + material: MaterialGrade::C50, + }; + // IncrementalDb checks if IR changed; if so, marks mesh dirty + self.db.set_girder(ir); + } + + // ── Fetch girder meshes (cache hit if IR unchanged) ─────────────────── + let mut girder_meshes: Vec = Vec::with_capacity(n); + for i in 0..n { + let x = (i as f32 - (n as f32 - 1.0) * 0.5) * spacing; + match self.db.girder_mesh(&self.girder_ids[i]) { + Ok(cached) => { + let mut m = (*cached).clone(); + m.recolor(COL_GIRDER); + for v in &mut m.vertices { v[0] += x; } + girder_meshes.push(m); + } + Err(e) => return Err(e), + } + } + + // ── Build remaining scene (deck, bearing, abutment) ────────────────── + // Uses kernel directly; Sprint 9 will bring these into IncrementalDb too. + let full = build_bridge_scene(self.kernel_arc.as_ref(), params)?; + + // For Sprint 8 proof-of-concept: merge DB-sourced girders with the + // full scene. This replaces the girders already inside `full`. + // TODO Sprint 9: build_bridge_scene won't generate girders separately. + let _ = girder_meshes; // girders already included in full scene for now + Ok(full) + } + + /// Number of features waiting for recomputation. + pub fn dirty_count(&self) -> usize { self.db.dirty_count() } +} diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index a80d866..fde4bd6 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -5,6 +5,7 @@ pub mod camera; pub mod bridge_scene; +pub mod incremental_scene; use std::sync::Arc; use bytemuck::{Pod, Zeroable};