diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..10762f9 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +runner = "wasm-bindgen-test-runner" diff --git a/.gitignore b/.gitignore index 25088ae..d924502 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,6 @@ rustc-ice-*.txt # Impeccable design detector scratch (ignore lists, design sidecar, critique snapshots). .impeccable/ **.png +!/public/models/box_textured.png +!.cargo/ !.cargo/config.toml diff --git a/Cargo.lock b/Cargo.lock index cf00400..b5b4d04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -768,6 +768,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbor4ii" version = "0.2.14" @@ -5415,6 +5421,16 @@ dependencies = [ "triomphe", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5868,6 +5884,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -6400,7 +6422,6 @@ dependencies = [ "js-sys", "polymodel-mesh", "polymodel-renderer-protocol", - "serde_json", "spinning_top", "talc", "three-d", @@ -6409,6 +6430,7 @@ dependencies = [ "tracing-wasm", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", "web-sys", ] @@ -9269,6 +9291,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" + [[package]] name = "wasm-streams" version = "0.4.2" diff --git a/crates/polymodel-mesh/Cargo.toml b/crates/polymodel-mesh/Cargo.toml index db5a344..94c665b 100644 --- a/crates/polymodel-mesh/Cargo.toml +++ b/crates/polymodel-mesh/Cargo.toml @@ -3,7 +3,7 @@ name = "polymodel-mesh" version = "0.1.0" edition = "2024" license = "MIT OR Apache-2.0" -description = "Multi-format mesh ingestion: STL/OBJ/glTF parsing into a renderer-agnostic TriMesh." +description = "Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker." [dependencies] polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } diff --git a/crates/polymodel-mesh/src/companions.rs b/crates/polymodel-mesh/src/companions.rs index 2e1a6a6..59bb635 100644 --- a/crates/polymodel-mesh/src/companions.rs +++ b/crates/polymodel-mesh/src/companions.rs @@ -9,12 +9,27 @@ use serde_json::Value; -/// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:` -/// `buffers[].uri` and `images[].uri`. +use crate::contract::MeshLoadError; + +/// Discover safe companion-resource URIs referenced by a `.gltf` document: every +/// non-`data:` `buffers[].uri` and `images[].uri`. /// /// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so /// callers can feed any primary without branching on format. -pub fn discover_companion_uris(primary: &[u8]) -> Vec { +pub fn discover_companion_uris(primary: &[u8]) -> Result, MeshLoadError> { + raw_companion_uris(primary) + .into_iter() + .map(|uri| { + if is_safe_relative_uri(&uri) { + Ok(uri) + } else { + Err(MeshLoadError::UnsafeCompanionUri(uri)) + } + }) + .collect() +} + +fn raw_companion_uris(primary: &[u8]) -> Vec { let Ok(Value::Object(root)) = serde_json::from_slice::(primary) else { return Vec::new(); }; @@ -112,15 +127,52 @@ mod tests { ], "images": [{"uri": "tex.png"}, {"bufferView": 0}] }"#; - let mut uris = discover_companion_uris(gltf); + let mut uris = discover_companion_uris(gltf).expect("safe companion URIs"); uris.sort(); assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]); } + #[test] + fn safe_discovery_rejects_unsafe_uris() { + for uri in [ + "https://evil.example/x.bin", + "//evil.example/x.bin", + "../escape.bin", + "%2e%2e/escape.bin", + ] { + let gltf = format!(r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}"}}]}}"#); + assert!(matches!( + discover_companion_uris(gltf.as_bytes()), + Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri + )); + } + } + + #[test] + fn safe_discovery_still_skips_data_urls() { + let gltf = br#"{ + "asset": {"version": "2.0"}, + "buffers": [{"uri": "data:application/octet-stream;base64,AAAA"}], + "images": [{"uri": "texture.png"}] + }"#; + assert_eq!( + discover_companion_uris(gltf).expect("safe discovery"), + vec!["texture.png".to_string()] + ); + } + #[test] fn discovers_no_uris_from_non_json() { - assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty()); - assert!(discover_companion_uris(b"not json at all").is_empty()); + assert!( + discover_companion_uris(b"glTF\x00\x00\x00\x00") + .expect("safe discovery") + .is_empty() + ); + assert!( + discover_companion_uris(b"not json at all") + .expect("safe discovery") + .is_empty() + ); } #[test] diff --git a/crates/polymodel-mesh/src/contract.rs b/crates/polymodel-mesh/src/contract.rs index bf64874..4934045 100644 --- a/crates/polymodel-mesh/src/contract.rs +++ b/crates/polymodel-mesh/src/contract.rs @@ -1,4 +1,4 @@ -//! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance. +//! Mesh contract types: the renderer-facing CPU model and its provenance. //! //! [`MeshFormat`], [`LengthUnit`], and [`Units`] are re-exported from //! [`polymodel_renderer_protocol`] so the app, worker, and mesh crate share a single @@ -6,9 +6,9 @@ pub use polymodel_renderer_protocol::{LengthUnit, MeshFormat, Units}; -// `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and -// `TriMesh` is a public field type, so both are re-exported alongside the contract. -pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh}; +// These `three-d-asset` types are the owned CPU-side contract shared with the renderer +// worker. They do not include GPU/runtime state. +pub use three_d_asset::{AxisAlignedBoundingBox, Geometry, Model, PointCloud, TriMesh}; /// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the /// camera should orbit and a bounding-radius estimate to frame it. @@ -20,14 +20,17 @@ pub struct CameraFit { pub radius: f32, } -/// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling. +/// A Polymodel model: owned CPU geometry/material data plus format provenance and units. /// -/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet -/// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`]. +/// glTF/OBJ keep the per-primitive geometry/material structure exposed by +/// `three-d-asset`; STL is represented as a one-part triangle model. Triangle normals are +/// geometry-derived during validation. Point-cloud geometry is preserved even though the +/// current worker renders triangles first. #[derive(Debug)] pub struct ModelMesh { - /// The underlying triangle mesh. - pub trimesh: TriMesh, + /// The owned CPU model, including primitive boundaries, transforms, materials, and + /// decoded textures. + pub model: Model, /// The format this mesh was loaded from. pub format: MeshFormat, /// Unit provenance and assumption. @@ -35,9 +38,40 @@ pub struct ModelMesh { } impl ModelMesh { - /// The axis-aligned bounding box over all vertex positions. + /// Construct a model contract from an owned CPU model. + pub fn new(model: Model, format: MeshFormat, units: Units) -> Self { + Self { + model, + format, + units, + } + } + + /// Number of CPU primitives/parts preserved from the source model. + pub fn part_count(&self) -> usize { + self.model.geometries.len() + } + + /// The axis-aligned bounding box over all preserved primitive positions in world space. pub fn aabb(&self) -> AxisAlignedBoundingBox { - self.trimesh.compute_aabb() + let mut aabb = AxisAlignedBoundingBox::EMPTY; + for primitive in &self.model.geometries { + match &primitive.geometry { + Geometry::Triangles(mesh) => { + aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions( + &mesh.positions.to_f32(), + primitive.transformation, + )); + } + Geometry::Points(points) => { + aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions( + &points.positions.to_f32(), + primitive.transformation, + )); + } + } + } + aabb } /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and @@ -66,6 +100,9 @@ pub enum MeshLoadError { /// missing or undecodable companion resource (buffer/texture). #[error("mesh decode failed: {0}")] Decode(String), + /// A glTF companion URI is unsafe to fetch or load. + #[error("unsafe companion URI: {0}")] + UnsafeCompanionUri(String), /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a /// non-finite position, or a point-sized bounding box. #[error("mesh is degenerate")] @@ -79,13 +116,30 @@ pub enum MeshLoadError { /// Lightweight stats for reporting back to the main thread. impl ModelMesh { - /// Number of unique vertex positions. + /// Number of source positions across all preserved parts. + /// + /// This is a sum of part-local triangle vertices and point-cloud points, not a global + /// deduplicated vertex count. pub fn vertex_count(&self) -> u32 { - self.trimesh.positions.len() as u32 + self.model + .geometries + .iter() + .map(|primitive| match &primitive.geometry { + Geometry::Triangles(mesh) => mesh.positions.len() as u32, + Geometry::Points(points) => points.positions.len() as u32, + }) + .sum() } - /// Number of triangles. + /// Number of triangles across triangle parts. Point-cloud parts contribute zero. pub fn triangle_count(&self) -> u32 { - self.trimesh.triangle_count() as u32 + self.model + .geometries + .iter() + .map(|primitive| match &primitive.geometry { + Geometry::Triangles(mesh) => mesh.triangle_count() as u32, + Geometry::Points(_) => 0, + }) + .sum() } } diff --git a/crates/polymodel-mesh/src/gltf.rs b/crates/polymodel-mesh/src/gltf.rs index 5447faf..3336854 100644 --- a/crates/polymodel-mesh/src/gltf.rs +++ b/crates/polymodel-mesh/src/gltf.rs @@ -5,22 +5,21 @@ //! alone. A multi-file `.gltf` resolves its external `.bin`/texture companions from the //! source's [`MeshResources`], inserted under their exact raw URI so `three-d-asset`'s //! `base_path.join(uri)` lookup is exact (it is not asked to discover dependencies -//! itself). Textures are decoded during deserialize (the `image`/`png` features are on) -//! and then discarded — [`TriMesh`](three_d_asset::TriMesh) carries no materials — but a -//! referenced-but-missing or undecodable texture fails the whole parse. glTF's base unit -//! is the metre. No filesystem or network access, so this is safe from +//! itself). Textures and materials decoded by `three-d-asset` stay in the owned CPU model. +//! glTF's base unit is the metre. No filesystem or network access, so this is safe from //! `wasm32-unknown-unknown`. use three_d_asset::io; +use crate::companions::{discover_companion_uris, is_safe_relative_uri}; use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; use crate::parser::MeshSource; /// Parse a glTF/GLB asset (plus companions) into a [`ModelMesh`]. /// /// The GLB magic (`b"glTF"`) is sniffed to pick the `.glb` vs `.gltf` key. Node world -/// transforms are baked into positions by the shared flattener; geometry normals are -/// derived by the shared validator. +/// transforms are retained on primitives; geometry normals are derived by the shared +/// validator for triangle primitives. pub fn load_gltf(source: &MeshSource<'_>) -> Result { if source.primary.is_empty() { return Err(MeshLoadError::Empty); @@ -30,21 +29,24 @@ pub fn load_gltf(source: &MeshSource<'_>) -> Result { } else { "asset.gltf" }; + discover_companion_uris(source.primary)?; let mut assets = io::RawAssets::new(); assets.insert(key, source.primary.to_vec()); for (uri, bytes) in source.resources.iter() { + if !is_safe_relative_uri(uri) { + return Err(MeshLoadError::UnsafeCompanionUri(uri.to_string())); + } assets.insert(uri, bytes.to_vec()); } - let model: three_d_asset::Model = assets + let mut model: three_d_asset::Model = assets .deserialize(key) .map_err(|e| MeshLoadError::Decode(e.to_string()))?; - let mut trimesh = crate::flatten_model(model); - crate::validate(&mut trimesh)?; - Ok(ModelMesh { - trimesh, - format: MeshFormat::Gltf, - units: Units::declared(LengthUnit::Meter), - }) + crate::validate_model(&mut model)?; + Ok(ModelMesh::new( + model, + MeshFormat::Gltf, + Units::declared(LengthUnit::Meter), + )) } #[cfg(test)] @@ -52,9 +54,10 @@ mod tests { use super::load_gltf; use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units}; use crate::parser::{MeshResources, MeshSource}; + use three_d_asset::{Geometry, TriMesh}; fn fixture(name: &str) -> Vec { - let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); + let path = format!("{}/../../public/models/{name}", env!("CARGO_MANIFEST_DIR")); std::fs::read(&path) .unwrap_or_else(|_| panic!("glTF fixture {name} should be committed at {path}")) } @@ -76,6 +79,18 @@ mod tests { ); } + fn first_triangle_mesh(model: &ModelMesh) -> &TriMesh { + model + .model + .geometries + .iter() + .find_map(|primitive| match &primitive.geometry { + Geometry::Triangles(mesh) => Some(mesh), + Geometry::Points(_) => None, + }) + .expect("model should contain triangle geometry") + } + #[test] fn parses_self_contained_glb() { let primary = fixture("box.glb"); @@ -87,12 +102,12 @@ mod tests { let model = load_gltf(&source).expect("box.glb should parse"); assert_eq!(model.format, MeshFormat::Gltf); assert_meter_units(&model); - assert_eq!(model.trimesh.triangle_count(), 12); - assert!(model.trimesh.normals.is_some()); + assert_eq!(model.triangle_count(), 12); + assert!(first_triangle_mesh(&model).normals.is_some()); } #[test] - fn parses_glb_with_baked_transform_and_multiple_primitives() { + fn parses_glb_with_world_space_bounds_and_multiple_primitives() { let primary = fixture("box_translated.glb"); let source = MeshSource { format: MeshFormat::Gltf, @@ -100,11 +115,12 @@ mod tests { resources: MeshResources::empty(), }; let model = load_gltf(&source).expect("box_translated.glb should parse"); - assert_eq!(model.trimesh.triangle_count(), 24); + assert!(model.part_count() > 1); + assert_eq!(model.triangle_count(), 24); let center = model.aabb().center(); assert!( (center.x - 10.0).abs() < 0.5, - "node translation (+10 X) is baked into world-space positions: got center.x = {}", + "node translation (+10 X) is reflected in world-space bounds: got center.x = {}", center.x ); } @@ -119,7 +135,7 @@ mod tests { }; let model = load_gltf(&source).expect("embedded-base64 .gltf should parse"); assert_meter_units(&model); - assert_eq!(model.trimesh.triangle_count(), 12); + assert_eq!(model.triangle_count(), 12); } #[test] @@ -133,7 +149,15 @@ mod tests { }; let model = load_gltf(&source).expect("external-buffer .gltf should parse"); assert_meter_units(&model); - assert_eq!(model.trimesh.triangle_count(), 12); + assert_eq!(model.triangle_count(), 12); + assert!( + model + .model + .materials + .iter() + .any(|material| material.albedo != three_d_asset::Srgba::WHITE), + "box.gltf should preserve a source base color factor" + ); } #[test] @@ -153,7 +177,7 @@ mod tests { resources: MeshResources::new(&comps), }; let model = load_gltf(&source).expect("exact box.bin must win over box.bin.bak"); - assert_eq!(model.trimesh.triangle_count(), 12); + assert_eq!(model.triangle_count(), 12); } #[test] @@ -170,7 +194,60 @@ mod tests { }; let model = load_gltf(&source).expect("textured .gltf should parse"); assert_meter_units(&model); - assert_eq!(model.trimesh.triangle_count(), 12); + assert_eq!(model.triangle_count(), 12); + assert!( + first_triangle_mesh(&model).uvs.is_some(), + "textured box keeps UVs" + ); + assert!( + model + .model + .materials + .iter() + .any(|material| material.albedo_texture.is_some()), + "textured box keeps decoded base-color texture data" + ); + } + + #[test] + fn rejects_unsafe_resource_key_even_if_caller_bypasses_discovery() { + let primary = br#"{ + "asset": {"version": "2.0"}, + "buffers": [{"uri": "../escape.bin", "byteLength": 4}] + }"#; + let comps = vec![("../escape.bin".to_string(), vec![0, 0, 0, 0])]; + let source = MeshSource { + format: MeshFormat::Gltf, + primary, + resources: MeshResources::new(&comps), + }; + assert!(matches!( + load_gltf(&source), + Err(MeshLoadError::UnsafeCompanionUri(uri)) if uri == "../escape.bin" + )); + } + + #[test] + fn rejects_unsafe_primary_companion_uri_even_without_resource() { + for uri in [ + "https://evil.example/x.bin", + "//evil.example/x.bin", + "../escape.bin", + "%2e%2e/escape.bin", + ] { + let primary = format!( + r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}","byteLength":4}}]}}"# + ); + let source = MeshSource { + format: MeshFormat::Gltf, + primary: primary.as_bytes(), + resources: MeshResources::empty(), + }; + assert!(matches!( + load_gltf(&source), + Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri + )); + } } #[test] diff --git a/crates/polymodel-mesh/src/lib.rs b/crates/polymodel-mesh/src/lib.rs index 514c212..208f41c 100644 --- a/crates/polymodel-mesh/src/lib.rs +++ b/crates/polymodel-mesh/src/lib.rs @@ -1,13 +1,18 @@ -//! Multi-format mesh ingestion: STL/OBJ/glTF/3MF parsing into a renderer-agnostic -//! [`TriMesh`]. +//! Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker. //! -//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over -//! [`three_d_asset::TriMesh`] 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 all 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, 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. @@ -20,7 +25,7 @@ pub mod parser; pub mod stl; pub mod threemf; -use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3}; +use three_d_asset::{Geometry, Model, Positions, TriMesh, Vec3}; use crate::contract::MeshLoadError; @@ -35,8 +40,6 @@ use crate::contract::MeshLoadError; /// * 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). /// -/// Call this **after** any per-format transform baking, so the AABB and normals reflect -/// world-space positions. pub fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { if trimesh.triangle_count() == 0 { return Err(MeshLoadError::Degenerate); @@ -74,6 +77,51 @@ pub fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> { 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 @@ -85,55 +133,10 @@ fn positions_all_finite(positions: &Positions) -> bool { } } -/// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single -/// [`TriMesh`], shared by the OBJ and glTF loaders. -/// -/// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world -/// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving -/// positions in local space, so each geometry is baked here (local → world) as it is -/// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g. -/// point clouds) is dropped. All positions are unified to F32 (OBJ arrives as F64); -/// [`validate`] recomputes normals afterwards. -pub fn flatten_model(model: three_d_asset::Model) -> TriMesh { - let mut positions: Vec = Vec::new(); - let mut indices: Vec = Vec::new(); - for primitive in model.geometries { - let Geometry::Triangles(mesh) = primitive.geometry else { - continue; - }; - let base = positions.len() as u32; - for p in mesh.positions.to_f32() { - positions.push(transform_point(primitive.transformation, p)); - } - match mesh.indices { - Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)), - Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), - Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)), - Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)), - } - } - TriMesh { - positions: Positions::F32(positions), - indices: Indices::U32(indices), - normals: None, - tangents: None, - uvs: None, - colors: None, - } -} - -fn transform_point(m: Mat4, p: Vec3) -> Vec3 { - Vec3::new( - p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x, - p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y, - p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z, - ) -} - #[cfg(test)] mod tests { use super::*; - use three_d_asset::{Indices, SquareMatrix}; + use three_d_asset::{Indices, Mat4, PointCloud, Primitive, SquareMatrix}; fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh { TriMesh { @@ -208,12 +211,12 @@ mod tests { } #[test] - fn flatten_bakes_translation_into_positions() { - let model = three_d_asset::Model { + fn validate_model_preserves_parts() { + let mut model = three_d_asset::Model { name: "test".into(), materials: Vec::new(), geometries: vec![ - three_d_asset::Primitive { + Primitive { name: "a".into(), transformation: Mat4::identity(), animations: Vec::new(), @@ -223,7 +226,7 @@ mod tests { )), material_index: None, }, - three_d_asset::Primitive { + Primitive { name: "b".into(), transformation: Mat4::identity(), animations: Vec::new(), @@ -235,8 +238,38 @@ mod tests { }, ], }; - let flat = flatten_model(model); - assert_eq!(flat.positions.len(), 6); - assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]); + 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(_))); } } diff --git a/crates/polymodel-mesh/src/obj.rs b/crates/polymodel-mesh/src/obj.rs index 21cda6b..cfa119f 100644 --- a/crates/polymodel-mesh/src/obj.rs +++ b/crates/polymodel-mesh/src/obj.rs @@ -13,29 +13,28 @@ use crate::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; /// Parse OBJ bytes into a [`ModelMesh`]. /// -/// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is -/// unitless, so the result assumes millimetres. Positions are normalized to F32 by the -/// shared validator (OBJ arrives as F64). +/// Convex polygons are fan-triangulated; geometry and material groups exposed by +/// `three-d-asset` are preserved. OBJ is unitless, so the result assumes millimetres. +/// Positions are normalized to F32 by the shared validator (OBJ arrives as F64). pub fn load_obj(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err(MeshLoadError::Empty); } - let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) + let mut model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) .map_err(|e| MeshLoadError::Decode(e.to_string()))?; - let mut trimesh = crate::flatten_model(model); - crate::validate(&mut trimesh)?; - Ok(ModelMesh { - trimesh, - format: MeshFormat::Obj, - units: Units::unitless_assumed_mm(), - }) + crate::validate_model(&mut model)?; + Ok(ModelMesh::new( + model, + MeshFormat::Obj, + Units::unitless_assumed_mm(), + )) } #[cfg(test)] mod tests { use super::load_obj; use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; - use three_d_asset::Positions; + use three_d_asset::{Geometry, Positions, TriMesh}; fn fixture(name: &str) -> Vec { let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); @@ -43,6 +42,18 @@ mod tests { .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}")) } + fn first_triangle_mesh(model: &crate::contract::ModelMesh) -> &TriMesh { + model + .model + .geometries + .iter() + .find_map(|primitive| match &primitive.geometry { + Geometry::Triangles(mesh) => Some(mesh), + Geometry::Points(_) => None, + }) + .expect("model should contain triangle geometry") + } + #[test] fn parses_quad_cube_fixture() { let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse"); @@ -54,14 +65,14 @@ mod tests { assumed: Some(LengthUnit::Millimeter), } ); - assert_eq!(model.trimesh.triangle_count(), 12); - assert!(matches!(model.trimesh.positions, Positions::F32(_))); - let normals = model - .trimesh + assert_eq!(model.triangle_count(), 12); + let mesh = first_triangle_mesh(&model); + assert!(matches!(mesh.positions, Positions::F32(_))); + let normals = mesh .normals .as_ref() .expect("geometry normals should be populated"); - assert_eq!(normals.len(), model.trimesh.positions.len()); + assert_eq!(normals.len(), mesh.positions.len()); assert!( normals .iter() diff --git a/crates/polymodel-mesh/src/parser.rs b/crates/polymodel-mesh/src/parser.rs index 81ed5e6..ec7a397 100644 --- a/crates/polymodel-mesh/src/parser.rs +++ b/crates/polymodel-mesh/src/parser.rs @@ -1,11 +1,57 @@ //! Parser seam — shared parse boundary and format dispatch. //! -//! The data types here ([`MeshResources`], [`MeshSource`]) are the only thing that -//! crosses the parse boundary. The dispatcher [`load_mesh`] is synchronous; the viewer is -//! the sole async layer (it prefetches glTF companions). All four formats -//! (STL/OBJ/glTF/3MF) parse client-side in the worker via `three-d-asset`. +//! The data types here ([`MeshResources`], [`MeshSource`]) are the synchronous parse +//! boundary. [`load_mesh_with_companions`] is the async boundary for callers that have a +//! primary URL and a fetch function: it keeps glTF companion discovery and safety +//! validation inside this crate. STL/OBJ/glTF/3MF parse client-side in the worker via +//! `three-d-asset`; [`RemoteMeshParser`] remains the drop-in seam for future server-side +//! conversions. use crate::contract::{MeshFormat, MeshLoadError, ModelMesh}; +use std::future::Future; + +/// Fetch and parse a mesh while keeping format-specific companion discovery in the mesh +/// crate. +/// +/// The caller supplies bytes for a URL-like key; this function validates glTF companion +/// URIs before invoking the fetcher for them. Companion bytes are inserted under their +/// exact raw URI before parsing so `three-d-asset` resolves external buffers/textures +/// without substring fallback. +pub async fn load_mesh_with_companions( + primary_url: &str, + format: MeshFormat, + mut fetch: F, +) -> Result +where + F: FnMut(String) -> Fut, + Fut: Future, MeshLoadError>>, +{ + if format == MeshFormat::Threemf { + return Err(MeshLoadError::UnsupportedFormat); + } + + let primary = fetch(primary_url.to_string()).await?; + let companions = if format == MeshFormat::Gltf { + let base = primary_url.rsplit_once('/').map(|(base, _)| base); + let mut companions = Vec::new(); + for uri in crate::companions::discover_companion_uris(&primary)? { + let companion_url = base.map_or_else(|| uri.clone(), |base| format!("{base}/{uri}")); + let bytes = fetch(companion_url).await?; + companions.push((uri, bytes)); + } + companions + } else { + Vec::new() + }; + + let source = MeshSource { + format, + primary: &primary, + resources: MeshResources::new(&companions), + }; + + DefaultMeshParser::::default().parse(&source).await +} /// Borrowed views of a mesh's companion resources (external glTF buffers/textures), /// keyed by their exact raw URI as referenced by the primary asset. @@ -199,7 +245,7 @@ mod tests { }) .expect("3MF dispatches to load_threemf"); assert_eq!(threemf_mesh.format, MeshFormat::Threemf); - assert_eq!(threemf_mesh.trimesh.triangle_count(), 25692); + assert_eq!(threemf_mesh.triangle_count(), 25692); } #[test] @@ -219,6 +265,8 @@ mod tests { .block_on(parser.parse(&source)) .expect("3MF parses locally"); assert_eq!(mesh.format, MeshFormat::Threemf); - assert_eq!(mesh.trimesh.triangle_count(), 25692); + assert_eq!(mesh.triangle_count(), 25692); + assert_eq!(mesh.format, MeshFormat::Gltf); + assert_eq!(mesh.triangle_count(), 12); } } diff --git a/crates/polymodel-mesh/src/stl.rs b/crates/polymodel-mesh/src/stl.rs index ee1bf14..f112b88 100644 --- a/crates/polymodel-mesh/src/stl.rs +++ b/crates/polymodel-mesh/src/stl.rs @@ -14,7 +14,9 @@ use std::io::Cursor; -use three_d_asset::{Indices, Positions, TriMesh, Vec3}; +use three_d_asset::{ + Geometry, Indices, Mat4, Model, Positions, Primitive, SquareMatrix, TriMesh, Vec3, +}; use crate::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; @@ -61,17 +63,30 @@ fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result stl_io::Triangle { stl_io::Triangle { @@ -119,6 +134,14 @@ mod tests { ); } + fn stl_mesh(model: &crate::contract::ModelMesh) -> &TriMesh { + assert_eq!(model.part_count(), 1); + match &model.model.geometries[0].geometry { + Geometry::Triangles(mesh) => mesh, + Geometry::Points(_) => panic!("STL should load as triangle geometry"), + } + } + #[test] fn parses_binary_stl() { let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse"); @@ -130,14 +153,11 @@ mod tests { assumed: Some(LengthUnit::Millimeter), } ); - assert_eq!(model.trimesh.triangle_count(), 4); - assert!(model.trimesh.positions.len() >= 4); - let normals = model - .trimesh - .normals - .as_ref() - .expect("normals should be populated"); - assert_eq!(normals.len(), model.trimesh.positions.len()); + assert_eq!(model.triangle_count(), 4); + let mesh = stl_mesh(&model); + assert!(mesh.positions.len() >= 4); + let normals = mesh.normals.as_ref().expect("normals should be populated"); + assert_eq!(normals.len(), mesh.positions.len()); } #[test] @@ -154,8 +174,8 @@ solid polymodel endsolid polymodel\n"; let model = load_stl(ascii).expect("ASCII STL should parse"); assert_eq!(model.format, MeshFormat::Stl); - assert_eq!(model.trimesh.triangle_count(), 1); - assert!(model.trimesh.normals.is_some()); + assert_eq!(model.triangle_count(), 1); + assert!(stl_mesh(&model).normals.is_some()); } #[test] @@ -166,8 +186,8 @@ endsolid polymodel\n"; [0.0, 1.0, 0.0], )])) .expect("'solid '-header binary should parse via retry"); - assert_eq!(model.trimesh.triangle_count(), 1); - assert!(model.trimesh.normals.is_some()); + assert_eq!(model.triangle_count(), 1); + assert!(stl_mesh(&model).normals.is_some()); } #[test] @@ -178,7 +198,7 @@ endsolid polymodel\n"; [10.0, 0.0, 4.0], )])) .expect("planar mesh should parse"); - assert_eq!(model.trimesh.triangle_count(), 1); + assert_eq!(model.triangle_count(), 1); } #[test] @@ -204,8 +224,7 @@ endsolid polymodel\n"; [2.0, 0.0, 0.0], )])) .expect("collinear mesh should parse (not degenerate)"); - let normals = model - .trimesh + let normals = stl_mesh(&model) .normals .as_ref() .expect("normals should be populated"); @@ -274,7 +293,7 @@ endsolid polymodel\n"; assumed: Some(LengthUnit::Millimeter), } ); - assert!(model.trimesh.normals.is_some()); - assert!(model.trimesh.triangle_count() > 0); + assert!(stl_mesh(&model).normals.is_some()); + assert!(model.triangle_count() > 0); } } diff --git a/crates/polymodel-mesh/src/threemf.rs b/crates/polymodel-mesh/src/threemf.rs index b75d7bb..933ed09 100644 --- a/crates/polymodel-mesh/src/threemf.rs +++ b/crates/polymodel-mesh/src/threemf.rs @@ -2,9 +2,9 @@ //! //! `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`, pure Rust), //! which reads the OPC container, parses the model XML, applies object transforms, and -//! produces a [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and -//! `Indices::U32`. Multi-object builds, transforms, and components are merged into a -//! single [`Model`] by `three-d-asset`; `flatten_model` concatenates them. +//! produces an owned [`Model`](three_d_asset::Model). Multi-object builds, transforms, and +//! components are surfaced through the `three-d-asset` CPU model and validated before they +//! reach the renderer worker. //! //! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface //! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres — @@ -29,20 +29,20 @@ pub fn load_threemf(bytes: &[u8]) -> Result { } let model: three_d_asset::Model = io::deserialize("asset.3mf", bytes.to_vec()) .map_err(|e| MeshLoadError::Decode(e.to_string()))?; - let mut trimesh = crate::flatten_model(model); - crate::validate(&mut trimesh)?; - Ok(ModelMesh { - trimesh, - format: MeshFormat::Threemf, - units: Units::declared(LengthUnit::Millimeter), - }) + let mut model = model; + crate::validate_model(&mut model)?; + Ok(ModelMesh::new( + model, + MeshFormat::Threemf, + Units::declared(LengthUnit::Millimeter), + )) } #[cfg(test)] mod tests { use super::load_threemf; use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; - use three_d_asset::Positions; + use three_d_asset::{Geometry, Positions}; fn fixture(name: &str) -> Vec { let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); @@ -55,25 +55,38 @@ mod tests { let model = load_threemf(&fixture("cube_gears.3mf")).expect("cube_gears.3mf should parse"); assert_eq!(model.format, MeshFormat::Threemf); assert_eq!(model.units, Units::declared(LengthUnit::Millimeter)); - // cube_gears is a 17-object build; after flattening it's 25,692 triangles. - assert_eq!(model.trimesh.triangle_count(), 25692); - assert!(matches!(model.trimesh.positions, Positions::F32(_))); + // cube_gears is a 17-object build with 25,692 triangles. + assert_eq!(model.triangle_count(), 25692); + let triangle_meshes: Vec<_> = model + .model + .geometries + .iter() + .filter_map(|primitive| match &primitive.geometry { + Geometry::Triangles(mesh) => Some(mesh), + Geometry::Points(_) => None, + }) + .collect(); + assert!(!triangle_meshes.is_empty()); + assert!( + triangle_meshes + .iter() + .all(|mesh| matches!(mesh.positions, Positions::F32(_))) + ); let aabb = model.aabb(); let size = aabb.size(); assert!( size.x > 0.0 && size.y > 0.0 && size.z > 0.0, "AABB must be non-degenerate: {size:?}" ); - let normals = model - .trimesh - .normals - .as_ref() - .expect("geometry normals should be populated"); - assert_eq!(normals.len(), model.trimesh.positions.len()); assert!( - normals + triangle_meshes .iter() - .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), + .all(|mesh| mesh.normals.as_ref().is_some_and(|normals| { + normals.len() == mesh.positions.len() + && normals + .iter() + .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()) + })), "normals must be finite" ); } diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index f1925d7..c6ee9e2 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -107,11 +107,11 @@ impl Units { /// Lightweight mesh statistics returned to the main thread after a successful load. /// -/// The full [`TriMesh`](three_d_asset::TriMesh) stays in the worker; only counts cross -/// back for display. +/// The full model geometry stays in the worker; only aggregate counts cross back for +/// display. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct MeshStats { - /// Number of unique vertices. + /// Sum of part-local vertex positions across renderable geometry. pub vertices: u32, /// Number of triangles. pub triangles: u32, diff --git a/crates/polymodel-renderer-worker/Cargo.toml b/crates/polymodel-renderer-worker/Cargo.toml index e09300b..f6dc1ea 100644 --- a/crates/polymodel-renderer-worker/Cargo.toml +++ b/crates/polymodel-renderer-worker/Cargo.toml @@ -42,4 +42,6 @@ spinning_top = "0.3" tracing = { version = "0.1", default-features = false, features = ["std"] } tracing-wasm = "0.2" getrandom = { version = "0.3", features = ["wasm_js"] } -serde_json = "1.0" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 4615f00..3e36a1e 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -15,9 +15,8 @@ use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::spawn_local; use web_sys::DedicatedWorkerGlobalScope; -use polymodel_mesh::companions::{discover_companion_uris, is_safe_relative_uri}; use polymodel_mesh::contract::ModelMesh; -use polymodel_mesh::parser::{MeshResources, MeshSource, load_mesh}; +use polymodel_mesh::parser::load_mesh_with_companions; use polymodel_renderer_protocol::{ KeyModifiers, MeshFormat, MeshStats, RendererCommand, RendererEvent, deserialize_command, serialize_event, @@ -50,7 +49,7 @@ struct InputState { struct RendererState { context: three_d::Context, - mesh: Option>, + model: Option>, ambient_light: three_d::AmbientLight, key_light: three_d::DirectionalLight, fill_light: three_d::DirectionalLight, @@ -154,7 +153,7 @@ impl WorkerState { self.canvas = Some(canvas); self.renderer = Some(RendererState { context, - mesh: None, + model: None, ambient_light, key_light, fill_light, @@ -175,21 +174,21 @@ fn build_scene( model: &ModelMesh, pixel_width: u32, pixel_height: u32, -) -> ( - three_d::Gm, - three_d::Camera, - three_d::OrbitControl, -) { +) -> Result< + ( + three_d::Model, + three_d::Camera, + three_d::OrbitControl, + ), + String, +> { let fit = model.camera_fit(); - let mesh = three_d::Gm::new( - three_d::Mesh::new(context, &model.trimesh), - three_d::PhysicalMaterial { - albedo: three_d::Srgba::new_opaque(116, 180, 255), - roughness: 0.72, - metallic: 0.0, - ..Default::default() - }, - ); + let cpu_model = model_with_default_materials(model); + let render_model = three_d::Model::::new(context, &cpu_model) + .map_err(|e| format!("renderer model: {e}"))?; + if render_model.iter().next().is_none() { + return Err("model contains no renderable triangle geometry".into()); + } let radius = fit.radius.max(0.001); let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); @@ -204,7 +203,129 @@ fn build_scene( radius * 100.0, ); let control = three_d::OrbitControl::new(target, radius * 0.01, radius * 50.0); - (mesh, camera, control) + Ok((render_model, camera, control)) +} + +fn model_with_default_materials(model: &ModelMesh) -> three_d_asset::Model { + let mut cpu_model = model.model.clone(); + let default_material_index = cpu_model.materials.len(); + cpu_model.materials.push(three_d_asset::PbrMaterial { + name: "polymodel default blue".to_string(), + albedo: three_d_asset::Srgba::new_opaque(116, 180, 255), + roughness: 0.72, + metallic: 0.0, + ..Default::default() + }); + for primitive in &mut cpu_model.geometries { + if primitive.material_index.is_none() { + primitive.material_index = Some(default_material_index); + } + } + cpu_model +} + +#[cfg(test)] +mod tests { + use super::*; + use polymodel_mesh::gltf::load_gltf; + use polymodel_mesh::parser::{MeshResources, MeshSource}; + use polymodel_mesh::stl::load_stl; + use three_d_asset::SquareMatrix; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + fn textured_box() -> ModelMesh { + let primary = include_bytes!("../../../public/models/box_textured.gltf"); + let companions = vec![ + ( + "box_textured.bin".to_string(), + include_bytes!("../../../public/models/box_textured.bin").to_vec(), + ), + ( + "box_textured.png".to_string(), + include_bytes!("../../../public/models/box_textured.png").to_vec(), + ), + ]; + let source = MeshSource { + format: MeshFormat::Gltf, + primary, + resources: MeshResources::new(&companions), + }; + load_gltf(&source).expect("textured box should parse") + } + + fn browser_context() -> three_d::Context { + let canvas = web_sys::OffscreenCanvas::new(8, 8).expect("offscreen canvas"); + let gl = canvas + .get_context("webgl2") + .expect("context lookup") + .expect("webgl2 context") + .dyn_into::() + .expect("WebGL2 context type"); + let glow_context = three_d::context::Context::from_webgl2_context(gl); + three_d::Context::from_gl_context(Arc::new(glow_context)).expect("three-d context") + } + + #[test] + fn no_material_parts_get_polymodel_blue_default() { + let stl = include_bytes!("../../../public/models/body_f_chest-v4.stl"); + let model = load_stl(stl).expect("STL fixture should parse"); + let cpu_model = model_with_default_materials(&model); + + assert_eq!(cpu_model.materials.len(), 1); + assert_eq!( + cpu_model.materials[0].albedo, + three_d_asset::Srgba::new_opaque(116, 180, 255) + ); + assert_eq!(cpu_model.geometries[0].material_index, Some(0)); + } + + #[wasm_bindgen_test] + fn textured_gltf_builds_physical_material_with_albedo_texture() { + let model = textured_box(); + let context = browser_context(); + let (render_model, _camera, _control) = + build_scene(&context, &model, 8, 8).expect("scene builds"); + + assert!( + render_model + .iter() + .any(|part| part.material.albedo_texture.is_some()), + "rendered model should bind a base-colour texture handle" + ); + } + + #[wasm_bindgen_test] + fn points_only_model_reports_no_renderable_triangle_geometry() { + let model = ModelMesh::new( + three_d_asset::Model { + name: "points only".to_string(), + geometries: vec![three_d_asset::Primitive { + name: "points".to_string(), + transformation: three_d_asset::Mat4::identity(), + animations: Vec::new(), + geometry: three_d_asset::Geometry::Points(three_d_asset::PointCloud { + positions: three_d_asset::Positions::F32(vec![ + three_d_asset::vec3(0.0, 0.0, 0.0), + three_d_asset::vec3(1.0, 0.0, 0.0), + ]), + colors: None, + }), + material_index: None, + }], + materials: Vec::new(), + }, + MeshFormat::Gltf, + polymodel_mesh::contract::Units::declared(polymodel_mesh::contract::LengthUnit::Meter), + ); + let context = browser_context(); + + let Err(message) = build_scene(&context, &model, 8, 8) else { + panic!("points are preserved but not rendered yet"); + }; + assert_eq!(message, "model contains no renderable triangle geometry"); + } } fn standard_lights( @@ -357,10 +478,10 @@ fn consume_input_and_render(st: &mut WorkerState) { 0.035, 0.043, 0.067, 1.0, 1.0, )); - if let Some(mesh) = &renderer.mesh { + if let Some(model) = &renderer.model { let _ = screen.render( &renderer.camera, - std::slice::from_ref(mesh), + model.iter(), &[ &renderer.ambient_light, &renderer.key_light, @@ -413,10 +534,17 @@ fn handle_load_mesh( let pw = st.pixel_width; let ph = st.pixel_height; if let Some(renderer) = st.renderer.as_mut() { - let (mesh, camera, control) = build_scene(&renderer.context, &model, pw, ph); - renderer.mesh = Some(mesh); - renderer.camera = camera; - renderer.control = control; + match build_scene(&renderer.context, &model, pw, ph) { + Ok((render_model, camera, control)) => { + renderer.model = Some(render_model); + renderer.camera = camera; + renderer.control = control; + } + Err(msg) => { + st.post_event(&RendererEvent::MeshError { message: msg }); + return; + } + } } st.needs_render = true; @@ -435,54 +563,31 @@ async fn load_mesh_async( expected_size: Option, expected_digest: Option>, ) -> Result { - let primary_bytes = fetch_bytes(primary_url) - .await - .map_err(|e| format!("fetch primary: {e}"))?; - - if expected_size.is_some() || expected_digest.is_some() { - let size = expected_size.unwrap_or(primary_bytes.len() as i64); - if let Err(e) = polymodel_renderer_protocol::verify_part_file( - &primary_bytes, - size, - expected_digest.as_deref(), - ) { - return Err(format!("part file verification failed: {e}")); - } - } - - let companions = if format == MeshFormat::Gltf { - let uris = discover_companion_uris(&primary_bytes); - let mut comps: Vec<(String, Vec)> = Vec::new(); - for uri in &uris { - if !is_safe_relative_uri(uri) { - return Err(format!("unsafe companion URI: {uri}")); + load_mesh_with_companions(primary_url, format, |url| { + let verify_primary = url == primary_url; + let expected_digest = expected_digest.clone(); + async move { + let bytes = fetch_bytes(&url).await.map_err(|e| { + polymodel_mesh::contract::MeshLoadError::Decode(format!("fetch {url}: {e}")) + })?; + if verify_primary && (expected_size.is_some() || expected_digest.is_some()) { + let size = expected_size.unwrap_or(bytes.len() as i64); + polymodel_renderer_protocol::verify_part_file( + &bytes, + size, + expected_digest.as_deref(), + ) + .map_err(|e| { + polymodel_mesh::contract::MeshLoadError::Decode(format!( + "part file verification failed: {e}" + )) + })?; } - let base = primary_url - .rsplit_once('/') - .map(|(base, _)| base) - .unwrap_or(""); - let companion_url = if base.is_empty() { - uri.clone() - } else { - format!("{base}/{uri}") - }; - let bytes = fetch_bytes(&companion_url) - .await - .map_err(|e| format!("fetch companion {uri}: {e}"))?; - comps.push((uri.clone(), bytes)); + Ok(bytes) } - comps - } else { - Vec::new() - }; - - let source = MeshSource { - format, - primary: &primary_bytes, - resources: MeshResources::new(&companions), - }; - - load_mesh(&source).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string()) } // --------------------------------------------------------------------------- @@ -542,14 +647,18 @@ async fn render_preview_png(model: &ModelMesh, width: u32, height: u32) -> Resul let glow_context = three_d::context::Context::from_webgl2_context(gl); let context = three_d::Context::from_gl_context(Arc::new(glow_context)) .map_err(|e| format!("preview three-d context: {e}"))?; - let (mesh, camera, _control) = build_scene(&context, model, width, height); + let (model, camera, _control) = build_scene(&context, model, width, height)?; let (ambient_light, key_light, fill_light) = standard_lights(&context); let screen = three_d::RenderTarget::screen(&context, width, height); let _ = screen .clear(three_d::ClearState::color_and_depth( 0.035, 0.043, 0.067, 1.0, 1.0, )) - .render(&camera, [&mesh], &[&ambient_light, &key_light, &fill_light]); + .render( + &camera, + model.iter(), + &[&ambient_light, &key_light, &fill_light], + ); offscreen_canvas_png_bytes(&canvas).await } diff --git a/flake.nix b/flake.nix index 881d06d..65e1f70 100644 --- a/flake.nix +++ b/flake.nix @@ -70,6 +70,8 @@ nodejs esbuild + chromium + chromedriver playwright-driver.browsers just @@ -109,6 +111,8 @@ shellHook = '' export PATH="${pkgs.dioxus-cli}/bin:${pkgs.wasm-bindgen-cli}/bin:$PATH"; export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$NIX_LD_LIBRARY_PATH" + export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="wasm-bindgen-test-runner" + export CHROME="${pkgs.chromium}/bin/chromium" export CC_wasm32_unknown_unknown="${pkgs.llvmPackages_18.clang-unwrapped}/bin/clang" export AR_wasm32_unknown_unknown="${pkgs.llvmPackages_18.bintools-unwrapped}/bin/llvm-ar" diff --git a/justfile b/justfile index 63b7603..6fd4f66 100644 --- a/justfile +++ b/justfile @@ -45,8 +45,12 @@ test: test-server: cargo nextest run -p polymodel --features server +# Run browser-backed wasm tests for the renderer worker. +test-renderer: + RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo test -p polymodel-renderer-worker --target wasm32-unknown-unknown + # Run all local validation expected before review. -test-all: fix check lint test test-server +test-all: fix check lint test test-server test-renderer # Run browser end-to-end tests. e2e: cd e2e && npm test @@ -94,7 +98,7 @@ verify-renderer-split: build-web @echo "==> AC.1b: checking app Cargo.toml for forbidden deps..." @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 @echo "==> AC.2: checking app wasm bundle size (debug build, debuginfo-dominated)..." - @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; } + @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; } @echo "==> AC.2b: checking worker wasm exists..." @test -f public/renderer_worker_bg.wasm && echo "PASS: worker wasm exists" || { echo "FAIL: worker wasm not found"; exit 1; } @echo "==> All renderer-split checks passed." diff --git a/src/profile.rs b/src/profile.rs index fffa12c..c46424b 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -859,22 +859,6 @@ mod tests { } } - fn fixture_actor(did: &str, handle: &str) -> library::Actor { - library::Actor { - avatar: None, - description: None, - did: Did::new_owned(did).unwrap(), - display_name: None, - follower_count: None, - following_count: None, - handle: Handle::new_owned(handle).unwrap(), - pronouns: None, - thing_count: None, - viewer: None, - extra_data: None, - } - } - #[test] fn profile_state_distinguishes_loading_invalid_error_and_populated() { assert_eq!(profile_state(true, None), ProfileState::Loading); diff --git a/src/thing_card.rs b/src/thing_card.rs index 7f35650..c707c8e 100644 --- a/src/thing_card.rs +++ b/src/thing_card.rs @@ -1,5 +1,9 @@ use dioxus::prelude::*; #[cfg(test)] +use jacquard_common::deps::smol_str::SmolStr; +#[cfg(test)] +use jacquard_common::types::cid::Cid; +#[cfg(test)] use jacquard_common::types::{ string::{AtUri, Cid, Datetime, Did, Handle}, uri::UriValue,