diff --git a/PROGRESS.md b/PROGRESS.md index bb0c43a..04b87b8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,7 @@ ## 타임라인 ### 2026-04-15 (계속) +- code — Sprint 33: IFC4X3 Add2 익스포터 Phase 1. `cimery-ifc` 크레이트 신설. STEP Part21 writer(`IfcWriter`, header+data+finish) + IfcGloballyUniqueId 생성(UUIDv4 → base64 22자) + `export_bridge()` API. 엔티티: IfcProject→IfcSite→IfcBridge 계층(IfcRelAggregates 관계) + 거더(IFCBEAM, span_count×girder_count 개) + 데크(IFCSLAB) + 피어(IFCCOLUMN, 내부 지점) + 교대(IFCFOOTING) + 받침(IFCBEARING — IFC4X3 신규 엔티티). 형상: IfcExtrudedAreaSolid + IfcRectangleProfileDef 단순화(Phase 2 에서 실제 단면). 단위: mm. 배치: IfcLocalPlacement 월드 원점 기준. 테스트 10개 통과. `cimery-app`에 `export_ifc_default` IPC 커맨드 추가. - code — Sprint 31~32: 헌치 + UI 재정리. - Sprint 31: 데크 헌치 (Haunch). `SceneParams.haunch_depth` (0~300mm) 추가. 거더 상부와 데크 soffit 사이 600mm 폭 × haunch_d 높이 블록을 거더마다 배치. 데크 위치는 `girder_h + haunch_depth + slab_thickness` 로 이동 (기존 6개 참조 일괄 수정). camber + skew 동시 적용. - Sprint 32: 속성 패널 카테고리 재정리 (누적 11개 슬라이더 섞여 혼잡). 5개 CollapsingHeader 로 분리: 상부구조·바닥판·선형/기하·하부구조·추가부재·표시. `ps!($ui, ...)` 매크로 hygiene 수정(ui 명시적 매개변수화). diff --git a/cimery/Cargo.toml b/cimery/Cargo.toml index d283c94..fcb819e 100644 --- a/cimery/Cargo.toml +++ b/cimery/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/evaluator", "crates/viewer", "crates/usd", + "crates/ifc", "crates/app", ] resolver = "2" @@ -28,6 +29,7 @@ cimery-kernel = { path = "crates/kernel" } cimery-incremental = { path = "crates/incremental" } cimery-evaluator = { path = "crates/evaluator" } cimery-usd = { path = "crates/usd" } +cimery-ifc = { path = "crates/ifc" } cimery-app = { path = "crates/app" } # Serialization diff --git a/cimery/crates/app/Cargo.toml b/cimery/crates/app/Cargo.toml index 7fa1f30..51ae8ec 100644 --- a/cimery/crates/app/Cargo.toml +++ b/cimery/crates/app/Cargo.toml @@ -19,6 +19,7 @@ cimery-kernel = { workspace = true } cimery-incremental = { workspace = true } cimery-evaluator = { workspace = true } cimery-usd = { workspace = true } +cimery-ifc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/cimery/crates/app/src/commands.rs b/cimery/crates/app/src/commands.rs index 75393cc..702f89e 100644 --- a/cimery/crates/app/src/commands.rs +++ b/cimery/crates/app/src/commands.rs @@ -226,6 +226,38 @@ pub async fn export_usd_default(app: AppHandle) -> CmdResult { } } +// ── IFC4X3 Add2 export (Sprint 33) ──────────────────────────────────────────── + +/// 기본 교량 파라미터로 IFC4X3 Add2 파일(.ifc) 생성. +#[tauri::command] +pub async fn export_ifc_default(app: AppHandle) -> CmdResult { + use tauri_plugin_dialog::DialogExt; + use cimery_ifc::{BridgeExportParams, export_bridge}; + + let path = app + .dialog() + .file() + .add_filter("IFC4X3 Add2", &["ifc"]) + .set_file_name("bridge.ifc") + .blocking_save_file(); + + let Some(p) = path else { + return CmdResult::err("cancelled"); + }; + let path_str = p.to_string(); + + let params = BridgeExportParams::default(); + let ifc_text = export_bridge(¶ms); + + match std::fs::write(&path_str, ifc_text) { + Ok(_) => { + log::info!("IFC exported: {path_str}"); + CmdResult::ok_path(path_str) + } + Err(e) => CmdResult::err(format!("write error: {e}")), + } +} + // ── CSV template ────────────────────────────────────────────────────────────── /// Generates a CSV parameter template for girder design. diff --git a/cimery/crates/app/src/main.rs b/cimery/crates/app/src/main.rs index de5cac0..38cdfee 100644 --- a/cimery/crates/app/src/main.rs +++ b/cimery/crates/app/src/main.rs @@ -47,6 +47,7 @@ fn main() { commands::open_project_dialog, commands::save_project_dialog, commands::export_usd_default, + commands::export_ifc_default, commands::export_csv_template, ]) // ── Run ───────────────────────────────────────────────────────────── diff --git a/cimery/crates/ifc/Cargo.toml b/cimery/crates/ifc/Cargo.toml new file mode 100644 index 0000000..3ff1acd --- /dev/null +++ b/cimery/crates/ifc/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cimery-ifc" +version.workspace = true +edition.workspace = true + +[dependencies] +cimery-ir = { workspace = true } +cimery-core = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } diff --git a/cimery/crates/ifc/src/bridge_export.rs b/cimery/crates/ifc/src/bridge_export.rs new file mode 100644 index 0000000..9ca890d --- /dev/null +++ b/cimery/crates/ifc/src/bridge_export.rs @@ -0,0 +1,406 @@ +//! IFC4X3 Add2 교량 익스포트 — 최상위 API. +//! +//! # 계층 (Phase 1) +//! ```text +//! IfcProject +//! └ IfcRelAggregates +//! └ IfcSite +//! └ IfcRelAggregates +//! └ IfcBridge +//! └ IfcRelContainedInSpatialStructure +//! ├ IfcBeam × N (거더) +//! ├ IfcSlab × 1 (데크) +//! ├ IfcColumn × M (피어 기둥) +//! ├ IfcFooting × 2 (교대) +//! └ IfcBearing × K (받침) +//! ``` +//! +//! # Phase 1 단순화 +//! - 형상: `IfcExtrudedAreaSolid` + `IfcRectangleProfileDef` 일률 적용. +//! 실제 PSC-I 단면·교대 복합 형상은 Phase 2. +//! - 배치: 월드 원점 기준 `IfcLocalPlacement`. +//! 선형·skew·camber 적용은 Phase 2. + +use crate::guid::new_ifc_guid; +use crate::writer::{IfcWriter, Ref, lit, real, real3, ref_list}; + +/// 익스포트 입력 파라미터 — viewer `SceneParams` 와 동일 의미지만 IFC 전용으로 +/// 필요한 부분만 발췌. 의존성 방향: viewer → ifc 가 아니라 사용자가 수동 전달. +#[derive(Debug, Clone)] +pub struct BridgeExportParams { + pub name: String, + pub span_m: f64, + pub span_count: usize, + pub girder_count: usize, + pub girder_spacing: f64, // mm + pub girder_height: f64, // mm + pub slab_thickness: f64, // mm + pub bearing_height: f64, // mm (typically 60) +} + +impl Default for BridgeExportParams { + fn default() -> Self { + Self { + name: "cimery-bridge".into(), + span_m: 40.0, + span_count: 1, + girder_count: 5, + girder_spacing: 2_500.0, + girder_height: 1_800.0, + slab_thickness: 220.0, + bearing_height: 60.0, + } + } +} + +/// 교량 전체를 IFC4X3 텍스트로 직렬화. 에러 없이 항상 성공 (내부 문자열 조립). +pub fn export_bridge(p: &BridgeExportParams) -> String { + let mut w = IfcWriter::new(&p.name); + + // ── Units, Geometric Context, World Placement ───────────────────────── + let (unit_assignment, world_placement, geom_ctx) = write_project_prelude(&mut w); + + // ── Project ─────────────────────────────────────────────────────────── + let project = w.alloc(); + w.emit( + project, + &format!( + "IFCPROJECT({},$,{},$,$,$,$,({}),{})", + lit(&new_ifc_guid()), + lit(&p.name), + geom_ctx, + unit_assignment, + ), + ); + + // ── Site ────────────────────────────────────────────────────────────── + let site = w.alloc(); + w.emit( + site, + &format!( + "IFCSITE({},$,'Site',$,$,{},$,$,.ELEMENT.,$,$,$,$,$)", + lit(&new_ifc_guid()), + world_placement, + ), + ); + + // ── Bridge (IFC4X3 IfcBridge) ───────────────────────────────────────── + let bridge = w.alloc(); + w.emit( + bridge, + &format!( + "IFCBRIDGE({},$,{},$,$,{},$,$,.ELEMENT.,.GIRDER.)", + lit(&new_ifc_guid()), + lit(&p.name), + world_placement, + ), + ); + + // Project aggregates Site, Site aggregates Bridge + w.write(&format!( + "IFCRELAGGREGATES({},$,$,$,{},({}))", + lit(&new_ifc_guid()), + project, + site, + )); + w.write(&format!( + "IFCRELAGGREGATES({},$,$,$,{},({}))", + lit(&new_ifc_guid()), + site, + bridge, + )); + + // ── Bridge elements ─────────────────────────────────────────────────── + let mut elements: Vec = Vec::new(); + + let span_mm = p.span_m * 1_000.0; + let span_count = p.span_count.max(1); + let total_mm = span_mm * span_count as f64; + + // Girders (span_count × girder_count) + for s in 0..span_count { + let z0 = span_mm * s as f64; + for i in 0..p.girder_count { + let x = (i as f64 - (p.girder_count as f64 - 1.0) * 0.5) * p.girder_spacing; + // Beam local placement: (x, BEARING_H, z0 + span/2) — centroid. + let placement = write_local_placement( + &mut w, + world_placement, + x, + p.bearing_height + p.girder_height * 0.5, + z0 + span_mm * 0.5, + ); + // Simple rectangular profile (700×girder_h) — Phase 2 에서 PSC-I 로 교체. + let profile = write_rect_profile(&mut w, 700.0, p.girder_height); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, span_mm); + let beam = w.alloc(); + w.emit( + beam, + &format!( + "IFCBEAM({},$,{},$,$,{},{},$,.BEAM.)", + lit(&new_ifc_guid()), + lit(&format!("Girder S{}-G{}", s + 1, i + 1)), + placement, + shape, + ), + ); + elements.push(beam); + } + } + + // Deck slab (전 구간 연속) + { + let half_w = (p.girder_count as f64 - 1.0) * p.girder_spacing * 0.5 + 1_000.0; + let placement = write_local_placement( + &mut w, + world_placement, + 0.0, + p.bearing_height + p.girder_height + p.slab_thickness * 0.5, + total_mm * 0.5, + ); + let profile = write_rect_profile(&mut w, half_w * 2.0, p.slab_thickness); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, total_mm); + let slab = w.alloc(); + w.emit( + slab, + &format!( + "IFCSLAB({},$,{},$,$,{},{},$,.FLOOR.)", + lit(&new_ifc_guid()), + lit("Deck Slab"), + placement, + shape, + ), + ); + elements.push(slab); + } + + // Pier columns (interior supports only, span_count-1) + for s in 1..span_count { + let pier_z = span_mm * s as f64; + let col_h = p.girder_height + 5_000.0; // column_height default + let placement = write_local_placement( + &mut w, + world_placement, + 0.0, + -col_h * 0.5, + pier_z, + ); + let profile = write_rect_profile(&mut w, 2_000.0, 2_000.0); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, col_h); + let col = w.alloc(); + w.emit( + col, + &format!( + "IFCCOLUMN({},$,{},$,$,{},{},$,.COLUMN.)", + lit(&new_ifc_guid()), + lit(&format!("Pier P{}", s)), + placement, + shape, + ), + ); + elements.push(col); + } + + // Abutments (IfcFooting) + for &(z, label) in &[(-400.0, "Abutment Start"), (total_mm + 400.0, "Abutment End")] { + let bwh = p.girder_height + p.bearing_height; + let total_w = (p.girder_count as f64 - 1.0) * p.girder_spacing + 3_000.0; + let placement = write_local_placement( + &mut w, + world_placement, + 0.0, + -bwh * 0.5, + z, + ); + let profile = write_rect_profile(&mut w, total_w, bwh); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, 800.0); + let foot = w.alloc(); + w.emit( + foot, + &format!( + "IFCFOOTING({},$,{},$,$,{},{},$,.PAD_FOOTING.)", + lit(&new_ifc_guid()), + lit(label), + placement, + shape, + ), + ); + elements.push(foot); + } + + // Bearings (IfcBearing - IFC4X3 신규) + for s in 0..=span_count { + let z = span_mm * s as f64; + for i in 0..p.girder_count { + let x = (i as f64 - (p.girder_count as f64 - 1.0) * 0.5) * p.girder_spacing; + let placement = write_local_placement( + &mut w, + world_placement, + x, + 0.0, // bearing top at Y=0 (girder soffit level) + z, + ); + let profile = write_rect_profile(&mut w, 450.0, p.bearing_height); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, 350.0); + let bear = w.alloc(); + w.emit( + bear, + &format!( + "IFCBEARING({},$,{},$,$,{},{},$,.ELASTOMERIC.)", + lit(&new_ifc_guid()), + lit(&format!("Bearing S{}-G{}", s, i + 1)), + placement, + shape, + ), + ); + elements.push(bear); + } + } + + // ── Spatial containment: Bridge contains all elements ───────────────── + if !elements.is_empty() { + w.write(&format!( + "IFCRELCONTAINEDINSPATIALSTRUCTURE({},$,'Contents','Bridge elements',{},{})", + lit(&new_ifc_guid()), + ref_list(&elements), + bridge, + )); + } + + w.finish() +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +/// Units + Origin + Geometric Context 를 한꺼번에 작성. +/// 반환: (unit_assignment, world_placement, geom_ctx) Ref. +fn write_project_prelude(w: &mut IfcWriter) -> (Ref, Ref, Ref) { + // Millimetre length unit. + let len_unit = w.write("IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.)"); + let ang_unit = w.write("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)"); + let solid_unit = w.write("IFCSIUNIT(*,.SOLIDANGLEUNIT.,$,.STERADIAN.)"); + let area_unit = w.write("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)"); + let vol_unit = w.write("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)"); + let time_unit = w.write("IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.)"); + let mass_unit = w.write("IFCSIUNIT(*,.MASSUNIT.,.KILO.,.GRAM.)"); + let unit_assignment = w.write(&format!( + "IFCUNITASSIGNMENT({})", + ref_list(&[len_unit, ang_unit, solid_unit, area_unit, vol_unit, time_unit, mass_unit]), + )); + + // World origin + X axis + Z axis. + let origin_pt = w.write(&format!("IFCCARTESIANPOINT({})", real3(0.0, 0.0, 0.0))); + let z_dir = w.write(&format!("IFCDIRECTION({})", real3(0.0, 0.0, 1.0))); + let x_dir = w.write(&format!("IFCDIRECTION({})", real3(1.0, 0.0, 0.0))); + let world_axis = w.write(&format!( + "IFCAXIS2PLACEMENT3D({},{},{})", + origin_pt, z_dir, x_dir, + )); + let world_placement = w.write(&format!( + "IFCLOCALPLACEMENT($,{})", + world_axis, + )); + + // Geometric representation context. + let geom_ctx = w.write(&format!( + "IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,{},$)", + world_axis, + )); + + (unit_assignment, world_placement, geom_ctx) +} + +/// LocalPlacement 작성 — parent + (x, y, z) 오프셋. +fn write_local_placement( + w: &mut IfcWriter, + parent: Ref, + x: f64, y: f64, z: f64, +) -> Ref { + let pt = w.write(&format!("IFCCARTESIANPOINT({})", real3(x, y, z))); + let zd = w.write(&format!("IFCDIRECTION({})", real3(0.0, 0.0, 1.0))); + let xd = w.write(&format!("IFCDIRECTION({})", real3(1.0, 0.0, 0.0))); + let axis = w.write(&format!("IFCAXIS2PLACEMENT3D({},{},{})", pt, zd, xd)); + w.write(&format!("IFCLOCALPLACEMENT({},{})", parent, axis)) +} + +/// 사각형 profile — width(X) × depth(Y) 중심 원점. +fn write_rect_profile(w: &mut IfcWriter, x_dim: f64, y_dim: f64) -> Ref { + let origin = w.write(&format!("IFCCARTESIANPOINT(({},{}))", real(0.0), real(0.0))); + let x_dir = w.write(&format!("IFCDIRECTION(({},{}))", real(1.0), real(0.0))); + let placement = w.write(&format!("IFCAXIS2PLACEMENT2D({},{})", origin, x_dir)); + w.write(&format!( + "IFCRECTANGLEPROFILEDEF(.AREA.,$,{},{},{})", + placement, real(x_dim), real(y_dim), + )) +} + +/// ExtrudedAreaSolid + ProductDefinitionShape 래핑 → element 의 Representation 필드. +fn write_extrude_shape( + w: &mut IfcWriter, + geom_ctx: Ref, + profile: Ref, + depth: f64, +) -> Ref { + let origin = w.write(&format!("IFCCARTESIANPOINT({})", real3(0.0, 0.0, 0.0))); + let z_dir = w.write(&format!("IFCDIRECTION({})", real3(0.0, 0.0, 1.0))); + let x_dir = w.write(&format!("IFCDIRECTION({})", real3(1.0, 0.0, 0.0))); + let ax3 = w.write(&format!("IFCAXIS2PLACEMENT3D({},{},{})", origin, z_dir, x_dir)); + let extrude_dir = w.write(&format!("IFCDIRECTION({})", real3(0.0, 0.0, 1.0))); + let solid = w.write(&format!( + "IFCEXTRUDEDAREASOLID({},{},{},{})", + profile, ax3, extrude_dir, real(depth), + )); + let rep = w.write(&format!( + "IFCSHAPEREPRESENTATION({},'Body','SweptSolid',({}))", + geom_ctx, solid, + )); + w.write(&format!("IFCPRODUCTDEFINITIONSHAPE($,$,({}))", rep)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_single_span_has_expected_entities() { + let p = BridgeExportParams::default(); + let ifc = export_bridge(&p); + assert!(ifc.contains("IFCPROJECT")); + assert!(ifc.contains("IFCSITE")); + assert!(ifc.contains("IFCBRIDGE")); + assert!(ifc.contains("IFCBEAM")); + assert!(ifc.contains("IFCSLAB")); + assert!(ifc.contains("IFCBEARING")); + assert!(ifc.contains("IFCFOOTING")); + assert!(ifc.contains("FILE_SCHEMA(('IFC4X3_ADD2'))")); + } + + #[test] + fn export_multi_span_has_piers() { + let p = BridgeExportParams { span_count: 3, ..Default::default() }; + let ifc = export_bridge(&p); + assert!(ifc.contains("IFCCOLUMN")); + // 피어 2개 (span_count - 1) + assert_eq!(ifc.matches("IFCCOLUMN").count(), 2); + } + + #[test] + fn girder_count_matches_param() { + let p = BridgeExportParams { + girder_count: 5, span_count: 2, ..Default::default() + }; + let ifc = export_bridge(&p); + // 5 girders × 2 spans = 10 IFCBEAM + assert_eq!(ifc.matches("IFCBEAM").count(), 10); + } + + #[test] + fn output_has_valid_step_structure() { + let ifc = export_bridge(&BridgeExportParams::default()); + assert!(ifc.starts_with("ISO-10303-21;")); + assert!(ifc.ends_with("END-ISO-10303-21;\n")); + assert!(ifc.contains("HEADER;")); + assert!(ifc.contains("DATA;")); + assert!(ifc.contains("ENDSEC;")); + } +} diff --git a/cimery/crates/ifc/src/guid.rs b/cimery/crates/ifc/src/guid.rs new file mode 100644 index 0000000..b3a266b --- /dev/null +++ b/cimery/crates/ifc/src/guid.rs @@ -0,0 +1,61 @@ +//! IfcGloballyUniqueId 생성. +//! +//! IFC 의 GlobalId 는 **base64 22자** (RFC4122 UUIDv4 128비트 → 6비트×22 = 132비트). +//! buildingSMART 가 사용하는 문자셋은 표준 base64 와 약간 다름: +//! `0-9, A-Z, a-z, _, $` (총 64개) + +use uuid::Uuid; + +const IFC_BASE64: &[u8; 64] = + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"; + +/// 새 IFC GlobalId 생성 (무작위 UUIDv4 기반). +pub fn new_ifc_guid() -> String { + ifc_guid_from_uuid(Uuid::new_v4()) +} + +/// UUID 를 IFC base64(22자) 로 인코딩. +pub fn ifc_guid_from_uuid(uuid: Uuid) -> String { + let bytes = uuid.as_bytes(); + let mut out = [0u8; 22]; + // 128-bit → 앞 2비트 잘라서 22×6=132비트, 실제로는 21×6 + 마지막 2비트. + // buildingSMART 표준 알고리즘(IfcCompressUUID): + // 첫 6비트를 최상위 2비트로 파킹하고 나머지 순차. + // 간단하게 high→low 순 스캔 후 매 6비트 인덱스로 변환 (근사). + // 정확한 압축 알고리즘은 buildingSMART C# 참조 — 여기선 뷰어 확인 용도로 + // 충분한 22자 무손실 변환을 구현 (역변환 가능). + let mut n: u128 = u128::from_be_bytes(*bytes); + for i in (0..22).rev() { + out[i] = IFC_BASE64[(n & 0x3F) as usize]; + n >>= 6; + } + // 마지막 남는 4비트(128-22*6=-4? 오히려 22*6=132 > 128 = 4비트 padding) 는 0. + String::from_utf8(out.to_vec()).expect("ASCII only") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ifc_guid_is_22_chars() { + let g = new_ifc_guid(); + assert_eq!(g.len(), 22); + } + + #[test] + fn deterministic_for_same_uuid() { + let u = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap(); + let a = ifc_guid_from_uuid(u); + let b = ifc_guid_from_uuid(u); + assert_eq!(a, b); + } + + #[test] + fn all_chars_valid_base64() { + let g = new_ifc_guid(); + for c in g.chars() { + assert!(IFC_BASE64.contains(&(c as u8)), "invalid char {c} in {g}"); + } + } +} diff --git a/cimery/crates/ifc/src/lib.rs b/cimery/crates/ifc/src/lib.rs new file mode 100644 index 0000000..7fb73a6 --- /dev/null +++ b/cimery/crates/ifc/src/lib.rs @@ -0,0 +1,31 @@ +//! cimery-ifc — IFC4X3 Add2 STEP Part21 익스포터 (Sprint 33, Phase 1). +//! +//! # 설계 +//! - **스펙:** IFC4X3_ADD2 (ISO 16739-1:2024, bSI infra 스펙 흡수 완료). +//! - **포맷:** STEP Part21 텍스트 (.ifc 확장자). +//! - **범위 Phase 1 (본 스프린트):** 프로젝트·사이트·브릿지 계층 + 거더(IfcBeam) + +//! 데크(IfcSlab) + 교각(IfcColumn) + 교대·기초(IfcFooting) + 받침(IfcBearing). +//! 형상은 `IfcExtrudedAreaSolid` + `IfcRectangleProfileDef` 단순화(Phase 2에서 +//! 실제 단면 형상으로 교체 예정). +//! - **좌표계:** `IfcLocalPlacement` (월드 원점 기준). 선형·skew 는 Phase 2. +//! - **GUID:** `IfcGloballyUniqueId` = 22자 base64 (RFC4122 UUIDv4 압축). +//! +//! # 사용 +//! ```ignore +//! let ifc_text = cimery_ifc::export_bridge(¶ms); +//! std::fs::write("bridge.ifc", ifc_text)?; +//! ``` +//! +//! # Phase 2 로드맵 +//! - IfcAlignment + IfcLinearPlacement (선형 기반 좌표) +//! - 실제 단면 profile (`IfcArbitraryClosedProfileDef` — PSC-I 14점) +//! - Property Set (`Pset_BeamCommon`, `Pset_BearingCommon`) +//! - IfcRelContainedInSpatialStructure 정리 +//! - IfcExpansionJoint, IfcPile, IfcKerb(방호벽) + +pub mod writer; +pub mod guid; +pub mod bridge_export; + +pub use bridge_export::{BridgeExportParams, export_bridge}; +pub use writer::IfcWriter; diff --git a/cimery/crates/ifc/src/writer.rs b/cimery/crates/ifc/src/writer.rs new file mode 100644 index 0000000..dd403d8 --- /dev/null +++ b/cimery/crates/ifc/src/writer.rs @@ -0,0 +1,164 @@ +//! STEP Part21 writer — IFC 엔티티 직렬화. +//! +//! # 구조 +//! ```text +//! ISO-10303-21; +//! HEADER; +//! FILE_DESCRIPTION(...); +//! FILE_NAME(...); +//! FILE_SCHEMA(('IFC4X3_ADD2')); +//! ENDSEC; +//! DATA; +//! #1 = IFCPROJECT(...); +//! #2 = IFCSITE(...); +//! ... +//! ENDSEC; +//! END-ISO-10303-21; +//! ``` +//! +//! 엔티티 ID 는 `#1`, `#2` 식 자동 증가. `alloc()` 으로 ID 할당 후 본문 작성. + +use std::fmt::Write; + +/// IFC 엔티티 참조 (`#N`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ref(pub u32); + +impl std::fmt::Display for Ref { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0) + } +} + +/// STEP Part21 스트림 작성자. +pub struct IfcWriter { + header_name: String, + next_id: u32, + data: String, // 각 엔티티 줄 누적. +} + +impl IfcWriter { + /// 빈 writer 생성. description·filename 기본값 cimery. + pub fn new(project_name: impl AsRef) -> Self { + let name = project_name.as_ref(); + Self { + header_name: format!("{name}.ifc"), + next_id: 1, + data: String::new(), + } + } + + /// 다음 자유 ID 할당. 본문 작성 전 예약만. + pub fn alloc(&mut self) -> Ref { + let id = self.next_id; + self.next_id += 1; + Ref(id) + } + + /// 이미 할당된 Ref 로 엔티티 한 줄 작성. + /// `body` 예: `"IFCPROJECT('GUID', $, 'name', $, $, $, $, (#2), #3)"` + pub fn emit(&mut self, r: Ref, body: &str) { + writeln!(self.data, "#{} = {};", r.0, body).expect("string write"); + } + + /// 할당 + 즉시 작성 (ID 를 따로 안 쓸 때). + pub fn write(&mut self, body: &str) -> Ref { + let r = self.alloc(); + self.emit(r, body); + r + } + + /// 최종 .ifc 텍스트 생성. + pub fn finish(self) -> String { + let mut out = String::new(); + out.push_str("ISO-10303-21;\n"); + out.push_str("HEADER;\n"); + writeln!( + out, + "FILE_DESCRIPTION(('ViewDefinition [BridgeViewDefinition]'), '2;1');" + ).unwrap(); + writeln!( + out, + "FILE_NAME('{}', '{}', (''), (''), 'cimery {}', 'cimery', '');", + escape_str(&self.header_name), + iso_timestamp_stub(), + env!("CARGO_PKG_VERSION"), + ).unwrap(); + out.push_str("FILE_SCHEMA(('IFC4X3_ADD2'));\n"); + out.push_str("ENDSEC;\n"); + out.push_str("DATA;\n"); + out.push_str(&self.data); + out.push_str("ENDSEC;\n"); + out.push_str("END-ISO-10303-21;\n"); + out + } +} + +/// IFC 문자열 이스케이프 — 작은따옴표 doubled, 역슬래시는 그대로. +pub fn escape_str(s: &str) -> String { + s.replace('\'', "''") +} + +/// IFC 문자열 리터럴 `'...'` 감싼 형식. +pub fn lit(s: &str) -> String { + format!("'{}'", escape_str(s)) +} + +/// IFC REAL 포맷 — 과학표기법 없이 최대 6자리 소수. +pub fn real(v: f64) -> String { + // IFC REAL 은 반드시 . 를 포함해야 함. + let s = format!("{v:.6}"); + // trailing 0 제거하되 "1." 형식은 유지. + let t = s.trim_end_matches('0'); + let t = if t.ends_with('.') { s.as_str() } else { t }; + t.to_string() +} + +/// IFC REAL 리스트 — `(x, y, z)`. +pub fn real3(x: f64, y: f64, z: f64) -> String { + format!("({}, {}, {})", real(x), real(y), real(z)) +} + +/// `(#1, #2, #3)` 형식. +pub fn ref_list(rs: &[Ref]) -> String { + let joined: Vec = rs.iter().map(|r| r.to_string()).collect(); + format!("({})", joined.join(", ")) +} + +/// build.rs 가 없으므로 임시 빌드 타임스탬프 — IFC FILE_NAME 용. +fn iso_timestamp_stub() -> &'static str { + // 엄격 IFC 검증기는 이 필드에 ISO8601 날짜를 요구하나, 대부분 뷰어는 무시. + "2026-04-15T00:00:00" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writer_produces_valid_shell() { + let mut w = IfcWriter::new("test"); + let p1 = w.write("IFCCARTESIANPOINT((0., 0., 0.))"); + let p2 = w.write("IFCCARTESIANPOINT((1., 2., 3.))"); + let out = w.finish(); + assert!(out.contains("ISO-10303-21;")); + assert!(out.contains("FILE_SCHEMA(('IFC4X3_ADD2'));")); + assert!(out.contains("#1 = IFCCARTESIANPOINT((0., 0., 0.));")); + assert!(out.contains("#2 = IFCCARTESIANPOINT((1., 2., 3.));")); + assert!(out.contains("END-ISO-10303-21;")); + let _ = (p1, p2); + } + + #[test] + fn real_formats_no_scientific() { + assert_eq!(real(0.0), "0.000000"); // 6자리 유지 (trim 후 "0." 는 fallback) + assert_eq!(real(1.5), "1.5"); + assert_eq!(real(40000.0), "40000.000000"); // trailing "." 피하려고 fallback + } + + #[test] + fn ref_list_formats_correctly() { + assert_eq!(ref_list(&[Ref(1), Ref(2), Ref(3)]), "(#1, #2, #3)"); + assert_eq!(ref_list(&[]), "()"); + } +}