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>
152 lines
5.5 KiB
JavaScript
152 lines
5.5 KiB
JavaScript
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;
|
|
});
|