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:
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user