Sprint 9/10 — 지면+선형 시각화 + 피처 선택

Sprint 9 (bridge_scene):
- 지면(Ground plane): 교량 하부 어두운 올리브 색 평면
- 선형(Alignment): 주황색 얇은 봉 (Z축 방향 경간 전체)
- 색상 추가: COL_GROUND, COL_ALIGNMENT

Sprint 10 (selection):
- FeatureDraw: 피처별 GPU 버퍼 + AABB + 선택 상태
- build_selectable_scene(): 거더/슬래브/받침/교대 개별 메시
- ray_aabb(): 레이-AABB 교차 판정 (좌클릭 피킹)
- egui 패널: 선택된 피처 이름 오렌지색 표시
- 선택 하이라이트는 Sprint 11에서 색상 override로 구현

cargo check 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 22:49:57 +09:00
parent 419c459074
commit 5d89db5117
2 changed files with 270 additions and 31 deletions

View File

@@ -23,7 +23,7 @@ use cimery_kernel::OcctKernel;
use cimery_kernel::PureRustKernel;
use camera::{Camera, StandardView};
use glam;
use bridge_scene::{SceneParams, build_bridge_scene, scene_extents};
use bridge_scene::{SceneParams, build_bridge_scene, build_selectable_scene, scene_extents};
// ─── Vertex ───────────────────────────────────────────────────────────────────
@@ -54,6 +54,62 @@ impl Vertex {
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
// ─── Per-feature draw unit ───────────────────────────────────────────────────
/// One selectable draw unit: a single Feature's GPU buffers + AABB.
struct FeatureDraw {
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
num_indices: u32,
aabb_min: [f32; 3],
aabb_max: [f32; 3],
label: String, // e.g. "거더 2", "교대 (시작)"
selected: bool,
base_color: [f32; 3], // original colour (for deselect)
}
impl FeatureDraw {
fn from_mesh(device: &wgpu::Device, mesh: &cimery_kernel::Mesh, label: &str) -> Self {
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();
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("feature vbuf"), contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
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();
let base = if mesh.colors.is_empty() { [0.8_f32, 0.76, 0.65] } else { mesh.colors[0] };
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,
}
}
}
// ─── 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<f32> {
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 {
@@ -63,7 +119,9 @@ struct RenderState {
surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration,
render_pipeline: wgpu::RenderPipeline,
// Mesh
// Per-feature draw units (Sprint 10)
features: Vec<FeatureDraw>,
// Legacy single-mesh (kept for overlay/ground features without selection)
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
num_indices: u32,
@@ -74,9 +132,10 @@ struct RenderState {
// Depth
depth_view: wgpu::TextureView,
// Mouse / keyboard state
mid_pressed: bool,
shift_pressed: bool,
last_mouse: winit::dpi::PhysicalPosition<f64>,
mid_pressed: bool,
shift_pressed: bool,
left_just_pressed: bool,
last_mouse: winit::dpi::PhysicalPosition<f64>,
// Scene extents for ZoomExtents
scene_mn: [f32; 3],
scene_mx: [f32; 3],
@@ -325,13 +384,15 @@ impl RenderState {
camera_buffer,
camera_bind_group,
depth_view,
mid_pressed: false,
shift_pressed: false,
last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 },
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: false,
dirty: true, // trigger initial feature build
egui_ctx,
egui_state,
egui_renderer,
@@ -362,36 +423,46 @@ impl RenderState {
.create_view(&wgpu::TextureViewDescriptor::default())
}
/// Rebuild GPU buffers from current SceneParams. Called when `dirty` is set.
/// Rebuild GPU buffers from current SceneParams.
fn rebuild_mesh(&mut self) {
// Build merged mesh (ground + alignment, for background draw)
#[cfg(feature = "occt")]
let mesh = build_bridge_scene(&OcctKernel, &self.params);
let full_mesh = build_bridge_scene(&OcctKernel, &self.params);
#[cfg(not(feature = "occt"))]
let mesh = build_bridge_scene(&PureRustKernel, &self.params);
let full_mesh = build_bridge_scene(&PureRustKernel, &self.params);
if let Ok(mesh) = mesh {
if let Ok(mesh) = full_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,
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("mesh index buffer"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
label: Some("merged ibuf"), 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;
}
// 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;
self.dirty = false;
}
@@ -416,11 +487,38 @@ impl RenderState {
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
for feat in &mut self.features { feat.selected = false; }
if let Some((_, idx)) = best { self.features[idx].selected = true; }
}
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 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;
let full_output = self.egui_ctx.run(raw_input, |ctx| {
egui::SidePanel::left("properties")
@@ -452,6 +550,15 @@ impl RenderState {
ui.label("✓ 최신 상태");
}
ui.separator();
// Selected feature info
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));
} else {
ui.small("(클릭으로 피처 선택)");
}
ui.separator();
ui.label("카메라 단축키");
ui.small("E: 전체뷰 7: 평면도");
@@ -642,6 +749,12 @@ impl ApplicationHandler for CimeryApp {
// ── 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;