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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user