diff --git a/nonpacks/static/js/geo_builder.js b/nonpacks/static/js/geo_builder.js
new file mode 100644
index 0000000..081bafa
--- /dev/null
+++ b/nonpacks/static/js/geo_builder.js
@@ -0,0 +1,243 @@
+// Pure geometry math for GeckoLib/Blockbench geo.json models.
+// No Three.js dependency — shared by model_viewer.js (browser) and unit tests (Node).
+//
+// Transforms follow the GeckoLib convention: each bone contributes
+// local = T(pivot) · R(bone.rotation) · T(−pivot)
+// and cubes are authored in model space (origin at feet, Y up).
+// `build()` flattens the bone tree into world-space cubes, avoiding the
+// nested-pivot double-counting that broke naive group-based renderers.
+(function (global, factory) {
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = factory();
+ } else {
+ global.GeoBuilder = factory();
+ }
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ var DEG = Math.PI / 180;
+
+ // ---------- minimal 4x4 matrix helpers (column-major, Three.js order) ----------
+ function identity() {
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
+ }
+
+ function multiply(a, b) {
+ var out = new Array(16);
+ for (var c = 0; c < 4; c++) {
+ for (var r = 0; r < 4; r++) {
+ out[c * 4 + r] =
+ a[0 * 4 + r] * b[c * 4 + 0] +
+ a[1 * 4 + r] * b[c * 4 + 1] +
+ a[2 * 4 + r] * b[c * 4 + 2] +
+ a[3 * 4 + r] * b[c * 4 + 3];
+ }
+ }
+ return out;
+ }
+
+ function translation(x, y, z) {
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
+ }
+
+ function rotationXYZ(rx, ry, rz) {
+ var cx = Math.cos(rx), sx = Math.sin(rx);
+ var cy = Math.cos(ry), sy = Math.sin(ry);
+ var cz = Math.cos(rz), sz = Math.sin(rz);
+ // R = Rz * Ry * Rx (matches Three.js Euler 'XYZ' default order)
+ var r = identity();
+ r[0] = cy * cz;
+ r[1] = cy * sz;
+ r[2] = -sy;
+ r[4] = sx * sy * cz - cx * sz;
+ r[5] = sx * sy * sz + cx * cz;
+ r[6] = sx * cy;
+ r[8] = cx * sy * cz + sx * sz;
+ r[9] = cx * sy * sz - sx * cz;
+ r[10] = cx * cy;
+ return r;
+ }
+
+ function transformPoint(m, p) {
+ var x = p[0], y = p[1], z = p[2];
+ var w = m[3] * x + m[7] * y + m[11] * z + m[15];
+ return [
+ (m[0] * x + m[4] * y + m[8] * z + m[12]) / w,
+ (m[1] * x + m[5] * y + m[9] * z + m[13]) / w,
+ (m[2] * x + m[6] * y + m[10] * z + m[14]) / w,
+ ];
+ }
+
+ // ---------- box UV ----------
+ // Minecraft "box UV" face rects (u, v, w, h) in texture pixels.
+ function boxUVRects(uv, size) {
+ var u = uv[0], v = uv[1];
+ var x = size[0], y = size[1], z = size[2];
+ return {
+ top: [u + z, v, x, z],
+ bottom: [u + z + x, v, x, z],
+ north: [u + z, v + z, x, y],
+ south: [u + z + x, v + z, x, y],
+ east: [u, v + z, z, y],
+ west: [u + z + x + z, v + z, z, y],
+ };
+ }
+
+ // Build one cube as a BufferGeometry-compatible mesh:
+ // 24 positions (6 faces x 4 corners), 24 UVs, 36 indices, outward winding.
+ function cubeGeometry(size, uv, tw, th) {
+ var hx = size[0] / 2, hy = size[1] / 2, hz = size[2] / 2;
+ var rects = boxUVRects(uv, size);
+ var order = ['east', 'west', 'top', 'bottom', 'south', 'north'];
+ var outward = {
+ east: [1, 0, 0], west: [-1, 0, 0],
+ top: [0, 1, 0], bottom: [0, -1, 0],
+ south: [0, 0, 1], north: [0, 0, -1],
+ };
+
+ var positions = [];
+ var uvs = [];
+ var indices = [];
+ var v = 0;
+
+ function pushFace(face, corners) {
+ var a = corners[0], b = corners[1], c = corners[2];
+ var abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
+ var acx = c[0] - a[0], acy = c[1] - a[1], acz = c[2] - a[2];
+ var nx = aby * acz - abz * acy;
+ var ny = abz * acx - abx * acz;
+ var nz = abx * acy - aby * acx;
+ var out = outward[face];
+ if (nx * out[0] + ny * out[1] + nz * out[2] < 0) {
+ var tmp = corners[1];
+ corners[1] = corners[2];
+ corners[2] = tmp;
+ }
+ for (var i = 0; i < 4; i++) {
+ var cor = corners[i];
+ positions.push(cor[0], cor[1], cor[2]);
+ uvs.push(cor[3], cor[4]);
+ }
+ indices.push(v, v + 1, v + 2, v, v + 2, v + 3);
+ v += 4;
+ }
+
+ for (var i = 0; i < order.length; i++) {
+ var face = order[i];
+ var rect = rects[face];
+ var u0 = rect[0] / tw, u1 = (rect[0] + rect[2]) / tw;
+ var vTop = 1 - (rect[1] + rect[3]) / th;
+ var vBot = 1 - rect[1] / th;
+ var cs;
+ if (face === 'east') {
+ cs = [
+ [hx, hy, -hz, u1, vTop], [hx, hy, hz, u0, vTop],
+ [hx, -hy, hz, u0, vBot], [hx, -hy, -hz, u1, vBot],
+ ];
+ } else if (face === 'west') {
+ cs = [
+ [-hx, hy, hz, u1, vTop], [-hx, hy, -hz, u0, vTop],
+ [-hx, -hy, -hz, u0, vBot], [-hx, -hy, hz, u1, vBot],
+ ];
+ } else if (face === 'top') {
+ cs = [
+ [-hx, hy, hz, u0, vTop], [hx, hy, hz, u1, vTop],
+ [hx, hy, -hz, u1, vBot], [-hx, hy, -hz, u0, vBot],
+ ];
+ } else if (face === 'bottom') {
+ cs = [
+ [-hx, -hy, -hz, u0, vTop], [hx, -hy, -hz, u1, vTop],
+ [hx, -hy, hz, u1, vBot], [-hx, -hy, hz, u0, vBot],
+ ];
+ } else if (face === 'south') {
+ cs = [
+ [hx, hy, hz, u0, vTop], [-hx, hy, hz, u1, vTop],
+ [-hx, -hy, hz, u1, vBot], [hx, -hy, hz, u0, vBot],
+ ];
+ } else { // north
+ cs = [
+ [-hx, hy, -hz, u0, vTop], [hx, hy, -hz, u1, vTop],
+ [hx, -hy, -hz, u1, vBot], [-hx, -hy, -hz, u0, vBot],
+ ];
+ }
+ pushFace(face, cs);
+ }
+
+ return { positions: positions, uvs: uvs, indices: indices };
+ }
+
+ // ---------- model build ----------
+ // Returns { cubes: [{matrix, size, uv, texture}], texture_width, texture_height }
+ function build(geo) {
+ var geometry = ((geo['minecraft:geometry'] || [])[0]) || null;
+ if (!geometry) return { cubes: [], texture_width: 64, texture_height: 64 };
+ var tw = (geometry.description && geometry.description.texture_width) || 64;
+ var th = (geometry.description && geometry.description.texture_height) || 64;
+ var textures = geo['afw_bone_textures'] || {};
+ var defaultTex = null;
+ for (var k in textures) { defaultTex = textures[k]; break; }
+
+ var bones = {};
+ (geometry.bones || []).forEach(function (b) { bones[b.name] = b; });
+
+ var cubes = [];
+ function walk(name, parentMatrix) {
+ var bone = bones[name];
+ if (!bone) return;
+ var pivot = bone.pivot || [0, 0, 0];
+ var rot = (bone.rotation || [0, 0, 0]).map(function (d) { return d * DEG; });
+ var local = multiply(
+ translation(pivot[0], pivot[1], pivot[2]),
+ multiply(
+ rotationXYZ(rot[0], rot[1], rot[2]),
+ translation(-pivot[0], -pivot[1], -pivot[2])
+ )
+ );
+ var world = multiply(parentMatrix, local);
+ var tex = textures[name] || defaultTex;
+
+ (bone.cubes || []).forEach(function (c) {
+ var origin = c.origin || [0, 0, 0];
+ var size = c.size || [1, 1, 1];
+ var cRot = (c.rotation || [0, 0, 0]).map(function (d) { return d * DEG; });
+ var cubeWorld = multiply(
+ world,
+ multiply(
+ translation(origin[0], origin[1], origin[2]),
+ multiply(
+ rotationXYZ(cRot[0], cRot[1], cRot[2]),
+ translation(size[0] / 2, size[1] / 2, size[2] / 2)
+ )
+ )
+ );
+ cubes.push({
+ matrix: cubeWorld,
+ size: size,
+ uv: c.uv || [0, 0],
+ texture: tex,
+ });
+ });
+
+ for (var child in bones) {
+ if (bones[child].parent === name) walk(child, world);
+ }
+ }
+
+ for (var root in bones) {
+ if (!bones[root].parent) walk(root, identity());
+ }
+
+ return { cubes: cubes, texture_width: tw, texture_height: th };
+ }
+
+ return {
+ build: build,
+ cubeGeometry: cubeGeometry,
+ boxUVRects: boxUVRects,
+ transformPoint: transformPoint,
+ identity: identity,
+ translation: translation,
+ rotationXYZ: rotationXYZ,
+ multiply: multiply,
+ };
+});
diff --git a/nonpacks/static/js/model_viewer.js b/nonpacks/static/js/model_viewer.js
index 074170f..a4f69d9 100644
--- a/nonpacks/static/js/model_viewer.js
+++ b/nonpacks/static/js/model_viewer.js
@@ -1,126 +1,55 @@
-// Minimal GeckoLib/Blockbench geo.json viewer (Three.js) for the Models tab.
-// Renders cubes with the standard "box UV" layout and the NoN `afw_bone_textures`
-// map (bone → texture), textures served through the gated pack_asset endpoint.
+// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder
+// geometry math (bone transforms + box-UV) and renders world-space cubes with
+// Three.js. Textures are served through the gated pack_asset endpoint.
(function () {
let renderer = null;
let scene = null;
let camera = null;
let meshRoot = null;
let rafId = null;
+ const textureLoader = new THREE.TextureLoader();
+ const materialCache = {};
function assetUrl(baseUrl, member) {
return baseUrl.replace('ASSET', member);
}
- function boneChildren(map, name, rootName) {
- return Object.keys(map).filter(
- b => map[b].parent === name || (name === rootName && !map[b].parent)
- );
+ function buildGeometry(size, uv, tw, th) {
+ const g = GeoBuilder.cubeGeometry(size, uv, tw, th);
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(g.positions, 3));
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs, 2));
+ geometry.setIndex(g.indices);
+ return geometry;
}
- // Minecraft "box UV" face rects (u, v, w, h) in texture pixels.
- function boxUVRects(uv, size) {
- const u = uv[0], v = uv[1];
- const x = size[0], y = size[1], z = size[2];
- return {
- top: [u + z, v, x, z],
- bottom: [u + z + x, v, x, z],
- north: [u + z, v + z, x, y],
- south: [u + z + x, v + z, x, y],
- east: [u, v + z, z, y],
- west: [u + z + x + z, v + z, z, y],
- };
- }
-
- function assignBoxUVs(geometry, uv, tw, th) {
- // Three.js BoxGeometry groups: 0=px(east) 1=nx(west) 2=py(top) 3=ny(bottom) 4=pz(south) 5=nz(north)
- const rects = boxUVRects(uv, [
- geometry.parameters.width,
- geometry.parameters.height,
- geometry.parameters.depth,
- ]);
- const order = ['east', 'west', 'top', 'bottom', 'south', 'north'];
- const uvs = geometry.attributes.uv;
- const positions = geometry.attributes.position;
- for (let g = 0; g < geometry.groups.length; g++) {
- const face = order[g];
- if (!face) break;
- const [ru, rv, rw, rh] = rects[face];
- const u0 = ru / tw;
- const v0 = 1 - (rv + rh) / th; // flip: image v=0 is the top
- const u1 = (ru + rw) / tw;
- const v1 = 1 - rv / th;
- const start = geometry.groups[g].start;
- const count = geometry.groups[g].count;
- for (let i = start; i < start + count; i++) {
- // Keep U/V in order (positions already give 0..1 corners per face).
- const px = positions.getX(i), py = positions.getY(i), pz = positions.getZ(i);
- let U, V;
- if (face === 'east' || face === 'west') { U = (pz + 0.5) * (u1 - u0) + u0; V = (py + 0.5) * (v1 - v0) + v0; }
- else if (face === 'top' || face === 'bottom') { U = (px + 0.5) * (u1 - u0) + u0; V = (pz + 0.5) * (v1 - v0) + v0; }
- else { U = (px + 0.5) * (u1 - u0) + u0; V = (py + 0.5) * (v1 - v0) + v0; }
- uvs.setXY(i, U, V);
- }
- }
- uvs.needsUpdate = true;
- }
-
- function buildBone(map, name, textures, tw, th, parentGroup) {
- const bone = map[name];
- const group = new THREE.Group();
- if (bone.pivot) group.position.set(bone.pivot[0], bone.pivot[1], bone.pivot[2]);
- if (bone.rotation) {
- const r = bone.rotation;
- group.rotation.order = 'XYZ';
- group.rotation.set(r[0] * Math.PI / 180, r[1] * Math.PI / 180, r[2] * Math.PI / 180);
- }
-
- const texMember = textures[name];
+ function materialFor(member, baseUrl) {
+ if (materialCache[member || '__gray__']) return materialCache[member || '__gray__'];
let material;
- if (texMember) {
- const loader = new THREE.TextureLoader();
- const texture = loader.load(assetUrl(window.__PACK_ASSET_BASE__ || '', texMember));
+ if (member) {
+ const texture = textureLoader.load(assetUrl(baseUrl, member));
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 });
} else {
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
}
-
- for (const cube of bone.cubes || []) {
- const size = new THREE.Vector3(cube.size[0], cube.size[1], cube.size[2]);
- const geometry = new THREE.BoxGeometry(size.x, size.y, size.z);
- assignBoxUVs(geometry, cube.uv || [0, 0], tw, th);
- const mesh = new THREE.Mesh(geometry, material);
- const cx = cube.origin[0] + size.x / 2;
- const cy = cube.origin[1] + size.y / 2;
- const cz = cube.origin[2] + size.z / 2;
- mesh.position.set(cx, cy, cz);
- if (cube.rotation) {
- const r = cube.rotation;
- mesh.rotation.set(r[0] * Math.PI / 180, r[1] * Math.PI / 180, r[2] * Math.PI / 180);
- }
- group.add(mesh);
- }
-
- for (const child of boneChildren(map, name, null)) {
- buildBone(map, child, textures, tw, th, group);
- }
- parentGroup.add(group);
+ materialCache[member || '__gray__'] = material;
+ return material;
}
function disposeObject(obj) {
obj.traverse((node) => {
if (node.geometry) node.geometry.dispose();
- if (node.material) {
- if (node.material.map) node.material.map.dispose();
- node.material.dispose();
- }
});
+ for (const key in materialCache) {
+ if (materialCache[key].map) materialCache[key].map.dispose();
+ materialCache[key].dispose();
+ }
+ for (const key in materialCache) delete materialCache[key];
}
function render(member, container, baseUrl) {
- window.__PACK_ASSET_BASE__ = baseUrl;
if (renderer) dispose();
const width = container.clientWidth || 480;
const height = container.clientHeight || 480;
@@ -147,22 +76,19 @@
meshRoot = new THREE.Group();
scene.add(meshRoot);
- const url = assetUrl(baseUrl, member);
- fetch(url, { headers: { 'Accept': 'application/json' } })
+ fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } })
.then(r => { if (!r.ok) throw new Error('http'); return r.json(); })
.then(geo => {
- const geometry = (geo['minecraft:geometry'] || [])[0];
- if (!geometry) throw new Error('no geometry');
- const desc = geometry.description || {};
- const tw = desc.texture_width || 64;
- const th = desc.texture_height || 64;
- const textures = geo['afw_bone_textures'] || {};
- const map = {};
- (geometry.bones || []).forEach(b => { map[b.name] = b; });
- const roots = Object.keys(map).filter(b => !map[b].parent);
- roots.forEach(root => buildBone(map, root, textures, tw, th, meshRoot));
+ const built = GeoBuilder.build(geo);
+ if (!built.cubes.length) throw new Error('no cubes');
+ for (const cube of built.cubes) {
+ const geometry = buildGeometry(cube.size, cube.uv, built.texture_width, built.texture_height);
+ const mesh = new THREE.Mesh(geometry, materialFor(cube.texture, baseUrl));
+ mesh.matrix = new THREE.Matrix4().fromArray(cube.matrix);
+ mesh.matrixAutoUpdate = false;
+ meshRoot.add(mesh);
+ }
- // Fit camera to bounds.
const box = new THREE.Box3().setFromObject(meshRoot);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
diff --git a/nonpacks/static/js/tests/fixtures/zombie.geo.json b/nonpacks/static/js/tests/fixtures/zombie.geo.json
new file mode 100644
index 0000000..195e1e2
--- /dev/null
+++ b/nonpacks/static/js/tests/fixtures/zombie.geo.json
@@ -0,0 +1,113 @@
+{
+ "format_version": "1.12.0",
+ "afw_bone_textures": {
+ "body": "needsofnature:textures/entity/zombie/zombie.png",
+ "leftleg": "needsofnature:textures/entity/zombie/zombie.png",
+ "rightleg": "needsofnature:textures/entity/zombie/zombie.png",
+ "d": "needsofnature:textures/entity/zombie/zombie.png"
+ },
+ "minecraft:geometry": [
+ {
+ "description": {
+ "identifier": "geometry.unknown",
+ "texture_width": 64,
+ "texture_height": 64,
+ "visible_bounds_width": 2,
+ "visible_bounds_height": 3.5,
+ "visible_bounds_offset": [0, 1.25, 0]
+ },
+ "bones": [
+ {
+ "name": "root",
+ "pivot": [0, 0, 0]
+ },
+ {
+ "name": "zombie",
+ "parent": "root",
+ "pivot": [0, 0, 0]
+ },
+ {
+ "name": "waist",
+ "parent": "zombie",
+ "pivot": [0, 12, 0]
+ },
+ {
+ "name": "head",
+ "parent": "waist",
+ "pivot": [0, 24, 0],
+ "cubes": [
+ {"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [0, 0]}
+ ]
+ },
+ {
+ "name": "headwear",
+ "parent": "waist",
+ "pivot": [0, 24, 0],
+ "cubes": [
+ {"origin": [-4, 24, -4], "size": [8, 8, 8], "inflate": 0.5, "uv": [32, 0]}
+ ]
+ },
+ {
+ "name": "body",
+ "parent": "waist",
+ "pivot": [0, 24, 0],
+ "cubes": [
+ {"origin": [-4, 12, -2], "size": [8, 12, 4], "uv": [16, 16]}
+ ]
+ },
+ {
+ "name": "d",
+ "parent": "body",
+ "pivot": [0, 12, 0],
+ "cubes": [
+ {"origin": [-1, 10.75, -6], "size": [2, 2, 5], "inflate": -0.3, "uv": [17, 8]}
+ ]
+ },
+ {
+ "name": "leftarm",
+ "parent": "waist",
+ "pivot": [5, 22, 0],
+ "mirror": true,
+ "cubes": [
+ {"origin": [4, 12, -2], "size": [4, 12, 4], "uv": [40, 16]}
+ ]
+ },
+ {
+ "name": "propleft",
+ "parent": "leftarm",
+ "pivot": [6, 12, 0]
+ },
+ {
+ "name": "rightarm",
+ "parent": "waist",
+ "pivot": [-5, 22, 0],
+ "cubes": [
+ {"origin": [-8, 12, -2], "size": [4, 12, 4], "uv": [40, 16]}
+ ]
+ },
+ {
+ "name": "propright",
+ "parent": "rightarm",
+ "pivot": [-6, 12, 0]
+ },
+ {
+ "name": "leftleg",
+ "parent": "zombie",
+ "pivot": [1.9, 12, 0],
+ "mirror": true,
+ "cubes": [
+ {"origin": [0, 0, -2], "size": [4, 12, 4], "uv": [0, 16]}
+ ]
+ },
+ {
+ "name": "rightleg",
+ "parent": "zombie",
+ "pivot": [-1.9, 12, 0],
+ "cubes": [
+ {"origin": [-4, 0, -2], "size": [4, 12, 4], "uv": [0, 16]}
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/nonpacks/static/js/tests/geo_builder.test.js b/nonpacks/static/js/tests/geo_builder.test.js
new file mode 100644
index 0000000..57e9427
--- /dev/null
+++ b/nonpacks/static/js/tests/geo_builder.test.js
@@ -0,0 +1,124 @@
+// 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) ----------
+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); Rz90 around pivot (0,10,0):
+// rel (1,1,1) -> (-1,1,1) -> +pivot = (-1,11,1)
+const rotCenter = worldCenter(rotBuilt.cubes[0].matrix);
+assertVec(rotCenter, [-1, 11, 1], 'rotated cube center around pivot (expect (-1,11,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))');
+
+// ---------- summary ----------
+if (failures === 0) {
+ console.log(`geo_builder tests OK (${checks} checks)`);
+} else {
+ console.error(`${failures}/${checks} checks FAILED`);
+ process.exit(1);
+}
diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html
index f15c45f..ad797c6 100644
--- a/nonpacks/templates/library/project_detail.html
+++ b/nonpacks/templates/library/project_detail.html
@@ -403,6 +403,7 @@
{% block extra_js %}
{% if models_manifest %}
+
{% endif %}