Files
yawn/server/web/assets/sculpt/operations.js
T
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

129 lines
4.1 KiB
JavaScript

import { preset, subdivide, decimate } from "./topology.js";
import {
hydrate,
snapshot,
ensureDocument,
splitPiece,
combinePieces,
} from "./document.js";
function bounds(mesh, piece) {
const min = [Infinity, Infinity, Infinity],
max = [-Infinity, -Infinity, -Infinity];
for (
let v = piece.vertexStart;
v < piece.vertexStart + piece.vertexCount;
v++
)
for (let k = 0; k < 3; k++) {
min[k] = Math.min(min[k], mesh.positions[v * 3 + k]);
max[k] = Math.max(max[k], mesh.positions[v * 3 + k]);
}
return { min, max };
}
// Runs on owned document copies in a disposable worker. Cancel terminates it.
export function operate(data, action, activeId, options = {}) {
const mesh = hydrate(data);
const active = mesh.pieces.find((p) => p.id === activeId);
let items = mesh.pieces.map((piece) => ({ mesh, piece }));
let note = "";
if (action === "add") {
if (items.length >= 16)
throw new Error("Keep up to 16 pieces in a project.");
const added = ensureDocument(preset(options.preset));
const piece = added.pieces[0];
Object.assign(piece, {
id: options.id,
name: options.preset[0].toUpperCase() + options.preset.slice(1),
color: options.color,
});
const boxes = mesh.pieces.map((p) => bounds(mesh, p));
const shape = bounds(added, piece);
// Compact deterministic placement; keep new clay separate and within bounds.
const candidates = [];
if (active) {
const b = bounds(mesh, active);
candidates.push([
b.max[0] - shape.min[0] + 0.15,
(b.min[1] + b.max[1]) / 2,
(b.min[2] + b.max[2]) / 2,
]);
}
for (let y = 0; y <= 6; y += 2.5)
for (let x = 0; x <= 6; x += 2.5)
for (const sx of x ? [1, -1] : [1])
for (const sy of y ? [1, -1] : [1])
candidates.push([x * sx, y * sy, 0]);
const offset = candidates.find((o) => {
const lo = shape.min.map((x, k) => x + o[k]),
hi = shape.max.map((x, k) => x + o[k]);
return (
lo.every((x) => x >= -8) &&
hi.every((x) => x <= 8) &&
boxes.every((b) =>
lo.some((x, k) => x > b.max[k] + 0.1 || hi[k] < b.min[k] - 0.1),
)
);
});
if (!offset)
throw new Error("No room for another piece. Delete a piece first.");
for (let i = 0; i < added.positions.length; i++)
added.positions[i] += offset[i % 3];
piece.symmetryX = Math.fround(offset[0]);
items.push({ mesh: added, piece });
activeId = piece.id;
} else {
if (!active) throw new Error("Select a piece of clay first.");
if (action === "delete")
items = items.filter((i) => i.piece.id !== activeId);
else if (action === "subdivide" || action === "decimate") {
const source = splitPiece(mesh, active);
const result =
action === "subdivide" ? subdivide(source) : decimate(source);
result.level = mesh.level;
const piece = {
...active,
vertexStart: 0,
indexStart: 0,
vertexCount: result.positions.length / 3,
indexCount: result.indices.length,
};
result.pieces = [piece];
items = items.map((i) =>
i.piece.id === activeId ? { mesh: result, piece } : i,
);
if (action === "decimate")
note = `${Math.round((1 - result.indices.length / source.indices.length) * 100)}% fewer triangles`;
} else throw new Error("Unknown clay operation.");
}
const combined = combinePieces(items);
combined.level = mesh.level;
return {
document: snapshot(combined),
activeId: combined.pieces.some((p) => p.id === activeId)
? activeId
: (combined.pieces[0]?.id ?? null),
note,
};
}
if (typeof self !== "undefined" && typeof document === "undefined")
self.onmessage = ({ data }) => {
try {
const result = operate(
data.document,
data.action,
data.activeId,
data.options,
);
self.postMessage(result, [
result.document.positions.buffer,
result.document.indices.buffer,
result.document.masks.buffer,
]);
} catch (error) {
self.postMessage({ error: error.message });
}
};