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:
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
12
cimery/crates/ifc/Cargo.toml
Normal file
12
cimery/crates/ifc/Cargo.toml
Normal 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 }
|
||||
406
cimery/crates/ifc/src/bridge_export.rs
Normal file
406
cimery/crates/ifc/src/bridge_export.rs
Normal 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;"));
|
||||
}
|
||||
}
|
||||
61
cimery/crates/ifc/src/guid.rs
Normal file
61
cimery/crates/ifc/src/guid.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
31
cimery/crates/ifc/src/lib.rs
Normal file
31
cimery/crates/ifc/src/lib.rs
Normal 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(¶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;
|
||||
164
cimery/crates/ifc/src/writer.rs
Normal file
164
cimery/crates/ifc/src/writer.rs
Normal 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(&[]), "()");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user