Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999//! 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::*;#[cfg(not(target_arch = "wasm32"))]use polymodel_renderer_protocol::MeshStats;#[cfg(target_arch = "wasm32")]use polymodel_renderer_protocol::{ KeyModifiers, LoadEvent, LoadIdentity, LoadReducer, LoadState, MeshStats, RendererCommand, RendererEvent, RendererNamespace, serialize_command,};use polymodel_renderer_protocol::{LoadErrorCode, MeshFormat};use std::cell::RefCell;use std::rc::Rc;
// ---------------------------------------------------------------------------// 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,}
#[derive(Clone, Debug, PartialEq, Eq)]pub enum ViewerSourceFormat { Renderer(MeshFormat), Step,}
#[derive(Clone, Debug, PartialEq, Eq)]pub struct ViewerMeshSource { pub key: String, pub name: String, pub url: String, pub format: ViewerSourceFormat, pub expected_size: Option<i64>, pub expected_digest: Option<Vec<u8>>,}
#[derive(Clone, Debug, PartialEq)]struct RenderSource { key: String, format: MeshFormat, primary_url: String, expected_size: Option<i64>, expected_digest: Option<Vec<u8>>,}
#[derive(Clone, Debug, PartialEq)]struct StepConversionReport { status: StepConversionStatus, details: String,}
#[derive(Clone, Debug, PartialEq)]enum StepConversionStatus { Idle, Converting, Converted, Failed,}
impl StepConversionReport { fn idle() -> Self { Self { status: StepConversionStatus::Idle, details: "STEP conversion has not been requested.".into(), } }
fn label(&self) -> &'static str { match self.status { StepConversionStatus::Idle => "STEP idle", StepConversionStatus::Converting => "STEP converting", StepConversionStatus::Converted => "STEP converted", StepConversionStatus::Failed => "STEP blocked", } }}
const DEMO_MODELS: [DemoModel; 3] = [ 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, }, DemoModel { id: "cube-gears-3mf", name: "Cube Gears (3MF)", asset_path: "/models/cube_gears.3mf", generation: 1, vertices: 12864, triangles: 25692, },];
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), Installing(SessionKey), Failed(SessionKey, LoadErrorCode, String), Cancelled(SessionKey), Superseded(SessionKey), Disposed, MeshError(String), RendererError(ViewerIssue),}
impl ViewerStatus { pub fn label(&self) -> &'static str { match self { Self::Ready(_) => "Viewer ready", Self::Loading(_) => "Loading model", Self::Installing(_) => "Installing model", Self::Failed(_, _, _) | Self::MeshError(_) => "Mesh unavailable", Self::Cancelled(_) => "Load cancelled", Self::Superseded(_) => "Load superseded", Self::Disposed => "Viewer disposed", 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::Installing(_) => "Installing the parsed mesh into the canvas renderer.", Self::Failed(_, _, detail) => detail, Self::Cancelled(_) => "The mesh load was cancelled.", Self::Superseded(_) => "The mesh load was replaced by a newer selection.", Self::Disposed => "The viewer renderer session has been disposed.", Self::MeshError(msg) => msg, Self::RendererError(issue) => issue.message(), } }
pub fn should_have_session(&self) -> bool { matches!( self, Self::Ready(_) | Self::Loading(_) | Self::Installing(_) | Self::Failed(_, _, _) | Self::Cancelled(_) | Self::Superseded(_) | Self::MeshError(_) ) }
pub fn key(&self) -> Option<&SessionKey> { match self { Self::Ready(key) | Self::Loading(key) | Self::Installing(key) | Self::Failed(key, _, _) | Self::Cancelled(key) | Self::Superseded(key) => Some(key), Self::Disposed | Self::MeshError(_) | Self::RendererError(_) => None, } }}
// ---------------------------------------------------------------------------// Pages// ---------------------------------------------------------------------------
#[component]pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); let step_report = use_signal(StepConversionReport::idle); let step_render_source: Signal<Option<RenderSource>> = use_signal(|| None); let active_step_object_url: Signal<Option<String>> = use_signal(|| None); 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; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); // The actual StartLoad command is sent to the worker by the // WorkerRendererSession effect (below). This resource just tracks // the requested status; readiness is published only after the // bridge receives InstallSucceeded. } });
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 |_| { clear_step_render_source(step_render_source, active_step_object_url); selected_model.set(*model_option); }, "{model_option.name}" } } StepConversionControl { step_report, step_render_source, active_step_object_url, selected_model, } }
div { class: "viewer-step-conversion-panel", "data-step-status": "{step_report.read().label()}", p { class: "viewer-step-conversion-label", "OpenCascade.js STEP worker" } p { "{step_report.read().details}" } }
ModelViewer { model: Some(model), render_source: step_render_source, fallback_to_demo: true, mesh_stats, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback: None, } } }}
#[component]pub fn AssetViewer( #[props(default)] initial_model: Option<DemoModel>, fallback: Option<ViewerFallbackImage>, #[props(default)] source: Option<ViewerMeshSource>,) -> Element { let render_source: Signal<Option<RenderSource>> = use_signal(|| None); let active_step_object_url: Signal<Option<String>> = use_signal(|| None); let step_report = use_signal(StepConversionReport::idle); 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(|| { let key = source .as_ref() .map(|source| SessionKey::new(source.key.clone(), 1)) .or_else(|| initial_model.map(|model| SessionKey::new(model.id, model.generation))) .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); ViewerStatus::Loading(key) }); let fallback_to_demo = source.is_none() && initial_model.is_some();
let _mesh_load = use_resource(move || { let model = initial_model; let mut status = status; let mut mesh_stats = mesh_stats; let source = source.clone(); let mut render_source = render_source; let active_step_object_url = active_step_object_url; let mut step_report = step_report; async move { let session_key = source .as_ref() .map(|source| SessionKey::new(source.key.clone(), 1)) .or_else(|| model.map(|model| SessionKey::new(model.id, model.generation))) .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); let using_source = source.is_some(); status.set(ViewerStatus::Loading(session_key.clone())); if let Some(source) = source { match source.format { ViewerSourceFormat::Renderer(format) => { clear_step_render_source(render_source, active_step_object_url); render_source.set(Some(RenderSource { key: source.key, format, primary_url: source.url, expected_size: source.expected_size, expected_digest: source.expected_digest, })); } ViewerSourceFormat::Step => { clear_step_render_source(render_source, active_step_object_url); step_report.set(StepConversionReport { status: StepConversionStatus::Converting, details: format!( "Converting {} in the STEP conversion worker.", source.name ), }); start_step_conversion( step_report, render_source, active_step_object_url, source.url, source.key, ); } } } else { clear_step_render_source(render_source, active_step_object_url); } if !using_source { mesh_stats.set(None); } } });
rsx! { div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", ModelViewer { model: initial_model, render_source, fallback_to_demo, mesh_stats, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback, } } }}
fn clear_step_render_source( mut step_render_source: Signal<Option<RenderSource>>, mut active_step_object_url: Signal<Option<String>>,) { if let Some(old_url) = active_step_object_url.write().take() { revoke_object_url(old_url); } step_render_source.set(None);}
#[cfg(not(target_arch = "wasm32"))]fn revoke_object_url(_url: String) {}
#[cfg(target_arch = "wasm32")]fn revoke_object_url(url: String) { let _ = web_sys::Url::revoke_object_url(&url);}
#[component]fn StepConversionControl( mut step_report: Signal<StepConversionReport>, step_render_source: Signal<Option<RenderSource>>, active_step_object_url: Signal<Option<String>>, mut selected_model: Signal<DemoModel>,) -> Element { let converting = matches!(step_report.read().status, StepConversionStatus::Converting); rsx! { button { class: "viewer-demo-button viewer-step-conversion-button", r#type: "button", disabled: converting, onclick: move |_| { selected_model.set(DEMO_MODELS[0]); clear_step_render_source(step_render_source, active_step_object_url); step_report.set(StepConversionReport { status: StepConversionStatus::Converting, details: "Loading /models/EInk_click_v101.step in the STEP conversion worker.".into(), }); start_step_conversion( step_report, step_render_source, active_step_object_url, "/models/EInk_click_v101.step".to_string(), "fixture:EInk_click_v101.step".to_string(), ); }, if converting { "Converting STEP…" } else { "Convert STEP fixture" } } }}
#[cfg(not(target_arch = "wasm32"))]fn start_step_conversion( mut step_report: Signal<StepConversionReport>, _step_render_source: Signal<Option<RenderSource>>, _active_step_object_url: Signal<Option<String>>, _source_url: String, _source_key: String,) { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: "STEP conversion is only available in the browser WASM build.".into(), });}
#[cfg(target_arch = "wasm32")]fn start_step_conversion( mut step_report: Signal<StepConversionReport>, mut step_render_source: Signal<Option<RenderSource>>, mut active_step_object_url: Signal<Option<String>>, source_url: String, source_key: String,) { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure;
let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = match web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) { Ok(worker) => worker, Err(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not spawn STEP worker: {error:?}"), }); return; } };
let worker_for_message = worker.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); let kind = js_string_field(&data, "kind").unwrap_or_default(); match kind.as_str() { "ready" => { let msg = js_sys::Object::new(); let _ = js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into()); let _ = js_sys::Reflect::set(&msg, &"stepUrl".into(), &source_url.clone().into()); let _ = worker_for_message.post_message(&msg); } "converted" => { let Some(buffer) = js_value_field(&data, "buffer") else { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: "STEP worker reported success without GLB bytes.".into(), }); worker_for_message.terminate(); return; }; let bytes = js_sys::Uint8Array::new(&buffer); let parts = js_sys::Array::new(); parts.push(&bytes.buffer()); let opts = web_sys::BlobPropertyBag::new(); opts.set_type("model/gltf-binary"); let blob = match web_sys::Blob::new_with_buffer_source_sequence_and_options(&parts, &opts) { Ok(blob) => blob, Err(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not create GLB Blob: {error:?}"), }); worker_for_message.terminate(); return; } }; let url = match web_sys::Url::create_object_url_with_blob(&blob) { Ok(url) => url, Err(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not create GLB object URL: {error:?}"), }); worker_for_message.terminate(); return; } };
if let Some(old_url) = active_step_object_url.write().take() { let _ = web_sys::Url::revoke_object_url(&old_url); } active_step_object_url.set(Some(url.clone())); step_render_source.set(Some(RenderSource { key: format!( "step:{}:{}", source_key, js_number_field(&data, "totalMs").unwrap_or_default() ), format: MeshFormat::Gltf, primary_url: url, expected_size: None, expected_digest: None, })); step_report.set(StepConversionReport { status: StepConversionStatus::Converted, details: conversion_summary(&data), }); worker_for_message.terminate(); } "error" => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: worker_error_summary(&data), }); worker_for_message.terminate(); } _ => {} } }) as Box<dyn FnMut(web_sys::MessageEvent)>); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget();
let mut step_report_error = step_report; let worker_for_error = worker.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { step_report_error.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("STEP worker error: {}", event.message()), }); worker_for_error.terminate(); }) as Box<dyn FnMut(web_sys::ErrorEvent)>); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); onerror.forget();}
#[cfg(target_arch = "wasm32")]fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<wasm_bindgen::JsValue> { js_sys::Reflect::get(value, &name.into()).ok()}
#[cfg(target_arch = "wasm32")]fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> { js_value_field(value, name).and_then(|v| v.as_string())}
#[cfg(target_arch = "wasm32")]fn js_number_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<f64> { js_value_field(value, name).and_then(|v| v.as_f64())}
#[cfg(target_arch = "wasm32")]fn conversion_summary(data: &wasm_bindgen::JsValue) -> String { let total = js_number_field(data, "totalMs").unwrap_or_default(); let step_bytes = js_number_field(data, "stepBytes").unwrap_or_default(); let glb_bytes = js_number_field(data, "glbBytes").unwrap_or_default(); let isolated = js_value_field(data, "crossOriginIsolated") .and_then(|v| v.as_bool()) .unwrap_or(false); format!( "Converted STEP to GLB in {:.0} ms; STEP {:.1} MB → GLB {:.1} MB; crossOriginIsolated={isolated}.", total, step_bytes / 1_048_576.0, glb_bytes / 1_048_576.0, )}
#[cfg(target_arch = "wasm32")]fn worker_error_summary(data: &wasm_bindgen::JsValue) -> String { let message = js_string_field(data, "message").unwrap_or_else(|| "unknown error".into()); let total = js_number_field(data, "totalMs").unwrap_or_default(); let isolated = js_value_field(data, "crossOriginIsolated") .and_then(|v| v.as_bool()) .unwrap_or(false); format!( "STEP worker blocked after {:.0} ms: {message}; crossOriginIsolated={isolated}.", total )}
#[derive(Props, Clone, PartialEq)]pub struct ModelViewerProps { model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, fallback_to_demo: bool, 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 render_source = props.render_source; let fallback_to_demo = props.fallback_to_demo;
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 = model.map(mesh_summary_for); let asset_path = summary.map(|s| s.asset_path).unwrap_or(""); let session_label = model .map(|model| format!("{}#{}", model.id, model.generation)) .unwrap_or_else(|| "asset-viewer#1".to_string()); 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, render_source, fallback_to_demo, 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: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, fallback_to_demo: bool, 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, render_source, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, mesh_stats, } }}
#[cfg(not(target_arch = "wasm32"))]#[component]fn WorkerSessionInner( model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, fallback_to_demo: bool, 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, render_source, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, mesh_stats, ); rsx! {}}
#[cfg(target_arch = "wasm32")]#[component]fn WorkerSessionInner( model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, fallback_to_demo: bool, 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()))); let worker_ready = use_signal(|| false);
// 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 worker_ready_clone = worker_ready;
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, worker_ready_clone, ); } });
// Transfer canvas + send LoadMesh/Resize when canvas is mounted. let model_val = model; let bridge_for_canvas = bridge.clone(); use_effect(move || { if !*worker_ready.read() { return; }
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.post_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 source = render_source.read().clone().or_else(|| { fallback_to_demo .then(|| { model_val .and_then(|model| pending_load_for(model).ok()) .map(|load| RenderSource { key: load.model.id.to_string(), format: load.format, primary_url: load.model.asset_path.to_string(), expected_size: None, expected_digest: None, }) }) .flatten() }); if let Some(source) = source { bt.start_load(source); let session = bt.current_session.borrow().clone(); if let Some(session) = session { status.set(ViewerStatus::Loading(session)); mesh_stats.set(None); } } }
// 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, load_reducer: Rc<RefCell<LoadReducer>>, next_identity: LoadIdentity, current_session: Rc<RefCell<Option<SessionKey>>>, 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, load_reducer: Rc::new(RefCell::new(LoadReducer::new( RendererNamespace::Interactive, ))), next_identity: LoadIdentity::new(0, 0), current_session: Rc::new(RefCell::new(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>>, mut worker_ready: Signal<bool>, ) { 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 reducer_msg = self.load_reducer.clone(); let current_session_msg = self.current_session.clone(); 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" { worker_ready.set(true); 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::RendererContextReady => {} RendererEvent::LoadSucceeded { namespace, identity, } => { let result = reducer_msg.borrow_mut().reduce(LoadEvent::LoadSucceeded { namespace, identity, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Installing(session)); } } RendererEvent::InstallSucceeded { namespace, identity, stats, } => { let result = reducer_msg .borrow_mut() .reduce(LoadEvent::InstallSucceeded { namespace, identity, scene: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { stats_msg.set(Some(stats)); status_msg.set(ViewerStatus::Ready(session)); } } RendererEvent::LoadFailed { namespace, identity, code, detail, } => { let result = reducer_msg.borrow_mut().reduce(LoadEvent::LoadFailed { namespace, identity, error: polymodel_renderer_protocol::LoadError { code, detail: detail.clone(), }, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Failed(session, code, detail)); } } RendererEvent::LoadCancelled { namespace, identity, } => { let result = reducer_msg.borrow_mut().reduce(LoadEvent::Cancel { namespace, identity, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Cancelled(session)); } } RendererEvent::PreviewImageRendered { .. } | RendererEvent::PreviewImageError { .. } => {} 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 allocate_identity(&mut self) -> LoadIdentity { let next = if self.next_identity.scene_generation == u32::MAX { LoadIdentity::new(self.next_identity.load_epoch.saturating_add(1), 0) } else { LoadIdentity::new( self.next_identity.load_epoch, self.next_identity.scene_generation + 1, ) }; self.next_identity = next; next }
fn start_load(&mut self, source: RenderSource) { let identity = self.allocate_identity(); let namespace = RendererNamespace::Interactive; let result = self.load_reducer.borrow_mut().reduce(LoadEvent::StartLoad { namespace, identity, }); if !matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } *self.current_session.borrow_mut() = Some(SessionKey::new( source.key.clone(), identity.scene_generation as u64, )); self.send_command(&RendererCommand::StartLoad { namespace, identity, format: source.format, primary_url: source.primary_url, expected_size: source.expected_size, expected_digest: source.expected_digest, }); }
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, LoadIdentity, MeshStats, RendererCommand, RendererEvent, RendererNamespace, 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 terminal_load_statuses_have_expected_session_retention() { let session = SessionKey::new("body-f-chest-v4", 1); assert!(ViewerStatus::Cancelled(session.clone()).should_have_session()); assert!(ViewerStatus::Superseded(session).should_have_session()); assert!(!ViewerStatus::Disposed.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::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), stats, }) .expect("serialize"); let evt = polymodel_renderer_protocol::deserialize_event(&bytes).expect("deserialize"); match evt { RendererEvent::InstallSucceeded { stats: s, .. } => { assert_eq!(s.vertices, 1234); assert_eq!(s.triangles, 4321); } _ => panic!("expected InstallSucceeded"), } }
/// 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 StartLoad. } 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_start_load_ordering() { let bridge = FakeWorkerBridge::default();
// StartLoad before canvas transfer is queued (not sent). bridge.send_command(RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: None, expected_digest: None, }); assert_eq!( bridge.command_count(), 0, "StartLoad before canvas must not send" );
// After canvas transfer + initial resize, StartLoad 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 start_load_identity_guard() { // When StartLoad(A) is dispatched then StartLoad(B) is dispatched before A resolves, // the final rendered mesh must be B. The worker's identity CAS 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::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/a.stl".into(), expected_size: None, expected_digest: None, }); bridge.send_command(RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), format: MeshFormat::Stl, primary_url: "/models/b.stl".into(), expected_size: None, expected_digest: None, });
let cmds = bridge.commands(); assert_eq!(cmds.len(), 2, "both StartLoad commands are sent"); // The worker's identity 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" ); }}