Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
9.9 kB · 275 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276//! Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker.//!//! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over//! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera//! framing hint. STL, OBJ, glTF (GLB, embedded-base64, or multi-file `.gltf` with//! external companions), and 3MF are parsed from in-memory bytes via `three-d-asset`, so//! this crate compiles to `wasm32-unknown-unknown`. The parser seam in [`parser`] retains//! a [`RemoteMeshParser`](parser::RemoteMeshParser) trait for future server-side formats//! (e.g. STEP via opencascade.js).//! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over//! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera//! framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf` with//! external companions) are parsed from in-memory bytes via `three-d-asset`, so this crate//! compiles to `wasm32-unknown-unknown`. 3MF cannot target `wasm32` and is routed to a//! remote parser (PM-49); the parser seam in [`parser`] is remote-ready.//!//! [`MeshFormat`] / [`LengthUnit`] / [`Units`] are owned by [`polymodel_renderer_protocol`]//! (the leaf crate shared with the app) to avoid a circular dependency.
pub mod companions;pub mod contract;pub mod gltf;pub mod obj;pub mod parser;pub mod stl;pub mod threemf;
use three_d_asset::{Geometry, Model, Positions, TriMesh, Vec3};
use crate::contract::MeshLoadError;
/// Validate and finalize a parsed [`TriMesh`] shared by every loader.////// Runs the geometry checks lifted from the STL path so STL, OBJ, and glTF all enforce/// the same invariants, then derives geometry normals and normalizes positions to F32:////// * no triangles, an out-of-range index, a non-finite position, or a point-sized/// bounding box → [`MeshLoadError::Degenerate`];/// * normals are geometry-derived (source facet/vertex normals are not relied upon);/// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero;/// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant).///pub fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { if trimesh.triangle_count() == 0 { return Err(MeshLoadError::Degenerate); } if !positions_all_finite(&trimesh.positions) { return Err(MeshLoadError::Degenerate); } let position_count = trimesh.positions.len() as u32; if trimesh .indices .to_u32() .is_some_and(|indices| indices.iter().any(|&i| i >= position_count)) { return Err(MeshLoadError::Degenerate); } let size = trimesh.compute_aabb().size(); if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { return Err(MeshLoadError::Degenerate); } trimesh.compute_normals(); if let Some(normals) = trimesh.normals.as_mut() { for n in normals { if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() { *n = Vec3::new(0.0, 0.0, 0.0); } } } if let Positions::F64(f64s) = &trimesh.positions { trimesh.positions = Positions::F32( f64s.iter() .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32)) .collect(), ); } Ok(())}
/// Validate and finalize a parsed CPU model while preserving primitive/material structure.pub fn validate_model(model: &mut Model) -> Result<(), MeshLoadError> { if model.geometries.is_empty() { return Err(MeshLoadError::Degenerate); }
let mut has_positions = false; for primitive in &mut model.geometries { match &mut primitive.geometry { Geometry::Triangles(mesh) => { validate(mesh)?; has_positions = true; } Geometry::Points(points) => { validate_point_positions(&mut points.positions)?; has_positions = true; } } }
if has_positions { Ok(()) } else { Err(MeshLoadError::Degenerate) }}
fn validate_point_positions(positions: &mut Positions) -> Result<(), MeshLoadError> { if positions.is_empty() || !positions_all_finite(positions) { return Err(MeshLoadError::Degenerate); } let size = positions.compute_aabb().size(); if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { return Err(MeshLoadError::Degenerate); } if let Positions::F64(f64s) = positions { *positions = Positions::F32( f64s.iter() .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32)) .collect(), ); } Ok(())}
fn positions_all_finite(positions: &Positions) -> bool { match positions { Positions::F32(vs) => vs .iter() .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), Positions::F64(vs) => vs .iter() .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()), }}
#[cfg(test)]mod tests { use super::*; use three_d_asset::{Indices, Mat4, PointCloud, Primitive, SquareMatrix};
fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh { TriMesh { positions: Positions::F32( positions .iter() .map(|p| Vec3::new(p[0], p[1], p[2])) .collect(), ), indices: Indices::U32(indices.to_vec()), normals: None, tangents: None, uvs: None, colors: None, } }
#[test] fn validate_rejects_zero_triangles() { let mut m = trimesh(&[[0.0, 0.0, 0.0]], &[]); assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); }
#[test] fn validate_rejects_non_finite_position() { let mut m = trimesh( &[[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], &[0, 1, 2], ); assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); }
#[test] fn validate_rejects_out_of_range_indices() { let mut m = trimesh( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], &[0, 1, 99], ); assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); }
#[test] fn validate_rejects_point_sized_aabb() { let mut m = trimesh(&[[5.0, 5.0, 5.0]; 3], &[0, 1, 2]); assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate))); }
#[test] fn validate_derives_normals_and_normalizes_f64() { let mut m = TriMesh { positions: Positions::F64( [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] .iter() .map(|p| three_d_asset::Vector3::new(p[0], p[1], p[2])) .collect(), ), indices: Indices::U32(vec![0, 1, 2]), normals: None, tangents: None, uvs: None, colors: None, }; validate(&mut m).expect("a valid triangle parses"); assert!(matches!(m.positions, Positions::F32(_)), "F64 -> F32"); let normals = m.normals.expect("normals populated"); assert_eq!(normals.len(), 3); assert!( normals .iter() .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()) ); }
#[test] fn validate_model_preserves_parts() { let mut model = three_d_asset::Model { name: "test".into(), materials: Vec::new(), geometries: vec![ Primitive { name: "a".into(), transformation: Mat4::identity(), animations: Vec::new(), geometry: Geometry::Triangles(trimesh( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], &[0, 1, 2], )), material_index: None, }, Primitive { name: "b".into(), transformation: Mat4::identity(), animations: Vec::new(), geometry: Geometry::Triangles(trimesh( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], &[0, 1, 2], )), material_index: None, }, ], }; validate_model(&mut model).expect("valid model"); assert_eq!(model.geometries.len(), 2); assert!(matches!( model.geometries[0].geometry, Geometry::Triangles(_) )); assert!(matches!( model.geometries[1].geometry, Geometry::Triangles(_) )); }
#[test] fn validate_model_preserves_points() { let mut model = three_d_asset::Model { name: "points".into(), materials: Vec::new(), geometries: vec![Primitive { name: "cloud".into(), transformation: Mat4::identity(), animations: Vec::new(), geometry: Geometry::Points(PointCloud { positions: Positions::F32(vec![ Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), ]), colors: None, }), material_index: None, }], }; validate_model(&mut model).expect("valid point cloud"); assert!(matches!(model.geometries[0].geometry, Geometry::Points(_))); }}