Files
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

267 lines
7.7 KiB
JavaScript

import { Sculpt, StrokePath, pressureValue } from "./engine.js";
import { sphere, updateNormals } from "./mesh.js";
import { cameraBasis, add, mul, sub } from "./math.js";
import { Mailbox } from "./mailbox.js";
import { hydrate } from "./document.js";
// Sole owner of brush-time geometry work. Exported for deterministic tests;
// the browser entry below only supplies worker rAF and postMessage.
export class StrokeProcessor {
constructor(mesh, mailbox, send, clock = () => performance.now()) {
this.engine = new Sculpt(mesh);
this.mailbox = mailbox;
this.send = send;
this.clock = clock;
this.packet = 0;
this.outstanding = 0;
this.job = null;
}
begin(data) {
if (this.job) throw new Error("Previous stroke has not settled");
const e = this.engine;
const moved = [];
for (let v = 0; v < data.positions.length / 3; v++) {
const i = v * 3,
p = e.mesh.positions;
if (
p[i] !== data.positions[i] ||
p[i + 1] !== data.positions[i + 1] ||
p[i + 2] !== data.positions[i + 2]
)
moved.push(v);
}
e.mesh.positions.set(data.positions);
if (data.masks) e.mesh.masks.set(data.masks);
if (data.activeId) e.activeId = data.activeId;
if (moved.length) {
const { faces } = updateNormals(e.mesh, moved);
e.bvh.refit(faces);
}
e.dirty.clear();
e.begin({ ...data.settings, activeId: data.activeId });
const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
this.job = {
...data,
basis,
target: { ...data.sample, version: data.version },
admittedAt: this.clock(),
ending: false,
attempts: 0,
};
this.path = new StrokePath(
Math.max(
1,
((data.settings.radius * data.rect.height) / (2 * data.camera.zoom)) *
0.15,
),
(sample) => {
this.job.attempts++;
const hit = e.bvh.raycast(
add(this.world(sample), mul(basis.forward, 24)),
mul(basis.forward, -1),
);
const piece = e.mesh.pieces.find((p) => p.id === e.activeId);
if (
!hit ||
!piece ||
hit.face * 3 < piece.indexStart ||
hit.face * 3 >= piece.indexStart + piece.indexCount
)
this.path.break();
else e.dab(hit, sample.pressure);
},
);
this.origin = this.world(data.sample);
}
world(sample) {
const { camera: c, rect: r, basis: b } = this.job;
return add(
c.target,
add(
mul(b.right, ((2 * sample.x - r.width) * c.zoom) / r.height),
mul(b.up, (1 - (2 * sample.y) / r.height) * c.zoom),
),
);
}
end(id, version) {
if (this.job?.id === id) {
this.job.ending = true;
this.job.finalVersion = version;
}
}
cancel(id, message) {
if (this.job?.id === id) {
this.job = null;
this.engine.stroke = null;
this.engine.dirty.clear();
this.outstanding = 0;
}
this.send({ type: "canceled", id, message });
}
acknowledge(packet) {
if (packet === this.outstanding) this.outstanding = 0;
}
publish() {
const e = this.engine;
if (!this.job || this.outstanding || !e.dirty.size) return;
const rows = Uint32Array.from(e.dirty);
if (this.job.settings.brush === "mask") {
const masks = Float32Array.from(rows, (v) => e.mesh.masks[v]);
const touched = Uint32Array.from(e.stroke.touched);
e.dirty.clear();
this.outstanding = ++this.packet;
this.send(
{
type: "mask",
id: this.job.id,
packet: this.packet,
masks,
rows,
touched,
},
[masks.buffer, rows.buffer, touched.buffer],
);
return;
}
const vertices = new Float32Array(rows.length * 8);
for (let row = 0; row < rows.length; row++) {
const i = rows[row] * 3,
j = row * 8,
p = e.mesh.positions,
n = e.mesh.normals;
vertices[j] = p[i];
vertices[j + 1] = p[i + 1];
vertices[j + 2] = p[i + 2];
vertices[j + 4] = n[i];
vertices[j + 5] = n[i + 1];
vertices[j + 6] = n[i + 2];
}
const touched = Uint32Array.from(e.stroke.touched);
e.dirty.clear();
this.outstanding = ++this.packet;
this.send(
{
type: "geometry",
id: this.job.id,
packet: this.packet,
vertices,
rows,
touched,
},
[vertices.buffer, rows.buffer, touched.buffer],
);
}
frame() {
const j = this.job;
if (!j) return false;
const start = this.clock();
j.attempts = 0;
if (!j.target) {
j.target = this.mailbox.read(j.id);
j.admittedAt = start;
}
if (j.target) {
const sample = j.target;
if (j.settings.brush === "grab") {
// Influence is fixed by the first effective pressure, even if newer
// samples replace that seed before this worker gets a frame.
const seed =
sample.seed ??
(pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0
? sample.pressure
: undefined);
if (this.engine.stroke.grab === null && seed !== undefined)
this.engine.dab(j.hit, seed, [0, 0, 0]);
this.engine.dab(
j.hit,
sample.pressure,
sub(this.world(sample), this.origin),
);
this.mailbox.acknowledge(sample.version);
j.target = null;
} else {
let iterations = 0;
while (
j.target &&
j.attempts < 64 &&
(iterations === 0 || this.clock() - start < 4)
) {
iterations++;
if (this.path.push(sample, 1)) {
this.mailbox.acknowledge(sample.version);
j.target = null;
}
}
if (j.target && this.clock() - j.admittedAt > 250) {
this.cancel(
j.id,
"Stroke canceled because editing fell behind. Previous strokes preserved. Try a smaller brush.",
);
return false;
}
}
}
this.publish();
if (
j.ending &&
!j.target &&
this.mailbox.processed === j.finalVersion &&
!this.outstanding &&
!this.engine.dirty.size
) {
this.engine.stroke = null;
this.job = null;
this.send({ type: "complete", id: j.id });
return false;
}
return true;
}
}
if (typeof self !== "undefined" && typeof document === "undefined") {
let processor,
frame = 0;
const schedule = () => {
if (!frame && processor?.job)
frame = requestAnimationFrame(() => {
frame = 0;
try {
if (processor.frame()) schedule();
} catch (error) {
self.postMessage({ type: "error", message: error.message });
}
});
};
self.onmessage = ({ data }) => {
try {
if (data.type === "init") {
if (typeof requestAnimationFrame !== "function")
throw new Error(
"Sculpt worker requires worker requestAnimationFrame support",
);
processor = new StrokeProcessor(
data.mesh ? hydrate(data.mesh) : sphere(data.level),
new Mailbox(data.row),
(message, transfer = []) => self.postMessage(message, transfer),
);
self.postMessage({ type: "ready" });
} else if (data.type === "begin") {
processor.begin(data);
schedule();
} else if (data.type === "end") {
processor.end(data.id, data.version);
schedule();
} else if (data.type === "ack") {
processor.acknowledge(data.packet);
schedule();
} else if (data.type === "cancel") {
if (frame) cancelAnimationFrame(frame);
frame = 0;
processor.cancel(data.id);
}
} catch (error) {
self.postMessage({ type: "error", message: error.message });
}
};
}