File size: 7,630 Bytes
8fb1496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// [e29 coach] Model import for the config panel: parse .glb / .gltf (GLTFLoader) and .fbx (FBXLoader)
// with vendored three.js r180 (MIT), then hand plain triangle arrays to the raw-WebGL engine.
// three.js is only used as a parser here; the engine renders the result itself.
// Imported models are VISUAL ONLY: colliders and hitboxes stay the built-in ones.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';

const MAX_VERTS = 1500000;

function extOf(name) { const m = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(name || ''); return m ? m[1].toLowerCase() : ''; }
function baseName(u) { try { u = decodeURIComponent(u); } catch (_) {} return u.split(/[\\/]/).pop().split('?')[0]; }

function parseBuffer(ext, buf, manager, basePath) {
  return new Promise((resolve, reject) => {
    if (ext === 'fbx') {
      try { resolve(new FBXLoader(manager).parse(buf, basePath || '')); } catch (e) { reject(e); }
    } else if (ext === 'glb' || ext === 'gltf') {
      new GLTFLoader(manager).parse(buf, basePath || '', (g) => resolve(g.scene || (g.scenes && g.scenes[0])), reject);
    } else reject(new Error('Unsupported file type: .' + ext + ' (use .glb, .gltf or .fbx)'));
  });
}

function waitManager(manager) {
  // textures load asynchronously after parse; wait (bounded) for the manager to go idle
  return new Promise((resolve) => {
    let done = false;
    const finish = () => { if (!done) { done = true; resolve(); } };
    manager.onLoad = finish;
    setTimeout(finish, 6000);
    setTimeout(() => { if (!manager._busy) finish(); }, 50);
  });
}

function makeManager(fileMap) {
  const manager = new THREE.LoadingManager();
  manager._busy = 0;
  manager.onStart = () => { manager._busy++; };
  manager.onError = (u) => console.info('[import] could not load resource:', baseName(u), '(material colour used instead)');
  if (fileMap) manager.setURLModifier((u) => fileMap[baseName(u).toLowerCase()] || u);
  return manager;
}

/** files: FileList/array from <input type=file multiple>. Picks the first .glb/.gltf/.fbx as the main file;
 *  other selected files (.bin, textures) resolve relative references by file name. */
export async function loadFromFiles(files) {
  files = Array.from(files || []);
  const main = files.find((f) => /\.(glb|gltf|fbx)$/i.test(f.name));
  if (!main) throw new Error('Pick a .glb, .gltf or .fbx file (plus its .bin/textures for .gltf/.fbx if separate)');
  const fileMap = {}; const urls = [];
  for (const f of files) { const u = URL.createObjectURL(f); urls.push(u); fileMap[f.name.toLowerCase()] = u; }
  const manager = makeManager(fileMap);
  const ext = extOf(main.name);
  const buf = ext === 'gltf' ? await main.text() : await main.arrayBuffer();
  const root = await parseBuffer(ext, buf, manager, '');
  await waitManager(manager);
  setTimeout(() => urls.forEach((u) => URL.revokeObjectURL(u)), 10000);
  return { root, name: main.name, source: 'file' };
}

export async function loadFromUrl(url) {
  const r = await fetch(url);
  if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + url);
  const ext = extOf(url);
  const buf = ext === 'gltf' ? await r.text() : await r.arrayBuffer();
  const manager = makeManager(null);
  const basePath = url.slice(0, url.lastIndexOf('/') + 1);
  const root = await parseBuffer(ext, buf, manager, basePath);
  await waitManager(manager);
  return { root, name: baseName(url), source: 'url' };
}

/** Flatten to world-space triangles, then normalise.
 *  mode 'scene': fit the XZ footprint to the 20x20 map (+-10), centre on X/Z, sit on y=0.
 *  mode 'character': fit into the collider's unit box (-0.5..0.5, feet at y=-0.5); the engine
 *  then scales it by the same matrix as the built-in character box (so fall/boom FX still apply). */
