Files
ParaWiki/cimery/crates/viewer/src/lib.rs
minsung 471fac53b3 Sprint 27/28 — Skew + 방호벽 + 관련 메타 갱신
Sprint 27: 경사각(Skew) 지원.
- SceneParams.skew_deg (-30°~30°) 추가.
- rotate_y_around_z(mesh, rad, pivot_z) 헬퍼: Y축 중심, 임의 Z pivot 회전.
  정점·법선 동시 회전.
- 적용 대상: 교대·교각·받침·신축이음 (각 지점 pivot_z 기준).
- 거더·데크는 직선 유지 (precast 거더 스큐 교량의 일반 관례).
- UI: "경사각(°)" 슬라이더.

Sprint 28: 방호벽(Parapet) MVP.
- 데크 양 엣지(half_w, -half_w) 에 1200mm×500mm RC 박스 전 구간 연속 배치.
- Y 기준: 데크 상면 (girder_h + slab_thickness).
- 색: COL_ABUTMENT 재사용 (콘크리트 브라운).
- build_bridge_scene / build_selectable_scene 양쪽 추가.
  선택 가능 씬에서는 "방호벽 (좌/우)" 라벨.

ProjectFile v2: skew_deg 필드 (default 0.0).
PROGRESS.md: Sprint 25~28 정리.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:42:21 +09:00

1049 lines
50 KiB
Rust

