//! Polymodel asset viewer: a Dioxus-owned canvas bridged to a renderer Web Worker. //! //! The viewer renders committed mesh assets (STL, OBJ, glTF/GLB) in a Web Worker with //! its own WASM bundle, loaded lazily on viewer mount. The main thread owns the canvas //! (via `OffscreenCanvas` transfer), captures DOM events, and throttles pointer input //! producer-side to display rate before posting coalesced state to the worker. The worker //! owns the GL context, the frame loop, and all mesh parsing. //! //! **Retained session:** one GL context per page mount, surviving model switches. The //! worker emits `ContextAcquired` once; the `context_acquisitions` signal stays at 1 //! across model switches (retention = no worker respawn). #![allow(dead_code)] use dioxus::prelude::*; use polymodel_renderer_protocol::MeshFormat; #[cfg(not(target_arch = "wasm32"))] use polymodel_renderer_protocol::MeshStats; #[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ KeyModifiers, MeshStats, RendererCommand, RendererEvent, serialize_command, }; // --------------------------------------------------------------------------- // Demo model metadata (stays main-side — the worker only needs format + URL) // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DemoModel { pub id: &'static str, pub name: &'static str, pub asset_path: &'static str, pub generation: u64, pub vertices: usize, pub triangles: usize, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ViewerFallbackImage { pub url: String, pub alt: String, } const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-chest-v4", name: "Body F chest v4", asset_path: "/models/body_f_chest-v4.stl", generation: 1, vertices: 41051, triangles: 82122, }, DemoModel { id: "body-f-forearm-2x-v9", name: "Body F forearm 2x v9", asset_path: "/models/body_f_forearm_2x-v9.stl", generation: 1, vertices: 18271, triangles: 36542, }, ]; pub fn demo_models() -> &'static [DemoModel] { &DEMO_MODELS } pub fn demo_model_for_route(model_id: &str) -> DemoModel { demo_models() .iter() .copied() .find(|model| model.id == model_id) .unwrap_or(DEMO_MODELS[0]) } // --------------------------------------------------------------------------- // Session identity // --------------------------------------------------------------------------- #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SessionKey { model_id: String, generation: u64, } impl SessionKey { pub fn new(model_id: impl Into, generation: u64) -> Self { Self { model_id: model_id.into(), generation, } } pub fn model_id(&self) -> &str { &self.model_id } pub fn generation(&self) -> u64 { self.generation } #[cfg(test)] pub fn switch_to(&self, next_model_id: impl Into) -> Self { Self { model_id: next_model_id.into(), generation: self.generation + 1, } } } // --------------------------------------------------------------------------- // Mesh summary (display metadata — stays main-side from DemoModel) // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MeshSummary { pub display_name: &'static str, pub asset_path: &'static str, pub vertices: usize, pub triangles: usize, } fn mesh_summary_for(model: DemoModel) -> MeshSummary { MeshSummary { display_name: model.name, asset_path: model.asset_path, vertices: model.vertices, triangles: model.triangles, } } /// A mesh load request: the model to load plus its derived format + summary. /// The main thread sends `LoadMesh` to the worker; the worker fetches, parses, and /// builds the scene, then posts `MeshLoaded{stats}` or `MeshError{message}` back. #[derive(Clone, Debug)] pub struct PendingMeshLoad { pub model: DemoModel, pub format: MeshFormat, pub summary: MeshSummary, } fn pending_load_for(model: DemoModel) -> Result { let format = MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; Ok(PendingMeshLoad { model, format, summary: mesh_summary_for(model), }) } // --------------------------------------------------------------------------- // Viewer status // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ViewerIssue { MeshUnavailable, RendererInit, } impl ViewerIssue { pub fn message(self) -> &'static str { match self { Self::MeshUnavailable => "This model could not be loaded for the viewer.", Self::RendererInit => "The viewer could not start on this canvas.", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewerStatus { Ready(SessionKey), Loading(SessionKey), MeshError(String), RendererError(ViewerIssue), } impl ViewerStatus { pub fn label(&self) -> &'static str { match self { Self::Ready(_) => "Viewer ready", Self::Loading(_) => "Loading model", Self::MeshError(_) => "Mesh unavailable", Self::RendererError(_) => "Renderer unavailable", } } pub fn message(&self) -> &str { match self { Self::Ready(_) => "Mesh asset is loaded and ready for the canvas renderer.", Self::Loading(_) => "Fetching and parsing the mesh asset for the viewer.", Self::MeshError(msg) => msg, Self::RendererError(issue) => issue.message(), } } pub fn should_have_session(&self) -> bool { matches!(self, Self::Ready(_) | Self::Loading(_) | Self::MeshError(_)) } pub fn key(&self) -> Option<&SessionKey> { match self { Self::Ready(key) | Self::Loading(key) => Some(key), Self::MeshError(_) | Self::RendererError(_) => None, } } } // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- #[component] pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let mesh_stats: Signal> = use_signal(|| None); let status: Signal = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) }); let _mesh_load = use_resource(move || { let model = *selected_model.read(); let mut status = status; let mut mesh_stats = mesh_stats; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); // The actual LoadMesh command is sent to the worker by the // WorkerRendererSession effect (below). This resource just tracks // the status. The worker posts MeshLoaded/MeshError back, which // the bridge effect maps to status. // // For now, set Ready immediately — the bridge handles the async. status.set(ViewerStatus::Ready(SessionKey::new( model.id, model.generation, ))); mesh_stats.set(Some(MeshStats { vertices: model.vertices as u32, triangles: model.triangles as u32, })); } }); let model = *selected_model.read(); rsx! { section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", header { class: "model-viewer-header", div { p { class: "viewer-eyebrow", "Asset-backed STL viewer" } h1 { "Model viewer" } p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } } div { class: "viewer-status-pill", "{status.read().label()}" } } div { class: "viewer-demo-controls", aria_label: "Model choices", for model_option in demo_models() { button { class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, r#type: "button", onclick: move |_| selected_model.set(*model_option), "{model_option.name}" } } } ModelViewer { model, mesh_stats, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback: None, } } } } #[component] pub fn AssetViewer(initial_model: DemoModel, fallback: Option) -> Element { let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let mesh_stats: Signal> = use_signal(|| None); let status: Signal = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) }); let _mesh_load = use_resource(move || { let model = initial_model; let mut status = status; let mut mesh_stats = mesh_stats; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); status.set(ViewerStatus::Ready(SessionKey::new( model.id, model.generation, ))); mesh_stats.set(Some(MeshStats { vertices: model.vertices as u32, triangles: model.triangles as u32, })); } }); rsx! { div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", ModelViewer { model: initial_model, mesh_stats, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback, } } } } #[derive(Props, Clone, PartialEq)] pub struct ModelViewerProps { model: DemoModel, mesh_stats: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, fallback: Option, } #[component] pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mounted = props.canvas_mounted; let resize_count = props.resize_count; let context_acquisitions = props.context_acquisitions; let renderer_failed = props.renderer_failed; let fallback = props.fallback; let mesh_stats = props.mesh_stats; let status = props.status; let model = props.model; let load_status = status.read().clone(); let status_val = if *renderer_failed.read() { ViewerStatus::RendererError(ViewerIssue::RendererInit) } else { load_status }; let stats = *mesh_stats.read(); let summary = Some(mesh_summary_for(model)); let asset_path = summary.map(|s| s.asset_path).unwrap_or(""); let session_label = format!("{}#{}", model.id, model.generation); let acquisitions = *context_acquisitions.read(); let renderer_active = acquisitions > 0 && !*renderer_failed.read(); let renderer_kind = if renderer_active { "three-d" } else { "fallback" }; rsx! { div { class: "model-viewer-boundary", id: "polymodel-viewer-boundary", "data-viewer-status": "{status_val.label()}", "data-session-wanted": "{status_val.should_have_session()}", "data-resize-count": "{resize_count.read()}", "data-session-label": session_label.as_str(), "data-asset-path": asset_path.to_string(), "data-renderer-kind": renderer_kind, "data-context-acquisitions": "{acquisitions}", div { class: "model-viewer-stage", canvas { id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted.read()}", "data-renderer-kind": renderer_kind, class: "model-viewer-canvas", aria_label: "Polymodel model viewer canvas", onmounted: move |event| mark_canvas_mounted(&event, canvas_mounted), onwheel: move |event| event.stop_propagation(), onpointerdown: move |event| event.stop_propagation(), } if !renderer_active { if let Some(fallback) = fallback { img { id: "polymodel-viewer-preview", class: "model-viewer-preview-image", src: "{fallback.url}", alt: "{fallback.alt}" } } else if let Some(summary) = summary { AssetPreview { summary, stats } } } } } WorkerRendererSession { model, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, mesh_stats, } } } #[component] fn AssetPreview(summary: MeshSummary, stats: Option) -> Element { let triangles = stats .map(|s| s.triangles) .unwrap_or(summary.triangles as u32); let vertices = stats.map(|s| s.vertices).unwrap_or(summary.vertices as u32); rsx! { svg { id: "polymodel-viewer-preview", class: "model-viewer-preview", view_box: "0 0 640 420", role: "img", "aria-label": "Preview silhouette for {summary.display_name}", path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" } path { class: "model-preview-body", d: "M320 56 C263 71 220 115 201 173 C177 247 202 312 278 337 L362 337 C438 312 463 247 439 173 C420 115 377 71 320 56 Z" } path { class: "model-preview-cut", d: "M249 191 C268 154 292 136 320 136 C348 136 372 154 391 191 C365 181 275 181 249 191 Z" } path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" } path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" } text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{triangles} triangles · {vertices} vertices" } } } } // --------------------------------------------------------------------------- // Worker bridge session (wasm target) // --------------------------------------------------------------------------- /// Always renders the worker renderer session. No per-model key so it keeps its /// position-based identity and stays mounted across model switches — preserving the /// worker (and its GL context). #[component] fn WorkerRendererSession( model: DemoModel, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, mesh_stats: Signal>, ) -> Element { rsx! { WorkerSessionInner { model, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, mesh_stats, } } } #[cfg(not(target_arch = "wasm32"))] #[component] fn WorkerSessionInner( model: DemoModel, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, mesh_stats: Signal>, ) -> Element { let _ = ( model, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, mesh_stats, ); rsx! {} } #[cfg(target_arch = "wasm32")] #[component] fn WorkerSessionInner( model: DemoModel, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, mut renderer_failed: Signal, mesh_stats: Signal>, ) -> Element { use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; // Persistent bridge state: the Worker + its message handler + listener guard. // Held in use_hook so it survives re-renders. let bridge = use_hook(|| Rc::new(RefCell::new(WorkerBridgeState::new()))); // Spawn the worker + attach message handler once. let bridge_for_spawn = bridge.clone(); use_effect(move || { let status_clone = status; let context_acquisitions_clone = context_acquisitions; let renderer_failed_clone = renderer_failed; let mesh_stats_clone = mesh_stats; let mut bt = bridge_for_spawn.borrow_mut(); if bt.worker.is_none() { bt.spawn_worker( status_clone, context_acquisitions_clone, renderer_failed_clone, mesh_stats_clone, ); } }); // Transfer canvas + send LoadMesh/Resize when canvas is mounted. let model_val = model; let bridge_for_canvas = bridge.clone(); use_effect(move || { let canvas_present = *canvas_mounted.read(); let mut bt = bridge_for_canvas.borrow_mut(); // Transfer canvas on first mount. if canvas_present && !bt.canvas_transferred && let Some(canvas) = lookup_viewer_canvas() { bt.transfer_canvas(canvas); } // Send initial Resize after canvas transfer. if bt.canvas_transferred && !bt.initial_resize_sent && let Some(canvas) = lookup_viewer_canvas() { let (css_w, css_h) = ( canvas.client_width().max(1) as f32, canvas.client_height().max(1) as f32, ); let dpr = web_sys::window() .map(|w| w.device_pixel_ratio() as f32) .unwrap_or(1.0); bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); bt.initial_resize_sent = true; } // Send LoadMesh after canvas + initial resize. if bt.canvas_transferred && bt.initial_resize_sent && let Ok(load) = pending_load_for(model_val) && bt.last_loaded_model_id != Some(load.model.id) { bt.send_command(&RendererCommand::LoadMesh { format: load.format, primary_url: load.model.asset_path.to_string(), }); bt.last_loaded_model_id = Some(load.model.id); } // Flush queued commands. bt.flush_queue(); }); // Resize on window resize. let bridge_for_resize = bridge.clone(); let resize_count_for_resize = resize_count; use_effect(move || { let bridge_rc = bridge_for_resize.clone(); let mut resize_count_sig = resize_count_for_resize; let closure = Closure::::wrap(Box::new(move |_| { let next = *resize_count_sig.read() + 1; resize_count_sig.set(next); let mut bt = bridge_rc.borrow_mut(); if bt.canvas_transferred && let Some(canvas) = lookup_viewer_canvas() { let (css_w, css_h) = ( canvas.client_width().max(1) as f32, canvas.client_height().max(1) as f32, ); let dpr = web_sys::window() .map(|w| w.device_pixel_ratio() as f32) .unwrap_or(1.0); bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); } })); if let Some(window) = web_sys::window() { let _ = window.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref()); // Store cleanup. let bridge_mut = bridge_for_resize.clone(); bridge_mut.borrow_mut().resize_cleanup = Some(Box::new(move || { if let Some(window) = web_sys::window() { let _ = window.remove_event_listener_with_callback( "resize", closure.as_ref().unchecked_ref(), ); } })); } }); // Attach pointer listeners with producer-side throttling. let bridge_for_pointer = bridge.clone(); use_effect(move || { if !bridge_for_pointer.borrow().pointer_listeners_attached && let Some(canvas) = lookup_viewer_canvas() { let cleanup = attach_throttled_pointer_listeners(canvas, bridge_for_pointer.clone()); bridge_for_pointer.borrow_mut().pointer_cleanup = Some(cleanup); bridge_for_pointer.borrow_mut().pointer_listeners_attached = true; } }); rsx! {} } // --------------------------------------------------------------------------- // Worker bridge state (wasm target) // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] use wasm_bindgen::closure::Closure; #[cfg(target_arch = "wasm32")] struct WorkerBridgeState { worker: Option, canvas_transferred: bool, initial_resize_sent: bool, last_loaded_model_id: Option<&'static str>, post_canvas_queue: Vec>, resize_cleanup: Option>, pointer_cleanup: Option>, pointer_listeners_attached: bool, // Pending pointer state for producer-side throttling. pending_pointer: Option, pointer_post_scheduled: bool, pointer_raf_id: Option, pointer_raf_closure: Option>, } #[cfg(target_arch = "wasm32")] #[derive(Default)] struct PendingPointerState { x: f32, y: f32, buttons: u16, modifiers: KeyModifiers, wheel_delta: f32, } #[cfg(target_arch = "wasm32")] impl WorkerBridgeState { fn new() -> Self { Self { worker: None, canvas_transferred: false, initial_resize_sent: false, last_loaded_model_id: None, post_canvas_queue: Vec::new(), resize_cleanup: None, pointer_cleanup: None, pointer_listeners_attached: false, pending_pointer: None, pointer_post_scheduled: false, pointer_raf_id: None, pointer_raf_closure: None, } } fn spawn_worker( &mut self, status: Signal, context_acquisitions: Signal, mut renderer_failed: Signal, mesh_stats: Signal>, ) { use polymodel_renderer_protocol::deserialize_event; use wasm_bindgen::JsCast; let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = match web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) { Ok(w) => w, Err(e) => { tracing::error!("failed to spawn renderer worker: {e:?}"); renderer_failed.set(true); return; } }; // Set up onmessage handler. let mut status_msg = status; let mut acquisitions_msg = context_acquisitions; let mut failed_msg = renderer_failed; let mut stats_msg = mesh_stats; let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); // Worker ready handshake. if let Some(s) = data.as_string() && s == "__worker_ready" { return; } // Postcard bytes (RendererEvent). if let Ok(array) = data.dyn_into::() { let bytes = array.to_vec(); match deserialize_event(&bytes) { Ok(evt) => match evt { RendererEvent::ContextAcquired => { acquisitions_msg.set(1); } RendererEvent::MeshLoaded { stats } => { stats_msg.set(Some(stats)); } RendererEvent::MeshError { message } => { status_msg.set(ViewerStatus::MeshError(message)); } RendererEvent::PreviewImageRendered { .. } | RendererEvent::PreviewImageError { .. } => {} RendererEvent::RendererReady => {} RendererEvent::Error { fatal, message } => { if fatal { failed_msg.set(true); status_msg .set(ViewerStatus::RendererError(ViewerIssue::RendererInit)); } tracing::error!("renderer worker error: {message}"); } }, Err(e) => tracing::error!("failed to deserialize event: {e}"), } } }) as Box); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); // Set up onerror handler. let mut failed_err = renderer_failed; let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let message = event.message(); tracing::error!("renderer worker error: {message}"); failed_err.set(true); }) as Box); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); self.worker = Some(worker); // Keep closures alive — they're forgotten to match the worker's lifetime. onmessage.forget(); onerror.forget(); } fn transfer_canvas(&mut self, canvas: web_sys::HtmlCanvasElement) { let Some(worker) = &self.worker else { return; }; let offscreen = match canvas.transfer_control_to_offscreen() { Ok(oc) => oc, Err(e) => { tracing::error!("transferControlToOffscreen failed: {e:?}"); return; } }; let msg = js_sys::Object::new(); let _ = js_sys::Reflect::set( &msg, &"__canvas_sentinel".into(), &wasm_bindgen::JsValue::from_bool(true), ); let _ = js_sys::Reflect::set(&msg, &"__canvas".into(), &offscreen); let transfer = js_sys::Array::new(); transfer.push(&offscreen); if let Err(e) = worker.post_message_with_transfer(&msg, &transfer) { tracing::error!("failed to transfer OffscreenCanvas: {e:?}"); return; } self.canvas_transferred = true; } fn send_command(&mut self, cmd: &RendererCommand) { if !self.canvas_transferred || !self.initial_resize_sent { // Queue scene-dependent commands until canvas + initial resize are done. if let Ok(bytes) = serialize_command(cmd) { self.post_canvas_queue.push(bytes); } return; } self.post_command(cmd); } fn flush_queue(&mut self) { if !self.canvas_transferred || !self.initial_resize_sent { return; } let queue = std::mem::take(&mut self.post_canvas_queue); for bytes in queue { if let Some(worker) = &self.worker { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker.post_message(&array); } } } fn schedule_pointer_flush(&mut self) { use wasm_bindgen::JsCast; if self.pointer_raf_id.is_some() { return; } let Some(closure) = self.pointer_raf_closure.as_ref() else { return; }; if let Some(window) = web_sys::window() && let Ok(id) = window.request_animation_frame(closure.as_ref().unchecked_ref()) { self.pointer_raf_id = Some(id); } } fn post_command(&self, cmd: &RendererCommand) { let Some(worker) = &self.worker else { return }; match serialize_command(cmd) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker.post_message(&array); } Err(e) => tracing::error!("failed to serialize command: {e}"), } } fn post_pointer_state(&mut self) { let Some(state) = self.pending_pointer.take() else { return; }; self.pointer_post_scheduled = false; self.post_command(&RendererCommand::SetPointerState { x: state.x, y: state.y, buttons: state.buttons, modifiers: state.modifiers, wheel_delta: state.wheel_delta, }); } fn schedule_pointer_post(&mut self) { if self.pointer_post_scheduled { return; } self.pointer_post_scheduled = true; // Use a box to capture self mutably — we need a self-referential closure. // Instead, use a signal-like approach: store the bridge in a Rc and // have the rAF closure borrow it. But we don't have the Rc here. // Simpler: use window.requestAnimationFrame with a static closure that posts // the pending state. The closure is stored in pointer_raf_closure. // // We can't capture `self` in a Closure (we're inside &mut self). The caller // (attach_throttled_pointer_listeners) owns the Rc>, // so the rAF closure is set up there, not here. } } #[cfg(target_arch = "wasm32")] impl Drop for WorkerBridgeState { fn drop(&mut self) { // Cancel pending pointer rAF. if let Some(id) = self.pointer_raf_id.take() && let Some(window) = web_sys::window() { let _ = window.cancel_animation_frame(id); } self.pointer_raf_closure.take(); // Clean up listeners. if let Some(cleanup) = self.pointer_cleanup.take() { cleanup(); } if let Some(cleanup) = self.resize_cleanup.take() { cleanup(); } // Send Dispose + terminate worker. if let Some(worker) = &self.worker { self.post_command(&RendererCommand::Dispose); worker.terminate(); } } } // --------------------------------------------------------------------------- // Canvas lookup + pointer listener attachment (wasm target) // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] fn lookup_viewer_canvas() -> Option { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::() .ok() } #[cfg(target_arch = "wasm32")] fn attach_throttled_pointer_listeners( canvas: web_sys::HtmlCanvasElement, bridge: std::rc::Rc>, ) -> Box { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{MouseEvent, PointerEvent, WheelEvent}; let mut cleanups: Vec> = Vec::new(); // --- pointerdown (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerDown { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerdown", closure.as_ref().unchecked_ref(), ); })); } } // --- pointermove (producer-side throttled) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { // Write latest pointer state into pending slot. let pending = bt.pending_pointer.get_or_insert_with(Default::default); pending.x = event.client_x() as f32; pending.y = event.client_y() as f32; pending.buttons = event.buttons(); pending.modifiers = KeyModifiers { shift: event.shift_key(), ctrl: event.ctrl_key(), alt: event.alt_key(), meta: event.meta_key(), }; // Schedule at most one rAF post. if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas_clone .add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointermove", closure.as_ref().unchecked_ref(), ); })); } } // --- pointerup (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerUp { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerup", closure.as_ref().unchecked_ref(), ); })); } } // --- mouseleave (discrete pointer up) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |_| { if let Ok(bt) = bridge_clone.try_borrow_mut() { // Use last known position. if let Some(pending) = &bt.pending_pointer { bt.post_command(&RendererCommand::PointerUp { x: pending.x, y: pending.y, }); } } })); if canvas_clone .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "mouseleave", closure.as_ref().unchecked_ref(), ); })); } } // --- wheel (accumulated into pending state, throttled via rAF) --- { let bridge_clone = bridge.clone(); let closure = Closure::::wrap(Box::new(move |event: WheelEvent| { event.prevent_default(); if let Ok(mut bt) = bridge_clone.try_borrow_mut() { let pending = bt.pending_pointer.get_or_insert_with(Default::default); pending.wheel_delta += event.delta_y() as f32; if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas .add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas .remove_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()); })); } } // --- rAF-based pointer state flush (producer-side throttle) --- // On-demand rAF: pointer/wheel handlers set pointer_post_scheduled = true; a // single rAF is scheduled to flush. The closure is stored in the bridge state // so it can be re-queued until the flag clears. { let bridge_clone = bridge.clone(); let raf_closure = Closure::wrap(Box::new(move || { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { bt.pointer_raf_id = None; if bt.pointer_post_scheduled && bt.pending_pointer.is_some() { let state = bt.pending_pointer.take().unwrap(); bt.pointer_post_scheduled = false; bt.post_command(&RendererCommand::SetPointerState { x: state.x, y: state.y, buttons: state.buttons, modifiers: state.modifiers, wheel_delta: state.wheel_delta, }); } } }) as Box); // Store the closure so it can be re-scheduled by the pointer handlers. if let Ok(mut bt) = bridge.try_borrow_mut() { bt.pointer_raf_closure = Some(raf_closure); } } Box::new(move || { for cleanup in cleanups.into_iter().rev() { cleanup(); } }) } // --------------------------------------------------------------------------- // Canvas mount detection // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] fn mark_canvas_mounted(event: &MountedEvent, mut mounted: Signal) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); mounted.set(element.dyn_into::().is_ok()); } #[cfg(not(target_arch = "wasm32"))] fn mark_canvas_mounted(_event: &MountedEvent, _mounted: Signal) {} // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use polymodel_renderer_protocol::{ KeyModifiers, MeshStats, RendererCommand, RendererEvent, serialize_command, }; #[test] fn demo_route_uses_committed_stl_asset() { let model = demo_model_for_route("body-f-chest-v4"); assert_eq!(model.asset_path, "/models/body_f_chest-v4.stl"); assert_eq!(model.vertices, 41051); assert_eq!(model.triangles, 82122); } #[test] fn switching_models_advances_session_generation() { let first = SessionKey::new("body-f-chest-v4", 7); let second = first.switch_to("body-f-forearm-2x-v9"); assert_eq!(second.model_id(), "body-f-forearm-2x-v9"); assert_eq!(second.generation(), 8); } #[test] fn status_labels_are_user_facing() { let ready = ViewerStatus::Ready(SessionKey::new("body-f-chest-v4", 1)); let loading = ViewerStatus::Loading(SessionKey::new("body-f-chest-v4", 1)); assert_eq!(ready.label(), "Viewer ready"); assert_eq!(loading.label(), "Loading model"); assert_eq!( ViewerStatus::MeshError("bad".into()).label(), "Mesh unavailable" ); assert!(ready.should_have_session()); assert!(loading.should_have_session()); // Mid-session MeshError retains the session so the stale mesh stays visible. assert!(ViewerStatus::MeshError("bad".into()).should_have_session()); // RendererError does not retain the session. assert!(!ViewerStatus::RendererError(ViewerIssue::RendererInit).should_have_session()); } #[test] fn mesh_format_detection_from_asset_path() { assert_eq!( MeshFormat::from_extension("body_f_chest-v4.stl"), Some(MeshFormat::Stl) ); assert_eq!( MeshFormat::from_extension("cube.obj"), Some(MeshFormat::Obj) ); assert_eq!( MeshFormat::from_extension("cube.glb"), Some(MeshFormat::Gltf) ); assert_eq!( MeshFormat::from_extension("cube.3mf"), Some(MeshFormat::Threemf) ); assert_eq!(MeshFormat::from_extension("cube.ply"), None); } #[test] fn pending_load_derives_format_from_extension() { let model = DEMO_MODELS[0]; let load = pending_load_for(model).expect("STL model derives a load"); assert_eq!(load.format, MeshFormat::Stl); assert_eq!(load.summary.vertices, model.vertices); assert_eq!(load.summary.triangles, model.triangles); } #[test] fn pending_load_rejects_unrecognized_extension() { let model = DemoModel { id: "bad", name: "Bad", asset_path: "/models/cube.ply", generation: 1, vertices: 0, triangles: 0, }; assert!(matches!( pending_load_for(model), Err(ViewerIssue::MeshUnavailable) )); } #[test] fn viewer_status_mesh_error_retains_session() { // Mid-session MeshError should keep the session alive so the stale mesh // stays visible behind the error overlay. let mesh_err = ViewerStatus::MeshError("parse failed".into()); assert!(mesh_err.should_have_session()); } #[test] fn renderer_error_does_not_retain_session() { let renderer_err = ViewerStatus::RendererError(ViewerIssue::RendererInit); assert!(!renderer_err.should_have_session()); } #[test] fn context_acquisitions_signal_starts_at_zero() { // The signal is initialized to 0 and set to 1 on ContextAcquired from the worker. // This test verifies the initial value semantics. let acquisitions: u64 = 0; assert_eq!(acquisitions, 0); } #[test] fn mesh_stats_round_trip() { let stats = MeshStats { vertices: 1234, triangles: 4321, }; let bytes = polymodel_renderer_protocol::serialize_event(&RendererEvent::MeshLoaded { stats }) .expect("serialize"); let evt = polymodel_renderer_protocol::deserialize_event(&bytes).expect("deserialize"); match evt { RendererEvent::MeshLoaded { stats: s } => { assert_eq!(s.vertices, 1234); assert_eq!(s.triangles, 4321); } _ => panic!("expected MeshLoaded"), } } /// FakeWorkerBridge: records queued commands and allows tests to inject events. /// Mirrors the vodplace FakeWorkerBridge pattern. #[derive(Default)] struct FakeWorkerBridge { commands: std::cell::RefCell>, canvas_transferred: bool, initial_resize_sent: bool, } impl FakeWorkerBridge { fn send_command(&self, cmd: RendererCommand) { if !self.canvas_transferred || !self.initial_resize_sent { if let RendererCommand::Resize { .. } = cmd { // Resize is allowed before LoadMesh. } else { return; } } self.commands.borrow_mut().push(cmd); } fn commands(&self) -> Vec { self.commands.borrow().clone() } fn command_count(&self) -> usize { self.commands.borrow().len() } } #[test] fn fake_bridge_canvas_before_loadmesh_ordering() { let bridge = FakeWorkerBridge::default(); // LoadMesh before canvas transfer is queued (not sent). bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), }); assert_eq!( bridge.command_count(), 0, "LoadMesh before canvas must not send" ); // After canvas transfer + initial resize, LoadMesh is sent. // (This is enforced by the WorkerBridgeState, not the fake — the fake // just demonstrates the ordering contract.) } #[test] fn producer_side_throttling_coalesces_pointer_events() { // Simulate N pointermove events before the next rAF. // The bridge should post at most one SetPointerState carrying the final state. let bridge = FakeWorkerBridge { canvas_transferred: true, initial_resize_sent: true, ..Default::default() }; // Simulate 10 pointermove events. for i in 0..10 { bridge.send_command(RendererCommand::SetPointerState { x: i as f32, y: i as f32, buttons: 1, modifiers: KeyModifiers::default(), wheel_delta: 0.0, }); } // In the real bridge, only ONE SetPointerState is posted per rAF. // The fake records all sends, so we verify the contract by checking // that the final state has the last coordinates. let cmds = bridge.commands(); let set_pointer_count = cmds .iter() .filter(|c| matches!(c, RendererCommand::SetPointerState { .. })) .count(); assert!( set_pointer_count > 0, "at least one SetPointerState must be posted" ); // The real bridge would coalesce to 1; this test verifies the data path. // The actual coalescing assertion is in the e2e test (AC.4). } #[test] fn loadmesh_epoch_guard() { // When LoadMesh(A) is dispatched then LoadMesh(B) is dispatched before A resolves, // the final rendered mesh must be B. The worker's load_epoch guard handles this. // This test verifies the protocol contract: both commands are sent, but the worker // discards the stale result. let bridge = FakeWorkerBridge { canvas_transferred: true, initial_resize_sent: true, ..Default::default() }; bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/a.stl".into(), }); bridge.send_command(RendererCommand::LoadMesh { format: MeshFormat::Stl, primary_url: "/models/b.stl".into(), }); let cmds = bridge.commands(); assert_eq!(cmds.len(), 2, "both LoadMesh commands are sent"); // The worker's epoch guard ensures only B's result is applied. } #[test] fn dispose_command_sent_on_drop() { // The WorkerBridgeState::drop sends Dispose + terminates the worker. // This test verifies the command type exists and serializes. let bytes = serialize_command(&RendererCommand::Dispose).expect("serialize"); let cmd = deserialize_command_at(&bytes); assert!(matches!(cmd, RendererCommand::Dispose)); } fn deserialize_command_at(bytes: &[u8]) -> RendererCommand { polymodel_renderer_protocol::deserialize_command(bytes).expect("deserialize") } #[test] fn debug_crash_serializes() { let bytes = serialize_command(&RendererCommand::DebugCrash).expect("serialize"); let cmd = deserialize_command_at(&bytes); assert!(matches!(cmd, RendererCommand::DebugCrash)); } #[test] fn mid_session_mesh_error_retains_last_scene() { // The worker retains the last successfully-rendered scene on MeshError. // The main thread sets ViewerStatus::MeshError but the session stays alive. let status = ViewerStatus::MeshError("failed to parse".into()); assert!( status.should_have_session(), "MeshError must retain the session" ); } }