Sprint 33 — IFC4X3 Add2 익스포터 Phase 1 (cimery-ifc 신설)

P2 로드맵 (Gitea #4) 첫 단계. 교량·토목 현재 표준인 IFC4X3 Add2 로 익스포트.

## cimery-ifc 크레이트 구성
- src/guid.rs: IfcGloballyUniqueId 생성.
  UUIDv4 128비트 → buildingSMART base64 22자 인코딩.
- src/writer.rs: STEP Part21 텍스트 writer.
  IfcWriter(alloc/emit/write/finish), Ref(#N), lit, real, real3, ref_list.
  HEADER 블록(FILE_DESCRIPTION/FILE_NAME/FILE_SCHEMA='IFC4X3_ADD2') + DATA 블록.
- src/bridge_export.rs: 교량 하이레벨 API.
  BridgeExportParams(span·girder·slab·bearing 치수) + export_bridge() -> String.

## 생성 엔티티 (Phase 1)
계층:
  IfcProject
    └ IfcRelAggregates → IfcSite
        └ IfcRelAggregates → IfcBridge
            └ IfcRelContainedInSpatialStructure → elements[]

원소:
- IFCBEAM × (span_count × girder_count) — 거더
- IFCSLAB × 1 — 데크 슬래브 (전 구간 연속)
- IFCCOLUMN × (span_count-1) — 피어 기둥 (내부 지점만)
- IFCFOOTING × 2 — 교대 (양 끝)
- IFCBEARING × (span_count+1)×girder_count — IFC4X3 신규 엔티티

형상 단순화(Phase 1):
- IFCEXTRUDEDAREASOLID + IFCRECTANGLEPROFILEDEF 일률.
- Phase 2 에서 IFCARBITRARYCLOSEDPROFILEDEF 로 PSC-I 14점 단면 매핑 예정.

단위: IFCSIUNIT mm (+ radian·squareMetre·cubicMetre·second·kilogram).
배치: IfcLocalPlacement 월드 원점 기준 (선형·skew·camber 는 Phase 2).

## 테스트
10개 통과:
- guid: 22자 고정, 동일 UUID 동일 GUID, 문자셋 유효성.
- writer: shell 구조, REAL 포맷, ref_list 포맷.
- bridge_export: 단경간 핵심 엔티티 포함, 다경간 피어 개수, 거더 수 일치, STEP 구조.

## 통합
- workspace Cargo.toml: crates/ifc 추가.
- cimery-app: export_ifc_default IPC 커맨드 (tauri dialog .ifc save).
- main.rs invoke_handler 등록.

Phase 2 로드맵(후속 스프린트):
- IfcAlignment + IfcLinearPlacement (선형 좌표)
- 실제 단면 profile (PSC-I · SteelBox)
- Pset_BeamCommon·Pset_BearingCommon
- IfcPile·IfcExpansionJoint·IfcKerb(방호벽)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 17:02:51 +09:00
parent 94ce89093f
commit 7f14423bcd
10 changed files with 711 additions and 0 deletions

View File

@@ -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 명시적 매개변수화).

View File

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

View File

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

View File

@@ -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(&params);
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.

View File

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

View File

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

View File

@@ -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<Ref> = 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;"));
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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(&params);
//! 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;

View File

@@ -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<str>) -> 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<String> = 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(&[]), "()");
}
}