3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git
src/main.rs (37.6K)
1 //! cce-model — a viewer for 3D model files.
2 //!
3 //! Opens STL, OBJ, glTF/GLB and PLY (read by cce-mesh-io), frames the model
4 //! in a three-quarter view over a grid floor and lets you turn it. Files are
5 //! read and split into lit meshes on a worker thread (`cce_mesh_io::load`
6 //! + `light::lit_parts`, one per material), then uploaded once and drawn
7 //! through cce-ui's lit pipeline — per-fragment light, metallic-roughness
8 //! specular, base-colour textures — between a screen-space background
9 //! gradient and the grid. The whole window is the scene. The wireframe and normals overlays are built on a worker the
10 //! first time either is asked for, so a big model does not pay for them
11 //! unless they are wanted.
12 //!
13 //! `r` swaps in a path-traced view (cce-ui's tracer): whenever the camera
14 //! has been still for `STILL`, each frame adds a sample until the cap
15 //! (`trace::SAMPLES_ON_MAINS`, fewer on battery), after which nothing more
16 //! is staged and the GPU is left idle with the result on screen. Any
17 //! camera move drops straight back to the raster view.
18 //!
19 //! Mouse: drag orbits, shift+drag or middle-drag pans, ctrl+wheel and pinch
20 //! zoom, a two-finger scroll orbits (and coasts), as in cce-designer. A file
21 //! dropped on the window opens. Keys: o open · ←/→ the folder's other models
22 //! · 0 frame all · g grid · w wireframe · n normals · r traced · q quit.
23
24 mod camera;
25 mod files;
26 mod light;
27 mod overlay;
28 mod trace;
29 mod units;
30
31 use std::path::{Path, PathBuf};
32 use std::sync::Arc;
33 use std::time::{Duration, Instant};
34
35 use cce_mesh_io::{Mesh, Texture, Unit, UpAxis};
36 use cce_ui::engine::{
37 AppSender, Application, LitDraw, LitMaterial, LitMeshId, LogicalPosition, LogicalSize, MeshId, PreparedRtScene,
38 RtCamera, SceneDraw, Stage3D, Vertex3D, WindowSettings,
39 };
40 use cce_ui::scene::layout::Rect;
41 use cce_ui::scene::paint::{DisplayList, PaintCtx};
42 use cce_ui::widget::scroll_motion::{current_scroll_phase, Bounds, ScrollMotion, ScrollPhase};
43 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
44 use glam::Vec3;
45
46 use camera::Camera;
47
48 /// Wheel notches in logical px of orbit, and of log-zoom.
49 const ORBIT_PX_PER_LINE: f32 = 10.0;
50 const ZOOM_PER_LINE: f32 = 0.15;
51 const ZOOM_PER_PX: f32 = 0.005;
52
53 /// The background gradient, linear RGB, top and bottom.
54 const SKY_TOP: [f32; 3] = [0.105, 0.11, 0.125];
55 const SKY_BOTTOM: [f32; 3] = [0.03, 0.03, 0.035];
56
57 /// How strongly the wireframe shows over the model.
58 const WIRE_OPACITY: f32 = 0.55;
59
60 const HINTS: &str =
61 "o open · ←/→ folder · 0 frame · g grid · w wireframe · n normals · r traced";
62
63 /// How long the camera must rest before the traced view takes over.
64 const STILL: Duration = Duration::from_millis(150);
65
66 /// Where the system lists its power supplies, for the battery check.
67 const POWER_SUPPLIES: &str = "/sys/class/power_supply";
68
69 #[derive(Debug, Clone)]
70 enum Message {
71 /// The model behind an `Arc`: the runner may clone a message, and a
72 /// clone must not copy the vertex buffer.
73 Loaded { generation: u64, path: PathBuf, result: Result<Arc<Model>, String> },
74 /// The wireframe and the normals of the model of `generation`.
75 Overlays { generation: u64, edges: Arc<Vec<Vertex3D>>, normals: Arc<Vec<Vertex3D>> },
76 /// The traced view's scene for the model of `generation`.
77 TraceScene { generation: u64, scene: Arc<PreparedRtScene> },
78 Quit,
79 }
80
81 /// A model read, welded, fitted and lit, ready to upload.
82 #[derive(Debug)]
83 struct Model {
84 name: String,
85 /// In the fitted space it is drawn in: the overlays are built from it.
86 mesh: Mesh,
87 parts: usize,
88 /// Its box's sides in file units, upright, and what those units are.
89 size: Vec3,
90 unit: Unit,
91 /// Kept after upload: a replacement renderer (a reconnect) starts with
92 /// no meshes and no images, and these are what go back up.
93 surfaces: Vec<light::LitPart>,
94 textures: Vec<Texture>,
95 grid: Vec<Vertex3D>,
96 grid_step: f32,
97 /// The grid's height in the fitted space: where the traced ground is.
98 floor_y: f32,
99 }
100
101 impl Model {
102 fn read(path: &Path) -> Result<Model, String> {
103 let mut scene = cce_mesh_io::load(path)?;
104 let textures = std::mem::take(&mut scene.textures);
105 let mut mesh = scene.merged();
106 // STL is Z-up by convention; the view is Y-up. Turned here, not by
107 // the reader, which reports the file as it is.
108 if scene.up == UpAxis::Z {
109 mesh.z_up_to_y_up();
110 }
111 let (lo, hi) = mesh.bounds().ok_or("the file holds no points")?;
112 // Every model is drawn inside the unit sphere, so the camera frames
113 // that sphere whatever the file's units.
114 let (centre, radius) = mesh.fit_to_unit();
115 let grid = overlay::grid(&overlay::Fit { centre, radius, lo, hi });
116 Ok(Model {
117 name: path.file_name().and_then(|n| n.to_str()).unwrap_or("?").to_string(),
118 parts: scene.parts.len(),
119 size: hi - lo,
120 unit: scene.unit,
121 surfaces: light::lit_parts(&mesh),
122 textures,
123 mesh,
124 grid: grid.lines,
125 grid_step: grid.step,
126 floor_y: (lo.y - centre.y) / radius,
127 })
128 }
129 }
130
131 /// The wireframe and normals of the current model, once built.
132 struct Overlays {
133 edges: Arc<Vec<Vertex3D>>,
134 normals: Arc<Vec<Vertex3D>>,
135 }
136
137 /// What the current renderer holds for us. A slot is created at its first
138 /// upload (a mesh of no vertices would be a zero-sized buffer) and updated
139 /// in place after, so stepping through a folder does not leak meshes.
140 struct Gpu {
141 background: MeshId,
142 /// One lit mesh per material; slots are reused by the next model, and
143 /// only the first `model_parts` belong to the current one.
144 model: Vec<LitMeshId>,
145 model_parts: usize,
146 grid: Option<MeshId>,
147 edges: Option<MeshId>,
148 normals: Option<MeshId>,
149 }
150
151 /// Which of the CPU-side vertex lists still have to go up.
152 #[derive(Default, Clone, Copy)]
153 struct Pending {
154 model: bool,
155 grid: bool,
156 edges: bool,
157 normals: bool,
158 }
159
160 fn upload(stage: &mut dyn Stage3D, slot: &mut Option<MeshId>, verts: &[Vertex3D]) {
161 if verts.is_empty() {
162 return;
163 }
164 match *slot {
165 Some(id) => stage.update_mesh(id, verts),
166 None => *slot = Some(stage.create_mesh(verts)),
167 }
168 }
169
170 enum Drag {
171 Orbit,
172 Pan,
173 }
174
175 struct ModelApp {
176 sender: AppSender<Message>,
177 /// Bumped by every open, so a slow load (or overlay build) the user has
178 /// moved past is dropped when it arrives.
179 generation: u64,
180 loading: Option<PathBuf>,
181 model: Option<Arc<Model>>,
182 error: Option<String>,
183 /// The model's textures as this renderer's image ids, by the scene's
184 /// texture index (`None` for one that is not uploaded).
185 texture_ids: Vec<Option<u32>>,
186 /// Whether a renderer has been handed over yet: a later one is a
187 /// replacement, and every image id died with the old one.
188 seen_renderer: bool,
189 /// The folder's models, for ←/→, and where the open one is among them.
190 files: Vec<PathBuf>,
191 file_at: usize,
192 overlays: Option<Overlays>,
193 building_overlays: bool,
194 show_grid: bool,
195 show_wire: bool,
196 show_normals: bool,
197 /// The path-traced view: asked for, its scene once built, whether the
198 /// current renderer holds it, and how far it has refined.
199 traced: bool,
200 /// Built on a worker — packed, and its BVH built when the renderer's
201 /// tracer wants one — so the UI thread only uploads it.
202 trace_scene: Option<Arc<PreparedRtScene>>,
203 /// Whether the renderer's tracer traverses a CPU-built BVH (asked in
204 /// `init_3d`): the worker builds one only then.
205 rt_needs_bvh: bool,
206 building_trace: bool,
207 trace_uploaded: bool,
208 /// A frame saying "preparing" has gone out ahead of the upload. The BVH
209 /// is the worker's now, so the upload is short (61 ms for 5M triangles
210 /// on the iGPU, against 2.1 s when it built the BVH too), but the first
211 /// one also makes the tracer's pipelines.
212 trace_announced: bool,
213 samples: u32,
214 sample_cap: u32,
215 /// The camera and pane last staged, and when they last changed: the
216 /// tracer waits for `STILL` after any change.
217 view_key: [u32; 8],
218 moved_at: Instant,
219 /// When the current refinement began, to log how long it took.
220 trace_began: Option<Instant>,
221 camera: Camera,
222 gpu: Option<Gpu>,
223 pending: Pending,
224 /// The view changed since the scene was last staged. A staged scene
225 /// stays in the backdrop until the next one, so a frame that only
226 /// changes the HUD does not redraw the model.
227 scene_dirty: bool,
228 /// Two-finger scroll orbit, in logical px: a finger tracks 1:1, the
229 /// lift coasts, a notch glides. Only how far it moved matters.
230 orbit_motion: ScrollMotion,
231 pointer: (f32, f32),
232 drag: Option<Drag>,
233 ctrl: bool,
234 shift: bool,
235 win: (f32, f32),
236 scale: f64,
237 }
238
239 impl ModelApp {
240 fn aspect(&self) -> f32 {
241 self.win.0 / self.win.1.max(1.0)
242 }
243
244 /// Read `path` on a worker. A file opened from outside the folder list
245 /// (the dialog, a drop, the command line) makes its folder the list.
246 fn open(&mut self, path: PathBuf, new_folder: bool) {
247 if new_folder {
248 (self.files, self.file_at) = files::siblings(&path);
249 }
250 self.generation += 1;
251 let generation = self.generation;
252 self.loading = Some(path.clone());
253 self.error = None;
254 let sender = self.sender.clone();
255 std::thread::spawn(move || {
256 // A reader that panics on a malformed file must not leave the
257 // window saying "Loading…" for ever: the panic becomes the error.
258 let started = std::time::Instant::now();
259 let result = std::panic::catch_unwind(|| Model::read(&path))
260 .unwrap_or_else(|_| Err("the reader crashed on this file".to_string()))
261 .map(Arc::new);
262 log::info!("[model] read and lit in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
263 // Fails only once the app has gone, when nobody wants the model.
264 let _ = sender.send(Message::Loaded { generation, path, result });
265 });
266 }
267
268 fn open_dialog(&mut self) {
269 let filters: &[(&str, &[&str])] = &[
270 ("3D models", cce_mesh_io::EXTENSIONS),
271 ("STL", &["stl"]),
272 ("OBJ", &["obj"]),
273 ("glTF", &["gltf", "glb"]),
274 ("PLY", &["ply"]),
275 ];
276 if let Some(path) = cce_ui::file_dialog::pick_file("Open model", filters) {
277 self.open(path, true);
278 }
279 }
280
281 /// The next (`step` 1) or previous (−1) model in the folder, wrapping.
282 fn step_file(&mut self, step: i64) {
283 if self.files.len() < 2 {
284 return;
285 }
286 let n = self.files.len() as i64;
287 self.file_at = (self.file_at as i64 + step).rem_euclid(n) as usize;
288 self.open(self.files[self.file_at].clone(), false);
289 }
290
291 /// Build the wireframe and normals on a worker, if they are wanted and
292 /// not built or being built.
293 fn want_overlays(&mut self) {
294 if !(self.show_wire || self.show_normals) || self.overlays.is_some() || self.building_overlays {
295 return;
296 }
297 let Some(model) = self.model.clone() else { return };
298 self.building_overlays = true;
299 let (generation, sender) = (self.generation, self.sender.clone());
300 std::thread::spawn(move || {
301 let started = std::time::Instant::now();
302 let edges = Arc::new(overlay::edges(&model.mesh));
303 let normals = Arc::new(overlay::normals(&model.mesh, &light::normals(&model.mesh)));
304 log::info!(
305 "[model] {} edges and {} normals in {:.0} ms",
306 edges.len() / 2,
307 normals.len() / 2,
308 started.elapsed().as_secs_f64() * 1e3
309 );
310 let _ = sender.send(Message::Overlays { generation, edges, normals });
311 });
312 }
313
314 /// Upload the model's textures (mipmapped: they are seen small as well
315 /// as large), freeing the ids of the ones before.
316 fn upload_textures(&mut self) {
317 for id in self.texture_ids.drain(..).flatten() {
318 cce_ui::draw::free_image(id);
319 }
320 if let Some(m) = &self.model {
321 self.texture_ids = m
322 .textures
323 .iter()
324 .map(|t| Some(cce_ui::draw::upload_rgba_mipmapped(t.rgba.clone(), t.width, t.height)))
325 .collect();
326 }
327 }
328
329 /// Build the tracer's scene on a worker, if the traced view is wanted
330 /// and it is not built or being built.
331 fn want_trace(&mut self) {
332 if !self.traced || self.trace_scene.is_some() || self.building_trace {
333 return;
334 }
335 let Some(model) = self.model.clone() else { return };
336 self.building_trace = true;
337 let (generation, sender, with_bvh) = (self.generation, self.sender.clone(), self.rt_needs_bvh);
338 std::thread::spawn(move || {
339 let started = Instant::now();
340 let (triangles, materials) = trace::scene(&model.mesh, &model.textures, model.floor_y);
341 let n_materials = materials.len();
342 let scene = PreparedRtScene::new(triangles, &materials, None, with_bvh);
343 log::info!(
344 "[model] trace scene: {} triangles, {} materials{} in {:.0} ms",
345 scene.triangle_count(),
346 n_materials,
347 if with_bvh { ", BVH" } else { "" },
348 started.elapsed().as_secs_f64() * 1e3
349 );
350 let _ = sender.send(Message::TraceScene { generation, scene: Arc::new(scene) });
351 });
352 }
353
354 fn toggle_trace(&mut self) {
355 self.traced = !self.traced;
356 self.samples = 0;
357 self.trace_began = None;
358 // Asked once a toggle: a plug pulled mid-session takes effect at the
359 // next `r` or the next model.
360 self.sample_cap = if trace::on_battery(Path::new(POWER_SUPPLIES)) {
361 trace::SAMPLES_ON_BATTERY
362 } else {
363 trace::SAMPLES_ON_MAINS
364 };
365 self.want_trace();
366 self.scene_dirty = true;
367 }
368
369 fn frame_all(&mut self) {
370 if self.model.is_some() {
371 self.camera.frame(Vec3::ZERO, 1.0, self.aspect());
372 self.orbit_motion = ScrollMotion::new();
373 self.scene_dirty = true;
374 }
375 }
376
377 /// Orbit by how far the scroll motion moved since `before`.
378 fn orbit_since(&mut self, before: (f32, f32)) -> bool {
379 let (dx, dy) = (self.orbit_motion.x.pos() - before.0, self.orbit_motion.y.pos() - before.1);
380 if dx == 0.0 && dy == 0.0 {
381 return false;
382 }
383 self.camera.orbit(dx, dy);
384 self.scene_dirty = true;
385 true
386 }
387
388 fn orbit_pos(&self) -> (f32, f32) {
389 (self.orbit_motion.x.pos(), self.orbit_motion.y.pos())
390 }
391
392 /// The HUD: the file (and its place in the folder), then what it holds.
393 fn hud_lines(&self) -> Vec<String> {
394 let place = if self.files.len() > 1 { format!(" · {} of {}", self.file_at + 1, self.files.len()) } else { String::new() };
395 if let Some(path) = &self.loading {
396 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
397 return vec![format!("Loading {name}…{place}")];
398 }
399 let Some(m) = &self.model else { return Vec::new() };
400 let mut facts = vec![
401 format!("{} triangles", group_thousands(m.mesh.triangles.len())),
402 format!("{} points", group_thousands(m.mesh.positions.len())),
403 ];
404 if m.parts > 1 {
405 facts.push(format!("{} parts", m.parts));
406 }
407 facts.push(units::dimensions(m.size, m.unit));
408 if self.show_grid {
409 facts.push(format!("grid {}", units::length(m.grid_step, m.unit)));
410 }
411 if self.traced {
412 let battery = if self.sample_cap < trace::SAMPLES_ON_MAINS { " (battery)" } else { "" };
413 facts.push(if self.building_trace || (self.trace_scene.is_some() && !self.trace_uploaded) {
414 "traced: preparing…".into()
415 } else if self.samples >= self.sample_cap {
416 format!("traced, {} samples{battery}", self.samples)
417 } else {
418 format!("traced {} / {}{battery}", self.samples, self.sample_cap)
419 });
420 }
421 if self.building_overlays {
422 facts.push("building overlays…".into());
423 } else if self.show_wire && m.mesh.triangles.len() > overlay::MAX_WIRE_TRIANGLES {
424 facts.push(format!("no wireframe over {} triangles", group_thousands(overlay::MAX_WIRE_TRIANGLES)));
425 }
426 vec![format!("{}{place}", m.name), facts.join(" · ")]
427 }
428 }
429
430 /// 1234567 → "1,234,567".
431 fn group_thousands(n: usize) -> String {
432 let digits = n.to_string();
433 let mut out = String::new();
434 for (i, c) in digits.chars().enumerate() {
435 if i > 0 && (digits.len() - i) % 3 == 0 {
436 out.push(',');
437 }
438 out.push(c);
439 }
440 out
441 }
442
443 /// The camera and pane, as bits: any change restarts the tracer's wait.
444 fn view_key(c: &Camera, pw: u32, ph: u32) -> [u32; 8] {
445 [c.yaw.to_bits(), c.pitch.to_bits(), c.distance.to_bits(), c.pivot.x.to_bits(), c.pivot.y.to_bits(), c.pivot.z.to_bits(), pw, ph]
446 }
447
448 /// A scene draw of `mesh` with every option at its plain default.
449 fn draw(mesh: MeshId, mvp: [[f32; 4]; 4]) -> SceneDraw {
450 SceneDraw {
451 mesh,
452 mvp,
453 wireframe: false,
454 wire_tint: [0.0; 4],
455 opacity: 1.0,
456 line_width: 1.0,
457 wire_base_width: 0.0,
458 prelit: false,
459 see_through: false,
460 instances: None,
461 screen_space: false,
462 }
463 }
464
465 impl Application for ModelApp {
466 type Message = Message;
467
468 fn create(sender: AppSender<Message>) -> Self {
469 let mut app = Self {
470 sender,
471 generation: 0,
472 loading: None,
473 model: None,
474 error: None,
475 texture_ids: Vec::new(),
476 seen_renderer: false,
477 files: Vec::new(),
478 file_at: 0,
479 overlays: None,
480 building_overlays: false,
481 show_grid: true,
482 show_wire: false,
483 show_normals: false,
484 traced: false,
485 trace_scene: None,
486 rt_needs_bvh: true,
487 building_trace: false,
488 trace_uploaded: false,
489 trace_announced: false,
490 samples: 0,
491 sample_cap: trace::SAMPLES_ON_MAINS,
492 view_key: [0; 8],
493 moved_at: Instant::now(),
494 trace_began: None,
495 camera: Camera::default(),
496 gpu: None,
497 pending: Pending::default(),
498 scene_dirty: true,
499 orbit_motion: ScrollMotion::new(),
500 pointer: (0.0, 0.0),
501 drag: None,
502 ctrl: false,
503 shift: false,
504 win: (1000.0, 750.0),
505 scale: 1.0,
506 };
507 if let Some(path) = std::env::args_os().nth(1) {
508 app.open(PathBuf::from(path), true);
509 }
510 app
511 }
512
513 fn settings(&self) -> WindowSettings {
514 let title = match &self.model {
515 Some(m) => format!("{} — Model", m.name),
516 None => "Model".to_string(),
517 };
518 WindowSettings { title, app_id: "cce-model".into(), width: 1000, height: 750, fullscreen: false, min_size: Some((320, 240)) }
519 }
520
521 fn update(&mut self, msg: Message, needs_rebuild: &mut bool, exit: &mut bool) {
522 match msg {
523 Message::Quit => *exit = true,
524 Message::Loaded { generation, path, result } => {
525 if generation != self.generation {
526 return;
527 }
528 self.loading = None;
529 match result {
530 Ok(model) => {
531 log::info!(
532 "[model] {}: {} triangles, {} materials, {} textures",
533 path.display(),
534 model.mesh.triangles.len(),
535 model.surfaces.len(),
536 model.textures.len()
537 );
538 self.model = Some(model);
539 self.upload_textures();
540 self.overlays = None;
541 self.building_overlays = false;
542 self.pending = Pending { model: true, grid: true, edges: false, normals: false };
543 self.trace_scene = None;
544 self.building_trace = false;
545 self.trace_uploaded = false;
546 self.trace_announced = false;
547 self.samples = 0;
548 self.frame_all();
549 self.want_overlays();
550 self.want_trace();
551 }
552 Err(e) => {
553 log::warn!("[model] {}: {e}", path.display());
554 let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
555 self.error = Some(format!("{name}: {e}"));
556 }
557 }
558 *needs_rebuild = true;
559 }
560 Message::TraceScene { generation, scene } => {
561 if generation != self.generation {
562 return;
563 }
564 self.building_trace = false;
565 self.trace_scene = Some(scene);
566 self.trace_uploaded = false;
567 self.trace_announced = false;
568 self.samples = 0;
569 *needs_rebuild = true;
570 }
571 Message::Overlays { generation, edges, normals } => {
572 if generation != self.generation {
573 return;
574 }
575 self.building_overlays = false;
576 self.overlays = Some(Overlays { edges, normals });
577 self.pending.edges = true;
578 self.pending.normals = true;
579 self.scene_dirty = true;
580 *needs_rebuild = true;
581 }
582 }
583 }
584
585 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
586 if self.orbit_motion.is_animating() {
587 let before = self.orbit_pos();
588 self.orbit_motion.tick(dt, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
589 if self.orbit_since(before) || self.orbit_motion.is_animating() {
590 *needs_rebuild = true;
591 }
592 }
593 }
594
595 fn load_system_fonts(&self) -> bool {
596 true
597 }
598
599 fn display_list_text(&self) -> bool {
600 true
601 }
602
603 /// Once per renderer, the first and any replacement after a reconnect:
604 /// a new renderer holds no meshes, so everything goes up again.
605 fn init_3d(&mut self, stage: &mut dyn Stage3D) {
606 // Drawn as a `screen_space` draw: these corners are NDC, at the far
607 // plane, untouched by the mvp; their z is ignored.
608 let bg = |x: f32, y: f32, color: [f32; 3]| Vertex3D { position: [x, y, 0.0], color };
609 let background = stage.create_mesh(&[
610 bg(-1.0, -1.0, SKY_BOTTOM),
611 bg(1.0, -1.0, SKY_BOTTOM),
612 bg(1.0, 1.0, SKY_TOP),
613 bg(-1.0, -1.0, SKY_BOTTOM),
614 bg(1.0, 1.0, SKY_TOP),
615 bg(-1.0, 1.0, SKY_TOP),
616 ]);
617 stage.set_scene_light(light::KEY.normalize().to_array());
618 self.rt_needs_bvh = stage.rt_needs_bvh();
619 match stage.lit() {
620 Some(lit) => lit.set_lit_light(light::rig()),
621 None => self.error = Some("this renderer has no lit pipeline; nothing can be drawn".into()),
622 }
623 // A replacement renderer: the images went with the old one.
624 if std::mem::replace(&mut self.seen_renderer, true) {
625 self.upload_textures();
626 }
627 self.gpu = Some(Gpu { background, model: Vec::new(), model_parts: 0, grid: None, edges: None, normals: None });
628 let has = self.model.is_some();
629 let built = self.overlays.is_some();
630 self.pending = Pending { model: has, grid: has, edges: built, normals: built };
631 // The tracer's scene and its accumulation died with the old renderer.
632 self.trace_uploaded = false;
633 self.trace_announced = false;
634 self.samples = 0;
635 self.scene_dirty = true;
636 }
637
638 fn stage_3d(&mut self, stage: &mut dyn Stage3D, size: LogicalSize, scale: f64) -> bool {
639 let Some(gpu) = self.gpu.as_mut() else { return false };
640 let pending = std::mem::take(&mut self.pending);
641 if let Some(m) = &self.model {
642 if let (true, Some(lit)) = (pending.model, stage.lit()) {
643 // On the UI thread, unavoidably: the stage is only lent here.
644 // Logged, because it is the one part of a load the window waits on.
645 let started = std::time::Instant::now();
646 for (i, part) in m.surfaces.iter().enumerate() {
647 match gpu.model.get(i) {
648 Some(&id) => lit.update_lit_mesh(id, &part.verts),
649 None => gpu.model.push(lit.create_lit_mesh(&part.verts)),
650 }
651 }
652 gpu.model_parts = m.surfaces.len();
653 let count: usize = m.surfaces.iter().map(|p| p.verts.len()).sum();
654 log::info!("[model] uploaded {count} vertices in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
655 }
656 if pending.grid {
657 upload(stage, &mut gpu.grid, &m.grid);
658 }
659 }
660 if let Some(o) = &self.overlays {
661 if pending.edges {
662 upload(stage, &mut gpu.edges, &o.edges);
663 }
664 if pending.normals {
665 upload(stage, &mut gpu.normals, &o.normals);
666 }
667 }
668 if pending.model || pending.grid || pending.edges || pending.normals {
669 self.scene_dirty = true;
670 }
671 let (pw, ph) = ((size.width as f64 * scale) as u32, (size.height as f64 * scale) as u32);
672 let view_proj = self.camera.view_proj(pw as f32 / ph.max(1) as f32);
673
674 // The traced view: once the camera has rested, a sample a frame up
675 // to the cap; then nothing, and the backdrop keeps the image.
676 let key = view_key(&self.camera, pw, ph);
677 if key != self.view_key {
678 self.view_key = key;
679 self.moved_at = Instant::now();
680 self.samples = 0;
681 self.trace_began = None;
682 }
683 let tracing = self.traced && self.model.is_some() && self.trace_scene.is_some();
684 if tracing && self.moved_at.elapsed() >= STILL {
685 if self.samples >= self.sample_cap {
686 return false;
687 }
688 if !self.trace_uploaded {
689 // The HUD is painted before this runs: return once so the
690 // frame saying "preparing" is presented, and upload on the
691 // next.
692 if !std::mem::replace(&mut self.trace_announced, true) {
693 return true;
694 }
695 let started = Instant::now();
696 stage.set_rt_scene_prepared(self.trace_scene.as_ref().unwrap());
697 stage.set_rt_environment(trace::environment(light::KEY.normalize().to_array()));
698 stage.set_rt_background(Some(trace::BACKGROUND));
699 log::info!("[model] trace scene uploaded in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
700 self.trace_uploaded = true;
701 }
702 let began = *self.trace_began.get_or_insert_with(Instant::now);
703 stage.stage_rt((0, 0, pw, ph), RtCamera { inv_mvp: view_proj.inverse().to_cols_array_2d() });
704 self.samples += 1;
705 // Whatever is staged next (a move's raster frame) must restage.
706 self.scene_dirty = true;
707 if self.samples == self.sample_cap {
708 log::info!("[model] traced {} samples in {:.0} ms; idle", self.samples, began.elapsed().as_secs_f64() * 1e3);
709 }
710 // One frame more than samples: the HUD is painted before this
711 // runs, so the frame after the last sample is the one that says
712 // the cap was reached; that frame stages nothing (above).
713 return true;
714 }
715 if !std::mem::replace(&mut self.scene_dirty, false) {
716 // Still waiting out STILL: keep the frames coming to notice it.
717 return tracing;
718 }
719 let mvp = view_proj.to_cols_array_2d();
720 // One logical px, whatever the output scale (clamped by the device).
721 let line_width = scale as f32;
722 let lines = |mesh| SceneDraw { wireframe: true, line_width, ..draw(mesh, mvp) };
723 let mut draws = vec![SceneDraw { screen_space: true, ..draw(gpu.background, mvp) }];
724 let mut lit_draws = Vec::new();
725 if let Some(m) = &self.model {
726 let wire = self.show_wire && self.overlays.is_some();
727 if let (true, Some(id)) = (self.show_grid, gpu.grid) {
728 draws.push(lines(id));
729 }
730 // The model, after the background and grid and before the wires.
731 let eye = self.camera.eye().to_array();
732 for (part, &id) in m.surfaces.iter().zip(&gpu.model).take(gpu.model_parts) {
733 let mat = &part.material;
734 lit_draws.push(LitDraw {
735 mesh: id,
736 mvp,
737 eye,
738 material: LitMaterial {
739 base_color: mat.color,
740 texture: mat.texture.and_then(|t| self.texture_ids.get(t).copied().flatten()),
741 metallic: mat.metallic,
742 roughness: mat.roughness,
743 },
744 opacity: 1.0,
745 wire_base_width: if wire { line_width } else { 0.0 },
746 before: draws.len() as u32,
747 });
748 }
749 if let (true, Some(id)) = (wire, gpu.edges) {
750 draws.push(SceneDraw { opacity: WIRE_OPACITY, ..lines(id) });
751 }
752 if let (true, true, Some(id)) = (self.show_normals, self.overlays.is_some(), gpu.normals) {
753 draws.push(lines(id));
754 }
755 }
756 stage.stage_scene((0, 0, pw, ph), draws);
757 if let Some(lit) = stage.lit() {
758 lit.stage_lit(lit_draws);
759 }
760 tracing
761 }
762
763 fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
764 self.win = (width, height);
765 self.scale = scale;
766 self.scene_dirty = true;
767 }
768
769 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
770 let (x, y) = (pos.x as f32, pos.y as f32);
771 let (dx, dy) = (x - self.pointer.0, y - self.pointer.1);
772 self.pointer = (x, y);
773 match self.drag {
774 Some(Drag::Orbit) => self.camera.orbit(dx, dy),
775 Some(Drag::Pan) => self.camera.pan(dx, dy, self.win.1),
776 None => return,
777 }
778 self.scene_dirty = true;
779 *needs_rebuild = true;
780 }
781
782 fn handle_mouse_input(
783 &mut self,
784 button: MouseButton,
785 state: ElementState,
786 pos: LogicalPosition,
787 _needs_rebuild: &mut bool,
788 ) -> Option<Message> {
789 self.pointer = (pos.x as f32, pos.y as f32);
790 if state == ElementState::Released {
791 self.drag = None;
792 return None;
793 }
794 self.drag = match button {
795 MouseButton::Left if self.shift => Some(Drag::Pan),
796 MouseButton::Left => Some(Drag::Orbit),
797 MouseButton::Middle => Some(Drag::Pan),
798 _ => None,
799 };
800 if self.drag.is_some() {
801 // A grab stops a coast, as a hand on a spinning turntable would.
802 self.orbit_motion = ScrollMotion::new();
803 }
804 None
805 }
806
807 fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, _pos: LogicalPosition, needs_rebuild: &mut bool) {
808 let scale = self.scale.max(0.001) as f32;
809 let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
810 let (dx, dy) = match delta {
811 MouseScrollDelta::LineDelta(x, y) => (*x * ORBIT_PX_PER_LINE, *y * ORBIT_PX_PER_LINE),
812 MouseScrollDelta::PixelDelta(p) => (p.x as f32 / scale, p.y as f32 / scale),
813 };
814 if self.ctrl {
815 let log_zoom = if discrete { -dy / ORBIT_PX_PER_LINE * ZOOM_PER_LINE } else { -dy * ZOOM_PER_PX };
816 self.camera.zoom(log_zoom.exp());
817 self.scene_dirty = true;
818 *needs_rebuild = true;
819 return;
820 }
821 let phase = if discrete { ScrollPhase::Wheel } else { current_scroll_phase() };
822 let before = self.orbit_pos();
823 self.orbit_motion.apply_phase(phase, dx, dy, discrete, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
824 if self.orbit_since(before) || self.orbit_motion.is_animating() {
825 *needs_rebuild = true;
826 }
827 }
828
829 fn handle_pinch(&mut self, factor: f32, _pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
830 if factor > 0.0 && factor != 1.0 {
831 self.camera.zoom(1.0 / factor);
832 self.scene_dirty = true;
833 *needs_rebuild = true;
834 }
835 true
836 }
837
838 fn drop_mimes(&self) -> &'static [&'static str] {
839 &["text/uri-list"]
840 }
841
842 /// A file dragged in from cce-files (or anything that offers a uri
843 /// list): the first one the viewer reads opens, and its folder becomes
844 /// the ←/→ list.
845 fn handle_drop(&mut self, _mime: &str, data: &[u8], _pos: LogicalPosition, needs_rebuild: &mut bool) {
846 let paths = files::dropped_paths(data);
847 match paths.iter().find(|p| files::readable(p)) {
848 Some(p) => self.open(p.clone(), true),
849 None => {
850 let what = paths.first().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
851 self.error = Some(match what {
852 Some(name) => format!("{name}: not a model this viewer reads"),
853 None => "nothing to open in that drop".into(),
854 });
855 }
856 }
857 *needs_rebuild = true;
858 }
859
860 fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
861 // Wheel and button events carry no modifiers, so track them from
862 // the key stream (ctrl+wheel zoom, shift+drag pan).
863 match &event.logical_key {
864 Key::Named(NamedKey::Control) => self.ctrl = event.state == ElementState::Pressed,
865 Key::Named(NamedKey::Shift) => self.shift = event.state == ElementState::Pressed,
866 _ => {
867 self.ctrl = event.ctrl;
868 self.shift = event.shift;
869 }
870 }
871 if event.state != ElementState::Pressed {
872 return None;
873 }
874 match &event.logical_key {
875 Key::Character(c) if c == "o" => self.open_dialog(),
876 Key::Character(c) if c == "0" => self.frame_all(),
877 Key::Character(c) if c == "g" => self.show_grid = !self.show_grid,
878 Key::Character(c) if c == "w" => {
879 self.show_wire = !self.show_wire;
880 self.want_overlays();
881 }
882 Key::Character(c) if c == "n" => {
883 self.show_normals = !self.show_normals;
884 self.want_overlays();
885 }
886 Key::Character(c) if c == "r" => self.toggle_trace(),
887 Key::Character(c) if c == "q" => return Some(Message::Quit),
888 Key::Named(NamedKey::ArrowLeft) => self.step_file(-1),
889 Key::Named(NamedKey::ArrowRight) => self.step_file(1),
890 _ => return None,
891 }
892 self.scene_dirty = true;
893 *needs_rebuild = true;
894 None
895 }
896
897 // style-audit: opt-out the 3D scene is the window's content, full-bleed under the HUD; a root plate would frost over it.
898 fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
899 self.win = (size.width, size.height);
900 self.scale = scale;
901 let mut pc = PaintCtx::new();
902 let inset = cce_ui::layout::root_plate_inset();
903 let text_in = cce_ui::layout::CONTROL_TEXT_INSET;
904
905 let centre = |pc: &mut PaintCtx, msg: &str| {
906 let w = msg.chars().count() as f32 * 7.4;
907 let x = ((size.width - w) / 2.0).max(inset);
908 pc.text(msg.to_string(), x, size.height / 2.0 - 8.0, 14.0, [190, 190, 196]);
909 };
910 if let Some(e) = &self.error {
911 centre(&mut pc, e);
912 } else if self.model.is_none() && self.loading.is_none() {
913 centre(&mut pc, "Press o to open a model (STL, OBJ, glTF or PLY), or drop one here");
914 }
915
916 let lines = self.hud_lines();
917 if !lines.is_empty() {
918 const LINE: f32 = 18.0;
919 let longest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
920 let w = 2.0 * text_in + longest as f32 * 6.6;
921 let h = 8.0 + LINE * lines.len() as f32;
922 pc.quad(Rect { x: inset, y: inset, width: w, height: h }, [0.0, 0.0, 0.0, 0.45]);
923 for (i, line) in lines.into_iter().enumerate() {
924 let colour = if i == 0 { [235, 235, 238] } else { [190, 190, 198] };
925 pc.text(line, inset + text_in, inset + 5.0 + LINE * i as f32, 12.0, colour);
926 }
927 }
928 if self.model.is_some() {
929 pc.text(HINTS, inset, size.height - inset - 14.0, 11.0, [120, 122, 132]);
930 }
931 Some(pc.finish())
932 }
933
934 fn clear_color(&self) -> [f32; 4] {
935 [SKY_BOTTOM[0], SKY_BOTTOM[1], SKY_BOTTOM[2], 1.0]
936 }
937 }
938
939 fn main() {
940 env_logger::init();
941 cce_ui::engine::run::<ModelApp>();
942 }
943
944 #[cfg(test)]
945 mod tests {
946 use super::*;
947
948 #[test]
949 fn thousands_are_grouped() {
950 assert_eq!(group_thousands(7), "7");
951 assert_eq!(group_thousands(1000), "1,000");
952 assert_eq!(group_thousands(1234567), "1,234,567");
953 }
954 }