//! Parser seam — shared parse boundary and format dispatch. //! //! 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>>, { 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. /// /// Empty for STL, OBJ, GLB, and embedded-base64 `.gltf`. The caller keeps an owned /// companion store alive across the async parse and borrows it here; [`crate::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 struct MeshResources<'a> { companions: &'a [(String, Vec)], } impl<'a> MeshResources<'a> { /// No companion resources. pub fn empty() -> MeshResources<'static> { MeshResources { companions: &[] } } /// Wrap an owned companion store `(raw_uri, bytes)`. pub fn new(companions: &'a [(String, Vec)]) -> Self { MeshResources { companions } } /// Iterate `(raw_uri, bytes)` pairs. pub 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 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`/`Threemf` are all parsed from the in-memory bytes via /// `three-d-asset`. pub fn load_mesh(source: &MeshSource<'_>) -> Result { match source.format { MeshFormat::Stl => crate::stl::load_stl(source.primary), MeshFormat::Obj => crate::obj::load_obj(source.primary), MeshFormat::Gltf => crate::gltf::load_gltf(source), MeshFormat::Threemf => crate::threemf::load_threemf(source.primary), } } /// 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 /// `DefaultMeshParser::new(remote)` at the call site. This trait shape, the viewer /// pipeline, and `build_parsed_mesh` are unchanged. pub trait MeshParser { /// Parse the source into a mesh. #[allow(async_fn_in_trait)] async fn parse(&self, source: &MeshSource<'_>) -> Result; } /// A remote (server-side) parser for formats that require conversion before rendering. /// /// Reserved for future server-side formats (e.g. STEP via opencascade.js → GLB). Not used /// by any current format — STL/OBJ/glTF/3MF all parse client-side. /// /// **Contract:** [`RemoteMeshParser::convert`] MUST return a **self-contained GLB** /// (embedded buffer), because the remote arm re-enters the pipeline via `Gltf` with no /// companion resources. pub trait RemoteMeshParser { /// Convert the source bytes (e.g. a `.3mf`) into self-contained GLB bytes. #[allow(async_fn_in_trait)] async fn convert(&self, bytes: &[u8]) -> Result, MeshLoadError>; } /// No remote parser wired in: the seam returns [`MeshLoadError::UnsupportedFormat`] until /// a server-side format (e.g. STEP) supplies one. pub struct Local; impl RemoteMeshParser for Local { async fn convert(&self, _bytes: &[u8]) -> Result, MeshLoadError> { Err(MeshLoadError::UnsupportedFormat) } } /// Production parser: routes by format. All formats (`Stl`/`Obj`/`Gltf`/`Threemf`) /// parse in-memory via `three-d-asset`. Kept for compatibility with call sites that /// construct it; `load_mesh` is the direct path. pub struct DefaultMeshParser { /// Reserved for server-side formats (STEP via opencascade.js). Not read by any /// current `MeshFormat` arm — all four parse client-side. #[allow(dead_code)] remote: R, } impl Default for DefaultMeshParser { fn default() -> Self { Self { remote: Local } } } impl DefaultMeshParser { /// Construct with a remote parser (PM-49 wires `ThreemfRemoteParser` here). pub fn new(remote: R) -> Self { Self { remote } } } impl MeshParser for DefaultMeshParser { async fn parse(&self, source: &MeshSource<'_>) -> Result { match source.format { MeshFormat::Stl | MeshFormat::Obj | MeshFormat::Gltf | MeshFormat::Threemf => { load_mesh(source) } } } } #[cfg(test)] mod tests { use super::*; use crate::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() { 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]); 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()); } 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); 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); 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); let threemf = fixture("cube_gears.3mf"); let threemf_mesh = load_mesh(&MeshSource { format: MeshFormat::Threemf, primary: &threemf, resources: MeshResources::empty(), }) .expect("3MF dispatches to load_threemf"); assert_eq!(threemf_mesh.format, MeshFormat::Threemf); assert_eq!(threemf_mesh.triangle_count(), 25692); } #[test] fn production_parser_parses_threemf_locally() { let parser = DefaultMeshParser::default(); let threemf = fixture("cube_gears.3mf"); let source = MeshSource { format: MeshFormat::Threemf, primary: &threemf, resources: MeshResources::empty(), }; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let mesh = rt .block_on(parser.parse(&source)) .expect("3MF parses locally"); assert_eq!(mesh.format, MeshFormat::Threemf); assert_eq!(mesh.triangle_count(), 25692); } #[tokio::test] async fn load_mesh_with_companions_parses_threemf_locally() { let threemf = fixture("cube_gears.3mf"); let mesh = load_mesh_with_companions("cube_gears.3mf", MeshFormat::Threemf, |url| { let threemf = threemf.clone(); async move { assert_eq!(url, "cube_gears.3mf"); Ok(threemf) } }) .await .expect("3MF parses through the async worker helper"); assert_eq!(mesh.format, MeshFormat::Threemf); assert_eq!(mesh.triangle_count(), 25692); } }