1// @effect-diagnostics globalFetch:off - This browser-only renderer owns abortable asset requests without an Effect runtime, like the live stream.
2import { Box3, Group, Mesh, MeshBasicMaterial, Texture, type Material, type Object3D } from "three";
3import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4import type { DeviceAssetSource } from "./model.ts";
5import { createDisplayProjection, updateDisplayUv, type PhoneDisplayLayout } from "./phoneScene.ts";
6
7/** GLB resources belong to one viewer; the live framebuffer texture belongs to its stream presentation. */
8export 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. */
34export 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
104export 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}