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/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"] }
|
||||
|
||||
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-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"
|
||||
|
||||
@@ -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에서 옴
|
||||
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<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()),
|
||||
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<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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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