atproto Thingiverse but good
12

Configure Feed

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

PM-18: mesh contract + STL ingestion (WASM)

Orual (Jun 29, 2026, 7:06 PM EDT) c89e6bee ebbb08c1

+521 -1
+40
Cargo.lock
··· 72 72 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 73 73 74 74 [[package]] 75 + name = "approx" 76 + version = "0.4.0" 77 + source = "registry+https://github.com/rust-lang/crates.io-index" 78 + checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" 79 + dependencies = [ 80 + "num-traits", 81 + ] 82 + 83 + [[package]] 75 84 name = "arbitrary" 76 85 version = "1.4.2" 77 86 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 636 645 version = "0.2.1" 637 646 source = "registry+https://github.com/rust-lang/crates.io-index" 638 647 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 648 + 649 + [[package]] 650 + name = "cgmath" 651 + version = "0.18.0" 652 + source = "registry+https://github.com/rust-lang/crates.io-index" 653 + checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" 654 + dependencies = [ 655 + "approx", 656 + "num-traits", 657 + ] 639 658 640 659 [[package]] 641 660 name = "charset" ··· 2994 3013 dependencies = [ 2995 3014 "cfg-if", 2996 3015 "crunchy", 3016 + "num-traits", 2997 3017 "zerocopy", 2998 3018 ] 2999 3019 ··· 5180 5200 "jacquard-derive", 5181 5201 "serde", 5182 5202 "serde_json", 5203 + "stl_io", 5183 5204 "thiserror 2.0.18", 5205 + "three-d-asset", 5184 5206 "tokio", 5185 5207 "tower", 5186 5208 "tower-http", ··· 6457 6479 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 6458 6480 6459 6481 [[package]] 6482 + name = "stl_io" 6483 + version = "0.11.0" 6484 + source = "registry+https://github.com/rust-lang/crates.io-index" 6485 + checksum = "567641995c51a3b8befddb13e1826187bcf7eb7ca8d13746bfd1cc5a22e89fa8" 6486 + 6487 + [[package]] 6460 6488 name = "string_cache" 6461 6489 version = "0.8.9" 6462 6490 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6730 6758 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 6731 6759 dependencies = [ 6732 6760 "cfg-if", 6761 + ] 6762 + 6763 + [[package]] 6764 + name = "three-d-asset" 6765 + version = "0.10.0" 6766 + source = "registry+https://github.com/rust-lang/crates.io-index" 6767 + checksum = "cd4e4e9bc05e4010efbaa0a0296870b2d28102171deffbf69de7b305cff2d47a" 6768 + dependencies = [ 6769 + "cgmath", 6770 + "half", 6771 + "thiserror 2.0.18", 6772 + "web-sys", 6733 6773 ] 6734 6774 6735 6775 [[package]]
+2
Cargo.toml
··· 21 21 serde_json = "1.0" 22 22 thiserror = "2.0" 23 23 tracing = { version = "0.1", default-features = false, features = ["std"] } 24 + three-d-asset = { version = "0.10", default-features = false } 25 + stl_io = "0.11" 24 26 25 27 axum = { version = "0.8", optional = true } 26 28 tower = { version = "0.5", optional = true }
+1 -1
src/main.rs
··· 1 1 use dioxus::prelude::*; 2 - 2 + mod mesh; 3 3 const FAVICON: Asset = asset!("/assets/favicon.jpg"); 4 4 const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); 5 5 const BASE_CSS: Asset = asset!("/assets/styling/base.css");
+116
src/mesh/contract.rs
··· 1 + //! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance. 2 + 3 + // `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and 4 + // `TriMesh` is a public field type, so both are re-exported alongside the contract. 5 + pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh}; 6 + 7 + /// Source format of a loaded mesh. 8 + /// 9 + /// STL today; OBJ/glTF/3MF direct upload is reserved for PM-23. There is intentionally 10 + /// no binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and 11 + /// both yield the same mesh. 12 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 + pub enum MeshFormat { 14 + /// Stereolithography (binary or ASCII). 15 + Stl, 16 + } 17 + 18 + /// A length unit. 19 + /// 20 + /// STL is unitless, so a loaded STL reports [`LengthUnit::Unknown`] as its source and 21 + /// relies on an assumed convention. 22 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 + pub enum LengthUnit { 24 + Millimeter, 25 + Centimeter, 26 + Meter, 27 + Inch, 28 + /// A thousandth of an inch — "thou" in machining, "mil" in PCB/electronics. 29 + Thou, 30 + Foot, 31 + Unknown, 32 + } 33 + 34 + /// Unit handling for a loaded model. 35 + /// 36 + /// `source` is the unit declared by the file; STL declares none, so this is 37 + /// [`LengthUnit::Unknown`]. `assumed` is the convention applied when the source is 38 + /// unknown — millimetres for Polymodel's STL ingestion. 39 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 + pub struct Units { 41 + /// The unit declared by the file (STL: [`LengthUnit::Unknown`]). 42 + pub source: LengthUnit, 43 + /// The convention assumed when `source` is unknown. 44 + pub assumed: Option<LengthUnit>, 45 + } 46 + 47 + impl Units { 48 + /// STL unit handling: source unknown, assume millimetres. 49 + pub fn stl_assumed_mm() -> Self { 50 + Self { 51 + source: LengthUnit::Unknown, 52 + assumed: Some(LengthUnit::Millimeter), 53 + } 54 + } 55 + } 56 + 57 + /// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the 58 + /// camera should orbit and a bounding-radius estimate to frame it. 59 + #[derive(Debug, Clone, Copy, PartialEq)] 60 + pub struct CameraFit { 61 + /// AABB centre. 62 + pub center: [f32; 3], 63 + /// Half the largest AABB extent. 64 + pub radius: f32, 65 + } 66 + 67 + /// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. 68 + /// 69 + /// Normals are geometry-derived (via [`TriMesh::compute_normals`]); STL facet normals are 70 + /// not relied upon. Bounds and camera framing come from [`Self::aabb`]. 71 + #[derive(Debug)] 72 + pub struct ModelMesh { 73 + /// The underlying triangle mesh. 74 + pub trimesh: TriMesh, 75 + /// The format this mesh was loaded from. 76 + pub format: MeshFormat, 77 + /// Unit provenance and assumption. 78 + pub units: Units, 79 + } 80 + 81 + impl ModelMesh { 82 + /// The axis-aligned bounding box over all vertex positions. 83 + pub fn aabb(&self) -> AxisAlignedBoundingBox { 84 + self.trimesh.compute_aabb() 85 + } 86 + 87 + /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and 88 + /// `radius` is half the largest box extent. 89 + pub fn camera_fit(&self) -> CameraFit { 90 + let aabb = self.aabb(); 91 + let center = aabb.center(); 92 + let size = aabb.size(); 93 + CameraFit { 94 + center: [center.x, center.y, center.z], 95 + radius: size.x.max(size.y).max(size.z) / 2.0, 96 + } 97 + } 98 + } 99 + 100 + /// Errors that can occur loading a mesh. 101 + #[derive(Debug, thiserror::Error)] 102 + pub enum MeshLoadError { 103 + /// The input was empty (zero bytes). 104 + #[error("mesh input is empty")] 105 + Empty, 106 + /// The bytes could not be parsed as a valid mesh of the expected format. 107 + #[error("failed to parse STL: {0}")] 108 + Parse(#[from] std::io::Error), 109 + /// The mesh parsed but is degenerate: no triangles, a non-finite position, or a 110 + /// point-sized bounding box. 111 + #[error("mesh is degenerate")] 112 + Degenerate, 113 + /// The format is not yet supported (reserved for PM-23). 114 + #[error("unsupported mesh format")] 115 + UnsupportedFormat, 116 + }
+18
src/mesh/mod.rs
··· 1 + //! Renderer-agnostic mesh contract and STL ingestion. 2 + //! 3 + //! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over 4 + //! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a 5 + //! camera framing hint. STL is parsed from in-memory bytes (no filesystem), so this 6 + //! module compiles to `wasm32-unknown-unknown` and can be consumed by any future 7 + //! renderer ([PM-14 research](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340)). 8 + //! 9 + //! Scope is STL-only for now; OBJ/glTF/3MF direct upload is tracked in PM-23. 10 + 11 + // This module is a renderer-agnostic ingestion foundation: it is consumed by PM-19 12 + // (Dioxus scaffold) and PM-20/PM-21 (renderers), none of which are wired into the 13 + // binary's main path yet. Its public surface is therefore intentionally unused at 14 + // present, so dead-code analysis is relaxed here until those tickets land. 15 + #![allow(dead_code)] 16 + 17 + pub mod contract; 18 + pub mod stl;
+344
src/mesh/stl.rs
··· 1 + //! STL ingestion: parse in-memory bytes into a [`ModelMesh`] via `stl_io`. 2 + //! 3 + //! Both binary and ASCII STL are handled by `stl_io::read_stl`, which probes the 4 + //! 80-byte header for a leading `"solid "` to pick its reader. A binary file whose 5 + //! header happens to start with `"solid "` is mis-routed to the ASCII reader and fails; 6 + //! on a parse error we neutralise the header in an owned copy and retry exactly once, so 7 + //! `stl_io` re-probes and picks its binary reader. We deliberately do **not** try to 8 + //! classify binary-vs-ASCII ourselves (size-based detection is an unreliable heuristic, 9 + //! and `stl_io` exposes no public binary-only entry point). 10 + //! 11 + //! Normals are geometry-derived ([`three_d_asset::TriMesh::compute_normals`]); STL facet 12 + //! normals are not relied upon. No filesystem or network access, so this is safe to call 13 + //! from a `wasm32-unknown-unknown` build. 14 + 15 + use std::io::Cursor; 16 + 17 + use three_d_asset::{Indices, Positions, TriMesh, Vec3}; 18 + 19 + use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; 20 + 21 + /// Parse STL bytes (binary or ASCII) into a [`ModelMesh`]. 22 + pub fn load_stl(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 23 + if bytes.is_empty() { 24 + return Err(MeshLoadError::Empty); 25 + } 26 + 27 + let mesh = stl_io::read_stl(&mut Cursor::new(bytes)).or_else(|_| { 28 + // `stl_io` mis-routes binaries whose 80-byte header starts with "solid " to its 29 + // ASCII reader. Neutralise the header so the probe routes to the binary reader 30 + // and retry exactly once. 31 + let mut buf = bytes.to_vec(); 32 + if buf.len() >= 80 { 33 + buf[..80].fill(0); 34 + } 35 + stl_io::read_stl(&mut Cursor::new(buf)) 36 + }); 37 + 38 + build_model_mesh(mesh?) 39 + } 40 + 41 + /// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`]. 42 + fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result<ModelMesh, MeshLoadError> { 43 + // No triangles → nothing to render. 44 + if mesh.faces.is_empty() { 45 + return Err(MeshLoadError::Degenerate); 46 + } 47 + // Reject non-finite positions up front so AABB / normal math can't be poisoned. 48 + if mesh 49 + .vertices 50 + .iter() 51 + .any(|v| !v.0.iter().copied().all(f32::is_finite)) 52 + { 53 + return Err(MeshLoadError::Degenerate); 54 + } 55 + 56 + let positions = Positions::F32( 57 + mesh.vertices 58 + .iter() 59 + .map(|v| Vec3::new(v.0[0], v.0[1], v.0[2])) 60 + .collect(), 61 + ); 62 + let indices = Indices::U32( 63 + mesh.faces 64 + .iter() 65 + .flat_map(|f| f.vertices) 66 + .map(|i| i as u32) 67 + .collect(), 68 + ); 69 + 70 + let mut trimesh = TriMesh { 71 + positions, 72 + indices, 73 + normals: None, 74 + tangents: None, 75 + uvs: None, 76 + colors: None, 77 + }; 78 + 79 + // Reject a point-sized bounding box (all three extents ~0). A flat plate or line — 80 + // zero extent on one or two axes — is valid geometry and must parse. 81 + let size = trimesh.compute_aabb().size(); 82 + if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { 83 + return Err(MeshLoadError::Degenerate); 84 + } 85 + 86 + // Geometry-derived normals; STL facet normals are not relied upon. 87 + trimesh.compute_normals(); 88 + // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched 89 + // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such 90 + // geometry is allowed to parse, so replace any non-finite normal with zero — the 91 + // normal is genuinely undefined there, but the mesh must never carry NaN downstream. 92 + if let Some(normals) = trimesh.normals.as_mut() { 93 + for n in normals { 94 + if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { 95 + *n = Vec3::new(0.0, 0.0, 0.0); 96 + } 97 + } 98 + } 99 + 100 + Ok(ModelMesh { 101 + trimesh, 102 + format: MeshFormat::Stl, 103 + units: Units::stl_assumed_mm(), 104 + }) 105 + } 106 + 107 + #[cfg(test)] 108 + mod tests { 109 + use super::load_stl; 110 + use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 111 + 112 + /// Build an STL [`stl_io::Triangle`] from three points with a zero facet normal 113 + /// (normals are geometry-derived, so the facet normal is irrelevant). 114 + fn tri(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Triangle { 115 + stl_io::Triangle { 116 + normal: stl_io::Normal::new([0.0, 0.0, 0.0]), 117 + vertices: [ 118 + stl_io::Vertex::new(a), 119 + stl_io::Vertex::new(b), 120 + stl_io::Vertex::new(c), 121 + ], 122 + } 123 + } 124 + 125 + /// Encode triangles as a binary STL (all-zero 80-byte header, per the spec). 126 + fn binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> { 127 + let mut buf = Vec::new(); 128 + stl_io::write_stl(&mut buf, triangles.iter()).expect("write_stl should encode"); 129 + assert!( 130 + buf.len() >= 80, 131 + "binary STL should start with an 80-byte header" 132 + ); 133 + buf 134 + } 135 + 136 + /// Encode triangles as a binary STL whose header starts with "solid " (the edge case 137 + /// that mis-routes `stl_io`'s ASCII probe). 138 + fn solid_header_binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> { 139 + let mut buf = binary_stl(triangles); 140 + buf[..6].copy_from_slice(b"solid "); 141 + buf 142 + } 143 + 144 + fn tetrahedron_stl() -> Vec<u8> { 145 + let v = [ 146 + [0.0, 0.0, 0.0], 147 + [1.0, 0.0, 0.0], 148 + [0.0, 1.0, 0.0], 149 + [0.0, 0.0, 1.0], 150 + ]; 151 + binary_stl(&[ 152 + tri(v[0], v[2], v[1]), 153 + tri(v[0], v[1], v[3]), 154 + tri(v[0], v[3], v[2]), 155 + tri(v[1], v[2], v[3]), 156 + ]) 157 + } 158 + 159 + fn assert_approx(actual: f32, expected: f32) { 160 + assert!( 161 + (actual - expected).abs() < 1e-5, 162 + "expected ~{expected}, got {actual}" 163 + ); 164 + } 165 + 166 + #[test] 167 + fn parses_binary_stl() { 168 + let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse"); 169 + assert_eq!(model.format, MeshFormat::Stl); 170 + assert_eq!( 171 + model.units, 172 + Units { 173 + source: LengthUnit::Unknown, 174 + assumed: Some(LengthUnit::Millimeter), 175 + } 176 + ); 177 + assert_eq!(model.trimesh.triangle_count(), 4); 178 + assert!( 179 + model.trimesh.positions.len() >= 4, 180 + "tetrahedron should have at least its 4 unique vertices" 181 + ); 182 + // Normals are geometry-derived and populated by compute_normals. 183 + let normals = model 184 + .trimesh 185 + .normals 186 + .as_ref() 187 + .expect("normals should be populated"); 188 + assert_eq!(normals.len(), model.trimesh.positions.len()); 189 + } 190 + 191 + #[test] 192 + fn parses_ascii_stl() { 193 + let ascii = b"\ 194 + solid polymodel 195 + facet normal 0 0 1 196 + outer loop 197 + vertex 0 0 0 198 + vertex 1 0 0 199 + vertex 0 1 0 200 + endloop 201 + endfacet 202 + endsolid polymodel\n"; 203 + let model = load_stl(ascii).expect("ASCII STL should parse"); 204 + assert_eq!(model.format, MeshFormat::Stl); 205 + assert_eq!(model.trimesh.triangle_count(), 1); 206 + assert!(model.trimesh.normals.is_some()); 207 + } 208 + 209 + #[test] 210 + fn parses_solid_header_binary_via_retry() { 211 + // Binary body with an "solid "-prefixed header: stl_io's ASCII probe mis-routes 212 + // the first attempt; the header-neutralised retry must recover it. 213 + let model = load_stl(&solid_header_binary_stl(&[tri( 214 + [0.0, 0.0, 0.0], 215 + [1.0, 0.0, 0.0], 216 + [0.0, 1.0, 0.0], 217 + )])) 218 + .expect("'solid '-header binary should parse via retry"); 219 + assert_eq!(model.trimesh.triangle_count(), 1); 220 + assert!( 221 + model.trimesh.normals.is_some(), 222 + "geometry normals must be populated even on the retry path" 223 + ); 224 + } 225 + 226 + #[test] 227 + fn parses_planar_mesh_is_not_degenerate() { 228 + // A flat triangle (zero extent on Y) is valid geometry, not degenerate. 229 + let model = load_stl(&binary_stl(&[tri( 230 + [10.0, 0.0, 0.0], 231 + [14.0, 0.0, 0.0], 232 + [10.0, 0.0, 4.0], 233 + )])) 234 + .expect("planar mesh should parse"); 235 + assert_eq!(model.trimesh.triangle_count(), 1); 236 + } 237 + 238 + #[test] 239 + fn camera_fit_off_center() { 240 + // Off-centre triangle: AABB x[10,14] z[0,4], y={0} → centre (12,0,2), radius 2. 241 + let model = load_stl(&binary_stl(&[tri( 242 + [10.0, 0.0, 0.0], 243 + [14.0, 0.0, 0.0], 244 + [10.0, 0.0, 4.0], 245 + )])) 246 + .expect("off-centre mesh should parse"); 247 + let fit = model.camera_fit(); 248 + assert_approx(fit.center[0], 12.0); 249 + assert_approx(fit.center[1], 0.0); 250 + assert_approx(fit.center[2], 2.0); 251 + assert_approx(fit.radius, 2.0); 252 + } 253 + 254 + #[test] 255 + fn collinear_mesh_yields_finite_normals() { 256 + // A collinear (1D) triangle is zero-area: geometry normals are undefined and 257 + // would normalize to NaN. The mesh still parses (not degenerate), and its 258 + // normals must be finite. 259 + let model = load_stl(&binary_stl(&[tri( 260 + [0.0, 0.0, 0.0], 261 + [1.0, 0.0, 0.0], 262 + [2.0, 0.0, 0.0], 263 + )])) 264 + .expect("collinear mesh should parse (not degenerate)"); 265 + let normals = model 266 + .trimesh 267 + .normals 268 + .as_ref() 269 + .expect("normals should be populated"); 270 + assert!( 271 + normals 272 + .iter() 273 + .all(|n| { n.x.is_finite() && n.y.is_finite() && n.z.is_finite() }), 274 + "normals must be finite even for zero-area geometry" 275 + ); 276 + } 277 + 278 + #[test] 279 + fn error_empty() { 280 + assert!(matches!(load_stl(&[]), Err(MeshLoadError::Empty))); 281 + } 282 + 283 + #[test] 284 + fn error_parse_garbage() { 285 + // Pure garbage fails both attempts (ASCII probe and binary reader). 286 + let garbage = vec![b'G'; 200]; 287 + assert!(matches!(load_stl(&garbage), Err(MeshLoadError::Parse(_)))); 288 + } 289 + 290 + #[test] 291 + fn error_parse_truncated() { 292 + // A valid binary STL truncated mid-body fails both attempts. 293 + let truncated = &tetrahedron_stl()[..90]; 294 + assert!(matches!(load_stl(truncated), Err(MeshLoadError::Parse(_)))); 295 + } 296 + 297 + #[test] 298 + fn error_zero_triangles() { 299 + // 80-byte header + a LE triangle count of zero is a valid but empty STL. 300 + let mut empty = vec![0u8; 84]; 301 + empty[80..84].copy_from_slice(&0u32.to_le_bytes()); 302 + assert!(matches!(load_stl(&empty), Err(MeshLoadError::Degenerate))); 303 + } 304 + 305 + #[test] 306 + fn error_point_sized_aabb() { 307 + // A triangle collapsed to a single point has a point-sized AABB. 308 + let point = binary_stl(&[tri([5.0, 5.0, 5.0], [5.0, 5.0, 5.0], [5.0, 5.0, 5.0])]); 309 + assert!(matches!(load_stl(&point), Err(MeshLoadError::Degenerate))); 310 + } 311 + 312 + #[test] 313 + fn error_non_finite_position() { 314 + // An infinite coordinate would poison AABB / normal math; it is rejected. 315 + let inf = binary_stl(&[tri( 316 + [f32::INFINITY, 0.0, 0.0], 317 + [1.0, 0.0, 0.0], 318 + [0.0, 1.0, 0.0], 319 + )]); 320 + assert!(matches!(load_stl(&inf), Err(MeshLoadError::Degenerate))); 321 + } 322 + 323 + /// End-to-end check against the committed real-world fixture. 324 + #[test] 325 + fn local_body_chest_fixture_parses() { 326 + let path = concat!( 327 + env!("CARGO_MANIFEST_DIR"), 328 + "/assets/models/body_f_chest-v4.stl" 329 + ); 330 + let bytes = std::fs::read(path) 331 + .expect("local fixture assets/models/body_f_chest-v4.stl should be committed"); 332 + let model = load_stl(&bytes).expect("local STL fixture should parse"); 333 + assert_eq!(model.format, MeshFormat::Stl); 334 + assert_eq!( 335 + model.units, 336 + Units { 337 + source: LengthUnit::Unknown, 338 + assumed: Some(LengthUnit::Millimeter), 339 + } 340 + ); 341 + assert!(model.trimesh.normals.is_some()); 342 + assert!(model.trimesh.triangle_count() > 0); 343 + } 344 + }