//! Protocol crate for the Polymodel renderer worker split. //! //! Owns the types shared between the Dioxus main thread and the renderer Web Worker: //! [`MeshFormat`] / [`LengthUnit`] / [`Units`] (relocated from the mesh contract so the //! app never depends on `three-d-asset`), the postcard message envelope //! ([`RendererCommand`] / [`RendererEvent`]), and [`MeshStats`]. //! //! The app depends on this crate (and only this crate); the worker and mesh crates depend //! on it too. Heavy deps (`three-d-asset`, `stl_io`, `tobj`, `gltf`) live in the mesh and //! worker crates, never here. use serde::{Deserialize, Serialize}; 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_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; } } } 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( 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) { 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); } match recognize_evidence(evidence) { Evidence::One(format) => Classification::Known(format), Evidence::None | Evidence::OverBound => Classification::Unknown, 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, 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 recognizes_glb(bytes) { candidates.push(FormatId::Gltf); } if recognizes_step(bytes) { candidates.push(FormatId::Step); } if recognizes_threemf(bytes) { candidates.push(FormatId::Threemf); } if recognizes_stl(bytes) { candidates.push(FormatId::Stl); } if let Ok(text) = core::str::from_utf8(bytes) { if recognizes_gltf_json(text) { candidates.push(FormatId::Gltf); } if recognizes_ldraw(text) { candidates.push(FormatId::Ldraw); } if recognizes_obj(text) { candidates.push(FormatId::Obj); } } candidates.sort_unstable_by_key(|format| *format as u8); candidates.dedup(); match candidates.as_slice() { [] => Evidence::None, [format] => Evidence::One(*format), _ => Evidence::Multiple, } } fn recognizes_glb(bytes: &[u8]) -> bool { let Some(header) = bytes.get(..12) else { return false; }; header.starts_with(b"glTF") && u32::from_le_bytes(header[4..8].try_into().unwrap_or_default()) == 2 && usize::try_from(u32::from_le_bytes( header[8..12].try_into().unwrap_or_default(), )) .is_ok_and(|length| length == bytes.len()) } fn recognizes_step(bytes: &[u8]) -> bool { bytes.starts_with(b"ISO-10303-21;") && bytes .strip_suffix(b"END-ISO-10303-21;") .is_some_and(|body| body.windows(b"DATA;".len()).any(|part| part == b"DATA;")) } fn recognizes_threemf(bytes: &[u8]) -> bool { matches!( bytes.get(..4), Some(b"PK\x03\x04" | b"PK\x05\x06" | b"PK\x07\x08") ) && bytes .windows(b"[Content_Types].xml".len()) .any(|part| part == b"[Content_Types].xml") && bytes .windows(b"3D/3dmodel.model".len()) .any(|part| part == b"3D/3dmodel.model") } fn recognizes_gltf_json(text: &str) -> bool { let compact: String = text .chars() .filter(|character| !character.is_whitespace()) .collect(); compact.starts_with('{') && compact.ends_with('}') && compact.contains("\"asset\":{") && (compact.contains("\"version\":\"2.0\"") || compact.contains("\"version\":\"1.0\"")) } /// Binary STL: 80-byte header + little-endian u32 triangle count at [80..84] /// whose implied total size equals the buffer length (80 + 4 + 50*count). /// ASCII STL: starts with `solid` and contains `facet normal` within the /// first evidence window. The binary size check takes priority so that /// `solid`-prefixed binaries that fail the size check are rejected (known /// ambiguous case; many binary STLs abuse the header keyword). fn recognizes_stl(bytes: &[u8]) -> bool { if bytes.len() >= 84 { let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap_or_default()) as usize; let expected = 84_usize.saturating_add(count.saturating_mul(50)); if expected == bytes.len() { return true; } } // ASCII STL: must start with "solid" followed by whitespace, and // contain "facet normal" within the evidence window. Binary STLs // that abuse the `solid` header keyword but fail the size check // will not contain `facet normal` as ASCII text, so they correctly // fall through to `false`. if bytes.starts_with(b"solid") { match bytes.get(5) { Some(b' ') | Some(b'\r') | Some(b'\n') => {} _ => return false, } let text = core::str::from_utf8(bytes).unwrap_or(""); // Search for `facet normal` in the first 8 KiB of evidence. let window = &text[..text.len().min(8 * 1024)]; if window.contains("facet normal") { return true; } } false } /// OBJ: UTF-8 text with at least one geometric statement (v/vn/vt/f). /// Lines starting with `v `, `vn `, `vt ` require parseable float fields; /// `f `, `l `, `p `, `o `, `g `, `s `, `usemtl `, `mtllib ` provide /// structural evidence. Comments/names alone are insufficient. fn recognizes_obj(text: &str) -> bool { let mut has_geometric = false; for line in text.lines() { let trimmed = line.trim_start(); // v — requires parseable floats if let Some(rest) = trimmed.strip_prefix("v ") { if rest .split_ascii_whitespace() .take(3) .all(|f| f.parse::().is_ok()) { has_geometric = true; } continue; } // vn if let Some(rest) = trimmed.strip_prefix("vn ") { if rest .split_ascii_whitespace() .take(3) .all(|f| f.parse::().is_ok()) { has_geometric = true; } continue; } // vt [float] if let Some(rest) = trimmed.strip_prefix("vt ") { if rest .split_ascii_whitespace() .take(2) .all(|f| f.parse::().is_ok()) { has_geometric = true; } continue; } // f — geometric if trimmed.starts_with("f ") { has_geometric = true; continue; } // Other OBJ statements (l, p, vp, o, g, s, usemtl, mtllib) are // structural, not geometric. } has_geometric } fn recognizes_ldraw(text: &str) -> bool { text.lines().any(|line| { let mut fields = line.split_ascii_whitespace(); matches!((fields.next(), fields.next(), fields.next()), (Some("0"), Some("FILE"), Some(name)) if !name.is_empty()) }) } #[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)); 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)); } #[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 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::UnsupportedMime ); assert_eq!( classify(Some(GENERIC_MIME), Some("x.dat"), &[]), Classification::Known(FormatId::Ldraw) ); let glb = b"glTF\x02\0\0\0\x0c\0\0\0"; assert_eq!( classify(None, None, glb), Classification::Known(FormatId::Gltf) ); assert_eq!(canonical_mime_for_evidence(glb), 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 3D/3dmodel.model"), 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 ); 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::Known(FormatId::Ldraw) ); } #[test] fn evidence_recognizers_reject_near_misses() { assert_eq!( recognize_evidence(b"glTF\x02\0\0\0\xff\0\0\0"), Evidence::None ); assert_eq!( recognize_evidence(b"ISO-10303-21;\nDATA;\n"), Evidence::None ); assert_eq!( recognize_evidence(b"PK\x03\x04[Content_Types].xml"), Evidence::None ); assert_eq!( recognize_evidence(b"prefix 3D/3dmodel.model [Content_Types].xml"), Evidence::None ); assert_eq!( recognize_evidence(br#"{"note":"asset version 2.0"}"#), Evidence::None ); assert_eq!(recognize_evidence(b"0 FILE\n"), Evidence::None); } #[test] fn evidence_recognizers_accept_strict_minimal_shapes() { assert_eq!( recognize_evidence(b"ISO-10303-21;\nDATA;\nEND-ISO-10303-21;"), Evidence::One(FormatId::Step) ); assert_eq!( recognize_evidence(b" 0 FILE main.dat\n"), Evidence::One(FormatId::Ldraw) ); } #[test] fn stl_binary_round_trip_and_near_misses() { // Minimal valid binary STL: 80-byte header + 4-byte count (1) + 50 bytes per triangle. let mut bin = vec![0u8; 84 + 50]; bin[80..84].copy_from_slice(&1u32.to_le_bytes()); assert_eq!(recognize_evidence(&bin), Evidence::One(FormatId::Stl)); assert_eq!(canonical_mime_for_evidence(&bin), Some("model/stl")); // Truncated binary STL (wrong size for count=1). let truncated = &bin[..84 + 40]; assert_eq!(recognize_evidence(truncated), Evidence::None); // Binary with count=0 and no triangle data has exact size 84. let zero_tri = vec![0u8; 84]; assert_eq!(recognize_evidence(&zero_tri), Evidence::One(FormatId::Stl)); // Binary with count claiming 1 but buffer too small → not STL. let mut too_short = vec![0u8; 84 + 10]; too_short[80..84].copy_from_slice(&1u32.to_le_bytes()); assert_eq!(recognize_evidence(&too_short), Evidence::None); } #[test] fn stl_ascii_recognized() { let ascii = b"solid mesh\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid mesh\n"; assert_eq!(recognize_evidence(ascii), Evidence::One(FormatId::Stl)); assert_eq!(canonical_mime_for_evidence(ascii), Some("model/stl")); } #[test] fn stl_near_misses_do_not_classify() { // ASCII `solid` without any `facet normal` → not STL. assert_eq!( recognize_evidence(b"solid mesh\nsome other content\nendsolid mesh\n"), Evidence::None ); // Plain text starting with "solid state drive manual" → not STL. assert_eq!( recognize_evidence(b"solid state drive manual contents here"), Evidence::None ); // `solid` without whitespace following → not STL. assert_eq!(recognize_evidence(b"solidarity forever\n"), Evidence::None); // Plain text with no structure markers → None. assert_eq!(recognize_evidence(b"hello world\n"), Evidence::None); } #[test] fn obj_recognized_with_geometric_lines() { let obj = b"o Cube\nv 1.0 2.0 3.0\nv 4.0 5.0 6.0\nv 7.0 8.0 9.0\nf 1 2 3\n"; assert_eq!(recognize_evidence(obj), Evidence::One(FormatId::Obj)); assert_eq!(canonical_mime_for_evidence(obj), Some("model/obj")); // OBJ with v, vn, vt, f → recognized. let obj2 = b"v 0 0 0\nvn 1 0 0\nvt 0.5 0.5\nf 1/1/1 2/2/1 3/3/1\n"; assert_eq!(recognize_evidence(obj2), Evidence::One(FormatId::Obj)); } #[test] fn obj_with_only_comments_and_names_is_none() { let comments = b"# Blender v3.0\no Cube\ng Group1\ns 1\nusemtl Material\nmtllib cube.mtl\n"; assert_eq!(recognize_evidence(comments), Evidence::None); // OBJ with a `v ` line that has non-parseable floats (letters) → no geometric evidence. let bad_float = b"v abc def ghi\no Cube\n"; assert_eq!(recognize_evidence(bad_float), Evidence::None); } #[test] fn over_bound_evidence_stays_over_bound() { assert_eq!( recognize_evidence(&[0; MAX_EVIDENCE_BYTES + 1]), Evidence::OverBound ); } } } pub mod scene; pub use load_state::{ LoadError, LoadErrorCode, LoadEvent, LoadIdentity, LoadReducer, LoadState, RendererNamespace, ResourceToken, TransitionOutcome, TransitionResult, }; /// Current wire protocol version. The envelope is mandatory for every postcard message. pub const PROTOCOL_VERSION: u16 = 2; /// Capability names negotiated before scene-dependent commands are accepted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Capability { CurrentFormats, CompoundLdraw, Preview, Cancellation, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProtocolHello { pub min_version: u16, pub max_version: u16, pub required: Vec, pub optional: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NegotiatedProtocol { pub version: u16, pub capabilities: Vec, pub session_id: u64, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum HandshakeState { Created, Negotiating, Negotiated(NegotiatedProtocol), Failed, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum HandshakeError { InvalidRange, NoVersionOverlap, MissingRequiredCapability(Capability), DuplicateCapability(Capability), ConflictingCapability(Capability), InvalidSession, OutOfOrder, } impl ProtocolHello { pub fn validate(&self) -> Result<(), HandshakeError> { if self.min_version == 0 || self.min_version > self.max_version { return Err(HandshakeError::InvalidRange); } let mut required = HashSet::with_capacity(self.required.len()); for capability in &self.required { if !required.insert(*capability) { return Err(HandshakeError::DuplicateCapability(*capability)); } } let mut optional = HashSet::with_capacity(self.optional.len()); for capability in &self.optional { if !optional.insert(*capability) { return Err(HandshakeError::DuplicateCapability(*capability)); } if required.contains(capability) { return Err(HandshakeError::ConflictingCapability(*capability)); } } Ok(()) } } impl NegotiatedProtocol { fn validate(&self) -> Result<(), HandshakeError> { if self.version != PROTOCOL_VERSION || self.session_id == 0 { return Err(HandshakeError::InvalidSession); } let mut seen = HashSet::with_capacity(self.capabilities.len()); for capability in &self.capabilities { if !seen.insert(*capability) { return Err(HandshakeError::DuplicateCapability(*capability)); } } Ok(()) } } pub fn negotiate( local: &ProtocolHello, remote: &ProtocolHello, session_id: u64, ) -> Result { local.validate()?; remote.validate()?; if session_id == 0 { return Err(HandshakeError::InvalidSession); } let version = local.max_version.min(remote.max_version); if version < local.min_version || version < remote.min_version { return Err(HandshakeError::NoVersionOverlap); } for capability in &local.required { if !remote.required.contains(capability) && !remote.optional.contains(capability) { return Err(HandshakeError::MissingRequiredCapability(*capability)); } } for capability in &remote.required { if !local.required.contains(capability) && !local.optional.contains(capability) { return Err(HandshakeError::MissingRequiredCapability(*capability)); } } let capabilities = local .required .iter() .chain(local.optional.iter()) .copied() .filter(|capability| { remote.required.contains(capability) || remote.optional.contains(capability) }) .collect(); Ok(NegotiatedProtocol { version, capabilities, session_id, }) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CommandPayload { Hello(ProtocolHello), Accept(NegotiatedProtocol), Command(RendererCommand), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum EventPayload { Hello(ProtocolHello), Negotiated(NegotiatedProtocol), Failed(String), Event(RendererEvent), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CommandEnvelope { pub version: u16, pub session_id: u64, pub payload: CommandPayload, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EventEnvelope { pub version: u16, pub session_id: u64, pub payload: EventPayload, } impl HandshakeState { pub fn session_id(&self) -> Option { match self { Self::Negotiated(session) => Some(session.session_id), _ => None, } } pub fn negotiated(&self) -> Option<&NegotiatedProtocol> { match self { Self::Negotiated(session) => Some(session), _ => None, } } pub fn accept_command( &mut self, envelope: &CommandEnvelope, ) -> Result, HandshakeError> { if envelope.version != PROTOCOL_VERSION { *self = Self::Failed; return Err(HandshakeError::NoVersionOverlap); } match (&*self, &envelope.payload) { (Self::Created, CommandPayload::Hello(hello)) => { hello.validate()?; *self = Self::Negotiating; Ok(None) } (Self::Negotiating, CommandPayload::Accept(negotiated)) if envelope.session_id == 0 && negotiated.validate().is_ok() => { *self = Self::Negotiated(negotiated.clone()); Ok(None) } (Self::Negotiated(session), CommandPayload::Command(command)) if session.session_id == envelope.session_id => { Ok(Some(command.clone())) } _ => { *self = Self::Failed; Err(HandshakeError::OutOfOrder) } } } pub fn accept_event( &mut self, envelope: &EventEnvelope, ) -> Result, HandshakeError> { if envelope.version != PROTOCOL_VERSION { *self = Self::Failed; return Err(HandshakeError::NoVersionOverlap); } match (&*self, &envelope.payload) { (Self::Created, EventPayload::Hello(hello)) => { hello.validate()?; if envelope.session_id != 0 { *self = Self::Failed; return Err(HandshakeError::InvalidSession); } *self = Self::Negotiating; Ok(None) } (Self::Negotiating, EventPayload::Negotiated(negotiated)) if envelope.session_id == negotiated.session_id && negotiated.validate().is_ok() => { *self = Self::Negotiated(negotiated.clone()); Ok(None) } (Self::Negotiated(session), EventPayload::Negotiated(negotiated)) if envelope.session_id == session.session_id && session == negotiated && negotiated.validate().is_ok() => { Ok(None) } (Self::Negotiated(session), EventPayload::Event(event)) if session.session_id == envelope.session_id => { Ok(Some(event.clone())) } _ => { *self = Self::Failed; Err(HandshakeError::OutOfOrder) } } } } // --------------------------------------------------------------------------- // Shared mesh-format types (relocated from polymodel-mesh::contract) // --------------------------------------------------------------------------- /// Source format of a loaded mesh. /// /// STL, OBJ, glTF (GLB, embedded, or multi-file `.gltf` with external buffers), and 3MF /// are all parsed client-side in the worker via `three-d-asset`. There is no /// binary/ASCII sub-label for STL — reliably telling them apart is hard, and both yield /// the same mesh. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum MeshFormat { /// Stereolithography (binary or ASCII). Stl, /// Wavefront OBJ (convex polygons fan-triangulated; lines/points dropped). Obj, /// glTF 2.0 — GLB, embedded-base64 `.gltf`, or multi-file `.gltf` + companions. Gltf, /// 3D Manufacturing Format. Parsed client-side in the worker via `three-d-asset`'s /// `3mf` feature (`lib3mf`, pure Rust). Threemf, /// Compound LDraw source. The renderer adapter owns parsing and scene conversion. CompoundLdraw, } impl MeshFormat { /// Detect a mesh format from a filename extension. /// /// The extension is taken from the final `.`-delimited segment, lower-cased, and /// matched with no leading dot. Returns `None` for empty or unknown extensions. pub fn from_extension(path: impl AsRef) -> Option { let path = path.as_ref(); let ext = path.rsplit('.').next()?.to_ascii_lowercase(); if ext.len() >= path.len() { return None; } match ext.as_str() { "stl" => Some(Self::Stl), "obj" => Some(Self::Obj), "gltf" | "glb" => Some(Self::Gltf), "3mf" => Some(Self::Threemf), _ => None, } } } /// A length unit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LengthUnit { Millimeter, Centimeter, Meter, Inch, /// A thousandth of an inch — "thou" in machining, "mil" in PCB/electronics. Thou, Foot, Unknown, /// LDraw units (LDU): exactly 1/64 inch, or 0.396875 mm. /// /// This variant is appended to preserve every existing postcard discriminant. LdrawUnit, } /// Unit handling for a loaded model. /// /// `source` is the unit declared by the file; STL/OBJ declare none, so they report /// [`LengthUnit::Unknown`] and rely on [`Units::unitless_assumed_mm`]. glTF's base unit is /// the metre, so a loaded glTF reports [`LengthUnit::Meter`] with no assumption. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Units { /// The unit declared by the file (STL/OBJ: [`LengthUnit::Unknown`]; glTF: [`LengthUnit::Meter`]). pub source: LengthUnit, /// The convention assumed when `source` is unknown. pub assumed: Option, } impl Units { /// Unit handling for a unitless format (STL, OBJ): source unknown, assume millimetres. pub fn unitless_assumed_mm() -> Self { Self { source: LengthUnit::Unknown, assumed: Some(LengthUnit::Millimeter), } } /// Unit handling for a format that declares its own unit (e.g. glTF → metres), /// so no assumption is applied. pub fn declared(source: LengthUnit) -> Self { Self { source, assumed: None, } } } // --------------------------------------------------------------------------- // Mesh stats (the only mesh data crossing back from the worker) // --------------------------------------------------------------------------- /// Lightweight mesh statistics returned to the main thread after a successful load. /// /// The full model geometry stays in the worker; only aggregate counts cross back for /// display. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct MeshStats { /// Sum of part-local vertex positions across renderable geometry. pub vertices: u32, /// Number of triangles. pub triangles: u32, } // --------------------------------------------------------------------------- // Part-file verification (client-side digest + size check) // --------------------------------------------------------------------------- /// Errors from [`verify_part_file`]. #[derive(Debug, thiserror::Error)] pub enum VerifyError { #[error("part file size mismatch: expected {expected}, got {actual}")] Size { expected: i64, actual: usize }, #[error("part file digest mismatch")] Digest, } /// Verify a reassembled part file's size and (optional) sha256 digest. /// /// Called on every consumer path (viewer mesh-load, direct download) after the /// streamed bytes are collected. Per-chunk CID verifies each piece on the PDS /// side; this full-file check catches assembly errors (reordering, swaps, /// drops) that per-chunk CID cannot. pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Result<(), VerifyError> { if bytes.len() as i64 != size { return Err(VerifyError::Size { expected: size, actual: bytes.len(), }); } if let Some(expected) = digest { let actual = sha2::Sha256::digest(bytes); if actual[..] != *expected { return Err(VerifyError::Digest); } } Ok(()) } // --------------------------------------------------------------------------- // Renderer command (main → worker) // --------------------------------------------------------------------------- /// A command sent from the main thread to the renderer worker. /// /// Serialized with postcard and posted as a `Uint8Array`. The worker deserializes and /// dispatches. Input commands (`SetPointerState`, `PointerDown`, `PointerUp`) only update /// worker-side accumulators — the worker's `requestAnimationFrame` loop consumes them /// per-frame, never rendering from a single message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererCommand { /// Begin a load. The source determines how bytes are obtained; the output determines /// whether the resulting candidate is installed or rendered once and discarded. StartLoad { namespace: RendererNamespace, identity: LoadIdentity, source: LoadSource, output: LoadOutput, }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, identity: LoadIdentity, }, /// Resize the worker's `OffscreenCanvas` and viewport. `css_w`/`css_h` are CSS pixels, /// `dpr` is `window.devicePixelRatio`. The worker sets canvas pixel dimensions to /// `css_w * dpr` × `css_h * dpr`. Resize { css_w: f32, css_h: f32, dpr: f32 }, /// Coalesced latest pointer state (producer-side throttled to display rate). /// `x`/`y` are canvas-relative CSS pixels; `buttons` is a bitmask matching /// `PointerEvent.buttons`; `wheel_delta` is the accumulated deltaY since the last post. SetPointerState { x: f32, y: f32, buttons: u16, modifiers: KeyModifiers, wheel_delta: f32, }, /// Discrete pointer-down event (infrequent; posted as events). PointerDown { x: f32, y: f32 }, /// Discrete pointer-up event (infrequent; posted as events). PointerUp { x: f32, y: f32 }, /// Tear down the renderer and stop the frame loop. The worker closes itself. Dispose, /// Deliberately panic — used by e2e tests to verify crash surfacing. DebugCrash, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum LoadSource { Url { format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, }, Bundle(ModelLoadBundle), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LoadOutput { Interactive, Preview { request_id: u32, width: u32, height: u32, }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelResource { pub key: String, pub path: String, pub root: String, pub byte_length: u64, pub sha256: [u8; 32], pub mime_type: String, pub target_cids: Box<[String]>, pub bytes: Box<[u8]>, } pub use polymodel_ldraw_identity::{ CacheKey, NormalizedPath, ResolverPathError, RootId, Sha256Hash, Span, TargetSelection, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelLoadBundle { pub version: u16, pub format: MeshFormat, pub primary: String, pub resources: Box<[ModelResource]>, pub source_identities: Vec, pub target_selections: Vec, } pub const MAX_MODEL_RESOURCES: usize = 16_384; pub const MAX_MODEL_TARGET_SELECTIONS: usize = 131_072; pub const MAX_MODEL_BUNDLE_BYTES: usize = 136 * 1024 * 1024; pub const MAX_MODEL_METADATA_BYTES: usize = 8 * 1024 * 1024; pub const MAX_MODEL_RESOURCE_BYTES: u64 = 128 * 1024 * 1024; pub const MAX_MODEL_CIDS_PER_RESOURCE: usize = 4_096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelBundleError { Empty, TooManyResources, EncodedBytesExceeded, MetadataExceeded, TotalBytesExceeded, DuplicateKey, PrimaryMissing, InvalidResource, SizeMismatch, DigestMismatch, InvalidTargetSelection, } impl ModelResource { pub fn validate(&self) -> Result<(), ModelBundleError> { if self.key.is_empty() || self.key != self.path || self.sha256.len() != 32 || self.mime_type.is_empty() || self.target_cids.len() > MAX_MODEL_CIDS_PER_RESOURCE { return Err(ModelBundleError::InvalidResource); } if self.byte_length != self.bytes.len() as u64 { return Err(ModelBundleError::SizeMismatch); } if sha2::Sha256::digest(&self.bytes)[..] != self.sha256[..] { return Err(ModelBundleError::DigestMismatch); } Ok(()) } fn metadata_bytes(&self) -> Option { self.key .len() .checked_add(self.path.len())? .checked_add(self.root.len())? .checked_add(self.mime_type.len())? .checked_add( self.target_cids .iter() .try_fold(0usize, |total, cid| total.checked_add(cid.len()))?, )? .checked_add(self.sha256.len()) } } impl ModelLoadBundle { pub fn validate(&self) -> Result<(), ModelBundleError> { if self.resources.is_empty() { return Err(ModelBundleError::Empty); } if self.resources.len() > MAX_MODEL_RESOURCES { return Err(ModelBundleError::TooManyResources); } if self.target_selections.len() > MAX_MODEL_TARGET_SELECTIONS { return Err(ModelBundleError::MetadataExceeded); } let mut keys = HashSet::with_capacity(self.resources.len()); let mut metadata = self.primary.len(); let mut total_bytes = 0u64; for resource in &self.resources { resource.validate()?; if !keys.insert(&resource.key) { return Err(ModelBundleError::DuplicateKey); } metadata = metadata .checked_add( resource .metadata_bytes() .ok_or(ModelBundleError::MetadataExceeded)?, ) .ok_or(ModelBundleError::MetadataExceeded)?; total_bytes = total_bytes .checked_add(resource.byte_length) .ok_or(ModelBundleError::TotalBytesExceeded)?; if metadata > MAX_MODEL_METADATA_BYTES { return Err(ModelBundleError::MetadataExceeded); } if total_bytes > MAX_MODEL_RESOURCE_BYTES { return Err(ModelBundleError::TotalBytesExceeded); } } if !keys.contains(&self.primary) { return Err(ModelBundleError::PrimaryMissing); } let identities = self .resources .iter() .map(|resource| { let path = NormalizedPath::new(&resource.path) .map_err(|_| ModelBundleError::InvalidTargetSelection)?; let root = match resource.root.as_str() { "mpd" => RootId::CurrentMpd, "manifest" => RootId::UploadedManifest, "models" | "parts" | "p" | "external" => RootId::UploadedLdraw, "official-parts" | "official-p" => RootId::OfficialLibrary, _ => return Err(ModelBundleError::InvalidTargetSelection), }; Ok(CacheKey::new(path, root, &resource.bytes)) }) .collect::, _>>()?; let source_identities = self .source_identities .iter() .chain(identities.iter()) .collect::>(); for selection in &self.target_selections { if !source_identities.contains(&selection.source) || !identities.contains(&selection.target) { return Err(ModelBundleError::InvalidTargetSelection); } } Ok(()) } pub fn encode(&self) -> Result, ModelBundleError> { self.validate()?; let encoded = postcard::to_allocvec(self).map_err(|_| ModelBundleError::EncodedBytesExceeded)?; if encoded.len() > MAX_MODEL_BUNDLE_BYTES { return Err(ModelBundleError::EncodedBytesExceeded); } Ok(encoded) } pub fn decode(bytes: &[u8]) -> Result { if bytes.len() > MAX_MODEL_BUNDLE_BYTES { return Err(ModelBundleError::EncodedBytesExceeded); } let bundle: Self = postcard::from_bytes(bytes).map_err(|_| ModelBundleError::InvalidResource)?; bundle.validate()?; Ok(bundle) } } /// Keyboard modifier flags accompanying a pointer state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct KeyModifiers { pub shift: bool, pub ctrl: bool, pub alt: bool, pub meta: bool, } // --------------------------------------------------------------------------- // Renderer event (worker → main) // --------------------------------------------------------------------------- /// An event sent from the renderer worker to the main thread. /// /// Serialized with postcard and posted as a `Uint8Array`. The main thread deserializes and /// maps to [`crate::viewer::ViewerStatus`] / `context_acquisitions`. /// /// The `"__worker_ready"` string handshake is posted as a bare JS string (not postcard) /// before the first `RendererEvent` — see the worker `main.rs`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererEvent { /// The WebGL2 context was created on the `OffscreenCanvas`. Emitted once per worker /// lifetime (retained across model switches). ContextAcquired, /// Context and initial viewport readiness only; mesh readiness is `InstallSucceeded`. RendererContextReady, /// Fetch/parse completed and a candidate is ready for installation. LoadSucceeded { namespace: RendererNamespace, identity: LoadIdentity, }, /// The candidate scene was installed; this is the only mesh-readiness publication point. InstallSucceeded { namespace: RendererNamespace, identity: LoadIdentity, stats: MeshStats, }, /// A load or install failed for the exact identity. LoadFailed { namespace: RendererNamespace, identity: LoadIdentity, code: LoadErrorCode, detail: String, }, /// Cancellation/supersession/disposal status publication for the exact identity. LoadCancelled { namespace: RendererNamespace, identity: LoadIdentity, }, /// A preview render completed successfully. `png` is raw PNG bytes. PreviewImageRendered { request_id: u32, png: Vec }, /// A preview render failed. Preview failures are non-fatal and do not affect /// the interactive viewer scene. PreviewImageError { request_id: u32, message: String }, /// A renderer error. `fatal: true` means the worker is unusable (context loss, /// unhandled panic) — the main thread surfaces `ViewerStatus::RendererError`. Error { fatal: bool, message: String }, } // --------------------------------------------------------------------------- // Postcard ser/deser helpers // --------------------------------------------------------------------------- /// Serialize a command in the mandatory versioned envelope. /// /// This compatibility helper is retained for callers that only have a command. New /// session-bound transports should use [`serialize_command_for_session`]. /// Serialize a command envelope. Scene-dependent commands must use the negotiated /// non-zero session; only Hello and Accept envelopes use session zero. pub fn serialize_command_envelope(envelope: &CommandEnvelope) -> Result, postcard::Error> { postcard::to_allocvec(envelope) } pub fn serialize_event_envelope(envelope: &EventEnvelope) -> Result, postcard::Error> { postcard::to_allocvec(envelope) } pub fn deserialize_command_envelope(bytes: &[u8]) -> Result { postcard::from_bytes(bytes) } pub fn deserialize_event_envelope(bytes: &[u8]) -> Result { postcard::from_bytes(bytes) } /// The capability required to dispatch a command, when the command is gated. /// The worker must reject a command when this capability was not negotiated. pub fn required_capabilities(command: &RendererCommand) -> Vec { match command { RendererCommand::StartLoad { source, output, .. } => { let mut required = Vec::with_capacity(2); if matches!(source, LoadSource::Bundle(_)) { required.push(Capability::CompoundLdraw); } if matches!(output, LoadOutput::Preview { .. }) { required.push(Capability::Preview); } required } RendererCommand::Cancel { .. } => vec![Capability::Cancellation], RendererCommand::Resize { .. } | RendererCommand::SetPointerState { .. } | RendererCommand::PointerDown { .. } | RendererCommand::PointerUp { .. } | RendererCommand::Dispose | RendererCommand::DebugCrash => Vec::new(), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use sha2::Sha256; #[test] fn verify_part_file_matching_size_and_digest_is_ok() { let data = b"hello world"; let digest = sha2::Sha256::digest(data); assert!(verify_part_file(data, data.len() as i64, Some(&digest)).is_ok()); } #[test] fn verify_part_file_size_mismatch_is_err() { let data = b"hello world"; let digest = sha2::Sha256::digest(data); let err = verify_part_file(data, 999, Some(&digest)).unwrap_err(); assert!(matches!( err, VerifyError::Size { expected: 999, actual: 11 } )); } #[test] fn verify_part_file_digest_mismatch_is_err() { let data = b"hello world"; let wrong_digest = [0u8; 32]; let err = verify_part_file(data, data.len() as i64, Some(&wrong_digest)).unwrap_err(); assert!(matches!(err, VerifyError::Digest)); } #[test] fn verify_part_file_no_digest_checks_size_only() { let data = b"hello world"; assert!(verify_part_file(data, data.len() as i64, None).is_ok()); assert!(verify_part_file(data, 999, None).is_err()); } #[test] fn from_extension_maps_known_formats() { assert_eq!( MeshFormat::from_extension("cube.stl"), Some(MeshFormat::Stl) ); assert_eq!( MeshFormat::from_extension("cube.obj"), Some(MeshFormat::Obj) ); assert_eq!( MeshFormat::from_extension("cube.gltf"), Some(MeshFormat::Gltf) ); assert_eq!( MeshFormat::from_extension("cube.glb"), Some(MeshFormat::Gltf) ); assert_eq!( MeshFormat::from_extension("cube.3mf"), Some(MeshFormat::Threemf) ); } #[test] fn from_extension_case_insensitive() { assert_eq!( MeshFormat::from_extension("CUBE.GLB"), Some(MeshFormat::Gltf) ); assert_eq!(MeshFormat::from_extension(".OBJ"), Some(MeshFormat::Obj)); assert_eq!( MeshFormat::from_extension("/models/Body_F_Chest-v4.STL"), Some(MeshFormat::Stl) ); } #[test] fn from_extension_rejects_unknown_and_extensionless() { assert_eq!(MeshFormat::from_extension("cube.ply"), None); assert_eq!(MeshFormat::from_extension("cube"), None); assert_eq!(MeshFormat::from_extension(""), None); assert_eq!(MeshFormat::from_extension("cube."), None); assert_eq!(MeshFormat::from_extension("."), None); assert_eq!(MeshFormat::from_extension("archive.tar.gz"), None); } fn roundtrip_command(cmd: &RendererCommand) -> RendererCommand { let envelope = CommandEnvelope { version: PROTOCOL_VERSION, session_id: 7, payload: CommandPayload::Command(cmd.clone()), }; match deserialize_command_envelope(&serialize_command_envelope(&envelope).unwrap()) .unwrap() .payload { CommandPayload::Command(command) => command, _ => panic!("expected command payload"), } } fn roundtrip_event(evt: &RendererEvent) -> RendererEvent { let envelope = EventEnvelope { version: PROTOCOL_VERSION, session_id: 7, payload: EventPayload::Event(evt.clone()), }; match deserialize_event_envelope(&serialize_event_envelope(&envelope).unwrap()) .unwrap() .payload { EventPayload::Event(event) => event, _ => panic!("expected event payload"), } } fn model_resource(key: &str, byte: u8) -> ModelResource { let bytes = vec![byte]; ModelResource { key: key.into(), path: key.into(), root: "models".into(), byte_length: bytes.len() as u64, sha256: Sha256::digest(&bytes).into(), mime_type: "text/plain".into(), target_cids: Box::new([format!("cid-{byte}")]), bytes: bytes.into_boxed_slice(), } } fn model_bundle() -> ModelLoadBundle { ModelLoadBundle { version: PROTOCOL_VERSION, format: MeshFormat::CompoundLdraw, primary: "models/main.ldr".into(), resources: Box::new([ model_resource("models/main.ldr", 1), model_resource("models/child.dat", 2), ]), source_identities: Vec::new(), target_selections: Vec::new(), } } fn url_source(format: MeshFormat, url: &str) -> LoadSource { LoadSource::Url { format, primary_url: url.into(), expected_size: None, expected_digest: None, } } #[test] fn all_commands_roundtrip() { let commands = vec![ RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), source: url_source(MeshFormat::Stl, "/models/cube.stl"), output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), source: url_source(MeshFormat::Obj, "/models/cube.obj"), output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 1), source: url_source(MeshFormat::Gltf, "/models/cube.glb"), output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(2, 2), source: url_source(MeshFormat::Threemf, "/models/cube.3mf"), output: LoadOutput::Preview { request_id: 7, width: 1024, height: 1024, }, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(3, 1), source: LoadSource::Bundle(model_bundle()), output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(3, 2), source: LoadSource::Bundle(model_bundle()), output: LoadOutput::Preview { request_id: 8, width: 640, height: 480, }, }, RendererCommand::Cancel { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), }, RendererCommand::Resize { css_w: 800.0, css_h: 600.0, dpr: 2.0, }, RendererCommand::SetPointerState { x: 100.0, y: 200.0, buttons: 1, modifiers: KeyModifiers { shift: true, ..Default::default() }, wheel_delta: 42.0, }, RendererCommand::PointerDown { x: 10.0, y: 20.0 }, RendererCommand::PointerUp { x: 15.0, y: 25.0 }, RendererCommand::Dispose, RendererCommand::DebugCrash, ]; for cmd in &commands { assert_eq!(&roundtrip_command(cmd), cmd, "failed on {cmd:?}"); } } #[test] fn bundled_preview_requires_bundle_and_preview_capabilities() { let command = RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(7, 4), source: LoadSource::Bundle(model_bundle()), output: LoadOutput::Preview { request_id: 91, width: 320, height: 240, }, }; assert_eq!( required_capabilities(&command), vec![Capability::CompoundLdraw, Capability::Preview] ); assert_eq!(roundtrip_command(&command), command); } #[test] fn all_events_roundtrip() { let events = vec![ RendererEvent::ContextAcquired, RendererEvent::RendererContextReady, RendererEvent::LoadSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), }, RendererEvent::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), stats: MeshStats { vertices: 1234, triangles: 4321, }, }, RendererEvent::LoadFailed { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), code: LoadErrorCode::Parse, detail: "failed to parse: bad bytes".into(), }, RendererEvent::LoadCancelled { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), }, RendererEvent::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(3, 1), stats: MeshStats { vertices: 12, triangles: 4, }, }, RendererEvent::InstallSucceeded { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(3, 2), stats: MeshStats { vertices: 12, triangles: 4, }, }, RendererEvent::PreviewImageRendered { request_id: 7, png: vec![137, 80, 78, 71], }, RendererEvent::PreviewImageError { request_id: 7, message: "preview failed".into(), }, RendererEvent::Error { fatal: true, message: "context lost".into(), }, RendererEvent::Error { fatal: false, message: "non-fatal warning".into(), }, ]; for evt in &events { assert_eq!(&roundtrip_event(evt), evt, "failed on {evt:?}"); } } #[test] fn ldraw_unit_is_appended_and_exact_conversion_is_one_boundary() { assert_eq!(LengthUnit::Unknown as u8 + 1, LengthUnit::LdrawUnit as u8); // 1 LDU = 1/64 inch exactly; 1 inch = 25.4 mm exactly (by SI definition). // Therefore 1 LDU = 25.4 / 64 mm = 0.396875 mm = 0.000396875 m exactly. // Verify with rational-derived constants to avoid decimal drift. const INCH_MM: f64 = 25.4; // exact: 1 inch = 25.4 mm const LDU_PER_INCH: f64 = 64.0; // exact: 64 LDU per inch const MM_PER_LDU: f64 = INCH_MM / LDU_PER_INCH; // 0.396875 mm, exact const M_PER_LDU: f64 = MM_PER_LDU / 1000.0; // 0.000396875 m, exact assert_eq!(MM_PER_LDU, 0.396875); assert_eq!(M_PER_LDU, 0.000396875); // 1000 LDU = 0.396875 m, not 0.4 m (the approximate value is rejected). assert_ne!(1000.0 * M_PER_LDU, 0.4); assert_eq!(1000.0 * MM_PER_LDU, 396.875); } #[test] fn handshake_rejects_pre_negotiation_and_stale_sessions() { let hello = ProtocolHello { min_version: PROTOCOL_VERSION, max_version: PROTOCOL_VERSION, required: vec![Capability::CurrentFormats], optional: vec![Capability::Preview], }; let negotiated = negotiate(&hello, &hello, 44).unwrap(); let mut state = HandshakeState::Created; let command = CommandEnvelope { version: PROTOCOL_VERSION, session_id: 44, payload: CommandPayload::Command(RendererCommand::Dispose), }; assert_eq!( state.accept_command(&command), Err(HandshakeError::OutOfOrder) ); let mut state = HandshakeState::Negotiated(negotiated); let stale = CommandEnvelope { session_id: 45, ..command }; assert_eq!( state.accept_command(&stale), Err(HandshakeError::OutOfOrder) ); } #[test] fn old_bare_postcard_messages_are_not_accepted() { let bare = postcard::to_allocvec(&RendererCommand::Dispose).unwrap(); assert!(deserialize_command_envelope(&bare).is_err()); } #[test] fn session_zero_scene_envelopes_are_rejected_by_state_machine() { let hello = ProtocolHello { min_version: PROTOCOL_VERSION, max_version: PROTOCOL_VERSION, required: vec![Capability::CurrentFormats], optional: vec![], }; let negotiated = negotiate(&hello, &hello, 9).unwrap(); let mut state = HandshakeState::Negotiated(negotiated); let command = CommandEnvelope { version: PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Command(RendererCommand::Dispose), }; assert_eq!( state.accept_command(&command), Err(HandshakeError::OutOfOrder) ); } #[test] fn handshake_rejects_duplicate_and_conflicting_messages() { let duplicate = ProtocolHello { min_version: PROTOCOL_VERSION, max_version: PROTOCOL_VERSION, required: vec![Capability::Preview, Capability::Preview], optional: vec![], }; assert_eq!( duplicate.validate(), Err(HandshakeError::DuplicateCapability(Capability::Preview)) ); let conflict = ProtocolHello { min_version: PROTOCOL_VERSION, max_version: PROTOCOL_VERSION, required: vec![Capability::Preview], optional: vec![Capability::Preview], }; assert_eq!( conflict.validate(), Err(HandshakeError::ConflictingCapability(Capability::Preview)) ); } #[test] fn independent_golden_command_envelope_bytes_remain_stable() { let envelope = CommandEnvelope { version: PROTOCOL_VERSION, session_id: 7, payload: CommandPayload::Command(RendererCommand::Dispose), }; let bytes = serialize_command_envelope(&envelope).unwrap(); assert_eq!(bytes, [2, 7, 2, 6]); } #[test] fn independent_golden_event_envelope_bytes_remain_stable() { let envelope = EventEnvelope { version: PROTOCOL_VERSION, session_id: 7, payload: EventPayload::Event(RendererEvent::ContextAcquired), }; let bytes = serialize_event_envelope(&envelope).unwrap(); assert_eq!(bytes, [2, 7, 3, 0]); } #[test] fn model_bundle_rejects_tampered_bytes_and_declared_size() { let mut bundle = model_bundle(); bundle.resources[0].bytes[0] ^= 0xff; assert_eq!(bundle.validate(), Err(ModelBundleError::DigestMismatch)); let mut bundle = model_bundle(); bundle.resources[0].byte_length += 1; assert_eq!(bundle.validate(), Err(ModelBundleError::SizeMismatch)); } #[test] fn deserialize_garbage_returns_error() { assert!(deserialize_command_envelope(&[0xFF, 0xFE, 0xFD]).is_err()); assert!(deserialize_event_envelope(&[0xFF, 0xFE, 0xFD]).is_err()); assert!(deserialize_command_envelope(&[]).is_err()); assert!(deserialize_event_envelope(&[]).is_err()); } }