Sprint 27/28 — Skew + 방호벽 + 관련 메타 갱신
Sprint 27: 경사각(Skew) 지원. - SceneParams.skew_deg (-30°~30°) 추가. - rotate_y_around_z(mesh, rad, pivot_z) 헬퍼: Y축 중심, 임의 Z pivot 회전. 정점·법선 동시 회전. - 적용 대상: 교대·교각·받침·신축이음 (각 지점 pivot_z 기준). - 거더·데크는 직선 유지 (precast 거더 스큐 교량의 일반 관례). - UI: "경사각(°)" 슬라이더. Sprint 28: 방호벽(Parapet) MVP. - 데크 양 엣지(half_w, -half_w) 에 1200mm×500mm RC 박스 전 구간 연속 배치. - Y 기준: 데크 상면 (girder_h + slab_thickness). - 색: COL_ABUTMENT 재사용 (콘크리트 브라운). - build_bridge_scene / build_selectable_scene 양쪽 추가. 선택 가능 씬에서는 "방호벽 (좌/우)" 라벨. ProjectFile v2: skew_deg 필드 (default 0.0). PROGRESS.md: Sprint 25~28 정리. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,9 @@ pub struct SceneParams {
|
||||
pub span_count: usize,
|
||||
/// 교각 형식 (Sprint 26). SingleColumn=T형, MultiColumn=π형.
|
||||
pub pier_type: PierType,
|
||||
/// 교축직각 대비 경사각 (Sprint 27) [deg]. 교대·교각·받침·신축이음에 적용.
|
||||
/// 거더·데크는 직선 유지 (precast 거더 스큐 교량의 일반 관례).
|
||||
pub skew_deg: f32,
|
||||
/// Number of girders (3–6).
|
||||
pub girder_count: usize,
|
||||
/// Girder centre-to-centre spacing [mm].
|
||||
@@ -62,6 +65,7 @@ impl Default for SceneParams {
|
||||
span_m: 40.0,
|
||||
span_count: 1,
|
||||
pier_type: PierType::SingleColumn,
|
||||
skew_deg: 0.0,
|
||||
girder_count: 5,
|
||||
girder_spacing: 2_500.0,
|
||||
girder_height: 1_800.0,
|
||||
@@ -138,6 +142,27 @@ fn translate(mut mesh: Mesh, dx: f32, dy: f32, dz: f32) -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if angle_rad.abs() < 1e-6 { return mesh; }
|
||||
let c = angle_rad.cos();
|
||||
let s = angle_rad.sin();
|
||||
for v in &mut mesh.vertices {
|
||||
let dx = v[0];
|
||||
let dz = v[2] - pivot_z;
|
||||
v[0] = c * dx + s * dz;
|
||||
v[2] = -s * dx + c * dz + pivot_z;
|
||||
}
|
||||
for n in &mut mesh.normals {
|
||||
let dx = n[0];
|
||||
let dz = n[2];
|
||||
n[0] = c * dx + s * dz;
|
||||
n[2] = -s * dx + c * dz;
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn merge(meshes: Vec<Mesh>) -> Mesh {
|
||||
cimery_kernel::sweep::merge_meshes(meshes)
|
||||
}
|
||||
@@ -228,6 +253,9 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
deck_mesh.recolor(COL_DECK);
|
||||
parts.push(translate(deck_mesh, 0.0, girder_h + p.slab_thickness, 0.0));
|
||||
|
||||
// Sprint 27: Skew rad (교대·교각·받침·신축이음에 적용. 거더·데크는 직선 유지).
|
||||
let skew_rad = p.skew_deg.to_radians();
|
||||
|
||||
// ── Bearings (모든 지점: 교대 2 + 교각 span_count-1) ──────────────────────
|
||||
const BEARING_PLAN_LEN: f32 = 350.0;
|
||||
const BEARING_PLAN_WID: f32 = 450.0;
|
||||
@@ -246,7 +274,8 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
};
|
||||
let mut mesh = kernel.bearing_mesh(&bearing_ir)?;
|
||||
mesh.recolor(COL_BEARING);
|
||||
parts.push(translate(mesh, x, 0.0, z - BEARING_PLAN_LEN * 0.5));
|
||||
let placed = translate(mesh, x, 0.0, z - BEARING_PLAN_LEN * 0.5);
|
||||
parts.push(rotate_y_around_z(placed, skew_rad, z));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,9 +288,8 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
);
|
||||
let mut mesh = kernel.pier_mesh(&pier_ir)?;
|
||||
mesh.recolor(COL_PIER);
|
||||
// pier_mesh 로컬 좌표: cap beam 상면이 Y=0 (거더 소핏 아래 bearing seat).
|
||||
// Y 오프셋은 pier_mesh 자체가 정의하는 로컬 → 변환 불필요 (0).
|
||||
parts.push(translate(mesh, 0.0, -BEARING_H, pier_z));
|
||||
let placed = translate(mesh, 0.0, -BEARING_H, pier_z);
|
||||
parts.push(rotate_y_around_z(placed, skew_rad, pier_z));
|
||||
}
|
||||
|
||||
// ── Abutments (양 끝) ─────────────────────────────────────────────────────
|
||||
@@ -269,11 +297,11 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
let total_w = (n_girders as f64 - 1.0) * spacing as f64 + 3_000.0;
|
||||
let breast_wall_h = (girder_h + BEARING_H) as f64;
|
||||
|
||||
for &(station, z) in &[(0.0f64, -800.0_f32), (total_m, total_mm)] {
|
||||
for &(station, z, pivot_z) in &[(0.0f64, -800.0_f32, 0.0_f32), (total_m, total_mm, total_mm)] {
|
||||
let abut_ir = AbutmentIR {
|
||||
id: FeatureId::new(),
|
||||
station,
|
||||
skew_angle: 0.0,
|
||||
skew_angle: p.skew_deg as f64,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: breast_wall_h,
|
||||
breast_wall_thickness: 800.0,
|
||||
@@ -288,7 +316,8 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
let mut mesh = kernel.abutment_mesh(&abut_ir)?;
|
||||
mesh.recolor(COL_ABUTMENT);
|
||||
let y = -(BEARING_H + abut_ir.breast_wall_height as f32);
|
||||
parts.push(translate(mesh, 0.0, y, z));
|
||||
let placed = translate(mesh, 0.0, y, z);
|
||||
parts.push(rotate_y_around_z(placed, skew_rad, pivot_z));
|
||||
}
|
||||
|
||||
// ── Ground plane ───────────────────────────────────────────────────────────
|
||||
@@ -360,11 +389,32 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
};
|
||||
if let Ok(mut mesh) = kernel.expansion_joint_mesh(&ej_ir) {
|
||||
mesh.recolor(COL_EXP_JOINT);
|
||||
parts.push(translate(mesh, 0.0, y_top, z));
|
||||
let placed = translate(mesh, 0.0, y_top, z);
|
||||
parts.push(rotate_y_around_z(placed, skew_rad, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parapets (Sprint 28: 방호벽) ──────────────────────────────────────────
|
||||
{
|
||||
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_width;
|
||||
for &x_center in &[x_outer - PARAPET_T * 0.5, -x_outer + PARAPET_T * 0.5] {
|
||||
let profile = vec![
|
||||
[x_center - PARAPET_T * 0.5, 0.0],
|
||||
[x_center + PARAPET_T * 0.5, 0.0],
|
||||
[x_center + PARAPET_T * 0.5, PARAPET_H],
|
||||
[x_center - PARAPET_T * 0.5, PARAPET_H],
|
||||
];
|
||||
let mut mesh = cimery_kernel::sweep::sweep_profile_flat(&profile, total_mm);
|
||||
mesh.recolor(COL_ABUTMENT);
|
||||
for v in &mut mesh.vertices { v[1] += y_base; }
|
||||
parts.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merge(parts))
|
||||
}
|
||||
|
||||
@@ -497,6 +547,9 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
for v in &mut deck.vertices { v[1] += girder_h + p.slab_thickness; }
|
||||
out.push(FeatureMesh { mesh: deck, label: "바닥판 슬래브".into() });
|
||||
|
||||
// Sprint 27: skew rad — 교대·교각·받침·신축이음에 적용.
|
||||
let skew_rad = p.skew_deg.to_radians();
|
||||
|
||||
// Bearings (모든 지점)
|
||||
const SEL_BEARING_LEN: f32 = 350.0;
|
||||
let support_zs: Vec<f32> = (0..=span_count).map(|i| span_mm * i as f32).collect();
|
||||
@@ -512,6 +565,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
let mut mesh = kernel.bearing_mesh(&bir)?;
|
||||
mesh.recolor(COL_BEARING);
|
||||
for v in &mut mesh.vertices { v[0] += x; v[2] += z - SEL_BEARING_LEN * 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) };
|
||||
@@ -529,6 +583,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
let mut mesh = kernel.pier_mesh(&pier_ir)?;
|
||||
mesh.recolor(COL_PIER);
|
||||
for v in &mut mesh.vertices { v[1] -= BEARING_H; v[2] += pier_z; }
|
||||
mesh = rotate_y_around_z(mesh, skew_rad, pier_z);
|
||||
out.push(FeatureMesh { mesh, label: format!("교각 P{}", s) });
|
||||
}
|
||||
|
||||
@@ -536,9 +591,9 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
let total_w = (n_girders as f64 - 1.0) * spacing as f64 + 3_000.0;
|
||||
let bwh = (girder_h + BEARING_H) as f64;
|
||||
let wing = WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 };
|
||||
for &(station, z) in &[(0.0f64, -800.0_f32), (total_m, total_mm)] {
|
||||
for &(station, z, pivot_z) in &[(0.0f64, -800.0_f32, 0.0_f32), (total_m, total_mm, total_mm)] {
|
||||
let air = AbutmentIR {
|
||||
id: FeatureId::new(), station, skew_angle: 0.0,
|
||||
id: FeatureId::new(), station, skew_angle: p.skew_deg as f64,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: bwh, breast_wall_thickness: 800.0,
|
||||
breast_wall_width: total_w, footing_length: 4_000.0,
|
||||
@@ -550,6 +605,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
mesh.recolor(COL_ABUTMENT);
|
||||
let y = -(BEARING_H + bwh as f32);
|
||||
for v in &mut mesh.vertices { v[1] += y; v[2] += z; }
|
||||
mesh = rotate_y_around_z(mesh, skew_rad, pivot_z);
|
||||
let side = if z < 0.0 { "시작" } else { "종점" };
|
||||
out.push(FeatureMesh { mesh, label: format!("교대 ({})", side) });
|
||||
}
|
||||
@@ -605,6 +661,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
v[1] += y_top;
|
||||
v[2] += z;
|
||||
}
|
||||
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) };
|
||||
@@ -612,6 +669,28 @@ 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; // 데크 우측 외곽
|
||||
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],
|
||||
[x_center + PARAPET_T * 0.5, 0.0],
|
||||
[x_center + PARAPET_T * 0.5, PARAPET_H],
|
||||
[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
|
||||
for v in &mut mesh.vertices { v[1] += y_base; }
|
||||
out.push(FeatureMesh { mesh, label: format!("방호벽 ({})", side_label) });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -633,6 +633,8 @@ impl RenderState {
|
||||
ui.selectable_value(&mut p.pier_type, cimery_core::PierType::MultiColumn, "π형(다주)");
|
||||
});
|
||||
if p.pier_type != prev_pt { dirty = true; }
|
||||
// Sprint 27: 경사각 (Skew)
|
||||
ps!("경사각 (°)", &mut p.skew_deg, -30.0..=30.0, 1.0);
|
||||
|
||||
ui.label("단면 형식");
|
||||
let prev_sec = p.section_type;
|
||||
|
||||
@@ -35,6 +35,9 @@ pub struct ProjectFile {
|
||||
pub span_count: usize,
|
||||
#[serde(default = "default_pier_type")]
|
||||
pub pier_type: String, // "single" | "multi"
|
||||
/// Sprint 27: 경사각 [deg]
|
||||
#[serde(default)]
|
||||
pub skew_deg: f32,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
@@ -65,6 +68,7 @@ impl ProjectFile {
|
||||
cimery_core::PierType::MultiColumn => "multi".into(),
|
||||
_ => "single".into(),
|
||||
},
|
||||
skew_deg: p.skew_deg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +92,7 @@ impl ProjectFile {
|
||||
"multi" => cimery_core::PierType::MultiColumn,
|
||||
_ => cimery_core::PierType::SingleColumn,
|
||||
},
|
||||
skew_deg: self.skew_deg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user