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,217 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { Mailbox } from "./mailbox.js";
|
||||
import { StrokeProcessor } from "./stroke-worker.js";
|
||||
import { Sculpt, defaults, StrokePath } from "./engine.js";
|
||||
import { sphere } from "./mesh.js";
|
||||
|
||||
const row = () => ({
|
||||
buffer: new SharedArrayBuffer(128),
|
||||
descriptor: { offset: 64 },
|
||||
});
|
||||
test("mailbox overwrites one slot, keeps a newer publication pending, wraps and rejects torn reads", (t) => {
|
||||
const m = new Mailbox(row()),
|
||||
p = { x: 12, y: -8, pressure: 0.5 };
|
||||
const first = m.write(7, p);
|
||||
assert.deepEqual(m.read(7), { ...p, seed: undefined, version: first });
|
||||
const second = m.write(7, { x: 20, y: 3, pressure: 0.75 });
|
||||
m.acknowledge(first);
|
||||
assert.equal(m.read(7).version, second);
|
||||
m.acknowledge(second);
|
||||
assert.equal(m.read(7), null);
|
||||
Atomics.store(m.words, 0, 0x7ffffffe);
|
||||
const wrapped = m.write(7, p);
|
||||
assert.equal(wrapped, -2147483648);
|
||||
assert.equal(m.read(7).x, 12);
|
||||
const load = Atomics.load;
|
||||
let changed = false;
|
||||
t.mock.method(Atomics, "load", (array, index) => {
|
||||
const v = load(array, index);
|
||||
if (array === m.words && index === 5 && !changed) {
|
||||
changed = true;
|
||||
m.write(7, { x: 99, y: 100, pressure: 0.2 });
|
||||
}
|
||||
return v;
|
||||
});
|
||||
assert.equal(m.read(7), null);
|
||||
Atomics.store(m.words, 0, 11);
|
||||
assert.equal(m.read(7), null);
|
||||
});
|
||||
test("actual concurrent SAB writer and reader never observe mixed coordinates", async (t) => {
|
||||
const shared = row(),
|
||||
m = new Mailbox(shared),
|
||||
control = new Int32Array(shared.buffer, 0, 1);
|
||||
const code = `import {parentPort,workerData} from 'node:worker_threads';
|
||||
const {Mailbox}=await import(workerData.module);
|
||||
const m=new Mailbox(workerData.row),control=new Int32Array(workerData.row.buffer,0,1);
|
||||
let reads=0,last=0;
|
||||
parentPort.postMessage('ready');
|
||||
while(!Atomics.load(control,0)||last!==100000){
|
||||
const p=m.read(7);if(!p)continue;
|
||||
if(p.y!==-p.x||p.pressure!==p.x*2||p.seed!==p.x*3)throw new Error('torn tuple');
|
||||
last=p.x;reads++;m.acknowledge(p.version);
|
||||
}parentPort.postMessage({reads,last});`;
|
||||
const worker = new Worker(
|
||||
new URL("data:text/javascript," + encodeURIComponent(code)),
|
||||
{
|
||||
workerData: {
|
||||
module: new URL("./mailbox.js", import.meta.url).href,
|
||||
row: shared,
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(() => worker.terminate());
|
||||
await new Promise((resolve, reject) => {
|
||||
worker.once("message", resolve);
|
||||
worker.once("error", reject);
|
||||
});
|
||||
const result = new Promise((resolve, reject) => {
|
||||
worker.once("message", resolve);
|
||||
worker.once("error", reject);
|
||||
});
|
||||
for (let i = 1; i <= 100000; i++) {
|
||||
m.write(7, { x: i, y: -i, pressure: i * 2 }, i * 3);
|
||||
if (i % 1000 === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
Atomics.store(control, 0, 1);
|
||||
const out = await result;
|
||||
assert.ok(out.reads > 0);
|
||||
assert.equal(out.last, 100000);
|
||||
});
|
||||
function setup(settings = {}, sample = { x: 400, y: 300, pressure: 0.7 }) {
|
||||
const mailbox = new Mailbox(row()),
|
||||
messages = [],
|
||||
mesh = sphere(4);
|
||||
let now = 0;
|
||||
const processor = new StrokeProcessor(
|
||||
mesh,
|
||||
mailbox,
|
||||
(m) => messages.push(m),
|
||||
() => now,
|
||||
);
|
||||
const version = mailbox.write(1, sample);
|
||||
processor.begin({
|
||||
id: 1,
|
||||
version,
|
||||
sample,
|
||||
positions: mesh.positions.slice(),
|
||||
settings: { ...defaults, symmetry: false, ...settings },
|
||||
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||
rect: { width: 800, height: 600 },
|
||||
hit: { position: [0, 0, 1], normal: [0, 0, 1] },
|
||||
});
|
||||
const ack = () => {
|
||||
for (const m of messages)
|
||||
if (m.type === "geometry") processor.acknowledge(m.packet);
|
||||
};
|
||||
return {
|
||||
processor,
|
||||
mailbox,
|
||||
messages,
|
||||
ack,
|
||||
advance: (ms) => {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
|
||||
const a = setup(),
|
||||
b = setup();
|
||||
a.processor.frame();
|
||||
b.processor.frame();
|
||||
a.ack();
|
||||
b.ack();
|
||||
for (let i = 0; i < 10000; i++)
|
||||
a.mailbox.write(1, {
|
||||
x: 410 + i / 100,
|
||||
y: 300 + Math.sin(i) * 30,
|
||||
pressure: 0.4,
|
||||
});
|
||||
const end = { x: 470, y: 310, pressure: 0.8 };
|
||||
const version = a.mailbox.write(1, end);
|
||||
b.mailbox.write(1, end);
|
||||
a.processor.frame();
|
||||
b.processor.frame();
|
||||
assert.deepEqual(
|
||||
a.processor.engine.mesh.positions,
|
||||
b.processor.engine.mesh.positions,
|
||||
);
|
||||
assert.equal(a.mailbox.processed, version);
|
||||
assert.equal(a.processor.job.target, null);
|
||||
});
|
||||
test("Grab seeds pressure-radius from first effective pressure and ignores a zero-pressure tail", () => {
|
||||
const a = setup(
|
||||
{ brush: "grab", pressureRadius: true },
|
||||
{ x: 400, y: 300, pressure: 0 },
|
||||
);
|
||||
a.processor.frame();
|
||||
const version = a.mailbox.write(1, { x: 460, y: 310, pressure: 0.8 }, 0.25);
|
||||
a.processor.frame();
|
||||
const expected = new Sculpt(sphere(4));
|
||||
expected.begin({ brush: "grab", pressureRadius: true, symmetry: false });
|
||||
expected.dab({ position: [0, 0, 1], normal: [0, 0, 1] }, 0.25, [0, 0, 0]);
|
||||
expected.dab(
|
||||
{ position: [0, 0, 1], normal: [0, 0, 1] },
|
||||
Math.fround(0.8),
|
||||
[0.3, -0.05, 0],
|
||||
);
|
||||
assert.deepEqual(a.processor.engine.mesh.positions, expected.mesh.positions);
|
||||
assert.equal(a.mailbox.processed, version);
|
||||
});
|
||||
test("one immutable output in flight, new dirty work survives acknowledgement, end waits for applied output", () => {
|
||||
const a = setup();
|
||||
a.processor.frame();
|
||||
const first = a.messages.find((m) => m.type === "geometry"),
|
||||
bytes = first.vertices.slice();
|
||||
const version = a.mailbox.write(1, { x: 470, y: 310, pressure: 0.9 });
|
||||
a.processor.end(1, version);
|
||||
a.processor.frame();
|
||||
assert.deepEqual(first.vertices, bytes);
|
||||
assert.equal(a.messages.filter((m) => m.type === "geometry").length, 1);
|
||||
assert.ok(a.processor.engine.dirty.size);
|
||||
assert.ok(!a.messages.some((m) => m.type === "complete"));
|
||||
a.processor.acknowledge(first.packet);
|
||||
a.processor.frame();
|
||||
const second = a.messages.filter((m) => m.type === "geometry").at(-1);
|
||||
assert.notEqual(second.packet, first.packet);
|
||||
a.processor.acknowledge(first.packet);
|
||||
assert.equal(a.processor.outstanding, second.packet);
|
||||
a.processor.acknowledge(second.packet);
|
||||
a.processor.frame();
|
||||
assert.equal(a.messages.at(-1).type, "complete");
|
||||
});
|
||||
test("worker bounds real work; unfinished segment age is not reset by release or newer samples", () => {
|
||||
const a = setup();
|
||||
a.processor.frame();
|
||||
a.ack();
|
||||
const dab = a.processor.engine.dab.bind(a.processor.engine);
|
||||
let calls = 0;
|
||||
a.processor.engine.dab = (...args) => {
|
||||
calls++;
|
||||
a.advance(5);
|
||||
dab(...args);
|
||||
};
|
||||
a.mailbox.write(1, { x: 590, y: 300, pressure: 0.8 });
|
||||
a.processor.frame();
|
||||
assert.equal(calls, 1);
|
||||
assert.ok(a.processor.job.target);
|
||||
a.advance(250);
|
||||
const latest = a.mailbox.write(1, { x: 610, y: 305, pressure: 0.9 });
|
||||
a.processor.end(1, latest);
|
||||
a.processor.frame();
|
||||
assert.equal(a.processor.job, null);
|
||||
assert.equal(a.messages.at(-1).type, "canceled");
|
||||
});
|
||||
test("resuming a segment preserves independently computed positions and pressure", () => {
|
||||
const emitted = [],
|
||||
path = new StrokePath(5, (p) => emitted.push(p));
|
||||
path.push({ x: 0, y: 0, pressure: 0.2 });
|
||||
while (!path.push({ x: 24, y: 18, pressure: 0.8 }, 1)) {}
|
||||
assert.equal(emitted.length, 7);
|
||||
emitted.forEach((p, i) => {
|
||||
assert.ok(Math.abs(p.x - 4 * i) < 1e-10);
|
||||
assert.ok(Math.abs(p.y - 3 * i) < 1e-10);
|
||||
assert.ok(Math.abs(p.pressure - (0.2 + 0.1 * i)) < 1e-10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user