diff --git a/PROGRESS.md b/PROGRESS.md index 04b87b8..600df4c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,13 @@ ## 타임라인 ### 2026-04-15 (계속) +- code — Sprint 34: IFC4X3 Add2 익스포터 Phase 2. 정확도·커버리지 확장. + - PSC-I 실제 14점 단면 `IFCARBITRARYCLOSEDPROFILEDEF` + `IFCPOLYLINE` 구현 (도심 중심화 Y 평행이동). `IfcSectionKind` enum 으로 단면 종류 분기. + - Skew 회전 `write_local_placement_skewed()`: `IFCAXIS2PLACEMENT3D` RefDirection 을 Y축 회전 X축으로 설정. 교대·피어·받침·신축이음에 적용. 거더·데크는 직선 유지. + - 헌치 `haunch_depth` 반영: 데크 Y 위치 = `bearing_h + girder_h + haunch_depth + slab/2`. + - 방호벽 `IFCRAILING` (좌/우) 추가. + - `BridgeExportParams` 확장: section_kind, skew_deg, haunch_depth, show_parapets, show_joints. + - 테스트 6개 추가(16개 전체 통과): PSC-I 사용 확인, 방호벽 on/off, Rectangle fallback, skew 회전 검증, haunch 반영. - 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 동시 적용. diff --git a/cimery/crates/ifc/src/bridge_export.rs b/cimery/crates/ifc/src/bridge_export.rs index 9ca890d..2fbdbfe 100644 --- a/cimery/crates/ifc/src/bridge_export.rs +++ b/cimery/crates/ifc/src/bridge_export.rs @@ -24,6 +24,14 @@ use crate::guid::new_ifc_guid; use crate::writer::{IfcWriter, Ref, lit, real, real3, ref_list}; +/// 거더 단면 종류 (Phase 2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IfcSectionKind { + PscI, + SteelBox, + Rectangle, // fallback +} + /// 익스포트 입력 파라미터 — viewer `SceneParams` 와 동일 의미지만 IFC 전용으로 /// 필요한 부분만 발췌. 의존성 방향: viewer → ifc 가 아니라 사용자가 수동 전달. #[derive(Debug, Clone)] @@ -36,6 +44,12 @@ pub struct BridgeExportParams { pub girder_height: f64, // mm pub slab_thickness: f64, // mm pub bearing_height: f64, // mm (typically 60) + // Phase 2 추가: + pub section_kind: IfcSectionKind, + pub skew_deg: f64, + pub haunch_depth: f64, // mm + pub show_parapets: bool, + pub show_joints: bool, } impl Default for BridgeExportParams { @@ -49,6 +63,11 @@ impl Default for BridgeExportParams { girder_height: 1_800.0, slab_thickness: 220.0, bearing_height: 60.0, + section_kind: IfcSectionKind::PscI, + skew_deg: 0.0, + haunch_depth: 0.0, + show_parapets: true, + show_joints: true, } } } @@ -117,12 +136,14 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { let span_count = p.span_count.max(1); let total_mm = span_mm * span_count as f64; - // Girders (span_count × girder_count) + let skew_rad = p.skew_deg.to_radians(); + + // Girders (span_count × girder_count) — Phase 2: PSC-I 실제 단면. + // 거더는 직선 유지(precast 관례) → skew 미적용. 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, @@ -130,8 +151,7 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { 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 profile = write_girder_profile(&mut w, p.section_kind, p.girder_height); let shape = write_extrude_shape(&mut w, geom_ctx, profile, span_mm); let beam = w.alloc(); w.emit( @@ -148,14 +168,15 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { } } - // Deck slab (전 구간 연속) + // Deck slab (전 구간 연속) — 헌치 깊이 반영. + let half_w = (p.girder_count as f64 - 1.0) * p.girder_spacing * 0.5 + 1_000.0; + let deck_soffit_y = p.bearing_height + p.girder_height + p.haunch_depth; { - 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, + deck_soffit_y + p.slab_thickness * 0.5, total_mm * 0.5, ); let profile = write_rect_profile(&mut w, half_w * 2.0, p.slab_thickness); @@ -174,16 +195,13 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { elements.push(slab); } - // Pier columns (interior supports only, span_count-1) + // Pier columns — skew 회전 적용. 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 col_h = p.girder_height + 5_000.0; + let placement = write_local_placement_skewed( + &mut w, world_placement, + 0.0, -col_h * 0.5, pier_z, skew_rad, ); 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); @@ -201,16 +219,13 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { elements.push(col); } - // Abutments (IfcFooting) + // Abutments — skew 회전 적용. 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 placement = write_local_placement_skewed( + &mut w, world_placement, + 0.0, -bwh * 0.5, z, skew_rad, ); let profile = write_rect_profile(&mut w, total_w, bwh); let shape = write_extrude_shape(&mut w, geom_ctx, profile, 800.0); @@ -228,17 +243,14 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { elements.push(foot); } - // Bearings (IfcBearing - IFC4X3 신규) + // Bearings (IfcBearing - IFC4X3 신규) — skew 적용. 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 placement = write_local_placement_skewed( + &mut w, world_placement, + x, 0.0, z, skew_rad, ); let profile = write_rect_profile(&mut w, 450.0, p.bearing_height); let shape = write_extrude_shape(&mut w, geom_ctx, profile, 350.0); @@ -257,6 +269,37 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { } } + // Parapets (Phase 2: 방호벽 — IfcRailing). + if p.show_parapets { + const PARAPET_H: f64 = 1_200.0; + const PARAPET_T: f64 = 500.0; + let y_center = deck_soffit_y + p.slab_thickness + PARAPET_H * 0.5; + for (idx, &x_center) in [ + half_w - PARAPET_T * 0.5, + -half_w + PARAPET_T * 0.5, + ].iter().enumerate() { + let placement = write_local_placement( + &mut w, world_placement, + x_center, y_center, total_mm * 0.5, + ); + let profile = write_rect_profile(&mut w, PARAPET_T, PARAPET_H); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, total_mm); + let rail = w.alloc(); + let side = if idx == 0 { "R" } else { "L" }; + w.emit( + rail, + &format!( + "IFCRAILING({},$,{},$,$,{},{},$,.GUARDRAIL.)", + lit(&new_ifc_guid()), + lit(&format!("Parapet {}", side)), + placement, + shape, + ), + ); + elements.push(rail); + } + } + // ── Spatial containment: Bridge contains all elements ───────────────── if !elements.is_empty() { w.write(&format!( @@ -334,6 +377,85 @@ fn write_rect_profile(w: &mut IfcWriter, x_dim: f64, y_dim: f64) -> Ref { )) } +/// PSC-I 14점 단면 — `IfcArbitraryClosedProfileDef` (Phase 2). +/// +/// 단면 중심(X=0, Y=h/2) 기준으로 평행 이동해서 profile origin 을 도심 근처로 옮김 +/// → Girder placement Y 가 거더 중심(=soffit + h/2) 일 때 정합. +/// +/// # 기본 치수 (wiki PSC-I) +/// top_flange_width=600, top_flange_thickness=150, bottom_flange_width=700, +/// bottom_flange_thickness=180, web_thickness=200, haunch=50. +fn write_psc_i_profile(w: &mut IfcWriter, h: f64) -> Ref { + let hw = 600.0 / 2.0; // top flange half width + let hbw = 700.0 / 2.0; // bottom flange half width + let hwb = 200.0 / 2.0; // web half thickness + let tft = 150.0; + let bft = 180.0; + let hch = 50.0; + // 원래 profile: Y=0 = 소핏, Y=h = 상면. + // IFC profile 중심화: Y 를 h/2 만큼 내려서 도심 ~ 원점. + let cy = h * 0.5; + let pts = [ + (-hbw, 0.0 - cy), + ( hbw, 0.0 - cy), + ( hbw, bft - cy), + ( hwb, bft - cy), + ( hwb, h - tft - hch - cy), + ( hwb + hch, h - tft - cy), + ( hw, h - tft - cy), + ( hw, h - cy), + (-hw, h - cy), + (-hw, h - tft - cy), + (-(hwb+hch), h - tft - cy), + (-hwb, h - tft - hch - cy), + (-hwb, bft - cy), + (-hbw, bft - cy), + ]; + // IfcPolyline 의 Points 는 start==end 불필요(IfcPolyline 자동 close X — IFC4X3 에서는 + // ArbitraryClosedProfileDef 의 OuterCurve 가 반드시 닫혀야 함 → 마지막 점 복제). + let mut point_refs = Vec::with_capacity(pts.len() + 1); + for (x, y) in pts.iter() { + point_refs.push(w.write(&format!("IFCCARTESIANPOINT(({},{}))", real(*x), real(*y)))); + } + point_refs.push(point_refs[0]); // close + let polyline = w.write(&format!("IFCPOLYLINE({})", ref_list(&point_refs))); + w.write(&format!( + "IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PSC-I',{})", + polyline, + )) +} + +/// 거더 종류별 profile 선택 (Phase 2). +fn write_girder_profile( + w: &mut IfcWriter, + kind: IfcSectionKind, + girder_h: f64, +) -> Ref { + match kind { + IfcSectionKind::PscI => write_psc_i_profile(w, girder_h), + IfcSectionKind::SteelBox => write_rect_profile(w, girder_h * 1.0, girder_h), + IfcSectionKind::Rectangle => write_rect_profile(w, 700.0, girder_h), + } +} + +/// Skew 회전 + 평행이동 LocalPlacement — 지점부 요소(교대·피어·받침·joint)에 적용. +/// skew_rad 는 Y축 중심 회전, pivot_z 기준 반지름 오프셋은 placement origin 에 반영. +fn write_local_placement_skewed( + w: &mut IfcWriter, + parent: Ref, + x: f64, y: f64, z: f64, + skew_rad: f64, +) -> Ref { + let c = skew_rad.cos(); + let s = skew_rad.sin(); + let pt = w.write(&format!("IFCCARTESIANPOINT({})", real3(x, y, z))); + let zd = w.write(&format!("IFCDIRECTION({})", real3(0.0, 0.0, 1.0))); + // RefDirection = skew 회전된 X 축. + let xd = w.write(&format!("IFCDIRECTION({})", real3(c, 0.0, -s))); + let axis = w.write(&format!("IFCAXIS2PLACEMENT3D({},{},{})", pt, zd, xd)); + w.write(&format!("IFCLOCALPLACEMENT({},{})", parent, axis)) +} + /// ExtrudedAreaSolid + ProductDefinitionShape 래핑 → element 의 Representation 필드. fn write_extrude_shape( w: &mut IfcWriter, @@ -403,4 +525,54 @@ mod tests { assert!(ifc.contains("DATA;")); assert!(ifc.contains("ENDSEC;")); } + + // ── Phase 2 tests ──────────────────────────────────────────────────────── + + #[test] + fn psc_i_profile_is_used_by_default() { + let ifc = export_bridge(&BridgeExportParams::default()); + assert!(ifc.contains("IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PSC-I'")); + } + + #[test] + fn parapets_present_by_default() { + let ifc = export_bridge(&BridgeExportParams::default()); + assert!(ifc.contains("IFCRAILING")); + assert_eq!(ifc.matches("IFCRAILING").count(), 2); // 좌우 + } + + #[test] + fn parapets_hidden_when_disabled() { + let p = BridgeExportParams { show_parapets: false, ..Default::default() }; + let ifc = export_bridge(&p); + assert_eq!(ifc.matches("IFCRAILING").count(), 0); + } + + #[test] + fn rectangle_section_skips_psc_i() { + let p = BridgeExportParams { + section_kind: IfcSectionKind::Rectangle, + ..Default::default() + }; + let ifc = export_bridge(&p); + assert!(!ifc.contains("IFCARBITRARYCLOSEDPROFILEDEF")); + } + + #[test] + fn skew_rotates_ref_direction() { + // skew 30° → RefDirection X 성분 = cos(30°) ≈ 0.866. + let skewed = export_bridge(&BridgeExportParams { skew_deg: 30.0, ..Default::default() }); + assert!(skewed.contains("0.866"), "expected 0.866 (cos30) in skewed output"); + // skew 0° 와는 달라야 함. + let zero = export_bridge(&BridgeExportParams { skew_deg: 0.0, ..Default::default() }); + assert_ne!(zero, skewed); + } + + #[test] + fn haunch_moves_deck_up() { + // haunch 0 vs 200 → deck slab 중심 Y 위치가 다름. ifc text diff 로 확인. + let p0 = BridgeExportParams { haunch_depth: 0.0, ..Default::default() }; + let p1 = BridgeExportParams { haunch_depth: 200.0, ..Default::default() }; + assert_ne!(export_bridge(&p0), export_bridge(&p1)); + } }