···67676868- **Run `just build-renderer-worker` before `dx serve`** (or rely on the `just serve` guard, which builds the worker if `public/renderer_worker_bg.wasm` is missing). Without the worker artifacts, the viewer route will fail silently.
6969- **Worker artifacts:** `public/renderer_worker.js` + `public/renderer_worker_bg.wasm` are generated by `wasm-bindgen --target web`; `public/renderer_worker_loader.js` is hand-authored (the ES-module wrapper that calls `init()`). The `.js` and `.wasm` are gitignored; the loader is committed.
7070-- **Crate structure:** `polymodel-renderer-protocol` (leaf: `MeshFormat`/`LengthUnit`/`Units`/message types + postcard) → `polymodel-mesh` (STL/OBJ/glTF parsing, depends on `three-d-asset`) → `polymodel-renderer-worker` (`[[bin]]` wasm target, depends on `three-d` + `polymodel-mesh`). The app depends only on `polymodel-renderer-protocol`.
7070+- **Crate structure:** `polymodel-renderer-protocol` (leaf: `MeshFormat`/`LengthUnit`/`Units`/message types + postcard) → `polymodel-mesh` (STL/OBJ/glTF/3MF parsing, depends on `three-d-asset`) → `polymodel-renderer-worker` (`[[bin]]` wasm target, depends on `three-d` + `polymodel-mesh`). The app depends only on `polymodel-renderer-protocol`.
7171- **Invariant:** `cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal` must show zero `three-d`, `three-d-asset`, `stl_io`, or `polymodel-mesh` entries. `just verify-renderer-split` checks this.
7272- **Build profile:** `worker-release` (inherits `release`, `opt-level = 3`, `lto = "fat"`, `panic = "abort"`) — the worker renders frames, so hot-path speed matters more than initial bytes.
7373+- **Supported viewer formats:** STL, OBJ, glTF (GLB/embedded/multi-file), and 3MF all parse client-side in the worker via `three-d-asset`. STEP converts server-side to glTF via opencascade.js. The `RemoteMeshParser` seam in `polymodel-mesh` is reserved for future server-side format additions.
73747475### Front-end implementation notes
7576
···7070 /// non-finite position, or a point-sized bounding box.
7171 #[error("mesh is degenerate")]
7272 Degenerate,
7373- /// The format is not supported by the active parser. `Threemf` returns this when no
7474- /// remote parser is wired in (PM-49 will supply one).
7373+ /// The format is not supported by the active parser. Returned by the
7474+ /// [`RemoteMeshParser`](crate::parser::RemoteMeshParser) seam when no conversion is
7575+ /// wired in (reserved for future server-side formats like STEP).
7576 #[error("unsupported mesh format")]
7677 UnsupportedFormat,
7778}
+7-5
crates/polymodel-mesh/src/lib.rs
···11-//! Multi-format mesh ingestion: STL/OBJ/glTF parsing into a renderer-agnostic [`TriMesh`].
11+//! Multi-format mesh ingestion: STL/OBJ/glTF/3MF parsing into a renderer-agnostic
22+//! [`TriMesh`].
23//!
34//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over
45//! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a
55-//! camera framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf`
66-//! with external companions) are parsed from in-memory bytes via `three-d-asset`, so this
77-//! crate compiles to `wasm32-unknown-unknown`. 3MF cannot target `wasm32` and is routed
88-//! to a remote parser (PM-49); the parser seam in [`parser`] is remote-ready.
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).
911//!
1012//! [`MeshFormat`] / [`LengthUnit`] / [`Units`] are owned by [`polymodel_renderer_protocol`]
1113//! (the leaf crate shared with the app) to avoid a circular dependency.
+36-60
crates/polymodel-mesh/src/parser.rs
···11-//! Parser seam — shared parse boundary, format dispatch, and the remote drop-in hook.
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). 3MF cannot compile to `wasm32`,
66-//! so it is served by a remote parser (PM-49); [`RemoteMeshParser`] + the `Threemf` arm
77-//! are the drop-in site, and wiring them in does not change the [`MeshParser`] trait
88-//! shape or the viewer pipeline.
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`.
97108use crate::contract::{MeshFormat, MeshLoadError, ModelMesh};
119···55535654/// Synchronously parse a [`MeshSource`] into a [`ModelMesh`], dispatching by format.
5755///
5858-/// `Stl`/`Obj`/`Gltf` are parsed from the in-memory bytes. `Threemf` is not supported
5959-/// here — it is routed to a remote parser (PM-49) by [`DefaultMeshParser`].
5656+/// `Stl`/`Obj`/`Gltf`/`Threemf` are all parsed from the in-memory bytes via
5757+/// `three-d-asset`.
6058pub fn load_mesh(source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> {
6159 match source.format {
6260 MeshFormat::Stl => crate::stl::load_stl(source.primary),
6361 MeshFormat::Obj => crate::obj::load_obj(source.primary),
6462 MeshFormat::Gltf => crate::gltf::load_gltf(source),
6565- MeshFormat::Threemf => Err(MeshLoadError::UnsupportedFormat),
6363+ MeshFormat::Threemf => crate::threemf::load_threemf(source.primary),
6664 }
6765}
6866···7977 async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError>;
8078}
81798282-/// A remote (server-side) parser for formats that cannot compile to `wasm32` (3MF).
8080+/// A remote (server-side) parser for formats that require conversion before rendering.
8181+///
8282+/// Reserved for future server-side formats (e.g. STEP via opencascade.js → GLB). Not used
8383+/// by any current format — STL/OBJ/glTF/3MF all parse client-side.
8384///
8485/// **Contract:** [`RemoteMeshParser::convert`] MUST return a **self-contained GLB**
8585-/// (embedded buffer), because the `Threemf` arm re-enters the pipeline via `Gltf` with no
8686+/// (embedded buffer), because the remote arm re-enters the pipeline via `Gltf` with no
8687/// companion resources.
8788pub trait RemoteMeshParser {
8889 /// Convert the source bytes (e.g. a `.3mf`) into self-contained GLB bytes.
···9091 async fn convert(&self, bytes: &[u8]) -> Result<Vec<u8>, MeshLoadError>;
9192}
92939393-/// No remote parser wired in: `Threemf` is unsupported until PM-49 supplies one.
9494+/// No remote parser wired in: the seam returns [`MeshLoadError::UnsupportedFormat`] until
9595+/// a server-side format (e.g. STEP) supplies one.
9496pub struct Local;
95979698impl RemoteMeshParser for Local {
···99101 }
100102}
101103102102-/// Production parser: routes by format. `Stl`/`Obj`/`Gltf` parse in-memory; `Threemf`
103103-/// delegates to a [`RemoteMeshParser`] (converting to a self-contained GLB, then
104104-/// re-entering via `Gltf` with no companions).
104104+/// Production parser: routes by format. All formats (`Stl`/`Obj`/`Gltf`/`Threemf`)
105105+/// parse in-memory via `three-d-asset`. Kept for compatibility with call sites that
106106+/// construct it; `load_mesh` is the direct path.
105107pub struct DefaultMeshParser<R: RemoteMeshParser = Local> {
108108+ /// Reserved for server-side formats (STEP via opencascade.js). Not read by any
109109+ /// current `MeshFormat` arm — all four parse client-side.
110110+ #[allow(dead_code)]
106111 remote: R,
107112}
108113···122127impl<R: RemoteMeshParser> MeshParser for DefaultMeshParser<R> {
123128 async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> {
124129 match source.format {
125125- MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf => load_mesh(source),
126126- MeshFormat::Threemf => {
127127- let glb = self.remote.convert(source.primary).await?;
128128- let reentry = MeshSource {
129129- format: MeshFormat::Gltf,
130130- primary: &glb,
131131- resources: MeshResources::empty(),
132132- };
133133- load_mesh(&reentry)
130130+ MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf | MeshFormat::Threemf => {
131131+ load_mesh(source)
134132 }
135133 }
136134 }
···148146 }
149147150148 #[test]
151151- fn load_mesh_dispatches_each_format_and_rejects_threemf() {
149149+ fn load_mesh_dispatches_each_format() {
152150 let mut stl = Vec::new();
153151 stl.extend_from_slice(&[0u8; 80]);
154152 stl.extend_from_slice(&4u32.to_le_bytes());
···193191 .expect("GLB dispatches to load_gltf");
194192 assert_eq!(gltf_mesh.format, MeshFormat::Gltf);
195193196196- assert!(matches!(
197197- load_mesh(&MeshSource {
198198- format: MeshFormat::Threemf,
199199- primary: b"not real 3mf",
200200- resources: MeshResources::empty(),
201201- }),
202202- Err(MeshLoadError::UnsupportedFormat)
203203- ));
204204- }
205205-206206- #[test]
207207- fn production_parser_with_no_remote_rejects_threemf() {
208208- let parser = DefaultMeshParser::default();
209209- let source = MeshSource {
194194+ let threemf = fixture("cube_gears.3mf");
195195+ let threemf_mesh = load_mesh(&MeshSource {
210196 format: MeshFormat::Threemf,
211211- primary: b"not real 3mf",
197197+ primary: &threemf,
212198 resources: MeshResources::empty(),
213213- };
214214- let rt = tokio::runtime::Builder::new_current_thread()
215215- .enable_all()
216216- .build()
217217- .unwrap();
218218- let result = rt.block_on(parser.parse(&source));
219219- assert!(matches!(result, Err(MeshLoadError::UnsupportedFormat)));
220220- }
221221-222222- struct StubRemote;
223223-224224- impl RemoteMeshParser for StubRemote {
225225- async fn convert(&self, _bytes: &[u8]) -> Result<Vec<u8>, MeshLoadError> {
226226- Ok(fixture("box.glb"))
227227- }
199199+ })
200200+ .expect("3MF dispatches to load_threemf");
201201+ assert_eq!(threemf_mesh.format, MeshFormat::Threemf);
202202+ assert_eq!(threemf_mesh.trimesh.triangle_count(), 25692);
228203 }
229204230205 #[test]
231231- fn production_parser_with_stub_remote_parses_threemf_via_glb() {
232232- let parser = DefaultMeshParser::new(StubRemote);
206206+ fn production_parser_parses_threemf_locally() {
207207+ let parser = DefaultMeshParser::default();
208208+ let threemf = fixture("cube_gears.3mf");
233209 let source = MeshSource {
234210 format: MeshFormat::Threemf,
235235- primary: b"fake 3mf bytes",
211211+ primary: &threemf,
236212 resources: MeshResources::empty(),
237213 };
238214 let rt = tokio::runtime::Builder::new_current_thread()
···241217 .unwrap();
242218 let mesh = rt
243219 .block_on(parser.parse(&source))
244244- .expect("stub remote -> GLB -> ModelMesh");
245245- assert_eq!(mesh.format, MeshFormat::Gltf);
246246- assert_eq!(mesh.trimesh.triangle_count(), 12);
220220+ .expect("3MF parses locally");
221221+ assert_eq!(mesh.format, MeshFormat::Threemf);
222222+ assert_eq!(mesh.trimesh.triangle_count(), 25692);
247223 }
248224}
+23-102
crates/polymodel-mesh/src/threemf.rs
···11//! 3MF ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`.
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.
88+//!
39//! 3MF's spec-default unit is the millimetre, and `three-d-asset` does not surface
410//! `model.unit` on the `Scene`, so the loader reports [`Units::declared`] millimetres —
55-//! correct for the vast majority of 3MF files. Multi-object builds, transforms, and
66-//! components are merged by `three-d-asset` into a single [`Model`]; colors, textures, and
77-//! beam lattices render as geometry only in v1. There is no filesystem or network access —
88-//! this is safe to call from a `wasm32-unknown-unknown` build.
1111+//! correct for the vast majority of 3MF files. Colors, textures, and beam lattices render
1212+//! as geometry only in v1 (the worker's `PhysicalMaterial` ignores them). There is no
1313+//! filesystem or network access — this is safe to call from a `wasm32-unknown-unknown`
1414+//! build.
9151016use three_d_asset::io;
1117···13191420/// Parse 3MF bytes into a [`ModelMesh`].
1521///
1616-/// `three-d-asset` dispatches `.3mf` through its `3mf` feature (`lib3mf`), which reads the
1717-/// OPC container, parses the model XML, applies object transforms, and produces a
1818-/// [`TriMesh`](three_d_asset::TriMesh) with `Positions::F32` and `Indices::U32`. The shared
1919-/// validator derives geometry normals and checks for degeneracy.
2222+/// The shared validator derives geometry normals and checks for degeneracy (no triangles,
2323+/// non-finite positions, point-sized AABB). 3MF declares its unit in the model XML; the
2424+/// spec default is millimetres. `three-d-asset` does not surface the unit attribute, so the
2525+/// declared default is reported.
2026pub fn load_threemf(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> {
2127 if bytes.is_empty() {
2228 return Err(MeshLoadError::Empty);
···4551 }
46524753 #[test]
4848- fn parses_consortium_cylinder_fixture() {
4949- let model = load_threemf(&fixture("cylinder.3mf")).expect("cylinder.3mf should parse");
5454+ fn parses_cube_gears_fixture() {
5555+ let model = load_threemf(&fixture("cube_gears.3mf")).expect("cube_gears.3mf should parse");
5056 assert_eq!(model.format, MeshFormat::Threemf);
5151- assert_eq!(
5252- model.units,
5353- Units::declared(LengthUnit::Millimeter)
5454- );
5555- assert!(model.trimesh.triangle_count() > 0);
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);
5660 assert!(matches!(model.trimesh.positions, Positions::F32(_)));
5761 let aabb = model.aabb();
5862 let size = aabb.size();
···8286 #[test]
8387 fn error_parse_garbage() {
8488 let garbage = b"this is definitely not a valid 3MF file !@#$";
8585- assert!(matches!(load_threemf(garbage), Err(MeshLoadError::Decode(_))));
8686- }
8787-8888- /// Real-world correctness spike: a 3MF with 82,122 triangles from real scan geometry
8989- /// (`body_f_chest-v4.stl` converted to 3MF). The gate is correct geometry through the
9090- /// shipping loader path. Run explicitly: `cargo test -p polymodel-mesh threemf_spike -- --ignored --nocapture`.
9191- #[test]
9292- #[ignore]
9393- fn threemf_spike_real_world_mesh() {
9494- let bytes = fixture("body_chest.3mf");
9595- let model = load_threemf(&bytes).expect("real-world 3MF should parse");
9696-9797- assert_eq!(model.format, MeshFormat::Threemf);
9898- assert_eq!(model.units, Units::declared(LengthUnit::Millimeter));
9999-100100- let triangles = model.triangle_count();
101101- let vertices = model.vertex_count();
102102- eprintln!("body_chest.3mf: {vertices} vertices, {triangles} triangles");
103103-104104- // The source STL has 82,122 triangles and 41,051 unique vertices.
105105- assert_eq!(triangles, 82122, "triangle count must match source STL");
106106- assert_eq!(vertices, 41051, "vertex count must match source STL");
107107-108108- // Finite AABB
109109- let aabb = model.aabb();
110110- let center = aabb.center();
111111- let size = aabb.size();
112112- eprintln!("AABB center: {center:?}, size: {size:?}");
113113- assert!(
114114- center.x.is_finite() && center.y.is_finite() && center.z.is_finite(),
115115- "AABB center must be finite"
116116- );
117117- assert!(
118118- size.x > 0.0 && size.y > 0.0 && size.z > 0.0,
119119- "AABB must be non-degenerate"
120120- );
121121-122122- // Normals populated and finite
123123- let normals = model
124124- .trimesh
125125- .normals
126126- .as_ref()
127127- .expect("normals populated");
128128- assert_eq!(normals.len(), vertices as usize);
129129- assert!(
130130- normals
131131- .iter()
132132- .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()),
133133- "all normals must be finite"
134134- );
135135- }
136136-}
137137-138138-#[cfg(test)]
139139-mod spike_debug {
140140- use three_d_asset::io;
141141- use three_d_asset::{Geometry, Model};
142142-143143- fn fixture(name: &str) -> Vec<u8> {
144144- let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR"));
145145- std::fs::read(&path).unwrap_or_else(|_| panic!("fixture {name} at {path}"))
146146- }
147147-148148- #[test]
149149- #[ignore]
150150- fn debug_parse_all_3mf() {
151151- for name in &["cylinder.3mf", "cube_gears.3mf", "pyramid_vertexcolor.3mf", "3DBenchy.3mf"] {
152152- let bytes = fixture(name);
153153- match io::deserialize::<Model>("asset.3mf", bytes) {
154154- Ok(model) => {
155155- let tv: usize = model.geometries.iter().map(|p| match &p.geometry {
156156- Geometry::Triangles(m) => m.positions.len(),
157157- _ => 0,
158158- }).sum();
159159- let tt: usize = model
160160- .geometries
161161- .iter()
162162- .filter_map(|p| match &p.geometry {
163163- Geometry::Triangles(m) => Some(m.triangle_count()),
164164- _ => None,
165165- })
166166- .sum();
167167- eprintln!("{name}: OK — {} geometries, {} verts, {} tris", model.geometries.len(), tv, tt);
168168- }
169169- Err(e) => eprintln!("{name}: ERR — {e}"),
170170- }
171171- }
8989+ assert!(matches!(
9090+ load_threemf(garbage),
9191+ Err(MeshLoadError::Decode(_))
9292+ ));
17293 }
17394}
+6-6
crates/polymodel-renderer-protocol/src/lib.rs
···17171818/// Source format of a loaded mesh.
1919///
2020-/// STL, OBJ, and glTF (GLB, embedded, or multi-file `.gltf` with external buffers) are
2121-/// parsed in the worker. `Threemf` is reserved: 3MF cannot compile to `wasm32`, so it is
2222-/// served by a remote parser (PM-49). There is no binary/ASCII sub-label for STL —
2323-/// reliably telling them apart is hard, and both yield the same mesh.
2020+/// STL, OBJ, glTF (GLB, embedded, or multi-file `.gltf` with external buffers), and 3MF
2121+/// are all parsed client-side in the worker via `three-d-asset`. There is no
2222+/// binary/ASCII sub-label for STL — reliably telling them apart is hard, and both yield
2323+/// the same mesh.
2424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2525pub enum MeshFormat {
2626 /// Stereolithography (binary or ASCII).
···2929 Obj,
3030 /// glTF 2.0 — GLB, embedded-base64 `.gltf`, or multi-file `.gltf` + companions.
3131 Gltf,
3232- /// 3D Manufacturing Format. Not parsed client-side; routed to a remote parser
3333- /// (PM-49). Kept here so format detection and the parser seam are remote-ready.
3232+ /// 3D Manufacturing Format. Parsed client-side in the worker via `three-d-asset`'s
3333+ /// `3mf` feature (`lib3mf`, pure Rust).
3434 Threemf,
3535}
3636