Skip to content
Source
Sign inSign up
Open History to see the last commit

androidFoldScene.ts

402 lines12.8 KB

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