//! Polymodel renderer Web Worker — wasm entry point and all rendering logic. //! //! This module is only compiled for `wasm32-unknown-unknown`. It owns the //! `requestAnimationFrame` frame loop, the `OffscreenCanvas` GL context, mesh loading, //! and the postcard message protocol bridge to the Dioxus main thread. use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::spawn_local; use web_sys::{AbortController, DedicatedWorkerGlobalScope}; use polymodel_ldraw_core::{ AdapterRequest, CancellationPolicy, Materialization, NormalizedPath, ParserProfile, RootId, }; use polymodel_mesh::contract::ModelMesh; use polymodel_mesh::parser::load_mesh_with_companions; use polymodel_renderer_protocol::{ Capability, CommandPayload, EventEnvelope, EventPayload, HandshakeState, KeyModifiers, LoadError, LoadErrorCode, LoadEvent, LoadIdentity, LoadReducer, MeshFormat, MeshStats, NegotiatedProtocol, ProtocolHello, RendererCommand, RendererEvent, RendererNamespace, ResourceToken, deserialize_command_envelope, serialize_event_envelope, }; use polymodel_renderer_worker::{ParseAndAdaptError, RendererError, parse_and_adapt_ldraw}; use three_d_asset::InnerSpace as _; use polymodel_renderer_worker::admission::model_with_default_materials; // --------------------------------------------------------------------------- // Allocator // --------------------------------------------------------------------------- use talc::{sync::TalcLock, wasm::*}; #[global_allocator] static TALC: TalcLock = TalcLock::new(WasmGrowAndClaim); // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- #[derive(Default)] struct InputState { x: f32, y: f32, buttons: u16, modifiers: KeyModifiers, wheel_delta: f32, button_down: bool, last_consumed: Option<(f32, f32)>, } struct RendererState { context: three_d::Context, model: Option>, ldraw_static_model: Option, ldraw_explicit_model: Option>, ldraw_conditional_model: Option>, ldraw_scene: Option>, ldraw_plan: Option, ldraw_style: polymodel_renderer_worker::RenderStyle, ldraw_last_view: Option<[f64; 3]>, ambient_light: three_d::AmbientLight, key_light: three_d::DirectionalLight, fill_light: three_d::DirectionalLight, camera: three_d::Camera, control: three_d::OrbitControl, scene_bounds: Option, } struct PreviewAbortSlot { identity: LoadIdentity, controller: AbortController, listener: Option>, } impl Drop for PreviewAbortSlot { fn drop(&mut self) { if let Some(listener) = self.listener.take() { let _ = self .controller .signal() .remove_event_listener_with_callback("abort", listener.as_ref().unchecked_ref()); } } } fn compound_abort_slot( identity: LoadIdentity, controller: AbortController, cancellation: CancellationPolicy, ) -> PreviewAbortSlot { let callback_cancellation = cancellation.clone(); let listener = Closure::wrap(Box::new(move || callback_cancellation.cancel()) as Box); let _ = controller .signal() .add_event_listener_with_callback("abort", listener.as_ref().unchecked_ref()); PreviewAbortSlot { identity, controller, listener: Some(listener), } } struct PreviewTarget { canvas: web_sys::OffscreenCanvas, context: three_d::Context, } struct VerifiedCompoundSource { primary: String, resources: Vec, source_identities: Vec, target_selections: Vec, } fn descriptor_root(root: &str) -> Result { match root { "mpd" => Ok(RootId::CurrentMpd), "manifest" => Ok(RootId::UploadedManifest), "models" | "parts" | "p" | "external" => Ok(RootId::UploadedLdraw), "official-parts" | "official-p" => Ok(RootId::OfficialLibrary), _ => Err(RendererError::ParseMessage(format!( "unsupported compound descriptor root: {root}" ))), } } fn validate_bundle_resources( primary: &str, resources: &[polymodel_renderer_protocol::ModelResource], ) -> Result<(), CompoundLoadError> { let mut normalized = HashSet::with_capacity(resources.len()); let mut primary_found = false; for resource in resources { resource .validate() .map_err(|_| CompoundLoadError::Bundle("model resource rejected"))?; let path = NormalizedPath::new(&resource.path) .map_err(|_| CompoundLoadError::Bundle("model resource path rejected"))?; let root = descriptor_root(&resource.root).map_err(CompoundLoadError::Adapt)?; let identity = format!("{root:?}:{}", path.as_str()); if !normalized.insert(identity) { return Err(CompoundLoadError::Bundle( "duplicate normalized resource identity", )); } if resource.path == primary { primary_found = true; } } if !primary_found { return Err(CompoundLoadError::Bundle( "model bundle primary resource missing", )); } Ok(()) } fn compound_materializations( resources: &[polymodel_renderer_protocol::ModelResource], primary: &str, ) -> Result, RendererError> { let mut materializations = Vec::with_capacity(resources.len().saturating_sub(1)); for resource in resources { if resource.path == primary { continue; } let path = NormalizedPath::new(&resource.path).map_err(|error| { RendererError::ParseMessage(format!("invalid compound resource path: {error}")) })?; materializations.push(Materialization::new( path, descriptor_root(&resource.root)?, resource.bytes.to_vec(), true, Some(format!("worker:{}", resource.key)), )); } Ok(materializations) } fn parse_compound_source( source: VerifiedCompoundSource, cancellation: CancellationPolicy, ) -> Result< ( polymodel_renderer_protocol::MeshStats, polymodel_renderer_protocol::scene::Scene, Vec, ), CompoundLoadError, > { let primary = source .resources .iter() .find(|resource| resource.path == source.primary) .ok_or(CompoundLoadError::Bundle( "model bundle primary resource missing", ))?; let primary_root = source .source_identities .first() .map(|identity| identity.resolved_root) .unwrap_or(descriptor_root(&primary.root)?); let materializations = compound_materializations(&source.resources, &source.primary)?; let target_selections = source.target_selections; tracing::debug!( primary = %source.primary, resources = source.resources.len(), "renderer worker: invoking compound parser" ); let scene = parse_and_adapt_ldraw( AdapterRequest { bytes: &primary.bytes, profile: ParserProfile::Compatibility, provenance_id: &source.primary, root_name: &source.primary, root: primary_root, materializations: &materializations, target_selections: &target_selections, cancellation, }, polymodel_ldraw_core::LdrawLimits::default(), Some(&source.resources), ) .map_err(|error| match error { ParseAndAdaptError::Parse(polymodel_ldraw_core::ParseError::Cancelled) => { CompoundLoadError::Cancelled } ParseAndAdaptError::Parse(error) => CompoundLoadError::Parse(error), ParseAndAdaptError::Adapt(error) => CompoundLoadError::Adapt(error), })?; let stats = polymodel_renderer_protocol::MeshStats { vertices: scene.stats.points.min(u64::from(u32::MAX)) as u32, triangles: scene.stats.triangles.min(u64::from(u32::MAX)) as u32, }; Ok((stats, scene, source.resources)) } const MAX_UNKNOWN_FETCH_BYTES: u64 = 128 * 1024 * 1024; fn next_generation(current: u32) -> Result { current .checked_add(1) .ok_or(RendererError::GenerationExhausted) } struct WorkerState { scope: DedicatedWorkerGlobalScope, canvas: Option, context_loss_listener: Option>, context_loss_pending: Rc>, renderer: Option, pending_scene: Option<( three_d::Model, three_d::Camera, three_d::OrbitControl, )>, pixel_width: u32, pixel_height: u32, cached_viewport: Option, frame_events: Vec, input: InputState, needs_render: bool, disposed: bool, load_reducer: LoadReducer, preview_reducer: LoadReducer, raf_id: Option, raf_retry_count: u8, raf_closure: Option>, load_epoch: u32, scene_generation: u32, interactive_compound_abort: Option, preview_abort: Option, pending_events: Vec, handshake: HandshakeState, local_hello: ProtocolHello, } impl WorkerState { fn new(scope: DedicatedWorkerGlobalScope) -> Self { Self { scope, canvas: None, context_loss_listener: None, context_loss_pending: Rc::new(Cell::new(false)), renderer: None, pending_scene: None, pixel_width: 1, pixel_height: 1, cached_viewport: None, frame_events: Vec::with_capacity(2), input: InputState::default(), needs_render: false, disposed: false, load_reducer: LoadReducer::new(RendererNamespace::Interactive), preview_reducer: LoadReducer::new(RendererNamespace::Preview), raf_id: None, raf_retry_count: 0, raf_closure: None, load_epoch: 0, scene_generation: 0, interactive_compound_abort: None, preview_abort: None, pending_events: Vec::new(), handshake: HandshakeState::Created, local_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], }, } } fn post_envelope(&self, envelope: &EventEnvelope) { match serialize_event_envelope(envelope) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = self.scope.post_message(&array); } Err(e) => tracing::error!("failed to serialize event envelope: {e}"), } } fn post_event(&mut self, evt: &RendererEvent) { let Some(session_id) = self.handshake.session_id() else { self.pending_events.push(evt.clone()); return; }; self.post_envelope(&EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id, payload: EventPayload::Event(evt.clone()), }); } fn flush_pending_events(&mut self) { let pending = std::mem::take(&mut self.pending_events); for event in pending { self.post_event(&event); } } fn post_negotiated(&mut self, negotiated: &NegotiatedProtocol) { self.post_envelope(&EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: negotiated.session_id, payload: EventPayload::Negotiated(negotiated.clone()), }); } fn process_context_loss(&mut self) { if !self.context_loss_pending.replace(false) || self.disposed { return; } tracing::warn!("renderer worker: processing deferred WebGL context loss"); self.dispose_resources(false); self.post_event(&RendererEvent::Error { fatal: true, message: "OffscreenCanvas WebGL context lost; restarting renderer".into(), }); } fn dispose_resources(&mut self, close_worker: bool) { if self.disposed { if close_worker { self.scope.close(); } return; } let _ = self.load_reducer.reduce(LoadEvent::Dispose { namespace: RendererNamespace::Interactive, }); let _ = self.preview_reducer.reduce(LoadEvent::Dispose { namespace: RendererNamespace::Preview, }); self.interactive_compound_abort .take() .map(|slot| slot.controller.abort()); self.preview_abort .take() .map(|slot| slot.controller.abort()); self.pending_scene = None; self.renderer.take(); if let Some(id) = self.raf_id.take() { let _ = self.scope.cancel_animation_frame(id); } self.raf_closure.take(); let listener = self.context_loss_listener.take(); if let (Some(canvas), Some(listener)) = (self.canvas.as_ref(), listener) { let _ = canvas.remove_event_listener_with_callback( "webglcontextlost", listener.as_ref().unchecked_ref(), ); } self.canvas.take(); self.disposed = true; if close_worker { self.scope.close(); } } fn init_renderer(&mut self, canvas: web_sys::OffscreenCanvas, state: Rc>) { tracing::info!("renderer worker: initializing WebGL2 renderer"); let opts = js_sys::Object::new(); let _ = js_sys::Reflect::set(&opts, &"desynchronized".into(), &JsValue::from_bool(true)); let gl = match canvas.get_context_with_context_options("webgl2", &opts) { Ok(Some(val)) => val.dyn_into::(), _ => { self.post_event(&RendererEvent::Error { fatal: true, message: "WebGL2 context could not be created on OffscreenCanvas".into(), }); return; } }; let gl = match gl { Ok(gl) => gl, Err(_) => { self.post_event(&RendererEvent::Error { fatal: true, message: "OffscreenCanvas context is not WebGL2".into(), }); return; } }; let glow_context = three_d::context::Context::from_webgl2_context(gl); let context = match three_d::Context::from_gl_context(Arc::new(glow_context)) { Ok(ctx) => ctx, Err(e) => { self.post_event(&RendererEvent::Error { fatal: true, message: format!("three-d context error: {e}"), }); return; } }; let (ambient_light, key_light, fill_light) = standard_lights(&context); let viewport = three_d::Viewport::new_at_origo(self.pixel_width, self.pixel_height); let camera = three_d::Camera::new_perspective( viewport, three_d::vec3(3.0, -4.0, 2.0), three_d::vec3(0.0, 0.0, 0.0), three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), 0.1, 100.0, ); let control = three_d::OrbitControl::new(three_d::vec3(0.0, 0.0, 0.0), 0.1, 10.0); let state_for_context_loss = Rc::downgrade(&state); let context_loss_pending = self.context_loss_pending.clone(); let listener = Closure::wrap(Box::new(move |event: web_sys::WebGlContextEvent| { event.prevent_default(); context_loss_pending.set(true); let Some(state_for_context_loss) = state_for_context_loss.upgrade() else { return; }; let Ok(mut state) = state_for_context_loss.try_borrow_mut() else { return; }; if state.disposed { return; } tracing::warn!("renderer worker: OffscreenCanvas WebGL context lost"); state.context_loss_pending.set(false); state.dispose_resources(false); state.post_event(&RendererEvent::Error { fatal: true, message: "OffscreenCanvas WebGL context lost; restarting renderer".into(), }); }) as Box); if let Err(error) = canvas .add_event_listener_with_callback("webglcontextlost", listener.as_ref().unchecked_ref()) { self.post_event(&RendererEvent::Error { fatal: true, message: format!("install OffscreenCanvas context-loss listener: {error:?}"), }); return; } self.context_loss_listener = Some(listener); self.canvas = Some(canvas); self.renderer = Some(RendererState { context, model: None, ldraw_static_model: None, ldraw_explicit_model: None, ldraw_conditional_model: None, ldraw_scene: None, ldraw_plan: None, ldraw_style: polymodel_renderer_worker::RenderStyle::Faces, ldraw_last_view: None, ambient_light, key_light, fill_light, camera, control, scene_bounds: None, }); self.post_event(&RendererEvent::ContextAcquired); self.post_event(&RendererEvent::RendererContextReady); tracing::info!("renderer worker: renderer context ready"); } } // --------------------------------------------------------------------------- // Scene building and admission // --------------------------------------------------------------------------- fn build_scene( context: &three_d::Context, model: &ModelMesh, pixel_width: u32, pixel_height: u32, ) -> Result< ( three_d::Model, three_d::Camera, three_d::OrbitControl, ), RendererError, > { let fit = model.camera_fit(); let cpu_model = model_with_default_materials(model); tracing::info!( format = ?model.format, parts = cpu_model.geometries.len(), materials = cpu_model.materials.len(), vertices = model.vertex_count(), triangles = model.triangle_count(), center = ?fit.center, radius = fit.radius, "renderer worker: building scene" ); let render_model = three_d::Model::::new(context, &cpu_model) .map_err(|error| RendererError::GpuResource(format!("renderer model: {error}")))?; if render_model.iter().next().is_none() { return Err(RendererError::ParseAdaptation( "model contains no renderable triangle geometry", )); } let radius = fit.radius.max(0.001); let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); let viewport = three_d::Viewport::new_at_origo(pixel_width, pixel_height); let camera = three_d::Camera::new_perspective( viewport, position, target, three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), (radius / 1000.0).max(0.000001), radius * 100.0, ); let control = three_d::OrbitControl::new(target, radius * 0.01, radius * 50.0); Ok((render_model, camera, control)) } #[cfg(test)] mod tests { use super::*; use polymodel_mesh::gltf::load_gltf; use polymodel_mesh::parser::{MeshResources, MeshSource, load_mesh}; use polymodel_mesh::stl::load_stl; use three_d_asset::SquareMatrix; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] fn compound_errors_use_variants_not_display_text() { let ordinary = CompoundLoadError::Bundle("ordinary cancelled-looking failure"); assert!(!matches!( ordinary, CompoundLoadError::Cancelled | CompoundLoadError::Superseded | CompoundLoadError::Disposed )); assert!(matches!( CompoundLoadError::Cancelled, CompoundLoadError::Cancelled )); assert!(matches!( CompoundLoadError::Superseded, CompoundLoadError::Superseded )); assert!(matches!( CompoundLoadError::Disposed, CompoundLoadError::Disposed )); } #[wasm_bindgen_test] fn preview_errors_use_cancelled_variant_not_display_text() { let ordinary = RendererError::External("preview load cancelled".to_owned()); assert!(!matches!(ordinary, RendererError::Cancelled)); let cancelled = RendererError::Cancelled; assert!(matches!(cancelled, RendererError::Cancelled)); } #[wasm_bindgen_test] fn cancelled_compound_parse_maps_to_cancelled_load_error() { let bytes = b"0 FILE root.ldr\n3 16 0 0 0 1 0 0 0 1 0 0\n"; let source = VerifiedCompoundSource { primary: "root.ldr".into(), resources: vec![polymodel_renderer_protocol::ModelResource { key: "root.ldr".into(), path: "root.ldr".into(), root: "mpd".into(), byte_length: bytes.len() as u64, sha256: [0; 32], mime_type: "application/x-ldraw".into(), target_cids: Box::default(), bytes: bytes.to_vec().into(), }], source_identities: Vec::new(), target_selections: Vec::new(), }; let cancellation = CancellationPolicy::new(); cancellation.cancel(); assert!(matches!( parse_compound_source(source, cancellation), Err(CompoundLoadError::Cancelled) )); } #[wasm_bindgen_test] fn generation_allocation_never_reuses_saturated_identity() { assert_eq!(next_generation(0), Ok(1)); assert_eq!(next_generation(u32::MAX - 1), Ok(u32::MAX)); assert_eq!( next_generation(u32::MAX), Err(RendererError::GenerationExhausted) ); } fn textured_box() -> ModelMesh { let primary = include_bytes!("../../../public/models/box_textured.gltf"); let companions = vec![ ( "box_textured.bin".to_string(), include_bytes!("../../../public/models/box_textured.bin").to_vec(), ), ( "box_textured.png".to_string(), include_bytes!("../../../public/models/box_textured.png").to_vec(), ), ]; let source = MeshSource { format: MeshFormat::Gltf, primary, resources: MeshResources::new(&companions), }; load_gltf(&source).expect("textured box should parse") } fn browser_context() -> three_d::Context { let canvas = web_sys::OffscreenCanvas::new(8, 8).expect("offscreen canvas"); let gl = canvas .get_context("webgl2") .expect("context lookup") .expect("webgl2 context") .dyn_into::() .expect("WebGL2 context type"); let glow_context = three_d::context::Context::from_webgl2_context(gl); three_d::Context::from_gl_context(Arc::new(glow_context)).expect("three-d context") } #[wasm_bindgen_test] fn descriptor_roots_map_to_their_typed_resource_roots() { let cases = [ ("mpd", RootId::CurrentMpd), ("manifest", RootId::UploadedManifest), ("models", RootId::UploadedLdraw), ("parts", RootId::UploadedLdraw), ("p", RootId::UploadedLdraw), ("external", RootId::UploadedLdraw), ("official-parts", RootId::OfficialLibrary), ("official-p", RootId::OfficialLibrary), ]; for (root, expected) in cases { assert_eq!( descriptor_root(root).expect("supported descriptor root"), expected ); } } #[wasm_bindgen_test] fn compound_materializations_exclude_the_primary_source() { let resources = vec![ polymodel_renderer_protocol::ModelResource { key: "models/omr-10030-1.mpd".into(), path: "models/omr-10030-1.mpd".into(), root: "mpd".into(), byte_length: 1, sha256: [0; 32], mime_type: "application/x-ldraw".into(), target_cids: Box::default(), bytes: Box::new([0]), }, polymodel_renderer_protocol::ModelResource { key: "parts/2625.dat".into(), path: "parts/2625.dat".into(), root: "official-parts".into(), byte_length: 1, sha256: [0; 32], mime_type: "application/x-ldraw".into(), target_cids: Box::default(), bytes: Box::new([0]), }, ]; let materializations = compound_materializations(&resources, "models/omr-10030-1.mpd") .expect("compound materializations should normalize"); assert_eq!(materializations.len(), 1); assert_eq!(materializations[0].path.as_str(), "parts/2625.dat"); } #[wasm_bindgen_test] fn compound_parser_uses_bundle_virtual_source_root_for_indexed_selections() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 child.ldr\n0 FILE child.ldr\n3 16 0 0 0 1 0 0 0 1 0\n"; let inspection = polymodel_ldraw_core::inspect_compound_source( bytes, &NormalizedPath::new("models/fixture.mpd").expect("fixture path"), RootId::UploadedLdraw, ) .expect("compound inspection should succeed"); let root_key = inspection.source_keys[0].1.clone(); let child_key = inspection.source_keys[1].1.clone(); let reference = &inspection.references[0]; let source = VerifiedCompoundSource { primary: "models/fixture.mpd".into(), resources: vec![polymodel_renderer_protocol::ModelResource { key: "models/fixture.mpd".into(), path: "models/fixture.mpd".into(), root: "models".into(), byte_length: bytes.len() as u64, sha256: [0; 32], mime_type: "application/x-ldraw".into(), target_cids: Box::default(), bytes: bytes.to_vec().into(), }], source_identities: inspection .source_keys .iter() .map(|(_, key)| key.clone()) .collect(), target_selections: vec![polymodel_renderer_protocol::TargetSelection { source: root_key, span: polymodel_renderer_protocol::Span { start: reference.span.start, end: reference.span.end, line: reference.span.line, column: reference.span.column, }, target: child_key, }], }; let (_stats, scene, _resources) = parse_compound_source(source, CancellationPolicy::default()) .expect("uploaded-root MPD selections should materialize"); assert_eq!(scene.stats.triangles, 1); assert!(scene.bounds.is_some()); } #[wasm_bindgen_test] fn descriptor_roots_reject_malformed_and_colliding_labels() { for root in ["", "manifest/", "official", "models/../parts", "unknown"] { let error = descriptor_root(root).expect_err("malformed root must be rejected"); assert!( error .to_string() .contains("unsupported compound descriptor root") ); } assert_ne!( descriptor_root("manifest").unwrap(), descriptor_root("models").unwrap(), "manifest and uploaded LDraw roots must never collide" ); assert_ne!( descriptor_root("mpd").unwrap(), descriptor_root("official-p").unwrap(), "current MPD and official roots must remain distinct" ); } #[wasm_bindgen_test] fn load_lifecycle_reaches_ready_only_after_install() { const NS: RendererNamespace = RendererNamespace::Interactive; let identity = LoadIdentity::new(4, 2); let mut reducer = LoadReducer::new(NS); assert_eq!( reducer .reduce(LoadEvent::StartLoad { namespace: NS, identity, }) .outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ); assert!( matches!(reducer.state(), polymodel_renderer_protocol::LoadState::Loading(id) if *id == identity) ); assert!(matches!( reducer .reduce(LoadEvent::LoadSucceeded { namespace: NS, identity, candidate: Some(ResourceToken(10)), }) .state, polymodel_renderer_protocol::LoadState::Installing(id) if id == identity )); assert!(!matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Ready(_) )); assert!(matches!( reducer .reduce(LoadEvent::InstallSucceeded { namespace: NS, identity, scene: Some(ResourceToken(11)), }) .state, polymodel_renderer_protocol::LoadState::Ready(id) if id == identity )); } #[wasm_bindgen_test] fn install_failure_before_projection_commit_never_reaches_ready() { const NS: RendererNamespace = RendererNamespace::Interactive; let identity = LoadIdentity::new(4, 3); let mut reducer = LoadReducer::new(NS); reducer.reduce(LoadEvent::StartLoad { namespace: NS, identity, }); reducer.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity, candidate: None, }); let result = reducer.reduce(LoadEvent::InstallFailed { namespace: NS, identity, error: LoadError { code: LoadErrorCode::Install, detail: "camera depth range exceeds perspective precision".into(), }, candidate: None, }); assert_eq!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Failed(epoch, LoadErrorCode::Install) if *epoch == identity.load_epoch )); assert!(!matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Ready(_) )); } #[wasm_bindgen_test] fn stale_worker_result_is_rejected_and_disposed() { const NS: RendererNamespace = RendererNamespace::Interactive; let old = LoadIdentity::new(1, 1); let current = LoadIdentity::new(1, 2); let mut reducer = LoadReducer::new(NS); reducer.reduce(LoadEvent::StartLoad { namespace: NS, identity: old, }); reducer.reduce(LoadEvent::StartLoad { namespace: NS, identity: current, }); let result = reducer.reduce(LoadEvent::LoadSucceeded { namespace: NS, identity: old, candidate: Some(ResourceToken(12)), }); assert_eq!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::IgnoredLateOrStale ); assert_eq!(result.disposed, vec![ResourceToken(12)]); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Loading(id) if *id == current )); } #[wasm_bindgen_test] fn worker_parser_covers_all_supported_formats() { let cases = [ ( MeshFormat::Stl, include_bytes!("../../../public/models/body_f_chest-v4.stl").as_slice(), ), ( MeshFormat::Obj, include_bytes!("../../../public/models/cube.obj").as_slice(), ), ( MeshFormat::Gltf, include_bytes!("../../../public/models/box.glb").as_slice(), ), ( MeshFormat::Threemf, include_bytes!("../../../public/models/cube_gears.3mf").as_slice(), ), ]; for (format, primary) in cases { let mesh = load_mesh(&MeshSource { format, primary, resources: MeshResources::empty(), }) .unwrap_or_else(|error| panic!("{format:?} fixture should parse: {error:?}")); assert_eq!(mesh.format, format); assert!( mesh.triangle_count() > 0, "{format:?} must contain triangles" ); } } #[test] fn no_material_parts_get_polymodel_blue_default() { let stl = include_bytes!("../../../public/models/body_f_chest-v4.stl"); let model = load_stl(stl).expect("STL fixture should parse"); let cpu_model = model_with_default_materials(&model); assert_eq!(cpu_model.materials.len(), 1); assert_eq!( cpu_model.materials[0].albedo, three_d_asset::Srgba::new_opaque(116, 180, 255) ); assert_eq!(cpu_model.geometries[0].material_index, Some(0)); } #[wasm_bindgen_test] fn textured_gltf_builds_physical_material_with_albedo_texture() { let model = textured_box(); let context = browser_context(); let (render_model, _camera, _control) = build_scene(&context, &model, 8, 8).expect("scene builds"); assert!( render_model .iter() .any(|part| part.material.albedo_texture.is_some()), "rendered model should bind a base-colour texture handle" ); } #[wasm_bindgen_test] fn points_only_model_reports_no_renderable_triangle_geometry() { let model = ModelMesh::new( three_d_asset::Model { name: "points only".to_string(), geometries: vec![three_d_asset::Primitive { name: "points".to_string(), transformation: three_d_asset::Mat4::identity(), animations: Vec::new(), geometry: three_d_asset::Geometry::Points(three_d_asset::PointCloud { positions: three_d_asset::Positions::F32(vec![ three_d_asset::vec3(0.0, 0.0, 0.0), three_d_asset::vec3(1.0, 0.0, 0.0), ]), colors: None, }), material_index: None, }], materials: Vec::new(), }, MeshFormat::Gltf, polymodel_mesh::contract::Units::declared(polymodel_mesh::contract::LengthUnit::Meter), ); let context = browser_context(); let Err(message) = build_scene(&context, &model, 8, 8) else { panic!("points are preserved but not rendered yet"); }; assert_eq!( message, RendererError::ParseAdaptation("model contains no renderable triangle geometry") ); } } fn create_preview_target(width: u32, height: u32) -> Result { let canvas = web_sys::OffscreenCanvas::new(width, height) .map_err(|error| RendererError::External(format!("preview canvas: {error:?}")))?; let options = js_sys::Object::new(); js_sys::Reflect::set( &options, &"preserveDrawingBuffer".into(), &JsValue::from_bool(true), ) .map_err(|error| RendererError::External(format!("preview context options: {error:?}")))?; let gl = canvas .get_context_with_context_options("webgl2", &options) .map_err(|error| RendererError::External(format!("preview context: {error:?}")))? .ok_or(RendererError::External( "preview WebGL2 context unavailable".to_owned(), ))? .dyn_into::() .map_err(|_| RendererError::External("preview context is not WebGL2".to_owned()))?; let glow_context = three_d::context::Context::from_webgl2_context(gl); let context = three_d::Context::from_gl_context(Arc::new(glow_context)) .map_err(|error| RendererError::GpuResource(format!("preview three-d context: {error}")))?; Ok(PreviewTarget { canvas, context }) } fn standard_lights( context: &three_d::Context, ) -> ( three_d::AmbientLight, three_d::DirectionalLight, three_d::DirectionalLight, ) { let policy = polymodel_renderer_worker::LdrawLightingMaterialPolicy::ldraw(); let ambient_light = three_d::AmbientLight::new( context, policy.ambient_energy, three_d::Srgba::new_opaque( policy.ambient_colour[0], policy.ambient_colour[1], policy.ambient_colour[2], ), ); let key_light = three_d::DirectionalLight::new( context, policy.key_energy, three_d::Srgba::new_opaque( policy.key_colour[0], policy.key_colour[1], policy.key_colour[2], ), three_d::vec3( policy.key_direction[0], policy.key_direction[1], policy.key_direction[2], ), ); let fill_light = three_d::DirectionalLight::new( context, policy.fill_energy, three_d::Srgba::new_opaque( policy.fill_colour[0], policy.fill_colour[1], policy.fill_colour[2], ), three_d::vec3( policy.fill_direction[0], policy.fill_direction[1], policy.fill_direction[2], ), ); (ambient_light, key_light, fill_light) } // --------------------------------------------------------------------------- // Frame loop // --------------------------------------------------------------------------- fn start_frame_loop(state: &Rc>) { let state_for_raf = state.clone(); let closure = Closure::wrap(Box::new(move || { let mut st = match state_for_raf.try_borrow_mut() { Ok(st) => st, Err(_) => return, }; st.raf_id = None; st.process_context_loss(); if st.disposed { return; } if st.needs_render && st.renderer.is_some() { match consume_input_and_render(&mut st) { Ok(()) => st.needs_render = false, Err(error) => { st.needs_render = false; st.post_event(&RendererEvent::Error { fatal: true, message: error.to_string(), }); st.dispose_resources(false); return; } } } schedule_next_frame(&mut st, state_for_raf.clone()); }) as Box); let mut st = state.borrow_mut(); st.raf_closure = Some(closure); schedule_next_frame(&mut st, state.clone()); } fn schedule_next_frame(st: &mut WorkerState, state: Rc>) { const MAX_RAF_RETRIES: u8 = 8; if st.raf_id.is_some() || st.disposed { return; } let scope = st.scope.clone(); let closure_ref = st.raf_closure.as_ref().expect("raf closure exists"); match scope.request_animation_frame(closure_ref.as_ref().unchecked_ref()) { Ok(id) => { st.raf_retry_count = 0; st.raf_id = Some(id); } Err(_) => { st.raf_id = None; if st.raf_retry_count >= MAX_RAF_RETRIES { st.post_event(&RendererEvent::Error { fatal: true, message: "requestAnimationFrame scheduling failed repeatedly".into(), }); st.dispose_resources(false); return; } st.raf_retry_count = st.raf_retry_count.saturating_add(1); let state_for_timeout = state.clone(); let timeout_closure = Closure::wrap(Box::new(move || { let st = state_for_timeout.borrow(); if st.disposed || st.raf_id.is_some() { return; } drop(st); let mut st = state_for_timeout.borrow_mut(); schedule_next_frame(&mut st, state_for_timeout.clone()); }) as Box); let scope = st.scope.clone(); let _ = scope.set_timeout_with_callback_and_timeout_and_arguments_0( timeout_closure.as_ref().unchecked_ref(), 16, ); timeout_closure.forget(); } } } fn consume_input_and_render(st: &mut WorkerState) -> Result<(), RendererError> { if st.renderer.is_none() { return Ok(()); } st.frame_events.clear(); // Read input into locals to avoid borrowing st.input across the assignment below. let (x, y, button_down, last_consumed, wheel_delta) = { let input = &st.input; ( input.x, input.y, input.button_down, input.last_consumed, input.wheel_delta, ) }; if button_down { if let Some((last_x, last_y)) = last_consumed { let dx = x - last_x; let dy = y - last_y; if dx != 0.0 || dy != 0.0 { st.frame_events.push(three_d::Event::MouseMotion { button: Some(three_d::MouseButton::Left), delta: (dx, dy), position: (x, y).into(), modifiers: Default::default(), handled: false, }); } } st.input.last_consumed = Some((x, y)); } if wheel_delta != 0.0 { st.frame_events.push(three_d::Event::MouseWheel { delta: (0.0, -wheel_delta), position: (x, y).into(), modifiers: Default::default(), handled: false, }); st.input.wheel_delta = 0.0; } if let Some(renderer) = st.renderer.as_mut() && !st.frame_events.is_empty() { renderer .control .handle_events(&mut renderer.camera, &mut st.frame_events); } let renderer = st.renderer.as_mut().expect("renderer checked above"); if !button_down && renderer.ldraw_style == polymodel_renderer_worker::RenderStyle::Edges && let Some(plan) = renderer.ldraw_plan.as_ref() { let direction = renderer.camera.position() - renderer.camera.target(); let length = f64::from(direction.magnitude()); if length > f64::EPSILON { let view = [ f64::from(direction.x) / length, f64::from(direction.y) / length, f64::from(direction.z) / length, ]; let changed = renderer.ldraw_last_view.is_none_or(|previous| { let dot = previous[0] * view[0] + previous[1] * view[1] + previous[2] * view[2]; dot < 0.9995 }); if changed { match polymodel_renderer_worker::build_conditional_edge_models_for_view( &renderer.context, renderer .ldraw_scene .as_deref() .expect("edge plan has scene"), plan, view, ) { Ok(conditional) => { renderer.ldraw_conditional_model = conditional; renderer.ldraw_last_view = Some(view); } Err(error) => { tracing::warn!(%error, "renderer worker: conditional scene update failed") } } } } } let viewport = st .cached_viewport .get_or_insert_with(|| three_d::Viewport::new_at_origo(st.pixel_width, st.pixel_height)); if viewport.width != st.pixel_width || viewport.height != st.pixel_height { *viewport = three_d::Viewport::new_at_origo(st.pixel_width, st.pixel_height); } renderer.camera.set_viewport(*viewport); if let Some(bounds) = renderer.scene_bounds { match polymodel_renderer_worker::camera_space_clip_planes( bounds, renderer.camera.position(), renderer.camera.target(), ) { Ok(projection) => { let fov = match renderer.camera.projection_type() { three_d_asset::ProjectionType::Perspective { field_of_view_y } => { Some(*field_of_view_y) } _ => None, }; if let Some(fov) = fov { renderer.camera.set_perspective_projection( fov, projection.near, projection.far, ); } else { tracing::error!("renderer worker: camera projection is not perspective"); } } Err(error) => { return Err(RendererError::External(format!( "renderer camera clipping rejected: {error}" ))); } } } let screen = three_d::RenderTarget::screen(&renderer.context, st.pixel_width, st.pixel_height); let screen = screen.clear(three_d::ClearState::color_and_depth( 0.78, 0.80, 0.84, 1.0, 1.0, )); let lights: [&dyn three_d::Light; 3] = [ &renderer.ambient_light, &renderer.key_light, &renderer.fill_light, ]; if let Some(model) = &renderer.model { let _ = screen.render(&renderer.camera, model.iter(), &lights); } if let Some(model) = &renderer.ldraw_static_model { let _ = screen.render(&renderer.camera, model, &lights); } if let Some(model) = renderer.ldraw_explicit_model.as_mut() { let _ = polymodel_renderer_worker::update_edge_model_culling_bounds(model, &renderer.camera); let _ = screen.render(&renderer.camera, model, &lights); } if let Some(model) = renderer.ldraw_conditional_model.as_mut() { let _ = polymodel_renderer_worker::update_edge_model_culling_bounds(model, &renderer.camera); let _ = screen.render(&renderer.camera, model, &lights); } Ok(()) } // --------------------------------------------------------------------------- // LoadMesh (async) // --------------------------------------------------------------------------- fn handle_load_mesh( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, ) { tracing::info!(?format, ?identity, %primary_url, "renderer worker: starting mesh load"); { let mut st = state.borrow_mut(); if st.disposed || namespace != RendererNamespace::Interactive { return; } let previous_identity = st.load_reducer.current_identity(); let result = st.load_reducer.reduce(LoadEvent::StartLoad { namespace, identity, }); for _token in result.disposed { st.pending_scene = None; st.renderer.as_mut().map(|renderer| renderer.model = None); } if !matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } st.load_epoch = identity.load_epoch; if let Some(renderer) = st.renderer.as_mut() { renderer.model = None; } st.pending_scene = None; st.needs_render = true; if let Some(previous_identity) = previous_identity && identity > previous_identity { st.post_event(&RendererEvent::LoadCancelled { namespace, identity: previous_identity, }); } } let abort = AbortController::new().ok(); if let Some(controller) = abort.clone() && let Some(previous) = state .borrow_mut() .interactive_compound_abort .replace(compound_abort_slot( identity, controller, CancellationPolicy::new(), )) { previous.controller.abort(); } let state_clone = state.clone(); spawn_local(async move { let result = load_mesh_async_with_verification( &primary_url, format, expected_size, expected_digest, abort.as_ref(), ) .await; let mut st = match state_clone.try_borrow_mut() { Ok(st) => st, Err(_) => return, }; if st.disposed { return; } match result { Ok(model) => { let stats = MeshStats { vertices: model.vertex_count(), triangles: model.triangle_count(), }; let parsed = st.load_reducer.reduce(LoadEvent::LoadSucceeded { namespace, identity, candidate: Some(ResourceToken(identity.scene_generation as u64)), }); if !matches!( parsed.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } st.post_event(&RendererEvent::LoadSucceeded { namespace, identity, }); let (pw, ph) = (st.pixel_width, st.pixel_height); let context = match st .renderer .as_ref() .map(|renderer| renderer.context.clone()) { Some(context) => context, None => { let error = LoadError { code: LoadErrorCode::Renderer, detail: "renderer context unavailable".into(), }; st.load_reducer.reduce(LoadEvent::InstallFailed { namespace, identity, error: error.clone(), candidate: None, }); st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: error.code, detail: error.detail, }); return; } }; st.scene_generation = match next_generation(st.scene_generation) { Ok(generation) => generation, Err(error) => { let detail = error.to_string(); let error = LoadError { code: LoadErrorCode::Install, detail: detail.clone(), }; st.load_reducer.reduce(LoadEvent::InstallFailed { namespace, identity, error, candidate: None, }); st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Install, detail: detail.to_owned(), }); return; } }; let candidate = match build_scene(&context, &model, pw, ph) { Ok(candidate) => candidate, Err(detail) => { let detail = detail.to_string(); let error = LoadError { code: LoadErrorCode::Install, detail: detail.clone(), }; st.load_reducer.reduce(LoadEvent::InstallFailed { namespace, identity, error, candidate: None, }); st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Install, detail, }); return; } }; st.pending_scene = Some(candidate); let committed = st.load_reducer.reduce(LoadEvent::InstallSucceeded { namespace, identity, scene: Some(ResourceToken(identity.scene_generation as u64 + 1)), }); if !matches!( committed.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.pending_scene = None; return; } if let Some((render_model, camera, control)) = st.pending_scene.take() { if let Some(renderer) = st.renderer.as_mut() { renderer.model = Some(render_model); renderer.ldraw_static_model = None; renderer.ldraw_explicit_model = None; renderer.ldraw_conditional_model = None; renderer.ldraw_scene = None; renderer.ldraw_plan = None; renderer.ldraw_style = polymodel_renderer_worker::RenderStyle::Faces; renderer.ldraw_last_view = None; renderer.scene_bounds = None; renderer.camera = camera; renderer.control = control; } } st.needs_render = true; st.post_event(&RendererEvent::InstallSucceeded { namespace, identity, stats, }); } Err(detail) => { let detail = detail.to_string(); let error = LoadError { code: LoadErrorCode::Fetch, detail: detail.clone(), }; let result = st.load_reducer.reduce(LoadEvent::LoadFailed { namespace, identity, error, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Fetch, detail, }); } } } }); } async fn load_mesh_async_with_verification( primary_url: &str, format: MeshFormat, expected_size: Option, expected_digest: Option>, abort: Option<&AbortController>, ) -> Result { tracing::info!( ?format, primary_url, "renderer worker: load_mesh_async begin" ); let model = load_mesh_with_companions(primary_url, format, |url| { let verify_primary = url == primary_url; let expected_digest = expected_digest.clone(); let abort = abort.cloned(); async move { tracing::debug!(%url, verify_primary, "renderer worker: fetching mesh resource"); let expected_bytes = if verify_primary { expected_size.and_then(|size| u64::try_from(size).ok()) } else { None }; let bytes = fetch_bytes(&url, abort.as_ref(), expected_bytes) .await .map_err(|error| { polymodel_mesh::contract::MeshLoadError::Decode(format!("fetch {url}: {error}")) })?; tracing::debug!( %url, verify_primary, bytes = bytes.len(), "renderer worker: fetched mesh resource" ); if verify_primary && (expected_size.is_some() || expected_digest.is_some()) { let size = expected_size.unwrap_or(bytes.len() as i64); tracing::debug!( %url, expected_size = size, bytes = bytes.len(), has_expected_digest = expected_digest.is_some(), "renderer worker: verifying primary part file" ); polymodel_renderer_protocol::verify_part_file( &bytes, size, expected_digest.as_deref(), ) .map_err(|e| { polymodel_mesh::contract::MeshLoadError::Decode(format!( "part file verification failed: {e}" )) })?; tracing::debug!(%url, "renderer worker: primary part file verified"); } Ok(bytes) } }) .await .map_err(|error| RendererError::External(format!("mesh load failed: {error}")))?; tracing::info!( ?format, parts = model.part_count(), vertices = model.vertex_count(), triangles = model.triangle_count(), "renderer worker: mesh parsed" ); Ok(model) } // --------------------------------------------------------------------------- fn start_compound_interactive( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, bundle: polymodel_renderer_protocol::ModelLoadBundle, ) { let mut st = state.borrow_mut(); if st.disposed || namespace != RendererNamespace::Interactive { return; } let previous_identity = st.load_reducer.current_identity(); let result = st.load_reducer.reduce(LoadEvent::StartLoad { namespace, identity, }); for _token in result.disposed { st.pending_scene = None; } if !matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } st.pending_scene = None; st.needs_render = true; if let Some(previous_identity) = previous_identity && identity > previous_identity { abort_compound_slot(&mut st, namespace, previous_identity); st.post_event(&RendererEvent::LoadCancelled { namespace, identity: previous_identity, }); } drop(st); handle_compound_load(state, namespace, identity, bundle); } fn handle_compound_load( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, bundle: polymodel_renderer_protocol::ModelLoadBundle, ) { let abort = AbortController::new().ok(); let cancellation = CancellationPolicy::new(); if let Some(abort) = &abort { if let Ok(mut st) = state.try_borrow_mut() { let slot = match namespace { RendererNamespace::Interactive => &mut st.interactive_compound_abort, RendererNamespace::Preview => &mut st.preview_abort, }; if let Some(previous) = slot.replace(compound_abort_slot( identity, abort.clone(), cancellation.clone(), )) { previous.controller.abort(); } } } let state_clone = state.clone(); spawn_local(async move { let result = load_compound_source(&state_clone, namespace, identity, bundle, abort).await; if let Err(detail) = &result { tracing::error!(?namespace, ?identity, %detail, "renderer worker: compound bundle load failed"); } finish_compound_source(&state_clone, namespace, identity, result, cancellation); }); } fn handle_compound_preview( state: &Rc>, request_id: u32, namespace: RendererNamespace, identity: LoadIdentity, bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, ) { if namespace != RendererNamespace::Preview { state .borrow_mut() .post_event(&RendererEvent::PreviewImageError { request_id, message: "compound preview requires the Preview namespace".to_owned(), }); return; } let abort = AbortController::new().ok(); let cancellation = CancellationPolicy::new(); { let mut st = state.borrow_mut(); let previous = st.preview_reducer.current_identity(); let transition = st.preview_reducer.reduce(LoadEvent::StartLoad { namespace, identity, }); if !matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::PreviewImageError { request_id, message: "compound preview identity was rejected".to_owned(), }); return; } if let Some(previous) = previous { if let Some(slot) = st.preview_abort.take() { slot.controller.abort(); } st.post_event(&RendererEvent::LoadCancelled { namespace, identity: previous, }); } if let Some(controller) = abort.as_ref() && let Some(previous) = st.preview_abort.replace(compound_abort_slot( identity, controller.clone(), cancellation.clone(), )) { previous.controller.abort(); } } let state_clone = state.clone(); spawn_local(async move { let result = render_compound_preview_request( &state_clone, identity, bundle, width.max(1), height.max(1), abort.clone(), cancellation, ) .await; finish_compound_preview(&state_clone, request_id, identity, result); }); } #[derive(Debug)] enum CompoundLoadError { Cancelled, Superseded, Disposed, Parse(polymodel_ldraw_core::ParseError), Adapt(RendererError), Bundle(&'static str), } impl From for CompoundLoadError { fn from(error: RendererError) -> Self { Self::Adapt(error) } } impl std::fmt::Display for CompoundLoadError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Cancelled => formatter.write_str("compound load cancelled"), Self::Superseded => formatter.write_str("compound load superseded"), Self::Disposed => formatter.write_str("renderer worker is disposed"), Self::Parse(error) => write!(formatter, "compound parse failed: {error}"), Self::Adapt(error) => write!(formatter, "compound adaptation failed: {error}"), Self::Bundle(detail) => write!(formatter, "invalid model bundle: {detail}"), } } } fn finish_compound_preview( state: &Rc>, request_id: u32, identity: LoadIdentity, result: Result, CompoundLoadError>, ) { let Ok(mut st) = state.try_borrow_mut() else { return; }; if st.disposed || st.preview_reducer.current_identity() != Some(identity) || st .preview_abort .as_ref() .is_none_or(|slot| slot.identity != identity) { return; } st.preview_abort.take(); if matches!( result, Err(CompoundLoadError::Cancelled | CompoundLoadError::Superseded | CompoundLoadError::Disposed) ) { let transition = st.preview_reducer.reduce(LoadEvent::Cancel { namespace: RendererNamespace::Preview, identity, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadCancelled { namespace: RendererNamespace::Preview, identity, }); } return; } match result { Ok(png) => st.post_event(&RendererEvent::PreviewImageRendered { request_id, png }), Err(message) => { let _ = st.preview_reducer.reduce(LoadEvent::LoadFailed { namespace: RendererNamespace::Preview, identity, error: LoadError { code: LoadErrorCode::Install, detail: message.to_string(), }, candidate: None, }); st.post_event(&RendererEvent::PreviewImageError { request_id, message: message.to_string(), }); } } } async fn render_compound_preview_request( state: &Rc>, identity: LoadIdentity, bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, abort: Option, cancellation: CancellationPolicy, ) -> Result, CompoundLoadError> { let source = load_compound_source( state, RendererNamespace::Preview, identity, bundle, abort.clone(), ) .await?; compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; let (_stats, scene, source_resources) = parse_compound_source(source, cancellation)?; compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; let bounds = scene .bounds .ok_or(CompoundLoadError::Bundle("LDraw scene has no bounds"))?; let target = create_preview_target(width, height)?; let scene = Arc::new(scene); let style = polymodel_renderer_worker::RenderStyle::Faces; let (static_model, (_explicit, _conditional), _picker, _plan) = polymodel_renderer_worker::build_scene_model_parts_for_style_with_resources( &target.context, &scene, [0.0, 0.0, 1.0], style, Some(&source_resources), )?; compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; let fit = polymodel_renderer_worker::scene_camera_fit(bounds, width, height) .map_err(|error| CompoundLoadError::Adapt(RendererError::External(error.to_string())))?; let projection = polymodel_renderer_worker::camera_space_clip_planes(bounds, fit.position, fit.center) .map_err(|error| { CompoundLoadError::Adapt(RendererError::External(error.to_string())) })?; let camera = three_d::Camera::new_perspective( three_d::Viewport::new_at_origo(width, height), fit.position, fit.center, three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), projection.near, projection.far, ); let (ambient, key, fill) = standard_lights(&target.context); let render_target = three_d::RenderTarget::screen(&target.context, width, height); let screen = render_target.clear(three_d::ClearState::color_and_depth( 0.78, 0.80, 0.84, 1.0, 1.0, )); let lights: [&dyn three_d::Light; 3] = [&ambient, &key, &fill]; screen.render(&camera, static_model.iter(), &lights); compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; let png = offscreen_canvas_png_bytes(&target.canvas).await?; Ok(png) } fn abort_compound_slot( state: &mut WorkerState, namespace: RendererNamespace, identity: LoadIdentity, ) { let slot = match namespace { RendererNamespace::Interactive => &mut state.interactive_compound_abort, RendererNamespace::Preview => &mut state.preview_abort, }; if slot.as_ref().is_some_and(|slot| slot.identity == identity) && let Some(slot) = slot.take() { slot.controller.abort(); } } fn compound_is_current( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, abort: Option<&AbortController>, ) -> Result<(), CompoundLoadError> { if abort.is_some_and(|controller| controller.signal().aborted()) { return Err(CompoundLoadError::Cancelled); } let st = state.borrow(); let reducer = match namespace { RendererNamespace::Interactive => &st.load_reducer, RendererNamespace::Preview => &st.preview_reducer, }; if st.disposed { return Err(CompoundLoadError::Disposed); } if reducer.current_identity() != Some(identity) { return Err(CompoundLoadError::Superseded); } Ok(()) } async fn load_compound_source( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, bundle: polymodel_renderer_protocol::ModelLoadBundle, abort: Option, ) -> Result { tracing::info!( ?namespace, ?identity, primary = %bundle.primary, resources = bundle.resources.len(), bytes = bundle.resources.iter().map(|resource| resource.bytes.len()).sum::(), "renderer worker: compound bundle received" ); if bundle.version != polymodel_renderer_protocol::PROTOCOL_VERSION { return Err(CompoundLoadError::Bundle( "model bundle protocol version mismatch", )); } if bundle.format != MeshFormat::CompoundLdraw { return Err(CompoundLoadError::Bundle( "model bundle format is not LDraw", )); } bundle .validate() .map_err(|_| CompoundLoadError::Bundle("model bundle rejected"))?; validate_bundle_resources(&bundle.primary, &bundle.resources)?; tracing::debug!( ?namespace, ?identity, primary = %bundle.primary, resources = bundle.resources.len(), "renderer worker: compound bundle validated" ); compound_is_current(state, namespace, identity, abort.as_ref())?; let total_bytes = bundle.resources.iter().try_fold(0_u64, |total, resource| { total .checked_add(resource.byte_length) .ok_or(CompoundLoadError::Bundle( "model bundle byte bound overflow", )) })?; if total_bytes > MAX_UNKNOWN_FETCH_BYTES { return Err(CompoundLoadError::Bundle( "model bundle exceeds decoded source bound", )); } compound_is_current(state, namespace, identity, abort.as_ref())?; let source = VerifiedCompoundSource { primary: bundle.primary, resources: bundle.resources.into_vec(), source_identities: bundle.source_identities, target_selections: bundle.target_selections, }; tracing::debug!( ?namespace, ?identity, primary = %source.primary, resources = source.resources.len(), "renderer worker: compound bundle accepted" ); Ok(source) } fn fail_compound_install( state: &mut WorkerState, namespace: RendererNamespace, identity: LoadIdentity, detail: String, ) { let error = LoadError { code: LoadErrorCode::Install, detail: detail.clone(), }; let reducer = match namespace { RendererNamespace::Interactive => &mut state.load_reducer, RendererNamespace::Preview => &mut state.preview_reducer, }; let transition = reducer.reduce(LoadEvent::InstallFailed { namespace, identity, error, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { state.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Install, detail, }); } } fn finish_compound_source( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, result: Result, cancellation: CancellationPolicy, ) { { let mut st = state.borrow_mut(); match result { Ok(source) => { let reducer = match namespace { RendererNamespace::Interactive => &st.load_reducer, RendererNamespace::Preview => &st.preview_reducer, }; if st.disposed || reducer.current_identity() != Some(identity) { return; } tracing::debug!( ?namespace, ?identity, "renderer worker: parsing compound bundle" ); drop(st); let parsed = parse_compound_source(source, cancellation); let mut st = state.borrow_mut(); let slot = match namespace { RendererNamespace::Interactive => &mut st.interactive_compound_abort, RendererNamespace::Preview => &mut st.preview_abort, }; if slot.as_ref().is_some_and(|slot| slot.identity == identity) { slot.take(); } let (stats, scene, source_resources) = match parsed { Ok((stats, scene, source_resources)) => { tracing::info!( ?namespace, ?identity, vertices = stats.vertices, triangles = stats.triangles, has_bounds = scene.bounds.is_some(), "renderer worker: compound parse succeeded" ); (stats, scene, source_resources) } Err( CompoundLoadError::Cancelled | CompoundLoadError::Superseded | CompoundLoadError::Disposed, ) => { let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, }; let transition = reducer.reduce(LoadEvent::Cancel { namespace, identity, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadCancelled { namespace, identity, }); } return; } Err( detail @ (CompoundLoadError::Parse(_) | CompoundLoadError::Adapt(_) | CompoundLoadError::Bundle(_)), ) => { let detail = detail.to_string(); tracing::error!( ?namespace, ?identity, error = %detail, "renderer worker: compound parse failed" ); let error = LoadError { code: LoadErrorCode::Parse, detail: detail.clone(), }; let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, }; let transition = reducer.reduce(LoadEvent::LoadFailed { namespace, identity, error, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Parse, detail, }); } return; } }; let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, }; let loaded = reducer.reduce(LoadEvent::LoadSucceeded { namespace, identity, candidate: None, }); if !matches!( loaded.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } st.post_event(&RendererEvent::LoadSucceeded { namespace, identity, }); let bounds = match scene.bounds { Some(bounds) => bounds, None => { fail_compound_install( &mut st, namespace, identity, "LDraw scene has no bounds".to_owned(), ); return; } }; let (candidate_models, fit) = if namespace == RendererNamespace::Interactive { let pixel_width = st.pixel_width; let pixel_height = st.pixel_height; let context = match st.renderer.as_ref() { Some(renderer) => renderer.context.clone(), None => { fail_compound_install( &mut st, namespace, identity, "renderer context is not attached".to_owned(), ); return; } }; let scene_ref = Arc::new(scene); let style = polymodel_renderer_worker::RenderStyle::Faces; let models = match polymodel_renderer_worker::build_scene_model_parts_for_style_with_resources( &context, &scene_ref, [0.0, 0.0, 1.0], style, Some(&source_resources), ) { Ok((static_model, edge_model, _picker, plan)) => { (static_model, edge_model, plan, style) } Err(detail) => { fail_compound_install( &mut st, namespace, identity, detail.to_string(), ); return; } }; let fit = match polymodel_renderer_worker::scene_camera_fit( bounds, pixel_width, pixel_height, ) { Ok(fit) => fit, Err(error) => { fail_compound_install(&mut st, namespace, identity, error.to_string()); return; } }; (Some((models, scene_ref)), Some(fit)) } else { (None, None) }; if let Some(( static_model, (explicit_model, conditional_model), plan, style, scene, )) = candidate_models.map(|((static_model, edge_models, plan, style), scene)| { (static_model, edge_models, plan, style, scene) }) { if st.load_reducer.current_identity() != Some(identity) || st.disposed { return; } let fit = match fit { Some(fit) => fit, None => return, }; let viewport = three_d::Viewport::new_at_origo(st.pixel_width, st.pixel_height); let projection = match polymodel_renderer_worker::camera_space_clip_planes( bounds, fit.position, fit.center, ) { Ok(projection) => projection, Err(error) => { fail_compound_install(&mut st, namespace, identity, error.to_string()); return; } }; let renderer = st.renderer.as_mut().expect("context checked above"); renderer.camera = three_d::Camera::new_perspective( viewport, fit.position, fit.center, three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), projection.near, projection.far, ); renderer.control = three_d::OrbitControl::new(fit.center, fit.control_min, fit.control_max); renderer.scene_bounds = Some(bounds); renderer.model = None; renderer.ldraw_static_model = Some(static_model); renderer.ldraw_explicit_model = explicit_model; renderer.ldraw_conditional_model = conditional_model; renderer.ldraw_plan = Some(plan); renderer.ldraw_style = style; renderer.ldraw_last_view = Some([0.0, 0.0, 1.0]); renderer.ldraw_scene = Some(scene); let installed = st.load_reducer.reduce(LoadEvent::InstallSucceeded { namespace, identity, scene: None, }); if !matches!( installed.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } st.needs_render = true; st.post_event(&RendererEvent::InstallSucceeded { namespace, identity, stats, }); } } Err(detail) => { let detail = detail.to_string(); let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, }; let transition = reducer.reduce(LoadEvent::LoadFailed { namespace, identity, error: LoadError { code: LoadErrorCode::Fetch, detail: detail.clone(), }, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadFailed { namespace, identity, code: LoadErrorCode::Fetch, detail, }); } } } } } fn handle_render_preview_image( state: &Rc>, request_id: u32, identity: LoadIdentity, format: MeshFormat, primary_url: String, width: u32, height: u32, ) { let abort = { let mut st = state.borrow_mut(); let previous = st.preview_reducer.current_identity(); let transition = st.preview_reducer.reduce(LoadEvent::StartLoad { namespace: RendererNamespace::Preview, identity, }); if !matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } let abort = AbortController::new().ok(); if let Some(controller) = abort.clone() { if let Some(previous_slot) = st.preview_abort.replace(compound_abort_slot( identity, controller, CancellationPolicy::new(), )) { previous_slot.controller.abort(); } } else if let Some(previous_slot) = st.preview_abort.take() { previous_slot.controller.abort(); } if let Some(previous) = previous { st.post_event(&RendererEvent::LoadCancelled { namespace: RendererNamespace::Preview, identity: previous, }); } abort }; let state_clone = state.clone(); spawn_local(async move { let result = render_preview_request( format, &primary_url, width.max(1), height.max(1), abort.clone(), ) .await; let Ok(mut st) = state_clone.try_borrow_mut() else { return; }; if st.preview_reducer.current_identity() != Some(identity) || st .preview_abort .as_ref() .is_none_or(|slot| slot.identity != identity) { return; } st.preview_abort.take(); match result { Ok(png) => st.post_event(&RendererEvent::PreviewImageRendered { request_id, png }), Err(RendererError::Cancelled) => { let transition = st.preview_reducer.reduce(LoadEvent::Cancel { namespace: RendererNamespace::Preview, identity, candidate: None, }); if matches!( transition.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadCancelled { namespace: RendererNamespace::Preview, identity, }); } } Err(error) => st.post_event(&RendererEvent::PreviewImageError { request_id, message: error.to_string(), }), } }); } async fn render_preview_request( format: MeshFormat, primary_url: &str, width: u32, height: u32, abort: Option, ) -> Result, RendererError> { let fetch_bytes = MAX_UNKNOWN_FETCH_BYTES.min( u64::from(width) .saturating_mul(u64::from(height)) .saturating_mul(16) .max(8 * 1024 * 1024), ); if abort .as_ref() .is_some_and(|controller| controller.signal().aborted()) { return Err(RendererError::Cancelled); } let model = load_mesh_async_with_abort(primary_url, format, fetch_bytes, abort.as_ref()).await?; if abort .as_ref() .is_some_and(|controller| controller.signal().aborted()) { return Err(RendererError::Cancelled); } render_preview_png(&model, width, height).await } async fn load_mesh_async_with_abort( primary_url: &str, format: MeshFormat, fetch_limit: u64, abort: Option<&AbortController>, ) -> Result { let remaining = Rc::new(Cell::new(fetch_limit)); let model = load_mesh_with_companions(primary_url, format, |url| { let route = url.clone(); let abort = abort.cloned(); let remaining = remaining.clone(); async move { let limit = remaining.get(); if limit == 0 { return Err(polymodel_mesh::contract::MeshLoadError::Decode( "preview fetch budget exhausted".into(), )); } let bytes = fetch_bytes(&route, abort.as_ref(), Some(limit)) .await .map_err(|e| { polymodel_mesh::contract::MeshLoadError::Decode(format!("fetch {route}: {e}")) })?; let fetched = u64::try_from(bytes.len()).map_err(|_| { polymodel_mesh::contract::MeshLoadError::Decode( "preview fetched byte count overflowed".into(), ) })?; remaining.set(limit.saturating_sub(fetched)); Ok(bytes) } }) .await .map_err(|error| RendererError::External(format!("mesh load failed: {error}")))?; Ok(model) } async fn render_preview_png( model: &ModelMesh, width: u32, height: u32, ) -> Result, RendererError> { let target = create_preview_target(width, height)?; let (model, camera, _control) = build_scene(&target.context, model, width, height)?; let (ambient_light, key_light, fill_light) = standard_lights(&target.context); let screen = three_d::RenderTarget::screen(&target.context, width, height); let _ = screen .clear(three_d::ClearState::color_and_depth( 0.78, 0.80, 0.84, 1.0, 1.0, )) .render( &camera, model.iter(), &[&ambient_light, &key_light, &fill_light], ); offscreen_canvas_png_bytes(&target.canvas).await } async fn offscreen_canvas_png_bytes( canvas: &web_sys::OffscreenCanvas, ) -> Result, RendererError> { let options = js_sys::Object::new(); js_sys::Reflect::set(&options, &"type".into(), &JsValue::from_str("image/png")) .map_err(|error| RendererError::External(format!("preview blob options: {error:?}")))?; let convert = js_sys::Reflect::get(canvas, &"convertToBlob".into()) .map_err(|error| { RendererError::External(format!("preview convertToBlob lookup: {error:?}")) })? .dyn_into::() .map_err(|_| RendererError::External("preview convertToBlob is unavailable".to_owned()))?; let promise = convert .call1(canvas, &options) .map_err(|error| RendererError::External(format!("preview convertToBlob: {error:?}")))? .dyn_into::() .map_err(|_| { RendererError::External("preview convertToBlob did not return a Promise".to_owned()) })?; let blob = JsFuture::from(promise) .await .map_err(|error| RendererError::External(format!("preview blob await: {error:?}")))? .dyn_into::() .map_err(|_| RendererError::External("preview result is not a Blob".to_owned()))?; let buffer = JsFuture::from(blob.array_buffer()) .await .map_err(|error| RendererError::External(format!("preview blob arrayBuffer: {error:?}")))?; Ok(js_sys::Uint8Array::new(&buffer).to_vec()) } async fn fetch_bytes( url: &str, abort: Option<&AbortController>, expected_bytes: Option, ) -> Result, RendererError> { let limit = expected_bytes.unwrap_or(MAX_UNKNOWN_FETCH_BYTES); let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); if let Some(abort) = abort { opts.set_signal(Some(&abort.signal())); } let request = web_sys::Request::new_with_str_and_init(url, &opts) .map_err(|error| RendererError::External(format!("request: {error:?}")))?; let global = js_sys::global(); let fetch_fn = js_sys::Reflect::get(&global, &"fetch".into()) .map_err(|error| RendererError::External(format!("no fetch: {error:?}")))?; let fetch_promise = js_sys::Function::from(fetch_fn) .call1(&global, &request) .map_err(|error| RendererError::External(format!("fetch call: {error:?}")))? .dyn_into::() .map_err(|_| RendererError::External("fetch: not a Promise".to_owned()))?; let response = JsFuture::from(fetch_promise) .await .map_err(|error| RendererError::External(format!("fetch: {error:?}")))? .dyn_into::() .map_err(|_| RendererError::External("fetch: not a Response".to_owned()))?; if !response.ok() { let status = response.status(); let status_text = response.status_text().chars().take(96).collect::(); let bounded_body = bounded_error_body(&response).await; let detail = if bounded_body.is_empty() { format!("fetch returned HTTP {status} {status_text}") } else { format!("fetch returned HTTP {status} {status_text}: {bounded_body}") }; return Err(RendererError::External(detail)); } let content_length = response .headers() .get("content-length") .ok() .flatten() .and_then(|value| value.parse::().ok()); if let Some(content_length) = content_length && content_length > limit { return Err(RendererError::External(format!( "response exceeds fetch bound: {content_length} bytes > {limit}" ))); } let body = js_sys::Reflect::get(&response, &"body".into()) .map_err(|error| RendererError::External(format!("response body: {error:?}")))?; if body.is_null() || body.is_undefined() { return Err(RendererError::External( "response has no readable body".to_owned(), )); } let reader = js_sys::Reflect::get(&body, &"getReader".into()) .map_err(|error| RendererError::External(format!("response reader: {error:?}")))? .dyn_into::() .map_err(|_| RendererError::External("response body has no reader".to_owned()))? .call0(&body) .map_err(|error| RendererError::External(format!("response reader: {error:?}")))?; let read = js_sys::Reflect::get(&reader, &"read".into()) .map_err(|error| RendererError::External(format!("response reader read: {error:?}")))? .dyn_into::() .map_err(|_| RendererError::External("response reader has no read method".to_owned()))?; let mut bytes = Vec::with_capacity( usize::try_from(content_length.unwrap_or(0).min(limit)).unwrap_or_default(), ); loop { let result = read .call0(&reader) .map_err(|error| RendererError::External(format!("response reader read: {error:?}")))? .dyn_into::() .map_err(|_| { RendererError::External("response reader read did not return a Promise".to_owned()) })?; let result = JsFuture::from(result) .await .map_err(|error| RendererError::External(format!("response body: {error:?}")))?; if js_sys::Reflect::get(&result, &"done".into()) .ok() .and_then(|done| done.as_bool()) .unwrap_or(false) { break; } let value = js_sys::Reflect::get(&result, &"value".into()) .map_err(|error| RendererError::External(format!("response body chunk: {error:?}")))?; let chunk = js_sys::Uint8Array::new(&value); let next_len = bytes .len() .checked_add(chunk.length() as usize) .ok_or(RendererError::External( "response exceeds fetch bound".to_owned(), ))?; if next_len > usize::try_from(limit).unwrap_or(usize::MAX) { return Err(RendererError::External(format!( "response exceeds fetch bound: more than {limit} bytes" ))); } bytes.extend_from_slice(&chunk.to_vec()); } Ok(bytes) } async fn bounded_error_body(response: &web_sys::Response) -> String { const MAX_ERROR_BODY_BYTES: u64 = 2048; let body = js_sys::Reflect::get(response, &"body".into()).ok(); let Some(body) = body.filter(|body| !body.is_null() && !body.is_undefined()) else { return String::new(); }; let Ok(reader) = js_sys::Reflect::get(&body, &"getReader".into()) .and_then(|function| function.dyn_into::()) .and_then(|function| function.call0(&body)) else { return String::new(); }; let Ok(read) = js_sys::Reflect::get(&reader, &"read".into()) .and_then(|function| function.dyn_into::()) else { return String::new(); }; let mut bytes = Vec::new(); while bytes.len() < MAX_ERROR_BODY_BYTES as usize { let Ok(promise) = read .call0(&reader) .and_then(|value| value.dyn_into::()) else { break; }; let Ok(result) = JsFuture::from(promise).await else { break; }; if js_sys::Reflect::get(&result, &"done".into()) .ok() .and_then(|done| done.as_bool()) .unwrap_or(false) { break; } let Ok(value) = js_sys::Reflect::get(&result, &"value".into()) else { break; }; let chunk = js_sys::Uint8Array::new(&value); let remaining = MAX_ERROR_BODY_BYTES as usize - bytes.len(); let chunk = chunk.to_vec(); bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } String::from_utf8_lossy(&bytes).chars().take(512).collect() } // --------------------------------------------------------------------------- // Command dispatch // --------------------------------------------------------------------------- fn log_command_summary(command: &RendererCommand) { match command { RendererCommand::StartLoad { namespace, identity, source, output, } => match source { polymodel_renderer_protocol::LoadSource::Bundle(bundle) => { let keys = bundle .resources .iter() .take(12) .map(|resource| resource.key.as_str()) .collect::>(); tracing::info!( ?namespace, ?identity, ?output, primary = %bundle.primary, resources = bundle.resources.len(), bytes = bundle.resources.iter().map(|resource| resource.bytes.len()).sum::(), resource_keys = ?keys, keys_truncated = bundle.resources.len() > 12, "renderer worker: compound StartLoad received" ); } polymodel_renderer_protocol::LoadSource::Url { format, primary_url, .. } => tracing::debug!( ?namespace, ?identity, ?output, ?format, primary_url = %primary_url, "renderer worker: URL StartLoad received" ), }, RendererCommand::Resize { css_w, css_h, dpr } => { tracing::debug!(css_w, css_h, dpr, "renderer worker: Resize received") } RendererCommand::Cancel { namespace, identity, } => { tracing::debug!(?namespace, ?identity, "renderer worker: Cancel received") } RendererCommand::SetPointerState { .. } | RendererCommand::PointerDown { .. } | RendererCommand::PointerUp { .. } => {} RendererCommand::Dispose => tracing::debug!("renderer worker: Dispose received"), RendererCommand::DebugCrash => tracing::warn!("renderer worker: DebugCrash received"), } } fn handle_command(state: &Rc>, cmd: RendererCommand) { let mut st = match state.try_borrow_mut() { Ok(st) => st, Err(_) => return, }; if st.disposed { return; } let Some(capabilities) = st .handshake .negotiated() .map(|negotiated| negotiated.capabilities.clone()) else { tracing::warn!("rejecting scene command before negotiated session"); return; }; let capability_allowed = polymodel_renderer_protocol::required_capabilities(&cmd) .iter() .all(|capability| capabilities.contains(capability)); if !capability_allowed { tracing::warn!("renderer command rejected by negotiated capabilities"); return; } match cmd { RendererCommand::StartLoad { namespace, identity, source, output, } => { drop(st); match (source, output) { ( polymodel_renderer_protocol::LoadSource::Url { format, primary_url, expected_size, expected_digest, }, polymodel_renderer_protocol::LoadOutput::Interactive, ) => handle_load_mesh( state, namespace, identity, format, primary_url, expected_size, expected_digest, ), ( polymodel_renderer_protocol::LoadSource::Url { format, primary_url, .. }, polymodel_renderer_protocol::LoadOutput::Preview { request_id, width, height, }, ) => handle_render_preview_image( state, request_id, identity, format, primary_url, width, height, ), ( polymodel_renderer_protocol::LoadSource::Bundle(bundle), polymodel_renderer_protocol::LoadOutput::Interactive, ) => start_compound_interactive(state, namespace, identity, bundle), ( polymodel_renderer_protocol::LoadSource::Bundle(bundle), polymodel_renderer_protocol::LoadOutput::Preview { request_id, width, height, }, ) => handle_compound_preview( state, request_id, namespace, identity, bundle, width, height, ), } } RendererCommand::Cancel { namespace, identity, } => { if namespace == RendererNamespace::Preview && st .preview_abort .as_ref() .is_some_and(|slot| slot.identity == identity) && let Some(slot) = st.preview_abort.take() { slot.controller.abort(); } abort_compound_slot(&mut st, namespace, identity); let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, }; let result = reducer.reduce(LoadEvent::Cancel { namespace, identity, candidate: None, }); if namespace == RendererNamespace::Interactive { st.pending_scene = None; } if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { st.post_event(&RendererEvent::LoadCancelled { namespace, identity, }); } } RendererCommand::Resize { css_w, css_h, dpr } => { if let Some(slot) = st.preview_abort.take() { slot.controller.abort(); if let Some(identity) = st.preview_reducer.current_identity() { let _ = st.preview_reducer.reduce(LoadEvent::Cancel { namespace: RendererNamespace::Preview, identity, candidate: None, }); st.post_event(&RendererEvent::LoadCancelled { namespace: RendererNamespace::Preview, identity, }); } } let _ = st.load_reducer.reduce(LoadEvent::Resize { namespace: RendererNamespace::Interactive, }); if !css_w.is_finite() || !css_h.is_finite() || !dpr.is_finite() || css_w <= 0.0 || css_h <= 0.0 || dpr <= 0.0 { return; } let w = (css_w * dpr).round().max(1.0) as u32; let h = (css_h * dpr).round().max(1.0) as u32; if let Some(canvas) = &st.canvas { canvas.set_width(w); canvas.set_height(h); } st.pixel_width = w; st.pixel_height = h; st.needs_render = true; } RendererCommand::SetPointerState { x, y, buttons, modifiers, wheel_delta, } => { st.input.x = x; st.input.y = y; st.input.buttons = buttons; st.input.modifiers = modifiers; st.input.wheel_delta += wheel_delta; st.needs_render = true; } RendererCommand::PointerDown { x, y } => { st.input.button_down = true; st.input.last_consumed = Some((x, y)); st.needs_render = true; } RendererCommand::PointerUp { x: _, y: _ } => { st.input.button_down = false; st.input.last_consumed = None; st.needs_render = true; } RendererCommand::Dispose => st.dispose_resources(true), RendererCommand::DebugCrash => { panic!("DebugCrash: deliberate worker crash for e2e testing"); } } } // --------------------------------------------------------------------------- // Canvas transfer handling // --------------------------------------------------------------------------- fn handle_canvas_transfer(state: &Rc>, value: &JsValue) { let canvas_val = js_sys::Reflect::get(value, &"__canvas".into()) .ok() .filter(|v| !v.is_undefined()); if let Some(canvas_val) = canvas_val { if let Ok(canvas) = canvas_val.dyn_into::() { let mut st = state.borrow_mut(); if st.renderer.is_none() { st.init_renderer(canvas, state.clone()); } } } } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- pub fn entry() { console_error_panic_hook::set_once(); let mut wasm_log_config = tracing_wasm::WASMLayerConfigBuilder::new(); wasm_log_config.set_max_level(tracing::Level::DEBUG); tracing_wasm::set_as_global_default_with_config(wasm_log_config.build()); tracing::info!("renderer worker: tracing initialized"); let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into(); let state = Rc::new(RefCell::new(WorkerState::new(scope.clone()))); start_frame_loop(&state); let state_for_msg = state.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); // Canvas sentinel: transferred JS object, not postcard. if data.is_object() && !data.is_undefined() { let is_canvas = js_sys::Reflect::get(&data, &"__canvas_sentinel".into()) .map(|v| v.is_truthy()) .unwrap_or(false); if is_canvas { handle_canvas_transfer(&state_for_msg, &data); return; } } // String messages are only the loader readiness sentinel. if let Some(s) = data.as_string() { if s == "__worker_ready" { return; } } // Every binary message is a versioned, session-gated command envelope. if let Ok(array) = data.dyn_into::() { let bytes = array.to_vec(); let envelope = match deserialize_command_envelope(&bytes) { Ok(envelope) => envelope, Err(e) => { tracing::error!("failed to deserialize command envelope: {e}"); return; } }; let mut st = match state_for_msg.try_borrow_mut() { Ok(st) => st, Err(_) => return, }; if matches!(envelope.payload, CommandPayload::Hello(_)) { let CommandPayload::Hello(remote) = envelope.payload else { return; }; let local = st.local_hello.clone(); let session_id = js_sys::Math::random().mul_add(u64::MAX as f64, 1.0) as u64; match polymodel_renderer_protocol::negotiate(&local, &remote, session_id.max(1)) { Ok(negotiated) => { st.handshake = HandshakeState::Negotiating; st.post_envelope(&EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: EventPayload::Hello(local), }); st.post_negotiated(&negotiated); // The main thread must echo this negotiated value as Accept. } Err(error) => { st.handshake = HandshakeState::Failed; st.post_envelope(&EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: EventPayload::Failed(format!("handshake failed: {error:?}")), }); } } return; } match st.handshake.accept_command(&envelope) { Ok(Some(cmd)) => { drop(st); log_command_summary(&cmd); handle_command(&state_for_msg, cmd); } Ok(None) => { if matches!(st.handshake, HandshakeState::Negotiated(_)) { st.flush_pending_events(); } } Err(error) => { tracing::warn!("rejected command before negotiated session: {error:?}") } } } }) as Box); scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget(); let _ = scope.post_message(&wasm_bindgen::JsValue::from_str("__worker_ready")); }