muse-engine-29 / engine /ui /model-import.js
axonis22's picture
Muse Engine 29 - Look Picker (Phase 29 engine, Look A + Look B by Muse Glimmer, lamp fix)
8fb1496 verified
Raw History Blame Contribute Delete
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;
}