Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421//! 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<String>, 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<String>) -> 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<PendingMeshLoad, ViewerIssue> { 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<Option<MeshStats>> = use_signal(|| None); let status: Signal<ViewerStatus> = 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<ViewerFallbackImage>) -> 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<Option<MeshStats>> = use_signal(|| None); let status: Signal<ViewerStatus> = 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<Option<MeshStats>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, fallback: Option<ViewerFallbackImage>,}
#[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<MeshStats>) -> 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<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, mesh_stats: Signal<Option<MeshStats>>,) -> 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<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, mesh_stats: Signal<Option<MeshStats>>,) -> 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<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, mut renderer_failed: Signal<bool>, mesh_stats: Signal<Option<MeshStats>>,) -> 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::<dyn FnMut(web_sys::Event)>::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<web_sys::Worker>, canvas_transferred: bool, initial_resize_sent: bool, last_loaded_model_id: Option<&'static str>, post_canvas_queue: Vec<Vec<u8>>, resize_cleanup: Option<Box<dyn FnOnce()>>, pointer_cleanup: Option<Box<dyn FnOnce()>>, pointer_listeners_attached: bool, // Pending pointer state for producer-side throttling. pending_pointer: Option<PendingPointerState>, pointer_post_scheduled: bool, pointer_raf_id: Option<i32>, pointer_raf_closure: Option<Closure<dyn FnMut()>>,}
#[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<ViewerStatus>, context_acquisitions: Signal<u64>, mut renderer_failed: Signal<bool>, mesh_stats: Signal<Option<MeshStats>>, ) { 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::<js_sys::Uint8Array>() { 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<dyn FnMut(web_sys::MessageEvent)>);
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<dyn FnMut(web_sys::ErrorEvent)>); 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<RefCell> 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<RefCell<WorkerBridgeState>>, // 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<web_sys::HtmlCanvasElement> { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::<web_sys::HtmlCanvasElement>() .ok()}
#[cfg(target_arch = "wasm32")]fn attach_throttled_pointer_listeners( canvas: web_sys::HtmlCanvasElement, bridge: std::rc::Rc<std::cell::RefCell<WorkerBridgeState>>,) -> Box<dyn FnOnce()> { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{MouseEvent, PointerEvent, WheelEvent};
let mut cleanups: Vec<Box<dyn FnOnce()>> = Vec::new();
// --- pointerdown (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::<dyn FnMut(PointerEvent)>::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::<dyn FnMut(PointerEvent)>::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::<dyn FnMut(PointerEvent)>::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::<dyn FnMut(MouseEvent)>::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::<dyn FnMut(WheelEvent)>::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<dyn FnMut()>);
// 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<bool>) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); mounted.set(element.dyn_into::<web_sys::HtmlCanvasElement>().is_ok());}
#[cfg(not(target_arch = "wasm32"))]fn mark_canvas_mounted(_event: &MountedEvent, _mounted: Signal<bool>) {}
// ---------------------------------------------------------------------------// 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<Vec<RendererCommand>>, 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<RendererCommand> { 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" ); }}