diff --git a/cimery/Cargo.toml b/cimery/Cargo.toml index 4f399bc..7e45f2a 100644 --- a/cimery/Cargo.toml +++ b/cimery/Cargo.toml @@ -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"] } diff --git a/cimery/crates/app/Cargo.toml b/cimery/crates/app/Cargo.toml new file mode 100644 index 0000000..c1fbc71 --- /dev/null +++ b/cimery/crates/app/Cargo.toml @@ -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"] } diff --git a/cimery/crates/app/src/main.rs b/cimery/crates/app/src/main.rs new file mode 100644 index 0000000..68aee12 --- /dev/null +++ b/cimery/crates/app/src/main.rs @@ -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 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`)"); +} diff --git a/cimery/crates/viewer/Cargo.toml b/cimery/crates/viewer/Cargo.toml index 37667fb..904db69 100644 --- a/cimery/crates/viewer/Cargo.toml +++ b/cimery/crates/viewer/Cargo.toml @@ -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" diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index e4fbd54..299fed4 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -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(kernel: &K, p: &SceneParams) -> Result< let mut parts: Vec = Vec::new(); - // ── Section from SceneParams (girder_height 파라미터 반영) ───────────────── - let section = PscISectionParams { - total_height: girder_h as f64, // ← SceneParams에서 옴 - 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, + // ── 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,11 +129,11 @@ pub fn build_bridge_scene(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()), - count: 1, - spacing: 0.0, - material: MaterialGrade::C50, + section_type: section_type_enum, + section: section_enum.clone(), + count: 1, + spacing: 0.0, + material: MaterialGrade::C50, }; let mut mesh = kernel.girder_mesh(&ir)?; mesh.recolor(COL_GIRDER); @@ -191,9 +219,9 @@ pub fn build_bridge_scene(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(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 = 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 { diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index de1be1f..33e9920 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -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, } 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 = 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 = 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 = + Ok(build_background_scene(&self.params)); if let Ok(mesh) = full_mesh { let verts: Vec = 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 { diff --git a/cimery/crates/viewer/src/project_file.rs b/cimery/crates/viewer/src/project_file.rs new file mode 100644 index 0000000..e2b5481 --- /dev/null +++ b/cimery/crates/viewer/src/project_file.rs @@ -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 { + 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 +}