Sprint 34 — IFC4X3 Add2 익스포터 Phase 2

Phase 1(사각 profile·월드 원점) → Phase 2(정확 단면·skew·헌치·방호벽).

## 변경
### bridge_export.rs
- `IfcSectionKind` enum: PscI / SteelBox / Rectangle.
- `BridgeExportParams` 확장:
  · section_kind, skew_deg, haunch_depth, show_parapets, show_joints.
- `write_psc_i_profile()` 신설:
  · PSC-I 14점(top/bottom flange + web + haunch) 을
    `IFCARBITRARYCLOSEDPROFILEDEF` + `IFCPOLYLINE` 으로 출력.
  · 도심 중심화 (Y 를 h/2 만큼 아래로 평행이동) → beam 중심 배치와 정합.
  · start==end 점 복제로 profile close.
- `write_girder_profile()`: section_kind 따라 PSC-I / Rectangle 분기.
- `write_local_placement_skewed()`:
  · IFCAXIS2PLACEMENT3D RefDirection = (cos θ, 0, -sin θ) 로 Y축 회전 반영.
  · 교대·피어·받침에 적용. 거더·데크는 직선 유지(precast 관례).
- 헌치: deck Y = bearing_h + girder_h + haunch_depth + slab/2.
- 방호벽: IFCRAILING .GUARDRAIL. 좌/우 2개 (500×1200 × total_mm).

### 테스트
- psc_i_profile_is_used_by_default
- parapets_present_by_default / parapets_hidden_when_disabled
- rectangle_section_skips_psc_i
- skew_rotates_ref_direction (30° → cos30≈0.866 검증)
- haunch_moves_deck_up

16개 전체 통과.

Phase 3 로드맵(후속):
- IfcAlignment + IfcLinearPlacement (선형 기반 좌표)
- Camber 반영 (solid 변형 또는 여러 extrude 단면)
- Pset_BeamCommon·Pset_BearingCommon (Property Set)
- IfcElementAssembly 로 신축이음·격벽·피어(column+capbeam) 그룹화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 17:58:23 +09:00
parent 7f14423bcd
commit 567345e933
2 changed files with 208 additions and 29 deletions

View File

@@ -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 동시 적용.

View File

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