Sprint 29/30 — 지점부 격벽 + 거더 솟음

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 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 14:01:58 +09:00
parent a2c6e8ee1f
commit 0013182835
4 changed files with 118 additions and 10 deletions

View File

@@ -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<K: GeomKernel>(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<K: GeomKernel>(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<K: GeomKernel>(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<K: GeomKernel>(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<K: GeomKernel>(
let mut out: Vec<FeatureMesh> = 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<K: GeomKernel>(
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<K: GeomKernel>(
}
}
// 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<K: GeomKernel>(
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<K: GeomKernel>(
// ── 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<K: GeomKernel>(
[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)
}

View File

@@ -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; }
});
// ── 표시 옵션 ─────────────────────────────────────────

View File

@@ -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,
}
}