Files
yawn/server/web/assets/sculpt/stroke-client.js
T
Ampandheaust d279ec4123 Add clay pieces, masking, and topology editing to sculpt
Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-10-01 07:55:48 +00:00

139 lines
4.0 KiB
JavaScript

import { Mailbox } from "./mailbox.js";
import { pressureValue } from "./engine.js";
export class StrokeClient {
constructor(row, mesh, 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"));
const topology =
typeof mesh === "number"
? { level: mesh }
: {
mesh: {
level: mesh.level,
positions: mesh.positions.slice(),
indices: mesh.indices.slice(),
masks: mesh.masks.slice(),
pieces: structuredClone(mesh.pieces),
},
};
this.worker.postMessage({ type: "init", row: row.share(), ...topology });
}
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, ...(data.masks ? [data.masks.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" || data.type === "mask") {
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;
}
}