diff --git a/AGENTS.md b/AGENTS.md index 9563a1c..779dea1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,10 @@ Initial product shape: - browser STL viewing as a core interaction; - Dioxus web UI backed by Rust and Jacquard 0.12. -STL viewer candidates: +STL viewer (decided): -- `three-d` is a viable web/WASM candidate with a browser/WebGL-oriented API. -- `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. +- `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/`. +- `kiss3d` was evaluated and rejected: no WebGL2/WebGPU adapter surfaced at runtime in the browser target (PM-21). ## Source of truth @@ -27,7 +27,7 @@ STL viewer candidates: ## VCS -Use jj, not raw git, for normal development. +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. - Create isolated work with jj workspaces, and immediately start a fresh change before editing: `jj new -m "PM-12: concise summary"`. - Use ticket-derived names like `pm-12-short-summary`. diff --git a/README.md b/README.md index 184c23c..5c6ab34 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ It is also a workplace demo for a highly integrated agent workflow built around Jira project/Confluence space key: `PM`. -## STL viewer candidates +## STL viewer -Tentative candidates: +Decision: three-d. -- [`three-d`](https://github.com/asny/three-d): viable web/WASM candidate with a browser/WebGL-oriented API. -- [`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. +- [`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/`. +- [`kiss3d`](https://github.com/dimforge/kiss3d): rejected — no WebGL2/WebGPU adapter surfaced at runtime in the browser target (PM-21). The viewer choice should be decided with a small spike that loads an STL, renders orbit controls, and fits cleanly into Dioxus. diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index f28e575..8ecc60a 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -3,8 +3,12 @@ import { expect, test } from '@playwright/test'; const boundary = '#polymodel-viewer-boundary'; const canvas = '#polymodel-viewer-canvas'; const preview = '#polymodel-viewer-preview'; -const chestAssetPath = '/assets/models/body_f_chest-v4.stl'; -const forearmAssetPath = '/assets/models/body_f_forearm_2x-v9.stl'; +const chestAssetPath = '/models/body_f_chest-v4.stl'; +const forearmAssetPath = '/models/body_f_forearm_2x-v9.stl'; + +async function acquisitions(page) { + return Number(await page.locator(boundary).getAttribute('data-context-acquisitions')); +} async function expectViewerReady(page) { await expect(page.locator(canvas)).toHaveCount(1); @@ -43,10 +47,12 @@ test('direct viewer routes select deterministic asset-backed models', async ({ p await expect(page.locator(boundary)).toContainText('Body F forearm 2x v9'); }); -test('model switching keeps one canvas and updates renderer session metadata', async ({ page }) => { +test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { await page.goto('/viewer/body-f-chest-v4'); await expectViewerReady(page); await expect(page.locator(canvas)).toHaveCount(1); + // The WebGL context is acquired exactly once for the initial mount. + await expect.poll(acquisitions.bind(null, page)).toBe(1); await page.getByRole('button', { name: 'Body F forearm 2x v9' }).click(); await expectViewerReady(page); @@ -55,11 +61,33 @@ test('model switching keeps one canvas and updates renderer session metadata', a await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); await expect(page.locator(boundary)).toContainText('18271'); await expect(page.locator(boundary)).toContainText('36542'); + // Switching models must NOT acquire a second WebGL context (in-place replace). + await expect.poll(acquisitions.bind(null, page)).toBe(1); await page.getByRole('button', { name: 'Body F chest v4' }).click(); await expectViewerReady(page); await expect(page.locator(canvas)).toHaveCount(1); await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); + await expect.poll(acquisitions.bind(null, page)).toBe(1); +}); + +test('rapid model switching through loading windows keeps a single WebGL context', async ({ page }) => { + await page.goto('/viewer/body-f-chest-v4'); + await expectViewerReady(page); + const chest = page.getByRole('button', { name: 'Body F chest v4' }); + const forearm = page.getByRole('button', { name: 'Body F forearm 2x v9' }); + + // Switch rapidly without waiting for ready between clicks, exercising the + // async Loading window and stale-fetch cancellation from use_resource. + await forearm.click(); + await chest.click(); + await forearm.click(); + await chest.click(); + + await expectViewerReady(page); + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); + // The retained context is never recreated across the switches. + await expect.poll(acquisitions.bind(null, page)).toBe(1); }); test('resize callback remains single after model transitions', async ({ page }) => { diff --git a/assets/models/EInk_click_v101.step b/public/models/EInk_click_v101.step similarity index 100% rename from assets/models/EInk_click_v101.step rename to public/models/EInk_click_v101.step diff --git a/assets/models/body_f_chest-v4.stl b/public/models/body_f_chest-v4.stl similarity index 100% rename from assets/models/body_f_chest-v4.stl rename to public/models/body_f_chest-v4.stl diff --git a/assets/models/body_f_forearm_2x-v9.stl b/public/models/body_f_forearm_2x-v9.stl similarity index 100% rename from assets/models/body_f_forearm_2x-v9.stl rename to public/models/body_f_forearm_2x-v9.stl diff --git a/src/mesh/stl.rs b/src/mesh/stl.rs index cc7cdfa..4c1bb60 100644 --- a/src/mesh/stl.rs +++ b/src/mesh/stl.rs @@ -325,10 +325,10 @@ endsolid polymodel\n"; fn local_body_chest_fixture_parses() { let path = concat!( env!("CARGO_MANIFEST_DIR"), - "/assets/models/body_f_chest-v4.stl" + "/public/models/body_f_chest-v4.stl" ); let bytes = std::fs::read(path) - .expect("local fixture assets/models/body_f_chest-v4.stl should be committed"); + .expect("local fixture public/models/body_f_chest-v4.stl should be committed"); let model = load_stl(&bytes).expect("local STL fixture should parse"); assert_eq!(model.format, MeshFormat::Stl); assert_eq!( diff --git a/src/viewer.rs b/src/viewer.rs index cbbd975..9e80c2e 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,9 +1,34 @@ +//! Asset-backed STL viewer. +//! +//! The viewer renders committed STL assets with [`three-d`] on a Dioxus-owned +//! canvas. The design (see the "STL viewer renderer decision" Confluence note) +//! rests on three production decisions made when closing PM-20/PM-22: +//! +//! * **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.** STL bytes are fetched on demand and parsed a +//! single time into a [`ParsedMesh`], which then flows to the renderer. The +//! renderer never parses STL itself. +//! * **On-demand assets.** Demo STLs live under `public/models/` and are fetched +//! from `/models/*.stl` at runtime instead of being embedded in the WASM bundle. + +// 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::contract::{MeshLoadError, ModelMesh}; use crate::mesh::stl::load_stl; use dioxus::prelude::*; -#[cfg(test)] -use std::cell::Cell; -#[cfg(test)] -use std::rc::Rc; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DemoModel { @@ -19,7 +44,7 @@ const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-chest-v4", name: "Body F chest v4", - asset_path: "/assets/models/body_f_chest-v4.stl", + asset_path: "/models/body_f_chest-v4.stl", generation: 1, vertices: 41051, triangles: 82122, @@ -27,7 +52,7 @@ const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-forearm-2x-v9", name: "Body F forearm 2x v9", - asset_path: "/assets/models/body_f_forearm_2x-v9.stl", + asset_path: "/models/body_f_forearm_2x-v9.stl", generation: 1, vertices: 18271, triangles: 36542, @@ -46,69 +71,60 @@ pub fn demo_model_for_route(model_id: &str) -> DemoModel { .unwrap_or(DEMO_MODELS[0]) } -const BODY_F_CHEST_V4_STL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/models/body_f_chest-v4.stl" -)); -const BODY_F_FOREARM_2X_V9_STL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/models/body_f_forearm_2x-v9.stl" -)); - -fn demo_model_bytes(asset_path: &str) -> Option<&'static [u8]> { - match asset_path { - "/assets/models/body_f_chest-v4.stl" => Some(BODY_F_CHEST_V4_STL), - "/assets/models/body_f_forearm_2x-v9.stl" => Some(BODY_F_FOREARM_2X_V9_STL), - _ => None, - } +/// 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, } -fn mesh_summary_from_model( - model: DemoModel, - mesh: &crate::mesh::contract::ModelMesh, -) -> MeshSummary { - MeshSummary { - display_name: model.name, - asset_path: model.asset_path, - vertices: mesh.trimesh.positions.len(), - triangles: mesh.trimesh.triangle_count(), +impl PartialEq for ParsedMesh { + fn eq(&self, other: &Self) -> bool { + self.content_hash == other.content_hash } } -fn load_demo_mesh_handle(model: DemoModel) -> Result { - let bytes = demo_model_bytes(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; - let mesh = load_stl(bytes).map_err(|_| ViewerIssue::MeshUnavailable)?; - Ok(ViewerMeshHandle { - key: SessionKey::new(model.id, model.generation), - summary: mesh_summary_from_model(model, &mesh), - }) +impl Eq for ParsedMesh {} + +/// Deterministic, in-process identity hash of mesh source bytes. +fn hash_bytes(bytes: &[u8]) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + hasher.finish() } -#[derive(Clone, Debug)] -pub struct ViewerMeshHandle { - pub key: SessionKey, - pub summary: MeshSummary, +/// Parse boundary for the viewer: a mockable parser turns source bytes into a +/// [`ModelMesh`]. Production uses [`StlParser`] (which wraps the shared +/// [`load_stl`]); tests inject a counting parser to assert one parse per load. +/// This is the only place in the viewer that calls [`load_stl`]. +trait MeshParser { + fn parse(&self, bytes: &[u8]) -> Result; } -impl ViewerMeshHandle { - #[cfg(test)] - pub fn from_demo_model(model: DemoModel) -> Self { - load_demo_mesh_handle(model).unwrap_or_else(|_| Self { - key: SessionKey::new(model.id, model.generation), - summary: MeshSummary { - display_name: model.name, - asset_path: model.asset_path, - vertices: model.vertices, - triangles: model.triangles, - }, - }) +/// Default STL parser. [`StlParser::parse`] is the single [`load_stl`] call site +/// in the viewer; the renderer consumes a [`ParsedMesh`] and never parses. +struct StlParser; + +impl MeshParser for StlParser { + fn parse(&self, bytes: &[u8]) -> Result { + load_stl(bytes) } } -impl PartialEq for ViewerMeshHandle { - fn eq(&self, other: &Self) -> bool { - self.key == other.key - } +/// Build a [`ParsedMesh`] from source bytes: hash the bytes, then parse once. +fn build_parsed_mesh( + bytes: &[u8], + parser: &impl MeshParser, +) -> Result, MeshLoadError> { + let content_hash = hash_bytes(bytes); + let mesh = parser.parse(bytes)?; + Ok(Arc::new(ParsedMesh { content_hash, mesh })) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -119,6 +135,72 @@ pub struct MeshSummary { pub triangles: usize, } +fn mesh_summary_from_model(model: DemoModel, mesh: &ModelMesh) -> MeshSummary { + MeshSummary { + display_name: model.name, + asset_path: model.asset_path, + vertices: mesh.trimesh.positions.len(), + triangles: mesh.trimesh.triangle_count(), + } +} + +/// A successfully loaded demo model: its parsed mesh plus display metadata. +/// Carried through signals to the renderer and the overlay. +#[derive(Clone, Debug)] +pub struct LoadedMesh { + pub model: DemoModel, + pub summary: MeshSummary, + pub parsed: Arc, +} + +/// Load a demo model's STL on demand (fetch on wasm, disk off wasm), parsing it +/// exactly once into a [`LoadedMesh`]. +async fn load_demo_mesh(model: DemoModel) -> Result { + let bytes = read_model_bytes(model).await?; + let parsed = build_parsed_mesh(&bytes, &StlParser).map_err(|_| ViewerIssue::MeshUnavailable)?; + let summary = mesh_summary_from_model(model, &parsed.mesh); + Ok(LoadedMesh { + model, + summary, + parsed, + }) +} + +/// Read a demo model's source 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_model_bytes(model: DemoModel) -> Result, ViewerIssue> { + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + fetch_bytes(model.asset_path).await + } + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + { + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); + std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable) + } +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +async fn fetch_bytes(url: &str) -> Result, ViewerIssue> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window().ok_or(ViewerIssue::MeshUnavailable)?; + let resp_value = JsFuture::from(window.fetch_with_str(url)) + .await + .map_err(|_| ViewerIssue::MeshUnavailable)?; + let response: web_sys::Response = resp_value + .dyn_into() + .map_err(|_| ViewerIssue::MeshUnavailable)?; + let array_buffer = response + .array_buffer() + .map_err(|_| ViewerIssue::MeshUnavailable)?; + let buffer = JsFuture::from(array_buffer) + .await + .map_err(|_| ViewerIssue::MeshUnavailable)?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SessionKey { model_id: String, @@ -150,7 +232,6 @@ impl SessionKey { } } -#[cfg_attr(not(test), allow(dead_code))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ViewerIssue { MeshUnavailable, @@ -166,11 +247,21 @@ impl ViewerIssue { } } -#[cfg_attr(not(test), allow(dead_code))] +/// 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), } @@ -178,6 +269,7 @@ 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", } @@ -186,75 +278,122 @@ impl ViewerStatus { 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(_)) + 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: MeshSummary, + pub mesh: Arc, + pub summary: MeshSummary, } impl RendererMount { - pub fn new(key: SessionKey, mesh: MeshSummary) -> Self { - Self { key, mesh } + pub fn new(key: SessionKey, mesh: Arc, summary: MeshSummary) -> Self { + Self { key, mesh, summary } } } -#[cfg_attr(not(test), allow(dead_code))] +/// A renderer adapter that can be mounted once and have its mesh replaced. +/// +/// The canvas source is renderer-specific: the three-d adapter looks its canvas +/// up by id, so its `Canvas` type is `()`. pub trait ViewerRenderer: Sized { type Canvas; type Error; + /// Acquire the rendering context and build the initial scene from `mount`. fn mount(canvas: Self::Canvas, mount: RendererMount) -> Result; + /// Replace the mesh resources on the existing context, reusing everything else. fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error>; + /// Re-read the canvas size and re-render. fn resize(&mut self) -> Result<(), Self::Error>; } -#[cfg(test)] +/// Mount-once / replace-mesh lifecycle for a [`ViewerRenderer`], keyed by mesh +/// identity so re-renders of the same model are no-ops and only a real model +/// switch triggers [`ViewerRenderer::replace_mesh`]. +/// +/// `reconcile` is loading-safe: a transient absence of a mesh (the async fetch +/// window) or a mesh-load error keeps the existing renderer alive; only a hard +/// renderer error or a missing canvas tears the session down. pub struct RendererLifecycle { active: Option, + active_hash: Option, + mounts: u64, } -#[cfg(test)] impl RendererLifecycle where R::Canvas: Clone, { pub fn new() -> Self { - Self { active: None } + Self { + active: None, + active_hash: None, + mounts: 0, + } } pub fn reconcile( &mut self, canvas: Option, status: &ViewerStatus, - mesh: Option, + mount: Option, ) -> Result<(), R::Error> { + // No canvas: the component unmounted or the canvas is gone. Tear down. let Some(canvas) = canvas else { self.active = None; + self.active_hash = None; return Ok(()); }; - - let (ViewerStatus::Ready(key), Some(mesh)) = (status, mesh) else { + // 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 mount = RendererMount::new(key.clone(), mesh); + let hash = mount.mesh.content_hash; match self.active.as_mut() { - Some(renderer) => renderer.replace_mesh(mount), None => { self.active = Some(R::mount(canvas, mount)?); - Ok(()) + 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> { @@ -264,31 +403,71 @@ where Ok(()) } + /// Borrow the active renderer for external event dispatch (pointer/wheel). + pub fn active_mut(&mut self) -> Option<&mut R> { + self.active.as_mut() + } + pub fn has_active_session(&self) -> bool { self.active.is_some() } + + /// Number of times `mount` has run. This is the WebGL-context acquisition + /// count: it should be exactly one per page mount regardless of switches. + pub fn mounts(&self) -> u64 { + self.mounts + } +} + +impl Default for RendererLifecycle +where + R::Canvas: Clone, +{ + fn default() -> Self { + Self::new() + } } #[component] pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); let canvas_mounted = use_signal(|| false); - let renderer_session = use_signal(String::new); let resize_count = use_signal(|| 0_u64); + let context_acquisitions = use_signal(|| 0_u64); + let renderer_failed = use_signal(|| false); + let loaded: Signal> = use_signal(|| None); + let status: Signal = use_signal(|| { + ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) + }); - let model = selected_model(); - let loaded_mesh = load_demo_mesh_handle(model); - let (status, visible_mesh, session_label) = match loaded_mesh { - Ok(mesh) => { - let label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); - (ViewerStatus::Ready(mesh.key.clone()), Some(mesh), label) + // 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)); + } + } } - Err(issue) => ( - ViewerStatus::MeshError(issue), - None, - format!("{}#{}", model.id, model.generation), - ), - }; + }); + + let model = *selected_model.read(); rsx! { section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", @@ -298,7 +477,7 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { h1 { "Model viewer" } p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } } - div { class: "viewer-status-pill", "{status.label()}" } + div { class: "viewer-status-pill", "{status.read().label()}" } } div { class: "viewer-demo-controls", aria_label: "Model choices", @@ -313,12 +492,12 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { } ModelViewer { - mesh: visible_mesh, + loaded, status, canvas_mounted, - renderer_session, resize_count, - session_label, + context_acquisitions, + renderer_failed, } } } @@ -326,26 +505,45 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { #[derive(Props, Clone, PartialEq)] pub struct ModelViewerProps { - mesh: Option, - status: ViewerStatus, + loaded: Signal>, + status: Signal, canvas_mounted: Signal, - renderer_session: Signal, resize_count: Signal, - session_label: String, + context_acquisitions: Signal, + renderer_failed: Signal, } #[component] pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mounted = props.canvas_mounted; - let renderer_session = props.renderer_session; let resize_count = props.resize_count; - let status = props.status.clone(); - let mesh = props.mesh.clone(); - let asset_path = mesh + let context_acquisitions = props.context_acquisitions; + let renderer_failed = props.renderer_failed; + 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(|mesh| mesh.summary.asset_path) - .unwrap_or(""); - let renderer_active = status.should_have_session() && renderer_session() == props.session_label; + .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 { @@ -356,17 +554,18 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { div { class: "model-viewer-boundary", id: "polymodel-viewer-boundary", - "data-viewer-status": "{status.label()}", - "data-session-wanted": "{status.should_have_session()}", - "data-resize-count": "{resize_count()}", - "data-session-label": "{props.session_label}", + "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()}", + "data-mounted": "{canvas_mounted.read()}", "data-renderer-kind": renderer_kind, class: "model-viewer-canvas", aria_label: "Polymodel model viewer canvas", @@ -375,71 +574,74 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { onpointerdown: move |event| event.stop_propagation(), } if !renderer_active { - if let Some(ref mesh) = mesh { - AssetPreview { mesh: mesh.clone() } + if let Some(summary) = summary { + AssetPreview { summary } } } div { class: "model-viewer-overlay", aria_live: "polite", - h2 { "{status.label()}" } - p { "{status.message()}" } - if let Some(ref mesh) = mesh { + h2 { "{status_val.label()}" } + p { "{status_val.message()}" } + if let Some(summary) = summary { dl { class: "viewer-mesh-summary", - div { dt { "Model" } dd { "{mesh.summary.display_name}" } } - div { dt { "Asset" } dd { "{mesh.summary.asset_path}" } } - div { dt { "Vertices" } dd { "{mesh.summary.vertices}" } } - div { dt { "Triangles" } dd { "{mesh.summary.triangles}" } } + div { dt { "Model" } dd { "{summary.display_name}" } } + div { dt { "Asset" } dd { "{summary.asset_path}" } } + div { dt { "Vertices" } dd { "{summary.vertices}" } } + div { dt { "Triangles" } dd { "{summary.triangles}" } } } } } } } CanvasRendererSession { - status: status.clone(), - mesh, + loaded, + status, canvas_mounted, - renderer_session, resize_count, + context_acquisitions, + renderer_failed, } } } #[component] -fn AssetPreview(mesh: ViewerMeshHandle) -> Element { +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 {mesh.summary.display_name}", + "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", "{mesh.summary.triangles} triangles · {mesh.summary.vertices} vertices" } + 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( - status: ViewerStatus, - mesh: Option, + loaded: Signal>, + status: Signal, canvas_mounted: Signal, - renderer_session: Signal, resize_count: Signal, + context_acquisitions: Signal, + renderer_failed: Signal, ) -> Element { - let mount = match (status, mesh) { - (ViewerStatus::Ready(key), Some(mesh)) if canvas_mounted() => { - Some(RendererMount::new(key, mesh.summary)) - } - _ => None, - }; - rsx! { - if let Some(mount) = mount { - BrowserRendererSession { key: "{mount.key.model_id()}-{mount.key.generation()}", mount, renderer_session, resize_count } + BrowserRendererSession { + loaded, + status, + canvas_mounted, + resize_count, + context_acquisitions, + renderer_failed, } } } @@ -447,250 +649,307 @@ fn CanvasRendererSession( #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] #[component] fn BrowserRendererSession( - mount: RendererMount, - renderer_session: Signal, + loaded: Signal>, + status: Signal, + canvas_mounted: Signal, resize_count: Signal, + context_acquisitions: Signal, + renderer_failed: Signal, ) -> Element { - let _ = mount; - let _ = renderer_session; - let _ = resize_count; + let _ = ( + loaded, + status, + canvas_mounted, + resize_count, + context_acquisitions, + renderer_failed, + ); rsx! {} } #[cfg(all(target_family = "wasm", target_os = "unknown"))] #[component] fn BrowserRendererSession( - mount: RendererMount, - renderer_session: Signal, + loaded: Signal>, + status: Signal, + canvas_mounted: Signal, resize_count: Signal, + context_acquisitions: Signal, + mut renderer_failed: Signal, ) -> Element { - let _session = use_hook(move || { - std::rc::Rc::new(ThreeDRendererSession::mount( - mount, - renderer_session, - resize_count, - )) + use std::cell::RefCell; + use std::rc::Rc; + + // Persistent retained session: a renderer lifecycle plus a guard that + // detaches the canvas listeners on unmount. The guard is held first so it + // drops (and detaches listeners) before the renderer (and its WebGL + // context) is released. Listener closures hold only a `Weak` reference to + // the lifecycle, so they never keep the session alive. + let holders = use_hook(|| { + let lifecycle: Rc>> = + Rc::new(RefCell::new(RendererLifecycle::new())); + let guard: Rc> = Rc::new(RefCell::new(ListenerGuard::default())); + Rc::new((guard, lifecycle)) }); + + let weak_lifecycle = Rc::downgrade(&holders.1); + use_effect(move || { + let canvas_present = *canvas_mounted.read(); + let status_val = status.read().clone(); + let loaded_val = loaded.read().clone(); + let mount_opt = loaded_val.as_ref().map(|loaded| { + RendererMount::new( + SessionKey::new(loaded.model.id, loaded.model.generation), + loaded.parsed.clone(), + loaded.summary, + ) + }); + + // Attach canvas listeners once, as soon as the canvas is in the DOM. + let needs_attach = canvas_present && holders.0.borrow().cleanup.is_none(); + if needs_attach && let Some(canvas) = lookup_viewer_canvas() { + let cleanup = attach_renderer_listeners(canvas, weak_lifecycle.clone(), resize_count); + holders.0.borrow_mut().cleanup = Some(cleanup); + } + + let mut lifecycle = holders.1.borrow_mut(); + let result = lifecycle.reconcile(canvas_present.then_some(()), &status_val, mount_opt); + let mounts = lifecycle.mounts(); + drop(lifecycle); + + // Publish the context-acquisition count (the single WebGL-context signal). + // This effect reads only canvas_mounted/status/loaded, so writing these signals never re-runs it. + context_acquisitions.set(mounts); + renderer_failed.set(result.is_err()); + if let Err(err) = result { + tracing::warn!(?err, "three-d renderer reconcile failed"); + } + }); + rsx! {} } +/// Holds the canvas-listener cleanup closure and runs it on drop (component +/// unmount), detaching listeners from the canvas and window. #[cfg(all(target_family = "wasm", target_os = "unknown"))] -struct ThreeDRendererSession { - mount: RendererMount, - renderer: Option>>, - renderer_session: Signal, +#[derive(Default)] +struct ListenerGuard { cleanup: Option>, } #[cfg(all(target_family = "wasm", target_os = "unknown"))] -impl ThreeDRendererSession { - fn mount( - mount: RendererMount, - mut renderer_session: Signal, - resize_count: Signal, - ) -> Self { - use wasm_bindgen::JsCast; - use wasm_bindgen::closure::Closure; - use web_sys::{Event, MouseEvent, PointerEvent, WheelEvent}; - - let mut cleanup_callbacks: Vec> = Vec::new(); - let renderer = match ThreeDRenderer::new(&mount) { - Ok(renderer) => Some(std::rc::Rc::new(std::cell::RefCell::new(renderer))), - Err(err) => { - tracing::warn!(error = %err, "three-d renderer initialization failed"); - None - } - }; +impl Drop for ListenerGuard { + fn drop(&mut self) { + if let Some(cleanup) = self.cleanup.take() { + cleanup(); + } + } +} - if let Some(window) = web_sys::window() { - let resize_renderer = renderer.clone(); - let mut resize_count = resize_count; - let resize_closure = Closure::::wrap(Box::new(move |_| { - resize_count.set(resize_count() + 1); - if let Some(renderer) = resize_renderer.as_ref() { - renderer.borrow_mut().resize_and_render(); - } - })); - if window - .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) - .is_ok() +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn lookup_viewer_canvas() -> Option { + use wasm_bindgen::JsCast; + web_sys::window()? + .document()? + .get_element_by_id("polymodel-viewer-canvas")? + .dyn_into::() + .ok() +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn attach_renderer_listeners( + canvas: web_sys::HtmlCanvasElement, + weak: std::rc::Weak>>, + resize_count: Signal, +) -> Box { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + use web_sys::{Event, MouseEvent, WheelEvent}; + + let mut cleanups: Vec> = Vec::new(); + + // Window resize: bump the resize counter and refresh the active renderer. + if let Some(window) = web_sys::window() { + let weak_resize = weak.clone(); + let mut resize_counter = resize_count; + let resize_closure = Closure::::wrap(Box::new(move |_| { + let next = *resize_counter.read() + 1; + resize_counter.set(next); + if let Some(handle) = weak_resize.upgrade() + && let Ok(mut lifecycle) = handle.try_borrow_mut() { - cleanup_callbacks.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(), - ); - } - })); + 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(renderer) = renderer.as_ref() { - let canvas = renderer.borrow().canvas.clone(); - let down_renderer = renderer.clone(); - let down = - Closure::::wrap(Box::new(move |event: PointerEvent| { - event.prevent_default(); - if let Some(target) = event - .target() - .and_then(|target| target.dyn_into::().ok()) - { - let _ = target.set_pointer_capture(event.pointer_id()); - } - down_renderer - .borrow_mut() - .pointer_down(event.client_x() as f32, event.client_y() as f32); - })); - if canvas - .add_event_listener_with_callback("pointerdown", down.as_ref().unchecked_ref()) - .is_ok() + if let Some(cleanup) = + register_canvas_pointer(&canvas, "pointerdown", weak.clone(), |renderer, event| { + if let Some(target) = event + .target() + .and_then(|target| target.dyn_into::().ok()) { - let canvas = canvas.clone(); - cleanup_callbacks.push(Box::new(move || { - let _ = canvas.remove_event_listener_with_callback( - "pointerdown", - down.as_ref().unchecked_ref(), - ); - })); + let _ = target.set_pointer_capture(event.pointer_id()); } + renderer.pointer_down(event.client_x() as f32, event.client_y() as f32); + }) + { + cleanups.push(cleanup); + } - let canvas = renderer.borrow().canvas.clone(); - let move_renderer = renderer.clone(); - let pointer_move = - Closure::::wrap(Box::new(move |event: PointerEvent| { - event.prevent_default(); - move_renderer - .borrow_mut() - .pointer_move(event.client_x() as f32, event.client_y() as f32); - })); - if canvas - .add_event_listener_with_callback( - "pointermove", - pointer_move.as_ref().unchecked_ref(), - ) - .is_ok() - { - let canvas = canvas.clone(); - cleanup_callbacks.push(Box::new(move || { - let _ = canvas.remove_event_listener_with_callback( - "pointermove", - pointer_move.as_ref().unchecked_ref(), - ); - })); - } + 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); + } - let canvas = renderer.borrow().canvas.clone(); - let up_renderer = renderer.clone(); - let pointer_up = - Closure::::wrap(Box::new(move |event: PointerEvent| { - event.prevent_default(); - up_renderer.borrow_mut().pointer_up(); - })); - if canvas - .add_event_listener_with_callback("pointerup", pointer_up.as_ref().unchecked_ref()) - .is_ok() - { - let canvas = canvas.clone(); - cleanup_callbacks.push(Box::new(move || { - let _ = canvas.remove_event_listener_with_callback( - "pointerup", - pointer_up.as_ref().unchecked_ref(), - ); - })); - } + if let Some(cleanup) = + register_canvas_pointer(&canvas, "pointerup", weak.clone(), |renderer, _event| { + renderer.pointer_up(); + }) + { + cleanups.push(cleanup); + } - let canvas = renderer.borrow().canvas.clone(); - let leave_renderer = renderer.clone(); - let pointer_leave = Closure::::wrap(Box::new(move |_| { - leave_renderer.borrow_mut().pointer_up(); - })); - if canvas - .add_event_listener_with_callback( - "mouseleave", - pointer_leave.as_ref().unchecked_ref(), - ) - .is_ok() + // pointerleave is dispatched as a MouseEvent; release any in-progress drag. + { + let canvas = canvas.clone(); + let leave_weak = weak.clone(); + let closure = Closure::::wrap(Box::new(move |_| { + if let Some(handle) = leave_weak.upgrade() + && let Ok(mut lifecycle) = handle.try_borrow_mut() + && let Some(renderer) = lifecycle.active_mut() { - let canvas = canvas.clone(); - cleanup_callbacks.push(Box::new(move || { - let _ = canvas.remove_event_listener_with_callback( - "mouseleave", - pointer_leave.as_ref().unchecked_ref(), - ); - })); + 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(), + ); + })); + } + } - let canvas = renderer.borrow().canvas.clone(); - let wheel_renderer = renderer.clone(); - let wheel = - Closure::::wrap(Box::new(move |event: WheelEvent| { - event.prevent_default(); - wheel_renderer.borrow_mut().wheel( - event.delta_y() as f32, - event.client_x() as f32, - event.client_y() as f32, - ); - })); - if canvas - .add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref()) - .is_ok() + // wheel zoom. + { + let canvas = canvas.clone(); + let wheel_weak = weak; + let closure = Closure::::wrap(Box::new(move |event: WheelEvent| { + event.prevent_default(); + if let Some(handle) = wheel_weak.upgrade() + && let Ok(mut lifecycle) = handle.try_borrow_mut() + && let Some(renderer) = lifecycle.active_mut() { - let canvas = canvas.clone(); - cleanup_callbacks.push(Box::new(move || { - let _ = canvas.remove_event_listener_with_callback( - "wheel", - wheel.as_ref().unchecked_ref(), - ); - })); + 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()); + })); } + } - if let Some(renderer) = renderer.as_ref() { - renderer.borrow_mut().resize_and_render(); - renderer_session.set(format!( - "{}#{}", - mount.key.model_id(), - mount.key.generation() - )); - } else { - renderer_session.set(String::new()); + Box::new(move || { + for cleanup in cleanups.into_iter().rev() { + cleanup(); } + }) +} - Self { - mount, - renderer, - renderer_session, - cleanup: Some(Box::new(move || { - for cleanup in cleanup_callbacks.into_iter().rev() { - cleanup(); - } - })), - } +/// Register a pointer listener on the canvas that dispatches to the active +/// renderer through a `Weak` lifecycle handle. Returns a cleanup closure on +/// success. Kept as a function (rather than a closure) to keep the types simple. +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn register_canvas_pointer( + canvas: &web_sys::HtmlCanvasElement, + name: &'static str, + weak: std::rc::Weak>>, + dispatch: impl Fn(&mut ThreeDRenderer, web_sys::PointerEvent) + 'static, +) -> Option> { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + + let closure = Closure::::wrap(Box::new( + move |event: web_sys::PointerEvent| { + event.prevent_default(); + if let Some(handle) = weak.upgrade() + && let Ok(mut lifecycle) = handle.try_borrow_mut() + && let Some(renderer) = lifecycle.active_mut() + { + dispatch(renderer, event); + } + }, + )); + if canvas + .add_event_listener_with_callback(name, closure.as_ref().unchecked_ref()) + .is_ok() + { + let canvas = canvas.clone(); + Some(Box::new(move || { + let _ = + canvas.remove_event_listener_with_callback(name, closure.as_ref().unchecked_ref()); + })) + } else { + None } } -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -impl Drop for ThreeDRendererSession { - fn drop(&mut self) { - tracing::debug!( - model_id = self.mount.key.model_id(), - generation = self.mount.key.generation(), - asset_path = self.mount.mesh.asset_path, - had_renderer = self.renderer.is_some(), - "dropping three-d renderer session" - ); - let session_label = format!( - "{}#{}", - self.mount.key.model_id(), - self.mount.key.generation() - ); - if (self.renderer_session)() == session_label { - self.renderer_session.set(String::new()); - } - if let Some(cleanup) = self.cleanup.take() { - cleanup(); - } +/// Typed renderer-initialization error, replacing the spike's `String` boundary. +/// +/// Parse/asset failures are handled at the loader boundary +/// ([`ViewerIssue::MeshUnavailable`]); this covers only what the renderer can +/// fail on while acquiring its WebGL2 context and building its scene. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ThreeDRendererError { + #[error("browser window is unavailable")] + WindowUnavailable, + #[error("browser document is unavailable")] + DocumentUnavailable, + #[error("viewer canvas element not found")] + CanvasNotFound, + #[error("viewer element is not a canvas")] + NotCanvas, + #[error("WebGL2 context could not be created")] + WebGL2Unavailable, + #[error("rendering context is not a WebGL2 context")] + NotWebGL2, + #[error("three-d core context error: {0}")] + Core(#[from] three_d::core::CoreError), +} + +impl From for ViewerIssue { + fn from(_: ThreeDRendererError) -> Self { + ViewerIssue::RendererInit } } @@ -712,38 +971,20 @@ struct ThreeDRenderer { #[cfg(all(target_family = "wasm", target_os = "unknown"))] impl ThreeDRenderer { - fn new(mount: &RendererMount) -> Result { - use wasm_bindgen::JsCast; - - let bytes = demo_model_bytes(mount.mesh.asset_path) - .ok_or_else(|| "unknown demo asset".to_string())?; - let model = load_stl(bytes).map_err(|err| err.to_string())?; - let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; - let document = window - .document() - .ok_or_else(|| "document unavailable".to_string())?; - let canvas = document - .get_element_by_id("polymodel-viewer-canvas") - .ok_or_else(|| "viewer canvas not found".to_string())? - .dyn_into::() - .map_err(|_| "viewer element is not a canvas".to_string())?; - let gl = canvas - .get_context("webgl2") - .map_err(|_| "webgl2 context lookup failed".to_string())? - .ok_or_else(|| "webgl2 unavailable".to_string())? - .dyn_into::() - .map_err(|_| "context is not WebGL2".to_string())?; - let glow_context = three_d::context::Context::from_webgl2_context(gl); - #[allow( - clippy::arc_with_non_send_sync, - reason = "three-d requires Arc for browser WebGL context construction" - )] - let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context)) - .map_err(|err| err.to_string())?; + /// Build the GPU mesh + camera framing for `model` against an existing + /// context. Shared by the initial mount and mesh replacement. + fn build_scene( + context: &three_d::Context, + canvas: &web_sys::HtmlCanvasElement, + model: &ModelMesh, + ) -> ( + three_d::Gm, + three_d::Camera, + three_d::OrbitControl, + ) { let fit = model.camera_fit(); - let cpu_mesh = model.trimesh; let mesh = three_d::Gm::new( - three_d::Mesh::new(&context, &cpu_mesh), + three_d::Mesh::new(context, &model.trimesh), three_d::PhysicalMaterial { albedo: three_d::Srgba::new_opaque(116, 180, 255), roughness: 0.72, @@ -754,7 +995,7 @@ impl ThreeDRenderer { 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 viewport = canvas_viewport(&canvas); + let viewport = canvas_viewport(canvas); let camera = three_d::Camera::new_perspective( viewport, position, @@ -765,36 +1006,7 @@ impl ThreeDRenderer { radius * 20.0, ); let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); - 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), - ); - let frame_started_ms = window.performance().map(|p| p.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, - }) + (mesh, camera, control) } fn resize_and_render(&mut self) { @@ -859,8 +1071,8 @@ impl ThreeDRenderer { if !self.first_render_logged { self.first_render_logged = true; let elapsed = web_sys::window() - .and_then(|w| w.performance()) - .map(|p| p.now() - self.frame_started_ms) + .and_then(|window| window.performance()) + .map(|performance| performance.now() - self.frame_started_ms) .unwrap_or(0.0); tracing::info!( backend = "WebGL2", @@ -871,11 +1083,95 @@ impl ThreeDRenderer { } } +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +impl ViewerRenderer for ThreeDRenderer { + type Canvas = (); + type Error = ThreeDRendererError; + + fn mount(_: Self::Canvas, mount: RendererMount) -> Result { + use wasm_bindgen::JsCast; + + let model = &mount.mesh.mesh; + let window = web_sys::window().ok_or(ThreeDRendererError::WindowUnavailable)?; + let document = window + .document() + .ok_or(ThreeDRendererError::DocumentUnavailable)?; + let canvas = document + .get_element_by_id("polymodel-viewer-canvas") + .ok_or(ThreeDRendererError::CanvasNotFound)? + .dyn_into::() + .map_err(|_| ThreeDRendererError::NotCanvas)?; + let gl = canvas + .get_context("webgl2") + .map_err(|_| ThreeDRendererError::WebGL2Unavailable)? + .ok_or(ThreeDRendererError::WebGL2Unavailable)? + .dyn_into::() + .map_err(|_| ThreeDRendererError::NotWebGL2)?; + let glow_context = three_d::context::Context::from_webgl2_context(gl); + #[allow( + clippy::arc_with_non_send_sync, + reason = "three-d requires Arc for browser WebGL context construction" + )] + let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context))?; + tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired"); + + let (mesh, camera, control) = Self::build_scene(&context, &canvas, model); + let ambient_light = + 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), + ); + 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(|w| w.device_pixel_ratio()) + .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); @@ -900,7 +1196,26 @@ fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal) { #[cfg(test)] mod tests { use super::*; - use crate::mesh::stl::load_stl; + use crate::mesh::contract::{LengthUnit, MeshFormat, Units}; + use std::cell::Cell; + use std::rc::Rc; + + fn fixture_bytes(asset_path: &str) -> Vec { + let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path); + std::fs::read(&path) + .unwrap_or_else(|_| panic!("demo STL asset {asset_path} should be committed at {path}")) + } + + fn parsed_for(model: DemoModel) -> Arc { + build_parsed_mesh(&fixture_bytes(model.asset_path), &StlParser) + .expect("demo STL asset should parse") + } + + fn mount_for(model: DemoModel) -> RendererMount { + let parsed = parsed_for(model); + let summary = mesh_summary_from_model(model, &parsed.mesh); + RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary) + } #[derive(Clone)] struct FakeCanvas; @@ -939,16 +1254,10 @@ mod tests { } } - fn first_mount() -> RendererMount { - let model = demo_models()[0]; - let handle = ViewerMeshHandle::from_demo_model(model); - RendererMount::new(handle.key, handle.summary) - } - #[test] fn demo_route_uses_committed_stl_asset() { let model = demo_model_for_route("body-f-chest-v4"); - assert_eq!(model.asset_path, "/assets/models/body_f_chest-v4.stl"); + assert_eq!(model.asset_path, "/models/body_f_chest-v4.stl"); assert_eq!(model.vertices, 41051); assert_eq!(model.triangles, 82122); } @@ -956,12 +1265,19 @@ mod tests { #[test] fn demo_model_metadata_matches_committed_fixture() { let model = demo_models()[0]; - let path = format!("{}{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); - let bytes = std::fs::read(path).expect("demo STL asset should be committed"); + 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] @@ -976,12 +1292,91 @@ mod tests { #[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()); + } + + #[test] + fn parsed_mesh_equality_uses_content_hash() { + let chest = parsed_for(DEMO_MODELS[0]); + let chest_again = parsed_for(DEMO_MODELS[0]); + let forearm = parsed_for(DEMO_MODELS[1]); + + 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"); + } + + #[test] + fn parses_each_loaded_mesh_once() { + #[derive(Default)] + struct CountingParser { + calls: Rc>, + } + impl MeshParser for CountingParser { + fn parse(&self, bytes: &[u8]) -> Result { + self.calls.set(self.calls.get() + 1); + load_stl(bytes) + } + } + + let parser = CountingParser::default(); + let bytes = fixture_bytes(DEMO_MODELS[0].asset_path); + let parsed = build_parsed_mesh(&bytes, &parser).expect("parses"); + assert_eq!( + parser.calls.get(), + 1, + "the boundary must parse once per load" + ); + + let again = build_parsed_mesh(&bytes, &parser).unwrap(); + assert_eq!( + again.content_hash, parsed.content_hash, + "the same bytes must hash to the same identity" + ); + } + + #[test] + fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() { + let errors: Vec = vec![ + ThreeDRendererError::WindowUnavailable, + ThreeDRendererError::DocumentUnavailable, + ThreeDRendererError::CanvasNotFound, + ThreeDRendererError::NotCanvas, + ThreeDRendererError::WebGL2Unavailable, + ThreeDRendererError::NotWebGL2, + ThreeDRendererError::Core(three_d::core::CoreError::ContextCreation("boom".into())), + ]; + for err in errors { + let issue: ViewerIssue = err.into(); + assert_eq!(issue, ViewerIssue::RendererInit); + } + } + + #[tokio::test] + async fn loaded_mesh_carries_parsed_payload_and_summary() { + let model = DEMO_MODELS[0]; + let loaded = load_demo_mesh(model) + .await + .expect("non-wasm disk load should succeed"); + + assert_eq!(loaded.model, model); + assert_eq!(loaded.summary.asset_path, model.asset_path); + assert_eq!(loaded.summary.vertices, model.vertices); + assert_eq!(loaded.summary.triangles, model.triangles); + assert_eq!( + loaded.parsed.content_hash, + hash_bytes(&fixture_bytes(model.asset_path)) + ); } #[test] @@ -990,47 +1385,76 @@ mod tests { let drops = Rc::new(Cell::new(0)); let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); let mut lifecycle = RendererLifecycle::::new(); - let mount = first_mount(); - let status = ViewerStatus::Ready(mount.key.clone()); + let chest = mount_for(DEMO_MODELS[0]); + let forearm = mount_for(DEMO_MODELS[1]); + let ready_chest = ViewerStatus::Ready(chest.key.clone()); + // Initial mount. lifecycle - .reconcile(Some(canvas.clone()), &status, Some(mount.mesh)) + .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(), 1); + assert_eq!(lifecycle.mounts(), 1); + // Resize forwards to the active renderer. lifecycle.resize().expect("resize should forward"); assert_eq!(events.get(), 2); - let replacement = - RendererMount::new(SessionKey::new("body-f-forearm-2x-v9", 2), mount.mesh); + // Same-identity re-render is a no-op (no replace). lifecycle - .reconcile( - Some(canvas), - &ViewerStatus::Ready(replacement.key), - Some(replacement.mesh), - ) + .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) + .expect("same-identity reconcile should succeed"); + assert_eq!(events.get(), 2); + + // 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(), 3); 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, - &ViewerStatus::RendererError(ViewerIssue::RendererInit), + ) + .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("drop path should succeed"); + .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); - } - - #[test] - fn mesh_handle_equality_uses_only_session_key() { - let mut body = ViewerMeshHandle::from_demo_model(DEMO_MODELS[0]); - let same_key = body.clone(); - body.summary.vertices += 10; - assert_eq!(body, same_key); + // 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); } }