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>
This commit is contained in:
Amp
2026-10-01 07:55:48 +00:00
co-authored by heaust
parent d930d8172f
commit d279ec4123
21 changed files with 2564 additions and 131 deletions
+128
View File
@@ -0,0 +1,128 @@
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 });
}
};