Files
yawn/server/web/assets/sculpt/engine.test.js
T
Ampandheaust 347b676cf1 Add core-powered web sculpting studio with tablet controls
Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-09-28 08:53:50 +00:00

343 lines
11 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { sphere, updateNormals } from "./mesh.js";
import { triangleHit } from "./bvh.js";
import {
Sculpt,
pressureValue,
StrokePath,
saveProject,
loadProject,
exportOBJ,
} from "./engine.js";
import { point, dot, unit, clampZoom } from "./math.js";
const hit = { position: [0.19, 0.11, 0.97], normal: unit([0.19, 0.11, 0.97]) };
const near = (a, b, epsilon = 1e-6) =>
assert.ok(Math.abs(a - b) < epsilon, `${a} != ${b}`);
test("zoom limits use apparent scale, including portrait values outside landscape limits", () => {
const portrait = { width: 600, height: 1000 },
landscape = { width: 1000, height: 600 };
near(clampZoom(20, portrait), 20);
near(clampZoom(30, portrait), 14 / 0.6);
near(clampZoom(0.1, portrait), 0.2 / 0.6);
near(clampZoom(20, landscape), 14);
near(clampZoom(0.1, landscape), 0.2);
});
test("welded outward sphere has two faces per edge and expected topology", () => {
const m = sphere(4),
edges = new Map();
assert.equal(m.positions.length / 3, 2562);
assert.equal(m.indices.length / 3, 5120);
for (let f = 0; f < m.indices.length / 3; f++) {
const ids = m.indices.subarray(f * 3, f * 3 + 3);
assert.ok(dot(point(m.faceNormals, f), point(m.positions, ids[0])) > 0);
for (let k = 0; k < 3; k++) {
const a = ids[k],
b = ids[(k + 1) % 3],
key = [Math.min(a, b), Math.max(a, b)].join(":");
edges.set(key, (edges.get(key) || 0) + 1);
}
}
assert.ok([...edges.values()].every((n) => n === 2));
});
test("BVH agrees with brute-force picking and sphere queries after edits and undo", () => {
const s = new Sculpt(sphere(4));
const check = () => {
for (const origin of [
[0.19, 0.11, 4],
[-0.31, 0.42, 4],
[1.4, 0, 4],
[0.01, -0.99, 4],
]) {
const direction = [0, 0, -1];
let expected = null;
for (let f = 0; f < s.mesh.indices.length / 3; f++) {
const h = triangleHit(s.mesh, f, origin, direction);
if (h && (!expected || h.t < expected.t)) expected = h;
}
const actual = s.bvh.raycast(origin, direction);
assert.equal(!!actual, !!expected);
if (actual) near(actual.t, expected.t);
}
const expected = [];
for (let v = 0; v < s.mesh.positions.length / 3; v++)
if (
Math.hypot(
...point(s.mesh.positions, v).map((x, k) => x - hit.position[k]),
) < 0.37
)
expected.push(v);
assert.deepEqual(
s.bvh.within(hit.position, 0.37).sort((a, b) => a - b),
expected,
);
};
check();
s.begin({ radius: 0.37 });
for (let i = 0; i < 12; i++) s.dab(hit, 0.7);
s.end();
check();
s.restore();
check();
});
test("local normals equal full recomputation including brush boundary", () => {
const s = new Sculpt(sphere(4));
s.begin({ radius: 0.29, symmetry: false });
s.dab(hit);
s.end();
const local = s.mesh.normals.slice();
updateNormals(s.mesh);
local.forEach((v, i) => near(v, s.mesh.normals[i]));
});
test("pressure zero is a no-op; nonlinear pressure has the independently expected displacement ratio", () => {
assert.equal(pressureValue(0, 2, 0.5), 0);
assert.equal(pressureValue(0.5, 1, 2), 0.25);
const a = new Sculpt(sphere(4)),
b = new Sculpt(sphere(4));
const before = a.mesh.positions.slice();
a.begin({ symmetry: false });
a.dab(hit, 0);
a.end();
assert.equal(a.undoStack.length, 0);
for (const [s, p] of [
[a, 0.5],
[b, 1],
]) {
s.begin({ symmetry: false, gamma: 2 });
s.dab(hit, p);
s.end();
}
let da = 0,
db = 0;
before.forEach((x, i) => {
da += Math.abs(a.mesh.positions[i] - x);
db += Math.abs(b.mesh.positions[i] - x);
});
near(da / db, 0.25, 1e-4);
});
test("symmetry does not double strength on seam", () => {
const a = new Sculpt(sphere(4)),
b = new Sculpt(sphere(4));
for (const [s, symmetry] of [
[a, false],
[b, true],
]) {
s.begin({ symmetry });
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
s.end();
}
assert.deepEqual(a.mesh.positions, b.mesh.positions);
});
test("grab uses stroke origin, not repeated accumulated deltas; smooth is order independent", () => {
const a = new Sculpt(sphere(4)),
b = new Sculpt(sphere(4));
a.begin({ brush: "grab", symmetry: false });
b.begin({ brush: "grab", symmetry: false });
a.dab(hit, 1, [0.1, 0.04, 0]);
a.dab(hit, 1, [0.2, 0.08, 0]);
b.dab(hit, 1, [0.2, 0.08, 0]);
assert.deepEqual(a.mesh.positions, b.mesh.positions);
a.end();
b.end();
const original = b.bvh.within.bind(b.bvh);
b.bvh.within = (...args) => original(...args).reverse();
for (const s of [a, b]) {
s.begin({ brush: "smooth", symmetry: false });
s.dab(hit);
s.end();
}
assert.deepEqual(a.mesh.positions, b.mesh.positions);
});
test("exact history, cancel, redo truncation and byte cap", () => {
const s = new Sculpt(sphere(4), 12000),
original = s.mesh.positions.slice();
s.begin({});
s.dab(hit);
s.end();
const after = s.mesh.positions.slice();
assert.ok(s.historyBytes > 0);
s.restore();
assert.deepEqual(s.mesh.positions, original);
s.restore(true);
assert.deepEqual(s.mesh.positions, after);
s.begin({});
s.dab(hit);
s.end(true);
assert.deepEqual(s.mesh.positions, after);
assert.equal(s.undoStack.length, 1);
s.restore();
s.begin({ brush: "inflate" });
s.dab(hit);
s.end();
assert.equal(s.redoStack.length, 0);
for (let i = 0; i < 60; i++) {
s.begin({});
s.dab(hit);
s.end();
}
assert.ok(s.historyBytes <= 12000);
assert.ok(s.undoStack.length <= 50);
});
test("arc-length and interpolated pressure do not depend on event frequency", () => {
const run = (step) => {
const samples = [];
const path = new StrokePath(7, (p) => samples.push(p));
for (let x = 0; x <= 100; x += step)
path.push({ x, y: 0, pressure: x / 100 });
return samples;
};
const a = run(1),
b = run(25);
assert.equal(a.length, 15);
assert.equal(a.length, b.length);
a.forEach((p, i) => {
near(p.x, b[i].x);
near(p.pressure, b[i].pressure);
});
});
test("project roundtrip, malformed project rejection, OBJ indices", () => {
const s = new Sculpt(sphere(4));
s.begin({});
s.dab(hit);
s.end();
const mesh = loadProject(saveProject(s.mesh));
assert.deepEqual(mesh.positions, s.mesh.positions);
for (const data of [
{ version: 2 },
{ version: 1, level: 99, positions: [] },
{ version: 1, level: 4, positions: ["x"] },
])
assert.throws(() => loadProject(JSON.stringify(data)));
const obj = exportOBJ(mesh).split("\n");
assert.equal(obj.filter((l) => l.startsWith("v ")).length, 2562);
assert.equal(obj.filter((l) => l.startsWith("f ")).length, 5120);
});
test("directional symmetry and Grab strength preserve the seam and overlap envelope", () => {
const center = { position: [0, 0, 1], normal: [0, 0, 1] };
const a = new Sculpt(sphere(4)),
b = new Sculpt(sphere(4));
const before = a.mesh.positions.slice();
a.begin({ brush: "grab", symmetry: true, strength: 1 });
a.dab(center, 1, [0.2, 0, 0]);
a.end();
assert.deepEqual(a.mesh.positions, before);
for (const [s, symmetry] of [
[a, true],
[b, false],
]) {
s.begin({ brush: "grab", symmetry, strength: 0.5 });
s.dab(center, 1, [0, 0.12, 0.08]);
s.end();
}
assert.deepEqual(a.mesh.positions, b.mesh.positions);
const s = new Sculpt(sphere(4));
s.dirty.clear();
const revision = s.revision;
s.begin({ brush: "grab", strength: 0 });
s.dab(hit, 1, [0.3, 0.1, 0.2]);
s.end();
assert.equal(s.revision, revision);
assert.equal(s.dirty.size, 0);
assert.equal(s.undoStack.length, 0);
const original = s.mesh.positions.slice(),
radius = 0.4,
drag = [0.13, 0.09, 0.05];
s.begin({ brush: "grab", strength: 0.5, symmetry: true, radius });
s.dab(hit, 0.8, drag);
s.end();
let overlaps = 0;
for (let v = 0; v < original.length / 3; v++) {
const p = point(original, v),
normal = point(sphereReference.normals, v);
const weights = [1, -1].map((m) => {
const c = [hit.position[0] * m, ...hit.position.slice(1)],
n = [hit.normal[0] * m, ...hit.normal.slice(1)];
const d2 = p.reduce((sum, x, k) => sum + (x - c[k]) ** 2, 0);
return d2 < radius ** 2 && dot(normal, n) >= 0.05
? (1 - d2 / radius ** 2) ** 2
: 0;
});
if (weights.every((w) => w > 0) && Math.abs(weights[0] - weights[1]) > 1e-4)
overlaps++;
const total = weights[0] + weights[1];
if (!total) continue;
for (let k = 0; k < 3; k++) {
const blend = k === 0 ? weights[0] - weights[1] : total;
const expected =
k === 0 && p[0] === 0
? 0
: p[k] + (drag[k] * 0.5 * 0.8 * Math.max(...weights) * blend) / total;
near(s.mesh.positions[v * 3 + k], expected);
}
}
assert.ok(overlaps > 0);
});
const sphereReference = sphere(4);
test("breaking inside the sampler callback aborts interpolation and resets continuity", () => {
const samples = [];
let path;
path = new StrokePath(7, (p) => {
samples.push(Math.round(p.x));
if (p.x >= 14) path.break();
});
path.push({ x: 0, y: 0, pressure: 1 });
path.push({ x: 100, y: 0, pressure: 1 });
assert.deepEqual(samples, [0, 7, 14]);
assert.equal(path.last, null);
path.push({ x: 101, y: 0, pressure: 0.3 });
assert.deepEqual(samples, [0, 7, 14, 101]);
});
test("history count eviction retains exactly the latest fifty real commits", () => {
const s = new Sculpt(sphere(4), 64 * 1024 * 1024),
snapshots = [];
for (let i = 0; i < 55; i++) {
const previous = s.mesh.positions.slice();
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
s.begin({ brush: "grab", strength: 1, symmetry: false });
s.dab(h, 1, [0.001, 0.0005, 0]);
s.end();
assert.notDeepEqual(s.mesh.positions, previous);
snapshots.push(s.mesh.positions.slice());
assert.ok(s.historyBytes < 64 * 1024 * 1024);
}
assert.equal(s.undoStack.length, 50);
for (let i = 0; i < 50; i++) assert.equal(s.restore(), true);
assert.deepEqual(s.mesh.positions, snapshots[4]);
assert.equal(s.restore(), false);
for (let i = 0; i < 50; i++) assert.equal(s.restore(true), true);
assert.deepEqual(s.mesh.positions, snapshots[54]);
});
test("bounded-resolution benchmark (CPU only, not a device guarantee)", () => {
for (const level of [4, 5, 6]) {
const s = new Sculpt(sphere(level));
s.dirty.clear();
const times = [];
s.begin({ radius: 0.22 });
for (let i = 0; i < 35; i++) {
const start = performance.now();
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
s.dab(h, 0.65);
times.push(performance.now() - start);
}
s.end();
times.sort((a, b) => a - b);
let lo = Infinity,
hi = -1;
for (const v of s.dirty) {
lo = Math.min(lo, v);
hi = Math.max(hi, v);
}
console.log(
JSON.stringify({
level,
vertices: s.mesh.positions.length / 3,
p50ms: times[17].toFixed(2),
p95ms: times[33].toFixed(2),
uploadBytes: (hi - lo + 1) * 32,
historyBytes: s.historyBytes,
}),
);
}
});