atproto Thingiverse but good
10

Configure Feed

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

PM-20: three-d renderer prototype

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

+560 -53
+58
Cargo.lock
··· 3259 3259 ] 3260 3260 3261 3261 [[package]] 3262 + name = "glow" 3263 + version = "0.17.0" 3264 + source = "registry+https://github.com/rust-lang/crates.io-index" 3265 + checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" 3266 + dependencies = [ 3267 + "js-sys", 3268 + "slotmap", 3269 + "wasm-bindgen", 3270 + "web-sys", 3271 + ] 3272 + 3273 + [[package]] 3262 3274 name = "gobject-sys" 3263 3275 version = "0.18.0" 3264 3276 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3954 3966 checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" 3955 3967 dependencies = [ 3956 3968 "cfb", 3969 + ] 3970 + 3971 + [[package]] 3972 + name = "instant" 3973 + version = "0.1.13" 3974 + source = "registry+https://github.com/rust-lang/crates.io-index" 3975 + checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" 3976 + dependencies = [ 3977 + "cfg-if", 3957 3978 ] 3958 3979 3959 3980 [[package]] ··· 5614 5635 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 5615 5636 5616 5637 [[package]] 5638 + name = "open-enum" 5639 + version = "0.5.2" 5640 + source = "registry+https://github.com/rust-lang/crates.io-index" 5641 + checksum = "2eb2508143a400b3361812094d987dd5adc81f0f5294a46491be648d6c94cab5" 5642 + dependencies = [ 5643 + "open-enum-derive", 5644 + ] 5645 + 5646 + [[package]] 5647 + name = "open-enum-derive" 5648 + version = "0.5.2" 5649 + source = "registry+https://github.com/rust-lang/crates.io-index" 5650 + checksum = "8d1296fab5231654a5aec8bf9e87ba4e3938c502fc4c3c0425a00084c78944be" 5651 + dependencies = [ 5652 + "proc-macro2", 5653 + "quote", 5654 + "syn 2.0.118", 5655 + ] 5656 + 5657 + [[package]] 5617 5658 name = "openssl" 5618 5659 version = "0.10.81" 5619 5660 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6040 6081 "jacquard", 6041 6082 "jacquard-common 0.12.0", 6042 6083 "jacquard-derive 0.12.0", 6084 + "js-sys", 6043 6085 "libsqlite3-sys", 6044 6086 "serde", 6045 6087 "serde_json", 6046 6088 "sqlx", 6047 6089 "stl_io", 6048 6090 "thiserror 2.0.18", 6091 + "three-d", 6049 6092 "three-d-asset", 6050 6093 "tokio", 6051 6094 "tower", ··· 6054 6097 "tracing-subscriber", 6055 6098 "tracing-wasm", 6056 6099 "wasm-bindgen", 6100 + "wasm-bindgen-futures", 6057 6101 "web-sys", 6058 6102 ] 6059 6103 ··· 7988 8032 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 7989 8033 dependencies = [ 7990 8034 "cfg-if", 8035 + ] 8036 + 8037 + [[package]] 8038 + name = "three-d" 8039 + version = "0.19.0" 8040 + source = "registry+https://github.com/rust-lang/crates.io-index" 8041 + checksum = "e73bbcfd30f69623bed55b90bcc084d316fab03a490c2087935c2aa287bd6995" 8042 + dependencies = [ 8043 + "cgmath", 8044 + "glow", 8045 + "instant", 8046 + "open-enum", 8047 + "thiserror 2.0.18", 8048 + "three-d-asset", 7991 8049 ] 7992 8050 7993 8051 [[package]]
+4 -1
Cargo.toml
··· 55 55 axum = { version = "0.8", optional = true } 56 56 tower = { version = "0.5", optional = true } 57 57 tower-http = { version = "0.6", optional = true, features = ["trace"] } 58 + three-d = { version = "0.19.0", default-features = false } 58 59 59 60 # Server-only indexing infrastructure. 60 61 # hydrant is pinned to a specific commit to protect against breaking API changes. ··· 85 86 86 87 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 87 88 console_error_panic_hook = "0.1" 89 + js-sys = "0.3" 88 90 tracing-wasm = "0.2" 89 91 wasm-bindgen = "=0.2.121" 90 - web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement"] } 92 + wasm-bindgen-futures = "0.4" 93 + web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "console", "Response"] } 91 94 92 95 [dev-dependencies] 93 96 tokio = { version = "1", features = ["rt", "macros"] }
assets/models/body_f_forearm_2x-v9.stl

