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

@@ -8,6 +8,7 @@ members = [
"crates/evaluator",
"crates/viewer",
"crates/usd",
"crates/app",
]
resolver = "2"
@@ -27,6 +28,7 @@ cimery-kernel = { path = "crates/kernel" }
cimery-incremental = { path = "crates/incremental" }
cimery-evaluator = { path = "crates/evaluator" }
cimery-usd = { path = "crates/usd" }
cimery-app = { path = "crates/app" }
# Serialization
serde = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,27 @@
[package]
name = "cimery-app"
version.workspace = true
edition.workspace = true
description = "cimery desktop application (Tauri v2 + Leptos UI)"
[features]
# Geometry backends
occt = ["cimery-kernel/occt"]
[dependencies]
cimery-core = { workspace = true }
cimery-ir = { workspace = true }
cimery-dsl = { workspace = true }
cimery-kernel = { workspace = true }
cimery-incremental = { workspace = true }
cimery-evaluator = { workspace = true }
cimery-usd = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
# Tauri v2 (ADR-001: desktop packaging)
# Uncomment when setting up Tauri project:
# tauri = { version = "2", features = ["devtools"] }

View File

@@ -0,0 +1,61 @@
//! cimery-app — Tauri v2 desktop application skeleton.
//!
//! ADR-001: Tauri v2 (desktop) + PWA (web) dual-target.
//! ADR-003 A3: Gitea CI → GitHub Actions for Win/macOS release builds.
//!
//! # Sprint 13 (this file): application shell scaffold
//! - Tauri integration commented out (requires `tauri init` + frontend setup)
//! - Core domain logic wired and accessible
//!
//! # Sprint 14: Leptos UI frontend
//! - Leptos component tree for ribbon/panel/viewport layout
//! - wgpu viewport embedded as a <canvas> element
//! - Property panel connected to cimery-dsl builders
//!
//! # Tauri setup checklist (run once):
//! 1. `cargo tauri init` in this directory
//! 2. Edit `tauri.conf.json`: app name, window size, icons
//! 3. Implement Tauri commands (IPC bridge) in `src/commands.rs`
//! 4. Set up Leptos frontend in `src/ui/`
use cimery_dsl::Girder;
use cimery_core::UnitExt;
use cimery_kernel::{GeomKernel, PureRustKernel};
fn main() {
env_logger::init();
// ── Quick sanity check: build a test girder ──────────────────────────────
let girder = Girder::builder()
.station_start(0.0.m())
.station_end(40.0.m())
.section_psc_i_default()
.count(5)
.spacing(2500.0.mm())
.build()
.expect("valid girder");
let mesh = PureRustKernel.girder_mesh(&girder.ir)
.expect("girder mesh");
log::info!(
"cimery-app startup OK — test girder: span={:.0}m, triangles={}",
girder.ir.span_m(), mesh.triangle_count()
);
// ── Tauri entry point (activate when Tauri is set up) ──────────────────
// Uncomment after `cargo tauri init`:
//
// tauri::Builder::default()
// .invoke_handler(tauri::generate_handler![
// commands::get_scene_params,
// commands::set_scene_params,
// commands::save_project,
// commands::load_project,
// ])
// .run(tauri::generate_context!())
// .expect("Tauri runtime error");
println!("cimery-app v{}", env!("CARGO_PKG_VERSION"));
println!("Tauri integration: pending (Sprint 14 — run `cargo tauri init`)");
}

View File

@@ -24,6 +24,8 @@ glam = "0.29"
cimery-ir = { workspace = true }
cimery-core = { workspace = true }
cimery-incremental = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
egui = "0.29"
egui-wgpu = "0.29"
egui-winit = "0.29"

View File

