···1616pub mod obj;
1717pub mod parser;
1818pub mod stl;
1919+pub mod threemf;
19202021use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3};
2122
+173
crates/polymodel-mesh/src/threemf.rs
···11+//! 3MF ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`.
22+//!
33+//! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface
44+//! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres —
55+//! correct for the vast majority of 3MF files. Multi-object builds, transforms, and
66+//! components are merged by `three-d-asset` into a single [`Model`]; colors, textures, and
77+//! beam lattices render as geometry only in v1. There is no filesystem or network access —
88+//! this is safe to call from a `wasm32-unknown-unknown` build.
99+1010+use three_d_asset::io;
1111+1212+use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units};
1313+1414+/// Parse 3MF bytes into a [`ModelMesh`].
1515+///
1616+/// `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`), which reads the
1717+/// OPC container, parses the model XML, applies object transforms, and produces a
1818+/// [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and `Indices::U32`. The shared
1919+/// validator derives geometry normals and checks for degeneracy.
2020+pub fn load_threemf(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> {
2121+ if bytes.is_empty() {
2222+ return Err(MeshLoadError::Empty);
2323+ }
2424+ let model: three_d_asset::Model = io::deserialize("asset.3mf", bytes.to_vec())
2525+ .map_err(|e| MeshLoadError::Decode(e.to_string()))?;
2626+ let mut trimesh = crate::flatten_model(model);
2727+ crate::validate(&mut trimesh)?;
2828+ Ok(ModelMesh {
2929+ trimesh,
3030+ format: MeshFormat::Threemf,
3131+ units: Units::declared(LengthUnit::Millimeter),
3232+ })
3333+}
3434+3535+#[cfg(test)]
3636+mod tests {
3737+ use super::load_threemf;
3838+ use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units};
3939+ use three_d_asset::Positions;
4040+4141+ fn fixture(name: &str) -> Vec<u8> {
4242+ let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
4343+ std::fs::read(&path)
4444+ .unwrap_or_else(|_| panic!("3MF fixture {name} should be committed at {path}"))
4545+ }
4646+4747+ #[test]
4848+ fn parses_consortium_cylinder_fixture() {
4949+ let model = load_threemf(&fixture("cylinder.3mf")).expect("cylinder.3mf should parse");
5050+ assert_eq!(model.format, MeshFormat::Threemf);
5151+ assert_eq!(
5252+ model.units,
5353+ Units::declared(LengthUnit::Millimeter)
5454+ );
5555+ assert!(model.trimesh.triangle_count() > 0);
5656+ assert!(matches!(model.trimesh.positions, Positions::F32(_)));
5757+ let aabb = model.aabb();
5858+ let size = aabb.size();
5959+ assert!(
6060+ size.x > 0.0 && size.y > 0.0 && size.z > 0.0,
6161+ "AABB must be non-degenerate: {size:?}"
6262+ );
6363+ let normals = model
6464+ .trimesh
6565+ .normals
6666+ .as_ref()
6767+ .expect("geometry normals should be populated");
6868+ assert_eq!(normals.len(), model.trimesh.positions.len());
6969+ assert!(
7070+ normals
7171+ .iter()
7272+ .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()),
7373+ "normals must be finite"
7474+ );
7575+ }
7676+7777+ #[test]
7878+ fn error_empty() {
7979+ assert!(matches!(load_threemf(&[]), Err(MeshLoadError::Empty)));
8080+ }
8181+8282+ #[test]
8383+ fn error_parse_garbage() {
8484+ let garbage = b"this is definitely not a valid 3MF file !@#$";
8585+ assert!(matches!(load_threemf(garbage), Err(MeshLoadError::Decode(_))));
8686+ }
8787+8888+ /// Real-world correctness spike: a 3MF with 82,122 triangles from real scan geometry
8989+ /// (`body_f_chest-v4.stl` converted to 3MF). The gate is correct geometry through the
9090+ /// shipping loader path. Run explicitly: `cargo test -p polymodel-mesh threemf_spike -- --ignored --nocapture`.
9191+ #[test]
9292+ #[ignore]
9393+ fn threemf_spike_real_world_mesh() {
9494+ let bytes = fixture("body_chest.3mf");
9595+ let model = load_threemf(&bytes).expect("real-world 3MF should parse");
9696+9797+ assert_eq!(model.format, MeshFormat::Threemf);
9898+ assert_eq!(model.units, Units::declared(LengthUnit::Millimeter));
9999+100100+ let triangles = model.triangle_count();
101101+ let vertices = model.vertex_count();
102102+ eprintln!("body_chest.3mf: {vertices} vertices, {triangles} triangles");
103103+104104+ // The source STL has 82,122 triangles and 41,051 unique vertices.
105105+ assert_eq!(triangles, 82122, "triangle count must match source STL");
106106+ assert_eq!(vertices, 41051, "vertex count must match source STL");
107107+108108+ // Finite AABB
109109+ let aabb = model.aabb();
110110+ let center = aabb.center();
111111+ let size = aabb.size();
112112+ eprintln!("AABB center: {center:?}, size: {size:?}");
113113+ assert!(
114114+ center.x.is_finite() && center.y.is_finite() && center.z.is_finite(),
115115+ "AABB center must be finite"
116116+ );
117117+ assert!(
118118+ size.x > 0.0 && size.y > 0.0 && size.z > 0.0,
119119+ "AABB must be non-degenerate"
120120+ );
121121+122122+ // Normals populated and finite
123123+ let normals = model
124124+ .trimesh
125125+ .normals
126126+ .as_ref()
127127+ .expect("normals populated");
128128+ assert_eq!(normals.len(), vertices as usize);
129129+ assert!(
130130+ normals
131131+ .iter()
132132+ .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()),
133133+ "all normals must be finite"
134134+ );
135135+ }
136136+}
137137+138138+#[cfg(test)]
139139+mod spike_debug {
140140+ use three_d_asset::io;
141141+ use three_d_asset::{Geometry, Model};
142142+143143+ fn fixture(name: &str) -> Vec<u8> {
144144+ let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
145145+ std::fs::read(&path).unwrap_or_else(|_| panic!("fixture {name} at {path}"))
146146+ }
147147+148148+ #[test]
149149+ #[ignore]
150150+ fn debug_parse_all_3mf() {
151151+ for name in &["cylinder.3mf", "cube_gears.3mf", "pyramid_vertexcolor.3mf", "3DBenchy.3mf"] {
152152+ let bytes = fixture(name);
153153+ match io::deserialize::<Model>("asset.3mf", bytes) {
154154+ Ok(model) => {
155155+ let tv: usize = model.geometries.iter().map(|p| match &p.geometry {
156156+ Geometry::Triangles(m) => m.positions.len(),
157157+ _ => 0,
158158+ }).sum();
159159+ let tt: usize = model
160160+ .geometries
161161+ .iter()
162162+ .filter_map(|p| match &p.geometry {
163163+ Geometry::Triangles(m) => Some(m.triangle_count()),
164164+ _ => None,
165165+ })
166166+ .sum();
167167+ eprintln!("{name}: OK — {} geometries, {} verts, {} tris", model.geometries.len(), tv, tt);
168168+ }
169169+ Err(e) => eprintln!("{name}: ERR — {e}"),
170170+ }
171171+ }
172172+ }
173173+}