Files
yawn/server/web/assets/sculpt/navigation.test.js
T
Ampandheaust 347b676cf1 Add core-powered web sculpting studio with tablet controls
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>
2026-09-28 08:53:50 +00:00

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