git.lucas.co / cce-mesh-io
mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git

commita95346cc26651f3efbd88b03dc238c6f03feec7d
parent40032c7deb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 11:17
Scene says what unit a file's coordinates are in

Unit::Millimetre for STL (what slicers assume), Metre for glTF (its spec),
Unspecified for OBJ and PLY, with millimetres() to convert. A convention,
not a guarantee, which the doc says: a caller showing a size names it.

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

 CLAUDE.md   |  2 ++
 src/gltf.rs |  6 ++++--
 src/lib.rs  | 32 +++++++++++++++++++++++++++++---
 src/obj.rs  |  4 ++--
 src/ply.rs  |  4 ++--
 src/stl.rs  |  5 +++--
 6 files changed, 42 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7642584..34afed3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,8 @@ particular to this crate.
   (`Mesh::z_up_to_y_up`); an importer of the designer's own exports must
   not, because cce-designer writes its Y-up world into STL as is (its STL
   export is not Z-up — a slicer will lay those models on their side).
+- `unit` is what the format conventionally means (STL mm, glTF metres, OBJ
+  and PLY unspecified); a caller showing a size says which.
 - Colours are linear RGB. A palette colour per triangle (`tri_color` →
   `colors`: MTL `Kd`, glTF base-colour factor) and, when the file colours
   its points, `corner_colors` (three per triangle) which win. They ride on
diff --git a/src/gltf.rs b/src/gltf.rs
index 4b0982c..b31dbb7 100644
--- a/src/gltf.rs
+++ b/src/gltf.rs
@@ -23,7 +23,7 @@ use gltf::buffer::Source;
 use gltf::mesh::Mode;
 
 use crate::mesh::Mesh;
-use crate::{Part, Scene, UpAxis};
+use crate::{Part, Scene, Unit, UpAxis};
 
 /// Deeper than any real node tree; a guard against a cycle the parser let by.
 const MAX_DEPTH: usize = 128;
@@ -115,7 +115,7 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
     if textured {
         log::info!("[mesh-io] glTF: base-colour textures are not drawn yet; textured materials show their factor");
     }
-    Ok(Scene { parts, up: UpAxis::Y })
+    Ok(Scene { parts, up: UpAxis::Y, unit: Unit::Metre })
 }
 
 /// A primitive's index list as triangles, or `None` for points and lines.
@@ -212,6 +212,8 @@ mod tests {
     fn a_node_transform_places_the_mesh_and_the_material_colours_it() {
         let s = read(&inline(r#"[{"name":"moved","mesh":0,"translation":[10,0,0],"scale":[2,2,2]}]"#, 5), None).unwrap();
         assert_eq!(s.up, UpAxis::Y);
+        assert_eq!(s.unit, Unit::Metre);
+        assert_eq!(Unit::Metre.millimetres(), Some(1000.0));
         let part = &s.parts[0];
         assert_eq!(part.name, "moved");
         assert_eq!(part.mesh.positions[1], Vec3::new(12.0, 0.0, 0.0));
diff --git a/src/lib.rs b/src/lib.rs
index 668b9e4..a1eff65 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,8 +3,9 @@
 //!
 //! Each reader returns a [`Scene`] as the file has it: named parts, in the
 //! file's own coordinates and units, with the up axis its format conventionally
-//! uses. Nothing is rotated, scaled or welded by a reader; [`load`] welds, and
-//! what to do about the up axis is the caller's choice ([`Mesh::z_up_to_y_up`]).
+//! uses, and the length [`Unit`] its format conventionally means. Nothing is
+//! rotated, scaled or welded by a reader; [`load`] welds, and what to do about
+//! the up axis is the caller's choice ([`Mesh::z_up_to_y_up`]).
 //! A viewer turns a Z-up STL upright; an editor importing its own Y-up export
 //! must not.
 //!
@@ -33,6 +34,30 @@ pub enum UpAxis {
     Z,
 }
 
+/// What one unit of a file's coordinates is, by its format's convention.
+/// None of these formats can be trusted to say: it is what a file of that
+/// kind usually means, and a caller showing a size should say which it is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Unit {
+    /// STL: what every slicer assumes.
+    Millimetre,
+    /// glTF: metres, by its spec.
+    Metre,
+    /// OBJ and PLY say nothing.
+    Unspecified,
+}
+
+impl Unit {
+    /// Millimetres per file unit, or `None` when the format does not say.
+    pub fn millimetres(self) -> Option<f32> {
+        match self {
+            Unit::Millimetre => Some(1.0),
+            Unit::Metre => Some(1000.0),
+            Unit::Unspecified => None,
+        }
+    }
+}
+
 /// One named piece of a scene: a glTF node's mesh, a whole STL.
 #[derive(Debug, Clone, Default)]
 pub struct Part {
@@ -41,11 +66,12 @@ pub struct Part {
 }
 
 /// What a file holds: its parts, already placed where the file's node tree
-/// puts them, and its up axis.
+/// puts them, its up axis and its unit.
 #[derive(Debug, Clone)]
 pub struct Scene {
     pub parts: Vec<Part>,
     pub up: UpAxis,
+    pub unit: Unit,
 }
 
 impl Scene {
diff --git a/src/obj.rs b/src/obj.rs
index f614ef6..ac3585f 100644
--- a/src/obj.rs
+++ b/src/obj.rs
@@ -14,7 +14,7 @@ use std::path::Path;
 use glam::Vec3;
 
 use crate::mesh::{Mesh, CLAY};
-use crate::{Part, Scene, UpAxis};
+use crate::{Part, Scene, Unit, UpAxis};
 
 pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
     let text = String::from_utf8_lossy(bytes);
@@ -78,7 +78,7 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
             _ => {}
         }
     }
-    Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y })
+    Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y, unit: Unit::Unspecified })
 }
 
 /// Each `newmtl` in an MTL file and its `Kd`.
