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>
1049 lines
50 KiB
Rust
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, ¶ms).expect("bridge scene");
|
|
#[cfg(not(feature = "occt"))]
|
|
let mesh = build_bridge_scene(&PureRustKernel, ¶ms).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(¶ms);
|
|
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(¶ms);
|
|
|
|
// ── 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");
|
|
}
|