1import {
2 Box3,
3 BufferAttribute,
4 Group,
5 Mesh,
6 MeshBasicMaterial,
7 Matrix4,
8 Quaternion,
9 Plane,
10 Raycaster,
11 Triangle,
12 Vector2,
13 Vector3,
14 type PerspectiveCamera,
15 type Texture,
16} from "three";
17import type { DuoRestFrame } from "./duoSnap.ts";
18import type { DeviceScreenSize } from "./stream.ts";
19
20export type DuoPanelId = 1 | 3;
21export type DuoHingeLeaf = "left" | "right";
22export type DuoFrameLayout = { width: number; height: number };
23
24export function duoRawPoint(panel: DuoPanelId, x: number, y: number) {
25 return panel === 1 ? { x, y } : { x: y, y: 1 - x };
26}
27export function duoFrameMatches(frame: DuoFrameLayout, screen: DeviceScreenSize) {
28 return (
29 frame.width > 0 &&
30 frame.height > 0 &&
31 Math.abs(frame.width / frame.height - screen.width / screen.height) <=
32 1 / frame.height + 1 / screen.height
33 );
34}
35export function duoDisplayKey(screen: DeviceScreenSize | null) {
36 return screen ? `${screen.screenId}:${screen.width}:${screen.height}:${screen.orientation}` : "";
37}
38
39/** Asset resources belong to its model slot; this scene owns only live-screen replacement materials. */
40export function createDuoScene(asset: Group, textures: Record<DuoPanelId, Texture>) {
41 const left = asset.getObjectByName("left-half");
42 const right = asset.getObjectByName("right-half");
43 const cover = asset.getObjectByName("cover-display");
44 const innerLeft = asset.getObjectByName("inner-display-left");
45 const innerRight = asset.getObjectByName("inner-display-right");
46 if (
47 !left ||
48 !right ||
49 !(cover instanceof Mesh) ||
50 !(innerLeft instanceof Mesh) ||
51 !(innerRight instanceof Mesh)
52 )
53 throw new Error("Duo model must have two hinge groups and all three display meshes");
54 const surfaces = { 1: [cover], 3: [innerLeft, innerRight] };
55 const boundsByPanel = ([1, 3] as const).map((id) => {
56 const bounds = new Box3();
57 for (const mesh of surfaces[id]) {
58 mesh.geometry.computeBoundingBox();
59 bounds.union(mesh.geometry.boundingBox!);
60 }
61 const size = bounds.getSize(new Vector3());
62 if (![size.x, size.y].every((value) => Number.isFinite(value) && value > 0))
63 throw new Error("Invalid Duo display bounds");
64 return { bounds, size };
65 });
66 const root = new Group();
67 const content = new Group();
68 content.add(asset);
69 root.add(content);
70 let currentAngle = Number.NaN;
71 const localBounds = new Box3();
72 const relative = new Matrix4();
73 const inverse = new Matrix4();
74 const materials = {
75 1: new MeshBasicMaterial({ map: textures[1], toneMapped: false }),
76 3: new MeshBasicMaterial({ map: textures[3], toneMapped: false }),
77 };
78 const originals = new Map<Mesh, Mesh["material"]>();
79 for (const id of [1, 3] as const) {
80 const { bounds, size } = boundsByPanel[id === 1 ? 0 : 1]!;
81 for (const mesh of surfaces[id]) {
82 const position = mesh.geometry.getAttribute("position");
83 const uv = new Float32Array(position.count * 2);
84 for (let i = 0; i < position.count; i++) {
85 const x = (position.getX(i) - bounds.min.x) / size.x;
86 uv[i * 2] = id === 1 ? 1 - x : x;
87 uv[i * 2 + 1] = (position.getY(i) - bounds.min.y) / size.y;
88 }
89 mesh.geometry.setAttribute("uv", new BufferAttribute(uv, 2));
90 originals.set(mesh, mesh.material);
91 mesh.material = materials[id];
92 }
93 }
94 const ray = new Raycaster();
95 let captured: {
96 panel: DuoPanelId;
97 plane: Plane;
98 triangle: Triangle;
99 uv: [Vector2, Vector2, Vector2];
100 key: string;
101 } | null = null;
102 return {
103 root,
104 setAngle(angle: number) {
105 if (angle === currentAngle) return;
106 currentAngle = angle;
107 const radians = ((180 - Math.min(180, Math.max(0, angle))) * Math.PI) / 360;
108 left.rotation.y = radians;
109 right.rotation.y = -radians;
110 content.position.set(0, 0, 0);
111 root.updateWorldMatrix(true, true);
112 inverse.copy(root.matrixWorld).invert();
113 localBounds.makeEmpty();
114 asset.traverse((object) => {
115 if (!(object instanceof Mesh)) return;
116 if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
117 if (!object.geometry.boundingBox) return;
118 relative.multiplyMatrices(inverse, object.matrixWorld);
119 localBounds.union(object.geometry.boundingBox.clone().applyMatrix4(relative));
120 });
121 content.position.copy(localBounds.getCenter(new Vector3())).negate();
122 },
123 restFrames(panel: DuoPanelId): DuoRestFrame[] {
124 root.updateWorldMatrix(true, true);
125 inverse.copy(root.matrixWorld).invert();
126 const frame = (meshes: Mesh[], face: DuoRestFrame["face"]): DuoRestFrame => {
127 const bounds = new Box3();
128 const normal = new Vector3();
129 const up = new Vector3();
130 for (const mesh of meshes) {
131 if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
132 relative.multiplyMatrices(inverse, mesh.matrixWorld);
133 bounds.union(mesh.geometry.boundingBox!.clone().applyMatrix4(relative));
134 normal.add(
135 new Vector3()
136 .fromBufferAttribute(mesh.geometry.getAttribute("normal"), 0)
137 .transformDirection(relative),
138 );
139 up.add(new Vector3(0, 1, 0).transformDirection(relative));
140 }
141 return {
142 face,
143 center: bounds.getCenter(new Vector3()),
144 normal: normal.normalize(),
145 up: up.normalize(),
146 };
147 };
148 if (panel === 1) return [frame([cover], "cover")];
149 const inside = frame([innerLeft, innerRight], "inside");
150 return currentAngle > 20 && currentAngle < 165
151 ? [inside, frame([innerLeft], "left"), frame([innerRight], "right")]
152 : [inside];
153 },
154 leafRotation(leaf: DuoHingeLeaf) {
155 root.updateWorldMatrix(true, true);
156 relative.multiplyMatrices(
157 root.matrixWorld.clone().invert(),
158 (leaf === "left" ? left : right).matrixWorld,
159 );
160 return new Quaternion().setFromRotationMatrix(relative.extractRotation(relative));
161 },
162 hingeLeafAt(x: number, y: number, camera: PerspectiveCamera): DuoHingeLeaf | null {
163 if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
164 root.updateMatrixWorld(true);
165 camera.updateMatrixWorld(true);
166 ray.setFromCamera(new Vector2(x * 2 - 1, 1 - y * 2), camera);
167 let object = ray.intersectObject(root, true)[0]?.object;
168 while (object) {
169 if (object === left) return "left";
170 if (object === right) return "right";
171 object = object.parent ?? undefined;
172 }
173 return null;
174 },
175 cancelInput() {
176 captured = null;
177 },
178 screenPoint(
179 x: number,
180 y: number,
181 camera: PerspectiveCamera,
182 screen: DeviceScreenSize | null,
183 readyKey: string,
184 extend = false,
185 ) {
186 const key = duoDisplayKey(screen);
187 if (!screen || !readyKey || key !== readyKey) {
188 captured = null;
189 return null;
190 }
191 root.updateMatrixWorld(true);
192 camera.updateMatrixWorld(true);
193 ray.setFromCamera(new Vector2(x * 2 - 1, 1 - y * 2), camera);
194 if (extend && captured?.key === key) {
195 const point = ray.ray.intersectPlane(captured.plane, new Vector3());
196 const bary = point ? captured.triangle.getBarycoord(point, new Vector3()) : null;
197 if (!bary) return null;
198 const uv = captured.uv[0]
199 .clone()
200 .multiplyScalar(bary.x)
201 .addScaledVector(captured.uv[1], bary.y)
202 .addScaledVector(captured.uv[2], bary.z);
203 return duoRawPoint(captured.panel, uv.x, 1 - uv.y);
204 }
205 const hit = ray.intersectObject(root, true)[0];
206 if (!hit?.uv || !hit.face || !(hit.object instanceof Mesh)) return null;
207 const panel: DuoPanelId | null = surfaces[1].includes(hit.object)
208 ? 1
209 : surfaces[3].includes(hit.object)
210 ? 3
211 : null;
212 if (!panel || panel !== screen.screenId) return null;
213 const indices = [hit.face.a, hit.face.b, hit.face.c] as const;
214 const positions = hit.object.geometry.getAttribute("position");
215 const triangle = new Triangle(
216 ...(indices.map((index) =>
217 new Vector3().fromBufferAttribute(positions, index).applyMatrix4(hit.object.matrixWorld),
218 ) as [Vector3, Vector3, Vector3]),
219 );
220 const hitUvs = hit.object.geometry.getAttribute("uv");
221 const uv = indices.map((index) => new Vector2().fromBufferAttribute(hitUvs, index)) as [
222 Vector2,
223 Vector2,
224 Vector2,
225 ];
226 captured = { panel, triangle, uv, plane: triangle.getPlane(new Plane()), key };
227 return duoRawPoint(panel, hit.uv.x, 1 - hit.uv.y);
228 },
229 dispose() {
230 for (const [mesh, material] of originals) mesh.material = material;
231 for (const material of Object.values(materials)) {
232 material.map = null;
233 material.dispose();
234 }
235 content.remove(asset);
236 captured = null;
237 },
238 };
239}