muse-engine-32 / engine /ui /model-import.js
axonis22's picture
Muse Engine 32: your FBX/GLB scene as the level, skinned characters, Model check, save/load setup
6c78aa4 verified
Raw History Blame Contribute Delete
59.4 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.
// [e30] Scenes can now load "as level": real-world units (cm/m/inch...) and up-axis (Z-up/Y-up) are converted
// with MuseSceneUnits (Muse-authored, engine/import/scene-units.js), and the triangles are handed to the engine
// as a collision mesh (MuseMeshCollider). "Fit to map" keeps the Engine 29 visual-only behaviour.
// [e31] Characters: skinned GLB/FBX rigs become animated enemies (GPU skinning in the engine). toCharacterRig() extracts the
// bind-pose mesh, joints, inverse binds, clips and fits a capsule + head sphere (Muse-authored engine/anim/*.js, coach-reviewed).
// Files without skinned meshes keep the Engine 29 behaviour (static model fitted to the collider box).
// [e32] Release candidate: loading progress (download / parse / textures / build), clear error messages for bad or unsupported
// files, a fixed texture wait (the old one sat out a 6 s timeout whenever textures finished before the parse returned), part
// batching for heavy static scenes (parts sharing a material are merged per 16 m cell, so they draw in one call and can be culled),
// "facts" for the Model check panel (MuseModelCheck, Muse-authored engine/check/model-check.js) and root-motion stripping
// (MuseRootMotion, Muse-authored engine/anim/root-motion.js): the rig keeps raw and in-place clips and the engine picks by toggle.
// [e32] Optional extra readers: a build may register more model formats in window.MuseImportReaders (see "extra readers" below);
// their scenes go through the same level / fit / static-character paths as FBX and glTF. The standard build registers none.
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) => { const sc = g.scene || (g.scenes && g.scenes[0]); if (sc) sc.animations = g.animations || []; resolve(sc); }, reject); // [e31] keep clips
} else reject(new Error('Unsupported file type: .' + ext + ' (use .glb, .gltf or .fbx)'));
});
}
function waitManagerE32(manager, ms) { // [e32] resolves as soon as nothing is pending (no fixed 6 s wait)
return new Promise((resolve) => {
if (!manager._started || manager._done) { resolve(); return; }
manager._resolve = resolve; setTimeout(resolve, ms || 20000);
});
}
function makeManager(fileMap, prog) {
const manager = new THREE.LoadingManager();
manager._started = false; manager._done = false; manager._resolve = null; manager.missing = []; manager.total = 0; manager._orig = new Map();
manager.onStart = () => { manager._started = true; manager._done = false; };
manager.images = new Set();
manager.onProgress = (u, loaded, total) => { manager.total = Math.max(manager.total, total); if (/\.(png|jpe?g|webp|tga|bmp|gif|ktx2|dds|tif?f)(\?|#|$)/i.test(manager._orig.get(u) || u) || /^data:image\//.test(u) || /^blob:/.test(u) && manager._orig.has(u) && /\.(png|jpe?g|webp|tga|bmp|gif)$/i.test(manager._orig.get(u))) manager.images.add(manager._orig.get(u) || u); if (prog) prog({ stage: 'textures', loaded, total }); };
manager.onLoad = () => { manager._done = true; if (manager._resolve) { const r = manager._resolve; manager._resolve = null; r(); } };
manager.onError = (u) => { const n = manager._orig.get(u) || baseName(u); manager.missing.push(n); manager.images.add(manager._orig.get(u) || u); console.info('[import] could not load resource:', n, '(material colour used instead)'); };
if (fileMap) manager.setURLModifier((u) => { const k = baseName(u).toLowerCase(); if (fileMap[k]) { manager._orig.set(fileMap[k], baseName(u)); return fileMap[k]; } return u; });
return manager;
}
// ---------------- [e32] clear errors for bad / unsupported files ----------------
const UNSUPPORTED = { obj: 'OBJ', blend: 'Blender', max: '3ds Max scene', mb: 'Maya binary scene', ma: 'Maya ASCII scene', dae: 'Collada', usd: 'USD', usdz: 'USDZ', usda: 'USD', usdc: 'USD', stl: 'STL', c4d: 'Cinema 4D', skp: 'SketchUp', ply: 'PLY', abc: 'Alembic', zip: 'ZIP archive' };
export class ImportError extends Error { constructor(msg, hint) { super(msg); this.name = 'ImportError'; this.hint = hint || ''; } }
// ---------------- [e32] extra readers ----------------
// window.MuseImportReaders[ext] = { label, hints: { max, oldFbx }, sniff(buf, name, ctx), build(buf, name, ctx) -> THREE.Group,
// matchTexture(wanted, fileNames), prepare(res, opts) -> opts (before conversion), post(info, res) (after conversion: Model check facts) }
// ctx = { THREE, ImportError, manager, prog, resolveTex(fileName) -> Promise<{ name, url, blob } | null> }.
function extraReader(ext) { const R = typeof window !== 'undefined' && window.MuseImportReaders; return R && ext && Object.prototype.hasOwnProperty.call(R, ext) ? R[ext] : null; }
function extraReaders() { const R = typeof window !== 'undefined' && window.MuseImportReaders; return R ? Object.keys(R).map((k) => [k, R[k]]).filter((e) => e[1]) : []; }
function extraHints(key) { return extraReaders().map((e) => (e[1].hints && e[1].hints[key]) || '').filter(Boolean).map((t) => t + ' ').join(''); }
function orList(items) { return items.length > 1 ? items.slice(0, -1).join(', ') + ' or ' + items[items.length - 1] : items[0]; }
function modelExtList() { return orList(['.glb', '.gltf', '.fbx'].concat(extraReaders().map((e) => '.' + e[0]))); }
export function checkExtension(name) {
const ext = extOf(name);
if (ext === 'glb' || ext === 'gltf' || ext === 'fbx') return ext;
if (extraReader(ext)) return ext;
if (ext === '3ds') throw new ImportError('.3ds (3D Studio) import is not included in this build: ' + baseName(name), 'Export FBX (binary, 2014-2020) or glTF binary (.glb) instead. See docs/HOWTO-AUTODESK.md.');
if (ext === 'max') throw new ImportError('.max (3ds Max scene) files cannot be read outside 3ds Max: ' + baseName(name), 'Export from Max instead: File > Export > FBX (binary, 2014-2020) or glTF (.glb). ' + extraHints('max') + 'See docs/HOWTO-AUTODESK.md.');
if (UNSUPPORTED[ext]) throw new ImportError('.' + ext + ' (' + UNSUPPORTED[ext] + ') files are not supported: ' + baseName(name), 'Export ' + orList(['FBX (binary, 2014-2020)', 'glTF binary (.glb)'].concat(extraReaders().map((e) => e[1].label || '.' + e[0]))) + ' from your DCC tool. See docs/HOWTO-AUTODESK.md.');
throw new ImportError(ext ? 'Unsupported file type .' + ext + ': ' + baseName(name) : 'File has no extension: ' + baseName(name), 'Use a ' + modelExtList() + ' file.');
}
function sniff(ext, buf, name) { // header checks before handing bytes to the parsers
const n = typeof buf === 'string' ? buf.length : buf.byteLength;
if (!n) throw new ImportError('The file is empty (0 bytes): ' + name, 'Re-export or re-download it.');
if (ext === 'glb') {
const h = new Uint8Array(buf, 0, Math.min(4, n)); const tag = String.fromCharCode.apply(null, h);
if (tag !== 'glTF') {
if (tag.charAt(0) === '{') throw new ImportError('This .glb is really a text glTF (JSON): ' + name, 'Rename it to .gltf (and select its .bin/textures too) or export glTF Binary (.glb).');
throw new ImportError('Not a binary glTF file (header is not "glTF"): ' + name, 'The file is damaged or has the wrong extension. Re-export as glTF Binary (.glb).');
}
const dv = new DataView(buf); // header says how long the file should be: catch truncated / damaged downloads early
const declared = n >= 12 ? dv.getUint32(8, true) : 0, jlen = n >= 20 ? dv.getUint32(12, true) : 0, jtag = n >= 20 ? dv.getUint32(16, true) : 0;
if (n < 20 || declared > n || jtag !== 0x4E4F534A || jlen > n - 20)
throw new ImportError('Damaged or truncated .glb: ' + name + ' (' + n + ' bytes' + (declared ? ', header says ' + declared : '') + ')', 'The download or export did not finish. Re-export it as glTF Binary (.glb) or copy the file again.');
} else if (ext === 'fbx') {
const h = new Uint8Array(buf, 0, Math.min(20, n)); const tag = String.fromCharCode.apply(null, h);
const txt = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(2048, n)));
if (tag.indexOf('Kaydara FBX Binary') !== 0 && !/FBXHeaderExtension|; FBX \d/.test(txt)) throw new ImportError('Not an FBX file (no FBX header): ' + name, 'The file is damaged or has the wrong extension. Re-export as FBX.');
// [e32 scope change] FBX 6.x and older (3ds Max / Maya 2006-2010, FBX 6.1 = version 6100, binary or ASCII) -> one clear message
let ver = 0;
if (tag.indexOf('Kaydara FBX Binary') === 0) { if (n >= 27) ver = new DataView(buf).getUint32(23, true); }
else { const vm = /FBXVersion:\s*(\d+)/.exec(txt) || /; FBX (\d)\.(\d)\.\d project file/.exec(txt); if (vm) ver = vm[2] != null ? (+vm[1]) * 1000 + (+vm[2]) * 100 : +vm[1]; }
if (ver > 0 && ver < 7000) throw oldFbxError(ver, name, tag.indexOf('Kaydara FBX Binary') === 0);
} else if (extraReader(ext)) { // [e32] an extra reader checks its own header
const xr = extraReader(ext); if (xr.sniff) xr.sniff(buf, name, { THREE, ImportError });
} else if (ext === 'gltf') {
const t = String(buf).trimStart();
if (t.charAt(0) !== '{') throw new ImportError('Not a glTF JSON file: ' + name, 'A .gltf must be JSON text. For binary files use the .glb extension.');
}
}
function oldFbxError(ver, name, binary) { // [e32 scope change] Paul's wording first, then the details
const major = Math.floor(ver / 1000), minor = Math.floor((ver % 1000) / 100);
return new ImportError((major === 6 ? 'Old FBX 6.x: re-export as FBX 2011+ or GLB' : 'Old FBX ' + major + '.x: re-export as FBX 2011+ or GLB') + '. ' + name + ' is FBX ' + major + '.' + minor +
' (version ' + ver + (binary === true ? ', binary' : binary === false ? ', ASCII' : '') + ', written by 3ds Max / Maya 2010 or earlier); Engine 32 reads FBX 7.x (2011 and later).',
'3ds Max / Maya 2011+: File > Export > FBX, Advanced Options > FBX File Format > Version = FBX 2014/2015 or later, Type = Binary. ' + extraHints('oldFbx') + 'Or open the old file in Blender (2.79 reads FBX 6.x ASCII) and export FBX 7.4 or glTF (.glb). See docs/HOWTO-AUTODESK.md.');
}
export function friendlyError(err, name) {
if (err instanceof ImportError) return err;
const m = String((err && err.message) || err || 'unknown error');
let r;
if ((r = /FBX version not supported, FileVersion: (\d+)/i.exec(m))) return oldFbxError(+r[1], name); // [e32 scope change] same message as the header check
if ((r = /Failed to load buffer "([^"]+)"/i.exec(m))) return new ImportError('Missing ' + r[1] + ' next to ' + name, 'Select the .gltf together with its .bin and texture files, or export a single glTF Binary (.glb).');
if (/DRACOLoader|KHR_draco/i.test(m)) return new ImportError('Draco-compressed glTF is not supported: ' + name, 'Export without Draco mesh compression.');
if (/KTX2|basisu|MeshoptDecoder|EXT_meshopt/i.test(m)) return new ImportError('Compressed glTF extensions (KTX2 / meshopt) are not supported: ' + name, 'Export with plain PNG/JPEG textures and no mesh compression.');
if (/Unexpected token|JSON/i.test(m) && /gltf|glb/i.test(name)) return new ImportError('Not valid glTF JSON: ' + name, 'The file is damaged. Re-export it.');
if (/Unsupported asset|asset version/i.test(m)) return new ImportError('Unsupported glTF version: ' + name + ' (' + m + ')', 'Export glTF 2.0.');
if (/Unknown format|Cannot read|out of bounds|Invalid typed array|Offset is outside/i.test(m)) return new ImportError('Could not read ' + name + ': the file looks damaged or truncated (' + m.slice(0, 120) + ')', 'Re-export it; for FBX use binary FBX 2014-2020.');
if (/No meshes found/i.test(m)) return new ImportError('No meshes found in ' + name, 'The file has no visible geometry (only cameras, lights or empty groups?).');
if (/too heavy/i.test(m)) return new ImportError(m, 'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or split the scene.');
const e = new ImportError('Could not load ' + name + ': ' + m.slice(0, 200), 'If it keeps failing, re-export as glTF Binary (.glb) or binary FBX 2014-2020.'); e.cause = err; return e;
}
/** 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, prog) {
files = Array.from(files || []);
const main = files.find((f) => /\.(glb|gltf|fbx)$/i.test(f.name) || extraReader(extOf(f.name)));
if (!main) { if (files.length) checkExtension(files[0].name); throw new ImportError('Pick a ' + modelExtList() + ' file', 'For .gltf/.fbx with separate .bin/texture files, select them together.'); }
const ext = checkExtension(main.name);
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, prog);
if (prog) prog({ stage: 'read', loaded: 0, total: main.size });
const buf = ext === 'gltf' ? await main.text() : await main.arrayBuffer();
if (prog) prog({ stage: 'read', loaded: main.size, total: main.size });
sniff(ext, buf, main.name);
if (prog) prog({ stage: 'parse' });
let root;
const xr = extraReader(ext);
if (xr) { // [e32] extra reader: its textures come from the other selected files (the reader's own name matching, else the same name in any case)
const names = files.filter((f) => f !== main).map((f) => f.name);
root = await xr.build(buf, main.name, { THREE, ImportError, manager, prog, resolveTex: async (want) => {
const hit = xr.matchTexture ? xr.matchTexture(want, names) : names.find((n) => n.toLowerCase() === baseName(want).toLowerCase()) || null;
if (!hit) return null;
const f = files.find((x) => x.name === hit); return f ? { name: hit, url: fileMap[hit.toLowerCase()], blob: f } : null;
} });
} else {
try { root = await parseBuffer(ext, buf, manager, ''); } catch (e) { throw friendlyError(e, main.name); }
await waitManagerE32(manager);
}
setTimeout(() => urls.forEach((u) => URL.revokeObjectURL(u)), 10000);
return { root, name: main.name, source: 'file', format: ext, bytes: main.size, manager };
}
async function fetchWithProgress(url, prog) { // [e32] streamed download so the panel/HUD can show a percentage
let r;
try { r = await fetch(url); } catch (e) { throw new ImportError('Could not reach ' + url + ' (' + ((e && e.message) || e) + ')', 'Check the address; other sites must allow CORS for this page to read the file.'); }
if (!r.ok) throw new ImportError((r.status === 404 ? 'File not found (HTTP 404): ' : 'Download failed (HTTP ' + r.status + '): ') + url, r.status === 404 ? 'Check the path/URL (it is relative to this page).' : 'Try again, or load the file from disk.');
const total = +r.headers.get('content-length') || 0;
if (!prog || !r.body || !r.body.getReader) return r.arrayBuffer();
const rd = r.body.getReader(); const chunks = []; let got = 0;
prog({ stage: 'download', loaded: 0, total });
for (;;) { const x = await rd.read(); if (x.done) break; chunks.push(x.value); got += x.value.length; prog({ stage: 'download', loaded: got, total }); }
const all = new Uint8Array(got); let o = 0; for (const c of chunks) { all.set(c, o); o += c.length; }
return all.buffer;
}
export async function loadFromUrl(url, prog) {
const ext = checkExtension(url);
const ab = await fetchWithProgress(url, prog);
const buf = ext === 'gltf' ? new TextDecoder().decode(ab) : ab;
const name = baseName(url);
sniff(ext, buf, name);
const manager = makeManager(null, prog);
const basePath = url.slice(0, url.lastIndexOf('/') + 1);
if (prog) prog({ stage: 'parse' });
let root;
const xr = extraReader(ext);
if (xr) { // [e32] extra reader: texture names may differ in case from the files next to it: try the name as written, then lower / upper case, then .png / .jpg
root = await xr.build(buf, name, { THREE, ImportError, manager, prog, resolveTex: async (want) => {
const b = baseName(want), dot = b.lastIndexOf('.'), stem = dot > 0 ? b.slice(0, dot) : b;
const cands = Array.from(new Set([b, b.toLowerCase(), b.toUpperCase(), stem.toLowerCase() + '.png', stem.toLowerCase() + '.jpg']));
for (const c of cands) {
let r = null; try { r = await fetch(basePath + encodeURIComponent(c)); } catch (_) { r = null; }
if (r && r.ok) { const blob = await r.blob(); return { name: c, url: URL.createObjectURL(blob), blob }; }
}
return null;
} });
} else {
try { root = await parseBuffer(ext, buf, manager, basePath); } catch (e) { throw friendlyError(e, name); }
await waitManagerE32(manager);
}
return { root, name, source: 'url', format: ext, bytes: ab.byteLength, manager };
}
// ---------------- [e32] batching (performance pass) ----------------
// Parts that share a material (same texture image or same colour, same UV/flip layout) are concatenated. Level scenes are split
// by a 16 m XZ grid first so the engine can still frustum-cull them. Returns { meshes, srcParts }.
export function batchMeshes(list, cell) {
const ids = new Map(); let nextId = 1;
const groups = new Map();
for (const m of list) {
let img = 'c' + m.color.map((v) => v.toFixed(3)).join(',');
if (m.image) { if (!ids.has(m.image)) ids.set(m.image, nextId++); img = 'i' + ids.get(m.image); }
let ck = '';
if (cell > 0) {
const P = m.positions; let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (let i = 0; i < P.length; i += 3) { const x = P[i], z = P[i + 2]; if (x < x0) x0 = x; if (x > x1) x1 = x; if (z < z0) z0 = z; if (z > z1) z1 = z; }
ck = '|' + Math.floor((x0 + x1) / 2 / cell) + ',' + Math.floor((z0 + z1) / 2 / cell);
}
const key = img + '|' + (m.flipY ? 1 : 0) + '|' + (m.uvs ? 1 : 0) + ck;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
const out = [];
for (const g of groups.values()) {
if (g.length === 1) { out.push(g[0]); continue; }
let n = 0; for (const m of g) n += m.positions.length;
const pos = new Float32Array(n), nrm = new Float32Array(n), uv = g[0].uvs ? new Float32Array(n / 3 * 2) : null;
let o = 0, ou = 0;
for (const m of g) { pos.set(m.positions, o); nrm.set(m.normals, o); o += m.positions.length; if (uv) { uv.set(m.uvs, ou); ou += m.uvs.length; } }
out.push(Object.assign({}, g[0], { positions: pos, normals: nrm, uvs: uv, name: g[0].name + (g.length > 1 ? ' +' + (g.length - 1) : ''), batched: g.length }));
}
return { meshes: out, srcParts: list.length };
}
function unitsFacts(meta, dec) { // [e32] was the unit / up information in the file, guessed from the size, or forced in the panel?
const glt = meta.format === 'glb' || meta.format === 'gltf';
const uSrc = (meta.forcedUnits && meta.forcedUnits !== 'auto') ? 'forced' : (glt || meta.unitScaleFactor != null) ? 'file' : 'guess';
const upSrc = (meta.forcedUp && meta.forcedUp !== 'auto') ? 'forced' : (glt || meta.upAxis != null) ? 'file' : 'guess';
return { units: { value: dec.units || 'm', source: uSrc }, up: { value: dec.zUp ? 'z' : 'y', source: upSrc } };
}
function tagImg(map) { // [e32] remember the texture's name on its image (glTF decodes to ImageBitmap, which has no src) for the texture budget
const im = map.image; try { if (im && !im.__museTex && map.name) im.__museTex = baseName(String(map.name)); } catch (_) { /* frozen image objects: keep unnamed */ }
return im;
}
function texFacts(manager, meshes) {
const missing = manager ? Array.from(new Set(manager.missing)) : [];
const imgs = new Set(); for (const m of meshes) if (m.image) imgs.add(m.image);
const refs = manager && manager.images ? manager.images.size : 0; // image URLs requested through the manager (buffers / data: URIs are not textures)
const sizes = []; for (const im of imgs) { const w = im.naturalWidth || im.width || 0, h = im.naturalHeight || im.height || 0; // [e32] for MuseTextureBudget
let nm = im.__museName || (im.src ? baseName((manager && manager._orig && manager._orig.get(im.src)) || im.src) : '');
if (!nm || /^blob:|^data:/.test(im.src || '') && !(manager && manager._orig && manager._orig.get(im.src))) nm = im.__museTex || nm;
if (!nm || /^blob:|^data:/.test(nm) || nm.length > 80) nm = 'texture ' + (sizes.length + 1);
if (w > 0 && h > 0) sizes.push({ name: nm, width: w, height: h }); }
return { referenced: Math.max(refs, imgs.size + missing.length), loaded: imgs.size, missing, sizes };
}
/** 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 ? tagImg(map) : 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 };
}
// ---------------- [e30] scene "as level" ----------------
// Naming conventions (Autodesk / game-engine style), matched on the mesh name or any parent name:
// UCX_* / *_col / *_collision / *_collider -> collision only (not drawn)
// *_nocol / *nocollide* / grass / foliage / plant / decal -> drawn, no collision
// PlayerStart / spawn / player_start / SpawnPoint (any node, e.g. an Empty/dummy) -> spawn point
const RX_COLONLY = /(^ucx_)|([_\-.\s](col|collision|collider)$)|(^(col|collision|collider)[_\-.\s])/i;
const RX_NOCOL = /(nocol|nocollide|no_collision)/i;
// foliage/decals: only when the object's OWN name starts with the word ("grass_0", "Foliage.001") and it is small
// (see the size guard in toLevelMeshes), so a ground tile like "platform-large-grass" or a "grass_field" still collides
const RX_FOLIAGE = /^(grass|foliage|plant|bush|flower|decal|leaf|leaves)([_\-.\s\d]|$)/i;
const RX_SPAWN = /^(playerstart|player_start|player-start|spawn|spawnpoint|spawn_point|start)([_\-.\s]?\d*)$/i;
function chainNames(o) { const n = []; for (let x = o; x; x = x.parent) if (x.name) n.push(x.name); return n; }
function flagOf(o) {
const names = chainNames(o);
if (names.some((n) => RX_COLONLY.test(n))) return 'colonly';
if (names.some((n) => RX_NOCOL.test(n))) return 'nocol';
if (o.name && RX_FOLIAGE.test(o.name)) return 'foliage';
return 'both';
}
function rawBounds(root) {
const box = new THREE.Box3(); const v = new THREE.Vector3();
root.traverse((o) => {
if (!o.isMesh || !o.geometry || !o.geometry.attributes.position) return;
const pa = o.geometry.attributes.position;
const step = Math.max(1, Math.floor(pa.count / 4000));
for (let i = 0; i < pa.count; i += step) { v.fromBufferAttribute(pa, i).applyMatrix4(o.matrixWorld); box.expandByPoint(v); }
});
return box;
}
function fallbackDecide(meta, size) { // used only if engine/import/scene-units.js is missing
const big = Math.max(size[0], size[1], size[2]);
let scale = 1, units = 'm', zUp = false;
if (meta.format === 'fbx' && meta.unitScaleFactor) { scale = meta.unitScaleFactor / 100; units = scale === 0.01 ? 'cm' : 'm'; }
else if (meta.format === 'fbx') { if (big > 4000) { scale = 0.001; units = 'mm'; } else if (big > 400) { scale = 0.01; units = 'cm'; } }
if (meta.format === 'fbx' && meta.upAxis === 2) zUp = true;
return { scale, zUp, upSign: 1, units, reason: 'fallback rules' };
}
export function toLevelMeshes(root, format, opts) {
opts = opts || {};
root.updateMatrixWorld(true);
const ud = root.userData || {};
const meta = { format, unitScaleFactor: ud.unitScaleFactor, upAxis: ud.fbxUpAxis, upAxisSign: ud.fbxUpAxisSign,
forcedUnits: opts.units || 'auto', forcedUp: opts.up || 'auto' };
const rb = rawBounds(root);
if (rb.isEmpty()) throw new Error('No meshes found in that file');
const rawSize = [rb.max.x - rb.min.x, rb.max.y - rb.min.y, rb.max.z - rb.min.z];
const SU = window.MuseSceneUnits;
const dec = (SU && SU.decide) ? SU.decide(meta, rawSize) : fallbackDecide(meta, rawSize);
const conv = new THREE.Matrix4().fromArray(Array.from((SU && SU.matrix) ? SU.matrix(dec) : [dec.scale,0,0,0, 0,dec.scale,0,0, 0,0,dec.scale,0, 0,0,0,1]));
if (!(SU && SU.matrix) && dec.zUp) conv.multiply(new THREE.Matrix4().makeRotationX(-Math.PI / 2 * (dec.upSign || 1)));
const out = []; const col = []; let verts = 0, colTris = 0, skippedCol = 0, colOnlyMeshes = 0;
const tmpC = new THREE.Color(); let spawn = null; const nm = new THREE.Matrix3();
root.traverse((o) => {
if (!spawn && o.name && RX_SPAWN.test(o.name)) { const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld).applyMatrix4(conv); spawn = { name: o.name, pos: [p.x, p.y, p.z] }; }
if (!o.isMesh || !o.geometry || !o.geometry.attributes.position) return;
let flag = flagOf(o);
const g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone();
const world = new THREE.Matrix4().multiplyMatrices(conv, o.matrixWorld);
g.applyMatrix4(world);
if (world.determinant() < 0) { // mirrored node: keep triangles front-facing
const pa = g.attributes.position;
for (let i = 0; i + 2 < pa.count; i += 3) { for (const a of [g.attributes.position, g.attributes.normal, g.attributes.uv]) { if (!a) continue; for (let k = 0; k < a.itemSize; k++) { const t = a.getComponent(i + 1, k); a.setComponent(i + 1, k, a.getComponent(i + 2, k)); a.setComponent(i + 2, k, t); } } }
}
if (!g.attributes.normal) g.computeVertexNormals();
if (flag === 'foliage') { g.computeBoundingBox(); const b = g.boundingBox; flag = (b.max.x - b.min.x < 3 && b.max.z - b.min.z < 3 && b.max.y - b.min.y < 1.5) ? 'nocol' : 'both'; }
const PA = g.attributes.position, NA = g.attributes.normal, UA = g.attributes.uv || null;
if (flag !== 'nocol') { for (let i = 0; i < PA.count; i++) col.push(PA.getX(i), PA.getY(i), PA.getZ(i)); colTris += Math.floor(PA.count / 3); }
else skippedCol += Math.floor(PA.count / 3);
if (flag === 'colonly') { colOnlyMeshes++; return; }
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: PA.count, materialIndex: 0 }];
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;
const n = take(NA, gr.start, count, 3);
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; }
out.push({ positions: take(PA, gr.start, count, 3), normals: n, uvs: UA ? take(UA, gr.start, count, 2) : null,
color: map ? [1, 1, 1] : color, image: map ? tagImg(map) : null, flipY: map ? !!map.flipY : false, textured: !!map, name: o.name || '' });
}
});
if (!out.length && !col.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 + ')');
// converted bounds
const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < col.length; i += 3) for (let k = 0; k < 3; k++) { const v = col[i + k]; if (v < mn[k]) mn[k] = v; if (v > mx[k]) mx[k] = v; }
if (!col.length) 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; }
// very far from the origin (geo-referenced CAD exports): recentre XZ so float precision stays sane
let shift = [0, 0, 0];
const cx = (mn[0] + mx[0]) / 2, cz = (mn[2] + mx[2]) / 2;
if (Math.hypot(cx, cz) > 2000) {
shift = [-cx, 0, -cz];
for (const m of out) for (let i = 0; i < m.positions.length; i += 3) { m.positions[i] += shift[0]; m.positions[i + 2] += shift[2]; }
for (let i = 0; i < col.length; i += 3) { col[i] += shift[0]; col[i + 2] += shift[2]; }
mn[0] += shift[0]; mx[0] += shift[0]; mn[2] += shift[2]; mx[2] += shift[2];
if (spawn) { spawn.pos[0] += shift[0]; spawn.pos[2] += shift[2]; }
}
return { meshes: out, collision: new Float32Array(col), verts, colTris, skippedCol, colOnlyMeshes, spawn, decision: dec, meta,
rawSize, size: [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]], min: mn, max: mx, shift,
textured: out.filter((m) => m.textured).length };
}
// ---------------- [e31] animated (skinned) characters ----------------
// Coach code (plumbing): walks the three.js parse result and produces plain arrays for the raw-WebGL engine.
// Maths helpers it calls are Muse-authored (engine/anim/): MuseClipMap (clip roles + bone keys), MuseCapsuleFit (capsule, bone roles,
// head sphere), MuseBoneBudget (GPU palette limit). The per-frame pose/crossfade maths (MuseSkeletonPose) runs in the engine.
function rawNameOf(o) { return (o.userData && (o.userData.originalName || o.userData.name)) || o.name || ''; }
const PROP_PATH = { position: 'translation', quaternion: 'rotation', scale: 'scale' };
function isIdentity(m, eps) { const e = m.elements; for (let i = 0; i < 16; i++) if (Math.abs(e[i] - (i % 5 === 0 ? 1 : 0)) > (eps || 1e-5)) return false; return true; }
function localBoneKey(name) {
const CM = window.MuseClipMap;
if (CM && CM.boneKey) { try { return CM.boneKey(name); } catch (_) {} }
let n = String(name || ''); const c = Math.max(n.lastIndexOf(':'), n.lastIndexOf('|')); if (c >= 0) n = n.slice(c + 1);
return n.toLowerCase().replace(/^(mixamorig|bip001|bip01)/, '').replace(/[\s_.]/g, '');
}
function fallbackClipMatch(names) { // used only if engine/anim/clip-map.js is missing
const roles = { idle: -1, walk: -1, run: -1, shoot: -1, death: -1 };
const rx = { idle: /idle|stand/i, walk: /walk/i, run: /run|jog|sprint/i, shoot: /shoot|fire|attack/i, death: /death|die|dead|dying/i };
const used = new Set();
for (const r in rx) { const i = names.findIndex((n, k) => !used.has(k) && rx[r].test(n)); if (i >= 0) { roles[r] = i; used.add(i); } }
return { roles, scores: {}, unmatched: names.map((_, i) => i).filter((i) => !used.has(i)) };
}
export function matchClips(names, overrides) {
const CM = window.MuseClipMap;
if (CM && CM.match) return CM.match(names, { overrides: overrides || {} });
return fallbackClipMatch(names);
}
/** Convert a parsed file with skinned meshes into an engine rig. Returns null when the file has no skinned mesh.
* opts: { units, up (as scenes), facing: 'auto'|'+z'|'-z'|'+x'|'-x', maxBones } */
export function toCharacterRig(root, format, opts) {
opts = opts || {};
root.updateMatrixWorld(true);
const skinned = [], rigid = [];
root.traverse((o) => {
if (!o.isMesh || !o.geometry || !o.geometry.attributes.position || o.visible === false) return;
if (o.isSkinnedMesh && o.skeleton && o.geometry.attributes.skinIndex && o.geometry.attributes.skinWeight) skinned.push(o); else rigid.push(o);
});
if (!skinned.length) return null;
// ---- joints: union of all skins (shared bones de-duplicated) ----
// three's FBXLoader duplicates a bone once per mesh that uses it (same name, identity local transform, nested under the first);
// fold those copies back into one joint when their inverse bind matrices agree, so a 4-mesh 62-bone rig stays 62 joints.
const joints = [], jointIdx = new Map(), invBind = [], boneJoint = new Map();
const isIdLocal = (c) => c.position.lengthSq() < 1e-12 && Math.abs(Math.abs(c.quaternion.w) - 1) < 1e-9 && Math.abs(c.scale.x - 1) < 1e-9 && Math.abs(c.scale.y - 1) < 1e-9 && Math.abs(c.scale.z - 1) < 1e-9;
const canon = (b) => { let c = b; while (c.parent && c.parent.isBone && c.parent.name === c.name && isIdLocal(c)) c = c.parent; return c; };
const sameM = (x, y) => { const a = x.elements, b = y.elements; let mx = 0; for (let i = 0; i < 16; i++) mx = Math.max(mx, Math.abs(a[i])); for (let i = 0; i < 16; i++) if (Math.abs(a[i] - b[i]) > 1e-4 * Math.max(1, mx)) return false; return true; };
let foldedCopies = 0;
for (const m of skinned) m.skeleton.bones.forEach((b, i) => {
if (!b || boneJoint.has(b)) return;
const ib = m.skeleton.boneInverses[i];
const c = canon(b);
if (c !== b && jointIdx.has(c) && sameM(invBind[jointIdx.get(c)], ib)) { boneJoint.set(b, jointIdx.get(c)); foldedCopies++; return; }
const key = (c !== b && !jointIdx.has(c)) ? c : b;
if (jointIdx.has(key)) { boneJoint.set(b, jointIdx.get(key)); return; }
jointIdx.set(key, joints.length); boneJoint.set(b, joints.length); if (key !== b) foldedCopies++; joints.push(key); invBind.push(ib.clone());
});
// ---- animated node set: joints + every ancestor up to the file root (armature nodes often carry the cm/Z-up transform) ----
const need = new Set();
for (const b of joints) for (let x = b; x; x = x.parent) { need.add(x); if (x === root) break; }
const nodes = [], nodeIdx = new Map();
root.traverse((o) => { if (need.has(o)) { nodeIdx.set(o, nodes.length); nodes.push(o); } });
const skeleton = { nodes: nodes.map((o) => ({ name: o.name, raw: rawNameOf(o), parent: (o !== root && o.parent && nodeIdx.has(o.parent)) ? nodeIdx.get(o.parent) : -1,
t: o.position.toArray(), r: o.quaternion.toArray(), s: o.scale.toArray() })) };
const J = joints.length;
const jointNode = new Int32Array(J), jointParent = new Int32Array(J);
joints.forEach((b, j) => {
jointNode[j] = nodeIdx.get(b);
let p = b.parent; while (p && !jointIdx.has(p) && p !== root) p = p.parent;
jointParent[j] = (p && jointIdx.has(p)) ? jointIdx.get(p) : -1;
});
const jointNames = joints.map(rawNameOf);
// ---- bind-space vertex data ----
const tmpC = new THREE.Color();
const parts = []; let verts = 0; const notes = [];
const pushPart = (g, mesh, jointMap, rigidJoint, A) => {
const PA = g.attributes.position, NA = g.attributes.normal, UA = g.attributes.uv || null;
const SI = g.attributes.skinIndex, SW = g.attributes.skinWeight;
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const groups = (g.groups && g.groups.length && Array.isArray(mesh.material)) ? g.groups : [{ start: 0, count: PA.count, materialIndex: 0 }];
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) throw new Error('Character too heavy for this WebGL1 engine (' + verts + ' verts > ' + MAX_VERTS + ')');
const pos = new Float32Array(count * 3), nrm = new Float32Array(count * 3), uv = UA ? new Float32Array(count * 2) : null;
const si = new Uint16Array(count * 4), sw = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
const v = gr.start + i;
pos[i * 3] = PA.getX(v); pos[i * 3 + 1] = PA.getY(v); pos[i * 3 + 2] = PA.getZ(v);
let nx = NA.getX(v), ny = NA.getY(v), nz = NA.getZ(v); const l = Math.hypot(nx, ny, nz) || 1;
nrm[i * 3] = nx / l; nrm[i * 3 + 1] = ny / l; nrm[i * 3 + 2] = nz / l;
if (uv) { uv[i * 2] = UA.getX(v); uv[i * 2 + 1] = UA.getY(v); }
if (rigidJoint != null) { si[i * 4] = rigidJoint; sw[i * 4] = 1; continue; }
let tot = 0;
for (let k = 0; k < 4; k++) { const w = SW.getComponent(v, k); const jl = SI.getComponent(v, k); const jj = jointMap[jl]; if (w > 0 && jj != null) { si[i * 4 + k] = jj; sw[i * 4 + k] = w; tot += w; } }
if (tot > 0) for (let k = 0; k < 4; k++) sw[i * 4 + k] /= tot; else { si[i * 4] = 0; sw[i * 4] = 1; }
}
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;
parts.push({ positions: pos, normals: nrm, uvs: uv, skinIndex: si, skinWeight: sw, A: A ? Array.from(A.elements) : null,
color: map ? [1, 1, 1] : color, image: map ? tagImg(map) : null, flipY: map ? !!map.flipY : false, textured: !!map, name: rawNameOf(mesh) });
}
};
for (const m of skinned) {
const g = m.geometry.index ? m.geometry.toNonIndexed() : m.geometry.clone();
g.applyMatrix4(m.bindMatrix); // bind space (three: skinned = bindMatrixInverse * sum(w * bone.matrixWorld * boneInverse) * bindMatrix * p)
if (!g.attributes.normal) g.computeVertexNormals();
const A = new THREE.Matrix4().multiplyMatrices(m.matrixWorld, m.bindMatrixInverse);
const jointMap = m.skeleton.bones.map((b) => boneJoint.get(b));
pushPart(g, m, jointMap, null, isIdentity(A, 1e-4) ? null : A);
if (!isIdentity(A, 1e-4)) notes.push('mesh ' + rawNameOf(m) + ' is not at its bind transform (handled)');
}
for (const m of rigid) { // props parented under a bone (weapons, helmets) follow that bone; others follow the top joint
let p = m.parent; while (p && !jointIdx.has(p) && !boneJoint.has(p) && p !== root) p = p.parent;
const j = (p && jointIdx.has(p)) ? jointIdx.get(p) : (p && boneJoint.has(p)) ? boneJoint.get(p) : jointParent.indexOf(-1);
const b = joints[j];
const M = new THREE.Matrix4().copy(invBind[j]).invert().multiply(new THREE.Matrix4().copy(b.matrixWorld).invert()).multiply(m.matrixWorld);
const g = m.geometry.index ? m.geometry.toNonIndexed() : m.geometry.clone();
g.applyMatrix4(M);
if (!g.attributes.normal) g.computeVertexNormals();
pushPart(g, m, null, j, null);
notes.push('rigid mesh ' + rawNameOf(m) + ' attached to ' + jointNames[j]);
}
// ---- bone roles + GPU palette budget ----
const CF = window.MuseCapsuleFit;
const roles = (CF && CF.findRoles) ? CF.findRoles(jointNames) : { head: jointNames.findIndex((n) => localBoneKey(n) === 'head'), hips: -1 };
const allSI = new Uint16Array(verts * 4), allSW = new Float32Array(verts * 4);
{ let o = 0; for (const pt of parts) { allSI.set(pt.skinIndex, o); allSW.set(pt.skinWeight, o); o += pt.skinIndex.length; } }
const budget = Math.max(4, opts.maxBones || 60);
const protect = [roles.head, roles.hips, roles.neck, roles.spine].filter((j) => j != null && j >= 0);
let red;
const BB = window.MuseBoneBudget;
if (BB && BB.reduce) red = BB.reduce({ parents: jointParent, skinIndex: allSI, skinWeight: allSW, budget, protect });
else { if (J > budget) throw new Error('Rig has ' + J + ' bones (> ' + budget + ') and bone-budget.js is missing'); red = { keep: joints.map((_, j) => j), slotOf: Int32Array.from(joints.map((_, j) => j)), skinIndex: allSI, skinWeight: allSW, merged: 0 }; }
{ let o = 0; for (const pt of parts) { const n = pt.skinIndex.length; pt.skinIndex = red.skinIndex.slice(o, o + n); pt.skinWeight = red.skinWeight.slice(o, o + n); o += n; } }
const S = red.keep.length;
const paletteNodes = new Int32Array(S), paletteInvBind = new Float32Array(16 * S);
red.keep.forEach((j, s) => { paletteNodes[s] = jointNode[j]; paletteInvBind.set(invBind[j].elements, 16 * s); });
// ---- units / up axis (same rules as scenes: MuseSceneUnits) from the bind-pose bounds ----
const A0 = parts[0].A ? new THREE.Matrix4().fromArray(parts[0].A) : new THREE.Matrix4();
const bb = new THREE.Box3(), v3 = new THREE.Vector3();
for (const pt of parts) { const A = pt.A ? new THREE.Matrix4().fromArray(pt.A) : null; const P = pt.positions; const step = Math.max(1, Math.floor(P.length / 3 / 3000));
for (let i = 0; i < P.length / 3; i += step) { v3.set(P[i * 3], P[i * 3 + 1], P[i * 3 + 2]); if (A) v3.applyMatrix4(A); bb.expandByPoint(v3); } }
const rawSize = [bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z];
const ud = root.userData || {};
const meta = { format, unitScaleFactor: ud.unitScaleFactor, upAxis: ud.fbxUpAxis, upAxisSign: ud.fbxUpAxisSign, forcedUnits: opts.units || 'auto', forcedUp: opts.up || 'auto' };
const SU = window.MuseSceneUnits;
const dec = (SU && SU.decide) ? SU.decide(meta, rawSize) : fallbackDecide(meta, rawSize);
const conv = new THREE.Matrix4().fromArray(Array.from((SU && SU.matrix) ? SU.matrix(dec) : [dec.scale, 0, 0, 0, 0, dec.scale, 0, 0, 0, 0, dec.scale, 0, 0, 0, 0, 1]));
if (!(SU && SU.matrix) && dec.zUp) conv.multiply(new THREE.Matrix4().makeRotationX(-Math.PI / 2 * (dec.upSign || 1)));
// bind-pose world vertices (metres, Y-up) for the capsule fit
const world = new Float32Array(verts * 3);
{ let o = 0; for (const pt of parts) { const M = new THREE.Matrix4().multiplyMatrices(conv, pt.A ? new THREE.Matrix4().fromArray(pt.A) : new THREE.Matrix4()); const P = pt.positions;
for (let i = 0; i < P.length; i += 3) { v3.set(P[i], P[i + 1], P[i + 2]).applyMatrix4(M); world[o++] = v3.x; world[o++] = v3.y; world[o++] = v3.z; } } }
let cap0 = (CF && CF.fit) ? CF.fit(world, {}) : null;
if (!cap0) { let y0 = Infinity, y1 = -Infinity; for (let i = 1; i < world.length; i += 3) { y0 = Math.min(y0, world[i]); y1 = Math.max(y1, world[i]); } cap0 = { cx: 0, cz: 0, radius: 0.3, yMin: y0, yMax: y1, height: y1 - y0, coverage: 0 }; }
let sanity = 1, reason = dec.reason || '';
if (!(cap0.height >= 0.25 && cap0.height <= 6)) { sanity = 1.8 / (cap0.height || 1); reason += (reason ? '; ' : '') + 'height ' + (+cap0.height).toPrecision(3) + ' m out of range -> rescaled to 1.8 m'; }
const FACING = { '+z': 0, '-z': Math.PI, '+x': -Math.PI / 2, '-x': Math.PI / 2 };
const facing = (opts.facing && opts.facing in FACING) ? opts.facing : '+z';
// fit: capsule axis to x=z=0, feet to y=0, uniform sanity scale, face +Z
const F = new THREE.Matrix4().makeRotationY(FACING[facing]).multiply(new THREE.Matrix4().makeScale(sanity, sanity, sanity))
.multiply(new THREE.Matrix4().makeTranslation(-cap0.cx, -cap0.yMin, -cap0.cz)).multiply(conv);
const fitPos = new Float32Array(world.length);
{ const Fp = new THREE.Matrix4().makeRotationY(FACING[facing]).multiply(new THREE.Matrix4().makeScale(sanity, sanity, sanity)).multiply(new THREE.Matrix4().makeTranslation(-cap0.cx, -cap0.yMin, -cap0.cz));
for (let i = 0; i < world.length; i += 3) { v3.set(world[i], world[i + 1], world[i + 2]).applyMatrix4(Fp); fitPos[i] = v3.x; fitPos[i + 1] = v3.y; fitPos[i + 2] = v3.z; } }
const capsule = (CF && CF.fit) ? CF.fit(fitPos, {}) : Object.assign({}, cap0, { cx: 0, cz: 0, yMin: 0, yMax: cap0.height * sanity, height: cap0.height * sanity });
// head sphere (bind pose), kept in bind space so the engine can move it with the head joint's skinning matrix
let head = null;
if (roles.head != null && roles.head >= 0) {
const hb = new THREE.Vector3().setFromMatrixPosition(new THREE.Matrix4().copy(invBind[roles.head]).invert());
const FA = new THREE.Matrix4().multiplyMatrices(F, A0);
const hp = hb.clone().applyMatrix4(FA);
const hs = (CF && CF.headSphere) ? CF.headSphere(fitPos, [hp.x, hp.y, hp.z], capsule.height, {}) : { c: [hp.x, hp.y + 0.1, hp.z], r: 0.12 };
const cRaw = new THREE.Vector3(hs.c[0], hs.c[1], hs.c[2]).applyMatrix4(FA.clone().invert());
const slot = red.slotOf[roles.head];
head = { joint: roles.head, slot, name: jointNames[roles.head], c: hs.c.slice(), r: hs.r, cRaw: [cRaw.x, cRaw.y, cRaw.z], headBone: [hp.x, hp.y, hp.z],
invBind: Array.from(invBind[roles.head].elements), node: jointNode[roles.head] };
}
// ---- clips ----
const byName = new Map(), byRaw = new Map(), byKey = new Map(), byUuid = new Map();
nodes.forEach((o, i) => { if (o.name && !byName.has(o.name)) byName.set(o.name, i); const r = rawNameOf(o); if (r && !byRaw.has(r)) byRaw.set(r, i);
const k = localBoneKey(r); if (k && !byKey.has(k)) byKey.set(k, i); byUuid.set(o.uuid, i); });
const anims = root.animations || [];
const clips = []; let tracksTotal = 0, tracksBound = 0;
for (const clip of anims) {
const tracks = [];
for (const tr of clip.tracks) {
tracksTotal++;
let pn; try { pn = THREE.PropertyBinding.parseTrackName(tr.name); } catch (_) { continue; }
const path = PROP_PATH[pn.propertyName]; if (!path) continue;
const nn = pn.nodeName;
let ni = byName.has(nn) ? byName.get(nn) : byRaw.has(nn) ? byRaw.get(nn) : byUuid.has(nn) ? byUuid.get(nn) : byKey.get(localBoneKey(nn));
if (ni == null) continue;
const stride = path === 'rotation' ? 4 : 3;
const n = tr.times.length;
let values = tr.values;
if (values.length === n * stride * 3) { const v = new Float32Array(n * stride); for (let i = 0; i < n; i++) for (let k = 0; k < stride; k++) v[i * stride + k] = values[i * stride * 3 + stride + k]; values = v; } // glTF CUBICSPLINE -> keys only
if (values.length !== n * stride) continue;
tracks.push({ node: ni, path, interp: tr.getInterpolation && tr.getInterpolation() === THREE.InterpolateDiscrete ? 'STEP' : 'LINEAR',
times: Float32Array.from(tr.times), values: Float32Array.from(values) });
tracksBound++;
}
if (tracks.length) clips.push({ name: clip.name || ('clip' + clips.length), duration: clip.duration > 0 ? clip.duration : Math.max(...tracks.map((t) => t.times[t.times.length - 1] || 0), 0.001), tracks });
}
const names = clips.map((c) => c.name);
const match = matchClips(names, opts.overrides);
// [e32] root motion: find the moving root (hips or above), measure it in metres and build in-place copies of every clip
const RM = window.MuseRootMotion;
const rootMotion = { node: -1, name: null, perClipM: clips.map(() => 0), up: null, mPerUnit: 1 };
let clipsStripped = clips;
if (RM && RM.pickRoot && clips.length) {
try {
const hipsNode = (roles.hips != null && roles.hips >= 0) ? jointNode[roles.hips] : -1;
const rn = RM.pickRoot(skeleton, clips, hipsNode);
if (rn >= 0) {
const o = nodes[rn];
const P = (o !== root && o.parent) ? o.parent.matrixWorld.clone() : new THREE.Matrix4();
const toEngine = new THREE.Matrix4().multiplyMatrices(conv, P); // parent-local -> engine metres (before the sanity rescale)
const upL = new THREE.Vector3(0, 1, 0).transformDirection(new THREE.Matrix4().copy(toEngine).invert());
const sc = new THREE.Vector3().setFromMatrixScale(toEngine);
const mPerUnit = ((Math.abs(sc.x) + Math.abs(sc.y) + Math.abs(sc.z)) / 3) * sanity;
const up = [upL.x, upL.y, upL.z];
const rj = jointNode.indexOf(rn); // [e32] show the bone's original name (three.js strips ':' from node names)
Object.assign(rootMotion, { node: rn, name: rj >= 0 && jointNames[rj] ? jointNames[rj] : skeleton.nodes[rn].name, up, mPerUnit });
rootMotion.perClipM = clips.map((c) => { const mm = RM.measure(c, rn, up); return mm ? +(mm.horiz * mPerUnit).toFixed(4) : 0; });
clipsStripped = clips.map((c) => RM.strip(c, rn, up, { mode: 'drift' }).clip);
}
} catch (err) { console.warn('[e32] root motion', err); clipsStripped = clips; }
}
const keyTimes = clips.map((c) => { let best = null; for (const t of c.tracks) if (!best || t.times.length > best.length) best = t.times; return best ? Array.from(best) : []; });
const keptSet = new Set(red.keep);
const mergedNames = jointNames.filter((_, j) => !keptSet.has(j));
return { parts, skeleton, clips: opts.stripRootMotion === false ? clips : clipsStripped, clipsRaw: clips, clipsStripped, rootMotion, keyTimes, mergedNames,
clipNames: names, match, joints: { count: J, names: jointNames, node: jointNode, parent: jointParent },
palette: { slots: S, budget, nodes: paletteNodes, invBind: paletteInvBind, merged: red.merged || 0, keep: red.keep.slice() },
roles, capsule, head, fit: Array.from(F.elements), a0: Array.from(A0.elements), decision: dec, reason, sanity, facing, rawSize,
verts, tracksTotal, tracksBound, notes, meta, foldedCopies };
}
/** convenience: load + convert + hand to the engine.
* opts (scene): { asLevel: true|false (default true), units: 'auto'|'m'|'cm'|'mm'|'in'|'ft', up: 'auto'|'y'|'z' }
* opts (character, [e31]): { units, up, facing: '+z'|'-z'|'+x'|'-x', overrides: {role: clipIndex}, noRig: true = Engine 29 static fit } */
export async function importInto(slot, { files, url, asLevel, units, up, facing, overrides, noRig, onProgress, stripRootMotion }) {
const E = window.MuseEngine29;
if (!E) throw new Error('engine not ready');
const prog = typeof onProgress === 'function' ? onProgress : null;
const res = files ? await loadFromFiles(files, prog) : await loadFromUrl(url, prog);
if (prog) prog({ stage: 'build' });
await new Promise((r) => setTimeout(r, 0)); // let the progress text paint before the heavy conversion
try { return await importParsed(E, slot, res, { url, asLevel, units, up, facing, overrides, noRig, prog, stripRootMotion }); }
catch (e) { throw friendlyError(e, res.name); }
}
async function importParsed(E, slot, res, opts) {
const xr = extraReader(res.format);
if (xr && xr.prepare) opts = xr.prepare(res, opts) || opts; // [e32] e.g. a Z-up-by-format reader rotates the root here
return importParsedInner(E, slot, res, opts);
}
function postExtra(info, res) { const xr = extraReader(res.format); if (xr && xr.post) xr.post(info, res); return info; } // [e32] extra reader: Model check facts
async function importParsedInner(E, slot, res, { url, asLevel, units, up, facing, overrides, noRig, prog, stripRootMotion }) {
if (slot === 'scene' && asLevel !== false) {
const lv = toLevelMeshes(res.root, res.format, { units, up });
const tris = Math.round(lv.verts / 3), tex = texFacts(res.manager, lv.meshes), uf = unitsFacts(lv.meta, lv.decision);
const bt = batchMeshes(lv.meshes, 16); lv.meshes = bt.meshes;
const d = lv.decision;
const info = { name: res.name, source: res.source, url: url || null, mode: 'level', meshes: bt.srcParts, verts: lv.verts, // [e32] meshes = parts in the file (drawParts = batches)
textured: lv.textured, colTris: lv.colTris, noColTris: lv.skippedCol, colOnlyMeshes: lv.colOnlyMeshes,
units: d.units, scale: +(+d.scale).toPrecision(4), zUp: !!d.zUp, reason: d.reason || '',
fbxUnitScaleFactor: lv.meta.unitScaleFactor == null ? null : lv.meta.unitScaleFactor, fbxUpAxis: lv.meta.upAxis == null ? null : lv.meta.upAxis,
origSize: lv.rawSize.map((v) => +v.toFixed(3)), size: lv.size.map((v) => +v.toFixed(3)),
min: lv.min.map((v) => +v.toFixed(3)), max: lv.max.map((v) => +v.toFixed(3)), shift: lv.shift, spawnNode: lv.spawn ? lv.spawn.name : null,
srcParts: bt.srcParts, drawParts: lv.meshes.length, triangles: tris, bytes: res.bytes || 0, format: res.format,
check: { kind: 'scene', name: res.name, format: res.format, units: uf.units, up: uf.up, triangles: tris, parts: bt.srcParts, sizeM: lv.size.map((v) => +v.toFixed(3)), textures: tex } };
if (prog) prog({ stage: 'upload' });
postExtra(info, res);
E.setImportedModel(slot, lv.meshes, info, { collision: lv.collision, spawn: lv.spawn ? lv.spawn.pos : null });
return E.importInfo(slot);
}
if (slot === 'character' && !noRig) { // [e31] skinned rig -> animated character
const E31 = E.skinInfo ? E.skinInfo() : { maxBones: 60 };
const rig = toCharacterRig(res.root, res.format, { units, up, facing, maxBones: E31.maxBones, overrides, stripRootMotion });
if (rig) {
let rtris = 0; for (const pt of rig.parts) rtris += pt.positions.length / 9;
const uf = unitsFacts(rig.meta, rig.decision), tex = texFacts(res.manager, rig.parts);
const c = rig.capsule, d = rig.decision;
const info = { name: res.name, source: res.source, url: url || null, mode: 'animated', format: res.format, meshes: rig.parts.length, verts: rig.verts,
joints: rig.joints.count, foldedBoneCopies: rig.foldedCopies, paletteSlots: rig.palette.slots, maxBones: rig.palette.budget, mergedJoints: rig.palette.merged,
clips: rig.clipNames.slice(), roles: Object.assign({}, rig.match.roles), unmatched: rig.match.unmatched.slice(), tracks: rig.tracksBound + '/' + rig.tracksTotal,
units: d.units, scale: +(+d.scale * rig.sanity).toPrecision(4), zUp: !!d.zUp, reason: rig.reason, facing: rig.facing,
fbxUnitScaleFactor: rig.meta.unitScaleFactor == null ? null : rig.meta.unitScaleFactor, fbxUpAxis: rig.meta.upAxis == null ? null : rig.meta.upAxis,
capsule: { radius: +c.radius.toFixed(3), height: +c.height.toFixed(3), coverage: +(+c.coverage || 0).toFixed(3) },
head: rig.head ? { bone: rig.head.name, r: +rig.head.r.toFixed(3), c: rig.head.c.map((v) => +v.toFixed(3)) } : null,
boneRoles: Object.fromEntries(Object.entries(rig.roles).map(([k, v]) => [k, v >= 0 ? rig.joints.names[v] : null])), notes: rig.notes.slice(0, 8),
origSize: rig.rawSize.map((v) => +v.toFixed(3)), triangles: Math.round(rtris), bytes: res.bytes || 0,
rootMotion: { node: rig.rootMotion.name, perClipM: rig.rootMotion.perClipM.slice() },
check: { kind: 'character', name: res.name, format: res.format, units: uf.units, up: uf.up, triangles: Math.round(rtris), parts: rig.parts.length,
sizeM: [0, +c.height.toFixed(3), 0], textures: tex,
rig: { skinned: true, bones: rig.joints.count, gpuSlots: rig.palette.slots, maxBones: rig.palette.budget, merged: rig.mergedNames.slice(), heightM: +c.height.toFixed(3),
boneList: rig.joints.names.map((n, j) => ({ name: n, parent: rig.joints.parent[j], kept: rig.palette.keep.indexOf(j) >= 0 })),
clips: rig.clipNames.map((n, k) => ({ name: n, duration: +rig.clipsRaw[k].duration.toFixed(4), times: rig.keyTimes[k], rootMotionM: rig.rootMotion.perClipM[k] })) } } };
if (prog) prog({ stage: 'upload' });
postExtra(info, res);
E.setImportedModel(slot, rig.parts, info, { rig });
return E.importInfo(slot);
}
}
const conv = toEngineMeshes(res.root, slot === 'scene' ? 'scene' : 'character');
const tris = Math.round(conv.verts / 3), tex = texFacts(res.manager, conv.meshes);
const bt = batchMeshes(conv.meshes, 0);
const ud = res.root.userData || {};
const uf = unitsFacts({ format: res.format, unitScaleFactor: ud.unitScaleFactor, upAxis: ud.fbxUpAxis, forcedUnits: 'auto', forcedUp: 'auto' }, { units: 'm', zUp: ud.fbxUpAxis === 2 });
const info = { name: res.name, source: res.source, url: url || null, mode: slot === 'scene' ? 'fit' : 'character', meshes: bt.srcParts, drawParts: bt.meshes.length, verts: conv.verts,
origSize: conv.origSize.map((v) => +v.toFixed(3)), scale: +conv.scale.toPrecision(4), srcParts: bt.srcParts, triangles: tris, bytes: res.bytes || 0, format: res.format,
check: { kind: slot, name: res.name, format: res.format, units: uf.units, up: uf.up, triangles: tris, parts: bt.srcParts, sizeM: conv.origSize.map((v) => +(v * conv.scale).toFixed(3)), textures: tex,
scene: slot === 'scene' ? { mode: 'fit' } : undefined, rig: slot === 'character' ? { skinned: false } : undefined } };
if (prog) prog({ stage: 'upload' });
postExtra(info, res);
E.setImportedModel(slot, bt.meshes, info);
return E.importInfo(slot) || info;
}