controlsTests.ts

test/gpu/controlsTests.ts · raw · all examples · site home

Exports: runControlsTests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// CameraControls driven by synthetic events: targets, mode switches, keys,
// drags, the double-press slide, zoom limits and saved settings
import * as THREE from "three";
import { CameraControls } from "@sparkjsdev/radix";

type Check = (name: string, fn: () => Promise<string | undefined | "skip">) => Promise<void>;

const STORAGE_KEY = "radix-gpu-tests";
const frame = () => new Promise((resolve) => setTimeout(resolve, 16));
const near = (a: THREE.Vector3, b: THREE.Vector3, tolerance = 1e-4) => a.distanceTo(b) < tolerance;

// Controls of a fresh camera at (0, 0, 5) facing the origin: the defaults
// without inertia or inversions, whatever this browser saved (set on the
// settings directly, so nothing is saved)
function make() {
  const canvas = document.createElement("canvas");
  const camera = new THREE.PerspectiveCamera();
  camera.position.set(0, 0, 5);
  const controls = new CameraControls({ canvas, object: camera });
  controls.settings = {
    ...CameraControls.DEFAULTS, invertLookX: false, invertLookY: false, inertia: { look: 0, move: 0, pointerLook: 0, pointerMove: 0 },
  };
  controls.lookAt(new THREE.Vector3());
  return { canvas, camera, controls };
}

function saved(storageKey: string) {
  return new CameraControls({ canvas: document.createElement("canvas"), object: new THREE.Object3D(), storageKey });
}

function pointer(canvas: HTMLCanvasElement, type: string, x: number, y: number, button = 0) {
  canvas.dispatchEvent(new PointerEvent(type, { pointerId: 1, pointerType: "mouse", button, clientX: x, clientY: y, bubbles: true }));
}

