Files
yawn/server/web/assets/sculpt/input.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

457 lines
15 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { Input } from "./input.js";
import { Sculpt, defaults } from "./engine.js";
import { sphere, updateNormals } from "./mesh.js";
import { Renderer } from "./renderer.js";
import { StrokeClient } from "./stroke-client.js";
import { StrokeProcessor } from "./stroke-worker.js";
import { Mailbox } from "./mailbox.js";
// Exercise the real client protocol, with frame delivery under test control.
// Structured clone/transfer and asynchronous messages match worker ownership.
class ControlledWorker {
postMessage(data, transfer = []) {
const copy = structuredClone(data, { transfer });
queueMicrotask(() => {
if (this.dead) return;
if (copy.type === "init") {
this.processor = new StrokeProcessor(
sphere(copy.level),
new Mailbox(copy.row),
(message, transfers = []) => {
const value = structuredClone(message, { transfer: transfers });
queueMicrotask(() => {
if (!this.dead) this.onmessage({ data: value });
});
},
() => 0,
);
this.onmessage({ data: { type: "ready" } });
} else if (copy.type === "begin") this.processor.begin(copy);
else if (copy.type === "end") this.processor.end(copy.id, copy.version);
else if (copy.type === "cancel") this.processor.cancel(copy.id);
else if (copy.type === "ack") this.processor.acknowledge(copy.packet);
});
}
terminate() {
this.dead = true;
}
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function setup(t) {
const oldWorker = globalThis.Worker,
oldWindow = globalThis.window;
globalThis.Worker = ControlledWorker;
globalThis.window = { addEventListener() {} };
t.after(() => {
globalThis.Worker = oldWorker;
globalThis.window = oldWindow;
client.dispose();
});
const row = { buffer: new SharedArrayBuffer(64), descriptor: { offset: 0 } };
row.share = () => ({ buffer: row.buffer, descriptor: row.descriptor });
const client = new StrokeClient(row, 4, (error) => {
throw error;
});
await client.ready;
const engine = new Sculpt(sphere(4)),
listeners = new Map();
let commits = 0,
message = "";
const rect = { left: 0, top: 0, width: 800, height: 600 };
const captures = new Set();
const viewport = {
canvas: { tagName: "CANVAS" },
querySelector() {
return this.canvas;
},
addEventListener: (name, fn) => listeners.set(name, fn),
focus() {},
setPointerCapture(id) {
captures.add(id);
},
hasPointerCapture(id) {
return captures.has(id);
},
releasePointerCapture(id) {
captures.delete(id);
listeners.get("lostpointercapture")({ pointerId: id });
},
getBoundingClientRect: () => ({ ...rect }),
};
const host = {
engine: () => engine,
processor: () => client,
error: (e) => {
throw e;
},
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
settings: { ...defaults, symmetry: false },
ready: () => true,
navigation: () => "sculpt",
palmRejection: () => false,
schedule() {},
invalidate() {},
changed() {},
committed() {
commits++;
},
message(text) {
message = text;
},
cursor() {},
pressure() {},
};
const input = new Input(viewport, host);
const event = (x = 400, y = 300, type = "pen", pressure = 0.7, id = 1) => ({
clientX: x,
clientY: y,
pointerType: type,
pressure,
pointerId: id,
button: 0,
target: viewport.canvas,
preventDefault() {},
});
const frame = async () => {
await flush();
client.worker.processor.frame();
await flush();
};
const finish = async () => {
let count = 0;
while (input.transaction) {
await frame();
assert.ok(++count < 1000);
}
};
return {
input,
engine,
host,
client,
viewport,
captures,
event,
rect,
listeners,
frame,
finish,
get commits() {
return commits;
},
get message() {
return message;
},
};
}
test("palm rejection ignores touch before capture, focus, cursor or geometry; pen still works", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.host.palmRejection = () => true;
a.viewport.focus =
a.host.cursor =
a.host.pressure =
() => {
throw Error("Rejected palm reached UI");
};
a.input.down(a.event(400, 300, "touch"));
a.input.move(a.event(460, 330, "touch"));
a.input.up(a.event(460, 330, "touch"));
assert.equal(a.captures.size, 0);
assert.equal(a.input.busy, false);
assert.deepEqual(a.engine.mesh.positions, before);
a.viewport.focus = a.host.cursor = a.host.pressure = () => {};
a.input.down(a.event());
a.input.up(a.event());
await a.finish();
assert.notDeepEqual(a.engine.mesh.positions, before);
});
test("pen navigation snapshots rotate/pan/zoom without sculpting", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.host.palmRejection = () => true;
for (const mode of ["rotate", "pan", "zoom"]) {
a.host.camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] };
a.host.navigation = () => mode;
a.input.down(a.event());
a.host.navigation = () => "sculpt";
a.input.move(a.event(440, 320));
a.input.up(a.event(440, 320));
const c = a.host.camera;
assert.equal(c.yaw, mode === "rotate" ? -0.32 : 0);
assert.equal(c.pitch, mode === "rotate" ? 0.16 : 0);
assert.deepEqual(c.target, mode === "pan" ? [-0.2, 0.1, 0] : [0, 0, 0]);
assert.equal(c.zoom, mode === "zoom" ? 1.5 * Math.exp(0.16) : 1.5);
assert.equal(a.input.busy, false);
}
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 0);
});
test("zero pen is a no-op; release flushes newest mouse movement and commits once", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.input.down(a.event(400, 300, "pen", 0));
a.input.up(a.event());
await a.finish();
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 0);
a.input.down(a.event(400, 300, "mouse", 0));
a.input.move(a.event(520, 300, "mouse", 0));
a.input.up(a.event(520, 300, "mouse", 0));
assert.equal(a.engine.undoStack.length, 0);
await a.finish();
assert.notDeepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 1);
assert.equal(a.commits, 2);
a.input.up(a.event());
assert.equal(a.commits, 2);
});
test("hundreds of moves occupy one slot, no main-thread dabs or per-frame BVH maintenance", async (t) => {
const a = await setup(t);
let changed = 0;
const original = a.engine.changed.bind(a.engine);
a.engine.changed = (ids) => {
changed++;
original(ids);
};
a.engine.dab = () => assert.fail("main thread performed a dab");
a.input.down(a.event());
for (let i = 0; i < 1000; i++) a.input.move(a.event(400 + i / 10, 310));
assert.equal(a.input.queue, undefined);
assert.equal(a.client.mailbox.words.byteLength, 64);
await a.frame();
await a.frame();
assert.equal(changed, 0);
a.input.up(a.event());
await a.finish();
assert.equal(changed, 1);
assert.equal(a.engine.undoStack.length, 1);
});
test("cancel rejects late geometry and rolls back without another animation frame", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.input.down(a.event());
await a.frame();
assert.notDeepEqual(a.engine.mesh.positions, before);
a.input.move(a.event(480, 310));
await flush();
a.client.worker.processor.frame(); // geometry message is now in flight
a.input.cancel();
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.input.busy, true);
await flush();
assert.equal(a.input.busy, false);
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 0);
});
test("cancel wins over a completion that has not yet committed on main", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.input.down(a.event());
await a.frame();
a.input.up(a.event());
await flush();
a.client.worker.processor.frame();
a.input.cancel();
await flush();
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 0);
assert.equal(a.commits, 0);
});
test("touch takeover, pen palm rejection, capture loss and pen-over-touch handoff", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.input.down(a.event(400, 300, "touch", 1, 1));
await a.frame();
a.input.down(a.event(450, 320, "touch", 1, 2));
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.input.active.mode, "gesture");
a.input.move(a.event(500, 340, "touch", 1, 2));
assert.notEqual(a.host.camera.zoom, 1.5);
a.input.up(a.event(400, 300, "touch", 0, 1));
a.input.up(a.event(500, 340, "touch", 0, 2));
await flush();
assert.equal(a.input.busy, false);
a.input.down(a.event(400, 300, "pen", 0.7, 3));
a.input.down(a.event(440, 310, "touch", 1, 4));
assert.equal(a.input.active.id, 3);
await a.frame();
a.listeners.get("lostpointercapture")({ pointerId: 3 });
await flush();
assert.equal(a.input.busy, false);
a.input.down(a.event(400, 300, "touch", 1, 5));
await a.frame();
a.input.down(a.event(400, 300, "pen", 0.8, 6));
await flush();
assert.equal(a.input.active.id, 6);
a.input.up(a.event(400, 300, "pen", 0, 6));
await a.finish();
assert.equal(a.engine.undoStack.length, 1);
});
test("resize before release, geometry delivery, or completion preserves existing undo and redo", async (t) => {
const a = await setup(t);
for (let i = 0; i < 2; i++) {
a.input.down(a.event(400 + i * 20, 300));
a.input.up(a.event());
await a.finish();
}
a.engine.restore();
const before = a.engine.mesh.positions.slice(),
undo = [...a.engine.undoStack],
redo = [...a.engine.redoStack];
for (const mode of ["release", "packet", "complete", "grab"]) {
a.host.settings.brush = mode === "grab" ? "grab" : "draw";
a.input.down(a.event());
a.input.move(a.event(440, 320));
await a.frame();
await a.frame();
assert.notDeepEqual(a.engine.mesh.positions, before);
if (mode === "complete") a.input.up(a.event());
a.rect.width = 650;
if (mode === "packet") {
a.input.move(a.event(470, 330));
} else a.input.up(a.event());
await a.finish();
assert.deepEqual(a.engine.mesh.positions, before);
assert.deepEqual(a.engine.undoStack, undo);
assert.deepEqual(a.engine.redoStack, redo);
a.rect.width = 800;
}
});
test("commits repair face-normal caches; undo/redo and adjacent strokes agree with full normals", async (t) => {
const a = await setup(t);
for (const x of [400, 430, 450]) {
a.input.down(a.event(x, 300));
a.input.move(a.event(x + 35, 330));
a.input.up(a.event());
await a.finish();
const expected = sphere(4);
expected.positions.set(a.engine.mesh.positions);
updateNormals(expected);
assert.deepEqual(a.engine.mesh.faceNormals, expected.faceNormals);
assert.deepEqual(a.engine.mesh.normals, expected.normals);
}
for (const redo of [false, true]) {
a.engine.restore(redo);
const expected = sphere(4);
expected.positions.set(a.engine.mesh.positions);
updateNormals(expected);
assert.deepEqual(a.engine.mesh.normals, expected.normals);
}
});
test("worker termination rolls back provisional geometry and late packets cannot revive it", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
a.input.down(a.event());
await a.frame();
a.client.dispose();
await flush();
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.input.busy, false);
assert.equal(a.engine.undoStack.length, 0);
});
test("pending pen termination and stale canvas cannot revive a canceled contact", async (t) => {
const a = await setup(t),
before = a.engine.mesh.positions.slice();
for (const termination of [
"pointercancel",
"lostpointercapture",
"pointerup",
"resize",
"canvas",
"unready",
"continuation",
]) {
a.input.down(a.event(400, 300, "touch", 1, 1));
await a.frame();
const pen = a.event(400, 300, "pen", 0.8, 2);
let prevented = false;
pen.preventDefault = () => {
prevented = true;
};
a.input.down(pen);
assert.ok(prevented);
assert.ok(a.captures.has(2));
const id = a.client.nextId;
if (termination === "pointerup") a.input.up(a.event(400, 300, "pen", 0, 2));
else if (termination === "resize") a.rect.left = 10;
else if (termination === "canvas")
a.viewport.canvas = { tagName: "CANVAS" };
else if (termination === "unready") a.host.ready = () => false;
else if (termination === "continuation") {
a.client.message({ type: "canceled", id: a.client.job.id });
a.listeners.get("pointercancel")({ pointerId: 2 });
} else a.listeners.get(termination)({ pointerId: 2 });
await flush();
assert.equal(a.client.nextId, id);
assert.equal(a.input.busy, false);
assert.equal(a.captures.size, 0);
assert.equal(a.engine.stroke, null);
assert.deepEqual(a.engine.mesh.positions, before);
assert.equal(a.engine.undoStack.length, 0);
a.rect.left = 0;
a.host.ready = () => true;
}
// A late old-touch cancellation cannot take ownership from the live pen.
a.input.down(a.event(400, 300, "touch", 1, 1));
await a.frame();
a.input.down(a.event(400, 300, "pen", 0.8, 2));
a.listeners.get("pointercancel")({ pointerId: 1 });
await flush();
assert.equal(a.input.active.id, 2);
a.input.up(a.event(400, 300, "pen", 0, 2));
await a.finish();
assert.equal(a.engine.undoStack.length, 1);
// DOM controls never acquire canvas capture or prevent touch defaults.
let prevented = false;
a.input.down({
...a.event(),
pointerType: "touch",
target: { tagName: "BUTTON" },
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, false);
assert.equal(a.captures.size, 0);
});
test("renderer staging remains immutable while upload is in flight", async () => {
const s = new Sculpt(sphere(4)),
r = new Renderer();
r.mesh = s.mesh;
r.canvas = { width: 800, height: 600 };
r.vertices = { view: new Float32Array((s.mesh.positions.length / 3) * 8) };
r.camera = { view: new Float32Array(20) };
const waits = [];
r.core = {
syncRows: () =>
new Promise((resolve, reject) => waits.push({ resolve, reject })),
};
const camera = {
yaw: 0,
pitch: 0,
zoom: 1.5,
target: [0, 0, 0],
color: [0.5, 0.3, 0.2],
};
const pending = r.publish(s, camera),
staged = r.vertices.view.slice();
s.begin({});
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
s.end();
assert.equal(await r.publish(s, camera), false);
assert.deepEqual(r.vertices.view, staged);
waits.shift().resolve();
await Promise.resolve();
waits.shift().resolve();
await pending;
assert.ok(s.dirty.size > 0);
const failure = r.publish(s, camera);
waits.shift().reject(new Error("upload"));
await assert.rejects(failure, /upload/);
assert.equal(r.busy, false);
});