From 0013182835698dca222d2f9c5fa287ee5e711182 Mon Sep 17 00:00:00 2001 From: minsung Date: Wed, 15 Apr 2026 14:01:58 +0900 Subject: [PATCH] =?UTF-8?q?Sprint=2029/30=20=E2=80=94=20=EC=A7=80=EC=A0=90?= =?UTF-8?q?=EB=B6=80=20=EA=B2=A9=EB=B2=BD=20+=20=EA=B1=B0=EB=8D=94=20?= =?UTF-8?q?=EC=86=9F=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 29: Diaphragm (지점부 격벽). - SceneParams.show_diaphragms 토글 (default true). - 모든 지점(교대+교각) 에서 인접 거더 사이 RC 벽 배치: · 두께(span 방향): 300mm · 높이: girder_h (거더 soffit ~ top) · 폭: spacing - 250mm (web clearance 양쪽 125mm) · 지점 Z 기준 중앙 배치, skew 회전 동시 적용 - build_bridge_scene + build_selectable_scene 양쪽 구현. - COL_DIAPHRAGM 색상 추가 (concrete 계열). - UI: "격벽 (Diaphragm)" 체크박스. Sprint 30: Camber (거더 솟음). - SceneParams.camber_mid_mm (0~200mm, step 5mm) 추가. - apply_camber_mesh(mesh, z0, z1, mid_mm) 헬퍼: u = z - z0 ∈ [0, span], y_off = 4·mid·u·(span-u)/span² (포물선). 지점(u=0 또는 u=span) 에서는 0, 중앙 u=span/2 에서 최대 mid. - 거더·데크에 경간마다 독립 적용. 다경간 교량도 경간별 정확한 solog. - UI: "솟음 (mm)" 슬라이더. ProjectFile: show_diaphragms + camber_mid_mm 필드 (default 값으로 v2 호환). Co-Authored-By: Claude Sonnet 4.6 --- PROGRESS.md | 4 + cimery/crates/viewer/src/bridge_scene.rs | 107 ++++++++++++++++++++--- cimery/crates/viewer/src/lib.rs | 7 ++ cimery/crates/viewer/src/project_file.rs | 10 +++ 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index fc3aa5d..cb9aa5d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,10 @@ ## 타임라인 ### 2026-04-15 (계속) +- code — Sprint 29~30: 거더교 MVP 추가 확장. + - Sprint 29: 지점부 격벽 (Diaphragm). `SceneParams.show_diaphragms` 토글(default true). 모든 지점(교대·교각) 에서 인접 거더 사이 RC 벽 자동 배치. 두께 300mm(span 방향), 높이 = girder_h, 폭 = spacing - 250mm(web clearance). skew 회전 동시 적용. `build_bridge_scene` + `build_selectable_scene` 양쪽. + - Sprint 30: 솟음 (Camber). `SceneParams.camber_mid_mm`(0~200mm) 추가. `apply_camber_mesh()` 헬퍼 — 경간 [z0, z1] 내 포물선 Y 오프셋 `4·mid·u·(span-u)/span²`. 거더·데크에 경간마다 독립 적용. 지점에서는 0. UI "솟음(mm)" 슬라이더. + - ProjectFile: `show_diaphragms`·`camber_mid_mm` 필드 (default 값). - code — Sprint 25~28: 거더교 MVP 완성도 보강. - Sprint 25: `build_selectable_scene` 의 `SectionType::PscI` 하드코딩 제거 → `p.section_type` 분기(PscI/SteelBox). - Sprint 26: 다경간 + 교각 배치. `SceneParams.span_count`(1~5) + `pier_type`(T형 SingleColumn / π형 MultiColumn) 추가. `span_m` 의미 변경: 경간당 길이. 씬 빌더가 경간마다 거더 세트 복제, 내부 지점에 피어 배치, 모든 지점에 받침·신축이음, 양 끝에 교대. `pier_ir_for_params()` 헬퍼(wiki Phase 1 MVP — CSB 2m·TB 2.5m 기본값). diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index 31c9fe5..770fcde 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -57,6 +57,11 @@ pub struct SceneParams { pub cross_beam_interval_m: f64, /// Show expansion joints at span ends. Sprint 19. pub show_expansion_joints: bool, + /// 지점부 격벽 표시 (Sprint 29). 모든 지점(교대+교각) 에서 거더 사이 RC 벽. + pub show_diaphragms: bool, + /// 거더 솟음 (Camber) 중앙 솟음량 [mm]. Sprint 30. + /// 거더·데크에 경간 중앙 기준 포물선 Y 오프셋 적용. 0 = 평탄. + pub camber_mid_mm: f32, } impl Default for SceneParams { @@ -75,6 +80,8 @@ impl Default for SceneParams { show_cross_beams: true, cross_beam_interval_m: 5.0, show_expansion_joints: true, + show_diaphragms: true, + camber_mid_mm: 0.0, } } } @@ -89,6 +96,7 @@ pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline pub const COL_CROSS_BEAM: [f32; 3] = [0.75, 0.73, 0.65]; // slightly lighter concrete pub const COL_EXP_JOINT: [f32; 3] = [0.20, 0.20, 0.25]; // dark steel pub const COL_PIER: [f32; 3] = [0.68, 0.64, 0.55]; // pier concrete (Sprint 26) +pub const COL_DIAPHRAGM: [f32; 3] = [0.70, 0.67, 0.58]; // diaphragm RC (Sprint 29) // ─── Pier helper (Sprint 26) ───────────────────────────────────────────────── @@ -142,6 +150,22 @@ fn translate(mut mesh: Mesh, dx: f32, dy: f32, dz: f32) -> Mesh { mesh } +/// Sprint 30: Camber(솟음) 포물선 Y 오프셋. +/// 경간 [z0, z1] 내에서 중앙에서 `mid_mm` 만큼 위로 솟음. +/// z - z0 = u ∈ [0, span]. y_off = 4 · mid · u · (span - u) / span² (중앙 u=span/2 에서 최대 = mid) +/// 경간 밖(z < z0 또는 z > z1)은 0. 거더·데크 mesh 사후 적용. +fn apply_camber_mesh(mesh: &mut Mesh, z0: f32, z1: f32, mid_mm: f32) { + if mid_mm.abs() < 1e-3 { return; } + let span = z1 - z0; + if span <= 0.0 { return; } + for v in &mut mesh.vertices { + let u = v[2] - z0; + if u > 0.0 && u < span { + v[1] += 4.0 * mid_mm * u * (span - u) / (span * span); + } + } +} + /// Sprint 27: Y 축 중심 회전 (skew). pivot (X, Y 는 무시, Z 만 사용) 기준 각도 [rad]. /// 정점·법선 모두 회전. 교대·교각·받침·신축이음에 skew 각 적용 시 사용. fn rotate_y_around_z(mut mesh: Mesh, angle_rad: f32, pivot_z: f32) -> Mesh { @@ -213,9 +237,10 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< GirderSectionType::SteelBox => SectionType::SteelBox, }; - // ── Girders (경간마다 독립 세트) ─────────────────────────────────────────── + // ── Girders (경간마다 독립 세트, camber 적용) ───────────────────────────── for s in 0..span_count { - let z_base = span_mm * s as f32; + let z_base = span_mm * s as f32; + let z_end = z_base + span_mm; let s_start = span_m * s as f64; for i in 0..n_girders { let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing; @@ -232,11 +257,13 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< }; let mut mesh = kernel.girder_mesh(&ir)?; mesh.recolor(COL_GIRDER); - parts.push(translate(mesh, x, 0.0, z_base)); + let mut placed = translate(mesh, x, 0.0, z_base); + apply_camber_mesh(&mut placed, z_base, z_end, p.camber_mid_mm); + parts.push(placed); } } - // ── Deck Slab (전 구간 연속) ─────────────────────────────────────────────── + // ── Deck Slab (전 구간 연속, 경간별 camber) ────────────────────────────── let half_width = ((n_girders as f32 - 1.0) * spacing) * 0.5 + 1_000.0; let deck_ir = DeckSlabIR { id: FeatureId::new(), @@ -251,7 +278,12 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< }; let mut deck_mesh = kernel.deck_slab_mesh(&deck_ir)?; deck_mesh.recolor(COL_DECK); - parts.push(translate(deck_mesh, 0.0, girder_h + p.slab_thickness, 0.0)); + let mut deck_placed = translate(deck_mesh, 0.0, girder_h + p.slab_thickness, 0.0); + for s in 0..span_count { + let z0 = span_mm * s as f32; + apply_camber_mesh(&mut deck_placed, z0, z0 + span_mm, p.camber_mid_mm); + } + parts.push(deck_placed); // Sprint 27: Skew rad (교대·교각·받침·신축이음에 적용. 거더·데크는 직선 유지). let skew_rad = p.skew_deg.to_radians(); @@ -415,6 +447,30 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< } } + // ── Diaphragms (Sprint 29: 지점부 격벽) ─────────────────────────────────── + // 모든 지점(교대+교각) 에서 인접 거더 사이 RC 벽. + // 높이 = girder_h, 두께(span 방향) = 300mm, 폭 = spacing - web_thickness 여유. + if p.show_diaphragms { + const DIA_THICK: f32 = 300.0; // span 방향 두께 + const WEB_CLEAR: f32 = 250.0; // 거더 web 양쪽 총 여유 (125mm×2) + let dia_w = spacing - WEB_CLEAR; + for &z in &support_zs { + for i in 0..n_girders.saturating_sub(1) { + let x_mid = ((i as f32) - (n_girders as f32 - 1.0) * 0.5 + 0.5) * spacing; + let profile = vec![ + [x_mid - dia_w * 0.5, 0.0], + [x_mid + dia_w * 0.5, 0.0], + [x_mid + dia_w * 0.5, girder_h], + [x_mid - dia_w * 0.5, girder_h], + ]; + let mut mesh = cimery_kernel::sweep::sweep_profile_flat(&profile, DIA_THICK); + mesh.recolor(COL_DIAPHRAGM); + let placed = translate(mesh, 0.0, 0.0, z - DIA_THICK * 0.5); + parts.push(rotate_y_around_z(placed, skew_rad, z)); + } + } + } + Ok(merge(parts)) } @@ -510,9 +566,10 @@ pub fn build_selectable_scene( let mut out: Vec = Vec::new(); - // Girders (경간마다 독립 세트) + // Girders (경간마다 독립 세트, camber 적용) for s in 0..span_count { let z_base = span_mm * s as f32; + let z_end = z_base + span_mm; let s_start = span_m * s as f64; for i in 0..n_girders { let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing; @@ -525,6 +582,7 @@ pub fn build_selectable_scene( let mut mesh = kernel.girder_mesh(&ir)?; mesh.recolor(COL_GIRDER); for v in &mut mesh.vertices { v[0] += x; v[2] += z_base; } + apply_camber_mesh(&mut mesh, z_base, z_end, p.camber_mid_mm); let label = if span_count > 1 { format!("거더 {}-{}", s + 1, i + 1) } else { @@ -534,7 +592,7 @@ pub fn build_selectable_scene( } } - // Deck Slab (전 구간 연속) + // Deck Slab (전 구간 연속, 경간별 camber) let half_w = ((n_girders as f32 - 1.0) * spacing) * 0.5 + 1_000.0; let deck_ir = DeckSlabIR { id: FeatureId::new(), station_start: 0.0, station_end: total_m, @@ -545,6 +603,10 @@ pub fn build_selectable_scene( let mut deck = kernel.deck_slab_mesh(&deck_ir)?; deck.recolor(COL_DECK); for v in &mut deck.vertices { v[1] += girder_h + p.slab_thickness; } + for s in 0..span_count { + let z0 = span_mm * s as f32; + apply_camber_mesh(&mut deck, z0, z0 + span_mm, p.camber_mid_mm); + } out.push(FeatureMesh { mesh: deck, label: "바닥판 슬래브".into() }); // Sprint 27: skew rad — 교대·교각·받침·신축이음에 적용. @@ -671,12 +733,11 @@ pub fn build_selectable_scene( // ── Parapets (Sprint 28: 방호벽) ─────────────────────────────────────── // 양쪽 데크 엣지를 따라 RC 방호벽 (높이 1200mm, 두께 500mm, 전 구간 연속). - // 데크 상면 (Y=girder_h+slab) 위에 서는 단순 박스. { const PARAPET_H: f32 = 1_200.0; const PARAPET_T: f32 = 500.0; let y_base = girder_h + p.slab_thickness; - let x_outer = half_w; // 데크 우측 외곽 + let x_outer = half_w; for &(x_center, side_label) in &[(x_outer - PARAPET_T * 0.5, "우"), (-x_outer + PARAPET_T * 0.5, "좌")] { let profile = vec![ [x_center - PARAPET_T * 0.5, 0.0], @@ -685,12 +746,38 @@ pub fn build_selectable_scene( [x_center - PARAPET_T * 0.5, PARAPET_H], ]; let mut mesh = cimery_kernel::sweep::sweep_profile_flat(&profile, total_mm); - mesh.recolor(COL_ABUTMENT); // parapet concrete color + mesh.recolor(COL_ABUTMENT); for v in &mut mesh.vertices { v[1] += y_base; } out.push(FeatureMesh { mesh, label: format!("방호벽 ({})", side_label) }); } } + // ── Diaphragms (Sprint 29: 지점부 격벽) ──────────────────────────────── + if p.show_diaphragms { + const DIA_THICK: f32 = 300.0; + const WEB_CLEAR: f32 = 250.0; + let dia_w = spacing - WEB_CLEAR; + for (sup_idx, &z) in support_zs.iter().enumerate() { + for i in 0..n_girders.saturating_sub(1) { + let x_mid = ((i as f32) - (n_girders as f32 - 1.0) * 0.5 + 0.5) * spacing; + let profile = vec![ + [x_mid - dia_w * 0.5, 0.0], + [x_mid + dia_w * 0.5, 0.0], + [x_mid + dia_w * 0.5, girder_h], + [x_mid - dia_w * 0.5, girder_h], + ]; + let mut mesh = cimery_kernel::sweep::sweep_profile_flat(&profile, DIA_THICK); + mesh.recolor(COL_DIAPHRAGM); + for v in &mut mesh.vertices { v[2] += z - DIA_THICK * 0.5; } + mesh = rotate_y_around_z(mesh, skew_rad, z); + let side = if sup_idx == 0 { "시작".to_string() } + else if sup_idx == span_count { "종점".to_string() } + else { format!("P{}", sup_idx) }; + out.push(FeatureMesh { mesh, label: format!("격벽 {}-{}", side, i + 1) }); + } + } + } + Ok(out) } diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index e88b379..677517d 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -635,6 +635,8 @@ impl RenderState { if p.pier_type != prev_pt { dirty = true; } // Sprint 27: 경사각 (Skew) ps!("경사각 (°)", &mut p.skew_deg, -30.0..=30.0, 1.0); + // Sprint 30: 솟음 (Camber) 중앙 솟음량 [mm] + ps!("솟음 (mm)", &mut p.camber_mid_mm, 0.0..=200.0, 5.0); ui.label("단면 형식"); let prev_sec = p.section_type; @@ -668,6 +670,11 @@ impl RenderState { let prev_ej = p.show_expansion_joints; ui.checkbox(&mut p.show_expansion_joints, "신축이음 (Exp. Joint)"); if prev_ej != p.show_expansion_joints { dirty = true; } + + // Sprint 29: 격벽 + let prev_d = p.show_diaphragms; + ui.checkbox(&mut p.show_diaphragms, "격벽 (Diaphragm)"); + if prev_d != p.show_diaphragms { dirty = true; } }); // ── 표시 옵션 ───────────────────────────────────────── diff --git a/cimery/crates/viewer/src/project_file.rs b/cimery/crates/viewer/src/project_file.rs index 098fa16..832b2b6 100644 --- a/cimery/crates/viewer/src/project_file.rs +++ b/cimery/crates/viewer/src/project_file.rs @@ -38,6 +38,12 @@ pub struct ProjectFile { /// Sprint 27: 경사각 [deg] #[serde(default)] pub skew_deg: f32, + /// Sprint 29: 격벽 표시 + #[serde(default = "default_true")] + pub show_diaphragms: bool, + /// Sprint 30: 솟음(Camber) 중앙값 [mm] + #[serde(default)] + pub camber_mid_mm: f32, } fn default_true() -> bool { true } @@ -69,6 +75,8 @@ impl ProjectFile { _ => "single".into(), }, skew_deg: p.skew_deg, + show_diaphragms: p.show_diaphragms, + camber_mid_mm: p.camber_mid_mm, } } @@ -93,6 +101,8 @@ impl ProjectFile { _ => cimery_core::PierType::SingleColumn, }, skew_deg: self.skew_deg, + show_diaphragms: self.show_diaphragms, + camber_mid_mm: self.camber_mid_mm, } }