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