git.lucas.co / cce-model
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 }