255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
// 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 = '<p class="empty-hint">Could not load this model.</p>';
|
|
});
|
|
|
|
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 };
|
|
})();
|