1import {
2 BoxGeometry,
3 CircleGeometry,
4 CylinderGeometry,
5 ExtrudeGeometry,
6 Group,
7 Mesh,
8 MeshBasicMaterial,
9 MeshPhysicalMaterial,
10 MeshStandardMaterial,
11 PlaneGeometry,
12 Raycaster,
13 Shape,
14 ShapeGeometry,
15 Vector2,
16 Vector3,
17 type Camera,
18 type Texture,
19} from "three";
20import type { PhoneDisplayLayout } from "./phoneScene.ts";
21
22const HEIGHT = 2.2;
23const DEPTH = 0.075;
24const INSET = 0.026;
25const CREASE = 0.004;
26const BEVEL = 0.008;
27// The hinge axis sits just above the inner screens so closed halves meet face to face.
28const PIVOT_Z = DEPTH / 2 + 0.005;
29const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002;
30/** Width over height of the unfolded inner display until a live frame reports its own. */
31export const DEFAULT_FOLD_INNER_ASPECT = 2076 / 2152;
32/**
33 * Unfolded inner displays are near square in either orientation. Cover displays are
34 * phone shaped (about 0.4-0.5, or 2-2.6 when rotated), so they never retune the body.
35 */
36export const isFoldInnerAspect = (aspect: number) =>
37 Number.isFinite(aspect) && aspect > 0.75 && aspect < 1.5;
38
39function panelPath(
40 halfWidth: number,
41 side: "left" | "right",
42 inset: number,
43 radius: number,
44 hingeInset = 0,
45) {
46 const left = side === "left" ? -halfWidth + inset : CREASE / 2 + hingeInset;
47 const right = side === "left" ? -CREASE / 2 - hingeInset : halfWidth - inset;
48 const bottom = -HEIGHT / 2 + inset;
49 const top = HEIGHT / 2 - inset;
50 const path = new Shape();
51 if (side === "left") {
52 path.moveTo(left + radius, bottom);
53 path.lineTo(right, bottom);
54 path.lineTo(right, top);
55 path.lineTo(left + radius, top);
56 path.quadraticCurveTo(left, top, left, top - radius);
57 path.lineTo(left, bottom + radius);
58 path.quadraticCurveTo(left, bottom, left + radius, bottom);
59 } else {
60 path.moveTo(left, bottom);
61 path.lineTo(right - radius, bottom);
62 path.quadraticCurveTo(right, bottom, right, bottom + radius);
63 path.lineTo(right, top - radius);
64 path.quadraticCurveTo(right, top, right - radius, top);
65 path.lineTo(left, top);
66 }
67 path.closePath();
68 return path;
69}
70
71function roundedRectPath(width: number, height: number, radius: number) {
72 const path = new Shape();
73 path.moveTo(-width / 2 + radius, -height / 2);
74 path.lineTo(width / 2 - radius, -height / 2);
75 path.quadraticCurveTo(width / 2, -height / 2, width / 2, -height / 2 + radius);
76 path.lineTo(width / 2, height / 2 - radius);
77 path.quadraticCurveTo(width / 2, height / 2, width / 2 - radius, height / 2);
78 path.lineTo(-width / 2 + radius, height / 2);
79 path.quadraticCurveTo(-width / 2, height / 2, -width / 2, height / 2 - radius);
80 path.lineTo(-width / 2, -height / 2 + radius);
81 path.quadraticCurveTo(-width / 2, -height / 2, -width / 2 + radius, -height / 2);
82 path.closePath();
83 return path;
84}
85
86function coverPath(halfWidth: number) {
87 return roundedRectPath(halfWidth - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07);
88}
89
90/**
91 * A procedural book-style foldable: one fixed half, one half rotating around a shared hinge.
92 * The inner display keeps the raw framebuffer's native aspect, portrait or landscape.
93 */
94export function createAndroidFoldScene(
95 texture: Texture,
96 layout: PhoneDisplayLayout,
97 initialAngle: number,
98 innerAspect = DEFAULT_FOLD_INNER_ASPECT,
99) {
100 const screenHeight = HEIGHT - 2 * INSET;
101 const halfWidth = (innerAspect * screenHeight) / 2 + INSET;
102 const root = new Group();
103 const orientation = new Group();
104 root.add(orientation);
105 const left = new Group();
106 const right = new Group();
107 orientation.add(left, right);
108 const frameMetal = new MeshStandardMaterial({
109 color: 0xa3abb2,
110 metalness: 0.9,
111 roughness: 0.28,
112 });
113 const polishedMetal = new MeshStandardMaterial({
114 color: 0xc4cad0,
115 metalness: 0.95,
116 roughness: 0.16,
117 });
118 const bezel = new MeshPhysicalMaterial({
119 color: 0x0b0d10,
120 metalness: 0.1,
121 roughness: 0.2,
122 clearcoat: 1,
123 });
124 const backGlass = new MeshPhysicalMaterial({
125 color: 0x2c3237,
126 metalness: 0.35,
127 roughness: 0.52,
128 clearcoat: 0.4,
129 clearcoatRoughness: 0.6,
130 });
131 const island = new MeshPhysicalMaterial({
132 color: 0x1a1e22,
133 metalness: 0.55,
134 roughness: 0.3,
135 clearcoat: 1,
136 });
137 const lensMaterial = new MeshPhysicalMaterial({
138 color: 0x061022,
139 metalness: 0.6,
140 roughness: 0.1,
141 clearcoat: 1,
142 });
143 const flashMaterial = new MeshBasicMaterial({ color: 0xf2ead6 });
144 const displayMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
145 const hitMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: false });
146 const coverMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
147 const materials = [
148 frameMetal,
149 polishedMetal,
150 bezel,
151 backGlass,
152 island,
153 lensMaterial,
154 flashMaterial,
155 displayMaterial,
156 hitMaterial,
157 coverMaterial,
158 ];
159
160 // Each half's meshes live in body coordinates; `left` pivots them around the hinge axis.
161 left.position.z = PIVOT_Z;
162 const leftBody = new Group();
163 leftBody.position.z = -PIVOT_Z;
164 left.add(leftBody);
165
166 function half(group: Group, side: "left" | "right", back: MeshPhysicalMaterial) {
167 const body = new Mesh(
168 new ExtrudeGeometry(panelPath(halfWidth, side, BEVEL, 0.1, BEVEL), {
169 depth: DEPTH - BEVEL * 2,
170 bevelEnabled: true,
171 bevelSize: BEVEL,
172 bevelThickness: BEVEL,
173 bevelSegments: 3,
174 curveSegments: 12,
175 }),
176 frameMetal,
177 );
178 body.position.z = -DEPTH / 2 + BEVEL;
179 group.add(body);
180 const frame = new Mesh(
181 new ShapeGeometry(panelPath(halfWidth, side, 0.01, 0.095, 0.002), 12),
182 bezel,
183 );
184 frame.position.z = DEPTH / 2 + 0.001;
185 group.add(frame);
186 // A back-facing shape mirrors X, so it is drawn from the opposite side's outline.
187 const rear = new Mesh(
188 new ShapeGeometry(
189 panelPath(halfWidth, side === "left" ? "right" : "left", 0.01, 0.095, 0.002),
190 12,
191 ),
192 back,
193 );
194 rear.name = `${side}-back`;
195 rear.rotation.y = Math.PI;
196 rear.position.set(0, 0, -DEPTH / 2 - 0.001);
197 group.add(rear);
198 const geometry = new ShapeGeometry(panelPath(halfWidth, side, INSET, 0.076));
199 geometry.computeBoundingBox();
200 const display = new Mesh(geometry, hitMaterial);
201 display.name = `${side}-inner-screen`;
202 display.position.z = DEPTH / 2 + 0.003;
203 group.add(display);
204 return display;
205 }
206
207 const innerLeft = half(leftBody, "left", bezel);
208 const innerRight = half(right, "right", backGlass);
209 // One indexed surface keeps adjacent pixels joined at the crease. The
210 // physical halves move separately underneath it.
211 const screenWidth = 2 * (halfWidth - INSET);
212 const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48);
213 const screenPositions = screenGeometry.getAttribute("position");
214 const screenUvs = screenGeometry.getAttribute("uv");
215 const baseX = new Float32Array(screenPositions.count);
216 for (let i = 0; i < screenPositions.count; i++) {
217 const y = screenPositions.getY(i);
218 const outerX = screenWidth / 2;
219 const outerY = screenHeight / 2;
220 const radius = 0.076;
221 const cornerY = Math.max(0, Math.abs(y) - (outerY - radius));
222 const limit = outerX - radius + Math.sqrt(Math.max(0, radius * radius - cornerY * cornerY));
223 const x = Math.max(-limit, Math.min(limit, screenPositions.getX(i)));
224 baseX[i] = x;
225 screenUvs.setXY(i, x / screenWidth + 0.5, y / screenHeight + 0.5);
226 }
227 screenUvs.needsUpdate = true;
228 const innerSurface = new Mesh(screenGeometry, displayMaterial);
229 innerSurface.name = "continuous-inner-screen";
230 orientation.add(innerSurface);
231 // The outer half of the hinge housing. It tucks behind the back glass when
232 // open and becomes the rounded spine when closed.
233 const spineSlack = 0.15;
234 const spine = new Mesh(
235 new CylinderGeometry(
236 SPINE_RADIUS,
237 SPINE_RADIUS,
238 HEIGHT - 0.012,
239 32,
240 1,
241 false,
242 Math.PI / 2 + spineSlack,
243 Math.PI - spineSlack * 2,
244 ),
245 polishedMetal,
246 );
247 spine.name = "hinge-spine";
248 spine.position.z = PIVOT_Z;
249 orientation.add(spine);
250
251 const cover = new Mesh(new ShapeGeometry(coverPath(halfWidth)), coverMaterial);
252 cover.name = "cover-screen";
253 cover.position.set(-halfWidth / 2, 0, -DEPTH / 2 - 0.003);
254 cover.rotation.y = Math.PI;
255 leftBody.add(cover);
256
257 // Rear components use back-surface coordinates, with outward positive Z.
258 const rearCamera = new Group();
259 rearCamera.name = "rear-camera";
260 const islandWidth = 0.46;
261 const islandHeight = 0.2;
262 rearCamera.position.set(
263 halfWidth - 0.07 - islandWidth / 2,
264 HEIGHT / 2 - 0.08 - islandHeight / 2,
265 -DEPTH / 2 - 0.002,
266 );
267 rearCamera.rotation.y = Math.PI;
268 right.add(rearCamera);
269 const plateDepth = 0.02;
270 const plate = new Mesh(
271 new ExtrudeGeometry(roundedRectPath(islandWidth, islandHeight, 0.07), {
272 depth: plateDepth,
273 bevelEnabled: true,
274 bevelSize: 0.008,
275 bevelThickness: 0.006,
276 bevelSegments: 3,
277 curveSegments: 12,
278 }),
279 island,
280 );
281 plate.name = "camera-plate";
282 rearCamera.add(plate);
283 const plateFront = plateDepth + 0.006;
284 for (const [x, radius] of [
285 [-0.14, 0.05],
286 [-0.01, 0.05],
287 [0.105, 0.036],
288 ] as const) {
289 const ring = new Mesh(
290 new CylinderGeometry(radius + 0.012, radius + 0.012, 0.012, 32),
291 frameMetal,
292 );
293 ring.rotation.x = Math.PI / 2;
294 ring.position.set(x, 0, plateFront + 0.004);
295 rearCamera.add(ring);
296 const lens = new Mesh(new CircleGeometry(radius, 32), lensMaterial);
297 lens.name = "camera-lens";
298 lens.position.set(x, 0, plateFront + 0.0105);
299 rearCamera.add(lens);
300 }
301 const flash = new Mesh(new CircleGeometry(0.018, 20), flashMaterial);
302 flash.position.set(0.185, 0.045, plateFront + 0.0005);
303 rearCamera.add(flash);
304
305 // Power and volume keys sit on the fixed half's outer edge.
306 for (const [y, length] of [
307 [0.52, 0.16],
308 [0.2, 0.3],
309 ] as const) {
310 const key = new Mesh(new BoxGeometry(0.02, length, DEPTH * 0.45), frameMetal);
311 key.position.set(halfWidth + 0.008, y, 0);
312 right.add(key);
313 }
314
315 const raycaster = new Raycaster();
316 const pointer = new Vector2();
317 const local = new Vector3();
318 let capturedDisplay: Mesh | null = null;
319 let activeLayout = layout;
320 let angle = initialAngle;
321 const updateVisibleScreen = () => {
322 const innerActive = angle >= 90;
323 innerSurface.visible = innerActive;
324 innerLeft.visible = innerActive;
325 innerRight.visible = innerActive;
326 cover.visible = !innerActive;
327 };
328 const setAngle = (next: number) => {
329 angle = Math.max(0, Math.min(180, next));
330 left.rotation.y = Math.PI * (1 - angle / 180);
331 spine.rotation.y = left.rotation.y / 2;
332 const radians = left.rotation.y;
333 const cosine = Math.cos(radians);
334 const sine = Math.sin(radians);
335 const frontZ = DEPTH / 2 + 0.004 - PIVOT_Z;
336 for (let i = 0; i < screenPositions.count; i++) {
337 const x = baseX[i]!;
338 if (x < 0) {
339 screenPositions.setXYZ(
340 i,
341 x * cosine + frontZ * sine,
342 screenPositions.getY(i),
343 -x * sine + frontZ * cosine + PIVOT_Z,
344 );
345 } else {
346 screenPositions.setXYZ(i, x, screenPositions.getY(i), frontZ + PIVOT_Z);
347 }
348 }
349 screenPositions.needsUpdate = true;
350 screenGeometry.computeBoundingBox();
351 screenGeometry.computeBoundingSphere();
352 updateVisibleScreen();
353 };
354 const setDisplay = (nextTexture: Texture, nextLayout: PhoneDisplayLayout) => {
355 activeLayout = nextLayout;
356 displayMaterial.map = nextTexture;
357 coverMaterial.map = nextTexture;
358 cover.geometry.computeBoundingBox();
359 const bounds = cover.geometry.boundingBox!;
360 const uv = cover.geometry.getAttribute("uv");
361 const position = cover.geometry.getAttribute("position");
362 for (let i = 0; i < uv.count; i++) {
363 const u = (position.getX(i) - bounds.min.x) / (bounds.max.x - bounds.min.x);
364 const v = (position.getY(i) - bounds.min.y) / (bounds.max.y - bounds.min.y);
365 uv.setXY(i, u, v);
366 }
367 uv.needsUpdate = true;
368 updateVisibleScreen();
369 };
370 setAngle(initialAngle);
371 setDisplay(texture, layout);
372
373 return {
374 root,
375 orientation,
376 width: halfWidth * 2,
377 height: HEIGHT,
378 innerAspect,
379 setAngle,
380 setDisplay,
381 screenPoint(x: number, y: number, camera: Camera, captured = false) {
382 orientation.updateWorldMatrix(true, true);
383 camera.updateMatrixWorld(true);
384 pointer.set(x * 2 - 1, 1 - y * 2);
385 raycaster.setFromCamera(pointer, camera);
386 const screens = cover.visible ? [cover] : [innerLeft, innerRight];
387 const hit = raycaster.intersectObjects(screens, false)[0];
388 if (!hit && !captured) {
389 capturedDisplay = null;
390 return null;
391 }
392 const display =
393 (hit?.object as Mesh | undefined) ??
394 (capturedDisplay?.visible ? capturedDisplay : screens[0]);
395 if (!display) return null;
396 if (hit) capturedDisplay = display;
397 if (hit) local.copy(hit.point);
398 else {
399 const plane = new Vector3(0, 0, 1).transformDirection(display.matrixWorld);
400 const point = new Vector3().setFromMatrixPosition(display.matrixWorld);
401 const distance =
402 plane.dot(point.clone().sub(raycaster.ray.origin)) / plane.dot(raycaster.ray.direction);
403 if (!Number.isFinite(distance)) return null;
404 local.copy(raycaster.ray.direction).multiplyScalar(distance).add(raycaster.ray.origin);
405 }
406 display.worldToLocal(local);
407 const bounds = display.geometry.boundingBox!;
408 const u = Math.max(0, Math.min(1, (local.x - bounds.min.x) / (bounds.max.x - bounds.min.x)));
409 const v = Math.max(0, Math.min(1, (bounds.max.y - local.y) / (bounds.max.y - bounds.min.y)));
410 const across = display === cover ? u : (display === innerLeft ? u : 1 + u) / 2;
411 return activeLayout.rotation === Math.PI ? { x: 1 - across, y: 1 - v } : { x: across, y: v };
412 },
413 dispose() {
414 root.traverse((object) => {
415 if (object instanceof Mesh) object.geometry.dispose();
416 });
417 for (const material of materials) material.dispose();
418 },
419 };
420}