| 1 | import { |
| 2 | BoxGeometry, |
| 3 | Group, |
| 4 | Mesh, |
| 5 | MeshBasicMaterial, |
| 6 | PerspectiveCamera, |
| 7 | PlaneGeometry, |
| 8 | Texture, |
| 9 | Vector3, |
| 10 | } from "three"; |
| 11 | import { expect, it, vi } from "vite-plus/test"; |
| 12 | import { createImportedPhoneScene, disposeDeviceModel } from "./modelScene.ts"; |
| 13 | import { phoneDisplayLayout } from "./phoneScene.ts"; |
| 14 | |
| 15 | function asset() { |
| 16 | const root = new Group(); |
| 17 | const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial()); |
| 18 | body.position.z = -0.02; |
| 19 | const screen = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial()); |
| 20 | screen.geometry.translate(0, 0, 0.043); |
| 21 | screen.name = "device-screen"; |
| 22 | root.add(body, screen); |
| 23 | return { root, screen, body }; |
| 24 | } |
| 25 | |
| 26 | it.each(["portrait", "landscape_left", "landscape_right", "portrait_upside_down"] as const)( |
| 27 | "retains the imported geometry while mapping screen input and framebuffer UVs in %s", |
| 28 | (orientation) => { |
| 29 | const { root, screen } = asset(); |
| 30 | const texture = new Texture(); |
| 31 | const original = screen.material; |
| 32 | // Optimization strips UVs from the placeholder material; the viewer generates framebuffer UVs. |
| 33 | screen.geometry.deleteAttribute("uv"); |
| 34 | const phone = createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)); |
| 35 | const layout = phoneDisplayLayout({ width: 1206, height: 2622, orientation }, 1206, 2622); |
| 36 | phone.setDisplay(texture, layout); |
| 37 | phone.root.rotation.set(0.2, -0.3, 0, "YXZ"); |
| 38 | phone.orientation.rotation.z = layout.rotation; |
| 39 | phone.root.updateMatrixWorld(true); |
| 40 | const camera = new PerspectiveCamera(32, 1, 0.1, 30); |
| 41 | camera.position.z = 6; |
| 42 | camera.updateMatrixWorld(true); |
| 43 | const point = phone.orientation.localToWorld(new Vector3(-0.25, -0.55, 0.043)).project(camera); |
| 44 | const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera); |
| 45 | const expected = |
| 46 | orientation === "landscape_left" |
| 47 | ? [0.25, 0.25] |
| 48 | : orientation === "landscape_right" |
| 49 | ? [0.75, 0.75] |
| 50 | : orientation === "portrait_upside_down" |
| 51 | ? [0.75, 0.25] |
| 52 | : [0.25, 0.75]; |
| 53 | expect(hit?.x).toBeCloseTo(expected[0]!); |
| 54 | expect(hit?.y).toBeCloseTo(expected[1]!); |
| 55 | const geometry = screen.geometry; |
| 56 | const rotated = phoneDisplayLayout( |
| 57 | { width: 2622, height: 1206, orientation: "landscape_left" }, |
| 58 | 2622, |
| 59 | 1206, |
| 60 | ); |
| 61 | phone.setDisplay(texture, rotated); |
| 62 | expect(screen.geometry).toBe(geometry); |
| 63 | const pos = geometry.getAttribute("position"); |
| 64 | const uv = geometry.getAttribute("uv"); |
| 65 | for (let i = 0; i < pos.count; i++) { |
| 66 | expect(uv.getX(i)).toBeCloseTo(pos.getY(i) / 2.2 + 0.5); |
| 67 | expect(uv.getY(i)).toBeCloseTo(0.5 - pos.getX(i)); |
| 68 | } |
| 69 | phone.dispose(); |
| 70 | expect(screen.material).toBe(original); |
| 71 | disposeDeviceModel(root); |
| 72 | texture.dispose(); |
| 73 | }, |
| 74 | ); |
| 75 | |
| 76 | it("rejects touches from the rear and preserves borrowed textures when releasing imported resources", () => { |
| 77 | const { root, screen, body } = asset(); |
| 78 | const texture = new Texture(); |
| 79 | const borrowed = vi.spyOn(texture, "dispose"); |
| 80 | const ownedTexture = new Texture(); |
| 81 | const owned = vi.spyOn(ownedTexture, "dispose"); |
| 82 | body.material.map = ownedTexture; |
| 83 | const geometry = vi.spyOn(body.geometry, "dispose"); |
| 84 | const phone = createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)); |
| 85 | const camera = new PerspectiveCamera(32, 1, 0.1, 30); |
| 86 | camera.position.z = 6; |
| 87 | phone.root.rotation.y = Math.PI; |
| 88 | expect(phone.screenPoint(0.5, 0.5, camera)).toBeNull(); |
| 89 | phone.dispose(); |
| 90 | expect(borrowed).not.toHaveBeenCalled(); |
| 91 | expect(geometry).not.toHaveBeenCalled(); |
| 92 | expect(screen.material.map).toBeNull(); |
| 93 | disposeDeviceModel(root); |
| 94 | expect(owned).toHaveBeenCalledOnce(); |
| 95 | expect(geometry).toHaveBeenCalledOnce(); |
| 96 | expect(borrowed).not.toHaveBeenCalled(); |
| 97 | texture.dispose(); |
| 98 | }); |
| 99 | |
| 100 | it("rejects missing and incorrectly normalized displays before changing their materials", () => { |
| 101 | const { root, screen } = asset(); |
| 102 | const original = screen.material; |
| 103 | const texture = new Texture(); |
| 104 | screen.geometry.scale(1, 2, 1); |
| 105 | expect(() => |
| 106 | createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)), |
| 107 | ).toThrow("not normalized"); |
| 108 | expect(screen.material).toBe(original); |
| 109 | screen.geometry.scale(1, 0.5, 1); |
| 110 | root.rotation.y = Math.PI; |
| 111 | expect(() => |
| 112 | createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)), |
| 113 | ).toThrow("not normalized"); |
| 114 | expect(screen.material).toBe(original); |
| 115 | screen.name = "wrong-screen"; |
| 116 | expect(() => |
| 117 | createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)), |
| 118 | ).toThrow("one device-screen"); |
| 119 | disposeDeviceModel(root); |
| 120 | texture.dispose(); |
| 121 | }); |