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:
minsung
2026-04-15 12:42:21 +09:00
parent b37a50c90c
commit 471fac53b3
5 changed files with 110 additions and 10 deletions

3
cimery/.gitignore vendored
View File

@@ -1,3 +1,6 @@
/target/
Cargo.lock
.env
# Tauri auto-generated (regenerated on each build)
crates/app/gen/

View File

@@ -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 (36).
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)
}

View File

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

View File

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