1import {
2 BoxGeometry,
3 Group,
4 Mesh,
5 MeshBasicMaterial,
6 PerspectiveCamera,
7 PlaneGeometry,
8 Texture,
9 Vector3,
10} from "three";
11import { expect, it, vi } from "vite-plus/test";
12import { createImportedPhoneScene, disposeDeviceModel } from "./modelScene.ts";
13import { phoneDisplayLayout } from "./phoneScene.ts";
14
15function 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
26it.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
76it("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
100it("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});