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