atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-22: productionize three-d viewer (PM-31 typed error, PM-34 on-demand assets, retain WebGL session)

docs(agents): main and default workspace head move together

Orual (Jun 29, 2026, 7:06 PM EDT) c53acb08 5895246a

+894 -442
+4 -4
AGENTS.md
··· 12 12 - browser STL viewing as a core interaction; 13 13 - Dioxus web UI backed by Rust and Jacquard 0.12. 14 14 15 - STL viewer candidates: 15 + STL viewer (decided): 16 16 17 - - `three-d` is a viable web/WASM candidate with a browser/WebGL-oriented API. 18 - - `kiss3d` also works on WASM and should be evaluated alongside `three-d`; choose based on STL loading, orbit controls, rendering quality, bundle/runtime costs, and Dioxus integration friction. 17 + - `three-d` is the chosen STL viewer (see the Confluence decision note). Renders STL on a Dioxus-owned canvas via WebGL2; the renderer session is retained across model switches and the STL loads on demand from `public/models/`. 18 + - `kiss3d` was evaluated and rejected: no WebGL2/WebGPU adapter surfaced at runtime in the browser target (PM-21). 19 19 20 20 ## Source of truth 21 21 ··· 27 27 28 28 ## VCS 29 29 30 - Use jj, not raw git, for normal development. 30 + Use jj, not raw git, for normal development. This repo commits large model assets (multi-MiB STL/STEP under `public/models/`), so a fresh clone needs `jj config set --repo snapshot.max-new-file-size 5242880` before snapshotting new large files (jj's default 1 MiB cap refuses them); already-committed assets check out fine without it. 31 31 32 32 - Create isolated work with jj workspaces, and immediately start a fresh change before editing: `jj new -m "PM-12: concise summary"`. 33 33 - Use ticket-derived names like `pm-12-short-summary`.
+4 -4
README.md
··· 14 14 15 15 Jira project/Confluence space key: `PM`. 16 16 17 - ## STL viewer candidates 17 + ## STL viewer 18 18 19 - Tentative candidates: 19 + Decision: three-d. 20 20 21 - - [`three-d`](https://github.com/asny/three-d): viable web/WASM candidate with a browser/WebGL-oriented API. 22 - - [`kiss3d`](https://github.com/dimforge/kiss3d): also works on WASM and should be evaluated alongside `three-d`, especially for STL loading, orbit controls, and Dioxus integration friction. 21 + - [`three-d`](https://github.com/asny/three-d): chosen STL viewer. Renders on a Dioxus-owned canvas via WebGL2; the renderer session is retained across model switches and STL assets load on demand from `public/models/`. 22 + - [`kiss3d`](https://github.com/dimforge/kiss3d): rejected — no WebGL2/WebGPU adapter surfaced at runtime in the browser target (PM-21). 23 23 24 24 The viewer choice should be decided with a small spike that loads an STL, renders orbit controls, and fits cleanly into Dioxus. 25 25
assets/models/EInk_click_v101.step public/models/EInk_click_v101.step
assets/models/body_f_chest-v4.stl public/models/body_f_chest-v4.stl
assets/models/body_f_forearm_2x-v9.stl public/models/body_f_forearm_2x-v9.stl
+31 -3
e2e/tests/viewer.spec.ts
··· 3 3 const boundary = '#polymodel-viewer-boundary'; 4 4 const canvas = '#polymodel-viewer-canvas'; 5 5 const preview = '#polymodel-viewer-preview'; 6 - const chestAssetPath = '/assets/models/body_f_chest-v4.stl'; 7 - const forearmAssetPath = '/assets/models/body_f_forearm_2x-v9.stl'; 6 + const chestAssetPath = '/models/body_f_chest-v4.stl'; 7 + const forearmAssetPath = '/models/body_f_forearm_2x-v9.stl'; 8 + 9 + async function acquisitions(page) { 10 + return Number(await page.locator(boundary).getAttribute('data-context-acquisitions')); 11 + } 8 12 9 13 async function expectViewerReady(page) { 10 14 await expect(page.locator(canvas)).toHaveCount(1); ··· 43 47 await expect(page.locator(boundary)).toContainText('Body F forearm 2x v9'); 44 48 }); 45 49 46 - test('model switching keeps one canvas and updates renderer session metadata', async ({ page }) => { 50 + test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { 47 51 await page.goto('/viewer/body-f-chest-v4'); 48 52 await expectViewerReady(page); 49 53 await expect(page.locator(canvas)).toHaveCount(1); 54 + // The WebGL context is acquired exactly once for the initial mount. 55 + await expect.poll(acquisitions.bind(null, page)).toBe(1); 50 56 51 57 await page.getByRole('button', { name: 'Body F forearm 2x v9' }).click(); 52 58 await expectViewerReady(page); ··· 55 61 await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 56 62 await expect(page.locator(boundary)).toContainText('18271'); 57 63 await expect(page.locator(boundary)).toContainText('36542'); 64 + // Switching models must NOT acquire a second WebGL context (in-place replace). 65 + await expect.poll(acquisitions.bind(null, page)).toBe(1); 58 66 59 67 await page.getByRole('button', { name: 'Body F chest v4' }).click(); 60 68 await expectViewerReady(page); 61 69 await expect(page.locator(canvas)).toHaveCount(1); 62 70 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 71 + await expect.poll(acquisitions.bind(null, page)).toBe(1); 72 + }); 73 + 74 + test('rapid model switching through loading windows keeps a single WebGL context', async ({ page }) => { 75 + await page.goto('/viewer/body-f-chest-v4'); 76 + await expectViewerReady(page); 77 + const chest = page.getByRole('button', { name: 'Body F chest v4' }); 78 + const forearm = page.getByRole('button', { name: 'Body F forearm 2x v9' }); 79 + 80 + // Switch rapidly without waiting for ready between clicks, exercising the 81 + // async Loading window and stale-fetch cancellation from use_resource. 82 + await forearm.click(); 83 + await chest.click(); 84 + await forearm.click(); 85 + await chest.click(); 86 + 87 + await expectViewerReady(page); 88 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); 89 + // The retained context is never recreated across the switches. 90 + await expect.poll(acquisitions.bind(null, page)).toBe(1); 63 91 }); 64 92 65 93 test('resize callback remains single after model transitions', async ({ page }) => {
+2 -2
src/mesh/stl.rs
··· 325 325 fn local_body_chest_fixture_parses() { 326 326 let path = concat!( 327 327 env!("CARGO_MANIFEST_DIR"), 328 - "/assets/models/body_f_chest-v4.stl" 328 + "/public/models/body_f_chest-v4.stl" 329 329 ); 330 330 let bytes = std::fs::read(path) 331 - .expect("local fixture assets/models/body_f_chest-v4.stl should be committed"); 331 + .expect("local fixture public/models/body_f_chest-v4.stl should be committed"); 332 332 let model = load_stl(&bytes).expect("local STL fixture should parse"); 333 333 assert_eq!(model.format, MeshFormat::Stl); 334 334 assert_eq!(
+853 -429
src/viewer.rs
··· 1 + //! Asset-backed STL viewer. 2 + //! 3 + //! The viewer renders committed STL assets with [`three-d`] on a Dioxus-owned 4 + //! canvas. The design (see the "STL viewer renderer decision" Confluence note) 5 + //! rests on three production decisions made when closing PM-20/PM-22: 6 + //! 7 + //! * **One WebGL2 session per page mount.** The session is retained across 8 + //! model switches and only the GPU mesh resources are replaced in place 9 + //! ([`ViewerRenderer::replace_mesh`]). The [`BrowserRendererSession`] 10 + //! component is identified by position (no per-model key), stays mounted 11 + //! through the async loading window, and drives a [`RendererLifecycle`] 12 + //! reconcile on signal changes — so the WebGL context is acquired exactly 13 + //! once per page mount. 14 + //! * **One parsed mesh per load.** STL bytes are fetched on demand and parsed a 15 + //! single time into a [`ParsedMesh`], which then flows to the renderer. The 16 + //! renderer never parses STL itself. 17 + //! * **On-demand assets.** Demo STLs live under `public/models/` and are fetched 18 + //! from `/models/*.stl` at runtime instead of being embedded in the WASM bundle. 19 + 20 + // The renderer session machinery (the three-d adapter, the reuse lifecycle, and 21 + // the typed error) is production-live only in the web target, where the wasm 22 + // `BrowserRendererSession` drives it. Off-web it exists to compile and to back 23 + // the unit tests, so allow dead code there rather than cfg-gating the whole 24 + // abstraction out of the non-wasm build. 25 + #![allow(dead_code)] 26 + 27 + use crate::mesh::contract::{MeshLoadError, ModelMesh}; 1 28 use crate::mesh::stl::load_stl; 2 29 use dioxus::prelude::*; 3 - #[cfg(test)] 4 - use std::cell::Cell; 5 - #[cfg(test)] 6 - use std::rc::Rc; 30 + use std::hash::{Hash, Hasher}; 31 + use std::sync::Arc; 7 32 8 33 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 9 34 pub struct DemoModel { ··· 19 44 DemoModel { 20 45 id: "body-f-chest-v4", 21 46 name: "Body F chest v4", 22 - asset_path: "/assets/models/body_f_chest-v4.stl", 47 + asset_path: "/models/body_f_chest-v4.stl", 23 48 generation: 1, 24 49 vertices: 41051, 25 50 triangles: 82122, ··· 27 52 DemoModel { 28 53 id: "body-f-forearm-2x-v9", 29 54 name: "Body F forearm 2x v9", 30 - asset_path: "/assets/models/body_f_forearm_2x-v9.stl", 55 + asset_path: "/models/body_f_forearm_2x-v9.stl", 31 56 generation: 1, 32 57 vertices: 18271, 33 58 triangles: 36542, ··· 46 71 .unwrap_or(DEMO_MODELS[0]) 47 72 } 48 73 49 - const BODY_F_CHEST_V4_STL: &[u8] = include_bytes!(concat!( 50 - env!("CARGO_MANIFEST_DIR"), 51 - "/assets/models/body_f_chest-v4.stl" 52 - )); 53 - const BODY_F_FOREARM_2X_V9_STL: &[u8] = include_bytes!(concat!( 54 - env!("CARGO_MANIFEST_DIR"), 55 - "/assets/models/body_f_forearm_2x-v9.stl" 56 - )); 74 + /// A mesh parsed once from its source bytes, carrying an identity hash so it can 75 + /// be compared cheaply for equality (used to decide mount vs. replace). 76 + /// 77 + /// Equality is content-addressed over [`ParsedMesh::content_hash`]; the float 78 + /// payload is never compared. Held behind [`Arc`] so it flows through Dioxus 79 + /// props (which require [`Clone`] + [`PartialEq`]) without `ModelMesh` itself 80 + /// needing to be `Clone`. 81 + #[derive(Debug)] 82 + pub struct ParsedMesh { 83 + pub content_hash: u64, 84 + pub mesh: ModelMesh, 85 + } 86 + 87 + impl PartialEq for ParsedMesh { 88 + fn eq(&self, other: &Self) -> bool { 89 + self.content_hash == other.content_hash 90 + } 91 + } 92 + 93 + impl Eq for ParsedMesh {} 94 + 95 + /// Deterministic, in-process identity hash of mesh source bytes. 96 + fn hash_bytes(bytes: &[u8]) -> u64 { 97 + let mut hasher = std::collections::hash_map::DefaultHasher::new(); 98 + bytes.hash(&mut hasher); 99 + hasher.finish() 100 + } 101 + 102 + /// Parse boundary for the viewer: a mockable parser turns source bytes into a 103 + /// [`ModelMesh`]. Production uses [`StlParser`] (which wraps the shared 104 + /// [`load_stl`]); tests inject a counting parser to assert one parse per load. 105 + /// This is the only place in the viewer that calls [`load_stl`]. 106 + trait MeshParser { 107 + fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError>; 108 + } 109 + 110 + /// Default STL parser. [`StlParser::parse`] is the single [`load_stl`] call site 111 + /// in the viewer; the renderer consumes a [`ParsedMesh`] and never parses. 112 + struct StlParser; 57 113 58 - fn demo_model_bytes(asset_path: &str) -> Option<&'static [u8]> { 59 - match asset_path { 60 - "/assets/models/body_f_chest-v4.stl" => Some(BODY_F_CHEST_V4_STL), 61 - "/assets/models/body_f_forearm_2x-v9.stl" => Some(BODY_F_FOREARM_2X_V9_STL), 62 - _ => None, 114 + impl MeshParser for StlParser { 115 + fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 116 + load_stl(bytes) 63 117 } 64 118 } 65 119 66 - fn mesh_summary_from_model( 67 - model: DemoModel, 68 - mesh: &crate::mesh::contract::ModelMesh, 69 - ) -> MeshSummary { 120 + /// Build a [`ParsedMesh`] from source bytes: hash the bytes, then parse once. 121 + fn build_parsed_mesh( 122 + bytes: &[u8], 123 + parser: &impl MeshParser, 124 + ) -> Result<Arc<ParsedMesh>, MeshLoadError> { 125 + let content_hash = hash_bytes(bytes); 126 + let mesh = parser.parse(bytes)?; 127 + Ok(Arc::new(ParsedMesh { content_hash, mesh })) 128 + } 129 + 130 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 131 + pub struct MeshSummary { 132 + pub display_name: &'static str, 133 + pub asset_path: &'static str, 134 + pub vertices: usize, 135 + pub triangles: usize, 136 + } 137 + 138 + fn mesh_summary_from_model(model: DemoModel, mesh: &ModelMesh) -> MeshSummary { 70 139 MeshSummary { 71 140 display_name: model.name, 72 141 asset_path: model.asset_path, ··· 75 144 } 76 145 } 77 146 78 - fn load_demo_mesh_handle(model: DemoModel) -> Result<ViewerMeshHandle, ViewerIssue> { 79 - let bytes = demo_model_bytes(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; 80 - let mesh = load_stl(bytes).map_err(|_| ViewerIssue::MeshUnavailable)?; 81 - Ok(ViewerMeshHandle { 82 - key: SessionKey::new(model.id, model.generation), 83 - summary: mesh_summary_from_model(model, &mesh), 84 - }) 85 - } 86 - 147 + /// A successfully loaded demo model: its parsed mesh plus display metadata. 148 + /// Carried through signals to the renderer and the overlay. 87 149 #[derive(Clone, Debug)] 88 - pub struct ViewerMeshHandle { 89 - pub key: SessionKey, 150 + pub struct LoadedMesh { 151 + pub model: DemoModel, 90 152 pub summary: MeshSummary, 153 + pub parsed: Arc<ParsedMesh>, 91 154 } 92 155 93 - impl ViewerMeshHandle { 94 - #[cfg(test)] 95 - pub fn from_demo_model(model: DemoModel) -> Self { 96 - load_demo_mesh_handle(model).unwrap_or_else(|_| Self { 97 - key: SessionKey::new(model.id, model.generation), 98 - summary: MeshSummary { 99 - display_name: model.name, 100 - asset_path: model.asset_path, 101 - vertices: model.vertices, 102 - triangles: model.triangles, 103 - }, 104 - }) 105 - } 156 + /// Load a demo model's STL on demand (fetch on wasm, disk off wasm), parsing it 157 + /// exactly once into a [`LoadedMesh`]. 158 + async fn load_demo_mesh(model: DemoModel) -> Result<LoadedMesh, ViewerIssue> { 159 + let bytes = read_model_bytes(model).await?; 160 + let parsed = build_parsed_mesh(&bytes, &StlParser).map_err(|_| ViewerIssue::MeshUnavailable)?; 161 + let summary = mesh_summary_from_model(model, &parsed.mesh); 162 + Ok(LoadedMesh { 163 + model, 164 + summary, 165 + parsed, 166 + }) 106 167 } 107 168 108 - impl PartialEq for ViewerMeshHandle { 109 - fn eq(&self, other: &Self) -> bool { 110 - self.key == other.key 169 + /// Read a demo model's source bytes: `fetch` on the web target, a hermetic disk 170 + /// read elsewhere (so the non-wasm build compiles and tests stay filesystem-backed). 171 + async fn read_model_bytes(model: DemoModel) -> Result<Vec<u8>, ViewerIssue> { 172 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 173 + { 174 + fetch_bytes(model.asset_path).await 175 + } 176 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 177 + { 178 + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); 179 + std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable) 111 180 } 112 181 } 113 182 114 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 115 - pub struct MeshSummary { 116 - pub display_name: &'static str, 117 - pub asset_path: &'static str, 118 - pub vertices: usize, 119 - pub triangles: usize, 183 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 184 + async fn fetch_bytes(url: &str) -> Result<Vec<u8>, ViewerIssue> { 185 + use wasm_bindgen::JsCast; 186 + use wasm_bindgen_futures::JsFuture; 187 + 188 + let window = web_sys::window().ok_or(ViewerIssue::MeshUnavailable)?; 189 + let resp_value = JsFuture::from(window.fetch_with_str(url)) 190 + .await 191 + .map_err(|_| ViewerIssue::MeshUnavailable)?; 192 + let response: web_sys::Response = resp_value 193 + .dyn_into() 194 + .map_err(|_| ViewerIssue::MeshUnavailable)?; 195 + let array_buffer = response 196 + .array_buffer() 197 + .map_err(|_| ViewerIssue::MeshUnavailable)?; 198 + let buffer = JsFuture::from(array_buffer) 199 + .await 200 + .map_err(|_| ViewerIssue::MeshUnavailable)?; 201 + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) 120 202 } 121 203 122 204 #[derive(Clone, Debug, PartialEq, Eq)] ··· 150 232 } 151 233 } 152 234 153 - #[cfg_attr(not(test), allow(dead_code))] 154 235 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 155 236 pub enum ViewerIssue { 156 237 MeshUnavailable, ··· 166 247 } 167 248 } 168 249 169 - #[cfg_attr(not(test), allow(dead_code))] 250 + /// Viewer state surfaced to the UI and the renderer reconcile. 251 + /// 252 + /// `Loading` is its own variant so the retained renderer can be kept alive 253 + /// across the async fetch window: [`RendererLifecycle::reconcile`] treats 254 + /// `Loading` (no parsed mesh yet) as "keep the active renderer", not "drop it". 170 255 #[derive(Clone, Debug, PartialEq, Eq)] 171 256 pub enum ViewerStatus { 257 + /// A parsed mesh is available for `key`. 172 258 Ready(SessionKey), 259 + /// A fetch/parse for `key` is in flight; a previously-loaded mesh may still 260 + /// be rendering on the retained canvas. 261 + Loading(SessionKey), 262 + /// Fetching or parsing the mesh failed. 173 263 MeshError(ViewerIssue), 264 + /// The renderer could not be initialized (e.g. WebGL2 unavailable). 174 265 RendererError(ViewerIssue), 175 266 } 176 267 ··· 178 269 pub fn label(&self) -> &'static str { 179 270 match self { 180 271 Self::Ready(_) => "Viewer ready", 272 + Self::Loading(_) => "Loading model", 181 273 Self::MeshError(_) => "Mesh unavailable", 182 274 Self::RendererError(_) => "Renderer unavailable", 183 275 } ··· 186 278 pub fn message(&self) -> &'static str { 187 279 match self { 188 280 Self::Ready(_) => "STL asset is loaded and ready for the canvas renderer.", 281 + Self::Loading(_) => "Fetching and parsing the STL asset for the viewer.", 189 282 Self::MeshError(issue) | Self::RendererError(issue) => issue.message(), 190 283 } 191 284 } 192 285 286 + /// Whether this status wants a live renderer session. `Loading` keeps the 287 + /// session (and the retained canvas) alive; only hard errors do not. 193 288 pub fn should_have_session(&self) -> bool { 194 - matches!(self, Self::Ready(_)) 289 + matches!(self, Self::Ready(_) | Self::Loading(_)) 290 + } 291 + 292 + pub fn key(&self) -> Option<&SessionKey> { 293 + match self { 294 + Self::Ready(key) | Self::Loading(key) => Some(key), 295 + Self::MeshError(_) | Self::RendererError(_) => None, 296 + } 195 297 } 196 298 } 197 299 300 + /// Everything the renderer needs for one mount or mesh replacement. 301 + /// 302 + /// `mesh` is the parsed payload behind an [`Arc`] (compared by content hash for 303 + /// [`PartialEq`]); `summary` is the display metadata; `key` identifies the model. 198 304 #[derive(Clone, Debug, PartialEq, Eq)] 199 305 pub struct RendererMount { 200 306 pub key: SessionKey, 201 - pub mesh: MeshSummary, 307 + pub mesh: Arc<ParsedMesh>, 308 + pub summary: MeshSummary, 202 309 } 203 310 204 311 impl RendererMount { 205 - pub fn new(key: SessionKey, mesh: MeshSummary) -> Self { 206 - Self { key, mesh } 312 + pub fn new(key: SessionKey, mesh: Arc<ParsedMesh>, summary: MeshSummary) -> Self { 313 + Self { key, mesh, summary } 207 314 } 208 315 } 209 316 210 - #[cfg_attr(not(test), allow(dead_code))] 317 + /// A renderer adapter that can be mounted once and have its mesh replaced. 318 + /// 319 + /// The canvas source is renderer-specific: the three-d adapter looks its canvas 320 + /// up by id, so its `Canvas` type is `()`. 211 321 pub trait ViewerRenderer: Sized { 212 322 type Canvas; 213 323 type Error; 214 324 325 + /// Acquire the rendering context and build the initial scene from `mount`. 215 326 fn mount(canvas: Self::Canvas, mount: RendererMount) -> Result<Self, Self::Error>; 327 + /// Replace the mesh resources on the existing context, reusing everything else. 216 328 fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error>; 329 + /// Re-read the canvas size and re-render. 217 330 fn resize(&mut self) -> Result<(), Self::Error>; 218 331 } 219 332 220 - #[cfg(test)] 333 + /// Mount-once / replace-mesh lifecycle for a [`ViewerRenderer`], keyed by mesh 334 + /// identity so re-renders of the same model are no-ops and only a real model 335 + /// switch triggers [`ViewerRenderer::replace_mesh`]. 336 + /// 337 + /// `reconcile` is loading-safe: a transient absence of a mesh (the async fetch 338 + /// window) or a mesh-load error keeps the existing renderer alive; only a hard 339 + /// renderer error or a missing canvas tears the session down. 221 340 pub struct RendererLifecycle<R: ViewerRenderer> { 222 341 active: Option<R>, 342 + active_hash: Option<u64>, 343 + mounts: u64, 223 344 } 224 345 225 - #[cfg(test)] 226 346 impl<R: ViewerRenderer> RendererLifecycle<R> 227 347 where 228 348 R::Canvas: Clone, 229 349 { 230 350 pub fn new() -> Self { 231 - Self { active: None } 351 + Self { 352 + active: None, 353 + active_hash: None, 354 + mounts: 0, 355 + } 232 356 } 233 357 234 358 pub fn reconcile( 235 359 &mut self, 236 360 canvas: Option<R::Canvas>, 237 361 status: &ViewerStatus, 238 - mesh: Option<MeshSummary>, 362 + mount: Option<RendererMount>, 239 363 ) -> Result<(), R::Error> { 364 + // No canvas: the component unmounted or the canvas is gone. Tear down. 240 365 let Some(canvas) = canvas else { 241 366 self.active = None; 367 + self.active_hash = None; 242 368 return Ok(()); 243 369 }; 244 - 245 - let (ViewerStatus::Ready(key), Some(mesh)) = (status, mesh) else { 370 + // A hard renderer failure tears the session down; mesh failures and the 371 + // async Loading window keep the retained renderer alive. 372 + if let ViewerStatus::RendererError(_) = status { 246 373 self.active = None; 374 + self.active_hash = None; 375 + return Ok(()); 376 + } 377 + // No mesh yet (Loading, or no model selected): keep the active renderer. 378 + let Some(mount) = mount else { 247 379 return Ok(()); 248 380 }; 249 - 250 - let mount = RendererMount::new(key.clone(), mesh); 381 + let hash = mount.mesh.content_hash; 251 382 match self.active.as_mut() { 252 - Some(renderer) => renderer.replace_mesh(mount), 253 383 None => { 254 384 self.active = Some(R::mount(canvas, mount)?); 255 - Ok(()) 385 + self.active_hash = Some(hash); 386 + self.mounts += 1; 387 + } 388 + Some(_) if self.active_hash == Some(hash) => { 389 + // Same mesh identity: no-op. The retained context and resources stay. 390 + } 391 + Some(renderer) => { 392 + renderer.replace_mesh(mount)?; 393 + self.active_hash = Some(hash); 256 394 } 257 395 } 396 + Ok(()) 258 397 } 259 398 260 399 pub fn resize(&mut self) -> Result<(), R::Error> { ··· 264 403 Ok(()) 265 404 } 266 405 406 + /// Borrow the active renderer for external event dispatch (pointer/wheel). 407 + pub fn active_mut(&mut self) -> Option<&mut R> { 408 + self.active.as_mut() 409 + } 410 + 267 411 pub fn has_active_session(&self) -> bool { 268 412 self.active.is_some() 269 413 } 414 + 415 + /// Number of times `mount` has run. This is the WebGL-context acquisition 416 + /// count: it should be exactly one per page mount regardless of switches. 417 + pub fn mounts(&self) -> u64 { 418 + self.mounts 419 + } 420 + } 421 + 422 + impl<R: ViewerRenderer> Default for RendererLifecycle<R> 423 + where 424 + R::Canvas: Clone, 425 + { 426 + fn default() -> Self { 427 + Self::new() 428 + } 270 429 } 271 430 272 431 #[component] 273 432 pub fn ViewerPage(initial_model: DemoModel) -> Element { 274 433 let mut selected_model = use_signal(|| initial_model); 275 434 let canvas_mounted = use_signal(|| false); 276 - let renderer_session = use_signal(String::new); 277 435 let resize_count = use_signal(|| 0_u64); 436 + let context_acquisitions = use_signal(|| 0_u64); 437 + let renderer_failed = use_signal(|| false); 438 + let loaded: Signal<Option<LoadedMesh>> = use_signal(|| None); 439 + let status: Signal<ViewerStatus> = use_signal(|| { 440 + ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) 441 + }); 278 442 279 - let model = selected_model(); 280 - let loaded_mesh = load_demo_mesh_handle(model); 281 - let (status, visible_mesh, session_label) = match loaded_mesh { 282 - Ok(mesh) => { 283 - let label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); 284 - (ViewerStatus::Ready(mesh.key.clone()), Some(mesh), label) 443 + // Fetch + parse the selected model on demand, re-keying on selection so a 444 + // rapid switch cancels the in-flight load. Side effects update `loaded` and 445 + // `status`; the returned resource is intentionally unused. 446 + let _mesh_load = use_resource(move || { 447 + let model = *selected_model.read(); 448 + let mut loaded = loaded; 449 + let mut status = status; 450 + async move { 451 + status.set(ViewerStatus::Loading(SessionKey::new( 452 + model.id, 453 + model.generation, 454 + ))); 455 + match load_demo_mesh(model).await { 456 + Ok(result) => { 457 + status.set(ViewerStatus::Ready(SessionKey::new( 458 + model.id, 459 + model.generation, 460 + ))); 461 + loaded.set(Some(result)); 462 + } 463 + Err(issue) => { 464 + status.set(ViewerStatus::MeshError(issue)); 465 + } 466 + } 285 467 } 286 - Err(issue) => ( 287 - ViewerStatus::MeshError(issue), 288 - None, 289 - format!("{}#{}", model.id, model.generation), 290 - ), 291 - }; 468 + }); 469 + 470 + let model = *selected_model.read(); 292 471 293 472 rsx! { 294 473 section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", ··· 298 477 h1 { "Model viewer" } 299 478 p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } 300 479 } 301 - div { class: "viewer-status-pill", "{status.label()}" } 480 + div { class: "viewer-status-pill", "{status.read().label()}" } 302 481 } 303 482 304 483 div { class: "viewer-demo-controls", aria_label: "Model choices", ··· 313 492 } 314 493 315 494 ModelViewer { 316 - mesh: visible_mesh, 495 + loaded, 317 496 status, 318 497 canvas_mounted, 319 - renderer_session, 320 498 resize_count, 321 - session_label, 499 + context_acquisitions, 500 + renderer_failed, 322 501 } 323 502 } 324 503 } ··· 326 505 327 506 #[derive(Props, Clone, PartialEq)] 328 507 pub struct ModelViewerProps { 329 - mesh: Option<ViewerMeshHandle>, 330 - status: ViewerStatus, 508 + loaded: Signal<Option<LoadedMesh>>, 509 + status: Signal<ViewerStatus>, 331 510 canvas_mounted: Signal<bool>, 332 - renderer_session: Signal<String>, 333 511 resize_count: Signal<u64>, 334 - session_label: String, 512 + context_acquisitions: Signal<u64>, 513 + renderer_failed: Signal<bool>, 335 514 } 336 515 337 516 #[component] 338 517 pub fn ModelViewer(props: ModelViewerProps) -> Element { 339 518 let canvas_mounted = props.canvas_mounted; 340 - let renderer_session = props.renderer_session; 341 519 let resize_count = props.resize_count; 342 - let status = props.status.clone(); 343 - let mesh = props.mesh.clone(); 344 - let asset_path = mesh 520 + let context_acquisitions = props.context_acquisitions; 521 + let renderer_failed = props.renderer_failed; 522 + let loaded = props.loaded; 523 + let status = props.status; 524 + 525 + let loaded_val = loaded.read().clone(); 526 + let load_status = status.read().clone(); 527 + // A renderer-init failure overrides the load status so the user sees the 528 + // renderer error rather than a stale "ready" while no context is active. 529 + let status_val = if *renderer_failed.read() { 530 + ViewerStatus::RendererError(ViewerIssue::RendererInit) 531 + } else { 532 + load_status 533 + }; 534 + let summary = loaded_val.as_ref().map(|loaded| loaded.summary); 535 + let asset_path = summary.map(|summary| summary.asset_path).unwrap_or(""); 536 + let session_label = loaded_val 345 537 .as_ref() 346 - .map(|mesh| mesh.summary.asset_path) 347 - .unwrap_or(""); 348 - let renderer_active = status.should_have_session() && renderer_session() == props.session_label; 538 + .map(|loaded| format!("{}#{}", loaded.model.id, loaded.model.generation)) 539 + .or_else(|| { 540 + status_val 541 + .key() 542 + .map(|key| format!("{}#{}", key.model_id(), key.generation())) 543 + }) 544 + .unwrap_or_default(); 545 + let acquisitions = *context_acquisitions.read(); 546 + let renderer_active = acquisitions > 0 && !*renderer_failed.read(); 349 547 let renderer_kind = if renderer_active { 350 548 "three-d" 351 549 } else { ··· 356 554 div { 357 555 class: "model-viewer-boundary", 358 556 id: "polymodel-viewer-boundary", 359 - "data-viewer-status": "{status.label()}", 360 - "data-session-wanted": "{status.should_have_session()}", 361 - "data-resize-count": "{resize_count()}", 362 - "data-session-label": "{props.session_label}", 557 + "data-viewer-status": "{status_val.label()}", 558 + "data-session-wanted": "{status_val.should_have_session()}", 559 + "data-resize-count": "{resize_count.read()}", 560 + "data-session-label": session_label.as_str(), 363 561 "data-asset-path": asset_path.to_string(), 364 562 "data-renderer-kind": renderer_kind, 563 + "data-context-acquisitions": "{acquisitions}", 365 564 366 565 div { class: "model-viewer-stage", 367 566 canvas { 368 567 id: "polymodel-viewer-canvas", 369 - "data-mounted": "{canvas_mounted()}", 568 + "data-mounted": "{canvas_mounted.read()}", 370 569 "data-renderer-kind": renderer_kind, 371 570 class: "model-viewer-canvas", 372 571 aria_label: "Polymodel model viewer canvas", ··· 375 574 onpointerdown: move |event| event.stop_propagation(), 376 575 } 377 576 if !renderer_active { 378 - if let Some(ref mesh) = mesh { 379 - AssetPreview { mesh: mesh.clone() } 577 + if let Some(summary) = summary { 578 + AssetPreview { summary } 380 579 } 381 580 } 382 581 div { class: "model-viewer-overlay", aria_live: "polite", 383 - h2 { "{status.label()}" } 384 - p { "{status.message()}" } 385 - if let Some(ref mesh) = mesh { 582 + h2 { "{status_val.label()}" } 583 + p { "{status_val.message()}" } 584 + if let Some(summary) = summary { 386 585 dl { class: "viewer-mesh-summary", 387 - div { dt { "Model" } dd { "{mesh.summary.display_name}" } } 388 - div { dt { "Asset" } dd { "{mesh.summary.asset_path}" } } 389 - div { dt { "Vertices" } dd { "{mesh.summary.vertices}" } } 390 - div { dt { "Triangles" } dd { "{mesh.summary.triangles}" } } 586 + div { dt { "Model" } dd { "{summary.display_name}" } } 587 + div { dt { "Asset" } dd { "{summary.asset_path}" } } 588 + div { dt { "Vertices" } dd { "{summary.vertices}" } } 589 + div { dt { "Triangles" } dd { "{summary.triangles}" } } 391 590 } 392 591 } 393 592 } 394 593 } 395 594 } 396 595 CanvasRendererSession { 397 - status: status.clone(), 398 - mesh, 596 + loaded, 597 + status, 399 598 canvas_mounted, 400 - renderer_session, 401 599 resize_count, 600 + context_acquisitions, 601 + renderer_failed, 402 602 } 403 603 } 404 604 } 405 605 406 606 #[component] 407 - fn AssetPreview(mesh: ViewerMeshHandle) -> Element { 607 + fn AssetPreview(summary: MeshSummary) -> Element { 408 608 rsx! { 409 609 svg { 410 610 id: "polymodel-viewer-preview", 411 611 class: "model-viewer-preview", 412 612 view_box: "0 0 640 420", 413 613 role: "img", 414 - "aria-label": "Preview silhouette for {mesh.summary.display_name}", 614 + "aria-label": "Preview silhouette for {summary.display_name}", 415 615 path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" } 416 616 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" } 417 617 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" } 418 618 path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" } 419 619 path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" } 420 - text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{mesh.summary.triangles} triangles · {mesh.summary.vertices} vertices" } 620 + text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{summary.triangles} triangles · {summary.vertices} vertices" } 421 621 } 422 622 } 423 623 } 424 624 625 + /// Always renders the browser renderer session. It has no per-model key, so it 626 + /// keeps its position-based identity and stays mounted across loading/model 627 + /// switches — which is what preserves the WebGL context. 425 628 #[component] 426 629 fn CanvasRendererSession( 427 - status: ViewerStatus, 428 - mesh: Option<ViewerMeshHandle>, 630 + loaded: Signal<Option<LoadedMesh>>, 631 + status: Signal<ViewerStatus>, 429 632 canvas_mounted: Signal<bool>, 430 - renderer_session: Signal<String>, 431 633 resize_count: Signal<u64>, 634 + context_acquisitions: Signal<u64>, 635 + renderer_failed: Signal<bool>, 432 636 ) -> Element { 433 - let mount = match (status, mesh) { 434 - (ViewerStatus::Ready(key), Some(mesh)) if canvas_mounted() => { 435 - Some(RendererMount::new(key, mesh.summary)) 436 - } 437 - _ => None, 438 - }; 439 - 440 637 rsx! { 441 - if let Some(mount) = mount { 442 - BrowserRendererSession { key: "{mount.key.model_id()}-{mount.key.generation()}", mount, renderer_session, resize_count } 638 + BrowserRendererSession { 639 + loaded, 640 + status, 641 + canvas_mounted, 642 + resize_count, 643 + context_acquisitions, 644 + renderer_failed, 443 645 } 444 646 } 445 647 } ··· 447 649 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 448 650 #[component] 449 651 fn BrowserRendererSession( 450 - mount: RendererMount, 451 - renderer_session: Signal<String>, 652 + loaded: Signal<Option<LoadedMesh>>, 653 + status: Signal<ViewerStatus>, 654 + canvas_mounted: Signal<bool>, 452 655 resize_count: Signal<u64>, 656 + context_acquisitions: Signal<u64>, 657 + renderer_failed: Signal<bool>, 453 658 ) -> Element { 454 - let _ = mount; 455 - let _ = renderer_session; 456 - let _ = resize_count; 659 + let _ = ( 660 + loaded, 661 + status, 662 + canvas_mounted, 663 + resize_count, 664 + context_acquisitions, 665 + renderer_failed, 666 + ); 457 667 rsx! {} 458 668 } 459 669 460 670 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 461 671 #[component] 462 672 fn BrowserRendererSession( 463 - mount: RendererMount, 464 - renderer_session: Signal<String>, 673 + loaded: Signal<Option<LoadedMesh>>, 674 + status: Signal<ViewerStatus>, 675 + canvas_mounted: Signal<bool>, 465 676 resize_count: Signal<u64>, 677 + context_acquisitions: Signal<u64>, 678 + mut renderer_failed: Signal<bool>, 466 679 ) -> Element { 467 - let _session = use_hook(move || { 468 - std::rc::Rc::new(ThreeDRendererSession::mount( 469 - mount, 470 - renderer_session, 471 - resize_count, 472 - )) 680 + use std::cell::RefCell; 681 + use std::rc::Rc; 682 + 683 + // Persistent retained session: a renderer lifecycle plus a guard that 684 + // detaches the canvas listeners on unmount. The guard is held first so it 685 + // drops (and detaches listeners) before the renderer (and its WebGL 686 + // context) is released. Listener closures hold only a `Weak` reference to 687 + // the lifecycle, so they never keep the session alive. 688 + let holders = use_hook(|| { 689 + let lifecycle: Rc<RefCell<RendererLifecycle<ThreeDRenderer>>> = 690 + Rc::new(RefCell::new(RendererLifecycle::new())); 691 + let guard: Rc<RefCell<ListenerGuard>> = Rc::new(RefCell::new(ListenerGuard::default())); 692 + Rc::new((guard, lifecycle)) 473 693 }); 694 + 695 + let weak_lifecycle = Rc::downgrade(&holders.1); 696 + use_effect(move || { 697 + let canvas_present = *canvas_mounted.read(); 698 + let status_val = status.read().clone(); 699 + let loaded_val = loaded.read().clone(); 700 + let mount_opt = loaded_val.as_ref().map(|loaded| { 701 + RendererMount::new( 702 + SessionKey::new(loaded.model.id, loaded.model.generation), 703 + loaded.parsed.clone(), 704 + loaded.summary, 705 + ) 706 + }); 707 + 708 + // Attach canvas listeners once, as soon as the canvas is in the DOM. 709 + let needs_attach = canvas_present && holders.0.borrow().cleanup.is_none(); 710 + if needs_attach && let Some(canvas) = lookup_viewer_canvas() { 711 + let cleanup = attach_renderer_listeners(canvas, weak_lifecycle.clone(), resize_count); 712 + holders.0.borrow_mut().cleanup = Some(cleanup); 713 + } 714 + 715 + let mut lifecycle = holders.1.borrow_mut(); 716 + let result = lifecycle.reconcile(canvas_present.then_some(()), &status_val, mount_opt); 717 + let mounts = lifecycle.mounts(); 718 + drop(lifecycle); 719 + 720 + // Publish the context-acquisition count (the single WebGL-context signal). 721 + // This effect reads only canvas_mounted/status/loaded, so writing these signals never re-runs it. 722 + context_acquisitions.set(mounts); 723 + renderer_failed.set(result.is_err()); 724 + if let Err(err) = result { 725 + tracing::warn!(?err, "three-d renderer reconcile failed"); 726 + } 727 + }); 728 + 474 729 rsx! {} 475 730 } 476 731 732 + /// Holds the canvas-listener cleanup closure and runs it on drop (component 733 + /// unmount), detaching listeners from the canvas and window. 477 734 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 478 - struct ThreeDRendererSession { 479 - mount: RendererMount, 480 - renderer: Option<std::rc::Rc<std::cell::RefCell<ThreeDRenderer>>>, 481 - renderer_session: Signal<String>, 735 + #[derive(Default)] 736 + struct ListenerGuard { 482 737 cleanup: Option<Box<dyn FnOnce()>>, 483 738 } 484 739 485 740 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 486 - impl ThreeDRendererSession { 487 - fn mount( 488 - mount: RendererMount, 489 - mut renderer_session: Signal<String>, 490 - resize_count: Signal<u64>, 491 - ) -> Self { 492 - use wasm_bindgen::JsCast; 493 - use wasm_bindgen::closure::Closure; 494 - use web_sys::{Event, MouseEvent, PointerEvent, WheelEvent}; 741 + impl Drop for ListenerGuard { 742 + fn drop(&mut self) { 743 + if let Some(cleanup) = self.cleanup.take() { 744 + cleanup(); 745 + } 746 + } 747 + } 495 748 496 - let mut cleanup_callbacks: Vec<Box<dyn FnOnce()>> = Vec::new(); 497 - let renderer = match ThreeDRenderer::new(&mount) { 498 - Ok(renderer) => Some(std::rc::Rc::new(std::cell::RefCell::new(renderer))), 499 - Err(err) => { 500 - tracing::warn!(error = %err, "three-d renderer initialization failed"); 501 - None 502 - } 503 - }; 749 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 750 + fn lookup_viewer_canvas() -> Option<web_sys::HtmlCanvasElement> { 751 + use wasm_bindgen::JsCast; 752 + web_sys::window()? 753 + .document()? 754 + .get_element_by_id("polymodel-viewer-canvas")? 755 + .dyn_into::<web_sys::HtmlCanvasElement>() 756 + .ok() 757 + } 504 758 505 - if let Some(window) = web_sys::window() { 506 - let resize_renderer = renderer.clone(); 507 - let mut resize_count = resize_count; 508 - let resize_closure = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_| { 509 - resize_count.set(resize_count() + 1); 510 - if let Some(renderer) = resize_renderer.as_ref() { 511 - renderer.borrow_mut().resize_and_render(); 512 - } 513 - })); 514 - if window 515 - .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) 516 - .is_ok() 759 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 760 + fn attach_renderer_listeners( 761 + canvas: web_sys::HtmlCanvasElement, 762 + weak: std::rc::Weak<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>, 763 + resize_count: Signal<u64>, 764 + ) -> Box<dyn FnOnce()> { 765 + use wasm_bindgen::JsCast; 766 + use wasm_bindgen::closure::Closure; 767 + use web_sys::{Event, MouseEvent, WheelEvent}; 768 + 769 + let mut cleanups: Vec<Box<dyn FnOnce()>> = Vec::new(); 770 + 771 + // Window resize: bump the resize counter and refresh the active renderer. 772 + if let Some(window) = web_sys::window() { 773 + let weak_resize = weak.clone(); 774 + let mut resize_counter = resize_count; 775 + let resize_closure = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_| { 776 + let next = *resize_counter.read() + 1; 777 + resize_counter.set(next); 778 + if let Some(handle) = weak_resize.upgrade() 779 + && let Ok(mut lifecycle) = handle.try_borrow_mut() 517 780 { 518 - cleanup_callbacks.push(Box::new(move || { 519 - if let Some(window) = web_sys::window() { 520 - let _ = window.remove_event_listener_with_callback( 521 - "resize", 522 - resize_closure.as_ref().unchecked_ref(), 523 - ); 524 - } 525 - })); 781 + let _ = lifecycle.resize(); 526 782 } 783 + })); 784 + if window 785 + .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) 786 + .is_ok() 787 + { 788 + cleanups.push(Box::new(move || { 789 + if let Some(window) = web_sys::window() { 790 + let _ = window.remove_event_listener_with_callback( 791 + "resize", 792 + resize_closure.as_ref().unchecked_ref(), 793 + ); 794 + } 795 + })); 527 796 } 797 + } 528 798 529 - if let Some(renderer) = renderer.as_ref() { 530 - let canvas = renderer.borrow().canvas.clone(); 531 - let down_renderer = renderer.clone(); 532 - let down = 533 - Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 534 - event.prevent_default(); 535 - if let Some(target) = event 536 - .target() 537 - .and_then(|target| target.dyn_into::<web_sys::HtmlCanvasElement>().ok()) 538 - { 539 - let _ = target.set_pointer_capture(event.pointer_id()); 540 - } 541 - down_renderer 542 - .borrow_mut() 543 - .pointer_down(event.client_x() as f32, event.client_y() as f32); 544 - })); 545 - if canvas 546 - .add_event_listener_with_callback("pointerdown", down.as_ref().unchecked_ref()) 547 - .is_ok() 799 + if let Some(cleanup) = 800 + register_canvas_pointer(&canvas, "pointerdown", weak.clone(), |renderer, event| { 801 + if let Some(target) = event 802 + .target() 803 + .and_then(|target| target.dyn_into::<web_sys::HtmlCanvasElement>().ok()) 548 804 { 549 - let canvas = canvas.clone(); 550 - cleanup_callbacks.push(Box::new(move || { 551 - let _ = canvas.remove_event_listener_with_callback( 552 - "pointerdown", 553 - down.as_ref().unchecked_ref(), 554 - ); 555 - })); 805 + let _ = target.set_pointer_capture(event.pointer_id()); 556 806 } 807 + renderer.pointer_down(event.client_x() as f32, event.client_y() as f32); 808 + }) 809 + { 810 + cleanups.push(cleanup); 811 + } 557 812 558 - let canvas = renderer.borrow().canvas.clone(); 559 - let move_renderer = renderer.clone(); 560 - let pointer_move = 561 - Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 562 - event.prevent_default(); 563 - move_renderer 564 - .borrow_mut() 565 - .pointer_move(event.client_x() as f32, event.client_y() as f32); 566 - })); 567 - if canvas 568 - .add_event_listener_with_callback( 569 - "pointermove", 570 - pointer_move.as_ref().unchecked_ref(), 571 - ) 572 - .is_ok() 573 - { 574 - let canvas = canvas.clone(); 575 - cleanup_callbacks.push(Box::new(move || { 576 - let _ = canvas.remove_event_listener_with_callback( 577 - "pointermove", 578 - pointer_move.as_ref().unchecked_ref(), 579 - ); 580 - })); 581 - } 813 + if let Some(cleanup) = 814 + register_canvas_pointer(&canvas, "pointermove", weak.clone(), |renderer, event| { 815 + renderer.pointer_move(event.client_x() as f32, event.client_y() as f32); 816 + }) 817 + { 818 + cleanups.push(cleanup); 819 + } 582 820 583 - let canvas = renderer.borrow().canvas.clone(); 584 - let up_renderer = renderer.clone(); 585 - let pointer_up = 586 - Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 587 - event.prevent_default(); 588 - up_renderer.borrow_mut().pointer_up(); 589 - })); 590 - if canvas 591 - .add_event_listener_with_callback("pointerup", pointer_up.as_ref().unchecked_ref()) 592 - .is_ok() 821 + if let Some(cleanup) = 822 + register_canvas_pointer(&canvas, "pointerup", weak.clone(), |renderer, _event| { 823 + renderer.pointer_up(); 824 + }) 825 + { 826 + cleanups.push(cleanup); 827 + } 828 + 829 + // pointerleave is dispatched as a MouseEvent; release any in-progress drag. 830 + { 831 + let canvas = canvas.clone(); 832 + let leave_weak = weak.clone(); 833 + let closure = Closure::<dyn FnMut(MouseEvent)>::wrap(Box::new(move |_| { 834 + if let Some(handle) = leave_weak.upgrade() 835 + && let Ok(mut lifecycle) = handle.try_borrow_mut() 836 + && let Some(renderer) = lifecycle.active_mut() 593 837 { 594 - let canvas = canvas.clone(); 595 - cleanup_callbacks.push(Box::new(move || { 596 - let _ = canvas.remove_event_listener_with_callback( 597 - "pointerup", 598 - pointer_up.as_ref().unchecked_ref(), 599 - ); 600 - })); 838 + renderer.pointer_up(); 601 839 } 602 - 603 - let canvas = renderer.borrow().canvas.clone(); 604 - let leave_renderer = renderer.clone(); 605 - let pointer_leave = Closure::<dyn FnMut(MouseEvent)>::wrap(Box::new(move |_| { 606 - leave_renderer.borrow_mut().pointer_up(); 607 - })); 608 - if canvas 609 - .add_event_listener_with_callback( 840 + })); 841 + if canvas 842 + .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref()) 843 + .is_ok() 844 + { 845 + cleanups.push(Box::new(move || { 846 + let _ = canvas.remove_event_listener_with_callback( 610 847 "mouseleave", 611 - pointer_leave.as_ref().unchecked_ref(), 612 - ) 613 - .is_ok() 614 - { 615 - let canvas = canvas.clone(); 616 - cleanup_callbacks.push(Box::new(move || { 617 - let _ = canvas.remove_event_listener_with_callback( 618 - "mouseleave", 619 - pointer_leave.as_ref().unchecked_ref(), 620 - ); 621 - })); 622 - } 848 + closure.as_ref().unchecked_ref(), 849 + ); 850 + })); 851 + } 852 + } 623 853 624 - let canvas = renderer.borrow().canvas.clone(); 625 - let wheel_renderer = renderer.clone(); 626 - let wheel = 627 - Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| { 628 - event.prevent_default(); 629 - wheel_renderer.borrow_mut().wheel( 630 - event.delta_y() as f32, 631 - event.client_x() as f32, 632 - event.client_y() as f32, 633 - ); 634 - })); 635 - if canvas 636 - .add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref()) 637 - .is_ok() 854 + // wheel zoom. 855 + { 856 + let canvas = canvas.clone(); 857 + let wheel_weak = weak; 858 + let closure = Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| { 859 + event.prevent_default(); 860 + if let Some(handle) = wheel_weak.upgrade() 861 + && let Ok(mut lifecycle) = handle.try_borrow_mut() 862 + && let Some(renderer) = lifecycle.active_mut() 638 863 { 639 - let canvas = canvas.clone(); 640 - cleanup_callbacks.push(Box::new(move || { 641 - let _ = canvas.remove_event_listener_with_callback( 642 - "wheel", 643 - wheel.as_ref().unchecked_ref(), 644 - ); 645 - })); 864 + renderer.wheel( 865 + event.delta_y() as f32, 866 + event.client_x() as f32, 867 + event.client_y() as f32, 868 + ); 646 869 } 870 + })); 871 + if canvas 872 + .add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()) 873 + .is_ok() 874 + { 875 + cleanups.push(Box::new(move || { 876 + let _ = canvas 877 + .remove_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()); 878 + })); 647 879 } 880 + } 648 881 649 - if let Some(renderer) = renderer.as_ref() { 650 - renderer.borrow_mut().resize_and_render(); 651 - renderer_session.set(format!( 652 - "{}#{}", 653 - mount.key.model_id(), 654 - mount.key.generation() 655 - )); 656 - } else { 657 - renderer_session.set(String::new()); 882 + Box::new(move || { 883 + for cleanup in cleanups.into_iter().rev() { 884 + cleanup(); 658 885 } 886 + }) 887 + } 659 888 660 - Self { 661 - mount, 662 - renderer, 663 - renderer_session, 664 - cleanup: Some(Box::new(move || { 665 - for cleanup in cleanup_callbacks.into_iter().rev() { 666 - cleanup(); 667 - } 668 - })), 669 - } 889 + /// Register a pointer listener on the canvas that dispatches to the active 890 + /// renderer through a `Weak` lifecycle handle. Returns a cleanup closure on 891 + /// success. Kept as a function (rather than a closure) to keep the types simple. 892 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 893 + fn register_canvas_pointer( 894 + canvas: &web_sys::HtmlCanvasElement, 895 + name: &'static str, 896 + weak: std::rc::Weak<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>, 897 + dispatch: impl Fn(&mut ThreeDRenderer, web_sys::PointerEvent) + 'static, 898 + ) -> Option<Box<dyn FnOnce()>> { 899 + use wasm_bindgen::JsCast; 900 + use wasm_bindgen::closure::Closure; 901 + 902 + let closure = Closure::<dyn FnMut(web_sys::PointerEvent)>::wrap(Box::new( 903 + move |event: web_sys::PointerEvent| { 904 + event.prevent_default(); 905 + if let Some(handle) = weak.upgrade() 906 + && let Ok(mut lifecycle) = handle.try_borrow_mut() 907 + && let Some(renderer) = lifecycle.active_mut() 908 + { 909 + dispatch(renderer, event); 910 + } 911 + }, 912 + )); 913 + if canvas 914 + .add_event_listener_with_callback(name, closure.as_ref().unchecked_ref()) 915 + .is_ok() 916 + { 917 + let canvas = canvas.clone(); 918 + Some(Box::new(move || { 919 + let _ = 920 + canvas.remove_event_listener_with_callback(name, closure.as_ref().unchecked_ref()); 921 + })) 922 + } else { 923 + None 670 924 } 671 925 } 672 926 673 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 674 - impl Drop for ThreeDRendererSession { 675 - fn drop(&mut self) { 676 - tracing::debug!( 677 - model_id = self.mount.key.model_id(), 678 - generation = self.mount.key.generation(), 679 - asset_path = self.mount.mesh.asset_path, 680 - had_renderer = self.renderer.is_some(), 681 - "dropping three-d renderer session" 682 - ); 683 - let session_label = format!( 684 - "{}#{}", 685 - self.mount.key.model_id(), 686 - self.mount.key.generation() 687 - ); 688 - if (self.renderer_session)() == session_label { 689 - self.renderer_session.set(String::new()); 690 - } 691 - if let Some(cleanup) = self.cleanup.take() { 692 - cleanup(); 693 - } 927 + /// Typed renderer-initialization error, replacing the spike's `String` boundary. 928 + /// 929 + /// Parse/asset failures are handled at the loader boundary 930 + /// ([`ViewerIssue::MeshUnavailable`]); this covers only what the renderer can 931 + /// fail on while acquiring its WebGL2 context and building its scene. 932 + #[derive(Debug, thiserror::Error)] 933 + pub(crate) enum ThreeDRendererError { 934 + #[error("browser window is unavailable")] 935 + WindowUnavailable, 936 + #[error("browser document is unavailable")] 937 + DocumentUnavailable, 938 + #[error("viewer canvas element not found")] 939 + CanvasNotFound, 940 + #[error("viewer element is not a canvas")] 941 + NotCanvas, 942 + #[error("WebGL2 context could not be created")] 943 + WebGL2Unavailable, 944 + #[error("rendering context is not a WebGL2 context")] 945 + NotWebGL2, 946 + #[error("three-d core context error: {0}")] 947 + Core(#[from] three_d::core::CoreError), 948 + } 949 + 950 + impl From<ThreeDRendererError> for ViewerIssue { 951 + fn from(_: ThreeDRendererError) -> Self { 952 + ViewerIssue::RendererInit 694 953 } 695 954 } 696 955 ··· 712 971 713 972 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 714 973 impl ThreeDRenderer { 715 - fn new(mount: &RendererMount) -> Result<Self, String> { 716 - use wasm_bindgen::JsCast; 717 - 718 - let bytes = demo_model_bytes(mount.mesh.asset_path) 719 - .ok_or_else(|| "unknown demo asset".to_string())?; 720 - let model = load_stl(bytes).map_err(|err| err.to_string())?; 721 - let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; 722 - let document = window 723 - .document() 724 - .ok_or_else(|| "document unavailable".to_string())?; 725 - let canvas = document 726 - .get_element_by_id("polymodel-viewer-canvas") 727 - .ok_or_else(|| "viewer canvas not found".to_string())? 728 - .dyn_into::<web_sys::HtmlCanvasElement>() 729 - .map_err(|_| "viewer element is not a canvas".to_string())?; 730 - let gl = canvas 731 - .get_context("webgl2") 732 - .map_err(|_| "webgl2 context lookup failed".to_string())? 733 - .ok_or_else(|| "webgl2 unavailable".to_string())? 734 - .dyn_into::<web_sys::WebGl2RenderingContext>() 735 - .map_err(|_| "context is not WebGL2".to_string())?; 736 - let glow_context = three_d::context::Context::from_webgl2_context(gl); 737 - #[allow( 738 - clippy::arc_with_non_send_sync, 739 - reason = "three-d requires Arc<glow::Context> for browser WebGL context construction" 740 - )] 741 - let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context)) 742 - .map_err(|err| err.to_string())?; 974 + /// Build the GPU mesh + camera framing for `model` against an existing 975 + /// context. Shared by the initial mount and mesh replacement. 976 + fn build_scene( 977 + context: &three_d::Context, 978 + canvas: &web_sys::HtmlCanvasElement, 979 + model: &ModelMesh, 980 + ) -> ( 981 + three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>, 982 + three_d::Camera, 983 + three_d::OrbitControl, 984 + ) { 743 985 let fit = model.camera_fit(); 744 - let cpu_mesh = model.trimesh; 745 986 let mesh = three_d::Gm::new( 746 - three_d::Mesh::new(&context, &cpu_mesh), 987 + three_d::Mesh::new(context, &model.trimesh), 747 988 three_d::PhysicalMaterial { 748 989 albedo: three_d::Srgba::new_opaque(116, 180, 255), 749 990 roughness: 0.72, ··· 754 995 let radius = fit.radius.max(1.0); 755 996 let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 756 997 let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); 757 - let viewport = canvas_viewport(&canvas); 998 + let viewport = canvas_viewport(canvas); 758 999 let camera = three_d::Camera::new_perspective( 759 1000 viewport, 760 1001 position, ··· 765 1006 radius * 20.0, 766 1007 ); 767 1008 let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); 768 - let ambient_light = 769 - three_d::AmbientLight::new(&context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); 770 - let key_light = three_d::DirectionalLight::new( 771 - &context, 772 - 2.6, 773 - three_d::Srgba::new_opaque(255, 255, 255), 774 - three_d::vec3(-0.45, -0.55, -0.70), 775 - ); 776 - let fill_light = three_d::DirectionalLight::new( 777 - &context, 778 - 0.9, 779 - three_d::Srgba::new_opaque(170, 205, 255), 780 - three_d::vec3(0.55, 0.35, -0.35), 781 - ); 782 - let frame_started_ms = window.performance().map(|p| p.now()).unwrap_or(0.0); 783 - 784 - Ok(Self { 785 - canvas, 786 - context, 787 - mesh, 788 - ambient_light, 789 - key_light, 790 - fill_light, 791 - camera, 792 - control, 793 - button_down: false, 794 - last_pointer: None, 795 - frame_started_ms, 796 - first_render_logged: false, 797 - }) 1009 + (mesh, camera, control) 798 1010 } 799 1011 800 1012 fn resize_and_render(&mut self) { ··· 859 1071 if !self.first_render_logged { 860 1072 self.first_render_logged = true; 861 1073 let elapsed = web_sys::window() 862 - .and_then(|w| w.performance()) 863 - .map(|p| p.now() - self.frame_started_ms) 1074 + .and_then(|window| window.performance()) 1075 + .map(|performance| performance.now() - self.frame_started_ms) 864 1076 .unwrap_or(0.0); 865 1077 tracing::info!( 866 1078 backend = "WebGL2", ··· 872 1084 } 873 1085 874 1086 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 1087 + impl ViewerRenderer for ThreeDRenderer { 1088 + type Canvas = (); 1089 + type Error = ThreeDRendererError; 1090 + 1091 + fn mount(_: Self::Canvas, mount: RendererMount) -> Result<Self, Self::Error> { 1092 + use wasm_bindgen::JsCast; 1093 + 1094 + let model = &mount.mesh.mesh; 1095 + let window = web_sys::window().ok_or(ThreeDRendererError::WindowUnavailable)?; 1096 + let document = window 1097 + .document() 1098 + .ok_or(ThreeDRendererError::DocumentUnavailable)?; 1099 + let canvas = document 1100 + .get_element_by_id("polymodel-viewer-canvas") 1101 + .ok_or(ThreeDRendererError::CanvasNotFound)? 1102 + .dyn_into::<web_sys::HtmlCanvasElement>() 1103 + .map_err(|_| ThreeDRendererError::NotCanvas)?; 1104 + let gl = canvas 1105 + .get_context("webgl2") 1106 + .map_err(|_| ThreeDRendererError::WebGL2Unavailable)? 1107 + .ok_or(ThreeDRendererError::WebGL2Unavailable)? 1108 + .dyn_into::<web_sys::WebGl2RenderingContext>() 1109 + .map_err(|_| ThreeDRendererError::NotWebGL2)?; 1110 + let glow_context = three_d::context::Context::from_webgl2_context(gl); 1111 + #[allow( 1112 + clippy::arc_with_non_send_sync, 1113 + reason = "three-d requires Arc<glow::Context> for browser WebGL context construction" 1114 + )] 1115 + let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context))?; 1116 + tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired"); 1117 + 1118 + let (mesh, camera, control) = Self::build_scene(&context, &canvas, model); 1119 + let ambient_light = 1120 + three_d::AmbientLight::new(&context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); 1121 + let key_light = three_d::DirectionalLight::new( 1122 + &context, 1123 + 2.6, 1124 + three_d::Srgba::new_opaque(255, 255, 255), 1125 + three_d::vec3(-0.45, -0.55, -0.70), 1126 + ); 1127 + let fill_light = three_d::DirectionalLight::new( 1128 + &context, 1129 + 0.9, 1130 + three_d::Srgba::new_opaque(170, 205, 255), 1131 + three_d::vec3(0.55, 0.35, -0.35), 1132 + ); 1133 + let frame_started_ms = window 1134 + .performance() 1135 + .map(|performance| performance.now()) 1136 + .unwrap_or(0.0); 1137 + 1138 + Ok(Self { 1139 + canvas, 1140 + context, 1141 + mesh, 1142 + ambient_light, 1143 + key_light, 1144 + fill_light, 1145 + camera, 1146 + control, 1147 + button_down: false, 1148 + last_pointer: None, 1149 + frame_started_ms, 1150 + first_render_logged: false, 1151 + }) 1152 + } 1153 + 1154 + fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error> { 1155 + let model = &mount.mesh.mesh; 1156 + let (mesh, camera, control) = Self::build_scene(&self.context, &self.canvas, model); 1157 + self.mesh = mesh; 1158 + self.camera = camera; 1159 + self.control = control; 1160 + self.render(); 1161 + Ok(()) 1162 + } 1163 + 1164 + fn resize(&mut self) -> Result<(), Self::Error> { 1165 + self.resize_and_render(); 1166 + Ok(()) 1167 + } 1168 + } 1169 + 1170 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 875 1171 fn canvas_viewport(canvas: &web_sys::HtmlCanvasElement) -> three_d::Viewport { 876 1172 let rect = canvas.get_bounding_client_rect(); 877 1173 let scale = web_sys::window() 878 - .map(|w| w.device_pixel_ratio()) 1174 + .map(|window| window.device_pixel_ratio()) 879 1175 .unwrap_or(1.0); 880 1176 let width = ((rect.width() * scale).round() as u32).max(1); 881 1177 let height = ((rect.height() * scale).round() as u32).max(1); ··· 900 1196 #[cfg(test)] 901 1197 mod tests { 902 1198 use super::*; 903 - use crate::mesh::stl::load_stl; 1199 + use crate::mesh::contract::{LengthUnit, MeshFormat, Units}; 1200 + use std::cell::Cell; 1201 + use std::rc::Rc; 1202 + 1203 + fn fixture_bytes(asset_path: &str) -> Vec<u8> { 1204 + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); 1205 + std::fs::read(&path) 1206 + .unwrap_or_else(|_| panic!("demo STL asset {asset_path} should be committed at {path}")) 1207 + } 1208 + 1209 + fn parsed_for(model: DemoModel) -> Arc<ParsedMesh> { 1210 + build_parsed_mesh(&fixture_bytes(model.asset_path), &StlParser) 1211 + .expect("demo STL asset should parse") 1212 + } 1213 + 1214 + fn mount_for(model: DemoModel) -> RendererMount { 1215 + let parsed = parsed_for(model); 1216 + let summary = mesh_summary_from_model(model, &parsed.mesh); 1217 + RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary) 1218 + } 904 1219 905 1220 #[derive(Clone)] 906 1221 struct FakeCanvas; ··· 939 1254 } 940 1255 } 941 1256 942 - fn first_mount() -> RendererMount { 943 - let model = demo_models()[0]; 944 - let handle = ViewerMeshHandle::from_demo_model(model); 945 - RendererMount::new(handle.key, handle.summary) 946 - } 947 - 948 1257 #[test] 949 1258 fn demo_route_uses_committed_stl_asset() { 950 1259 let model = demo_model_for_route("body-f-chest-v4"); 951 - assert_eq!(model.asset_path, "/assets/models/body_f_chest-v4.stl"); 1260 + assert_eq!(model.asset_path, "/models/body_f_chest-v4.stl"); 952 1261 assert_eq!(model.vertices, 41051); 953 1262 assert_eq!(model.triangles, 82122); 954 1263 } ··· 956 1265 #[test] 957 1266 fn demo_model_metadata_matches_committed_fixture() { 958 1267 let model = demo_models()[0]; 959 - let path = format!("{}{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); 960 - let bytes = std::fs::read(path).expect("demo STL asset should be committed"); 1268 + let bytes = fixture_bytes(model.asset_path); 961 1269 let mesh = load_stl(&bytes).expect("demo STL asset should parse"); 962 1270 963 1271 assert_eq!(mesh.trimesh.positions.len(), model.vertices); 964 1272 assert_eq!(mesh.trimesh.triangle_count(), model.triangles); 1273 + assert_eq!(mesh.format, MeshFormat::Stl); 1274 + assert_eq!( 1275 + mesh.units, 1276 + Units { 1277 + source: LengthUnit::Unknown, 1278 + assumed: Some(LengthUnit::Millimeter), 1279 + } 1280 + ); 965 1281 } 966 1282 967 1283 #[test] ··· 976 1292 #[test] 977 1293 fn status_labels_are_user_facing() { 978 1294 let ready = ViewerStatus::Ready(SessionKey::new("body-f-chest-v4", 1)); 1295 + let loading = ViewerStatus::Loading(SessionKey::new("body-f-chest-v4", 1)); 979 1296 980 1297 assert_eq!(ready.label(), "Viewer ready"); 1298 + assert_eq!(loading.label(), "Loading model"); 981 1299 assert_eq!( 982 1300 ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).label(), 983 1301 "Mesh unavailable" 984 1302 ); 1303 + assert!(ready.should_have_session()); 1304 + assert!(loading.should_have_session()); 1305 + assert!(!ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).should_have_session()); 1306 + } 1307 + 1308 + #[test] 1309 + fn parsed_mesh_equality_uses_content_hash() { 1310 + let chest = parsed_for(DEMO_MODELS[0]); 1311 + let chest_again = parsed_for(DEMO_MODELS[0]); 1312 + let forearm = parsed_for(DEMO_MODELS[1]); 1313 + 1314 + assert_eq!(chest, chest_again, "same source bytes must be equal"); 1315 + assert_ne!(chest, forearm, "different source bytes must differ"); 1316 + assert_eq!(&chest, &chest_again, "Arc/ParsedMesh compare by hash"); 1317 + } 1318 + 1319 + #[test] 1320 + fn parses_each_loaded_mesh_once() { 1321 + #[derive(Default)] 1322 + struct CountingParser { 1323 + calls: Rc<Cell<usize>>, 1324 + } 1325 + impl MeshParser for CountingParser { 1326 + fn parse(&self, bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> { 1327 + self.calls.set(self.calls.get() + 1); 1328 + load_stl(bytes) 1329 + } 1330 + } 1331 + 1332 + let parser = CountingParser::default(); 1333 + let bytes = fixture_bytes(DEMO_MODELS[0].asset_path); 1334 + let parsed = build_parsed_mesh(&bytes, &parser).expect("parses"); 1335 + assert_eq!( 1336 + parser.calls.get(), 1337 + 1, 1338 + "the boundary must parse once per load" 1339 + ); 1340 + 1341 + let again = build_parsed_mesh(&bytes, &parser).unwrap(); 1342 + assert_eq!( 1343 + again.content_hash, parsed.content_hash, 1344 + "the same bytes must hash to the same identity" 1345 + ); 1346 + } 1347 + 1348 + #[test] 1349 + fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() { 1350 + let errors: Vec<ThreeDRendererError> = vec![ 1351 + ThreeDRendererError::WindowUnavailable, 1352 + ThreeDRendererError::DocumentUnavailable, 1353 + ThreeDRendererError::CanvasNotFound, 1354 + ThreeDRendererError::NotCanvas, 1355 + ThreeDRendererError::WebGL2Unavailable, 1356 + ThreeDRendererError::NotWebGL2, 1357 + ThreeDRendererError::Core(three_d::core::CoreError::ContextCreation("boom".into())), 1358 + ]; 1359 + for err in errors { 1360 + let issue: ViewerIssue = err.into(); 1361 + assert_eq!(issue, ViewerIssue::RendererInit); 1362 + } 1363 + } 1364 + 1365 + #[tokio::test] 1366 + async fn loaded_mesh_carries_parsed_payload_and_summary() { 1367 + let model = DEMO_MODELS[0]; 1368 + let loaded = load_demo_mesh(model) 1369 + .await 1370 + .expect("non-wasm disk load should succeed"); 1371 + 1372 + assert_eq!(loaded.model, model); 1373 + assert_eq!(loaded.summary.asset_path, model.asset_path); 1374 + assert_eq!(loaded.summary.vertices, model.vertices); 1375 + assert_eq!(loaded.summary.triangles, model.triangles); 1376 + assert_eq!( 1377 + loaded.parsed.content_hash, 1378 + hash_bytes(&fixture_bytes(model.asset_path)) 1379 + ); 985 1380 } 986 1381 987 1382 #[test] ··· 990 1385 let drops = Rc::new(Cell::new(0)); 991 1386 let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); 992 1387 let mut lifecycle = RendererLifecycle::<FakeRenderer>::new(); 993 - let mount = first_mount(); 994 - let status = ViewerStatus::Ready(mount.key.clone()); 1388 + let chest = mount_for(DEMO_MODELS[0]); 1389 + let forearm = mount_for(DEMO_MODELS[1]); 1390 + let ready_chest = ViewerStatus::Ready(chest.key.clone()); 995 1391 1392 + // Initial mount. 996 1393 lifecycle 997 - .reconcile(Some(canvas.clone()), &status, Some(mount.mesh)) 1394 + .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) 998 1395 .expect("mount should succeed"); 999 1396 assert!(lifecycle.has_active_session()); 1397 + assert!(lifecycle.active_mut().is_some()); 1000 1398 assert_eq!(events.get(), 1); 1399 + assert_eq!(lifecycle.mounts(), 1); 1001 1400 1401 + // Resize forwards to the active renderer. 1002 1402 lifecycle.resize().expect("resize should forward"); 1003 1403 assert_eq!(events.get(), 2); 1004 1404 1005 - let replacement = 1006 - RendererMount::new(SessionKey::new("body-f-forearm-2x-v9", 2), mount.mesh); 1405 + // Same-identity re-render is a no-op (no replace). 1007 1406 lifecycle 1008 - .reconcile( 1009 - Some(canvas), 1010 - &ViewerStatus::Ready(replacement.key), 1011 - Some(replacement.mesh), 1012 - ) 1407 + .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) 1408 + .expect("same-identity reconcile should succeed"); 1409 + assert_eq!(events.get(), 2); 1410 + 1411 + // Switch to a different model: replace once, still a single mount. 1412 + let ready_forearm = ViewerStatus::Ready(forearm.key.clone()); 1413 + lifecycle 1414 + .reconcile(Some(canvas.clone()), &ready_forearm, Some(forearm.clone())) 1013 1415 .expect("replace should succeed"); 1014 1416 assert_eq!(events.get(), 3); 1015 1417 assert_eq!(drops.get(), 0); 1418 + assert_eq!(lifecycle.mounts(), 1); 1016 1419 1420 + // Loading window (no mesh yet) keeps the active renderer alive. 1017 1421 lifecycle 1018 1422 .reconcile( 1423 + Some(canvas.clone()), 1424 + &ViewerStatus::Loading(SessionKey::new("body-f-forearm-2x-v9", 1)), 1019 1425 None, 1020 - &ViewerStatus::RendererError(ViewerIssue::RendererInit), 1426 + ) 1427 + .expect("loading reconcile should succeed"); 1428 + assert!(lifecycle.has_active_session()); 1429 + assert_eq!(drops.get(), 0); 1430 + 1431 + // A mesh-load error also keeps the retained renderer. 1432 + lifecycle 1433 + .reconcile( 1434 + Some(canvas.clone()), 1435 + &ViewerStatus::MeshError(ViewerIssue::MeshUnavailable), 1021 1436 None, 1022 1437 ) 1023 - .expect("drop path should succeed"); 1438 + .expect("mesh-error reconcile should succeed"); 1439 + assert!(lifecycle.has_active_session()); 1440 + 1441 + // A hard renderer error tears the session down. 1442 + lifecycle 1443 + .reconcile( 1444 + Some(canvas.clone()), 1445 + &ViewerStatus::RendererError(ViewerIssue::RendererInit), 1446 + Some(forearm.clone()), 1447 + ) 1448 + .expect("renderer-error reconcile should succeed"); 1024 1449 assert!(!lifecycle.has_active_session()); 1450 + assert!(lifecycle.active_mut().is_none()); 1025 1451 assert_eq!(drops.get(), 1); 1026 - } 1027 1452 1028 - #[test] 1029 - fn mesh_handle_equality_uses_only_session_key() { 1030 - let mut body = ViewerMeshHandle::from_demo_model(DEMO_MODELS[0]); 1031 - let same_key = body.clone(); 1032 - body.summary.vertices += 10; 1033 - 1034 - assert_eq!(body, same_key); 1453 + // Canvas gone with no active session is a clean no-op. 1454 + lifecycle 1455 + .reconcile(None, &ready_forearm, None) 1456 + .expect("drop path should succeed"); 1457 + assert!(!lifecycle.has_active_session()); 1458 + assert_eq!(drops.get(), 1); 1035 1459 } 1036 1460 }