Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
147 lines
4.6 KiB
JavaScript
147 lines
4.6 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Navigation, axes, dragView } from "./navigation.js";
|
|
import { cameraBasis, dot } from "./math.js";
|
|
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-10, `${a} != ${b}`);
|
|
test("all six cardinal cameras and pole departures have orthonormal finite bases", () => {
|
|
for (const a of axes) {
|
|
const b = cameraBasis(a.yaw, a.pitch);
|
|
a.vector.forEach((v, k) => near(b.forward[k], v));
|
|
near(dot(b.right, b.right), 1);
|
|
near(dot(b.up, b.up), 1);
|
|
near(dot(b.right, b.up), 0);
|
|
const c = {};
|
|
dragView(c, { yaw: a.yaw, pitch: a.pitch }, "orbit", 7, -3, {
|
|
width: 800,
|
|
height: 600,
|
|
});
|
|
assert.ok(cameraBasis(c.yaw, c.pitch).up.every(Number.isFinite));
|
|
}
|
|
});
|
|
test("drag uses snapshot totals, asymmetric pan, bounded zoom and no target drift", () => {
|
|
const start = { yaw: 0, pitch: 0, zoom: 2, target: [3, 4, 5] },
|
|
c = structuredClone(start),
|
|
rect = { width: 800, height: 400 };
|
|
dragView(c, start, "pan", 30, -10, rect);
|
|
assert.deepEqual(c.target, [2.7, 3.9, 5]);
|
|
dragView(c, start, "pan", 60, 20, rect);
|
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
|
dragView(c, start, "orbit", 25, -10, rect);
|
|
near(c.yaw, -0.2);
|
|
near(c.pitch, -0.08);
|
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
|
dragView(c, start, "zoom", 0, 50, rect);
|
|
near(c.zoom, 2 * Math.exp(0.4));
|
|
dragView(c, start, "zoom", 0, 1e5, rect);
|
|
near(c.zoom, 14);
|
|
});
|
|
function fixture() {
|
|
const n = Object.create(Navigation.prototype),
|
|
captures = new Set();
|
|
let ready = true;
|
|
const rect = { left: 0, top: 0, width: 800, height: 600 },
|
|
camera = { yaw: 0.3, pitch: 0.2, zoom: 2, target: [1, 2, 3] };
|
|
n.host = {
|
|
camera,
|
|
ready: () => ready,
|
|
start() {},
|
|
changed() {},
|
|
invalidate() {},
|
|
};
|
|
n.viewport = { getBoundingClientRect: () => ({ ...rect }) };
|
|
n.root = {
|
|
setPointerCapture: (id) => captures.add(id),
|
|
hasPointerCapture: (id) => captures.has(id),
|
|
releasePointerCapture: (id) => {
|
|
captures.delete(id);
|
|
if (n.active?.id === id) n.cancel();
|
|
},
|
|
};
|
|
const event = (x = 100, y = 100, id = 1, axis) => ({
|
|
pointerId: id,
|
|
clientX: x,
|
|
clientY: y,
|
|
button: 0,
|
|
preventDefault() {},
|
|
target: {
|
|
closest: (s) =>
|
|
s === "[data-nav]"
|
|
? { dataset: { nav: "orbit" }, focus() {} }
|
|
: axis === undefined
|
|
? null
|
|
: { dataset: { axis } },
|
|
},
|
|
});
|
|
return { n, camera, rect, captures, event, ready: (v) => (ready = v) };
|
|
}
|
|
test("navigation admission, single pointer, release coordinates, cancel and viewport rollback", () => {
|
|
const { n, camera, rect, captures, event, ready } = fixture(),
|
|
before = structuredClone(camera);
|
|
ready(false);
|
|
n.down(event());
|
|
assert.equal(n.busy, false);
|
|
ready(true);
|
|
n.down(event());
|
|
n.move(event(120, 110, 2));
|
|
assert.deepEqual(camera, before);
|
|
n.up(event(125, 115));
|
|
near(camera.yaw, 0.1);
|
|
near(camera.pitch, 0.32);
|
|
assert.equal(n.busy, false);
|
|
assert.equal(captures.size, 0);
|
|
const committed = structuredClone(camera);
|
|
n.down(event());
|
|
n.move(event(180, 140));
|
|
n.cancel();
|
|
assert.deepEqual(camera, committed);
|
|
n.down(event());
|
|
n.move(event(200, 160));
|
|
rect.width = 700;
|
|
n.up(event(210, 170));
|
|
assert.deepEqual(camera, committed);
|
|
assert.equal(n.busy, false);
|
|
});
|
|
test("Escape is consumed only by an active navigation gesture", () => {
|
|
const { n, camera, captures, event, ready } = fixture();
|
|
const before = structuredClone(camera);
|
|
let prevented = 0,
|
|
stopped = 0;
|
|
const escape = {
|
|
key: "Escape",
|
|
preventDefault: () => prevented++,
|
|
stopPropagation: () => stopped++,
|
|
};
|
|
n.key(escape);
|
|
ready(false);
|
|
n.key(escape);
|
|
assert.equal(prevented, 0);
|
|
assert.equal(stopped, 0);
|
|
assert.deepEqual(camera, before);
|
|
ready(true);
|
|
n.down(event());
|
|
n.move(event(180, 140));
|
|
n.key(escape);
|
|
n.up(event(200, 160));
|
|
assert.deepEqual(camera, before);
|
|
assert.equal(n.busy, false);
|
|
assert.equal(captures.size, 0);
|
|
assert.equal(prevented, 1);
|
|
assert.equal(stopped, 1);
|
|
});
|
|
test("axis clicks snap/flip without changing zoom or target; drags never snap", () => {
|
|
const { n, camera, event } = fixture();
|
|
n.down(event(100, 100, 1, "0"));
|
|
n.up(event(101, 101));
|
|
near(camera.yaw, Math.PI / 2);
|
|
near(camera.pitch, 0);
|
|
n.down(event(100, 100, 1, "0"));
|
|
n.up(event(100, 100));
|
|
near(camera.yaw, -Math.PI / 2);
|
|
assert.deepEqual(camera.target, [1, 2, 3]);
|
|
near(camera.zoom, 2);
|
|
n.down(event(100, 100, 1, "1"));
|
|
n.up(event(140, 115));
|
|
near(camera.yaw, -Math.PI / 2 - 0.32);
|
|
near(camera.pitch, 0.12);
|
|
});
|