1import {
2 BoxGeometry,
3 CircleGeometry,
4 CylinderGeometry,
5 ExtrudeGeometry,
6 Float32BufferAttribute,
7 Group,
8 Mesh,
9 MeshBasicMaterial,
10 MeshPhysicalMaterial,
11 MeshStandardMaterial,
12 Plane,
13 Raycaster,
14 Shape,
15 ShapeGeometry,
16 Vector2,
17 Vector3,
18 type Camera,
19 type BufferGeometry,
20 type Texture,
21} from "three";
22import type { DeviceScreenSize } from "./stream.ts";
23import { IOS_PHONE_SHAPE, type DeviceShapeProfile } from "./shapeProfile.ts";
24
25const SCREEN_HEIGHT = 2.2;
26
27function roundedPath(width: number, height: number, radius: number, path = new Shape()) {
28 const x = -width / 2;
29 const y = -height / 2;
30 path.moveTo(x + radius, y);
31 path.lineTo(x + width - radius, y);
32 path.quadraticCurveTo(x + width, y, x + width, y + radius);
33 path.lineTo(x + width, y + height - radius);
34 path.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
35 path.lineTo(x + radius, y + height);
36 path.quadraticCurveTo(x, y + height, x, y + height - radius);
37 path.lineTo(x, y + radius);
38 path.quadraticCurveTo(x, y, x + radius, y);
39 return path;
40}
41
42/** Display orientation is independent of orbit. Texture coordinates stay in raw framebuffer space. */
43export function phoneDisplayLayout(
44 screen: DeviceScreenSize | null,
45 rawWidth: number,
46 rawHeight: number,
47) {
48 const width = rawWidth || screen?.width || 900;
49 const height = rawHeight || screen?.height || 1950;
50 const landscape =
51 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
52 const rotation =
53 screen?.orientation === "landscape_left"
54 ? -Math.PI / 2
55 : screen?.orientation === "landscape_right"
56 ? Math.PI / 2
57 : screen?.orientation === "portrait_upside_down"
58 ? Math.PI
59 : 0;
60 return {
61 aspect: Math.min(width, height) / Math.max(width, height),
62 rotation,
63 landscape,
64 rawLandscape: width > height,
65 };
66}
67
68/** An original procedural device body. It makes no claim to reproduce a particular hardware model. */
69export function createPhoneScene(
70 texture: Texture,
71 layout: ReturnType<typeof phoneDisplayLayout>,
72 profile: DeviceShapeProfile = IOS_PHONE_SHAPE,
73) {
74 const { aspect } = layout;
75 const root = new Group();
76 const orientation = new Group();
77 root.add(orientation);
78 const screenWidth = SCREEN_HEIGHT * aspect;
79 const width = screenWidth + profile.bezel * 2;
80 const height = SCREEN_HEIGHT + profile.bezel * 2;
81 const backZ = 0.01 - profile.depth;
82 const metal = new MeshStandardMaterial({ color: 0xb5bcc7, metalness: 0.88, roughness: 0.27 });
83 const glass = new MeshPhysicalMaterial({
84 color: 0x141820,
85 metalness: 0.15,
86 roughness: 0.2,
87 clearcoat: 1,
88 });
89 const backMaterial = new MeshStandardMaterial({
90 color: profile.backColor,
91 metalness: 0.45,
92 roughness: 0.32,
93 });
94 const lensMaterial = new MeshPhysicalMaterial({
95 color: 0x071326,
96 metalness: 0.6,
97 roughness: 0.12,
98 clearcoat: 1,
99 });
100 const materials = [metal, glass, backMaterial, lensMaterial];
101
102 const body = new Mesh(
103 new ExtrudeGeometry(roundedPath(width, height, profile.bodyRadius), {
104 depth: profile.depth,
105 bevelEnabled: true,
106 bevelSize: 0.012,
107 bevelThickness: 0.012,
108 bevelSegments: 3,
109 steps: 1,
110 curveSegments: 12,
111 }),
112 metal,
113 );
114 body.position.z = 0.025 - profile.depth;
115 orientation.add(body);
116 const face = new Mesh(
117 new ShapeGeometry(roundedPath(width - 0.014, height - 0.014, profile.bodyRadius - 0.01), 16),
118 glass,
119 );
120 face.position.z = 0.04;
121 orientation.add(face);
122 const back = new Mesh(
123 new ShapeGeometry(roundedPath(width - 0.012, height - 0.012, profile.bodyRadius - 0.01), 16),
124 backMaterial,
125 );
126 back.name = "device-back";
127 back.rotation.y = Math.PI;
128 back.position.z = backZ;
129 orientation.add(back);
130
131 const screenGeometry = new ShapeGeometry(
132 roundedPath(screenWidth, SCREEN_HEIGHT, profile.screenRadius),
133 20,
134 );
135 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, layout);
136 const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
137 const display = new Mesh(screenGeometry, screenMaterial);
138 display.position.z = 0.043;
139 orientation.add(display);
140
141 for (const { edge, offset, length } of profile.buttons) {
142 const button = new Mesh(
143 new BoxGeometry(
144 edge === "top" ? length : 0.026,
145 edge === "top" ? 0.026 : length,
146 profile.depth * 0.65,
147 ),
148 metal,
149 );
150 button.position.set(
151 edge === "top" ? offset : (edge === "left" ? -1 : 1) * (width / 2 + 0.015),
152 edge === "top" ? height / 2 + 0.015 : offset,
153 (0.025 + backZ) / 2,
154 );
155 orientation.add(button);
156 }
157 // Rear components share a surface-relative coordinate system, with outward positive Z.
158 const rearCamera = new Group();
159 rearCamera.name = "rear-camera";
160 rearCamera.position.set(
161 width / 2 - profile.camera.insetX,
162 height / 2 - profile.camera.insetY,
163 back.position.z + 0.001,
164 );
165 rearCamera.rotation.y = Math.PI;
166 orientation.add(rearCamera);
167 const plateFront = 0.025 + 0.007;
168 const ringDepth = 0.025;
169 const cameraPlate = new Mesh(
170 new ExtrudeGeometry(
171 roundedPath(
172 profile.camera.width,
173 profile.camera.height,
174 Math.min(profile.camera.width, profile.camera.height) / 4,
175 ),
176 {
177 depth: 0.025,
178 bevelEnabled: true,
179 bevelSize: 0.009,
180 bevelThickness: 0.007,
181 bevelSegments: 2,
182 },
183 ),
184 backMaterial,
185 );
186 cameraPlate.name = "camera-plate";
187 rearCamera.add(cameraPlate);
188 for (const [x, y] of profile.camera.lenses) {
189 const radius = profile.camera.lensRadius;
190 const ring = new Mesh(new CylinderGeometry(radius + 0.014, radius + 0.014, 0.025, 32), metal);
191 ring.rotation.x = Math.PI / 2;
192 ring.name = "camera-ring";
193 ring.position.set(x, y, plateFront + ringDepth / 2 - 0.003);
194 rearCamera.add(ring);
195 const lens = new Mesh(new CircleGeometry(radius, 32), lensMaterial);
196 lens.name = "camera-lens";
197 lens.position.set(x, y, ring.position.z + ringDepth / 2 + 0.0005);
198 rearCamera.add(lens);
199 }
200 const flashMaterial = new MeshBasicMaterial({ color: 0xf2ead6 });
201 if (profile.camera.flash) {
202 const flash = new Mesh(new CircleGeometry(0.022, 20), flashMaterial);
203 flash.position.set(profile.camera.flash[0], profile.camera.flash[1], plateFront + 0.0005);
204 rearCamera.add(flash);
205 }
206
207 let activeLayout = layout;
208 const screenPoint = createDisplayProjection(
209 display,
210 orientation,
211 screenWidth,
212 SCREEN_HEIGHT,
213 () => activeLayout,
214 );
215 return {
216 root,
217 orientation,
218 width,
219 height,
220 screenPoint,
221 setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) {
222 activeLayout = nextLayout;
223 screenMaterial.map = nextTexture;
224 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, activeLayout);
225 },
226 dispose() {
227 root.traverse((object) => {
228 if (object instanceof Mesh) object.geometry.dispose();
229 });
230 for (const material of materials) material.dispose();
231 screenMaterial.dispose();
232 flashMaterial.dispose();
233 },
234 };
235}
236
237export type PhoneDisplayLayout = ReturnType<typeof phoneDisplayLayout>;
238
239/** Canonical portrait geometry maps to raw framebuffer coordinates in every OS orientation. */
240export function updateDisplayUv(
241 geometry: BufferGeometry,
242 width: number,
243 height: number,
244 layout: PhoneDisplayLayout,
245) {
246 const position = geometry.getAttribute("position");
247 if (!geometry.hasAttribute("uv")) {
248 geometry.setAttribute(
249 "uv",
250 new Float32BufferAttribute(new Float32Array(position.count * 2), 2),
251 );
252 }
253 const uv = geometry.getAttribute("uv");
254 for (let i = 0; i < position.count; i++) {
255 const u = (position.getX(i) + width / 2) / width;
256 const v = (position.getY(i) + height / 2) / height;
257 uv.setXY(
258 i,
259 layout.rawLandscape ? (layout.rotation > 0 ? 1 - v : v) : u,
260 layout.rawLandscape ? (layout.rotation > 0 ? u : 1 - u) : v,
261 );
262 }
263 uv.needsUpdate = true;
264}
265
266/** New touches hit only the front display; captured drags project onto its plane and clamp. */
267export function createDisplayProjection(
268 display: Mesh,
269 orientation: Group,
270 width: number,
271 height: number,
272 getLayout: () => PhoneDisplayLayout,
273) {
274 const raycaster = new Raycaster();
275 const pointer = new Vector2();
276 const local = new Vector3();
277 display.geometry.computeBoundingBox();
278 const z = display.position.z + (display.geometry.boundingBox?.max.z ?? 0);
279 const plane = new Plane(new Vector3(0, 0, 1), -z);
280 return (x: number, y: number, camera: Camera, captured = false) => {
281 orientation.updateWorldMatrix(true, true);
282 camera.updateMatrixWorld(true);
283 pointer.set(x * 2 - 1, 1 - y * 2);
284 raycaster.setFromCamera(pointer, camera);
285 if (!captured) {
286 const hit = raycaster.intersectObject(display, false)[0];
287 if (!hit) return null;
288 local.copy(hit.point);
289 orientation.worldToLocal(local);
290 } else {
291 const ray = raycaster.ray.clone().applyMatrix4(orientation.matrixWorld.clone().invert());
292 if (!ray.intersectPlane(plane, local)) return null;
293 }
294 const u = Math.min(1, Math.max(0, (local.x + width / 2) / width));
295 const v = Math.min(1, Math.max(0, (local.y + height / 2) / height));
296 const { rotation } = getLayout();
297 if (rotation === -Math.PI / 2) return { x: v, y: u };
298 if (rotation === Math.PI / 2) return { x: 1 - v, y: 1 - u };
299 if (rotation === Math.PI) return { x: 1 - u, y: v };
300 return { x: u, y: 1 - v };
301 };
302}