craft-mesh: 3MF reader (threemf): core and the materials extension (#920)

Reads a 3MF package into the crate's Scene: mesh objects, component
objects and build items with their transforms (as a node hierarchy; an
object placed several times is one mesh), the unit, base materials,
colour groups, texture coordinate groups and their textures.

The package is opened with craft-opc (3MF is an OPC package) and the model
part is read with craft-xml's pull reader, so a large part never becomes a
tree. read_model_part reads a model part on its own.

Ported from storytold/deckcraft#743 (crates/mesh/src/threemf.rs). What
that reader did for DeckCraft and stays there: baking placements into
world-space triangles, the turn to Y-up, scaling to metres, and decoding
the textures. Here placements stay nodes, positions stay in the file's
unit and axes (Model::unit says which), and a texture is its bytes.

Co-authored-by: uvxszi <uvxszi@gmail.com>
This commit is contained in:
Dylan M. Tayloranduvxszi authored and GitHub committed 2026-11-11 18:40:11 -05:00
1 parent f2d658d08f
commit 9e9c044fab
8 files changed
+789 -7

No files matched your search

Generated
+2
View File
@@ -1170,6 +1170,8 @@ dependencies = [
name = "craft-mesh"
version = "0.1.0"
dependencies = [
"craft-opc",
"craft-xml",
"miniz_oxide 0.9.1",
"serde_json",
]
+1
View File
@@ -176,6 +176,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
stay in each app.
- [`craft-mesh`](crates/mesh/README.md): 3D mesh file formats behind one neutral model (meshes
of polygons with per-corner indices, materials, a node hierarchy): STL, OBJ with MTL, PLY,
glTF 2.0 / GLB and 3MF, one feature each. Readers are bounded by `Limits`, never open a file themselves
glTF 2.0 / GLB and FBX, one feature each. Readers are bounded by `Limits`, never open a file themselves
and never panic. Carved from EffectCraft, SolidCraft, TextureCraft and MaterialCraft.
- [`craft-midi`](crates/midi/README.md): Standard MIDI Files: a bounded reader for formats 0, 1 and
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-mesh"
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers and an FBX reader, bounded on hostile input"
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers and FBX and 3MF readers, bounded on hostile input"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -11,14 +11,18 @@ repository.workspace = true
workspace = true
[features]
default = ["stl", "obj", "ply", "gltf", "fbx"]
default = ["stl", "obj", "ply", "gltf", "fbx", "threemf"]
stl = []
obj = []
ply = []
gltf = ["dep:serde_json"]
fbx = ["dep:miniz_oxide"]
threemf = ["dep:craft-opc", "dep:craft-xml"]
[dependencies]
serde_json = { workspace = true, optional = true }
# zlib-compressed arrays in binary FBX.
miniz_oxide = { version = "0.9", optional = true }
# 3MF is an OPC package of XML parts.
craft-opc = { workspace = true, optional = true }
craft-xml = { workspace = true, optional = true }
+24 -2
View File
@@ -9,9 +9,10 @@ a `Scene` into bytes. One Cargo feature per format, all on by default.
| `obj` | Wavefront OBJ with MTL | yes | OBJ and MTL | none |
| `ply` | PLY, ASCII and both binary byte orders | yes | no (no app writes PLY) | none |
| `gltf` | glTF 2.0 (`.gltf`) and GLB | yes | GLB | `serde_json` |
| `threemf` | 3MF, core and materials extension | yes | no | `craft-opc`, `craft-xml` |
| `fbx` | FBX 6.x and 7.x, binary and ASCII | yes | no | `miniz_oxide` |
USD, Alembic, COLLADA and 3MF are not here yet.
USD, Alembic and COLLADA are not here yet.
## The neutral model
@@ -84,6 +85,17 @@ cameras; `KHR_lights_punctual`. Primitives of a mesh that name the same accessor
vertices. `write_glb` writes nodes, meshes (positions, normals, UV sets, colours), materials and
images.
**3MF.** The package is opened with `craft-opc` and the model part found through its `3dmodel`
relationship; `threemf::read_model_part` reads a model part's XML on its own. Mesh objects,
component objects and build items with their transforms (every build item a root node, every
component a child node, an object placed several times one mesh), the unit (`Model::unit`; the
scene is not scaled), base materials, colour groups (a position per vertex and colour, since
colours are per position here), texture coordinate groups (a UV pool indexed per corner) and
their textures (the package part's bytes as `Image::data`, tile styles as wrap modes). Not read:
components in another model part (the production extension), composite materials,
multi-properties, slices and beam lattices; the first three are named in a warning.
Not read: sparse accessors and required geometry extensions (Draco, meshopt) are
**FBX.** Binary files (32-bit record offsets before 7.5, 64-bit from it; every property type;
arrays plain or zlib-compressed) and ASCII files give the same record tree, which
`fbx::read_document` returns as it is. From it: `Geometry` meshes with whole polygons and the
@@ -112,9 +124,19 @@ mutations of every byte of good files.
| Limit | Default | Applies to |
|---|---|---|
| `max_vertices` | 20 000 000 | entries per attribute pool (OBJ: each of `v`, `vt`, `vn`, counting unused ones; glTF: vertices over all meshes) |
| `max_vertices` | 20 000 000 | entries per attribute pool (OBJ: each of `v`, `vt`, `vn`, counting unused ones; glTF: vertices over all meshes; 3MF: vertices, colours and texture coordinates of the file together) |
| `max_triangles` | 20 000 000 | triangles after fanning polygons, strips and fans |
| `max_face_corners` | 4096 | corners of one face; PLY list lengths |
| `max_meshes` | 100 000 | OBJ objects, glTF meshes, 3MF objects |
| `max_parts` | 100 000 | groups, material runs, glTF primitives |
| `max_materials` | 100 000 | materials; images; OBJ material libraries; 3MF base materials and textures together |
| `max_nodes` | 100 000 | nodes, and node visits when the hierarchy is walked (ends cycles); 3MF placed objects, build items and components |
| `max_depth` | 64 | node nesting; 3MF component nesting (ends cycles) |
| `max_data_bytes` | 1 GiB | buffers and material libraries taken from the resolver; embedded image bytes; skin and animation values; the binary chunk `write_glb` builds; what a 3MF package inflates to, and its textures |
| `max_extras` | 10 000 | skins, animations, cameras, lights, buffers (each); channels of one animation |
Fixed bounds: a PLY header of 1 MiB, 64 elements and 256 properties per element; 2^24 components
for glTF accessors without a buffer view, over the whole file; 10 000 parts in a 3MF package.
| `max_meshes` | 100 000 | OBJ objects, glTF meshes, FBX meshes |
| `max_parts` | 100 000 | groups, material runs, glTF primitives, material slots of an FBX mesh |
| `max_materials` | 100 000 | materials; images; OBJ material libraries |
+10 -3
View File
@@ -1,7 +1,8 @@
//! 3D mesh file formats behind one neutral model.
//!
//! Each format is a module behind a Cargo feature of the same name: [`stl`], [`obj`] (with MTL),
//! [`ply`], [`gltf`] (glTF 2.0 and GLB) and [`fbx`]. A reader turns bytes into a [`Scene`]; a writer turns
//! [`ply`], [`gltf`] (glTF 2.0 and GLB), [`fbx`] and [`threemf`] (3MF). A reader turns bytes into a [`Scene`]; a
//! writer turns
//! a [`Scene`] into bytes. Mapping a [`Scene`] onto an application's own mesh, body or document
//! types is the caller's business, as are computing missing normals, tangents and UVs, welding,
//! unit scaling and turning a file's up axis.
@@ -24,11 +25,15 @@
//! (an OBJ material library, a glTF buffer or image) is listed in [`Scene::external`] and asked
//! of the caller's [`Resolver`], which may decline.
//!
//! Depends on no other Craft crate.
//! Depends on no other Craft crate, except that `threemf` opens its package with `craft-opc`
//! and reads its XML with `craft-xml`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// The helpers the formats share are not all used by every subset of them.
#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf", feature = "fbx")), allow(dead_code, unused_imports))]
#![cfg_attr(
not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf", feature = "fbx", feature = "threemf")),
allow(dead_code, unused_imports)
)]
#[cfg(feature = "fbx")]
pub mod fbx;
@@ -40,6 +45,8 @@ pub mod obj;
pub mod ply;
#[cfg(feature = "stl")]
pub mod stl;
#[cfg(feature = "threemf")]
pub mod threemf;
use std::collections::HashMap;
use std::fmt;
+2
View File
@@ -12,6 +12,8 @@ mod obj;
mod ply;
#[cfg(feature = "stl")]
mod stl;
#[cfg(feature = "threemf")]
mod threemf;
/// Every truncation of `good` and two mutations of every byte go through `read`: it may refuse
/// them, it must not panic, and what it accepts must be a valid scene.
+226
View File
@@ -0,0 +1,226 @@
//! 3MF: a package with every property kind, placements, limits and hostile files.
use super::hammer;
use crate::threemf::{Model, Unit, is_zip, read, read_model, read_model_part};
use crate::{AlphaMode, Corner, Error, IDENTITY, Limits, NONE, Scene, Wrap, no_external, transform_point};
const RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>"#;
const TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>"#;
/// Object 1: a square of two triangles, one in a base material and one textured. Object 2: a
/// triangle with a colour per corner and one without a property. Object 3 places 1 and 2.
/// The build places 3 (moved) and 1 again.
const MODEL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<model unit="inch" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:m="http://schemas.microsoft.com/3dmanufacturing/material/2015/02">
<resources>
<basematerials id="10">
<base name="Red" displaycolor="#FF0000"/>
<base name="Glass" displaycolor="#0000FF40"/>
</basematerials>
<m:colorgroup id="20">
<m:color color="#FF0000"/>
<m:color color="#00FF0080"/>
</m:colorgroup>
<m:texture2d id="30" path="/3D/Textures/wood.png" contenttype="image/png" tilestyleu="mirror" tilestylev="clamp"/>
<m:texture2dgroup id="40" texid="30">
<m:tex2coord u="0" v="0"/>
<m:tex2coord u="1" v="0.25"/>
<m:tex2coord u="1" v="1"/>
</m:texture2dgroup>
<object id="1" name="Square" type="model" pid="10" pindex="1">
<mesh>
<vertices>
<vertex x="0" y="0" z="0"/>
<vertex x="1" y="0" z="0"/>
<vertex x="1" y="1" z="0"/>
<vertex x="0" y="1" z="0"/>
</vertices>
<triangles>
<triangle v1="0" v2="1" v3="2"/>
<triangle v1="0" v2="2" v3="3" pid="40" p1="0" p2="2" p3="1"/>
<triangle v1="0" v2="2" v3="9"/>
</triangles>
</mesh>
</object>
<object id="2" name="Tri">
<mesh>
<vertices>
<vertex x="0" y="0" z="1"/>
<vertex x="1" y="0" z="1"/>
<vertex x="0" y="1" z="1"/>
</vertices>
<triangles>
<triangle v1="0" v2="1" v3="2" pid="20" p1="0" p2="1" p3="0"/>
<triangle v1="2" v2="1" v3="0" pid="20" p1="1"/>
<triangle v1="0" v2="1" v3="2"/>
</triangles>
</mesh>
</object>
<object id="3" name="Pair">
<components>
<component objectid="1"/>
<component objectid="2" transform="2 0 0 0 2 0 0 0 2 0 0 5"/>
<component objectid="77"/>
</components>
</object>
</resources>
<build>
<item objectid="3" transform="1 0 0 0 1 0 0 0 1 10 20 30"/>
<item objectid="1"/>
</build>
</model>"##;
fn package(model: &str) -> Vec<u8> {
let part = |name: &str, data: &[u8]| (name.to_string(), data.to_vec());
craft_opc::zip_entries(&[
part("[Content_Types].xml", TYPES.as_bytes()),
part("_rels/.rels", RELS.as_bytes()),
part("3D/3dmodel.model", model.as_bytes()),
part("3D/Textures/wood.png", b"wood image"),
])
.unwrap()
}
fn parse(bytes: &[u8]) -> crate::Result<Scene> {
read(bytes, &Limits::default())
}
fn part(model: &str, limits: &Limits) -> crate::Result<Model> {
read_model_part(model.as_bytes(), limits, &mut no_external)
}
#[test]
fn a_package_with_every_property_kind() {
let bytes = package(MODEL);
assert!(is_zip(&bytes));
let Model { scene: s, unit } = read_model(&bytes, &Limits::default()).unwrap();
s.validate().unwrap();
assert_eq!((unit, unit.metres()), (Unit::Inch, 0.0254));
// Placements: the build's items are roots, components their children; one mesh per object.
let names: Vec<&str> = s.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["Pair", "Square", "Tri", "Square"]);
assert_eq!((s.roots.as_slice(), s.nodes[0].children.as_slice()), (&[0, 3][..], &[1, 2][..]));
assert_eq!(s.nodes.iter().map(|n| n.mesh).collect::<Vec<_>>(), [None, Some(0), Some(1), Some(0)]);
assert_eq!(s.meshes.len(), 2);
let placed = s.instances(&Limits::default()).unwrap();
assert_eq!(placed.iter().map(|p| p.0).collect::<Vec<_>>(), [0, 1, 0]);
// The item's move, then the component's scale and lift (a row vector times the matrix).
assert_eq!(transform_point(&placed[1].1, [1.0, 0.0, 1.0]), [12.0, 20.0, 37.0]);
assert_eq!(placed[2].1, IDENTITY);
// The square: the object's default base material, then a textured triangle. The triangle
// that names vertex 9 is left out. Positions are the file's vertices.
let square = &s.meshes[0];
assert_eq!((square.name.as_str(), square.positions.len(), square.colors.len(), square.parts.len()), ("Square", 4, 0, 2));
let glass = &s.materials[square.parts[0].material.unwrap()];
assert_eq!((glass.name.as_str(), glass.base_color, glass.alpha_mode), ("Glass", [0.0, 0.0, 1.0, 64.0 / 255.0], AlphaMode::Blend));
assert_eq!(square.parts[0].corners, [0, 1, 2].map(Corner::at));
// Coordinates through p1 p2 p3, v flipped; the pool holds only what is used, once.
assert_eq!(square.parts[1].corners, [(0, 0), (2, 1), (3, 2)].map(|(position, uv)| Corner { position, normal: NONE, uv }));
assert_eq!(square.uv_sets, [vec![[0.0, 1.0], [1.0, 0.0], [1.0, 0.75]]]);
let wood = &s.materials[square.parts[1].material.unwrap()];
let texture = wood.base_color_texture.unwrap();
assert_eq!((texture.wrap, wood.base_color), ([Wrap::Mirror, Wrap::Clamp], [1.0; 4]));
let image = &s.images[texture.image];
assert_eq!(
(image.uri.as_deref(), image.mime_type.as_deref(), image.data.as_deref()),
(Some("/3D/Textures/wood.png"), Some("image/png"), Some(&b"wood image"[..]))
);
// The triangle: a colour per corner makes a position per vertex and colour; a triangle
// without a property is white.
let tri = &s.meshes[1];
let (red, green, white) = ([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 128.0 / 255.0], [1.0; 4]);
assert_eq!(tri.colors, [red, green, red, green, green, white, white, white]);
assert_eq!((tri.positions[0], tri.positions[2]), (tri.positions[4], tri.positions[3]));
assert_eq!(tri.parts.len(), 2);
assert_eq!(tri.parts[0].corners, [0, 1, 2, 3, 1, 4].map(Corner::at));
assert_eq!((tri.parts[1].material, tri.parts[1].corners.len()), (None, 3));
assert_eq!(s.materials[tri.parts[0].material.unwrap()].base_color, [1.0; 4]);
assert_eq!(s.warnings.len(), 2, "{:?}", s.warnings);
assert!(s.warnings[0].starts_with("1 triangle") && s.warnings[1].starts_with("1 build item"));
}
#[test]
fn the_model_part_alone_and_what_is_not_read() {
// No package: textures are asked of the caller, who may decline.
let m = part(MODEL, &Limits::default()).unwrap();
assert_eq!((m.scene.images[0].data.as_ref(), m.scene.external.as_slice()), (None, &["/3D/Textures/wood.png".to_string()][..]));
// A model without build items places every object once; the default unit is the millimetre.
let bare = "<model><resources><object id='5'><mesh><vertices><vertex x='0' y='0' z='0'/><vertex x='1' y='0' z='0'/><vertex x='0' y='1' z='0'/></vertices>\
<triangles><triangle v1='0' v2='1' v3='2'/></triangles></mesh></object><object id='6'><components><component objectid='5' path='/3D/other.model'/>\
<component objectid='5' transform='1 2 3'/></components></object><m:compositematerials id='9' matid='1' matindices='0 1'/></resources></model>";
let m = part(bare, &Limits::default()).unwrap();
m.scene.validate().unwrap();
assert_eq!((m.unit, m.scene.roots.len(), m.scene.nodes.len()), (Unit::Millimeter, 2, 3));
// A transform that is not twelve numbers is the identity.
assert_eq!(m.scene.nodes[2].transform, IDENTITY);
assert_eq!(m.scene.warnings.len(), 2, "{:?}", m.scene.warnings);
// A package without the relationship still finds its model part; one without a model part,
// and XML that is not a model, are refused.
let entry = |name: &str, data: &str| (name.to_string(), data.as_bytes().to_vec());
let loose = craft_opc::zip_entries(&[entry("Model/thing.model", bare)]).unwrap();
assert_eq!(parse(&loose).unwrap().meshes.len(), 1);
assert!(matches!(parse(&craft_opc::zip_entries(&[entry("readme.txt", "hello")]).unwrap()), Err(Error::Malformed(_))));
assert!(matches!(part("<svg/>", &Limits::default()), Err(Error::Malformed(_))));
assert!(matches!(part("", &Limits::default()), Err(Error::Malformed(_))));
assert!(matches!(parse(b"not a zip"), Err(Error::Malformed(_))));
}
#[test]
fn every_limit_is_kept() {
let d = Limits::default;
let refused = |limits: Limits| matches!(read(&package(MODEL), &limits), Err(Error::TooLarge(_)));
// 7 vertices, 2 colours and 3 coordinates; 6 triangles; 3 objects; 4 parts; 2 base
// materials and a texture; 4 placed objects (5 components and items); components 1 deep.
for (at, under) in [
(Limits { max_vertices: 12, ..d() }, Limits { max_vertices: 11, ..d() }),
(Limits { max_triangles: 6, ..d() }, Limits { max_triangles: 5, ..d() }),
(Limits { max_meshes: 3, ..d() }, Limits { max_meshes: 2, ..d() }),
(Limits { max_parts: 4, ..d() }, Limits { max_parts: 3, ..d() }),
(Limits { max_materials: 3, ..d() }, Limits { max_materials: 2, ..d() }),
(Limits { max_nodes: 4, ..d() }, Limits { max_nodes: 3, ..d() }),
(Limits { max_depth: 1, ..d() }, Limits { max_depth: 0, ..d() }),
] {
assert!(!refused(at.clone()), "{at:?}");
assert!(refused(under.clone()), "{under:?}");
}
// The package may not inflate past the data limit, and a texture counts against it.
assert!(refused(Limits { max_data_bytes: MODEL.len() - 1, ..d() }));
let texture = |max_data_bytes: usize| read_model_part(MODEL.as_bytes(), &Limits { max_data_bytes, ..d() }, &mut |_: &str| Some(vec![0; 100]));
assert!(texture(100).is_ok());
assert!(matches!(texture(99), Err(Error::TooLarge(_))));
// An object that is its own component ends at the depth limit instead of going round.
let cycle = "<model><resources><object id='1'><components><component objectid='1'/></components></object></resources><build><item objectid='1'/></build></model>";
assert!(matches!(part(cycle, &d()), Err(Error::TooLarge(_))));
// Two objects placing each other twice double at every level: the node limit ends it.
let fan = "<model><resources><object id='1'><components><component objectid='2'/><component objectid='2'/></components></object>\
<object id='2'><components><component objectid='1'/><component objectid='1'/></components></object></resources><build><item objectid='1'/></build></model>";
assert!(matches!(part(fan, &Limits { max_nodes: 1000, max_depth: 64, ..d() }), Err(Error::TooLarge(_))));
}
#[test]
fn hostile_files_fail_cleanly() {
hammer(MODEL.as_bytes(), &|b| read_model_part(b, &Limits::default(), &mut no_external).map(|m| m.scene));
// The package: every 7th truncation and mutation (inflating each is slow).
let good = package(MODEL);
for i in (0..good.len()).step_by(7) {
let mut b = good.clone();
b[i] ^= 0xA5;
for bytes in [&good[..i], &b[..]] {
if let Ok(s) = parse(bytes) {
s.validate().unwrap();
}
}
}
}
+518
View File
@@ -0,0 +1,518 @@
//! 3MF, read: the 3MF Consortium's core specification and its materials and properties
//! extension.
//!
//! A 3MF file is an OPC package (a ZIP archive with relationships): [`read`] and [`read_model`]
//! open it with `craft-opc`, find the model part through the package's `3dmodel` relationship
//! (or, without one, the first `.model` part) and read it. [`read_model_part`] reads a model
//! part's XML on its own, for a caller that has the parts some other way.
//!
//! Read: mesh objects (vertices and triangles), component objects and build items with their
//! transforms, the model's unit, base materials (`displaycolor`), colour groups and texture
//! coordinate groups with their textures.
//!
//! - Every build item is a root [`Node`] and every component a child node, each with its
//! transform; an object placed several times is one [`Mesh`] named by several nodes. A file
//! without build items places every object once.
//! - A triangle's property group decides its [`Part`]: one per base material, one for vertex
//! colours, one per texture, one for triangles without a property. Base materials become
//! materials of that colour (linear); the colour part has a white material; a texture part's
//! material has the texture as its base colour, with the tile styles as wrap modes.
//! - Colour groups are per corner in the file and per position in the model, so a vertex that
//! takes different colours in different triangles becomes one position per colour. Without
//! colours the positions are the file's vertices, in order.
//! - Texture coordinates are a pool indexed per corner; v is flipped to point down.
//! - A texture's image is the package part its `path` names: its bytes are [`Image::data`],
//! its path [`Image::uri`].
//!
//! Positions stay in the file's unit ([`Model::unit`]) and axes (3MF is Z-up).
//!
//! Not read, and named in a warning when present: components in another model part (the
//! production extension), composite materials and multi-properties. Slices, beam lattices and
//! other extensions are passed over. A triangle that names a vertex that is not there is left
//! out and counted in a warning.
use std::collections::HashMap;
use craft_xml::{Event, Options, to_utf8};
use crate::{
AlphaMode, Corner, Error, IDENTITY, Image, Limits, Mat4, Material, Mesh, Node, Part, Resolver, Result, Scene, TextureRef, Wrap, count_warning,
malformed, srgb_to_linear, too_large,
};
const KIND: &str = "3MF";
/// Most parts a package may have.
pub const MAX_ENTRIES: usize = 10_000;
/// The unit of a model's coordinates.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Unit {
Micron,
#[default]
Millimeter,
Centimeter,
Inch,
Foot,
Meter,
}
impl Unit {
pub fn metres(self) -> f64 {
match self {
Unit::Micron => 1e-6,
Unit::Millimeter => 0.001,
Unit::Centimeter => 0.01,
Unit::Inch => 0.0254,
Unit::Foot => 0.3048,
Unit::Meter => 1.0,
}
}
}
/// What a 3MF file holds: the scene, and the unit its positions are in.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Model {
pub scene: Scene,
pub unit: Unit,
}
/// Is this a ZIP archive (which a 3MF file is)? Other formats are ZIP archives too.
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04")
}
/// Read a 3MF file into a scene. [`read_model`] also gives the unit.
pub fn read(bytes: &[u8], limits: &Limits) -> Result<Scene> {
Ok(read_model(bytes, limits)?.scene)
}
fn package_error(e: craft_opc::Error) -> Error {
if e.is_limit() { too_large(KIND, e) } else { malformed(KIND, e) }
}
/// Read a 3MF file. The package may inflate to [`Limits::max_data_bytes`] in all.
pub fn read_model(bytes: &[u8], limits: &Limits) -> Result<Model> {
let most = limits.max_data_bytes as u64;
let mut package = craft_opc::LazyPackage::open(bytes, &craft_opc::Limits::new(MAX_ENTRIES, most, most)).map_err(package_error)?;
let related = package.rels("").map_err(package_error)?.first("3dmodel").filter(|r| !r.external).map(|r| r.target.clone());
let root = related
.filter(|r| package.has(r))
.or_else(|| package.names().into_iter().find(|n| n.ends_with(".model")).map(str::to_string))
.ok_or_else(|| malformed(KIND, "the package has no model part"))?;
let xml = package.read(&root).map_err(package_error)?.ok_or_else(|| malformed(KIND, "the package has no model part"))?;
// A texture that cannot be read leaves its image without data.
read_model_part(&xml, limits, &mut |path: &str| package.read_optional_limited(path, most).ok().flatten())
}
/// A 3MF transform is twelve numbers, the rows of a 4×3 matrix that a row vector multiplies
/// from the left; anything else is the identity.
fn transform(s: Option<&str>) -> Mat4 {
let v: Vec<f64> = s.unwrap_or("").split_whitespace().take(13).filter_map(|t| t.parse::<f64>().ok()).filter(|f| f.is_finite()).collect();
let mut m = IDENTITY;
if let Ok(v) = <[f64; 12]>::try_from(v) {
for (i, x) in v.into_iter().enumerate() {
m[(i / 3) * 4 + i % 3] = x;
}
}
m
}
fn wrap(s: Option<&str>) -> Wrap {
match s {
Some("mirror") => Wrap::Mirror,
Some("clamp" | "none") => Wrap::Clamp,
_ => Wrap::Repeat,
}
}
/// `#RRGGBB` or `#RRGGBBAA` as display values in 0..1.
fn color(s: Option<&str>) -> Option<[f32; 4]> {
let hex = s?.trim().strip_prefix('#')?;
let byte = |i: usize| hex.get(i * 2..i * 2 + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|b| f32::from(b) / 255.0);
match hex.len() {
6 => Some([byte(0)?, byte(1)?, byte(2)?, 1.0]),
8 => Some([byte(0)?, byte(1)?, byte(2)?, byte(3)?]),
_ => None,
}
}
struct Triangle {
v: [u32; 3],
pid: Option<u32>,
p: [Option<u32>; 3],
}
#[derive(Default)]
struct Object {
name: String,
vertices: Vec<[f64; 3]>,
triangles: Vec<Triangle>,
pid: Option<u32>,
pindex: Option<u32>,
components: Vec<(u32, Mat4)>,
}
struct Texture {
path: String,
content_type: Option<String>,
wrap: [Wrap; 2],
}
/// The resource group being read.
#[derive(Clone, Copy)]
enum Group {
Base(u32),
Colors(u32),
Coords(u32),
}
#[derive(Default)]
struct Parsed {
unit: Unit,
base: HashMap<u32, Vec<(String, [f32; 4])>>,
colors: HashMap<u32, Vec<[f32; 4]>>,
textures: HashMap<u32, Texture>,
/// Coordinate group id → (texture id, coordinates).
coords: HashMap<u32, (u32, Vec<[f32; 2]>)>,
/// In file order.
objects: Vec<(u32, Object)>,
build: Vec<(u32, Mat4)>,
/// Elements that are not read, for the warnings.
foreign_components: usize,
mixed_materials: usize,
}
fn parse(xml: &[u8], limits: &Limits) -> Result<Parsed> {
let text = to_utf8(xml);
let mut events = Options::local().events(&text);
let mut m = Parsed::default();
let mut group: Option<Group> = None;
let mut object: Option<(u32, Object)> = None;
// Over the whole file, not each object or group alone.
let (mut vertices, mut triangles, mut properties, mut components) = (0usize, 0usize, 0usize, 0usize);
let room = |count: &mut usize, most: usize, what: &str| {
*count += 1;
if *count > most { Err(too_large(KIND, format!("more than {most} {what}"))) } else { Ok(()) }
};
let mut rooted = false;
while let Some(event) = events.next().map_err(|e| if e.is_limit() { too_large(KIND, e) } else { malformed(KIND, e) })? {
let (name, attrs) = match event {
Event::Start { name, attrs } => (name, attrs),
Event::End { name } => {
match name.as_str() {
"basematerials" | "colorgroup" | "texture2dgroup" => group = None,
"object" => m.objects.extend(object.take()),
_ => {}
}
continue;
}
_ => continue,
};
let attr = |key: &str| attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
let id = |key: &str| attr(key).and_then(|s| s.trim().parse::<u32>().ok());
let number = |key: &str| attr(key).and_then(|s| s.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
if !rooted {
if name != "model" {
return Err(malformed(KIND, "the model part's root element is not `model`"));
}
rooted = true;
}
match name.as_str() {
"model" => {
m.unit = match attr("unit") {
Some("micron") => Unit::Micron,
Some("centimeter") => Unit::Centimeter,
Some("inch") => Unit::Inch,
Some("foot") => Unit::Foot,
Some("meter") => Unit::Meter,
_ => Unit::Millimeter,
}
}
"basematerials" => group = id("id").map(Group::Base),
"colorgroup" => group = id("id").map(Group::Colors),
"texture2dgroup" => {
group = id("id").map(Group::Coords);
if let (Some(id), Some(texture)) = (id("id"), id("texid")) {
m.coords.entry(id).or_insert((texture, vec![]));
}
}
"compositematerials" | "multiproperties" => m.mixed_materials += 1,
"base" => {
if let Some(Group::Base(id)) = group {
room(&mut properties, limits.max_materials, "base materials")?;
let c = color(attr("displaycolor")).unwrap_or([0.8, 0.8, 0.8, 1.0]);
m.base.entry(id).or_default().push((attr("name").unwrap_or("").to_string(), c));
}
}
"color" => {
if let Some(Group::Colors(id)) = group {
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
m.colors.entry(id).or_default().push(color(attr("color")).unwrap_or([1.0; 4]));
}
}
"tex2coord" => {
if let Some(Group::Coords(id)) = group
&& let Some((_, list)) = m.coords.get_mut(&id)
{
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
// 3MF's v runs up from the bottom of the image.
list.push([number("u") as f32, 1.0 - number("v") as f32]);
}
}
"texture2d" => {
if let (Some(id), Some(path)) = (id("id"), attr("path")) {
room(&mut properties, limits.max_materials, "base materials and textures")?;
let texture = Texture {
path: path.to_string(),
content_type: attr("contenttype").map(str::to_string),
wrap: [wrap(attr("tilestyleu")), wrap(attr("tilestylev"))],
};
m.textures.entry(id).or_insert(texture);
}
}
"object" => {
if m.objects.len() >= limits.max_meshes {
return Err(too_large(KIND, format!("more than {} objects", limits.max_meshes)));
}
let o = Object { name: attr("name").unwrap_or("").to_string(), pid: id("pid"), pindex: id("pindex"), ..Object::default() };
object = id("id").map(|id| (id, o));
}
"vertex" => {
if let Some((_, o)) = object.as_mut() {
room(&mut vertices, limits.max_vertices, "vertices, colours and texture coordinates")?;
o.vertices.push([number("x"), number("y"), number("z")]);
}
}
"triangle" => {
if let Some((_, o)) = object.as_mut() {
room(&mut triangles, limits.max_triangles, "triangles")?;
// One without its three vertices is counted as left out when the mesh is built.
let v = [id("v1"), id("v2"), id("v3")].map(|v| v.unwrap_or(u32::MAX));
o.triangles.push(Triangle { v, pid: id("pid"), p: [id("p1"), id("p2"), id("p3")] });
}
}
"component" => {
if let (Some((_, o)), Some(target)) = (object.as_mut(), id("objectid")) {
if attr("path").is_some() {
m.foreign_components += 1;
} else {
// Each one becomes a node; the node limit is checked when they are placed.
room(&mut components, limits.max_nodes, "components")?;
o.components.push((target, transform(attr("transform"))));
}
}
}
"item" => {
if let Some(target) = id("objectid") {
if m.build.len() >= limits.max_nodes {
return Err(too_large(KIND, format!("more than {} build items", limits.max_nodes)));
}
m.build.push((target, transform(attr("transform"))));
}
}
_ => {}
}
}
if !rooted {
return Err(malformed(KIND, "the model part has no `model` element"));
}
Ok(m)
}
/// What a triangle is drawn with.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Look {
Plain,
Base(u32, u32),
Colors,
Texture(u32),
}
struct Build<'a, 'r> {
parsed: &'a Parsed,
limits: &'a Limits,
texture: Resolver<'r>,
scene: Scene,
materials: HashMap<Look, usize>,
data_left: usize,
parts: usize,
dropped: usize,
}
impl Build<'_, '_> {
/// The scene's material for `look`, made on first use.
fn material(&mut self, look: Look) -> Result<Option<usize>> {
if look == Look::Plain {
return Ok(None);
}
if let Some(i) = self.materials.get(&look) {
return Ok(Some(*i));
}
let m = self.parsed;
let material = match look {
Look::Base(group, index) => {
let (name, c) = m.base.get(&group).and_then(|l| l.get(index as usize)).cloned().unwrap_or((String::new(), [0.8, 0.8, 0.8, 1.0]));
let base_color = [srgb_to_linear(c[0]), srgb_to_linear(c[1]), srgb_to_linear(c[2]), c[3]];
Material { alpha_mode: if c[3] < 0.999 { AlphaMode::Blend } else { AlphaMode::Opaque }, ..Material::plain(name, base_color) }
}
Look::Texture(id) => {
let texture = match m.textures.get(&id) {
Some(t) => {
let data = (self.texture)(&t.path);
self.data_left = self
.data_left
.checked_sub(data.as_ref().map_or(0, Vec::len))
.ok_or_else(|| too_large(KIND, format!("textures of more than {} bytes", self.limits.max_data_bytes)))?;
self.scene.external.push(t.path.clone());
self.scene.images.push(Image { name: String::new(), uri: Some(t.path.clone()), mime_type: t.content_type.clone(), data });
Some(TextureRef { image: self.scene.images.len() - 1, uv_set: 0, wrap: t.wrap })
}
None => None,
};
Material { base_color_texture: texture, ..Material::plain("", [1.0; 4]) }
}
Look::Colors | Look::Plain => Material::plain("", [1.0; 4]),
};
self.scene.materials.push(material);
self.materials.insert(look, self.scene.materials.len() - 1);
Ok(Some(self.scene.materials.len() - 1))
}
/// The mesh of an object's triangles; `None` when none of them can be kept.
fn mesh(&mut self, o: &Object) -> Result<Option<Mesh>> {
let m = self.parsed;
// What each triangle is drawn with: its own property group or the object's.
let look_of = |t: &Triangle| {
let (pid, p1) = match t.pid {
Some(pid) => (Some(pid), t.p[0].unwrap_or(0)),
None => (o.pid, o.pindex.unwrap_or(0)),
};
let look = match pid {
Some(g) if m.base.contains_key(&g) => Look::Base(g, p1),
Some(g) if m.colors.contains_key(&g) => Look::Colors,
Some(g) => m.coords.get(&g).map_or(Look::Plain, |c| Look::Texture(c.0)),
None => Look::Plain,
};
(look, pid, [p1, t.p[1].unwrap_or(p1), t.p[2].unwrap_or(p1)])
};
let colored = o.triangles.iter().any(|t| look_of(t).0 == Look::Colors);
let mut mesh = Mesh { name: o.name.clone(), ..Mesh::default() };
if !colored {
mesh.positions.clone_from(&o.vertices);
}
// With colours: a position per vertex and colour.
let mut position_of: HashMap<(u32, [u32; 4]), u32> = HashMap::new();
let mut uvs: Vec<[f32; 2]> = vec![];
let mut uv_of: HashMap<(u32, u32), u32> = HashMap::new();
let mut part_of: HashMap<Look, usize> = HashMap::new();
for t in &o.triangles {
if t.v.iter().any(|v| *v as usize >= o.vertices.len()) {
self.dropped += 1;
continue;
}
let (look, pid, p) = look_of(t);
let at = match part_of.get(&look) {
Some(at) => *at,
None => {
self.parts += 1;
if self.parts > self.limits.max_parts {
return Err(too_large(KIND, format!("more than {} parts", self.limits.max_parts)));
}
mesh.parts.push(Part { material: self.material(look)?, ..Part::default() });
part_of.insert(look, mesh.parts.len() - 1);
mesh.parts.len() - 1
}
};
for (v, p) in t.v.into_iter().zip(p) {
let mut corner = Corner::at(v);
if colored {
let c = match look {
Look::Colors => pid.and_then(|g| m.colors.get(&g)).and_then(|l| l.get(p as usize)).copied().unwrap_or([1.0; 4]),
_ => [1.0; 4],
};
corner.position = *position_of.entry((v, c.map(f32::to_bits))).or_insert_with(|| {
mesh.positions.extend(o.vertices.get(v as usize));
mesh.colors.push(c);
(mesh.positions.len() - 1) as u32
});
}
if let (Look::Texture(_), Some(group)) = (look, pid)
&& let Some(uv) = m.coords.get(&group).and_then(|c| c.1.get(p as usize))
{
corner.uv = *uv_of.entry((group, p)).or_insert_with(|| {
uvs.push(*uv);
(uvs.len() - 1) as u32
});
}
if let Some(part) = mesh.parts.get_mut(at) {
part.corners.push(corner);
}
}
}
if !uvs.is_empty() {
mesh.uv_sets.push(uvs);
}
Ok((!mesh.parts.is_empty()).then_some(mesh))
}
}
/// Read a model part's XML. `texture` is asked for the package parts textures name (by their
/// `path`, as written: a part name such as `/3D/Textures/wood.png`), and may decline.
pub fn read_model_part(xml: &[u8], limits: &Limits, texture: Resolver) -> Result<Model> {
let parsed = parse(xml, limits)?;
let mut b = Build {
parsed: &parsed,
limits,
texture,
scene: Scene::default(),
materials: HashMap::new(),
data_left: limits.max_data_bytes,
parts: 0,
dropped: 0,
};
// Each object's mesh once, however often it is placed. The first object of an id wins.
let mut objects: HashMap<u32, (&Object, Option<usize>)> = HashMap::new();
for (id, o) in &parsed.objects {
if objects.contains_key(id) {
continue;
}
let mesh = match b.mesh(o)? {
Some(mesh) => {
b.scene.meshes.push(mesh);
Some(b.scene.meshes.len() - 1)
}
None => None,
};
objects.insert(*id, (o, mesh));
}
// Build items, or every object once; components below them, in order.
let items: Vec<(u32, Mat4)> =
if parsed.build.is_empty() { parsed.objects.iter().map(|(id, _)| (*id, IDENTITY)).collect() } else { parsed.build.clone() };
let mut missing = 0usize;
let mut stack: Vec<(u32, Mat4, Option<usize>, usize)> = items.into_iter().rev().map(|(id, t)| (id, t, None, 0)).collect();
while let Some((id, transform, parent, depth)) = stack.pop() {
if depth > limits.max_depth {
return Err(too_large(KIND, format!("components nest deeper than {}", limits.max_depth)));
}
let Some((o, mesh)) = objects.get(&id) else {
missing += 1;
continue;
};
if b.scene.nodes.len() >= limits.max_nodes {
return Err(too_large(KIND, format!("more than {} placed objects", limits.max_nodes)));
}
let node = b.scene.nodes.len();
b.scene.nodes.push(Node { name: o.name.clone(), transform, mesh: *mesh, ..Node::default() });
match parent.and_then(|p| b.scene.nodes.get_mut(p)) {
Some(p) => p.children.push(node),
None => b.scene.roots.push(node),
}
stack.extend(o.components.iter().rev().map(|(target, t)| (*target, *t, Some(node), depth + 1)));
}
let mut scene = b.scene;
count_warning(&mut scene.warnings, b.dropped, "triangle(s) named a vertex that is not there and were left out");
count_warning(&mut scene.warnings, missing, "build item(s) or component(s) named an object that is not there");
count_warning(&mut scene.warnings, parsed.foreign_components, "component(s) in another model part were not read");
count_warning(&mut scene.warnings, parsed.mixed_materials, "composite material or multi-property group(s) were not read");
Ok(Model { scene, unit: parsed.unit })
}