From 5d89db51170ee11cc763d150a1f0c0a1c5b72833 Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 14 Apr 2026 22:49:57 +0900 Subject: [PATCH] =?UTF-8?q?Sprint=209/10=20=E2=80=94=20=EC=A7=80=EB=A9=B4+?= =?UTF-8?q?=EC=84=A0=ED=98=95=20=EC=8B=9C=EA=B0=81=ED=99=94=20+=20?= =?UTF-8?q?=ED=94=BC=EC=B2=98=20=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cimery/crates/viewer/src/bridge_scene.rs | 134 +++++++++++++++++- cimery/crates/viewer/src/lib.rs | 167 +++++++++++++++++++---- 2 files changed, 270 insertions(+), 31 deletions(-) diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index 676f7b7..e4fbd54 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -47,10 +47,12 @@ impl Default for SceneParams { } // ── 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 -pub const COL_BEARING: [f32; 3] = [0.30, 0.30, 0.35]; // dark rubber/steel -pub const COL_ABUTMENT: [f32; 3] = [0.65, 0.60, 0.50]; // brown concrete +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 +pub const COL_BEARING: [f32; 3] = [0.30, 0.30, 0.35]; // dark rubber/steel +pub const COL_ABUTMENT: [f32; 3] = [0.65, 0.60, 0.50]; // brown concrete +pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground +pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -175,9 +177,133 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< parts.push(translate(mesh, 0.0, y, z)); } + // ── Ground plane ─────────────────────────────────────────────────────────── + { + let ground_y = -(BEARING_H + breast_wall_h as f32 + 1_000.0 + 200.0); // footing bottom - margin + let hw = total_w as f32 * 0.5 + 8_000.0; // wider than bridge + let half_z = span_mm * 0.5 + 8_000.0; + let thickness = 500.0_f32; + let profile = vec![ + [-hw, -thickness], [hw, -thickness], [hw, 0.0], [-hw, 0.0], + ]; + let mut ground = cimery_kernel::sweep::sweep_profile_flat(&profile, half_z * 2.0); + ground.recolor(COL_GROUND); + parts.push(translate(ground, 0.0, ground_y, -half_z)); + } + + // ── Alignment centreline ─────────────────────────────────────────────────── + { + let radius = 80.0_f32; // thin rod + let mut align = cimery_kernel::sweep::polygon_prism(0.0, 0.0, radius, 8, span_mm); + align.recolor(COL_ALIGNMENT); + parts.push(translate(align, 0.0, girder_h * 0.5, 0.0)); + } + Ok(merge(parts)) } +// ─── Selectable scene (per-feature meshes) ──────────────────────────────────── + +pub struct FeatureMesh { + pub mesh: Mesh, + pub label: String, +} + +/// Build bridge scene as a list of individually-selectable feature meshes. +pub fn build_selectable_scene( + kernel: &K, + p: &SceneParams, +) -> Result, 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; + + let section = PscISectionParams { + total_height: girder_h as f64, + top_flange_width: 600.0, + top_flange_thickness: 150.0, + bottom_flange_width: 700.0, + bottom_flange_thickness: 180.0, + web_thickness: 200.0, + haunch: 50.0, + }; + + let mut out: Vec = Vec::new(); + + // Girders + for i in 0..n_girders { + let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing; + let ir = GirderIR { + id: FeatureId::new(), station_start: 0.0, station_end: span_m, + offset_from_alignment: x as f64, section_type: SectionType::PscI, + section: SectionParams::PscI(section.clone()), + count: 1, spacing: 0.0, material: MaterialGrade::C50, + }; + let mut mesh = kernel.girder_mesh(&ir)?; + mesh.recolor(COL_GIRDER); + for v in &mut mesh.vertices { v[0] += x; } + out.push(FeatureMesh { mesh, label: format!("거더 {}", i + 1) }); + } + + // Deck Slab (one unit) + let half_w = ((n_girders as f32 - 1.0) * spacing) * 0.5 + 1_000.0; + let deck_ir = DeckSlabIR { + id: FeatureId::new(), station_start: 0.0, station_end: span_m, + width_left: half_w as f64, width_right: half_w as f64, + thickness: p.slab_thickness as f64, haunch_depth: 0.0, + cross_slope: 2.0, material: MaterialGrade::C40, + }; + let mut deck = kernel.deck_slab_mesh(&deck_ir)?; + deck.recolor(COL_DECK); + for v in &mut deck.vertices { v[1] += girder_h + p.slab_thickness; } + out.push(FeatureMesh { mesh: deck, label: "바닥판 슬래브".into() }); + + // Bearings + for &z in &[0.0_f32, span_mm] { + for i in 0..n_girders { + let x = (i as f32 - (n_girders as f32 - 1.0) * 0.5) * spacing; + let bir = BearingIR { + id: FeatureId::new(), station: if z < 1.0 { 0.0 } else { span_m }, + bearing_type: BearingType::Elastomeric, + plan_length: 350.0, plan_width: 450.0, + total_height: BEARING_H as f64, capacity_vertical: 1_500.0, + }; + let mut mesh = kernel.bearing_mesh(&bir)?; + mesh.recolor(COL_BEARING); + for v in &mut mesh.vertices { v[0] += x; v[2] += z - 225.0; } + let side = if z < 1.0 { "시작" } else { "종점" }; + out.push(FeatureMesh { mesh, label: format!("받침 {}-{}", side, i + 1) }); + } + } + + // Abutments + let total_w = (n_girders as f64 - 1.0) * spacing as f64 + 3_000.0; + let bwh = (girder_h + BEARING_H) as f64; + let wing = WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 }; + for &(station, z) in &[(0.0f64, -800.0_f32), (span_m, span_mm)] { + let air = AbutmentIR { + id: FeatureId::new(), station, skew_angle: 0.0, + abutment_type: AbutmentType::ReverseT, + breast_wall_height: bwh, breast_wall_thickness: 800.0, + breast_wall_width: total_w, footing_length: 4_000.0, + footing_width: total_w + 1_000.0, footing_thickness: 1_000.0, + wing_wall_left: wing.clone(), wing_wall_right: wing.clone(), + material: MaterialGrade::C40, + }; + let mut mesh = kernel.abutment_mesh(&air)?; + mesh.recolor(COL_ABUTMENT); + let y = -(BEARING_H + bwh as f32); + for v in &mut mesh.vertices { v[1] += y; v[2] += z; } + let side = if z < 0.0 { "시작" } else { "종점" }; + out.push(FeatureMesh { mesh, label: format!("교대 ({})", side) }); + } + + Ok(out) +} + /// Bounding box of the full bridge scene (for camera setup). pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) { let span_mm = (p.span_m * 1_000.0) as f32; diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index fde4bd6..de1be1f 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -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 = 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 { + 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, + // 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, + 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], @@ -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 = 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;