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;
}