git.lucas.co / cce-model
3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git

commit8d0ef64884fa14c57f16fae506f6882f03a5e35d
parentdc6f83cd9a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 11:26
Grid floor, wireframe and normals, sizes, ←/→ and drops (milestone 3)

- Grid floor under the model, laid out in the file's own units on 1-2-5
  steps through its origin (every fifth line stronger); g toggles it.
- w wireframe and n normals, built on a worker the first time either is
  asked for. No wireframe past 2M triangles: 7.5M edges painted a 5M model
  solid white and cost 360 MB. Normals are thinned to 50,000 and drawn as
  long as the gap between them, so a dense model's do not fur over it.
- HUD on two lines: the file and its place in the folder; triangles,
  points, parts, its size and the grid step in mm or m when the format
  says what its unit is (cce-mesh-io a95346c), bare numbers when not.
- ←/→ step through the folder's models, wrapping; a file dropped on the
  window (text/uri-list, file: URIs only) opens and makes its folder the
  list. Key hints along the bottom.
- Clip planes keep 3 radii for the grid's corners; framing margin 1.3.

Checked in a scale-2 shadow: overlays on OBJ, glTF and the 5M STL (1.2 s
for its normals on the worker, HUD says why there is no wireframe, 912 MB
peak), orbit with overlays on, ←/→ wrapping, glTF in metres. cce-files
opens table.glb in this viewer through its desktop entry (the M3 gate).
Not tested end to end: a drop, since no cce app can drag a file out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      |  30 +++++-
 Cargo.toml     |   2 +-
 src/camera.rs  |  14 ++-
 src/files.rs   |  88 +++++++++++++++++
 src/main.rs    | 304 ++++++++++++++++++++++++++++++++++++++++++++++++---------
 src/overlay.rs | 228 +++++++++++++++++++++++++++++++++++++++++++
 src/units.rs   |  86 ++++++++++++++++
 7 files changed, 695 insertions(+), 57 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ea0ab9e..cae80e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,8 +5,9 @@ A viewer for 3D model files. Read the workspace guide
 particular to this crate. The plan it is built to — formats, milestones,
 what each one must pass — is the design doc "cce-model: a general-purpose
 3D viewer" (claude.ai/code/artifact/1ebec744-8990-4884-ad92-73c72e0ea265).
-Milestones 1 and 2 are here: STL, OBJ, glTF/GLB and PLY, read by the
-shared `cce-mesh-io` crate, on the existing raster stage.
+Milestones 1–3 are here: STL, OBJ, glTF/GLB and PLY read by the shared
+`cce-mesh-io` crate, a grid floor, wireframe and normals overlays, sizes in
+the file's units, ←/→ through the folder and drag-and-drop.
 
 ## Shape
 
@@ -25,6 +26,16 @@ shared `cce-mesh-io` crate, on the existing raster stage.
 - `src/light.rs` — the light bake: crease-aware corner normals (from
   cce-mesh-io) against a fixed key/fill/ambient rig, into `Vertex3D`s.
 - `src/camera.rs` — orbit camera: yaw, pitch, distance about a pivot.
