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:
@@ -51,6 +51,8 @@ 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_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_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_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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -175,9 +177,133 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
|
|||||||
parts.push(translate(mesh, 0.0, y, z));
|
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))
|
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<K: GeomKernel>(
|
||||||
|
kernel: &K,
|
||||||
|
p: &SceneParams,
|
||||||
|
) -> Result<Vec<FeatureMesh>, 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<FeatureMesh> = 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).
|
/// Bounding box of the full bridge scene (for camera setup).
|
||||||
pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) {
|
pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) {
|
||||||
let span_mm = (p.span_m * 1_000.0) as f32;
|
let span_mm = (p.span_m * 1_000.0) as f32;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use cimery_kernel::OcctKernel;
|
|||||||
use cimery_kernel::PureRustKernel;
|
use cimery_kernel::PureRustKernel;
|
||||||
use camera::{Camera, StandardView};
|
use camera::{Camera, StandardView};
|
||||||
use glam;
|
use glam;
|
||||||
use bridge_scene::{SceneParams, build_bridge_scene, scene_extents};
|
use bridge_scene::{SceneParams, build_bridge_scene, build_selectable_scene, scene_extents};
|
||||||
|
|
||||||
// ─── Vertex ───────────────────────────────────────────────────────────────────
|
// ─── Vertex ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,6 +54,62 @@ impl Vertex {
|
|||||||
|
|
||||||
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
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 ─────────────────────────────────────────────────────────────
|
// ─── RenderState ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct RenderState {
|
struct RenderState {
|
||||||
@@ -63,7 +119,9 @@ struct RenderState {
|
|||||||
surface: wgpu::Surface<'static>,
|
surface: wgpu::Surface<'static>,
|
||||||
surface_config: wgpu::SurfaceConfiguration,
|
surface_config: wgpu::SurfaceConfiguration,
|
||||||
render_pipeline: wgpu::RenderPipeline,
|
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,
|
vertex_buffer: wgpu::Buffer,
|
||||||
index_buffer: wgpu::Buffer,
|
index_buffer: wgpu::Buffer,
|
||||||
num_indices: u32,
|
num_indices: u32,
|
||||||
@@ -76,6 +134,7 @@ struct RenderState {
|
|||||||
// Mouse / keyboard state
|
// Mouse / keyboard state
|
||||||
mid_pressed: bool,
|
mid_pressed: bool,
|
||||||
shift_pressed: bool,
|
shift_pressed: bool,
|
||||||
|
left_just_pressed: bool,
|
||||||
last_mouse: winit::dpi::PhysicalPosition<f64>,
|
last_mouse: winit::dpi::PhysicalPosition<f64>,
|
||||||
// Scene extents for ZoomExtents
|
// Scene extents for ZoomExtents
|
||||||
scene_mn: [f32; 3],
|
scene_mn: [f32; 3],
|
||||||
@@ -325,13 +384,15 @@ impl RenderState {
|
|||||||
camera_buffer,
|
camera_buffer,
|
||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
depth_view,
|
depth_view,
|
||||||
|
features: vec![], // filled by first rebuild_mesh
|
||||||
mid_pressed: false,
|
mid_pressed: false,
|
||||||
shift_pressed: false,
|
shift_pressed: false,
|
||||||
|
left_just_pressed:false,
|
||||||
last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 },
|
last_mouse: winit::dpi::PhysicalPosition { x: 0.0, y: 0.0 },
|
||||||
scene_mn,
|
scene_mn,
|
||||||
scene_mx,
|
scene_mx,
|
||||||
params,
|
params,
|
||||||
dirty: false,
|
dirty: true, // trigger initial feature build
|
||||||
egui_ctx,
|
egui_ctx,
|
||||||
egui_state,
|
egui_state,
|
||||||
egui_renderer,
|
egui_renderer,
|
||||||
@@ -362,36 +423,46 @@ impl RenderState {
|
|||||||
.create_view(&wgpu::TextureViewDescriptor::default())
|
.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) {
|
fn rebuild_mesh(&mut self) {
|
||||||
|
// Build merged mesh (ground + alignment, for background draw)
|
||||||
#[cfg(feature = "occt")]
|
#[cfg(feature = "occt")]
|
||||||
let mesh = build_bridge_scene(&OcctKernel, &self.params);
|
let full_mesh = build_bridge_scene(&OcctKernel, &self.params);
|
||||||
#[cfg(not(feature = "occt"))]
|
#[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()
|
let verts: Vec<Vertex> = mesh.vertices.iter()
|
||||||
.zip(mesh.normals.iter()).zip(mesh.colors.iter())
|
.zip(mesh.normals.iter()).zip(mesh.colors.iter())
|
||||||
.map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c })
|
.map(|((p, n), c)| Vertex { position: *p, normal: *n, base_color: *c })
|
||||||
.collect();
|
.collect();
|
||||||
self.vertex_buffer = self.device.create_buffer_init(
|
self.vertex_buffer = self.device.create_buffer_init(
|
||||||
&wgpu::util::BufferInitDescriptor {
|
&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("mesh vertex buffer"),
|
label: Some("merged vbuf"), contents: bytemuck::cast_slice(&verts),
|
||||||
contents: bytemuck::cast_slice(&verts),
|
|
||||||
usage: wgpu::BufferUsages::VERTEX,
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
});
|
});
|
||||||
self.index_buffer = self.device.create_buffer_init(
|
self.index_buffer = self.device.create_buffer_init(
|
||||||
&wgpu::util::BufferInitDescriptor {
|
&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("mesh index buffer"),
|
label: Some("merged ibuf"), contents: bytemuck::cast_slice(&mesh.indices),
|
||||||
contents: bytemuck::cast_slice(&mesh.indices),
|
|
||||||
usage: wgpu::BufferUsages::INDEX,
|
usage: wgpu::BufferUsages::INDEX,
|
||||||
});
|
});
|
||||||
self.num_indices = mesh.indices.len() as u32;
|
self.num_indices = mesh.indices.len() as u32;
|
||||||
// Update camera extents
|
}
|
||||||
|
|
||||||
|
// 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);
|
let (mn, mx) = scene_extents(&self.params);
|
||||||
self.scene_mn = mn;
|
self.scene_mn = mn;
|
||||||
self.scene_mx = mx;
|
self.scene_mx = mx;
|
||||||
}
|
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,8 +487,35 @@ impl RenderState {
|
|||||||
|
|
||||||
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
|
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
|
||||||
// ── egui UI (use local copies to avoid self-borrow in closure) ───────
|
// ── 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 raw_input = self.egui_state.take_egui_input(&self.window);
|
||||||
let mut p = self.params.clone();
|
let mut p = self.params.clone();
|
||||||
|
let p_features = &self.features; // borrow for egui display only
|
||||||
let mut dirty = self.dirty;
|
let mut dirty = self.dirty;
|
||||||
let was_dirty = dirty;
|
let was_dirty = dirty;
|
||||||
let mut apply = false;
|
let mut apply = false;
|
||||||
@@ -452,6 +550,15 @@ impl RenderState {
|
|||||||
ui.label("✓ 최신 상태");
|
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.separator();
|
||||||
ui.label("카메라 단축키");
|
ui.label("카메라 단축키");
|
||||||
ui.small("E: 전체뷰 7: 평면도");
|
ui.small("E: 전체뷰 7: 평면도");
|
||||||
@@ -642,6 +749,12 @@ impl ApplicationHandler for CimeryApp {
|
|||||||
// ── Resize ────────────────────────────────────────────────────────
|
// ── Resize ────────────────────────────────────────────────────────
|
||||||
WindowEvent::Resized(sz) => state.resize(sz),
|
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) ────────────────────────
|
// ── Mouse orbit / pan (middle button drag) ────────────────────────
|
||||||
WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => {
|
WindowEvent::MouseInput { button: MouseButton::Middle, state: btn_state, .. } => {
|
||||||
state.mid_pressed = btn_state == ElementState::Pressed;
|
state.mid_pressed = btn_state == ElementState::Pressed;
|
||||||
|
|||||||
Reference in New Issue
Block a user