// [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 . 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; }