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
+151
View File
@@ -0,0 +1,151 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sphere, buildMesh } from "./mesh.js";
import { snapshot, ensureDocument, splitPiece } from "./document.js";
import { operate } from "./operations.js";
import { Sculpt } from "./engine.js";
import { decimate } from "./topology.js";
test("add, color, mask, subdivision, decimation and delete round-trip mixed history", () => {
const engine = new Sculpt(sphere(4));
const original = snapshot(engine.mesh);
const add = operate(original, "add", engine.activeId, {
preset: "torus",
id: "ring",
color: [0.7, 0.4, 0.5],
});
engine.install(add.document, add.activeId);
assert.equal(engine.activeId, "ring");
assert.equal(engine.mesh.pieces.length, 2);
assert.deepEqual(
engine.mesh.positions.subarray(0, original.positions.length),
original.positions,
);
engine.setColor([0.3, 0.6, 0.7]);
engine.editMask(true);
const beforeSubdivision = snapshot(engine.mesh);
const divided = operate(beforeSubdivision, "subdivide", "ring");
engine.install(divided.document);
assert.equal(
engine.mesh.pieces[1].indexCount,
beforeSubdivision.pieces[1].indexCount * 4,
);
const masked = snapshot(engine.mesh);
const unchanged = operate(masked, "decimate", "ring");
assert.equal(engine.install(unchanged.document), false);
engine.editMask();
const simplified = operate(snapshot(engine.mesh), "decimate", "ring");
assert.ok(simplified.document.indices.length < engine.mesh.indices.length);
engine.install(simplified.document);
const removed = operate(snapshot(engine.mesh), "delete", "ring");
engine.install(removed.document, removed.activeId);
assert.equal(engine.mesh.pieces.length, 1);
const final = snapshot(engine.mesh),
finalState = engine.state;
while (engine.restore()) {}
assert.deepEqual(snapshot(engine.mesh), original);
assert.equal(engine.state, 0);
while (engine.restore(true)) {}
assert.deepEqual(snapshot(engine.mesh), final);
assert.equal(engine.state, finalState);
const empty = operate(snapshot(engine.mesh), "delete", engine.activeId);
engine.install(empty.document, empty.activeId);
assert.equal(engine.activeId, null);
assert.equal(engine.mesh.positions.length, 0);
});
test("piece sculpting and local symmetry cannot change neighboring clay", () => {
const first = ensureDocument(sphere(4));
const result = operate(snapshot(first), "add", "clay-1", {
preset: "sphere",
id: "second",
color: [0.5, 0.7, 0.4],
});
const e = new Sculpt();
e.install(result.document, "second");
const before = e.mesh.positions.slice(),
piece = e.mesh.pieces[1];
assert.equal(piece.symmetryX, Math.fround(2.15));
const seam = [];
for (
let v = piece.vertexStart;
v < piece.vertexStart + piece.vertexCount;
v++
)
if (before[v * 3] === piece.symmetryX) seam.push(v);
assert.ok(seam.length > 0);
const hit = e.bvh.raycast([piece.symmetryX + 0.2, 0, 5], [0, 0, -1]);
e.begin({ brush: "draw", radius: 0.5, symmetry: true });
e.dab(hit);
e.end();
for (const v of seam) assert.equal(e.mesh.positions[v * 3], piece.symmetryX);
assert.deepEqual(
e.mesh.positions.subarray(0, first.positions.length),
before.subarray(0, first.positions.length),
);
assert.notDeepEqual(
splitPiece(e.mesh, piece).positions,
before.subarray(piece.vertexStart * 3),
);
});
test("decimator keeps tetrahedron components and open boundary fan intact", () => {
const tetra = [1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1];
const faces = [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
const mesh = buildMesh(
[...tetra, ...tetra.map((x, i) => x + (i % 3 === 0 ? 4 : 0))],
[...faces, ...faces.map((i) => i + 4)],
);
const result = decimate(mesh);
assert.deepEqual(result.indices, mesh.indices);
assert.deepEqual(result.positions, mesh.positions);
const p = [0, 0, 0],
f = [];
for (let i = 0; i < 6; i++) {
p.push(Math.cos((i * Math.PI) / 3), Math.sin((i * Math.PI) / 3), 0);
f.push(0, i + 1, ((i + 1) % 6) + 1);
}
const fan = buildMesh(p, f),
reduced = decimate(fan);
assert.deepEqual(reduced.positions, fan.positions);
assert.deepEqual(reduced.indices, fan.indices);
});
test("every geometry brush respects protection; mask erase and cancellation are exact", () => {
const e = new Sculpt(sphere(4));
const hit = e.bvh.raycast([0.2, 0.1, 5], [0, 0, -1]);
const positions = e.mesh.positions.slice(),
normals = e.mesh.normals.slice();
e.editMask(true);
for (const brush of ["draw", "inflate", "smooth", "flatten", "grab"]) {
e.begin({ brush, strength: 1, radius: 0.5 });
e.dab(hit, 1, [0.3, -0.2, 0.1]);
e.end();
assert.deepEqual(e.mesh.positions, positions, brush);
}
const protectedMasks = e.mesh.masks.slice();
const geometryChange = e.changed;
e.changed = () => {
throw new Error("Mask edit performed geometry maintenance");
};
e.begin({ brush: "mask", invert: true, radius: 0.5 });
e.dab(hit);
e.end(true);
assert.deepEqual(e.mesh.masks, protectedMasks);
e.begin({ brush: "mask", invert: true, radius: 0.5 });
e.dab(hit);
e.end();
assert.ok(e.mesh.masks.some((x) => x < 1));
const erased = e.mesh.masks.slice();
e.restore();
assert.deepEqual(e.mesh.masks, protectedMasks);
e.restore(true);
assert.deepEqual(e.mesh.masks, erased);
e.editMask();
assert.ok(e.mesh.masks.every((x) => x === 0));
e.restore();
assert.deepEqual(e.mesh.masks, erased);
assert.deepEqual(e.mesh.normals, normals);
assert.deepEqual(e.mesh.positions, positions);
e.changed = geometryChange;
});