Spaces:
Running
Running
File size: 59,421 Bytes
6c78aa4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 | // [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;
}
|