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
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMesh } from "./mesh.js";
import {
MAX_TRIANGLES,
MAX_VERTICES,
decimate,
preset,
subdivide,
} from "./topology.js";
function inspect(mesh) {
const edges = new Map();
let volume = 0;
for (let k = 0; k < mesh.indices.length; k += 3) {
const face = Array.from(mesh.indices.subarray(k, k + 3));
assert.equal(new Set(face).size, 3);
const [a, b, c] = face;
const p = mesh.positions;
volume +=
(p[a * 3] * (p[b * 3 + 1] * p[c * 3 + 2] - p[b * 3 + 2] * p[c * 3 + 1]) +
p[a * 3 + 1] * (p[b * 3 + 2] * p[c * 3] - p[b * 3] * p[c * 3 + 2]) +
p[a * 3 + 2] * (p[b * 3] * p[c * 3 + 1] - p[b * 3 + 1] * p[c * 3])) /
6;
for (const [x, y] of [
[a, b],
[b, c],
[c, a],
]) {
const key = x < y ? `${x},${y}` : `${y},${x}`;
edges.set(key, (edges.get(key) || 0) + 1);
}
}
assert.ok([...mesh.positions, ...mesh.normals].every(Number.isFinite));
assert.ok([...edges.values()].every((count) => count === 2));
assert.ok(
volume > 0,
`expected outward winding, signed volume was ${volume}`,
);
}
test("presets are bounded, outward, closed manifold meshes", () => {
for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) {
const mesh = preset(name);
assert.ok(mesh.positions.length / 3 <= MAX_VERTICES);
assert.ok(mesh.indices.length / 3 <= MAX_TRIANGLES);
assert.ok(mesh.indices.length / 3 >= 2000);
assert.equal(mesh.masks.length, mesh.positions.length / 3);
inspect(mesh);
}
});
test("subdivision shares midpoints and interpolates masks without changing winding", () => {
const tetra = buildMesh(
[1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1],
[0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3],
0,
[0, 1, 0.5, 0],
);
const result = subdivide(tetra);
assert.equal(result.indices.length, tetra.indices.length * 4);
assert.equal(result.positions.length / 3, 10); // 4 vertices + 6 unique edges.
const midpoint =
Array.from(result.positions).findIndex(
(_, k, p) =>
k % 3 === 0 && p[k] === 0 && p[k + 1] === 0 && p[k + 2] === 1,
) / 3;
assert.equal(result.masks[midpoint], 0.5);
assert.deepEqual(
Array.from(result.positions.slice(0, 12)),
Array.from(tetra.positions),
);
inspect(result);
});
test("decimation keeps a closed mesh and protects masked neighborhoods", () => {
const source = preset("sphere", 4);
source.masks[0] = 1;
const protectedFaces = Array.from(source.incident[0], (f) =>
Array.from(source.indices.slice(f * 3, f * 3 + 3)).map((v) =>
Array.from(source.positions.slice(v * 3, v * 3 + 3)).join(","),
),
);
const result = decimate(source, 0.75);
assert.ok(result.indices.length < source.indices.length);
inspect(result);
const coordinates = new Set();
for (let v = 0; v < result.positions.length / 3; v++)
coordinates.add(
Array.from(result.positions.slice(v * 3, v * 3 + 3)).join(","),
);
assert.ok(protectedFaces.flat().every((point) => coordinates.has(point)));
assert.equal([...result.masks].filter(Boolean).length, 1);
});
test("fully masked decimation is a no-op", () => {
const source = preset("torus", 2);
source.masks.fill(1);
const result = decimate(source, 0.25);
assert.deepEqual(result.indices, source.indices);
assert.deepEqual(result.positions, source.positions);
assert.deepEqual(result.masks, source.masks);
});
test("subdivision rejects cap overflow before allocating output", () => {
const positions = new Float32Array((MAX_VERTICES - 1) * 3);
const mesh = buildMesh(positions, new Uint32Array([0, 1, 2]));
assert.throws(() => subdivide(mesh), /limits/);
});