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>
111 lines
3.7 KiB
JavaScript
111 lines
3.7 KiB
JavaScript
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/);
|
|
});
|