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 }); } }; }