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>
139 lines
4.0 KiB
JavaScript
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;
|
|
}
|
|
}
|