diff --git a/PROGRESS.md b/PROGRESS.md index 4d1fd2e..fc3aa5d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,17 @@ ## 타임라인 ### 2026-04-15 (계속) +- 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 기본값). + - Sprint 27: Skew 지원. `SceneParams.skew_deg`(-30~30°) 추가. `rotate_y_around_z()` 헬퍼로 교대·교각·받침·신축이음 Y축 회전. 거더·데크는 직선 유지(precast 관례). + - Sprint 28: 방호벽 (Parapet) 기본 형상. 데크 양 엣지에 1200mm×500mm RC 박스 전 구간 연속, Y=데크 상면 기준. + - ProjectFile v2: `span_count`·`pier_type`·`skew_deg` 필드 (v1 호환 default). + - UI 리본: "경간 수" 슬라이더, "교각 형식" T/π 선택, "경사각(°)" 슬라이더. + - 뷰어 타이틀에 build timestamp 주입(`build.rs`), stale 바이너리 판별 지원. + - Ortho 카메라 추가 (키 `O` / egui 버튼): perspective ↔ 평행 투영 토글. +- raw — raw/engineer-knowledge/cet-hmeg-pier-2021/ 신설. 한맥기술(2021) PierZainer 분석 리포트 v3 + Excel 수식 분석 + 9개 도면 + 발표/에러 리포트 인입(약 18 파일). +- wiki — 교각 6개 페이지 컴파일: [[교각 형식 분류]] · [[교각 4 레이어 구조]] · [[교각 파라미터 카탈로그]] · [[교각 자동계산 수식]] · [[교각 3점 기준좌표계]] · [[기둥 단면 형상 카탈로그]]. index.md "도메인: 교량" 카테고리 신규 6항목 추가, log.md 7건 기록. - code — Sprint 24: salsa 0.16 증분 쿼리 백엔드. `--features salsa-backend`로 활성화. `SalsaIncrementalDb` — salsa `#[query_group]` + IR Eq 지원. 기존 `IncrementalDb` (수동) 완전 보존. 동일 공개 API. 테스트 20개 전부 통과 (수동 12 + salsa 8). `cimery-ir` 전 IR 구조체에 `PartialEq` 추가 + 수동 `Eq` impl (빌더 검증 도메인). `Mesh + KernelError`도 동일. `cargo check --workspace` 0 warnings. WASM: 수동 backend 유지, salsa는 WASM 안정화 후 기본값 승격 예정. - code — Sprint 23: Tauri v2 앱 래핑. `cimery-app`에 tauri v2 + tauri-plugin-dialog 적용. `tauri.conf.json`(창 설정·번들 설정) + `capabilities/default.json` + `frontend/index.html`(런처 UI: 홈·프로젝트·USD익스포트·CSV템플릿) + `src/commands.rs`(IPC: launch_viewer·new_project·open_project_dialog·save_project_dialog·export_usd_default·export_csv_template) + `build.rs`(tauri_build). `cargo check --workspace` 0 errors. 뷰어는 same-dir 바이너리 탐색 + PATH fallback으로 사이드카 실행. `.github/workflows/release.yml` Tauri bundle 3단계(viewer→tauri-bundle→release) 워크플로로 교체. Tauri v2 앱 래핑. `cimery-app`에 tauri v2 + tauri-plugin-dialog 적용. `tauri.conf.json`(창 설정·번들 설정) + `capabilities/default.json` + `frontend/index.html`(런처 UI: 홈·프로젝트·USD익스포트·CSV템플릿) + `src/commands.rs`(IPC: launch_viewer·new_project·open_project_dialog·save_project_dialog·export_usd_default·export_csv_template) + `build.rs`(tauri_build). `cargo check --workspace` 0 errors. 뷰어는 same-dir 바이너리 탐색 + PATH fallback으로 사이드카 실행. `.github/workflows/release.yml` Tauri bundle 3단계(viewer→tauri-bundle→release) 워크플로로 교체. diff --git a/cimery/.gitignore b/cimery/.gitignore index 7265440..750178e 100644 --- a/cimery/.gitignore +++ b/cimery/.gitignore @@ -1,3 +1,6 @@ /target/ Cargo.lock .env + +# Tauri auto-generated (regenerated on each build) +crates/app/gen/ diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index 821c9ad..31c9fe5 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -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 { cimery_kernel::sweep::merge_meshes(meshes) } @@ -228,6 +253,9 @@ pub fn build_bridge_scene(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(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(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(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(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(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( 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 = (0..=span_count).map(|i| span_mm * i as f32).collect(); @@ -512,6 +565,7 @@ pub fn build_selectable_scene( 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( 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( 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( 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( 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( } } + // ── 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) } diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index ecc530f..e88b379 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -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; diff --git a/cimery/crates/viewer/src/project_file.rs b/cimery/crates/viewer/src/project_file.rs index b6d4ea7..098fa16 100644 --- a/cimery/crates/viewer/src/project_file.rs +++ b/cimery/crates/viewer/src/project_file.rs @@ -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, } }