Spaces:
Running
Running
Download engine/ui/model-import.js from axonis22/muse-engine-29: direct link, hf CLI and curl.
- Browser
- Download file 7.63 kB
-
https://huggingface.co/spaces/axonis22/muse-engine-29/resolve/main/engine/ui/model-import.js
- Command line
-
hf download hf://spaces/axonis22/muse-engine-29/engine/ui/model-import.js
-
curl -L -o model-import.js https://huggingface.co/spaces/axonis22/muse-engine-29/resolve/main/engine/ui/model-import.js
7.63 kB
| // [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 <input type=file multiple>. 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; | |
| } | |