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:
Amp
2026-09-28 08:53:50 +00:00
co-authored by heaust
parent eb8aa0e9dc
commit 347b676cf1
25 changed files with 5468 additions and 1 deletions
+148
View File
@@ -0,0 +1,148 @@
// Explicit browser benchmark; never imported by the application.
// await (await import('/assets/sculpt/latency-check.js')).check()
import { Input } from "./input.js";
import { Renderer } from "./renderer.js";
import { StrokeClient } from "./stroke-client.js";
import { Sculpt } from "./engine.js";
export async function check() {
const viewport = document.getElementById("viewport");
const move = Input.prototype.move,
publish = Renderer.prototype.publish,
message = StrokeClient.prototype.message,
dab = Sculpt.prototype.dab;
const capture = viewport.setPointerCapture;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise(requestAnimationFrame);
const wait = async (predicate) => {
const start = performance.now();
while (!predicate()) {
if (performance.now() - start > 15000)
throw new Error("Latency check timed out");
await sleep(10);
}
};
let input,
durations = [],
uploads = [],
packets = [],
mainDabs = 0,
canceled = false;
Input.prototype.move = function (event) {
input = this;
const start = performance.now();
const result = move.call(this, event);
durations.push(performance.now() - start);
return result;
};
Sculpt.prototype.dab = function (...args) {
mainDabs++;
return dab.apply(this, args);
};
StrokeClient.prototype.message = function (data) {
const start = performance.now();
const result = message.call(this, data);
if (data.type === "geometry") packets.push(performance.now() - start);
return result;
};
Renderer.prototype.publish = async function (...args) {
const start = performance.now();
try {
return await publish.apply(this, args);
} finally {
uploads.push(performance.now() - start);
}
};
viewport.setPointerCapture = () => {};
const pointer = (type, x, y, pressure = 0.8) => {
const r = viewport.getBoundingClientRect();
return new PointerEvent(type, {
bubbles: true,
pointerId: 7,
pointerType: "pen",
clientX: r.left + r.width / 2 + x,
clientY: r.top + r.height / 2 + y,
pressure,
button: 0,
buttons: type === "pointerup" ? 0 : 1,
});
};
const send = (event) => viewport.querySelector("canvas").dispatchEvent(event);
const percentile = (values, p) =>
[...values].sort((a, b) => a - b)[
Math.min(values.length - 1, Math.floor(values.length * p))
] ?? 0;
const results = [];
try {
for (const brush of ["draw", "grab"])
for (const perFrame of [1, 4, 16]) {
await wait(
() =>
document.getElementById("loading").hidden &&
!document.getElementById("new").disabled,
);
const old = viewport.querySelector("canvas");
document.getElementById("new").click();
document.getElementById("density").value = "5";
document.getElementById("new-dialog").close("create");
await wait(
() =>
old !== viewport.querySelector("canvas") &&
document.getElementById("loading").hidden,
);
document.querySelector(`[data-brush="${brush}"]`).click();
await sleep(300);
durations = [];
uploads = [];
packets = [];
mainDabs = 0;
canceled = false;
send(pointer("pointerdown", -80, -30));
for (let f = 0; f < 60; f++) {
await frame();
const samples = Array.from({ length: perFrame }, (_, i) => {
const t = (f + (i + 1) / perFrame) / 60;
return pointer(
"pointermove",
-80 + 160 * t,
-30 + 30 * Math.sin(t * Math.PI),
0.3 + 0.6 * t,
);
});
const event = samples.at(-1);
Object.defineProperty(event, "getCoalescedEvents", {
value: () => samples,
});
send(event);
if (input && !input.busy) canceled = true;
}
const release = performance.now();
send(pointer("pointerup", 80, -30, 0));
await wait(() => !document.getElementById("save").disabled);
const releaseMs = performance.now() - release;
const undoEntries = input.host.engine().undoStack.length;
canceled ||= undoEntries !== 1;
if (canceled || mainDabs || input.queue !== undefined)
throw new Error("Worker input regression");
results.push({
brush,
samplesPerFrame: perFrame,
sharedInputBytes: input.host.processor().mailbox.words.byteLength,
mainDabs,
canceled,
releaseMs,
mainInputP95: percentile(durations, 0.95),
mainPacketP95: percentile(packets, 0.95),
uploadP95: percentile(uploads, 0.95),
undoEntries,
});
}
return results;
} finally {
Input.prototype.move = move;
StrokeClient.prototype.message = message;
Sculpt.prototype.dab = dab;
Renderer.prototype.publish = publish;
viewport.setPointerCapture = capture;
}
}