diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index dbc4fb9..458b212 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -243,15 +243,33 @@ pub mod format_registry { { return None; } - if let (Some(file), Some(declared)) = (filename_format, declared_format) - && file != declared - { - return None; + if let (Some(file), Some(declared_format)) = (filename_format, declared_format) { + if file != declared_format { + return None; + } + if file == FormatId::Gltf + && let Some(ext) = extension(filename.unwrap_or_default()) + { + let expected = match ext.to_ascii_lowercase().as_str() { + "gltf" => "model/gltf+json", + "glb" => "model/gltf-binary", + _ => return None, + }; + if declared.as_deref() != Some(expected) + && declared.as_deref() != Some(GENERIC_MIME) + { + return None; + } + } } - filename - .filter(|_| filename_format.is_some()) - .and_then(canonical_mime_for_filename) - .or_else(|| declared_format.map(|format| format.capability().canonical_mime)) + if let Some(filename) = filename.filter(|_| filename_format.is_some()) { + return canonical_mime_for_filename(filename); + } + declared_format.map(|format| match declared.as_deref() { + Some("model/gltf-binary") => "model/gltf-binary", + Some("model/gltf+json") => "model/gltf+json", + _ => format.capability().canonical_mime, + }) } pub fn classify( @@ -263,9 +281,13 @@ pub mod format_registry { .map(normalize_mime) .filter(|mime| !mime.is_empty()); if let Some(mime) = declared.as_deref().filter(|mime| *mime != GENERIC_MIME) { - return format_for_mime(mime) - .map(Classification::Known) - .unwrap_or(Classification::UnsupportedMime); + let Some(format) = format_for_mime(mime) else { + return Classification::UnsupportedMime; + }; + if canonical_mime(filename, Some(mime)).is_none() { + return Classification::UnsupportedMime; + } + return Classification::Known(format); } if let Some(format) = filename.and_then(format_for_extension) { return Classification::Known(format); @@ -276,6 +298,17 @@ pub mod format_registry { Evidence::Multiple => Classification::Ambiguous, } } + + pub fn canonical_mime_for_evidence(bytes: &[u8]) -> Option<&'static str> { + match recognize_evidence(bytes) { + Evidence::One(FormatId::Gltf) if bytes.starts_with(b"glTF") => { + Some("model/gltf-binary") + } + Evidence::One(FormatId::Gltf) => Some("model/gltf+json"), + Evidence::One(format) => Some(format.capability().canonical_mime), + Evidence::None | Evidence::Multiple | Evidence::OverBound => None, + } + } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Evidence { None, @@ -295,8 +328,12 @@ pub mod format_registry { candidates.push(FormatId::Step); } if bytes.starts_with(b"PK") - && (bytes.windows(18).any(|w| w == b"[Content_Types].xml") - || bytes.windows(19).any(|w| w == b"3D/3dmodel.model")) + && (bytes + .windows(b"[Content_Types].xml".len()) + .any(|w| w == b"[Content_Types].xml") + || bytes + .windows(b"3D/3dmodel.model".len()) + .any(|w| w == b"3D/3dmodel.model")) { candidates.push(FormatId::Threemf); } @@ -329,8 +366,9 @@ pub mod format_registry { assert_eq!(LDRAW.extensions, &["ldr", "mpd", "dat"]); assert_eq!(LDRAW.canonical_mime, "application/x-ldraw"); assert_eq!(LDRAW.units, Units::declared(LengthUnit::LdrawUnit)); - assert!(LDRAW.compound && LDRAW.generated_preview && LDRAW.ordinary_download); - assert!(!THREEMF.generated_preview); + let ldraw = FormatId::Ldraw.capability(); + assert!(ldraw.compound && ldraw.generated_preview && ldraw.ordinary_download); + assert!(!FormatId::Threemf.capability().generated_preview); assert_eq!(STEP.conversion_target, Some(FormatId::Gltf)); assert_eq!(GLTF.units, Units::declared(LengthUnit::Meter)); } @@ -375,11 +413,84 @@ pub mod format_registry { None ); } + #[test] + fn every_frozen_registry_row_has_expected_capabilities_and_aliases() { + let expected = [ + ( + FormatId::Stl, + "stl", + &["stl"][..], + "model/stl", + true, + false, + None, + ), + ( + FormatId::Obj, + "obj", + &["obj"][..], + "model/obj", + true, + false, + None, + ), + ( + FormatId::Gltf, + "gltf", + &["gltf", "glb"][..], + "model/gltf+json", + true, + false, + None, + ), + ( + FormatId::Threemf, + "3mf", + &["3mf"][..], + "model/3mf", + false, + false, + None, + ), + ( + FormatId::Ldraw, + "ldraw", + &["ldr", "mpd", "dat"][..], + "application/x-ldraw", + true, + true, + None, + ), + ( + FormatId::Step, + "step", + &["step", "stp"][..], + "model/step", + true, + false, + Some(FormatId::Gltf), + ), + ]; + for (id, canonical_id, extensions, mime, preview, compound, conversion) in expected { + let capability = id.capability(); + assert_eq!(capability.canonical_id, canonical_id); + assert_eq!(capability.extensions, extensions); + assert_eq!(capability.canonical_mime, mime); + assert_eq!(capability.generated_preview, preview); + assert_eq!(capability.compound, compound); + assert_eq!(capability.conversion_target, conversion); + assert!(capability.ordinary_download); + } + assert_eq!(format_for_mime("application/sla"), Some(FormatId::Stl)); + assert_eq!(format_for_mime("model/ldraw"), Some(FormatId::Ldraw)); + assert_eq!(format_for_mime("application/x-step"), Some(FormatId::Step)); + } + #[test] fn classification_is_mime_first_then_filename_then_bounded_evidence() { assert_eq!( classify(Some("model/obj"), Some("x.stl"), &[]), - Classification::Known(FormatId::Obj) + Classification::UnsupportedMime ); assert_eq!( classify(Some(GENERIC_MIME), Some("x.dat"), &[]), @@ -389,6 +500,22 @@ pub mod format_registry { classify(None, None, b"glTF\x02\0\0\0"), Classification::Known(FormatId::Gltf) ); + assert_eq!( + canonical_mime_for_evidence(b"glTF\x02\0\0\0"), + Some("model/gltf-binary") + ); + assert_eq!( + canonical_mime_for_evidence(br#"{"asset":{"version":"2.0"}}"#), + Some("model/gltf+json") + ); + assert_eq!( + recognize_evidence(b"PK\x03\x04[Content_Types].xml"), + Evidence::One(FormatId::Threemf) + ); + assert_eq!( + recognize_evidence(&[0; MAX_EVIDENCE_BYTES + 1]), + Evidence::OverBound + ); assert_eq!( classify(Some("text/plain"), Some("x.dat"), &[]), Classification::UnsupportedMime diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index a55b11e..d92d835 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -124,12 +124,27 @@ test('logged-out Thing detail acquires the complete model bundle in one public X 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', ); + await expect(page.locator(boundary)).toHaveAttribute('data-viewer-status', 'Viewer ready', { + timeout: 15_000, + }); + await expect(page.locator(boundary)).toHaveAttribute('data-current-identity', /.+/); + await expect(page.locator(boundary)).toHaveAttribute('data-interactive-namespace', 'Interactive'); + await expect(page.locator(boundary)).toHaveAttribute('data-preview-namespace', 'Preview'); + await expect(page.locator(boundary)).toHaveAttribute( + 'data-protocol-capabilities', + expect.stringContaining('CompoundLdraw'), + ); + const interactiveIdentity = await page.locator(boundary).getAttribute('data-current-identity'); await expect(page.locator(boundary)).toHaveAttribute('data-compound-boundary', 'true'); expect(bundleRequests).toHaveLength(1); expect(new URL(bundleRequests[0].url()).searchParams.get('uri')).toBe( 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', ); expect(resourceRequests).toHaveLength(0); + // Preview is a separate renderer namespace and must not replace the current + // interactive identity/readiness after the complete bundle has arrived. + await expect(page.locator(boundary)).toHaveAttribute('data-current-identity', interactiveIdentity); + await expect(page.locator(boundary)).toHaveAttribute('data-viewer-status', 'Viewer ready'); const downloadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); diff --git a/src/publish.rs b/src/publish.rs index b479c75..bfc2028 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -1991,15 +1991,25 @@ impl Flow { match file.read_bytes().await { Ok(bytes) => { let bytes = bytes.to_vec(); - let mime = file - .content_type() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| mime_from_filename(&filename).to_string()); + let (mime, format) = match classify_geometry_upload( + file.content_type().as_deref(), + &filename, + &bytes, + ) { + Ok(value) => value, + Err(error) => { + upload.set(UploadStatus::Error(format!( + "Upload failed for {filename}: {error}" + ))); + let flow = self.clone(); + flow.save_draft().await; + return; + } + }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()) .await { Ok(staged) => { - let format = format_from_filename(&filename); let mut part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), @@ -2088,13 +2098,16 @@ impl Flow { return; } }; - let mime = file - .content_type() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| mime_from_filename(&filename).to_string()); + let (mime, format) = + match classify_geometry_upload(file.content_type().as_deref(), &filename, &bytes) { + Ok(value) => value, + Err(error) => { + upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); + return; + } + }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { - let format = format_from_filename(&filename); let mut part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), @@ -2324,10 +2337,36 @@ fn mime_from_filename(name: &str) -> &'static str { }) } -/// Short geometry format label from a filename, if recognized. -fn format_from_filename(name: &str) -> Option { - format_registry::format_for_extension(name) - .map(|format| format.capability().canonical_id.to_owned()) +/// Classify a browser-selected geometry file before authenticated staging. +/// +/// A browser may report `application/octet-stream` for a recognized extension; +/// that neutral declaration is replaced with the registry's canonical MIME. A +/// specific unsupported or conflicting declaration is rejected, while an +/// extensionless neutral file is accepted only when bounded evidence selects a +/// single format. +fn classify_geometry_upload( + declared_mime: Option<&str>, + filename: &str, + evidence: &[u8], +) -> Result<(String, Option), String> { + let classification = format_registry::classify(declared_mime, Some(filename), evidence); + let format = match classification { + format_registry::Classification::Known(format) => format, + format_registry::Classification::UnsupportedMime => { + return Err("unsupported or conflicting geometry MIME".into()); + } + format_registry::Classification::Ambiguous => { + return Err("ambiguous extensionless geometry content".into()); + } + format_registry::Classification::Unknown => return Err("unknown geometry format".into()), + }; + let mime = format_registry::canonical_mime(Some(filename), declared_mime) + .or_else(|| format_registry::canonical_mime_for_evidence(evidence)) + .ok_or_else(|| "unable to determine canonical geometry MIME".to_string())?; + Ok(( + mime.to_owned(), + Some(format.capability().canonical_id.to_owned()), + )) } fn supports_generated_preview(name: &str) -> bool { @@ -2453,12 +2492,11 @@ async fn generate_preview( mod renderer_preview { use polymodel_renderer_protocol::{ Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadIdentity, - LoadOutput, LoadSource, MeshFormat, ModelLoadBundle, ModelResource, ProtocolHello, - RendererCommand, RendererEvent, RendererNamespace, deserialize_event_envelope, + LoadOutput, LoadSource, MeshFormat, ProtocolHello, RendererCommand, RendererEvent, + RendererNamespace, deserialize_event_envelope, format_registry::{self, FormatId}, required_capabilities, serialize_command_envelope, }; - use sha2::{Digest, Sha256}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; @@ -2497,28 +2535,17 @@ mod renderer_preview { } if format_registry::format_for_extension(filename) == Some(FormatId::Ldraw) { - let byte_length = bytes.len() as u64; - let bundle = ModelLoadBundle { - version: polymodel_renderer_protocol::PROTOCOL_VERSION, - format: MeshFormat::CompoundLdraw, - primary: filename.to_owned(), - resources: vec![ModelResource { - key: filename.to_owned(), - path: filename.to_owned(), - root: "models".to_owned(), - byte_length, - sha256: Sha256::digest(&bytes).to_vec(), - mime_type: "application/x-ldraw".to_owned(), - target_cids: Vec::new(), - bytes, - }], - }; - return request_preview_png(LoadSource::Bundle(bundle), width, height).await; + // Compound bundles are materialized by the server-owned G path only + // after projection. Draft staging has no resource URI or companion + // manifest, so never dispatch a partial browser-local bundle. + return Err("LDraw preview requires a complete projected model bundle".to_owned()); } let format = MeshFormat::from_extension(filename) .ok_or_else(|| "unsupported preview format".to_string())?; - let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?; + let mime = format_registry::canonical_mime_for_filename(filename) + .ok_or_else(|| "unsupported preview MIME".to_string())?; + let blob_url = blob_url_for_bytes(bytes, mime)?; let result = request_preview_png( LoadSource::Url { format, @@ -2534,16 +2561,6 @@ mod renderer_preview { result } - fn mime_from_format(format: MeshFormat) -> &'static str { - match format { - MeshFormat::Stl => "model/stl", - MeshFormat::Obj => "model/obj", - MeshFormat::Gltf => "model/gltf+json", - MeshFormat::Threemf => "model/3mf", - MeshFormat::CompoundLdraw => "application/x-ldraw", - } - } - fn blob_url_for_bytes(bytes: Vec, mime: &str) -> Result { let array = js_sys::Uint8Array::from(bytes.as_slice()); let parts = js_sys::Array::new(); @@ -2876,9 +2893,17 @@ mod tests { assert_eq!(mime_from_filename("scene.glb"), "model/gltf-binary"); assert_eq!(mime_from_filename("model.dat"), "application/x-ldraw"); assert_eq!(mime_from_filename("weird"), "application/octet-stream"); - assert_eq!(format_from_filename("a.glb").as_deref(), Some("gltf")); - assert_eq!(format_from_filename("a.dat").as_deref(), Some("ldraw")); - assert_eq!(format_from_filename("a.txt"), None); + assert_eq!( + format_registry::format_for_extension("a.glb") + .map(|format| format.capability().canonical_id), + Some("gltf") + ); + assert_eq!( + format_registry::format_for_extension("a.dat") + .map(|format| format.capability().canonical_id), + Some("ldraw") + ); + assert_eq!(format_registry::format_for_extension("a.txt"), None); } #[test] @@ -2898,6 +2923,29 @@ mod tests { .any(|value| value == expected) ); } + let source = include_str!("publish.rs"); + assert_eq!( + source + .lines() + .filter(|line| line.contains("accept: GEOMETRY_PICKER_ACCEPT")) + .count(), + 4 + ); + } + + #[test] + fn geometry_upload_classification_canonicalizes_neutral_and_rejects_conflicts() { + assert_eq!( + classify_geometry_upload(Some(format_registry::GENERIC_MIME), "part.dat", &[]).unwrap(), + ("application/x-ldraw".into(), Some("ldraw".into())) + ); + assert_eq!( + classify_geometry_upload(Some(format_registry::GENERIC_MIME), "scene.glb", &[]) + .unwrap(), + ("model/gltf-binary".into(), Some("gltf".into())) + ); + assert!(classify_geometry_upload(Some("model/gltf+json"), "scene.glb", &[]).is_err()); + assert!(classify_geometry_upload(Some("text/plain"), "scene.glb", &[]).is_err()); } #[test] diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 6a89547..cb5d48f 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -99,8 +99,16 @@ fn viewer_source_for_part(part: &PartDetail) -> Option { } fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option { + let projected_mime = part.view.file.mime_type.as_str().trim(); + if !projected_mime.is_empty() + && !projected_mime.split(';').next().is_some_and(|mime| { + mime.trim() + .eq_ignore_ascii_case(format_registry::GENERIC_MIME) + }) + { + return viewer_format_candidate(projected_mime); + } [ - Some(part.view.file.mime_type.as_str()), Some(url), Some(name), part.view.format.as_ref().map(|format| format.as_str()), @@ -1144,21 +1152,21 @@ mod tests { ), ( part_detail_for_viewer( - "application/x-unknown", + format_registry::GENERIC_MIME, Some("scene.3mf"), Some("model/obj"), ), "drawing.glb", - ViewerSourceFormat::Step, + ViewerSourceFormat::Renderer(MeshFormat::Gltf), ), ( part_detail_for_viewer( - "application/x-unknown", + format_registry::GENERIC_MIME, Some("scene.3mf"), Some("model/obj"), ), - "drawing.glb", - ViewerSourceFormat::Renderer(MeshFormat::Gltf), + "drawing.step", + ViewerSourceFormat::Step, ), ( part_detail_for_viewer( @@ -1186,19 +1194,19 @@ mod tests { ); assert_eq!( part_viewer_format(&cases[1].0, url, "drawing.ply"), - Some(cases[1].2.clone()) + Some(ViewerSourceFormat::Step) ); assert_eq!( part_viewer_format(&cases[2].0, "parts/fixture.ply", cases[2].1), - Some(cases[2].2.clone()) + Some(ViewerSourceFormat::Step) ); assert_eq!( part_viewer_format(&cases[3].0, "parts/fixture.ply", cases[3].1), - Some(cases[3].2.clone()) + None ); assert_eq!( part_viewer_format(&cases[4].0, "parts/fixture.ply", cases[4].1), - Some(cases[4].2.clone()) + None ); } diff --git a/src/viewer.rs b/src/viewer.rs index 177695b..7f610bd 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -15,11 +15,12 @@ #[cfg(target_arch = "wasm32")] use crate::client::PolymodelClient; use dioxus::prelude::*; +use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; #[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, - LoadIdentity, LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, - RendererNamespace, deserialize_event_envelope, serialize_command_envelope, + CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, LoadIdentity, + LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, RendererNamespace, + deserialize_event_envelope, serialize_command_envelope, }; use polymodel_renderer_protocol::{ KeyModifiers, LoadErrorCode, MeshFormat, MeshStats, RendererCommand, RendererEvent, @@ -915,6 +916,13 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { "data-session-wanted": "{status_val.should_have_session()}", "data-resize-count": "{resize_count.read()}", "data-session-label": session_label.as_str(), + "data-current-identity": if matches!(status_val, ViewerStatus::Ready(_)) { + session_label.as_str() + } else { + "" + }, + "data-interactive-namespace": "Interactive", + "data-preview-namespace": "Preview", "data-asset-path": asset_path.to_string(), "data-renderer-kind": renderer_kind, "data-context-acquisitions": "{acquisitions}", @@ -1447,6 +1455,10 @@ fn pending_plan_is_current(acquisition_epoch: u32, restart_epoch: u32) -> bool { acquisition_epoch == restart_epoch } +fn compound_capability_is_available(negotiated: Option<&NegotiatedProtocol>) -> bool { + negotiated.is_some_and(|protocol| protocol.capabilities.contains(&Capability::CompoundLdraw)) +} + fn context_acquisitions_after_event(current: u64, event: &RendererEvent) -> u64 { if matches!(event, RendererEvent::ContextAcquired) { 1 @@ -1862,17 +1874,11 @@ impl WorkerBridgeState { } fn start_compound_load(&mut self, pending: PendingCompoundLoad) { - if self.compound_started_key.as_deref() == Some(pending.logical.key.as_str()) { + if !compound_capability_is_available(self.handshake.borrow().negotiated()) { + tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); return; } - let missing_compound_capability = { - let handshake = self.handshake.borrow(); - handshake.negotiated().is_some_and(|negotiated| { - !negotiated.capabilities.contains(&Capability::CompoundLdraw) - }) - }; - if missing_compound_capability { - tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); + if self.compound_started_key.as_deref() == Some(pending.logical.key.as_str()) { return; } let identity = self.allocate_identity(); @@ -2500,45 +2506,30 @@ mod tests { } } - #[cfg(target_arch = "wasm32")] #[test] fn compound_load_without_negotiated_capability_stays_non_ready_and_reports_existing_error() { - use polymodel_renderer_protocol::{Capability, HandshakeState, NegotiatedProtocol}; - - let mut handshake = HandshakeState::Created; - let hello = ProtocolHello { - min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, - max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, - required: vec![Capability::CurrentFormats], - optional: vec![Capability::Preview], - }; + use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; + let negotiated = NegotiatedProtocol { version: polymodel_renderer_protocol::PROTOCOL_VERSION, capabilities: vec![Capability::CurrentFormats, Capability::Preview], session_id: 7, }; - assert!(handshake.accept_hello(hello).is_ok()); - assert!(handshake.accept_negotiated(negotiated).is_ok()); - let command = RendererCommand::StartLoad { - namespace: RendererNamespace::Interactive, - identity: LoadIdentity::new(1, 1), - source: LoadSource::Bundle(ModelLoadBundle { - version: polymodel_renderer_protocol::PROTOCOL_VERSION, - format: MeshFormat::CompoundLdraw, - primary: "main.dat".into(), - resources: Vec::new(), - }), - output: LoadOutput::Interactive, - }; - assert!(required_capabilities(&command).contains(&Capability::CompoundLdraw)); - assert!( - !handshake - .negotiated() - .unwrap() - .capabilities - .contains(&Capability::CompoundLdraw) - ); - assert!(!command_is_ready_for_worker(true, Some(7), true, &command)); + assert!(!compound_capability_is_available(Some(&negotiated))); + assert!(!compound_capability_is_available(None)); + + let reducer = LoadReducer::new(RendererNamespace::Interactive); + assert!(matches!( + reducer.state(), + polymodel_renderer_protocol::LoadState::Idle + )); + assert_eq!(reducer.current_identity(), None); + assert!(!compound_capability_is_available(Some(&negotiated))); + assert!(matches!( + reducer.state(), + polymodel_renderer_protocol::LoadState::Idle + )); + assert_eq!(reducer.current_identity(), None); } #[test]