// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder // geometry math (bone transforms + box/per-face UV) and renders world-space // cubes with Three.js. Textures are served through the gated pack_asset and // vanilla_asset endpoints, resolved per cube: // 1. afw_bone_textures (pack member), composited over the vanilla entity // skin when one exists (NoN `*_features` overlays sit on the vanilla base) // 2. the model's default pack skin (server-matched) // 3. the bundled vanilla entity skin // 4. gray (function () { let renderer = null; let scene = null; let camera = null; let meshRoot = null; let rafId = null; const textureLoader = new THREE.TextureLoader(); const materialCache = {}; let vanillaIndex = null; let vanillaIndexPromise = null; function assetUrl(baseUrl, member) { return baseUrl.replace('ASSET', member); } function entityNameFromMember(member) { let base = member.slice(member.lastIndexOf('/') + 1); if (base.endsWith('.geo.json')) base = base.slice(0, -'.geo.json'.length); for (const suffix of ['.mf', '.fm', '.m', '.f', '.g']) { if (base.endsWith(suffix)) { base = base.slice(0, -suffix.length); break; } } return base; } 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); geometry.computeVertexNormals(); return geometry; } function fetchVanillaIndex(opts) { if (vanillaIndexPromise) return vanillaIndexPromise; if (!opts.vanillaBaseUrl) { vanillaIndexPromise = Promise.resolve({}); } else { vanillaIndexPromise = fetch(assetUrl(opts.vanillaBaseUrl, 'entity_index.json')) .then(r => r.ok ? r.json() : {}) .catch(() => ({})); } return vanillaIndexPromise; } function vanillaSkinUrl(opts, index, entity) { const rel = (index && index[entity]) || null; return rel ? assetUrl(opts.vanillaBaseUrl, 'entity/' + rel) : null; } // desc: { url, vanillaUrl } — url is the pack/overlay member, vanillaUrl the // base skin to composite it over (when available). function resolveTexture(cube, entity, opts, index) { if (cube.texture) { return { url: assetUrl(opts.baseUrl, cube.texture), vanillaUrl: vanillaSkinUrl(opts, index, entity), }; } if (opts.defaultTexture) { return { url: assetUrl(opts.baseUrl, opts.defaultTexture), vanillaUrl: null }; } const rel = (index && index[entity]) || null; if (rel) { return { url: null, vanillaUrl: vanillaSkinUrl(opts, index, entity) }; } return null; } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('img')); img.src = url; }); } async function prepareMaterial(desc, tw, th) { const key = (desc.url || '') + '|' + (desc.vanillaUrl || ''); if (materialCache[key]) return materialCache[key]; let texture = null; if (desc.url && desc.vanillaUrl) { try { const [overlay, base] = await Promise.all([loadImage(desc.url), loadImage(desc.vanillaUrl)]); const canvas = document.createElement('canvas'); canvas.width = tw; canvas.height = th; const ctx = canvas.getContext('2d'); ctx.drawImage(base, 0, 0, tw, th); ctx.drawImage(overlay, 0, 0, tw, th); texture = new THREE.CanvasTexture(canvas); } catch (e) { texture = null; } } const singleUrl = texture ? null : (desc.url || desc.vanillaUrl); if (singleUrl) texture = textureLoader.load(singleUrl); let material; if (texture) { texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 }); if (singleUrl) { textureLoader.load(singleUrl, undefined, undefined, () => { if (material.map) material.map.dispose(); material.map = null; material.color.setHex(0x9a9a9a); material.needsUpdate = true; }); } } else { material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 }); } materialCache[key] = material; return material; } function disposeObject(obj) { obj.traverse((node) => { if (node.geometry) node.geometry.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, opts) { 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, preserveDrawingBuffer: 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 entity = entityNameFromMember(member); fetch(assetUrl(opts.baseUrl, member), { headers: { 'Accept': 'application/json' } }) .then(r => { if (!r.ok) throw new Error('http'); return r.json(); }) .then(geo => Promise.all([geo, fetchVanillaIndex(opts)])) .then(([geo, index]) => { const built = GeoBuilder.build(geo); if (!built.cubes.length) throw new Error('no cubes'); const tw = built.texture_width, th = built.texture_height; const jobs = built.cubes.map((cube) => { const desc = resolveTexture(cube, entity, opts, index); return desc ? prepareMaterial(desc, tw, th) : Promise.resolve(null); }); return Promise.all(jobs).then((materials) => ({ built, materials })); }) .then(({ built, materials }) => { for (let i = 0; i < built.cubes.length; i++) { const cube = built.cubes[i]; const geometry = buildGeometry(cube.size, cube.uv, built.texture_width, built.texture_height); let material = materials[i]; if (!material) { material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 }); } const mesh = new THREE.Mesh(geometry, material); mesh.matrix = new THREE.Matrix4().fromArray(cube.matrix); mesh.matrixAutoUpdate = false; meshRoot.add(mesh); } 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 }; })();