atproto Thingiverse but good
12

Configure Feed

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

PM-19: Dioxus canvas-isolated viewer scaffold

Orual (Jun 29, 2026, 7:06 PM EDT) 587876f4 c89e6bee

+937 -21
+2 -2
.gitignore
··· 7 7 !.polytoken 8 8 node_modules/ 9 9 .polytoken/permissions.local.yaml 10 - 11 - # Local-only Confluence draft staging (source of truth is Confluence space PM) 10 + /e2e/test-results/** 11 + /e2e/playwright-report/** 12 12 /docs/**
+16 -14
Cargo.lock
··· 3855 3855 3856 3856 [[package]] 3857 3857 name = "js-sys" 3858 - version = "0.3.102" 3858 + version = "0.3.98" 3859 3859 source = "registry+https://github.com/rust-lang/crates.io-index" 3860 - checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" 3860 + checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" 3861 3861 dependencies = [ 3862 3862 "cfg-if", 3863 3863 "futures-util", 3864 + "once_cell", 3864 3865 "wasm-bindgen", 3865 3866 ] 3866 3867 ··· 5208 5209 "tower-http", 5209 5210 "tracing", 5210 5211 "tracing-wasm", 5212 + "wasm-bindgen", 5211 5213 "web-sys", 5212 5214 ] 5213 5215 ··· 7420 7422 7421 7423 [[package]] 7422 7424 name = "wasm-bindgen" 7423 - version = "0.2.125" 7425 + version = "0.2.121" 7424 7426 source = "registry+https://github.com/rust-lang/crates.io-index" 7425 - checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" 7427 + checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" 7426 7428 dependencies = [ 7427 7429 "cfg-if", 7428 7430 "once_cell", ··· 7433 7435 7434 7436 [[package]] 7435 7437 name = "wasm-bindgen-futures" 7436 - version = "0.4.75" 7438 + version = "0.4.71" 7437 7439 source = "registry+https://github.com/rust-lang/crates.io-index" 7438 - checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" 7440 + checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" 7439 7441 dependencies = [ 7440 7442 "js-sys", 7441 7443 "wasm-bindgen", ··· 7443 7445 7444 7446 [[package]] 7445 7447 name = "wasm-bindgen-macro" 7446 - version = "0.2.125" 7448 + version = "0.2.121" 7447 7449 source = "registry+https://github.com/rust-lang/crates.io-index" 7448 - checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" 7450 + checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" 7449 7451 dependencies = [ 7450 7452 "quote", 7451 7453 "wasm-bindgen-macro-support", ··· 7453 7455 7454 7456 [[package]] 7455 7457 name = "wasm-bindgen-macro-support" 7456 - version = "0.2.125" 7458 + version = "0.2.121" 7457 7459 source = "registry+https://github.com/rust-lang/crates.io-index" 7458 - checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" 7460 + checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" 7459 7461 dependencies = [ 7460 7462 "bumpalo", 7461 7463 "proc-macro2", ··· 7466 7468 7467 7469 [[package]] 7468 7470 name = "wasm-bindgen-shared" 7469 - version = "0.2.125" 7471 + version = "0.2.121" 7470 7472 source = "registry+https://github.com/rust-lang/crates.io-index" 7471 - checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" 7473 + checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" 7472 7474 dependencies = [ 7473 7475 "unicode-ident", 7474 7476 ] ··· 7488 7490 7489 7491 [[package]] 7490 7492 name = "web-sys" 7491 - version = "0.3.102" 7493 + version = "0.3.98" 7492 7494 source = "registry+https://github.com/rust-lang/crates.io-index" 7493 - checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" 7495 + checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" 7494 7496 dependencies = [ 7495 7497 "js-sys", 7496 7498 "wasm-bindgen",
+2 -1
Cargo.toml
··· 31 31 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 32 32 console_error_panic_hook = "0.1" 33 33 tracing-wasm = "0.2" 34 - web-sys = { version = "0.3", features = ["Window", "Document", "Element"] } 34 + wasm-bindgen = "=0.2.121" 35 + web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement"] } 35 36 36 37 [dev-dependencies] 37 38 tokio = { version = "1", features = ["rt", "macros"] }
+168
assets/styling/polymodel.css
··· 28 28 line-height: 1.45; 29 29 } 30 30 31 + .demo-card-action, 32 + .viewer-hero-link, 33 + .viewer-route-nav a { 34 + display: inline-flex; 35 + align-items: center; 36 + width: fit-content; 37 + margin-top: 0.85rem; 38 + color: var(--color-base); 39 + background: var(--color-primary); 40 + padding: 0.45rem 0.7rem; 41 + text-decoration: none; 42 + font-weight: 700; 43 + } 44 + 45 + .demo-card-action:hover, 46 + .viewer-hero-link:hover, 47 + .viewer-route-nav a:hover { 48 + filter: brightness(1.05); 49 + } 50 + 31 51 .demo-context { 32 52 margin: 1rem 0 1.5rem; 33 53 padding: 0.75rem 1rem; ··· 39 59 .demo-context strong { 40 60 color: var(--color-text); 41 61 } 62 + 63 + .viewer-route-page { 64 + min-height: 100vh; 65 + padding: clamp(1rem, 4vw, 3rem); 66 + background: var(--color-background); 67 + color: var(--color-text); 68 + } 69 + 70 + .viewer-route-nav { 71 + margin: 0 auto 1rem; 72 + max-width: 72rem; 73 + } 74 + 75 + .model-viewer-shell { 76 + display: grid; 77 + gap: 1rem; 78 + max-width: 72rem; 79 + margin: 0 auto; 80 + } 81 + 82 + .model-viewer-header, 83 + .model-viewer-boundary, 84 + .viewer-demo-controls { 85 + border: 1px solid var(--color-border); 86 + background: var(--color-surface); 87 + } 88 + 89 + .model-viewer-header { 90 + display: flex; 91 + align-items: flex-start; 92 + justify-content: space-between; 93 + gap: 1rem; 94 + padding: clamp(1rem, 3vw, 1.5rem); 95 + } 96 + 97 + .model-viewer-header h1, 98 + .model-viewer-header p { 99 + margin: 0; 100 + } 101 + 102 + .viewer-eyebrow { 103 + font-family: var(--font-mono); 104 + color: var(--color-primary); 105 + text-transform: uppercase; 106 + letter-spacing: 0.08em; 107 + font-size: 0.75rem; 108 + } 109 + 110 + .viewer-status-pill { 111 + white-space: nowrap; 112 + border: 1px solid var(--color-primary); 113 + color: var(--color-primary); 114 + padding: 0.35rem 0.55rem; 115 + font-family: var(--font-mono); 116 + font-size: 0.75rem; 117 + } 118 + 119 + .viewer-demo-controls { 120 + display: flex; 121 + flex-wrap: wrap; 122 + gap: 0.5rem; 123 + padding: 0.75rem; 124 + } 125 + 126 + .viewer-demo-button { 127 + border: 1px solid var(--color-border); 128 + background: var(--color-background); 129 + color: var(--color-text); 130 + padding: 0.45rem 0.65rem; 131 + cursor: pointer; 132 + } 133 + 134 + .viewer-demo-button:hover, 135 + .viewer-demo-button.is-active { 136 + border-color: var(--color-primary); 137 + color: var(--color-primary); 138 + } 139 + 140 + .model-viewer-boundary { 141 + position: relative; 142 + overflow: hidden; 143 + min-height: min(72vh, 42rem); 144 + } 145 + 146 + .model-viewer-stage { 147 + position: relative; 148 + min-height: min(72vh, 42rem); 149 + background: 150 + linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), 151 + radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), 152 + var(--color-background); 153 + } 154 + 155 + .model-viewer-canvas { 156 + display: block; 157 + width: 100%; 158 + min-height: min(72vh, 42rem); 159 + touch-action: none; 160 + cursor: grab; 161 + } 162 + 163 + .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 + } 172 + 173 + .model-viewer-overlay h2, 174 + .model-viewer-overlay p, 175 + .viewer-mesh-summary { 176 + margin: 0; 177 + } 178 + 179 + .model-viewer-overlay p + .viewer-mesh-summary, 180 + .viewer-mesh-summary + .viewer-lifecycle-note { 181 + margin-top: 0.75rem; 182 + } 183 + 184 + .viewer-mesh-summary { 185 + display: grid; 186 + grid-template-columns: repeat(2, minmax(0, 1fr)); 187 + gap: 0.5rem 1rem; 188 + } 189 + 190 + .viewer-mesh-summary div { 191 + display: grid; 192 + gap: 0.15rem; 193 + } 194 + 195 + .viewer-mesh-summary dt { 196 + color: var(--color-subtle); 197 + font-family: var(--font-mono); 198 + font-size: 0.7rem; 199 + text-transform: uppercase; 200 + } 201 + 202 + .viewer-mesh-summary dd { 203 + margin: 0; 204 + } 205 + 206 + .viewer-lifecycle-note { 207 + color: var(--color-subtle); 208 + font-size: 0.85rem; 209 + }
+76
e2e/package-lock.json
··· 1 + { 2 + "name": "polymodel-e2e", 3 + "lockfileVersion": 3, 4 + "requires": true, 5 + "packages": { 6 + "": { 7 + "name": "polymodel-e2e", 8 + "devDependencies": { 9 + "@playwright/test": "1.59.1" 10 + } 11 + }, 12 + "node_modules/@playwright/test": { 13 + "version": "1.59.1", 14 + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", 15 + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", 16 + "dev": true, 17 + "license": "Apache-2.0", 18 + "dependencies": { 19 + "playwright": "1.59.1" 20 + }, 21 + "bin": { 22 + "playwright": "cli.js" 23 + }, 24 + "engines": { 25 + "node": ">=18" 26 + } 27 + }, 28 + "node_modules/fsevents": { 29 + "version": "2.3.2", 30 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 31 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 32 + "dev": true, 33 + "hasInstallScript": true, 34 + "license": "MIT", 35 + "optional": true, 36 + "os": [ 37 + "darwin" 38 + ], 39 + "engines": { 40 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 41 + } 42 + }, 43 + "node_modules/playwright": { 44 + "version": "1.59.1", 45 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", 46 + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", 47 + "dev": true, 48 + "license": "Apache-2.0", 49 + "dependencies": { 50 + "playwright-core": "1.59.1" 51 + }, 52 + "bin": { 53 + "playwright": "cli.js" 54 + }, 55 + "engines": { 56 + "node": ">=18" 57 + }, 58 + "optionalDependencies": { 59 + "fsevents": "2.3.2" 60 + } 61 + }, 62 + "node_modules/playwright-core": { 63 + "version": "1.59.1", 64 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", 65 + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", 66 + "dev": true, 67 + "license": "Apache-2.0", 68 + "bin": { 69 + "playwright-core": "cli.js" 70 + }, 71 + "engines": { 72 + "node": ">=18" 73 + } 74 + } 75 + } 76 + }
+1 -1
e2e/package.json
··· 6 6 "test": "playwright test" 7 7 }, 8 8 "devDependencies": { 9 - "@playwright/test": "^1.57.0" 9 + "@playwright/test": "1.59.1" 10 10 } 11 11 }
+3 -3
e2e/playwright.config.ts
··· 1 1 import { defineConfig, devices } from '@playwright/test'; 2 - 3 - export default defineConfig({ 2 + import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; 3 + function findNixChromiumHeadlessShell(): string | undefined { const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; if (!browsersPath || !existsSync(browsersPath)) return undefined; const findExecutable = (root: string): string | undefined => { for (const entry of readdirSync(root)) { const candidate = `${root}/${entry}`; const stat = statSync(candidate); if (stat.isFile() && entry === 'chrome-headless-shell') return candidate; if (stat.isDirectory()) { const found = findExecutable(candidate); if (found) return found; } } return undefined; }; for (const entry of readdirSync(browsersPath)) { if (entry.startsWith('chromium_headless_shell-')) { const found = findExecutable(realpathSync(`${browsersPath}/${entry}`)); if (found) return found; } } return undefined; } const chromiumExecutablePath = findNixChromiumHeadlessShell(); export default defineConfig({ 4 4 testDir: './tests', 5 5 timeout: 30_000, 6 6 expect: { timeout: 5_000 }, ··· 9 9 trace: 'on-first-retry', 10 10 }, 11 11 projects: [ 12 - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, 12 + { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : undefined } }, 13 13 ], 14 14 webServer: { 15 15 command: 'just serve',
+77
e2e/tests/viewer.spec.ts
··· 1 + import { expect, test } from '@playwright/test'; 2 + 3 + const boundary = '#polymodel-viewer-boundary'; 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(); 9 + 10 + await expect(page).toHaveURL(/\/viewer\/calibration-cube$/); 11 + await expectViewerReady(page); 12 + 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 + }); 16 + 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'); 22 + 23 + await page.goto('/viewer/benchy-hull'); 24 + 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'); 27 + }); 28 + 29 + test('viewer state controls show loading and product-facing errors', async ({ page }) => { 30 + await page.goto('/viewer/calibration-cube'); 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'); 42 + 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'); 46 + }); 47 + 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'); 51 + await expectViewerReady(page); 52 + await page.getByRole('button', { name: 'Benchy hull stub' }).click(); 53 + 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'); 56 + 57 + await page.getByRole('button', { name: 'Calibration cube' }).click(); 58 + await expect(page.locator(canvas)).toHaveCount(1); 59 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); 60 + }); 61 + 62 + test('resize callback remains single after state and model transitions', async ({ page }) => { 63 + await page.goto('/viewer/calibration-cube'); 64 + const currentResizeCount = async () => Number(await page.locator(boundary).getAttribute('data-resize-count')); 65 + await expectViewerReady(page); 66 + const before = await currentResizeCount(); 67 + await page.setViewportSize({ width: 1100, height: 760 }); 68 + await expect.poll(currentResizeCount).toBe(before + 1); 69 + 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(); 73 + 74 + const afterTransitions = await currentResizeCount(); 75 + await page.setViewportSize({ width: 1000, height: 700 }); 76 + await expect.poll(currentResizeCount).toBe(afterTransitions + 1); 77 + });
+62
src/main.rs
··· 1 1 use dioxus::prelude::*; 2 + 2 3 mod mesh; 4 + mod viewer; 5 + 6 + use viewer::{ViewerPage, demo_model_for_route, demo_models}; 7 + 3 8 const FAVICON: Asset = asset!("/assets/favicon.jpg"); 4 9 const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); 5 10 const BASE_CSS: Asset = asset!("/assets/styling/base.css"); ··· 17 22 dioxus::launch(App); 18 23 } 19 24 25 + #[derive(Clone, Debug, PartialEq, Routable)] 26 + enum Route { 27 + #[route("/")] 28 + Browse {}, 29 + #[route("/viewer/:model_id")] 30 + Viewer { model_id: String }, 31 + } 32 + 20 33 #[component] 21 34 fn App() -> Element { 22 35 rsx! { ··· 32 45 Stylesheet { href: BROWSE_CSS } 33 46 Stylesheet { href: POLYMODEL_CSS } 34 47 48 + Router::<Route> {} 49 + } 50 + } 51 + 52 + #[component] 53 + fn Browse() -> Element { 54 + rsx! { 35 55 main { class: "browse-page", 36 56 header { class: "browse-header", 37 57 h1 { "Polymodel" } ··· 41 61 strong { "Workflow: " } 42 62 "Jira + Confluence + jj + Polytoken." 43 63 } 64 + Link { 65 + class: "viewer-hero-link", 66 + to: Route::Viewer { model_id: demo_models()[0].id.to_string() }, 67 + "Open canvas-isolated viewer demo" 68 + } 44 69 } 45 70 section { class: "browse-grid", 46 71 for card in roadmap_cards() { ··· 51 76 } 52 77 } 53 78 79 + #[component] 80 + fn Viewer(model_id: String) -> Element { 81 + let model = demo_model_for_route(&model_id); 82 + 83 + rsx! { 84 + main { class: "viewer-route-page", 85 + nav { class: "viewer-route-nav", aria_label: "Viewer navigation", 86 + Link { to: Route::Browse {}, "← Back to browse" } 87 + } 88 + ViewerPage { key: "{model.id}", initial_model: model } 89 + } 90 + } 91 + } 92 + 54 93 #[derive(Clone, Copy, PartialEq)] 55 94 struct RoadmapCardData { 56 95 token: &'static str, 57 96 title: &'static str, 58 97 body: &'static str, 98 + viewer_link: Option<&'static str>, 59 99 } 60 100 61 101 fn roadmap_cards() -> [RoadmapCardData; 6] { ··· 64 104 token: "NSID", 65 105 title: "Project lexicon", 66 106 body: "Define the model/project record: title, description, files, preview assets, tags, license, and attribution.", 107 + viewer_link: None, 67 108 }, 68 109 RoadmapCardData { 69 110 token: "DID", 70 111 title: "Profile publishing", 71 112 body: "Use ATProto identity and profile-oriented feeds so makers can publish and browse from their own repos.", 113 + viewer_link: None, 72 114 }, 73 115 RoadmapCardData { 74 116 token: "★", 75 117 title: "Likes and saves", 76 118 body: "Start with lightweight feedback records that can drive hot/recent discovery without inventing a heavyweight backend.", 119 + viewer_link: None, 77 120 }, 78 121 RoadmapCardData { 79 122 token: "STL", 80 123 title: "Model viewer", 81 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"), 82 126 }, 83 127 RoadmapCardData { 84 128 token: "PM", 85 129 title: "Jira and Confluence", 86 130 body: "Use Jira and Confluence as both planning/demo narrative and agent-readable workflow state.", 131 + viewer_link: None, 87 132 }, 88 133 RoadmapCardData { 89 134 token: "pt", 90 135 title: "Polytoken workflow", 91 136 body: "Use facets, skills, hooks, project vars, jj workspaces, and review gates to make the demo workflow real.", 137 + viewer_link: None, 92 138 }, 93 139 ] 94 140 } ··· 103 149 div { class: "video-card-body demo-card-body", 104 150 h2 { class: "card-title", "{card.title}" } 105 151 p { "{card.body}" } 152 + if let Some(model_id) = card.viewer_link { 153 + Link { 154 + class: "demo-card-action", 155 + to: Route::Viewer { model_id: model_id.to_string() }, 156 + "Try the viewer scaffold" 157 + } 158 + } 106 159 } 107 160 } 108 161 } ··· 115 168 #[test] 116 169 fn roadmap_includes_stl_viewer() { 117 170 assert!(roadmap_cards().iter().any(|card| card.token == "STL")); 171 + } 172 + 173 + #[test] 174 + fn roadmap_links_to_viewer_scaffold() { 175 + assert!( 176 + roadmap_cards() 177 + .iter() 178 + .any(|card| card.viewer_link.is_some()) 179 + ); 118 180 } 119 181 }
+530
src/viewer.rs
··· 1 + use dioxus::prelude::*; 2 + #[cfg(test)] 3 + use std::cell::Cell; 4 + #[cfg(test)] 5 + use std::rc::Rc; 6 + 7 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 8 + pub struct DemoModel { 9 + pub id: &'static str, 10 + pub name: &'static str, 11 + pub generation: u64, 12 + pub vertices: usize, 13 + pub triangles: usize, 14 + } 15 + 16 + const DEMO_MODELS: [DemoModel; 2] = [ 17 + DemoModel { 18 + id: "calibration-cube", 19 + name: "Calibration cube", 20 + generation: 1, 21 + vertices: 8, 22 + triangles: 12, 23 + }, 24 + DemoModel { 25 + id: "benchy-hull", 26 + name: "Benchy hull stub", 27 + generation: 2, 28 + vertices: 42, 29 + triangles: 80, 30 + }, 31 + ]; 32 + 33 + pub fn demo_models() -> &'static [DemoModel] { 34 + &DEMO_MODELS 35 + } 36 + 37 + pub fn demo_model_for_route(model_id: &str) -> DemoModel { 38 + demo_models() 39 + .iter() 40 + .copied() 41 + .find(|model| model.id == model_id) 42 + .unwrap_or(DEMO_MODELS[0]) 43 + } 44 + 45 + #[derive(Clone, Debug)] 46 + pub struct ViewerMeshHandle { 47 + pub key: SessionKey, 48 + pub summary: MeshSummary, 49 + } 50 + 51 + impl ViewerMeshHandle { 52 + pub fn from_demo_model(model: DemoModel) -> Self { 53 + Self { 54 + key: SessionKey::new(model.id, model.generation), 55 + summary: MeshSummary { 56 + display_name: model.name, 57 + vertices: model.vertices, 58 + triangles: model.triangles, 59 + }, 60 + } 61 + } 62 + } 63 + 64 + impl PartialEq for ViewerMeshHandle { 65 + fn eq(&self, other: &Self) -> bool { 66 + self.key == other.key 67 + } 68 + } 69 + 70 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 71 + pub struct MeshSummary { 72 + pub display_name: &'static str, 73 + pub vertices: usize, 74 + pub triangles: usize, 75 + } 76 + 77 + #[derive(Clone, Debug, PartialEq, Eq)] 78 + pub struct SessionKey { 79 + model_id: String, 80 + generation: u64, 81 + } 82 + 83 + impl SessionKey { 84 + pub fn new(model_id: impl Into<String>, generation: u64) -> Self { 85 + Self { 86 + model_id: model_id.into(), 87 + generation, 88 + } 89 + } 90 + 91 + pub fn model_id(&self) -> &str { 92 + &self.model_id 93 + } 94 + 95 + pub fn generation(&self) -> u64 { 96 + self.generation 97 + } 98 + 99 + #[cfg(test)] 100 + pub fn switch_to(&self, next_model_id: impl Into<String>) -> Self { 101 + Self { 102 + model_id: next_model_id.into(), 103 + generation: self.generation + 1, 104 + } 105 + } 106 + } 107 + 108 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 109 + pub enum ViewerIssue { 110 + Unsupported, 111 + Corrupt, 112 + TooLarge, 113 + RendererInit, 114 + } 115 + 116 + impl ViewerIssue { 117 + pub fn message(self) -> &'static str { 118 + 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.", 122 + Self::RendererInit => "The viewer could not start on this canvas.", 123 + } 124 + } 125 + } 126 + 127 + #[derive(Clone, Debug, PartialEq, Eq)] 128 + pub enum ViewerStatus { 129 + NoMesh, 130 + Loading, 131 + Ready(SessionKey), 132 + MeshError(ViewerIssue), 133 + RendererError(ViewerIssue), 134 + } 135 + 136 + impl ViewerStatus { 137 + pub fn label(&self) -> &'static str { 138 + match self { 139 + Self::NoMesh => "No mesh selected", 140 + Self::Loading => "Loading mesh", 141 + Self::Ready(_) => "Stub renderer ready", 142 + Self::MeshError(_) => "Mesh unavailable", 143 + Self::RendererError(_) => "Renderer unavailable", 144 + } 145 + } 146 + 147 + pub fn message(&self) -> &'static str { 148 + 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 + } 154 + Self::MeshError(issue) | Self::RendererError(issue) => issue.message(), 155 + } 156 + } 157 + 158 + pub fn should_have_session(&self) -> bool { 159 + matches!(self, Self::Ready(_)) 160 + } 161 + } 162 + 163 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 164 + enum DemoMode { 165 + NoMesh, 166 + Loading, 167 + Ready, 168 + Unsupported, 169 + Corrupt, 170 + TooLarge, 171 + RendererError, 172 + } 173 + 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 + } 185 + } 186 + } 187 + 188 + #[cfg(test)] 189 + pub struct SessionLifecycle { 190 + active: Option<TrackedSession>, 191 + cleanups: Rc<Cell<usize>>, 192 + } 193 + 194 + #[cfg(test)] 195 + impl SessionLifecycle { 196 + pub fn new(cleanups: Rc<Cell<usize>>) -> Self { 197 + Self { 198 + active: None, 199 + cleanups, 200 + } 201 + } 202 + 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 + } 217 + } 218 + } 219 + 220 + pub fn has_active_session(&self) -> bool { 221 + self.active.is_some() 222 + } 223 + } 224 + 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 + #[component] 246 + pub fn ViewerPage(initial_model: DemoModel) -> Element { 247 + let mut selected_model = use_signal(|| initial_model); 248 + let mut mode = use_signal(|| DemoMode::Ready); 249 + let canvas_mounted = use_signal(|| false); 250 + let resize_count = use_signal(|| 0_u64); 251 + 252 + let model = selected_model(); 253 + let mesh = ViewerMeshHandle::from_demo_model(model); 254 + let status = mode().status(&mesh.key); 255 + let visible_mesh = status.should_have_session().then_some(mesh.clone()); 256 + let session_label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); 257 + rsx! { 258 + section { class: "model-viewer-shell", aria_label: "Dioxus canvas-isolated model viewer scaffold", 259 + header { class: "model-viewer-header", 260 + 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." } 264 + } 265 + div { class: "viewer-status-pill", "{status.label()}" } 266 + } 267 + 268 + div { class: "viewer-demo-controls", aria_label: "Demo model controls", 269 + for model_option in demo_models() { 270 + button { 271 + class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, 272 + r#type: "button", 273 + onclick: move |_| { 274 + selected_model.set(*model_option); 275 + mode.set(DemoMode::Ready); 276 + }, 277 + "{model_option.name}" 278 + } 279 + } 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 + } 290 + 291 + ModelViewer { 292 + mesh: visible_mesh, 293 + status, 294 + canvas_mounted, 295 + resize_count, 296 + session_label, 297 + } 298 + } 299 + } 300 + } 301 + 302 + #[derive(Props, Clone, PartialEq)] 303 + pub struct ModelViewerProps { 304 + mesh: Option<ViewerMeshHandle>, 305 + status: ViewerStatus, 306 + canvas_mounted: Signal<bool>, 307 + resize_count: Signal<u64>, 308 + session_label: String, 309 + } 310 + 311 + #[component] 312 + pub fn ModelViewer(props: ModelViewerProps) -> Element { 313 + let canvas_mounted = props.canvas_mounted; 314 + let resize_count = props.resize_count; 315 + let status = props.status.clone(); 316 + let mesh = props.mesh.clone(); 317 + 318 + 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}", 320 + 321 + div { class: "model-viewer-stage", 322 + canvas { 323 + id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted()}", 324 + class: "model-viewer-canvas", 325 + aria_label: "Polymodel model viewer canvas", 326 + onmounted: move |event| mark_canvas_mounted(event, canvas_mounted), 327 + onwheel: move |event| event.stop_propagation(), 328 + onpointerdown: move |event| event.stop_propagation(), 329 + } 330 + div { class: "model-viewer-overlay", aria_live: "polite", 331 + h2 { "{status.label()}" } 332 + p { "{status.message()}" } 333 + if let Some(mesh) = mesh { 334 + dl { class: "viewer-mesh-summary", 335 + div { dt { "Model" } dd { "{mesh.summary.display_name}" } } 336 + div { dt { "Session" } dd { "{props.session_label}" } } 337 + div { dt { "Vertices" } dd { "{mesh.summary.vertices}" } } 338 + div { dt { "Triangles" } dd { "{mesh.summary.triangles}" } } 339 + } 340 + } 341 + p { class: "viewer-lifecycle-note", 342 + "Canvas mounted: {canvas_mounted()} · resize callbacks: {resize_count()} · session wanted: {status.should_have_session()}" 343 + } 344 + } 345 + } 346 + } 347 + StubCanvasSession { 348 + status: status.clone(), 349 + canvas_mounted, 350 + resize_count, 351 + } 352 + } 353 + } 354 + 355 + #[component] 356 + fn StubCanvasSession( 357 + status: ViewerStatus, 358 + canvas_mounted: Signal<bool>, 359 + resize_count: Signal<u64>, 360 + ) -> Element { 361 + let session_key = match status { 362 + ViewerStatus::Ready(ref key) if canvas_mounted() => Some(key.clone()), 363 + _ => None, 364 + }; 365 + 366 + rsx! { 367 + if let Some(key) = session_key { 368 + StubCanvasSessionInner { key: "{key.model_id()}-{key.generation()}", session_key: key, resize_count } 369 + } 370 + } 371 + } 372 + 373 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 374 + #[component] 375 + fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal<u64>) -> Element { 376 + let _ = session_key; 377 + let _ = resize_count; 378 + rsx! {} 379 + } 380 + 381 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 382 + #[component] 383 + fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal<u64>) -> Element { 384 + use wasm_bindgen::JsCast; 385 + use wasm_bindgen::closure::Closure; 386 + use web_sys::Event; 387 + 388 + let _session = use_hook(move || { 389 + let mut cleanup: Option<Box<dyn FnOnce()>> = None; 390 + 391 + if let Some(window) = web_sys::window() { 392 + let mut resize_count = resize_count; 393 + let resize_closure = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_| { 394 + resize_count.set(resize_count() + 1); 395 + })); 396 + 397 + if window 398 + .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) 399 + .is_ok() 400 + { 401 + cleanup = Some(Box::new(move || { 402 + if let Some(window) = web_sys::window() { 403 + let _ = window.remove_event_listener_with_callback( 404 + "resize", 405 + resize_closure.as_ref().unchecked_ref(), 406 + ); 407 + } 408 + })); 409 + } 410 + } 411 + 412 + std::rc::Rc::new(StubBrowserSession { 413 + key: session_key, 414 + cleanup, 415 + }) 416 + }); 417 + 418 + rsx! {} 419 + } 420 + 421 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 422 + struct StubBrowserSession { 423 + key: SessionKey, 424 + cleanup: Option<Box<dyn FnOnce()>>, 425 + } 426 + 427 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 428 + impl Drop for StubBrowserSession { 429 + fn drop(&mut self) { 430 + tracing::debug!( 431 + model_id = self.key.model_id(), 432 + generation = self.key.generation(), 433 + "dropping stub canvas session" 434 + ); 435 + if let Some(cleanup) = self.cleanup.take() { 436 + cleanup(); 437 + } 438 + } 439 + } 440 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 441 + fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal<bool>) { 442 + mounted.set(true); 443 + } 444 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 445 + fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal<bool>) { 446 + use dioxus::web::WebEventExt; 447 + use wasm_bindgen::JsCast; 448 + let element = event.as_web_event(); 449 + mounted.set(element.dyn_into::<web_sys::HtmlCanvasElement>().is_ok()); 450 + } 451 + #[cfg(test)] 452 + mod tests { 453 + use super::*; 454 + 455 + #[test] 456 + fn switching_models_advances_session_generation() { 457 + let first = SessionKey::new("calibration-cube", 7); 458 + let second = first.switch_to("benchy-hull"); 459 + 460 + assert_eq!(second.model_id(), "benchy-hull"); 461 + assert_eq!(second.generation(), 8); 462 + } 463 + 464 + #[test] 465 + fn status_labels_are_user_facing() { 466 + let ready = ViewerStatus::Ready(SessionKey::new("calibration-cube", 1)); 467 + 468 + assert_eq!(ViewerStatus::Loading.label(), "Loading mesh"); 469 + assert_eq!(ready.label(), "Stub renderer ready"); 470 + assert_eq!( 471 + ViewerStatus::MeshError(ViewerIssue::Corrupt).label(), 472 + "Mesh unavailable" 473 + ); 474 + } 475 + 476 + #[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 + } 495 + 496 + #[test] 497 + fn demo_models_are_switchable() { 498 + let models = demo_models(); 499 + 500 + assert!(models.len() >= 2); 501 + assert_ne!(models[0].id, models[1].id); 502 + } 503 + 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; 509 + 510 + assert_eq!(cube, same_key); 511 + } 512 + 513 + #[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)); 517 + 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); 529 + } 530 + }