+- `src/overlay.rs` — the line meshes: grid floor (laid out in FILE units on
+  1-2-5 steps through the file's origin, then fitted), edges, normals.
+  Edges and normals are built on a worker the first time `w` or `n` asks
+  (`Message::Overlays`, tied to the load's generation). No wireframe past
+  `MAX_WIRE_TRIANGLES` (2M): it paints the model solid and costs 360 MB;
+  normals are thinned to `MAX_NORMALS` and drawn as long as their spacing.
+- `src/units.rs` — sizes in mm/m only when the format says what its unit
+  is (`cce_mesh_io::Unit`); OBJ and PLY sizes are bare numbers.
+- `src/files.rs` — the folder's models for ←/→ (any file opened from
+  outside the list makes its folder the list) and `text/uri-list` drops.
 
 ## Things that are not obvious
 
@@ -44,6 +55,11 @@ shared `cce-mesh-io` crate, on the existing raster stage.
 - **A staged scene persists** in the backdrop until the next one, so
   `stage_3d` stages only when `scene_dirty` (camera, resize, upload); a HUD
   change repaints the 2D pass alone.
+- **Every GPU slot is created once and updated in place** (`upload`), so
+  stepping through a folder does not leak meshes; `init_3d` clears the
+  slots and marks every CPU-side list pending for a replacement renderer.
+- **The clip planes keep 3 radii** around the pivot, for the grid's
+  corners, not just the model's 1.
 - No root plate, on purpose (`style-audit: opt-out` in `display_list`): the
   scene is the window's content, and a root plate would frost over it.
 
@@ -64,7 +80,15 @@ then `ctl windows` for the id and `shot-window <id>`. The window is
 640×360. `pointer-press` / `pointer-move-by` / `pointer-release` drive an
 orbit, `pointer-pinch 1.6` a zoom, `pointer-scroll 0 -12 finger` then
 `pointer-scroll finger-stop` a scroll orbit and its coast, `keypress 11` the
-`0` key. The window's display is the shadow's
+`0` key, 17/49/34 `w`/`n`/`g`, 105/106 ←/→. A new viewer window can map
+WITHOUT focus (keys then reach nothing, or a stale window): `ctl
+focus-window cce-model` after each launch, and close windows by pid
+(checking `/proc/<pid>/environ` for the shadow's display) — one launched by
+cce-files has argv0 `cce-model`, so a `release/cce-model` match misses it.
+cce-files in a shadow finds the real desktop entries with
+`XDG_DATA_HOME=$HOME/.local/share` and a tree build via a PATH symlink.
+Drops are untested end to end: cce-ui has no drag SOURCE, so no cce app
+can drag a file out. The window's display is the shadow's
 `WAYLAND_DISPLAY` from `cce-shadow env`, which changes when the instance
 restarts — match it when picking processes to stop. The log carries
 `read and lit in N ms` (worker) and `uploaded N vertices in N ms` (the UI
diff --git a/Cargo.toml b/Cargo.toml
index e94ffad..b80fc6f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
 
 [dependencies]
 cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "8d48c406c14a2ea12441c30e4e54bc68021b7930" }
-cce-mesh-io = { git = "https://github.com/lsgalante/cce-mesh-io.git", rev = "40032c7deb28dee609d963c8c694ce15cd84922c" }
+cce-mesh-io = { git = "https://github.com/lsgalante/cce-mesh-io.git", rev = "a95346cc26651f3efbd88b03dc238c6f03feec7d" }
 glam = "0.29"
 log = "0.4"
 env_logger = "0.11"
diff --git a/src/camera.rs b/src/camera.rs
index 7219811..2f907bc 100644
--- a/src/camera.rs
+++ b/src/camera.rs
@@ -16,8 +16,10 @@ const MAX_PITCH: f32 = 89.0 * std::f32::consts::PI / 180.0;
 /// The three-quarter view a model opens in.
 const HOME_YAW: f32 = 35.0 * std::f32::consts::PI / 180.0;
 const HOME_PITCH: f32 = 22.0 * std::f32::consts::PI / 180.0;
+/// How far from the pivot, in model radii, the clip planes keep the scene.
+const CLIP_RADII: f32 = 3.0;
 /// Room left around a framed model, as a share of its size.
-const FRAME_MARGIN: f32 = 1.15;
+const FRAME_MARGIN: f32 = 1.3;
 
 #[derive(Debug, Clone)]
 pub struct Camera {
@@ -48,10 +50,12 @@ impl Camera {
 
     /// Projection times view, for a viewport `aspect` wide per unit high.
     pub fn view_proj(&self, aspect: f32) -> Mat4 {
-        // The clip planes hug the model: near as far out as it allows (depth
-        // precision lives there), far just past its back.
-        let near = (self.distance - 1.5 * self.radius).max(self.distance * 0.01);
-        let far = self.distance + 1.5 * self.radius;
+        // The clip planes hug the scene: near as far out as it allows (depth
+        // precision lives there), far just past its back. The scene is the
+        // model AND the grid floor under it, whose corners reach about 2.5
+        // radii from the centre; hence 3.
+        let near = (self.distance - CLIP_RADII * self.radius).max(self.distance * 0.01);
+        let far = self.distance + CLIP_RADII * self.radius;
         let proj = Mat4::perspective_rh(FOV_Y_DEGREES.to_radians(), aspect.max(1e-3), near, far.max(near * 2.0));
         proj * Mat4::look_at_rh(self.eye(), self.pivot, Vec3::Y)
     }
diff --git a/src/files.rs b/src/files.rs
new file mode 100644
index 0000000..b99ad34
--- /dev/null
+++ b/src/files.rs
@@ -0,0 +1,88 @@
+//! Which files the viewer can step through, and what a drop hands it.
+
+use std::path::{Path, PathBuf};
+
+/// True when the extension is one cce-mesh-io reads.
+pub fn readable(path: &Path) -> bool {
+    path.extension()
+        .and_then(|e| e.to_str())
+        .is_some_and(|e| cce_mesh_io::EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
+}
+
+/// The models in `path`'s folder, by name (case aside), and where `path` is
+/// among them — what ←/→ step through. A folder that cannot be read gives
+/// the file alone.
+pub fn siblings(path: &Path) -> (Vec<PathBuf>, usize) {
+    let mut files: Vec<PathBuf> = path
+        .parent()
+        .and_then(|dir| std::fs::read_dir(if dir.as_os_str().is_empty() { Path::new(".") } else { dir }).ok())
+        .into_iter()
+        .flatten()
+        .filter_map(|e| e.ok().map(|e| e.path()))
+        .filter(|p| p.is_file() && readable(p))
+        .collect();
+    files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase()));
+    let at = files.iter().position(|p| p.file_name() == path.file_name());
+    match at {
+        Some(i) => (files, i),
+        None => (vec![path.to_path_buf()], 0),
+    }
+}
+
+/// The local paths in a `text/uri-list` drop (RFC 2483: one URI a line,
+/// `#` lines are comments), percent-decoded. Anything not `file:` is left
+/// out: the viewer opens files, not URLs.
+pub fn dropped_paths(data: &[u8]) -> Vec<PathBuf> {
+    String::from_utf8_lossy(data)
+        .lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty() && !l.starts_with('#'))
+        .filter_map(|l| l.strip_prefix("file://"))
+        // file://host/path: only the local host (empty, or localhost).
+        .filter_map(|rest| rest.strip_prefix("localhost").or(Some(rest)).filter(|r| r.starts_with('/')))
+        .map(|p| PathBuf::from(percent_decode(p)))
+        .collect()
+}
+
+fn percent_decode(s: &str) -> String {
+    let b = s.as_bytes();
+    let mut out = Vec::with_capacity(b.len());
+    let mut i = 0;
+    while i < b.len() {
+        if b[i] == b'%' && i + 2 < b.len() {
+            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
+                out.push(v);
+                i += 3;
+                continue;
+            }
+        }
+        out.push(b[i]);
+        i += 1;
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_uri_list_gives_its_local_files() {
+        let list = b"# from cce-files\r\nfile:///home/me/My%20Parts/bracket.stl\r\nhttps://example.com/x.stl\r\nfile://localhost/tmp/a.glb\r\nfile://elsewhere/tmp/b.glb\r\n";
+        assert_eq!(dropped_paths(list), vec![PathBuf::from("/home/me/My Parts/bracket.stl"), PathBuf::from("/tmp/a.glb")]);
+    }
+
+    #[test]
+    fn siblings_are_the_models_in_the_folder_in_name_order() {
+        let dir = std::env::temp_dir().join(format!("cce-model-files-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        for f in ["b.STL", "a.obj", "notes.txt", "c.glb"] {
+            std::fs::write(dir.join(f), b"").unwrap();
+        }
+        let (files, at) = siblings(&dir.join("b.STL"));
+        std::fs::remove_dir_all(&dir).ok();
+        let names: Vec<_> = files.iter().map(|p| p.file_name().unwrap().to_str().unwrap()).collect();
+        assert_eq!(names, ["a.obj", "b.STL", "c.glb"]);
+        assert_eq!(at, 1);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 1d3ec57..b60053e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,21 +1,29 @@
 //! cce-model — a viewer for 3D model files.
 //!
 //! Opens STL, OBJ, glTF/GLB and PLY (read by cce-mesh-io), frames the model
-//! in a three-quarter view and lets you turn it. Files are read and lit on a
-//! worker thread (`cce_mesh_io::load` + `light::bake`), then uploaded once
-//! and drawn through cce-ui's scene pass as one prelit mesh under a
-//! screen-space background gradient. The whole window is the scene.
+//! in a three-quarter view over a grid floor and lets you turn it. Files are
+//! read and lit on a worker thread (`cce_mesh_io::load` + `light::bake`),
+//! then uploaded once and drawn through cce-ui's scene pass as one prelit
+//! mesh under a screen-space background gradient. The whole window is the
+//! scene. The wireframe and normals overlays are built on a worker the
+//! first time either is asked for, so a big model does not pay for them
+//! unless they are wanted.
 //!
 //! Mouse: drag orbits, shift+drag or middle-drag pans, ctrl+wheel and pinch
-//! zoom, a two-finger scroll orbits (and coasts), as in cce-designer.
-//! Keys: o open · 0 frame all · q quit.
+//! zoom, a two-finger scroll orbits (and coasts), as in cce-designer. A file
+//! dropped on the window opens. Keys: o open · ←/→ the folder's other models
+//! · 0 frame all · g grid · w wireframe · n normals · q quit.
 
 mod camera;
+mod files;
 mod light;
+mod overlay;
+mod units;
 
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
+use cce_mesh_io::{Mesh, Unit, UpAxis};
 use cce_ui::engine::{
     AppSender, Application, LogicalPosition, LogicalSize, MeshId, SceneDraw, Stage3D, Vertex3D, WindowSettings,
 };
@@ -26,7 +34,6 @@ use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta,
 use glam::Vec3;
 
 use camera::Camera;
-use cce_mesh_io::UpAxis;
 
 /// Wheel notches in logical px of orbit, and of log-zoom.
 const ORBIT_PX_PER_LINE: f32 = 10.0;
@@ -37,24 +44,36 @@ const ZOOM_PER_PX: f32 = 0.005;
 const SKY_TOP: [f32; 3] = [0.105, 0.11, 0.125];
 const SKY_BOTTOM: [f32; 3] = [0.03, 0.03, 0.035];
 
+/// How strongly the wireframe shows over the model.
+const WIRE_OPACITY: f32 = 0.55;
+
+const HINTS: &str = "o open   ·   ←/→ folder   ·   0 frame   ·   g grid   ·   w wireframe   ·   n normals";
+
 #[derive(Debug, Clone)]
 enum Message {
     /// The model behind an `Arc`: the runner may clone a message, and a
     /// clone must not copy the vertex buffer.
     Loaded { generation: u64, path: PathBuf, result: Result<Arc<Model>, String> },
+    /// The wireframe and the normals of the model of `generation`.
+    Overlays { generation: u64, edges: Arc<Vec<Vertex3D>>, normals: Arc<Vec<Vertex3D>> },
     Quit,
 }
 
-/// A model read, welded and lit, ready to upload.
+/// A model read, welded, fitted and lit, ready to upload.
 #[derive(Debug)]
 struct Model {
     name: String,
-    triangles: usize,
-    /// How many named pieces the file holds (glTF nodes; 1 for the rest).
+    /// In the fitted space it is drawn in: the overlays are built from it.
+    mesh: Mesh,
     parts: usize,
+    /// Its box's sides in file units, upright, and what those units are.
+    size: Vec3,
+    unit: Unit,
     /// Kept after upload: a replacement renderer (a reconnect) starts with
-    /// no meshes, and this is what goes back up.
+    /// no meshes, and these are what go back up.
     verts: Vec<Vertex3D>,
+    grid: Vec<Vertex3D>,
+    grid_step: f32,
 }
 
 impl Model {
@@ -66,24 +85,58 @@ impl Model {
         if scene.up == UpAxis::Z {
             mesh.z_up_to_y_up();
         }
+        let (lo, hi) = mesh.bounds().ok_or("the file holds no points")?;
         // Every model is drawn inside the unit sphere, so the camera frames
         // that sphere whatever the file's units.
-        mesh.fit_to_unit();
+        let (centre, radius) = mesh.fit_to_unit();
+        let grid = overlay::grid(&overlay::Fit { centre, radius, lo, hi });
         Ok(Model {
             name: path.file_name().and_then(|n| n.to_str()).unwrap_or("?").to_string(),
-            triangles: mesh.triangles.len(),
             parts: scene.parts.len(),
+            size: hi - lo,
+            unit: scene.unit,
             verts: light::bake(&mesh),
+            mesh,
+            grid: grid.lines,
+            grid_step: grid.step,
         })
     }
 }
 
-/// What the current renderer holds for us.
+/// The wireframe and normals of the current model, once built.
+struct Overlays {
+    edges: Arc<Vec<Vertex3D>>,
+    normals: Arc<Vec<Vertex3D>>,
+}
+
+/// What the current renderer holds for us. A slot is created at its first
+/// upload (a mesh of no vertices would be a zero-sized buffer) and updated
+/// in place after, so stepping through a folder does not leak meshes.
 struct Gpu {
     background: MeshId,
-    /// Created at the first upload: a mesh of no vertices would be a
-    /// zero-sized buffer.
     model: Option<MeshId>,
+    grid: Option<MeshId>,
+    edges: Option<MeshId>,
+    normals: Option<MeshId>,
+}
+
+/// Which of the CPU-side vertex lists still have to go up.
+#[derive(Default, Clone, Copy)]
+struct Pending {
+    model: bool,
+    grid: bool,
+    edges: bool,
+    normals: bool,
+}
+
+fn upload(stage: &mut dyn Stage3D, slot: &mut Option<MeshId>, verts: &[Vertex3D]) {
+    if verts.is_empty() {
+        return;
+    }
+    match *slot {
+        Some(id) => stage.update_mesh(id, verts),
+        None => *slot = Some(stage.create_mesh(verts)),
+    }
 }
 
 enum Drag {
@@ -93,16 +146,23 @@ enum Drag {
 
 struct ModelApp {
     sender: AppSender<Message>,
-    /// Bumped by every open, so a slow load the user has moved past is
-    /// dropped when it arrives.
+    /// Bumped by every open, so a slow load (or overlay build) the user has
+    /// moved past is dropped when it arrives.
     generation: u64,
     loading: Option<PathBuf>,
     model: Option<Arc<Model>>,
     error: Option<String>,
+    /// The folder's models, for ←/→, and where the open one is among them.
+    files: Vec<PathBuf>,
+    file_at: usize,
+    overlays: Option<Overlays>,
+    building_overlays: bool,
+    show_grid: bool,
+    show_wire: bool,
+    show_normals: bool,
     camera: Camera,
     gpu: Option<Gpu>,
-    /// The model's vertices are not on the GPU yet.
-    upload_pending: bool,
+    pending: Pending,
     /// The view changed since the scene was last staged. A staged scene
     /// stays in the backdrop until the next one, so a frame that only
     /// changes the HUD does not redraw the model.
@@ -123,7 +183,12 @@ impl ModelApp {
         self.win.0 / self.win.1.max(1.0)
     }
 
-    fn open(&mut self, path: PathBuf) {
+    /// Read `path` on a worker. A file opened from outside the folder list
+    /// (the dialog, a drop, the command line) makes its folder the list.
+    fn open(&mut self, path: PathBuf, new_folder: bool) {
+        if new_folder {
+            (self.files, self.file_at) = files::siblings(&path);
+        }
         self.generation += 1;
         let generation = self.generation;
         self.loading = Some(path.clone());
@@ -151,8 +216,41 @@ impl ModelApp {
             ("PLY", &["ply"]),
         ];
         if let Some(path) = cce_ui::file_dialog::pick_file("Open model", filters) {
-            self.open(path);
+            self.open(path, true);
+        }
+    }
+
+    /// The next (`step` 1) or previous (−1) model in the folder, wrapping.
+    fn step_file(&mut self, step: i64) {
+        if self.files.len() < 2 {
+            return;
         }
+        let n = self.files.len() as i64;
+        self.file_at = (self.file_at as i64 + step).rem_euclid(n) as usize;
+        self.open(self.files[self.file_at].clone(), false);
+    }
+
+    /// Build the wireframe and normals on a worker, if they are wanted and
+    /// not built or being built.
+    fn want_overlays(&mut self) {
+        if !(self.show_wire || self.show_normals) || self.overlays.is_some() || self.building_overlays {
+            return;
+        }
+        let Some(model) = self.model.clone() else { return };
+        self.building_overlays = true;
+        let (generation, sender) = (self.generation, self.sender.clone());
+        std::thread::spawn(move || {
+            let started = std::time::Instant::now();
+            let edges = Arc::new(overlay::edges(&model.mesh));
+            let normals = Arc::new(overlay::normals(&model.mesh, light::CREASE_DEGREES));
+            log::info!(
+                "[model] {} edges and {} normals in {:.0} ms",
+                edges.len() / 2,
+                normals.len() / 2,
+                started.elapsed().as_secs_f64() * 1e3
+            );
+            let _ = sender.send(Message::Overlays { generation, edges, normals });
+        });
     }
 
     fn frame_all(&mut self) {
@@ -178,14 +276,31 @@ impl ModelApp {
         (self.orbit_motion.x.pos(), self.orbit_motion.y.pos())
     }
 
-    fn hud_line(&self) -> Option<String> {
+    /// The HUD: the file (and its place in the folder), then what it holds.
+    fn hud_lines(&self) -> Vec<String> {
+        let place = if self.files.len() > 1 { format!("   ·   {} of {}", self.file_at + 1, self.files.len()) } else { String::new() };
         if let Some(path) = &self.loading {
             let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
-            return Some(format!("Loading {name}…"));
+            return vec![format!("Loading {name}…{place}")];
+        }
+        let Some(m) = &self.model else { return Vec::new() };
+        let mut facts = vec![
+            format!("{} triangles", group_thousands(m.mesh.triangles.len())),
+            format!("{} points", group_thousands(m.mesh.positions.len())),
+        ];
+        if m.parts > 1 {
+            facts.push(format!("{} parts", m.parts));
+        }
+        facts.push(units::dimensions(m.size, m.unit));
+        if self.show_grid {
+            facts.push(format!("grid {}", units::length(m.grid_step, m.unit)));
         }
-        let m = self.model.as_ref()?;
-        let parts = if m.parts > 1 { format!("   ·   {} parts", m.parts) } else { String::new() };
-        Some(format!("{}   ·   {} triangles{parts}", m.name, group_thousands(m.triangles)))
+        if self.building_overlays {
+            facts.push("building overlays…".into());
+        } else if self.show_wire && m.mesh.triangles.len() > overlay::MAX_WIRE_TRIANGLES {
+            facts.push(format!("no wireframe over {} triangles", group_thousands(overlay::MAX_WIRE_TRIANGLES)));
+        }
+        vec![format!("{}{place}", m.name), facts.join("   ·   ")]
     }
 }
 
@@ -229,9 +344,16 @@ impl Application for ModelApp {
             loading: None,
             model: None,
             error: None,
+            files: Vec::new(),
+            file_at: 0,
+            overlays: None,
+            building_overlays: false,
+            show_grid: true,
+            show_wire: false,
+            show_normals: false,
             camera: Camera::default(),
             gpu: None,
-            upload_pending: false,
+            pending: Pending::default(),
             scene_dirty: true,
             orbit_motion: ScrollMotion::new(),
             pointer: (0.0, 0.0),
@@ -242,7 +364,7 @@ impl Application for ModelApp {
             scale: 1.0,
         };
         if let Some(path) = std::env::args_os().nth(1) {
-            app.open(PathBuf::from(path));
+            app.open(PathBuf::from(path), true);
         }
         app
     }
@@ -265,10 +387,13 @@ impl Application for ModelApp {
                 self.loading = None;
                 match result {
                     Ok(model) => {
-                        log::info!("[model] {}: {} triangles", path.display(), model.triangles);
+                        log::info!("[model] {}: {} triangles", path.display(), model.mesh.triangles.len());
                         self.model = Some(model);
-                        self.upload_pending = true;
+                        self.overlays = None;
+                        self.building_overlays = false;
+                        self.pending = Pending { model: true, grid: true, edges: false, normals: false };
                         self.frame_all();
+                        self.want_overlays();
                     }
                     Err(e) => {
                         log::warn!("[model] {}: {e}", path.display());
@@ -278,6 +403,17 @@ impl Application for ModelApp {
                 }
                 *needs_rebuild = true;
             }
+            Message::Overlays { generation, edges, normals } => {
+                if generation != self.generation {
+                    return;
+                }
+                self.building_overlays = false;
+                self.overlays = Some(Overlays { edges, normals });
+                self.pending.edges = true;
+                self.pending.normals = true;
+                self.scene_dirty = true;
+                *needs_rebuild = true;
+            }
         }
     }
 
@@ -300,7 +436,7 @@ impl Application for ModelApp {
     }
 
     /// Once per renderer, the first and any replacement after a reconnect:
-    /// a new renderer holds no meshes, so the model goes up again.
+    /// a new renderer holds no meshes, so everything goes up again.
     fn init_3d(&mut self, stage: &mut dyn Stage3D) {
         // Drawn as a `screen_space` draw: these corners are NDC, at the far
         // plane, untouched by the mvp; their z is ignored.
@@ -314,25 +450,37 @@ impl Application for ModelApp {
             bg(-1.0, 1.0, SKY_TOP),
         ]);
         stage.set_scene_light(light::KEY.normalize().to_array());
-        self.gpu = Some(Gpu { background, model: None });
-        self.upload_pending = self.model.is_some();
+        self.gpu = Some(Gpu { background, model: None, grid: None, edges: None, normals: None });
+        let has = self.model.is_some();
+        let built = self.overlays.is_some();
+        self.pending = Pending { model: has, grid: has, edges: built, normals: built };
         self.scene_dirty = true;
     }
 
     fn stage_3d(&mut self, stage: &mut dyn Stage3D, size: LogicalSize, scale: f64) -> bool {
         let Some(gpu) = self.gpu.as_mut() else { return false };
-        if self.upload_pending {
-            if let Some(m) = &self.model {
+        let pending = std::mem::take(&mut self.pending);
+        if let Some(m) = &self.model {
+            if pending.model {
                 // On the UI thread, unavoidably: the stage is only lent here.
                 // Logged, because it is the one part of a load the window waits on.
                 let started = std::time::Instant::now();
-                match gpu.model {
-                    Some(id) => stage.update_mesh(id, &m.verts),
-                    None => gpu.model = Some(stage.create_mesh(&m.verts)),
-                }
+                upload(stage, &mut gpu.model, &m.verts);
                 log::info!("[model] uploaded {} vertices in {:.0} ms", m.verts.len(), started.elapsed().as_secs_f64() * 1e3);
             }
-            self.upload_pending = false;
+            if pending.grid {
+                upload(stage, &mut gpu.grid, &m.grid);
+            }
+        }
+        if let Some(o) = &self.overlays {
+            if pending.edges {
+                upload(stage, &mut gpu.edges, &o.edges);
+            }
+            if pending.normals {
+                upload(stage, &mut gpu.normals, &o.normals);
+            }
+        }
+        if pending.model || pending.grid || pending.edges || pending.normals {
             self.scene_dirty = true;
         }
         if !std::mem::replace(&mut self.scene_dirty, false) {
@@ -340,9 +488,25 @@ impl Application for ModelApp {
         }
         let (pw, ph) = ((size.width as f64 * scale) as u32, (size.height as f64 * scale) as u32);
         let mvp = self.camera.view_proj(pw as f32 / ph.max(1) as f32).to_cols_array_2d();
+        // One logical px, whatever the output scale (clamped by the device).
+        let line_width = scale as f32;
+        let lines = |mesh| SceneDraw { wireframe: true, line_width, ..draw(mesh, mvp) };
         let mut draws = vec![SceneDraw { screen_space: true, ..draw(gpu.background, mvp) }];
-        if let (Some(id), Some(_)) = (gpu.model, &self.model) {
-            draws.push(SceneDraw { prelit: true, ..draw(id, mvp) });
+        if self.model.is_some() {
+            let wire = self.show_wire && self.overlays.is_some();
+            if let (true, Some(id)) = (self.show_grid, gpu.grid) {
+                draws.push(lines(id));
+            }
+            if let Some(id) = gpu.model {
+                let base = if wire { line_width } else { 0.0 };
+                draws.push(SceneDraw { prelit: true, wire_base_width: base, ..draw(id, mvp) });
+            }
+            if let (true, Some(id)) = (wire, gpu.edges) {
+                draws.push(SceneDraw { opacity: WIRE_OPACITY, ..lines(id) });
+            }
+            if let (true, true, Some(id)) = (self.show_normals, self.overlays.is_some(), gpu.normals) {
+                draws.push(lines(id));
+            }
         }
         stage.stage_scene((0, 0, pw, ph), draws);
         false
@@ -423,6 +587,28 @@ impl Application for ModelApp {
         true
     }
 
+    fn drop_mimes(&self) -> &'static [&'static str] {
+        &["text/uri-list"]
+    }
+
+    /// A file dragged in from cce-files (or anything that offers a uri
+    /// list): the first one the viewer reads opens, and its folder becomes
+    /// the ←/→ list.
+    fn handle_drop(&mut self, _mime: &str, data: &[u8], _pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let paths = files::dropped_paths(data);
+        match paths.iter().find(|p| files::readable(p)) {
+            Some(p) => self.open(p.clone(), true),
+            None => {
+                let what = paths.first().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
+                self.error = Some(match what {
+                    Some(name) => format!("{name}: not a model this viewer reads"),
+                    None => "nothing to open in that drop".into(),
+                });
+            }
+        }
+        *needs_rebuild = true;
+    }
+
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
         // Wheel and button events carry no modifiers, so track them from
         // the key stream (ctrl+wheel zoom, shift+drag pan).
@@ -440,9 +626,21 @@ impl Application for ModelApp {
         match &event.logical_key {
             Key::Character(c) if c == "o" => self.open_dialog(),
             Key::Character(c) if c == "0" => self.frame_all(),
+            Key::Character(c) if c == "g" => self.show_grid = !self.show_grid,
+            Key::Character(c) if c == "w" => {
+                self.show_wire = !self.show_wire;
+                self.want_overlays();
+            }
+            Key::Character(c) if c == "n" => {
+                self.show_normals = !self.show_normals;
+                self.want_overlays();
+            }
             Key::Character(c) if c == "q" => return Some(Message::Quit),
+            Key::Named(NamedKey::ArrowLeft) => self.step_file(-1),
+            Key::Named(NamedKey::ArrowRight) => self.step_file(1),
             _ => return None,
         }
+        self.scene_dirty = true;
         *needs_rebuild = true;
         None
     }
@@ -463,13 +661,23 @@ impl Application for ModelApp {
         if let Some(e) = &self.error {
             centre(&mut pc, e);
         } else if self.model.is_none() && self.loading.is_none() {
-            centre(&mut pc, "Press o to open a model (STL, OBJ, glTF or PLY)");
+            centre(&mut pc, "Press o to open a model (STL, OBJ, glTF or PLY), or drop one here");
         }
 
-        if let Some(hud) = self.hud_line() {
-            let w = 2.0 * text_in + hud.chars().count() as f32 * 6.6;
-            pc.quad(Rect { x: inset, y: inset, width: w, height: 24.0 }, [0.0, 0.0, 0.0, 0.45]);
-            pc.text(hud, inset + text_in, inset + 5.0, 12.0, [230, 230, 230]);
+        let lines = self.hud_lines();
+        if !lines.is_empty() {
+            const LINE: f32 = 18.0;
+            let longest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
+            let w = 2.0 * text_in + longest as f32 * 6.6;
+            let h = 8.0 + LINE * lines.len() as f32;
+            pc.quad(Rect { x: inset, y: inset, width: w, height: h }, [0.0, 0.0, 0.0, 0.45]);
+            for (i, line) in lines.into_iter().enumerate() {
+                let colour = if i == 0 { [235, 235, 238] } else { [190, 190, 198] };
+                pc.text(line, inset + text_in, inset + 5.0 + LINE * i as f32, 12.0, colour);
+            }
+        }
+        if self.model.is_some() {
+            pc.text(HINTS, inset, size.height - inset - 14.0, 11.0, [120, 122, 132]);
         }
         Some(pc.finish())
     }
diff --git a/src/overlay.rs b/src/overlay.rs
new file mode 100644
index 0000000..3b6921a
--- /dev/null
+++ b/src/overlay.rs
@@ -0,0 +1,228 @@
+//! What is drawn over and under the model as lines: the grid floor, the
+//! wireframe and the normals. Each is a list of vertex PAIRS for a wireframe
+//! scene draw, in the fitted space the model is drawn in (the unit sphere).
+//!
+//! The grid is laid out in the FILE's units, so its lines fall on round
+//! numbers of millimetres (or whatever the file is in) and one of them runs
+//! through the file's origin; only then is it carried into the fitted space.
+
+use std::collections::HashSet;
+
+use cce_mesh_io::Mesh;
+use cce_ui::engine::Vertex3D;
+use glam::Vec3;
+
+use crate::units;
+
+/// The colours of the lines, linear RGB, chosen against the background
+/// gradient: a minor grid line barely above it, every fifth a step brighter.
+const GRID_MINOR: [f32; 3] = [0.075, 0.078, 0.088];
+const GRID_MAJOR: [f32; 3] = [0.16, 0.165, 0.185];
+const EDGE: [f32; 3] = [0.82, 0.84, 0.9];
+const NORMAL_BASE: [f32; 3] = [0.15, 0.55, 0.85];
+const NORMAL_TIP: [f32; 3] = [0.55, 0.9, 1.0];
+
+/// About this many cells across the model's footprint.
+const GRID_CELLS: f32 = 12.0;
+/// The longest a normal is drawn, in the fitted space (the model is 2
+/// across). A dense model's are shorter: see [`normals`].
+const NORMAL_LENGTH: f32 = 0.04;
+const NORMAL_LENGTH_MIN: f32 = 0.004;
+/// Above this many triangles there is no wireframe: its edges outnumber
+/// the window's pixels, so it paints the model solid (a 5M-triangle model's
+/// was a white silhouette) and costs 360 MB doing it.
+pub const MAX_WIRE_TRIANGLES: usize = 2_000_000;
+/// More normals than this are thinned to every n-th: past it they are a
+/// solid fur that hides the model they describe.
+pub const MAX_NORMALS: usize = 50_000;
+
+/// Where the fitted space sits in the file's: a file point p is drawn at
+/// (p − centre) / radius.
+#[derive(Debug, Clone, Copy)]
+pub struct Fit {
+    pub centre: Vec3,
+    pub radius: f32,
+    /// The model's box, in file units (after the turn upright).
+    pub lo: Vec3,
+    pub hi: Vec3,
+}
+
+impl Fit {
+    fn place(&self, p: Vec3) -> [f32; 3] {
+        ((p - self.centre) / self.radius).to_array()
+    }
+}
+
+pub struct Grid {
+    pub lines: Vec<Vertex3D>,
+    /// One cell's side, in file units.
+    pub step: f32,
+}
+
+/// A floor under the model: square, a little wider than its footprint,
+/// at the height of its lowest point.
+pub fn grid(fit: &Fit) -> Grid {
+    let size = fit.hi - fit.lo;
+    let footprint = size.x.max(size.z).max(fit.radius * 0.5);
+    let step = units::nice_step(footprint / GRID_CELLS);
+    let half = 0.75 * footprint + step;
+    let (cx, cz) = ((fit.lo.x + fit.hi.x) / 2.0, (fit.lo.z + fit.hi.z) / 2.0);
+    // Whole multiples of the step, so the lines sit on round numbers and
+    // index 0 is the file's origin.
+    let (x0, x1) = (((cx - half) / step).floor() as i64, ((cx + half) / step).ceil() as i64);
+    let (z0, z1) = (((cz - half) / step).floor() as i64, ((cz + half) / step).ceil() as i64);
+    let y = fit.lo.y;
+    let colour = |k: i64| if k % 5 == 0 { GRID_MAJOR } else { GRID_MINOR };
+    let mut lines = Vec::new();
+    let mut line = |a: Vec3, b: Vec3, color: [f32; 3]| {
+        lines.push(Vertex3D { position: fit.place(a), color });
+        lines.push(Vertex3D { position: fit.place(b), color });
+    };
+    for k in x0..=x1 {
+        let x = k as f32 * step;
+        line(Vec3::new(x, y, z0 as f32 * step), Vec3::new(x, y, z1 as f32 * step), colour(k));
+    }
+    for k in z0..=z1 {
+        let z = k as f32 * step;
+        line(Vec3::new(x0 as f32 * step, y, z), Vec3::new(x1 as f32 * step, y, z), colour(k));
+    }
+    Grid { lines, step }
+}
+
+/// Every edge of every triangle, once; nothing past [`MAX_WIRE_TRIANGLES`].
+pub fn edges(mesh: &Mesh) -> Vec<Vertex3D> {
+    if mesh.triangles.len() > MAX_WIRE_TRIANGLES {
+        return Vec::new();
+    }
+    let mut keys: Vec<u64> = Vec::with_capacity(mesh.triangles.len() * 3);
+    for t in &mesh.triangles {
+        for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
+            keys.push(((a.min(b) as u64) << 32) | a.max(b) as u64);
+        }
+    }
+    keys.sort_unstable();
+    keys.dedup();
+    let mut out = Vec::with_capacity(keys.len() * 2);
+    for k in keys {
+        for i in [(k >> 32) as usize, (k & 0xffff_ffff) as usize] {
+            out.push(Vertex3D { position: mesh.positions[i].to_array(), color: EDGE });
+        }
+    }
+    out
+}
+
+/// A short line out of each point along each normal it is lit with — one
+/// per point on a smooth surface, one per face meeting at a crease — and
+/// every n-th of them past [`MAX_NORMALS`].
+///
+/// Each is about as long as the gap between the normals drawn (the square
+/// root of the surface's area over their number), up to [`NORMAL_LENGTH`]:
+/// at a fixed length, a dense model's normals overlap into a fur that hides
+/// the surface they describe.
+pub fn normals(mesh: &Mesh, crease_degrees: f32) -> Vec<Vertex3D> {
+    let corner = mesh.corner_normals(crease_degrees);
+    let mut seen: HashSet<(u32, [i16; 3])> = HashSet::new();
+    let mut picked: Vec<(Vec3, Vec3)> = Vec::new();
+    for (t, tri) in mesh.triangles.iter().enumerate() {
+        for (k, &v) in tri.iter().enumerate() {
+            let n = corner[t * 3 + k];
+            let q = (n * 1000.0).round();
+            if seen.insert((v, [q.x as i16, q.y as i16, q.z as i16])) {
+                picked.push((mesh.positions[v as usize], n));
+            }
+        }
+    }
+    let stride = picked.len().div_ceil(MAX_NORMALS).max(1);
+    let area: f32 = mesh
+        .triangles
+        .iter()
+        .map(|t| {
+            let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
+            (b - a).cross(c - a).length() / 2.0
+        })
+        .sum();
+    let shown = picked.len().div_ceil(stride).max(1) as f32;
+    let length = (area / shown).sqrt().clamp(NORMAL_LENGTH_MIN, NORMAL_LENGTH);
+    picked
+        .iter()
+        .step_by(stride)
+        .flat_map(|&(p, n)| {
+            [
+                Vertex3D { position: p.to_array(), color: NORMAL_BASE },
+                Vertex3D { position: (p + n * length).to_array(), color: NORMAL_TIP },
+            ]
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn cube() -> Mesh {
+        let p = |i: u32| Vec3::new((i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32);
+        let quads = [[1, 3, 7, 5], [4, 6, 2, 0], [2, 6, 7, 3], [4, 0, 1, 5], [4, 5, 7, 6], [1, 0, 2, 3]];
+        Mesh {
+            positions: (0..8).map(p).collect(),
+            triangles: quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect(),
+            tri_color: vec![0; 12],
+            colors: vec![cce_mesh_io::CLAY],
+            corner_colors: None,
+        }
+    }
+
+    #[test]
+    fn a_cube_has_eighteen_edges() {
+        // Twelve sides and one diagonal across each of its six faces.
+        assert_eq!(edges(&cube()).len(), 18 * 2);
+    }
+
+    #[test]
+    fn a_cube_has_three_normals_a_corner() {
+        // Every corner meets three faces at 90°, past the crease.
+        assert_eq!(normals(&cube(), 40.0).len(), 8 * 3 * 2);
+    }
+
+    #[test]
+    fn the_grid_is_on_round_numbers_under_the_model() {
+        // A 120 × 30 × 80 part standing on z... after the turn: y is up.
+        let fit = Fit { centre: Vec3::new(60.0, 15.0, 40.0), radius: 75.0, lo: Vec3::ZERO, hi: Vec3::new(120.0, 30.0, 80.0) };
+        let g = grid(&fit);
+        assert_eq!(g.step, 10.0, "120 mm across / 12 cells");
+        for pair in g.lines.chunks(2) {
+            let a = Vec3::from(pair[0].position) * fit.radius + fit.centre;
+            assert!((a.y - 0.0).abs() < 1e-3, "the floor is at the model's lowest point");
+            for c in [a.x, a.z] {
+                assert!((c / g.step - (c / g.step).round()).abs() < 1e-3, "a line end off the grid: {c}");
+            }
+        }
+        // The line through the file's origin is a major one.
+        let origin_line = g.lines.chunks(2).find(|p| {
+            let a = Vec3::from(p[0].position) * fit.radius + fit.centre;
+            let b = Vec3::from(p[1].position) * fit.radius + fit.centre;
+            a.x.abs() < 1e-3 && b.x.abs() < 1e-3
+        });
+        assert_eq!(origin_line.unwrap()[0].color, GRID_MAJOR);
+    }
+
+    #[test]
+    fn too_many_normals_are_thinned() {
+        let mut m = Mesh::default();
+        let n = 300; // 90,000 points: past the cap, so every other one
+        for i in 0..n {
+            for j in 0..n {
+                m.positions.push(Vec3::new(i as f32, 0.0, j as f32));
+            }
+        }
+        for i in 0..n - 1 {
+            for j in 0..n - 1 {
+                let a = (i * n + j) as u32;
+                m.triangles.push([a, a + 1, a + n as u32]);
+                m.triangles.push([a + 1, a + n as u32 + 1, a + n as u32]);
+            }
+        }
+        m.tri_color = vec![0; m.triangles.len()];
+        let lines = normals(&m, 40.0).len() / 2;
+        assert!(lines <= MAX_NORMALS && lines > MAX_NORMALS / 3, "{lines}");
+    }
+}
diff --git a/src/units.rs b/src/units.rs
new file mode 100644
index 0000000..68d189c
--- /dev/null
+++ b/src/units.rs
@@ -0,0 +1,86 @@
+//! Lengths as the HUD says them: in millimetres, or metres once they reach a
+//! metre, when the file's format says what its units are; as bare numbers
+//! when it does not (OBJ, PLY), since calling those millimetres would be a
+//! guess presented as a measurement.
+
+use cce_mesh_io::Unit;
+use glam::Vec3;
+
+/// The smallest of 1, 2 or 5 × 10ⁿ that is at least `raw`: a step a ruler
+/// would use.
+pub fn nice_step(raw: f32) -> f32 {
+    if !(raw > 0.0) || !raw.is_finite() {
+        return 1.0;
+    }
+    // In f64, so 5 × 10⁻² comes out as the f32 nearest 0.05, not a hair
+    // under it: the grid's lines are whole multiples of this.
+    let raw = raw as f64;
+    let decade = 10f64.powi(raw.log10().floor() as i32);
+    for m in [1.0, 2.0, 5.0] {
+        if m * decade >= raw * (1.0 - 1e-6) {
+            return (m * decade) as f32;
+        }
+    }
+    (10.0 * decade) as f32
+}
+
+/// About three significant figures, without trailing zeros.
+fn number(v: f32) -> String {
+    let s = match v.abs() {
+        a if a >= 100.0 => format!("{v:.0}"),
+        a if a >= 10.0 => format!("{v:.1}"),
+        a if a >= 1.0 => format!("{v:.2}"),
+        _ => format!("{v:.3}"),
+    };
+    if s.contains('.') {
+        s.trim_end_matches('0').trim_end_matches('.').to_string()
+    } else {
+        s
+    }
+}
+
+/// The unit suffix and divisor for lengths up to `largest` file units.
+fn scale(largest: f32, unit: Unit) -> (&'static str, f32) {
+    match unit.millimetres() {
+        Some(mm) if largest * mm >= 1000.0 => (" m", mm / 1000.0),
+        Some(mm) => (" mm", mm),
+        None => ("", 1.0),
+    }
+}
+
+/// One length in file units, as said.
+pub fn length(v: f32, unit: Unit) -> String {
+    let (suffix, k) = scale(v, unit);
+    format!("{}{suffix}", number(v * k))
+}
+
+/// A box's width × height × depth, all in the unit its largest side needs.
+pub fn dimensions(size: Vec3, unit: Unit) -> String {
+    let (suffix, k) = scale(size.max_element(), unit);
+    format!("{} × {} × {}{suffix}", number(size.x * k), number(size.y * k), number(size.z * k))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn steps_are_one_two_five() {
+        assert_eq!(nice_step(10.0), 10.0);
+        assert_eq!(nice_step(11.0), 20.0);
+        assert_eq!(nice_step(3.0), 5.0);
+        assert_eq!(nice_step(0.07), 0.1);
+        assert_eq!(nice_step(0.0), 1.0);
+    }
+
+    #[test]
+    fn lengths_say_their_unit_only_when_the_format_does() {
+        assert_eq!(length(10.0, Unit::Millimetre), "10 mm");
+        assert_eq!(length(0.5, Unit::Metre), "500 mm");
+        assert_eq!(length(2.5, Unit::Metre), "2.5 m");
+        assert_eq!(length(0.25, Unit::Unspecified), "0.25");
+        assert_eq!(dimensions(Vec3::new(120.0, 45.26, 80.0), Unit::Millimetre), "120 × 45.3 × 80 mm");
+        assert_eq!(dimensions(Vec3::new(1.8, 0.75, 0.9), Unit::Metre), "1.8 × 0.75 × 0.9 m");
+        assert_eq!(dimensions(Vec3::new(2.0, 1.0, 0.5), Unit::Unspecified), "2 × 1 × 0.5");
+    }
+}