atproto Thingiverse but good
12

Configure Feed

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

PM-29: wire viewer to real assets

Orual (Jun 29, 2026, 7:06 PM EDT) 3c62f9ef fbfd48ed

+309 -244
+1
AGENTS.md
··· 54 54 - Dioxus 0.7 is the UI framework. 55 55 - Jacquard 0.12 is the AT Protocol client stack. 56 56 - The project uses Rust 2024 on nightly with `wasm32-unknown-unknown` available. 57 + - for e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. 57 58 58 59 ## Agent behavior 59 60
+10 -10
assets/styling/polymodel.css
··· 145 145 146 146 .model-viewer-stage { 147 147 position: relative; 148 - min-height: min(72vh, 42rem); 148 + min-height: min(72vh, 42rem); display: grid; grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; 149 149 background: 150 150 linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), 151 151 radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), ··· 155 155 .model-viewer-canvas { 156 156 display: block; 157 157 width: 100%; 158 - min-height: min(72vh, 42rem); 158 + min-height: 0; height: 100%; grid-row: 1; grid-column: 1; 159 159 touch-action: none; 160 160 cursor: grab; 161 161 } 162 162 163 + .model-viewer-preview { grid-row: 1; grid-column: 1; align-self: stretch; justify-self: stretch; width: 100%; height: 100%; padding: 2rem; box-sizing: border-box; } 164 + .model-preview-shadow { fill: rgb(0 0 0 / 0.22); } 165 + .model-preview-body { fill: color-mix(in srgb, var(--color-primary) 38%, transparent); stroke: var(--color-primary); stroke-width: 4; } 166 + .model-preview-cut { fill: color-mix(in srgb, var(--color-background) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } 167 + .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; } 168 + .model-preview-label { fill: var(--color-muted); font-family: var(--font-mono); font-size: 18px; } 163 169 .model-viewer-overlay { 164 - position: absolute; 165 - inset: auto 1rem 1rem 1rem; 166 - max-width: 38rem; 167 - padding: 1rem; 168 - border: 1px solid var(--color-border); 169 - background: color-mix(in srgb, var(--color-surface) 92%, transparent); 170 - pointer-events: none; 171 - } 170 + position: relative; 171 + margin: 1rem; max-width: none; padding: 1rem; border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-surface) 92%, transparent); } 172 172 173 173 .model-viewer-overlay h2, 174 174 .model-viewer-overlay p,
+43 -47
e2e/tests/viewer.spec.ts
··· 2 2 3 3 const boundary = '#polymodel-viewer-boundary'; 4 4 const canvas = '#polymodel-viewer-canvas'; 5 - async function expectViewerReady(page) { await expect(page.locator(canvas)).toHaveCount(1); await expect(page.locator(canvas)).toHaveAttribute('data-mounted', 'true'); } 6 - test('viewer is reachable from browse and owns one mounted canvas', async ({ page }) => { 7 - await page.goto('/'); 8 - await page.getByRole('link', { name: 'Open canvas-isolated viewer demo' }).click(); 5 + const preview = '#polymodel-viewer-preview'; 6 + const assetPath = '/assets/models/body_f_chest-v4.stl'; 9 7 10 - await expect(page).toHaveURL(/\/viewer\/calibration-cube$/); 11 - await expectViewerReady(page); 8 + async function expectViewerReady(page) { 9 + await expect(page.locator(canvas)).toHaveCount(1); 12 10 await expect(page.locator(canvas)).toHaveAttribute('data-mounted', 'true'); 13 - await expect(page.locator(boundary)).toContainText('Calibration cube'); 14 - await expect(page.locator(boundary)).toContainText('Stub renderer ready'); 15 - }); 11 + await expect(page.locator(preview)).toBeVisible(); 12 + } 16 13 17 - test('direct viewer routes select deterministic demo models', async ({ page }) => { 18 - await page.goto('/viewer/calibration-cube'); 19 - await expectViewerReady(page); 20 - await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); 21 - await expect(page.locator(boundary)).toContainText('Calibration cube'); 14 + test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { 15 + await page.goto('/'); 16 + await page.locator('header').getByRole('link', { name: 'Open viewer' }).click(); 22 17 23 - await page.goto('/viewer/benchy-hull'); 18 + await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); 24 19 await expectViewerReady(page); 25 - await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'benchy-hull#2'); 26 - await expect(page.locator(boundary)).toContainText('Benchy hull stub'); 20 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 21 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', assetPath); 22 + await expect(page.locator(boundary)).toContainText('Body F chest v4'); 23 + await expect(page.locator(boundary)).toContainText(assetPath); 24 + await expect(page.locator(boundary)).toContainText('41051'); 25 + await expect(page.locator(boundary)).toContainText('82122'); 26 + await expect(page.locator(boundary)).toContainText('Viewer ready'); 27 27 }); 28 28 29 - test('viewer state controls show loading and product-facing errors', async ({ page }) => { 30 - await page.goto('/viewer/calibration-cube'); 29 + test('direct viewer routes select deterministic asset-backed models', async ({ page }) => { 30 + await page.goto('/viewer/body-f-chest-v4'); 31 31 await expectViewerReady(page); 32 - await page.getByRole('button', { name: 'No mesh' }).click(); await expect(page.locator(boundary)).toContainText('No mesh selected'); await expect(page.locator(boundary)).not.toContainText('Vertices'); await page.getByRole('button', { name: 'Loading' }).click(); 33 - await expect(page.locator(boundary)).toContainText('Loading mesh'); 34 - await expect(page.locator(boundary)).not.toContainText('Vertices'); 35 - 36 - await page.getByRole('button', { name: 'Unsupported' }).click(); 37 - await expect(page.locator(boundary)).toContainText('This model format is not supported yet.'); 38 - await expect(page.locator(boundary)).not.toContainText('Vertices'); 39 - 40 - await page.getByRole('button', { name: 'Too large' }).click(); 41 - await expect(page.locator(boundary)).toContainText('This model is too large for the browser viewer.'); await expect(page.locator(boundary)).not.toContainText('Vertices'); 32 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 33 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', assetPath); 34 + await expect(page.locator(boundary)).toContainText('Body F chest v4'); 42 35 43 - await page.getByRole('button', { name: 'Renderer error' }).click(); 44 - await expect(page.locator(boundary)).toContainText('The viewer could not start on this canvas.'); 45 - await expect(page.locator(boundary)).toHaveAttribute('data-session-wanted', 'false'); await expect(page.locator(boundary)).not.toContainText('Vertices'); 36 + await page.goto('/viewer/body-f-chest-v4-detail'); 37 + await expectViewerReady(page); 38 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4-detail#2'); 39 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', assetPath); 40 + await expect(page.locator(boundary)).toContainText('Body F chest detail'); 46 41 }); 47 42 48 - test('model switching keeps one canvas and updates the session', async ({ page }) => { 49 - await page.goto('/viewer/calibration-cube'); 50 - await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); 43 + test('model switching keeps one canvas and updates renderer session metadata', async ({ page }) => { 44 + await page.goto('/viewer/body-f-chest-v4'); 51 45 await expectViewerReady(page); 52 - await page.getByRole('button', { name: 'Benchy hull stub' }).click(); 46 + await expect(page.locator(canvas)).toHaveCount(1); 47 + 48 + await page.getByRole('button', { name: 'Body F chest detail' }).click(); 53 49 await expect(page.locator(canvas)).toHaveCount(1); 54 - await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'benchy-hull#2'); 55 - await expect(page.locator(boundary)).toContainText('Benchy hull stub'); 50 + await expect(page.locator(preview)).toBeVisible(); 51 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4-detail#2'); 52 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', assetPath); 56 53 57 - await page.getByRole('button', { name: 'Calibration cube' }).click(); 54 + await page.getByRole('button', { name: 'Body F chest v4' }).click(); 58 55 await expect(page.locator(canvas)).toHaveCount(1); 59 - await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); 56 + await expect(page.locator(preview)).toBeVisible(); 57 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 60 58 }); 61 59 62 - test('resize callback remains single after state and model transitions', async ({ page }) => { 63 - await page.goto('/viewer/calibration-cube'); 60 + test('resize callback remains single after model transitions', async ({ page }) => { 61 + await page.goto('/viewer/body-f-chest-v4'); 64 62 const currentResizeCount = async () => Number(await page.locator(boundary).getAttribute('data-resize-count')); 65 63 await expectViewerReady(page); 66 64 const before = await currentResizeCount(); 67 65 await page.setViewportSize({ width: 1100, height: 760 }); 68 66 await expect.poll(currentResizeCount).toBe(before + 1); 69 67 70 - await page.getByRole('button', { name: 'Renderer error' }).click(); 71 - await page.getByRole('button', { name: 'Ready' }).click(); 72 - await page.getByRole('button', { name: 'Benchy hull stub' }).click(); 68 + await page.getByRole('button', { name: 'Body F chest detail' }).click(); 73 69 74 - const afterTransitions = await currentResizeCount(); 70 + const afterTransition = await currentResizeCount(); 75 71 await page.setViewportSize({ width: 1000, height: 700 }); 76 - await expect.poll(currentResizeCount).toBe(afterTransitions + 1); 72 + await expect.poll(currentResizeCount).toBe(afterTransition + 1); 77 73 });
+4 -4
src/main.rs
··· 64 64 Link { 65 65 class: "viewer-hero-link", 66 66 to: Route::Viewer { model_id: demo_models()[0].id.to_string() }, 67 - "Open canvas-isolated viewer demo" 67 + "Open viewer" 68 68 } 69 69 } 70 70 section { class: "browse-grid", ··· 122 122 token: "STL", 123 123 title: "Model viewer", 124 124 body: "Spike three-d and kiss3d as WASM viewer candidates; choose based on STL loading, orbit controls, rendering quality, and Dioxus integration friction.", 125 - viewer_link: Some("calibration-cube"), 125 + viewer_link: Some("body-f-chest-v4"), 126 126 }, 127 127 RoadmapCardData { 128 128 token: "PM", ··· 153 153 Link { 154 154 class: "demo-card-action", 155 155 to: Route::Viewer { model_id: model_id.to_string() }, 156 - "Try the viewer scaffold" 156 + "Open viewer" 157 157 } 158 158 } 159 159 } ··· 171 171 } 172 172 173 173 #[test] 174 - fn roadmap_links_to_viewer_scaffold() { 174 + fn roadmap_links_to_viewer() { 175 175 assert!( 176 176 roadmap_cards() 177 177 .iter()
+251 -183
src/viewer.rs
··· 8 8 pub struct DemoModel { 9 9 pub id: &'static str, 10 10 pub name: &'static str, 11 + pub asset_path: &'static str, 11 12 pub generation: u64, 12 13 pub vertices: usize, 13 14 pub triangles: usize, ··· 15 16 16 17 const DEMO_MODELS: [DemoModel; 2] = [ 17 18 DemoModel { 18 - id: "calibration-cube", 19 - name: "Calibration cube", 19 + id: "body-f-chest-v4", 20 + name: "Body F chest v4", 21 + asset_path: "/assets/models/body_f_chest-v4.stl", 20 22 generation: 1, 21 - vertices: 8, 22 - triangles: 12, 23 + vertices: 41051, 24 + triangles: 82122, 23 25 }, 24 26 DemoModel { 25 - id: "benchy-hull", 26 - name: "Benchy hull stub", 27 + id: "body-f-chest-v4-detail", 28 + name: "Body F chest detail", 29 + asset_path: "/assets/models/body_f_chest-v4.stl", 27 30 generation: 2, 28 - vertices: 42, 29 - triangles: 80, 31 + vertices: 41051, 32 + triangles: 82122, 30 33 }, 31 34 ]; 32 35 ··· 54 57 key: SessionKey::new(model.id, model.generation), 55 58 summary: MeshSummary { 56 59 display_name: model.name, 60 + asset_path: model.asset_path, 57 61 vertices: model.vertices, 58 62 triangles: model.triangles, 59 63 }, ··· 70 74 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 71 75 pub struct MeshSummary { 72 76 pub display_name: &'static str, 77 + pub asset_path: &'static str, 73 78 pub vertices: usize, 74 79 pub triangles: usize, 75 80 } ··· 105 110 } 106 111 } 107 112 113 + #[cfg_attr(not(test), allow(dead_code))] 108 114 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 109 115 pub enum ViewerIssue { 110 - Unsupported, 111 - Corrupt, 112 - TooLarge, 116 + MeshUnavailable, 113 117 RendererInit, 114 118 } 115 119 116 120 impl ViewerIssue { 117 121 pub fn message(self) -> &'static str { 118 122 match self { 119 - Self::Unsupported => "This model format is not supported yet.", 120 - Self::Corrupt => "This model could not be read. Try another file.", 121 - Self::TooLarge => "This model is too large for the browser viewer.", 123 + Self::MeshUnavailable => "This model could not be loaded for the viewer.", 122 124 Self::RendererInit => "The viewer could not start on this canvas.", 123 125 } 124 126 } 125 127 } 126 128 129 + #[cfg_attr(not(test), allow(dead_code))] 127 130 #[derive(Clone, Debug, PartialEq, Eq)] 128 131 pub enum ViewerStatus { 129 - NoMesh, 130 - Loading, 131 132 Ready(SessionKey), 132 133 MeshError(ViewerIssue), 133 134 RendererError(ViewerIssue), ··· 136 137 impl ViewerStatus { 137 138 pub fn label(&self) -> &'static str { 138 139 match self { 139 - Self::NoMesh => "No mesh selected", 140 - Self::Loading => "Loading mesh", 141 - Self::Ready(_) => "Stub renderer ready", 140 + Self::Ready(_) => "Viewer ready", 142 141 Self::MeshError(_) => "Mesh unavailable", 143 142 Self::RendererError(_) => "Renderer unavailable", 144 143 } ··· 146 145 147 146 pub fn message(&self) -> &'static str { 148 147 match self { 149 - Self::NoMesh => "Choose a model to start the viewer.", 150 - Self::Loading => "Preparing the mesh contract for the canvas boundary…", 151 - Self::Ready(_) => { 152 - "Stub rendering is active. A real renderer can attach behind this canvas boundary later." 153 - } 148 + Self::Ready(_) => "STL asset is loaded and ready for the canvas renderer.", 154 149 Self::MeshError(issue) | Self::RendererError(issue) => issue.message(), 155 150 } 156 151 } ··· 160 155 } 161 156 } 162 157 163 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 164 - enum DemoMode { 165 - NoMesh, 166 - Loading, 167 - Ready, 168 - Unsupported, 169 - Corrupt, 170 - TooLarge, 171 - RendererError, 158 + #[derive(Clone, Debug, PartialEq, Eq)] 159 + pub struct RendererMount { 160 + pub key: SessionKey, 161 + pub mesh: MeshSummary, 172 162 } 173 163 174 - impl DemoMode { 175 - fn status(self, key: &SessionKey) -> ViewerStatus { 176 - match self { 177 - Self::NoMesh => ViewerStatus::NoMesh, 178 - Self::Loading => ViewerStatus::Loading, 179 - Self::Ready => ViewerStatus::Ready(key.clone()), 180 - Self::Unsupported => ViewerStatus::MeshError(ViewerIssue::Unsupported), 181 - Self::Corrupt => ViewerStatus::MeshError(ViewerIssue::Corrupt), 182 - Self::TooLarge => ViewerStatus::MeshError(ViewerIssue::TooLarge), 183 - Self::RendererError => ViewerStatus::RendererError(ViewerIssue::RendererInit), 184 - } 164 + impl RendererMount { 165 + pub fn new(key: SessionKey, mesh: MeshSummary) -> Self { 166 + Self { key, mesh } 185 167 } 186 168 } 187 169 170 + #[cfg_attr(not(test), allow(dead_code))] 171 + pub trait ViewerRenderer: Sized { 172 + type Canvas; 173 + type Error; 174 + 175 + fn mount(canvas: Self::Canvas, mount: RendererMount) -> Result<Self, Self::Error>; 176 + fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error>; 177 + fn resize(&mut self) -> Result<(), Self::Error>; 178 + } 179 + 188 180 #[cfg(test)] 189 - pub struct SessionLifecycle { 190 - active: Option<TrackedSession>, 191 - cleanups: Rc<Cell<usize>>, 181 + pub struct RendererLifecycle<R: ViewerRenderer> { 182 + active: Option<R>, 192 183 } 193 184 194 185 #[cfg(test)] 195 - impl SessionLifecycle { 196 - pub fn new(cleanups: Rc<Cell<usize>>) -> Self { 197 - Self { 198 - active: None, 199 - cleanups, 186 + impl<R: ViewerRenderer> RendererLifecycle<R> 187 + where 188 + R::Canvas: Clone, 189 + { 190 + pub fn new() -> Self { 191 + Self { active: None } 192 + } 193 + 194 + pub fn reconcile( 195 + &mut self, 196 + canvas: Option<R::Canvas>, 197 + status: &ViewerStatus, 198 + mesh: Option<MeshSummary>, 199 + ) -> Result<(), R::Error> { 200 + let Some(canvas) = canvas else { 201 + self.active = None; 202 + return Ok(()); 203 + }; 204 + 205 + let (ViewerStatus::Ready(key), Some(mesh)) = (status, mesh) else { 206 + self.active = None; 207 + return Ok(()); 208 + }; 209 + 210 + let mount = RendererMount::new(key.clone(), mesh); 211 + match self.active.as_mut() { 212 + Some(renderer) => renderer.replace_mesh(mount), 213 + None => { 214 + self.active = Some(R::mount(canvas, mount)?); 215 + Ok(()) 216 + } 200 217 } 201 218 } 202 219 203 - pub fn reconcile(&mut self, status: &ViewerStatus) { 204 - match status { 205 - ViewerStatus::Ready(key) => { 206 - let same_session = self 207 - .active 208 - .as_ref() 209 - .is_some_and(|session| session.key == *key); 210 - if !same_session { 211 - self.active = Some(TrackedSession::new(key.clone(), Rc::clone(&self.cleanups))); 212 - } 213 - } 214 - _ => { 215 - self.active = None; 216 - } 220 + pub fn resize(&mut self) -> Result<(), R::Error> { 221 + if let Some(renderer) = self.active.as_mut() { 222 + renderer.resize()?; 217 223 } 224 + Ok(()) 218 225 } 219 226 220 227 pub fn has_active_session(&self) -> bool { ··· 222 229 } 223 230 } 224 231 225 - #[cfg(test)] 226 - struct TrackedSession { 227 - key: SessionKey, 228 - cleanups: Rc<Cell<usize>>, 229 - } 230 - 231 - #[cfg(test)] 232 - impl TrackedSession { 233 - fn new(key: SessionKey, cleanups: Rc<Cell<usize>>) -> Self { 234 - Self { key, cleanups } 235 - } 236 - } 237 - 238 - #[cfg(test)] 239 - impl Drop for TrackedSession { 240 - fn drop(&mut self) { 241 - self.cleanups.set(self.cleanups.get() + 1); 242 - } 243 - } 244 - 245 232 #[component] 246 233 pub fn ViewerPage(initial_model: DemoModel) -> Element { 247 234 let mut selected_model = use_signal(|| initial_model); 248 - let mut mode = use_signal(|| DemoMode::Ready); 249 235 let canvas_mounted = use_signal(|| false); 250 236 let resize_count = use_signal(|| 0_u64); 251 237 252 238 let model = selected_model(); 253 239 let mesh = ViewerMeshHandle::from_demo_model(model); 254 - let status = mode().status(&mesh.key); 240 + let status = ViewerStatus::Ready(mesh.key.clone()); 255 241 let visible_mesh = status.should_have_session().then_some(mesh.clone()); 256 242 let session_label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); 243 + 257 244 rsx! { 258 - section { class: "model-viewer-shell", aria_label: "Dioxus canvas-isolated model viewer scaffold", 245 + section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", 259 246 header { class: "model-viewer-header", 260 247 div { 261 - p { class: "viewer-eyebrow", "Canvas viewer boundary" } 262 - h1 { "Model viewer scaffold" } 263 - p { "Renderer-agnostic Dioxus route with one owned canvas and explicit lifecycle states." } 248 + p { class: "viewer-eyebrow", "Asset-backed STL viewer" } 249 + h1 { "Model viewer" } 250 + p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } 264 251 } 265 252 div { class: "viewer-status-pill", "{status.label()}" } 266 253 } 267 254 268 - div { class: "viewer-demo-controls", aria_label: "Demo model controls", 255 + div { class: "viewer-demo-controls", aria_label: "Model choices", 269 256 for model_option in demo_models() { 270 257 button { 271 258 class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, 272 259 r#type: "button", 273 - onclick: move |_| { 274 - selected_model.set(*model_option); 275 - mode.set(DemoMode::Ready); 276 - }, 260 + onclick: move |_| selected_model.set(*model_option), 277 261 "{model_option.name}" 278 262 } 279 263 } 280 - } 281 - 282 - div { class: "viewer-demo-controls", aria_label: "Viewer state controls", 283 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::NoMesh), "No mesh" } button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Loading), "Loading" } 284 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Ready), "Ready" } 285 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Unsupported), "Unsupported" } 286 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Corrupt), "Corrupt" } 287 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::TooLarge), "Too large" } 288 - button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::RendererError), "Renderer error" } 289 264 } 290 265 291 266 ModelViewer { ··· 314 289 let resize_count = props.resize_count; 315 290 let status = props.status.clone(); 316 291 let mesh = props.mesh.clone(); 292 + let asset_path = mesh 293 + .as_ref() 294 + .map(|mesh| mesh.summary.asset_path) 295 + .unwrap_or(""); 317 296 318 297 rsx! { 319 - 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}", 298 + div { 299 + class: "model-viewer-boundary", 300 + id: "polymodel-viewer-boundary", 301 + "data-viewer-status": "{status.label()}", 302 + "data-session-wanted": "{status.should_have_session()}", 303 + "data-resize-count": "{resize_count()}", 304 + "data-session-label": "{props.session_label}", 305 + "data-asset-path": asset_path.to_string(), 320 306 321 307 div { class: "model-viewer-stage", 322 308 canvas { 323 - id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted()}", 309 + id: "polymodel-viewer-canvas", 310 + "data-mounted": "{canvas_mounted()}", 324 311 class: "model-viewer-canvas", 325 312 aria_label: "Polymodel model viewer canvas", 326 313 onmounted: move |event| mark_canvas_mounted(event, canvas_mounted), 327 314 onwheel: move |event| event.stop_propagation(), 328 315 onpointerdown: move |event| event.stop_propagation(), 329 316 } 317 + if let Some(ref mesh) = mesh { 318 + AssetPreview { mesh: mesh.clone() } 319 + } 330 320 div { class: "model-viewer-overlay", aria_live: "polite", 331 321 h2 { "{status.label()}" } 332 322 p { "{status.message()}" } 333 - if let Some(mesh) = mesh { 323 + if let Some(ref mesh) = mesh { 334 324 dl { class: "viewer-mesh-summary", 335 325 div { dt { "Model" } dd { "{mesh.summary.display_name}" } } 336 - div { dt { "Session" } dd { "{props.session_label}" } } 326 + div { dt { "Asset" } dd { "{mesh.summary.asset_path}" } } 337 327 div { dt { "Vertices" } dd { "{mesh.summary.vertices}" } } 338 328 div { dt { "Triangles" } dd { "{mesh.summary.triangles}" } } 339 329 } 340 330 } 341 - p { class: "viewer-lifecycle-note", 342 - "Canvas mounted: {canvas_mounted()} · resize callbacks: {resize_count()} · session wanted: {status.should_have_session()}" 343 - } 344 331 } 345 332 } 346 333 } 347 - StubCanvasSession { 334 + CanvasRendererSession { 348 335 status: status.clone(), 336 + mesh, 349 337 canvas_mounted, 350 338 resize_count, 351 339 } ··· 353 341 } 354 342 355 343 #[component] 356 - fn StubCanvasSession( 344 + fn AssetPreview(mesh: ViewerMeshHandle) -> Element { 345 + rsx! { 346 + svg { 347 + id: "polymodel-viewer-preview", 348 + class: "model-viewer-preview", 349 + view_box: "0 0 640 420", 350 + role: "img", 351 + "aria-label": "Preview silhouette for {mesh.summary.display_name}", 352 + path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" } 353 + 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" } 354 + 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" } 355 + path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" } 356 + path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" } 357 + text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{mesh.summary.triangles} triangles · {mesh.summary.vertices} vertices" } 358 + } 359 + } 360 + } 361 + 362 + #[component] 363 + fn CanvasRendererSession( 357 364 status: ViewerStatus, 365 + mesh: Option<ViewerMeshHandle>, 358 366 canvas_mounted: Signal<bool>, 359 367 resize_count: Signal<u64>, 360 368 ) -> Element { 361 - let session_key = match status { 362 - ViewerStatus::Ready(ref key) if canvas_mounted() => Some(key.clone()), 369 + let mount = match (status, mesh) { 370 + (ViewerStatus::Ready(key), Some(mesh)) if canvas_mounted() => { 371 + Some(RendererMount::new(key, mesh.summary)) 372 + } 363 373 _ => None, 364 374 }; 365 375 366 376 rsx! { 367 - if let Some(key) = session_key { 368 - StubCanvasSessionInner { key: "{key.model_id()}-{key.generation()}", session_key: key, resize_count } 377 + if let Some(mount) = mount { 378 + BrowserRendererSession { key: "{mount.key.model_id()}-{mount.key.generation()}", mount, resize_count } 369 379 } 370 380 } 371 381 } 372 382 373 383 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 374 384 #[component] 375 - fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal<u64>) -> Element { 376 - let _ = session_key; 385 + fn BrowserRendererSession(mount: RendererMount, resize_count: Signal<u64>) -> Element { 386 + let _ = mount; 377 387 let _ = resize_count; 378 388 rsx! {} 379 389 } 380 390 381 391 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 382 392 #[component] 383 - fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal<u64>) -> Element { 393 + fn BrowserRendererSession(mount: RendererMount, resize_count: Signal<u64>) -> Element { 384 394 use wasm_bindgen::JsCast; 385 395 use wasm_bindgen::closure::Closure; 386 396 use web_sys::Event; ··· 409 419 } 410 420 } 411 421 412 - std::rc::Rc::new(StubBrowserSession { 413 - key: session_key, 414 - cleanup, 415 - }) 422 + std::rc::Rc::new(BrowserRendererStub { mount, cleanup }) 416 423 }); 417 424 418 425 rsx! {} 419 426 } 420 427 421 428 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 422 - struct StubBrowserSession { 423 - key: SessionKey, 429 + struct BrowserRendererStub { 430 + mount: RendererMount, 424 431 cleanup: Option<Box<dyn FnOnce()>>, 425 432 } 426 433 427 434 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 428 - impl Drop for StubBrowserSession { 435 + impl Drop for BrowserRendererStub { 429 436 fn drop(&mut self) { 430 437 tracing::debug!( 431 - model_id = self.key.model_id(), 432 - generation = self.key.generation(), 433 - "dropping stub canvas session" 438 + model_id = self.mount.key.model_id(), 439 + generation = self.mount.key.generation(), 440 + asset_path = self.mount.mesh.asset_path, 441 + "dropping browser renderer session" 434 442 ); 435 443 if let Some(cleanup) = self.cleanup.take() { 436 444 cleanup(); 437 445 } 438 446 } 439 447 } 448 + 440 449 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 441 450 fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal<bool>) { 442 451 mounted.set(true); 443 452 } 453 + 444 454 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 445 455 fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal<bool>) { 446 456 use dioxus::web::WebEventExt; ··· 448 458 let element = event.as_web_event(); 449 459 mounted.set(element.dyn_into::<web_sys::HtmlCanvasElement>().is_ok()); 450 460 } 461 + 451 462 #[cfg(test)] 452 463 mod tests { 453 464 use super::*; 465 + use crate::mesh::stl::load_stl; 466 + 467 + #[derive(Clone)] 468 + struct FakeCanvas; 469 + 470 + struct FakeRenderer { 471 + events: Rc<Cell<usize>>, 472 + drops: Rc<Cell<usize>>, 473 + } 474 + 475 + impl ViewerRenderer for FakeRenderer { 476 + type Canvas = (FakeCanvas, Rc<Cell<usize>>, Rc<Cell<usize>>); 477 + type Error = (); 478 + 479 + fn mount(canvas: Self::Canvas, _mount: RendererMount) -> Result<Self, Self::Error> { 480 + canvas.1.set(canvas.1.get() + 1); 481 + Ok(Self { 482 + events: canvas.1, 483 + drops: canvas.2, 484 + }) 485 + } 486 + 487 + fn replace_mesh(&mut self, _mount: RendererMount) -> Result<(), Self::Error> { 488 + self.events.set(self.events.get() + 1); 489 + Ok(()) 490 + } 491 + 492 + fn resize(&mut self) -> Result<(), Self::Error> { 493 + self.events.set(self.events.get() + 1); 494 + Ok(()) 495 + } 496 + } 497 + 498 + impl Drop for FakeRenderer { 499 + fn drop(&mut self) { 500 + self.drops.set(self.drops.get() + 1); 501 + } 502 + } 503 + 504 + fn first_mount() -> RendererMount { 505 + let model = demo_models()[0]; 506 + let handle = ViewerMeshHandle::from_demo_model(model); 507 + RendererMount::new(handle.key, handle.summary) 508 + } 509 + 510 + #[test] 511 + fn demo_route_uses_committed_stl_asset() { 512 + let model = demo_model_for_route("body-f-chest-v4"); 513 + assert_eq!(model.asset_path, "/assets/models/body_f_chest-v4.stl"); 514 + assert_eq!(model.vertices, 41051); 515 + assert_eq!(model.triangles, 82122); 516 + } 517 + 518 + #[test] 519 + fn demo_model_metadata_matches_committed_fixture() { 520 + let model = demo_models()[0]; 521 + let path = format!("{}{}", env!("CARGO_MANIFEST_DIR"), model.asset_path); 522 + let bytes = std::fs::read(path).expect("demo STL asset should be committed"); 523 + let mesh = load_stl(&bytes).expect("demo STL asset should parse"); 524 + 525 + assert_eq!(mesh.trimesh.positions.len(), model.vertices); 526 + assert_eq!(mesh.trimesh.triangle_count(), model.triangles); 527 + } 454 528 455 529 #[test] 456 530 fn switching_models_advances_session_generation() { 457 - let first = SessionKey::new("calibration-cube", 7); 458 - let second = first.switch_to("benchy-hull"); 531 + let first = SessionKey::new("body-f-chest-v4", 7); 532 + let second = first.switch_to("body-f-chest-v4-detail"); 459 533 460 - assert_eq!(second.model_id(), "benchy-hull"); 534 + assert_eq!(second.model_id(), "body-f-chest-v4-detail"); 461 535 assert_eq!(second.generation(), 8); 462 536 } 463 537 464 538 #[test] 465 539 fn status_labels_are_user_facing() { 466 - let ready = ViewerStatus::Ready(SessionKey::new("calibration-cube", 1)); 540 + let ready = ViewerStatus::Ready(SessionKey::new("body-f-chest-v4", 1)); 467 541 468 - assert_eq!(ViewerStatus::Loading.label(), "Loading mesh"); 469 - assert_eq!(ready.label(), "Stub renderer ready"); 542 + assert_eq!(ready.label(), "Viewer ready"); 470 543 assert_eq!( 471 - ViewerStatus::MeshError(ViewerIssue::Corrupt).label(), 544 + ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).label(), 472 545 "Mesh unavailable" 473 546 ); 474 547 } 475 548 476 549 #[test] 477 - fn modeled_errors_have_product_messages() { 478 - assert_eq!( 479 - ViewerIssue::Unsupported.message(), 480 - "This model format is not supported yet." 481 - ); 482 - assert_eq!( 483 - ViewerIssue::Corrupt.message(), 484 - "This model could not be read. Try another file." 485 - ); 486 - assert_eq!( 487 - ViewerIssue::TooLarge.message(), 488 - "This model is too large for the browser viewer." 489 - ); 490 - assert_eq!( 491 - ViewerIssue::RendererInit.message(), 492 - "The viewer could not start on this canvas." 493 - ); 494 - } 550 + fn renderer_lifecycle_mounts_replaces_resizes_and_drops() { 551 + let events = Rc::new(Cell::new(0)); 552 + let drops = Rc::new(Cell::new(0)); 553 + let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops)); 554 + let mut lifecycle = RendererLifecycle::<FakeRenderer>::new(); 555 + let mount = first_mount(); 556 + let status = ViewerStatus::Ready(mount.key.clone()); 495 557 496 - #[test] 497 - fn demo_models_are_switchable() { 498 - let models = demo_models(); 558 + lifecycle 559 + .reconcile(Some(canvas.clone()), &status, Some(mount.mesh)) 560 + .expect("mount should succeed"); 561 + assert!(lifecycle.has_active_session()); 562 + assert_eq!(events.get(), 1); 499 563 500 - assert!(models.len() >= 2); 501 - assert_ne!(models[0].id, models[1].id); 502 - } 564 + lifecycle.resize().expect("resize should forward"); 565 + assert_eq!(events.get(), 2); 503 566 504 - #[test] 505 - fn mesh_handle_equality_uses_only_session_key() { 506 - let mut cube = ViewerMeshHandle::from_demo_model(DEMO_MODELS[0]); 507 - let same_key = cube.clone(); 508 - cube.summary.vertices += 10; 567 + let replacement = 568 + RendererMount::new(SessionKey::new("body-f-chest-v4-detail", 2), mount.mesh); 569 + lifecycle 570 + .reconcile( 571 + Some(canvas), 572 + &ViewerStatus::Ready(replacement.key), 573 + Some(replacement.mesh), 574 + ) 575 + .expect("replace should succeed"); 576 + assert_eq!(events.get(), 3); 577 + assert_eq!(drops.get(), 0); 509 578 510 - assert_eq!(cube, same_key); 579 + lifecycle 580 + .reconcile( 581 + None, 582 + &ViewerStatus::RendererError(ViewerIssue::RendererInit), 583 + None, 584 + ) 585 + .expect("drop path should succeed"); 586 + assert!(!lifecycle.has_active_session()); 587 + assert_eq!(drops.get(), 1); 511 588 } 512 589 513 590 #[test] 514 - fn lifecycle_drops_replaced_and_error_sessions_once() { 515 - let cleanups = Rc::new(Cell::new(0)); 516 - let mut lifecycle = SessionLifecycle::new(Rc::clone(&cleanups)); 591 + fn mesh_handle_equality_uses_only_session_key() { 592 + let mut body = ViewerMeshHandle::from_demo_model(DEMO_MODELS[0]); 593 + let same_key = body.clone(); 594 + body.summary.vertices += 10; 517 595 518 - lifecycle.reconcile(&ViewerStatus::Ready(SessionKey::new("a", 1))); 519 - assert!(lifecycle.has_active_session()); 520 - assert_eq!(cleanups.get(), 0); 521 - 522 - lifecycle.reconcile(&ViewerStatus::Ready(SessionKey::new("b", 2))); 523 - assert!(lifecycle.has_active_session()); 524 - assert_eq!(cleanups.get(), 1); 525 - 526 - lifecycle.reconcile(&ViewerStatus::RendererError(ViewerIssue::RendererInit)); 527 - assert!(!lifecycle.has_active_session()); 528 - assert_eq!(cleanups.get(), 2); 596 + assert_eq!(body, same_key); 529 597 } 530 598 }