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:
@@ -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 동시 적용.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user