Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643//! 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<u8>)]) -> 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<Arc<ParsedMesh>, MeshLoadError> { let companions: Vec<(String, Vec<u8>)> = 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<ParsedMesh>,}
/// 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<LoadedMesh, ViewerIssue> { 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<Vec<u8>, 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<Vec<(String, Vec<u8>)>, 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<Vec<u8>, 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<String>, generation: u64) -> Self { Self { model_id: model_id.into(), generation, } }
pub fn model_id(&self) -> &str { &self.model_id }
pub fn generation(&self) -> u64 { self.generation }
#[cfg(test)] pub fn switch_to(&self, next_model_id: impl Into<String>) -> Self { Self { model_id: next_model_id.into(), generation: self.generation + 1, } }}
#[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<ParsedMesh>, pub summary: MeshSummary,}
impl RendererMount { pub fn new(key: SessionKey, mesh: Arc<ParsedMesh>, 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<Self, Self::Error>; /// 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<R: ViewerRenderer> { active: Option<R>, active_hash: Option<u64>, mounts: u64,}
impl<R: ViewerRenderer> RendererLifecycle<R>where R::Canvas: Clone,{ pub fn new() -> Self { Self { active: None, active_hash: None, mounts: 0, } }
pub fn reconcile( &mut self, canvas: Option<R::Canvas>, status: &ViewerStatus, mount: Option<RendererMount>, ) -> 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<R: ViewerRenderer> Default for RendererLifecycle<R>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<Option<LoadedMesh>> = use_signal(|| None); let status: Signal<ViewerStatus> = 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<ViewerFallbackImage>) -> Element { let canvas_mounted = use_signal(|| false); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let loaded: Signal<Option<LoadedMesh>> = use_signal(|| None); let status: Signal<ViewerStatus> = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) });
let _mesh_load = use_resource(move || { let 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<Option<LoadedMesh>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, fallback: Option<ViewerFallbackImage>,}
#[component]pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mounted = props.canvas_mounted; let resize_count = props.resize_count; let context_acquisitions = props.context_acquisitions; let renderer_failed = props.renderer_failed; let fallback = props.fallback; let 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<Option<LoadedMesh>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>,) -> 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<Option<LoadedMesh>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>,) -> 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<Option<LoadedMesh>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, mut renderer_failed: Signal<bool>,) -> 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<RefCell<RendererLifecycle<ThreeDRenderer>>> = Rc::new(RefCell::new(RendererLifecycle::new())); let guard: Rc<RefCell<ListenerGuard>> = 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<Box<dyn FnOnce()>>,}
#[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<web_sys::HtmlCanvasElement> { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::<web_sys::HtmlCanvasElement>() .ok()}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]fn attach_renderer_listeners( canvas: web_sys::HtmlCanvasElement, weak: std::rc::Weak<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>, resize_count: Signal<u64>,) -> Box<dyn FnOnce()> { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{Event, MouseEvent, WheelEvent};
let mut cleanups: Vec<Box<dyn FnOnce()>> = 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::<dyn FnMut(Event)>::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::<web_sys::HtmlCanvasElement>().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::<dyn FnMut(MouseEvent)>::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::<dyn FnMut(WheelEvent)>::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<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>, dispatch: impl Fn(&mut ThreeDRenderer, web_sys::PointerEvent) + 'static,) -> Option<Box<dyn FnOnce()>> { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure;
let closure = Closure::<dyn FnMut(web_sys::PointerEvent)>::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<ThreeDRendererError> 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<three_d::Mesh, three_d::PhysicalMaterial>, 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::Mesh, three_d::PhysicalMaterial>, 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::Mesh, three_d::PhysicalMaterial>, 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<Self, Self::Error> { 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::<web_sys::HtmlCanvasElement>() .map_err(|_| ThreeDRendererError::NotCanvas)?; let gl = canvas .get_context("webgl2") .map_err(|_| ThreeDRendererError::WebGL2Unavailable)? .ok_or(ThreeDRendererError::WebGL2Unavailable)? .dyn_into::<web_sys::WebGl2RenderingContext>() .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<glow::Context> 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<bool>) { mounted.set(true);}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal<bool>) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); mounted.set(element.dyn_into::<web_sys::HtmlCanvasElement>().is_ok());}
#[cfg(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<u8> { 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<ParsedMesh> { 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<Cell<usize>>, drops: Rc<Cell<usize>>, }
impl ViewerRenderer for FakeRenderer { type Canvas = (FakeCanvas, Rc<Cell<usize>>, Rc<Cell<usize>>); type Error = ();
fn mount(canvas: Self::Canvas, _mount: RendererMount) -> Result<Self, Self::Error> { 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<Cell<usize>>, } impl CountingParser { fn new() -> Self { Self { calls: Rc::new(Cell::new(0)), } } } impl MeshParser for CountingParser { async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> { 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<ThreeDRendererError> = 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::<FakeRenderer>::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); }}