//! Asset-backed mesh viewer. //! //! The viewer renders committed mesh assets ([STL], OBJ, glTF/GLB) with [`three-d`] on a //! Dioxus-owned canvas. The design (see the "STL viewer renderer decision" Confluence //! note) rests on three production decisions: //! //! * **One WebGL2 session per page mount.** The session is retained across //! model switches and only the GPU mesh resources are replaced in place //! ([`ViewerRenderer::replace_mesh`]). The [`BrowserRendererSession`] //! component is identified by position (no per-model key), stays mounted //! through the async loading window, and drives a [`RendererLifecycle`] //! reconcile on signal changes — so the WebGL context is acquired exactly //! once per page mount. //! * **One parsed mesh per load.** Asset bytes are fetched on demand and parsed a //! single time into a [`ParsedMesh`], which then flows to the renderer. The //! renderer never parses itself. //! * **On-demand assets.** Demo models live under `public/models/` and are fetched //! at runtime instead of being embedded in the WASM bundle. //! //! [STL]: crate::mesh::stl // The renderer session machinery (the three-d adapter, the reuse lifecycle, and // the typed error) is production-live only in the web target, where the wasm // `BrowserRendererSession` drives it. Off-web it exists to compile and to back // the unit tests, so allow dead code there rather than cfg-gating the whole // abstraction out of the non-wasm build. #![allow(dead_code)] use crate::mesh::companions::{discover_companion_uris, is_safe_relative_uri}; use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh}; use crate::mesh::parser::{MeshParser, MeshResources, MeshSource, ProductionMeshParser}; use dioxus::prelude::*; use std::hash::{Hash, Hasher}; use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DemoModel { pub id: &'static str, pub name: &'static str, pub asset_path: &'static str, pub generation: u64, pub vertices: usize, pub triangles: usize, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ViewerFallbackImage { pub url: String, pub alt: String, } const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-chest-v4", name: "Body F chest v4", asset_path: "/models/body_f_chest-v4.stl", generation: 1, vertices: 41051, triangles: 82122, }, DemoModel { id: "body-f-forearm-2x-v9", name: "Body F forearm 2x v9", asset_path: "/models/body_f_forearm_2x-v9.stl", generation: 1, vertices: 18271, triangles: 36542, }, ]; pub fn demo_models() -> &'static [DemoModel] { &DEMO_MODELS } pub fn demo_model_for_route(model_id: &str) -> DemoModel { demo_models() .iter() .copied() .find(|model| model.id == model_id) .unwrap_or(DEMO_MODELS[0]) } /// A mesh parsed once from its source bytes, carrying an identity hash so it can /// be compared cheaply for equality (used to decide mount vs. replace). /// /// Equality is content-addressed over [`ParsedMesh::content_hash`]; the float /// payload is never compared. Held behind [`Arc`] so it flows through Dioxus /// props (which require [`Clone`] + [`PartialEq`]) without `ModelMesh` itself /// needing to be `Clone`. #[derive(Debug)] pub struct ParsedMesh { pub content_hash: u64, pub mesh: ModelMesh, } impl PartialEq for ParsedMesh { fn eq(&self, other: &Self) -> bool { self.content_hash == other.content_hash } } impl Eq for ParsedMesh {} /// Deterministic, in-process identity hash of a mesh source. /// /// Covers the primary bytes plus the sorted set of `(companion URI, companion bytes)`, /// so changing a companion (or none vs. some) changes identity and forces a reload. For /// a companion-free source this reduces to hashing the primary bytes alone. fn identity_hash(primary: &[u8], companions: &[(String, Vec)]) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); primary.hash(&mut hasher); if !companions.is_empty() { // A marker so "primary alone" can never collide with "primary + companions". 0xFFu8.hash(&mut hasher); let mut sorted: Vec<(&str, &[u8])> = companions .iter() .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice())) .collect(); sorted.sort_unstable(); for (uri, bytes) in &sorted { uri.hash(&mut hasher); bytes.hash(&mut hasher); } } hasher.finish() } /// Hash a single byte slice (companion-free sources). fn hash_bytes(bytes: &[u8]) -> u64 { identity_hash(bytes, &[]) } /// Parse boundary: the shared [`MeshParser`] (in [`crate::mesh::parser`]) turns a /// [`MeshSource`] into a [`ModelMesh`]. Production uses /// [`ProductionMeshParser`] (which dispatches by format); tests inject a counting parser /// to assert one parse per load. /// /// Build a [`ParsedMesh`] from a mesh source: hash the primary plus companions, then /// parse once through the parser. async fn build_parsed_mesh( source: &MeshSource<'_>, parser: &impl MeshParser, ) -> Result, MeshLoadError> { let companions: Vec<(String, Vec)> = source .resources .iter() .map(|(uri, bytes)| (uri.to_string(), bytes.to_vec())) .collect(); let content_hash = identity_hash(source.primary, &companions); let mesh = parser.parse(source).await?; Ok(Arc::new(ParsedMesh { content_hash, mesh })) } #[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_from_model(model: DemoModel, mesh: &ModelMesh) -> MeshSummary { MeshSummary { display_name: model.name, asset_path: model.asset_path, vertices: mesh.trimesh.positions.len(), triangles: mesh.trimesh.triangle_count(), } } /// A successfully loaded demo model: its parsed mesh plus display metadata. /// Carried through signals to the renderer and the overlay. #[derive(Clone, Debug)] pub struct LoadedMesh { pub model: DemoModel, pub summary: MeshSummary, pub parsed: Arc, } /// Load a demo model on demand (fetch the primary, prefetch glTF companions, parse /// once) into a [`LoadedMesh`]. The format is derived from the asset extension; an /// unrecognized extension surfaces as [`ViewerIssue::MeshUnavailable`]. async fn load_demo_mesh(model: DemoModel) -> Result { let format = MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; let primary = read_bytes(model.asset_path).await?; let companions = prefetch_companions(&primary, model.asset_path, format).await?; let source = MeshSource { format, primary: &primary, resources: if companions.is_empty() { MeshResources::empty() } else { MeshResources::new(&companions) }, }; let parsed = build_parsed_mesh(&source, &ProductionMeshParser::default()) .await .map_err(|_| ViewerIssue::MeshUnavailable)?; let summary = mesh_summary_from_model(model, &parsed.mesh); Ok(LoadedMesh { model, summary, parsed, }) } /// Read a model resource's bytes: `fetch` on the web target, a hermetic disk read /// elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). async fn read_bytes(asset_path: &str) -> Result, ViewerIssue> { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { fetch_bytes(asset_path).await } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] { let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable) } } /// For a multi-file `.gltf`, discover and fetch its companion buffers/textures — only /// safe, relative, in-directory URIs. GLB (magic `glTF`) and embedded-base64 assets are /// self-contained and need no companions. Companions are keyed by their exact raw URI. async fn prefetch_companions( primary: &[u8], asset_path: &str, format: MeshFormat, ) -> Result)>, ViewerIssue> { if format != MeshFormat::Gltf || primary.starts_with(b"glTF") { return Ok(Vec::new()); } let base_dir = asset_path .rsplit_once('/') .map(|(dir, _)| dir) .unwrap_or(""); let mut companions = Vec::new(); for uri in discover_companion_uris(primary) { if !is_safe_relative_uri(&uri) { return Err(ViewerIssue::MeshUnavailable); } let bytes = read_bytes(&format!("{base_dir}/{uri}")).await?; companions.push((uri, bytes)); } Ok(companions) } #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn fetch_bytes(url: &str) -> Result, ViewerIssue> { use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; let window = web_sys::window().ok_or(ViewerIssue::MeshUnavailable)?; let resp_value = JsFuture::from(window.fetch_with_str(url)) .await .map_err(|_| ViewerIssue::MeshUnavailable)?; let response: web_sys::Response = resp_value .dyn_into() .map_err(|_| ViewerIssue::MeshUnavailable)?; let array_buffer = response .array_buffer() .map_err(|_| ViewerIssue::MeshUnavailable)?; let buffer = JsFuture::from(array_buffer) .await .map_err(|_| ViewerIssue::MeshUnavailable)?; Ok(js_sys::Uint8Array::new(&buffer).to_vec()) } #[derive(Clone, Debug, PartialEq, Eq)] 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, } } } #[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.", } } } /// Viewer state surfaced to the UI and the renderer reconcile. /// /// `Loading` is its own variant so the retained renderer can be kept alive /// across the async fetch window: [`RendererLifecycle::reconcile`] treats /// `Loading` (no parsed mesh yet) as "keep the active renderer", not "drop it". #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewerStatus { /// A parsed mesh is available for `key`. Ready(SessionKey), /// A fetch/parse for `key` is in flight; a previously-loaded mesh may still /// be rendering on the retained canvas. Loading(SessionKey), /// Fetching or parsing the mesh failed. MeshError(ViewerIssue), /// The renderer could not be initialized (e.g. WebGL2 unavailable). RendererError(ViewerIssue), } impl ViewerStatus { pub fn label(&self) -> &'static str { match self { Self::Ready(_) => "Viewer ready", Self::Loading(_) => "Loading model", Self::MeshError(_) => "Mesh unavailable", Self::RendererError(_) => "Renderer unavailable", } } pub fn message(&self) -> &'static str { match self { Self::Ready(_) => "STL asset is loaded and ready for the canvas renderer.", Self::Loading(_) => "Fetching and parsing the STL asset for the viewer.", Self::MeshError(issue) | Self::RendererError(issue) => issue.message(), } } /// Whether this status wants a live renderer session. `Loading` keeps the /// session (and the retained canvas) alive; only hard errors do not. pub fn should_have_session(&self) -> bool { matches!(self, Self::Ready(_) | Self::Loading(_)) } pub fn key(&self) -> Option<&SessionKey> { match self { Self::Ready(key) | Self::Loading(key) => Some(key), Self::MeshError(_) | Self::RendererError(_) => None, } } } /// Everything the renderer needs for one mount or mesh replacement. /// /// `mesh` is the parsed payload behind an [`Arc`] (compared by content hash for /// [`PartialEq`]); `summary` is the display metadata; `key` identifies the model. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RendererMount { pub key: SessionKey, pub mesh: Arc, pub summary: MeshSummary, } impl RendererMount { pub fn new(key: SessionKey, mesh: Arc, summary: MeshSummary) -> Self { Self { key, mesh, summary } } } /// A renderer adapter that can be mounted once and have its mesh replaced. /// /// The canvas source is renderer-specific: the three-d adapter looks its canvas /// up by id, so its `Canvas` type is `()`. pub trait ViewerRenderer: Sized { type Canvas; type Error; /// Acquire the rendering context and build the initial scene from `mount`. fn mount(canvas: Self::Canvas, mount: RendererMount) -> Result; /// Replace the mesh resources on the existing context, reusing everything else. fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error>; /// Re-read the canvas size and re-render. fn resize(&mut self) -> Result<(), Self::Error>; } /// Mount-once / replace-mesh lifecycle for a [`ViewerRenderer`], keyed by mesh /// identity so re-renders of the same model are no-ops and only a real model /// switch triggers [`ViewerRenderer::replace_mesh`]. /// /// `reconcile` is loading-safe: a transient absence of a mesh (the async fetch /// window) or a mesh-load error keeps the existing renderer alive; only a hard /// renderer error or a missing canvas tears the session down. pub struct RendererLifecycle { active: Option, active_hash: Option, mounts: u64, } impl RendererLifecycle where R::Canvas: Clone, { pub fn new() -> Self { Self { active: None, active_hash: None, mounts: 0, } } pub fn reconcile( &mut self, canvas: Option, status: &ViewerStatus, mount: Option, ) -> Result<(), R::Error> { // No canvas: the component unmounted or the canvas is gone. Tear down. let Some(canvas) = canvas else { self.active = None; self.active_hash = None; return Ok(()); }; // A hard renderer failure tears the session down; mesh failures and the // async Loading window keep the retained renderer alive. if let ViewerStatus::RendererError(_) = status { self.active = None; self.active_hash = None; return Ok(()); } // No mesh yet (Loading, or no model selected): keep the active renderer. let Some(mount) = mount else { return Ok(()); }; let hash = mount.mesh.content_hash; match self.active.as_mut() { None => { let mut renderer = R::mount(canvas, mount)?; renderer.resize()?; self.active = Some(renderer); self.active_hash = Some(hash); self.mounts += 1; } Some(_) if self.active_hash == Some(hash) => { // Same mesh identity: no-op. The retained context and resources stay. } Some(renderer) => { renderer.replace_mesh(mount)?; self.active_hash = Some(hash); } } Ok(()) } pub fn resize(&mut self) -> Result<(), R::Error> { if let Some(renderer) = self.active.as_mut() { renderer.resize()?; } Ok(()) } /// Borrow the active renderer for external event dispatch (pointer/wheel). pub fn active_mut(&mut self) -> Option<&mut R> { self.active.as_mut() } pub fn has_active_session(&self) -> bool { self.active.is_some() } /// Number of times `mount` has run. This is the WebGL-context acquisition /// count: it should be exactly one per page mount regardless of switches. pub fn mounts(&self) -> u64 { self.mounts } } impl Default for RendererLifecycle where R::Canvas: Clone, { fn default() -> Self { Self::new() } } #[component] pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let loaded: Signal> = use_signal(|| None); let status: Signal = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) }); // Fetch + parse the selected model on demand, re-keying on selection so a // rapid switch cancels the in-flight load. Side effects update `loaded` and // `status`; the returned resource is intentionally unused. let _mesh_load = use_resource(move || { let model = *selected_model.read(); let mut loaded = loaded; let mut status = status; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); match load_demo_mesh(model).await { Ok(result) => { status.set(ViewerStatus::Ready(SessionKey::new( model.id, model.generation, ))); loaded.set(Some(result)); } Err(issue) => { status.set(ViewerStatus::MeshError(issue)); } } } }); let model = *selected_model.read(); rsx! { section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", header { class: "model-viewer-header", div { p { class: "viewer-eyebrow", "Asset-backed STL viewer" } h1 { "Model viewer" } p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } } div { class: "viewer-status-pill", "{status.read().label()}" } } div { class: "viewer-demo-controls", aria_label: "Model choices", for model_option in demo_models() { button { class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, r#type: "button", onclick: move |_| selected_model.set(*model_option), "{model_option.name}" } } } ModelViewer { loaded, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback: None, } } } } #[component] pub fn AssetViewer(initial_model: DemoModel, fallback: Option) -> Element { let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let loaded: 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 mut loaded = loaded; let mut status = status; async move { status.set(ViewerStatus::Loading(SessionKey::new( initial_model.id, initial_model.generation, ))); match load_demo_mesh(initial_model).await { Ok(result) => { status.set(ViewerStatus::Ready(SessionKey::new( initial_model.id, initial_model.generation, ))); loaded.set(Some(result)); } Err(issue) => status.set(ViewerStatus::MeshError(issue)), } } }); rsx! { div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", ModelViewer { loaded, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, fallback, } } } } #[derive(Props, Clone, PartialEq)] pub struct ModelViewerProps { loaded: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, fallback: Option, } #[component] pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mounted = props.canvas_mounted; let resize_count = props.resize_count; let context_acquisitions = props.context_acquisitions; let renderer_failed = props.renderer_failed; let fallback = props.fallback; let loaded = props.loaded; let status = props.status; let loaded_val = loaded.read().clone(); let load_status = status.read().clone(); // A renderer-init failure overrides the load status so the user sees the // renderer error rather than a stale "ready" while no context is active. let status_val = if *renderer_failed.read() { ViewerStatus::RendererError(ViewerIssue::RendererInit) } else { load_status }; let summary = loaded_val.as_ref().map(|loaded| loaded.summary); let asset_path = summary.map(|summary| summary.asset_path).unwrap_or(""); let session_label = loaded_val .as_ref() .map(|loaded| format!("{}#{}", loaded.model.id, loaded.model.generation)) .or_else(|| { status_val .key() .map(|key| format!("{}#{}", key.model_id(), key.generation())) }) .unwrap_or_default(); let acquisitions = *context_acquisitions.read(); let renderer_active = acquisitions > 0 && !*renderer_failed.read(); let renderer_kind = if renderer_active { "three-d" } else { "fallback" }; rsx! { div { class: "model-viewer-boundary", id: "polymodel-viewer-boundary", "data-viewer-status": "{status_val.label()}", "data-session-wanted": "{status_val.should_have_session()}", "data-resize-count": "{resize_count.read()}", "data-session-label": session_label.as_str(), "data-asset-path": asset_path.to_string(), "data-renderer-kind": renderer_kind, "data-context-acquisitions": "{acquisitions}", div { class: "model-viewer-stage", canvas { id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted.read()}", "data-renderer-kind": renderer_kind, class: "model-viewer-canvas", aria_label: "Polymodel model viewer canvas", onmounted: move |event| mark_canvas_mounted(event, canvas_mounted), onwheel: move |event| event.stop_propagation(), onpointerdown: move |event| event.stop_propagation(), } if !renderer_active { if let Some(fallback) = fallback { img { id: "polymodel-viewer-preview", class: "model-viewer-preview-image", src: "{fallback.url}", alt: "{fallback.alt}" } } else if let Some(summary) = summary { AssetPreview { summary } } } } } CanvasRendererSession { loaded, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, } } } #[component] fn AssetPreview(summary: MeshSummary) -> Element { 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", "{summary.triangles} triangles · {summary.vertices} vertices" } } } } /// Always renders the browser renderer session. It has no per-model key, so it /// keeps its position-based identity and stays mounted across loading/model /// switches — which is what preserves the WebGL context. #[component] fn CanvasRendererSession( loaded: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, ) -> Element { rsx! { BrowserRendererSession { loaded, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, } } } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] #[component] fn BrowserRendererSession( loaded: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, ) -> Element { let _ = ( loaded, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, ); rsx! {} } #[cfg(all(target_family = "wasm", target_os = "unknown"))] #[component] fn BrowserRendererSession( loaded: Signal>, status: Signal, canvas_mounted: Signal, resize_count: Signal, context_acquisitions: Signal, mut renderer_failed: Signal, ) -> Element { use std::cell::RefCell; use std::rc::Rc; // Persistent retained session: a renderer lifecycle plus a guard that // detaches the canvas listeners on unmount. The guard is held first so it // drops (and detaches listeners) before the renderer (and its WebGL // context) is released. Listener closures hold only a `Weak` reference to // the lifecycle, so they never keep the session alive. let holders = use_hook(|| { let lifecycle: Rc>> = Rc::new(RefCell::new(RendererLifecycle::new())); let guard: Rc> = Rc::new(RefCell::new(ListenerGuard::default())); Rc::new((guard, lifecycle)) }); let weak_lifecycle = Rc::downgrade(&holders.1); use_effect(move || { let canvas_present = *canvas_mounted.read(); let status_val = status.read().clone(); let loaded_val = loaded.read().clone(); let mount_opt = loaded_val.as_ref().map(|loaded| { RendererMount::new( SessionKey::new(loaded.model.id, loaded.model.generation), loaded.parsed.clone(), loaded.summary, ) }); // Attach canvas listeners once, as soon as the canvas is in the DOM. let needs_attach = canvas_present && holders.0.borrow().cleanup.is_none(); if needs_attach && let Some(canvas) = lookup_viewer_canvas() { let cleanup = attach_renderer_listeners(canvas, weak_lifecycle.clone(), resize_count); holders.0.borrow_mut().cleanup = Some(cleanup); } let mut lifecycle = holders.1.borrow_mut(); let result = lifecycle.reconcile(canvas_present.then_some(()), &status_val, mount_opt); let mounts = lifecycle.mounts(); drop(lifecycle); // Publish the context-acquisition count (the single WebGL-context signal). // This effect reads only canvas_mounted/status/loaded, so writing these signals never re-runs it. context_acquisitions.set(mounts); renderer_failed.set(result.is_err()); if let Err(err) = result { tracing::warn!(?err, "three-d renderer reconcile failed"); } }); rsx! {} } /// Holds the canvas-listener cleanup closure and runs it on drop (component /// unmount), detaching listeners from the canvas and window. #[cfg(all(target_family = "wasm", target_os = "unknown"))] #[derive(Default)] struct ListenerGuard { cleanup: Option>, } #[cfg(all(target_family = "wasm", target_os = "unknown"))] impl Drop for ListenerGuard { fn drop(&mut self) { if let Some(cleanup) = self.cleanup.take() { cleanup(); } } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn lookup_viewer_canvas() -> Option { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::() .ok() } #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn attach_renderer_listeners( canvas: web_sys::HtmlCanvasElement, weak: std::rc::Weak>>, resize_count: Signal, ) -> Box { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{Event, MouseEvent, WheelEvent}; let mut cleanups: Vec> = Vec::new(); // Window resize: bump the resize counter and refresh the active renderer. if let Some(window) = web_sys::window() { let weak_resize = weak.clone(); let mut resize_counter = resize_count; let resize_closure = Closure::::wrap(Box::new(move |_| { let next = *resize_counter.read() + 1; resize_counter.set(next); if let Some(handle) = weak_resize.upgrade() && let Ok(mut lifecycle) = handle.try_borrow_mut() { let _ = lifecycle.resize(); } })); if window .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { if let Some(window) = web_sys::window() { let _ = window.remove_event_listener_with_callback( "resize", resize_closure.as_ref().unchecked_ref(), ); } })); } } if let Some(cleanup) = register_canvas_pointer(&canvas, "pointerdown", weak.clone(), |renderer, event| { if let Some(target) = event .target() .and_then(|target| target.dyn_into::().ok()) { let _ = target.set_pointer_capture(event.pointer_id()); } renderer.pointer_down(event.client_x() as f32, event.client_y() as f32); }) { cleanups.push(cleanup); } if let Some(cleanup) = register_canvas_pointer(&canvas, "pointermove", weak.clone(), |renderer, event| { renderer.pointer_move(event.client_x() as f32, event.client_y() as f32); }) { cleanups.push(cleanup); } if let Some(cleanup) = register_canvas_pointer(&canvas, "pointerup", weak.clone(), |renderer, _event| { renderer.pointer_up(); }) { cleanups.push(cleanup); } // pointerleave is dispatched as a MouseEvent; release any in-progress drag. { let canvas = canvas.clone(); let leave_weak = weak.clone(); let closure = Closure::::wrap(Box::new(move |_| { if let Some(handle) = leave_weak.upgrade() && let Ok(mut lifecycle) = handle.try_borrow_mut() && let Some(renderer) = lifecycle.active_mut() { renderer.pointer_up(); } })); if canvas .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas.remove_event_listener_with_callback( "mouseleave", closure.as_ref().unchecked_ref(), ); })); } } // wheel zoom. { let canvas = canvas.clone(); let wheel_weak = weak; let closure = Closure::::wrap(Box::new(move |event: WheelEvent| { event.prevent_default(); if let Some(handle) = wheel_weak.upgrade() && let Ok(mut lifecycle) = handle.try_borrow_mut() && let Some(renderer) = lifecycle.active_mut() { renderer.wheel( event.delta_y() as f32, event.client_x() as f32, event.client_y() as f32, ); } })); 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()); })); } } Box::new(move || { for cleanup in cleanups.into_iter().rev() { cleanup(); } }) } /// Register a pointer listener on the canvas that dispatches to the active /// renderer through a `Weak` lifecycle handle. Returns a cleanup closure on /// success. Kept as a function (rather than a closure) to keep the types simple. #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn register_canvas_pointer( canvas: &web_sys::HtmlCanvasElement, name: &'static str, weak: std::rc::Weak>>, dispatch: impl Fn(&mut ThreeDRenderer, web_sys::PointerEvent) + 'static, ) -> Option> { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; let closure = Closure::::wrap(Box::new( move |event: web_sys::PointerEvent| { event.prevent_default(); if let Some(handle) = weak.upgrade() && let Ok(mut lifecycle) = handle.try_borrow_mut() && let Some(renderer) = lifecycle.active_mut() { dispatch(renderer, event); } }, )); if canvas .add_event_listener_with_callback(name, closure.as_ref().unchecked_ref()) .is_ok() { let canvas = canvas.clone(); Some(Box::new(move || { let _ = canvas.remove_event_listener_with_callback(name, closure.as_ref().unchecked_ref()); })) } else { None } } /// Typed renderer-initialization error, replacing the spike's `String` boundary. /// /// Parse/asset failures are handled at the loader boundary /// ([`ViewerIssue::MeshUnavailable`]); this covers only what the renderer can /// fail on while acquiring its WebGL2 context and building its scene. #[derive(Debug, thiserror::Error)] pub(crate) enum ThreeDRendererError { #[error("browser window is unavailable")] WindowUnavailable, #[error("browser document is unavailable")] DocumentUnavailable, #[error("viewer canvas element not found")] CanvasNotFound, #[error("viewer element is not a canvas")] NotCanvas, #[error("WebGL2 context could not be created")] WebGL2Unavailable, #[error("rendering context is not a WebGL2 context")] NotWebGL2, #[error("three-d core context error: {0}")] Core(#[from] three_d::core::CoreError), } impl From for ViewerIssue { fn from(_: ThreeDRendererError) -> Self { ViewerIssue::RendererInit } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] struct ThreeDRenderer { canvas: web_sys::HtmlCanvasElement, context: three_d::Context, mesh: three_d::Gm, ambient_light: three_d::AmbientLight, key_light: three_d::DirectionalLight, fill_light: three_d::DirectionalLight, camera: three_d::Camera, control: three_d::OrbitControl, button_down: bool, last_pointer: Option<(f32, f32)>, frame_started_ms: f64, first_render_logged: bool, } /// Viewport-parameterized scene construction shared by the viewer and offscreen /// preview capture. The `viewport` dims MUST equal the backing-store dims of the /// canvas the caller intends to render into (it does NOT call `canvas_viewport` /// or apply `devicePixelRatio` — the caller owns canvas sizing). #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub(crate) fn build_scene_with_viewport( context: &three_d::Context, model: &ModelMesh, viewport: three_d::Viewport, ) -> ( three_d::Gm, three_d::Camera, three_d::OrbitControl, ) { let fit = model.camera_fit(); let mesh = three_d::Gm::new( three_d::Mesh::new(context, &model.trimesh), three_d::PhysicalMaterial { albedo: three_d::Srgba::new_opaque(116, 180, 255), roughness: 0.72, metallic: 0.0, ..Default::default() }, ); let radius = fit.radius.max(1.0); 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 camera = three_d::Camera::new_perspective( viewport, position, target, three_d::vec3(0.0, 0.0, 1.0), three_d::degrees(45.0), (radius / 100.0).max(0.1), radius * 20.0, ); let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); (mesh, camera, control) } /// Standard three-light rig shared by the viewer and preview capture so both /// produce identical lighting. #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub(crate) fn standard_lights( context: &three_d::Context, ) -> ( three_d::AmbientLight, three_d::DirectionalLight, three_d::DirectionalLight, ) { let ambient_light = three_d::AmbientLight::new(context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); let key_light = three_d::DirectionalLight::new( context, 2.6, three_d::Srgba::new_opaque(255, 255, 255), three_d::vec3(-0.45, -0.55, -0.70), ); let fill_light = three_d::DirectionalLight::new( context, 0.9, three_d::Srgba::new_opaque(170, 205, 255), three_d::vec3(0.55, 0.35, -0.35), ); (ambient_light, key_light, fill_light) } #[cfg(all(target_family = "wasm", target_os = "unknown"))] impl ThreeDRenderer { /// Build the GPU mesh + camera framing for `model` against an existing /// context. Shared by the initial mount and mesh replacement. fn build_scene( context: &three_d::Context, canvas: &web_sys::HtmlCanvasElement, model: &ModelMesh, ) -> ( three_d::Gm, three_d::Camera, three_d::OrbitControl, ) { build_scene_with_viewport(context, model, canvas_viewport(canvas)) } fn resize_and_render(&mut self) { let viewport = canvas_viewport(&self.canvas); self.camera.set_viewport(viewport); self.render(); } fn pointer_down(&mut self, x: f32, y: f32) { self.button_down = true; self.last_pointer = Some((x, y)); } fn pointer_move(&mut self, x: f32, y: f32) { if !self.button_down { return; } let Some((last_x, last_y)) = self.last_pointer.replace((x, y)) else { return; }; let mut event = three_d::Event::MouseMotion { button: Some(three_d::MouseButton::Left), delta: (x - last_x, y - last_y), position: (x, y).into(), modifiers: Default::default(), handled: false, }; self.control .handle_events(&mut self.camera, std::slice::from_mut(&mut event)); self.render(); } fn pointer_up(&mut self) { self.button_down = false; self.last_pointer = None; } fn wheel(&mut self, delta_y: f32, x: f32, y: f32) { let mut event = three_d::Event::MouseWheel { delta: (0.0, -delta_y), position: (x, y).into(), modifiers: Default::default(), handled: false, }; self.control .handle_events(&mut self.camera, std::slice::from_mut(&mut event)); self.render(); } fn render(&mut self) { let viewport = self.camera.viewport(); let screen = three_d::RenderTarget::screen(&self.context, viewport.width, viewport.height); let _ = screen .clear(three_d::ClearState::color_and_depth( 0.035, 0.043, 0.067, 1.0, 1.0, )) .render( &self.camera, [&self.mesh], &[&self.ambient_light, &self.key_light, &self.fill_light], ); if !self.first_render_logged { self.first_render_logged = true; let elapsed = web_sys::window() .and_then(|window| window.performance()) .map(|performance| performance.now() - self.frame_started_ms) .unwrap_or(0.0); tracing::info!( backend = "WebGL2", first_render_ms = elapsed, "three-d renderer ready" ); } } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] impl ViewerRenderer for ThreeDRenderer { type Canvas = (); type Error = ThreeDRendererError; fn mount(_: Self::Canvas, mount: RendererMount) -> Result { use wasm_bindgen::JsCast; let model = &mount.mesh.mesh; let window = web_sys::window().ok_or(ThreeDRendererError::WindowUnavailable)?; let document = window .document() .ok_or(ThreeDRendererError::DocumentUnavailable)?; let canvas = document .get_element_by_id("polymodel-viewer-canvas") .ok_or(ThreeDRendererError::CanvasNotFound)? .dyn_into::() .map_err(|_| ThreeDRendererError::NotCanvas)?; let gl = canvas .get_context("webgl2") .map_err(|_| ThreeDRendererError::WebGL2Unavailable)? .ok_or(ThreeDRendererError::WebGL2Unavailable)? .dyn_into::() .map_err(|_| ThreeDRendererError::NotWebGL2)?; let glow_context = three_d::context::Context::from_webgl2_context(gl); #[allow( clippy::arc_with_non_send_sync, reason = "three-d requires Arc for browser WebGL context construction" )] let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context))?; tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired"); let (mesh, camera, control) = Self::build_scene(&context, &canvas, model); let (ambient_light, key_light, fill_light) = standard_lights(&context); let frame_started_ms = window .performance() .map(|performance| performance.now()) .unwrap_or(0.0); Ok(Self { canvas, context, mesh, ambient_light, key_light, fill_light, camera, control, button_down: false, last_pointer: None, frame_started_ms, first_render_logged: false, }) } fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error> { let model = &mount.mesh.mesh; let (mesh, camera, control) = Self::build_scene(&self.context, &self.canvas, model); self.mesh = mesh; self.camera = camera; self.control = control; self.render(); Ok(()) } fn resize(&mut self) -> Result<(), Self::Error> { self.resize_and_render(); Ok(()) } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn canvas_viewport(canvas: &web_sys::HtmlCanvasElement) -> three_d::Viewport { let rect = canvas.get_bounding_client_rect(); let scale = web_sys::window() .map(|window| window.device_pixel_ratio()) .unwrap_or(1.0); let width = ((rect.width() * scale).round() as u32).max(1); let height = ((rect.height() * scale).round() as u32).max(1); canvas.set_width(width); canvas.set_height(height); three_d::Viewport::new_at_origo(width, height) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal) { mounted.set(true); } #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); mounted.set(element.dyn_into::().is_ok()); } #[cfg(test)] mod tests { use super::*; use crate::mesh::contract::{LengthUnit, MeshFormat, Units}; use crate::mesh::stl::load_stl; use std::cell::Cell; use std::rc::Rc; fn fixture_bytes(asset_path: &str) -> Vec { let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); std::fs::read(&path) .unwrap_or_else(|_| panic!("demo asset {asset_path} should be committed at {path}")) } async fn parsed_for(model: DemoModel) -> Arc { let bytes = fixture_bytes(model.asset_path); let source = MeshSource { format: MeshFormat::Stl, primary: &bytes, resources: MeshResources::empty(), }; build_parsed_mesh(&source, &ProductionMeshParser::default()) .await .expect("demo STL asset should parse") } async fn mount_for(model: DemoModel) -> RendererMount { let parsed = parsed_for(model).await; let summary = mesh_summary_from_model(model, &parsed.mesh); RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary) } #[derive(Clone)] struct FakeCanvas; struct FakeRenderer { events: Rc>, drops: Rc>, } impl ViewerRenderer for FakeRenderer { type Canvas = (FakeCanvas, Rc>, Rc>); type Error = (); fn mount(canvas: Self::Canvas, _mount: RendererMount) -> Result { canvas.1.set(canvas.1.get() + 1); Ok(Self { events: canvas.1, drops: canvas.2, }) } fn replace_mesh(&mut self, _mount: RendererMount) -> Result<(), Self::Error> { self.events.set(self.events.get() + 1); Ok(()) } fn resize(&mut self) -> Result<(), Self::Error> { self.events.set(self.events.get() + 1); Ok(()) } } impl Drop for FakeRenderer { fn drop(&mut self) { self.drops.set(self.drops.get() + 1); } } #[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 demo_model_metadata_matches_committed_fixture() { let model = demo_models()[0]; let bytes = fixture_bytes(model.asset_path); let mesh = load_stl(&bytes).expect("demo STL asset should parse"); assert_eq!(mesh.trimesh.positions.len(), model.vertices); assert_eq!(mesh.trimesh.triangle_count(), model.triangles); assert_eq!(mesh.format, MeshFormat::Stl); assert_eq!( mesh.units, Units { source: LengthUnit::Unknown, assumed: Some(LengthUnit::Millimeter), } ); } #[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(ViewerIssue::MeshUnavailable).label(), "Mesh unavailable" ); assert!(ready.should_have_session()); assert!(loading.should_have_session()); assert!(!ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).should_have_session()); } #[tokio::test] async fn parsed_mesh_equality_uses_content_hash() { let chest = parsed_for(DEMO_MODELS[0]).await; let chest_again = parsed_for(DEMO_MODELS[0]).await; let forearm = parsed_for(DEMO_MODELS[1]).await; assert_eq!(chest, chest_again, "same source bytes must be equal"); assert_ne!(chest, forearm, "different source bytes must differ"); assert_eq!(&chest, &chest_again, "Arc/ParsedMesh compare by hash"); } #[tokio::test] async fn parses_each_loaded_mesh_once() { struct CountingParser { calls: Rc>, } impl CountingParser { fn new() -> Self { Self { calls: Rc::new(Cell::new(0)), } } } impl MeshParser for CountingParser { async fn parse(&self, source: &MeshSource<'_>) -> Result { self.calls.set(self.calls.get() + 1); crate::mesh::stl::load_stl(source.primary) } } let parser = CountingParser::new(); let bytes = fixture_bytes(DEMO_MODELS[0].asset_path); let source = MeshSource { format: MeshFormat::Stl, primary: &bytes, resources: MeshResources::empty(), }; let parsed = build_parsed_mesh(&source, &parser).await.expect("parses"); assert_eq!( parser.calls.get(), 1, "the boundary must parse once per load" ); let again = build_parsed_mesh(&source, &parser).await.unwrap(); assert_eq!( again.content_hash, parsed.content_hash, "the same bytes must hash to the same identity" ); } #[test] fn identity_changes_when_a_companion_changes() { // No companions -> reduces to hashing the primary. let primary = b"primary bytes"; let none = identity_hash(primary, &[]); assert_eq!(none, hash_bytes(primary)); let with_a = identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]); let with_b = identity_hash(primary, &[("box.bin".to_string(), b"BBB".to_vec())]); assert_ne!(none, with_a, "adding a companion changes identity"); assert_ne!(with_a, with_b, "changing a companion changes identity"); assert_eq!( with_a, identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]), "same companions hash to the same identity" ); } #[test] fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() { let errors: Vec = vec![ ThreeDRendererError::WindowUnavailable, ThreeDRendererError::DocumentUnavailable, ThreeDRendererError::CanvasNotFound, ThreeDRendererError::NotCanvas, ThreeDRendererError::WebGL2Unavailable, ThreeDRendererError::NotWebGL2, ThreeDRendererError::Core(three_d::core::CoreError::ContextCreation("boom".into())), ]; for err in errors { let issue: ViewerIssue = err.into(); assert_eq!(issue, ViewerIssue::RendererInit); } } #[tokio::test] async fn loaded_mesh_carries_parsed_payload_and_summary() { let model = DEMO_MODELS[0]; let loaded = load_demo_mesh(model) .await .expect("non-wasm disk load should succeed"); assert_eq!(loaded.model, model); assert_eq!(loaded.summary.asset_path, model.asset_path); assert_eq!(loaded.summary.vertices, model.vertices); assert_eq!(loaded.summary.triangles, model.triangles); assert_eq!( loaded.parsed.content_hash, hash_bytes(&fixture_bytes(model.asset_path)) ); } #[tokio::test] async fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { let events = Rc::new(Cell::new(0)); let drops = Rc::new(Cell::new(0)); let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); let mut lifecycle = RendererLifecycle::::new(); let chest = mount_for(DEMO_MODELS[0]).await; let forearm = mount_for(DEMO_MODELS[1]).await; let ready_chest = ViewerStatus::Ready(chest.key.clone()); // Initial mount. lifecycle .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) .expect("mount should succeed"); assert!(lifecycle.has_active_session()); assert!(lifecycle.active_mut().is_some()); assert_eq!( events.get(), 2, "initial mount must also draw the first frame" ); assert_eq!(lifecycle.mounts(), 1); // Resize forwards to the active renderer. lifecycle.resize().expect("resize should forward"); assert_eq!(events.get(), 3); // Same-identity re-render is a no-op (no replace). lifecycle .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) .expect("same-identity reconcile should succeed"); assert_eq!(events.get(), 3); // Switch to a different model: replace once, still a single mount. let ready_forearm = ViewerStatus::Ready(forearm.key.clone()); lifecycle .reconcile(Some(canvas.clone()), &ready_forearm, Some(forearm.clone())) .expect("replace should succeed"); assert_eq!(events.get(), 4); assert_eq!(drops.get(), 0); assert_eq!(lifecycle.mounts(), 1); // Loading window (no mesh yet) keeps the active renderer alive. lifecycle .reconcile( Some(canvas.clone()), &ViewerStatus::Loading(SessionKey::new("body-f-forearm-2x-v9", 1)), None, ) .expect("loading reconcile should succeed"); assert!(lifecycle.has_active_session()); assert_eq!(drops.get(), 0); // A mesh-load error also keeps the retained renderer. lifecycle .reconcile( Some(canvas.clone()), &ViewerStatus::MeshError(ViewerIssue::MeshUnavailable), None, ) .expect("mesh-error reconcile should succeed"); assert!(lifecycle.has_active_session()); // A hard renderer error tears the session down. lifecycle .reconcile( Some(canvas.clone()), &ViewerStatus::RendererError(ViewerIssue::RendererInit), Some(forearm.clone()), ) .expect("renderer-error reconcile should succeed"); assert!(!lifecycle.has_active_session()); assert!(lifecycle.active_mut().is_none()); assert_eq!(drops.get(), 1); // Canvas gone with no active session is a clean no-op. lifecycle .reconcile(None, &ready_forearm, None) .expect("drop path should succeed"); assert!(!lifecycle.has_active_session()); assert_eq!(drops.get(), 1); } }