mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git
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));
}