// 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. (function () { let renderer = null; let scene = null; let camera = null; let meshRoot = null; let rafId = null; 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) ); } // 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]; let material; if (texMember) { const loader = new THREE.TextureLoader(); const texture = loader.load(assetUrl(window.__PACK_ASSET_BASE__ || '', texMember)); 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); } 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(); } }); } function render(member, container, baseUrl) { window.__PACK_ASSET_BASE__ = baseUrl; if (renderer) dispose(); const width = container.clientWidth || 480; const height = container.clientHeight || 480; scene = new THREE.Scene(); scene.background = new THREE.Color(0x1e1e2e); camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 2000); camera.position.set(0, 20, 60); camera.lookAt(0, 12, 0); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio || 1); container.appendChild(renderer.domElement); scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(30, 60, 30); scene.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.3); fill.position.set(-30, 20, -30); scene.add(fill); meshRoot = new THREE.Group(); scene.add(meshRoot); const url = assetUrl(baseUrl, member); fetch(url, { 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)); // 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()); const radius = Math.max(size.x, size.y, size.z) / 2 || 10; meshRoot.position.sub(center); camera.position.set(radius * 2.2, radius * 1.6, radius * 2.6); camera.near = radius / 10; camera.far = radius * 40; camera.updateProjectionMatrix(); camera.lookAt(0, 0, 0); }) .catch(() => { container.innerHTML = '

Could not load this model.

'; }); let isDragging = false; let lastX = 0, lastY = 0; renderer.domElement.addEventListener('mousedown', (e) => { isDragging = true; lastX = e.clientX; lastY = e.clientY; }); window.addEventListener('mouseup', () => { isDragging = false; }); window.addEventListener('mousemove', (e) => { if (!isDragging || !meshRoot) return; const dx = e.clientX - lastX, dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; meshRoot.rotation.y += dx * 0.01; meshRoot.rotation.x += dy * 0.01; }); renderer.domElement.addEventListener('wheel', (e) => { e.preventDefault(); camera.position.multiplyScalar(e.deltaY > 0 ? 1.06 : 0.94); }, { passive: false }); function animate() { rafId = requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); } function dispose() { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (renderer) { if (meshRoot) disposeObject(meshRoot); renderer.dispose(); if (renderer.domElement && renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } } renderer = null; scene = null; camera = null; meshRoot = null; } window.PacksModelViewer = { render: render, dispose: dispose }; })();