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

commit36b77424cbf9465bc9fb28d115a5f18abad07759
parent9dabd108ce
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:34
Read through cce-mesh-io: glTF and PLY join STL and OBJ (milestone 2)

The readers moved to the shared cce-mesh-io crate, which also reads
glTF/GLB (node tree, materials' base colour, COLOR_0) and PLY (ASCII and
binary, vertex colours). What stays here is the light bake (light.rs,
formerly mesh.rs) and the app.

The reader reports a file's up axis instead of turning it, so the turn
upright for a Z-up STL happens here. The HUD counts parts when a file has
more than one; a reader that panics on a malformed file becomes an error
in the window instead of leaving it on "Loading..." for ever. The desktop
entry claims glTF and the STL alias types too.

Checked in a scale-2 shadow: a glTF hierarchy (.gltf with a percent-
encoded external buffer, and .glb), a vertex-coloured binary PLY, and a
5,001,600-triangle STL: 1.8 s read and lit on the worker while the window
shows "Loading...", then a 54 ms upload on the UI thread; 910 MB peak.

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

 CLAUDE.md         |  45 +++++----
 Cargo.toml        |   1 +
 cce-model.desktop |   4 +-
 src/light.rs      |  99 ++++++++++++++++++
 src/load/mod.rs   |  33 ------
 src/load/obj.rs   | 128 -----------------------
 src/load/stl.rs   | 136 -------------------------
 src/main.rs       |  69 ++++++++-----
 src/mesh.rs       | 297 ------------------------------------------------------
 9 files changed, 168 insertions(+), 644 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ac8596c..ea0ab9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,7 +5,8 @@ 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).
-Milestone 1 (STL and OBJ on the existing raster stage) is what is here.
+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.
 
 ## Shape
 
@@ -13,33 +14,33 @@ Milestone 1 (STL and OBJ on the existing raster stage) is what is here.
   `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.
+  generation drops a load the user has moved past, and a reader's panic is
+  caught there and shown as an error. 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 (360 MB for a
+  5M-triangle model; slimming it is open).
+- Reading is `cce_mesh_io::load` (see that crate's CLAUDE.md): a `Scene` of
+  parts in the file's own coordinates plus its up axis. This app merges the
+  parts, turns a Z-up scene upright, fits it to the unit sphere and bakes.
+- `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.
 
 ## 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
