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:
@@ -8,6 +8,7 @@ members = [
|
|||||||
"crates/evaluator",
|
"crates/evaluator",
|
||||||
"crates/viewer",
|
"crates/viewer",
|
||||||
"crates/usd",
|
"crates/usd",
|
||||||
|
"crates/app",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ cimery-kernel = { path = "crates/kernel" }
|
|||||||
cimery-incremental = { path = "crates/incremental" }
|
cimery-incremental = { path = "crates/incremental" }
|
||||||
cimery-evaluator = { path = "crates/evaluator" }
|
cimery-evaluator = { path = "crates/evaluator" }
|
||||||
cimery-usd = { path = "crates/usd" }
|
cimery-usd = { path = "crates/usd" }
|
||||||
|
cimery-app = { path = "crates/app" }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
27
cimery/crates/app/Cargo.toml
Normal file
27
cimery/crates/app/Cargo.toml
Normal 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"] }
|
||||||
61
cimery/crates/app/src/main.rs
Normal file
61
cimery/crates/app/src/main.rs
Normal 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`)");
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ glam = "0.29"
|
|||||||
cimery-ir = { workspace = true }
|
cimery-ir = { workspace = true }
|
||||||
cimery-core = { workspace = true }
|
cimery-core = { workspace = true }
|
||||||
cimery-incremental = { workspace = true }
|
cimery-incremental = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
egui = "0.29"
|
egui = "0.29"
|
||||||
egui-wgpu = "0.29"
|
egui-wgpu = "0.29"
|
||||||
egui-winit = "0.29"
|
egui-winit = "0.29"
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ use cimery_kernel::{GeomKernel, KernelError, Mesh};
|
|||||||
|
|
||||||
// ─── Scene parameters (user-editable) ────────────────────────────────────────
|
// ─── 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.
|
/// Parameters that define the bridge geometry.
|
||||||
/// Changing any field and calling `build_bridge_scene` regenerates the mesh.
|
/// Changing any field and calling `build_bridge_scene` regenerates the mesh.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -28,10 +32,14 @@ pub struct SceneParams {
|
|||||||
pub girder_count: usize,
|
pub girder_count: usize,
|
||||||
/// Girder centre-to-centre spacing [mm].
|
/// Girder centre-to-centre spacing [mm].
|
||||||
pub girder_spacing: f32,
|
pub girder_spacing: f32,
|
||||||
/// PSC-I total height [mm].
|
/// Girder total height [mm].
|
||||||
pub girder_height: f32,
|
pub girder_height: f32,
|
||||||
/// Slab thickness [mm].
|
/// Slab thickness [mm].
|
||||||
pub slab_thickness: f32,
|
pub slab_thickness: f32,
|
||||||
|
/// Girder cross-section type.
|
||||||
|
pub section_type: GirderSectionType,
|
||||||
|
/// Show alignment centreline.
|
||||||
|
pub show_alignment: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SceneParams {
|
impl Default for SceneParams {
|
||||||
@@ -41,6 +49,8 @@ impl Default for SceneParams {
|
|||||||
girder_count: 5,
|
girder_count: 5,
|
||||||
girder_spacing: 2_500.0,
|
girder_spacing: 2_500.0,
|
||||||
girder_height: 1_800.0,
|
girder_height: 1_800.0,
|
||||||
|
section_type: GirderSectionType::PscI,
|
||||||
|
show_alignment: true,
|
||||||
slab_thickness: 220.0,
|
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();
|
let mut parts: Vec<Mesh> = Vec::new();
|
||||||
|
|
||||||
// ── Section from SceneParams (girder_height 파라미터 반영) ─────────────────
|
// ── Section from SceneParams ───────────────────────────────────────────────
|
||||||
let section = PscISectionParams {
|
let section_enum = match p.section_type {
|
||||||
total_height: girder_h as f64, // ← SceneParams에서 옴
|
GirderSectionType::PscI => SectionParams::PscI(PscISectionParams {
|
||||||
top_flange_width: 600.0,
|
total_height: girder_h as f64,
|
||||||
top_flange_thickness: 150.0,
|
top_flange_width: 600.0,
|
||||||
bottom_flange_width: 700.0,
|
top_flange_thickness: 150.0,
|
||||||
bottom_flange_thickness: 180.0,
|
bottom_flange_width: 700.0,
|
||||||
web_thickness: 200.0,
|
bottom_flange_thickness: 180.0,
|
||||||
haunch: 50.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 ────────────────────────────────────────────────────────────────
|
// ── Girders ────────────────────────────────────────────────────────────────
|
||||||
@@ -101,11 +129,11 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
|||||||
station_start: 0.0,
|
station_start: 0.0,
|
||||||
station_end: span_m,
|
station_end: span_m,
|
||||||
offset_from_alignment: x as f64,
|
offset_from_alignment: x as f64,
|
||||||
section_type: SectionType::PscI,
|
section_type: section_type_enum,
|
||||||
section: SectionParams::PscI(section.clone()),
|
section: section_enum.clone(),
|
||||||
count: 1,
|
count: 1,
|
||||||
spacing: 0.0,
|
spacing: 0.0,
|
||||||
material: MaterialGrade::C50,
|
material: MaterialGrade::C50,
|
||||||
};
|
};
|
||||||
let mut mesh = kernel.girder_mesh(&ir)?;
|
let mut mesh = kernel.girder_mesh(&ir)?;
|
||||||
mesh.recolor(COL_GIRDER);
|
mesh.recolor(COL_GIRDER);
|
||||||
@@ -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));
|
parts.push(translate(ground, 0.0, ground_y, -half_z));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Alignment centreline ───────────────────────────────────────────────────
|
// ── Alignment centreline (optional) ───────────────────────────────────────
|
||||||
{
|
if p.show_alignment {
|
||||||
let radius = 80.0_f32; // thin rod
|
let radius = 80.0_f32;
|
||||||
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, span_mm);
|
let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, span_mm);
|
||||||
align.recolor(COL_ALIGNMENT);
|
align.recolor(COL_ALIGNMENT);
|
||||||
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
|
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))
|
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) ────────────────────────────────────
|
// ─── Selectable scene (per-feature meshes) ────────────────────────────────────
|
||||||
|
|
||||||
pub struct FeatureMesh {
|
pub struct FeatureMesh {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod bridge_scene;
|
pub mod bridge_scene;
|
||||||
pub mod incremental_scene;
|
pub mod incremental_scene;
|
||||||
|
pub mod project_file;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
@@ -23,7 +24,11 @@ use cimery_kernel::OcctKernel;
|
|||||||
use cimery_kernel::PureRustKernel;
|
use cimery_kernel::PureRustKernel;
|
||||||
use camera::{Camera, StandardView};
|
use camera::{Camera, StandardView};
|
||||||
use glam;
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Vertex ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -56,41 +61,63 @@ const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
|||||||
|
|
||||||
// ─── Per-feature draw unit ───────────────────────────────────────────────────
|
// ─── 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 {
|
struct FeatureDraw {
|
||||||
vertex_buffer: wgpu::Buffer,
|
vertex_buffer: wgpu::Buffer,
|
||||||
index_buffer: wgpu::Buffer,
|
index_buffer: wgpu::Buffer,
|
||||||
num_indices: u32,
|
num_indices: u32,
|
||||||
aabb_min: [f32; 3],
|
aabb_min: [f32; 3],
|
||||||
aabb_max: [f32; 3],
|
aabb_max: [f32; 3],
|
||||||
label: String, // e.g. "거더 2", "교대 (시작)"
|
label: String,
|
||||||
selected: bool,
|
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 {
|
impl FeatureDraw {
|
||||||
fn from_mesh(device: &wgpu::Device, mesh: &cimery_kernel::Mesh, label: &str) -> Self {
|
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()
|
let verts: Vec<Vertex> = mesh.vertices.iter()
|
||||||
.zip(mesh.normals.iter()).zip(mesh.colors.iter())
|
.zip(mesh.normals.iter()).zip(mesh.colors.iter())
|
||||||
.map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c })
|
.map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c })
|
||||||
.collect();
|
.collect();
|
||||||
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("feature vbuf"), contents: bytemuck::cast_slice(&verts),
|
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 {
|
let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("feature ibuf"), contents: bytemuck::cast_slice(&mesh.indices),
|
label: Some("feature ibuf"), contents: bytemuck::cast_slice(&mesh.indices),
|
||||||
usage: wgpu::BufferUsages::INDEX,
|
usage: wgpu::BufferUsages::INDEX,
|
||||||
});
|
});
|
||||||
let (mn, mx) = mesh.aabb();
|
let (mn, mx) = mesh.aabb();
|
||||||
let base = if mesh.colors.is_empty() { [0.8_f32, 0.76, 0.65] } else { mesh.colors[0] };
|
|
||||||
Self {
|
Self {
|
||||||
vertex_buffer: vbuf, index_buffer: ibuf,
|
vertex_buffer: vbuf, index_buffer: ibuf,
|
||||||
num_indices: mesh.indices.len() as u32,
|
num_indices: mesh.indices.len() as u32,
|
||||||
aabb_min: mn, aabb_max: mx, label: label.to_owned(),
|
aabb_min: mn, aabb_max: mx,
|
||||||
selected: false, base_color: base,
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Ray casting ─────────────────────────────────────────────────────────────
|
||||||
@@ -425,11 +452,9 @@ impl RenderState {
|
|||||||
|
|
||||||
/// Rebuild GPU buffers from current SceneParams.
|
/// Rebuild GPU buffers from current SceneParams.
|
||||||
fn rebuild_mesh(&mut self) {
|
fn rebuild_mesh(&mut self) {
|
||||||
// Build merged mesh (ground + alignment, for background draw)
|
// Background mesh: ground + alignment only (no features — avoids double draw)
|
||||||
#[cfg(feature = "occt")]
|
let full_mesh: Result<cimery_kernel::Mesh, cimery_kernel::KernelError> =
|
||||||
let full_mesh = build_bridge_scene(&OcctKernel, &self.params);
|
Ok(build_background_scene(&self.params));
|
||||||
#[cfg(not(feature = "occt"))]
|
|
||||||
let full_mesh = build_bridge_scene(&PureRustKernel, &self.params);
|
|
||||||
|
|
||||||
if let Ok(mesh) = full_mesh {
|
if let Ok(mesh) = full_mesh {
|
||||||
let verts: Vec<Vertex> = mesh.vertices.iter()
|
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)); }
|
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; }
|
for feat in &mut self.features { feat.selected = false; }
|
||||||
if let Some((_, idx)) = best { self.features[idx].selected = true; }
|
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);
|
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.girder_height, 1_000.0..=3_000.0, 100.0);
|
||||||
param_slider!("슬래브 두께(mm)",&mut p.slab_thickness, 150.0..=400.0, 10.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();
|
ui.separator();
|
||||||
if dirty {
|
if dirty {
|
||||||
if ui.button("▶ 적용 (Apply)").clicked() { apply = true; }
|
if ui.button("▶ 적용 (Apply)").clicked() { apply = true; }
|
||||||
@@ -550,6 +594,28 @@ impl RenderState {
|
|||||||
ui.label("✓ 최신 상태");
|
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();
|
ui.separator();
|
||||||
// Selected feature info
|
// Selected feature info
|
||||||
if let Some(idx) = p_features.iter().position(|f| f.selected) {
|
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_pipeline(&self.render_pipeline);
|
||||||
rp.set_bind_group(0, &self.camera_bind_group, &[]);
|
rp.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||||
|
|
||||||
|
// 1. Background (ground + alignment)
|
||||||
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
|
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
|
||||||
rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
rp.draw_indexed(0..self.num_indices, 0, 0..1);
|
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 ──────────────────────────────────────────────────────
|
// ── egui render ──────────────────────────────────────────────────────
|
||||||
let screen_desc = egui_wgpu::ScreenDescriptor {
|
let screen_desc = egui_wgpu::ScreenDescriptor {
|
||||||
|
|||||||
79
cimery/crates/viewer/src/project_file.rs
Normal file
79
cimery/crates/viewer/src/project_file.rs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user