//! cimery-viewer — Sprint 5: Interactive Parametric.
//!
//! 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;
pub mod incremental_scene;
pub mod project_file;
pub mod alignment_scene; // Sprint 17
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
use winit::{
application::ApplicationHandler,
event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::{Window, WindowId},
};
use wgpu::util::DeviceExt;
#[cfg(feature = "occt")]
use cimery_kernel::OcctKernel;
#[cfg(not(feature = "occt"))]
use cimery_kernel::PureRustKernel;
use camera::{Camera, Projection, StandardView};
use glam;
use bridge_scene::{
GirderSectionType, SceneParams,
build_bridge_scene, build_selectable_scene, build_background_scene, scene_extents,
};
use project_file::ProjectFile;
// ─── Vertex ───────────────────────────────────────────────────────────────────
/// Per-vertex data sent to GPU: position + normal + base color.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
position: [f32; 3],
normal: [f32; 3],
base_color: [f32; 3], // material base colour (modulated by lighting)
}
impl Vertex {
const ATTRIBS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![
0 => Float32x3, // position
1 => Float32x3, // normal
2 => Float32x3, // base_color
];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
// ─── Per-feature draw unit ───────────────────────────────────────────────────
/// Selection highlight colour (yellow-orange).
const COL_HIGHLIGHT: [f32; 3] = [1.0, 0.78, 0.10];
/// One selectable draw unit: per-feature GPU buffers + AABB + highlight support.
struct FeatureDraw {
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
num_indices: u32,
aabb_min: [f32; 3],
aabb_max: [f32; 3],
label: String,
selected: bool,
base_color: [f32; 3],
/// CPU copy of vertices for color rebuild.
cpu_verts: Vec<Vertex>,
}
impl FeatureDraw {
fn from_mesh(device: &wgpu::Device, mesh: &cimery_kernel::Mesh, label: &str) -> Self {
let base = if mesh.colors.is_empty() { [0.8_f32, 0.76, 0.65] } else { mesh.colors[0] };
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 | wgpu::BufferUsages::COPY_DST,
});
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();
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,
cpu_verts: verts,
}
}
/// Apply selection highlight or restore base colour.
fn update_highlight(&self, queue: &wgpu::Queue) {
let color = if self.selected {
let b = self.base_color;
[b[0] * 0.2 + COL_HIGHLIGHT[0] * 0.8,
b[1] * 0.2 + COL_HIGHLIGHT[1] * 0.8,
b[2] * 0.2 + COL_HIGHLIGHT[2] * 0.8]
} else {
self.base_color
};
let new_verts: Vec<Vertex> = self.cpu_verts.iter()
.map(|v| Vertex { position: v.position, normal: v.normal, base_color: color })
.collect();
queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&new_verts));
}
}
// ─── 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 {
window: Arc<Window>,
device: wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration,
render_pipeline: wgpu::RenderPipeline,
// 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,
// Camera
camera: Camera,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
// Depth
depth_view: wgpu::TextureView,
// Mouse / keyboard state
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],
// Scene parameters (user-editable via egui panel)
params: SceneParams,
dirty: bool, // needs mesh rebuild
// Alignment scene (Sprint 17)
alignment_scene: alignment_scene::AlignmentScene,
// egui
egui_ctx: egui::Context,
egui_state: egui_winit::State,
egui_renderer: egui_wgpu::Renderer,
}
impl RenderState {
async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
// ── Instance + surface ────────────────────────────────────────────────
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
let surface = instance
.create_surface(Arc::clone(&window))
.expect("create surface");
// ── Adapter + device ──────────────────────────────────────────────────
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.expect("no suitable GPU adapter");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("cimery device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
},
None,
)
.await
.expect("failed to create GPU device");
// ── Surface config ────────────────────────────────────────────────────
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.iter().find(|f| f.is_srgb()).copied()
.unwrap_or(caps.formats[0]);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: caps.present_modes[0],
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &surface_config);
// ── Depth texture ─────────────────────────────────────────────────────
let depth_view = Self::make_depth_view(&device, &surface_config);
// ── Bridge scene (parametric) ─────────────────────────────────────────
let params = SceneParams::default();
#[cfg(feature = "occt")]
let mesh = build_bridge_scene(&OcctKernel, &params).expect("bridge scene");
#[cfg(not(feature = "occt"))]
let mesh = build_bridge_scene(&PureRustKernel, &params).expect("bridge scene");
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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh vertex buffer"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh index buffer"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
let num_indices = mesh.indices.len() as u32;
// ── Camera ────────────────────────────────────────────────────────────
// Camera for full bridge scene.
// 주의: scene_extents 는 지반·교대까지 포함해서 center Y ≈ -1790mm (지반 아래).
// 초기부터 카메라가 교량 중심을 향하도록 Y 타겟은 거더+데크 중심으로 계산.
let (mn, mx) = scene_extents(&params);
let cx = (mn[0] + mx[0]) * 0.5;
let cy = (params.girder_height + params.slab_thickness) * 0.5;
let cz = (mn[2] + mx[2]) * 0.5;
let span = (mx[2] - mn[2]).max(mx[0] - mn[0]);
let mut camera = Camera {
target: glam::Vec3::new(cx, cy, cz),
radius: span * 1.2,
yaw: std::f32::consts::FRAC_PI_4,
pitch: 0.30,
fov_y: 60.0_f32.to_radians(),
aspect: 16.0 / 9.0,
znear: 10.0,
zfar: 10_000_000.0,
projection: Projection::Perspective,
};
let _ = mesh.aabb(); // keep aabb call for future use
camera.resize(surface_config.width, surface_config.height);
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("camera buffer"),
contents: bytemuck::cast_slice(&[camera.to_uniform()]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera bg"),
layout: &camera_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
// ── Pipeline ──────────────────────────────────────────────────────────
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("cimery shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline layout"),
bind_group_layouts: &[&camera_bgl],
push_constant_ranges: &[],
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("render pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
cache: None,
});
let (scene_mn, scene_mx) = scene_extents(&params);
// ── egui ──────────────────────────────────────────────────────────────
let egui_ctx = egui::Context::default();
// Load Korean system font (Windows Malgun Gothic) for CJK support
{
let mut fonts = egui::FontDefinitions::default();
let font_path = "C:\\Windows\\Fonts\\malgun.ttf";
if let Ok(data) = std::fs::read(font_path) {
fonts.font_data.insert(
"MalgunGothic".to_owned(),
egui::FontData::from_owned(data),
);
// Insert as primary font (index 0) so Korean renders by default
for family in fonts.families.values_mut() {
family.insert(0, "MalgunGothic".to_owned());
}
egui_ctx.set_fonts(fonts);
log::info!("Korean font loaded: {}", font_path);
} else {
log::warn!("Korean font not found at {}; UI labels will show boxes", font_path);
}
}
let egui_state = egui_winit::State::new(
egui_ctx.clone(),
egui::ViewportId::ROOT,
&*window,
None, None, None,
);
// egui renders 2D UI overlay — no depth buffer
let egui_renderer = egui_wgpu::Renderer::new(
&device, format, None, 1, false,
);
RenderState {
window,
device,
queue,
surface,
surface_config,
render_pipeline,
vertex_buffer,
index_buffer,
num_indices,
camera,
camera_buffer,
camera_bind_group,
depth_view,
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: true, // trigger initial feature build
alignment_scene: alignment_scene::AlignmentScene::none(),
egui_ctx,
egui_state,
egui_renderer,
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn make_depth_view(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth texture"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor::default())
}
/// Rebuild GPU buffers from current SceneParams.
fn rebuild_mesh(&mut self) {
// Background mesh: ground + alignment only (no features — avoids double draw)
let full_mesh: Result<cimery_kernel::Mesh, cimery_kernel::KernelError> =
Ok(build_background_scene(&self.params));
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("merged vbuf"), contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
self.index_buffer = self.device.create_buffer_init(
&wgpu::util::BufferInitDescriptor {
label: Some("merged ibuf"), contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
self.num_indices = mesh.indices.len() as u32;
}
// 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;
// Apply 후 교량 상부구조 (거더+데크) 로 제한한 BB로 카메라 자동 피트.
// scene_extents 는 지반·교대 푸팅까지 포함되어 center Y ≈ -1790mm(지반 아래) 가 되고,
// girder_h 가 바뀌어도 top/bot 이 같은 비율로 이동해 center 가 고정 → 카메라가
// 거더 중심을 추적하지 않아 "거더는 그대로"처럼 보이는 착시.
// → Y 범위를 [0, girder_h + slab] 로만 잡아 target Y = 거더+데크 중심이 되게 함.
let focus_top = self.params.girder_height + self.params.slab_thickness;
let focus_mn = [mn[0], 0.0_f32, mn[2]];
let focus_mx = [mx[0], focus_top, mx[2]];
self.camera.zoom_extents(focus_mn, focus_mx);
self.update_camera();
self.dirty = false;
}
fn update_camera(&self) {
self.queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[self.camera.to_uniform()]),
);
}
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.surface_config.width = new_size.width;
self.surface_config.height = new_size.height;
self.surface.configure(&self.device, &self.surface_config);
self.depth_view = Self::make_depth_view(&self.device, &self.surface_config);
self.camera.resize(new_size.width, new_size.height);
self.update_camera();
}
}
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 + apply highlight
for feat in &mut self.features { feat.selected = false; }
if let Some((_, idx)) = best { self.features[idx].selected = true; }
// Rewrite GPU vertex colours for all affected features
for feat in &self.features { feat.update_highlight(&self.queue); }
}
let raw_input = self.egui_state.take_egui_input(&self.window);
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;
// Projection toggle: egui 버튼이 self.camera 를 직접 못 건드리므로 플래그로 전달.
let mut toggle_ortho = false;
let is_ortho_now = self.camera.projection == Projection::Orthographic;
// Sprint 17: alignment display info (capture before closure)
let state_alignment_name: Option<String> = self.alignment_scene.alignment
.as_ref().map(|a| a.name.clone());
let state_alignment_len = self.alignment_scene.total_length_m();
let mut alignment_load_path: Option<std::path::PathBuf> = None;
// Sprint 14: Tab state for ribbon panels (persist across frames)
// Use a static-style approach: store active tab in params (or separate)
// For now: use a local var captured in closure — OK for per-frame UI
let full_output = self.egui_ctx.run(raw_input, |ctx| {
// ── Top ribbon bar (Sprint 14) ─────────────────────────────────
egui::TopBottomPanel::top("ribbon")
.exact_height(28.0)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.heading("cimery");
ui.separator();
// Quick-access toolbar buttons
if ui.small_button("E 전체뷰").clicked() {
// Handled via keyboard shortcut; duplicate here for accessibility
}
ui.separator();
let kernel_label = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRust" };
ui.small(format!("커널: {}", kernel_label));
ui.separator();
// Feature counters
ui.small(format!("피처: {}", p_features.len()));
});
});
// ── Left properties panel (Sprint 14 enhanced) ────────────────
egui::SidePanel::left("properties")
.resizable(true)
.min_width(240.0)
.default_width(260.0)
.show(ctx, |ui| {
// Panel title
ui.add_space(4.0);
ui.heading("속성 패널");
ui.separator();
// ── 상부구조 (Superstructure) ──────────────────────────
egui::CollapsingHeader::new("▼ 상부구조 (Superstructure)")
.default_open(true)
.show(ui, |ui| {
macro_rules! ps {
($lbl:expr, $v:expr, $r:expr, $s:expr) => {{
ui.label($lbl);
if ui.add(egui::Slider::new($v, $r).step_by($s)).changed() {
dirty = true;
}
}};
}
ps!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0);
ps!("거더 수", &mut p.girder_count, 3..=7, 1.0);
ps!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0);
ps!("거더 높이 (mm)", &mut p.girder_height, 1_000.0..=3_000.0, 100.0);
ps!("슬래브 두께 (mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0);
// Sprint 26: 다경간 지원
ps!("경간 수", &mut p.span_count, 1..=5, 1.0);
ui.label("교각 형식");
let prev_pt = p.pier_type;
ui.horizontal(|ui| {
ui.selectable_value(&mut p.pier_type, cimery_core::PierType::SingleColumn, "T형(단주)");
ui.selectable_value(&mut p.pier_type, cimery_core::PierType::MultiColumn, "π형(다주)");
});
if p.pier_type != prev_pt { dirty = true; }
// Sprint 27: 경사각 (Skew)
ps!("경사각 (°)", &mut p.skew_deg, -30.0..=30.0, 1.0);
ui.label("단면 형식");
let prev_sec = p.section_type;
egui::ComboBox::from_id_salt("section_type")
.selected_text(match p.section_type {
GirderSectionType::PscI => "PSC I형",
GirderSectionType::SteelBox => "강재 박스",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut p.section_type, GirderSectionType::PscI, "PSC I형");
ui.selectable_value(&mut p.section_type, GirderSectionType::SteelBox, "강재 박스");
});
if p.section_type != prev_sec { dirty = true; }
});
// ── Should Features (Sprint 19) ────────────────────────
egui::CollapsingHeader::new("▼ 추가 부재 (Should Features)")
.default_open(true)
.show(ui, |ui| {
let prev_cb = p.show_cross_beams;
ui.checkbox(&mut p.show_cross_beams, "가로보 (Cross Beam)");
if prev_cb != p.show_cross_beams { dirty = true; }
if p.show_cross_beams {
ui.label(" 가로보 간격 (m)");
if ui.add(egui::Slider::new(&mut p.cross_beam_interval_m, 3.0..=20.0).step_by(1.0)).changed() {
dirty = true;
}
}
let prev_ej = p.show_expansion_joints;
ui.checkbox(&mut p.show_expansion_joints, "신축이음 (Exp. Joint)");
if prev_ej != p.show_expansion_joints { dirty = true; }
});
// ── 표시 옵션 ─────────────────────────────────────────
egui::CollapsingHeader::new("▼ 표시 (Display)")
.default_open(false)
.show(ui, |ui| {
let prev_al = p.show_alignment;
ui.checkbox(&mut p.show_alignment, "선형 표시");
if prev_al != p.show_alignment { dirty = true; }
// 투영 모드 토글 (dirty 와 무관, 즉시 적용)
ui.horizontal(|ui| {
ui.label("투영:");
let label = if is_ortho_now { "■ Ortho (O)" } else { "◇ Perspective (O)" };
if ui.button(label).clicked() {
toggle_ortho = true;
}
});
});
ui.separator();
// Apply button
if dirty {
let btn = egui::Button::new("▶ 적용 (Apply)")
.fill(egui::Color32::from_rgb(50, 100, 200));
if ui.add(btn).clicked() { apply = true; }
} else {
ui.label(egui::RichText::new("✓ 최신 상태")
.color(egui::Color32::from_rgb(80, 200, 80)));
}
ui.separator();
// ── 선형 (Alignment, Sprint 17) ────────────────────────
egui::CollapsingHeader::new("▼ 선형 (Alignment)")
.default_open(false)
.show(ui, |ui| {
let aname = state_alignment_name.as_deref().unwrap_or("없음");
ui.label(format!("파일: {}", aname));
if state_alignment_len > 0.0 {
ui.label(format!("길이: {:.0} m", state_alignment_len));
}
if ui.button("📐 선형 불러오기").clicked() {
let p = std::path::Path::new("alignments/BR-001.json");
alignment_load_path = Some(p.to_path_buf());
}
});
ui.separator();
// ── 프로젝트 저장/불러오기 ──────────────────────────
egui::CollapsingHeader::new("▼ 프로젝트")
.default_open(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
if ui.button("💾 저장").clicked() {
let pf = ProjectFile::from_params("project", &self.params);
let path = project_file::default_save_path("project");
match pf.save(&path) {
Ok(_) => log::info!("Saved to {:?}", path),
Err(e) => log::error!("Save failed: {e}"),
}
}
if ui.button("📂 불러오기").clicked() {
let path = project_file::default_save_path("project");
if let Ok(pf) = ProjectFile::load(&path) {
p = pf.to_params();
dirty = true;
apply = true;
}
}
});
});
ui.separator();
// ── 선택 피처 표시 ────────────────────────────────────
if let Some(idx) = p_features.iter().position(|f| f.selected) {
ui.colored_label(
egui::Color32::from_rgb(255, 200, 50),
format!("▶ 선택: {}", p_features[idx].label),
);
} else {
ui.small("(좌클릭으로 피처 선택)");
}
ui.separator();
// ── 카메라 단축키 ──────────────────────────────────────
egui::CollapsingHeader::new("▼ 단축키")
.default_open(false)
.show(ui, |ui| {
ui.small("E: 전체뷰 (ZoomExtents)");
ui.small("7: 평면도 1: 정면 3: 측면");
ui.small("Home: 아이소 뷰 4: 왼쪽");
ui.small("가운데버튼: 회전");
ui.small("Shift+가운데: 팬");
ui.small("스크롤: 줌");
ui.small("Esc: 종료");
});
});
});
self.egui_state.handle_platform_output(&self.window, full_output.platform_output);
self.params = p;
self.dirty = dirty;
// Sprint 17: load alignment file if requested
if let Some(path) = alignment_load_path {
match alignment_scene::AlignmentScene::from_file(&path) {
Ok(as_) => {
log::info!("Alignment loaded: {} ({:.0} m)", as_.name(), as_.total_length_m());
self.alignment_scene = as_;
self.dirty = true;
}
Err(e) => log::warn!("Alignment load failed: {e}"),
}
}
if apply { self.rebuild_mesh(); }
if toggle_ortho {
self.camera.toggle_projection();
self.update_camera();
}
// ── 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 {
label: Some("render encoder"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.10, g: 0.16, b: 0.24, a: 1.0, // dark blue-grey bg
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: None,
});
rp.set_pipeline(&self.render_pipeline);
rp.set_bind_group(0, &self.camera_bind_group, &[]);
// 1. Background (ground + alignment)
rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rp.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..self.num_indices, 0, 0..1);
// 2. Per-feature meshes (with selection highlight)
for feat in &self.features {
rp.set_vertex_buffer(0, feat.vertex_buffer.slice(..));
rp.set_index_buffer(feat.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..feat.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(())
}
}
// ─── CimeryApp ────────────────────────────────────────────────────────────────
pub struct CimeryApp {
state: Option<RenderState>,
}
impl CimeryApp {
pub fn new() -> Self { Self { state: None } }
}
impl Default for CimeryApp {
fn default() -> Self { Self::new() }
}
/// 빌드 타임스탬프 (build.rs 에서 주입). 타이틀에 표시해서 사용자가 실행 중인
/// 바이너리가 최신 빌드인지 즉시 확인 가능.
const BUILD_TS: &str = env!("BUILD_TIMESTAMP");
impl ApplicationHandler for CimeryApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let kernel = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRustKernel" };
let title = format!("cimery viewer [{kernel}] — build {BUILD_TS}");
let attrs = Window::default_attributes()
.with_title(title)
.with_inner_size(winit::dpi::LogicalSize::new(1280u32, 720u32));
let window = Arc::new(
event_loop.create_window(attrs).expect("create window"),
);
self.state = Some(pollster::block_on(RenderState::new(Arc::clone(&window))));
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
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(),
// ── Shift modifier tracking ───────────────────────────────────────
WindowEvent::ModifiersChanged(mods) => {
state.shift_pressed = mods.state().shift_key();
}
// ── Keyboard shortcuts ────────────────────────────────────────────
WindowEvent::KeyboardInput { event: KeyEvent { physical_key, state: key_state, .. }, .. }
if key_state == ElementState::Pressed =>
{
match physical_key {
PhysicalKey::Code(KeyCode::Escape) => event_loop.exit(),
// E → ZoomExtents (fit all)
PhysicalKey::Code(KeyCode::KeyE) => {
state.camera.zoom_extents(state.scene_mn, state.scene_mx);
state.update_camera();
}
// O → Perspective ↔ Orthographic 투영 토글 (실측 확인용)
PhysicalKey::Code(KeyCode::KeyO) => {
state.camera.toggle_projection();
state.update_camera();
}
// Numpad 7 / 7 → Top view
PhysicalKey::Code(KeyCode::Numpad7) | PhysicalKey::Code(KeyCode::Digit7) => {
state.camera.set_standard_view(StandardView::Top);
state.update_camera();
}
// Numpad 1 / 1 → Front view
PhysicalKey::Code(KeyCode::Numpad1) | PhysicalKey::Code(KeyCode::Digit1) => {
state.camera.set_standard_view(StandardView::Front);
state.update_camera();
}
// Numpad 3 / 3 → Right side view
PhysicalKey::Code(KeyCode::Numpad3) | PhysicalKey::Code(KeyCode::Digit3) => {
state.camera.set_standard_view(StandardView::Right);
state.update_camera();
}
// Numpad 4 / 4 → Left side view
PhysicalKey::Code(KeyCode::Numpad4) | PhysicalKey::Code(KeyCode::Digit4) => {
state.camera.set_standard_view(StandardView::Left);
state.update_camera();
}
// Home → Isometric (default view)
PhysicalKey::Code(KeyCode::Home) => {
state.camera.set_standard_view(StandardView::Iso);
state.update_camera();
}
_ => {}
}
}
// ── 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;
}
WindowEvent::CursorMoved { position, .. } => {
if state.mid_pressed {
let dx = (position.x - state.last_mouse.x) as f32;
let dy = (position.y - state.last_mouse.y) as f32;
if state.shift_pressed {
// Shift + Middle = Pan (translate target)
state.camera.pan(dx, dy);
} else {
// Middle = Orbit
state.camera.orbit(dx, dy);
}
state.update_camera();
}
state.last_mouse = position;
}
// ── Zoom (scroll wheel) ───────────────────────────────────────────
WindowEvent::MouseWheel { delta, .. } => {
let scroll = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01,
};
state.camera.zoom(scroll);
state.update_camera();
}
// ── Render ────────────────────────────────────────────────────────
WindowEvent::RedrawRequested => {
match state.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let sz = state.window.inner_size();
state.resize(sz);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("GPU OOM — exiting");
event_loop.exit();
}
Err(e) => log::warn!("surface error: {:?}", e),
}
state.window.request_redraw();
}
_ => {}
}
}
}
// ─── Entry point ─────────────────────────────────────────────────────────────
/// Run the cimery viewer. Blocks until the window is closed.
pub fn run_viewer() {
let event_loop = EventLoop::new().expect("create event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = CimeryApp::new();
event_loop.run_app(&mut app).expect("event loop error");
}