| 1 | // @effect-diagnostics globalFetch:off - This browser-only renderer owns abortable asset requests without an Effect runtime, like the live stream. |
| 2 | import { Box3, Group, Mesh, MeshBasicMaterial, Texture, type Material, type Object3D } from "three"; |
| 3 | import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; |
| 4 | import type { DeviceAssetSource } from "./model.ts"; |
| 5 | import { createDisplayProjection, updateDisplayUv, type PhoneDisplayLayout } from "./phoneScene.ts"; |
| 6 | |
| 7 | /** GLB resources belong to one viewer; the live framebuffer texture belongs to its stream presentation. */ |
| 8 | export function disposeDeviceModel(root: Object3D) { |
| 9 | const geometries = new Set<Mesh["geometry"]>(); |
| 10 | const materials = new Set<Material>(); |
| 11 | const textures = new Set<Texture>(); |
| 12 | root.traverse((object) => { |
| 13 | if (!(object instanceof Mesh)) return; |
| 14 | geometries.add(object.geometry); |
| 15 | for (const material of Array.isArray(object.material) ? object.material : [object.material]) { |
| 16 | materials.add(material); |
| 17 | for (const value of Object.values(material)) { |
| 18 | if (value instanceof Texture) textures.add(value); |
| 19 | } |
| 20 | } |
| 21 | }); |
| 22 | for (const geometry of geometries) geometry.dispose(); |
| 23 | for (const material of materials) material.dispose(); |
| 24 | const images = new Set<ImageBitmap>(); |
| 25 | for (const texture of textures) { |
| 26 | texture.dispose(); |
| 27 | if (typeof ImageBitmap !== "undefined" && texture.image instanceof ImageBitmap) |
| 28 | images.add(texture.image); |
| 29 | } |
| 30 | for (const image of images) image.close(); |
| 31 | } |
| 32 | |
| 33 | /** Assets are converted offline: portrait, front +Z, centered display, height 2.2 and planar display UVs. */ |
| 34 | export function createImportedPhoneScene( |
| 35 | asset: Group, |
| 36 | texture: Texture, |
| 37 | initial: PhoneDisplayLayout, |
| 38 | ) { |
| 39 | const screens: Mesh[] = []; |
| 40 | asset.traverse((object) => { |
| 41 | if (object instanceof Mesh && object.name === "device-screen") screens.push(object); |
| 42 | }); |
| 43 | const display = screens[0]; |
| 44 | if (!display || screens.length !== 1) |
| 45 | throw new Error("Device model must have one device-screen mesh"); |
| 46 | asset.updateMatrixWorld(true); |
| 47 | const screenBounds = new Box3().setFromObject(display); |
| 48 | const screenWidth = screenBounds.max.x - screenBounds.min.x; |
| 49 | const screenHeight = screenBounds.max.y - screenBounds.min.y; |
| 50 | if ( |
| 51 | display.matrixWorld.elements.some( |
| 52 | (value, index) => |
| 53 | !Number.isFinite(value) || Math.abs(value - (index % 5 === 0 ? 1 : 0)) > 0.001, |
| 54 | ) || |
| 55 | !Number.isFinite(screenWidth) || |
| 56 | !Number.isFinite(screenHeight) || |
| 57 | !Number.isFinite(screenBounds.min.z) || |
| 58 | !Number.isFinite(screenBounds.max.z) || |
| 59 | screenWidth <= 0 || |
| 60 | Math.abs(screenHeight - 2.2) > 0.001 || |
| 61 | Math.abs(screenBounds.min.x + screenBounds.max.x) > 0.001 || |
| 62 | Math.abs(screenBounds.min.y + screenBounds.max.y) > 0.001 || |
| 63 | screenBounds.max.z - screenBounds.min.z > 0.001 |
| 64 | ) |
| 65 | throw new Error("Device model display is not normalized"); |
| 66 | updateDisplayUv(display.geometry, screenWidth, screenHeight, initial); |
| 67 | const originalMaterial = display.material; |
| 68 | const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false }); |
| 69 | display.material = screenMaterial; |
| 70 | const root = new Group(); |
| 71 | const orientation = new Group(); |
| 72 | orientation.add(asset); |
| 73 | root.add(orientation); |
| 74 | const bounds = new Box3().setFromObject(asset); |
| 75 | let layout = initial; |
| 76 | const projection = createDisplayProjection( |
| 77 | display, |
| 78 | orientation, |
| 79 | screenWidth, |
| 80 | screenHeight, |
| 81 | () => layout, |
| 82 | ); |
| 83 | return { |
| 84 | root, |
| 85 | orientation, |
| 86 | width: bounds.max.x - bounds.min.x, |
| 87 | height: bounds.max.y - bounds.min.y, |
| 88 | setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) { |
| 89 | layout = nextLayout; |
| 90 | screenMaterial.map = nextTexture; |
| 91 | updateDisplayUv(display.geometry, screenWidth, screenHeight, layout); |
| 92 | }, |
| 93 | screenPoint: projection, |
| 94 | dispose() { |
| 95 | // The model slot owns imported resources. This scene owns only its replacement screen material. |
| 96 | display.material = originalMaterial; |
| 97 | screenMaterial.map = null; |
| 98 | screenMaterial.dispose(); |
| 99 | orientation.remove(asset); |
| 100 | }, |
| 101 | }; |
| 102 | } |
| 103 | |
| 104 | export async function loadDeviceModel(source: DeviceAssetSource, signal: AbortSignal) { |
| 105 | const response = await fetch(source.url, { signal }); |
| 106 | if (!response.ok) throw new Error(`Device model request failed: ${response.status}`); |
| 107 | const data = await response.arrayBuffer(); |
| 108 | signal.throwIfAborted(); |
| 109 | const gltf = await new GLTFLoader().parseAsync( |
| 110 | data, |
| 111 | new URL(".", new URL(source.url, globalThis.location.href)).href, |
| 112 | ); |
| 113 | if (signal.aborted) { |
| 114 | disposeDeviceModel(gltf.scene); |
| 115 | signal.throwIfAborted(); |
| 116 | } |
| 117 | return { asset: gltf.scene, dispose: () => disposeDeviceModel(gltf.scene) }; |
| 118 | } |