export function toEngineMeshes(root, mode) {
  root.updateMatrixWorld(true);
  const out = []; let verts = 0;
  const tmpC = new THREE.Color();
  root.traverse((o) => {
    if (!o.isMesh || !o.geometry || !o.geometry.attributes.position) return;
    let g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone();
    g.applyMatrix4(o.matrixWorld);
    if (!g.attributes.normal) g.computeVertexNormals();
    const mats = Array.isArray(o.material) ? o.material : [o.material];
    const groups = (g.groups && g.groups.length && Array.isArray(o.material)) ? g.groups : [{ start: 0, count: g.attributes.position.count, materialIndex: 0 }];
    const PA = g.attributes.position, NA = g.attributes.normal, UA = g.attributes.uv || null;
    const take = (attr, start, count, dim) => {
      const a = new Float32Array(count * dim);
      for (let i = 0; i < count; i++) {
        a[i * dim] = attr.getX(start + i); a[i * dim + 1] = attr.getY(start + i);
        if (dim === 3) a[i * dim + 2] = attr.getZ(start + i);
      }
      return a;
    };
    for (const gr of groups) {
      const count = Math.min(gr.count, PA.count - gr.start);
      if (count <= 0) continue;
      verts += count;
      if (verts > MAX_VERTS) break;
      const mat = mats[gr.materialIndex || 0] || mats[0] || {};
      let color = [0.8, 0.8, 0.8];
      if (mat.color) { mat.color.getRGB(tmpC, THREE.SRGBColorSpace); color = [tmpC.r, tmpC.g, tmpC.b]; }
      const map = mat.map && mat.map.image ? mat.map : null;
      out.push({
        positions: take(PA, gr.start, count, 3),
        normals: take(NA, gr.start, count, 3),
        uvs: UA ? take(UA, gr.start, count, 2) : null,
        color: map ? [1, 1, 1] : color,
        image: map ? map.image : null,
        flipY: map ? !!map.flipY : false
      });
    }
  });
  if (!out.length) throw new Error('No meshes found in that file');
  if (verts > MAX_VERTS) throw new Error('Model too heavy for this WebGL1 engine (' + verts + ' verts > ' + MAX_VERTS + ')');
  // bounds
  let mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
  for (const m of out) for (let i = 0; i < m.positions.length; i += 3) for (let k = 0; k < 3; k++) {
    const v = m.positions[i + k]; if (v < mn[k]) mn[k] = v; if (v > mx[k]) mx[k] = v;
  }
  const size = [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]];
  const cx = (mn[0] + mx[0]) / 2, cz = (mn[2] + mx[2]) / 2;
  let s, oy;
  if (mode === 'scene') {
    const foot = Math.max(size[0], size[2]) || size[1] || 1;
    s = 20 / foot; oy = -mn[1] * s;
  } else {
    const big = Math.max(size[1], size[0], size[2]) || 1;
    s = 1 / big; oy = -0.5 - mn[1] * s;
  }
  for (const m of out) {
    const p = m.positions;
    for (let i = 0; i < p.length; i += 3) { p[i] = (p[i] - cx) * s; p[i + 1] = p[i + 1] * s + oy; p[i + 2] = (p[i + 2] - cz) * s; }
    // uniform scale keeps normals valid (renormalise in case the source had scaled nodes)
    const n = m.normals;
    for (let i = 0; i < n.length; i += 3) { const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; n[i] /= l; n[i + 1] /= l; n[i + 2] /= l; }
  }
  return { meshes: out, verts, origSize: size, scale: s };
}

/** convenience: load + convert + hand to the engine */
export async function importInto(slot, { files, url }) {
  const E = window.MuseEngine29;
  if (!E) throw new Error('engine not ready');
  const res = files ? await loadFromFiles(files) : await loadFromUrl(url);
  const conv = toEngineMeshes(res.root, slot === 'scene' ? 'scene' : 'character');
  const info = { name: res.name, source: res.source, url: url || null, meshes: conv.meshes.length, verts: conv.verts,
    origSize: conv.origSize.map((v) => +v.toFixed(3)), scale: +conv.scale.toPrecision(4) };
  E.setImportedModel(slot, conv.meshes, info);
  return info;
}