diff --git a/src/ply.rs b/src/ply.rs
index fb3039f..7a38733 100644
--- a/src/ply.rs
+++ b/src/ply.rs
@@ -16,7 +16,7 @@
 use glam::Vec3;
 
 use crate::mesh::{srgb_to_linear, Mesh, CLAY};
-use crate::{Part, Scene, UpAxis};
+use crate::{Part, Scene, Unit, UpAxis};
 
 #[derive(Debug, Clone, Copy, PartialEq)]
 enum Scalar {
@@ -213,7 +213,7 @@ pub fn read(bytes: &[u8]) -> Result<Scene, String> {
     let corner_colors = (!point_colors.is_empty())
         .then(|| faces.iter().flat_map(|f| f.map(|i| point_colors[i as usize])).collect());
     let mesh = Mesh { tri_color: vec![0; faces.len()], positions, triangles: faces, colors: vec![CLAY], corner_colors };
-    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Y })
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Y, unit: Unit::Unspecified })
 }
 
 fn skip_list(body: &mut Body, count: Scalar, item: Scalar) -> Result<(), String> {
diff --git a/src/stl.rs b/src/stl.rs
index 28d5246..0ef52dc 100644
--- a/src/stl.rs
+++ b/src/stl.rs
@@ -13,7 +13,7 @@
 use glam::Vec3;
 
 use crate::mesh::{Mesh, CLAY};
-use crate::{Part, Scene, UpAxis};
+use crate::{Part, Scene, Unit, UpAxis};
 
 pub fn read(bytes: &[u8]) -> Result<Scene, String> {
     let corners = if is_binary(bytes) { binary(bytes)? } else { ascii(bytes)? };
@@ -25,7 +25,7 @@ pub fn read(bytes: &[u8]) -> Result<Scene, String> {
         colors: vec![CLAY],
         corner_colors: None,
     };
-    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Z })
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Z, unit: Unit::Millimetre })
 }
 
 fn is_binary(bytes: &[u8]) -> bool {
@@ -116,6 +116,7 @@ mod tests {
     fn points_are_as_written_and_the_scene_says_z_is_up() {
         let s = read(&binary_of(&[[[0., 0., 2.], [1., 0., 0.], [0., 3., 0.]]])).unwrap();
         assert_eq!(s.up, UpAxis::Z);
+        assert_eq!(s.unit, Unit::Millimetre);
         assert_eq!(s.parts[0].mesh.positions[0], Vec3::new(0.0, 0.0, 2.0));
     }