1import {
2 BoxGeometry,
3 Group,
4 Mesh,
5 MeshBasicMaterial,
6 PerspectiveCamera,
7 PlaneGeometry,
8 Texture,
9 Vector3,
10} from "three";
11import { expect, it } from "vite-plus/test";
12import { createDuoScene, duoDisplayKey, duoRawPoint } from "./duoScene.ts";
13
14function fixture() {
15 const asset = new Group();
16 const left = new Group();
17 left.name = "left-half";
18 const right = new Group();
19 right.name = "right-half";
20 const screen = (name: string, x: number, z: number, rear = false) => {
21 const mesh = new Mesh(new PlaneGeometry(1, 2), new MeshBasicMaterial());
22 if (rear) mesh.geometry.rotateY(Math.PI);
23 mesh.geometry.translate(x, 0, z);
24 mesh.name = name;
25 return mesh;
26 };
27 const innerLeft = screen("inner-display-left", -0.5, 0.06);
28 const innerRight = screen("inner-display-right", 0.5, 0.06);
29 const cover = screen("cover-display", -0.5, -0.06, true);
30 left.add(innerLeft, cover);
31 right.add(innerRight);
32 for (const [group, x] of [
33 [left, -0.5],
34 [right, 0.5],
35 ] as const) {
36 const body = new Mesh(new BoxGeometry(1, 2, 0.1), new MeshBasicMaterial());
37 body.geometry.translate(x, 0, 0);
38 group.add(body);
39 }
40 asset.add(left, right);
41 const scene = createDuoScene(asset, { 1: new Texture(), 3: new Texture() });
42 const camera = new PerspectiveCamera(36, 1, 0.1, 50);
43 camera.position.z = 6;
44 camera.updateMatrixWorld();
45 return { scene, camera, left, right, innerLeft, innerRight };
46}
47
48it("keeps one continuous inner UV map across the hinge and folds opposite leaves", () => {
49 const { scene, left, right, innerLeft, innerRight } = fixture();
50 scene.setAngle(90);
51 expect(left.rotation.y).toBeCloseTo(Math.PI / 4);
52 expect(right.rotation.y).toBeCloseTo(-Math.PI / 4);
53 const values = (mesh: Mesh) =>
54 Array.from({ length: mesh.geometry.getAttribute("uv").count }, (_, i) =>
55 mesh.geometry.getAttribute("uv").getX(i),
56 );
57 expect(Math.max(...values(innerLeft))).toBe(0.5);
58 expect(Math.min(...values(innerRight))).toBe(0.5);
59 scene.dispose();
60});
61
62it("derives resting views from the hinged display planes independently of the inspection orbit", () => {
63 const { scene } = fixture();
64 scene.setAngle(90);
65 const frames = scene.restFrames(3);
66 expect(frames.map((frame) => frame.face)).toEqual(["inside", "left", "right"]);
67 expect(frames[1]!.normal.x).toBeGreaterThan(0.5);
68 expect(frames[2]!.normal.x).toBeLessThan(-0.5);
69 scene.root.rotation.set(0.5, 1.2, -0.7);
70 scene.root.position.set(3, -2, 1);
71 const rotated = scene.restFrames(3);
72 for (let index = 0; index < frames.length; index++) {
73 expect(rotated[index]!.normal.distanceTo(frames[index]!.normal)).toBeLessThan(1e-6);
74 expect(rotated[index]!.center.distanceTo(frames[index]!.center)).toBeLessThan(1e-6);
75 }
76 scene.setAngle(180);
77 expect(scene.restFrames(3).map((frame) => frame.face)).toEqual(["inside"]);
78 expect(scene.restFrames(1).map((frame) => frame.face)).toEqual(["cover"]);
79 scene.dispose();
80});
81
82it("maps active display input through hardware mounting and blocks rear, inactive and stale surfaces", () => {
83 const { scene, camera, innerLeft } = fixture();
84 scene.setAngle(180);
85 const screen = { width: 2007, height: 2853, orientation: "portrait" as const, screenId: 3 };
86 const key = duoDisplayKey(screen);
87 scene.root.updateMatrixWorld();
88 const world = innerLeft.localToWorld(new Vector3(-0.5, -0.5, 0.06)).project(camera);
89 const x = (world.x + 1) / 2,
90 y = (1 - world.y) / 2;
91 const hit = scene.screenPoint(x, y, camera, screen, key);
92 expect(hit?.x).toBeCloseTo(0.75);
93 expect(hit?.y).toBeCloseTo(0.75);
94 expect(
95 scene.screenPoint(
96 x,
97 y,
98 camera,
99 { ...screen, screenId: 1 },
100 duoDisplayKey({ ...screen, screenId: 1 }),
101 ),
102 ).toBeNull();
103 expect(scene.screenPoint(x, y, camera, screen, "old")).toBeNull();
104 expect(scene.screenPoint(x, y, camera, screen, key)).not.toBeNull();
105 expect(scene.screenPoint(x, 1.1, camera, screen, key, true)?.x).toBeGreaterThan(1);
106 expect(duoRawPoint(1, 0.2, 0.7)).toEqual({ x: 0.2, y: 0.7 });
107 scene.dispose();
108});