export async function runControlsTests(check: Check) {
  await check("CameraControls lookAt and getTarget agree", async () => {
    const { camera, controls } = make();
    try {
      const target = new THREE.Vector3(1, 2, -3);
      controls.lookAt(target);
      const forward = camera.getWorldDirection(new THREE.Vector3());
      const expected = target.clone().sub(camera.position).normalize();
      return near(controls.getTarget(), target) && near(forward, expected) ? undefined : `target ${controls.getTarget().toArray()}`;
    } finally {
      controls.dispose();
    }
  });

  await check("CameraControls switching fly / orbit keeps the pose", async () => {
    const { camera, controls } = make();
    try {
      controls.update();
      const position = camera.position.clone();
      const quaternion = camera.quaternion.clone();
      controls.settings.mode = "orbit";
      await frame();
      controls.update();
      controls.settings.mode = "fly";
      await frame();
      controls.update();
      return near(camera.position, position) && camera.quaternion.angleTo(quaternion) < 1e-5 ? undefined : `moved to ${camera.position.toArray()}`;
    } finally {
      controls.dispose();
    }
  });

  await check("CameraControls keys move, drags turn and orbit, a double press slides", async () => {
    const { canvas, camera, controls } = make();
    try {
      controls.update();
      document.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
      for (let i = 0; i < 5; i += 1) {
        await frame();
        controls.update();
      }
      document.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" }));
      if (!(camera.position.z < 4.99) || Math.abs(camera.position.x) > 1e-6) {
        return `W moved to ${camera.position.toArray()}`;
      }

      const position = camera.position.clone();
      const quaternion = camera.quaternion.clone();
      pointer(canvas, "pointerdown", 100, 100);
      pointer(canvas, "pointermove", 200, 100);
      await frame();
      controls.update();
      pointer(canvas, "pointerup", 200, 100);
      const yaw = new THREE.Euler().setFromQuaternion(camera.quaternion, "YXZ").y;
      if (!near(camera.position, position) || !(yaw < -0.2)) {
        return `fly drag: yaw ${yaw}, position ${camera.position.toArray()}`;
      }

      // A second press soon after the first slides forward on an upward drag
      const turned = camera.quaternion.clone();
      const before = camera.position.clone();
      pointer(canvas, "pointerdown", 100, 200);
      pointer(canvas, "pointermove", 100, 100);
      await frame();
      controls.update();
      pointer(canvas, "pointerup", 100, 100);
      const forward = camera.getWorldDirection(new THREE.Vector3());
      const step = camera.position.clone().sub(before);
      if (camera.quaternion.angleTo(turned) > 1e-6 || !(step.dot(forward) > 0.1)) {
        return `slide: step ${step.toArray()}`;
      }

      // Orbit: a drag keeps the distance to the target (after the slide's double press expires)
      await new Promise((resolve) => setTimeout(resolve, controls.settings.doublePress + 50));
      camera.quaternion.copy(quaternion);
      controls.settings.mode = "orbit";
      const target = controls.getTarget();
      const distance = controls.distance;
      pointer(canvas, "pointerdown", 100, 100);
      pointer(canvas, "pointermove", 180, 140);
      await frame();
      controls.update();
      pointer(canvas, "pointerup", 180, 140);
      const radius = camera.position.distanceTo(target);
      return near(controls.getTarget(), target) && Math.abs(radius - distance) < 1e-4 && camera.quaternion.angleTo(quaternion) > 0.1
        ? undefined : `orbit: radius ${radius} of ${distance}, target ${controls.getTarget().toArray()}`;
    } finally {
      controls.dispose();
    }
  });

  await check("CameraControls a released orbit drag glides, also after a short pause, not after a long one", async () => {
    const { canvas, camera, controls } = make();
    try {
      controls.settings = { ...controls.settings, mode: "orbit", inertia: { ...controls.settings.inertia, pointerLook: 0.15 } };
      controls.update();
      // A drag of 5 moves a frame apart, held `pause` ms, released; the
      // turn in the 150 ms after the release
      const glide = async (pause: number) => {
        pointer(canvas, "pointerdown", 100, 100);
        for (let i = 1; i <= 5; i += 1) {
          await frame();
          pointer(canvas, "pointermove", 100 + i * 20, 100);
          controls.update();
        }
        const held = performance.now();
        while (performance.now() - held < pause) {
          await frame();
          controls.update();
        }
        pointer(canvas, "pointerup", 200, 100);
        const released = camera.quaternion.clone();
        const start = performance.now();
        while (performance.now() - start < 150) {
          await frame();
          controls.update();
        }
        await new Promise((resolve) => setTimeout(resolve, controls.settings.doublePress));
        return camera.quaternion.angleTo(released);
      };
      const [quick, short, long] = [await glide(0), await glide(40), await glide(250)];
      return quick > 0.05 && short > 0.02 && long < 1e-6 ? undefined : `turned after release: ${quick.toFixed(3)} (no pause), ${short.toFixed(3)} (40 ms), ${long.toFixed(4)} (250 ms)`;
    } finally {
      controls.dispose();
    }
  });

  await check("CameraControls orbit zoom stays within the distance limits", async () => {
    const { canvas, camera, controls } = make();
    try {
      controls.settings = { ...controls.settings, mode: "orbit", minDistance: 1, maxDistance: 10 };
      const target = controls.getTarget();
      controls.update();
      const zoom = async (deltaY: number) => {
        canvas.dispatchEvent(new WheelEvent("wheel", { deltaY, cancelable: true }));
        await frame();
        controls.update();
        return camera.position.distanceTo(target);
      };
      const nearest = await zoom(-100000);
      const farthest = await zoom(100000);
      return Math.abs(nearest - 1) < 1e-4 && Math.abs(farthest - 10) < 1e-4 && near(controls.getTarget(), target) ? undefined : `distances ${nearest}, ${farthest}`;
    } finally {
      controls.dispose();
    }
  });

  await check("CameraControls saves page settings per storageKey and resets them", async () => {
    const key = `radix-controls:${STORAGE_KEY}`;
    const first = saved(STORAGE_KEY);
    try {
      first.set({ moveSpeed: 7, mode: "orbit" });
      const stored = JSON.parse(localStorage.getItem(key) ?? "{}") as Record<string, unknown>;
      const second = saved(STORAGE_KEY);
      const loaded = second.settings.moveSpeed;
      second.dispose();
      first.reset();
      const third = saved(STORAGE_KEY);
      const reset = third.settings.moveSpeed;
      third.dispose();
      return stored.moveSpeed === 7 && stored.mode === "orbit" && loaded === 7 && reset === CameraControls.DEFAULTS.moveSpeed && localStorage.getItem(key) === null
        ? undefined : `saved ${JSON.stringify(stored)}, loaded ${loaded}, after reset ${reset}`;
    } finally {
      first.dispose();
      localStorage.removeItem(key);
    }
  });
}