//! Parser seam — shared parse boundary, format dispatch, and the remote drop-in hook. //! //! 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). 3MF cannot compile to `wasm32`, //! so it is served by a remote parser (PM-49); [`RemoteMeshParser`] + the `Threemf` arm //! are the drop-in site, and wiring them in does not change the [`MeshParser`] trait //! shape or the viewer pipeline. use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh}; /// Borrowed views of a mesh's companion resources (external glTF buffers/textures), /// keyed by their exact raw URI as referenced by the primary asset. /// /// Empty for STL, OBJ, GLB, and embedded-base64 `.gltf`. The viewer keeps an owned /// companion store alive across the async parse and borrows it here; [`crate::mesh::gltf`] /// inserts each entry into a `three-d-asset` `RawAssets` under the exact same URI so its /// `base_path.join(uri)` lookup is an exact match (avoiding `RawAssets`' substring /// fallback foot-gun). pub(crate) struct MeshResources<'a> { companions: &'a [(String, Vec)], } impl<'a> MeshResources<'a> { /// No companion resources. pub(crate) fn empty() -> MeshResources<'static> { MeshResources { companions: &[] } } /// Wrap an owned companion store `(raw_uri, bytes)`. pub(crate) fn new(companions: &'a [(String, Vec)]) -> Self { MeshResources { companions } } /// Iterate `(raw_uri, bytes)` pairs. pub(crate) fn iter(&self) -> impl Iterator { self.companions .iter() .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) } } /// The bytes needed to parse one mesh: its primary asset plus any companion resources. /// /// Passed by reference through the parse boundary so the trait shape stays fixed for the /// remote-parser drop-in (PM-49). pub(crate) struct MeshSource<'a> { /// Detected source format. pub format: MeshFormat, /// Primary asset bytes (the `.stl`/`.obj`/`.gltf`/`.glb`/`.3mf` itself). pub primary: &'a [u8], /// Companion resource bytes, keyed by exact raw URI. pub resources: MeshResources<'a>, } /// Synchronously parse a [`MeshSource`] into a [`ModelMesh`], dispatching by format. /// /// `Stl`/`Obj`/`Gltf` are parsed client-side from the in-memory bytes. `Threemf` is not /// supported here — it is routed to a remote parser (PM-49) by [`ProductionMeshParser`]. pub(crate) fn load_mesh(source: &MeshSource<'_>) -> Result { match source.format { MeshFormat::Stl => crate::mesh::stl::load_stl(source.primary), MeshFormat::Obj => crate::mesh::obj::load_obj(source.primary), MeshFormat::Gltf => crate::mesh::gltf::load_gltf(source), // PM-49: ThreemfRemoteParser converts to GLB, then re-enters via Gltf. MeshFormat::Threemf => Err(MeshLoadError::UnsupportedFormat), } } /// Parse boundary: turn a [`MeshSource`] into a [`ModelMesh`]. Generic for static /// dispatch (no boxed `dyn`); tests inject a counter to assert one parse per load. /// /// **Drop-in contract (PM-49):** the only changes are a `RemoteMeshParser` /// implementation (e.g. `ThreemfRemoteParser`) and constructing /// `ProductionMeshParser::new(remote)` at the call site. This trait shape, the viewer /// pipeline, and `build_parsed_mesh` are unchanged. pub(crate) trait MeshParser { /// Parse the source into a mesh. async fn parse(&self, source: &MeshSource<'_>) -> Result; } /// A remote (server-side) parser for formats that cannot compile to `wasm32` (3MF). /// /// **Contract:** [`RemoteMeshParser::convert`] MUST return a **self-contained GLB** /// (embedded buffer), because the `Threemf` arm re-enters the pipeline via `Gltf` with no /// companion resources. pub(crate) trait RemoteMeshParser { /// Convert the source bytes (e.g. a `.3mf`) into self-contained GLB bytes. async fn convert(&self, bytes: &[u8]) -> Result, MeshLoadError>; } /// No remote parser wired in: `Threemf` is unsupported until PM-49 supplies one. pub(crate) struct NoRemote; impl RemoteMeshParser for NoRemote { async fn convert(&self, _bytes: &[u8]) -> Result, MeshLoadError> { Err(MeshLoadError::UnsupportedFormat) } } /// Production parser: routes by format. `Stl`/`Obj`/`Gltf` parse in-memory; `Threemf` /// delegates to a [`RemoteMeshParser`] (converting to a self-contained GLB, then /// re-entering via `Gltf` with no companions). pub(crate) struct ProductionMeshParser { remote: R, } impl Default for ProductionMeshParser { fn default() -> Self { Self { remote: NoRemote } } } impl ProductionMeshParser { /// Construct with a remote parser (PM-49 wires `ThreemfRemoteParser` here). pub(crate) fn new(remote: R) -> Self { Self { remote } } } impl MeshParser for ProductionMeshParser { async fn parse(&self, source: &MeshSource<'_>) -> Result { match source.format { MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf => load_mesh(source), MeshFormat::Threemf => { let glb = self.remote.convert(source.primary).await?; let reentry = MeshSource { format: MeshFormat::Gltf, primary: &glb, resources: MeshResources::empty(), }; load_mesh(&reentry) } } } } #[cfg(test)] mod tests { use super::*; use crate::mesh::contract::MeshFormat; fn fixture(name: &str) -> Vec { let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); std::fs::read(&path) .unwrap_or_else(|_| panic!("fixture {name} should be committed at {path}")) } #[test] fn load_mesh_dispatches_each_format_and_rejects_threemf() { // Stl: a minimal binary STL tetrahedron (80-byte header + count + 4 triangles). let mut stl = Vec::new(); stl.extend_from_slice(&[0u8; 80]); stl.extend_from_slice(&4u32.to_le_bytes()); let v = [ [0.0f32; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], ]; let faces = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]; for f in faces { stl.extend_from_slice(&[0u8; 12]); // facet normal (3 zero f32) for i in f { for &c in &v[i] { stl.extend_from_slice(&c.to_le_bytes()); } } stl.extend_from_slice(&0u16.to_le_bytes()); // attribute } let stl_mesh = load_mesh(&MeshSource { format: MeshFormat::Stl, primary: &stl, resources: MeshResources::empty(), }) .expect("STL dispatches to load_stl"); assert_eq!(stl_mesh.format, MeshFormat::Stl); // Obj: a single triangle. let obj_mesh = load_mesh(&MeshSource { format: MeshFormat::Obj, primary: b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", resources: MeshResources::empty(), }) .expect("OBJ dispatches to load_obj"); assert_eq!(obj_mesh.format, MeshFormat::Obj); // Gltf: a self-contained GLB. let glb = fixture("box.glb"); let gltf_mesh = load_mesh(&MeshSource { format: MeshFormat::Gltf, primary: &glb, resources: MeshResources::empty(), }) .expect("GLB dispatches to load_gltf"); assert_eq!(gltf_mesh.format, MeshFormat::Gltf); // Threemf: unsupported without a remote parser. assert!(matches!( load_mesh(&MeshSource { format: MeshFormat::Threemf, primary: b"not real 3mf", resources: MeshResources::empty(), }), Err(MeshLoadError::UnsupportedFormat) )); } #[test] fn production_parser_with_no_remote_rejects_threemf() { let parser = ProductionMeshParser::default(); let source = MeshSource { format: MeshFormat::Threemf, primary: b"not real 3mf", resources: MeshResources::empty(), }; // Drive the async parse on a current-thread runtime (no Send bound needed). let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let result = rt.block_on(parser.parse(&source)); assert!(matches!(result, Err(MeshLoadError::UnsupportedFormat))); } // AC.6 — drop-in proof: a stub remote returning a self-contained GLB makes a // `Threemf` source parse via the GLB loader, with the trait shape unchanged. struct StubRemote; impl RemoteMeshParser for StubRemote { async fn convert(&self, _bytes: &[u8]) -> Result, MeshLoadError> { // Returns a real self-contained GLB regardless of input. Ok(fixture("box.glb")) } } #[test] fn production_parser_with_stub_remote_parses_threemf_via_glb() { let parser = ProductionMeshParser::new(StubRemote); let source = MeshSource { format: MeshFormat::Threemf, primary: b"fake 3mf bytes", resources: MeshResources::empty(), }; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let mesh = rt .block_on(parser.parse(&source)) .expect("stub remote -> GLB -> ModelMesh"); assert_eq!(mesh.format, MeshFormat::Gltf); assert_eq!(mesh.trimesh.triangle_count(), 12); } }