atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-49: spike three-d-asset 3mf feature

Orual (Jun 29, 2026, 7:06 PM EDT) 52d381d3 e7a7319a

+235 -4
+60 -3
Cargo.lock
··· 2768 2768 2769 2769 [[package]] 2770 2770 name = "flate2" 2771 - version = "1.1.9" 2771 + version = "1.1.4" 2772 2772 source = "registry+https://github.com/rust-lang/crates.io-index" 2773 - checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 2773 + checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" 2774 2774 dependencies = [ 2775 2775 "crc32fast", 2776 + "libz-rs-sys", 2776 2777 "miniz_oxide", 2777 2778 ] 2778 2779 ··· 4871 4872 checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" 4872 4873 4873 4874 [[package]] 4875 + name = "lib3mf" 4876 + version = "0.1.6" 4877 + source = "registry+https://github.com/rust-lang/crates.io-index" 4878 + checksum = "909b625efea54d0a560d2a1b2483cedb313f90200f7fc5e39e7e3a191b87fe6a" 4879 + dependencies = [ 4880 + "quick-xml", 4881 + "thiserror 2.0.18", 4882 + "urlencoding", 4883 + "zip 8.6.0", 4884 + ] 4885 + 4886 + [[package]] 4874 4887 name = "libappindicator" 4875 4888 version = "0.9.0" 4876 4889 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4983 4996 dependencies = [ 4984 4997 "libc", 4985 4998 "x11", 4999 + ] 5000 + 5001 + [[package]] 5002 + name = "libz-rs-sys" 5003 + version = "0.5.5" 5004 + source = "registry+https://github.com/rust-lang/crates.io-index" 5005 + checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" 5006 + dependencies = [ 5007 + "zlib-rs", 4986 5008 ] 4987 5009 4988 5010 [[package]] ··· 6331 6353 "wasm-bindgen", 6332 6354 "wasm-bindgen-futures", 6333 6355 "web-sys", 6334 - "zip", 6356 + "zip 2.4.2", 6335 6357 ] 6336 6358 6337 6359 [[package]] ··· 6605 6627 checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 6606 6628 6607 6629 [[package]] 6630 + name = "quick-xml" 6631 + version = "0.39.4" 6632 + source = "registry+https://github.com/rust-lang/crates.io-index" 6633 + checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" 6634 + dependencies = [ 6635 + "memchr", 6636 + ] 6637 + 6638 + [[package]] 6608 6639 name = "quick_cache" 6609 6640 version = "0.6.24" 6610 6641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8364 8395 "gltf", 8365 8396 "half", 8366 8397 "image", 8398 + "lib3mf", 8367 8399 "thiserror 2.0.18", 8368 8400 "wavefront_obj", 8369 8401 "web-sys", ··· 8921 8953 version = "2.1.2" 8922 8954 source = "registry+https://github.com/rust-lang/crates.io-index" 8923 8955 checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" 8956 + 8957 + [[package]] 8958 + name = "typed-path" 8959 + version = "0.12.3" 8960 + source = "registry+https://github.com/rust-lang/crates.io-index" 8961 + checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" 8924 8962 8925 8963 [[package]] 8926 8964 name = "typenum" ··· 10107 10145 "memchr", 10108 10146 "thiserror 2.0.18", 10109 10147 ] 10148 + 10149 + [[package]] 10150 + name = "zip" 10151 + version = "8.6.0" 10152 + source = "registry+https://github.com/rust-lang/crates.io-index" 10153 + checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" 10154 + dependencies = [ 10155 + "crc32fast", 10156 + "flate2", 10157 + "indexmap", 10158 + "memchr", 10159 + "typed-path", 10160 + ] 10161 + 10162 + [[package]] 10163 + name = "zlib-rs" 10164 + version = "0.5.5" 10165 + source = "registry+https://github.com/rust-lang/crates.io-index" 10166 + checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" 10110 10167 10111 10168 [[package]] 10112 10169 name = "zmij"
+1 -1
crates/polymodel-mesh/Cargo.toml
··· 7 7 8 8 [dependencies] 9 9 polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } 10 - three-d-asset = { version = "0.10", default-features = false, features = ["gltf", "obj", "data-url", "png"] } 10 + three-d-asset = { version = "0.10", default-features = false, features = ["gltf", "obj", "data-url", "png", "3mf"] } 11 11 stl_io = "0.11" 12 12 serde_json = "1.0" 13 13 thiserror = { workspace = true }
crates/polymodel-mesh/public/models/3DBenchy.3mf

