Skip to content
Source
Sign inSign up

modelScene.ts

117 lines4.5 KB

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();
32​
33export function createImportedPhoneScene(
34 asset: Group,
35 texture: Texture,
36 initial: PhoneDisplayLayout,
37) {
38 const screens: Mesh[] = [];
39 asset.traverse((object) => {
40 if (object instanceof Mesh && object.name === "device-screen") screens.push(object);
41 });
42 const display = screens[0];
43 if (!display || screens.length !== 1)
44 throw new Error("Device model must have one device-screen mesh");
45 asset.updateMatrixWorld(true);
46 const screenBounds = new Box3().setFromObject(display);
47 const screenWidth = screenBounds.max.x - screenBounds.min.x;
48 const screenHeight = screenBounds.max.y - screenBounds.min.y;
49 if (
50 display.matrixWorld.elements.some(
51 (value, index) =>
52 !Number.isFinite(value) || Math.abs(value - (index % 5 === 0 ? 1 : 0)) > 0.001,
53 ) ||
54 !Number.isFinite(screenWidth) ||
55 !Number.isFinite(screenHeight) ||
56 !Number.isFinite(screenBounds.min.z) ||
57 !Number.isFinite(screenBounds.max.z) ||
58 screenWidth <= 0 ||
59 Math.abs(screenHeight - 2.2) > 0.001 ||
60 Math.abs(screenBounds.min.x + screenBounds.max.x) > 0.001 ||
61 Math.abs(screenBounds.min.y + screenBounds.max.y) > 0.001 ||
62 screenBounds.max.z - screenBounds.min.z > 0.001
63 )
64 throw new Error("Device model display is not normalized");
65 updateDisplayUv(display.geometry, screenWidth, screenHeight, initial);
66 const originalMaterial = display.material;
67 const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
68 display.material = screenMaterial;
69 const root = new Group();
70 const orientation = new Group();
71 orientation.add(asset);
72 root.add(orientation);
73 const bounds = new Box3().setFromObject(asset);
74 let layout = initial;
75 const projection = createDisplayProjection(
76 display,
77 orientation,
78 screenWidth,
79 screenHeight,
80 () => layout,
81 );
82 return {
83 root,
84 orientation,
85 width: bounds.max.x - bounds.min.x,
86 height: bounds.max.y - bounds.min.y,
87 setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) {
88 layout = nextLayout;
89 screenMaterial.map = nextTexture;
90 updateDisplayUv(display.geometry, screenWidth, screenHeight, layout);
91 },
92 screenPoint: projection,
93 dispose() {
94 // The model slot owns imported resources. This scene owns only its replacement screen material.
95 display.material = originalMaterial;
96 screenMaterial.map = null;
97 screenMaterial.dispose();
98 orientation.remove(asset);
99 },
100 };
102​
103export async function loadDeviceModel(source: DeviceAssetSource, signal: AbortSignal) {
104 const response = await fetch(source.url, { signal });
105 if (!response.ok) throw new Error(`Device model request failed: ${response.status}`);
106 const data = await response.arrayBuffer();
107 signal.throwIfAborted();
108 const gltf = await new GLTFLoader().parseAsync(
109 data,
110 new URL(".", new URL(source.url, globalThis.location.href)).href,
111 );
112 if (signal.aborted) {
113 disposeDeviceModel(gltf.scene);
114 signal.throwIfAborted();
115 }
116 return { asset: gltf.scene, dispose: () => disposeDeviceModel(gltf.scene) };