Sprint 6/7/8 — Alignment 로더 + CSV 라운드트립 + IncrementalDb 스캐폴드

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<GirderIR> 파싱
- 테스트 3개 (template, multi-row, invalid span)

Sprint 8 (IncrementalDb 스캐폴드):
- incremental_scene.rs: IncrementalBridge<K>
- 안정적 girder ID (슬롯 기반), DB 캐시 → X-translate
- Sprint 9에서 전체 Feature IncrementalDb 통합 예정

cargo test 60개 전부 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 20:55:16 +09:00
parent 257630f64b
commit 263806b834
6 changed files with 377 additions and 0 deletions

View File

@@ -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<ParamDef> {
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<String> = 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<String> = 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<Vec<GirderIR>, 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<f64> = line.split(',')
.map(|s| s.trim().parse::<f64>().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());
}
}

View File

@@ -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;