···33version = "0.1.0"
44edition = "2024"
55license = "MIT OR Apache-2.0"
66-description = "Multi-format mesh ingestion: STL/OBJ/glTF parsing into a renderer-agnostic TriMesh."
66+description = "Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker."
7788[dependencies]
99polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" }
+58-6
crates/polymodel-mesh/src/companions.rs
···991010use serde_json::Value;
11111212-/// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:`
1313-/// `buffers[].uri` and `images[].uri`.
1212+use crate::contract::MeshLoadError;
1313+1414+/// Discover safe companion-resource URIs referenced by a `.gltf` document: every
1515+/// non-`data:` `buffers[].uri` and `images[].uri`.
1416///
1517/// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so
1618/// callers can feed any primary without branching on format.
1717-pub fn discover_companion_uris(primary: &[u8]) -> Vec<String> {
1919+pub fn discover_companion_uris(primary: &[u8]) -> Result<Vec<String>, MeshLoadError> {
2020+ raw_companion_uris(primary)
2121+ .into_iter()
2222+ .map(|uri| {
2323+ if is_safe_relative_uri(&uri) {
2424+ Ok(uri)
2525+ } else {
2626+ Err(MeshLoadError::UnsafeCompanionUri(uri))
2727+ }
2828+ })
2929+ .collect()
3030+}
3131+3232+fn raw_companion_uris(primary: &[u8]) -> Vec<String> {
1833 let Ok(Value::Object(root)) = serde_json::from_slice::<Value>(primary) else {
1934 return Vec::new();
2035 };
···112127 ],
113128 "images": [{"uri": "tex.png"}, {"bufferView": 0}]
114129 }"#;
115115- let mut uris = discover_companion_uris(gltf);
130130+ let mut uris = discover_companion_uris(gltf).expect("safe companion URIs");
116131 uris.sort();
117132 assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]);
118133 }
119134120135 #[test]
136136+ fn safe_discovery_rejects_unsafe_uris() {
137137+ for uri in [
138138+ "https://evil.example/x.bin",
139139+ "//evil.example/x.bin",
140140+ "../escape.bin",
141141+ "%2e%2e/escape.bin",
142142+ ] {
143143+ let gltf = format!(r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}"}}]}}"#);
144144+ assert!(matches!(
145145+ discover_companion_uris(gltf.as_bytes()),
146146+ Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri
147147+ ));
148148+ }
149149+ }
150150+151151+ #[test]
152152+ fn safe_discovery_still_skips_data_urls() {
153153+ let gltf = br#"{
154154+ "asset": {"version": "2.0"},
155155+ "buffers": [{"uri": "data:application/octet-stream;base64,AAAA"}],
156156+ "images": [{"uri": "texture.png"}]
157157+ }"#;
158158+ assert_eq!(
159159+ discover_companion_uris(gltf).expect("safe discovery"),
160160+ vec!["texture.png".to_string()]
161161+ );
162162+ }
163163+164164+ #[test]
121165 fn discovers_no_uris_from_non_json() {
122122- assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty());
123123- assert!(discover_companion_uris(b"not json at all").is_empty());
166166+ assert!(
167167+ discover_companion_uris(b"glTF\x00\x00\x00\x00")
168168+ .expect("safe discovery")
169169+ .is_empty()
170170+ );
171171+ assert!(
172172+ discover_companion_uris(b"not json at all")
173173+ .expect("safe discovery")
174174+ .is_empty()
175175+ );
124176 }
125177126178 #[test]
+69-15
crates/polymodel-mesh/src/contract.rs
···11-//! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance.
11+//! Mesh contract types: the renderer-facing CPU model and its provenance.
22//!
33//! [`MeshFormat`], [`LengthUnit`], and [`Units`] are re-exported from
44//! [`polymodel_renderer_protocol`] so the app, worker, and mesh crate share a single
···6677pub use polymodel_renderer_protocol::{LengthUnit, MeshFormat, Units};
8899-// `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and
1010-// `TriMesh` is a public field type, so both are re-exported alongside the contract.
1111-pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh};
99+// These `three-d-asset` types are the owned CPU-side contract shared with the renderer
1010+// worker. They do not include GPU/runtime state.
1111+pub use three_d_asset::{AxisAlignedBoundingBox, Geometry, Model, PointCloud, TriMesh};
12121313/// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the
1414/// camera should orbit and a bounding-radius estimate to frame it.
···2020 pub radius: f32,
2121}
22222323-/// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling.
2323+/// A Polymodel model: owned CPU geometry/material data plus format provenance and units.
2424///
2525-/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet
2626-/// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`].
2525+/// glTF/OBJ keep the per-primitive geometry/material structure exposed by
2626+/// `three-d-asset`; STL is represented as a one-part triangle model. Triangle normals are
2727+/// geometry-derived during validation. Point-cloud geometry is preserved even though the
2828+/// current worker renders triangles first.
2729#[derive(Debug)]
2830pub struct ModelMesh {
2929- /// The underlying triangle mesh.
3030- pub trimesh: TriMesh,
3131+ /// The owned CPU model, including primitive boundaries, transforms, materials, and
3232+ /// decoded textures.
3333+ pub model: Model,
3134 /// The format this mesh was loaded from.
3235 pub format: MeshFormat,
3336 /// Unit provenance and assumption.
···3538}
36393740impl ModelMesh {
3838- /// The axis-aligned bounding box over all vertex positions.
4141+ /// Construct a model contract from an owned CPU model.
4242+ pub fn new(model: Model, format: MeshFormat, units: Units) -> Self {
4343+ Self {
4444+ model,
4545+ format,
4646+ units,
4747+ }
4848+ }
4949+5050+ /// Number of CPU primitives/parts preserved from the source model.
5151+ pub fn part_count(&self) -> usize {
5252+ self.model.geometries.len()
5353+ }
5454+5555+ /// The axis-aligned bounding box over all preserved primitive positions in world space.
3956 pub fn aabb(&self) -> AxisAlignedBoundingBox {
4040- self.trimesh.compute_aabb()
5757+ let mut aabb = AxisAlignedBoundingBox::EMPTY;
5858+ for primitive in &self.model.geometries {
5959+ match &primitive.geometry {
6060+ Geometry::Triangles(mesh) => {
6161+ aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions(
6262+ &mesh.positions.to_f32(),
6363+ primitive.transformation,
6464+ ));
6565+ }
6666+ Geometry::Points(points) => {
6767+ aabb.expand_with_aabb(AxisAlignedBoundingBox::new_with_transformed_positions(
6868+ &points.positions.to_f32(),
6969+ primitive.transformation,
7070+ ));
7171+ }
7272+ }
7373+ }
7474+ aabb
4175 }
42764377 /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and
···66100 /// missing or undecodable companion resource (buffer/texture).
67101 #[error("mesh decode failed: {0}")]
68102 Decode(String),
103103+ /// A glTF companion URI is unsafe to fetch or load.
104104+ #[error("unsafe companion URI: {0}")]
105105+ UnsafeCompanionUri(String),
69106 /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a
70107 /// non-finite position, or a point-sized bounding box.
71108 #[error("mesh is degenerate")]
···7911680117/// Lightweight stats for reporting back to the main thread.
81118impl ModelMesh {
8282- /// Number of unique vertex positions.
119119+ /// Number of source positions across all preserved parts.
120120+ ///
121121+ /// This is a sum of part-local triangle vertices and point-cloud points, not a global
122122+ /// deduplicated vertex count.
83123 pub fn vertex_count(&self) -> u32 {
8484- self.trimesh.positions.len() as u32
124124+ self.model
125125+ .geometries
126126+ .iter()
127127+ .map(|primitive| match &primitive.geometry {
128128+ Geometry::Triangles(mesh) => mesh.positions.len() as u32,
129129+ Geometry::Points(points) => points.positions.len() as u32,
130130+ })
131131+ .sum()
85132 }
861338787- /// Number of triangles.
134134+ /// Number of triangles across triangle parts. Point-cloud parts contribute zero.
88135 pub fn triangle_count(&self) -> u32 {
8989- self.trimesh.triangle_count() as u32
136136+ self.model
137137+ .geometries
138138+ .iter()
139139+ .map(|primitive| match &primitive.geometry {
140140+ Geometry::Triangles(mesh) => mesh.triangle_count() as u32,
141141+ Geometry::Points(_) => 0,
142142+ })
143143+ .sum()
90144 }
91145}
+101-24
crates/polymodel-mesh/src/gltf.rs
···55//! alone. A multi-file `.gltf` resolves its external `.bin`/texture companions from the
66//! source's [`MeshResources`], inserted under their exact raw URI so `three-d-asset`'s
77//! `base_path.join(uri)` lookup is exact (it is not asked to discover dependencies
88-//! itself). Textures are decoded during deserialize (the `image`/`png` features are on)
99-//! and then discarded — [`TriMesh`](three_d_asset::TriMesh) carries no materials — but a
1010-//! referenced-but-missing or undecodable texture fails the whole parse. glTF's base unit
1111-//! is the metre. No filesystem or network access, so this is safe from
88+//! itself). Textures and materials decoded by `three-d-asset` stay in the owned CPU model.
99+//! glTF's base unit is the metre. No filesystem or network access, so this is safe from
1210//! `wasm32-unknown-unknown`.
13111412use three_d_asset::io;
15131414+use crate::companions::{discover_companion_uris, is_safe_relative_uri};
1615use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units};
1716use crate::parser::MeshSource;
18171918/// Parse a glTF/GLB asset (plus companions) into a [`ModelMesh`].
2019///
2120/// The GLB magic (`b"glTF"`) is sniffed to pick the `.glb` vs `.gltf` key. Node world
2222-/// transforms are baked into positions by the shared flattener; geometry normals are
2323-/// derived by the shared validator.
2121+/// transforms are retained on primitives; geometry normals are derived by the shared
2222+/// validator for triangle primitives.
2423pub fn load_gltf(source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> {
2524 if source.primary.is_empty() {
2625 return Err(MeshLoadError::Empty);
···3029 } else {
3130 "asset.gltf"
3231 };
3232+ discover_companion_uris(source.primary)?;
3333 let mut assets = io::RawAssets::new();
3434 assets.insert(key, source.primary.to_vec());
3535 for (uri, bytes) in source.resources.iter() {
3636+ if !is_safe_relative_uri(uri) {
3737+ return Err(MeshLoadError::UnsafeCompanionUri(uri.to_string()));
3838+ }
3639 assets.insert(uri, bytes.to_vec());
3740 }
3838- let model: three_d_asset::Model = assets
4141+ let mut model: three_d_asset::Model = assets
3942 .deserialize(key)
4043 .map_err(|e| MeshLoadError::Decode(e.to_string()))?;
4141- let mut trimesh = crate::flatten_model(model);
4242- crate::validate(&mut trimesh)?;
4343- Ok(ModelMesh {
4444- trimesh,
4545- format: MeshFormat::Gltf,
4646- units: Units::declared(LengthUnit::Meter),
4747- })
4444+ crate::validate_model(&mut model)?;
4545+ Ok(ModelMesh::new(
4646+ model,
4747+ MeshFormat::Gltf,
4848+ Units::declared(LengthUnit::Meter),
4949+ ))
4850}
49515052#[cfg(test)]
···5254 use super::load_gltf;
5355 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, ModelMesh, Units};
5456 use crate::parser::{MeshResources, MeshSource};
5757+ use three_d_asset::{Geometry, TriMesh};
55585659 fn fixture(name: &str) -> Vec<u8> {
5757- let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
6060+ let path = format!("{}/../../public/models/{name}", env!("CARGO_MANIFEST_DIR"));
5861 std::fs::read(&path)
5962 .unwrap_or_else(|_| panic!("glTF fixture {name} should be committed at {path}"))
6063 }
···7679 );
7780 }
78818282+ fn first_triangle_mesh(model: &ModelMesh) -> &TriMesh {
8383+ model
8484+ .model
8585+ .geometries
8686+ .iter()
8787+ .find_map(|primitive| match &primitive.geometry {
8888+ Geometry::Triangles(mesh) => Some(mesh),
8989+ Geometry::Points(_) => None,
9090+ })
9191+ .expect("model should contain triangle geometry")
9292+ }
9393+7994 #[test]
8095 fn parses_self_contained_glb() {
8196 let primary = fixture("box.glb");
···87102 let model = load_gltf(&source).expect("box.glb should parse");
88103 assert_eq!(model.format, MeshFormat::Gltf);
89104 assert_meter_units(&model);
9090- assert_eq!(model.trimesh.triangle_count(), 12);
9191- assert!(model.trimesh.normals.is_some());
105105+ assert_eq!(model.triangle_count(), 12);
106106+ assert!(first_triangle_mesh(&model).normals.is_some());
92107 }
9310894109 #[test]
9595- fn parses_glb_with_baked_transform_and_multiple_primitives() {
110110+ fn parses_glb_with_world_space_bounds_and_multiple_primitives() {
96111 let primary = fixture("box_translated.glb");
97112 let source = MeshSource {
98113 format: MeshFormat::Gltf,
···100115 resources: MeshResources::empty(),
101116 };
102117 let model = load_gltf(&source).expect("box_translated.glb should parse");
103103- assert_eq!(model.trimesh.triangle_count(), 24);
118118+ assert!(model.part_count() > 1);
119119+ assert_eq!(model.triangle_count(), 24);
104120 let center = model.aabb().center();
105121 assert!(
106122 (center.x - 10.0).abs() < 0.5,
107107- "node translation (+10 X) is baked into world-space positions: got center.x = {}",
123123+ "node translation (+10 X) is reflected in world-space bounds: got center.x = {}",
108124 center.x
109125 );
110126 }
···119135 };
120136 let model = load_gltf(&source).expect("embedded-base64 .gltf should parse");
121137 assert_meter_units(&model);
122122- assert_eq!(model.trimesh.triangle_count(), 12);
138138+ assert_eq!(model.triangle_count(), 12);
123139 }
124140125141 #[test]
···133149 };
134150 let model = load_gltf(&source).expect("external-buffer .gltf should parse");
135151 assert_meter_units(&model);
136136- assert_eq!(model.trimesh.triangle_count(), 12);
152152+ assert_eq!(model.triangle_count(), 12);
153153+ assert!(
154154+ model
155155+ .model
156156+ .materials
157157+ .iter()
158158+ .any(|material| material.albedo != three_d_asset::Srgba::WHITE),
159159+ "box.gltf should preserve a source base color factor"
160160+ );
137161 }
138162139163 #[test]
···153177 resources: MeshResources::new(&comps),
154178 };
155179 let model = load_gltf(&source).expect("exact box.bin must win over box.bin.bak");
156156- assert_eq!(model.trimesh.triangle_count(), 12);
180180+ assert_eq!(model.triangle_count(), 12);
157181 }
158182159183 #[test]
···170194 };
171195 let model = load_gltf(&source).expect("textured .gltf should parse");
172196 assert_meter_units(&model);
173173- assert_eq!(model.trimesh.triangle_count(), 12);
197197+ assert_eq!(model.triangle_count(), 12);
198198+ assert!(
199199+ first_triangle_mesh(&model).uvs.is_some(),
200200+ "textured box keeps UVs"
201201+ );
202202+ assert!(
203203+ model
204204+ .model
205205+ .materials
206206+ .iter()
207207+ .any(|material| material.albedo_texture.is_some()),
208208+ "textured box keeps decoded base-color texture data"
209209+ );
210210+ }
211211+212212+ #[test]
213213+ fn rejects_unsafe_resource_key_even_if_caller_bypasses_discovery() {
214214+ let primary = br#"{
215215+ "asset": {"version": "2.0"},
216216+ "buffers": [{"uri": "../escape.bin", "byteLength": 4}]
217217+ }"#;
218218+ let comps = vec![("../escape.bin".to_string(), vec![0, 0, 0, 0])];
219219+ let source = MeshSource {
220220+ format: MeshFormat::Gltf,
221221+ primary,
222222+ resources: MeshResources::new(&comps),
223223+ };
224224+ assert!(matches!(
225225+ load_gltf(&source),
226226+ Err(MeshLoadError::UnsafeCompanionUri(uri)) if uri == "../escape.bin"
227227+ ));
228228+ }
229229+230230+ #[test]
231231+ fn rejects_unsafe_primary_companion_uri_even_without_resource() {
232232+ for uri in [
233233+ "https://evil.example/x.bin",
234234+ "//evil.example/x.bin",
235235+ "../escape.bin",
236236+ "%2e%2e/escape.bin",
237237+ ] {
238238+ let primary = format!(
239239+ r#"{{"asset":{{"version":"2.0"}},"buffers":[{{"uri":"{uri}","byteLength":4}}]}}"#
240240+ );
241241+ let source = MeshSource {
242242+ format: MeshFormat::Gltf,
243243+ primary: primary.as_bytes(),
244244+ resources: MeshResources::empty(),
245245+ };
246246+ assert!(matches!(
247247+ load_gltf(&source),
248248+ Err(MeshLoadError::UnsafeCompanionUri(found)) if found == uri
249249+ ));
250250+ }
174251 }
175252176253 #[test]
+98-65
crates/polymodel-mesh/src/lib.rs
···11-//! Multi-format mesh ingestion: STL/OBJ/glTF/3MF parsing into a renderer-agnostic
22-//! [`TriMesh`].
11+//! Multi-format mesh ingestion into owned CPU geometry/material data for the renderer worker.
32//!
44-//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over
55-//! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a
66-//! camera framing hint. STL, OBJ, glTF (GLB, embedded-base64, or multi-file `.gltf`
77-//! with external companions), and 3MF are all parsed from in-memory bytes via
88-//! `three-d-asset`, so this crate compiles to `wasm32-unknown-unknown`. The parser seam
99-//! in [`parser`] retains a [`RemoteMeshParser`](parser::RemoteMeshParser) trait for
1010-//! future server-side formats (e.g. STEP via opencascade.js).
33+//! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over
44+//! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera
55+//! framing hint. STL, OBJ, glTF (GLB, embedded-base64, or multi-file `.gltf` with
66+//! external companions), and 3MF are parsed from in-memory bytes via `three-d-asset`, so
77+//! this crate compiles to `wasm32-unknown-unknown`. The parser seam in [`parser`] retains
88+//! a [`RemoteMeshParser`](parser::RemoteMeshParser) trait for future server-side formats
99+//! (e.g. STEP via opencascade.js).
1010+//! Defines [`ModelMesh`] (in [`contract`]), a Polymodel wrapper over
1111+//! [`three_d_asset::Model`] that carries format provenance, unit assumptions, and a camera
1212+//! framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf` with
1313+//! external companions) are parsed from in-memory bytes via `three-d-asset`, so this crate
1414+//! compiles to `wasm32-unknown-unknown`. 3MF cannot target `wasm32` and is routed to a
1515+//! remote parser (PM-49); the parser seam in [`parser`] is remote-ready.
1116//!
1217//! [`MeshFormat`] / [`LengthUnit`] / [`Units`] are owned by [`polymodel_renderer_protocol`]
1318//! (the leaf crate shared with the app) to avoid a circular dependency.
···2025pub mod stl;
2126pub mod threemf;
22272323-use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3};
2828+use three_d_asset::{Geometry, Model, Positions, TriMesh, Vec3};
24292530use crate::contract::MeshLoadError;
2631···3540/// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero;
3641/// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant).
3742///
3838-/// Call this **after** any per-format transform baking, so the AABB and normals reflect
3939-/// world-space positions.
4043pub fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> {
4144 if trimesh.triangle_count() == 0 {
4245 return Err(MeshLoadError::Degenerate);
···7477 Ok(())
7578}
76798080+/// Validate and finalize a parsed CPU model while preserving primitive/material structure.
8181+pub fn validate_model(model: &mut Model) -> Result<(), MeshLoadError> {
8282+ if model.geometries.is_empty() {
8383+ return Err(MeshLoadError::Degenerate);
8484+ }
8585+8686+ let mut has_positions = false;
8787+ for primitive in &mut model.geometries {
8888+ match &mut primitive.geometry {
8989+ Geometry::Triangles(mesh) => {
9090+ validate(mesh)?;
9191+ has_positions = true;
9292+ }
9393+ Geometry::Points(points) => {
9494+ validate_point_positions(&mut points.positions)?;
9595+ has_positions = true;
9696+ }
9797+ }
9898+ }
9999+100100+ if has_positions {
101101+ Ok(())
102102+ } else {
103103+ Err(MeshLoadError::Degenerate)
104104+ }
105105+}
106106+107107+fn validate_point_positions(positions: &mut Positions) -> Result<(), MeshLoadError> {
108108+ if positions.is_empty() || !positions_all_finite(positions) {
109109+ return Err(MeshLoadError::Degenerate);
110110+ }
111111+ let size = positions.compute_aabb().size();
112112+ if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON {
113113+ return Err(MeshLoadError::Degenerate);
114114+ }
115115+ if let Positions::F64(f64s) = positions {
116116+ *positions = Positions::F32(
117117+ f64s.iter()
118118+ .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32))
119119+ .collect(),
120120+ );
121121+ }
122122+ Ok(())
123123+}
124124+77125fn positions_all_finite(positions: &Positions) -> bool {
78126 match positions {
79127 Positions::F32(vs) => vs
···85133 }
86134}
871358888-/// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single
8989-/// [`TriMesh`], shared by the OBJ and glTF loaders.
9090-///
9191-/// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world
9292-/// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving
9393-/// positions in local space, so each geometry is baked here (local → world) as it is
9494-/// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g.
9595-/// point clouds) is dropped. All positions are unified to F32 (OBJ arrives as F64);
9696-/// [`validate`] recomputes normals afterwards.
9797-pub fn flatten_model(model: three_d_asset::Model) -> TriMesh {
9898- let mut positions: Vec<Vec3> = Vec::new();
9999- let mut indices: Vec<u32> = Vec::new();
100100- for primitive in model.geometries {
101101- let Geometry::Triangles(mesh) = primitive.geometry else {
102102- continue;
103103- };
104104- let base = positions.len() as u32;
105105- for p in mesh.positions.to_f32() {
106106- positions.push(transform_point(primitive.transformation, p));
107107- }
108108- match mesh.indices {
109109- Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)),
110110- Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)),
111111- Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)),
112112- Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)),
113113- }
114114- }
115115- TriMesh {
116116- positions: Positions::F32(positions),
117117- indices: Indices::U32(indices),
118118- normals: None,
119119- tangents: None,
120120- uvs: None,
121121- colors: None,
122122- }
123123-}
124124-125125-fn transform_point(m: Mat4, p: Vec3) -> Vec3 {
126126- Vec3::new(
127127- p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x,
128128- p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y,
129129- p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z,
130130- )
131131-}
132132-133136#[cfg(test)]
134137mod tests {
135138 use super::*;
136136- use three_d_asset::{Indices, SquareMatrix};
139139+ use three_d_asset::{Indices, Mat4, PointCloud, Primitive, SquareMatrix};
137140138141 fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh {
139142 TriMesh {
···208211 }
209212210213 #[test]
211211- fn flatten_bakes_translation_into_positions() {
212212- let model = three_d_asset::Model {
214214+ fn validate_model_preserves_parts() {
215215+ let mut model = three_d_asset::Model {
213216 name: "test".into(),
214217 materials: Vec::new(),
215218 geometries: vec![
216216- three_d_asset::Primitive {
219219+ Primitive {
217220 name: "a".into(),
218221 transformation: Mat4::identity(),
219222 animations: Vec::new(),
···223226 )),
224227 material_index: None,
225228 },
226226- three_d_asset::Primitive {
229229+ Primitive {
227230 name: "b".into(),
228231 transformation: Mat4::identity(),
229232 animations: Vec::new(),
···235238 },
236239 ],
237240 };
238238- let flat = flatten_model(model);
239239- assert_eq!(flat.positions.len(), 6);
240240- assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]);
241241+ validate_model(&mut model).expect("valid model");
242242+ assert_eq!(model.geometries.len(), 2);
243243+ assert!(matches!(
244244+ model.geometries[0].geometry,
245245+ Geometry::Triangles(_)
246246+ ));
247247+ assert!(matches!(
248248+ model.geometries[1].geometry,
249249+ Geometry::Triangles(_)
250250+ ));
251251+ }
252252+253253+ #[test]
254254+ fn validate_model_preserves_points() {
255255+ let mut model = three_d_asset::Model {
256256+ name: "points".into(),
257257+ materials: Vec::new(),
258258+ geometries: vec![Primitive {
259259+ name: "cloud".into(),
260260+ transformation: Mat4::identity(),
261261+ animations: Vec::new(),
262262+ geometry: Geometry::Points(PointCloud {
263263+ positions: Positions::F32(vec![
264264+ Vec3::new(0.0, 0.0, 0.0),
265265+ Vec3::new(1.0, 0.0, 0.0),
266266+ ]),
267267+ colors: None,
268268+ }),
269269+ material_index: None,
270270+ }],
271271+ };
272272+ validate_model(&mut model).expect("valid point cloud");
273273+ assert!(matches!(model.geometries[0].geometry, Geometry::Points(_)));
241274 }
242275}
+28-17
crates/polymodel-mesh/src/obj.rs
···13131414/// Parse OBJ bytes into a [`ModelMesh`].
1515///
1616-/// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is
1717-/// unitless, so the result assumes millimetres. Positions are normalized to F32 by the
1818-/// shared validator (OBJ arrives as F64).
1616+/// Convex polygons are fan-triangulated; geometry and material groups exposed by
1717+/// `three-d-asset` are preserved. OBJ is unitless, so the result assumes millimetres.
1818+/// Positions are normalized to F32 by the shared validator (OBJ arrives as F64).
1919pub fn load_obj(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> {
2020 if bytes.is_empty() {
2121 return Err(MeshLoadError::Empty);
2222 }
2323- let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec())
2323+ let mut model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec())
2424 .map_err(|e| MeshLoadError::Decode(e.to_string()))?;
2525- let mut trimesh = crate::flatten_model(model);
2626- crate::validate(&mut trimesh)?;
2727- Ok(ModelMesh {
2828- trimesh,
2929- format: MeshFormat::Obj,
3030- units: Units::unitless_assumed_mm(),
3131- })
2525+ crate::validate_model(&mut model)?;
2626+ Ok(ModelMesh::new(
2727+ model,
2828+ MeshFormat::Obj,
2929+ Units::unitless_assumed_mm(),
3030+ ))
3231}
33323433#[cfg(test)]
3534mod tests {
3635 use super::load_obj;
3736 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units};
3838- use three_d_asset::Positions;
3737+ use three_d_asset::{Geometry, Positions, TriMesh};
39384039 fn fixture(name: &str) -> Vec<u8> {
4140 let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
···4342 .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}"))
4443 }
45444545+ fn first_triangle_mesh(model: &crate::contract::ModelMesh) -> &TriMesh {
4646+ model
4747+ .model
4848+ .geometries
4949+ .iter()
5050+ .find_map(|primitive| match &primitive.geometry {
5151+ Geometry::Triangles(mesh) => Some(mesh),
5252+ Geometry::Points(_) => None,
5353+ })
5454+ .expect("model should contain triangle geometry")
5555+ }
5656+4657 #[test]
4758 fn parses_quad_cube_fixture() {
4859 let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse");
···5465 assumed: Some(LengthUnit::Millimeter),
5566 }
5667 );
5757- assert_eq!(model.trimesh.triangle_count(), 12);
5858- assert!(matches!(model.trimesh.positions, Positions::F32(_)));
5959- let normals = model
6060- .trimesh
6868+ assert_eq!(model.triangle_count(), 12);
6969+ let mesh = first_triangle_mesh(&model);
7070+ assert!(matches!(mesh.positions, Positions::F32(_)));
7171+ let normals = mesh
6172 .normals
6273 .as_ref()
6374 .expect("geometry normals should be populated");
6464- assert_eq!(normals.len(), model.trimesh.positions.len());
7575+ assert_eq!(normals.len(), mesh.positions.len());
6576 assert!(
6677 normals
6778 .iter()
+54-6
crates/polymodel-mesh/src/parser.rs
···11//! Parser seam — shared parse boundary and format dispatch.
22//!
33-//! The data types here ([`MeshResources`], [`MeshSource`]) are the only thing that
44-//! crosses the parse boundary. The dispatcher [`load_mesh`] is synchronous; the viewer is
55-//! the sole async layer (it prefetches glTF companions). All four formats
66-//! (STL/OBJ/glTF/3MF) parse client-side in the worker via `three-d-asset`.
33+//! The data types here ([`MeshResources`], [`MeshSource`]) are the synchronous parse
44+//! boundary. [`load_mesh_with_companions`] is the async boundary for callers that have a
55+//! primary URL and a fetch function: it keeps glTF companion discovery and safety
66+//! validation inside this crate. STL/OBJ/glTF/3MF parse client-side in the worker via
77+//! `three-d-asset`; [`RemoteMeshParser`] remains the drop-in seam for future server-side
88+//! conversions.
79810use crate::contract::{MeshFormat, MeshLoadError, ModelMesh};
1111+use std::future::Future;
1212+1313+/// Fetch and parse a mesh while keeping format-specific companion discovery in the mesh
1414+/// crate.
1515+///
1616+/// The caller supplies bytes for a URL-like key; this function validates glTF companion
1717+/// URIs before invoking the fetcher for them. Companion bytes are inserted under their
1818+/// exact raw URI before parsing so `three-d-asset` resolves external buffers/textures
1919+/// without substring fallback.
2020+pub async fn load_mesh_with_companions<F, Fut>(
2121+ primary_url: &str,
2222+ format: MeshFormat,
2323+ mut fetch: F,
2424+) -> Result<ModelMesh, MeshLoadError>
2525+where
2626+ F: FnMut(String) -> Fut,
2727+ Fut: Future<Output = Result<Vec<u8>, MeshLoadError>>,
2828+{
2929+ if format == MeshFormat::Threemf {
3030+ return Err(MeshLoadError::UnsupportedFormat);
3131+ }
3232+3333+ let primary = fetch(primary_url.to_string()).await?;
3434+ let companions = if format == MeshFormat::Gltf {
3535+ let base = primary_url.rsplit_once('/').map(|(base, _)| base);
3636+ let mut companions = Vec::new();
3737+ for uri in crate::companions::discover_companion_uris(&primary)? {
3838+ let companion_url = base.map_or_else(|| uri.clone(), |base| format!("{base}/{uri}"));
3939+ let bytes = fetch(companion_url).await?;
4040+ companions.push((uri, bytes));
4141+ }
4242+ companions
4343+ } else {
4444+ Vec::new()
4545+ };
4646+4747+ let source = MeshSource {
4848+ format,
4949+ primary: &primary,
5050+ resources: MeshResources::new(&companions),
5151+ };
5252+5353+ DefaultMeshParser::<Local>::default().parse(&source).await
5454+}
9551056/// Borrowed views of a mesh's companion resources (external glTF buffers/textures),
1157/// keyed by their exact raw URI as referenced by the primary asset.
···199245 })
200246 .expect("3MF dispatches to load_threemf");
201247 assert_eq!(threemf_mesh.format, MeshFormat::Threemf);
202202- assert_eq!(threemf_mesh.trimesh.triangle_count(), 25692);
248248+ assert_eq!(threemf_mesh.triangle_count(), 25692);
203249 }
204250205251 #[test]
···219265 .block_on(parser.parse(&source))
220266 .expect("3MF parses locally");
221267 assert_eq!(mesh.format, MeshFormat::Threemf);
222222- assert_eq!(mesh.trimesh.triangle_count(), 25692);
268268+ assert_eq!(mesh.triangle_count(), 25692);
269269+ assert_eq!(mesh.format, MeshFormat::Gltf);
270270+ assert_eq!(mesh.triangle_count(), 12);
223271 }
224272}
···22//!
33//! `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`, pure Rust),
44//! which reads the OPC container, parses the model XML, applies object transforms, and
55-//! produces a [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and
66-//! `Indices::U32`. Multi-object builds, transforms, and components are merged into a
77-//! single [`Model`] by `three-d-asset`; `flatten_model` concatenates them.
55+//! produces an owned [`Model`](three_d_asset::Model). Multi-object builds, transforms, and
66+//! components are surfaced through the `three-d-asset` CPU model and validated before they
77+//! reach the renderer worker.
88//!
99//! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface
1010//! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres —
···2929 }
3030 let model: three_d_asset::Model = io::deserialize("asset.3mf", bytes.to_vec())
3131 .map_err(|e| MeshLoadError::Decode(e.to_string()))?;
3232- let mut trimesh = crate::flatten_model(model);
3333- crate::validate(&mut trimesh)?;
3434- Ok(ModelMesh {
3535- trimesh,
3636- format: MeshFormat::Threemf,
3737- units: Units::declared(LengthUnit::Millimeter),
3838- })
3232+ let mut model = model;
3333+ crate::validate_model(&mut model)?;
3434+ Ok(ModelMesh::new(
3535+ model,
3636+ MeshFormat::Threemf,
3737+ Units::declared(LengthUnit::Millimeter),
3838+ ))
3939}
40404141#[cfg(test)]
4242mod tests {
4343 use super::load_threemf;
4444 use crate::contract::{LengthUnit, MeshFormat, MeshLoadError, Units};
4545- use three_d_asset::Positions;
4545+ use three_d_asset::{Geometry, Positions};
46464747 fn fixture(name: &str) -> Vec<u8> {
4848 let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
···5555 let model = load_threemf(&fixture("cube_gears.3mf")).expect("cube_gears.3mf should parse");
5656 assert_eq!(model.format, MeshFormat::Threemf);
5757 assert_eq!(model.units, Units::declared(LengthUnit::Millimeter));
5858- // cube_gears is a 17-object build; after flattening it's 25,692 triangles.
5959- assert_eq!(model.trimesh.triangle_count(), 25692);
6060- assert!(matches!(model.trimesh.positions, Positions::F32(_)));
5858+ // cube_gears is a 17-object build with 25,692 triangles.
5959+ assert_eq!(model.triangle_count(), 25692);
6060+ let triangle_meshes: Vec<_> = model
6161+ .model
6262+ .geometries
6363+ .iter()
6464+ .filter_map(|primitive| match &primitive.geometry {
6565+ Geometry::Triangles(mesh) => Some(mesh),
6666+ Geometry::Points(_) => None,
6767+ })
6868+ .collect();
6969+ assert!(!triangle_meshes.is_empty());
7070+ assert!(
7171+ triangle_meshes
7272+ .iter()
7373+ .all(|mesh| matches!(mesh.positions, Positions::F32(_)))
7474+ );
6175 let aabb = model.aabb();
6276 let size = aabb.size();
6377 assert!(
6478 size.x > 0.0 && size.y > 0.0 && size.z > 0.0,
6579 "AABB must be non-degenerate: {size:?}"
6680 );
6767- let normals = model
6868- .trimesh
6969- .normals
7070- .as_ref()
7171- .expect("geometry normals should be populated");
7272- assert_eq!(normals.len(), model.trimesh.positions.len());
7381 assert!(
7474- normals
8282+ triangle_meshes
7583 .iter()
7676- .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()),
8484+ .all(|mesh| mesh.normals.as_ref().is_some_and(|normals| {
8585+ normals.len() == mesh.positions.len()
8686+ && normals
8787+ .iter()
8888+ .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite())
8989+ })),
7790 "normals must be finite"
7891 );
7992 }
+3-3
crates/polymodel-renderer-protocol/src/lib.rs
···107107108108/// Lightweight mesh statistics returned to the main thread after a successful load.
109109///
110110-/// The full [`TriMesh`](three_d_asset::TriMesh) stays in the worker; only counts cross
111111-/// back for display.
110110+/// The full model geometry stays in the worker; only aggregate counts cross back for
111111+/// display.
112112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113113pub struct MeshStats {
114114- /// Number of unique vertices.
114114+ /// Sum of part-local vertex positions across renderable geometry.
115115 pub vertices: u32,
116116 /// Number of triangles.
117117 pub triangles: u32,
+3-1
crates/polymodel-renderer-worker/Cargo.toml
···4242tracing = { version = "0.1", default-features = false, features = ["std"] }
4343tracing-wasm = "0.2"
4444getrandom = { version = "0.3", features = ["wasm_js"] }
4545-serde_json = "1.0"
4545+4646+[dev-dependencies]
4747+wasm-bindgen-test = "0.3"