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>
175 lines
5.1 KiB
JavaScript
175 lines
5.1 KiB
JavaScript
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);
|
|
});
|