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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user