Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
73 kB · 1995 lines
Rust
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996//! 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<RendererLocation>, pub compound: bool, pub generated_preview: bool, pub ordinary_download: bool, pub conversion_target: Option<FormatId>, 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<MeshFormat> { 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<FormatId> { 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<FormatId> { 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 <float> <float> <float> — requires parseable floats if let Some(rest) = trimmed.strip_prefix("v ") { if rest .split_ascii_whitespace() .take(3) .all(|f| f.parse::<f32>().is_ok()) { has_geometric = true; } continue; } // vn <float> <float> <float> if let Some(rest) = trimmed.strip_prefix("vn ") { if rest .split_ascii_whitespace() .take(3) .all(|f| f.parse::<f32>().is_ok()) { has_geometric = true; } continue; } // vt <float> [float] if let Some(rest) = trimmed.strip_prefix("vt ") { if rest .split_ascii_whitespace() .take(2) .all(|f| f.parse::<f32>().is_ok()) { has_geometric = true; } continue; } // f <vertex list> — 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<Capability>, pub optional: Vec<Capability>,}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct NegotiatedProtocol { pub version: u16, pub capabilities: Vec<Capability>, 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<NegotiatedProtocol, HandshakeError> { 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<u64> { 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<Option<RendererCommand>, 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<Option<RendererEvent>, 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<str>) -> Option<Self> { 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<LengthUnit>,}
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<i64>, expected_digest: Option<Vec<u8>>, }, 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<CacheKey>, pub target_selections: Vec<TargetSelection>,}
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<usize> { 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::<Result<HashSet<_>, _>>()?; let source_identities = self .source_identities .iter() .chain(identities.iter()) .collect::<HashSet<_>>(); 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<Vec<u8>, 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<Self, ModelBundleError> { 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<u8> }, /// 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<Vec<u8>, postcard::Error> { postcard::to_allocvec(envelope)}
pub fn serialize_event_envelope(envelope: &EventEnvelope) -> Result<Vec<u8>, postcard::Error> { postcard::to_allocvec(envelope)}
pub fn deserialize_command_envelope(bytes: &[u8]) -> Result<CommandEnvelope, postcard::Error> { postcard::from_bytes(bytes)}
pub fn deserialize_event_envelope(bytes: &[u8]) -> Result<EventEnvelope, postcard::Error> { 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<Capability> { 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()); }}