From 456c4a290e835ff111253bbc731196b79f221d47 Mon Sep 17 00:00:00 2001 From: Orual Date: Fri, 7 Aug 2026 18:57:11 -0400 Subject: [PATCH] PM-83: implement format registry and LDraw publishing integration Centralize format capabilities, propagate LDraw and glTF MIME metadata, enable LDraw publishing and Thing-detail dispatch, preserve compound bundle negotiation, and regenerate authored lexicon output. Epic: PM-86 Task: PM-83 --- .../space_polymodel_library_defs.json | 4 + .../src/space_polymodel/library.rs | 11 +- crates/polymodel-renderer-protocol/src/lib.rs | 395 ++++++++++++++++++ lexicons/library/defs.json | 4 + src/publish.rs | 86 ++-- src/thing_detail.rs | 60 +-- src/viewer.rs | 35 ++ 7 files changed, 508 insertions(+), 87 deletions(-) diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json index 2670331..6eeb337 100644 --- a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json +++ b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json @@ -200,6 +200,10 @@ "model/stl", "model/3mf", "model/obj", + "model/gltf+json", + "model/gltf-binary", + "application/x-ldraw", + "model/ldraw", "application/octet-stream", "model/step", "application/step" diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index 653e618..ca4bc68 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -493,6 +493,10 @@ impl LexiconSchema for Chunk { "model/stl", "model/3mf", "model/obj", + "model/gltf+json", + "model/gltf-binary", + "application/x-ldraw", + "model/ldraw", "application/octet-stream", "model/step", "application/step", @@ -515,8 +519,11 @@ impl LexiconSchema for Chunk { path: ValidationPath::from_field("blob"), accepted: vec![ "model/stl".to_string(), "model/3mf".to_string(), "model/obj" - .to_string(), "application/octet-stream".to_string(), - "model/step".to_string(), "application/step".to_string() + .to_string(), "model/gltf+json".to_string(), + "model/gltf-binary".to_string(), "application/x-ldraw" + .to_string(), "model/ldraw".to_string(), + "application/octet-stream".to_string(), "model/step" + .to_string(), "application/step".to_string() ], actual: mime.to_string(), }); diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index f817d03..dbc4fb9 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -14,6 +14,401 @@ use sha2::Digest; use std::collections::HashSet; mod load_state; +pub mod format_registry { + use super::{LengthUnit, MeshFormat, Units}; + + pub const GENERIC_MIME: &str = "application/octet-stream"; + pub const MAX_EVIDENCE_BYTES: usize = 64 * 1024; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum FormatId { + Stl, + Obj, + Gltf, + Threemf, + Ldraw, + Step, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum RendererLocation { + Worker(MeshFormat), + CompoundWorker(MeshFormat), + Conversion, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct FormatCapability { + pub id: FormatId, + pub canonical_id: &'static str, + pub extensions: &'static [&'static str], + pub mime_aliases: &'static [&'static str], + pub canonical_mime: &'static str, + pub renderer: Option, + pub compound: bool, + pub generated_preview: bool, + pub ordinary_download: bool, + pub conversion_target: Option, + pub units: Units, + pub parser_location: &'static str, + } + + const STL: FormatCapability = FormatCapability { + id: FormatId::Stl, + canonical_id: "stl", + extensions: &["stl"], + mime_aliases: &["model/stl", "application/sla"], + canonical_mime: "model/stl", + renderer: Some(RendererLocation::Worker(MeshFormat::Stl)), + compound: false, + generated_preview: true, + ordinary_download: true, + conversion_target: None, + units: Units { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + }, + parser_location: "renderer-worker", + }; + const OBJ: FormatCapability = FormatCapability { + id: FormatId::Obj, + canonical_id: "obj", + extensions: &["obj"], + mime_aliases: &["model/obj"], + canonical_mime: "model/obj", + renderer: Some(RendererLocation::Worker(MeshFormat::Obj)), + compound: false, + generated_preview: true, + ordinary_download: true, + conversion_target: None, + units: Units { + source: LengthUnit::Unknown, + assumed: Some(LengthUnit::Millimeter), + }, + parser_location: "renderer-worker", + }; + const GLTF: FormatCapability = FormatCapability { + id: FormatId::Gltf, + canonical_id: "gltf", + extensions: &["gltf", "glb"], + mime_aliases: &["model/gltf+json", "model/gltf-binary"], + canonical_mime: "model/gltf+json", + renderer: Some(RendererLocation::Worker(MeshFormat::Gltf)), + compound: false, + generated_preview: true, + ordinary_download: true, + conversion_target: None, + units: Units { + source: LengthUnit::Meter, + assumed: None, + }, + parser_location: "renderer-worker", + }; + const THREEMF: FormatCapability = FormatCapability { + id: FormatId::Threemf, + canonical_id: "3mf", + extensions: &["3mf"], + mime_aliases: &["model/3mf"], + canonical_mime: "model/3mf", + renderer: Some(RendererLocation::Worker(MeshFormat::Threemf)), + compound: false, + generated_preview: false, + ordinary_download: true, + conversion_target: None, + units: Units { + source: LengthUnit::Unknown, + assumed: None, + }, + parser_location: "renderer-worker", + }; + const LDRAW: FormatCapability = FormatCapability { + id: FormatId::Ldraw, + canonical_id: "ldraw", + extensions: &["ldr", "mpd", "dat"], + mime_aliases: &["application/x-ldraw", "model/ldraw"], + canonical_mime: "application/x-ldraw", + renderer: Some(RendererLocation::CompoundWorker(MeshFormat::CompoundLdraw)), + compound: true, + generated_preview: true, + ordinary_download: true, + conversion_target: None, + units: Units { + source: LengthUnit::LdrawUnit, + assumed: None, + }, + parser_location: "ldraw-compound-worker", + }; + const STEP: FormatCapability = FormatCapability { + id: FormatId::Step, + canonical_id: "step", + extensions: &["step", "stp"], + mime_aliases: &["model/step", "application/step", "application/x-step"], + canonical_mime: "model/step", + renderer: Some(RendererLocation::Conversion), + compound: false, + generated_preview: true, + ordinary_download: true, + conversion_target: Some(FormatId::Gltf), + units: Units { + source: LengthUnit::Unknown, + assumed: None, + }, + parser_location: "step-to-gltf-converter", + }; + + pub const CAPABILITIES: &[FormatCapability] = &[STL, OBJ, GLTF, THREEMF, LDRAW, STEP]; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Classification { + Known(FormatId), + Unknown, + Ambiguous, + UnsupportedMime, + } + + impl FormatId { + pub const fn capability(self) -> &'static FormatCapability { + match self { + Self::Stl => &STL, + Self::Obj => &OBJ, + Self::Gltf => &GLTF, + Self::Threemf => &THREEMF, + Self::Ldraw => &LDRAW, + Self::Step => &STEP, + } + } + pub const fn renderer_format(self) -> Option { + match self { + Self::Stl => Some(MeshFormat::Stl), + Self::Obj => Some(MeshFormat::Obj), + Self::Gltf => Some(MeshFormat::Gltf), + Self::Threemf => Some(MeshFormat::Threemf), + Self::Ldraw => Some(MeshFormat::CompoundLdraw), + Self::Step => None, + } + } + } + + fn normalize_mime(value: &str) -> String { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + } + fn extension(filename: &str) -> Option<&str> { + filename + .rsplit_once('.') + .map(|(_, ext)| ext) + .filter(|ext| !ext.is_empty()) + } + pub fn format_for_extension(filename: &str) -> Option { + let ext = extension(filename)?.to_ascii_lowercase(); + CAPABILITIES + .iter() + .find(|cap| cap.extensions.iter().any(|known| *known == ext)) + .map(|cap| cap.id) + } + pub fn format_for_mime(mime: &str) -> Option { + let mime = normalize_mime(mime); + CAPABILITIES + .iter() + .find(|cap| cap.mime_aliases.iter().any(|known| *known == mime)) + .map(|cap| cap.id) + } + pub fn canonical_mime_for_filename(filename: &str) -> Option<&'static str> { + let format = format_for_extension(filename)?; + match ( + format, + extension(filename).map(|ext| ext.to_ascii_lowercase()), + ) { + (FormatId::Gltf, Some(ext)) if ext == "glb" => Some("model/gltf-binary"), + (FormatId::Gltf, Some(ext)) if ext == "gltf" => Some("model/gltf+json"), + _ => Some(format.capability().canonical_mime), + } + } + pub fn canonical_mime( + filename: Option<&str>, + declared_mime: Option<&str>, + ) -> Option<&'static str> { + let declared = declared_mime + .map(normalize_mime) + .filter(|mime| !mime.is_empty()); + let filename_format = filename.and_then(format_for_extension); + let declared_format = declared.as_deref().and_then(format_for_mime); + if declared + .as_deref() + .is_some_and(|mime| mime != GENERIC_MIME && declared_format.is_none()) + { + return None; + } + if let (Some(file), Some(declared)) = (filename_format, declared_format) + && file != declared + { + return None; + } + filename + .filter(|_| filename_format.is_some()) + .and_then(canonical_mime_for_filename) + .or_else(|| declared_format.map(|format| format.capability().canonical_mime)) + } + + pub fn classify( + declared_mime: Option<&str>, + filename: Option<&str>, + evidence: &[u8], + ) -> Classification { + let declared = declared_mime + .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); + } + if let Some(format) = filename.and_then(format_for_extension) { + return Classification::Known(format); + } + match recognize_evidence(evidence) { + Evidence::One(format) => Classification::Known(format), + Evidence::None | Evidence::OverBound => Classification::Unknown, + Evidence::Multiple => Classification::Ambiguous, + } + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Evidence { + None, + One(FormatId), + Multiple, + OverBound, + } + pub fn recognize_evidence(bytes: &[u8]) -> Evidence { + if bytes.len() > MAX_EVIDENCE_BYTES { + return Evidence::OverBound; + } + let mut candidates = Vec::new(); + if bytes.starts_with(b"glTF") { + candidates.push(FormatId::Gltf); + } + if bytes.starts_with(b"ISO-10303-21;") { + 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")) + { + candidates.push(FormatId::Threemf); + } + if let Ok(text) = core::str::from_utf8(bytes) { + if text.contains("\"asset\"") && text.contains("\"version\"") { + candidates.push(FormatId::Gltf); + } + if text + .lines() + .any(|line| line.trim_start().starts_with("0 FILE ")) + { + candidates.push(FormatId::Ldraw); + } + } + candidates.sort_unstable_by_key(|format| *format as u8); + candidates.dedup(); + match candidates.as_slice() { + [] => Evidence::None, + [format] => Evidence::One(*format), + _ => Evidence::Multiple, + } + } + + #[cfg(test)] + mod tests { + use super::*; + #[test] + fn registry_capabilities_and_policies_are_frozen() { + assert_eq!(CAPABILITIES.len(), 6); + 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); + assert_eq!(STEP.conversion_target, Some(FormatId::Gltf)); + assert_eq!(GLTF.units, Units::declared(LengthUnit::Meter)); + } + #[test] + fn aliases_extensions_and_url_load_neutrality_are_explicit() { + for name in ["x.ldr", "x.MPD", "x.Dat"] { + assert_eq!(format_for_extension(name), Some(FormatId::Ldraw)); + } + assert_eq!( + format_for_mime(" APPLICATION/X-LDRAW; charset=utf-8 "), + Some(FormatId::Ldraw) + ); + assert_eq!(format_for_mime("application/sla"), Some(FormatId::Stl)); + assert_eq!(MeshFormat::from_extension("x.ldr"), None); + assert_eq!(MeshFormat::from_extension("x.mpd"), None); + assert_eq!(MeshFormat::from_extension("x.dat"), None); + } + #[test] + fn canonical_gltf_mime_preserves_container_and_rejects_conflicts() { + assert_eq!( + canonical_mime_for_filename("scene.gltf"), + Some("model/gltf+json") + ); + assert_eq!( + canonical_mime_for_filename("scene.GLB"), + Some("model/gltf-binary") + ); + assert_eq!( + canonical_mime(Some("scene.gltf"), Some(GENERIC_MIME)), + Some("model/gltf+json") + ); + assert_eq!( + canonical_mime(Some("scene.glb"), Some(GENERIC_MIME)), + Some("model/gltf-binary") + ); + assert_eq!( + canonical_mime(Some("scene.gltf"), Some("model/gltf-binary")), + None + ); + assert_eq!( + canonical_mime(Some("scene.glb"), Some("model/gltf+json")), + None + ); + } + #[test] + fn classification_is_mime_first_then_filename_then_bounded_evidence() { + assert_eq!( + classify(Some("model/obj"), Some("x.stl"), &[]), + Classification::Known(FormatId::Obj) + ); + assert_eq!( + classify(Some(GENERIC_MIME), Some("x.dat"), &[]), + Classification::Known(FormatId::Ldraw) + ); + assert_eq!( + classify(None, None, b"glTF\x02\0\0\0"), + Classification::Known(FormatId::Gltf) + ); + assert_eq!( + classify(Some("text/plain"), Some("x.dat"), &[]), + Classification::UnsupportedMime + ); + assert_eq!(classify(None, None, &[]), Classification::Unknown); + assert_eq!( + classify(None, None, &[0; MAX_EVIDENCE_BYTES + 1]), + Classification::Unknown + ); + assert_eq!( + classify( + None, + None, + b"0 FILE main.dat\n{\"asset\":{\"version\":\"2.0\"}}" + ), + Classification::Ambiguous + ); + } + } +} pub mod scene; pub use load_state::{ diff --git a/lexicons/library/defs.json b/lexicons/library/defs.json index 7f22e9b..a1e96f7 100644 --- a/lexicons/library/defs.json +++ b/lexicons/library/defs.json @@ -220,6 +220,10 @@ "model/stl", "model/3mf", "model/obj", + "model/gltf+json", + "model/gltf-binary", + "application/x-ldraw", + "model/ldraw", "application/octet-stream", "model/step", "application/step" diff --git a/src/publish.rs b/src/publish.rs index 13ea841..fa1b79f 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -25,6 +25,7 @@ use draft::{ use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::library::Image; +use polymodel_renderer_protocol::format_registry::{self, FormatId}; use state::PublishPhase; pub(crate) mod draft; @@ -44,6 +45,7 @@ const LICENSE_OPTIONS: &[&str] = &[ ]; const DEFAULT_LICENSE: &str = "CC-BY-4.0"; const CUSTOM_LICENSE_VALUE: &str = "__custom__"; +const GEOMETRY_PICKER_ACCEPT: &str = ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,.ldr,.mpd,.dat,model/gltf+json,model/gltf-binary,application/x-ldraw,model/ldraw,model/*,application/octet-stream"; /// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's /// `blob_cdn_url`) so the flow can preview an image right after upload. @@ -657,7 +659,7 @@ impl Flow { input { r#type: "file", multiple: true, - accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", + accept: GEOMETRY_PICKER_ACCEPT, class: "wizard-file-input", aria_label: "Upload model files", onchange: move |evt| { @@ -820,7 +822,7 @@ impl Flow { class: "wizard-file-input", r#type: "file", multiple: true, - accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", + accept: GEOMETRY_PICKER_ACCEPT, onchange: move |evt| { let files = evt.files(); if !files.is_empty() { @@ -943,7 +945,7 @@ impl Flow { input { class: "wizard-file-input", r#type: "file", - accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", + accept: GEOMETRY_PICKER_ACCEPT, onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let flow = addfile_w.clone(); @@ -2311,38 +2313,26 @@ fn chip_label(flow: &Flow, r: &StrongRef) -> String { /// Best-effort MIME type from a filename extension (the server re-sniffs image /// dimensions independently). fn mime_from_filename(name: &str) -> &'static str { - match extension(name).as_deref() { - Some("stl") => "model/stl", - Some("obj") => "model/obj", - Some("gltf") => "model/gltf+json", - Some("glb") => "model/gltf-binary", - Some("3mf") => "model/3mf", - Some("step") | Some("stp") => "model/step", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("webp") => "image/webp", - Some("gif") => "image/gif", - _ => "application/octet-stream", - } + format_registry::canonical_mime_for_filename(name).unwrap_or_else(|| { + match extension(name).as_deref() { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => format_registry::GENERIC_MIME, + } + }) } /// Short geometry format label from a filename, if recognized. fn format_from_filename(name: &str) -> Option { - match extension(name).as_deref() { - Some("stl") => Some("stl".into()), - Some("obj") => Some("obj".into()), - Some("gltf") | Some("glb") => Some("gltf".into()), - Some("3mf") => Some("3mf".into()), - Some("step") | Some("stp") => Some("step".into()), - _ => None, - } + format_registry::format_for_extension(name) + .map(|format| format.capability().canonical_id.to_owned()) } fn supports_generated_preview(name: &str) -> bool { - matches!( - extension(name).as_deref(), - Some("stl" | "obj" | "gltf" | "glb" | "step" | "stp") - ) + format_registry::format_for_extension(name) + .is_some_and(|format| format.capability().generated_preview) } fn extension(name: &str) -> Option { @@ -2465,6 +2455,7 @@ mod renderer_preview { Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadIdentity, LoadOutput, LoadSource, MeshFormat, ModelLoadBundle, ModelResource, ProtocolHello, RendererCommand, RendererEvent, RendererNamespace, deserialize_event_envelope, + format_registry::{self, FormatId}, required_capabilities, serialize_command_envelope, }; use sha2::{Digest, Sha256}; @@ -2480,7 +2471,7 @@ mod renderer_preview { width: u32, height: u32, ) -> Result, String> { - if is_step_filename(filename) { + if format_registry::format_for_extension(filename) == Some(FormatId::Step) { let step_url = blob_url_for_bytes(bytes, "model/step")?; let glb_url = match convert_step_to_glb_url(step_url.clone()).await { Ok(url) => url, @@ -2505,7 +2496,7 @@ mod renderer_preview { return result; } - if is_ldraw_filename(filename) { + 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, @@ -2517,7 +2508,7 @@ mod renderer_preview { root: "models".to_owned(), byte_length, sha256: Sha256::digest(&bytes).to_vec(), - mime_type: "text/plain".to_owned(), + mime_type: "application/x-ldraw".to_owned(), target_cids: Vec::new(), bytes, }], @@ -2549,7 +2540,7 @@ mod renderer_preview { MeshFormat::Obj => "model/obj", MeshFormat::Gltf => "model/gltf+json", MeshFormat::Threemf => "model/3mf", - MeshFormat::CompoundLdraw => "application/octet-stream", + MeshFormat::CompoundLdraw => "application/x-ldraw", } } @@ -2565,20 +2556,6 @@ mod renderer_preview { .map_err(|e| format!("preview blob URL: {e:?}")) } - fn is_step_filename(filename: &str) -> bool { - filename - .rsplit_once('.') - .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "step" | "stp")) - .unwrap_or(false) - } - - fn is_ldraw_filename(filename: &str) -> bool { - filename - .rsplit_once('.') - .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "ldr" | "mpd")) - .unwrap_or(false) - } - async fn convert_step_to_glb_url(step_url: String) -> Result { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); @@ -2895,11 +2872,25 @@ mod tests { fn mime_and_format_from_extension() { assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); assert_eq!(mime_from_filename("photo.png"), "image/png"); + assert_eq!(mime_from_filename("scene.gltf"), "model/gltf+json"); + 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); } + #[test] + fn geometry_picker_accept_is_shared_and_explicit() { + for expected in [ + ".ldr", ".mpd", ".dat", "application/x-ldraw", "model/ldraw", + "model/gltf+json", "model/gltf-binary", + ] { + assert!(GEOMETRY_PICKER_ACCEPT.split(',').any(|value| value == expected)); + } + } + #[test] fn generated_preview_support_includes_renderer_formats_and_step() { assert!(supports_generated_preview("part.stl")); @@ -2907,6 +2898,9 @@ mod tests { assert!(supports_generated_preview("part.glb")); assert!(supports_generated_preview("part.step")); assert!(supports_generated_preview("part.STP")); + assert!(supports_generated_preview("part.ldr")); + assert!(supports_generated_preview("part.mpd")); + assert!(supports_generated_preview("part.dat")); assert!(!supports_generated_preview("part.3mf")); assert!(!supports_generated_preview("notes.txt")); } diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 639f7bb..9699e40 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -11,7 +11,10 @@ use polymodel_api::space_polymodel::graph::{ delete_save::DeleteSave, }; use polymodel_api::space_polymodel::library; -use polymodel_renderer_protocol::MeshFormat; +use polymodel_renderer_protocol::{ + MeshFormat, + format_registry::{self, FormatId}, +}; use crate::Route; use crate::author_byline::AuthorByline; @@ -113,45 +116,12 @@ fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option Option { let value = candidate.trim().to_lowercase_smolstr(); - if matches!( - value.as_str(), - "step" | "stp" | "model/step" | "application/step" | "application/x-step" - ) || extension_is(&value, "step") - || extension_is(&value, "stp") - { - return Some(ViewerSourceFormat::Step); - } - if matches!( - value.as_str(), - "ldraw" | "ldr" | "mpd" | "application/x-ldraw" | "model/ldraw" - ) || extension_is(&value, "ldr") - || extension_is(&value, "mpd") - { - return Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)); - } - if matches!(value.as_str(), "stl" | "model/stl" | "application/sla") { - return Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)); - } - if matches!(value.as_str(), "obj" | "model/obj") { - return Some(ViewerSourceFormat::Renderer(MeshFormat::Obj)); - } - if matches!( - value.as_str(), - "gltf" | "glb" | "model/gltf+json" | "model/gltf-binary" - ) { - return Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)); + let format = format_registry::format_for_mime(value.as_str()) + .or_else(|| format_registry::format_for_extension(value.as_str()))?; + match format { + FormatId::Step => Some(ViewerSourceFormat::Step), + _ => format.renderer_format().map(ViewerSourceFormat::Renderer), } - if matches!(value.as_str(), "3mf" | "model/3mf") { - return Some(ViewerSourceFormat::Renderer(MeshFormat::Threemf)); - } - MeshFormat::from_extension(value.as_str()).map(ViewerSourceFormat::Renderer) -} - -fn extension_is(value: &SmolStr, extension: &str) -> bool { - value - .as_str() - .rsplit_once('.') - .is_some_and(|(_, ext)| ext == extension) } #[cfg_attr(not(feature = "server"), allow(dead_code))] @@ -1143,6 +1113,18 @@ mod tests { viewer_format_candidate("models/main.ldr"), Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); + assert_eq!( + viewer_format_candidate("models/main.dat"), + Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) + ); + assert_eq!( + viewer_format_candidate("model/ldraw; charset=utf-8"), + Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) + ); + assert_eq!( + viewer_format_candidate("model/gltf-binary"), + Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)) + ); assert_eq!( viewer_format_candidate("model/stl"), Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)) diff --git a/src/viewer.rs b/src/viewer.rs index 0dc4116..0c1cf24 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -2500,6 +2500,41 @@ 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], + }; + 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)); + } + #[test] fn bridge_gates_scene_commands_until_canvas_session_and_resize() { let start = RendererCommand::StartLoad { -- knotmirror 0.0.1