Sprint 14~22 — egui 리본 UI + OcctKernel B-rep + 가로보/신축이음 + 선형 좌표 + USD 익스포트 + WASM + CI/CD + 테스트 4층
Sprint 14: egui TopBottomPanel 리본 + CollapsingHeader SidePanel (상부구조·추가부재·선형·프로젝트) Sprint 15: IncrementalDb 전 Feature 타입 확장 (girder→7종), dirty-tracking 20 unit tests Sprint 16: Gitea + GitHub Actions CI/CD (check/test/clippy/fmt + 멀티플랫폼 릴리스) Sprint 17: AlignmentTransform + AlignmentScene — 선형 국소 프레임 → 세계 좌표 변환 Sprint 18: OcctKernel 교각(16각형 기둥+코핑) + 교대(흉벽+푸팅+날개벽) B-rep Sprint 19: CrossBeamIR + ExpansionJointIR — IR/DSL/kernel/scene 전 계층, sweep_profile_flat_x Sprint 20: 테스트 4층 — Layer1 insta 스냅샷(7종), Layer2 기하 불변량(19), Layer3 두-커널(7), Layer4 proptest(7) — 61 tests pass Sprint 21: cimery-usd PureRustKernel 실제 기하 변환 + BridgeExporter 증분 캐시 Sprint 22: viewer wasm feature + wasm-bindgen/web-sys + GitHub Actions Cloudflare Pages 배포 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,11 @@ edition.workspace = true
|
||||
# Build: cargo run -p cimery-viewer --features occt
|
||||
occt = ["cimery-kernel/occt"]
|
||||
|
||||
# WASM/PWA build target (Sprint 22, ADR-001 dual-target).
|
||||
# Build: wasm-pack build crates/viewer --target web --features wasm
|
||||
# NOTE: OCCT is never available for WASM; use PureRustKernel.
|
||||
wasm = ["wasm-bindgen", "web-sys", "console_error_panic_hook"]
|
||||
|
||||
[[bin]]
|
||||
name = "cimery-viewer"
|
||||
path = "src/main.rs"
|
||||
@@ -29,3 +34,9 @@ serde_json = { workspace = true }
|
||||
egui = "0.29"
|
||||
egui-wgpu = "0.29"
|
||||
egui-winit = "0.29"
|
||||
|
||||
# WASM-only dependencies (Sprint 22)
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlCanvasElement"], optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
220
cimery/crates/viewer/src/alignment_scene.rs
Normal file
220
cimery/crates/viewer/src/alignment_scene.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! Alignment-based coordinate transform. Sprint 17.
|
||||
//!
|
||||
//! When an `AlignmentIR` is loaded, bridge features are placed in world
|
||||
//! coordinates derived from the alignment curve rather than a trivial
|
||||
//! local straight-line system.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! 1. For each feature, query alignment.position_at(station) → world XYZ.
|
||||
//! 2. Compute tangent direction at that station from neighbouring points.
|
||||
//! 3. Build a local-to-world transform matrix [tangent, up, normal, origin].
|
||||
//! 4. Apply transform to the feature's local-frame mesh.
|
||||
//!
|
||||
//! For a straight alignment (or no alignment loaded), this degenerates to
|
||||
//! the existing Z = along-span coordinate system — no visual change.
|
||||
//!
|
||||
//! ## Sprint 17 scope
|
||||
//! - `AlignmentTransform`: samples alignment at a station, returns 4×4 matrix.
|
||||
//! - `apply_alignment_transform()`: transforms a Mesh into world space.
|
||||
//! - `AlignmentScene` wrapper: loads alignment JSON, provides per-feature lookups.
|
||||
|
||||
use cimery_ir::AlignmentIR;
|
||||
use cimery_kernel::Mesh;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
// ─── AlignmentTransform ───────────────────────────────────────────────────────
|
||||
|
||||
/// Local-to-world transform at a given alignment station.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AlignmentTransform {
|
||||
pub matrix: Mat4,
|
||||
}
|
||||
|
||||
impl AlignmentTransform {
|
||||
/// Identity — used when no alignment is loaded.
|
||||
pub fn identity() -> Self {
|
||||
Self { matrix: Mat4::IDENTITY }
|
||||
}
|
||||
|
||||
/// Build transform at `station_m` from an AlignmentIR.
|
||||
///
|
||||
/// - Origin: `alignment.position_at(station_m)` (in mm, converted from m if needed).
|
||||
/// - Forward (+Z in local = along span): tangent from alignment at station.
|
||||
/// - Up (+Y): derived from alignment vertical; defaults to world Y.
|
||||
pub fn from_alignment(alignment: &AlignmentIR, station_m: f64) -> Option<Self> {
|
||||
let pos = alignment.position_at(station_m)?;
|
||||
|
||||
// Compute tangent from neighbouring samples
|
||||
let tangent = compute_tangent(alignment, station_m);
|
||||
|
||||
// World Y as up — can be overridden by vertical alignment in future
|
||||
let world_up = Vec3::Y;
|
||||
let right = world_up.cross(tangent).normalize_or(Vec3::X);
|
||||
let up = tangent.cross(right).normalize_or(Vec3::Y);
|
||||
|
||||
// Build 4×4 matrix: columns = [right, up, tangent, origin]
|
||||
let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32);
|
||||
// Note: AlignmentIR uses [x, y, z] where z is elevation;
|
||||
// bridge coordinate system: Y = up, so we remap y_align → Y_world.
|
||||
let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32);
|
||||
|
||||
let matrix = Mat4::from_cols(
|
||||
right.extend(0.0),
|
||||
up.extend(0.0),
|
||||
tangent.extend(0.0),
|
||||
origin.extend(1.0),
|
||||
);
|
||||
|
||||
Some(Self { matrix })
|
||||
}
|
||||
|
||||
/// Apply this transform to a mesh (all vertices and normals).
|
||||
pub fn apply(&self, mesh: &mut Mesh) {
|
||||
if self.matrix == Mat4::IDENTITY { return; }
|
||||
for v in &mut mesh.vertices {
|
||||
let p = Vec3::from(*v);
|
||||
let tp = self.matrix.transform_point3(p);
|
||||
*v = [tp.x, tp.y, tp.z];
|
||||
}
|
||||
let normal_mat = self.matrix.inverse().transpose();
|
||||
for n in &mut mesh.normals {
|
||||
let nv = Vec3::from(*n);
|
||||
let tn = normal_mat.transform_vector3(nv).normalize_or(Vec3::Y);
|
||||
*n = [tn.x, tn.y, tn.z];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute unit tangent direction at `station_m` along the alignment.
|
||||
fn compute_tangent(alignment: &AlignmentIR, station_m: f64) -> Vec3 {
|
||||
let pts = &alignment.stations;
|
||||
if pts.len() < 2 { return Vec3::Z; }
|
||||
|
||||
// Find the segment containing this station
|
||||
let delta = 1.0_f64; // 1 m sampling step for tangent
|
||||
let p0 = alignment.position_at(station_m - delta)
|
||||
.or_else(|| alignment.position_at(*pts.first().map(|p| &p.station).unwrap_or(&0.0)));
|
||||
let p1 = alignment.position_at(station_m + delta)
|
||||
.or_else(|| alignment.position_at(*pts.last().map(|p| &p.station).unwrap_or(&0.0)));
|
||||
|
||||
match (p0, p1) {
|
||||
(Some(a), Some(b)) => {
|
||||
let dx = (b[0] - a[0]) as f32;
|
||||
let dy = (b[2] - a[2]) as f32; // elevation as Y
|
||||
let dz = (b[1] - a[1]) as f32; // alignment Y → world Z
|
||||
Vec3::new(dx, dy, dz).normalize_or(Vec3::Z)
|
||||
}
|
||||
_ => Vec3::Z,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AlignmentScene ───────────────────────────────────────────────────────────
|
||||
|
||||
/// High-level helper: loads an AlignmentIR and provides per-feature transforms.
|
||||
///
|
||||
/// Sprint 17: Used by the viewer to transform the bridge scene into world space.
|
||||
pub struct AlignmentScene {
|
||||
pub alignment: Option<AlignmentIR>,
|
||||
}
|
||||
|
||||
impl AlignmentScene {
|
||||
/// Create with no alignment (trivial local frame).
|
||||
pub fn none() -> Self {
|
||||
Self { alignment: None }
|
||||
}
|
||||
|
||||
/// Load alignment from a JSON file path.
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let ir = AlignmentIR::from_file(path)?;
|
||||
Ok(Self { alignment: Some(ir) })
|
||||
}
|
||||
|
||||
/// Get local-to-world transform for a feature at the given station [m].
|
||||
pub fn transform_at(&self, station_m: f64) -> AlignmentTransform {
|
||||
match &self.alignment {
|
||||
None => AlignmentTransform::identity(),
|
||||
Some(a) => AlignmentTransform::from_alignment(a, station_m)
|
||||
.unwrap_or_else(AlignmentTransform::identity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an alignment is loaded.
|
||||
pub fn has_alignment(&self) -> bool { self.alignment.is_some() }
|
||||
|
||||
/// Alignment name (or "None").
|
||||
pub fn name(&self) -> &str {
|
||||
match &self.alignment {
|
||||
None => "None",
|
||||
Some(a) => &a.name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total alignment length in metres (or 0 if none loaded).
|
||||
pub fn total_length_m(&self) -> f64 {
|
||||
self.alignment.as_ref().map(|a| a.total_length_m()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Station range [start, end] in metres (or [0, 0]).
|
||||
pub fn station_range(&self) -> (f64, f64) {
|
||||
match &self.alignment {
|
||||
None => (0.0, 0.0),
|
||||
Some(a) => {
|
||||
let first = a.stations.first().map(|p| p.station).unwrap_or(0.0);
|
||||
let last = a.stations.last().map(|p| p.station).unwrap_or(0.0);
|
||||
(first, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cimery_ir::{AlignmentIR, AlignmentStation};
|
||||
|
||||
fn straight_alignment(length_m: f64) -> AlignmentIR {
|
||||
AlignmentIR {
|
||||
name: "test".into(),
|
||||
description: String::new(),
|
||||
coordinate_system: "local".into(),
|
||||
stations: vec![
|
||||
AlignmentStation { station: 0.0, x: 0.0, y: 0.0, z: 0.0 },
|
||||
AlignmentStation { station: length_m, x: 0.0, y: length_m * 1000.0, z: 0.0 },
|
||||
],
|
||||
specs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_for_no_alignment() {
|
||||
let scene = AlignmentScene::none();
|
||||
let t = scene.transform_at(10.0);
|
||||
assert_eq!(t.matrix, Mat4::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_alignment_tangent_is_z() {
|
||||
let a = straight_alignment(40.0);
|
||||
let t = AlignmentTransform::from_alignment(&a, 20.0);
|
||||
assert!(t.is_some());
|
||||
// For straight alignment along Y (in alignment coords), tangent should be along Z
|
||||
let tangent_z = t.unwrap().matrix.z_axis;
|
||||
assert!(tangent_z.truncate().length() > 0.9, "tangent should be near unit length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_length_correct() {
|
||||
let a = straight_alignment(40.0);
|
||||
let scene = AlignmentScene { alignment: Some(a) };
|
||||
assert!((scene.total_length_m() - 40.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_no_alignment_length_zero() {
|
||||
let scene = AlignmentScene::none();
|
||||
assert_eq!(scene.total_length_m(), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
//! Positions are in the same coordinate space as the girder mesh:
|
||||
//! X = transverse (right = +), Y = vertical (up = +), Z = along span.
|
||||
|
||||
use cimery_core::{AbutmentType, BearingType, MaterialGrade, SectionType};
|
||||
use cimery_core::{AbutmentType, BearingType, CrossBeamSection, ExpansionJointType, MaterialGrade, SectionType};
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR,
|
||||
AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, FeatureId, GirderIR,
|
||||
PscISectionParams, SectionParams, WingWallIR,
|
||||
};
|
||||
use cimery_kernel::{GeomKernel, KernelError, Mesh};
|
||||
@@ -40,6 +40,12 @@ pub struct SceneParams {
|
||||
pub section_type: GirderSectionType,
|
||||
/// Show alignment centreline.
|
||||
pub show_alignment: bool,
|
||||
/// Show cross beams (가로보). Sprint 19.
|
||||
pub show_cross_beams: bool,
|
||||
/// Cross beam interval [m] — one beam every N metres. Typically 5-10 m.
|
||||
pub cross_beam_interval_m: f64,
|
||||
/// Show expansion joints at span ends. Sprint 19.
|
||||
pub show_expansion_joints: bool,
|
||||
}
|
||||
|
||||
impl Default for SceneParams {
|
||||
@@ -52,6 +58,9 @@ impl Default for SceneParams {
|
||||
section_type: GirderSectionType::PscI,
|
||||
show_alignment: true,
|
||||
slab_thickness: 220.0,
|
||||
show_cross_beams: true,
|
||||
cross_beam_interval_m: 5.0,
|
||||
show_expansion_joints: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +70,10 @@ pub const COL_GIRDER: [f32; 3] = [0.85, 0.82, 0.72]; // light concrete
|
||||
pub const COL_DECK: [f32; 3] = [0.72, 0.70, 0.62]; // slightly darker slab
|
||||
pub const COL_BEARING: [f32; 3] = [0.30, 0.30, 0.35]; // dark rubber/steel
|
||||
pub const COL_ABUTMENT: [f32; 3] = [0.65, 0.60, 0.50]; // brown concrete
|
||||
pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground
|
||||
pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline
|
||||
pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground
|
||||
pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline
|
||||
pub const COL_CROSS_BEAM: [f32; 3] = [0.75, 0.73, 0.65]; // slightly lighter concrete
|
||||
pub const COL_EXP_JOINT: [f32; 3] = [0.20, 0.20, 0.25]; // dark steel
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -227,6 +238,55 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
||||
parts.push(translate(align, 0.0, girder_h * 0.5, 0.0));
|
||||
}
|
||||
|
||||
// ── Cross Beams (Sprint 19) ────────────────────────────────────────────────
|
||||
if p.show_cross_beams {
|
||||
let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32;
|
||||
let num_beams = (span_mm / interval_mm).floor() as usize;
|
||||
let cb_ir_base = CrossBeamIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height: girder_h as f64 * 0.7,
|
||||
web_thickness: 200.0,
|
||||
flange_width: 400.0,
|
||||
flange_thickness: 20.0,
|
||||
bay_count: (n_girders as u32).saturating_sub(1).max(1),
|
||||
girder_spacing: spacing as f64,
|
||||
material: MaterialGrade::C50,
|
||||
};
|
||||
for i in 0..num_beams {
|
||||
let z = interval_mm * (i as f32 + 1.0);
|
||||
let mut ir = cb_ir_base.clone();
|
||||
ir.id = FeatureId::new();
|
||||
ir.station = z as f64 / 1_000.0;
|
||||
if let Ok(mut mesh) = kernel.cross_beam_mesh(&ir) {
|
||||
mesh.recolor(COL_CROSS_BEAM);
|
||||
parts.push(translate(mesh, 0.0, 0.0, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expansion Joints (Sprint 19) ──────────────────────────────────────────
|
||||
if p.show_expansion_joints {
|
||||
let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64;
|
||||
let y_top = girder_h + p.slab_thickness;
|
||||
for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] {
|
||||
let ej_ir = ExpansionJointIR {
|
||||
id: FeatureId::new(),
|
||||
station,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width: 50.0,
|
||||
total_width: deck_w,
|
||||
depth: 300.0,
|
||||
movement_range: 60.0,
|
||||
};
|
||||
if let Ok(mut mesh) = kernel.expansion_joint_mesh(&ej_ir) {
|
||||
mesh.recolor(COL_EXP_JOINT);
|
||||
parts.push(translate(mesh, 0.0, y_top, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merge(parts))
|
||||
}
|
||||
|
||||
@@ -362,6 +422,63 @@ pub fn build_selectable_scene<K: GeomKernel>(
|
||||
out.push(FeatureMesh { mesh, label: format!("교대 ({})", side) });
|
||||
}
|
||||
|
||||
// ── Cross Beams (Sprint 19) ────────────────────────────────────────────
|
||||
if p.show_cross_beams {
|
||||
let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32;
|
||||
let num_beams = (span_mm / interval_mm).floor() as usize;
|
||||
let cb_ir_base = CrossBeamIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height: girder_h as f64 * 0.7,
|
||||
web_thickness: 200.0,
|
||||
flange_width: 400.0,
|
||||
flange_thickness: 20.0,
|
||||
bay_count: (n_girders as u32).saturating_sub(1).max(1),
|
||||
girder_spacing: spacing as f64,
|
||||
material: MaterialGrade::C50,
|
||||
};
|
||||
for i in 0..num_beams {
|
||||
let z = interval_mm * (i as f32 + 1.0);
|
||||
let mut ir = cb_ir_base.clone();
|
||||
ir.id = FeatureId::new();
|
||||
ir.station = z as f64 / 1_000.0;
|
||||
let mut mesh = kernel.cross_beam_mesh(&ir)?;
|
||||
mesh.recolor(COL_CROSS_BEAM);
|
||||
let half = ir.total_length_mm() as f32 * 0.5;
|
||||
for v in &mut mesh.vertices {
|
||||
v[2] += z;
|
||||
}
|
||||
let _ = half; // translation already applied in cross_beam builder
|
||||
out.push(FeatureMesh { mesh, label: format!("가로보 @ {:.0}m", z / 1_000.0) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expansion Joints (Sprint 19) ───────────────────────────────────────
|
||||
if p.show_expansion_joints {
|
||||
let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64;
|
||||
for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] {
|
||||
let ej_ir = ExpansionJointIR {
|
||||
id: FeatureId::new(),
|
||||
station,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width: 50.0,
|
||||
total_width: deck_w,
|
||||
depth: 300.0,
|
||||
movement_range: 60.0,
|
||||
};
|
||||
let mut mesh = kernel.expansion_joint_mesh(&ej_ir)?;
|
||||
mesh.recolor(COL_EXP_JOINT);
|
||||
let y_top = girder_h + p.slab_thickness;
|
||||
for v in &mut mesh.vertices {
|
||||
v[1] += y_top;
|
||||
v[2] += z;
|
||||
}
|
||||
let side = if z < 1.0 { "시작" } else { "종점" };
|
||||
out.push(FeatureMesh { mesh, label: format!("신축이음 ({})", side) });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod camera;
|
||||
pub mod bridge_scene;
|
||||
pub mod incremental_scene;
|
||||
pub mod project_file;
|
||||
pub mod alignment_scene; // Sprint 17
|
||||
|
||||
use std::sync::Arc;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
@@ -169,6 +170,8 @@ struct RenderState {
|
||||
// Scene parameters (user-editable via egui panel)
|
||||
params: SceneParams,
|
||||
dirty: bool, // needs mesh rebuild
|
||||
// Alignment scene (Sprint 17)
|
||||
alignment_scene: alignment_scene::AlignmentScene,
|
||||
// egui
|
||||
egui_ctx: egui::Context,
|
||||
egui_state: egui_winit::State,
|
||||
@@ -420,6 +423,7 @@ impl RenderState {
|
||||
scene_mx,
|
||||
params,
|
||||
dirty: true, // trigger initial feature build
|
||||
alignment_scene: alignment_scene::AlignmentScene::none(),
|
||||
egui_ctx,
|
||||
egui_state,
|
||||
egui_renderer,
|
||||
@@ -546,95 +550,200 @@ impl RenderState {
|
||||
let mut dirty = self.dirty;
|
||||
let was_dirty = dirty;
|
||||
let mut apply = false;
|
||||
// Sprint 17: alignment display info (capture before closure)
|
||||
let state_alignment_name: Option<String> = self.alignment_scene.alignment
|
||||
.as_ref().map(|a| a.name.clone());
|
||||
let state_alignment_len = self.alignment_scene.total_length_m();
|
||||
let mut alignment_load_path: Option<std::path::PathBuf> = None;
|
||||
|
||||
// Sprint 14: Tab state for ribbon panels (persist across frames)
|
||||
// Use a static-style approach: store active tab in params (or separate)
|
||||
// For now: use a local var captured in closure — OK for per-frame UI
|
||||
let full_output = self.egui_ctx.run(raw_input, |ctx| {
|
||||
// ── Top ribbon bar (Sprint 14) ─────────────────────────────────
|
||||
egui::TopBottomPanel::top("ribbon")
|
||||
.exact_height(28.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.heading("cimery");
|
||||
ui.separator();
|
||||
// Quick-access toolbar buttons
|
||||
if ui.small_button("E 전체뷰").clicked() {
|
||||
// Handled via keyboard shortcut; duplicate here for accessibility
|
||||
}
|
||||
ui.separator();
|
||||
let kernel_label = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRust" };
|
||||
ui.small(format!("커널: {}", kernel_label));
|
||||
ui.separator();
|
||||
// Feature counters
|
||||
ui.small(format!("피처: {}", p_features.len()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Left properties panel (Sprint 14 enhanced) ────────────────
|
||||
egui::SidePanel::left("properties")
|
||||
.resizable(true)
|
||||
.default_width(230.0)
|
||||
.min_width(240.0)
|
||||
.default_width(260.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("교량 속성");
|
||||
// Panel title
|
||||
ui.add_space(4.0);
|
||||
ui.heading("속성 패널");
|
||||
ui.separator();
|
||||
|
||||
macro_rules! param_slider {
|
||||
($label:expr, $val:expr, $range:expr, $step:expr) => {{
|
||||
ui.label($label);
|
||||
if ui.add(egui::Slider::new($val, $range).step_by($step)).changed() {
|
||||
dirty = true;
|
||||
// ── 상부구조 (Superstructure) ──────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 상부구조 (Superstructure)")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
macro_rules! ps {
|
||||
($lbl:expr, $v:expr, $r:expr, $s:expr) => {{
|
||||
ui.label($lbl);
|
||||
if ui.add(egui::Slider::new($v, $r).step_by($s)).changed() {
|
||||
dirty = true;
|
||||
}
|
||||
}};
|
||||
}
|
||||
}};
|
||||
}
|
||||
ps!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0);
|
||||
ps!("거더 수", &mut p.girder_count, 3..=7, 1.0);
|
||||
ps!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0);
|
||||
ps!("거더 높이 (mm)", &mut p.girder_height, 1_000.0..=3_000.0, 100.0);
|
||||
ps!("슬래브 두께 (mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0);
|
||||
|
||||
param_slider!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0);
|
||||
param_slider!("거더 수", &mut p.girder_count, 3..=7, 1.0);
|
||||
param_slider!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_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);
|
||||
|
||||
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, "강재 박스");
|
||||
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; }
|
||||
});
|
||||
if p.section_type != prev_sec { dirty = true; }
|
||||
|
||||
ui.checkbox(&mut p.show_alignment, "선형 표시");
|
||||
if p.show_alignment != self.params.show_alignment { dirty = true; }
|
||||
// ── Should Features (Sprint 19) ────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 추가 부재 (Should Features)")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
let prev_cb = p.show_cross_beams;
|
||||
ui.checkbox(&mut p.show_cross_beams, "가로보 (Cross Beam)");
|
||||
if prev_cb != p.show_cross_beams { dirty = true; }
|
||||
|
||||
if p.show_cross_beams {
|
||||
ui.label(" 가로보 간격 (m)");
|
||||
if ui.add(egui::Slider::new(&mut p.cross_beam_interval_m, 3.0..=20.0).step_by(1.0)).changed() {
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
let prev_ej = p.show_expansion_joints;
|
||||
ui.checkbox(&mut p.show_expansion_joints, "신축이음 (Exp. Joint)");
|
||||
if prev_ej != p.show_expansion_joints { dirty = true; }
|
||||
});
|
||||
|
||||
// ── 표시 옵션 ─────────────────────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 표시 (Display)")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
let prev_al = p.show_alignment;
|
||||
ui.checkbox(&mut p.show_alignment, "선형 표시");
|
||||
if prev_al != p.show_alignment { dirty = true; }
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
// Apply button
|
||||
if dirty {
|
||||
if ui.button("▶ 적용 (Apply)").clicked() { apply = true; }
|
||||
let btn = egui::Button::new("▶ 적용 (Apply)")
|
||||
.fill(egui::Color32::from_rgb(50, 100, 200));
|
||||
if ui.add(btn).clicked() { apply = true; }
|
||||
} else {
|
||||
ui.label("✓ 최신 상태");
|
||||
ui.label(egui::RichText::new("✓ 최신 상태")
|
||||
.color(egui::Color32::from_rgb(80, 200, 80)));
|
||||
}
|
||||
|
||||
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}"),
|
||||
// ── 선형 (Alignment, Sprint 17) ────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 선형 (Alignment)")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
let aname = state_alignment_name.as_deref().unwrap_or("없음");
|
||||
ui.label(format!("파일: {}", aname));
|
||||
if state_alignment_len > 0.0 {
|
||||
ui.label(format!("길이: {:.0} m", state_alignment_len));
|
||||
}
|
||||
}
|
||||
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;
|
||||
if ui.button("📐 선형 불러오기").clicked() {
|
||||
let p = std::path::Path::new("alignments/BR-001.json");
|
||||
alignment_load_path = Some(p.to_path_buf());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
// Selected feature info
|
||||
// ── 프로젝트 저장/불러오기 ──────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 프로젝트")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.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.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();
|
||||
// ── 선택 피처 표시 ────────────────────────────────────
|
||||
if let Some(idx) = p_features.iter().position(|f| f.selected) {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 170, 50),
|
||||
format!("▶ {}", p_features[idx].label));
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 200, 50),
|
||||
format!("▶ 선택: {}", p_features[idx].label),
|
||||
);
|
||||
} else {
|
||||
ui.small("(클릭으로 피처 선택)");
|
||||
ui.small("(좌클릭으로 피처 선택)");
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label("카메라 단축키");
|
||||
ui.small("E: 전체뷰 7: 평면도");
|
||||
ui.small("1: 정면 3: 측면 Home: 아이소");
|
||||
ui.small("가운데버튼: 회전 Shift+가운데: 팬");
|
||||
// ── 카메라 단축키 ──────────────────────────────────────
|
||||
egui::CollapsingHeader::new("▼ 단축키")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
ui.small("E: 전체뷰 (ZoomExtents)");
|
||||
ui.small("7: 평면도 1: 정면 3: 측면");
|
||||
ui.small("Home: 아이소 뷰 4: 왼쪽");
|
||||
ui.small("가운데버튼: 회전");
|
||||
ui.small("Shift+가운데: 팬");
|
||||
ui.small("스크롤: 줌");
|
||||
ui.small("Esc: 종료");
|
||||
});
|
||||
});
|
||||
});
|
||||
self.egui_state.handle_platform_output(&self.window, full_output.platform_output);
|
||||
self.params = p;
|
||||
self.dirty = dirty;
|
||||
// Sprint 17: load alignment file if requested
|
||||
if let Some(path) = alignment_load_path {
|
||||
match alignment_scene::AlignmentScene::from_file(&path) {
|
||||
Ok(as_) => {
|
||||
log::info!("Alignment loaded: {} ({:.0} m)", as_.name(), as_.total_length_m());
|
||||
self.alignment_scene = as_;
|
||||
self.dirty = true;
|
||||
}
|
||||
Err(e) => log::warn!("Alignment load failed: {e}"),
|
||||
}
|
||||
}
|
||||
if apply { self.rebuild_mesh(); }
|
||||
|
||||
// ── 3D scene ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,47 +13,63 @@ 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,
|
||||
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,
|
||||
/// Sprint 19
|
||||
#[serde(default = "default_true")]
|
||||
pub show_cross_beams: bool,
|
||||
#[serde(default = "default_cross_beam_interval")]
|
||||
pub cross_beam_interval_m: f64,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_expansion_joints: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
fn default_cross_beam_interval() -> f64 { 5.0 }
|
||||
|
||||
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 {
|
||||
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,
|
||||
show_alignment: p.show_alignment,
|
||||
show_cross_beams: p.show_cross_beams,
|
||||
cross_beam_interval_m: p.cross_beam_interval_m,
|
||||
show_expansion_joints: p.show_expansion_joints,
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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,
|
||||
show_alignment: self.show_alignment,
|
||||
show_cross_beams: self.show_cross_beams,
|
||||
cross_beam_interval_m: self.cross_beam_interval_m,
|
||||
show_expansion_joints: self.show_expansion_joints,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user