// [e29 coach] Model import for the config panel: parse .glb / .gltf (GLTFLoader) and .fbx (FBXLoader) // with vendored three.js r180 (MIT), then hand plain triangle arrays to the raw-WebGL engine. // three.js is only used as a parser here; the engine renders the result itself. // Imported models are VISUAL ONLY: colliders and hitboxes stay the built-in ones. import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; const MAX_VERTS = 1500000; function extOf(name) { const m = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(name || ''); return m ? m[1].toLowerCase() : ''; } function baseName(u) { try { u = decodeURIComponent(u); } catch (_) {} return u.split(/[\\/]/).pop().split('?')[0]; } function parseBuffer(ext, buf, manager, basePath) { return new Promise((resolve, reject) => { if (ext === 'fbx') { try { resolve(new FBXLoader(manager).parse(buf, basePath || '')); } catch (e) { reject(e); } } else if (ext === 'glb' || ext === 'gltf') { new GLTFLoader(manager).parse(buf, basePath || '', (g) => resolve(g.scene || (g.scenes && g.scenes[0])), reject); } else reject(new Error('Unsupported file type: .' + ext + ' (use .glb, .gltf or .fbx)')); }); } function waitManager(manager) { // textures load asynchronously after parse; wait (bounded) for the manager to go idle return new Promise((resolve) => { let done = false; const finish = () => { if (!done) { done = true; resolve(); } }; manager.onLoad = finish; setTimeout(finish, 6000); setTimeout(() => { if (!manager._busy) finish(); }, 50); }); } function makeManager(fileMap) { const manager = new THREE.LoadingManager(); manager._busy = 0; manager.onStart = () => { manager._busy++; }; manager.onError = (u) => console.info('[import] could not load resource:', baseName(u), '(material colour used instead)'); if (fileMap) manager.setURLModifier((u) => fileMap[baseName(u).toLowerCase()] || u); return manager; } /** files: FileList/array from . Picks the first .glb/.gltf/.fbx as the main file; * other selected files (.bin, textures) resolve relative references by file name. */ export async function loadFromFiles(files) { files = Array.from(files || []); const main = files.find((f) => /\.(glb|gltf|fbx)$/i.test(f.name)); if (!main) throw new Error('Pick a .glb, .gltf or .fbx file (plus its .bin/textures for .gltf/.fbx if separate)'); const fileMap = {}; const urls = []; for (const f of files) { const u = URL.createObjectURL(f); urls.push(u); fileMap[f.name.toLowerCase()] = u; } const manager = makeManager(fileMap); const ext = extOf(main.name); const buf = ext === 'gltf' ? await main.text() : await main.arrayBuffer(); const root = await parseBuffer(ext, buf, manager, ''); await waitManager(manager); setTimeout(() => urls.forEach((u) => URL.revokeObjectURL(u)), 10000); return { root, name: main.name, source: 'file' }; } export async function loadFromUrl(url) { const r = await fetch(url); if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + url); const ext = extOf(url); const buf = ext === 'gltf' ? await r.text() : await r.arrayBuffer(); const manager = makeManager(null); const basePath = url.slice(0, url.lastIndexOf('/') + 1); const root = await parseBuffer(ext, buf, manager, basePath); await waitManager(manager); return { root, name: baseName(url), source: 'url' }; } /** Flatten to world-space triangles, then normalise. * mode 'scene': fit the XZ footprint to the 20x20 map (+-10), centre on X/Z, sit on y=0. * mode 'character': fit into the collider's unit box (-0.5..0.5, feet at y=-0.5); the engine * then scales it by the same matrix as the built-in character box (so fall/boom FX still apply). */ export function toEngineMeshes(root, mode) { root.updateMatrixWorld(true); const out = []; let verts = 0; const tmpC = new THREE.Color(); root.traverse((o) => { if (!o.isMesh || !o.geometry || !o.geometry.attributes.position) return; let g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone(); g.applyMatrix4(o.matrixWorld); if (!g.attributes.normal) g.computeVertexNormals(); const mats = Array.isArray(o.material) ? o.material : [o.material]; const groups = (g.groups && g.groups.length && Array.isArray(o.material)) ? g.groups : [{ start: 0, count: g.attributes.position.count, materialIndex: 0 }]; const PA = g.attributes.position, NA = g.attributes.normal, UA = g.attributes.uv || null; const take = (attr, start, count, dim) => { const a = new Float32Array(count * dim); for (let i = 0; i < count; i++) { a[i * dim] = attr.getX(start + i); a[i * dim + 1] = attr.getY(start + i); if (dim === 3) a[i * dim + 2] = attr.getZ(start + i); } return a; }; for (const gr of groups) { const count = Math.min(gr.count, PA.count - gr.start); if (count <= 0) continue; verts += count; if (verts > MAX_VERTS) break; const mat = mats[gr.materialIndex || 0] || mats[0] || {}; let color = [0.8, 0.8, 0.8]; if (mat.color) { mat.color.getRGB(tmpC, THREE.SRGBColorSpace); color = [tmpC.r, tmpC.g, tmpC.b]; } const map = mat.map && mat.map.image ? mat.map : null; out.push({ positions: take(PA, gr.start, count, 3), normals: take(NA, gr.start, count, 3), uvs: UA ? take(UA, gr.start, count, 2) : null, color: map ? [1, 1, 1] : color, image: map ? map.image : null, flipY: map ? !!map.flipY : false }); } }); if (!out.length) throw new Error('No meshes found in that file'); if (verts > MAX_VERTS) throw new Error('Model too heavy for this WebGL1 engine (' + verts + ' verts > ' + MAX_VERTS + ')'); // bounds let mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity]; for (const m of out) for (let i = 0; i < m.positions.length; i += 3) for (let k = 0; k < 3; k++) { const v = m.positions[i + k]; if (v < mn[k]) mn[k] = v; if (v > mx[k]) mx[k] = v; } const size = [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]]; const cx = (mn[0] + mx[0]) / 2, cz = (mn[2] + mx[2]) / 2; let s, oy; if (mode === 'scene') { const foot = Math.max(size[0], size[2]) || size[1] || 1; s = 20 / foot; oy = -mn[1] * s; } else { const big = Math.max(size[1], size[0], size[2]) || 1; s = 1 / big; oy = -0.5 - mn[1] * s; } for (const m of out) { const p = m.positions; for (let i = 0; i < p.length; i += 3) { p[i] = (p[i] - cx) * s; p[i + 1] = p[i + 1] * s + oy; p[i + 2] = (p[i + 2] - cz) * s; } // uniform scale keeps normals valid (renormalise in case the source had scaled nodes) const n = m.normals; for (let i = 0; i < n.length; i += 3) { const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; n[i] /= l; n[i + 1] /= l; n[i + 2] /= l; } } return { meshes: out, verts, origSize: size, scale: s }; } /** convenience: load + convert + hand to the engine */ export async function importInto(slot, { files, url }) { const E = window.MuseEngine29; if (!E) throw new Error('engine not ready'); const res = files ? await loadFromFiles(files) : await loadFromUrl(url); const conv = toEngineMeshes(res.root, slot === 'scene' ? 'scene' : 'character'); const info = { name: res.name, source: res.source, url: url || null, meshes: conv.meshes.length, verts: conv.verts, origSize: conv.origSize.map((v) => +v.toFixed(3)), scale: +conv.scale.toPrecision(4) }; E.setImportedModel(slot, conv.meshes, info); return info; }