3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git
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;