//! cimery-viewer — Sprint 5: Interactive Parametric. //! //! egui Properties panel (left) + real-time bridge scene regeneration. //! Parameter change → rebuild_mesh() → new GPU buffers → immediate redraw. 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}; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, keyboard::{KeyCode, PhysicalKey}, window::{Window, WindowId}, }; use wgpu::util::DeviceExt; #[cfg(feature = "occt")] use cimery_kernel::OcctKernel; #[cfg(not(feature = "occt"))] use cimery_kernel::PureRustKernel; use camera::{Camera, Projection, StandardView}; use glam; use bridge_scene::{ GirderSectionType, SceneParams, build_bridge_scene, build_selectable_scene, build_background_scene, scene_extents, }; use project_file::ProjectFile; // ─── Vertex ─────────────────────────────────────────────────────────────────── /// Per-vertex data sent to GPU: position + normal + base color. #[repr(C)] #[derive(Copy, Clone, Debug, Pod, Zeroable)] struct Vertex { position: [f32; 3], normal: [f32; 3], base_color: [f32; 3], // material base colour (modulated by lighting) } impl Vertex { const ATTRIBS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 0 => Float32x3, // position 1 => Float32x3, // normal 2 => Float32x3, // base_color ]; fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; // ─── Per-feature draw unit ─────────────────────────────────────────────────── /// 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, selected: bool, 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 | 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(); 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, 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 ───────────────────────────────────────────────────────────── /// Ray-AABB intersection test (slab method). Returns distance or None. fn ray_aabb( ray_origin: glam::Vec3, ray_dir: glam::Vec3, mn: [f32;3], mx: [f32;3], ) -> Option { let mn = glam::Vec3::from(mn); let mx = glam::Vec3::from(mx); let inv = 1.0 / ray_dir; let t1 = (mn - ray_origin) * inv; let t2 = (mx - ray_origin) * inv; let tmin = t1.min(t2).max_element(); let tmax = t1.max(t2).min_element(); if tmax >= tmin && tmax > 0.0 { Some(tmin.max(0.0)) } else { None } } // ─── RenderState ───────────────────────────────────────────────────────────── struct RenderState { window: Arc, device: wgpu::Device, queue: wgpu::Queue, surface: wgpu::Surface<'static>, surface_config: wgpu::SurfaceConfiguration, render_pipeline: wgpu::RenderPipeline, // Per-feature draw units (Sprint 10) features: Vec, // Legacy single-mesh (kept for overlay/ground features without selection) vertex_buffer: wgpu::Buffer, index_buffer: wgpu::Buffer, num_indices: u32, // Camera camera: Camera, camera_buffer: wgpu::Buffer, camera_bind_group: wgpu::BindGroup, // Depth depth_view: wgpu::TextureView, // Mouse / keyboard state mid_pressed: bool, shift_pressed: bool, left_just_pressed: bool, last_mouse: winit::dpi::PhysicalPosition, // Scene extents for ZoomExtents scene_mn: [f32; 3], scene_mx: [f32; 3], // 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, egui_renderer: egui_wgpu::Renderer, } impl RenderState { async fn new(window: Arc) -> Self { let size = window.inner_size(); // ── Instance + surface ──────────────────────────────────────────────── let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::all(), ..Default::default() }); let surface = instance .create_surface(Arc::clone(&window)) .expect("create surface"); // ── Adapter + device ────────────────────────────────────────────────── let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::default(), compatible_surface: Some(&surface), force_fallback_adapter: false, }) .await .expect("no suitable GPU adapter"); let (device, queue) = adapter .request_device( &wgpu::DeviceDescriptor { label: Some("cimery device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), ..Default::default() }, None, ) .await .expect("failed to create GPU device"); // ── Surface config ──────────────────────────────────────────────────── let caps = surface.get_capabilities(&adapter); let format = caps.formats.iter().find(|f| f.is_srgb()).copied() .unwrap_or(caps.formats[0]); let surface_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: size.width.max(1), height: size.height.max(1), present_mode: caps.present_modes[0], alpha_mode: caps.alpha_modes[0], view_formats: vec![], desired_maximum_frame_latency: 2, }; surface.configure(&device, &surface_config); // ── Depth texture ───────────────────────────────────────────────────── let depth_view = Self::make_depth_view(&device, &surface_config); // ── Bridge scene (parametric) ───────────────────────────────────────── let params = SceneParams::default(); #[cfg(feature = "occt")] let mesh = build_bridge_scene(&OcctKernel, ¶ms).expect("bridge scene"); #[cfg(not(feature = "occt"))] let mesh = build_bridge_scene(&PureRustKernel, ¶ms).expect("bridge scene"); 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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh vertex buffer"), contents: bytemuck::cast_slice(&verts), usage: wgpu::BufferUsages::VERTEX, }); let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh index buffer"), contents: bytemuck::cast_slice(&mesh.indices), usage: wgpu::BufferUsages::INDEX, }); let num_indices = mesh.indices.len() as u32; // ── Camera ──────────────────────────────────────────────────────────── // Camera for full bridge scene. // 주의: scene_extents 는 지반·교대까지 포함해서 center Y ≈ -1790mm (지반 아래). // 초기부터 카메라가 교량 중심을 향하도록 Y 타겟은 거더+데크 중심으로 계산. let (mn, mx) = scene_extents(¶ms); let cx = (mn[0] + mx[0]) * 0.5; let cy = (params.girder_height + params.slab_thickness) * 0.5; let cz = (mn[2] + mx[2]) * 0.5; let span = (mx[2] - mn[2]).max(mx[0] - mn[0]); let mut camera = Camera { target: glam::Vec3::new(cx, cy, cz), radius: span * 1.2, yaw: std::f32::consts::FRAC_PI_4, pitch: 0.30, fov_y: 60.0_f32.to_radians(), aspect: 16.0 / 9.0, znear: 10.0, zfar: 10_000_000.0, projection: Projection::Perspective, }; let _ = mesh.aabb(); // keep aabb call for future use camera.resize(surface_config.width, surface_config.height); let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("camera buffer"), contents: bytemuck::cast_slice(&[camera.to_uniform()]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("camera bgl"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }], }); let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("camera bg"), layout: &camera_bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: camera_buffer.as_entire_binding(), }], }); // ── Pipeline ────────────────────────────────────────────────────────── let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cimery shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pipeline layout"), bind_group_layouts: &[&camera_bgl], push_constant_ranges: &[], }); let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("render pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[Vertex::desc()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: wgpu::PipelineCompilationOptions::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: Some(wgpu::Face::Back), polygon_mode: wgpu::PolygonMode::Fill, unclipped_depth: false, conservative: false, }, depth_stencil: Some(wgpu::DepthStencilState { format: DEPTH_FORMAT, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::Less, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false, }, multiview: None, cache: None, }); let (scene_mn, scene_mx) = scene_extents(¶ms); // ── egui ────────────────────────────────────────────────────────────── let egui_ctx = egui::Context::default(); // Load Korean system font (Windows Malgun Gothic) for CJK support { let mut fonts = egui::FontDefinitions::default(); let font_path = "C:\\Windows\\Fonts\\malgun.ttf"; if let Ok(data) = std::fs::read(font_path) { fonts.font_data.insert( "MalgunGothic".to_owned(), egui::FontData::from_owned(data), ); // Insert as primary font (index 0) so Korean renders by default for family in fonts.families.values_mut() { family.insert(0, "MalgunGothic".to_owned()); } egui_ctx.set_fonts(fonts); log::info!("Korean font loaded: {}", font_path); } else { log::warn!("Korean font not found at {}; UI labels will show boxes", font_path); } } let egui_state = egui_winit::State::new( egui_ctx.clone(), egui::ViewportId::ROOT, &*window, None, None, None, ); // egui renders 2D UI overlay — no depth buffer let egui_renderer = egui_wgpu::Renderer::new( &device, format, None, 1, false, ); RenderState { window, device, queue, surface, surface_config, render_pipeline, vertex_buffer, index_buffer, num_indices, camera, camera_buffer, camera_bind_group, depth_view, features: vec![], // filled by first rebuild_mesh mid_pressed: false, shift_pressed: false, left_just_pressed:false, last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 }, scene_mn, scene_mx, params, dirty: true, // trigger initial feature build alignment_scene: alignment_scene::AlignmentScene::none(), egui_ctx, egui_state, egui_renderer, } } // ── Helpers ─────────────────────────────────────────────────────────────── fn make_depth_view( device: &wgpu::Device, config: &wgpu::SurfaceConfiguration, ) -> wgpu::TextureView { device.create_texture(&wgpu::TextureDescriptor { label: Some("depth texture"), size: wgpu::Extent3d { width: config.width.max(1), height: config.height.max(1), depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }) .create_view(&wgpu::TextureViewDescriptor::default()) } /// Rebuild GPU buffers from current SceneParams. fn rebuild_mesh(&mut self) { // 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() .zip(mesh.normals.iter()).zip(mesh.colors.iter()) .map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c }) .collect(); self.vertex_buffer = self.device.create_buffer_init( &wgpu::util::BufferInitDescriptor { label: Some("merged vbuf"), contents: bytemuck::cast_slice(&verts), usage: wgpu::BufferUsages::VERTEX, }); self.index_buffer = self.device.create_buffer_init( &wgpu::util::BufferInitDescriptor { label: Some("merged ibuf"), contents: bytemuck::cast_slice(&mesh.indices), usage: wgpu::BufferUsages::INDEX, }); self.num_indices = mesh.indices.len() as u32; } // Build per-feature meshes for selection (Sprint 10) #[cfg(feature = "occt")] let sel = build_selectable_scene(&OcctKernel, &self.params); #[cfg(not(feature = "occt"))] let sel = build_selectable_scene(&PureRustKernel, &self.params); if let Ok(feature_meshes) = sel { self.features = feature_meshes.into_iter() .map(|fm| FeatureDraw::from_mesh(&self.device, &fm.mesh, &fm.label)) .collect(); } let (mn, mx) = scene_extents(&self.params); self.scene_mn = mn; self.scene_mx = mx; // Apply 후 교량 상부구조 (거더+데크) 로 제한한 BB로 카메라 자동 피트. // scene_extents 는 지반·교대 푸팅까지 포함되어 center Y ≈ -1790mm(지반 아래) 가 되고, // girder_h 가 바뀌어도 top/bot 이 같은 비율로 이동해 center 가 고정 → 카메라가 // 거더 중심을 추적하지 않아 "거더는 그대로"처럼 보이는 착시. // → Y 범위를 [0, girder_h + slab] 로만 잡아 target Y = 거더+데크 중심이 되게 함. let focus_top = self.params.girder_height + self.params.slab_thickness; let focus_mn = [mn[0], 0.0_f32, mn[2]]; let focus_mx = [mx[0], focus_top, mx[2]]; self.camera.zoom_extents(focus_mn, focus_mx); self.update_camera(); self.dirty = false; } fn update_camera(&self) { self.queue.write_buffer( &self.camera_buffer, 0, bytemuck::cast_slice(&[self.camera.to_uniform()]), ); } fn resize(&mut self, new_size: winit::dpi::PhysicalSize) { if new_size.width > 0 && new_size.height > 0 { self.surface_config.width = new_size.width; self.surface_config.height = new_size.height; self.surface.configure(&self.device, &self.surface_config); self.depth_view = Self::make_depth_view(&self.device, &self.surface_config); self.camera.resize(new_size.width, new_size.height); self.update_camera(); } } fn render(&mut self) -> Result<(), wgpu::SurfaceError> { // ── egui UI (use local copies to avoid self-borrow in closure) ─────── // Handle pick on left click (before egui, so egui can consume) if self.left_just_pressed { self.left_just_pressed = false; let mx = self.last_mouse.x as f32; let my = self.last_mouse.y as f32; let w = self.surface_config.width as f32; let h = self.surface_config.height as f32; // Compute ray from camera through pixel let ndc_x = (mx / w) * 2.0 - 1.0; let ndc_y = -(my / h) * 2.0 + 1.0; let vp_inv = self.camera.view_proj().inverse(); let near = vp_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 0.0)); let far = vp_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0)); let ray_dir = (far - near).normalize(); // Hit test against each feature AABB let mut best: Option<(f32, usize)> = None; for (i, feat) in self.features.iter().enumerate() { if let Some(t) = ray_aabb(near, ray_dir, feat.aabb_min, feat.aabb_max) { if best.map_or(true, |(bt, _)| t < bt) { best = Some((t, i)); } } } // 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); let mut p = self.params.clone(); let p_features = &self.features; // borrow for egui display only let mut dirty = self.dirty; let was_dirty = dirty; let mut apply = false; // Projection toggle: egui 버튼이 self.camera 를 직접 못 건드리므로 플래그로 전달. let mut toggle_ortho = false; let is_ortho_now = self.camera.projection == Projection::Orthographic; // Sprint 17: alignment display info (capture before closure) let state_alignment_name: Option = 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 = 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) .min_width(240.0) .default_width(260.0) .show(ctx, |ui| { // Panel title ui.add_space(4.0); ui.heading("속성 패널"); ui.separator(); // ── 상부구조 (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); 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; } }); // ── 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; } // 투영 모드 토글 (dirty 와 무관, 즉시 적용) ui.horizontal(|ui| { ui.label("투영:"); let label = if is_ortho_now { "■ Ortho (O)" } else { "◇ Perspective (O)" }; if ui.button(label).clicked() { toggle_ortho = true; } }); }); ui.separator(); // Apply button if dirty { let btn = egui::Button::new("▶ 적용 (Apply)") .fill(egui::Color32::from_rgb(50, 100, 200)); if ui.add(btn).clicked() { apply = true; } } else { ui.label(egui::RichText::new("✓ 최신 상태") .color(egui::Color32::from_rgb(80, 200, 80))); } ui.separator(); // ── 선형 (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.button("📐 선형 불러오기").clicked() { let p = std::path::Path::new("alignments/BR-001.json"); alignment_load_path = Some(p.to_path_buf()); } }); ui.separator(); // ── 프로젝트 저장/불러오기 ────────────────────────── 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, 200, 50), format!("▶ 선택: {}", p_features[idx].label), ); } else { ui.small("(좌클릭으로 피처 선택)"); } ui.separator(); // ── 카메라 단축키 ────────────────────────────────────── 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(); } if toggle_ortho { self.camera.toggle_projection(); self.update_camera(); } // ── 3D scene ───────────────────────────────────────────────────────── let output = self.surface.get_current_texture()?; let view = output.texture.create_view(&Default::default()); let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("render encoder"), }); { let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("main pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.10, g: 0.16, b: 0.24, a: 1.0, // dark blue-grey bg }), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), occlusion_query_set: None, timestamp_writes: None, }); 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 { size_in_pixels: [self.surface_config.width, self.surface_config.height], pixels_per_point: self.window.scale_factor() as f32, }; let tris = self.egui_ctx.tessellate( full_output.shapes, screen_desc.pixels_per_point, ); for (id, delta) in full_output.textures_delta.set { self.egui_renderer.update_texture(&self.device, &self.queue, id, &delta); } // Submit 3D first self.queue.submit(std::iter::once(enc.finish())); // egui uses its own encoder (avoids wgpu 22 lifetime issue) let mut egui_enc = self.device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("egui encoder") }, ); self.egui_renderer.update_buffers( &self.device, &self.queue, &mut egui_enc, &tris, &screen_desc, ); { // wgpu 22: use forget_lifetime() so render pass can be passed to egui renderer let mut rp = egui_enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("egui pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, }).forget_lifetime(); self.egui_renderer.render(&mut rp, &tris, &screen_desc); } for id in full_output.textures_delta.free { self.egui_renderer.free_texture(&id); } let _ = was_dirty; self.queue.submit(std::iter::once(egui_enc.finish())); output.present(); Ok(()) } } // ─── CimeryApp ──────────────────────────────────────────────────────────────── pub struct CimeryApp { state: Option, } impl CimeryApp { pub fn new() -> Self { Self { state: None } } } impl Default for CimeryApp { fn default() -> Self { Self::new() } } /// 빌드 타임스탬프 (build.rs 에서 주입). 타이틀에 표시해서 사용자가 실행 중인 /// 바이너리가 최신 빌드인지 즉시 확인 가능. const BUILD_TS: &str = env!("BUILD_TIMESTAMP"); impl ApplicationHandler for CimeryApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let kernel = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRustKernel" }; let title = format!("cimery viewer [{kernel}] — build {BUILD_TS}"); let attrs = Window::default_attributes() .with_title(title) .with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32)); let window = Arc::new( event_loop.create_window(attrs).expect("create window"), ); self.state = Some(pollster::block_on(RenderState::new(Arc::clone(&window)))); } fn window_event( &mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent, ) { let Some(state) = self.state.as_mut() else { return }; if state.window.id() != window_id { return; } // Forward to egui first; if egui consumes the event, skip camera let egui_resp = state.egui_state.on_window_event(&state.window, &event); if egui_resp.consumed { return; } match event { // ── Exit ────────────────────────────────────────────────────────── WindowEvent::CloseRequested => event_loop.exit(), // ── Shift modifier tracking ─────────────────────────────────────── WindowEvent::ModifiersChanged(mods) => { state.shift_pressed = mods.state().shift_key(); } // ── Keyboard shortcuts ──────────────────────────────────────────── WindowEvent::KeyboardInput { event: KeyEvent { physical_key, state: key_state, .. }, .. } if key_state == ElementState::Pressed => { match physical_key { PhysicalKey::Code(KeyCode::Escape) => event_loop.exit(), // E → ZoomExtents (fit all) PhysicalKey::Code(KeyCode::KeyE) => { state.camera.zoom_extents(state.scene_mn, state.scene_mx); state.update_camera(); } // O → Perspective ↔ Orthographic 투영 토글 (실측 확인용) PhysicalKey::Code(KeyCode::KeyO) => { state.camera.toggle_projection(); state.update_camera(); } // Numpad 7 / 7 → Top view PhysicalKey::Code(KeyCode::Numpad7) | PhysicalKey::Code(KeyCode::Digit7) => { state.camera.set_standard_view(StandardView::Top); state.update_camera(); } // Numpad 1 / 1 → Front view PhysicalKey::Code(KeyCode::Numpad1) | PhysicalKey::Code(KeyCode::Digit1) => { state.camera.set_standard_view(StandardView::Front); state.update_camera(); } // Numpad 3 / 3 → Right side view PhysicalKey::Code(KeyCode::Numpad3) | PhysicalKey::Code(KeyCode::Digit3) => { state.camera.set_standard_view(StandardView::Right); state.update_camera(); } // Numpad 4 / 4 → Left side view PhysicalKey::Code(KeyCode::Numpad4) | PhysicalKey::Code(KeyCode::Digit4) => { state.camera.set_standard_view(StandardView::Left); state.update_camera(); } // Home → Isometric (default view) PhysicalKey::Code(KeyCode::Home) => { state.camera.set_standard_view(StandardView::Iso); state.update_camera(); } _ => {} } } // ── Resize ──────────────────────────────────────────────────────── WindowEvent::Resized(sz) => state.resize(sz), // ── Left click: pick feature ────────────────────────────────────── WindowEvent::MouseInput { button: MouseButton::Left, state: btn_state, .. } => { if btn_state == ElementState::Pressed { state.left_just_pressed = true; } } // ── Mouse orbit / pan (middle button drag) ──────────────────────── WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => { state.mid_pressed = btn_state == ElementState::Pressed; } WindowEvent::CursorMoved { position, .. } => { if state.mid_pressed { let dx = (position.x - state.last_mouse.x) as f32; let dy = (position.y - state.last_mouse.y) as f32; if state.shift_pressed { // Shift + Middle = Pan (translate target) state.camera.pan(dx, dy); } else { // Middle = Orbit state.camera.orbit(dx, dy); } state.update_camera(); } state.last_mouse = position; } // ── Zoom (scroll wheel) ─────────────────────────────────────────── WindowEvent::MouseWheel { delta, .. } => { let scroll = match delta { MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01, }; state.camera.zoom(scroll); state.update_camera(); } // ── Render ──────────────────────────────────────────────────────── WindowEvent::RedrawRequested => { match state.render() { Ok(()) => {} Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { let sz = state.window.inner_size(); state.resize(sz); } Err(wgpu::SurfaceError::OutOfMemory) => { log::error!("GPU OOM — exiting"); event_loop.exit(); } Err(e) => log::warn!("surface error: {:?}", e), } state.window.request_redraw(); } _ => {} } } } // ─── Entry point ───────────────────────────────────────────────────────────── /// Run the cimery viewer. Blocks until the window is closed. pub fn run_viewer() { let event_loop = EventLoop::new().expect("create event loop"); event_loop.set_control_flow(ControlFlow::Poll); let mut app = CimeryApp::new(); event_loop.run_app(&mut app).expect("event loop error"); }