//! Leaf 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; mod load_state; pub use load_state::{ LoadError, LoadErrorCode, LoadEvent, LoadIdentity, LoadReducer, LoadState, RendererNamespace, ResourceToken, TransitionOutcome, TransitionResult, }; // --------------------------------------------------------------------------- // 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, } 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, } /// 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 an interactive load. The main thread allocates the identity and the worker /// must not allocate or infer one. The worker discovers and fetches glTF companions. StartLoad { namespace: RendererNamespace, identity: LoadIdentity, format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, identity: LoadIdentity, }, /// Render a mesh URL to a PNG preview image. This is intentionally handled by /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. RenderPreviewImage { request_id: u32, format: MeshFormat, primary_url: String, width: u32, height: u32, }, /// 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, } /// 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 [`RendererCommand`] to postcard bytes. pub fn serialize_command(cmd: &RendererCommand) -> Result, postcard::Error> { postcard::to_allocvec(cmd) } /// Serialize a [`RendererEvent`] to postcard bytes. pub fn serialize_event(evt: &RendererEvent) -> Result, postcard::Error> { postcard::to_allocvec(evt) } /// Deserialize a [`RendererCommand`] from postcard bytes. pub fn deserialize_command(bytes: &[u8]) -> Result { postcard::from_bytes(bytes) } /// Deserialize a [`RendererEvent`] from postcard bytes. pub fn deserialize_event(bytes: &[u8]) -> Result { postcard::from_bytes(bytes) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[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 { deserialize_command(&serialize_command(cmd).unwrap()).unwrap() } fn roundtrip_event(evt: &RendererEvent) -> RendererEvent { deserialize_event(&serialize_event(evt).unwrap()).unwrap() } #[test] fn all_commands_roundtrip() { let commands = vec![ RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: Some(4096), expected_digest: Some(vec![0u8; 32]), }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), format: MeshFormat::Obj, primary_url: "/models/cube.obj".into(), expected_size: None, expected_digest: None, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 1), format: MeshFormat::Gltf, primary_url: "/models/cube.glb".into(), expected_size: Some(2048), expected_digest: None, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), format: MeshFormat::Threemf, primary_url: "/models/cube.3mf".into(), expected_size: None, expected_digest: Some(vec![1u8; 32]), }, RendererCommand::Cancel { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), }, RendererCommand::RenderPreviewImage { request_id: 7, format: MeshFormat::Stl, primary_url: "blob:http://localhost/preview".into(), width: 1024, height: 1024, }, 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 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::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 deserialize_garbage_returns_error() { assert!(deserialize_command(&[0xFF, 0xFE, 0xFD]).is_err()); assert!(deserialize_event(&[0xFF, 0xFE, 0xFD]).is_err()); assert!(deserialize_command(&[]).is_err()); assert!(deserialize_event(&[]).is_err()); } }