Sprint 5 — 인터랙티브 파라메트릭 + egui 속성 패널

- 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) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 20:35:43 +09:00
parent 3645b85828
commit 23ddcfade0
3 changed files with 230 additions and 41 deletions

View File

@@ -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"

View File

@@ -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 2080 m.
pub span_m: f64,
/// Number of girders (36).
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,13 +69,13 @@ fn merge(meshes: Vec<Mesh>) -> Mesh {
// ─── Scene builder ────────────────────────────────────────────────────────────
/// Build a complete bridge scene mesh using the provided kernel.
pub fn build_bridge_scene<K: GeomKernel>(kernel: &K) -> Result<Mesh, KernelError> {
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
/// Build a complete bridge scene mesh using the provided kernel and parameters.
pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<Mesh, KernelError> {
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<Mesh> = Vec::new();
@@ -78,14 +108,14 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K) -> Result<Mesh, KernelError
station_end: SPAN_M,
width_left: half_width as f64,
width_right: half_width as f64,
thickness: 220.0,
thickness: p.slab_thickness as f64,
haunch_depth: 0.0,
cross_slope: 2.0,
material: MaterialGrade::C40,
};
let mut deck_mesh = kernel.deck_slab_mesh(&deck_ir)?;
deck_mesh.recolor(COL_DECK);
parts.push(translate(deck_mesh, 0.0, GIRDER_H + 220.0, 0.0));
parts.push(translate(deck_mesh, 0.0, GIRDER_H + p.slab_thickness, 0.0));
// ── Bearings ───────────────────────────────────────────────────────────────
// 5 per abutment, one under each girder
@@ -109,7 +139,8 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K) -> Result<Mesh, KernelError
// ── Abutments ──────────────────────────────────────────────────────────────
let wing = WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 };
let total_w = (N_GIRDERS as f64 - 1.0) * SPACING as f64 + 3_000.0; // incl. overhangs
let total_w = (N_GIRDERS as f64 - 1.0) * SPACING as f64 + 3_000.0;
let breast_wall_h = (GIRDER_H + BEARING_H) as f64;
for &(station, z) in &[(0.0f64, -800.0_f32), (SPAN_M, SPAN_MM)] {
let abut_ir = AbutmentIR {
@@ -117,7 +148,7 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K) -> Result<Mesh, KernelError
station,
skew_angle: 0.0,
abutment_type: AbutmentType::ReverseT,
breast_wall_height: (GIRDER_H + BEARING_H) as f64,
breast_wall_height: breast_wall_h,
breast_wall_thickness: 800.0,
breast_wall_width: total_w,
footing_length: 4_000.0,
@@ -137,10 +168,10 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K) -> Result<Mesh, KernelError
}
/// Bounding box of the full bridge scene (for camera setup).
pub fn scene_extents() -> ([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])
}

View File

@@ -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 ───────────────────────────────────────────────────────────────────
@@ -86,6 +79,13 @@ struct RenderState {
// 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
// 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, &params).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, &params).expect("bridge scene");
let verts: Vec<Vertex> = 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(&params);
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(&params);
// ── 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<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();
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(),