Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
11 kB · 283 lines
Rust
at commit f2497d02
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284//! 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<F, Fut>( primary_url: &str, format: MeshFormat, mut fetch: F,) -> Result<ModelMesh, MeshLoadError>where F: FnMut(String) -> Fut, Fut: Future<Output = Result<Vec<u8>, 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::<Local>::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<u8>)],}
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<u8>)]) -> Self { MeshResources { companions } }
/// Iterate `(raw_uri, bytes)` pairs. pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> { 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<ModelMesh, MeshLoadError> { 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<ModelMesh, MeshLoadError>;}
/// 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<Vec<u8>, 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<Vec<u8>, 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<R: RemoteMeshParser = Local> { /// 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<Local> { fn default() -> Self { Self { remote: Local } }}
impl<R: RemoteMeshParser> DefaultMeshParser<R> { /// Construct with a remote parser (PM-49 wires `ThreemfRemoteParser` here). pub fn new(remote: R) -> Self { Self { remote } }}
impl<R: RemoteMeshParser> MeshParser for DefaultMeshParser<R> { async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 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<u8> { 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); }}