From 23ddcfade0a23445bfd09c7b12911bf91bee8a82 Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 14 Apr 2026 20:35:43 +0900 Subject: [PATCH] =?UTF-8?q?Sprint=205=20=E2=80=94=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EB=9E=99=ED=8B=B0=EB=B8=8C=20=ED=8C=8C=EB=9D=BC=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=20+=20egui=20=EC=86=8D=EC=84=B1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SceneParams: 경간/거더수/간격/높이/슬래브두께 — 실시간 변경 가능 - egui SidePanel(좌측): 슬라이더 편집 → ▶ 적용 → 씬 재생성 - rebuild_mesh(): 파라미터 변경 시 GPU 버텍스·인덱스 버퍼 재생성 - wgpu 22 + egui-wgpu 0.29: forget_lifetime() 로 render pass 전달 - 3D 인코더와 egui 인코더 분리 (wgpu 22 lifetime 호환) - build_bridge_scene(&SceneParams): 경간·거더수·간격 파라메트릭 Co-Authored-By: Claude Opus 4.6 (1M context) --- cimery/crates/viewer/Cargo.toml | 4 + cimery/crates/viewer/src/bridge_scene.rs | 67 ++++++-- cimery/crates/viewer/src/lib.rs | 200 ++++++++++++++++++++--- 3 files changed, 230 insertions(+), 41 deletions(-) diff --git a/cimery/crates/viewer/Cargo.toml b/cimery/crates/viewer/Cargo.toml index 1a224a2..37667fb 100644 --- a/cimery/crates/viewer/Cargo.toml +++ b/cimery/crates/viewer/Cargo.toml @@ -23,3 +23,7 @@ pollster = "0.3" glam = "0.29" cimery-ir = { workspace = true } cimery-core = { workspace = true } +cimery-incremental = { workspace = true } +egui = "0.29" +egui-wgpu = "0.29" +egui-winit = "0.29" diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index 95a56e1..d58d2d7 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -16,6 +16,36 @@ use cimery_ir::{ }; use cimery_kernel::{GeomKernel, KernelError, Mesh}; +// ─── Scene parameters (user-editable) ──────────────────────────────────────── + +/// Parameters that define the bridge geometry. +/// Changing any field and calling `build_bridge_scene` regenerates the mesh. +#[derive(Debug, Clone)] +pub struct SceneParams { + /// Girder span [m]. Range 20–80 m. + pub span_m: f64, + /// Number of girders (3–6). + pub girder_count: usize, + /// Girder centre-to-centre spacing [mm]. + pub girder_spacing: f32, + /// PSC-I total height [mm]. + pub girder_height: f32, + /// Slab thickness [mm]. + pub slab_thickness: f32, +} + +impl Default for SceneParams { + fn default() -> Self { + Self { + span_m: 40.0, + girder_count: 5, + girder_spacing: 2_500.0, + girder_height: 1_800.0, + slab_thickness: 220.0, + } + } +} + // ── Part colours (linear sRGB) ────────────────────────────────────────────── 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 @@ -39,14 +69,14 @@ fn merge(meshes: Vec) -> Mesh { // ─── Scene builder ──────────────────────────────────────────────────────────── -/// Build a complete bridge scene mesh using the provided kernel. -pub fn build_bridge_scene(kernel: &K) -> Result { - const SPAN_M: f64 = 40.0; - const SPAN_MM: f32 = 40_000.0; - const N_GIRDERS: usize = 5; - const SPACING: f32 = 2_500.0; // mm c/c - const GIRDER_H: f32 = 1_800.0; // mm - const BEARING_H: f32 = 60.0; // mm +/// Build a complete bridge scene mesh using the provided kernel and parameters. +pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result { + let SPAN_M = p.span_m; + let SPAN_MM = (p.span_m * 1_000.0) as f32; + let N_GIRDERS = p.girder_count.max(1).min(10); + let SPACING = p.girder_spacing; + let GIRDER_H = p.girder_height; + const BEARING_H: f32 = 60.0; // mm let mut parts: Vec = Vec::new(); @@ -78,14 +108,14 @@ pub fn build_bridge_scene(kernel: &K) -> Result(kernel: &K) -> Result(kernel: &K) -> Result(kernel: &K) -> Result ([f32; 3], [f32; 3]) { - const SPAN_MM: f32 = 40_000.0; - const HALF_W: f32 = 6_500.0; - const TOP_Y: f32 = 2_020.0; // top of slab - const BOT_Y: f32 = -3_000.0; // footing bottom approx - ([-HALF_W, BOT_Y, -2_000.0], [HALF_W, TOP_Y, SPAN_MM + 2_000.0]) +pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) { + let span_mm = (p.span_m * 1_000.0) as f32; + let half_w = ((p.girder_count as f32 - 1.0) * p.girder_spacing * 0.5 + 2_000.0).max(5_000.0); + let top_y = p.girder_height + p.slab_thickness + 200.0; + let bot_y = -(p.girder_height + 3_000.0 + 1_000.0); + ([-half_w, bot_y, -2_000.0], [half_w, top_y, span_mm + 2_000.0]) } diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index 5952927..378fadb 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -1,15 +1,7 @@ -//! cimery-viewer — Sprint 2. +//! cimery-viewer — Sprint 5: Interactive Parametric. //! -//! Renders a PSC-I girder mesh (from StubKernel or OcctKernel) with: -//! - Perspective camera (Revit-style orbit: middle-mouse drag + scroll) -//! - Depth buffer -//! - Simple directional lighting from surface normals -//! - Back-face culling -//! -//! # Sprint 3 upgrade path -//! - Swap `StubKernel` → `OcctKernel` once OCCT compiles. -//! - Add ViewCube widget overlay. -//! - Add selection highlight. +//! 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; @@ -30,6 +22,7 @@ use cimery_kernel::OcctKernel; use cimery_kernel::PureRustKernel; use camera::{Camera, StandardView}; use glam; +use bridge_scene::{SceneParams, build_bridge_scene, scene_extents}; // ─── Vertex ─────────────────────────────────────────────────────────────────── @@ -80,12 +73,19 @@ struct RenderState { // Depth depth_view: wgpu::TextureView, // Mouse / keyboard state - mid_pressed: bool, + mid_pressed: bool, shift_pressed: bool, - last_mouse: winit::dpi::PhysicalPosition, + last_mouse: winit::dpi::PhysicalPosition, // Scene extents for ZoomExtents - scene_mn: [f32; 3], - scene_mx: [f32; 3], + scene_mn: [f32; 3], + scene_mx: [f32; 3], + // Scene parameters (user-editable via egui panel) + params: SceneParams, + dirty: bool, // needs mesh rebuild + // egui + egui_ctx: egui::Context, + egui_state: egui_winit::State, + egui_renderer: egui_wgpu::Renderer, } impl RenderState { @@ -144,14 +144,12 @@ impl RenderState { // ── Depth texture ───────────────────────────────────────────────────── let depth_view = Self::make_depth_view(&device, &surface_config); - // ── Full bridge scene (Sprint 4) ────────────────────────────────────── - // Girder + DeckSlab + Bearing + Abutment + // ── Bridge scene (parametric) ───────────────────────────────────────── + let params = SceneParams::default(); #[cfg(feature = "occt")] - let mesh = bridge_scene::build_bridge_scene(&OcctKernel) - .expect("OcctKernel bridge scene"); + let mesh = build_bridge_scene(&OcctKernel, ¶ms).expect("bridge scene"); #[cfg(not(feature = "occt"))] - let mesh = bridge_scene::build_bridge_scene(&PureRustKernel) - .expect("PureRustKernel bridge scene"); + let mesh = build_bridge_scene(&PureRustKernel, ¶ms).expect("bridge scene"); let verts: Vec = mesh.vertices.iter() .zip(mesh.normals.iter()) @@ -173,7 +171,7 @@ impl RenderState { // ── Camera ──────────────────────────────────────────────────────────── // Camera for full bridge scene - let (mn, mx) = bridge_scene::scene_extents(); + let (mn, mx) = scene_extents(¶ms); let cx = (mn[0] + mx[0]) * 0.5; let cy = (mn[1] + mx[1]) * 0.5; let cz = (mn[2] + mx[2]) * 0.5; @@ -276,7 +274,19 @@ impl RenderState { cache: None, }); - let (scene_mn, scene_mx) = bridge_scene::scene_extents(); + let (scene_mn, scene_mx) = scene_extents(¶ms); + + // ── egui ────────────────────────────────────────────────────────────── + let egui_ctx = egui::Context::default(); + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + &*window, + None, None, None, + ); + let egui_renderer = egui_wgpu::Renderer::new( + &device, format, Some(DEPTH_FORMAT), 1, false, + ); RenderState { window, @@ -297,6 +307,11 @@ impl RenderState { last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 }, scene_mn, scene_mx, + params, + dirty: false, + egui_ctx, + egui_state, + egui_renderer, } } @@ -324,6 +339,39 @@ impl RenderState { .create_view(&wgpu::TextureViewDescriptor::default()) } + /// Rebuild GPU buffers from current SceneParams. Called when `dirty` is set. + fn rebuild_mesh(&mut self) { + #[cfg(feature = "occt")] + let mesh = build_bridge_scene(&OcctKernel, &self.params); + #[cfg(not(feature = "occt"))] + let mesh = build_bridge_scene(&PureRustKernel, &self.params); + + if let Ok(mesh) = 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("mesh vertex buffer"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.index_buffer = self.device.create_buffer_init( + &wgpu::util::BufferInitDescriptor { + label: Some("mesh index buffer"), + contents: bytemuck::cast_slice(&mesh.indices), + usage: wgpu::BufferUsages::INDEX, + }); + self.num_indices = mesh.indices.len() as u32; + // Update camera extents + let (mn, mx) = scene_extents(&self.params); + self.scene_mn = mn; + self.scene_mx = mx; + } + self.dirty = false; + } + fn update_camera(&self) { self.queue.write_buffer( &self.camera_buffer, @@ -344,6 +392,66 @@ impl RenderState { } fn render(&mut self) -> Result<(), wgpu::SurfaceError> { + // ── egui UI (use local copies to avoid self-borrow in closure) ─────── + let raw_input = self.egui_state.take_egui_input(&self.window); + let mut p = self.params.clone(); + let mut dirty = self.dirty; + let was_dirty = dirty; + let mut apply = false; + + let full_output = self.egui_ctx.run(raw_input, |ctx| { + egui::SidePanel::left("properties") + .resizable(true) + .default_width(230.0) + .show(ctx, |ui| { + ui.heading("교량 속성"); + ui.separator(); + + let prev = p.span_m; + ui.label("경간 (m)"); + ui.add(egui::Slider::new(&mut p.span_m, 20.0..=80.0).step_by(1.0)); + if (p.span_m - prev).abs() > 0.001 { dirty = true; } + + let prev = p.girder_count; + ui.label("거더 수"); + ui.add(egui::Slider::new(&mut p.girder_count, 3..=7)); + if p.girder_count != prev { dirty = true; } + + let prev = p.girder_spacing; + ui.label("c/c 간격 (mm)"); + ui.add(egui::Slider::new(&mut p.girder_spacing, 1_500.0..=4_000.0).step_by(100.0)); + if (p.girder_spacing - prev).abs() > 1.0 { dirty = true; } + + let prev = p.girder_height; + ui.label("거더 높이 (mm)"); + ui.add(egui::Slider::new(&mut p.girder_height, 1_000.0..=3_000.0).step_by(100.0)); + if (p.girder_height - prev).abs() > 1.0 { dirty = true; } + + let prev = p.slab_thickness; + ui.label("슬래브 두께 (mm)"); + ui.add(egui::Slider::new(&mut p.slab_thickness, 150.0..=400.0).step_by(10.0)); + if (p.slab_thickness - prev).abs() > 1.0 { dirty = true; } + + ui.separator(); + if dirty { + apply = ui.button("▶ 적용").clicked(); + } else { + ui.label("✓ 최신 상태"); + } + + ui.separator(); + ui.label("카메라 단축키"); + ui.small("E: 전체뷰 7: 평면도"); + ui.small("1: 정면 3: 측면 Home: 아이소"); + ui.small("가운데버튼: 회전 Shift+가운데: 팬"); + }); + }); + self.egui_state.handle_platform_output(&self.window, full_output.platform_output); + self.params = p; + self.dirty = dirty; + if apply { self.rebuild_mesh(); } + + // ── 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 { @@ -379,7 +487,49 @@ impl RenderState { rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); rp.draw_indexed(0..self.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(()) } @@ -423,6 +573,10 @@ impl ApplicationHandler for CimeryApp { 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(),