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);
}
});
}