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
+174
View File
@@ -0,0 +1,174 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMesh, sphere } from "./mesh.js";
import { BVH } from "./bvh.js";
import { Sculpt } from "./engine.js";
import {
ensureDocument,
snapshot,
hydrate,
splitPiece,
combinePieces,
saveDocument,
loadDocument,
} from "./document.js";
test("document v2 roundtrip owns canonical arrays and supports empty documents", () => {
const mesh = ensureDocument(sphere(4));
mesh.masks[3] = 0.75;
const copy = loadDocument(saveDocument(mesh));
assert.deepEqual(snapshot(copy), snapshot(mesh));
copy.positions[0] = 7;
assert.notEqual(copy.positions[0], mesh.positions[0]);
const empty = buildMesh([], [], 0);
empty.pieces = [];
assert.equal(loadDocument(saveDocument(empty)).positions.length, 0);
});
test("piece splitting localizes indices and combination restores contiguous ranges", () => {
const a = ensureDocument(
buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0),
);
a.pieces[0].id = "a";
const b = hydrate(snapshot(a));
b.pieces[0].id = "b";
const combined = combinePieces([
{ mesh: a, piece: a.pieces[0] },
{ mesh: b, piece: b.pieces[0] },
]);
assert.deepEqual(
combined.pieces.map((p) => [p.vertexStart, p.indexStart]),
[
[0, 0],
[3, 3],
],
);
assert.deepEqual(
splitPiece(combined, combined.pieces[1]).indices,
new Uint32Array([0, 1, 2]),
);
});
test("combining a level 6 sphere avoids argument limits and preserves its level", () => {
const mesh = ensureDocument(sphere(6));
mesh.masks[40000] = 0.625;
const combined = combinePieces([{ mesh, piece: mesh.pieces[0] }]);
assert.equal(combined.indices.length, 245760);
assert.equal(combined.level, 6);
assert.deepEqual(snapshot(combined), snapshot(mesh));
});
test("combination preflights aggregate limits and accepts no pieces", () => {
const mesh = ensureDocument(sphere(6));
const other = hydrate(snapshot(mesh));
other.pieces[0].id = "other";
assert.throws(
() => combinePieces([{ mesh }, { mesh: other }]),
/topology limits/,
);
const empty = combinePieces([]);
assert.equal(empty.positions.length, 0);
assert.deepEqual(empty.pieces, []);
});
test("hydrate rejects invalid ranges, cross-piece indices, and coordinates", () => {
const base = snapshot(
ensureDocument(buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0)),
);
assert.throws(() =>
hydrate({
...base,
positions: Float32Array.from([9, ...base.positions.slice(1)]),
}),
);
assert.throws(() =>
hydrate({ ...base, pieces: [{ ...base.pieces[0], vertexCount: 2 }] }),
);
const left = hydrate(base),
right = hydrate(base);
right.pieces[0].id = "other";
const two = combinePieces([
{ mesh: left, piece: left.pieces[0] },
{ mesh: right, piece: right.pieces[0] },
]);
const bad = snapshot(two);
bad.indices[3] = 0;
assert.throws(() => hydrate(bad), /outside/);
});
test("canonical no-op installs preserve redo and every live identity", () => {
const sculpt = new Sculpt(sphere(4));
sculpt.setColor([0.2, 0.4, 0.6]);
sculpt.restore();
const identities = {
mesh: sculpt.mesh,
bvh: sculpt.bvh,
dirty: sculpt.dirty,
undo: sculpt.undoStack,
redo: sculpt.redoStack,
};
const values = {
state: sculpt.state,
nextState: sculpt.nextState,
revision: sculpt.revision,
};
assert.equal(sculpt.install(hydrate(snapshot(sculpt.mesh))), false);
for (const [name, value] of Object.entries(identities))
assert.equal(
sculpt[
name === "undo" ? "undoStack" : name === "redo" ? "redoStack" : name
],
value,
);
assert.deepEqual(
{
state: sculpt.state,
nextState: sculpt.nextState,
revision: sculpt.revision,
},
values,
);
assert.equal(sculpt.redoStack.length, 1);
});
test("structural install and restore failures are atomic", () => {
const sculpt = new Sculpt(sphere(4));
const changed = hydrate(snapshot(sculpt.mesh));
changed.positions[0] += 0.01;
const before = () => ({
mesh: sculpt.mesh,
bvh: sculpt.bvh,
dirty: sculpt.dirty,
undo: sculpt.undoStack,
redo: sculpt.redoStack,
state: sculpt.state,
nextState: sculpt.nextState,
revision: sculpt.revision,
});
const assertSame = (value) => {
assert.equal(sculpt.mesh, value.mesh);
assert.equal(sculpt.bvh, value.bvh);
assert.equal(sculpt.dirty, value.dirty);
assert.equal(sculpt.undoStack, value.undo);
assert.equal(sculpt.redoStack, value.redo);
assert.equal(sculpt.state, value.state);
assert.equal(sculpt.nextState, value.nextState);
assert.equal(sculpt.revision, value.revision);
};
const originalBounds = BVH.prototype.bounds;
BVH.prototype.bounds = () => {
throw new Error("injected BVH failure");
};
const installState = before();
try {
assert.throws(() => sculpt.install(changed), /injected BVH failure/);
} finally {
BVH.prototype.bounds = originalBounds;
}
assertSame(installState);
assert.equal(sculpt.install(changed), true);
sculpt.undoStack.at(-1).before.activeId = "missing";
const restoreState = before();
assert.throws(() => sculpt.restore(), /active piece/);
assertSame(restoreState);
});