Sprint 14~22 — egui 리본 UI + OcctKernel B-rep + 가로보/신축이음 + 선형 좌표 + USD 익스포트 + WASM + CI/CD + 테스트 4층

Sprint 14: egui TopBottomPanel 리본 + CollapsingHeader SidePanel (상부구조·추가부재·선형·프로젝트)
Sprint 15: IncrementalDb 전 Feature 타입 확장 (girder→7종), dirty-tracking 20 unit tests
Sprint 16: Gitea + GitHub Actions CI/CD (check/test/clippy/fmt + 멀티플랫폼 릴리스)
Sprint 17: AlignmentTransform + AlignmentScene — 선형 국소 프레임 → 세계 좌표 변환
Sprint 18: OcctKernel 교각(16각형 기둥+코핑) + 교대(흉벽+푸팅+날개벽) B-rep
Sprint 19: CrossBeamIR + ExpansionJointIR — IR/DSL/kernel/scene 전 계층, sweep_profile_flat_x
Sprint 20: 테스트 4층 — Layer1 insta 스냅샷(7종), Layer2 기하 불변량(19), Layer3 두-커널(7), Layer4 proptest(7) — 61 tests pass
Sprint 21: cimery-usd PureRustKernel 실제 기하 변환 + BridgeExporter 증분 캐시
Sprint 22: viewer wasm feature + wasm-bindgen/web-sys + GitHub Actions Cloudflare Pages 배포

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 08:18:06 +09:00
parent 81349c97d2
commit 1f9ca3a00f
37 changed files with 3569 additions and 259 deletions

View File

