From 587876f42bb0ff35f6648bb25a61f337c9d8432f Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 20 Jun 2026 23:31:30 -0400 Subject: [PATCH] PM-19: Dioxus canvas-isolated viewer scaffold --- .gitignore | 4 +- Cargo.lock | 30 +- Cargo.toml | 3 +- assets/styling/polymodel.css | 168 +++++++++++ e2e/package-lock.json | 76 +++++ e2e/package.json | 2 +- e2e/playwright.config.ts | 6 +- e2e/tests/viewer.spec.ts | 77 +++++ src/main.rs | 62 ++++ src/viewer.rs | 530 +++++++++++++++++++++++++++++++++++ 10 files changed, 937 insertions(+), 21 deletions(-) create mode 100644 e2e/package-lock.json create mode 100644 e2e/tests/viewer.spec.ts create mode 100644 src/viewer.rs diff --git a/.gitignore b/.gitignore index 7b5f821..ef4b562 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ !.polytoken node_modules/ .polytoken/permissions.local.yaml - -# Local-only Confluence draft staging (source of truth is Confluence space PM) +/e2e/test-results/** +/e2e/playwright-report/** /docs/** diff --git a/Cargo.lock b/Cargo.lock index 31ec065..281bf5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3855,12 +3855,13 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -5208,6 +5209,7 @@ dependencies = [ "tower-http", "tracing", "tracing-wasm", + "wasm-bindgen", "web-sys", ] @@ -7420,9 +7422,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -7433,9 +7435,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7443,9 +7445,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7453,9 +7455,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -7466,9 +7468,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -7488,9 +7490,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 88ae599..ee853aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,8 @@ tower-http = { version = "0.6", optional = true, features = ["trace"] } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] console_error_panic_hook = "0.1" tracing-wasm = "0.2" -web-sys = { version = "0.3", features = ["Window", "Document", "Element"] } +wasm-bindgen = "=0.2.121" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement"] } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/assets/styling/polymodel.css b/assets/styling/polymodel.css index 45a6300..89dff7c 100644 --- a/assets/styling/polymodel.css +++ b/assets/styling/polymodel.css @@ -28,6 +28,26 @@ line-height: 1.45; } +.demo-card-action, +.viewer-hero-link, +.viewer-route-nav a { + display: inline-flex; + align-items: center; + width: fit-content; + margin-top: 0.85rem; + color: var(--color-base); + background: var(--color-primary); + padding: 0.45rem 0.7rem; + text-decoration: none; + font-weight: 700; +} + +.demo-card-action:hover, +.viewer-hero-link:hover, +.viewer-route-nav a:hover { + filter: brightness(1.05); +} + .demo-context { margin: 1rem 0 1.5rem; padding: 0.75rem 1rem; @@ -39,3 +59,151 @@ .demo-context strong { color: var(--color-text); } + +.viewer-route-page { + min-height: 100vh; + padding: clamp(1rem, 4vw, 3rem); + background: var(--color-background); + color: var(--color-text); +} + +.viewer-route-nav { + margin: 0 auto 1rem; + max-width: 72rem; +} + +.model-viewer-shell { + display: grid; + gap: 1rem; + max-width: 72rem; + margin: 0 auto; +} + +.model-viewer-header, +.model-viewer-boundary, +.viewer-demo-controls { + border: 1px solid var(--color-border); + background: var(--color-surface); +} + +.model-viewer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: clamp(1rem, 3vw, 1.5rem); +} + +.model-viewer-header h1, +.model-viewer-header p { + margin: 0; +} + +.viewer-eyebrow { + font-family: var(--font-mono); + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; +} + +.viewer-status-pill { + white-space: nowrap; + border: 1px solid var(--color-primary); + color: var(--color-primary); + padding: 0.35rem 0.55rem; + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.viewer-demo-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem; +} + +.viewer-demo-button { + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); + padding: 0.45rem 0.65rem; + cursor: pointer; +} + +.viewer-demo-button:hover, +.viewer-demo-button.is-active { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.model-viewer-boundary { + position: relative; + overflow: hidden; + min-height: min(72vh, 42rem); +} + +.model-viewer-stage { + position: relative; + min-height: min(72vh, 42rem); + background: + linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), + radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), + var(--color-background); +} + +.model-viewer-canvas { + display: block; + width: 100%; + min-height: min(72vh, 42rem); + touch-action: none; + cursor: grab; +} + +.model-viewer-overlay { + position: absolute; + inset: auto 1rem 1rem 1rem; + max-width: 38rem; + padding: 1rem; + border: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + pointer-events: none; +} + +.model-viewer-overlay h2, +.model-viewer-overlay p, +.viewer-mesh-summary { + margin: 0; +} + +.model-viewer-overlay p + .viewer-mesh-summary, +.viewer-mesh-summary + .viewer-lifecycle-note { + margin-top: 0.75rem; +} + +.viewer-mesh-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem 1rem; +} + +.viewer-mesh-summary div { + display: grid; + gap: 0.15rem; +} + +.viewer-mesh-summary dt { + color: var(--color-subtle); + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; +} + +.viewer-mesh-summary dd { + margin: 0; +} + +.viewer-lifecycle-note { + color: var(--color-subtle); + font-size: 0.85rem; +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..4a902bf --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "polymodel-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polymodel-e2e", + "devDependencies": { + "@playwright/test": "1.59.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json index fbde2fb..54c5896 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,6 +6,6 @@ "test": "playwright test" }, "devDependencies": { - "@playwright/test": "^1.57.0" + "@playwright/test": "1.59.1" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 9a07be9..4c44c03 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; +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({ testDir: './tests', timeout: 30_000, expect: { timeout: 5_000 }, @@ -9,7 +9,7 @@ export default defineConfig({ trace: 'on-first-retry', }, projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : undefined } }, ], webServer: { command: 'just serve', diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts new file mode 100644 index 0000000..d7c16b5 --- /dev/null +++ b/e2e/tests/viewer.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; + +const boundary = '#polymodel-viewer-boundary'; +const canvas = '#polymodel-viewer-canvas'; +async function expectViewerReady(page) { await expect(page.locator(canvas)).toHaveCount(1); await expect(page.locator(canvas)).toHaveAttribute('data-mounted', 'true'); } +test('viewer is reachable from browse and owns one mounted canvas', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Open canvas-isolated viewer demo' }).click(); + + await expect(page).toHaveURL(/\/viewer\/calibration-cube$/); + await expectViewerReady(page); + await expect(page.locator(canvas)).toHaveAttribute('data-mounted', 'true'); + await expect(page.locator(boundary)).toContainText('Calibration cube'); + await expect(page.locator(boundary)).toContainText('Stub renderer ready'); +}); + +test('direct viewer routes select deterministic demo models', async ({ page }) => { + await page.goto('/viewer/calibration-cube'); + await expectViewerReady(page); + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); + await expect(page.locator(boundary)).toContainText('Calibration cube'); + + await page.goto('/viewer/benchy-hull'); + await expectViewerReady(page); + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'benchy-hull#2'); + await expect(page.locator(boundary)).toContainText('Benchy hull stub'); +}); + +test('viewer state controls show loading and product-facing errors', async ({ page }) => { + await page.goto('/viewer/calibration-cube'); + await expectViewerReady(page); + 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(); + await expect(page.locator(boundary)).toContainText('Loading mesh'); + await expect(page.locator(boundary)).not.toContainText('Vertices'); + + await page.getByRole('button', { name: 'Unsupported' }).click(); + await expect(page.locator(boundary)).toContainText('This model format is not supported yet.'); + await expect(page.locator(boundary)).not.toContainText('Vertices'); + + await page.getByRole('button', { name: 'Too large' }).click(); + await expect(page.locator(boundary)).toContainText('This model is too large for the browser viewer.'); await expect(page.locator(boundary)).not.toContainText('Vertices'); + + await page.getByRole('button', { name: 'Renderer error' }).click(); + await expect(page.locator(boundary)).toContainText('The viewer could not start on this canvas.'); + await expect(page.locator(boundary)).toHaveAttribute('data-session-wanted', 'false'); await expect(page.locator(boundary)).not.toContainText('Vertices'); +}); + +test('model switching keeps one canvas and updates the session', async ({ page }) => { + await page.goto('/viewer/calibration-cube'); + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); + await expectViewerReady(page); + await page.getByRole('button', { name: 'Benchy hull stub' }).click(); + await expect(page.locator(canvas)).toHaveCount(1); + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'benchy-hull#2'); + await expect(page.locator(boundary)).toContainText('Benchy hull stub'); + + await page.getByRole('button', { name: 'Calibration cube' }).click(); + await expect(page.locator(canvas)).toHaveCount(1); + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'calibration-cube#1'); +}); + +test('resize callback remains single after state and model transitions', async ({ page }) => { + await page.goto('/viewer/calibration-cube'); + const currentResizeCount = async () => Number(await page.locator(boundary).getAttribute('data-resize-count')); + await expectViewerReady(page); + const before = await currentResizeCount(); + await page.setViewportSize({ width: 1100, height: 760 }); + await expect.poll(currentResizeCount).toBe(before + 1); + + await page.getByRole('button', { name: 'Renderer error' }).click(); + await page.getByRole('button', { name: 'Ready' }).click(); + await page.getByRole('button', { name: 'Benchy hull stub' }).click(); + + const afterTransitions = await currentResizeCount(); + await page.setViewportSize({ width: 1000, height: 700 }); + await expect.poll(currentResizeCount).toBe(afterTransitions + 1); +}); diff --git a/src/main.rs b/src/main.rs index 763f8cf..6717863 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,10 @@ use dioxus::prelude::*; + mod mesh; +mod viewer; + +use viewer::{ViewerPage, demo_model_for_route, demo_models}; + const FAVICON: Asset = asset!("/assets/favicon.jpg"); const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); const BASE_CSS: Asset = asset!("/assets/styling/base.css"); @@ -17,6 +22,14 @@ fn main() { dioxus::launch(App); } +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[route("/")] + Browse {}, + #[route("/viewer/:model_id")] + Viewer { model_id: String }, +} + #[component] fn App() -> Element { rsx! { @@ -32,6 +45,13 @@ fn App() -> Element { Stylesheet { href: BROWSE_CSS } Stylesheet { href: POLYMODEL_CSS } + Router:: {} + } +} + +#[component] +fn Browse() -> Element { + rsx! { main { class: "browse-page", header { class: "browse-header", h1 { "Polymodel" } @@ -41,6 +61,11 @@ fn App() -> Element { strong { "Workflow: " } "Jira + Confluence + jj + Polytoken." } + Link { + class: "viewer-hero-link", + to: Route::Viewer { model_id: demo_models()[0].id.to_string() }, + "Open canvas-isolated viewer demo" + } } section { class: "browse-grid", for card in roadmap_cards() { @@ -51,11 +76,26 @@ fn App() -> Element { } } +#[component] +fn Viewer(model_id: String) -> Element { + let model = demo_model_for_route(&model_id); + + rsx! { + main { class: "viewer-route-page", + nav { class: "viewer-route-nav", aria_label: "Viewer navigation", + Link { to: Route::Browse {}, "← Back to browse" } + } + ViewerPage { key: "{model.id}", initial_model: model } + } + } +} + #[derive(Clone, Copy, PartialEq)] struct RoadmapCardData { token: &'static str, title: &'static str, body: &'static str, + viewer_link: Option<&'static str>, } fn roadmap_cards() -> [RoadmapCardData; 6] { @@ -64,31 +104,37 @@ fn roadmap_cards() -> [RoadmapCardData; 6] { token: "NSID", title: "Project lexicon", body: "Define the model/project record: title, description, files, preview assets, tags, license, and attribution.", + viewer_link: None, }, RoadmapCardData { token: "DID", title: "Profile publishing", body: "Use ATProto identity and profile-oriented feeds so makers can publish and browse from their own repos.", + viewer_link: None, }, RoadmapCardData { token: "★", title: "Likes and saves", body: "Start with lightweight feedback records that can drive hot/recent discovery without inventing a heavyweight backend.", + viewer_link: None, }, RoadmapCardData { token: "STL", title: "Model viewer", body: "Spike three-d and kiss3d as WASM viewer candidates; choose based on STL loading, orbit controls, rendering quality, and Dioxus integration friction.", + viewer_link: Some("calibration-cube"), }, RoadmapCardData { token: "PM", title: "Jira and Confluence", body: "Use Jira and Confluence as both planning/demo narrative and agent-readable workflow state.", + viewer_link: None, }, RoadmapCardData { token: "pt", title: "Polytoken workflow", body: "Use facets, skills, hooks, project vars, jj workspaces, and review gates to make the demo workflow real.", + viewer_link: None, }, ] } @@ -103,6 +149,13 @@ fn RoadmapCard(card: RoadmapCardData) -> Element { div { class: "video-card-body demo-card-body", h2 { class: "card-title", "{card.title}" } p { "{card.body}" } + if let Some(model_id) = card.viewer_link { + Link { + class: "demo-card-action", + to: Route::Viewer { model_id: model_id.to_string() }, + "Try the viewer scaffold" + } + } } } } @@ -116,4 +169,13 @@ mod tests { fn roadmap_includes_stl_viewer() { assert!(roadmap_cards().iter().any(|card| card.token == "STL")); } + + #[test] + fn roadmap_links_to_viewer_scaffold() { + assert!( + roadmap_cards() + .iter() + .any(|card| card.viewer_link.is_some()) + ); + } } diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..b80d78d --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,530 @@ +use dioxus::prelude::*; +#[cfg(test)] +use std::cell::Cell; +#[cfg(test)] +use std::rc::Rc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DemoModel { + pub id: &'static str, + pub name: &'static str, + pub generation: u64, + pub vertices: usize, + pub triangles: usize, +} + +const DEMO_MODELS: [DemoModel; 2] = [ + DemoModel { + id: "calibration-cube", + name: "Calibration cube", + generation: 1, + vertices: 8, + triangles: 12, + }, + DemoModel { + id: "benchy-hull", + name: "Benchy hull stub", + generation: 2, + vertices: 42, + triangles: 80, + }, +]; + +pub fn demo_models() -> &'static [DemoModel] { + &DEMO_MODELS +} + +pub fn demo_model_for_route(model_id: &str) -> DemoModel { + demo_models() + .iter() + .copied() + .find(|model| model.id == model_id) + .unwrap_or(DEMO_MODELS[0]) +} + +#[derive(Clone, Debug)] +pub struct ViewerMeshHandle { + pub key: SessionKey, + pub summary: MeshSummary, +} + +impl ViewerMeshHandle { + pub fn from_demo_model(model: DemoModel) -> Self { + Self { + key: SessionKey::new(model.id, model.generation), + summary: MeshSummary { + display_name: model.name, + vertices: model.vertices, + triangles: model.triangles, + }, + } + } +} + +impl PartialEq for ViewerMeshHandle { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeshSummary { + pub display_name: &'static str, + pub vertices: usize, + pub triangles: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionKey { + model_id: String, + generation: u64, +} + +impl SessionKey { + pub fn new(model_id: impl Into, generation: u64) -> Self { + Self { + model_id: model_id.into(), + generation, + } + } + + pub fn model_id(&self) -> &str { + &self.model_id + } + + pub fn generation(&self) -> u64 { + self.generation + } + + #[cfg(test)] + pub fn switch_to(&self, next_model_id: impl Into) -> Self { + Self { + model_id: next_model_id.into(), + generation: self.generation + 1, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewerIssue { + Unsupported, + Corrupt, + TooLarge, + RendererInit, +} + +impl ViewerIssue { + pub fn message(self) -> &'static str { + match self { + Self::Unsupported => "This model format is not supported yet.", + Self::Corrupt => "This model could not be read. Try another file.", + Self::TooLarge => "This model is too large for the browser viewer.", + Self::RendererInit => "The viewer could not start on this canvas.", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ViewerStatus { + NoMesh, + Loading, + Ready(SessionKey), + MeshError(ViewerIssue), + RendererError(ViewerIssue), +} + +impl ViewerStatus { + pub fn label(&self) -> &'static str { + match self { + Self::NoMesh => "No mesh selected", + Self::Loading => "Loading mesh", + Self::Ready(_) => "Stub renderer ready", + Self::MeshError(_) => "Mesh unavailable", + Self::RendererError(_) => "Renderer unavailable", + } + } + + pub fn message(&self) -> &'static str { + match self { + Self::NoMesh => "Choose a model to start the viewer.", + Self::Loading => "Preparing the mesh contract for the canvas boundary…", + Self::Ready(_) => { + "Stub rendering is active. A real renderer can attach behind this canvas boundary later." + } + Self::MeshError(issue) | Self::RendererError(issue) => issue.message(), + } + } + + pub fn should_have_session(&self) -> bool { + matches!(self, Self::Ready(_)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DemoMode { + NoMesh, + Loading, + Ready, + Unsupported, + Corrupt, + TooLarge, + RendererError, +} + +impl DemoMode { + fn status(self, key: &SessionKey) -> ViewerStatus { + match self { + Self::NoMesh => ViewerStatus::NoMesh, + Self::Loading => ViewerStatus::Loading, + Self::Ready => ViewerStatus::Ready(key.clone()), + Self::Unsupported => ViewerStatus::MeshError(ViewerIssue::Unsupported), + Self::Corrupt => ViewerStatus::MeshError(ViewerIssue::Corrupt), + Self::TooLarge => ViewerStatus::MeshError(ViewerIssue::TooLarge), + Self::RendererError => ViewerStatus::RendererError(ViewerIssue::RendererInit), + } + } +} + +#[cfg(test)] +pub struct SessionLifecycle { + active: Option, + cleanups: Rc>, +} + +#[cfg(test)] +impl SessionLifecycle { + pub fn new(cleanups: Rc>) -> Self { + Self { + active: None, + cleanups, + } + } + + pub fn reconcile(&mut self, status: &ViewerStatus) { + match status { + ViewerStatus::Ready(key) => { + let same_session = self + .active + .as_ref() + .is_some_and(|session| session.key == *key); + if !same_session { + self.active = Some(TrackedSession::new(key.clone(), Rc::clone(&self.cleanups))); + } + } + _ => { + self.active = None; + } + } + } + + pub fn has_active_session(&self) -> bool { + self.active.is_some() + } +} + +#[cfg(test)] +struct TrackedSession { + key: SessionKey, + cleanups: Rc>, +} + +#[cfg(test)] +impl TrackedSession { + fn new(key: SessionKey, cleanups: Rc>) -> Self { + Self { key, cleanups } + } +} + +#[cfg(test)] +impl Drop for TrackedSession { + fn drop(&mut self) { + self.cleanups.set(self.cleanups.get() + 1); + } +} + +#[component] +pub fn ViewerPage(initial_model: DemoModel) -> Element { + let mut selected_model = use_signal(|| initial_model); + let mut mode = use_signal(|| DemoMode::Ready); + let canvas_mounted = use_signal(|| false); + let resize_count = use_signal(|| 0_u64); + + let model = selected_model(); + let mesh = ViewerMeshHandle::from_demo_model(model); + let status = mode().status(&mesh.key); + let visible_mesh = status.should_have_session().then_some(mesh.clone()); + let session_label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); + rsx! { + section { class: "model-viewer-shell", aria_label: "Dioxus canvas-isolated model viewer scaffold", + header { class: "model-viewer-header", + div { + p { class: "viewer-eyebrow", "Canvas viewer boundary" } + h1 { "Model viewer scaffold" } + p { "Renderer-agnostic Dioxus route with one owned canvas and explicit lifecycle states." } + } + div { class: "viewer-status-pill", "{status.label()}" } + } + + div { class: "viewer-demo-controls", aria_label: "Demo model controls", + for model_option in demo_models() { + button { + class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, + r#type: "button", + onclick: move |_| { + selected_model.set(*model_option); + mode.set(DemoMode::Ready); + }, + "{model_option.name}" + } + } + } + + div { class: "viewer-demo-controls", aria_label: "Viewer state controls", + 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" } + button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Ready), "Ready" } + button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Unsupported), "Unsupported" } + button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::Corrupt), "Corrupt" } + button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::TooLarge), "Too large" } + button { class: "viewer-demo-button", r#type: "button", onclick: move |_| mode.set(DemoMode::RendererError), "Renderer error" } + } + + ModelViewer { + mesh: visible_mesh, + status, + canvas_mounted, + resize_count, + session_label, + } + } + } +} + +#[derive(Props, Clone, PartialEq)] +pub struct ModelViewerProps { + mesh: Option, + status: ViewerStatus, + canvas_mounted: Signal, + resize_count: Signal, + session_label: String, +} + +#[component] +pub fn ModelViewer(props: ModelViewerProps) -> Element { + let canvas_mounted = props.canvas_mounted; + let resize_count = props.resize_count; + let status = props.status.clone(); + let mesh = props.mesh.clone(); + + rsx! { + 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}", + + div { class: "model-viewer-stage", + canvas { + id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted()}", + class: "model-viewer-canvas", + aria_label: "Polymodel model viewer canvas", + onmounted: move |event| mark_canvas_mounted(event, canvas_mounted), + onwheel: move |event| event.stop_propagation(), + onpointerdown: move |event| event.stop_propagation(), + } + div { class: "model-viewer-overlay", aria_live: "polite", + h2 { "{status.label()}" } + p { "{status.message()}" } + if let Some(mesh) = mesh { + dl { class: "viewer-mesh-summary", + div { dt { "Model" } dd { "{mesh.summary.display_name}" } } + div { dt { "Session" } dd { "{props.session_label}" } } + div { dt { "Vertices" } dd { "{mesh.summary.vertices}" } } + div { dt { "Triangles" } dd { "{mesh.summary.triangles}" } } + } + } + p { class: "viewer-lifecycle-note", + "Canvas mounted: {canvas_mounted()} · resize callbacks: {resize_count()} · session wanted: {status.should_have_session()}" + } + } + } + } + StubCanvasSession { + status: status.clone(), + canvas_mounted, + resize_count, + } + } +} + +#[component] +fn StubCanvasSession( + status: ViewerStatus, + canvas_mounted: Signal, + resize_count: Signal, +) -> Element { + let session_key = match status { + ViewerStatus::Ready(ref key) if canvas_mounted() => Some(key.clone()), + _ => None, + }; + + rsx! { + if let Some(key) = session_key { + StubCanvasSessionInner { key: "{key.model_id()}-{key.generation()}", session_key: key, resize_count } + } + } +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +#[component] +fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal) -> Element { + let _ = session_key; + let _ = resize_count; + rsx! {} +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +#[component] +fn StubCanvasSessionInner(session_key: SessionKey, resize_count: Signal) -> Element { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + use web_sys::Event; + + let _session = use_hook(move || { + let mut cleanup: Option> = None; + + if let Some(window) = web_sys::window() { + let mut resize_count = resize_count; + let resize_closure = Closure::::wrap(Box::new(move |_| { + resize_count.set(resize_count() + 1); + })); + + if window + .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) + .is_ok() + { + cleanup = Some(Box::new(move || { + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback( + "resize", + resize_closure.as_ref().unchecked_ref(), + ); + } + })); + } + } + + std::rc::Rc::new(StubBrowserSession { + key: session_key, + cleanup, + }) + }); + + rsx! {} +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +struct StubBrowserSession { + key: SessionKey, + cleanup: Option>, +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +impl Drop for StubBrowserSession { + fn drop(&mut self) { + tracing::debug!( + model_id = self.key.model_id(), + generation = self.key.generation(), + "dropping stub canvas session" + ); + if let Some(cleanup) = self.cleanup.take() { + cleanup(); + } + } +} +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal) { + mounted.set(true); +} +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal) { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + let element = event.as_web_event(); + mounted.set(element.dyn_into::().is_ok()); +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switching_models_advances_session_generation() { + let first = SessionKey::new("calibration-cube", 7); + let second = first.switch_to("benchy-hull"); + + assert_eq!(second.model_id(), "benchy-hull"); + assert_eq!(second.generation(), 8); + } + + #[test] + fn status_labels_are_user_facing() { + let ready = ViewerStatus::Ready(SessionKey::new("calibration-cube", 1)); + + assert_eq!(ViewerStatus::Loading.label(), "Loading mesh"); + assert_eq!(ready.label(), "Stub renderer ready"); + assert_eq!( + ViewerStatus::MeshError(ViewerIssue::Corrupt).label(), + "Mesh unavailable" + ); + } + + #[test] + fn modeled_errors_have_product_messages() { + assert_eq!( + ViewerIssue::Unsupported.message(), + "This model format is not supported yet." + ); + assert_eq!( + ViewerIssue::Corrupt.message(), + "This model could not be read. Try another file." + ); + assert_eq!( + ViewerIssue::TooLarge.message(), + "This model is too large for the browser viewer." + ); + assert_eq!( + ViewerIssue::RendererInit.message(), + "The viewer could not start on this canvas." + ); + } + + #[test] + fn demo_models_are_switchable() { + let models = demo_models(); + + assert!(models.len() >= 2); + assert_ne!(models[0].id, models[1].id); + } + + #[test] + fn mesh_handle_equality_uses_only_session_key() { + let mut cube = ViewerMeshHandle::from_demo_model(DEMO_MODELS[0]); + let same_key = cube.clone(); + cube.summary.vertices += 10; + + assert_eq!(cube, same_key); + } + + #[test] + fn lifecycle_drops_replaced_and_error_sessions_once() { + let cleanups = Rc::new(Cell::new(0)); + let mut lifecycle = SessionLifecycle::new(Rc::clone(&cleanups)); + + lifecycle.reconcile(&ViewerStatus::Ready(SessionKey::new("a", 1))); + assert!(lifecycle.has_active_session()); + assert_eq!(cleanups.get(), 0); + + lifecycle.reconcile(&ViewerStatus::Ready(SessionKey::new("b", 2))); + assert!(lifecycle.has_active_session()); + assert_eq!(cleanups.get(), 1); + + lifecycle.reconcile(&ViewerStatus::RendererError(ViewerIssue::RendererInit)); + assert!(!lifecycle.has_active_session()); + assert_eq!(cleanups.get(), 2); + } +} -- 2.51.2