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
+238
View File
@@ -0,0 +1,238 @@
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";
// 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 (moved.length) {
const { faces } = updateNormals(e.mesh, moved);
e.bvh.refit(faces);
}
e.dirty.clear();
e.begin(data.settings);
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),
);
if (!hit) 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);
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(
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 });
}
};
}