@@ -7,6 +7,7 @@ pub mod camera;
pub mod bridge_scene;
pub mod incremental_scene;
pub mod project_file;
pub mod alignment_scene; // Sprint 17
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
@@ -169,6 +170,8 @@ struct RenderState {
// Scene parameters (user-editable via egui panel)
params: SceneParams,
dirty: bool, // needs mesh rebuild
// Alignment scene (Sprint 17)
alignment_scene: alignment_scene::AlignmentScene,
// egui
egui_ctx: egui::Context,
egui_state: egui_winit::State,
@@ -420,6 +423,7 @@ impl RenderState {
scene_mx,
params,
dirty: true, // trigger initial feature build
alignment_scene: alignment_scene::AlignmentScene::none(),
egui_ctx,
egui_state,
egui_renderer,
@@ -546,95 +550,200 @@ impl RenderState {
let mut dirty = self.dirty;
let was_dirty = dirty;
let mut apply = false;
// Sprint 17: alignment display info (capture before closure)
let state_alignment_name: Option<String> = self.alignment_scene.alignment
.as_ref().map(|a| a.name.clone());
let state_alignment_len = self.alignment_scene.total_length_m();
let mut alignment_load_path: Option<std::path::PathBuf> = None;
// Sprint 14: Tab state for ribbon panels (persist across frames)
// Use a static-style approach: store active tab in params (or separate)
// For now: use a local var captured in closure — OK for per-frame UI
let full_output = self.egui_ctx.run(raw_input, |ctx| {
// ── Top ribbon bar (Sprint 14) ─────────────────────────────────
egui::TopBottomPanel::top("ribbon")
.exact_height(28.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.heading("cimery");
ui.separator();
// Quick-access toolbar buttons
if ui.small_button("E 전체뷰").clicked() {
// Handled via keyboard shortcut; duplicate here for accessibility
}
ui.separator();
let kernel_label = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRust" };
ui.small(format!("커널: {}", kernel_label));
ui.separator();
// Feature counters
ui.small(format!("피처: {}", p_features.len()));
});
});
// ── Left properties panel (Sprint 14 enhanced) ────────────────
egui::SidePanel::left("properties")
.resizable(true)
.default_width(230.0)
.min_width(240.0)
.default_width(260.0)
.show(ctx, |ui| {
ui.heading("교량 속성");
// Panel title
ui.add_space(4.0);
ui.heading("속성 패널");
ui.separator();
macro_rules! param_slider {
($label:expr, $val:expr, $range:expr, $step:expr) => {{
ui.label($label);
if ui.add(egui::Slider::new($val, $range).step_by($step)).changed() {
dirty = true;
// ── 상부구조 (Superstructure) ──────────────────────────
egui::CollapsingHeader::new("▼ 상부구조 (Superstructure)")
.default_open(true)
.show(ui, |ui| {
macro_rules! ps {
($lbl:expr, $v:expr, $r:expr, $s:expr) => {{
ui.label($lbl);
if ui.add(egui::Slider::new($v, $r).step_by($s)).changed() {
dirty = true;
}
}};
}
}};
}
ps!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0);
ps!("거더 수", &mut p.girder_count, 3..=7, 1.0);
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);
param_slider!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0);
param_slider!("거더 수", &mut p.girder_count, 3..=7, 1.0);
param_slider!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0);
param_slider!("거더 높이 (mm)",&mut p.girder_height, 1_000.0..=3_000.0, 100.0);
param_slider!("슬래브 두께(mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0);
ui.separator();
ui.label("단면 형식");
let prev_sec = p.section_type;
egui::ComboBox::from_id_salt("section_type")
.selected_text(match p.section_type {
GirderSectionType::PscI => "PSC I형",
GirderSectionType::SteelBox => "강재 박스",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut p.section_type, GirderSectionType::PscI, "PSC I형");
ui.selectable_value(&mut p.section_type, GirderSectionType::SteelBox, "강재 박스");
ui.label("단면 형식");
let prev_sec = p.section_type;
egui::ComboBox::from_id_salt("section_type")
.selected_text(match p.section_type {
GirderSectionType::PscI => "PSC I형",
GirderSectionType::SteelBox => "강재 박스",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut p.section_type, GirderSectionType::PscI, "PSC I형");
ui.selectable_value(&mut p.section_type, GirderSectionType::SteelBox, "강재 박스");
});
if p.section_type != prev_sec { dirty = true; }
});
if p.section_type != prev_sec { dirty = true; }
ui.checkbox(&mut p.show_alignment, "선형 표시");
if p.show_alignment != self.params.show_alignment { dirty = true; }
// ── Should Features (Sprint 19) ────────────────────────
egui::CollapsingHeader::new("▼ 추가 부재 (Should Features)")
.default_open(true)
.show(ui, |ui| {
let prev_cb = p.show_cross_beams;
ui.checkbox(&mut p.show_cross_beams, "가로보 (Cross Beam)");
if prev_cb != p.show_cross_beams { dirty = true; }
if p.show_cross_beams {
ui.label(" 가로보 간격 (m)");
if ui.add(egui::Slider::new(&mut p.cross_beam_interval_m, 3.0..=20.0).step_by(1.0)).changed() {
dirty = true;
}
}
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; }
});
// ── 표시 옵션 ─────────────────────────────────────────
egui::CollapsingHeader::new("▼ 표시 (Display)")
.default_open(false)
.show(ui, |ui| {
let prev_al = p.show_alignment;
ui.checkbox(&mut p.show_alignment, "선형 표시");
if prev_al != p.show_alignment { dirty = true; }
});
ui.separator();
// Apply button
if dirty {
if ui.button("▶ 적용 (Apply)").clicked() { apply = true; }
let btn = egui::Button::new("▶ 적용 (Apply)")
.fill(egui::Color32::from_rgb(50, 100, 200));
if ui.add(btn).clicked() { apply = true; }
} else {
ui.label("✓ 최신 상태");
ui.label(egui::RichText::new("✓ 최신 상태")
.color(egui::Color32::from_rgb(80, 200, 80)));
}
ui.separator();
// Project save/load
ui.label("프로젝트");
ui.horizontal(|ui| {
if ui.small_button("💾 저장").clicked() {
let pf = ProjectFile::from_params("project", &self.params);
let path = project_file::default_save_path("project");
match pf.save(&path) {
Ok(_) => log::info!("Saved to {:?}", path),
Err(e) => log::error!("Save failed: {e}"),
// ── 선형 (Alignment, Sprint 17) ────────────────────────
egui::CollapsingHeader::new("▼ 선형 (Alignment)")
.default_open(false)
.show(ui, |ui| {
let aname = state_alignment_name.as_deref().unwrap_or("없음");
ui.label(format!("파일: {}", aname));
if state_alignment_len > 0.0 {
ui.label(format!("길이: {:.0} m", state_alignment_len));
}
}
if ui.small_button("📂 불러오기").clicked() {
let path = project_file::default_save_path("project");
if let Ok(pf) = ProjectFile::load(&path) {
p = pf.to_params();
dirty = true;
apply = true;
if ui.button("📐 선형 불러오기").clicked() {
let p = std::path::Path::new("alignments/BR-001.json");
alignment_load_path = Some(p.to_path_buf());
}
}
});
});
ui.separator();
// Selected feature info
// ── 프로젝트 저장/불러오기 ──────────────────────────
egui::CollapsingHeader::new("▼ 프로젝트")
.default_open(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
if ui.button("💾 저장").clicked() {
let pf = ProjectFile::from_params("project", &self.params);
let path = project_file::default_save_path("project");
match pf.save(&path) {
Ok(_) => log::info!("Saved to {:?}", path),
Err(e) => log::error!("Save failed: {e}"),
}
}
if ui.button("📂 불러오기").clicked() {
let path = project_file::default_save_path("project");
if let Ok(pf) = ProjectFile::load(&path) {
p = pf.to_params();
dirty = true;
apply = true;
}
}
});
});
ui.separator();
// ── 선택 피처 표시 ────────────────────────────────────
if let Some(idx) = p_features.iter().position(|f| f.selected) {
ui.colored_label(egui::Color32::from_rgb(255, 170, 50),
format!("{}", p_features[idx].label));
ui.colored_label(
egui::Color32::from_rgb(255, 200, 50),
format!("▶ 선택: {}", p_features[idx].label),
);
} else {
ui.small("(클릭으로 피처 선택)");
ui.small("(클릭으로 피처 선택)");
}
ui.separator();
ui.label("카메라 단축키");
ui.small("E: 전체뷰 7: 평면도");
ui.small("1: 정면 3: 측면 Home: 아이소");
ui.small("가운데버튼: 회전 Shift+가운데: 팬");
// ── 카메라 단축키 ──────────────────────────────────────
egui::CollapsingHeader::new("▼ 단축키")
.default_open(false)
.show(ui, |ui| {
ui.small("E: 전체뷰 (ZoomExtents)");
ui.small("7: 평면도 1: 정면 3: 측면");
ui.small("Home: 아이소 뷰 4: 왼쪽");
ui.small("가운데버튼: 회전");
ui.small("Shift+가운데: 팬");
ui.small("스크롤: 줌");
ui.small("Esc: 종료");
});
});
});
self.egui_state.handle_platform_output(&self.window, full_output.platform_output);
self.params = p;
self.dirty = dirty;
// Sprint 17: load alignment file if requested
if let Some(path) = alignment_load_path {
match alignment_scene::AlignmentScene::from_file(&path) {
Ok(as_) => {
log::info!("Alignment loaded: {} ({:.0} m)", as_.name(), as_.total_length_m());
self.alignment_scene = as_;
self.dirty = true;
}
Err(e) => log::warn!("Alignment load failed: {e}"),
}
}
if apply { self.rebuild_mesh(); }
// ── 3D scene ─────────────────────────────────────────────────────────