This is a binary file and will not be displayed.

crates/polymodel-mesh/public/models/cube_gears.3mf

This is a binary file and will not be displayed.

crates/polymodel-mesh/public/models/cylinder.3mf

This is a binary file and will not be displayed.

crates/polymodel-mesh/public/models/pyramid_vertexcolor.3mf

This is a binary file and will not be displayed.

+1
crates/polymodel-mesh/src/lib.rs
··· 16 16 pub mod obj; 17 17 pub mod parser; 18 18 pub mod stl; 19 + pub mod threemf; 19 20 20 21 use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3}; 21 22
+173
crates/polymodel-mesh/src/threemf.rs
··· 1 + //! 3MF ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`. 2 + //! 3 + //! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface 4 + //! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres — 5 + //! correct for the vast majority of 3MF files. Multi-object builds, transforms, and 6 + //! components are merged by `three-d-asset` into a single [`Model`]; colors, textures, and 7 + //! beam lattices render as geometry only in v1. There is no filesystem or network access — 8 + //! this is safe to call from a `wasm32-unknown-unknown` build. 9 + 10 + use three_d_asset::io; 11 + 12 + use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; 13 + 14 + /// Parse 3MF bytes into a [`ModelMesh`]. 15 + /// 16 + /// `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`), which reads the 17 + /// OPC container, parses the model XML, applies object transforms, and produces a 18 + /// [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and `Indices::U32`. The shared 19 + /// validator derives geometry normals and checks for degeneracy. 20 + pub fn load_threemf(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 21 + if bytes.is_empty() { 22 + return Err(MeshLoadError::Empty); 23 + } 24 + let model: three_d_asset::Model = io::deserialize("asset.3mf", bytes.to_vec()) 25 + .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 26 + let mut trimesh = crate::flatten_model(model); 27 + crate::validate(&mut trimesh)?; 28 + Ok(ModelMesh { 29 + trimesh, 30 + format: MeshFormat::Threemf, 31 + units: Units::declared(LengthUnit::Millimeter), 32 + }) 33 + } 34 + 35 + #[cfg(test)] 36 + mod tests { 37 + use super::load_threemf; 38 + use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 39 + use three_d_asset::Positions; 40 + 41 + fn fixture(name: &str) -> Vec<u8> { 42 + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 43 + std::fs::read(&path) 44 + .unwrap_or_else(|_| panic!("3MF fixture {name} should be committed at {path}")) 45 + } 46 + 47 + #[test] 48 + fn parses_consortium_cylinder_fixture() { 49 + let model = load_threemf(&fixture("cylinder.3mf")).expect("cylinder.3mf should parse"); 50 + assert_eq!(model.format, MeshFormat::Threemf); 51 + assert_eq!( 52 + model.units, 53 + Units::declared(LengthUnit::Millimeter) 54 + ); 55 + assert!(model.trimesh.triangle_count() > 0); 56 + assert!(matches!(model.trimesh.positions, Positions::F32(_))); 57 + let aabb = model.aabb(); 58 + let size = aabb.size(); 59 + assert!( 60 + size.x > 0.0 && size.y > 0.0 && size.z > 0.0, 61 + "AABB must be non-degenerate: {size:?}" 62 + ); 63 + let normals = model 64 + .trimesh 65 + .normals 66 + .as_ref() 67 + .expect("geometry normals should be populated"); 68 + assert_eq!(normals.len(), model.trimesh.positions.len()); 69 + assert!( 70 + normals 71 + .iter() 72 + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), 73 + "normals must be finite" 74 + ); 75 + } 76 + 77 + #[test] 78 + fn error_empty() { 79 + assert!(matches!(load_threemf(&[]), Err(MeshLoadError::Empty))); 80 + } 81 + 82 + #[test] 83 + fn error_parse_garbage() { 84 + let garbage = b"this is definitely not a valid 3MF file !@#$"; 85 + assert!(matches!(load_threemf(garbage), Err(MeshLoadError::Decode(_)))); 86 + } 87 + 88 + /// Real-world correctness spike: a 3MF with 82,122 triangles from real scan geometry 89 + /// (`body_f_chest-v4.stl` converted to 3MF). The gate is correct geometry through the 90 + /// shipping loader path. Run explicitly: `cargo test -p polymodel-mesh threemf_spike -- --ignored --nocapture`. 91 + #[test] 92 + #[ignore] 93 + fn threemf_spike_real_world_mesh() { 94 + let bytes = fixture("body_chest.3mf"); 95 + let model = load_threemf(&bytes).expect("real-world 3MF should parse"); 96 + 97 + assert_eq!(model.format, MeshFormat::Threemf); 98 + assert_eq!(model.units, Units::declared(LengthUnit::Millimeter)); 99 + 100 + let triangles = model.triangle_count(); 101 + let vertices = model.vertex_count(); 102 + eprintln!("body_chest.3mf: {vertices} vertices, {triangles} triangles"); 103 + 104 + // The source STL has 82,122 triangles and 41,051 unique vertices. 105 + assert_eq!(triangles, 82122, "triangle count must match source STL"); 106 + assert_eq!(vertices, 41051, "vertex count must match source STL"); 107 + 108 + // Finite AABB 109 + let aabb = model.aabb(); 110 + let center = aabb.center(); 111 + let size = aabb.size(); 112 + eprintln!("AABB center: {center:?}, size: {size:?}"); 113 + assert!( 114 + center.x.is_finite() && center.y.is_finite() && center.z.is_finite(), 115 + "AABB center must be finite" 116 + ); 117 + assert!( 118 + size.x > 0.0 && size.y > 0.0 && size.z > 0.0, 119 + "AABB must be non-degenerate" 120 + ); 121 + 122 + // Normals populated and finite 123 + let normals = model 124 + .trimesh 125 + .normals 126 + .as_ref() 127 + .expect("normals populated"); 128 + assert_eq!(normals.len(), vertices as usize); 129 + assert!( 130 + normals 131 + .iter() 132 + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), 133 + "all normals must be finite" 134 + ); 135 + } 136 + } 137 + 138 + #[cfg(test)] 139 + mod spike_debug { 140 + use three_d_asset::io; 141 + use three_d_asset::{Geometry, Model}; 142 + 143 + fn fixture(name: &str) -> Vec<u8> { 144 + let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 145 + std::fs::read(&path).unwrap_or_else(|_| panic!("fixture {name} at {path}")) 146 + } 147 + 148 + #[test] 149 + #[ignore] 150 + fn debug_parse_all_3mf() { 151 + for name in &["cylinder.3mf", "cube_gears.3mf", "pyramid_vertexcolor.3mf", "3DBenchy.3mf"] { 152 + let bytes = fixture(name); 153 + match io::deserialize::<Model>("asset.3mf", bytes) { 154 + Ok(model) => { 155 + let tv: usize = model.geometries.iter().map(|p| match &p.geometry { 156 + Geometry::Triangles(m) => m.positions.len(), 157 + _ => 0, 158 + }).sum(); 159 + let tt: usize = model 160 + .geometries 161 + .iter() 162 + .filter_map(|p| match &p.geometry { 163 + Geometry::Triangles(m) => Some(m.triangle_count()), 164 + _ => None, 165 + }) 166 + .sum(); 167 + eprintln!("{name}: OK — {} geometries, {} verts, {} tris", model.geometries.len(), tv, tt); 168 + } 169 + Err(e) => eprintln!("{name}: ERR — {e}"), 170 + } 171 + } 172 + } 173 + }