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>
129 lines
4.1 KiB
JavaScript
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 });
|
|
}
|
|
};
|