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
+126
View File
@@ -0,0 +1,126 @@
import { Mailbox } from "./mailbox.js";
import { pressureValue } from "./engine.js";
export class StrokeClient {
constructor(row, level, onError) {
this.mailbox = new Mailbox(row.share());
this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
type: "module",
name: "sculpt-samples",
});
this.nextId = 0;
this.onError = onError;
this.ready = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.timer = setTimeout(
() => this.fail(new Error("Sculpt worker initialization timed out")),
20000,
);
this.worker.onmessage = ({ data }) => this.message(data);
this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
this.worker.onmessageerror = () =>
this.fail(new Error("Sculpt worker message failed"));
this.worker.postMessage({ type: "init", row: row.share(), level });
}
begin(data, apply) {
if (this.job || this.disposed)
throw new Error("Sculpt worker is unavailable");
const id = ++this.nextId;
const promise = new Promise((resolve, reject) => {
this.job = { id, settings: data.settings, apply, resolve, reject };
});
this.write(data.sample);
this.worker.postMessage(
{ type: "begin", ...data, id, version: this.version },
[data.positions.buffer],
);
return promise;
}
write(sample) {
const j = this.job;
if (!j || j.canceling) return;
if (j.settings.brush === "grab") {
if (
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) ===
0 &&
this.version !== undefined &&
j.hasSample
)
return;
if (
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0 &&
j.seed === undefined
)
j.seed = sample.pressure;
}
this.version = this.mailbox.write(j.id, sample, j.seed);
j.hasSample = true;
}
end() {
if (!this.job || this.job.canceling) return;
this.worker.postMessage({
type: "end",
id: this.job.id,
version: this.version,
});
this.armTimeout();
}
cancel() {
if (!this.job) return;
this.job.canceling = true;
this.worker.postMessage({ type: "cancel", id: this.job.id });
this.armTimeout();
}
armTimeout() {
clearTimeout(this.timer);
this.timer = setTimeout(
() => this.fail(new Error("Sculpt worker did not settle")),
10000,
);
}
message(data) {
if (this.disposed) return;
if (data.type === "ready") {
clearTimeout(this.timer);
this.resolveReady();
return;
}
if (data.type === "error") {
this.fail(new Error(data.message));
return;
}
const j = this.job;
if (data.type === "geometry") {
if (j?.id === data.id && !j.canceling) j.apply(data);
this.worker.postMessage({ type: "ack", packet: data.packet });
} else if (
j?.id === data.id &&
["complete", "canceled"].includes(data.type)
) {
clearTimeout(this.timer);
this.job = null;
j.resolve({
canceled: j.canceling || data.type === "canceled",
message: data.message,
});
}
}
fail(error) {
if (this.disposed) return;
this.rejectReady(error);
this.job?.reject(error);
this.job = null;
this.dispose();
this.onError(error);
}
dispose() {
this.disposed = true;
clearTimeout(this.timer);
this.worker.terminate();
this.rejectReady(new Error("Sculpt worker disposed"));
this.job?.resolve({ canceled: true });
this.job = null;
}
}