atproto Thingiverse but good
10

Configure Feed

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

PM-70: renderer fidelity

Orual (Jun 29, 2026, 7:06 PM EDT) 3b4dcacc df4fdafc

+754 -275
+2
.cargo/config.toml
··· 1 + [target.wasm32-unknown-unknown] 2 + runner = "wasm-bindgen-test-runner"
+2
.gitignore
··· 30 30 # Impeccable design detector scratch (ignore lists, design sidecar, critique snapshots). 31 31 .impeccable/ 32 32 **.png 33 + !/public/models/box_textured.png 34 + !.cargo/ 33 35 !.cargo/config.toml
+62 -1
Cargo.lock
··· 769 769 ] 770 770 771 771 [[package]] 772 + name = "cast" 773 + version = "0.3.0" 774 + source = "registry+https://github.com/rust-lang/crates.io-index" 775 + checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" 776 + 777 + [[package]] 772 778 name = "cbor4ii" 773 779 version = "0.2.14" 774 780 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5416 5422 ] 5417 5423 5418 5424 [[package]] 5425 + name = "minicov" 5426 + version = "0.3.8" 5427 + source = "registry+https://github.com/rust-lang/crates.io-index" 5428 + checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" 5429 + dependencies = [ 5430 + "cc", 5431 + "walkdir", 5432 + ] 5433 + 5434 + [[package]] 5419 5435 name = "miniz_oxide" 5420 5436 version = "0.8.9" 5421 5437 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5867 5883 version = "1.21.4" 5868 5884 source = "registry+https://github.com/rust-lang/crates.io-index" 5869 5885 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 5886 + 5887 + [[package]] 5888 + name = "oorandom" 5889 + version = "11.1.5" 5890 + source = "registry+https://github.com/rust-lang/crates.io-index" 5891 + checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" 5870 5892 5871 5893 [[package]] 5872 5894 name = "opaque-debug" ··· 6400 6422 "js-sys", 6401 6423 "polymodel-mesh", 6402 6424 "polymodel-renderer-protocol", 6403 - "serde_json", 6404 6425 "spinning_top", 6405 6426 "talc", 6406 6427 "three-d", ··· 6409 6430 "tracing-wasm", 6410 6431 "wasm-bindgen", 6411 6432 "wasm-bindgen-futures", 6433 + "wasm-bindgen-test", 6412 6434 "web-sys", 6413 6435 ] 6414 6436 ··· 9268 9290 dependencies = [ 9269 9291 "unicode-ident", 9270 9292 ] 9293 + 9294 + [[package]] 9295 + name = "wasm-bindgen-test" 9296 + version = "0.3.71" 9297 + source = "registry+https://github.com/rust-lang/crates.io-index" 9298 + checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" 9299 + dependencies = [ 9300 + "async-trait", 9301 + "cast", 9302 + "js-sys", 9303 + "libm", 9304 + "minicov", 9305 + "nu-ansi-term", 9306 + "num-traits", 9307 + "oorandom", 9308 + "serde", 9309 + "serde_json", 9310 + "wasm-bindgen", 9311 + "wasm-bindgen-futures", 9312 + "wasm-bindgen-test-macro", 9313 + "wasm-bindgen-test-shared", 9314 + ] 9315 + 9316 + [[package]] 9317 + name = "wasm-bindgen-test-macro" 9318 + version = "0.3.71" 9319 + source = "registry+https://github.com/rust-lang/crates.io-index" 9320 + checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" 9321 + dependencies = [ 9322 + "proc-macro2", 9323 + "quote", 9324 + "syn 2.0.118", 9325 + ] 9326 + 9327 + [[package]] 9328 + name = "wasm-bindgen-test-shared" 9329 + version = "0.2.121" 9330 + source = "registry+https://github.com/rust-lang/crates.io-index" 9331 + checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" 9271 9332 9272 9333 [[package]] 9273 9334 name = "wasm-streams"
+1 -1
crates/polymodel-mesh/Cargo.toml
··· 3 3 version = "0.1.0" 4 4 edition = "2024" 5 5 license = "MIT OR Apache-2.0" 6 - description = "Multi-format mesh ingestion: STL/OBJ/glTF parsing into a renderer-agnostic TriMesh." 6 + description = "Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker." 7 7 8 8 [dependencies] 9 9 polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" }
+58 -6
crates/polymodel-mesh/src/companions.rs
··· 9 9 10 10 use serde_json::Value; 11 11 12 - /// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:` 13 - /// `buffers[].uri` and `images[].uri`. 12 + use crate::contract::MeshLoadError; 13 + 14 + /// Discover safe companion-resource URIs referenced by a `.gltf` document: every 15 + /// non-`data:` `buffers[].uri` and `images[].uri`. 14 16 /// 15 17 /// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so 16 18 /// callers can feed any primary without branching on format. 17 - pub fn discover_companion_uris(primary: &[u8]) -> Vec<String> { 19 + pub fn discover_companion_uris(primary: &[u8]) -> Result<Vec<String>, MeshLoadError> { 20 + raw_companion_uris(primary) 21 + .into_iter() 22 + .map(|uri| { 23 + if is_safe_relative_uri(&uri) { 24 + Ok(uri) 25 + } else { 26 + Err(MeshLoadError::UnsafeCompanionUri(uri)) 27 + } 28 + }) 29 + .collect() 30 + } 31 + 32 + fn raw_companion_uris(primary: &[u8]) -> Vec<String> { 18 33 let Ok(Value::Object(root)) = serde_json::from_slice::<Value>(primary) else { 19 34 return Vec::new(); 20 35 }; ··· 112 127 ], 113 128 "images": [{"uri": "tex.png"}, {"bufferView": 0}] 114 129 }"#; 115 - let mut uris = discover_companion_uris(gltf); 130 + let mut uris = discover_companion_uris(gltf).expect("safe companion URIs"); 116 131 uris.sort(); 117 132 assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]); 118 133 } 119 134 120 135 #[test] 136 + fn safe_discovery_rejects_unsafe_uris() { 137 + for uri in [ 138 + "https://evil.example/x.bin", 139 + "//evil.example/x.bin", 140 + "../escape.bin", 141 + "%2e%2e/escape.bin", 142 + ] { 143 + let gltf = format!(r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}"}}]}}"#); 144 + assert!(matches!( 145 + discover_companion_uris(gltf.as_bytes()), 146 + Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri 147 + )); 148 + } 149 + } 150 + 151 + #[test] 152 + fn safe_discovery_still_skips_data_urls() { 153 + let gltf = br#"{ 154 + "asset": {"version": "2.0"}, 155 + "buffers": [{"uri": "data:application/octet-stream;base64,AAAA"}], 156 + "images": [{"uri": "texture.png"}] 157 + }"#; 158 + assert_eq!( 159 + discover_companion_uris(gltf).expect("safe discovery"), 160 + vec!["texture.png".to_string()] 161 + ); 162 + } 163 + 164 + #[test] 121 165 fn discovers_no_uris_from_non_json() { 122 - assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty()); 123 - assert!(discover_companion_uris(b"not json at all").is_empty()); 166 + assert!( 167 + discover_companion_uris(b"glTF\x00\x00\x00\x00") 168 + .expect("safe discovery") 169 + .is_empty() 170 + ); 171 + assert!( 172 + discover_companion_uris(b"not json at all") 173 + .expect("safe discovery") 174 + .is_empty() 175 + ); 124 176 } 125 177 126 178 #[test]
+69 -15
crates/polymodel-mesh/src/contract.rs
··· 1 - //! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance. 1 + //! Mesh contract types: the renderer-facing CPU model and its provenance. 2 2 //! 3 3 //! [`MeshFormat`], [`LengthUnit`], and [`Units`] are re-exported from 4 4 //! [`polymodel_renderer_protocol`] so the app, worker, and mesh crate share a single ··· 6 6 7 7 pub use polymodel_renderer_protocol::{LengthUnit, MeshFormat, Units}; 8 8 9 - // `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and 10 - // `TriMesh` is a public field type, so both are re-exported alongside the contract. 11 - pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh}; 9 + // These `three-d-asset` types are the owned CPU-side contract shared with the renderer 10 + // worker. They do not include GPU/runtime state. 11 + pub use three_d_asset::{AxisAlignedBoundingBox, Geometry, Model, PointCloud, TriMesh}; 12 12 13 13 /// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the 14 14 /// camera should orbit and a bounding-radius estimate to frame it. ··· 20 20 pub radius: f32, 21 21 } 22 22 23 - /// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. 23 + /// A Polymodel model: owned CPU geometry/material data plus format provenance and units. 24 24 /// 25 - /// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet 26 - /// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`]. 25 + /// glTF/OBJ keep the per-primitive geometry/material structure exposed by 26 + /// `three-d-asset`; STL is represented as a one-part triangle model. Triangle normals are 27 + /// geometry-derived during validation. Point-cloud geometry is preserved even though the 28 + /// current worker renders triangles first. 27 29 #[derive(Debug)] 28 30 pub struct ModelMesh { 29 - /// The underlying triangle mesh. 30 - pub trimesh: TriMesh, 31 + /// The owned CPU model, including primitive boundaries, transforms, materials, and 32 + /// decoded textures. 33 + pub model: Model, 31 34 /// The format this mesh was loaded from. 32 35 pub format: MeshFormat, 33 36 /// Unit provenance and assumption. ··· 35 38 } 36 39 37 40 impl ModelMesh { 38 - /// The axis-aligned bounding box over all vertex positions. 41 + /// Construct a model contract from an owned CPU model. 42 + pub fn new(model: Model, format: MeshFormat, units: Units) -> Self { 43 + Self { 44 + model, 45 + format, 46 + units, 47 + } 48 + } 49 + 50 + /// Number of CPU primitives/parts preserved from the source model. 51 + pub fn part_count(&self) -> usize { 52 + self.model.geometries.len() 53 + } 54 + 55 + /// The axis-aligned bounding box over all preserved primitive positions in world space. 39 56 pub fn aabb(&self) -> AxisAlignedBoundingBox { 40 - self.trimesh.compute_aabb() 57 + let mut aabb = AxisAlignedBoundingBox::EMPTY; 58 + for primitive in &self.model.geometries { 59 + match &primitive.geometry { 60 + Geometry::Triangles(mesh) => { 61 + aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions( 62 + &mesh.positions.to_f32(), 63 + primitive.transformation, 64 + )); 65 + } 66 + Geometry::Points(points) => { 67 + aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions( 68 + &points.positions.to_f32(), 69 + primitive.transformation, 70 + )); 71 + } 72 + } 73 + } 74 + aabb 41 75 } 42 76 43 77 /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and ··· 66 100 /// missing or undecodable companion resource (buffer/texture). 67 101 #[error("mesh decode failed: {0}")] 68 102 Decode(String), 103 + /// A glTF companion URI is unsafe to fetch or load. 104 + #[error("unsafe companion URI: {0}")] 105 + UnsafeCompanionUri(String), 69 106 /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a 70 107 /// non-finite position, or a point-sized bounding box. 71 108 #[error("mesh is degenerate")] ··· 79 116 80 117 /// Lightweight stats for reporting back to the main thread. 81 118 impl ModelMesh { 82 - /// Number of unique vertex positions. 119 + /// Number of source positions across all preserved parts. 120 + /// 121 + /// This is a sum of part-local triangle vertices and point-cloud points, not a global 122 + /// deduplicated vertex count. 83 123 pub fn vertex_count(&self) -> u32 { 84 - self.trimesh.positions.len() as u32 124 + self.model 125 + .geometries 126 + .iter() 127 + .map(|primitive| match &primitive.geometry { 128 + Geometry::Triangles(mesh) => mesh.positions.len() as u32, 129 + Geometry::Points(points) => points.positions.len() as u32, 130 + }) 131 + .sum() 85 132 } 86 133 87 - /// Number of triangles. 134 + /// Number of triangles across triangle parts. Point-cloud parts contribute zero. 88 135 pub fn triangle_count(&self) -> u32 { 89 - self.trimesh.triangle_count() as u32 136 + self.model 137 + .geometries 138 + .iter() 139 + .map(|primitive| match &primitive.geometry { 140 + Geometry::Triangles(mesh) => mesh.triangle_count() as u32, 141 + Geometry::Points(_) => 0, 142 + }) 143 + .sum() 90 144 } 91 145 }
+101 -24
crates/polymodel-mesh/src/gltf.rs
··· 5 5 //! alone. A multi-file `.gltf` resolves its external `.bin`/texture companions from the 6 6 //! source's [`MeshResources`], inserted under their exact raw URI so `three-d-asset`'s 7 7 //! `base_path.join(uri)` lookup is exact (it is not asked to discover dependencies 8 - //! itself). Textures are decoded during deserialize (the `image`/`png` features are on) 9 - //! and then discarded — [`TriMesh`](three_d_asset::TriMesh) carries no materials — but a 10 - //! referenced-but-missing or undecodable texture fails the whole parse. glTF's base unit 11 - //! is the metre. No filesystem or network access, so this is safe from 8 + //! itself). Textures and materials decoded by `three-d-asset` stay in the owned CPU model. 9 + //! glTF's base unit is the metre. No filesystem or network access, so this is safe from 12 10 //! `wasm32-unknown-unknown`. 13 11 14 12 use three_d_asset::io; 15 13 14 + use crate::companions::{discover_companion_uris, is_safe_relative_uri}; 16 15 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; 17 16 use crate::parser::MeshSource; 18 17 19 18 /// Parse a glTF/GLB asset (plus companions) into a [`ModelMesh`]. 20 19 /// 21 20 /// The GLB magic (`b"glTF"`) is sniffed to pick the `.glb` vs `.gltf` key. Node world 22 - /// transforms are baked into positions by the shared flattener; geometry normals are 23 - /// derived by the shared validator. 21 + /// transforms are retained on primitives; geometry normals are derived by the shared 22 + /// validator for triangle primitives. 24 23 pub fn load_gltf(source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 25 24 if source.primary.is_empty() { 26 25 return Err(MeshLoadError::Empty); ··· 30 29 } else { 31 30 "asset.gltf" 32 31 }; 32 + discover_companion_uris(source.primary)?; 33 33 let mut assets = io::RawAssets::new(); 34 34 assets.insert(key, source.primary.to_vec()); 35 35 for (uri, bytes) in source.resources.iter() { 36 + if !is_safe_relative_uri(uri) { 37 + return Err(MeshLoadError::UnsafeCompanionUri(uri.to_string())); 38 + } 36 39 assets.insert(uri, bytes.to_vec()); 37 40 } 38 - let model: three_d_asset::Model = assets 41 + let mut model: three_d_asset::Model = assets 39 42 .deserialize(key) 40 43 .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 41 - let mut trimesh = crate::flatten_model(model); 42 - crate::validate(&mut trimesh)?; 43 - Ok(ModelMesh { 44 - trimesh, 45 - format: MeshFormat::Gltf, 46 - units: Units::declared(LengthUnit::Meter), 47 - }) 44 + crate::validate_model(&mut model)?; 45 + Ok(ModelMesh::new( 46 + model, 47 + MeshFormat::Gltf, 48 + Units::declared(LengthUnit::Meter), 49 + )) 48 50 } 49 51 50 52 #[cfg(test)] ··· 52 54 use super::load_gltf; 53 55 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; 54 56 use crate::parser::{MeshResources, MeshSource}; 57 + use three_d_asset::{Geometry, TriMesh}; 55 58 56 59 fn fixture(name: &str) -> Vec<u8> { 57 - let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); 60 + let path = format!("{}/../../public/models/{name}", env!("CARGO_MANIFEST_DIR")); 58 61 std::fs::read(&path) 59 62 .unwrap_or_else(|_| panic!("glTF fixture {name} should be committed at {path}")) 60 63 } ··· 76 79 ); 77 80 } 78 81 82 + fn first_triangle_mesh(model: &ModelMesh) -> &TriMesh { 83 + model 84 + .model 85 + .geometries 86 + .iter() 87 + .find_map(|primitive| match &primitive.geometry { 88 + Geometry::Triangles(mesh) => Some(mesh), 89 + Geometry::Points(_) => None, 90 + }) 91 + .expect("model should contain triangle geometry") 92 + } 93 + 79 94 #[test] 80 95 fn parses_self_contained_glb() { 81 96 let primary = fixture("box.glb"); ··· 87 102 let model = load_gltf(&source).expect("box.glb should parse"); 88 103 assert_eq!(model.format, MeshFormat::Gltf); 89 104 assert_meter_units(&model); 90 - assert_eq!(model.trimesh.triangle_count(), 12); 91 - assert!(model.trimesh.normals.is_some()); 105 + assert_eq!(model.triangle_count(), 12); 106 + assert!(first_triangle_mesh(&model).normals.is_some()); 92 107 } 93 108 94 109 #[test] 95 - fn parses_glb_with_baked_transform_and_multiple_primitives() { 110 + fn parses_glb_with_world_space_bounds_and_multiple_primitives() { 96 111 let primary = fixture("box_translated.glb"); 97 112 let source = MeshSource { 98 113 format: MeshFormat::Gltf, ··· 100 115 resources: MeshResources::empty(), 101 116 }; 102 117 let model = load_gltf(&source).expect("box_translated.glb should parse"); 103 - assert_eq!(model.trimesh.triangle_count(), 24); 118 + assert!(model.part_count() > 1); 119 + assert_eq!(model.triangle_count(), 24); 104 120 let center = model.aabb().center(); 105 121 assert!( 106 122 (center.x - 10.0).abs() < 0.5, 107 - "node translation (+10 X) is baked into world-space positions: got center.x = {}", 123 + "node translation (+10 X) is reflected in world-space bounds: got center.x = {}", 108 124 center.x 109 125 ); 110 126 } ··· 119 135 }; 120 136 let model = load_gltf(&source).expect("embedded-base64 .gltf should parse"); 121 137 assert_meter_units(&model); 122 - assert_eq!(model.trimesh.triangle_count(), 12); 138 + assert_eq!(model.triangle_count(), 12); 123 139 } 124 140 125 141 #[test] ··· 133 149 }; 134 150 let model = load_gltf(&source).expect("external-buffer .gltf should parse"); 135 151 assert_meter_units(&model); 136 - assert_eq!(model.trimesh.triangle_count(), 12); 152 + assert_eq!(model.triangle_count(), 12); 153 + assert!( 154 + model 155 + .model 156 + .materials 157 + .iter() 158 + .any(|material| material.albedo != three_d_asset::Srgba::WHITE), 159 + "box.gltf should preserve a source base color factor" 160 + ); 137 161 } 138 162 139 163 #[test] ··· 153 177 resources: MeshResources::new(&comps), 154 178 }; 155 179 let model = load_gltf(&source).expect("exact box.bin must win over box.bin.bak"); 156 - assert_eq!(model.trimesh.triangle_count(), 12); 180 + assert_eq!(model.triangle_count(), 12); 157 181 } 158 182 159 183 #[test] ··· 170 194 }; 171 195 let model = load_gltf(&source).expect("textured .gltf should parse"); 172 196 assert_meter_units(&model); 173 - assert_eq!(model.trimesh.triangle_count(), 12); 197 + assert_eq!(model.triangle_count(), 12); 198 + assert!( 199 + first_triangle_mesh(&model).uvs.is_some(), 200 + "textured box keeps UVs" 201 + ); 202 + assert!( 203 + model 204 + .model 205 + .materials 206 + .iter() 207 + .any(|material| material.albedo_texture.is_some()), 208 + "textured box keeps decoded base-color texture data" 209 + ); 210 + } 211 + 212 + #[test] 213 + fn rejects_unsafe_resource_key_even_if_caller_bypasses_discovery() { 214 + let primary = br#"{ 215 + "asset": {"version": "2.0"}, 216 + "buffers": [{"uri": "../escape.bin", "byteLength": 4}] 217 + }"#; 218 + let comps = vec![("../escape.bin".to_string(), vec![0, 0, 0, 0])]; 219 + let source = MeshSource { 220 + format: MeshFormat::Gltf, 221 + primary, 222 + resources: MeshResources::new(&comps), 223 + }; 224 + assert!(matches!( 225 + load_gltf(&source), 226 + Err(MeshLoadError::UnsafeCompanionUri(uri)) if uri == "../escape.bin" 227 + )); 228 + } 229 + 230 + #[test] 231 + fn rejects_unsafe_primary_companion_uri_even_without_resource() { 232 + for uri in [ 233 + "https://evil.example/x.bin", 234 + "//evil.example/x.bin", 235 + "../escape.bin", 236 + "%2e%2e/escape.bin", 237 + ] { 238 + let primary = format!( 239 + r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}","byteLength":4}}]}}"# 240 + ); 241 + let source = MeshSource { 242 + format: MeshFormat::Gltf, 243 + primary: primary.as_bytes(), 244 + resources: MeshResources::empty(), 245 + }; 246 + assert!(matches!( 247 + load_gltf(&source), 248 + Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri 249 + )); 250 + } 174 251 } 175 252 176 253 #[test]
+98 -65
crates/polymodel-mesh/src/lib.rs
··· 1 - //! Multi-format mesh ingestion: STL/OBJ/glTF/3MF parsing into a renderer-agnostic 2 - //! [`TriMesh`]. 1 + //! Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker. 3 2 //! 4 - //! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over 5 - //! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a 6 - //! camera framing hint. STL, OBJ, glTF (GLB, embedded-base64, or multi-file `.gltf` 7 - //! with external companions), and 3MF are all parsed from in-memory bytes via 8 - //! `three-d-asset`, so this crate compiles to `wasm32-unknown-unknown`. The parser seam 9 - //! in [`parser`] retains a [`RemoteMeshParser`](parser::RemoteMeshParser) trait for 10 - //! future server-side formats (e.g. STEP via opencascade.js). 3 + //! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over 4 + //! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera 5 + //! framing hint. STL, OBJ, glTF (GLB, embedded-base64, or multi-file `.gltf` with 6 + //! external companions), and 3MF are parsed from in-memory bytes via `three-d-asset`, so 7 + //! this crate compiles to `wasm32-unknown-unknown`. The parser seam in [`parser`] retains 8 + //! a [`RemoteMeshParser`](parser::RemoteMeshParser) trait for future server-side formats 9 + //! (e.g. STEP via opencascade.js). 10 + //! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over 11 + //! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera 12 + //! framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf` with 13 + //! external companions) are parsed from in-memory bytes via `three-d-asset`, so this crate 14 + //! compiles to `wasm32-unknown-unknown`. 3MF cannot target `wasm32` and is routed to a 15 + //! remote parser (PM-49); the parser seam in [`parser`] is remote-ready. 11 16 //! 12 17 //! [`MeshFormat`] / [`LengthUnit`] / [`Units`] are owned by [`polymodel_renderer_protocol`] 13 18 //! (the leaf crate shared with the app) to avoid a circular dependency. ··· 20 25 pub mod stl; 21 26 pub mod threemf; 22 27 23 - use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3}; 28 + use three_d_asset::{Geometry, Model, Positions, TriMesh, Vec3}; 24 29 25 30 use crate::contract::MeshLoadError; 26 31 ··· 35 40 /// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero; 36 41 /// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant). 37 42 /// 38 - /// Call this **after** any per-format transform baking, so the AABB and normals reflect 39 - /// world-space positions. 40 43 pub fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { 41 44 if trimesh.triangle_count() == 0 { 42 45 return Err(MeshLoadError::Degenerate); ··· 74 77 Ok(()) 75 78 } 76 79 80 + /// Validate and finalize a parsed CPU model while preserving primitive/material structure. 81 + pub fn validate_model(model: &mut Model) -> Result<(), MeshLoadError> { 82 + if model.geometries.is_empty() { 83 + return Err(MeshLoadError::Degenerate); 84 + } 85 + 86 + let mut has_positions = false; 87 + for primitive in &mut model.geometries { 88 + match &mut primitive.geometry { 89 + Geometry::Triangles(mesh) => { 90 + validate(mesh)?; 91 + has_positions = true; 92 + } 93 + Geometry::Points(points) => { 94 + validate_point_positions(&mut points.positions)?; 95 + has_positions = true; 96 + } 97 + } 98 + } 99 + 100 + if has_positions { 101 + Ok(()) 102 + } else { 103 + Err(MeshLoadError::Degenerate) 104 + } 105 + } 106 + 107 + fn validate_point_positions(positions: &mut Positions) -> Result<(), MeshLoadError> { 108 + if positions.is_empty() || !positions_all_finite(positions) { 109 + return Err(MeshLoadError::Degenerate); 110 + } 111 + let size = positions.compute_aabb().size(); 112 + if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON { 113 + return Err(MeshLoadError::Degenerate); 114 + } 115 + if let Positions::F64(f64s) = positions { 116 + *positions = Positions::F32( 117 + f64s.iter() 118 + .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32)) 119 + .collect(), 120 + ); 121 + } 122 + Ok(()) 123 + } 124 + 77 125 fn positions_all_finite(positions: &Positions) -> bool { 78 126 match positions { 79 127 Positions::F32(vs) => vs ··· 85 133 } 86 134 } 87 135 88 - /// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single 89 - /// [`TriMesh`], shared by the OBJ and glTF loaders. 90 - /// 91 - /// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world 92 - /// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving 93 - /// positions in local space, so each geometry is baked here (local → world) as it is 94 - /// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g. 95 - /// point clouds) is dropped. All positions are unified to F32 (OBJ arrives as F64); 96 - /// [`validate`] recomputes normals afterwards. 97 - pub fn flatten_model(model: three_d_asset::Model) -> TriMesh { 98 - let mut positions: Vec<Vec3> = Vec::new(); 99 - let mut indices: Vec<u32> = Vec::new(); 100 - for primitive in model.geometries { 101 - let Geometry::Triangles(mesh) = primitive.geometry else { 102 - continue; 103 - }; 104 - let base = positions.len() as u32; 105 - for p in mesh.positions.to_f32() { 106 - positions.push(transform_point(primitive.transformation, p)); 107 - } 108 - match mesh.indices { 109 - Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)), 110 - Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), 111 - Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), 112 - Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)), 113 - } 114 - } 115 - TriMesh { 116 - positions: Positions::F32(positions), 117 - indices: Indices::U32(indices), 118 - normals: None, 119 - tangents: None, 120 - uvs: None, 121 - colors: None, 122 - } 123 - } 124 - 125 - fn transform_point(m: Mat4, p: Vec3) -> Vec3 { 126 - Vec3::new( 127 - p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x, 128 - p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y, 129 - p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z, 130 - ) 131 - } 132 - 133 136 #[cfg(test)] 134 137 mod tests { 135 138 use super::*; 136 - use three_d_asset::{Indices, SquareMatrix}; 139 + use three_d_asset::{Indices, Mat4, PointCloud, Primitive, SquareMatrix}; 137 140 138 141 fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh { 139 142 TriMesh { ··· 208 211 } 209 212 210 213 #[test] 211 - fn flatten_bakes_translation_into_positions() { 212 - let model = three_d_asset::Model { 214 + fn validate_model_preserves_parts() { 215 + let mut model = three_d_asset::Model { 213 216 name: "test".into(), 214 217 materials: Vec::new(), 215 218 geometries: vec![ 216 - three_d_asset::Primitive { 219 + Primitive { 217 220 name: "a".into(), 218 221 transformation: Mat4::identity(), 219 222 animations: Vec::new(), ··· 223 226 )), 224 227 material_index: None, 225 228 }, 226 - three_d_asset::Primitive { 229 + Primitive { 227 230 name: "b".into(), 228 231 transformation: Mat4::identity(), 229 232 animations: Vec::new(), ··· 235 238 }, 236 239 ], 237 240 }; 238 - let flat = flatten_model(model); 239 - assert_eq!(flat.positions.len(), 6); 240 - assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]); 241 + validate_model(&mut model).expect("valid model"); 242 + assert_eq!(model.geometries.len(), 2); 243 + assert!(matches!( 244 + model.geometries[0].geometry, 245 + Geometry::Triangles(_) 246 + )); 247 + assert!(matches!( 248 + model.geometries[1].geometry, 249 + Geometry::Triangles(_) 250 + )); 251 + } 252 + 253 + #[test] 254 + fn validate_model_preserves_points() { 255 + let mut model = three_d_asset::Model { 256 + name: "points".into(), 257 + materials: Vec::new(), 258 + geometries: vec![Primitive { 259 + name: "cloud".into(), 260 + transformation: Mat4::identity(), 261 + animations: Vec::new(), 262 + geometry: Geometry::Points(PointCloud { 263 + positions: Positions::F32(vec![ 264 + Vec3::new(0.0, 0.0, 0.0), 265 + Vec3::new(1.0, 0.0, 0.0), 266 + ]), 267 + colors: None, 268 + }), 269 + material_index: None, 270 + }], 271 + }; 272 + validate_model(&mut model).expect("valid point cloud"); 273 + assert!(matches!(model.geometries[0].geometry, Geometry::Points(_))); 241 274 } 242 275 }
+28 -17
crates/polymodel-mesh/src/obj.rs
··· 13 13 14 14 /// Parse OBJ bytes into a [`ModelMesh`]. 15 15 /// 16 - /// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is 17 - /// unitless, so the result assumes millimetres. Positions are normalized to F32 by the 18 - /// shared validator (OBJ arrives as F64). 16 + /// Convex polygons are fan-triangulated; geometry and material groups exposed by 17 + /// `three-d-asset` are preserved. OBJ is unitless, so the result assumes millimetres. 18 + /// Positions are normalized to F32 by the shared validator (OBJ arrives as F64). 19 19 pub fn load_obj(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 20 20 if bytes.is_empty() { 21 21 return Err(MeshLoadError::Empty); 22 22 } 23 - let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) 23 + let mut model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) 24 24 .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 25 - let mut trimesh = crate::flatten_model(model); 26 - crate::validate(&mut trimesh)?; 27 - Ok(ModelMesh { 28 - trimesh, 29 - format: MeshFormat::Obj, 30 - units: Units::unitless_assumed_mm(), 31 - }) 25 + crate::validate_model(&mut model)?; 26 + Ok(ModelMesh::new( 27 + model, 28 + MeshFormat::Obj, 29 + Units::unitless_assumed_mm(), 30 + )) 32 31 } 33 32 34 33 #[cfg(test)] 35 34 mod tests { 36 35 use super::load_obj; 37 36 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 38 - use three_d_asset::Positions; 37 + use three_d_asset::{Geometry, Positions, TriMesh}; 39 38 40 39 fn fixture(name: &str) -> Vec<u8> { 41 40 let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); ··· 43 42 .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}")) 44 43 } 45 44 45 + fn first_triangle_mesh(model: &crate::contract::ModelMesh) -> &TriMesh { 46 + model 47 + .model 48 + .geometries 49 + .iter() 50 + .find_map(|primitive| match &primitive.geometry { 51 + Geometry::Triangles(mesh) => Some(mesh), 52 + Geometry::Points(_) => None, 53 + }) 54 + .expect("model should contain triangle geometry") 55 + } 56 + 46 57 #[test] 47 58 fn parses_quad_cube_fixture() { 48 59 let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse"); ··· 54 65 assumed: Some(LengthUnit::Millimeter), 55 66 } 56 67 ); 57 - assert_eq!(model.trimesh.triangle_count(), 12); 58 - assert!(matches!(model.trimesh.positions, Positions::F32(_))); 59 - let normals = model 60 - .trimesh 68 + assert_eq!(model.triangle_count(), 12); 69 + let mesh = first_triangle_mesh(&model); 70 + assert!(matches!(mesh.positions, Positions::F32(_))); 71 + let normals = mesh 61 72 .normals 62 73 .as_ref() 63 74 .expect("geometry normals should be populated"); 64 - assert_eq!(normals.len(), model.trimesh.positions.len()); 75 + assert_eq!(normals.len(), mesh.positions.len()); 65 76 assert!( 66 77 normals 67 78 .iter()
+54 -6
crates/polymodel-mesh/src/parser.rs
··· 1 1 //! Parser seam — shared parse boundary and format dispatch. 2 2 //! 3 - //! The data types here ([`MeshResources`], [`MeshSource`]) are the only thing that 4 - //! crosses the parse boundary. The dispatcher [`load_mesh`] is synchronous; the viewer is 5 - //! the sole async layer (it prefetches glTF companions). All four formats 6 - //! (STL/OBJ/glTF/3MF) parse client-side in the worker via `three-d-asset`. 3 + //! The data types here ([`MeshResources`], [`MeshSource`]) are the synchronous parse 4 + //! boundary. [`load_mesh_with_companions`] is the async boundary for callers that have a 5 + //! primary URL and a fetch function: it keeps glTF companion discovery and safety 6 + //! validation inside this crate. STL/OBJ/glTF/3MF parse client-side in the worker via 7 + //! `three-d-asset`; [`RemoteMeshParser`] remains the drop-in seam for future server-side 8 + //! conversions. 7 9 8 10 use crate::contract::{MeshFormat, MeshLoadError, ModelMesh}; 11 + use std::future::Future; 12 + 13 + /// Fetch and parse a mesh while keeping format-specific companion discovery in the mesh 14 + /// crate. 15 + /// 16 + /// The caller supplies bytes for a URL-like key; this function validates glTF companion 17 + /// URIs before invoking the fetcher for them. Companion bytes are inserted under their 18 + /// exact raw URI before parsing so `three-d-asset` resolves external buffers/textures 19 + /// without substring fallback. 20 + pub async fn load_mesh_with_companions<F, Fut>( 21 + primary_url: &str, 22 + format: MeshFormat, 23 + mut fetch: F, 24 + ) -> Result<ModelMesh, MeshLoadError> 25 + where 26 + F: FnMut(String) -> Fut, 27 + Fut: Future<Output = Result<Vec<u8>, MeshLoadError>>, 28 + { 29 + if format == MeshFormat::Threemf { 30 + return Err(MeshLoadError::UnsupportedFormat); 31 + } 32 + 33 + let primary = fetch(primary_url.to_string()).await?; 34 + let companions = if format == MeshFormat::Gltf { 35 + let base = primary_url.rsplit_once('/').map(|(base, _)| base); 36 + let mut companions = Vec::new(); 37 + for uri in crate::companions::discover_companion_uris(&primary)? { 38 + let companion_url = base.map_or_else(|| uri.clone(), |base| format!("{base}/{uri}")); 39 + let bytes = fetch(companion_url).await?; 40 + companions.push((uri, bytes)); 41 + } 42 + companions 43 + } else { 44 + Vec::new() 45 + }; 46 + 47 + let source = MeshSource { 48 + format, 49 + primary: &primary, 50 + resources: MeshResources::new(&companions), 51 + }; 52 + 53 + DefaultMeshParser::<Local>::default().parse(&source).await 54 + } 9 55 10 56 /// Borrowed views of a mesh's companion resources (external glTF buffers/textures), 11 57 /// keyed by their exact raw URI as referenced by the primary asset. ··· 199 245 }) 200 246 .expect("3MF dispatches to load_threemf"); 201 247 assert_eq!(threemf_mesh.format, MeshFormat::Threemf); 202 - assert_eq!(threemf_mesh.trimesh.triangle_count(), 25692); 248 + assert_eq!(threemf_mesh.triangle_count(), 25692); 203 249 } 204 250 205 251 #[test] ··· 219 265 .block_on(parser.parse(&source)) 220 266 .expect("3MF parses locally"); 221 267 assert_eq!(mesh.format, MeshFormat::Threemf); 222 - assert_eq!(mesh.trimesh.triangle_count(), 25692); 268 + assert_eq!(mesh.triangle_count(), 25692); 269 + assert_eq!(mesh.format, MeshFormat::Gltf); 270 + assert_eq!(mesh.triangle_count(), 12); 223 271 } 224 272 }
+42 -23
crates/polymodel-mesh/src/stl.rs
··· 14 14 15 15 use std::io::Cursor; 16 16 17 - use three_d_asset::{Indices, Positions, TriMesh, Vec3}; 17 + use three_d_asset::{ 18 + Geometry, Indices, Mat4, Model, Positions, Primitive, SquareMatrix, TriMesh, Vec3, 19 + }; 18 20 19 21 use crate::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; 20 22 ··· 61 63 62 64 crate::validate(&mut trimesh)?; 63 65 64 - Ok(ModelMesh { 65 - trimesh, 66 - format: MeshFormat::Stl, 67 - units: Units::unitless_assumed_mm(), 68 - }) 66 + let model = Model { 67 + name: "stl".to_string(), 68 + materials: Vec::new(), 69 + geometries: vec![Primitive { 70 + name: "stl".to_string(), 71 + transformation: Mat4::identity(), 72 + animations: Vec::new(), 73 + geometry: Geometry::Triangles(trimesh), 74 + material_index: None, 75 + }], 76 + }; 77 + 78 + Ok(ModelMesh::new( 79 + model, 80 + MeshFormat::Stl, 81 + Units::unitless_assumed_mm(), 82 + )) 69 83 } 70 84 71 85 #[cfg(test)] 72 86 mod tests { 73 87 use super::load_stl; 74 88 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 89 + use three_d_asset::{Geometry, TriMesh}; 75 90 76 91 fn tri(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Triangle { 77 92 stl_io::Triangle { ··· 119 134 ); 120 135 } 121 136 137 + fn stl_mesh(model: &crate::contract::ModelMesh) -> &TriMesh { 138 + assert_eq!(model.part_count(), 1); 139 + match &model.model.geometries[0].geometry { 140 + Geometry::Triangles(mesh) => mesh, 141 + Geometry::Points(_) => panic!("STL should load as triangle geometry"), 142 + } 143 + } 144 + 122 145 #[test] 123 146 fn parses_binary_stl() { 124 147 let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse"); ··· 130 153 assumed: Some(LengthUnit::Millimeter), 131 154 } 132 155 ); 133 - assert_eq!(model.trimesh.triangle_count(), 4); 134 - assert!(model.trimesh.positions.len() >= 4); 135 - let normals = model 136 - .trimesh 137 - .normals 138 - .as_ref() 139 - .expect("normals should be populated"); 140 - assert_eq!(normals.len(), model.trimesh.positions.len()); 156 + assert_eq!(model.triangle_count(), 4); 157 + let mesh = stl_mesh(&model); 158 + assert!(mesh.positions.len() >= 4); 159 + let normals = mesh.normals.as_ref().expect("normals should be populated"); 160 + assert_eq!(normals.len(), mesh.positions.len()); 141 161 } 142 162 143 163 #[test] ··· 154 174 endsolid polymodel\n"; 155 175 let model = load_stl(ascii).expect("ASCII STL should parse"); 156 176 assert_eq!(model.format, MeshFormat::Stl); 157 - assert_eq!(model.trimesh.triangle_count(), 1); 158 - assert!(model.trimesh.normals.is_some()); 177 + assert_eq!(model.triangle_count(), 1); 178 + assert!(stl_mesh(&model).normals.is_some()); 159 179 } 160 180 161 181 #[test] ··· 166 186 [0.0, 1.0, 0.0], 167 187 )])) 168 188 .expect("'solid '-header binary should parse via retry"); 169 - assert_eq!(model.trimesh.triangle_count(), 1); 170 - assert!(model.trimesh.normals.is_some()); 189 + assert_eq!(model.triangle_count(), 1); 190 + assert!(stl_mesh(&model).normals.is_some()); 171 191 } 172 192 173 193 #[test] ··· 178 198 [10.0, 0.0, 4.0], 179 199 )])) 180 200 .expect("planar mesh should parse"); 181 - assert_eq!(model.trimesh.triangle_count(), 1); 201 + assert_eq!(model.triangle_count(), 1); 182 202 } 183 203 184 204 #[test] ··· 204 224 [2.0, 0.0, 0.0], 205 225 )])) 206 226 .expect("collinear mesh should parse (not degenerate)"); 207 - let normals = model 208 - .trimesh 227 + let normals = stl_mesh(&model) 209 228 .normals 210 229 .as_ref() 211 230 .expect("normals should be populated"); ··· 274 293 assumed: Some(LengthUnit::Millimeter), 275 294 } 276 295 ); 277 - assert!(model.trimesh.normals.is_some()); 278 - assert!(model.trimesh.triangle_count() > 0); 296 + assert!(stl_mesh(&model).normals.is_some()); 297 + assert!(model.triangle_count() > 0); 279 298 } 280 299 }
+35 -22
crates/polymodel-mesh/src/threemf.rs
··· 2 2 //! 3 3 //! `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`, pure Rust), 4 4 //! which reads the OPC container, parses the model XML, applies object transforms, and 5 - //! produces a [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and 6 - //! `Indices::U32`. Multi-object builds, transforms, and components are merged into a 7 - //! single [`Model`] by `three-d-asset`; `flatten_model` concatenates them. 5 + //! produces an owned [`Model`](three_d_asset::Model). Multi-object builds, transforms, and 6 + //! components are surfaced through the `three-d-asset` CPU model and validated before they 7 + //! reach the renderer worker. 8 8 //! 9 9 //! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface 10 10 //! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres — ··· 29 29 } 30 30 let model: three_d_asset::Model = io::deserialize("asset.3mf", bytes.to_vec()) 31 31 .map_err(|e| MeshLoadError::Decode(e.to_string()))?; 32 - let mut trimesh = crate::flatten_model(model); 33 - crate::validate(&mut trimesh)?; 34 - Ok(ModelMesh { 35 - trimesh, 36 - format: MeshFormat::Threemf, 37 - units: Units::declared(LengthUnit::Millimeter), 38 - }) 32 + let mut model = model; 33 + crate::validate_model(&mut model)?; 34 + Ok(ModelMesh::new( 35 + model, 36 + MeshFormat::Threemf, 37 + Units::declared(LengthUnit::Millimeter), 38 + )) 39 39 } 40 40 41 41 #[cfg(test)] 42 42 mod tests { 43 43 use super::load_threemf; 44 44 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; 45 - use three_d_asset::Positions; 45 + use three_d_asset::{Geometry, Positions}; 46 46 47 47 fn fixture(name: &str) -> Vec<u8> { 48 48 let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); ··· 55 55 let model = load_threemf(&fixture("cube_gears.3mf")).expect("cube_gears.3mf should parse"); 56 56 assert_eq!(model.format, MeshFormat::Threemf); 57 57 assert_eq!(model.units, Units::declared(LengthUnit::Millimeter)); 58 - // cube_gears is a 17-object build; after flattening it's 25,692 triangles. 59 - assert_eq!(model.trimesh.triangle_count(), 25692); 60 - assert!(matches!(model.trimesh.positions, Positions::F32(_))); 58 + // cube_gears is a 17-object build with 25,692 triangles. 59 + assert_eq!(model.triangle_count(), 25692); 60 + let triangle_meshes: Vec<_> = model 61 + .model 62 + .geometries 63 + .iter() 64 + .filter_map(|primitive| match &primitive.geometry { 65 + Geometry::Triangles(mesh) => Some(mesh), 66 + Geometry::Points(_) => None, 67 + }) 68 + .collect(); 69 + assert!(!triangle_meshes.is_empty()); 70 + assert!( 71 + triangle_meshes 72 + .iter() 73 + .all(|mesh| matches!(mesh.positions, Positions::F32(_))) 74 + ); 61 75 let aabb = model.aabb(); 62 76 let size = aabb.size(); 63 77 assert!( 64 78 size.x > 0.0 && size.y > 0.0 && size.z > 0.0, 65 79 "AABB must be non-degenerate: {size:?}" 66 80 ); 67 - let normals = model 68 - .trimesh 69 - .normals 70 - .as_ref() 71 - .expect("geometry normals should be populated"); 72 - assert_eq!(normals.len(), model.trimesh.positions.len()); 73 81 assert!( 74 - normals 82 + triangle_meshes 75 83 .iter() 76 - .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), 84 + .all(|mesh| mesh.normals.as_ref().is_some_and(|normals| { 85 + normals.len() == mesh.positions.len() 86 + && normals 87 + .iter() 88 + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()) 89 + })), 77 90 "normals must be finite" 78 91 ); 79 92 }
+3 -3
crates/polymodel-renderer-protocol/src/lib.rs
··· 107 107 108 108 /// Lightweight mesh statistics returned to the main thread after a successful load. 109 109 /// 110 - /// The full [`TriMesh`](three_d_asset::TriMesh) stays in the worker; only counts cross 111 - /// back for display. 110 + /// The full model geometry stays in the worker; only aggregate counts cross back for 111 + /// display. 112 112 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 113 113 pub struct MeshStats { 114 - /// Number of unique vertices. 114 + /// Sum of part-local vertex positions across renderable geometry. 115 115 pub vertices: u32, 116 116 /// Number of triangles. 117 117 pub triangles: u32,
+3 -1
crates/polymodel-renderer-worker/Cargo.toml
··· 42 42 tracing = { version = "0.1", default-features = false, features = ["std"] } 43 43 tracing-wasm = "0.2" 44 44 getrandom = { version = "0.3", features = ["wasm_js"] } 45 - serde_json = "1.0" 45 + 46 + [dev-dependencies] 47 + wasm-bindgen-test = "0.3"
+182 -73
crates/polymodel-renderer-worker/src/worker.rs
··· 15 15 use wasm_bindgen_futures::spawn_local; 16 16 use web_sys::DedicatedWorkerGlobalScope; 17 17 18 - use polymodel_mesh::companions::{discover_companion_uris, is_safe_relative_uri}; 19 18 use polymodel_mesh::contract::ModelMesh; 20 - use polymodel_mesh::parser::{MeshResources, MeshSource, load_mesh}; 19 + use polymodel_mesh::parser::load_mesh_with_companions; 21 20 use polymodel_renderer_protocol::{ 22 21 KeyModifiers, MeshFormat, MeshStats, RendererCommand, RendererEvent, deserialize_command, 23 22 serialize_event, ··· 50 49 51 50 struct RendererState { 52 51 context: three_d::Context, 53 - mesh: Option<three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>>, 52 + model: Option<three_d::Model<three_d::PhysicalMaterial>>, 54 53 ambient_light: three_d::AmbientLight, 55 54 key_light: three_d::DirectionalLight, 56 55 fill_light: three_d::DirectionalLight, ··· 154 153 self.canvas = Some(canvas); 155 154 self.renderer = Some(RendererState { 156 155 context, 157 - mesh: None, 156 + model: None, 158 157 ambient_light, 159 158 key_light, 160 159 fill_light, ··· 175 174 model: &ModelMesh, 176 175 pixel_width: u32, 177 176 pixel_height: u32, 178 - ) -> ( 179 - three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>, 180 - three_d::Camera, 181 - three_d::OrbitControl, 182 - ) { 177 + ) -> Result< 178 + ( 179 + three_d::Model<three_d::PhysicalMaterial>, 180 + three_d::Camera, 181 + three_d::OrbitControl, 182 + ), 183 + String, 184 + > { 183 185 let fit = model.camera_fit(); 184 - let mesh = three_d::Gm::new( 185 - three_d::Mesh::new(context, &model.trimesh), 186 - three_d::PhysicalMaterial { 187 - albedo: three_d::Srgba::new_opaque(116, 180, 255), 188 - roughness: 0.72, 189 - metallic: 0.0, 190 - ..Default::default() 191 - }, 192 - ); 186 + let cpu_model = model_with_default_materials(model); 187 + let render_model = three_d::Model::<three_d::PhysicalMaterial>::new(context, &cpu_model) 188 + .map_err(|e| format!("renderer model: {e}"))?; 189 + if render_model.iter().next().is_none() { 190 + return Err("model contains no renderable triangle geometry".into()); 191 + } 193 192 let radius = fit.radius.max(0.001); 194 193 let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 195 194 let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); ··· 204 203 radius * 100.0, 205 204 ); 206 205 let control = three_d::OrbitControl::new(target, radius * 0.01, radius * 50.0); 207 - (mesh, camera, control) 206 + Ok((render_model, camera, control)) 207 + } 208 + 209 + fn model_with_default_materials(model: &ModelMesh) -> three_d_asset::Model { 210 + let mut cpu_model = model.model.clone(); 211 + let default_material_index = cpu_model.materials.len(); 212 + cpu_model.materials.push(three_d_asset::PbrMaterial { 213 + name: "polymodel default blue".to_string(), 214 + albedo: three_d_asset::Srgba::new_opaque(116, 180, 255), 215 + roughness: 0.72, 216 + metallic: 0.0, 217 + ..Default::default() 218 + }); 219 + for primitive in &mut cpu_model.geometries { 220 + if primitive.material_index.is_none() { 221 + primitive.material_index = Some(default_material_index); 222 + } 223 + } 224 + cpu_model 225 + } 226 + 227 + #[cfg(test)] 228 + mod tests { 229 + use super::*; 230 + use polymodel_mesh::gltf::load_gltf; 231 + use polymodel_mesh::parser::{MeshResources, MeshSource}; 232 + use polymodel_mesh::stl::load_stl; 233 + use three_d_asset::SquareMatrix; 234 + use wasm_bindgen_test::*; 235 + 236 + wasm_bindgen_test_configure!(run_in_browser); 237 + 238 + fn textured_box() -> ModelMesh { 239 + let primary = include_bytes!("../../../public/models/box_textured.gltf"); 240 + let companions = vec![ 241 + ( 242 + "box_textured.bin".to_string(), 243 + include_bytes!("../../../public/models/box_textured.bin").to_vec(), 244 + ), 245 + ( 246 + "box_textured.png".to_string(), 247 + include_bytes!("../../../public/models/box_textured.png").to_vec(), 248 + ), 249 + ]; 250 + let source = MeshSource { 251 + format: MeshFormat::Gltf, 252 + primary, 253 + resources: MeshResources::new(&companions), 254 + }; 255 + load_gltf(&source).expect("textured box should parse") 256 + } 257 + 258 + fn browser_context() -> three_d::Context { 259 + let canvas = web_sys::OffscreenCanvas::new(8, 8).expect("offscreen canvas"); 260 + let gl = canvas 261 + .get_context("webgl2") 262 + .expect("context lookup") 263 + .expect("webgl2 context") 264 + .dyn_into::<web_sys::WebGl2RenderingContext>() 265 + .expect("WebGL2 context type"); 266 + let glow_context = three_d::context::Context::from_webgl2_context(gl); 267 + three_d::Context::from_gl_context(Arc::new(glow_context)).expect("three-d context") 268 + } 269 + 270 + #[test] 271 + fn no_material_parts_get_polymodel_blue_default() { 272 + let stl = include_bytes!("../../../public/models/body_f_chest-v4.stl"); 273 + let model = load_stl(stl).expect("STL fixture should parse"); 274 + let cpu_model = model_with_default_materials(&model); 275 + 276 + assert_eq!(cpu_model.materials.len(), 1); 277 + assert_eq!( 278 + cpu_model.materials[0].albedo, 279 + three_d_asset::Srgba::new_opaque(116, 180, 255) 280 + ); 281 + assert_eq!(cpu_model.geometries[0].material_index, Some(0)); 282 + } 283 + 284 + #[wasm_bindgen_test] 285 + fn textured_gltf_builds_physical_material_with_albedo_texture() { 286 + let model = textured_box(); 287 + let context = browser_context(); 288 + let (render_model, _camera, _control) = 289 + build_scene(&context, &model, 8, 8).expect("scene builds"); 290 + 291 + assert!( 292 + render_model 293 + .iter() 294 + .any(|part| part.material.albedo_texture.is_some()), 295 + "rendered model should bind a base-colour texture handle" 296 + ); 297 + } 298 + 299 + #[wasm_bindgen_test] 300 + fn points_only_model_reports_no_renderable_triangle_geometry() { 301 + let model = ModelMesh::new( 302 + three_d_asset::Model { 303 + name: "points only".to_string(), 304 + geometries: vec![three_d_asset::Primitive { 305 + name: "points".to_string(), 306 + transformation: three_d_asset::Mat4::identity(), 307 + animations: Vec::new(), 308 + geometry: three_d_asset::Geometry::Points(three_d_asset::PointCloud { 309 + positions: three_d_asset::Positions::F32(vec![ 310 + three_d_asset::vec3(0.0, 0.0, 0.0), 311 + three_d_asset::vec3(1.0, 0.0, 0.0), 312 + ]), 313 + colors: None, 314 + }), 315 + material_index: None, 316 + }], 317 + materials: Vec::new(), 318 + }, 319 + MeshFormat::Gltf, 320 + polymodel_mesh::contract::Units::declared(polymodel_mesh::contract::LengthUnit::Meter), 321 + ); 322 + let context = browser_context(); 323 + 324 + let Err(message) = build_scene(&context, &model, 8, 8) else { 325 + panic!("points are preserved but not rendered yet"); 326 + }; 327 + assert_eq!(message, "model contains no renderable triangle geometry"); 328 + } 208 329 } 209 330 210 331 fn standard_lights( ··· 357 478 0.035, 0.043, 0.067, 1.0, 1.0, 358 479 )); 359 480 360 - if let Some(mesh) = &renderer.mesh { 481 + if let Some(model) = &renderer.model { 361 482 let _ = screen.render( 362 483 &renderer.camera, 363 - std::slice::from_ref(mesh), 484 + model.iter(), 364 485 &[ 365 486 &renderer.ambient_light, 366 487 &renderer.key_light, ··· 413 534 let pw = st.pixel_width; 414 535 let ph = st.pixel_height; 415 536 if let Some(renderer) = st.renderer.as_mut() { 416 - let (mesh, camera, control) = build_scene(&renderer.context, &model, pw, ph); 417 - renderer.mesh = Some(mesh); 418 - renderer.camera = camera; 419 - renderer.control = control; 537 + match build_scene(&renderer.context, &model, pw, ph) { 538 + Ok((render_model, camera, control)) => { 539 + renderer.model = Some(render_model); 540 + renderer.camera = camera; 541 + renderer.control = control; 542 + } 543 + Err(msg) => { 544 + st.post_event(&RendererEvent::MeshError { message: msg }); 545 + return; 546 + } 547 + } 420 548 } 421 549 422 550 st.needs_render = true; ··· 435 563 expected_size: Option<i64>, 436 564 expected_digest: Option<Vec<u8>>, 437 565 ) -> Result<ModelMesh, String> { 438 - let primary_bytes = fetch_bytes(primary_url) 439 - .await 440 - .map_err(|e| format!("fetch primary: {e}"))?; 441 - 442 - if expected_size.is_some() || expected_digest.is_some() { 443 - let size = expected_size.unwrap_or(primary_bytes.len() as i64); 444 - if let Err(e) = polymodel_renderer_protocol::verify_part_file( 445 - &primary_bytes, 446 - size, 447 - expected_digest.as_deref(), 448 - ) { 449 - return Err(format!("part file verification failed: {e}")); 450 - } 451 - } 452 - 453 - let companions = if format == MeshFormat::Gltf { 454 - let uris = discover_companion_uris(&primary_bytes); 455 - let mut comps: Vec<(String, Vec<u8>)> = Vec::new(); 456 - for uri in &uris { 457 - if !is_safe_relative_uri(uri) { 458 - return Err(format!("unsafe companion URI: {uri}")); 566 + load_mesh_with_companions(primary_url, format, |url| { 567 + let verify_primary = url == primary_url; 568 + let expected_digest = expected_digest.clone(); 569 + async move { 570 + let bytes = fetch_bytes(&url).await.map_err(|e| { 571 + polymodel_mesh::contract::MeshLoadError::Decode(format!("fetch {url}: {e}")) 572 + })?; 573 + if verify_primary && (expected_size.is_some() || expected_digest.is_some()) { 574 + let size = expected_size.unwrap_or(bytes.len() as i64); 575 + polymodel_renderer_protocol::verify_part_file( 576 + &bytes, 577 + size, 578 + expected_digest.as_deref(), 579 + ) 580 + .map_err(|e| { 581 + polymodel_mesh::contract::MeshLoadError::Decode(format!( 582 + "part file verification failed: {e}" 583 + )) 584 + })?; 459 585 } 460 - let base = primary_url 461 - .rsplit_once('/') 462 - .map(|(base, _)| base) 463 - .unwrap_or(""); 464 - let companion_url = if base.is_empty() { 465 - uri.clone() 466 - } else { 467 - format!("{base}/{uri}") 468 - }; 469 - let bytes = fetch_bytes(&companion_url) 470 - .await 471 - .map_err(|e| format!("fetch companion {uri}: {e}"))?; 472 - comps.push((uri.clone(), bytes)); 586 + Ok(bytes) 473 587 } 474 - comps 475 - } else { 476 - Vec::new() 477 - }; 478 - 479 - let source = MeshSource { 480 - format, 481 - primary: &primary_bytes, 482 - resources: MeshResources::new(&companions), 483 - }; 484 - 485 - load_mesh(&source).map_err(|e| e.to_string()) 588 + }) 589 + .await 590 + .map_err(|e| e.to_string()) 486 591 } 487 592 488 593 // --------------------------------------------------------------------------- ··· 542 647 let glow_context = three_d::context::Context::from_webgl2_context(gl); 543 648 let context = three_d::Context::from_gl_context(Arc::new(glow_context)) 544 649 .map_err(|e| format!("preview three-d context: {e}"))?; 545 - let (mesh, camera, _control) = build_scene(&context, model, width, height); 650 + let (model, camera, _control) = build_scene(&context, model, width, height)?; 546 651 let (ambient_light, key_light, fill_light) = standard_lights(&context); 547 652 let screen = three_d::RenderTarget::screen(&context, width, height); 548 653 let _ = screen 549 654 .clear(three_d::ClearState::color_and_depth( 550 655 0.035, 0.043, 0.067, 1.0, 1.0, 551 656 )) 552 - .render(&camera, [&mesh], &[&ambient_light, &key_light, &fill_light]); 657 + .render( 658 + &camera, 659 + model.iter(), 660 + &[&ambient_light, &key_light, &fill_light], 661 + ); 553 662 554 663 offscreen_canvas_png_bytes(&canvas).await 555 664 }
+4
flake.nix
··· 70 70 71 71 nodejs 72 72 esbuild 73 + chromium 74 + chromedriver 73 75 playwright-driver.browsers 74 76 75 77 just ··· 109 111 110 112 shellHook = '' 111 113 export PATH="${pkgs.dioxus-cli}/bin:${pkgs.wasm-bindgen-cli}/bin:$PATH"; export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$NIX_LD_LIBRARY_PATH" 114 + export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="wasm-bindgen-test-runner" 115 + export CHROME="${pkgs.chromium}/bin/chromium" 112 116 export CC_wasm32_unknown_unknown="${pkgs.llvmPackages_18.clang-unwrapped}/bin/clang" 113 117 export AR_wasm32_unknown_unknown="${pkgs.llvmPackages_18.bintools-unwrapped}/bin/llvm-ar" 114 118
+6 -2
justfile
··· 45 45 test-server: 46 46 cargo nextest run -p polymodel --features server 47 47 48 + # Run browser-backed wasm tests for the renderer worker. 49 + test-renderer: 50 + RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo test -p polymodel-renderer-worker --target wasm32-unknown-unknown 51 + 48 52 # Run all local validation expected before review. 49 - test-all: fix check lint test test-server 53 + test-all: fix check lint test test-server test-renderer 50 54 # Run browser end-to-end tests. 51 55 e2e: 52 56 cd e2e && npm test ··· 94 98 @echo "==> AC.1b: checking app Cargo.toml for forbidden deps..." 95 99 @if grep -qE '^\s*(three-d|three-d-asset|stl_io|polymodel-mesh)\b' Cargo.toml; then echo "FAIL: forbidden dep found in Cargo.toml"; exit 1; else echo "PASS: no forbidden deps in Cargo.toml"; fi 96 100 @echo "==> AC.2: checking app wasm bundle size (debug build, debuginfo-dominated)..." 97 - @APP_WASM="target/dx/polymodel/debug/web/public/wasm/polymodel_bg.wasm" && test -f "$APP_WASM" && SIZE=$(stat -c%s "$APP_WASM") && echo "app wasm: $SIZE bytes" && test "$SIZE" -lt 125829120 && echo "PASS: app wasm < 120MB ceiling" || { echo "FAIL: app wasm >= 120MB or not found"; exit 1; } 101 + @APP_WASM="target/dx/polymodel/debug/web/public/wasm/polymodel_bg.wasm" && test -f "$APP_WASM" && SIZE=$(stat -c%s "$APP_WASM") && echo "app wasm: $SIZE bytes" && test "$SIZE" -lt 134217728 && echo "PASS: app wasm < 128MB ceiling" || { echo "FAIL: app wasm >= 128MB or not found"; exit 1; } 98 102 @echo "==> AC.2b: checking worker wasm exists..." 99 103 @test -f public/renderer_worker_bg.wasm && echo "PASS: worker wasm exists" || { echo "FAIL: worker wasm not found"; exit 1; } 100 104 @echo "==> All renderer-split checks passed."
-16
src/profile.rs
··· 859 859 } 860 860 } 861 861 862 - fn fixture_actor(did: &str, handle: &str) -> library::Actor { 863 - library::Actor { 864 - avatar: None, 865 - description: None, 866 - did: Did::new_owned(did).unwrap(), 867 - display_name: None, 868 - follower_count: None, 869 - following_count: None, 870 - handle: Handle::new_owned(handle).unwrap(), 871 - pronouns: None, 872 - thing_count: None, 873 - viewer: None, 874 - extra_data: None, 875 - } 876 - } 877 - 878 862 #[test] 879 863 fn profile_state_distinguishes_loading_invalid_error_and_populated() { 880 864 assert_eq!(profile_state(true, None), ProfileState::Loading);
+4
src/thing_card.rs
··· 1 1 use dioxus::prelude::*; 2 2 #[cfg(test)] 3 + use jacquard_common::deps::smol_str::SmolStr; 4 + #[cfg(test)] 5 + use jacquard_common::types::cid::Cid; 6 + #[cfg(test)] 3 7 use jacquard_common::types::{ 4 8 string::{AtUri, Cid, Datetime, Did, Handle}, 5 9 uri::UriValue,