//! 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)] #[cfg(target_arch = "wasm32")] use crate::client::PolymodelClient; use dioxus::prelude::*; use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; #[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, RendererNamespace, deserialize_event_envelope, serialize_command_envelope, }; use polymodel_renderer_protocol::{ KeyModifiers, LoadErrorCode, MeshFormat, MeshStats, RendererCommand, RendererEvent, }; #[cfg(target_arch = "wasm32")] use std::cell::RefCell; #[cfg(target_arch = "wasm32")] 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, pub expected_digest: Option>, } #[cfg(target_arch = "wasm32")] #[derive(Clone, Debug, PartialEq, Eq)] struct CompoundLogicalIntent { key: String, } #[cfg(target_arch = "wasm32")] #[derive(Clone, Debug, PartialEq, Eq)] struct PendingCompoundLoad { logical: CompoundLogicalIntent, bundle: ModelLoadBundle, acquisition_epoch: u32, } #[cfg(not(target_arch = "wasm32"))] type CompoundLogicalIntent = (); #[cfg(not(target_arch = "wasm32"))] type PendingCompoundLoad = (); #[derive(Clone, Debug, PartialEq)] struct RenderSource { key: String, format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, } #[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, generation: u64) -> Self { Self { model_id: model_id.into(), generation, } } pub fn model_id(&self) -> &str { &self.model_id } pub fn generation(&self) -> u64 { self.generation } #[cfg(test)] pub fn switch_to(&self, next_model_id: impl Into) -> Self { Self { model_id: next_model_id.into(), generation: self.generation + 1, } } } // --------------------------------------------------------------------------- // Mesh summary (display metadata — stays main-side from DemoModel) // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MeshSummary { pub display_name: &'static str, pub asset_path: &'static str, pub vertices: usize, pub triangles: usize, } fn mesh_summary_for(model: DemoModel) -> MeshSummary { MeshSummary { display_name: model.name, asset_path: model.asset_path, vertices: model.vertices, triangles: model.triangles, } } /// A mesh load request: the model to load plus its derived format + summary. /// The main thread sends `LoadMesh` to the worker; the worker fetches, parses, and /// builds the scene, then posts `MeshLoaded{stats}` or `MeshError{message}` back. #[derive(Clone, Debug)] pub struct PendingMeshLoad { pub model: DemoModel, pub format: MeshFormat, pub summary: MeshSummary, } fn pending_load_for(model: DemoModel) -> Result { let format = MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; Ok(PendingMeshLoad { model, format, summary: mesh_summary_for(model), }) } // --------------------------------------------------------------------------- // Viewer status // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ViewerIssue { MeshUnavailable, RendererInit, } impl ViewerIssue { pub fn message(self) -> &'static str { match self { Self::MeshUnavailable => "This model could not be loaded for the viewer.", Self::RendererInit => "The viewer could not start on this canvas.", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewerStatus { Ready(SessionKey), Loading(SessionKey), 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> = use_signal(|| None); let compound_load: Signal> = use_signal(|| None); let compound_intent: Signal> = use_signal(|| None); let compound_boundary: Signal = use_signal(|| false); let active_step_object_url: Signal> = use_signal(|| None); let canvas_mounted = use_signal(|| false); let canvas_mount_generation = use_signal(|| 0_u32); let canvas_mount_epoch = use_signal(|| None::); let canvas_mount_requested = use_signal(|| true); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let restart_epoch = use_signal(|| 0_u32); let worker_session_id = use_signal(|| None::); let worker_capabilities = use_signal(String::new); let cancelled_loads = use_signal(|| 0_u32); let mesh_stats: Signal> = use_signal(|| None); let status: Signal = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) }); let _mesh_load = use_resource(move || { let model = *selected_model.read(); let mut status = status; 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, compound_load, compound_intent, compound_boundary, fallback_to_demo: true, mesh_stats, status, canvas_mounted, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, fallback: None, } } } } #[component] pub fn AssetViewer( #[props(default)] initial_model: Option, fallback: Option, #[props(default)] source: Option, ) -> Element { let render_source: Signal> = use_signal(|| None); let compound_load: Signal> = use_signal(|| None); #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] let mut compound_intent: Signal> = use_signal(|| None); let compound_boundary: Signal = use_signal(|| false); let active_step_object_url: Signal> = use_signal(|| None); let step_report = use_signal(StepConversionReport::idle); let canvas_mounted = use_signal(|| false); let canvas_mount_generation = use_signal(|| 0_u32); let canvas_mount_epoch = use_signal(|| None::); let canvas_mount_requested = use_signal(|| true); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let restart_epoch = use_signal(|| 0_u32); let worker_session_id = use_signal(|| None::); let worker_capabilities = use_signal(String::new); let cancelled_loads = use_signal(|| 0_u32); let mesh_stats: Signal> = use_signal(|| None); let status: Signal = 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 mut compound_boundary = compound_boundary; let mut compound_load = compound_load; 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(); compound_boundary.set(false); status.set(ViewerStatus::Loading(session_key.clone())); if let Some(source) = source { match source.format { ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw) => { clear_step_render_source(render_source, active_step_object_url); render_source.set(None); #[cfg(target_arch = "wasm32")] compound_intent.set(Some(CompoundLogicalIntent { key: source.key })); compound_load.set(None); } 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, compound_load, compound_intent, compound_boundary, fallback_to_demo, mesh_stats, status, canvas_mounted, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, fallback, } } } } #[cfg(target_arch = "wasm32")] #[derive(serde::Serialize)] struct ModelBundleRequest<'a> { uri: &'a str, } #[cfg(target_arch = "wasm32")] fn start_compound_plan_load( logical: CompoundLogicalIntent, expected_restart_epoch: u32, restart_epoch: Signal, expected_intent: Signal>, client: PolymodelClient, mut pending: Signal>, mut status: Signal, ) { wasm_bindgen_futures::spawn_local(async move { let path = format!( "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", urlencoding::encode(&logical.key) ); let result = client .app_bytes::>(http::Method::GET, &path, None) .await .and_then(|bytes| { ModelLoadBundle::decode(&bytes) .map_err(|error| crate::client::RawError::Decode(format!("{error:?}"))) }); let current = *restart_epoch.read() == expected_restart_epoch && expected_intent.read().as_ref() == Some(&logical); if !current { return; } match result { Ok(bundle) => pending.set(Some(PendingCompoundLoad { logical, bundle, acquisition_epoch: expected_restart_epoch, })), Err(error) => status.set(ViewerStatus::MeshError(format!( "model bundle acquisition failed: {error}" ))), } }); } fn clear_step_render_source( mut step_render_source: Signal>, mut active_step_object_url: Signal>, ) { 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, step_render_source: Signal>, active_step_object_url: Signal>, mut selected_model: Signal, ) -> 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, _step_render_source: Signal>, _active_step_object_url: Signal>, _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, mut step_render_source: Signal>, mut active_step_object_url: Signal>, 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); 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); 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 { js_sys::Reflect::get(value, &name.into()).ok() } #[cfg(target_arch = "wasm32")] fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { 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 { 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, render_source: Signal>, compound_load: Signal>, compound_intent: Signal>, compound_boundary: Signal, canvas_mount_generation: Signal, canvas_mount_epoch: Signal>, canvas_mount_requested: Signal, fallback_to_demo: bool, mesh_stats: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, restart_epoch: Signal, worker_session_id: Signal>, worker_capabilities: Signal, cancelled_loads: Signal, fallback: Option, } #[component] pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mount_generation = props.canvas_mount_generation; let canvas_mount_epoch = props.canvas_mount_epoch; let canvas_mount_requested = props.canvas_mount_requested; 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 restart_epoch = props.restart_epoch; let worker_session_id = props.worker_session_id; let worker_capabilities = props.worker_capabilities; let cancelled_loads = props.cancelled_loads; let fallback = props.fallback; let mesh_stats = props.mesh_stats; let status = props.status; let compound_boundary = props.compound_boundary; let compound_intent = props.compound_intent; let model = props.model; let render_source = props.render_source; let compound_load = props.compound_load; 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-current-identity": match status_val { ViewerStatus::Ready(_) => session_label.as_str(), _ => "", }, "data-interactive-namespace": "Interactive", "data-preview-namespace": "Preview", "data-asset-path": asset_path.to_string(), "data-renderer-kind": renderer_kind, "data-context-acquisitions": "{acquisitions}", "data-protocol-session-id": "{worker_session_id.read().unwrap_or_default()}", "data-protocol-capabilities": worker_capabilities.read().as_str(), "data-restart-epoch": "{restart_epoch.read()}", "data-cancelled-loads": "{cancelled_loads.read()}", "data-compound-boundary": "{compound_boundary.read()}", div { class: "model-viewer-stage", if *canvas_mount_requested.read() { canvas { key: "viewer-canvas-{restart_epoch.read()}", 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, canvas_mount_generation, canvas_mount_epoch, restart_epoch, ) }, 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, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, } } } #[component] fn AssetPreview(summary: MeshSummary, stats: Option) -> Element { let triangles = stats .map(|s| s.triangles) .unwrap_or(summary.triangles as u32); let vertices = stats.map(|s| s.vertices).unwrap_or(summary.vertices as u32); rsx! { svg { id: "polymodel-viewer-preview", class: "model-viewer-preview", view_box: "0 0 640 420", role: "img", "aria-label": "Preview silhouette for {summary.display_name}", path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" } path { class: "model-preview-body", d: "M320 56 C263 71 220 115 201 173 C177 247 202 312 278 337 L362 337 C438 312 463 247 439 173 C420 115 377 71 320 56 Z" } path { class: "model-preview-cut", d: "M249 191 C268 154 292 136 320 136 C348 136 372 154 391 191 C365 181 275 181 249 191 Z" } path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" } path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" } text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{triangles} triangles · {vertices} vertices" } } } } // --------------------------------------------------------------------------- // Worker bridge session (wasm target) // --------------------------------------------------------------------------- /// Always renders the worker renderer session. No per-model key so it keeps its /// position-based identity and stays mounted across model switches — preserving the /// worker (and its GL context). #[component] fn WorkerRendererSession( model: Option, render_source: Signal>, compound_load: Signal>, compound_intent: Signal>, compound_boundary: Signal, canvas_mount_generation: Signal, canvas_mount_epoch: Signal>, canvas_mount_requested: Signal, fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, restart_epoch: Signal, worker_session_id: Signal>, worker_capabilities: Signal, cancelled_loads: Signal, mesh_stats: Signal>, ) -> Element { rsx! { WorkerSessionInner { model, render_source, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, } } } #[cfg(not(target_arch = "wasm32"))] #[component] fn WorkerSessionInner( model: Option, render_source: Signal>, compound_load: Signal>, compound_intent: Signal>, compound_boundary: Signal, canvas_mount_generation: Signal, canvas_mount_epoch: Signal>, canvas_mount_requested: Signal, fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, restart_epoch: Signal, worker_session_id: Signal>, worker_capabilities: Signal, cancelled_loads: Signal, mesh_stats: Signal>, ) -> Element { let _ = ( model, render_source, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, ); rsx! {} } #[cfg(target_arch = "wasm32")] #[component] fn WorkerSessionInner( model: Option, render_source: Signal>, compound_load: Signal>, compound_intent: Signal>, compound_boundary: Signal, canvas_mount_generation: Signal, canvas_mount_epoch: Signal>, canvas_mount_requested: Signal, fallback_to_demo: bool, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, mut renderer_failed: Signal, restart_epoch: Signal, mut worker_session_id: Signal>, mut worker_capabilities: Signal, mut cancelled_loads: Signal, mesh_stats: Signal>, ) -> Element { use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; // Persistent bridge state: the Worker + its message handler + listener guard. // Held in use_hook so it survives re-renders. let bridge = use_hook(|| Rc::new(RefCell::new(WorkerBridgeState::new()))); let worker_ready = use_signal(|| false); // A restart first removes the transferred canvas for one render. Re-enable // the slot on the following reactive pass so Dioxus must mount a fresh node. let mut canvas_mount_requested_for_effect = canvas_mount_requested; let canvas_mount_epoch_for_effect = canvas_mount_epoch; use_effect(move || { let epoch = *restart_epoch.read(); if !*canvas_mount_requested_for_effect.read() && *canvas_mount_epoch_for_effect.read() != Some(epoch) { canvas_mount_requested_for_effect.set(true); } }); // Spawn only after the canvas for the current restart epoch has mounted. let bridge_for_spawn = bridge.clone(); use_effect(move || { let mounted_generation = *canvas_mount_generation.read(); let mounted_epoch = *canvas_mount_epoch.read(); let mounted = *canvas_mounted.read(); let epoch = *restart_epoch.read(); if !mounted || mounted_generation == 0 || mounted_epoch != Some(epoch) { return; } 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 worker_session_id_clone = worker_session_id; let worker_capabilities_clone = worker_capabilities; let cancelled_loads_clone = cancelled_loads; let compound_boundary_clone = compound_boundary; let mut bt = bridge_for_spawn.borrow_mut(); if bt.worker.is_none() { bt.spawn_worker( bridge_for_spawn.clone(), WorkerSpawnSignals { status: status_clone, context_acquisitions: context_acquisitions_clone, renderer_failed: renderer_failed_clone, mesh_stats: mesh_stats_clone, worker_ready: worker_ready_clone, worker_session_id: worker_session_id_clone, worker_capabilities: worker_capabilities_clone, cancelled_loads: cancelled_loads_clone, compound_boundary: compound_boundary_clone, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, restart_epoch, }, ); } }); // Transfer canvas + send LoadMesh/Resize when canvas is mounted. let model_val = model; let mut compound_load_for_canvas = compound_load; let compound_intent_for_canvas = compound_intent; let bridge_for_canvas = bridge.clone(); #[cfg(target_arch = "wasm32")] let client_for_canvas = use_context::(); use_effect(move || { if !*worker_ready.read() { return; } // The worker handshake can complete after the readiness sentinel. Read // the negotiated session signal so the queued resize/load work is retried // when the bridge becomes able to send protocol commands. let _ = *worker_session_id.read(); let canvas_present = *canvas_mounted.read() && *canvas_mount_epoch.read() == Some(*restart_epoch.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.borrow() && 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.flush_queue(); } // Queue compound loads only after the replacement canvas has transferred, // negotiated, and issued its initial resize. A fresh anonymous plan is // reacquired for each bridge epoch; never replay a retired route plan. if bt.canvas_transferred { 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() }); let compound = compound_load_for_canvas.read().clone(); if compound .as_ref() .is_some_and(|compound| compound.acquisition_epoch != bt.restart_epoch) { // A plan fetched before a worker restart belongs to the retired // route/session epoch. Drop it; the mesh resource will reacquire // the same logical intent for the replacement epoch. compound_load_for_canvas.set(None); } #[cfg(target_arch = "wasm32")] if *bt.initial_resize_sent.borrow() && let Some(intent) = compound_intent_for_canvas.read().clone() && bt.compound_plan_refresh_epoch != Some(bt.restart_epoch) { bt.compound_plan_refresh_epoch = Some(bt.restart_epoch); start_compound_plan_load( intent, bt.restart_epoch, restart_epoch, compound_intent_for_canvas, client_for_canvas.clone(), compound_load_for_canvas, status, ); } if let Some(compound) = compound.filter(|compound| compound.acquisition_epoch == bt.restart_epoch) { bt.start_compound_load(compound); if let Some(session) = bt.current_session.borrow().clone() { status.set(ViewerStatus::Loading(session)); mesh_stats.set(None); } } else if *bt.initial_resize_sent.borrow() && let Some(source) = source && bt.last_started_source_key.as_deref() != Some(source.key.as_str()) { bt.last_started_source_key = Some(source.key.clone()); bt.start_load(source); if let Some(session) = bt.current_session.borrow().clone() { 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::::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 after the current // restart epoch's canvas has mounted. The mount signals make this rerun for // the replacement node rather than reusing the retired canvas guard. let bridge_for_pointer = bridge.clone(); use_effect(move || { let mounted = *canvas_mounted.read(); let mounted_generation = *canvas_mount_generation.read(); let mounted_epoch = *canvas_mount_epoch.read(); let epoch = *restart_epoch.read(); if mounted && mounted_generation > 0 && mounted_epoch == Some(epoch) && !bridge_for_pointer.borrow().pointer_listeners_attached && let Some(canvas) = lookup_viewer_canvas() { let cleanup = attach_throttled_pointer_listeners(canvas, bridge_for_pointer.clone()); bridge_for_pointer.borrow_mut().pointer_cleanup = Some(cleanup); bridge_for_pointer.borrow_mut().pointer_listeners_attached = true; } }); rsx! {} } // --------------------------------------------------------------------------- // Worker bridge state (wasm target) // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] use wasm_bindgen::closure::Closure; #[cfg(target_arch = "wasm32")] struct WorkerBridgeState { worker: Option, canvas_transferred: bool, initial_resize_sent: Rc>, load_reducer: Rc>, next_identity: LoadIdentity, current_session: Rc>>, handshake: Rc>, session_id: Rc>>, post_canvas_queue: Rc>>, pending_resize: Option, compound_started_key: Option, active_compound_identity: Rc>>, current_intent: Option, compound_plan_refresh_epoch: Option, last_started_source_key: Option, restart_epoch: u32, resize_cleanup: Option>, pointer_cleanup: Option>, pointer_listeners_attached: bool, // Pending pointer state for producer-side throttling. pending_pointer: Option, pointer_post_scheduled: bool, pointer_raf_id: Option, pointer_raf_closure: Option>, } #[derive(Default)] struct PendingPointerState { x: f32, y: f32, buttons: u16, modifiers: KeyModifiers, wheel_delta: f32, } fn update_pending_pointer(pending: &mut Option, next: PendingPointerState) { let current = pending.get_or_insert_with(Default::default); current.x = next.x; current.y = next.y; current.buttons = next.buttons; current.modifiers = next.modifiers; current.wheel_delta += next.wheel_delta; } fn accumulate_pending_wheel(pending: &mut Option, delta: f32) { pending.get_or_insert_with(Default::default).wheel_delta += delta; } fn command_is_ready_for_worker( canvas_transferred: bool, session_id: Option, initial_resize_sent: bool, command: &RendererCommand, ) -> bool { canvas_transferred && session_id.is_some() && (initial_resize_sent || matches!(command, RendererCommand::Resize { .. })) } fn pending_plan_is_current(acquisition_epoch: u32, restart_epoch: u32) -> bool { acquisition_epoch == restart_epoch } fn compound_capability_is_available(negotiated: Option<&NegotiatedProtocol>) -> bool { negotiated.is_some_and(|protocol| protocol.capabilities.contains(&Capability::CompoundLdraw)) } fn context_acquisitions_after_event(current: u64, event: &RendererEvent) -> u64 { if matches!(event, RendererEvent::ContextAcquired) { 1 } else { current } } #[cfg(target_arch = "wasm32")] struct WorkerSpawnSignals { status: Signal, context_acquisitions: Signal, renderer_failed: Signal, mesh_stats: Signal>, worker_ready: Signal, worker_session_id: Signal>, worker_capabilities: Signal, cancelled_loads: Signal, compound_boundary: Signal, canvas_mounted: Signal, canvas_mount_epoch: Signal>, canvas_mount_requested: Signal, restart_epoch: Signal, } #[cfg(target_arch = "wasm32")] impl WorkerBridgeState { fn new() -> Self { Self { worker: None, canvas_transferred: false, initial_resize_sent: Rc::new(RefCell::new(false)), load_reducer: Rc::new(RefCell::new(LoadReducer::new( RendererNamespace::Interactive, ))), next_identity: LoadIdentity::new(0, 0), current_session: Rc::new(RefCell::new(None)), handshake: Rc::new(RefCell::new(HandshakeState::Created)), session_id: Rc::new(RefCell::new(None)), post_canvas_queue: Rc::new(RefCell::new(Vec::new())), pending_resize: None, compound_started_key: None, active_compound_identity: Rc::new(RefCell::new(None)), current_intent: None, compound_plan_refresh_epoch: None, last_started_source_key: None, restart_epoch: 0, 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, bridge: std::rc::Rc>, signals: WorkerSpawnSignals, ) { use wasm_bindgen::JsCast; let WorkerSpawnSignals { status, context_acquisitions, renderer_failed, mesh_stats, worker_ready, worker_session_id, worker_capabilities, cancelled_loads, compound_boundary, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, restart_epoch, } = signals; let mut renderer_failed = renderer_failed; let mut worker_ready = worker_ready; 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. Both fatal event paths use the same reset so // a worker cannot leave a transferred canvas or stale session behind. let restart: Rc = Rc::new({ move || { let next_epoch = { let mut bt = bridge.borrow_mut(); bt.reset_for_restart(); bt.restart_epoch }; let mut worker_ready = worker_ready; let mut canvas_mounted = canvas_mounted; let mut canvas_mount_epoch = canvas_mount_epoch; let mut canvas_mount_requested = canvas_mount_requested; let mut renderer_failed = renderer_failed; let mut restart_epoch = restart_epoch; let mut worker_session_id = worker_session_id; worker_ready.set(false); // Remove the transferred node for one render before the new epoch // can mount. The mount callback is the only source of mounted=true. canvas_mounted.set(false); canvas_mount_epoch.set(None); canvas_mount_requested.set(false); renderer_failed.set(true); restart_epoch.set(next_epoch); worker_session_id.set(None); } }); let restart_for_message = restart.clone(); let mut status_msg = status; let mut acquisitions_msg = context_acquisitions; let mut failed_msg = renderer_failed; let mut stats_msg = mesh_stats; let mut worker_session_id_msg = worker_session_id; let mut worker_capabilities_msg = worker_capabilities; let mut cancelled_loads_msg = cancelled_loads; let mut compound_boundary_msg = compound_boundary; let active_compound_identity_msg = self.active_compound_identity.clone(); let reducer_msg = self.load_reducer.clone(); let current_session_msg = self.current_session.clone(); let handshake_msg = self.handshake.clone(); let session_id_msg = self.session_id.clone(); let queue_msg = self.post_canvas_queue.clone(); let worker_for_message = worker.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); failed_msg.set(false); let hello = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Hello(ProtocolHello { min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, required: vec![Capability::CurrentFormats, Capability::Cancellation], optional: vec![Capability::CompoundLdraw, Capability::Preview], }), }; match serialize_command_envelope(&hello) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); if let Err(error) = worker_for_message.post_message(&array) { tracing::error!("failed to send renderer protocol hello: {error:?}"); } } Err(error) => { tracing::error!("failed to serialize renderer protocol hello: {error}") } } return; } // Postcard bytes (versioned RendererEvent envelope). if let Ok(array) = data.dyn_into::() { let bytes = array.to_vec(); let envelope = match deserialize_event_envelope(&bytes) { Ok(envelope) => envelope, Err(e) => { tracing::error!("failed to deserialize event envelope: {e}"); return; } }; if matches!(envelope.payload, EventPayload::Failed(_)) { if let EventPayload::Failed(message) = envelope.payload { tracing::error!("renderer handshake failed: {message}"); failed_msg.set(true); } return; } let negotiated = match &envelope.payload { EventPayload::Negotiated(protocol) => Some(protocol.clone()), _ => None, }; let evt = match handshake_msg.borrow_mut().accept_event(&envelope) { Ok(Some(evt)) => evt, Ok(None) => { if let Some(protocol) = negotiated { let session_id = protocol.session_id; *session_id_msg.borrow_mut() = Some(session_id); worker_session_id_msg.set(Some(session_id)); worker_capabilities_msg.set( protocol .capabilities .iter() .map(|capability| format!("{capability:?}")) .collect::>() .join(","), ); let accept = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Accept(protocol), }; match serialize_command_envelope(&accept) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); if let Err(error) = worker_for_message.post_message(&array) { tracing::error!( "failed to accept renderer handshake: {error:?}" ); } } Err(error) => { tracing::error!("failed to serialize handshake accept: {error}") } } let queued = std::mem::take(&mut *queue_msg.borrow_mut()); for command in queued { let envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id, payload: CommandPayload::Command(command), }; if let Ok(bytes) = serialize_command_envelope(&envelope) { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker_for_message.post_message(&array); } } } return; } Err(error) => { tracing::warn!("rejected renderer event envelope: {error:?}"); return; } }; match evt { RendererEvent::ContextAcquired => { let current_acquisitions = *acquisitions_msg.read(); let next_acquisitions = context_acquisitions_after_event( current_acquisitions, &RendererEvent::ContextAcquired, ); acquisitions_msg.set(next_acquisitions); } 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)); compound_boundary_msg.set( active_compound_identity_msg.borrow().as_ref() == Some(&identity), ); 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, } => { if namespace == RendererNamespace::Interactive { let next_cancelled_loads = cancelled_loads_msg.read().saturating_add(1); cancelled_loads_msg.set(next_cancelled_loads); } 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 { restart_for_message(); status_msg.set(ViewerStatus::RendererError(ViewerIssue::RendererInit)); } tracing::error!("renderer worker error: {message}"); } } } }) as Box); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); // Set up onerror handler. let restart_for_error = restart.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let message = event.message(); tracing::error!("renderer worker error: {message}"); restart_for_error(); }) as Box); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); self.worker = Some(worker); // Keep closures alive — they're forgotten to match the worker's lifetime. onmessage.forget(); onerror.forget(); } fn transfer_canvas(&mut self, canvas: web_sys::HtmlCanvasElement) { let Some(worker) = &self.worker else { return; }; let offscreen = match canvas.transfer_control_to_offscreen() { Ok(oc) => oc, Err(e) => { tracing::error!("transferControlToOffscreen failed: {e:?}"); return; } }; let msg = js_sys::Object::new(); let _ = js_sys::Reflect::set( &msg, &"__canvas_sentinel".into(), &wasm_bindgen::JsValue::from_bool(true), ); let _ = js_sys::Reflect::set(&msg, &"__canvas".into(), &offscreen); let transfer = js_sys::Array::new(); transfer.push(&offscreen); if let Err(e) = worker.post_message_with_transfer(&msg, &transfer) { tracing::error!("failed to transfer OffscreenCanvas: {e:?}"); return; } self.canvas_transferred = true; } fn 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_compound_load(&mut self, pending: PendingCompoundLoad) { if !compound_capability_is_available(self.handshake.borrow().negotiated()) { tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); return; } if self.compound_started_key.as_deref() == Some(pending.logical.key.as_str()) { return; } 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.compound_started_key = Some(pending.logical.key.clone()); *self.active_compound_identity.borrow_mut() = Some(identity); self.current_intent = Some(pending.logical.clone()); *self.current_session.borrow_mut() = Some(SessionKey::new( pending.logical.key.clone(), identity.scene_generation as u64, )); self.send_command(&RendererCommand::StartLoad { namespace, identity, source: LoadSource::Bundle(pending.bundle), output: LoadOutput::Interactive, }); } fn start_load(&mut self, source: RenderSource) { *self.active_compound_identity.borrow_mut() = None; 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, source: LoadSource::Url { format: source.format, primary_url: source.primary_url, expected_size: source.expected_size, expected_digest: source.expected_digest, }, output: LoadOutput::Interactive, }); } fn reset_for_restart(&mut self) { if let Some(worker) = self.worker.take() { worker.terminate(); } 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(); self.pending_pointer = None; self.pointer_post_scheduled = false; if let Some(cleanup) = self.pointer_cleanup.take() { cleanup(); } self.pointer_listeners_attached = false; self.canvas_transferred = false; *self.initial_resize_sent.borrow_mut() = false; *self.handshake.borrow_mut() = HandshakeState::Created; *self.session_id.borrow_mut() = None; self.pending_resize = None; self.post_canvas_queue.borrow_mut().clear(); self.compound_started_key = None; *self.active_compound_identity.borrow_mut() = None; self.compound_plan_refresh_epoch = None; self.last_started_source_key = None; self.restart_epoch = self.restart_epoch.saturating_add(1); self.next_identity = LoadIdentity::new(self.restart_epoch, 0); self.load_reducer = Rc::new(RefCell::new(LoadReducer::new( RendererNamespace::Interactive, ))); } fn send_command(&mut self, cmd: &RendererCommand) { if matches!(cmd, RendererCommand::Resize { .. }) { self.pending_resize = Some(cmd.clone()); } if !command_is_ready_for_worker( self.canvas_transferred, *self.session_id.borrow(), *self.initial_resize_sent.borrow(), cmd, ) { self.post_canvas_queue.borrow_mut().push(cmd.clone()); return; } self.post_command(cmd); } fn flush_queue(&mut self) { if !self.canvas_transferred || self.session_id.borrow().is_none() { return; } if let Some(resize) = self.pending_resize.take() { self.post_command(&resize); *self.initial_resize_sent.borrow_mut() = true; } if !*self.initial_resize_sent.borrow() { return; } let queue = std::mem::take(&mut *self.post_canvas_queue.borrow_mut()); for command in queue { if !matches!(command, RendererCommand::Resize { .. }) { self.post_command(&command); } } } 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 }; let Some(session_id) = *self.session_id.borrow() else { return; }; let envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id, payload: CommandPayload::Command(cmd.clone()), }; match serialize_command_envelope(&envelope) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker.post_message(&array); } Err(e) => tracing::error!("failed to serialize command envelope: {e}"), } } fn post_pointer_state(&mut self) { let Some(state) = self.pending_pointer.take() else { return; }; self.pointer_post_scheduled = false; self.post_command(&RendererCommand::SetPointerState { x: state.x, y: state.y, buttons: state.buttons, modifiers: state.modifiers, wheel_delta: state.wheel_delta, }); } fn schedule_pointer_post(&mut self) { if self.pointer_post_scheduled { return; } self.pointer_post_scheduled = true; // Use a box to capture self mutably — we need a self-referential closure. // Instead, use a signal-like approach: store the bridge in a Rc and // have the rAF closure borrow it. But we don't have the Rc here. // Simpler: use window.requestAnimationFrame with a static closure that posts // the pending state. The closure is stored in pointer_raf_closure. // // We can't capture `self` in a Closure (we're inside &mut self). The caller // (attach_throttled_pointer_listeners) owns the Rc>, // so the rAF closure is set up there, not here. } } #[cfg(target_arch = "wasm32")] impl Drop for WorkerBridgeState { fn drop(&mut self) { // Cancel pending pointer rAF. if let Some(id) = self.pointer_raf_id.take() && let Some(window) = web_sys::window() { let _ = window.cancel_animation_frame(id); } self.pointer_raf_closure.take(); // Clean up listeners. if let Some(cleanup) = self.pointer_cleanup.take() { cleanup(); } if let Some(cleanup) = self.resize_cleanup.take() { cleanup(); } // Send Dispose + terminate worker. if let Some(worker) = &self.worker { self.post_command(&RendererCommand::Dispose); worker.terminate(); } } } // --------------------------------------------------------------------------- // Canvas lookup + pointer listener attachment (wasm target) // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] fn lookup_viewer_canvas() -> Option { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::() .ok() } #[cfg(target_arch = "wasm32")] fn attach_throttled_pointer_listeners( canvas: web_sys::HtmlCanvasElement, bridge: std::rc::Rc>, ) -> Box { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{MouseEvent, PointerEvent, WheelEvent}; let mut cleanups: Vec> = Vec::new(); // --- pointerdown (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerDown { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerdown", closure.as_ref().unchecked_ref(), ); })); } } // --- pointermove (producer-side throttled) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { update_pending_pointer( &mut bt.pending_pointer, PendingPointerState { x: event.client_x() as f32, y: event.client_y() as f32, buttons: event.buttons(), modifiers: KeyModifiers { shift: event.shift_key(), ctrl: event.ctrl_key(), alt: event.alt_key(), meta: event.meta_key(), }, wheel_delta: 0.0, }, ); // Schedule at most one rAF post. if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas_clone .add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointermove", closure.as_ref().unchecked_ref(), ); })); } } // --- pointerup (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerUp { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerup", closure.as_ref().unchecked_ref(), ); })); } } // --- mouseleave (discrete pointer up) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::::wrap(Box::new(move |_| { if let Ok(bt) = bridge_clone.try_borrow_mut() { // Use last known position. if let Some(pending) = &bt.pending_pointer { bt.post_command(&RendererCommand::PointerUp { x: pending.x, y: pending.y, }); } } })); if canvas_clone .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "mouseleave", closure.as_ref().unchecked_ref(), ); })); } } // --- wheel (accumulated into pending state, throttled via rAF) --- { let bridge_clone = bridge.clone(); let closure = Closure::::wrap(Box::new(move |event: WheelEvent| { event.prevent_default(); if let Ok(mut bt) = bridge_clone.try_borrow_mut() { accumulate_pending_wheel(&mut bt.pending_pointer, event.delta_y() as f32); if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas .add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas .remove_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()); })); } } // --- rAF-based pointer state flush (producer-side throttle) --- // On-demand rAF: pointer/wheel handlers set pointer_post_scheduled = true; a // single rAF is scheduled to flush. The closure is stored in the bridge state // so it can be re-queued until the flag clears. { let bridge_clone = bridge.clone(); let raf_closure = Closure::wrap(Box::new(move || { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { bt.pointer_raf_id = None; if bt.pointer_post_scheduled && bt.pending_pointer.is_some() { let state = bt.pending_pointer.take().unwrap(); bt.pointer_post_scheduled = false; bt.post_command(&RendererCommand::SetPointerState { x: state.x, y: state.y, buttons: state.buttons, modifiers: state.modifiers, wheel_delta: state.wheel_delta, }); } } }) as Box); // Store the closure so it can be re-scheduled by the pointer handlers. if let Ok(mut bt) = bridge.try_borrow_mut() { bt.pointer_raf_closure = Some(raf_closure); } } Box::new(move || { for cleanup in cleanups.into_iter().rev() { cleanup(); } }) } // --------------------------------------------------------------------------- // Canvas mount detection // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] fn mark_canvas_mounted( event: &MountedEvent, mut mounted: Signal, mut generation: Signal, mut mounted_epoch: Signal>, current_epoch: Signal, ) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); if element.dyn_ref::().is_some() { mounted.set(true); let next_generation = generation.read().saturating_add(1); generation.set(next_generation); mounted_epoch.set(Some(*current_epoch.read())); } else { mounted.set(false); mounted_epoch.set(None); } } #[cfg(not(target_arch = "wasm32"))] fn mark_canvas_mounted( _event: &MountedEvent, _mounted: Signal, _generation: Signal, _mounted_epoch: Signal>, _current_epoch: Signal, ) { } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use polymodel_renderer_protocol::{ KeyModifiers, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, MeshStats, RendererCommand, RendererEvent, RendererNamespace, }; #[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_envelope( &polymodel_renderer_protocol::EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 7, payload: polymodel_renderer_protocol::EventPayload::Event( RendererEvent::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), stats, }, ), }, ) .expect("serialize"); let evt = match polymodel_renderer_protocol::deserialize_event_envelope(&bytes) .expect("deserialize") .payload { polymodel_renderer_protocol::EventPayload::Event(evt) => evt, _ => panic!("expected event"), }; match evt { RendererEvent::InstallSucceeded { stats: s, .. } => { assert_eq!(s.vertices, 1234); assert_eq!(s.triangles, 4321); } _ => panic!("expected InstallSucceeded"), } } #[test] fn compound_load_without_negotiated_capability_stays_non_ready_and_reports_existing_error() { use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; let negotiated = NegotiatedProtocol { version: polymodel_renderer_protocol::PROTOCOL_VERSION, capabilities: vec![Capability::CurrentFormats, Capability::Preview], session_id: 7, }; assert!(!compound_capability_is_available(Some(&negotiated))); assert!(!compound_capability_is_available(None)); let reducer = LoadReducer::new(RendererNamespace::Interactive); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Idle )); assert_eq!(reducer.current_identity(), None); assert!(!compound_capability_is_available(Some(&negotiated))); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Idle )); assert_eq!(reducer.current_identity(), None); } #[test] fn bridge_gates_scene_commands_until_canvas_session_and_resize() { let start = RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), source: LoadSource::Url { format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: None, expected_digest: None, }, output: LoadOutput::Interactive, }; let resize = RendererCommand::Resize { css_w: 100.0, css_h: 100.0, dpr: 1.0, }; assert!(!command_is_ready_for_worker(false, None, false, &start)); assert!(!command_is_ready_for_worker(true, Some(7), false, &start)); assert!(command_is_ready_for_worker(true, Some(7), false, &resize)); assert!(command_is_ready_for_worker(true, Some(7), true, &start)); } #[test] fn producer_side_throttling_keeps_latest_pointer_and_accumulates_wheel() { let mut pending = None; for i in 0..10 { update_pending_pointer( &mut pending, PendingPointerState { x: i as f32, y: (i * 2) as f32, buttons: 1, modifiers: KeyModifiers::default(), wheel_delta: 0.0, }, ); } accumulate_pending_wheel(&mut pending, 2.5); accumulate_pending_wheel(&mut pending, -1.0); let pending = pending.expect("pointer events create one pending frame"); assert_eq!((pending.x, pending.y, pending.buttons), (9.0, 18.0, 1)); assert_eq!(pending.wheel_delta, 1.5); } #[test] fn stale_load_result_is_rejected_by_real_reducer() { let namespace = RendererNamespace::Interactive; let old = LoadIdentity::new(1, 1); let current = LoadIdentity::new(1, 2); let mut reducer = LoadReducer::new(namespace); assert_eq!( reducer .reduce(LoadEvent::StartLoad { namespace, identity: old, }) .outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ); reducer.reduce(LoadEvent::StartLoad { namespace, identity: current, }); let result = reducer.reduce(LoadEvent::LoadSucceeded { namespace, identity: old, candidate: Some(polymodel_renderer_protocol::ResourceToken(12)), }); assert_eq!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::IgnoredLateOrStale ); assert_eq!( result.disposed, vec![polymodel_renderer_protocol::ResourceToken(12)] ); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Loading(id) if id == ¤t )); } #[test] fn restart_epoch_rejects_old_plan_and_context_is_acquired_once() { assert!(!pending_plan_is_current(0, 1)); assert!(pending_plan_is_current(1, 1)); let context = RendererEvent::ContextAcquired; assert_eq!(context_acquisitions_after_event(0, &context), 1); assert_eq!(context_acquisitions_after_event(1, &context), 1); let other = RendererEvent::RendererContextReady; assert_eq!(context_acquisitions_after_event(1, &other), 1); } fn deserialize_command_at(bytes: &[u8]) -> RendererCommand { match polymodel_renderer_protocol::deserialize_command_envelope(bytes) .expect("deserialize") .payload { polymodel_renderer_protocol::CommandPayload::Command(command) => command, _ => panic!("expected command"), } } #[test] fn debug_crash_serializes() { let bytes = polymodel_renderer_protocol::serialize_command_envelope( &polymodel_renderer_protocol::CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 7, payload: polymodel_renderer_protocol::CommandPayload::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" ); } }