| 1 | import { Box3, PerspectiveCamera, Texture, Vector3 } from "three"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts"; |
| 4 | import { IOS_TABLET_SHAPE, IOS_PHONE_SHAPE, ANDROID_PHONE_SHAPE } from "./shapeProfile.ts"; |
| 5 | |
| 6 | const orientations = [ |
| 7 | "portrait", |
| 8 | "landscape_left", |
| 9 | "landscape_right", |
| 10 | "portrait_upside_down", |
| 11 | ] as const; |
| 12 | |
| 13 | describe("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 | |
| 128 | it.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 | ); |