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