+  the vertex colours as they are, so `light::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.
+- **Every model is moved into the unit sphere** (`Mesh::fit_to_unit`)
+  before it is baked, so the camera frames every file alike. (Until
+  cce-ui 38bcad1 this was also a workaround: any vertex near z = 9.99 was
+  drawn as the background quad. The background is a `screen_space` draw now.)
+- **A Z-up scene (STL) is turned upright here, not by the reader.** So a
+  cce-designer STL export, which is its Y-up world written as is, shows on
+  its side — as it would in a slicer.
 - **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.
@@ -63,6 +64,10 @@ 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. Real test models: the slicers' resource STLs under
+`0` key. 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
+thread's only share of a load). 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
index 51b3590..5bf12f2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2021"
 
 [dependencies]
 cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "38bcad1d1ae95dc2ab7f4c9164ee2f778ae8506b" }
+cce-mesh-io = { git = "https://github.com/lsgalante/cce-mesh-io.git", rev = "40032c7deb28dee609d963c8c694ce15cd84922c" }
 glam = "0.29"
 log = "0.4"
 env_logger = "0.11"
diff --git a/cce-model.desktop b/cce-model.desktop
index d9bd04f..4f363a7 100644
--- a/cce-model.desktop
+++ b/cce-model.desktop
@@ -2,10 +2,10 @@
 Type=Application
 Name=Model
 GenericName=3D Model Viewer
-Comment=View STL and OBJ models
+Comment=View STL, OBJ, glTF and PLY models
 Exec=cce-model %f
 Icon=cce-model
 Terminal=false
 Categories=Graphics;3DGraphics;Viewer;
-MimeType=model/stl;application/sla;model/obj;
+MimeType=model/stl;model/x.stl-ascii;model/x.stl-binary;application/sla;model/obj;model/gltf+json;model/gltf-binary;
 NoDisplay=false
diff --git a/src/light.rs b/src/light.rs
new file mode 100644
index 0000000..624e756
--- /dev/null
+++ b/src/light.rs
@@ -0,0 +1,99 @@
+//! The light, baked into the vertices the raster pass draws.
+//!
+//! 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.
+
+use cce_mesh_io::Mesh;
+use cce_ui::engine::Vertex3D;
+use glam::Vec3;
+
+/// 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;
+
+/// The triangle list the raster pass draws, each corner lit.
+pub fn bake(mesh: &Mesh) -> Vec<Vertex3D> {
+    let normals = mesh.corner_normals(CREASE_DEGREES);
+    let mut out = Vec::with_capacity(mesh.triangles.len() * 3);
+    for (f, t) in mesh.triangles.iter().enumerate() {
+        for (k, &v) in t.iter().enumerate() {
+            let light = shade(normals[f * 3 + k]);
+            out.push(Vertex3D {
+                position: mesh.positions[v as usize].to_array(),
+                color: mesh.corner_color(f, k).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 twelve triangles over eight shared points.
+    fn cube(corner_colors: Option<Vec<[f32; 3]>>) -> 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,
+        }
+    }
+
+    #[test]
+    fn a_cube_bakes_to_six_flat_shades() {
+        let verts = bake(&cube(None));
+        assert_eq!(verts.len(), 36);
+        // Its edges are past the crease, so each face is one shade from its
+        // own normal, and no two faces are lit alike.
+        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:?}");
+    }
+
+    #[test]
+    fn corner_colours_are_lit_as_they_are() {
+        let verts = bake(&cube(Some(vec![[0.0, 0.5, 0.0]; 36])));
+        assert!(verts.iter().all(|v| v.color[0] == 0.0 && v.color[1] > 0.0 && v.color[2] == 0.0));
+    }
+
+    #[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");
+    }
+}
diff --git a/src/load/mod.rs b/src/load/mod.rs
deleted file mode 100644
index 715a78e..0000000
--- a/src/load/mod.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-//! 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
deleted file mode 100644
index 9f79c4a..0000000
--- a/src/load/obj.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-//! 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
deleted file mode 100644
index 6c7939e..0000000
--- a/src/load/stl.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-//! 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
index 8dd3557..1d3ec57 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,9 @@
 //! 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
+//! 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.
 //!
 //! Mouse: drag orbits, shift+drag or middle-drag pans, ctrl+wheel and pinch
@@ -11,8 +11,7 @@
 //! Keys: o open · 0 frame all · q quit.
 
 mod camera;
-mod load;
-mod mesh;
+mod light;
 
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
@@ -27,6 +26,7 @@ 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;
@@ -50,6 +50,8 @@ enum Message {
 struct Model {
     name: String,
     triangles: usize,
+    /// How many named pieces the file holds (glTF nodes; 1 for the rest).
+    parts: usize,
     /// Kept after upload: a replacement renderer (a reconnect) starts with
     /// no meshes, and this is what goes back up.
     verts: Vec<Vertex3D>,
@@ -57,14 +59,21 @@ struct Model {
 
 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.
+        let scene = cce_mesh_io::load(path)?;
+        let mut mesh = scene.merged();
+        // STL is Z-up by convention; the view is Y-up. Turned here, not by
+        // the reader, which reports the file as it is.
+        if scene.up == UpAxis::Z {
+            mesh.z_up_to_y_up();
+        }
+        // Every model is drawn inside the unit sphere, 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(),
+            parts: scene.parts.len(),
+            verts: light::bake(&mesh),
         })
     }
 }
@@ -121,14 +130,26 @@ impl ModelApp {
         self.error = None;
         let sender = self.sender.clone();
         std::thread::spawn(move || {
-            let result = Model::read(&path).map(Arc::new);
+            // A reader that panics on a malformed file must not leave the
+            // window saying "Loading…" for ever: the panic becomes the error.
+            let started = std::time::Instant::now();
+            let result = std::panic::catch_unwind(|| Model::read(&path))
+                .unwrap_or_else(|_| Err("the reader crashed on this file".to_string()))
+                .map(Arc::new);
+            log::info!("[model] read and lit in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
             // 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"])];
+        let filters: &[(&str, &[&str])] = &[
+            ("3D models", cce_mesh_io::EXTENSIONS),
+            ("STL", &["stl"]),
+            ("OBJ", &["obj"]),
+            ("glTF", &["gltf", "glb"]),
+            ("PLY", &["ply"]),
+        ];
         if let Some(path) = cce_ui::file_dialog::pick_file("Open model", filters) {
             self.open(path);
         }
@@ -163,7 +184,8 @@ impl ModelApp {
             return Some(format!("Loading {name}…"));
         }
         let m = self.model.as_ref()?;
-        Some(format!("{}   ·   {} triangles", m.name, group_thousands(m.triangles)))
+        let parts = if m.parts > 1 { format!("   ·   {} parts", m.parts) } else { String::new() };
+        Some(format!("{}   ·   {} triangles{parts}", m.name, group_thousands(m.triangles)))
     }
 }
 
@@ -291,7 +313,7 @@ impl Application for ModelApp {
             bg(1.0, 1.0, SKY_TOP),
             bg(-1.0, 1.0, SKY_TOP),
         ]);
-        stage.set_scene_light(mesh::KEY.normalize().to_array());
+        stage.set_scene_light(light::KEY.normalize().to_array());
         self.gpu = Some(Gpu { background, model: None });
         self.upload_pending = self.model.is_some();
         self.scene_dirty = true;
@@ -301,10 +323,14 @@ impl Application for ModelApp {
         let Some(gpu) = self.gpu.as_mut() else { return false };
         if self.upload_pending {
             if let Some(m) = &self.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)),
                 }
+                log::info!("[model] uploaded {} vertices in {:.0} ms", m.verts.len(), started.elapsed().as_secs_f64() * 1e3);
             }
             self.upload_pending = false;
             self.scene_dirty = true;
@@ -437,7 +463,7 @@ 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 or OBJ)");
+            centre(&mut pc, "Press o to open a model (STL, OBJ, glTF or PLY)");
         }
 
         if let Some(hud) = self.hud_line() {
@@ -468,17 +494,4 @@ mod tests {
         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
deleted file mode 100644
index e7f3b5f..0000000
--- a/src/mesh.rs
+++ /dev/null
@@ -1,297 +0,0 @@
-//! 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.
-    ///
-    /// The camera frames the unit sphere, so every model opens the same way
-    /// whatever its file's units. (It was also a workaround until cce-ui
-    /// 38bcad1, when any vertex near z = 9.99 was drawn as a corner of the
-    /// screen-space background; that is a per-draw flag now.)
-    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;