Files
yawn/server/web/assets/sculpt/operations.test.js
T
Ampandheaust d279ec4123 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>
2026-10-01 07:55:48 +00:00

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;
});