This is a binary file and will not be displayed.

+22 -15
e2e/tests/viewer.spec.ts
··· 3 3 const boundary = '#polymodel-viewer-boundary'; 4 4 const canvas = '#polymodel-viewer-canvas'; 5 5 const preview = '#polymodel-viewer-preview'; 6 - const assetPath = '/assets/models/body_f_chest-v4.stl'; 6 + const chestAssetPath = '/assets/models/body_f_chest-v4.stl'; 7 + const forearmAssetPath = '/assets/models/body_f_forearm_2x-v9.stl'; 7 8 8 9 async function expectViewerReady(page) { 9 10 await expect(page.locator(canvas)).toHaveCount(1); 10 11 await expect(page.locator(canvas)).toHaveAttribute('data-mounted', 'true'); 11 - await expect(page.locator(preview)).toBeVisible(); 12 + await expect(page.locator(boundary)).toHaveAttribute('data-renderer-kind', 'three-d'); 13 + await expect(page.locator(canvas)).toHaveAttribute('data-renderer-kind', 'three-d'); 14 + await expect(page.locator(preview)).toHaveCount(0); 12 15 } 13 16 14 17 test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { ··· 18 21 await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); 19 22 await expectViewerReady(page); 20 23 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); 24 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); 22 25 await expect(page.locator(boundary)).toContainText('Body F chest v4'); 23 - await expect(page.locator(boundary)).toContainText(assetPath); 26 + await expect(page.locator(boundary)).toContainText(chestAssetPath); 24 27 await expect(page.locator(boundary)).toContainText('41051'); 25 28 await expect(page.locator(boundary)).toContainText('82122'); 26 29 await expect(page.locator(boundary)).toContainText('Viewer ready'); ··· 30 33 await page.goto('/viewer/body-f-chest-v4'); 31 34 await expectViewerReady(page); 32 35 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); 36 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); 34 37 await expect(page.locator(boundary)).toContainText('Body F chest v4'); 35 38 36 - await page.goto('/viewer/body-f-chest-v4-detail'); 39 + await page.goto('/viewer/body-f-forearm-2x-v9'); 37 40 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'); 41 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); 42 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 43 + await expect(page.locator(boundary)).toContainText('Body F forearm 2x v9'); 41 44 }); 42 45 43 46 test('model switching keeps one canvas and updates renderer session metadata', async ({ page }) => { ··· 45 48 await expectViewerReady(page); 46 49 await expect(page.locator(canvas)).toHaveCount(1); 47 50 48 - await page.getByRole('button', { name: 'Body F chest detail' }).click(); 51 + await page.getByRole('button', { name: 'Body F forearm 2x v9' }).click(); 52 + await expectViewerReady(page); 49 53 await expect(page.locator(canvas)).toHaveCount(1); 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); 54 + await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); 55 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 56 + await expect(page.locator(boundary)).toContainText('18271'); 57 + await expect(page.locator(boundary)).toContainText('36542'); 53 58 54 59 await page.getByRole('button', { name: 'Body F chest v4' }).click(); 60 + await expectViewerReady(page); 55 61 await expect(page.locator(canvas)).toHaveCount(1); 56 - await expect(page.locator(preview)).toBeVisible(); 57 62 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 58 63 }); 59 64 ··· 65 70 await page.setViewportSize({ width: 1100, height: 760 }); 66 71 await expect.poll(currentResizeCount).toBe(before + 1); 67 72 68 - await page.getByRole('button', { name: 'Body F chest detail' }).click(); 73 + await page.getByRole('button', { name: 'Body F forearm 2x v9' }).click(); 74 + await expectViewerReady(page); 75 + await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 69 76 70 77 const afterTransition = await currentResizeCount(); 71 78 await page.setViewportSize({ width: 1000, height: 700 });
+1
justfile
··· 7 7 cargo fmt --all 8 8 cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 9 9 cargo clippy -p polymodel --all-targets --features server --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 10 + cargo clippy -p polymodel --target wasm32-unknown-unknown --features web --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 10 11 11 12 # Compile checks across the whole workspace plus the app's server/wasm targets. 12 13 # Note: the server check uses compile-time `query!` macros that validate against a
+475 -37
src/viewer.rs
··· 1 + use crate::mesh::stl::load_stl; 1 2 use dioxus::prelude::*; 2 3 #[cfg(test)] 3 4 use std::cell::Cell; ··· 24 25 triangles: 82122, 25 26 }, 26 27 DemoModel { 27 - id: "body-f-chest-v4-detail", 28 - name: "Body F chest detail", 29 - asset_path: "/assets/models/body_f_chest-v4.stl", 30 - generation: 2, 31 - vertices: 41051, 32 - triangles: 82122, 28 + id: "body-f-forearm-2x-v9", 29 + name: "Body F forearm 2x v9", 30 + asset_path: "/assets/models/body_f_forearm_2x-v9.stl", 31 + generation: 1, 32 + vertices: 18271, 33 + triangles: 36542, 33 34 }, 34 35 ]; 35 36 ··· 45 46 .unwrap_or(DEMO_MODELS[0]) 46 47 } 47 48 49 + const BODY_F_CHEST_V4_STL: &[u8] = include_bytes!(concat!( 50 + env!("CARGO_MANIFEST_DIR"), 51 + "/assets/models/body_f_chest-v4.stl" 52 + )); 53 + const BODY_F_FOREARM_2X_V9_STL: &[u8] = include_bytes!(concat!( 54 + env!("CARGO_MANIFEST_DIR"), 55 + "/assets/models/body_f_forearm_2x-v9.stl" 56 + )); 57 + 58 + fn demo_model_bytes(asset_path: &str) -> Option<&'static [u8]> { 59 + match asset_path { 60 + "/assets/models/body_f_chest-v4.stl" => Some(BODY_F_CHEST_V4_STL), 61 + "/assets/models/body_f_forearm_2x-v9.stl" => Some(BODY_F_FOREARM_2X_V9_STL), 62 + _ => None, 63 + } 64 + } 65 + 66 + fn mesh_summary_from_model( 67 + model: DemoModel, 68 + mesh: &crate::mesh::contract::ModelMesh, 69 + ) -> MeshSummary { 70 + MeshSummary { 71 + display_name: model.name, 72 + asset_path: model.asset_path, 73 + vertices: mesh.trimesh.positions.len(), 74 + triangles: mesh.trimesh.triangle_count(), 75 + } 76 + } 77 + 78 + fn load_demo_mesh_handle(model: DemoModel) -> Result<ViewerMeshHandle, ViewerIssue> { 79 + let bytes = demo_model_bytes(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; 80 + let mesh = load_stl(bytes).map_err(|_| ViewerIssue::MeshUnavailable)?; 81 + Ok(ViewerMeshHandle { 82 + key: SessionKey::new(model.id, model.generation), 83 + summary: mesh_summary_from_model(model, &mesh), 84 + }) 85 + } 86 + 48 87 #[derive(Clone, Debug)] 49 88 pub struct ViewerMeshHandle { 50 89 pub key: SessionKey, ··· 52 91 } 53 92 54 93 impl ViewerMeshHandle { 94 + #[cfg(test)] 55 95 pub fn from_demo_model(model: DemoModel) -> Self { 56 - Self { 96 + load_demo_mesh_handle(model).unwrap_or_else(|_| Self { 57 97 key: SessionKey::new(model.id, model.generation), 58 98 summary: MeshSummary { 59 99 display_name: model.name, ··· 61 101 vertices: model.vertices, 62 102 triangles: model.triangles, 63 103 }, 64 - } 104 + }) 65 105 } 66 106 } 67 107 ··· 233 273 pub fn ViewerPage(initial_model: DemoModel) -> Element { 234 274 let mut selected_model = use_signal(|| initial_model); 235 275 let canvas_mounted = use_signal(|| false); 276 + let renderer_session = use_signal(String::new); 236 277 let resize_count = use_signal(|| 0_u64); 237 278 238 279 let model = selected_model(); 239 - let mesh = ViewerMeshHandle::from_demo_model(model); 240 - let status = ViewerStatus::Ready(mesh.key.clone()); 241 - let visible_mesh = status.should_have_session().then_some(mesh.clone()); 242 - let session_label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); 280 + let loaded_mesh = load_demo_mesh_handle(model); 281 + let (status, visible_mesh, session_label) = match loaded_mesh { 282 + Ok(mesh) => { 283 + let label = format!("{}#{}", mesh.key.model_id(), mesh.key.generation()); 284 + (ViewerStatus::Ready(mesh.key.clone()), Some(mesh), label) 285 + } 286 + Err(issue) => ( 287 + ViewerStatus::MeshError(issue), 288 + None, 289 + format!("{}#{}", model.id, model.generation), 290 + ), 291 + }; 243 292 244 293 rsx! { 245 294 section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", ··· 267 316 mesh: visible_mesh, 268 317 status, 269 318 canvas_mounted, 319 + renderer_session, 270 320 resize_count, 271 321 session_label, 272 322 } ··· 279 329 mesh: Option<ViewerMeshHandle>, 280 330 status: ViewerStatus, 281 331 canvas_mounted: Signal<bool>, 332 + renderer_session: Signal<String>, 282 333 resize_count: Signal<u64>, 283 334 session_label: String, 284 335 } ··· 286 337 #[component] 287 338 pub fn ModelViewer(props: ModelViewerProps) -> Element { 288 339 let canvas_mounted = props.canvas_mounted; 340 + let renderer_session = props.renderer_session; 289 341 let resize_count = props.resize_count; 290 342 let status = props.status.clone(); 291 343 let mesh = props.mesh.clone(); ··· 293 345 .as_ref() 294 346 .map(|mesh| mesh.summary.asset_path) 295 347 .unwrap_or(""); 348 + let renderer_active = status.should_have_session() && renderer_session() == props.session_label; 349 + let renderer_kind = if renderer_active { 350 + "three-d" 351 + } else { 352 + "fallback" 353 + }; 296 354 297 355 rsx! { 298 356 div { ··· 303 361 "data-resize-count": "{resize_count()}", 304 362 "data-session-label": "{props.session_label}", 305 363 "data-asset-path": asset_path.to_string(), 364 + "data-renderer-kind": renderer_kind, 306 365 307 366 div { class: "model-viewer-stage", 308 367 canvas { 309 368 id: "polymodel-viewer-canvas", 310 369 "data-mounted": "{canvas_mounted()}", 370 + "data-renderer-kind": renderer_kind, 311 371 class: "model-viewer-canvas", 312 372 aria_label: "Polymodel model viewer canvas", 313 373 onmounted: move |event| mark_canvas_mounted(event, canvas_mounted), 314 374 onwheel: move |event| event.stop_propagation(), 315 375 onpointerdown: move |event| event.stop_propagation(), 316 376 } 317 - if let Some(ref mesh) = mesh { 318 - AssetPreview { mesh: mesh.clone() } 377 + if !renderer_active { 378 + if let Some(ref mesh) = mesh { 379 + AssetPreview { mesh: mesh.clone() } 380 + } 319 381 } 320 382 div { class: "model-viewer-overlay", aria_live: "polite", 321 383 h2 { "{status.label()}" } ··· 335 397 status: status.clone(), 336 398 mesh, 337 399 canvas_mounted, 400 + renderer_session, 338 401 resize_count, 339 402 } 340 403 } ··· 364 427 status: ViewerStatus, 365 428 mesh: Option<ViewerMeshHandle>, 366 429 canvas_mounted: Signal<bool>, 430 + renderer_session: Signal<String>, 367 431 resize_count: Signal<u64>, 368 432 ) -> Element { 369 433 let mount = match (status, mesh) { ··· 375 439 376 440 rsx! { 377 441 if let Some(mount) = mount { 378 - BrowserRendererSession { key: "{mount.key.model_id()}-{mount.key.generation()}", mount, resize_count } 442 + BrowserRendererSession { key: "{mount.key.model_id()}-{mount.key.generation()}", mount, renderer_session, resize_count } 379 443 } 380 444 } 381 445 } 382 446 383 447 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 384 448 #[component] 385 - fn BrowserRendererSession(mount: RendererMount, resize_count: Signal<u64>) -> Element { 449 + fn BrowserRendererSession( 450 + mount: RendererMount, 451 + renderer_session: Signal<String>, 452 + resize_count: Signal<u64>, 453 + ) -> Element { 386 454 let _ = mount; 455 + let _ = renderer_session; 387 456 let _ = resize_count; 388 457 rsx! {} 389 458 } 390 459 391 460 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 392 461 #[component] 393 - fn BrowserRendererSession(mount: RendererMount, resize_count: Signal<u64>) -> Element { 394 - use wasm_bindgen::JsCast; 395 - use wasm_bindgen::closure::Closure; 396 - use web_sys::Event; 397 - 462 + fn BrowserRendererSession( 463 + mount: RendererMount, 464 + renderer_session: Signal<String>, 465 + resize_count: Signal<u64>, 466 + ) -> Element { 398 467 let _session = use_hook(move || { 399 - let mut cleanup: Option<Box<dyn FnOnce()>> = None; 468 + std::rc::Rc::new(ThreeDRendererSession::mount( 469 + mount, 470 + renderer_session, 471 + resize_count, 472 + )) 473 + }); 474 + rsx! {} 475 + } 476 + 477 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 478 + struct ThreeDRendererSession { 479 + mount: RendererMount, 480 + renderer: Option<std::rc::Rc<std::cell::RefCell<ThreeDRenderer>>>, 481 + renderer_session: Signal<String>, 482 + cleanup: Option<Box<dyn FnOnce()>>, 483 + } 484 + 485 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 486 + impl ThreeDRendererSession { 487 + fn mount( 488 + mount: RendererMount, 489 + mut renderer_session: Signal<String>, 490 + resize_count: Signal<u64>, 491 + ) -> Self { 492 + use wasm_bindgen::JsCast; 493 + use wasm_bindgen::closure::Closure; 494 + use web_sys::{Event, MouseEvent, PointerEvent, WheelEvent}; 495 + 496 + let mut cleanup_callbacks: Vec<Box<dyn FnOnce()>> = Vec::new(); 497 + let renderer = match ThreeDRenderer::new(&mount) { 498 + Ok(renderer) => Some(std::rc::Rc::new(std::cell::RefCell::new(renderer))), 499 + Err(err) => { 500 + tracing::warn!(error = %err, "three-d renderer initialization failed"); 501 + None 502 + } 503 + }; 400 504 401 505 if let Some(window) = web_sys::window() { 506 + let resize_renderer = renderer.clone(); 402 507 let mut resize_count = resize_count; 403 508 let resize_closure = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_| { 404 509 resize_count.set(resize_count() + 1); 510 + if let Some(renderer) = resize_renderer.as_ref() { 511 + renderer.borrow_mut().resize_and_render(); 512 + } 405 513 })); 406 - 407 514 if window 408 515 .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()) 409 516 .is_ok() 410 517 { 411 - cleanup = Some(Box::new(move || { 518 + cleanup_callbacks.push(Box::new(move || { 412 519 if let Some(window) = web_sys::window() { 413 520 let _ = window.remove_event_listener_with_callback( 414 521 "resize", ··· 419 526 } 420 527 } 421 528 422 - std::rc::Rc::new(BrowserRendererStub { mount, cleanup }) 423 - }); 529 + if let Some(renderer) = renderer.as_ref() { 530 + let canvas = renderer.borrow().canvas.clone(); 531 + let down_renderer = renderer.clone(); 532 + let down = 533 + Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 534 + event.prevent_default(); 535 + if let Some(target) = event 536 + .target() 537 + .and_then(|target| target.dyn_into::<web_sys::HtmlCanvasElement>().ok()) 538 + { 539 + let _ = target.set_pointer_capture(event.pointer_id()); 540 + } 541 + down_renderer 542 + .borrow_mut() 543 + .pointer_down(event.client_x() as f32, event.client_y() as f32); 544 + })); 545 + if canvas 546 + .add_event_listener_with_callback("pointerdown", down.as_ref().unchecked_ref()) 547 + .is_ok() 548 + { 549 + let canvas = canvas.clone(); 550 + cleanup_callbacks.push(Box::new(move || { 551 + let _ = canvas.remove_event_listener_with_callback( 552 + "pointerdown", 553 + down.as_ref().unchecked_ref(), 554 + ); 555 + })); 556 + } 424 557 425 - rsx! {} 426 - } 558 + let canvas = renderer.borrow().canvas.clone(); 559 + let move_renderer = renderer.clone(); 560 + let pointer_move = 561 + Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 562 + event.prevent_default(); 563 + move_renderer 564 + .borrow_mut() 565 + .pointer_move(event.client_x() as f32, event.client_y() as f32); 566 + })); 567 + if canvas 568 + .add_event_listener_with_callback( 569 + "pointermove", 570 + pointer_move.as_ref().unchecked_ref(), 571 + ) 572 + .is_ok() 573 + { 574 + let canvas = canvas.clone(); 575 + cleanup_callbacks.push(Box::new(move || { 576 + let _ = canvas.remove_event_listener_with_callback( 577 + "pointermove", 578 + pointer_move.as_ref().unchecked_ref(), 579 + ); 580 + })); 581 + } 427 582 428 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 429 - struct BrowserRendererStub { 430 - mount: RendererMount, 431 - cleanup: Option<Box<dyn FnOnce()>>, 583 + let canvas = renderer.borrow().canvas.clone(); 584 + let up_renderer = renderer.clone(); 585 + let pointer_up = 586 + Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { 587 + event.prevent_default(); 588 + up_renderer.borrow_mut().pointer_up(); 589 + })); 590 + if canvas 591 + .add_event_listener_with_callback("pointerup", pointer_up.as_ref().unchecked_ref()) 592 + .is_ok() 593 + { 594 + let canvas = canvas.clone(); 595 + cleanup_callbacks.push(Box::new(move || { 596 + let _ = canvas.remove_event_listener_with_callback( 597 + "pointerup", 598 + pointer_up.as_ref().unchecked_ref(), 599 + ); 600 + })); 601 + } 602 + 603 + let canvas = renderer.borrow().canvas.clone(); 604 + let leave_renderer = renderer.clone(); 605 + let pointer_leave = Closure::<dyn FnMut(MouseEvent)>::wrap(Box::new(move |_| { 606 + leave_renderer.borrow_mut().pointer_up(); 607 + })); 608 + if canvas 609 + .add_event_listener_with_callback( 610 + "mouseleave", 611 + pointer_leave.as_ref().unchecked_ref(), 612 + ) 613 + .is_ok() 614 + { 615 + let canvas = canvas.clone(); 616 + cleanup_callbacks.push(Box::new(move || { 617 + let _ = canvas.remove_event_listener_with_callback( 618 + "mouseleave", 619 + pointer_leave.as_ref().unchecked_ref(), 620 + ); 621 + })); 622 + } 623 + 624 + let canvas = renderer.borrow().canvas.clone(); 625 + let wheel_renderer = renderer.clone(); 626 + let wheel = 627 + Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| { 628 + event.prevent_default(); 629 + wheel_renderer.borrow_mut().wheel( 630 + event.delta_y() as f32, 631 + event.client_x() as f32, 632 + event.client_y() as f32, 633 + ); 634 + })); 635 + if canvas 636 + .add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref()) 637 + .is_ok() 638 + { 639 + let canvas = canvas.clone(); 640 + cleanup_callbacks.push(Box::new(move || { 641 + let _ = canvas.remove_event_listener_with_callback( 642 + "wheel", 643 + wheel.as_ref().unchecked_ref(), 644 + ); 645 + })); 646 + } 647 + } 648 + 649 + if let Some(renderer) = renderer.as_ref() { 650 + renderer.borrow_mut().resize_and_render(); 651 + renderer_session.set(format!( 652 + "{}#{}", 653 + mount.key.model_id(), 654 + mount.key.generation() 655 + )); 656 + } else { 657 + renderer_session.set(String::new()); 658 + } 659 + 660 + Self { 661 + mount, 662 + renderer, 663 + renderer_session, 664 + cleanup: Some(Box::new(move || { 665 + for cleanup in cleanup_callbacks.into_iter().rev() { 666 + cleanup(); 667 + } 668 + })), 669 + } 670 + } 432 671 } 433 672 434 673 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 435 - impl Drop for BrowserRendererStub { 674 + impl Drop for ThreeDRendererSession { 436 675 fn drop(&mut self) { 437 676 tracing::debug!( 438 677 model_id = self.mount.key.model_id(), 439 678 generation = self.mount.key.generation(), 440 679 asset_path = self.mount.mesh.asset_path, 441 - "dropping browser renderer session" 680 + had_renderer = self.renderer.is_some(), 681 + "dropping three-d renderer session" 442 682 ); 683 + let session_label = format!( 684 + "{}#{}", 685 + self.mount.key.model_id(), 686 + self.mount.key.generation() 687 + ); 688 + if (self.renderer_session)() == session_label { 689 + self.renderer_session.set(String::new()); 690 + } 443 691 if let Some(cleanup) = self.cleanup.take() { 444 692 cleanup(); 445 693 } 446 694 } 447 695 } 448 696 697 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 698 + struct ThreeDRenderer { 699 + canvas: web_sys::HtmlCanvasElement, 700 + context: three_d::Context, 701 + mesh: three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>, 702 + ambient_light: three_d::AmbientLight, 703 + key_light: three_d::DirectionalLight, 704 + fill_light: three_d::DirectionalLight, 705 + camera: three_d::Camera, 706 + control: three_d::OrbitControl, 707 + button_down: bool, 708 + last_pointer: Option<(f32, f32)>, 709 + frame_started_ms: f64, 710 + first_render_logged: bool, 711 + } 712 + 713 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 714 + impl ThreeDRenderer { 715 + fn new(mount: &RendererMount) -> Result<Self, String> { 716 + use wasm_bindgen::JsCast; 717 + 718 + let bytes = demo_model_bytes(mount.mesh.asset_path) 719 + .ok_or_else(|| "unknown demo asset".to_string())?; 720 + let model = load_stl(bytes).map_err(|err| err.to_string())?; 721 + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; 722 + let document = window 723 + .document() 724 + .ok_or_else(|| "document unavailable".to_string())?; 725 + let canvas = document 726 + .get_element_by_id("polymodel-viewer-canvas") 727 + .ok_or_else(|| "viewer canvas not found".to_string())? 728 + .dyn_into::<web_sys::HtmlCanvasElement>() 729 + .map_err(|_| "viewer element is not a canvas".to_string())?; 730 + let gl = canvas 731 + .get_context("webgl2") 732 + .map_err(|_| "webgl2 context lookup failed".to_string())? 733 + .ok_or_else(|| "webgl2 unavailable".to_string())? 734 + .dyn_into::<web_sys::WebGl2RenderingContext>() 735 + .map_err(|_| "context is not WebGL2".to_string())?; 736 + let glow_context = three_d::context::Context::from_webgl2_context(gl); 737 + #[allow( 738 + clippy::arc_with_non_send_sync, 739 + reason = "three-d requires Arc<glow::Context> for browser WebGL context construction" 740 + )] 741 + let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context)) 742 + .map_err(|err| err.to_string())?; 743 + let fit = model.camera_fit(); 744 + let cpu_mesh = model.trimesh; 745 + let mesh = three_d::Gm::new( 746 + three_d::Mesh::new(&context, &cpu_mesh), 747 + three_d::PhysicalMaterial { 748 + albedo: three_d::Srgba::new_opaque(116, 180, 255), 749 + roughness: 0.72, 750 + metallic: 0.0, 751 + ..Default::default() 752 + }, 753 + ); 754 + let radius = fit.radius.max(1.0); 755 + let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); 756 + let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); 757 + let viewport = canvas_viewport(&canvas); 758 + let camera = three_d::Camera::new_perspective( 759 + viewport, 760 + position, 761 + target, 762 + three_d::vec3(0.0, 0.0, 1.0), 763 + three_d::degrees(45.0), 764 + (radius / 100.0).max(0.1), 765 + radius * 20.0, 766 + ); 767 + let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); 768 + let ambient_light = 769 + three_d::AmbientLight::new(&context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); 770 + let key_light = three_d::DirectionalLight::new( 771 + &context, 772 + 2.6, 773 + three_d::Srgba::new_opaque(255, 255, 255), 774 + three_d::vec3(-0.45, -0.55, -0.70), 775 + ); 776 + let fill_light = three_d::DirectionalLight::new( 777 + &context, 778 + 0.9, 779 + three_d::Srgba::new_opaque(170, 205, 255), 780 + three_d::vec3(0.55, 0.35, -0.35), 781 + ); 782 + let frame_started_ms = window.performance().map(|p| p.now()).unwrap_or(0.0); 783 + 784 + Ok(Self { 785 + canvas, 786 + context, 787 + mesh, 788 + ambient_light, 789 + key_light, 790 + fill_light, 791 + camera, 792 + control, 793 + button_down: false, 794 + last_pointer: None, 795 + frame_started_ms, 796 + first_render_logged: false, 797 + }) 798 + } 799 + 800 + fn resize_and_render(&mut self) { 801 + let viewport = canvas_viewport(&self.canvas); 802 + self.camera.set_viewport(viewport); 803 + self.render(); 804 + } 805 + 806 + fn pointer_down(&mut self, x: f32, y: f32) { 807 + self.button_down = true; 808 + self.last_pointer = Some((x, y)); 809 + } 810 + 811 + fn pointer_move(&mut self, x: f32, y: f32) { 812 + if !self.button_down { 813 + return; 814 + } 815 + let Some((last_x, last_y)) = self.last_pointer.replace((x, y)) else { 816 + return; 817 + }; 818 + let mut event = three_d::Event::MouseMotion { 819 + button: Some(three_d::MouseButton::Left), 820 + delta: (x - last_x, y - last_y), 821 + position: (x, y).into(), 822 + modifiers: Default::default(), 823 + handled: false, 824 + }; 825 + self.control 826 + .handle_events(&mut self.camera, std::slice::from_mut(&mut event)); 827 + self.render(); 828 + } 829 + 830 + fn pointer_up(&mut self) { 831 + self.button_down = false; 832 + self.last_pointer = None; 833 + } 834 + 835 + fn wheel(&mut self, delta_y: f32, x: f32, y: f32) { 836 + let mut event = three_d::Event::MouseWheel { 837 + delta: (0.0, -delta_y), 838 + position: (x, y).into(), 839 + modifiers: Default::default(), 840 + handled: false, 841 + }; 842 + self.control 843 + .handle_events(&mut self.camera, std::slice::from_mut(&mut event)); 844 + self.render(); 845 + } 846 + 847 + fn render(&mut self) { 848 + let viewport = self.camera.viewport(); 849 + let screen = three_d::RenderTarget::screen(&self.context, viewport.width, viewport.height); 850 + let _ = screen 851 + .clear(three_d::ClearState::color_and_depth( 852 + 0.035, 0.043, 0.067, 1.0, 1.0, 853 + )) 854 + .render( 855 + &self.camera, 856 + [&self.mesh], 857 + &[&self.ambient_light, &self.key_light, &self.fill_light], 858 + ); 859 + if !self.first_render_logged { 860 + self.first_render_logged = true; 861 + let elapsed = web_sys::window() 862 + .and_then(|w| w.performance()) 863 + .map(|p| p.now() - self.frame_started_ms) 864 + .unwrap_or(0.0); 865 + tracing::info!( 866 + backend = "WebGL2", 867 + first_render_ms = elapsed, 868 + "three-d renderer ready" 869 + ); 870 + } 871 + } 872 + } 873 + 874 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 875 + fn canvas_viewport(canvas: &web_sys::HtmlCanvasElement) -> three_d::Viewport { 876 + let rect = canvas.get_bounding_client_rect(); 877 + let scale = web_sys::window() 878 + .map(|w| w.device_pixel_ratio()) 879 + .unwrap_or(1.0); 880 + let width = ((rect.width() * scale).round() as u32).max(1); 881 + let height = ((rect.height() * scale).round() as u32).max(1); 882 + canvas.set_width(width); 883 + canvas.set_height(height); 884 + three_d::Viewport::new_at_origo(width, height) 885 + } 886 + 449 887 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 450 888 fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal<bool>) { 451 889 mounted.set(true); ··· 529 967 #[test] 530 968 fn switching_models_advances_session_generation() { 531 969 let first = SessionKey::new("body-f-chest-v4", 7); 532 - let second = first.switch_to("body-f-chest-v4-detail"); 970 + let second = first.switch_to("body-f-forearm-2x-v9"); 533 971 534 - assert_eq!(second.model_id(), "body-f-chest-v4-detail"); 972 + assert_eq!(second.model_id(), "body-f-forearm-2x-v9"); 535 973 assert_eq!(second.generation(), 8); 536 974 } 537 975 ··· 565 1003 assert_eq!(events.get(), 2); 566 1004 567 1005 let replacement = 568 - RendererMount::new(SessionKey::new("body-f-chest-v4-detail", 2), mount.mesh); 1006 + RendererMount::new(SessionKey::new("body-f-forearm-2x-v9", 2), mount.mesh); 569 1007 lifecycle 570 1008 .reconcile( 571 1009 Some(canvas),