Files
NoN-Site/nonpacks/static/js/tests/geo_builder.test.js
T

176 lines
7.9 KiB
JavaScript

// Unit tests for geo_builder.js — run with: node static/js/tests/geo_builder.test.js
// No framework: asserts, prints a summary, exits non-zero on failure.
'use strict';
const path = require('path');
const GeoBuilder = require(path.join(__dirname, '..', 'geo_builder.js'));
const zombie = require(path.join(__dirname, 'fixtures', 'zombie.geo.json'));
let failures = 0;
let checks = 0;
function approx(a, b, tol = 1e-6) {
return Math.abs(a - b) <= tol;
}
function assertVec(actual, expected, label, tol = 1e-6) {
checks++;
const ok = approx(actual[0], expected[0], tol) && approx(actual[1], expected[1], tol) && approx(actual[2], expected[2], tol);
if (!ok) {
failures++;
console.error(`FAIL ${label}: got [${actual.map(v => v.toFixed(4)).join(', ')}] expected [${expected.join(', ')}]`);
}
}
function assert(cond, label) {
checks++;
if (!cond) { failures++; console.error(`FAIL ${label}`); }
}
function worldCenter(matrix) {
return GeoBuilder.transformPoint(matrix, [0, 0, 0]);
}
// ---------- 1. Real zombie model (nested hierarchy, identity rotations) ----------
const built = GeoBuilder.build(zombie);
const tw = built.texture_width, th = built.texture_height;
assert(built.cubes.length === 8, `zombie cube count (got ${built.cubes.length})`);
const centers = built.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
function cubeNear(center) {
return centers.filter(({ center: c }) =>
approx(c[0], center[0], 1e-6) && approx(c[1], center[1], 1e-6) && approx(c[2], center[2], 1e-6)
).map(x => x.c);
}
assert(cubeNear([0, 28, 0]).length >= 2, 'head + headwear at (0,28,0)'); // head & headwear
assert(cubeNear([0, 18, 0]).length === 1, 'body at (0,18,0)');
assert(cubeNear([6, 18, 0]).length === 1, 'leftarm at (6,18,0)');
assert(cubeNear([-6, 18, 0]).length === 1, 'rightarm at (-6,18,0)');
assert(cubeNear([2, 6, 0]).length === 1, 'leftleg at (2,6,0)');
assert(cubeNear([-2, 6, 0]).length === 1, 'rightleg at (-2,6,0)');
assert(cubeNear([0, 11.75, -3.5]).length === 1, 'd at (0,11.75,-3.5)');
// Head = the (0,28,0) cube with uv [0,0]; headwear has uv [32,0].
const headCube = cubeNear([0, 28, 0]).find(c => c.uv[0] === 0 && c.uv[1] === 0);
assert(!!headCube, 'head cube identified');
if (headCube) {
// Head north face (last face) must sample the vanilla (8..16, 8..16) region.
const geo = GeoBuilder.cubeGeometry(headCube.size, headCube.uv, tw, th);
const northStart = 5 * 8; // face order: east,west,top,bottom,south,north
let minU = 2, maxU = -1, minV = 2, maxV = -1;
for (let i = 0; i < 8; i += 2) {
minU = Math.min(minU, geo.uvs[northStart + i]);
maxU = Math.max(maxU, geo.uvs[northStart + i]);
minV = Math.min(minV, geo.uvs[northStart + i + 1]);
maxV = Math.max(maxV, geo.uvs[northStart + i + 1]);
}
assert(approx(minU, 8 / 64) && approx(maxU, 16 / 64), `head north U in (8..16)/64 (got ${minU.toFixed(3)}..${maxU.toFixed(3)})`);
assert(approx(minV, 1 - 16 / 64) && approx(maxV, 1 - 8 / 64), `head north V in [0.75,0.875] (got ${minV.toFixed(3)}..${maxV.toFixed(3)})`);
}
// All UVs within [0,1].
let allInRange = true;
for (const { c } of centers) {
const g = GeoBuilder.cubeGeometry(c.size, c.uv, tw, th);
for (const u of g.uvs) if (u < 0 || u > 1) { allInRange = false; break; }
}
assert(allInRange, 'all UVs within [0,1]');
// Unmapped bones default to the pack's main texture.
assert(headCube.texture === 'needsofnature:textures/entity/zombie/zombie.png', 'head uses default texture');
// ---------- 2. Synthetic rotated bone: Rz(-90°) around pivot (0,10,0) ----------
// Rotations are negated to match the Bedrock/GeckoLib direction.
const rotated = {
'minecraft:geometry': [{
description: { identifier: 'geometry.t', texture_width: 64, texture_height: 64 },
bones: [{
name: 'waist', pivot: [0, 10, 0], rotation: [0, 0, 90],
cubes: [{ origin: [0, 10, 0], size: [2, 2, 2], uv: [0, 0] }],
}],
}],
};
const rotBuilt = GeoBuilder.build(rotated);
assert(rotBuilt.cubes.length === 1, 'rotated model has one cube');
// cube center = origin + size/2 = (1,11,1); Rz(-90) around pivot (0,10,0):
// rel (1,1,1) -> (1,-1,1) -> +pivot = (1,9,1)
const rotCenter = worldCenter(rotBuilt.cubes[0].matrix);
assertVec(rotCenter, [1, 9, 1], 'rotated cube center around pivot (expect (1,9,1))');
// ---------- 3. Nested pivot with rotation ----------
const nested = {
'minecraft:geometry': [{
description: { identifier: 'geometry.n', texture_width: 64, texture_height: 64 },
bones: [
{ name: 'root', pivot: [0, 0, 0] },
{ name: 'waist', pivot: [0, 12, 0], parent: 'root', rotation: [0, 0, 0] },
{ name: 'head', pivot: [0, 24, 0], parent: 'waist',
cubes: [{ origin: [-4, 24, -4], size: [8, 8, 8], uv: [0, 0] }] },
],
}],
};
const nestedBuilt = GeoBuilder.build(nested);
assertVec(worldCenter(nestedBuilt.cubes[0].matrix), [0, 28, 0], 'nested pivot head center (expect (0,28,0))');
// ---------- 4. Real wolf model: rotated bones must produce a quadruped layout ----------
const wolf = require(path.join(__dirname, 'fixtures', 'wolf.geo.json'));
const wolfBuilt = GeoBuilder.build(wolf);
const wolfCenters = wolfBuilt.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
const wBody = wolfCenters.find(({ c }) => c.size[0] === 8 && c.size[1] === 6 && c.size[2] === 7);
assert(!!wBody, 'wolf body cube found');
if (wBody) {
// Body high (y~10.5) and in the front half (z<2). Under the old rotation
// sign the body landed at the back (z=7) — this catches the regression.
assertVec(wBody.center, [-1, 10.5, -3], 'wolf body center (expect (-1,10.5,-3))');
}
const wHead = wolfCenters.find(({ c }) => c.size[0] === 6 && c.size[1] === 6 && c.size[2] === 4);
assert(!!wHead, 'wolf head cube found');
if (wHead) {
assertVec(wHead.center, [-1, 10.5, -7], 'wolf head center (expect (-1,10.5,-7))');
}
// 4 legs (size 2x8x2 cubes whose y is near the ground).
const wLegs = wolfCenters.filter(({ c, center }) => c.size[0] === 2 && c.size[1] === 8 && c.size[2] === 2 && center[1] < 6);
assert(wLegs.length === 4, `wolf has 4 legs near the ground (got ${wLegs.length})`);
wLegs.forEach(({ center }) => assert(approx(center[1], 4.0, 0.2), `leg at ground level y=${center[1].toFixed(1)}`));
// Tail: the cube with the largest |z|, behind the body.
let wTail = wolfCenters[0];
for (const w of wolfCenters) {
if (Math.abs(w.center[2]) > Math.abs(wTail.center[2])) wTail = w;
}
assert(wTail.center[2] > 8, `wolf tail behind body (z=${wTail.center[2].toFixed(1)})`);
assert(wBody && wTail.center[2] > wBody.center[2], 'tail z > body z');
// ---------- 5. Real polar bear: cube-level pivot + rotation ----------
// The polar bear's rear torso cube has its own pivot/rotation; rotating around
// the cube origin (not its pivot) sent it flying to z≈48. Regression test.
const polar = require(path.join(__dirname, 'fixtures', 'polar_bear.geo.json'));
const polarBuilt = GeoBuilder.build(polar);
const polarCubes = polarBuilt.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
const pRear = polarCubes.find(({ c }) => c.size[0] === 18.2 && c.size[1] === 18.2 && c.size[2] === 14.3);
assert(!!pRear, 'polar bear rear torso cube found');
if (pRear) {
assertVec(pRear.center, [0, 17.55, 7.8], 'polar bear rear torso center (expect (0,17.55,7.8))');
}
const pFront = polarCubes.find(({ c }) => c.size[0] === 15.6 && c.size[1] === 15.6 && c.size[2] === 13);
if (pFront) {
// Rear torso sits behind and at the same height as the front torso.
assert(pRear.center[2] > pFront.center[2], 'rear torso behind front torso');
assert(approx(pRear.center[1], pFront.center[1], 1.5), 'rear + front torso at similar height');
}
// ---------- summary ----------
if (failures === 0) {
console.log(`geo_builder tests OK (${checks} checks)`);
} else {
console.error(`${failures}/${checks} checks FAILED`);
process.exit(1);
}