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

commit6bba290c3cfe9f8b1e180e020a7284971bef4ac4
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:10
cce-model: a viewer for STL and OBJ files (milestone 1)

Opens a model from the command line or `o`, reads it on a worker thread,
frames it in a three-quarter view and lets you turn it: drag orbits,
shift- or middle-drag pans, ctrl+wheel and pinch zoom, a two-finger scroll
orbits and coasts as in cce-designer; `0` frames all, `q` quits.

Drawn through cce-ui's portable 3D hooks as one prelit mesh over a
screen-space gradient. The vertex has no normal, so smooth shading is baked
per corner (crease-aware normals, a fixed key/fill/ambient rig) after the
mesh is welded by position. Models are moved into the unit sphere before
upload: cce-ui's scene shader reads any vertex near z = 9.99 as a corner of
the background quad, which flung a ring of a 25 mm sphere across the window.

STL binary and ASCII (told apart by size, turned Z-up to Y-up); OBJ with
any polygon, negative indices and MTL Kd colours. The readers know nothing
of the app, so milestone 2 can lift them into a shared crate.

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

 .cargo/config.toml |   7 +
 .gitignore         |   2 +
 CLAUDE.md          |  68 ++++++++
 Cargo.toml         |  10 ++
 Makefile           |  15 ++
 cce-model.desktop  |  11 ++
 src/camera.rs      | 158 ++++++++++++++++++
 src/load/mod.rs    |  33 ++++
 src/load/obj.rs    | 128 ++++++++++++++
 src/load/stl.rs    | 136 +++++++++++++++
 src/main.rs        | 483 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/mesh.rs        | 298 +++++++++++++++++++++++++++++++++
 12 files changed, 1349 insertions(+)

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..93c6cb4
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,7 @@
+# git.lucas.co is static hosting, so it speaks git's dumb HTTP protocol only.
+# Cargo's built-in git client tries smart HTTP first and fails on the response
+# content-type; the git CLI handles dumb HTTP the same way `git clone` does.
+# Needed by anyone building this crate on its own, so it is committed here
+# rather than left to the installing machine's environment.
+[net]
+git-fetch-with-cli = true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ac8596c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,68 @@
+# cce-model
+
+A viewer for 3D model files. Read the workspace guide
+(`../cce-compositor/WORKSPACE.md`) first; this file covers only what is
+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).
+Milestone 1 (STL and OBJ on the existing raster stage) is what is here.
+
+## Shape
+
+- `src/main.rs` — the `Application`. Portable hooks only: `create`,
+  `init_3d` / `stage_3d` (never the native `renderer_init` /
+  `stage_renderer`). A file is read on a worker thread and comes back through
+  the `AppSender` as an `Arc<Model>` (the runner clones messages); a
+  generation drops a load the user has moved past. The model's baked
+  vertices stay on the CPU after upload, because `init_3d` runs again for a
+  replacement renderer after a reconnect and every mesh must go back up.
+- `src/load/` — one reader per format (`stl.rs`, `obj.rs`), each returning
+  a `Mesh` as the file has it; `load()` welds. Nothing here knows about the
+  app: milestone 2 lifts it whole into a shared `cce-mesh-io` crate.
+- `src/mesh.rs` — weld, crease-aware corner normals, and the light bake.
+- `src/camera.rs` — orbit camera: yaw, pitch, distance about a pivot.
+
+## Things that are not obvious
+
+- **Smooth shading is baked.** cce-ui's `Vertex3D` is position + colour, and
+  its own shading is flat (screen-space derivatives). A `prelit` draw shows
+  the vertex colours as they are, so `mesh::bake` lights every corner from
+  its normal against a fixed studio rig (key, fill, sky/ground ambient). The
+  rig is fixed in world space, so the bake never changes as the camera moves.
+  Faces meeting past `CREASE_DEGREES` keep their own normals.
+- **Every model is moved into the unit sphere** (`Mesh::fit_to_unit`) before
+  it is baked. Partly for the camera, but mainly because cce-ui's
+  `scene3d.wgsl` treats ANY vertex with |z − 9.99| < 0.01 as a corner of the
+  screen-space background quad, in whatever mesh it appears. A 25 mm sphere
+  spanning z = 9.99 had a ring of its points flung across the window as
+  spikes. Do not upload geometry in file units.
+- **STL is turned Z-up → Y-up on load** ((x, y, z) → (x, z, −y), a rotation,
+  so winding is kept). OBJ is taken as Y-up, as it nearly always is.
+- **Binary vs ASCII STL is decided by size** (84 + 50·n bytes), not by the
+  word `solid`, which many binary headers begin with.
+- **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.
+- 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.
+
+## Verify
+
+Headless, in a scale-2 shadow, always through `cce-shadow run`:
+
+```sh
+cce-shadow start --new --scale 2          # note the agent-N it prints
+CCE_SHADOW_INSTANCE=agent-N cce-shadow ctl idle timeouts 0 0
+CCE_SHADOW_INSTANCE=agent-N cce-shadow run env CCE_FONTS_DIR=$HOME/Dropbox/Fonts \
+    /home/lsgalante/projects/cce/target/release/cce-model /path/to/model.stl &
+```
+
+then `ctl windows` for the id and `shot-window <id>`. The window is
+1000×750 logical, larger than the 640×360 logical headless output at scale
+2, so use `shot-window`, not `shot`, and keep pointer targets inside
+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. Real test models: the slicers' resource STLs under
+`~/.local/share/Steam/steamapps/compatdata/*/pfx/drive_c/Program Files/`
+(ChiTuBox's `high_precision_sphere.stl`, Bambu Studio's calibration towers).
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..c611968
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "cce-model"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "13700f16b074b27da794ea57b17867d48ff80d2c" }
+glam = "0.29"
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..683eed2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+# Binaries are enumerated by ccebuild from cargo metadata — never name one here.
+install: build
+	@command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+	ccebuild install --no-build cce-model
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/cce-model.desktop b/cce-model.desktop
new file mode 100644
index 0000000..d9bd04f
--- /dev/null
+++ b/cce-model.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Type=Application
+Name=Model
+GenericName=3D Model Viewer
+Comment=View STL and OBJ models
+Exec=cce-model %f
+Icon=cce-model
+Terminal=false
+Categories=Graphics;3DGraphics;Viewer;
+MimeType=model/stl;application/sla;model/obj;
+NoDisplay=false
diff --git a/src/camera.rs b/src/camera.rs
new file mode 100644
index 0000000..7219811
--- /dev/null
+++ b/src/camera.rs
@@ -0,0 +1,158 @@
+//! The orbit camera: an eye on a sphere around a pivot, looking at it.
+//!
+//! Yaw turns about the world's up (Y), pitch tilts toward the poles and
+//! stops just short of them, where the up vector would flip and the view
+//! would roll. Distance is how far the eye sits from the pivot. `radius` is
+//! the size of what is being looked at; it sets the clip planes and the
+//! zoom range, so a 2 mm part and a 200 m building handle alike.
+
+use glam::{Mat4, Vec3};
+
+/// Vertical field of view.
+pub const FOV_Y_DEGREES: f32 = 35.0;
+/// Radians of orbit per logical px of drag or scroll.
+const ORBIT_RAD_PER_PX: f32 = 0.006;
+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;
+/// Room left around a framed model, as a share of its size.
+const FRAME_MARGIN: f32 = 1.15;
+
+#[derive(Debug, Clone)]
+pub struct Camera {
+    pub pivot: Vec3,
+    pub yaw: f32,
+    pub pitch: f32,
+    pub distance: f32,
+    pub radius: f32,
+}
+
+impl Default for Camera {
+    fn default() -> Self {
+        Self { pivot: Vec3::ZERO, yaw: HOME_YAW, pitch: HOME_PITCH, distance: 4.0, radius: 1.0 }
+    }
+}
+
+impl Camera {
+    /// Unit vector from the pivot to the eye.
+    fn toward_eye(&self) -> Vec3 {
+        let (sy, cy) = self.yaw.sin_cos();
+        let (sp, cp) = self.pitch.sin_cos();
+        Vec3::new(cp * sy, sp, cp * cy)
+    }
+
+    pub fn eye(&self) -> Vec3 {
+        self.pivot + self.distance * self.toward_eye()
+    }
+
+    /// 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;
+        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)
+    }
+
+    /// Centre on `centre` and back off until a sphere of `radius` round it
+    /// shows whole in a viewport `aspect` wide per unit high, from the home
+    /// three-quarter view.
+    pub fn frame(&mut self, centre: Vec3, radius: f32, aspect: f32) {
+        self.pivot = centre;
+        self.radius = radius.max(1e-6);
+        self.yaw = HOME_YAW;
+        self.pitch = HOME_PITCH;
+        self.distance = self.fit_distance(aspect);
+    }
+
+    /// The distance at which a sphere of `radius` round the pivot just fits
+    /// the narrower of the two fields of view.
+    fn fit_distance(&self, aspect: f32) -> f32 {
+        let half_v = FOV_Y_DEGREES.to_radians() / 2.0;
+        let half_h = (half_v.tan() * aspect.max(1e-3)).atan();
+        FRAME_MARGIN * self.radius / half_v.min(half_h).sin()
+    }
+
+    /// Turn by a drag of (dx, dy) logical px: the surface under the pointer
+    /// follows it, so dragging right swings the model's front to the right.
+    pub fn orbit(&mut self, dx: f32, dy: f32) {
+        self.yaw -= dx * ORBIT_RAD_PER_PX;
+        self.pitch = (self.pitch + dy * ORBIT_RAD_PER_PX).clamp(-MAX_PITCH, MAX_PITCH);
+    }
+
+    /// Slide the pivot by a drag of (dx, dy) logical px in a viewport
+    /// `view_h` logical px high, so the point under the pointer stays under it.
+    pub fn pan(&mut self, dx: f32, dy: f32, view_h: f32) {
+        let per_px = 2.0 * self.distance * (FOV_Y_DEGREES.to_radians() / 2.0).tan() / view_h.max(1.0);
+        let forward = -self.toward_eye();
+        let right = forward.cross(Vec3::Y).normalize_or_zero();
+        let up = right.cross(forward);
+        self.pivot += (-right * dx + up * dy) * per_px;
+    }
+
+    /// Move the eye toward the pivot (`factor` < 1) or away from it.
+    pub fn zoom(&mut self, factor: f32) {
+        self.distance = (self.distance * factor).clamp(self.radius * 1e-3, self.radius * 100.0);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Where a world point lands in normalized device coordinates.
+    fn ndc(cam: &Camera, aspect: f32, p: Vec3) -> Vec3 {
+        cam.view_proj(aspect).project_point3(p)
+    }
+
+    #[test]
+    fn a_framed_box_fits_the_view_at_any_aspect() {
+        for aspect in [0.5, 1.0, 1.6, 3.0] {
+            let mut cam = Camera::default();
+            let (lo, hi) = (Vec3::new(-3.0, 0.0, -1.0), Vec3::new(5.0, 2.0, 1.0));
+            cam.frame((lo + hi) / 2.0, (hi - lo).length() / 2.0, aspect);
+            for i in 0..8 {
+                let p = Vec3::new(
+                    if i & 1 == 0 { lo.x } else { hi.x },
+                    if i & 2 == 0 { lo.y } else { hi.y },
+                    if i & 4 == 0 { lo.z } else { hi.z },
+                );
+                let q = ndc(&cam, aspect, p);
+                assert!(q.x.abs() <= 1.0 && q.y.abs() <= 1.0, "aspect {aspect}: corner {p} at {q}");
+                assert!((0.0..=1.0).contains(&q.z), "aspect {aspect}: corner {p} clipped in depth ({})", q.z);
+            }
+        }
+    }
+
+    #[test]
+    fn a_pan_keeps_the_point_under_the_pointer() {
+        let mut cam = Camera::default();
+        cam.frame(Vec3::ZERO, 3f32.sqrt(), 1.0);
+        let before = ndc(&cam, 1.0, cam.pivot);
+        let pivot = cam.pivot;
+        // 100 px right in a 1000 px view is 0.2 of NDC's 2-unit width.
+        cam.pan(100.0, 0.0, 1000.0);
+        let after = ndc(&cam, 1.0, pivot);
+        assert!((after.x - before.x - 0.2).abs() < 1e-3, "moved {} in x", after.x - before.x);
+        assert!((after.y - before.y).abs() < 1e-3);
+    }
+
+    #[test]
+    fn dragging_right_brings_the_left_side_round() {
+        let mut cam = Camera { yaw: 0.0, pitch: 0.0, ..Camera::default() };
+        let left = Vec3::new(-1.0, 0.0, 0.0);
+        cam.orbit(100.0, 0.0);
+        // The eye has swung toward -X, the side that was on the left.
+        assert!(cam.eye().dot(left) > 0.0);
+    }
+
+    #[test]
+    fn pitch_stops_short_of_the_pole() {
+        let mut cam = Camera::default();
+        cam.orbit(0.0, 1e6);
+        assert!(cam.pitch < std::f32::consts::FRAC_PI_2);
+        assert!(cam.view_proj(1.0).is_finite());
+    }
+}
diff --git a/src/load/mod.rs b/src/load/mod.rs
new file mode 100644
index 0000000..715a78e
--- /dev/null
+++ b/src/load/mod.rs
@@ -0,0 +1,33 @@
+//! Reading model files into a [`Mesh`]. One reader per format; each returns
+//! the mesh as the file has it, and [`load`] welds it.
+//!
+//! This module is written to move out whole: milestone 2 of the viewer's
+//! design doc lifts it into a shared `cce-mesh-io` crate, so cce-designer
+//! can import what it already exports. Nothing in it knows about the app.
+
+mod obj;
+mod stl;
+
+use std::path::Path;
+
+use crate::mesh::Mesh;
+
+/// File extensions the viewer opens, lowercase.
+pub const EXTENSIONS: &[&str] = &["stl", "obj"];
+
+/// Read `path` by its extension and weld the result.
+pub fn load(path: &Path) -> Result<Mesh, String> {
+    let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
+    let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
+    let mut mesh = match ext.as_str() {
+        "stl" => stl::read(&bytes)?,
+        "obj" => obj::read(&bytes, path.parent())?,
+        "" => return Err("no file extension, so no way to tell the format".into()),
+        other => return Err(format!("cannot read .{other} files (yet)")),
+    };
+    mesh.weld();
+    if mesh.triangles.is_empty() {
+        return Err("the file holds no triangles".into());
+    }
+    Ok(mesh)
+}
diff --git a/src/load/obj.rs b/src/load/obj.rs
new file mode 100644
index 0000000..9f79c4a
--- /dev/null
+++ b/src/load/obj.rs
@@ -0,0 +1,128 @@
+//! Wavefront OBJ, with its MTL colours.
+//!
+//! What is read: `v` points, `f` faces (any polygon, fanned into triangles;
+//! `i`, `i/t`, `i//n` and `i/t/n` corners; negative indices counting back
+//! from the newest point), `usemtl`, and `mtllib` for each material's `Kd`
+//! diffuse colour. Texture coordinates and the file's normals are skipped:
+//! the viewer derives normals itself and draws no textures yet. A missing
+//! MTL file is not an error; its materials fall back to clay.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use glam::Vec3;
+
+use crate::mesh::{Mesh, CLAY};
+
+pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Mesh, String> {
+    let text = String::from_utf8_lossy(bytes);
+    let mut mesh = Mesh { colors: vec![CLAY], ..Mesh::default() };
+    // Material name → index into `mesh.colors`, filled as `mtllib`s are read.
+    let mut library: HashMap<String, [f32; 3]> = HashMap::new();
+    let mut slot: HashMap<String, u32> = HashMap::new();
+    let mut current = 0u32;
+    let mut corners: Vec<u32> = Vec::new();
+
+    for (line_no, line) in text.lines().enumerate() {
+        let line = line.split('#').next().unwrap_or("");
+        let mut words = line.split_whitespace();
+        let err = |what: &str| format!("line {}: {what}", line_no + 1);
+        match words.next() {
+            Some("v") => {
+                let mut xyz = [0f32; 3];
+                for v in &mut xyz {
+                    *v = words.next().and_then(|w| w.parse().ok()).ok_or_else(|| err("a point needs three numbers"))?;
+                }
+                mesh.positions.push(Vec3::from(xyz));
+            }
+            Some("f") => {
+                corners.clear();
+                for w in words {
+                    let i: i64 = w.split('/').next().and_then(|s| s.parse().ok()).ok_or_else(|| err("a bad face corner"))?;
+                    let n = mesh.positions.len() as i64;
+                    let index = if i > 0 { i - 1 } else { n + i };
+                    if i == 0 || !(0..n).contains(&index) {
+                        return Err(err(&format!("face corner {i} names no point (there are {n} so far)")));
+                    }
+                    corners.push(index as u32);
+                }
+                if corners.len() < 3 {
+                    return Err(err("a face needs three corners"));
+                }
+                for k in 1..corners.len() - 1 {
+                    mesh.triangles.push([corners[0], corners[k], corners[k + 1]]);
+                    mesh.tri_color.push(current);
+                }
+            }
+            Some("mtllib") => {
+                // A library name may hold spaces, so take the rest of the line.
+                let name = line.trim_start().strip_prefix("mtllib").unwrap_or("").trim();
+                if let Some(dir) = dir {
+                    match std::fs::read_to_string(dir.join(name)) {
+                        Ok(mtl) => library.extend(read_mtl(&mtl)),
+                        Err(e) => log::warn!("[model] material library {name}: {e}"),
+                    }
+                }
+            }
+            Some("usemtl") => {
+                let name = words.next().unwrap_or("").to_string();
+                current = *slot.entry(name.clone()).or_insert_with(|| {
+                    mesh.colors.push(library.get(&name).copied().unwrap_or(CLAY));
+                    (mesh.colors.len() - 1) as u32
+                });
+            }
+            _ => {}
+        }
+    }
+    Ok(mesh)
+}
+
+/// Each `newmtl` in an MTL file and its `Kd`.
+fn read_mtl(text: &str) -> HashMap<String, [f32; 3]> {
+    let mut out = HashMap::new();
+    let mut name: Option<String> = None;
+    for line in text.lines() {
+        let mut words = line.split_whitespace();
+        match words.next() {
+            Some("newmtl") => name = words.next().map(str::to_string),
+            Some("Kd") => {
+                let rgb: Vec<f32> = words.take(3).filter_map(|w| w.parse().ok()).collect();
+                if let (Some(n), [r, g, b]) = (&name, rgb.as_slice()) {
+                    out.insert(n.clone(), [*r, *g, *b]);
+                }
+            }
+            _ => {}
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_quad_is_two_triangles_and_negative_indices_count_back() {
+        let m = read(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf -4/1/1 -3/2/1 -2/3/1 -1/4/1\n", None).unwrap();
+        assert_eq!(m.triangles, vec![[0, 1, 2], [0, 2, 3]]);
+    }
+
+    #[test]
+    fn materials_colour_their_faces() {
+        let dir = std::env::temp_dir().join(format!("cce-model-obj-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        std::fs::write(dir.join("m.mtl"), "newmtl red\nKd 0.8 0.1 0.1\n").unwrap();
+        let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nusemtl red\nf 1 3 2\nusemtl missing\nf 2 1 3\n";
+        let m = read(obj, Some(&dir)).unwrap();
+        std::fs::remove_dir_all(&dir).ok();
+        assert_eq!(m.colors[m.tri_color[0] as usize], CLAY, "before any usemtl");
+        assert_eq!(m.colors[m.tri_color[1] as usize], [0.8, 0.1, 0.1]);
+        assert_eq!(m.colors[m.tri_color[2] as usize], CLAY, "a material the library lacks");
+    }
+
+    #[test]
+    fn a_face_past_the_points_is_an_error() {
+        let e = read(b"v 0 0 0\nv 1 0 0\nf 1 2 3\n", None).unwrap_err();
+        assert!(e.contains("line 3") && e.contains("names no point"), "{e}");
+    }
+}
diff --git a/src/load/stl.rs b/src/load/stl.rs
new file mode 100644
index 0000000..6c7939e
--- /dev/null
+++ b/src/load/stl.rs
@@ -0,0 +1,136 @@
+//! STL, binary and ASCII.
+//!
+//! Binary is told from ASCII by its size, not by its first word: the format
+//! leaves the 80-byte header free, and plenty of binary files begin it with
+//! `solid`, the word that opens an ASCII one. A binary file is exactly
+//! 84 + 50 × its triangle count bytes long.
+//!
+//! STL is Z-up by convention (it comes from CAD and goes to printers) and
+//! the viewer is Y-up, so every point is turned a quarter about X on the way
+//! in: (x, y, z) → (x, z, −y). A rotation, not a mirror, so the winding and
+//! with it the outward side of every face are kept. The file's own facet
+//! normals are ignored; the viewer derives its own from the winding.
+
+use glam::Vec3;
+
+use crate::mesh::{Mesh, CLAY};
+
+pub fn read(bytes: &[u8]) -> Result<Mesh, String> {
+    let corners = if is_binary(bytes) { binary(bytes)? } else { ascii(bytes)? };
+    let n = corners.len() / 3;
+    Ok(Mesh {
+        positions: corners.into_iter().map(|p| Vec3::new(p.x, p.z, -p.y)).collect(),
+        triangles: (0..n as u32).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect(),
+        tri_color: vec![0; n],
+        colors: vec![CLAY],
+    })
+}
+
+fn is_binary(bytes: &[u8]) -> bool {
+    if bytes.len() < 84 {
+        return false;
+    }
+    let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as u64;
+    84 + 50 * count == bytes.len() as u64 || !bytes.starts_with(b"solid")
+}
+
+fn binary(bytes: &[u8]) -> Result<Vec<Vec3>, String> {
+    let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize;
+    let need = 84 + 50 * count;
+    if bytes.len() < need {
+        return Err(format!(
+            "binary STL says {count} triangles ({need} bytes) but the file is {} bytes; it is cut short",
+            bytes.len()
+        ));
+    }
+    let f = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
+    let mut out = Vec::with_capacity(count * 3);
+    for t in 0..count {
+        // 12 bytes of facet normal, three 12-byte corners, 2 bytes attribute.
+        let base = 84 + 50 * t + 12;
+        for k in 0..3 {
+            let o = base + 12 * k;
+            out.push(Vec3::new(f(o), f(o + 4), f(o + 8)));
+        }
+    }
+    Ok(out)
+}
+
+fn ascii(bytes: &[u8]) -> Result<Vec<Vec3>, String> {
+    let text = std::str::from_utf8(bytes).map_err(|_| "not a binary STL, and not text either".to_string())?;
+    let mut out = Vec::new();
+    for (line_no, line) in text.lines().enumerate() {
+        let mut words = line.split_whitespace();
+        if words.next() != Some("vertex") {
+            continue;
+        }
+        let mut xyz = [0f32; 3];
+        for v in &mut xyz {
+            *v = words
+                .next()
+                .and_then(|w| w.parse().ok())
+                .ok_or_else(|| format!("line {}: a vertex needs three numbers", line_no + 1))?;
+        }
+        out.push(Vec3::from(xyz));
+    }
+    if out.len() % 3 != 0 {
+        return Err(format!("{} vertices is not a whole number of triangles", out.len()));
+    }
+    Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn binary_of(tris: &[[[f32; 3]; 3]]) -> Vec<u8> {
+        let mut b = vec![0u8; 80];
+        b[..5].copy_from_slice(b"solid"); // the header may say anything, this included
+        b.extend((tris.len() as u32).to_le_bytes());
+        for t in tris {
+            b.extend([0u8; 12]);
+            for p in t {
+                for c in p {
+                    b.extend(c.to_le_bytes());
+                }
+            }
+            b.extend([0u8; 2]);
+        }
+        b
+    }
+
+    #[test]
+    fn a_binary_file_headed_solid_is_read_as_binary() {
+        let m = read(&binary_of(&[[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]])).unwrap();
+        assert_eq!(m.triangles.len(), 1);
+        assert_eq!(m.positions[1], Vec3::new(1.0, 0.0, 0.0));
+    }
+
+    #[test]
+    fn z_up_becomes_y_up() {
+        let m = read(&binary_of(&[[[0., 0., 2.], [1., 0., 0.], [0., 3., 0.]]])).unwrap();
+        assert_eq!(m.positions[0], Vec3::new(0.0, 2.0, 0.0), "+Z is up");
+        assert_eq!(m.positions[2], Vec3::new(0.0, 0.0, -3.0), "+Y goes away from the viewer");
+    }
+
+    #[test]
+    fn ascii_is_read() {
+        let text = "solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
+        assert_eq!(read(text.as_bytes()).unwrap().triangles.len(), 1);
+    }
+
+    #[test]
+    fn a_short_binary_file_is_an_error() {
+        let mut b = binary_of(&[[[0.; 3], [1., 0., 0.], [0., 1., 0.]]; 2]);
+        b.truncate(b.len() - 30);
+        b[..5].copy_from_slice(b"model"); // not "solid": nothing to try as text
+        let e = read(&b).unwrap_err();
+        assert!(e.contains("cut short"), "{e}");
+    }
+
+    #[test]
+    fn a_bad_ascii_vertex_is_an_error() {
+        let e = read(b"solid t\nvertex 0 0\n").unwrap_err();
+        assert!(e.contains("line 2"), "{e}");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..22988fe
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,483 @@
+//! cce-model — a viewer for 3D model files.
+//!
+//! Opens STL and OBJ (milestone 1 of the design doc; glTF and PLY follow),
+//! frames the model in a three-quarter view and lets you turn it. Files are
+//! read and lit on a worker thread (`load` + `mesh::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.
+//!
+//! 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.
+
+mod camera;
+mod load;
+mod mesh;
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use cce_ui::engine::{
+    AppSender, Application, LogicalPosition, LogicalSize, MeshId, SceneDraw, Stage3D, Vertex3D, WindowSettings,
+};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::scroll_motion::{current_scroll_phase, Bounds, ScrollMotion, ScrollPhase};
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+use glam::Vec3;
+
+use camera::Camera;
+
+/// Wheel notches in logical px of orbit, and of log-zoom.
+const ORBIT_PX_PER_LINE: f32 = 10.0;
+const ZOOM_PER_LINE: f32 = 0.15;
+const ZOOM_PER_PX: f32 = 0.005;
+
+/// The background gradient, linear RGB, top and bottom.
+const SKY_TOP: [f32; 3] = [0.105, 0.11, 0.125];
+const SKY_BOTTOM: [f32; 3] = [0.03, 0.03, 0.035];
+
+#[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> },
+    Quit,
+}
+
+/// A model read, welded and lit, ready to upload.
+#[derive(Debug)]
+struct Model {
+    name: String,
+    triangles: usize,
+    /// Kept after upload: a replacement renderer (a reconnect) starts with
+    /// no meshes, and this is what goes back up.
+    verts: Vec<Vertex3D>,
+}
+
+impl Model {
+    fn read(path: &Path) -> Result<Model, String> {
+        let mut mesh = load::load(path)?;
+        // Every model is drawn inside the unit sphere (see `fit_to_unit`),
+        // so the camera frames that sphere whatever the file's units.
+        mesh.fit_to_unit();
+        Ok(Model {
+            name: path.file_name().and_then(|n| n.to_str()).unwrap_or("?").to_string(),
+            triangles: mesh.triangles.len(),
+            verts: mesh.bake(),
+        })
+    }
+}
+
+/// What the current renderer holds for us.
+struct Gpu {
+    background: MeshId,
+    /// Created at the first upload: a mesh of no vertices would be a
+    /// zero-sized buffer.
+    model: Option<MeshId>,
+}
+
+enum Drag {
+    Orbit,
+    Pan,
+}
+
+struct ModelApp {
+    sender: AppSender<Message>,
+    /// Bumped by every open, so a slow load the user has moved past is
+    /// dropped when it arrives.
+    generation: u64,
+    loading: Option<PathBuf>,
+    model: Option<Arc<Model>>,
+    error: Option<String>,
+    camera: Camera,
+    gpu: Option<Gpu>,
+    /// The model's vertices are not on the GPU yet.
+    upload_pending: bool,
+    /// 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.
+    scene_dirty: bool,
+    /// Two-finger scroll orbit, in logical px: a finger tracks 1:1, the
+    /// lift coasts, a notch glides. Only how far it moved matters.
+    orbit_motion: ScrollMotion,
+    pointer: (f32, f32),
+    drag: Option<Drag>,
+    ctrl: bool,
+    shift: bool,
+    win: (f32, f32),
+    scale: f64,
+}
+
+impl ModelApp {
+    fn aspect(&self) -> f32 {
+        self.win.0 / self.win.1.max(1.0)
+    }
+
+    fn open(&mut self, path: PathBuf) {
+        self.generation += 1;
+        let generation = self.generation;
+        self.loading = Some(path.clone());
+        self.error = None;
+        let sender = self.sender.clone();
+        std::thread::spawn(move || {
+            let result = Model::read(&path).map(Arc::new);
+            // Fails only once the app has gone, when nobody wants the model.
+            let _ = sender.send(Message::Loaded { generation, path, result });
+        });
+    }
+
+    fn open_dialog(&mut self) {
+        let filters: &[(&str, &[&str])] = &[("3D models", load::EXTENSIONS), ("STL", &["stl"]), ("OBJ", &["obj"])];
+        if let Some(path) = cce_ui::file_dialog::pick_file("Open model", filters) {
+            self.open(path);
+        }
+    }
+
+    fn frame_all(&mut self) {
+        if self.model.is_some() {
+            self.camera.frame(Vec3::ZERO, 1.0, self.aspect());
+            self.orbit_motion = ScrollMotion::new();
+            self.scene_dirty = true;
+        }
+    }
+
+    /// Orbit by how far the scroll motion moved since `before`.
+    fn orbit_since(&mut self, before: (f32, f32)) -> bool {
+        let (dx, dy) = (self.orbit_motion.x.pos() - before.0, self.orbit_motion.y.pos() - before.1);
+        if dx == 0.0 && dy == 0.0 {
+            return false;
+        }
+        self.camera.orbit(dx, dy);
+        self.scene_dirty = true;
+        true
+    }
+
+    fn orbit_pos(&self) -> (f32, f32) {
+        (self.orbit_motion.x.pos(), self.orbit_motion.y.pos())
+    }
+
+    fn hud_line(&self) -> Option<String> {
+        if let Some(path) = &self.loading {
+            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
+            return Some(format!("Loading {name}…"));
+        }
+        let m = self.model.as_ref()?;
+        Some(format!("{}   ·   {} triangles", m.name, group_thousands(m.triangles)))
+    }
+}
+
+/// 1234567 → "1,234,567".
+fn group_thousands(n: usize) -> String {
+    let digits = n.to_string();
+    let mut out = String::new();
+    for (i, c) in digits.chars().enumerate() {
+        if i > 0 && (digits.len() - i) % 3 == 0 {
+            out.push(',');
+        }
+        out.push(c);
+    }
+    out
+}
+
+/// A scene draw of `mesh` with every option at its plain default.
+fn draw(mesh: MeshId, mvp: [[f32; 4]; 4]) -> SceneDraw {
+    SceneDraw {
+        mesh,
+        mvp,
+        wireframe: false,
+        wire_tint: [0.0; 4],
+        opacity: 1.0,
+        line_width: 1.0,
+        wire_base_width: 0.0,
+        prelit: false,
+        see_through: false,
+        instances: None,
+    }
+}
+
+impl Application for ModelApp {
+    type Message = Message;
+
+    fn create(sender: AppSender<Message>) -> Self {
+        let mut app = Self {
+            sender,
+            generation: 0,
+            loading: None,
+            model: None,
+            error: None,
+            camera: Camera::default(),
+            gpu: None,
+            upload_pending: false,
+            scene_dirty: true,
+            orbit_motion: ScrollMotion::new(),
+            pointer: (0.0, 0.0),
+            drag: None,
+            ctrl: false,
+            shift: false,
+            win: (1000.0, 750.0),
+            scale: 1.0,
+        };
+        if let Some(path) = std::env::args_os().nth(1) {
+            app.open(PathBuf::from(path));
+        }
+        app
+    }
+
+    fn settings(&self) -> WindowSettings {
+        let title = match &self.model {
+            Some(m) => format!("{} — Model", m.name),
+            None => "Model".to_string(),
+        };
+        WindowSettings { title, app_id: "cce-model".into(), width: 1000, height: 750, fullscreen: false, min_size: Some((320, 240)) }
+    }
+
+    fn update(&mut self, msg: Message, needs_rebuild: &mut bool, exit: &mut bool) {
+        match msg {
+            Message::Quit => *exit = true,
+            Message::Loaded { generation, path, result } => {
+                if generation != self.generation {
+                    return;
+                }
+                self.loading = None;
+                match result {
+                    Ok(model) => {
+                        log::info!("[model] {}: {} triangles", path.display(), model.triangles);
+                        self.model = Some(model);
+                        self.upload_pending = true;
+                        self.frame_all();
+                    }
+                    Err(e) => {
+                        log::warn!("[model] {}: {e}", path.display());
+                        let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
+                        self.error = Some(format!("{name}: {e}"));
+                    }
+                }
+                *needs_rebuild = true;
+            }
+        }
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.orbit_motion.is_animating() {
+            let before = self.orbit_pos();
+            self.orbit_motion.tick(dt, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
+            if self.orbit_since(before) || self.orbit_motion.is_animating() {
+                *needs_rebuild = true;
+            }
+        }
+    }
+
+    fn load_system_fonts(&self) -> bool {
+        true
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    /// Once per renderer, the first and any replacement after a reconnect:
+    /// a new renderer holds no meshes, so the model goes up again.
+    fn init_3d(&mut self, stage: &mut dyn Stage3D) {
+        let bg = |x: f32, y: f32, color: [f32; 3]| Vertex3D { position: [x, y, 9.99], color };
+        // z = 9.99 is the scene pass's screen-space sentinel: these corners
+        // are NDC, drawn behind everything, untouched by the mvp.
+        let background = stage.create_mesh(&[
+            bg(-1.0, -1.0, SKY_BOTTOM),
+            bg(1.0, -1.0, SKY_BOTTOM),
+            bg(1.0, 1.0, SKY_TOP),
+            bg(-1.0, -1.0, SKY_BOTTOM),
+            bg(1.0, 1.0, SKY_TOP),
+            bg(-1.0, 1.0, SKY_TOP),
+        ]);
+        stage.set_scene_light(mesh::KEY.normalize().to_array());
+        self.gpu = Some(Gpu { background, model: None });
+        self.upload_pending = self.model.is_some();
+        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 {
+                match gpu.model {
+                    Some(id) => stage.update_mesh(id, &m.verts),
+                    None => gpu.model = Some(stage.create_mesh(&m.verts)),
+                }
+            }
+            self.upload_pending = false;
+            self.scene_dirty = true;
+        }
+        if !std::mem::replace(&mut self.scene_dirty, false) {
+            return false;
+        }
+        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();
+        let mut draws = vec![draw(gpu.background, mvp)];
+        if let (Some(id), Some(_)) = (gpu.model, &self.model) {
+            draws.push(SceneDraw { prelit: true, ..draw(id, mvp) });
+        }
+        stage.stage_scene((0, 0, pw, ph), draws);
+        false
+    }
+
+    fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
+        self.win = (width, height);
+        self.scale = scale;
+        self.scene_dirty = true;
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (x, y) = (pos.x as f32, pos.y as f32);
+        let (dx, dy) = (x - self.pointer.0, y - self.pointer.1);
+        self.pointer = (x, y);
+        match self.drag {
+            Some(Drag::Orbit) => self.camera.orbit(dx, dy),
+            Some(Drag::Pan) => self.camera.pan(dx, dy, self.win.1),
+            None => return,
+        }
+        self.scene_dirty = true;
+        *needs_rebuild = true;
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        _needs_rebuild: &mut bool,
+    ) -> Option<Message> {
+        self.pointer = (pos.x as f32, pos.y as f32);
+        if state == ElementState::Released {
+            self.drag = None;
+            return None;
+        }
+        self.drag = match button {
+            MouseButton::Left if self.shift => Some(Drag::Pan),
+            MouseButton::Left => Some(Drag::Orbit),
+            MouseButton::Middle => Some(Drag::Pan),
+            _ => None,
+        };
+        if self.drag.is_some() {
+            // A grab stops a coast, as a hand on a spinning turntable would.
+            self.orbit_motion = ScrollMotion::new();
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, _pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let scale = self.scale.max(0.001) as f32;
+        let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+        let (dx, dy) = match delta {
+            MouseScrollDelta::LineDelta(x, y) => (*x * ORBIT_PX_PER_LINE, *y * ORBIT_PX_PER_LINE),
+            MouseScrollDelta::PixelDelta(p) => (p.x as f32 / scale, p.y as f32 / scale),
+        };
+        if self.ctrl {
+            let log_zoom = if discrete { -dy / ORBIT_PX_PER_LINE * ZOOM_PER_LINE } else { -dy * ZOOM_PER_PX };
+            self.camera.zoom(log_zoom.exp());
+            self.scene_dirty = true;
+            *needs_rebuild = true;
+            return;
+        }
+        let phase = if discrete { ScrollPhase::Wheel } else { current_scroll_phase() };
+        let before = self.orbit_pos();
+        self.orbit_motion.apply_phase(phase, dx, dy, discrete, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
+        if self.orbit_since(before) || self.orbit_motion.is_animating() {
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_pinch(&mut self, factor: f32, _pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
+        if factor > 0.0 && factor != 1.0 {
+            self.camera.zoom(1.0 / factor);
+            self.scene_dirty = true;
+            *needs_rebuild = true;
+        }
+        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).
+        match &event.logical_key {
+            Key::Named(NamedKey::Control) => self.ctrl = event.state == ElementState::Pressed,
+            Key::Named(NamedKey::Shift) => self.shift = event.state == ElementState::Pressed,
+            _ => {
+                self.ctrl = event.ctrl;
+                self.shift = event.shift;
+            }
+        }
+        if event.state != ElementState::Pressed {
+            return None;
+        }
+        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 == "q" => return Some(Message::Quit),
+            _ => return None,
+        }
+        *needs_rebuild = true;
+        None
+    }
+
+    // style-audit: opt-out the 3D scene is the window's content, full-bleed under the HUD; a root plate would frost over it.
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        self.win = (size.width, size.height);
+        self.scale = scale;
+        let mut pc = PaintCtx::new();
+        let inset = cce_ui::layout::root_plate_inset();
+        let text_in = cce_ui::layout::CONTROL_TEXT_INSET;
+
+        let centre = |pc: &mut PaintCtx, msg: &str| {
+            let w = msg.chars().count() as f32 * 7.4;
+            let x = ((size.width - w) / 2.0).max(inset);
+            pc.text(msg.to_string(), x, size.height / 2.0 - 8.0, 14.0, [190, 190, 196]);
+        };
+        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 or OBJ)");
+        }
+
+        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]);
+        }
+        Some(pc.finish())
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        [SKY_BOTTOM[0], SKY_BOTTOM[1], SKY_BOTTOM[2], 1.0]
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<ModelApp>();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn thousands_are_grouped() {
+        assert_eq!(group_thousands(7), "7");
+        assert_eq!(group_thousands(1000), "1,000");
+        assert_eq!(group_thousands(1234567), "1,234,567");
+    }
+
+    #[test]
+    fn a_soup_cube_bakes_to_twelve_lit_triangles() {
+        let mut m = mesh::soup_cube();
+        m.weld();
+        let verts = m.bake();
+        assert_eq!(verts.len(), 36);
+        // Six faces, each one flat colour from its own normal: six distinct shades.
+        let mut shades: Vec<u32> = verts.iter().map(|v| (v.color[0] * 1e4) as u32).collect();
+        shades.sort();
+        shades.dedup();
+        assert_eq!(shades.len(), 6, "{shades:?}");
+    }
+}
diff --git a/src/mesh.rs b/src/mesh.rs
new file mode 100644
index 0000000..2b751d1
--- /dev/null
+++ b/src/mesh.rs
@@ -0,0 +1,298 @@
+//! A model as the viewer holds it: one welded triangle mesh with a colour
+//! per triangle, and the vertices the raster pass draws, baked from it.
+//!
+//! ## Why the light is baked
+//!
+//! cce-ui's scene vertex is a position and a colour, nothing more. Its own
+//! shading is flat, from screen-space derivatives, so a curved surface reads
+//! as facets. A `prelit` draw skips that and shows the vertex colours as
+//! they are, so smooth shading is a matter of lighting each corner here,
+//! from its normal, before upload. The lights are fixed in world space (a
+//! key, a fill and a sky/ground ambient), so the bake never changes as the
+//! camera orbits: the model turns under a studio rig, as on a turntable.
+//!
+//! ## Why the mesh is welded
+//!
+//! An STL has no shared points: each triangle carries its own corners. A
+//! smooth normal is the average of the faces meeting at a point, which
+//! needs those faces to name the same point, so every loader's output is
+//! welded by position before normals are taken.
+
+use std::collections::HashMap;
+
+use cce_ui::engine::Vertex3D;
+use glam::Vec3;
+
+/// The colour of a surface that names none (an STL; an OBJ without a
+/// material), in the linear values the scene pass draws: a warm clay.
+pub const CLAY: [f32; 3] = [0.42, 0.40, 0.36];
+
+/// Faces meeting at a sharper angle than this keep separate normals, so a
+/// cube's edges stay edges and a sphere's facets blend.
+pub const CREASE_DEGREES: f32 = 40.0;
+
+/// Direction TOWARD the key light, world space (Y up): above and to the
+/// left of the home three-quarter view, so a model opens with a lit side
+/// and a shaded side rather than lit flat from the camera.
+pub const KEY: Vec3 = Vec3::new(-0.35, 0.75, 0.55);
+const KEY_STRENGTH: f32 = 0.95;
+/// Toward the fill: the home view's right, low, so the shaded side is not black.
+const FILL: Vec3 = Vec3::new(0.75, 0.1, -0.1);
+const FILL_STRENGTH: f32 = 0.25;
+/// Ambient from above and below: a face turned to the sky is lit a little
+/// more than one turned to the floor.
+const SKY: f32 = 0.20;
+const GROUND: f32 = 0.06;
+
+#[derive(Debug, Clone, Default)]
+pub struct Mesh {
+    pub positions: Vec<Vec3>,
+    pub triangles: Vec<[u32; 3]>,
+    /// One per triangle: an index into `colors`.
+    pub tri_color: Vec<u32>,
+    pub colors: Vec<[f32; 3]>,
+}
+
+impl Mesh {
+    /// The axis-aligned box around every point, or `None` for no points.
+    pub fn bounds(&self) -> Option<(Vec3, Vec3)> {
+        let first = *self.positions.first()?;
+        Some(self.positions.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))))
+    }
+
+    /// Move and scale the mesh so its bounding box is centred on the origin
+    /// and its farthest point is 1 from it, and return the (centre, radius)
+    /// it had, so its real size can still be reported. The radius is the
+    /// farthest point's, not the box's half-diagonal: a sphere's box corners
+    /// sit 1.7 times farther out than any of its points, and framing those
+    /// left a framed sphere small in the window.
+    ///
+    /// Not only for a tidy camera: cce-ui's scene pass reads any vertex
+    /// whose z is within 0.01 of 9.99 as a corner of the screen-space
+    /// background quad (`scene3d.wgsl`), whatever mesh it is in. A model
+    /// spanning z = 9.99 in its own units had a ring of its points flung
+    /// across the screen. Inside a unit sphere no point comes near it.
+    pub fn fit_to_unit(&mut self) -> (Vec3, f32) {
+        let Some((lo, hi)) = self.bounds() else { return (Vec3::ZERO, 1.0) };
+        let centre = (lo + hi) / 2.0;
+        let radius = self.positions.iter().map(|p| p.distance(centre)).fold(0.0, f32::max).max(f32::MIN_POSITIVE);
+        for p in &mut self.positions {
+            *p = (*p - centre) / radius;
+        }
+        (centre, radius)
+    }
+
+    /// Merge points that sit at the same place (to a millionth of the
+    /// model's size) and drop the triangles that collapse doing so.
+    pub fn weld(&mut self) {
+        let Some((lo, hi)) = self.bounds() else { return };
+        let cell = ((hi - lo).length() * 1e-6).max(f32::MIN_POSITIVE);
+        let key = |p: Vec3| {
+            let q = (p - lo) / cell;
+            [q.x.round() as i64, q.y.round() as i64, q.z.round() as i64]
+        };
+        let mut index: HashMap<[i64; 3], u32> = HashMap::with_capacity(self.positions.len());
+        let mut positions = Vec::with_capacity(self.positions.len());
+        let remap: Vec<u32> = self
+            .positions
+            .iter()
+            .map(|p| {
+                *index.entry(key(*p)).or_insert_with(|| {
+                    positions.push(*p);
+                    (positions.len() - 1) as u32
+                })
+            })
+            .collect();
+        let mut triangles = Vec::with_capacity(self.triangles.len());
+        let mut tri_color = Vec::with_capacity(self.triangles.len());
+        for (t, c) in self.triangles.iter().zip(&self.tri_color) {
+            let [a, b, c3] = t.map(|i| remap[i as usize]);
+            if a != b && b != c3 && a != c3 {
+                triangles.push([a, b, c3]);
+                tri_color.push(*c);
+            }
+        }
+        self.positions = positions;
+        self.triangles = triangles;
+        self.tri_color = tri_color;
+    }
+
+    /// A normal for each triangle corner (three per triangle, in order):
+    /// the area-weighted average of the faces at that point which meet this
+    /// triangle within the crease angle.
+    pub fn corner_normals(&self, crease_degrees: f32) -> Vec<Vec3> {
+        let crease_cos = crease_degrees.to_radians().cos();
+        // The cross product's length is twice the area, so summing raw
+        // crosses weights each face by its area.
+        let cross: Vec<Vec3> = self
+            .triangles
+            .iter()
+            .map(|t| {
+                let [a, b, c] = t.map(|i| self.positions[i as usize]);
+                (b - a).cross(c - a)
+            })
+            .collect();
+        let unit: Vec<Vec3> = cross.iter().map(|n| n.normalize_or_zero()).collect();
+
+        // Faces at each point, as one flat list with offsets.
+        let mut start = vec![0u32; self.positions.len() + 1];
+        for t in &self.triangles {
+            for &v in t {
+                start[v as usize + 1] += 1;
+            }
+        }
+        for i in 1..start.len() {
+            start[i] += start[i - 1];
+        }
+        let mut fill = start.clone();
+        let mut faces = vec![0u32; self.triangles.len() * 3];
+        for (f, t) in self.triangles.iter().enumerate() {
+            for &v in t {
+                faces[fill[v as usize] as usize] = f as u32;
+                fill[v as usize] += 1;
+            }
+        }
+
+        let mut out = Vec::with_capacity(self.triangles.len() * 3);
+        for (f, t) in self.triangles.iter().enumerate() {
+            for &v in t {
+                let around = &faces[start[v as usize] as usize..start[v as usize + 1] as usize];
+                let sum: Vec3 = around
+                    .iter()
+                    .filter(|&&g| unit[g as usize].dot(unit[f]) >= crease_cos)
+                    .map(|&g| cross[g as usize])
+                    .sum();
+                out.push(sum.try_normalize().unwrap_or(unit[f]));
+            }
+        }
+        out
+    }
+
+    /// The triangle list the raster pass draws, each corner lit.
+    pub fn bake(&self) -> Vec<Vertex3D> {
+        let normals = self.corner_normals(CREASE_DEGREES);
+        let mut out = Vec::with_capacity(self.triangles.len() * 3);
+        for (f, t) in self.triangles.iter().enumerate() {
+            let albedo = self.colors.get(self.tri_color[f] as usize).copied().unwrap_or(CLAY);
+            for (k, &v) in t.iter().enumerate() {
+                let light = shade(normals[f * 3 + k]);
+                out.push(Vertex3D {
+                    position: self.positions[v as usize].to_array(),
+                    color: albedo.map(|c| (c * light).min(1.0)),
+                });
+            }
+        }
+        out
+    }
+}
+
+/// How much light reaches a surface facing `n`, as a multiplier on its
+/// albedo.
+pub fn shade(n: Vec3) -> f32 {
+    let ambient = GROUND + (SKY - GROUND) * (0.5 + 0.5 * n.y);
+    let key = KEY_STRENGTH * n.dot(KEY.normalize()).max(0.0);
+    let fill = FILL_STRENGTH * n.dot(FILL.normalize()).max(0.0);
+    ambient + key + fill
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// A unit cube as an STL would carry it: twelve triangles, every corner
+    /// its own point.
+    pub(crate) fn soup_cube() -> Mesh {
+        let c = |x: f32, y: f32, z: f32| Vec3::new(x, y, z);
+        let quads = [
+            [c(1., 0., 0.), c(1., 1., 0.), c(1., 1., 1.), c(1., 0., 1.)],
+            [c(0., 0., 1.), c(0., 1., 1.), c(0., 1., 0.), c(0., 0., 0.)],
+            [c(0., 1., 0.), c(0., 1., 1.), c(1., 1., 1.), c(1., 1., 0.)],
+            [c(0., 0., 1.), c(0., 0., 0.), c(1., 0., 0.), c(1., 0., 1.)],
+            [c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)],
+            [c(1., 0., 0.), c(0., 0., 0.), c(0., 1., 0.), c(1., 1., 0.)],
+        ];
+        let mut m = Mesh::default();
+        for q in quads {
+            for i in [0, 1, 2, 0, 2, 3] {
+                m.positions.push(q[i]);
+            }
+        }
+        m.triangles = (0..12).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect();
+        m.tri_color = vec![0; 12];
+        m.colors = vec![CLAY];
+        m
+    }
+
+    #[test]
+    fn welding_a_cube_leaves_its_eight_corners() {
+        let mut m = soup_cube();
+        assert_eq!(m.positions.len(), 36);
+        m.weld();
+        assert_eq!(m.positions.len(), 8);
+        assert_eq!(m.triangles.len(), 12);
+    }
+
+    #[test]
+    fn a_cube_keeps_its_edges_and_its_faces_point_out() {
+        let mut m = soup_cube();
+        m.weld();
+        let normals = m.corner_normals(CREASE_DEGREES);
+        let (lo, hi) = m.bounds().unwrap();
+        let center = (lo + hi) / 2.0;
+        for (f, t) in m.triangles.iter().enumerate() {
+            let [a, b, c] = t.map(|i| m.positions[i as usize]);
+            let face = (b - a).cross(c - a).normalize();
+            assert!(face.dot((a + b + c) / 3.0 - center) > 0.0, "triangle {f} faces inward");
+            for k in 0..3 {
+                // 90° edges are past the crease: every corner keeps its face's normal.
+                assert!(normals[f * 3 + k].dot(face) > 0.999, "triangle {f} corner {k} was smoothed");
+            }
+        }
+    }
+
+    #[test]
+    fn a_shallow_fold_is_smoothed() {
+        // Two triangles meeting at 20°: one shared normal along the fold.
+        let mut m = Mesh::default();
+        let lift = 20f32.to_radians().tan();
+        m.positions = vec![Vec3::ZERO, Vec3::Z, Vec3::new(-1.0, 0.0, 0.5), Vec3::new(1.0, lift, 0.5)];
+        m.triangles = vec![[0, 1, 2], [0, 3, 1]];
+        m.tri_color = vec![0, 0];
+        let n = m.corner_normals(CREASE_DEGREES);
+        assert!(n[0].dot(n[3]) > 0.9999, "the shared point has two normals");
+    }
+
+    #[test]
+    fn welding_drops_a_collapsed_triangle() {
+        let mut m = Mesh::default();
+        m.positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::X, Vec3::X * (1.0 + 1e-9), Vec3::Y];
+        m.triangles = vec![[0, 1, 2], [3, 4, 5]];
+        m.tri_color = vec![0, 0];
+        m.weld();
+        assert_eq!(m.triangles, vec![[0, 1, 2]]);
+    }
+
+    #[test]
+    fn a_fitted_mesh_stays_clear_of_the_background_sentinel() {
+        // The sphere that showed the bug: z from 0 to 25.
+        let mut m = soup_cube();
+        for p in &mut m.positions {
+            *p = *p * 25.0 + Vec3::new(-12.5, -12.5, 0.0);
+        }
+        let (centre, radius) = m.fit_to_unit();
+        assert_eq!(centre, Vec3::new(0.0, 0.0, 12.5));
+        assert!((radius - 25.0 * 3f32.sqrt() / 2.0).abs() < 1e-3, "a cube's corners are its farthest points");
+        assert!(m.positions.iter().all(|p| p.length() <= 1.0 + 1e-5));
+    }
+
+    #[test]
+    fn no_face_is_left_black() {
+        for n in [Vec3::X, -Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Z, -Vec3::Z] {
+            assert!(shade(n) >= GROUND, "{n} is darker than the ambient floor");
+        }
+        assert!(shade(KEY.normalize()) > 1.0 - 0.01, "the key-lit face should be near full");
+    }
+}
+
+#[cfg(test)]
+pub(crate) use tests::soup_cube;