Sprint 11/12/13 — 선택하이라이트 + 저장/로드 + Tauri앱 스켈레톤

Sprint 11 (Selection highlight + 단면 UI):
- FeatureDraw: CPU 정점 저장, update_highlight() — 선택 시 yellow-orange
- 렌더 루프: background mesh(지면+선형) + 피처별 독립 draw call 분리
- SceneParams: GirderSectionType (PscI / SteelBox), show_alignment
- egui: 단면형식 ComboBox, 선형표시 checkbox
- SteelBox 단면 지원 (span 비례 자동 치수)
- build_background_scene(): 지면+선형만 반환

Sprint 12 (Project save/load):
- project_file.rs: ProjectFile struct, to_params/from_params, save/load JSON
- egui: 💾 저장 / 📂 불러오기 버튼
- projects/ 폴더 자동 생성

Sprint 13 (Tauri app skeleton):
- crates/app/: Cargo.toml + main.rs (Tauri v2 통합 scaffold)
- 기동 시 PureRustKernel 동작 검증
- Tauri setup checklist 주석으로 문서화
- workspace에 cimery-app 추가

cargo check --workspace 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 22:59:11 +09:00
parent 5d89db5117
commit 81349c97d2
7 changed files with 339 additions and 32 deletions

View File

@@ -6,6 +6,7 @@
pub mod camera;
pub mod bridge_scene;
pub mod incremental_scene;
pub mod project_file;
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
@@ -23,7 +24,11 @@ use cimery_kernel::OcctKernel;
use cimery_kernel::PureRustKernel;
use camera::{Camera, StandardView};
use glam;
use bridge_scene::{SceneParams, build_bridge_scene, build_selectable_scene, scene_extents};
use bridge_scene::{
GirderSectionType, SceneParams,
build_bridge_scene, build_selectable_scene, build_background_scene, scene_extents,
};
use project_file::ProjectFile;
// ─── Vertex ───────────────────────────────────────────────────────────────────
@@ -56,41 +61,63 @@ const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
// ─── Per-feature draw unit ───────────────────────────────────────────────────
/// One selectable draw unit: a single Feature's GPU buffers + AABB.
/// Selection highlight colour (yellow-orange).
const COL_HIGHLIGHT: [f32; 3] = [1.0, 0.78, 0.10];
/// One selectable draw unit: per-feature GPU buffers + AABB + highlight support.
struct FeatureDraw {
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
num_indices: u32,
aabb_min: [f32; 3],
aabb_max: [f32; 3],
label: String, // e.g. "거더 2", "교대 (시작)"
label: String,
selected: bool,
base_color: [f32; 3], // original colour (for deselect)
base_color: [f32; 3],
/// CPU copy of vertices for color rebuild.
cpu_verts: Vec<Vertex>,
}
impl FeatureDraw {
fn from_mesh(device: &wgpu::Device, mesh: &cimery_kernel::Mesh, label: &str) -> Self {
let base = if mesh.colors.is_empty() { [0.8_f32, 0.76, 0.65] } else { mesh.colors[0] };
let verts: Vec<Vertex> = mesh.vertices.iter()
.zip(mesh.normals.iter()).zip(mesh.colors.iter())
.map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c })
.collect();
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("feature vbuf"), contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("feature ibuf"), contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
let (mn, mx) = mesh.aabb();
let base = if mesh.colors.is_empty() { [0.8_f32, 0.76, 0.65] } else { mesh.colors[0] };
Self {
vertex_buffer: vbuf, index_buffer: ibuf,
num_indices: mesh.indices.len() as u32,
aabb_min: mn, aabb_max: mx, label: label.to_owned(),
selected: false, base_color: base,
aabb_min: mn, aabb_max: mx,
label: label.to_owned(), selected: false, base_color: base,
cpu_verts: verts,
}
}
/// Apply selection highlight or restore base colour.
fn update_highlight(&self, queue: &wgpu::Queue) {
let color = if self.selected {
let b = self.base_color;
[b[0] * 0.2 + COL_HIGHLIGHT[0] * 0.8,
b[1] * 0.2 + COL_HIGHLIGHT[1] * 0.8,
b[2] * 0.2 + COL_HIGHLIGHT[2] * 0.8]
} else {
self.base_color
};
let new_verts: Vec<Vertex> = self.cpu_verts.iter()
.map(|v| Vertex { position: v.position, normal: v.normal, base_color: color })
.collect();
queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&new_verts));
}
}
// ─── Ray casting ─────────────────────────────────────────────────────────────
@@ -425,11 +452,9 @@ impl RenderState {
/// Rebuild GPU buffers from current SceneParams.
fn rebuild_mesh(&mut self) {
// Build merged mesh (ground + alignment, for background draw)
#[cfg(feature = "occt")]
let full_mesh = build_bridge_scene(&OcctKernel, &self.params);
#[cfg(not(feature = "occt"))]
let full_mesh = build_bridge_scene(&PureRustKernel, &self.params);
// Background mesh: ground + alignment only (no features — avoids double draw)
let full_mesh: Result<cimery_kernel::Mesh, cimery_kernel::KernelError> =
Ok(build_background_scene(&self.params));
if let Ok(mesh) = full_mesh {
let verts: Vec<Vertex> = mesh.vertices.iter()
@@ -508,9 +533,11 @@ impl RenderState {
if best.map_or(true, |(bt, _)| t < bt) { best = Some((t, i)); }
}
}
// Update selection
// Update selection + apply highlight
for feat in &mut self.features { feat.selected = false; }
if let Some((_, idx)) = best { self.features[idx].selected = true; }
// Rewrite GPU vertex colours for all affected features
for feat in &self.features { feat.update_highlight(&self.queue); }
}
let raw_input = self.egui_state.take_egui_input(&self.window);
@@ -543,6 +570,23 @@ impl RenderState {
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, "강재 박스");
});
if p.section_type != prev_sec { dirty = true; }
ui.checkbox(&mut p.show_alignment, "선형 표시");
if p.show_alignment != self.params.show_alignment { dirty = true; }
ui.separator();
if dirty {
if ui.button("▶ 적용 (Apply)").clicked() { apply = true; }
@@ -550,6 +594,28 @@ impl RenderState {
ui.label("✓ 최신 상태");
}
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}"),
}
}
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;
}
}
});
ui.separator();
// Selected feature info
if let Some(idx) = p_features.iter().position(|f| f.selected) {
@@ -603,9 +669,18 @@ impl RenderState {
});
rp.set_pipeline(&self.render_pipeline);
rp.set_bind_group(0, &self.camera_bind_group, &[]);
// 1. Background (ground + alignment)
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..self.num_indices, 0, 0..1);
// 2. Per-feature meshes (with selection highlight)
for feat in &self.features {
rp.set_vertex_buffer(0, feat.vertex_buffer.slice(..));
rp.set_index_buffer(feat.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..feat.num_indices, 0, 0..1);
}
}
// ── egui render ──────────────────────────────────────────────────────
let screen_desc = egui_wgpu::ScreenDescriptor {