Sprint 25/26 — 단면 분기 수정 + 다경간 + 피어 배치

Sprint 25: build_selectable_scene 의 SectionType::PscI 하드코딩 제거.
- p.section_type 에 따라 PscI / SteelBox 분기 (build_bridge_scene 과 동일 로직).
- 사용자가 단면 형식 전환 시 정상 반영.

Sprint 26: 다경간 + 교각 배치 (ParaWiki 교각 wiki Phase 1 MVP).
- SceneParams: span_count (1~5) + pier_type (SingleColumn/MultiColumn) 추가.
- span_m 의미 변경: 전체 교량 길이 → 경간당 길이. total = span_m × span_count.
- pier_ir_for_params() helper: 기본 사각 기둥·2m CSB·2.5m TB·5m column_height.
- build_bridge_scene / build_selectable_scene:
  · 거더: 경간마다 독립 세트 (span_count × girder_count 개)
  · 데크 슬래브: 전 구간 연속 1개
  · 받침: 모든 지점 (교대 2 + 교각 span_count-1)
  · 교각: 내부 지점 span_count-1 개 (새 기능)
  · 교대: 양 끝 (z=-800, z=total_mm)
  · 가로보: 경간마다 반복
  · 신축이음: 모든 지점 (선택적)
- build_background_scene + scene_extents: total_mm 기준으로 ground·alignment 길이 확장.
- project_file: version=2, span_count·pier_type 필드 추가 (v1 호환 default 값).
- UI ribbon: "경간 수" 슬라이더 + "교각 형식" T/π 선택.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 12:37:14 +09:00
parent e4ed82f8bc
commit b37a50c90c
7 changed files with 4888 additions and 112 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default cimery app capabilities","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save","dialog:allow-message"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,13 @@
//! Positions are in the same coordinate space as the girder mesh:
//! X = transverse (right = +), Y = vertical (up = +), Z = along span.
use cimery_core::{AbutmentType, BearingType, CrossBeamSection, ExpansionJointType, MaterialGrade, SectionType};
use cimery_core::{
AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType,
MaterialGrade, PierType, SectionType,
};
use cimery_ir::{
AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, FeatureId, GirderIR,
PscISectionParams, SectionParams, WingWallIR,
AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, FeatureId,
GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR,
};
use cimery_kernel::{GeomKernel, KernelError, Mesh};
@@ -26,8 +29,13 @@ pub enum GirderSectionType { PscI, SteelBox }
/// Changing any field and calling `build_bridge_scene` regenerates the mesh.
#[derive(Debug, Clone)]
pub struct SceneParams {
/// Girder span [m]. Range 2080 m.
/// Length of ONE span [m]. Range 2080 m. Sprint 26 의미 변경: 경간 = 교각·교대
/// 간 한 구간의 길이 (전체 교량 길이 = span_m × span_count).
pub span_m: f64,
/// 경간 수 (Sprint 26). 1 = 단경간(피어 없음), 2+ = 다경간(내부에 피어 배치).
pub span_count: usize,
/// 교각 형식 (Sprint 26). SingleColumn=T형, MultiColumn=π형.
pub pier_type: PierType,
/// Number of girders (36).
pub girder_count: usize,
/// Girder centre-to-centre spacing [mm].
@@ -52,6 +60,8 @@ impl Default for SceneParams {
fn default() -> Self {
Self {
span_m: 40.0,
span_count: 1,
pier_type: PierType::SingleColumn,
girder_count: 5,
girder_spacing: 2_500.0,
girder_height: 1_800.0,
@@ -74,6 +84,48 @@ pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground
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)
// ─── Pier helper (Sprint 26) ─────────────────────────────────────────────────
/// 교각 IR 생성 — MVP Phase 1 (wiki 파라미터 카탈로그 30% 입력만 반영).
/// 단면 직사각형 고정, CSB/CSL 은 기본값. 컬럼 수는 pier_type 으로 결정.
pub fn pier_ir_for_params(
station_m: f64,
n_girders: usize,
spacing_mm: f32,
girder_h_mm: f32,
pier_type: PierType,
) -> PierIR {
let n = n_girders as f64;
let total_trans = (n - 1.0) * spacing_mm as f64 + 2_000.0;
let column_count = match pier_type {
PierType::SingleColumn => 1,
PierType::MultiColumn => 2,
_ => 1,
};
let col_spacing = if column_count > 1 { total_trans / 2.0 } else { 0.0 };
PierIR {
id: FeatureId::new(),
station: station_m,
skew_angle: 0.0,
pier_type,
column_shape: ColumnShape::Rectangular,
column_count: column_count as u32,
column_spacing: col_spacing,
column_diameter: 2_000.0, // wiki CSB 매트릭스 (9-13k × ≤15k 영역)
column_depth: 2_000.0,
column_height: girder_h_mm as f64 + 5_000.0,
cap_beam: CapBeamIR {
length: total_trans,
width: 2_500.0, // wiki TB ≥ 2500
depth: 1_500.0,
cantilever_left: 800.0,
cantilever_right: 800.0,
},
material: MaterialGrade::C40,
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -93,9 +145,13 @@ fn merge(meshes: Vec<Mesh>) -> Mesh {
// ─── Scene builder ────────────────────────────────────────────────────────────
/// Build a complete bridge scene mesh using the provided kernel and parameters.
/// Sprint 26: 다경간 + 피어 배치 지원 (span_count 로 제어).
pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<Mesh, KernelError> {
let span_m = p.span_m;
let span_mm = (p.span_m * 1_000.0) as f32;
let span_count = p.span_count.max(1).min(5);
let total_m = span_m * span_count as f64;
let total_mm = span_mm * span_count as f32;
let n_girders = p.girder_count.max(1).min(10);
let spacing = p.girder_spacing;
let girder_h = p.girder_height;
@@ -132,13 +188,16 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
GirderSectionType::SteelBox => SectionType::SteelBox,
};
// ── Girders ────────────────────────────────────────────────────────────────
// ── Girders (경간마다 독립 세트) ───────────────────────────────────────────
for s in 0..span_count {
let z_base = span_mm * s as f32;
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;
let ir = GirderIR {
id: FeatureId::new(),
station_start: 0.0,
station_end: span_m,
station_start: s_start,
station_end: s_start + span_m,
offset_from_alignment: x as f64,
section_type: section_type_enum,
section: section_enum.clone(),
@@ -148,16 +207,16 @@ 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, 0.0));
parts.push(translate(mesh, x, 0.0, z_base));
}
}
// ── Deck Slab ──────────────────────────────────────────────────────────────
// KDS: min 220 mm, width = (N-1)*spacing + 2 × cantilever
let half_width = ((n_girders as f32 - 1.0) * spacing) * 0.5 + 1_000.0; // 1 m cantilever
// ── Deck Slab (전 구간 연속) ───────────────────────────────────────────────
let half_width = ((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: span_m,
station_end: total_m,
width_left: half_width as f64,
width_right: half_width as f64,
thickness: p.slab_thickness as f64,
@@ -169,17 +228,16 @@ 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));
// ── Bearings ───────────────────────────────────────────────────────────────
// 5 per abutment, one under each girder
// plan_length(350mm) = 경간 방향 → half = 175mm 으로 Z 센터링.
// ── Bearings (모든 지점: 교대 2 + 교각 span_count-1) ──────────────────────
const BEARING_PLAN_LEN: f32 = 350.0;
const BEARING_PLAN_WID: f32 = 450.0;
for &z in &[0.0_f32, span_mm] {
let support_zs: Vec<f32> = (0..=span_count).map(|i| span_mm * i as f32).collect();
for &z in &support_zs {
for i in 0..n_girders {
let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing;
let bearing_ir = BearingIR {
id: FeatureId::new(),
station: if z < 1.0 { 0.0 } else { span_m },
station: z as f64 / 1_000.0,
bearing_type: BearingType::Elastomeric,
plan_length: BEARING_PLAN_LEN as f64,
plan_width: BEARING_PLAN_WID as f64,
@@ -188,17 +246,30 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
};
let mut mesh = kernel.bearing_mesh(&bearing_ir)?;
mesh.recolor(COL_BEARING);
// Z 중심 = 교대 위치(z). bearing mesh Z = [0, plan_length] → 오프셋 = z - plan_length/2.
parts.push(translate(mesh, x, 0.0, z - BEARING_PLAN_LEN * 0.5));
}
}
// ── Abutments ──────────────────────────────────────────────────────────────
// ── Piers (내부 지점 span_count-1 개, Sprint 26) ──────────────────────────
for s in 1..span_count {
let pier_station_m = span_m * s as f64;
let pier_z = span_mm * s as f32;
let pier_ir = pier_ir_for_params(
pier_station_m, n_girders, spacing, girder_h, p.pier_type,
);
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));
}
// ── Abutments (양 끝) ─────────────────────────────────────────────────────
let wing = WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 };
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), (span_m, span_mm)] {
for &(station, z) in &[(0.0f64, -800.0_f32), (total_m, total_mm)] {
let abut_ir = AbutmentIR {
id: FeatureId::new(),
station,
@@ -222,30 +293,30 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
// ── Ground plane ───────────────────────────────────────────────────────────
{
let ground_y = -(BEARING_H + breast_wall_h as f32 + 1_000.0 + 200.0); // footing bottom - margin
let hw = total_w as f32 * 0.5 + 8_000.0; // wider than bridge
let half_z = span_mm * 0.5 + 8_000.0;
let ground_y = -(BEARING_H + breast_wall_h as f32 + 1_000.0 + 200.0);
let hw = total_w as f32 * 0.5 + 8_000.0;
let half_z = total_mm * 0.5 + 8_000.0;
let thickness = 500.0_f32;
let profile = vec![
[-hw, -thickness], [hw, -thickness], [hw, 0.0], [-hw, 0.0],
];
let mut ground = cimery_kernel::sweep::sweep_profile_flat(&profile, half_z * 2.0);
ground.recolor(COL_GROUND);
parts.push(translate(ground, 0.0, ground_y, -half_z));
parts.push(translate(ground, 0.0, ground_y, total_mm * 0.5 - half_z));
}
// ── Alignment centreline (optional) ───────────────────────────────────────
if p.show_alignment {
let radius = 80.0_f32;
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, span_mm);
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, total_mm);
align.recolor(COL_ALIGNMENT);
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
}
// ── Cross Beams (Sprint 19) ────────────────────────────────────────────────
// ── Cross Beams (경간마다) ────────────────────────────────────────────────
if p.show_cross_beams {
let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32;
let num_beams = (span_mm / interval_mm).floor() as usize;
let num_per_span = (span_mm / interval_mm).floor() as usize;
let cb_ir_base = CrossBeamIR {
id: FeatureId::new(),
station: 0.0,
@@ -258,8 +329,10 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
girder_spacing: spacing as f64,
material: MaterialGrade::C50,
};
for i in 0..num_beams {
let z = interval_mm * (i as f32 + 1.0);
for s in 0..span_count {
let z_base = span_mm * s as f32;
for i in 0..num_per_span {
let z = z_base + interval_mm * (i as f32 + 1.0);
let mut ir = cb_ir_base.clone();
ir.id = FeatureId::new();
ir.station = z as f64 / 1_000.0;
@@ -269,15 +342,16 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
}
}
}
}
// ── Expansion Joints (Sprint 19) ──────────────────────────────────────────
// ── Expansion Joints (양 교대 + 내부 피어 위치) ──────────────────────────
if p.show_expansion_joints {
let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64;
let y_top = girder_h + p.slab_thickness;
for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] {
for &z in &support_zs {
let ej_ir = ExpansionJointIR {
id: FeatureId::new(),
station,
station: z as f64 / 1_000.0,
joint_type: ExpansionJointType::RubberType,
gap_width: 50.0,
total_width: deck_w,
@@ -297,8 +371,11 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
// ─── Background scene (ground + alignment only, no features) ─────────────────
/// Returns only the non-selectable background elements: ground plane + alignment line.
/// Sprint 26: 다경간 지원 — ground·alignment 가 전체 교량 길이 기준.
pub fn build_background_scene(p: &SceneParams) -> Mesh {
let span_mm = (p.span_m * 1_000.0) as f32;
let span_count = p.span_count.max(1).min(5);
let total_mm = span_mm * span_count as f32;
let girder_h = p.girder_height;
let n_girders = p.girder_count.max(1).min(10);
let spacing = p.girder_spacing;
@@ -309,17 +386,17 @@ pub fn build_background_scene(p: &SceneParams) -> Mesh {
let mut parts: Vec<Mesh> = Vec::new();
// Ground
// Ground — 전체 교량 길이 + 양끝 8m 여유
let hw = total_w as f32 * 0.5 + 8_000.0;
let half_z = span_mm * 0.5 + 8_000.0;
let half_z = total_mm * 0.5 + 8_000.0;
let profile = vec![[-hw, -500.0_f32], [hw, -500.0], [hw, 0.0], [-hw, 0.0]];
let mut g = cimery_kernel::sweep::sweep_profile_flat(&profile, half_z * 2.0);
g.recolor(COL_GROUND);
parts.push(translate(g, 0.0, ground_y, -half_z));
parts.push(translate(g, 0.0, ground_y, total_mm * 0.5 - half_z));
// Alignment
// Alignment — 전체 교량 길이
if p.show_alignment {
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, 80.0, 8, span_mm);
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, 80.0, 8, total_mm);
align.recolor(COL_ALIGNMENT);
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
}
@@ -346,7 +423,10 @@ pub fn build_selectable_scene<K: GeomKernel>(
let girder_h = p.girder_height;
const BEARING_H: f32 = 60.0;
let section = PscISectionParams {
// Sprint 25: 단면 타입 하드코딩 제거. p.section_type 에 따라 PscI / SteelBox 분기.
let (section_enum, section_type_enum) = match p.section_type {
GirderSectionType::PscI => (
SectionParams::PscI(PscISectionParams {
total_height: girder_h as f64,
top_flange_width: 600.0,
top_flange_thickness: 150.0,
@@ -354,29 +434,60 @@ pub fn build_selectable_scene<K: GeomKernel>(
bottom_flange_thickness: 180.0,
web_thickness: 200.0,
haunch: 50.0,
}),
SectionType::PscI,
),
GirderSectionType::SteelBox => {
use cimery_ir::SteelBoxParams;
let h = girder_h as f64;
(
SectionParams::SteelBox(SteelBoxParams {
total_height: h,
top_width: h * 1.2,
bottom_width: h * 0.8,
web_thickness: 20.0,
top_flange_thickness: 25.0,
bottom_flange_thickness: 22.0,
}),
SectionType::SteelBox,
)
}
};
let span_count = p.span_count.max(1).min(5);
let total_m = span_m * span_count as f64;
let total_mm = span_mm * span_count as f32;
let mut out: Vec<FeatureMesh> = Vec::new();
// Girders
// Girders (경간마다 독립 세트)
for s in 0..span_count {
let z_base = span_mm * s as f32;
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;
let ir = GirderIR {
id: FeatureId::new(), station_start: 0.0, station_end: span_m,
offset_from_alignment: x as f64, section_type: SectionType::PscI,
section: SectionParams::PscI(section.clone()),
id: FeatureId::new(), station_start: s_start, station_end: s_start + span_m,
offset_from_alignment: x as f64, section_type: section_type_enum,
section: section_enum.clone(),
count: 1, spacing: 0.0, material: MaterialGrade::C50,
};
let mut mesh = kernel.girder_mesh(&ir)?;
mesh.recolor(COL_GIRDER);
for v in &mut mesh.vertices { v[0] += x; }
out.push(FeatureMesh { mesh, label: format!("거더 {}", i + 1) });
for v in &mut mesh.vertices { v[0] += x; v[2] += z_base; }
let label = if span_count > 1 {
format!("거더 {}-{}", s + 1, i + 1)
} else {
format!("거더 {}", i + 1)
};
out.push(FeatureMesh { mesh, label });
}
}
// Deck Slab (one unit)
// Deck Slab (전 구간 연속)
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: span_m,
id: FeatureId::new(), station_start: 0.0, station_end: total_m,
width_left: half_w as f64, width_right: half_w as f64,
thickness: p.slab_thickness as f64, haunch_depth: 0.0,
cross_slope: 2.0, material: MaterialGrade::C40,
@@ -386,14 +497,14 @@ 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() });
// Bearings
// plan_length(350mm) = 경간 방향 → Z 센터링 오프셋 = z - 175.
// Bearings (모든 지점)
const SEL_BEARING_LEN: f32 = 350.0;
for &z in &[0.0_f32, span_mm] {
let support_zs: Vec<f32> = (0..=span_count).map(|i| span_mm * i as f32).collect();
for (sup_idx, &z) in support_zs.iter().enumerate() {
for i in 0..n_girders {
let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing;
let bir = BearingIR {
id: FeatureId::new(), station: if z < 1.0 { 0.0 } else { span_m },
id: FeatureId::new(), station: z as f64 / 1_000.0,
bearing_type: BearingType::Elastomeric,
plan_length: SEL_BEARING_LEN as f64, plan_width: 450.0,
total_height: BEARING_H as f64, capacity_vertical: 1_500.0,
@@ -401,16 +512,31 @@ 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; }
let side = if z < 1.0 { "시작" } else { "종점" };
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) });
}
}
// Abutments
// Piers (내부 지점, Sprint 26)
for s in 1..span_count {
let pier_station_m = span_m * s as f64;
let pier_z = span_mm * s as f32;
let pier_ir = pier_ir_for_params(
pier_station_m, n_girders, spacing, girder_h, p.pier_type,
);
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; }
out.push(FeatureMesh { mesh, label: format!("교각 P{}", s) });
}
// Abutments (양 끝)
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), (span_m, span_mm)] {
for &(station, z) in &[(0.0f64, -800.0_f32), (total_m, total_mm)] {
let air = AbutmentIR {
id: FeatureId::new(), station, skew_angle: 0.0,
abutment_type: AbutmentType::ReverseT,
@@ -428,10 +554,10 @@ pub fn build_selectable_scene<K: GeomKernel>(
out.push(FeatureMesh { mesh, label: format!("교대 ({})", side) });
}
// ── Cross Beams (Sprint 19) ────────────────────────────────────────────
// Cross Beams (경간마다)
if p.show_cross_beams {
let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32;
let num_beams = (span_mm / interval_mm).floor() as usize;
let num_per_span = (span_mm / interval_mm).floor() as usize;
let cb_ir_base = CrossBeamIR {
id: FeatureId::new(),
station: 0.0,
@@ -444,29 +570,28 @@ pub fn build_selectable_scene<K: GeomKernel>(
girder_spacing: spacing as f64,
material: MaterialGrade::C50,
};
for i in 0..num_beams {
let z = interval_mm * (i as f32 + 1.0);
for s in 0..span_count {
let z_base = span_mm * s as f32;
for i in 0..num_per_span {
let z = z_base + interval_mm * (i as f32 + 1.0);
let mut ir = cb_ir_base.clone();
ir.id = FeatureId::new();
ir.station = z as f64 / 1_000.0;
let mut mesh = kernel.cross_beam_mesh(&ir)?;
mesh.recolor(COL_CROSS_BEAM);
let half = ir.total_length_mm() as f32 * 0.5;
for v in &mut mesh.vertices {
v[2] += z;
}
let _ = half; // translation already applied in cross_beam builder
for v in &mut mesh.vertices { v[2] += z; }
out.push(FeatureMesh { mesh, label: format!("가로보 @ {:.0}m", z / 1_000.0) });
}
}
}
// ── Expansion Joints (Sprint 19) ───────────────────────────────────────
// ── Expansion Joints (모든 지점) ───────────────────────────────────────
if p.show_expansion_joints {
let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64;
for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] {
for (sup_idx, &z) in support_zs.iter().enumerate() {
let ej_ir = ExpansionJointIR {
id: FeatureId::new(),
station,
station: z as f64 / 1_000.0,
joint_type: ExpansionJointType::RubberType,
gap_width: 50.0,
total_width: deck_w,
@@ -480,7 +605,9 @@ pub fn build_selectable_scene<K: GeomKernel>(
v[1] += y_top;
v[2] += z;
}
let side = if z < 1.0 { "시작" } else { "종점" };
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) });
}
}
@@ -489,10 +616,11 @@ pub fn build_selectable_scene<K: GeomKernel>(
}
/// Bounding box of the full bridge scene (for camera setup).
/// Sprint 26: 다경간 지원 → Z 범위가 span_m × span_count.
pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) {
let span_mm = (p.span_m * 1_000.0) as f32;
let total_mm = (p.span_m * 1_000.0) as f32 * p.span_count.max(1) as f32;
let half_w = ((p.girder_count as f32 - 1.0) * p.girder_spacing * 0.5 + 2_000.0).max(5_000.0);
let top_y = p.girder_height + p.slab_thickness + 200.0;
let bot_y = -(p.girder_height + 3_000.0 + 1_000.0);
([-half_w, bot_y, -2_000.0], [half_w, top_y, span_mm + 2_000.0])
([-half_w, bot_y, -2_000.0], [half_w, top_y, total_mm + 2_000.0])
}

View File

@@ -624,6 +624,15 @@ impl RenderState {
ps!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0);
ps!("거더 높이 (mm)", &mut p.girder_height, 1_000.0..=3_000.0, 100.0);
ps!("슬래브 두께 (mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0);
// Sprint 26: 다경간 지원
ps!("경간 수", &mut p.span_count, 1..=5, 1.0);
ui.label("교각 형식");
let prev_pt = p.pier_type;
ui.horizontal(|ui| {
ui.selectable_value(&mut p.pier_type, cimery_core::PierType::SingleColumn, "T형(단주)");
ui.selectable_value(&mut p.pier_type, cimery_core::PierType::MultiColumn, "π형(다주)");
});
if p.pier_type != prev_pt { dirty = true; }
ui.label("단면 형식");
let prev_sec = p.section_type;

View File

@@ -30,15 +30,22 @@ pub struct ProjectFile {
pub cross_beam_interval_m: f64,
#[serde(default = "default_true")]
pub show_expansion_joints: bool,
/// Sprint 26: 다경간
#[serde(default = "default_span_count")]
pub span_count: usize,
#[serde(default = "default_pier_type")]
pub pier_type: String, // "single" | "multi"
}
fn default_true() -> bool { true }
fn default_cross_beam_interval() -> f64 { 5.0 }
fn default_span_count() -> usize { 1 }
fn default_pier_type() -> String { "single".into() }
impl ProjectFile {
pub fn from_params(name: &str, p: &SceneParams) -> Self {
Self {
version: 1,
version: 2,
name: name.to_owned(),
span_m: p.span_m,
girder_count: p.girder_count,
@@ -53,6 +60,11 @@ impl ProjectFile {
show_cross_beams: p.show_cross_beams,
cross_beam_interval_m: p.cross_beam_interval_m,
show_expansion_joints: p.show_expansion_joints,
span_count: p.span_count,
pier_type: match p.pier_type {
cimery_core::PierType::MultiColumn => "multi".into(),
_ => "single".into(),
},
}
}
@@ -71,6 +83,11 @@ impl ProjectFile {
show_cross_beams: self.show_cross_beams,
cross_beam_interval_m: self.cross_beam_interval_m,
show_expansion_joints: self.show_expansion_joints,
span_count: self.span_count,
pier_type: match self.pier_type.as_str() {
"multi" => cimery_core::PierType::MultiColumn,
_ => cimery_core::PierType::SingleColumn,
},
}
}