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