@@ -18,6 +18,10 @@ use cimery_kernel::{GeomKernel, KernelError, Mesh};
// ─── Scene parameters (user-editable) ────────────────────────────────────────
/// Girder cross-section type (affects geometry kernel call).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GirderSectionType { PscI, SteelBox }
/// Parameters that define the bridge geometry.
/// Changing any field and calling `build_bridge_scene` regenerates the mesh.
#[derive(Debug, Clone)]
@@ -28,10 +32,14 @@ pub struct SceneParams {
pub girder_count: usize,
/// Girder centre-to-centre spacing [mm].
pub girder_spacing: f32,
/// PSC-I total height [mm].
/// Girder total height [mm].
pub girder_height: f32,
/// Slab thickness [mm].
pub slab_thickness: f32,
/// Girder cross-section type.
pub section_type: GirderSectionType,
/// Show alignment centreline.
pub show_alignment: bool,
}
impl Default for SceneParams {
@@ -41,6 +49,8 @@ impl Default for SceneParams {
girder_count: 5,
girder_spacing: 2_500.0,
girder_height: 1_800.0,
section_type: GirderSectionType::PscI,
show_alignment: true,
slab_thickness: 220.0,
}
}
@@ -82,15 +92,33 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
let mut parts: Vec<Mesh> = Vec::new();
// ── Section from SceneParams (girder_height 파라미터 반영) ─────────────────
let section = PscISectionParams {
total_height: girder_h as f64, // ← SceneParams에서 옴
// ── Section from SceneParams ───────────────────────────────────────────────
let section_enum = match p.section_type {
GirderSectionType::PscI => SectionParams::PscI(PscISectionParams {
total_height: girder_h as f64,
top_flange_width: 600.0,
top_flange_thickness: 150.0,
bottom_flange_width: 700.0,
bottom_flange_thickness: 180.0,
web_thickness: 200.0,
haunch: 50.0,
}),
GirderSectionType::SteelBox => {
use cimery_ir::SteelBoxParams;
let h = girder_h as f64;
SectionParams::SteelBox(SteelBoxParams {
total_height: h,
top_width: h * 1.2,
bottom_width: h * 0.8,
web_thickness: 20.0,
top_flange_thickness: 25.0,
bottom_flange_thickness: 22.0,
})
}
};
let section_type_enum = match p.section_type {
GirderSectionType::PscI => SectionType::PscI,
GirderSectionType::SteelBox => SectionType::SteelBox,
};
// ── Girders ────────────────────────────────────────────────────────────────
@@ -101,8 +129,8 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
station_start: 0.0,
station_end: span_m,
offset_from_alignment: x as f64,
section_type: SectionType::PscI,
section: SectionParams::PscI(section.clone()),
section_type: section_type_enum,
section: section_enum.clone(),
count: 1,
spacing: 0.0,
material: MaterialGrade::C50,
@@ -191,9 +219,9 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
parts.push(translate(ground, 0.0, ground_y, -half_z));
}
// ── Alignment centreline ───────────────────────────────────────────────────
{
let radius = 80.0_f32; // thin rod
// ── Alignment centreline (optional) ───────────────────────────────────────
if p.show_alignment {
let radius = 80.0_f32;
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, span_mm);
align.recolor(COL_ALIGNMENT);
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
@@ -202,6 +230,39 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
Ok(merge(parts))
}
// ─── Background scene (ground + alignment only, no features) ─────────────────
/// Returns only the non-selectable background elements: ground plane + alignment line.
pub fn build_background_scene(p: &SceneParams) -> Mesh {
let span_mm = (p.span_m * 1_000.0) as f32;
let girder_h = p.girder_height;
let n_girders = p.girder_count.max(1).min(10);
let spacing = p.girder_spacing;
const BEARING_H: f32 = 60.0;
let breast_wall_h = (girder_h + BEARING_H) as f64;
let total_w = (n_girders as f64 - 1.0) * spacing as f64 + 3_000.0;
let ground_y = -(BEARING_H + breast_wall_h as f32 + 1_000.0 + 200.0);
let mut parts: Vec<Mesh> = Vec::new();
// Ground
let hw = total_w as f32 * 0.5 + 8_000.0;
let half_z = span_mm * 0.5 + 8_000.0;
let profile = vec![[-hw, -500.0_f32], [hw, -500.0], [hw, 0.0], [-hw, 0.0]];
let mut g = cimery_kernel::sweep::sweep_profile_flat(&profile, half_z * 2.0);
g.recolor(COL_GROUND);
parts.push(translate(g, 0.0, ground_y, -half_z));
// Alignment
if p.show_alignment {
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, 80.0, 8, span_mm);
align.recolor(COL_ALIGNMENT);
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
}
cimery_kernel::sweep::merge_meshes(parts)
}
// ─── Selectable scene (per-feature meshes) ────────────────────────────────────
pub struct FeatureMesh {

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 {

View File

@@ -0,0 +1,79 @@
//! cimery project file — JSON save/load of SceneParams.
//!
//! Format: `cimery/projects/*.cimery.json`
//! Sprint 12: SceneParams only. Sprint 13+: Feature IRs + Alignment.
use serde::{Deserialize, Serialize};
use super::bridge_scene::{GirderSectionType, SceneParams};
// ─── Serialisable form of SceneParams ────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct SectionTypeStr(String);
#[derive(Serialize, Deserialize)]
pub struct ProjectFile {
pub version: u32,
pub name: String,
pub span_m: f64,
pub girder_count: usize,
pub girder_spacing: f32,
pub girder_height: f32,
pub slab_thickness: f32,
pub section_type: String, // "psc_i" | "steel_box"
pub show_alignment: bool,
}
impl ProjectFile {
pub fn from_params(name: &str, p: &SceneParams) -> Self {
Self {
version: 1,
name: name.to_owned(),
span_m: p.span_m,
girder_count: p.girder_count,
girder_spacing: p.girder_spacing,
girder_height: p.girder_height,
slab_thickness: p.slab_thickness,
section_type: match p.section_type {
GirderSectionType::PscI => "psc_i".into(),
GirderSectionType::SteelBox => "steel_box".into(),
},
show_alignment: p.show_alignment,
}
}
pub fn to_params(&self) -> SceneParams {
SceneParams {
span_m: self.span_m,
girder_count: self.girder_count,
girder_spacing: self.girder_spacing,
girder_height: self.girder_height,
slab_thickness: self.slab_thickness,
section_type: match self.section_type.as_str() {
"steel_box" => GirderSectionType::SteelBox,
_ => GirderSectionType::PscI,
},
show_alignment: self.show_alignment,
}
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(path, json)
}
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}
}
/// Default project save path (relative to cimery workspace root).
pub fn default_save_path(name: &str) -> std::path::PathBuf {
let mut p = std::path::PathBuf::from("projects");
std::fs::create_dir_all(&p).ok();
p.push(format!("{}.cimery.json", name));
p
}