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