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:
@@ -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"
|
||||
|
||||
@@ -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,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])
|
||||
}
|
||||
|
||||
@@ -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, ¶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<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(¶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<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(),
|
||||
|
||||
Reference in New Issue
Block a user