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 %}