1import { Box3, PerspectiveCamera, Texture, Vector3 } from "three";
2import { describe, expect, it } from "vite-plus/test";
3import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts";
4import { IOS_TABLET_SHAPE, IOS_PHONE_SHAPE, ANDROID_PHONE_SHAPE } from "./shapeProfile.ts";
5
6const orientations = [
7 "portrait",
8 "landscape_left",
9 "landscape_right",
10 "portrait_upside_down",
11] as const;
12
13describe("3D phone input", () => {
14 it.each([IOS_TABLET_SHAPE, IOS_PHONE_SHAPE, ANDROID_PHONE_SHAPE])(
15 "preserves input projection for the $id silhouette in every orientation",
16 (profile) => {
17 for (const orientation of orientations) {
18 const texture = new Texture();
19 const aspect = profile.id === "ios-tablet" ? 0.75 : 0.45;
20 const layout = phoneDisplayLayout(
21 { width: 1200 * aspect, height: 1200, orientation },
22 1200 * aspect,
23 1200,
24 );
25 const phone = createPhoneScene(texture, layout, profile);
26 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
27 camera.position.z = 6;
28 camera.updateMatrixWorld(true);
29 phone.root.rotation.set(0.2, -0.3, 0, "YXZ");
30 phone.orientation.rotation.z = layout.rotation;
31 phone.root.updateMatrixWorld(true);
32 const point = new Vector3((-2.2 * aspect) / 4, -2.2 / 4, 0.043);
33 phone.orientation.localToWorld(point);
34 point.project(camera);
35 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
36 const expected =
37 orientation === "landscape_left"
38 ? { x: 0.25, y: 0.25 }
39 : orientation === "landscape_right"
40 ? { x: 0.75, y: 0.75 }
41 : orientation === "portrait_upside_down"
42 ? { x: 0.75, y: 0.25 }
43 : { x: 0.25, y: 0.75 };
44 expect(hit?.x).toBeCloseTo(expected.x);
45 expect(hit?.y).toBeCloseTo(expected.y);
46 phone.dispose();
47 texture.dispose();
48 }
49 },
50 );
51 it.each([0.6, 2.2])("keeps device coordinates accurate at %s camera zoom", (zoom) => {
52 const texture = new Texture();
53 const layout = phoneDisplayLayout(null, 900, 1950);
54 const phone = createPhoneScene(texture, layout);
55 const camera = new PerspectiveCamera(32, 0.65, 0.1, 30);
56 camera.position.z = 6 / zoom;
57 camera.updateMatrixWorld(true);
58 phone.root.rotation.set(0.2, -0.3, 0, "YXZ");
59 phone.root.updateMatrixWorld(true);
60 const point = new Vector3((-2.2 * layout.aspect) / 4, -2.2 / 4, 0.043);
61 phone.orientation.localToWorld(point);
62 point.project(camera);
63 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
64 expect(hit?.x).toBeCloseTo(0.25);
65 expect(hit?.y).toBeCloseTo(0.75);
66 phone.dispose();
67 texture.dispose();
68 });
69
70 it.each(orientations)("maps perspective hits to displayed coordinates in %s", (orientation) => {
71 const texture = new Texture();
72 const layout = phoneDisplayLayout({ width: 1170, height: 2532, orientation }, 1170, 2532);
73 const phone = createPhoneScene(texture, layout);
74 const camera = new PerspectiveCamera(32, 0.65, 0.1, 30);
75 camera.position.z = 6;
76 camera.updateMatrixWorld(true);
77 phone.root.rotation.set(0.3, -0.45, 0, "YXZ");
78 phone.orientation.rotation.z = layout.rotation;
79 phone.root.updateMatrixWorld(true);
80 // A point one quarter across and three quarters down the portrait display.
81 const point = new Vector3((-2.2 * layout.aspect) / 4, -2.2 / 4, 0.043);
82 phone.orientation.localToWorld(point);
83 point.project(camera);
84 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
85 const expected =
86 orientation === "landscape_left"
87 ? { x: 0.25, y: 0.25 }
88 : orientation === "landscape_right"
89 ? { x: 0.75, y: 0.75 }
90 : orientation === "portrait_upside_down"
91 ? { x: 0.75, y: 0.25 }
92 : { x: 0.25, y: 0.75 };
93 expect(hit?.x).toBeCloseTo(expected.x);
94 expect(hit?.y).toBeCloseTo(expected.y);
95 phone.dispose();
96 texture.dispose();
97 });
98
99 it("rejects new gestures outside the display, but clamps captured drags at its edge", () => {
100 const texture = new Texture();
101 const phone = createPhoneScene(texture, phoneDisplayLayout(null, 900, 1950));
102 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
103 camera.position.z = 5;
104 expect(phone.screenPoint(1, 0.5, camera)).toBeNull();
105 expect(phone.screenPoint(1, 0.5, camera, true)).toEqual({ x: 1, y: 0.5 });
106 phone.root.rotation.y = Math.PI;
107 expect(phone.screenPoint(0.5, 0.5, camera)).toBeNull();
108 phone.dispose();
109 texture.dispose();
110 });
111
112 it.each(["landscape_left", "landscape_right"] as const)(
113 "unrotates an already-landscape framebuffer in %s",
114 (orientation) => {
115 const texture = new Texture();
116 const layout = phoneDisplayLayout({ width: 1950, height: 900, orientation }, 1950, 900);
117 const phone = createPhoneScene(texture, layout);
118 phone.orientation.rotation.z = layout.rotation;
119 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
120 camera.position.z = 5;
121 expect(phone.screenPoint(0.5, 0.5, camera)).toEqual({ x: 0.5, y: 0.5 });
122 phone.dispose();
123 texture.dispose();
124 },
125 );
126});
127
128it.each([IOS_PHONE_SHAPE, IOS_TABLET_SHAPE, ANDROID_PHONE_SHAPE])(
129 "attaches the $id rear camera plate to the back and seats its lenses on the housing",
130 (profile) => {
131 const texture = new Texture();
132 const phone = createPhoneScene(texture, phoneDisplayLayout(null, 900, 1950), profile);
133 phone.root.updateMatrixWorld(true);
134 const back = new Box3().setFromObject(phone.root.getObjectByName("device-back")!);
135 const plate = new Box3().setFromObject(phone.root.getObjectByName("camera-plate")!);
136 expect(plate.min.z).toBeLessThan(back.min.z);
137 expect(plate.max.z).toBeGreaterThanOrEqual(back.min.z);
138 const assembly = phone.root.getObjectByName("rear-camera")!;
139 const rings = assembly.children.filter((part) => part.name === "camera-ring");
140 const lenses = assembly.children.filter((part) => part.name === "camera-lens");
141 for (const [index, ring] of rings.entries()) {
142 const ringBounds = new Box3().setFromObject(ring);
143 expect(ringBounds.intersectsBox(plate)).toBe(true);
144 const lensBounds = new Box3().setFromObject(lenses[index]!);
145 expect(Math.abs(lensBounds.max.z - ringBounds.min.z)).toBeLessThan(0.001);
146 }
147 phone.dispose();
148 texture.dispose();
149 },
150);