Files

2427 lines
129 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Costume maths (Costume tab), pure and node-testable: the hero body as a .glb for Blender, glTF /
// OBJ imports, weights from the file's bones or taken over from the LS body, the bind-pose fix of
// linked skeletons and the new .msh fields, material script and .dds.
//
// Spaces: "posed" = the skeleton's rest pose (SkinKit.pose without an animation) in LS units, where
// every mesh is rigidly skinned; a vertex is stored as u · A⁻¹ with A = Σ w · BipedSpaceTM · World so
// the game puts it back exactly. glTF: right-handed, metres → LS (x, y, -z) · 100, winding reversed.
const CostumeKit = (() => {
const SK = window.SkinKit;
const M4 = SK.M4;
const UNIT = 100; // LS units per glTF metre
const MAX_BONES = 24; // no client mesh uses more (7 509 skinned meshes checked)
const qmul = (a, b) => [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];
const qconj = (q) => [-q[0], -q[1], -q[2], q[3]];
function qrot(q, v) {
const [x, y, z, w] = q;
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
}
// row-vector affine maths (SkinKit's layout; a glTF column-major matrix array is the same numbers)
const xf = (M, x, y, z, o, i) => {
o[i] = x * M[0] + y * M[4] + z * M[8] + M[12];
o[i + 1] = x * M[1] + y * M[5] + z * M[9] + M[13];
o[i + 2] = x * M[2] + y * M[6] + z * M[10] + M[14];
};
const xn = (M, x, y, z, o, i) => {
o[i] = x * M[0] + y * M[4] + z * M[8];
o[i + 1] = x * M[1] + y * M[5] + z * M[9];
o[i + 2] = x * M[2] + y * M[6] + z * M[10];
};
function trs(t = [0, 0, 0], q = [0, 0, 0, 1], s = [1, 1, 1]) {
const m = M4.fromQT(q, t);
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) m[r * 4 + c] *= s[r];
return m;
}
const normName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
// =====================================================================
// the LS side: rest pose, meshes in it
// =====================================================================
/** The skeleton's rest pose (wp, wr, skin) — where the costume is modelled. */
const restPose = (sk) => SK.pose(sk, null, null, 0);
/**
* An LS mesh (parseMsh fields) in the rest pose: posed positions / normals, and its weights by
* skeleton bone index (4 per vertex). → { pos, nrm, uv, idx, bone, w, subs }
*/
function posed(sk, P, data) {
const pal = SK.palette(sk, data);
const m = { data, skinPos: new Float32Array(data.pos.length), skinNrm: data.normal ? new Float32Array(data.normal.length) : null };
if (pal) SK.skin(m, pal, P);
else m.skinPos.set(data.pos);
const V = data.vertexCount ?? data.pos.length / 3;
const bone = new Int16Array(V * 4).fill(-1);
const w = new Float32Array(V * 4);
if (pal) for (let i = 0; i < V * 4; i++) {
const b = pal[data.boneIdx[i]];
if (data.weights[i] > 0 && b >= 0) {
bone[i] = b;
w[i] = data.weights[i];
}
}
return { pos: m.skinPos, nrm: m.skinNrm, uv: data.uv0, idx: data.indices, bone, w, subs: data.subs };
}
// =====================================================================
// .glb export (the body, or any LS mesh, for Blender)
// =====================================================================
/**
* meshes: [{ name, pos, nrm, uv, idx, bone, w, groups: [{ start, count, image }] }] in the rest
* pose (posed()); images: [{ name, png }]. Every skeleton bone becomes a joint.
*/
function exportGlb(sk, P, meshes, images = []) {
const bin = [];
let binLen = 0;
const json = { asset: { version: "2.0", generator: "LS Tools · Costume" }, scene: 0, scenes: [{ nodes: [] }], nodes: [], meshes: [], accessors: [], bufferViews: [], buffers: [], materials: [], textures: [], images: [], samplers: [{ magFilter: 9729, minFilter: 9729 }], skins: [] };
function view(bytes, target) {
const pad = (4 - (binLen % 4)) % 4;
if (pad) bin.push(new Uint8Array(pad)), (binLen += pad);
json.bufferViews.push(Object.assign({ buffer: 0, byteOffset: binLen, byteLength: bytes.byteLength }, target ? { target } : {}));
bin.push(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
binLen += bytes.byteLength;
return json.bufferViews.length - 1;
}
function acc(arr, type, componentType, target, extra = {}) {
const n = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }[type];
json.accessors.push(Object.assign({ bufferView: view(arr, target), componentType, count: arr.length / n, type }, extra));
return json.accessors.length - 1;
}
// joints: the bones in the rest pose (LS → glTF: z mirrored, metres)
const n = sk.data.bones.length;
const toG = (p) => [p[0] / UNIT, p[1] / UNIT, -p[2] / UNIT];
const qG = (q) => [-q[0], -q[1], q[2], q[3]];
const ibm = new Float32Array(n * 16);
for (let i = 0; i < n; i++) {
const pi = sk.parent[i];
const node = { name: sk.data.bones[i].name.text };
let q = P.wr[i], t = P.wp[i];
if (pi >= 0) {
const c = qconj(P.wr[pi]);
q = qmul(c, P.wr[i]);
t = qrot(c, [P.wp[i][0] - P.wp[pi][0], P.wp[i][1] - P.wp[pi][1], P.wp[i][2] - P.wp[pi][2]]);
}
const ql = Math.hypot(...q);
node.translation = toG(t);
node.rotation = qG(q.map((v) => v / ql));
json.nodes.push(node);
ibm.set(M4.invertAffine(M4.fromQT(qG(P.wr[i]), toG(P.wp[i]))), i * 16);
}
sk.parent.forEach((pi, i) => pi >= 0 && (json.nodes[pi].children ||= []).push(i));
const roots = sk.parent.map((p, i) => (p < 0 ? i : -1)).filter((i) => i >= 0);
json.skins.push({ name: "LS skeleton", joints: [...Array(n).keys()], inverseBindMatrices: acc(ibm, "MAT4", 5126), skeleton: roots[0] });
// textures
const imgIndex = new Map();
for (const im of images) {
if (!im?.png || imgIndex.has(im.name)) continue;
json.images.push({ name: im.name, mimeType: "image/png", bufferView: view(im.png) });
json.textures.push({ sampler: 0, source: json.images.length - 1 });
imgIndex.set(im.name, json.textures.length - 1);
}
const matIndex = new Map();
const material = (image) => {
const key = image || "";
if (!matIndex.has(key)) {
const m = { name: image ? image.replace(/\.[^.]+$/, "") : "plain", pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1 }, doubleSided: true };
if (image && imgIndex.has(image)) m.pbrMetallicRoughness.baseColorTexture = { index: imgIndex.get(image) };
else m.pbrMetallicRoughness.baseColorFactor = [0.8, 0.8, 0.8, 1];
json.materials.push(m);
matIndex.set(key, json.materials.length - 1);
}
return matIndex.get(key);
};
// meshes: one primitive per draw group
for (const m of meshes) {
const V = m.pos.length / 3;
const pos = new Float32Array(V * 3), nrm = m.nrm ? new Float32Array(V * 3) : null;
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (let v = 0; v < V; v++) {
const p = toG([m.pos[v * 3], m.pos[v * 3 + 1], m.pos[v * 3 + 2]]);
pos.set(p, v * 3);
for (let k = 0; k < 3; k++) (min[k] = Math.min(min[k], p[k])), (max[k] = Math.max(max[k], p[k]));
if (nrm) {
const x = m.nrm[v * 3], y = m.nrm[v * 3 + 1], z = -m.nrm[v * 3 + 2];
const l = Math.hypot(x, y, z) || 1;
nrm.set([x / l, y / l, z / l], v * 3);
}
}
const joints = new Uint16Array(V * 4), weights = new Float32Array(V * 4);
let skinned = false;
for (let v = 0; v < V; v++) {
let t = 0;
for (let j = 0; j < 4; j++) if (m.bone[v * 4 + j] >= 0) t += m.w[v * 4 + j];
for (let j = 0; j < 4; j++) {
const b = m.bone[v * 4 + j];
if (b >= 0 && t > 0) {
joints[v * 4 + j] = b;
weights[v * 4 + j] = m.w[v * 4 + j] / t;
skinned = true;
}
}
if (t <= 0) weights[v * 4] = 1, (joints[v * 4] = roots[0]);
}
const attributes = { POSITION: acc(pos, "VEC3", 5126, 34962, { min, max }) };
if (nrm) attributes.NORMAL = acc(nrm, "VEC3", 5126, 34962);
if (m.uv) attributes.TEXCOORD_0 = acc(Float32Array.from(m.uv), "VEC2", 5126, 34962);
if (skinned) {
attributes.JOINTS_0 = acc(joints, "VEC4", 5123, 34962);
attributes.WEIGHTS_0 = acc(weights, "VEC4", 5126, 34962);
}
const groups = m.groups?.length ? m.groups : [{ start: 0, count: m.idx.length, image: null }];
const primitives = groups.map((g) => {
const ix = new Uint32Array(g.count);
for (let i = 0; i < g.count; i += 3) {
// mirrored: clockwise (D3D) faces become counter-clockwise ones by swapping two corners
ix[i] = m.idx[g.start + i];
ix[i + 1] = m.idx[g.start + i + 2];
ix[i + 2] = m.idx[g.start + i + 1];
}
return { attributes, indices: acc(ix, "SCALAR", 5125, 34963), material: material(g.image) };
});
json.meshes.push({ name: m.name, primitives });
json.nodes.push(Object.assign({ name: m.name, mesh: json.meshes.length - 1 }, skinned ? { skin: 0 } : {}));
json.scenes[0].nodes.push(json.nodes.length - 1);
}
json.scenes[0].nodes.push(...roots);
for (const k of ["materials", "textures", "images"]) if (!json[k].length) delete json[k];
if (!json.images) delete json.samplers;
json.buffers.push({ byteLength: binLen });
return glbPack(json, bin, binLen);
}
function glbPack(json, chunks, binLen) {
const enc = new TextEncoder().encode(JSON.stringify(json));
const jl = Math.ceil(enc.length / 4) * 4;
const bl = Math.ceil(binLen / 4) * 4;
const out = new Uint8Array(12 + 8 + jl + 8 + bl);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x46546c67, true); // glTF
dv.setUint32(4, 2, true);
dv.setUint32(8, out.length, true);
dv.setUint32(12, jl, true);
dv.setUint32(16, 0x4e4f534a, true); // JSON
out.set(enc, 20);
out.fill(0x20, 20 + enc.length, 20 + jl);
dv.setUint32(20 + jl, bl, true);
dv.setUint32(24 + jl, 0x004e4942, true); // BIN
let o = 28 + jl;
for (const c of chunks) out.set(c, o), (o += c.length);
return out;
}
// =====================================================================
// glTF / .glb import
// =====================================================================
const COMP = { 5120: [Int8Array, 1, 127], 5121: [Uint8Array, 1, 255], 5122: [Int16Array, 2, 32767], 5123: [Uint16Array, 2, 65535], 5125: [Uint32Array, 4, 0], 5126: [Float32Array, 4, 0] };
const SIZE = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
function b64(s) {
if (typeof atob === "function") return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
return new Uint8Array(Buffer.from(s, "base64"));
}
const dataUri = (uri) => {
const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(uri);
return m ? (m[2] ? b64(m[3]) : new TextEncoder().encode(decodeURIComponent(m[3]))) : null;
};
/**
* A .glb / .gltf → { meshes: [{ name, prims: [{ pos, nrm, uv, idx, joints, weights, material }] }],
* joints: [{ name, parent, world }], materials: [{ name, image, color }], images: [{ name, bytes, mime }] }
* with every position already where the file shows it (node transforms, skins at their rest pose).
* readRel(uri) → Uint8Array for external .bin / images.
*/
async function loadGltf(bytes, readRel = null) {
let json, binChunk = null;
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length >= 12 && dv.getUint32(0, true) === 0x46546c67) {
let o = 12;
while (o + 8 <= bytes.length) {
const len = dv.getUint32(o, true), type = dv.getUint32(o + 4, true);
const chunk = bytes.subarray(o + 8, o + 8 + len);
if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(chunk));
else if (type === 0x004e4942) binChunk = chunk;
o += 8 + len;
}
} else json = JSON.parse(new TextDecoder().decode(bytes));
if (!json) throw new Error("Not a glTF file.");
if (!/^2/.test(json.asset?.version || "")) throw new Error(`glTF ${json.asset?.version || "?"}: only glTF 2.0 files can be read (export again from Blender).`);
const buffers = [];
for (const [i, b] of (json.buffers || []).entries()) {
if (b.uri == null) buffers.push(binChunk);
else if (b.uri.startsWith("data:")) buffers.push(dataUri(b.uri));
else if (readRel) buffers.push(await readRel(decodeURIComponent(b.uri)));
else throw new Error(`The .bin file ${b.uri} is needed.`);
if (!buffers[i]) throw new Error(`Buffer ${i} is missing.`);
}
const viewBytes = (vi) => {
const v = json.bufferViews[vi];
const b = buffers[v.buffer];
return { bytes: b.subarray(v.byteOffset || 0, (v.byteOffset || 0) + v.byteLength), stride: v.byteStride || 0 };
};
function read(ai) {
const a = json.accessors[ai];
const n = SIZE[a.type];
const [T, size, norm] = COMP[a.componentType];
const out = new Float32Array(a.count * n);
if (a.bufferView != null) {
const { bytes: vb, stride } = viewBytes(a.bufferView);
const step = stride || size * n;
const d = new DataView(vb.buffer, vb.byteOffset, vb.byteLength);
const get = { 5120: "getInt8", 5121: "getUint8", 5122: "getInt16", 5123: "getUint16", 5125: "getUint32", 5126: "getFloat32" }[a.componentType];
for (let i = 0; i < a.count; i++) for (let k = 0; k < n; k++) {
let v = d[get]((a.byteOffset || 0) + i * step + k * size, true);
if (a.normalized && norm) v = Math.max(v / norm, -1);
out[i * n + k] = v;
}
}
if (a.sparse) {
const s = a.sparse;
const ib = viewBytes(s.indices.bufferView).bytes, vb = viewBytes(s.values.bufferView).bytes;
const id = new DataView(ib.buffer, ib.byteOffset + (s.indices.byteOffset || 0));
const vd = new DataView(vb.buffer, vb.byteOffset + (s.values.byteOffset || 0));
const ig = { 5121: ["getUint8", 1], 5123: ["getUint16", 2], 5125: ["getUint32", 4] }[s.indices.componentType];
for (let i = 0; i < s.count; i++) {
const at = id[ig[0]](i * ig[1], true);
for (let k = 0; k < n; k++) out[at * n + k] = vd.getFloat32((i * n + k) * 4, true);
}
}
void T;
return out;
}
// node world matrices (rest: the file's own TRS)
const nodes = json.nodes || [];
const local = nodes.map((nd) => (nd.matrix ? Float64Array.from(nd.matrix) : trs(nd.translation, nd.rotation, nd.scale)));
const parent = new Int32Array(nodes.length).fill(-1);
nodes.forEach((nd, i) => (nd.children || []).forEach((c) => (parent[c] = i)));
const world = new Array(nodes.length);
const worldOf = (i) => world[i] || (world[i] = parent[i] >= 0 ? M4.mul(local[i], worldOf(parent[i])) : local[i]);
// joints of every skin, by node
const jointIndex = new Map();
const joints = [];
for (const s of json.skins || []) for (const ni of s.joints) if (!jointIndex.has(ni)) {
jointIndex.set(ni, joints.length);
joints.push({ node: ni, name: nodes[ni].name || `joint${ni}`, parent: -1, world: worldOf(ni) });
}
for (const j of joints) {
let p = parent[j.node];
while (p >= 0 && !jointIndex.has(p)) p = parent[p];
j.parent = p >= 0 ? jointIndex.get(p) : -1;
}
const materials = (json.materials || []).map((m, i) => {
const pbr = m.pbrMetallicRoughness || {};
const t = pbr.baseColorTexture;
return { name: m.name || `material${i}`, image: t ? (json.textures?.[t.index]?.source ?? -1) : -1, uvSet: t?.texCoord || 0, color: pbr.baseColorFactor || [1, 1, 1, 1], blend: m.alphaMode === "BLEND" || m.alphaMode === "MASK" };
});
const images = [];
for (const [i, im] of (json.images || []).entries()) {
let b = null;
try {
if (im.bufferView != null) b = viewBytes(im.bufferView).bytes;
else if (im.uri?.startsWith("data:")) b = dataUri(im.uri);
else if (im.uri && readRel) b = await readRel(decodeURIComponent(im.uri));
} catch {
b = null;
}
images.push({ name: im.name || (im.uri && !im.uri.startsWith("data:") ? im.uri.split(/[\\/]/).pop() : `image${i}`), bytes: b, mime: im.mimeType || (/\.jpe?g$/i.test(im.uri || "") ? "image/jpeg" : "image/png") });
}
// mesh instances
const meshes = [];
nodes.forEach((nd, ni) => {
if (nd.mesh == null) return;
const mesh = json.meshes[nd.mesh];
const skin = nd.skin != null ? json.skins[nd.skin] : null;
// a skinned vertex at the rest pose: v · IBM · JointWorld (the mesh node's own transform is ignored)
let mats = null, jmap = null;
if (skin) {
const ib = skin.inverseBindMatrices != null ? read(skin.inverseBindMatrices) : null;
mats = skin.joints.map((jn, k) => M4.mul(ib ? Float64Array.from(ib.subarray(k * 16, k * 16 + 16)) : trs(), worldOf(jn)));
jmap = skin.joints.map((jn) => jointIndex.get(jn));
}
const W = worldOf(ni);
const prims = [];
for (const p of mesh.primitives) {
const mode = p.mode ?? 4;
if (mode !== 4 && mode !== 5 && mode !== 6) continue;
const A = p.attributes;
if (A.POSITION == null) continue;
const src = read(A.POSITION);
const V = src.length / 3;
const nsrc = A.NORMAL != null ? read(A.NORMAL) : null;
const mat = p.material != null ? materials[p.material] : null;
const uvKey = `TEXCOORD_${mat?.uvSet || 0}`;
const uv = A[uvKey] != null ? read(A[uvKey]) : A.TEXCOORD_0 != null ? read(A.TEXCOORD_0) : null;
const pos = new Float32Array(V * 3), nrm = nsrc ? new Float32Array(V * 3) : null;
let jw = null, ww = null;
if (skin && A.JOINTS_0 != null && A.WEIGHTS_0 != null) {
const j0 = read(A.JOINTS_0), w0 = read(A.WEIGHTS_0);
const j1 = A.JOINTS_1 != null ? read(A.JOINTS_1) : null, w1 = A.WEIGHTS_1 != null ? read(A.WEIGHTS_1) : null;
const k = j1 && w1 ? 8 : 4;
jw = new Int32Array(V * k);
ww = new Float32Array(V * k);
for (let v = 0; v < V; v++) {
for (let i = 0; i < 4; i++) (jw[v * k + i] = jmap[j0[v * 4 + i]] ?? -1), (ww[v * k + i] = w0[v * 4 + i]);
if (k === 8) for (let i = 0; i < 4; i++) (jw[v * k + 4 + i] = jmap[j1[v * 4 + i]] ?? -1), (ww[v * k + 4 + i] = w1[v * 4 + i]);
// rest-pose position
const M = new Float64Array(16);
let t = 0;
for (let i = 0; i < 4; i++) {
const w = w0[v * 4 + i];
if (w <= 0) continue;
const S = mats[j0[v * 4 + i]];
if (!S) continue;
for (let e = 0; e < 16; e++) M[e] += w * S[e];
t += w;
}
if (k === 8) for (let i = 0; i < 4; i++) {
const w = w1[v * 4 + i];
const S = w > 0 && mats[j1[v * 4 + i]];
if (!S) continue;
for (let e = 0; e < 16; e++) M[e] += w * S[e];
t += w;
}
const use = t > 0 ? M.map((e) => e / t) : W;
xf(use, src[v * 3], src[v * 3 + 1], src[v * 3 + 2], pos, v * 3);
if (nrm) xn(use, nsrc[v * 3], nsrc[v * 3 + 1], nsrc[v * 3 + 2], nrm, v * 3);
}
jw.k = k;
} else
for (let v = 0; v < V; v++) {
xf(W, src[v * 3], src[v * 3 + 1], src[v * 3 + 2], pos, v * 3);
if (nrm) xn(W, nsrc[v * 3], nsrc[v * 3 + 1], nsrc[v * 3 + 2], nrm, v * 3);
}
let idx = p.indices != null ? Uint32Array.from(read(p.indices)) : Uint32Array.from({ length: V }, (_, i) => i);
if (mode === 5) idx = strip(idx);
if (mode === 6) idx = fan(idx);
// a mirrored node (negative scale) turns its faces inside out
if (det3(skin ? null : W) < 0) for (let i = 0; i < idx.length; i += 3) [idx[i + 1], idx[i + 2]] = [idx[i + 2], idx[i + 1]];
prims.push(compact({ pos, nrm, uv, idx, joints: jw, weights: ww, material: p.material ?? -1 }));
}
if (prims.length) meshes.push({ name: nd.name || mesh.name || `mesh${meshes.length}`, prims });
});
if (!meshes.length) throw new Error("No meshes in this file.");
return { meshes, joints, materials, images, ccw: true };
}
/** Only the vertices a primitive's faces use (primitives often share one vertex list), in order. */
function compact(p) {
const V = p.pos.length / 3;
const used = new Int32Array(V).fill(-1);
for (const i of p.idx) used[i] = 0;
let n = 0;
for (let v = 0; v < V; v++) if (used[v] === 0) used[v] = n++;
if (n === V) return p;
const pick = (a, k) => {
if (!a) return a;
const o = new a.constructor(n * k);
for (let v = 0; v < V; v++) if (used[v] >= 0) for (let j = 0; j < k; j++) o[used[v] * k + j] = a[v * k + j];
return o;
};
const k = p.joints?.k || 4;
const joints = pick(p.joints, k);
if (joints) joints.k = k;
return { ...p, pos: pick(p.pos, 3), nrm: pick(p.nrm, 3), uv: pick(p.uv, 2), joints, weights: pick(p.weights, k), idx: p.idx.map((i) => used[i]) };
}
const det3 = (m) => (m ? m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8]) + m[2] * (m[4] * m[9] - m[5] * m[8]) : 1);
function strip(ix) {
const out = [];
for (let i = 2; i < ix.length; i++) out.push(...(i % 2 ? [ix[i - 1], ix[i - 2], ix[i]] : [ix[i - 2], ix[i - 1], ix[i]]));
return Uint32Array.from(out);
}
function fan(ix) {
const out = [];
for (let i = 2; i < ix.length; i++) out.push(ix[0], ix[i - 1], ix[i]);
return Uint32Array.from(out);
}
// =====================================================================
// .obj import (no bones: weights come from the body)
// =====================================================================
/** → same shape as loadGltf; images carry only names (map_Kd), the caller loads them. */
function parseObj(text, mtlText = "") {
const mtl = new Map();
let cur = null;
for (const line of mtlText.split(/\r?\n/)) {
const t = line.trim().split(/\s+/);
if (t[0] === "newmtl") mtl.set((cur = line.trim().slice(7).trim()), { kd: [1, 1, 1], map: null });
else if (cur && t[0] === "Kd") mtl.get(cur).kd = t.slice(1, 4).map(Number);
else if (cur && /^map_Kd$/i.test(t[0])) mtl.get(cur).map = line.trim().replace(/^map_Kd\s+(-\S+\s+\S+\s+)*/i, "").trim();
}
const P = [], T = [], N = [];
// one mesh per object ("o" / "g"), one primitive per material in it
const objects = new Map(); // name → Map(material → { map, pos, uv, nrm, idx })
let obj = "default", mat = "default", g = null;
const use = () => {
if (!objects.has(obj)) objects.set(obj, new Map());
const o = objects.get(obj);
if (!o.has(mat)) o.set(mat, { map: new Map(), pos: [], uv: [], nrm: [], idx: [] });
g = o.get(mat);
};
use();
for (const line of text.split(/\r?\n/)) {
const t = line.trim().split(/\s+/);
if (t[0] === "v") P.push(+t[1], +t[2], +t[3]);
else if (t[0] === "vt") T.push(+t[1], 1 - +(t[2] ?? 0)); // OBJ v grows upwards, D3D downwards
else if (t[0] === "vn") N.push(+t[1], +t[2], +t[3]);
else if (t[0] === "usemtl") (mat = line.trim().slice(7).trim()), use();
else if (t[0] === "o" || (t[0] === "g" && t.length > 1)) (obj = line.trim().slice(2).trim() || obj), use();
else if (t[0] === "f") {
const corner = (s) => {
if (g.map.has(s)) return g.map.get(s);
const [a, b, c] = s.split("/").map((x) => (x ? parseInt(x, 10) : 0));
const fix = (i, len) => (i < 0 ? len / 3 + i : i - 1);
const pi = fix(a, P.length);
g.pos.push(P[pi * 3], P[pi * 3 + 1], P[pi * 3 + 2]);
const ti = b ? (b < 0 ? T.length / 2 + b : b - 1) : -1;
g.uv.push(ti >= 0 ? T[ti * 2] : 0, ti >= 0 ? T[ti * 2 + 1] : 0);
const ni = c ? fix(c, N.length) : -1;
g.nrm.push(ni >= 0 ? N[ni * 3] : 0, ni >= 0 ? N[ni * 3 + 1] : 0, ni >= 0 ? N[ni * 3 + 2] : 0);
g.map.set(s, g.pos.length / 3 - 1);
return g.pos.length / 3 - 1;
};
const c = t.slice(1).map(corner);
for (let i = 2; i < c.length; i++) g.idx.push(c[0], c[i - 1], c[i]);
}
}
const materials = [], images = [], meshes = [];
const matIndex = new Map();
const material = (name) => {
if (matIndex.has(name)) return matIndex.get(name);
const m = mtl.get(name);
let image = -1;
if (m?.map) {
image = images.findIndex((x) => x.name === m.map);
if (image < 0) images.push({ name: m.map, bytes: null, mime: /\.jpe?g$/i.test(m.map) ? "image/jpeg" : "image/png" }), (image = images.length - 1);
}
materials.push({ name, image, color: [...(m?.kd || [1, 1, 1]), 1], blend: false });
matIndex.set(name, materials.length - 1);
return materials.length - 1;
};
for (const [name, mats] of objects) {
const prims = [];
for (const [mname, gr] of mats) {
if (!gr.idx.length) continue;
const hasN = gr.nrm.some((v) => v !== 0);
prims.push({ pos: Float32Array.from(gr.pos), nrm: hasN ? Float32Array.from(gr.nrm) : null, uv: Float32Array.from(gr.uv), idx: Uint32Array.from(gr.idx), joints: null, weights: null, material: material(mname) });
}
if (prims.length) meshes.push({ name, prims });
}
if (!meshes.length) throw new Error("No faces in this .obj file.");
return { meshes, joints: [], materials, images, ccw: true };
}
// =====================================================================
// .fbx import (binary FBX 7.x: Blender, Maya, Unreal / FModel / UE Viewer exports)
// =====================================================================
/** zlib data → bytes (the browser's DecompressionStream; node tests pass their own). */
async function inflateZlib(b) {
const s = new Blob([b]).stream().pipeThrough(new DecompressionStream("deflate"));
return new Uint8Array(await new Response(s).arrayBuffer());
}
/** Binary FBX → its node tree [{ name, props, kids }]. */
async function fbxTree(bytes, inflate = inflateZlib) {
const head = String.fromCharCode(...bytes.subarray(0, 18));
if (head !== "Kaydara FBX Binary") throw new Error(/^; FBX/.test(new TextDecoder().decode(bytes.subarray(0, 64))) ? "A text (ASCII) .fbx: export it as binary FBX (Blender: the default)." : "Not an .fbx file.");
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const version = dv.getUint32(23, true);
const wide = version >= 7500;
const u = (o) => (wide ? Number(dv.getBigUint64(o, true)) : dv.getUint32(o, true));
const W = wide ? 8 : 4;
const latin = new TextDecoder("utf-8");
async function array(o, type) {
const len = dv.getUint32(o, true), enc = dv.getUint32(o + 4, true), clen = dv.getUint32(o + 8, true);
let raw = bytes.subarray(o + 12, o + 12 + clen);
if (enc === 1) raw = await inflate(raw);
const d = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
const out = type === "d" ? new Float64Array(len) : type === "f" ? new Float32Array(len) : type === "l" ? new Float64Array(len) : type === "b" ? new Uint8Array(len) : new Int32Array(len);
for (let i = 0; i < len; i++)
out[i] = type === "d" ? d.getFloat64(i * 8, true) : type === "f" ? d.getFloat32(i * 4, true) : type === "l" ? Number(d.getBigInt64(i * 8, true)) : type === "b" ? d.getUint8(i) : d.getInt32(i * 4, true);
return [out, 12 + clen];
}
async function node(o) {
const end = u(o), np = u(o + W);
const nl = bytes[o + 3 * W];
if (!end) return [null, o + 3 * W + 1];
const name = String.fromCharCode(...bytes.subarray(o + 3 * W + 1, o + 3 * W + 1 + nl));
let p = o + 3 * W + 1 + nl;
const props = [];
for (let i = 0; i < np; i++) {
const t = String.fromCharCode(bytes[p++]);
if (t === "Y") props.push(dv.getInt16(p, true)), (p += 2);
else if (t === "C") props.push(!!bytes[p]), (p += 1);
else if (t === "I") props.push(dv.getInt32(p, true)), (p += 4);
else if (t === "F") props.push(dv.getFloat32(p, true)), (p += 4);
else if (t === "D") props.push(dv.getFloat64(p, true)), (p += 8);
else if (t === "L") props.push(Number(dv.getBigInt64(p, true))), (p += 8);
else if (t === "S" || t === "R") {
const l = dv.getUint32(p, true);
props.push(t === "S" ? latin.decode(bytes.subarray(p + 4, p + 4 + l)) : bytes.subarray(p + 4, p + 4 + l));
p += 4 + l;
} else if ("fdlib".includes(t)) {
const [a, n] = await array(p, t);
props.push(a);
p += n;
} else throw new Error(`FBX: unknown property type ${t}`);
}
const kids = [];
while (p < end) {
const [k, next] = await node(p);
if (!k) {
p = next;
break;
}
kids.push(k);
p = next;
}
return [{ name, props, kids }, end];
}
const top = [];
let o = 27;
while (o < bytes.length - 3 * W - 1) {
const [n, next] = await node(o);
if (!n) break;
top.push(n);
o = next;
}
return { version, top };
}
/**
* A binary .fbx → same shape as loadGltf: one mesh per model and material (parts of their own), the
* bones (LimbNode / Null under a skin) as joints at their bind pose, weights from the skin clusters.
* Units and axes from the file's GlobalSettings → metres, y up; faces as counter-clockwise.
* images carry only names (texture file names), the caller loads them.
*/
async function parseFbx(bytes, inflate) {
const { top } = await fbxTree(bytes, inflate);
const kid = (n, name) => n?.kids.find((k) => k.name === name);
const P70 = (n) => {
const out = {};
for (const p of kid(n, "Properties70")?.kids || []) out[p.props[0]] = p.props.slice(4);
return out;
};
// units and axes
const gs = P70(kid(top.find((n) => n.name === "GlobalSettings"), ""));
const g = P70(top.find((n) => n.name === "GlobalSettings"));
void gs;
const unit = (g.UnitScaleFactor?.[0] ?? 1) / 100; // cm → m
const axis = (k, s) => [0, 1, 2].map((i) => (i === k ? s : 0));
const up = axis(g.UpAxis?.[0] ?? 1, g.UpAxisSign?.[0] ?? 1);
const front = axis(g.FrontAxis?.[0] ?? 2, g.FrontAxisSign?.[0] ?? 1);
const coord = axis(g.CoordAxis?.[0] ?? 0, g.CoordAxisSign?.[0] ?? 1);
// file vector v → (v·coord, v·up, v·front) × unit
const C = [coord, up, front];
const conv = (x, y, z, s = unit) => C.map((a) => (a[0] * x + a[1] * y + a[2] * z) * s);
const det = coord[0] * (up[1] * front[2] - up[2] * front[1]) - coord[1] * (up[0] * front[2] - up[2] * front[0]) + coord[2] * (up[0] * front[1] - up[1] * front[0]);
// objects and connections
const objs = new Map();
for (const n of top.find((t) => t.name === "Objects")?.kids || []) objs.set(n.props[0], n);
const childrenOf = new Map(), parentsOf = new Map();
for (const c of top.find((t) => t.name === "Connections")?.kids || []) {
const [, child, parent, prop] = c.props;
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
childrenOf.get(parent).push({ id: child, prop });
if (!parentsOf.has(child)) parentsOf.set(child, []);
parentsOf.get(child).push({ id: parent, prop });
}
const kidsOf = (id, type) => (childrenOf.get(id) || []).map((c) => objs.get(c.id)).filter((o) => o && (!type || o.name === type));
const parentsOfType = (id, type) => (parentsOf.get(id) || []).map((c) => objs.get(c.id)).filter((o) => o && (!type || o.name === type));
const nameOf = (o) => String(o.props[1]).split("\u0000")[0];
const subType = (o) => o.props[2];
// world matrices (column-major, like glTF = this kit's row-vector layout): bind pose, else Lcl
const bind = new Map();
for (const pose of [...objs.values()].filter((o) => o.name === "Pose"))
for (const pn of pose.kids.filter((k) => k.name === "PoseNode")) {
const id = kid(pn, "Node")?.props[0], m = kid(pn, "Matrix")?.props[0];
if (id != null && m && !bind.has(id)) bind.set(id, Float64Array.from(m));
}
const D2R = Math.PI / 180;
const euler = (r) => {
// FBX XYZ order: x first (column vectors R = Rz · Ry · Rx) → a quaternion
const [x, y, z] = r.map((v) => (v || 0) * D2R / 2);
const qx = [Math.sin(x), 0, 0, Math.cos(x)], qy = [0, Math.sin(y), 0, Math.cos(y)], qz = [0, 0, Math.sin(z), Math.cos(z)];
const mul = (a, b) => [a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]];
return mul(qz, mul(qy, qx));
};
const localOf = (o) => {
const p = P70(o);
const R = M4.fromQT(euler(p["Lcl Rotation"] || [0, 0, 0]), [0, 0, 0]);
const pre = M4.fromQT(euler(p.PreRotation || [0, 0, 0]), [0, 0, 0]);
const post = M4.fromQT(euler(p.PostRotation || [0, 0, 0]), [0, 0, 0]);
// row vectors: v · S · post⁻¹ · R · pre · T
const s = p["Lcl Scaling"] || [1, 1, 1];
let m = trs([0, 0, 0], [0, 0, 0, 1], s);
m = M4.mul(m, M4.invertAffine(post));
m = M4.mul(m, R);
m = M4.mul(m, pre);
const t = p["Lcl Translation"] || [0, 0, 0];
m[12] += t[0], m[13] += t[1], m[14] += t[2];
return m;
};
const worldCache = new Map();
const worldOf = (o) => {
const id = o.props[0];
if (bind.has(id)) return bind.get(id);
if (worldCache.has(id)) return worldCache.get(id);
const par = parentsOfType(id, "Model")[0];
const w = par ? M4.mul(localOf(o), worldOf(par)) : localOf(o);
worldCache.set(id, w);
return w;
};
// bones: every model a skin cluster points at, with their model ancestors
const clusters = [...objs.values()].filter((o) => o.name === "Deformer" && subType(o) === "Cluster");
const boneIds = new Set();
const linkOf = new Map();
for (const c of clusters) {
const b = kidsOf(c.props[0], "Model")[0];
if (!b) continue;
linkOf.set(c.props[0], b.props[0]);
const tl = kid(c, "TransformLink")?.props[0];
if (tl && !bind.has(b.props[0])) bind.set(b.props[0], Float64Array.from(tl));
for (let x = b; x; x = parentsOfType(x.props[0], "Model")[0]) {
if (subType(x) === "Mesh") break;
boneIds.add(x.props[0]);
}
}
// no skin: the skeleton as it is (with one, bones nothing is weighted to — weapon / camera
// sockets far off — would only blur the rig's shape)
if (!boneIds.size) for (const o of objs.values()) if (o.name === "Model" && subType(o) === "LimbNode") boneIds.add(o.props[0]);
const boneList = [...boneIds];
const boneIndex = new Map(boneList.map((id, i) => [id, i]));
const joints = boneList.map((id) => {
const o = objs.get(id), w = worldOf(o);
const par = parentsOfType(id, "Model")[0];
// the joint's place in metres, y up (only its position is used: the rig's shape)
return { name: nameOf(o), parent: par && boneIndex.has(par.props[0]) ? boneIndex.get(par.props[0]) : -1, world: trs(conv(w[12], w[13], w[14])) };
});
// materials + textures
const matList = [], matIndex = new Map(), images = [];
const materialOf = (o) => {
const id = o.props[0];
if (matIndex.has(id)) return matIndex.get(id);
const p = P70(o);
const tex = (childrenOf.get(id) || []).filter((c) => /diffuse|base/i.test(c.prop || "")).map((c) => objs.get(c.id)).find((t) => t?.name === "Texture") || kidsOf(id, "Texture")[0];
let image = -1;
if (tex) {
const f = String(kid(tex, "RelativeFilename")?.props[0] || kid(tex, "FileName")?.props[0] || "").replace(/\\/g, "/");
if (f) {
image = images.findIndex((i) => i.name === f);
if (image < 0) images.push({ name: f, bytes: null, mime: /\.jpe?g$/i.test(f) ? "image/jpeg" : "image/png" }), (image = images.length - 1);
}
}
const col = p.DiffuseColor || p.Diffuse || [1, 1, 1];
matList.push({ name: nameOf(o), image, color: [col[0], col[1], col[2], 1], blend: false });
matIndex.set(id, matList.length - 1);
return matList.length - 1;
};
// meshes
const meshes = [];
const used = new Map();
for (const geo of [...objs.values()].filter((o) => o.name === "Geometry" && subType(o) === "Mesh")) {
const model = parentsOfType(geo.props[0], "Model")[0];
const cp = kid(geo, "Vertices")?.props[0], pvi = kid(geo, "PolygonVertexIndex")?.props[0];
if (!cp || !pvi) continue;
const N = cp.length / 3;
// control points at the bind pose (the skin's mesh matrix, else the model's world)
const skin = kidsOf(geo.props[0], "Deformer").find((d) => subType(d) === "Skin");
const cl = skin ? kidsOf(skin.props[0], "Deformer").filter((d) => subType(d) === "Cluster") : [];
// (a cluster's Transform takes the mesh into its bone's space: mesh = Transform · TransformLink)
const c0 = cl.find((c) => kid(c, "Transform") && kid(c, "TransformLink"));
const meshM = c0 ? M4.mul(Float64Array.from(kid(c0, "Transform").props[0]), Float64Array.from(kid(c0, "TransformLink").props[0])) : model ? worldOf(model) : trs();
const P = new Float32Array(N * 3);
for (let v = 0; v < N; v++) {
const x = cp[v * 3], y = cp[v * 3 + 1], z = cp[v * 3 + 2];
const q = [x * meshM[0] + y * meshM[4] + z * meshM[8] + meshM[12], x * meshM[1] + y * meshM[5] + z * meshM[9] + meshM[13], x * meshM[2] + y * meshM[6] + z * meshM[10] + meshM[14]];
P.set(conv(...q), v * 3);
}
// weights per control point: the 4 biggest
const ws = Array.from({ length: N }, () => []);
for (const c of cl) {
const b = boneIndex.get(linkOf.get(c.props[0]));
const ix = kid(c, "Indexes")?.props[0], wt = kid(c, "Weights")?.props[0];
if (b == null || !ix || !wt) continue;
for (let i = 0; i < ix.length; i++) if (wt[i] > 0) ws[ix[i]]?.push([b, wt[i]]);
}
// layers: normals, uvs, materials (by polygon vertex / control point, direct / indexed)
const layer = (name, data, index) => {
const l = kid(geo, name);
if (!l) return null;
return { map: kid(l, "MappingInformationType")?.props[0], ref: kid(l, "ReferenceInformationType")?.props[0], data: kid(l, data)?.props[0], index: kid(l, index)?.props[0] };
};
const ln = layer("LayerElementNormal", "Normals", "NormalsIndex"), lu = layer("LayerElementUV", "UV", "UVIndex"), lm = layer("LayerElementMaterial", "Materials", null);
const pick = (l, corner, vert, poly) => {
if (!l?.data) return -1;
const at = l.map === "ByPolygonVertex" ? corner : l.map === "ByPolygon" ? poly : l.map === "AllSame" ? 0 : vert;
return l.ref === "IndexToDirect" || l.ref === "Index" ? l.index?.[at] ?? at : at;
};
const modelMats = model ? kidsOf(model.props[0], "Material") : [];
// corners → vertices (split where normals / uvs differ), faces fanned, grouped by material
const groups = new Map();
let poly = 0, start = 0;
for (let i = 0; i < pvi.length; i++) {
if (pvi[i] >= 0) continue;
const corners = [];
for (let k = start; k <= i; k++) corners.push(k);
const mi = lm?.data ? lm.data[lm.map === "AllSame" ? 0 : poly] ?? 0 : 0;
if (!groups.has(mi)) groups.set(mi, { key: new Map(), pos: [], nrm: [], uv: [], jw: [], ww: [], idx: [] });
const gr = groups.get(mi);
const vid = corners.map((k) => {
const v = pvi[k] < 0 ? ~pvi[k] : pvi[k];
const ni = pick(ln, k, v, poly), ui = pick(lu, k, v, poly);
const key = `${v}/${ni}/${ui}`;
if (gr.key.has(key)) return gr.key.get(key);
const id = gr.pos.length / 3;
gr.key.set(key, id);
gr.pos.push(P[v * 3], P[v * 3 + 1], P[v * 3 + 2]);
if (ni >= 0) {
// turned like the vertices (the mesh matrix, then the file's axes)
const nx = ln.data[ni * 3], ny = ln.data[ni * 3 + 1], nz = ln.data[ni * 3 + 2];
gr.nrm.push(...conv(nx * meshM[0] + ny * meshM[4] + nz * meshM[8], nx * meshM[1] + ny * meshM[5] + nz * meshM[9], nx * meshM[2] + ny * meshM[6] + nz * meshM[10], 1));
}
else gr.nrm.push(0, 0, 0);
gr.uv.push(ui >= 0 ? lu.data[ui * 2] : 0, ui >= 0 ? 1 - lu.data[ui * 2 + 1] : 0); // FBX v grows upwards
const w4 = ws[v].sort((a, b) => b[1] - a[1]).slice(0, 4);
for (let j = 0; j < 4; j++) gr.jw.push(w4[j] ? w4[j][0] : -1), gr.ww.push(w4[j] ? w4[j][1] : 0);
return id;
});
for (let k = 2; k < vid.length; k++) det < 0 ? gr.idx.push(vid[0], vid[k], vid[k - 1]) : gr.idx.push(vid[0], vid[k - 1], vid[k]);
poly++;
start = i + 1;
}
const base = model ? nameOf(model) : "mesh";
for (const [mi, gr] of groups) {
const mo = modelMats[mi];
const material = mo ? materialOf(mo) : -1;
let nm = groups.size > 1 && mo ? nameOf(mo) : base;
used.set(nm, (used.get(nm) || 0) + 1);
if (used.get(nm) > 1) nm += `_${used.get(nm)}`;
const jw = Int32Array.from(gr.jw);
jw.k = 4;
const hasN = gr.nrm.some((x) => x !== 0);
meshes.push({ name: nm, prims: [{ pos: Float32Array.from(gr.pos), nrm: hasN ? Float32Array.from(gr.nrm) : null, uv: Float32Array.from(gr.uv), idx: Uint32Array.from(gr.idx), joints: cl.length ? jw : null, weights: cl.length ? Float32Array.from(gr.ww) : null, material }] });
}
}
if (!meshes.length) throw new Error("No meshes in this .fbx file.");
// facing: the toes in front of the ankles (+z); a model looking the other way is turned round
const data = { meshes, joints, materials: matList, images, ccw: true };
faceForward(data);
return data;
}
/** Turns a model whose feet point to −z half round (y), so it faces +z like a glTF. */
function faceForward(data) {
const ys = data.joints.map((j) => j.world[13]);
if (!ys.length) return;
const lowY = Math.min(...ys), H = Math.max(...ys) - lowY;
// the lowest joints (toes) against their parents (the ankles): toes stick out forwards
let dz = 0, n = 0;
for (const j of data.joints) {
const p = data.joints[j.parent];
if (!p || j.world[13] > lowY + 0.06 * H || p.world[13] <= j.world[13] + 0.02 * H) continue;
dz += j.world[14] - p.world[14];
n++;
}
if (!n || dz >= 0) return;
const flip = (arr) => {
for (let i = 0; i < arr.length; i += 3) (arr[i] = -arr[i]), (arr[i + 2] = -arr[i + 2]);
};
for (const j of data.joints) (j.world[12] = -j.world[12]), (j.world[14] = -j.world[14]);
for (const m of data.meshes) for (const p of m.prims) flip(p.pos), p.nrm && flip(p.nrm);
}
// =====================================================================
// weights from the body: the closest point on the LS body's surface
// =====================================================================
/** sources: posed() meshes → a searchable surface. */
function surface(sources) {
const tris = [];
for (const s of sources) {
for (let i = 0; i + 2 < s.idx.length; i += 3) {
const c = [s.idx[i], s.idx[i + 1], s.idx[i + 2]];
const p = c.map((v) => [s.pos[v * 3], s.pos[v * 3 + 1], s.pos[v * 3 + 2]]);
const ctr = [0, 1, 2].map((k) => (p[0][k] + p[1][k] + p[2][k]) / 3);
const r = Math.max(...p.map((q) => Math.hypot(q[0] - ctr[0], q[1] - ctr[1], q[2] - ctr[2])));
// the outward normal (the mesh's own vertex normals)
const n = s.nrm ? [0, 1, 2].map((k) => c.reduce((t, v) => t + s.nrm[v * 3 + k], 0)) : null;
tris.push({ p, ctr, r, w: c.map((v) => weightsOf(s, v)), n: n && Math.hypot(...n) ? n.map((x) => x / Math.hypot(...n)) : null });
}
}
return gridOf(tris);
}
/** A uniform grid (LS units) over faces, so a point only looks at the faces near it. */
function gridOf(tris) {
const cell = 6;
const map = new Map();
tris.forEach((t, ti) => {
const lo = [0, 1, 2].map((k) => Math.floor(Math.min(t.p[0][k], t.p[1][k], t.p[2][k]) / cell));
const hi = [0, 1, 2].map((k) => Math.floor(Math.max(t.p[0][k], t.p[1][k], t.p[2][k]) / cell));
for (let x = lo[0]; x <= hi[0]; x++) for (let y = lo[1]; y <= hi[1]; y++) for (let z = lo[2]; z <= hi[2]; z++) {
const key = `${x},${y},${z}`;
if (!map.has(key)) map.set(key, []);
map.get(key).push(ti);
}
});
tris.grid = { cell, map };
return tris;
}
function weightsOf(s, v) {
const out = [];
for (let j = 0; j < 4; j++) if (s.bone[v * 4 + j] >= 0 && s.w[v * 4 + j] > 0) out.push([s.bone[v * 4 + j], s.w[v * 4 + j]]);
return out;
}
// closest point on a triangle (Ericson), → barycentric [u, v, w]
function closest(p, a, b, c) {
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]], ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
const dot = (x, y) => x[0] * y[0] + x[1] * y[1] + x[2] * y[2];
const d1 = dot(ab, ap), d2 = dot(ac, ap);
if (d1 <= 0 && d2 <= 0) return [1, 0, 0];
const bp = [p[0] - b[0], p[1] - b[1], p[2] - b[2]];
const d3 = dot(ab, bp), d4 = dot(ac, bp);
if (d3 >= 0 && d4 <= d3) return [0, 1, 0];
const vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
const v = d1 / (d1 - d3);
return [1 - v, v, 0];
}
const cp = [p[0] - c[0], p[1] - c[1], p[2] - c[2]];
const d5 = dot(ab, cp), d6 = dot(ac, cp);
if (d6 >= 0 && d5 <= d6) return [0, 0, 1];
const vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
const w = d2 / (d2 - d6);
return [1 - w, 0, w];
}
const va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
return [0, 1 - w, w];
}
const den = 1 / (va + vb + vc);
const v = vb * den, w = vc * den;
return [1 - v - w, v, w];
}
/** The body's weights at the surface point closest to p. → [[bone, weight]…] */
function transfer(tris, p) {
const hit = nearest(tris, p);
if (!hit) return [];
const acc = new Map();
hit.b.forEach((bw, k) => {
for (const [bone, w] of hit.t.w[k]) acc.set(bone, (acc.get(bone) || 0) + bw * w);
});
return [...acc];
}
/** The surface point closest to p: { t (face), b (barycentric), q (point), d (distance) } or null. */
function nearest(tris, p) {
let best = Infinity, hit = null;
const test = (t) => {
const dc = Math.hypot(p[0] - t.ctr[0], p[1] - t.ctr[1], p[2] - t.ctr[2]) - t.r;
if (dc > 0 && dc * dc >= best) return;
const b = closest(p, ...t.p);
const q = [0, 1, 2].map((k) => b[0] * t.p[0][k] + b[1] * t.p[1][k] + b[2] * t.p[2][k]);
const d = (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2;
if (d < best) (best = d), (hit = { t, b });
};
const g = tris.grid;
if (g) {
// rings of cells around p; done once the best face is closer than the next ring can be
const c = p.map((v) => Math.floor(v / g.cell));
const seen = new Set();
for (let k = 0; k <= 12 && !(hit && Math.sqrt(best) <= k * g.cell); k++)
for (let x = -k; x <= k; x++) for (let y = -k; y <= k; y++) for (let z = -k; z <= k; z++) {
if (Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) !== k) continue;
for (const ti of g.map.get(`${c[0] + x},${c[1] + y},${c[2] + z}`) || []) if (!seen.has(ti)) seen.add(ti), test(tris[ti]);
}
if (!hit || Math.sqrt(best) > 12 * g.cell) for (const t of tris) test(t); // far from the body: every face
} else for (const t of tris) test(t);
if (!hit) return null;
const q = [0, 1, 2].map((k) => hit.b[0] * hit.t.p[0][k] + hit.b[1] * hit.t.p[1][k] + hit.b[2] * hit.t.p[2][k]);
return { ...hit, q, d: Math.sqrt(best) };
}
// =====================================================================
// fit to the LS body: a humanoid rig (any bone names) reshaped to the hero's proportions
// =====================================================================
const v3 = {
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
mul: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
len: (a) => Math.hypot(a[0], a[1], a[2]),
unit: (a) => {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
},
};
// 3×3 matrices, row-major, acting on column vectors
const m3 = {
id: () => [1, 0, 0, 0, 1, 0, 0, 0, 1],
mul: (a, b) => [0, 1, 2].flatMap((r) => [0, 1, 2].map((c) => a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c])),
app: (m, v) => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]],
/** The smallest rotation taking unit u onto unit w. */
rot(u, w) {
const c = v3.dot(u, w);
if (c < -0.9999) {
const p = v3.unit(Math.abs(u[0]) < 0.9 ? v3.cross(u, [1, 0, 0]) : v3.cross(u, [0, 1, 0]));
return [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => 2 * p[i] * p[j] - (i === j ? 1 : 0)));
}
const [x, y, z] = v3.cross(u, w);
const f = 1 / (1 + c);
return [c + f * x * x, f * x * y - z, f * x * z + y, f * x * y + z, c + f * y * y, f * y * z - x, f * x * z - y, f * y * z + x, c + f * z * z];
},
/** Scales by s along unit u and by r across it. */
along: (u, s, r) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => (i === j ? r : 0) + (s - r) * u[i] * u[j])),
};
const sorted = (a) => Float64Array.from(a).sort();
const pct = (a, p) => (a.length ? sorted(a)[Math.min(a.length - 1, Math.floor(a.length * p))] : 0);
/**
* The joints of a humanoid rig found from its shape, whatever their names (rest pose, LS space):
* the pelvis has a leg on each side going down and the spine going up, the chest is where the arms
* branch off sideways, neck / head go highest, the hand is where the fingers branch, the thumb is
* the finger reaching furthest forward. Other children of the chest: hanging far down = a cape,
* staying up at the neck = hair and the like.
*/
function humanoid(joints, pos) {
const n = joints.length;
const parent = joints.map((j) => j.parent);
const kids = joints.map(() => []);
parent.forEach((p, i) => p >= 0 && kids[p].push(i));
const lo = new Float64Array(n), hi = new Float64Array(n), xlo = new Float64Array(n), xhi = new Float64Array(n), cnt = new Int32Array(n), depth = new Int32Array(n);
const stat = (i, d) => {
depth[i] = d;
lo[i] = hi[i] = pos[i][1];
xlo[i] = xhi[i] = pos[i][0];
cnt[i] = 1;
for (const k of kids[i]) {
stat(k, d + 1);
lo[i] = Math.min(lo[i], lo[k]);
hi[i] = Math.max(hi[i], hi[k]);
xlo[i] = Math.min(xlo[i], xlo[k]);
xhi[i] = Math.max(xhi[i], xhi[k]);
cnt[i] += cnt[k];
}
};
parent.forEach((p, i) => p < 0 && stat(i, 0));
const H = Math.max(...hi) - Math.min(...lo);
if (!(H > 0)) throw new Error("The rig has no height.");
const floor = Math.min(...lo);
const best = (list, score) => list.reduce((a, b) => (a < 0 || score(b) > score(a) ? b : a), -1);
// what goes up from a joint: its own children, else a branch beside it further up the tree
// (e.g. legs that hang from a lower-body bone with the spine as its sibling)
const upFrom = (i) => {
const y = pos[i][1];
for (let c = i, a = i; a >= 0; c = a, a = parent[a]) {
const up = kids[a].filter((k) => (a === i || k !== c) && hi[k] > y + 0.3 * H);
if (up.length) return up;
}
return [];
};
// pelvis: the deepest joint with a leg on each side going down and something going up
let pelvis = -1;
for (let i = 0; i < n; i++) {
const [x, y] = pos[i];
// legs reach the floor (coat tails hanging from the chest don't)
const down = kids[i].filter((k) => lo[k] < y - 0.3 * H && lo[k] < floor + 0.15 * H);
if (down.some((k) => pos[k][0] > x + 0.01 * H) && down.some((k) => pos[k][0] < x - 0.01 * H) && upFrom(i).length)
if (pelvis < 0 || depth[i] > depth[pelvis]) pelvis = i;
}
if (pelvis < 0) throw new Error("No humanoid in this rig: no hips with two legs and a spine.");
const same = (a, b) => v3.len(v3.sub(pos[a], pos[b])) < 0.004 * H;
/** Follows the best-scoring child; a joint sitting on the previous one replaces it. */
const chain = (start, score, stop = () => false) => {
const out = [start];
let i = start;
while (kids[i].length && !stop(i)) {
i = best(kids[i], score);
if (same(i, out[out.length - 1])) out[out.length - 1] = i;
else out.push(i);
}
return out;
};
const [px, py] = pos[pelvis];
const leg = (k) => {
const c = chain(k, (b) => -lo[b] + cnt[b] * 1e-6);
return { thigh: c[0], calf: c[1] ?? -1, foot: c[2] ?? -1, toe: c[3] ?? -1 };
};
const legs = kids[pelvis].filter((k) => lo[k] < py - 0.3 * H && lo[k] < floor + 0.15 * H).map(leg); // (not skirt / thigh helpers)
// the spine, up to where the arms branch
// two different children reaching far to each side
const armsAt = (i) => {
const l = kids[i].filter((k) => xhi[k] > pos[i][0] + 0.2 * H), r = kids[i].filter((k) => xlo[k] < pos[i][0] - 0.2 * H);
return l.length > 0 && r.length > 0 && (l.length > 1 || r.length > 1 || l[0] !== r[0]);
};
const spine = [];
let chest = best(upFrom(pelvis), (k) => cnt[k]);
for (;;) {
spine.push(chest);
if (armsAt(chest) || !kids[chest].length) break;
chest = best(kids[chest], (k) => hi[k] + cnt[k] * 1e-6);
}
if (!armsAt(chest)) throw new Error("No arms in this rig.");
const armRootL = best(kids[chest], (k) => xhi[k]);
const armRootR = best(kids[chest], (k) => -xlo[k]);
const rest = kids[chest].filter((k) => k !== armRootL && k !== armRootR);
// the neck: the remaining child reaching highest near the middle (most joints: the face's)
const headRoot = best(rest.filter((k) => Math.abs(pos[k][0] - px) < 0.05 * H), (k) => hi[k] + cnt[k] * 1e-3);
const neckChain = headRoot >= 0 ? chain(headRoot, (b) => hi[b] + cnt[b] * 1e-3, (i) => kids[i].length > 3 && i !== headRoot) : [];
const neck = neckChain[0] ?? -1;
const head = neckChain.length > 1 ? neckChain[1] : neck;
// arms: outwards to the hand (two or more children with joints of their own: finger chains;
// single-joint helpers such as twist bones don't count)
const fingersAt = (i) => kids[i].filter((k) => cnt[k] > 1).length >= 2;
const arm = (root, side) => {
// the hand: the joint furthest from the shoulder where three or more chains branch (fingers;
// sleeve / cloth chains on the forearm branch too, but nearer); the arm is the way to it
const sub = [];
const walkSub = (i) => (sub.push(i), kids[i].forEach(walkSub));
walkSub(root);
const hands = sub.filter((j) => kids[j].filter((k) => cnt[k] > 1).length >= 3);
let full;
if (hands.length) {
const hand = best(hands, (j) => v3.len(v3.sub(pos[j], pos[root])));
full = [];
for (let j = hand; j >= 0; j = j === root ? -1 : parent[j]) full.unshift(j);
// joints on the same spot: the later one
full = full.filter((j, k) => k === full.length - 1 || !same(j, full[k + 1]));
} else full = chain(root, (b) => (side > 0 ? xhi[b] : -xlo[b]) + cnt[b] * 1e-6);
// twist bones (marked as twist in the file) sit halfway along the arm: not joints of it
let at = -1;
full.forEach((j, k) => fingersAt(j) && (at = k));
const c = (at >= 0 ? full.slice(0, at + 1) : full).filter((j, k, all) => !joints[j].twist || k === all.length - 1);
// no finger chains (one-joint fingers): the hand is the joint with the most children
let hi2 = c.length - 1;
if (!fingersAt(c[hi2])) {
const most = c.reduce((a, j, k) => (kids[j].length > kids[c[a]].length ? k : a), 0);
if (kids[c[most]].length >= 3) hi2 = most;
}
const hand = c[hi2];
const before = c.slice(0, hi2);
const leaves = [];
const walk = (i, path) => (kids[i].length ? kids[i].forEach((k) => walk(k, [...path, k])) : leaves.push(path));
kids[hand].forEach((k) => walk(k, [k]));
const paths = leaves
.map((p) => p.filter((j) => v3.len(v3.sub(pos[j], pos[hand])) > 0.01 * H))
.filter((p) => p.length >= 2) // one-joint leaves are helpers (palm, twist), not fingers
// nor a weapon / prop chain reaching far past the fingertips
.filter((p) => !before.length || v3.len(v3.sub(pos[p[p.length - 1]], pos[hand])) < 0.9 * v3.len(v3.sub(pos[hand], pos[before[before.length - 1]])));
// thumb: the finger branching off most from the others (its first joint's direction from the
// hand against theirs; a hand turned in an A-pose has no clear "forward"), then from the
// thumb outwards: index, middle, ring, little
const dir = (p) => v3.unit(v3.sub(pos[p[0]], pos[hand]));
const mean = v3.unit(paths.reduce((acc, p) => v3.add(acc, dir(p)), [0, 0, 0]));
const thumb = paths.length >= 3 ? paths.reduce((a, p) => (v3.dot(dir(p), mean) < v3.dot(dir(a), mean) ? p : a)) : null;
let fingers = paths.sort((a, b) => pos[a[a.length - 1]][2] - pos[b[b.length - 1]][2]);
if (thumb) {
// the others along the line of their knuckles (the two bases furthest apart), the end
// nearer the thumb first
const rest = paths.filter((p) => p !== thumb);
let ends = [rest[0], rest[0]], far = -1;
for (const a of rest) for (const b of rest) {
const d = v3.len(v3.sub(pos[a[0]], pos[b[0]]));
if (d > far) (far = d), (ends = [a, b]);
}
const ax = v3.unit(v3.sub(pos[ends[1][0]], pos[ends[0][0]]));
const tc = v3.mul(thumb.reduce((acc, j) => v3.add(acc, pos[j]), [0, 0, 0]), 1 / thumb.length);
const near = (p) => v3.len(v3.sub(pos[p[0]], tc));
const sign = near(ends[0]) <= near(ends[1]) ? 1 : -1;
fingers = [thumb, ...rest.sort((a, b) => sign * (v3.dot(pos[a[0]], ax) - v3.dot(pos[b[0]], ax)))];
}
return { hand, fore: before[before.length - 1] ?? -1, upper: before[before.length - 2] ?? -1, clav: before.length >= 3 ? before[before.length - 3] : -1, fingers };
};
if (legs.length < 2) throw new Error("No legs in this rig.");
const L = { ...arm(armRootL, 1), ...legs.reduce((a, b) => (pos[b.thigh][0] > pos[a.thigh][0] ? b : a)) };
const R = { ...arm(armRootR, -1), ...legs.reduce((a, b) => (pos[b.thigh][0] < pos[a.thigh][0] ? b : a)) };
for (const [s, side] of [[L, "left"], [R, "right"]]) if (s.upper < 0 || s.fore < 0 || s.calf < 0 || s.foot < 0) throw new Error(`The ${side} arm or leg of the rig has too few joints (${["clav", "upper", "fore", "hand", "thigh", "calf", "foot"].map((k) => `${k} ${s[k] >= 0 ? joints[s[k]].name : "-"}`).join(", ")}).`);
const neckY = pos[neck >= 0 ? neck : chest][1];
const capes = rest.filter((k) => k !== headRoot && lo[k] < neckY - 0.25 * H);
const headStuff = rest.filter((k) => k !== headRoot && lo[k] >= neckY - 0.25 * H && pos[k][1] >= neckY - 0.03 * H);
return { pelvis, spine, chest, neck, head, L, R, capes, headStuff, kids, parent, pos, H };
}
/** File joint → LS bone for a found humanoid (human_man.skl's names). */
function humanoidMap(sk, h) {
const bone = (name) => sk.byName.get(name.toLowerCase()) ?? -1;
const n = h.parent.length;
const map = new Int32Array(n).fill(-1);
const set = (j, name) => {
const b = bone(name);
if (j >= 0 && b >= 0) map[j] = b;
};
set(h.pelvis, "Bip01 Pelvis");
const sp = h.spine;
sp.forEach((j, i) => set(j, i === sp.length - 1 ? "Bip01 Spine2" : i === 0 ? "Bip01 Spine" : "Bip01 Spine1"));
if (h.neck !== h.head) set(h.neck, "Bip01 Neck");
set(h.head, "Bip01 Head");
for (const [S, s] of [["L", h.L], ["R", h.R]]) {
set(s.clav, `Bip01 ${S} Clavicle`);
set(s.upper, `Bip01 ${S} UpperArm`);
set(s.fore, `Bip01 ${S} Forearm`);
set(s.hand, `Bip01 ${S} Hand`);
set(s.thigh, `Bip01 ${S} Thigh`);
set(s.calf, `Bip01 ${S} Calf`);
set(s.foot, `Bip01 ${S} Foot`);
// toes stay on the foot: no hero mesh uses Toe0, its pivot sits at the tip of the shoe
set(s.toe, `Bip01 ${S} Foot`);
// fingers: thumb → Finger0, the next → Finger1, the others → Finger2 (the hero has three);
// a joint shared by fingers of two hero fingers stays with the hand
const owner = new Map();
s.fingers.forEach((path, fi) => {
const f = Math.min(fi, 2);
path.forEach((j, d) => {
const name = `Bip01 ${S} Finger${f}${d ? Math.min(d, 2) : ""}`;
owner.set(j, owner.has(j) && owner.get(j) !== name ? null : name);
});
});
for (const [j, name] of owner) if (name) set(j, name);
}
// a cape: along its chains onto the hero's mantle (Mantle01…05), else the chest
const mantle = [1, 2, 3, 4, 5].map((i) => bone(`Bip01 Mantle0${i}`));
const walkCape = (j, d) => {
map[j] = mantle[Math.min(d, 4)] >= 0 ? mantle[Math.min(d, 4)] : bone("Bip01 Spine2");
for (const k of h.kids[j]) walkCape(k, d + 1);
};
h.capes.forEach((j) => walkCape(j, 0));
for (const j of h.headStuff) map[j] = bone("Bip01 Head");
// the rest moves with its closest mapped ancestor (twist bones, face bones…); above the hips: the pelvis
const out = new Int32Array(n);
for (let j = 0; j < n; j++) {
let k = j;
while (k >= 0 && map[k] < 0) k = h.parent[k];
out[j] = k >= 0 ? map[k] : bone("Bip01 Pelvis");
}
return out;
}
/** The hero body's vertices with their main bone (posed() meshes): what the fit measures against. */
function bodyPoints(lsBody) {
const out = [];
for (const m of lsBody) {
const V = m.pos.length / 3;
for (let v = 0; v < V; v++) {
let bb = -1, bw = 0;
for (let j = 0; j < 4; j++) if (m.bone[v * 4 + j] >= 0 && m.w[v * 4 + j] > bw) (bw = m.w[v * 4 + j]), (bb = m.bone[v * 4 + j]);
if (bb >= 0) out.push([[m.pos[v * 3], m.pos[v * 3 + 1], m.pos[v * 3 + 2]], bb]);
}
}
return out;
}
/**
* Median distance from the line a→b of the points owned by `owners`, between 15% and 85% along it;
* with `side` (a unit vector across the line): only that component (half the width / depth).
*/
function radius(points, owners, a, b, side = null, minN = 8) {
const ab = v3.sub(b, a), l = v3.len(ab), u = v3.unit(ab);
const out = [];
for (const [p, o] of points) {
if (!owners.has(o)) continue;
const d = v3.sub(p, a);
const t = v3.dot(d, u);
if (t < 0.15 * l || t > 0.85 * l) continue;
const across = v3.sub(d, v3.mul(u, t));
out.push(side ? Math.abs(v3.dot(across, side)) : v3.len(across));
}
return out.length >= minN ? pct(out, 0.5) : 0;
}
/**
* Per LS bone, how the model (LS space, before the fit) lands on the hero: arms, legs and torso
* keep their joints on the hero's joints (turned onto them, the hero's lengths and thickness), the
* head, hands and toes scale as a whole, a cape keeps the body's height. points: the model's
* vertices [pos, main LS bone, hair?]; hero: bodyPoints(). → { T: Map(bone → { a, A, M, N }), info }
*/
function fitTransforms(sk, P, h, points, hero, opts = {}) {
const b = (name) => sk.byName.get(name.toLowerCase()) ?? -1;
const W = (name) => P.wp[b(name)];
const J = (j) => h.pos[j];
const T = new Map();
const info = {};
const put = (name, a, A, M, N) => b(name) >= 0 && T.set(b(name), { a, A, M, N });
const owners = (names) => new Set(names.map(b).filter((x) => x >= 0));
// how far limbs / torso go from the model's own build (kept within limits) to exactly the hero's
// thickness (0…1): at 1 a costume is as thick as the hero's body everywhere, like the game's own
// costumes (victory_cn…), and bends like it in every animation
const tB = Math.max(0, Math.min(1, opts.lsBuild ?? 0));
const lsBuild = (own, hero) => own ** (1 - tB) * hero ** tB;
const seg = (name, ja, jb, la, lb, radNames = [name]) => {
const a = J(ja), e = J(jb), A = W(la), E = W(lb);
const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A));
const s = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1);
const rl = radNames.length ? radius(hero, owners(radNames), A, E) : 0;
const rm = radNames.length ? radius(points, owners(radNames), a, e) : 0;
// as thick as the hero's limb, but not much off the model's own build (a slim sleeve on the man's
// muscled forearm swells up)
// (and never thinner than the model itself unless the hero is: a short arm scaled down came out
// a stick)
const r = rl && rm ? Math.max(0.6 * s, Math.min(2 * s, lsBuild(Math.max(0.6 * s, Math.min(Math.max(1.25 * s, Math.min(1, rl / rm)), rl / rm)), rl / rm))) : s;
const R = m3.rot(u, U);
put(name, a, A, m3.mul(R, m3.along(u, s, r)), m3.mul(R, m3.along(u, 1 / s, 1 / r)));
info[name] = { length: +s.toFixed(2), thickness: +r.toFixed(2) };
return { R, s, r, u };
};
const whole = (name, a, A, s, R = m3.id()) => {
put(name, a, A, m3.mul(R, m3.along([0, 1, 0], s, s)), m3.mul(R, m3.along([0, 1, 0], 1 / s, 1 / s)));
info[name] = { scale: +s.toFixed(2) };
};
const same = (name, from) => b(name) >= 0 && b(from) >= 0 && T.has(b(from)) && T.set(b(name), T.get(b(from)));
// torso: the first spine joint → the neck, as thick as the hero's
const neckJ = h.neck >= 0 ? h.neck : h.head;
// torso: as long and as wide as the hero's; front to back it keeps its own shape (scaled like the
// length) unless keepVolume is off — the hero's chest is flat, a bust or a coat would be pressed flat
const torso = (() => {
const a = J(h.spine[0]), e = J(neckJ), A = W("Bip01 Spine"), E = W("Bip01 Neck");
const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A));
const sl = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1);
const across = (axis, dir) => v3.unit(v3.sub(dir, v3.mul(axis, v3.dot(dir, axis))));
const xm = across(u, [1, 0, 0]), xl = across(U, [1, 0, 0]);
const zm = v3.cross(u, xm);
const own = owners(["Bip01 Spine", "Bip01 Spine1", "Bip01 Spine2"]);
const ratio = (side, sideL) => {
const rl = radius(hero, own, A, E, sideL), rm = radius(points, own, a, e, side);
// like the limbs: the hero's width, not far off the model's own build (a thin inner body
// under armour / a cape measured alone blew the chest up into a barrel)
return rl && rm ? Math.max(0.6 * sl, Math.min(1.5 * sl, lsBuild(Math.max(0.6 * sl, Math.min(1.5 * sl, rl / rm)), rl / rm))) : sl;
};
const rw = ratio(xm, xl);
const rd = opts.keepVolume ? lsBuild(Math.max(sl, rw), Math.max(rw, ratio(zm, v3.cross(U, xl)))) : ratio(zm, v3.cross(U, xl));
const R = m3.rot(u, U);
const S = (a1, b1, c1) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => a1 * u[i] * u[j] + b1 * xm[i] * xm[j] + c1 * zm[i] * zm[j]));
put("Bip01 Spine", a, A, m3.mul(R, S(sl, rw, rd)), m3.mul(R, S(1 / sl, 1 / rw, 1 / rd)));
info["Bip01 Spine"] = { length: +sl.toFixed(2), thickness: +rw.toFixed(2), depth: +rd.toFixed(2) };
return { R, s: sl, r: rw };
})();
same("Bip01 Spine1", "Bip01 Spine");
same("Bip01 Spine2", "Bip01 Spine");
// hips: to the hero's hip width, standing on the hero's pelvis
const hip = v3.len(v3.sub(W("Bip01 L Thigh"), W("Bip01 R Thigh"))) / (v3.len(v3.sub(J(h.L.thigh), J(h.R.thigh))) || 1);
const pu = v3.unit(v3.sub(J(h.spine[0]), J(h.pelvis)));
const PR = m3.rot(pu, v3.unit(v3.sub(W("Bip01 Spine"), W("Bip01 Pelvis"))));
// across: as wide as the hero's hips; front to back like the torso (its volume kept or not)
const px = v3.unit(v3.sub([1, 0, 0], v3.mul(pu, pu[0]))), pz = v3.cross(pu, px);
const hd = opts.keepVolume ? Math.max(torso.s, hip) : hip;
const PS = (a1, b1, c1) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => a1 * pu[i] * pu[j] + b1 * px[i] * px[j] + c1 * pz[i] * pz[j]));
put("Bip01 Pelvis", J(h.pelvis), W("Bip01 Pelvis"), m3.mul(PR, PS(torso.s, hip, hd)), m3.mul(PR, PS(1 / torso.s, 1 / hip, 1 / hd)));
info["Bip01 Pelvis"] = { width: +hip.toFixed(2) };
same("Bip01", "Bip01 Pelvis");
// a cape: hangs from the neck, as long as the body is tall
const tall = W("Bip01 Neck")[1] / (J(neckJ)[1] || 1);
for (let i = 1; i <= 5; i++) put(`Bip01 Mantle0${i}`, J(neckJ), W("Bip01 Neck"), m3.along([0, 1, 0], tall, torso.r), m3.along([0, 1, 0], 1 / tall, 1 / torso.r));
info.cape = { height: +tall.toFixed(2) };
if (h.neck !== h.head && h.neck >= 0) seg("Bip01 Neck", h.neck, h.head, "Bip01 Neck", "Bip01 Head", []);
// head: as a whole, as TALL as the hero's (its face mesh, chin to crown) — measured on what
// hangs on the head joint itself (face, skull), so elf ears, a ponytail or hair bones don't
// count; width alone took ears and long hair for a big head. The chin sits on the hero's chin,
// so the neck stays the hero's length (top-aligned, a smaller head left a long neck).
const headPts = (pts) => pts.filter((p) => p[1] === b("Bip01 Head") && !p[2]).map(([p]) => p);
const centre = (ps) => (ps.length ? v3.mul(ps.reduce((acc, p) => v3.add(acc, p), [0, 0, 0]), 1 / ps.length) : null);
const own = points.filter((p) => p[3]).map(([p]) => p);
const hpL = headPts(hero), hpM = own.length >= 30 ? own : headPts(points);
const box = (ps) => [0, 1, 2].map((k) => [pct(ps.map((p) => p[k]), 0.005), pct(ps.map((p) => p[k]), 0.995)]);
const bL = hpL.length ? box(hpL) : null, bM = hpM.length ? box(hpM) : null;
const mid = (bx) => bx.map(([lo, hi]) => (lo + hi) / 2);
const cL = bL ? mid(bL) : centre(hpL) || W("Bip01 Head"), cM = bM ? mid(bM) : centre(hpM) || J(h.head);
const hl = bL ? bL[1][1] - bL[1][0] : 0, hmM = bM ? bM[1][1] - bM[1][0] : 0;
const hs = (hl && hmM ? hl / hmM : 1) * (opts.headScale || 1);
const cLs = bL ? [cL[0], bL[1][0] + (cM[1] - bM[1][0]) * hs, cL[2]] : cL;
whole("Bip01 Head", cM, cLs, hs);
const hm = hmM / 2;
// long hair: the head's scale only around the head, the body's (the cape's) further down
if (T.has(b("Bip01 Head")) && hm) T.get(b("Bip01 Head")).soft = { o: [0, cM[1] - hm, 0], u: [0, -1, 0], R: hm, alt: T.get(b("Bip01 Mantle01")) || T.get(b("Bip01 Spine")) };
info.headSize = { hero: +hl.toFixed(1), model: +hmM.toFixed(1) };
for (const [S, s] of [["L", h.L], ["R", h.R]]) {
if (s.clav >= 0) seg(`Bip01 ${S} Clavicle`, s.clav, s.upper, `Bip01 ${S} Clavicle`, `Bip01 ${S} UpperArm`, []);
seg(`Bip01 ${S} UpperArm`, s.upper, s.fore, `Bip01 ${S} UpperArm`, `Bip01 ${S} Forearm`);
seg(`Bip01 ${S} Forearm`, s.fore, s.hand, `Bip01 ${S} Forearm`, `Bip01 ${S} Hand`);
// hand: the hero's hand length, turned to where the hero's fingers point
const bases = s.fingers.slice(1).map((p) => p.find((j) => v3.len(v3.sub(J(j), J(s.hand))) > 0.01 * h.H)).filter((j) => j !== undefined);
const mb = bases.length ? v3.mul(bases.reduce((acc, j) => v3.add(acc, J(j)), [0, 0, 0]), 1 / bases.length) : v3.add(J(s.hand), v3.sub(J(s.hand), J(s.fore)));
const lb = v3.mul(v3.add(W(`Bip01 ${S} Finger1`), W(`Bip01 ${S} Finger2`)), 0.5);
const hs = v3.len(v3.sub(lb, W(`Bip01 ${S} Hand`))) / (v3.len(v3.sub(mb, J(s.hand))) || 1);
// as long as the hero's hand, as thick as its palm (a realistic hand scaled to the chibi's long
// hand as a whole comes out a big fist)
const hu = v3.unit(v3.sub(mb, J(s.hand))), HU = v3.unit(v3.sub(lb, W(`Bip01 ${S} Hand`)));
const palmL = radius(hero, owners([`Bip01 ${S} Hand`]), W(`Bip01 ${S} Hand`), lb, null, 3);
const palmM = radius(points, owners([`Bip01 ${S} Hand`]), J(s.hand), mb, null, 3);
const hr = palmL && palmM ? Math.max(0.5 * hs, Math.min(hs, palmL / palmM)) : hs;
const HR = m3.rot(hu, HU);
put(`Bip01 ${S} Hand`, J(s.hand), W(`Bip01 ${S} Hand`), m3.mul(HR, m3.along(hu, hs, hr)), m3.mul(HR, m3.along(hu, 1 / hs, 1 / hr)));
info[`Bip01 ${S} Hand`] = { length: +hs.toFixed(2), thickness: +hr.toFixed(2) };
const hlen = v3.len(v3.sub(mb, J(s.hand)));
if (T.has(b(`Bip01 ${S} Hand`))) T.get(b(`Bip01 ${S} Hand`)).soft = { o: J(s.hand), u: v3.unit(v3.sub(J(s.fore), J(s.hand))), R: hlen * 0.6, alt: T.get(b(`Bip01 ${S} Forearm`)) };
const groups = [[], [], []];
s.fingers.forEach((p, fi) => groups[Math.min(fi, 2)].push(p));
groups.forEach((paths, g) => {
const names = [`Bip01 ${S} Finger${g}`, `Bip01 ${S} Finger${g}1`, `Bip01 ${S} Finger${g}2`];
if (paths.length && names.every((n) => b(n) >= 0) && paths.every((p) => p.length >= 2)) {
const at = (d) => v3.mul(paths.reduce((acc, p) => v3.add(acc, J(p[Math.min(d, p.length - 1)])), [0, 0, 0]), 1 / paths.length);
const hasThird = paths.some((p) => p.length >= 3);
let last = null;
const means = [];
for (let d = 0; d < 2; d++) {
if (d === 1 && !hasThird) break;
const a = at(d), e = at(d + 1), A = W(names[d]), E = W(names[d + 1]);
const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A));
const sl = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1);
// as thick as the hero's finger there (its hand mesh; three fingers on one chain together
// as wide as the hero's mitten), else as the palm, never fatter than the palm
const own = owners([names[d]]);
const rl = radius(hero, own, A, E, null, 3), rm = radius(points, own, a, e, null, 3);
const r = rl && rm ? Math.max(0.5 * sl, Math.min(Math.max(hr, sl), rl / rm)) : Math.min(hr, Math.max(sl, hr));
const R = m3.rot(u, U);
put(names[d], a, A, m3.mul(R, m3.along(u, sl, r)), m3.mul(R, m3.along(u, 1 / sl, 1 / r)));
means[d] = { a, A, M: T.get(b(names[d])).M, u, r };
last = { R, sl, r, d, u };
}
// the tip: as the joint before it, from its own joint
const d = last.d + 1;
// the tip: as the joint before it (as long as that, not as long as the hand is thick: realistic
// finger tips came out claws on the hero's short fingers)
put(names[d], at(d), W(names[d]), m3.mul(last.R, m3.along(last.u, last.sl, last.r)), m3.mul(last.R, m3.along(last.u, 1 / last.sl, 1 / last.r)));
means[d] = { a: at(d), A: W(names[d]), M: T.get(b(names[d])).M, u: means[d - 1].u, r: last.r };
// several fingers on one chain: each its own way, its knuckles on the hero's pivots (moved
// only sideways, across the finger), so a fist curls every finger at its own joints — the
// mean alone bends a shorter little finger halfway along its bones
if (paths.length > 1) {
const SNUG = 0.75; // a bit closer together, like the 5-finger CN hands
const joint = (p, k) => J(p[Math.min(k, p.length - 1)]);
const land = (p, k) => {
const m = means[k], off = v3.sub(joint(p, k), m.a);
return v3.add(m.A, m3.app(m.M, v3.mul(v3.sub(off, v3.mul(m.u, v3.dot(off, m.u))), SNUG)));
};
for (let k = 0; k <= d; k++) {
const t = T.get(b(names[k]));
t.multi = paths.map((p) => {
const a = joint(p, k), A = land(p, k);
let M = means[k].M, N = t.N;
if (k < d) {
const e = joint(p, k + 1), E = land(p, k + 1), le = v3.len(v3.sub(e, a));
if (le > 1e-6) {
const u = v3.unit(v3.sub(e, a)), R = m3.rot(u, v3.unit(v3.sub(E, A))), sl = v3.len(v3.sub(E, A)) / le, r = means[k].r;
(M = m3.mul(R, m3.along(u, sl, r))), (N = m3.mul(R, m3.along(u, 1 / sl, 1 / r)));
}
}
return { path: p.map(J), a, A, M, N };
});
}
}
if (d === 1) same(names[2], names[1]);
info[`${S} fingers ${g}`] = { fingers: paths.length, length: +last.sl.toFixed(2), thickness: +last.r.toFixed(2) };
} else for (const n of names) same(n, `Bip01 ${S} Hand`);
});
seg(`Bip01 ${S} Thigh`, s.thigh, s.calf, `Bip01 ${S} Thigh`, `Bip01 ${S} Calf`);
seg(`Bip01 ${S} Calf`, s.calf, s.foot, `Bip01 ${S} Calf`, `Bip01 ${S} Foot`);
if (s.toe >= 0) {
// foot + toes as one piece: as long as the hero's shoe (its mesh, the toe bone sits far
// ahead of the ankle) and as thick, so a boot keeps its shape instead of stretching thin
// turned only about the vertical (the hero's toe bone points down at a different slope:
// following it tipped the shoe toes-up), as high as the hero's ankle so the sole stays flat
// on the floor, as long forwards as the hero's shoe and as wide
const a = J(s.foot), A = W(`Bip01 ${S} Foot`);
const flat = (d) => v3.unit([d[0], 0, d[2]]);
const u = flat(v3.sub(J(s.toe), a)), U = flat(v3.sub(W(`Bip01 ${S} Toe0`), A));
const own = owners([`Bip01 ${S} Foot`, `Bip01 ${S} Toe0`]);
const reach = (pts, c, dir) => pct(pts.filter(([, o]) => own.has(o)).map(([p]) => v3.dot(v3.sub(p, c), dir)).filter((t) => t > 0), 0.98);
const el = reach(hero, A, U), em = reach(points, a, u);
const sj = v3.len(v3.sub(W(`Bip01 ${S} Toe0`), A)) / (v3.len(v3.sub(J(s.toe), a)) || 1);
const sl = el && em ? el / em : sj;
const rl = radius(hero, own, A, v3.add(A, v3.mul(U, el || 1))), rm = radius(points, own, a, v3.add(a, v3.mul(u, em || 1)));
const r = rl && rm ? Math.max(0.5 * sl, Math.min(2 * sl, rl / rm)) : sl;
const R = m3.rot(u, U);
const sv = a[1] > 1 ? Math.max(0.5 * sl, Math.min(2.5, A[1] / a[1])) : r; // ankle height (the floor at 0)
const side = v3.cross([0, 1, 0], u);
const S3 = (x, y, z) => m3.mul(m3.mul(m3.along(u, x, 1), m3.along([0, 1, 0], y, 1)), m3.along(side, z, 1));
put(`Bip01 ${S} Foot`, a, A, m3.mul(R, S3(sl, sv, r)), m3.mul(S3(1 / sl, 1 / sv, 1 / r), m3.rot(U, u)));
same(`Bip01 ${S} Toe0`, `Bip01 ${S} Foot`);
info[`Bip01 ${S} Foot`] = { length: +sl.toFixed(2), thickness: +r.toFixed(2), height: +sv.toFixed(2) };
} else same(`Bip01 ${S} Foot`, `Bip01 ${S} Calf`);
}
return { T, info };
}
/** A point (and its normal) through the fit: Σ w · (A + M (p − a)). */
/** Of several fingers on one bone: the one whose joints p is closest to. */
function nearestPath(list, p) {
let best = list[0], bd = Infinity;
for (const f of list)
for (let i = 0; i < f.path.length; i++) {
const a = f.path[i], e = f.path[Math.min(i + 1, f.path.length - 1)], ab = v3.sub(e, a), l2 = v3.dot(ab, ab);
const t = l2 ? Math.max(0, Math.min(1, v3.dot(v3.sub(p, a), ab) / l2)) : 0;
const dd = v3.len(v3.sub(p, v3.add(a, v3.mul(ab, t))));
if (dd < bd) (bd = dd), (best = f);
}
return best;
}
function fitPoint(T, ws, p, nrm) {
let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0;
for (const [bn, w] of ws) {
let t = T.get(bn);
if (!t) continue;
if (t.multi) t = nearestPath(t.multi, p);
let q = v3.add(t.A, m3.app(t.M, v3.sub(p, t.a))), nn = m3.app(t.N, nrm);
const sf = t.soft;
const depth = sf && sf.alt ? v3.dot(v3.sub(p, sf.o), sf.u) : 0;
if (depth > 0) {
const f = Math.min(1, depth / sf.R), a = sf.alt;
const q2 = v3.add(a.A, m3.app(a.M, v3.sub(p, a.a))), n2 = m3.app(a.N, nrm);
// it carries on from where the part leaves it: the neighbour's transform shifted to meet it
const edge = v3.sub(p, v3.mul(sf.u, depth));
const shift = v3.sub(v3.add(t.A, m3.app(t.M, v3.sub(edge, t.a))), v3.add(a.A, m3.app(a.M, v3.sub(edge, a.a))));
q = v3.add(v3.mul(q, 1 - f), v3.mul(v3.add(q2, shift), f));
nn = v3.add(v3.mul(nn, 1 - f), v3.mul(n2, f));
}
t = null;
x += w * q[0];
y += w * q[1];
z += w * q[2];
nx += w * nn[0];
ny += w * nn[1];
nz += w * nn[2];
tw += w;
}
if (!tw) return null;
const l = Math.hypot(nx, ny, nz) || 1;
return [[x / tw, y / tw, z / tw], [nx / l, ny / l, nz / l]];
}
// =====================================================================
// the costume: imported file → .msh fields
// =====================================================================
/** File joint names → skeleton bone indices (same names, any case / spacing). */
function jointMap(sk, joints) {
const byNorm = new Map(sk.data.bones.map((b, i) => [normName(b.name.text), i]));
const direct = joints.map((j) => byNorm.get(normName(j.name)) ?? -1);
// an unknown joint (e.g. an extra skirt bone) moves with its closest known ancestor
return joints.map((j, i) => {
let k = i;
while (k >= 0 && direct[k] < 0) k = joints[k].parent;
return k >= 0 ? direct[k] : -1;
});
}
/** LS-space scale of the file: from bones both have, else metres vs centimetres by height. */
function autoScale(sk, P, file) {
const map = jointMap(sk, file.joints);
let a = 0, b = 0, n = 0;
file.joints.forEach((j, i) => {
const bi = map[i];
if (bi < 0 || normName(j.name) !== normName(sk.data.bones[bi].name.text)) return;
a += Math.hypot(P.wp[bi][0], P.wp[bi][1], P.wp[bi][2]);
b += Math.hypot(j.world[12], j.world[13], j.world[14]);
n++;
});
if (n >= 3 && b > 1e-6) return { scale: a / b, by: `${n} matching bones` };
let lo = Infinity, hi = -Infinity;
for (const m of file.meshes) for (const p of m.prims) for (let v = 1; v < p.pos.length; v += 3) (lo = Math.min(lo, p.pos[v])), (hi = Math.max(hi, p.pos[v]));
const h = hi - lo;
return h < 10 ? { scale: UNIT, by: `height ${h.toFixed(2)} → metres` } : { scale: 1, by: `height ${h.toFixed(0)} → LS units` };
}
/**
* What every part of one import shares: glTF → LS placement, the rig's bones on the hero's (by name,
* or found by shape and fitted onto the hero's proportions — measured once on every mesh of the
* costume, opts.measure, so the body, a helmet and a cape fit the same way).
*/
function prepare(file, sk, P, opts = {}) {
const scale = opts.scale || autoScale(sk, P, file).scale;
const off = opts.offset || [0, 0, 0];
const ry = ((opts.rotY || 0) * Math.PI) / 180;
const cy = Math.cos(ry), sy = Math.sin(ry);
// glTF → LS: mirror z, scale, turn about y, move
const toLs = (x, y, z) => {
x *= scale;
y *= scale;
z *= -scale;
return [x * cy + z * sy + off[0], y + off[1], -x * sy + z * cy + off[2]];
};
const turnN = (x, y, z) => [x * cy - z * sy, y, -x * sy - z * cy]; // a normal: mirrored and turned
const hasFileWeights = file.meshes.some((m) => m.prims.some((p) => p.joints));
let hum = null, hairJ = null, headJ = null;
if (opts.fit && file.joints.length && hasFileWeights) {
hum = humanoid(file.joints, file.joints.map((j) => toLs(j.world[12], j.world[13], j.world[14])));
hairJ = new Uint8Array(file.joints.length);
const mark = (j) => ((hairJ[j] = 1), hum.kids[j].forEach(mark));
hum.headStuff.forEach(mark);
// the head and everything on it (face rigs: jaw, lids, brows… and the hair)
headJ = new Uint8Array(file.joints.length);
const markH = (j) => ((headJ[j] = 1), hum.kids[j].forEach(markH));
if (hum.head >= 0) markH(hum.head);
}
const jmap = hum ? humanoidMap(sk, hum) : jointMap(sk, file.joints);
const mode = hum ? "file" : opts.weights === "auto" || !opts.weights ? (hasFileWeights && jmap.some((b) => b >= 0) ? "file" : "body") : opts.weights;
/** A skinned vertex's weights on the hero's bones, and whether it hangs on hair-like joints. */
const weightsAt = (p, v) => {
const k = p.joints.k || 4;
const acc = new Map();
let top = -1, tw = 0;
for (let j = 0; j < k; j++) {
const w = p.weights[v * k + j], ji = p.joints[v * k + j];
const b = w > 0 && ji >= 0 ? jmap[ji] : -1;
if (b >= 0) acc.set(b, (acc.get(b) || 0) + w);
if (w > tw && ji >= 0) (tw = w), (top = ji);
}
// onHead: mostly on the head joint itself (the face, the skull — not hair / ear / eye bones)
return { ws: [...acc], hair: !!hairJ && top >= 0 && hairJ[top] === 1, onHead: !!hum && top === hum.head && tw >= 0.5, headish: !!headJ && top >= 0 && headJ[top] === 1 };
};
let T = null, fit = null;
if (hum) {
const points = [];
for (const m of file.meshes) {
if (opts.measure && !opts.measure.has(m.name)) continue;
for (const p of m.prims) {
if (!p.joints) continue;
for (let v = 0; v < p.pos.length / 3; v++) {
const { ws, hair, onHead } = weightsAt(p, v);
if (!ws.length) continue;
const top = ws.reduce((a, c) => (c[1] > a[1] ? c : a));
points.push([toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]), top[0], hair, onHead]);
}
}
}
const r = fitTransforms(sk, P, hum, points, opts.fitHero || [], { keepVolume: opts.keepVolume !== false, headScale: opts.headScale || 1, lsBuild: opts.lsBuild });
T = r.T;
const name = (j) => file.joints[j]?.name ?? "—";
fit = { info: r.info, joints: { pelvis: name(hum.pelvis), chest: name(hum.chest), head: name(hum.head), leftHand: name(hum.L.hand), rightHand: name(hum.R.hand), leftFoot: name(hum.L.foot), capes: hum.capes.length, hair: hum.headStuff.length } };
}
return { scale, toLs, turnN, hasFileWeights, hum, jmap, mode, weightsAt, T, fit };
}
/** Which costume part each mesh of the file looks like: mostly on the head → helmet, on a cape → cloak, else body. */
const WEAPON = /weapon|sword|axe|staff|wand|spear|lance|gun|rifle|pistol|bow|shield|blade|katana|dagger|knife|scythe|hammer|club/i;
function partsGuess(file, sk, P, opts = {}) {
const R = opts.rig || prepare(file, sk, P, opts);
const head = sk.byName.get("bip01 head") ?? -1;
const neck = sk.byName.get("bip01 neck") ?? -1;
const out = {};
for (const m of file.meshes) {
let h = 0, c = 0, t = 0;
for (const p of m.prims) {
if (!p.joints) continue;
for (let v = 0; v < p.pos.length / 3; v++)
for (const [b, w] of R.weightsAt(p, v).ws) {
t += w;
if (b === head || b === neck) h += w;
else if (/mantle/i.test(sk.data.bones[b].name.text)) c += w;
}
}
// a weapon the model holds isn't clothes (the game's weapons are items of their own)
out[m.name] = WEAPON.test(m.name) ? "skip" : !t ? "body" : h / t > 0.6 ? "helmet" : c / t > 0.6 ? "cloak" : "body";
}
return out;
}
/**
* file (loadGltf / parseObj), the skeleton + its rest pose, the body surface for weights, and
* opts { scale, offset: [x, y, z], rotY (°), weights: "auto" | "file" | "body" | "bone", bone,
* maxBones, minWeight, include: Set of mesh names (all when missing), splitHands, fit, fitHero,
* measure, rig (a prepare() to share between parts) }.
* → { data (parseMsh-like fields), groups: [{ material, start, count }], report, hand? } — hand: the
* same for the hand mesh when splitHands.
*/
function build(file, sk, P, body, opts = {}) {
const R = opts.rig || prepare(file, sk, P, opts);
const { scale, toLs, turnN, mode } = R;
const maxBones = opts.maxBones || MAX_BONES;
const minW = opts.minWeight ?? 0.01;
const report = { scale, mode, vertices: 0, faces: 0, fromBody: 0, folded: [], bones: 0, subs: 0, fit: R.fit };
// group primitives by material → sub-meshes
const byMat = new Map();
for (const m of file.meshes) {
if (opts.include && !opts.include.has(m.name)) continue;
for (const p of m.prims) {
if (!byMat.has(p.material)) byMat.set(p.material, []);
byMat.get(p.material).push(p);
}
}
const pos = [], nrm = [], uv = [], idx = [], groupsAll = [];
const vb = []; // per vertex: [[bone, w]…]
const headV = []; // per vertex: 1 = on the head joint itself (face, eyes, mouth), 2 = hair
const tris = mode === "body" || mode === "file" ? body : null;
let hasNrm = true;
for (const [mi, prims] of byMat) {
const start = idx.length;
for (const p of prims) {
const o = pos.length / 3;
const V = p.pos.length / 3;
for (let v = 0; v < V; v++) {
const q = toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]);
pos.push(...q);
if (p.nrm) nrm.push(...turnN(p.nrm[v * 3], p.nrm[v * 3 + 1], p.nrm[v * 3 + 2]));
else (hasNrm = false), nrm.push(0, 0, 0);
uv.push(p.uv ? p.uv[v * 2] : 0, p.uv ? p.uv[v * 2 + 1] : 0);
let ws = [];
if (mode === "bone") ws = [[opts.bone ?? 0, 1]];
else if (mode === "file" && p.joints) {
const r = R.weightsAt(p, v);
ws = r.ws;
headV.push(r.headish ? (r.hair ? 2 : 1) : 0);
}
if (headV.length < pos.length / 3) headV.push(0);
if (!ws.length && tris && !R.T) {
ws = transfer(tris, q);
report.fromBody++;
}
vb.push(ws);
}
for (let i = 0; i < p.idx.length; i += 3) idx.push(o + p.idx[i], o + p.idx[i + 2], o + p.idx[i + 1]); // mirrored: back to clockwise
}
groupsAll.push({ material: mi, start, count: idx.length - start });
}
const V = pos.length / 3;
if (!V) throw new Error("Nothing to import (no meshes picked).");
if (!hasNrm) smoothNormals(pos, idx, nrm);
let skirtSet = null;
let bareHands = false; // outfit only: the model's hands are skin — the hero's own are worn
if (R.T) {
// every vertex through its bones' fit; parts without bones then take the body's weights
for (let v = 0; v < V; v++) {
const p = [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]];
const r = vb[v].length ? fitPoint(R.T, vb[v], p, [nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]]) : null;
if (r) for (let k = 0; k < 3; k++) (pos[v * 3 + k] = r[0][k]), (nrm[v * 3 + k] = r[1][k]);
else if (tris) {
vb[v] = transfer(tris, p);
report.fromBody++;
}
}
if (tris && opts.torsoFromBody === true) {
const torso = new Set(["bip01 pelvis", "bip01 spine", "bip01 spine1", "bip01 spine2"].map((n) => sk.byName.get(n) ?? -1));
for (let v = 0; v < V; v++) {
if (!vb[v].length || !vb[v].every(([bn]) => torso.has(bn))) continue;
const ws = transfer(tris, [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]]).filter(([bn]) => torso.has(bn));
if (ws.length) vb[v] = ws;
}
}
// outfit only: the model's own skin and face go, the hero's body is worn under the clothes
if (opts.outfit) {
const drop = outfitDrop(pos, uv, idx, groupsAll, headV, { ...opts, matNames: file.materials.map((m) => m.name), body: tris, tone: opts.tone !== undefined ? opts.tone : fileTone(file, R, opts) });
report.outfit = drop.report;
// the model's hands: gloves (mostly kept) or bare (mostly skin: the hero's hands are worn)
const hb = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text));
const hs = vb.map((ws) => ws.reduce((t, [b, w]) => t + (hb[b] ? w : 0), 0));
let hAll = 0, hKept = 0;
for (let i = 0; i < idx.length; i += 3) if (hs[idx[i]] + hs[idx[i + 1]] + hs[idx[i + 2]] > 1.5) (hAll++, (hKept += drop.tri[i / 3] ? 0 : 1));
bareHands = hAll > 0 && hKept < 0.6 * hAll;
report.outfit.gloves = hAll > 0 && !bareHands;
const keepIdx = [];
for (const g of groupsAll) {
const s = keepIdx.length;
for (let i = g.start; i < g.start + g.count; i += 3) if (!drop.tri[i / 3]) keepIdx.push(idx[i], idx[i + 1], idx[i + 2]);
g.start = s;
g.count = keepIdx.length - s;
}
idx.length = keepIdx.length;
for (let i = 0; i < keepIdx.length; i++) idx[i] = keepIdx[i];
for (let g = groupsAll.length - 1; g >= 0; g--) if (!groupsAll[g].count) groupsAll.splice(g, 1);
if (!idx.length) return null; // nothing but skin
}
skirtSet = new Set();
report.skirt = skirtWeights(sk, P, opts.fitHero, pos, vb, idx, skirtSet);
const mantle = [1, 2, 3, 4, 5].map((i) => sk.byName.get(`bip01 mantle0${i}`) ?? -1).filter((x) => x >= 0);
if (mantle.length > 1) {
const isMantle = new Set(mantle);
const ys = mantle.map((m) => P.wp[m][1]);
for (let v = 0; v < V; v++) {
if (!vb[v].length || !vb[v].every(([bn]) => isMantle.has(bn))) continue;
const y = pos[v * 3 + 1];
let k = 0;
while (k < ys.length - 2 && y < ys[k + 1]) k++;
const t = Math.max(0, Math.min(1, (ys[k] - y) / (ys[k] - ys[k + 1] || 1)));
vb[v] = t <= 0 ? [[mantle[k], 1]] : t >= 1 ? [[mantle[k + 1], 1]] : [[mantle[k], 1 - t], [mantle[k + 1], t]];
}
}
}
if (R.T && tris && opts.cover !== false) report.covered = cover(sk, tris, pos, vb, idx, skirtSet);
// 4 biggest, normalised
const limit = (ws) => {
ws = ws.filter(([, w]) => w > 0).sort((a, b) => b[1] - a[1]).slice(0, 4);
const t = ws.reduce((s, [, w]) => s + w, 0);
ws = ws.map(([b, w]) => [b, w / t]).filter(([, w]) => w >= minW);
const t2 = ws.reduce((s, [, w]) => s + w, 0);
return ws.map(([b, w]) => [b, w / t2]);
};
// smooth weights: neighbours (edges, and vertices on the same spot — UV / material / piece borders)
// average their weights a few times, so no edge goes from one bone to another at once — that
// stretched the belly / hips open when the hero's 4 spine bones turn and the rig had 2
const passes = opts.smoothWeights ?? (R.T ? 3 : 0); // fitted rigs only: a rig with the hero's bones keeps its weights exactly
if (passes > 0) {
const nb = Array.from({ length: V }, () => new Set());
for (let i = 0; i < idx.length; i += 3)
for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) nb[a].add(b), nb[b].add(a);
const cell = new Map();
const key = (v) => `${Math.round(pos[v * 3] / 0.2)},${Math.round(pos[v * 3 + 1] / 0.2)},${Math.round(pos[v * 3 + 2] / 0.2)}`;
for (let v = 0; v < V; v++) {
const k = key(v);
if (!cell.has(k)) cell.set(k, []);
cell.get(k).push(v);
}
for (const vs of cell.values()) if (vs.length > 1) for (const a of vs) for (const b of vs) if (a !== b) nb[a].add(b);
for (let it = 0; it < passes; it++) {
const next = vb.map((ws, v) => {
if (!ws.length || !nb[v].size) return ws;
const acc = new Map();
for (const [bn, w] of ws) acc.set(bn, w * 0.5);
let n = 0;
for (const u of nb[v]) if (vb[u].length) n++;
if (!n) return ws;
for (const u of nb[v]) for (const [bn, w] of vb[u]) acc.set(bn, (acc.get(bn) || 0) + (0.5 * w) / n);
return [...acc];
});
for (let v = 0; v < V; v++) vb[v] = next[v];
}
}
const W0 = vb.map(limit);
// vertices on the same spot (UV / material seams, split normals) move as one: their weights
// averaged — a few hundredths apart they opened holes along the seams in a pose
if (R.T || passes > 0) {
const at = new Map();
for (let v = 0; v < V; v++) {
const k = `${Math.round(pos[v * 3] * 1000)},${Math.round(pos[v * 3 + 1] * 1000)},${Math.round(pos[v * 3 + 2] * 1000)}`;
if (!at.has(k)) at.set(k, []);
at.get(k).push(v);
}
for (const vs of at.values()) {
if (vs.length < 2 || vs.every((v) => W0[v].length === W0[vs[0]].length && W0[v].every(([b, w], j) => W0[vs[0]][j][0] === b && W0[vs[0]][j][1] === w))) continue;
const acc = new Map();
for (const v of vs) for (const [b, w] of W0[v]) acc.set(b, (acc.get(b) || 0) + w / vs.length);
const one = limit([...acc]);
for (const v of vs) W0[v] = one;
}
}
// outfit only: the hero's own body (and hands) inside the clothes, without the faces the
// clothes hide (they'd poke through in a pose) — its weights and material as the game's
const nOut = idx.length; // the model's faces; the hero's come after
const heroHand = new Set(); // faces of the hero's hand mesh
if (opts.base?.length) {
const occ = rayGrid(pos, idx, skirtSet);
let hidden = 0, kept = 0;
// shoes / gloves: the hero's feet / hands inside them go (a hero toe is longer than most shoes)
const footB = sk.data.bones.map((b) => /^bip01 [lr] (foot|toe)/i.test(b.name.text));
const handB = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text));
// shoes: the model's clothes reach below the hero's ankles (by place: shoes are often on the calf's weights)
const ankle = Math.max(...["l", "r"].map((S) => P.wp[sk.byName.get(`bip01 ${S} foot`) ?? 0]?.[1] ?? -Infinity));
let shod = 0;
for (let i = 0; i < idx.length; i += 3) if ((pos[idx[i] * 3 + 1] + pos[idx[i + 1] * 3 + 1] + pos[idx[i + 2] * 3 + 1]) / 3 < ankle) shod++;
const gloved = !!report.outfit?.gloves;
const heroOn = (b, v, B) => {
let t = 0;
for (let j = 0; j < 4; j++) if (b.bone[v * 4 + j] >= 0 && B[b.bone[v * 4 + j]]) t += b.w[v * 4 + j];
return t > 0.5;
};
for (const b of opts.base) {
const o = pos.length / 3, Vb = b.pos.length / 3;
for (let v = 0; v < Vb; v++) {
pos.push(b.pos[v * 3], b.pos[v * 3 + 1], b.pos[v * 3 + 2]);
if (b.nrm) nrm.push(b.nrm[v * 3], b.nrm[v * 3 + 1], b.nrm[v * 3 + 2]);
else nrm.push(0, 1, 0);
uv.push(b.uv[v * 2], b.uv[v * 2 + 1]);
W0.push(limit(weightsOf(b, v)));
}
const at = (v) => [b.pos[v * 3], b.pos[v * 3 + 1], b.pos[v * 3 + 2]];
const nAt = (v) => (b.nrm ? [b.nrm[v * 3], b.nrm[v * 3 + 1], b.nrm[v * 3 + 2]] : [0, 0, 0]);
b.subs.forEach((s, k) => {
const start = idx.length;
for (let i = s.indexStart; i < s.indexStart + s.faceCount * 3; i += 3) {
const c = [b.idx[i], b.idx[i + 1], b.idx[i + 2]];
const ps = c.map(at), ns = c.map(nAt);
ps.push(v3.mul(v3.add(v3.add(ps[0], ps[1]), ps[2]), 1 / 3));
ns.push(v3.add(v3.add(ns[0], ns[1]), ns[2]));
const inShoe = shod > 20 && c.every((v) => heroOn(b, v, footB));
const inGlove = gloved && c.every((v) => heroOn(b, v, handB));
if (inShoe || inGlove || (occ && ps.every((p, j) => occ.covered(p, v3.unit(ns[j]))))) {
hidden++;
continue;
}
kept++;
if (b.hand) heroHand.add(idx.length);
idx.push(o + c[0], o + c[1], o + c[2]);
}
if (idx.length > start) groupsAll.push({ material: b.mats[k], start, count: idx.length - start });
});
}
report.heroBody = { kept, hidden };
}
/** One mesh from the faces keep(t) picks: its own vertices, ≤ maxBones bones, bind-space positions. */
function part(keep, rep) {
const subs = [], groups = [], idxOut = [], map = new Int32Array(pos.length / 3).fill(-1), from = [];
for (const g of groupsAll) {
const start = idxOut.length, base = from.length;
// the group's vertices in their file order (a whole mesh comes back exactly as it was)
const picked = [];
for (let i = g.start; i < g.start + g.count; i += 3) if (keep(i)) picked.push(i);
const usedV = [...new Set(picked.flatMap((i) => [idx[i], idx[i + 1], idx[i + 2]]))].sort((a, b) => a - b);
for (const v of usedV) (map[v] = from.length), from.push(v);
for (const i of picked) idxOut.push(map[idx[i]], map[idx[i + 1]], map[idx[i + 2]]);
if (idxOut.length === start) continue;
subs.push({ minIndex: base, vertexCount: from.length - base, indexStart: start, faceCount: (idxOut.length - start) / 3 });
groups.push({ material: g.material, start, count: idxOut.length - start });
}
const n = from.length;
if (!n) return null;
if (n > 65535) throw new Error(`${n.toLocaleString()} vertices: the game reads at most 65 535 per mesh. Decimate it in Blender (or split it into body / hand meshes).`);
// at most maxBones different bones: the least used one goes into its parent, again and again
let W = from.map((v) => W0[v]);
for (;;) {
const total = new Map();
for (const ws of W) for (const [b, w] of ws) total.set(b, (total.get(b) || 0) + w);
if (total.size <= maxBones) break;
const [drop] = [...total].filter(([b]) => sk.parent[b] >= 0).sort((a, b) => a[1] - b[1])[0];
const into = sk.parent[drop];
rep.folded.push(`${sk.data.bones[drop].name.text} → ${sk.data.bones[into].name.text}`);
W = W.map((ws) => {
if (!ws.some(([b]) => b === drop)) return ws;
const acc = new Map();
for (const [b, w] of ws) acc.set(b === drop ? into : b, (acc.get(b === drop ? into : b) || 0) + w);
return limit([...acc]);
});
}
// palette + the stored (bind space) positions: u · A⁻¹
const used = [...new Set(W.flat().map(([b]) => b))].sort((a, b) => a - b);
const slot = new Map(used.map((b, i) => [b, i]));
const weights = new Float32Array(n * 4), boneIdx = new Uint8Array(n * 4);
const out = new Float32Array(n * 3), onrm = new Float32Array(n * 3), ouv = new Float32Array(n * 2);
const A = new Float64Array(16);
let unweighted = 0;
for (let i = 0; i < n; i++) {
const v = from[i];
ouv[i * 2] = uv[v * 2];
ouv[i * 2 + 1] = uv[v * 2 + 1];
const ws = W[i];
A.fill(0);
if (!ws.length) {
unweighted++;
out.set(pos.slice(v * 3, v * 3 + 3), i * 3);
onrm.set(nrm.slice(v * 3, v * 3 + 3), i * 3);
continue;
}
ws.forEach(([b, w], j) => {
weights[i * 4 + j] = w;
boneIdx[i * 4 + j] = slot.get(b);
const S = P.skin[b];
for (let e = 0; e < 16; e++) A[e] += w * S[e];
});
const inv = M4.invertAffine(A);
xf(inv, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], out, i * 3);
xn(inv, nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2], onrm, i * 3);
const l = Math.hypot(onrm[i * 3], onrm[i * 3 + 1], onrm[i * 3 + 2]) || 1;
for (let k = 0; k < 3; k++) onrm[i * 3 + k] /= l;
}
Object.assign(rep, { vertices: n, faces: idxOut.length / 3, bones: used.length, subs: subs.length, unweighted, boneNames: used.map((b) => sk.data.bones[b].name.text) });
const data = {
version: 2000,
type: 1,
subs,
vertexCount: n,
faceCount: idxOut.length / 3,
pos: out,
normal: onrm,
uv0: ouv,
boneNames: used.map((b) => ({ raw: null, text: sk.data.bones[b].name.text })),
weights,
boneIdx,
indices: Uint16Array.from(idxOut),
controlPoints: [],
};
return { data, groups, report: rep };
}
const notHeroHand = (i) => !heroHand.has(i);
if (!opts.splitHands) return part(notHeroHand, report);
if (bareHands) {
// what's left of bare hands (nails, a ring) goes too: the game's default hands are worn
const hb = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text));
const hs = W0.map((ws) => ws.reduce((t, [b, w]) => t + (hb[b] ? w : 0), 0));
const r = part((i) => notHeroHand(i) && !(i < nOut && hs[idx[i]] + hs[idx[i + 1]] + hs[idx[i + 2]] > 1.5), report);
return Object.assign(r, { handNote: "bare hands: the hero's own hands are worn" });
}
// hands apart (they replace default_hand): faces that mostly move with a hand or its fingers
const handBone = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text));
const handShare = W0.map((ws) => ws.reduce((s, [b, w]) => s + (handBone[b] ? w : 0), 0));
const isHand = (i) => handShare[idx[i]] + handShare[idx[i + 1]] + handShare[idx[i + 2]] > 1.5;
// only real hands (with fingers): cuffs alone would take the bare hands off in the game
const fingered = idx.some((v, i) => i < nOut && i % 3 === 0 && isHand(i) && [0, 1, 2].some((k) => W0[idx[i + k]].some(([b]) => /finger/i.test(sk.data.bones[b].name.text))));
if (!fingered) return Object.assign(part(notHeroHand, report), { handNote: "no fingers in the model: nothing split" });
// the hero's hand mesh (outfit only) goes with the model's gloves / sleeves on the hands
const inHand = (i) => heroHand.has(i) || (i < nOut && isHand(i));
const hand = part(inHand, { ...report, folded: [] });
const main = part((i) => !inHand(i), report);
if (!main) throw new Error("Everything moves with the hands: untick Split the hands.");
main.hand = hand;
report.handVertices = hand ? hand.report.vertices : 0;
return main;
}
/**
* A long skirt / dress the model hangs on its legs tears between them when the hero runs (the
* cloth between the knees follows one calf or the other): its cloth — leg-weighted vertices well
* outside the hero's leg — goes onto the hips and both thighs instead, like the game's own skirts
* (more thigh lower down, left / right by side). Trousers, boots and tights stay on the legs.
*/
function skirtWeights(sk, P, hero, pos, vb, idx, skirt = null) {
const b = (n) => sk.byName.get(n.toLowerCase()) ?? -1;
const W = (n) => P.wp[b(n)];
if (!idx || ["Bip01 Pelvis", "Bip01 L Thigh", "Bip01 R Thigh", "Bip01 L Calf"].some((n) => !W(n))) return 0;
const legBones = new Set(["L", "R"].flatMap((S) => [`Bip01 ${S} Thigh`, `Bip01 ${S} Calf`, `Bip01 ${S} Foot`, `Bip01 ${S} Toe0`].map(b)));
const pelvis = b("Bip01 Pelvis"), thL = b("Bip01 L Thigh"), thR = b("Bip01 R Thigh");
const hips = new Set([pelvis, b("Bip01"), b("Bip01 Spine")]);
const ty = W("Bip01 L Thigh")[1], ky = W("Bip01 L Calf")[1], xL = W("Bip01 L Thigh")[0], xR = W("Bip01 R Thigh")[0];
const xc = (xL + xR) / 2, half = Math.abs(xL - xR) / 2;
const V = vb.length;
// pieces of the mesh (connected faces)
const root = Int32Array.from({ length: V }, (_, i) => i);
const find = (i) => (root[i] === i ? i : (root[i] = find(root[i])));
for (let i = 0; i < idx.length; i += 3) for (let k = 1; k < 3; k++) root[find(idx[i + k])] = find(idx[i]);
// pieces touching (vertices on the same spot: a hem trim, a lining) are one piece
const at = new Map();
for (let v = 0; v < V; v++) {
const k = `${Math.round(pos[v * 3] * 100)},${Math.round(pos[v * 3 + 1] * 100)},${Math.round(pos[v * 3 + 2] * 100)}`;
if (at.has(k)) root[find(v)] = find(at.get(k));
else at.set(k, v);
}
// a skirt / dress is a piece that crosses the middle between the legs well below the crotch
// (trousers part there, boots and socks hang on one leg)
const below = ty - 0.25 * (ty - ky);
const mid = new Map();
const low = new Map();
for (let v = 0; v < V; v++) {
if (pos[v * 3 + 1] >= below) continue;
low.set(find(v), (low.get(find(v)) || 0) + 1);
if (Math.abs(pos[v * 3] - xc) < 0.35 * half) mid.set(find(v), (mid.get(find(v)) || 0) + 1);
}
// (a good share of it: trousers / legs with a strap or pouch in the middle aren't a skirt)
const isSkirt = (r) => (mid.get(r) || 0) >= Math.max(2, 0.03 * (low.get(r) || 0));
const legOrHips = (ws) => ws.reduce((t, [bn, w]) => t + (legBones.has(bn) || hips.has(bn) ? w : 0), 0);
let n = 0;
for (let v = 0; v < V; v++) {
if (!vb[v].length || !isSkirt(find(v))) continue;
const x = pos[v * 3], y = pos[v * 3 + 1];
if (y > ty || legOrHips(vb[v]) < 0.6) continue;
// more thigh further down, the side's thigh (sharpened: in front of a leg it follows that leg)
const share = Math.max(0, Math.min(0.95, (ty - y) / (ty - ky || 1)));
const t = Math.max(0, Math.min(1, (x - xR) / (xL - xR || 1)));
const side = t * t * (3 - 2 * t);
vb[v] = [[pelvis, 1 - share], [thL, share * side], [thR, share * (1 - side)]].filter(([, w]) => w > 0.001);
n++;
skirt?.add(v);
}
return n;
}
/**
* The costume covers the hero's body: a vertex of a body part that ends up inside the hero's body
* surface (thinner legs, a narrow waist…) is pushed out onto it, a little outside, so the game's
* body never shows through — the costume is at least the hero's size, like the game's own
* costumes. Torso, arms and legs only (not head, hands, capes); the push is spread over the
* neighbours so the cloth stays smooth. → how many vertices moved
*/
/**
* Outfit only: which faces of the model are its own body. The skin tone is read off the face (the
* faces on the head joint itself); every face whose texture is that tone goes, and so does the
* face itself — skin, eyes, mouth: whatever lies inside the hero's head (opts.face, its surface),
* the hero's own face shows there. Hair, hats and clothes stay. A few faces either way along a
* border follow their neighbours (no specks). opts.texel(material, u, v) → [r, g, b, a] | null;
* opts.skinTol scales the colour match (1 = normal).
* → { tri: Uint8Array (1 = drop), report }
*/
function outfitDrop(pos, uv, idx, groups, headV, opts) {
const F = idx.length / 3;
const tri = new Uint8Array(F);
const mat = new Int32Array(F).fill(-1);
for (const g of groups) for (let i = g.start; i < g.start + g.count; i += 3) mat[i / 3] = g.material;
const texel = opts.texel || (() => null);
const hsv = (c) => {
const r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
let h = 0;
if (d) h = mx === r ? ((g - b) / d + 6) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [h * 60, mx ? d / mx : 0, mx];
};
// texture colour at a point of face f: k = 0…2 near a corner, 3 the middle
const sample = (f, k) => {
const i = f * 3;
const w = k === 3 ? [1 / 3, 1 / 3, 1 / 3] : [0, 1, 2].map((j) => (j === k ? 0.7 : 0.15));
let u = 0, v = 0;
for (let j = 0; j < 3; j++) (u += w[j] * uv[idx[i + j] * 2]), (v += w[j] * uv[idx[i + j] * 2 + 1]);
const c = texel(mat[f], u, v);
// alpha is often a mask (game rips): only a clear, black texel is "nothing"
return c && !((c[3] ?? 255) < 16 && c[0] + c[1] + c[2] < 30) ? hsv(c) : null;
};
// on the head (the head joint or any below it: a face rig, the hair)
const onHead = (f) => headV[idx[f * 3]] > 0 && headV[idx[f * 3 + 1]] > 0 && headV[idx[f * 3 + 2]] > 0;
const isHair = (f) => headV[idx[f * 3]] === 2 || headV[idx[f * 3 + 1]] === 2 || headV[idx[f * 3 + 2]] === 2;
// how far out of the hero's head (its surface, + outside) a face of the model is; null: not near it
const head = opts.face?.length ? opts.face : null;
const depth = (f) => {
if (!head) return null;
const c = [0, 1, 2].map((k) => (pos[idx[f * 3] * 3 + k] + pos[idx[f * 3 + 1] * 3 + k] + pos[idx[f * 3 + 2] * 3 + k]) / 3);
const h = nearest(head, c);
return h && h.t.n && h.d < 6 ? v3.dot(v3.sub(c, h.q), h.t.n) : null;
};
const name = (f) => opts.matNames?.[mat[f]] || "";
// not skin by their material name: hair, eyes, a mouth, glasses…
const NOT_SKIN = /hair|bang|fringe|tail|hat|cap|helm|eye|iris|pupil|brow|lash|teeth|tooth|tongue|mouth|glass|lens|mask|shadow|highlight|sclera|cheek|blush|髪|目|瞳|眉|歯|舌/i;
// the face's own bits, gone with it (the hero's face has its own)
const FACE_BITS = /eye|iris|pupil|brow|lash|teeth|tooth|tongue|mouth|sclera|highlight|shadow|blush|cheek|目|瞳|眉|歯|舌/i;
const tone = opts.tone !== undefined ? opts.tone : skinTone(F, (f) => onHead(f) && !isHair(f) && !NOT_SKIN.test(name(f)) && depth(f) !== null, sample);
if (opts.toneOnly) return { tone };
const tol = opts.skinTol ?? 1;
const skin = (c) => {
if (!c || !tone) return false;
const dh = Math.abs(((c[0] - tone[0] + 540) % 360) - 180);
return dh <= 16 * tol && c[1] >= tone[1] * (1 - 0.5 * tol) && c[1] <= tone[1] * (1 + 0.9 * tol) + 0.04 && c[2] >= tone[2] - 0.35 * tol && c[2] <= tone[2] + 0.25 * tol;
};
// skin lies on the hero's body (the fit put the limbs there); a boot's toe, a cuff in a skin-like
// colour stands off it
const bodyS = opts.body?.length ? opts.body : null;
const onBody = (f) => {
if (!bodyS) return true;
const c = [0, 1, 2].map((k) => (pos[idx[f * 3] * 3 + k] + pos[idx[f * 3 + 1] * 3 + k] + pos[idx[f * 3 + 2] * 3 + k]) / 3);
const h = nearest(bodyS, c);
if (opts.debugBody) opts.debugBody(h, c);
return !!h && (!h.t.n || v3.dot(v3.sub(c, h.q), h.t.n) < (opts.skinOff ?? 2.5));
};
const skinFace = (f) => {
let n = 0;
for (let k = 0; k < 4; k++) n += skin(sample(f, k)) ? 1 : 0;
return n >= 3;
};
let face = 0;
const fixed = new Uint8Array(F);
for (let f = 0; f < F; f++) {
if (onHead(f)) {
// the face: skin near or in the hero's head, the face's bits, anything well inside it;
// hair, hats, glasses outside it stay
fixed[f] = 1;
const d = depth(f);
const hair = isHair(f) || /hair|bang|fringe|髪/i.test(name(f));
if (!hair && (skinFace(f) || FACE_BITS.test(name(f)) || (d !== null && d < 0.3))) tri[f] = 1;
else if (hair && d !== null && d < -0.5) tri[f] = 1;
continue;
}
tri[f] = skinFace(f) && onBody(f) ? 1 : 0;
}
// the face's material (mostly skin on the head) goes whole: eyes, lashes, a mouth painted or
// modelled in it aren't skin-coloured
{
const tot = new Map(), sk = new Map();
for (let f = 0; f < F; f++) if (onHead(f) && !isHair(f)) {
tot.set(mat[f], (tot.get(mat[f]) || 0) + 1);
sk.set(mat[f], (sk.get(mat[f]) || 0) + tri[f]);
}
for (let f = 0; f < F; f++) if (onHead(f) && !isHair(f) && !/hair|bang|fringe|髪/i.test(name(f)) && (sk.get(mat[f]) >= 0.25 * tot.get(mat[f]) || /face|skin|head|顔|肌/i.test(name(f)))) tri[f] = 1;
}
// a piece on the head (connected faces of one material: the face, an eye, the hair) goes or stays
// whole — what's painted on a face (brows, a mouth) isn't skin-coloured but goes with it
{
const root = new Int32Array(F).map((_, i) => i);
const find = (i) => (root[i] === i ? i : (root[i] = find(root[i])));
const byV = new Map();
for (let f = 0; f < F; f++) {
if (!onHead(f)) continue;
for (let j = 0; j < 3; j++) {
const k = `${idx[f * 3 + j]}:${mat[f]}`;
if (byV.has(k)) root[find(f)] = find(byV.get(k));
else byV.set(k, f);
}
}
const tot = new Map(), dropN = new Map();
for (let f = 0; f < F; f++) if (onHead(f)) {
const r = find(f);
tot.set(r, (tot.get(r) || 0) + 1);
dropN.set(r, (dropN.get(r) || 0) + tri[f]);
}
for (let f = 0; f < F; f++) if (onHead(f)) {
const r = find(f);
tri[f] = dropN.get(r) >= 0.5 * tot.get(r) ? 1 : 0;
face += tri[f];
}
}
// borders: a face follows its neighbours (vertices by position, so across seams too)
const key = (v) => `${Math.round(pos[v * 3] * 100)},${Math.round(pos[v * 3 + 1] * 100)},${Math.round(pos[v * 3 + 2] * 100)}`;
const at = new Map();
const vid = new Int32Array(idx.length);
for (let i = 0; i < idx.length; i++) {
const k = key(idx[i]);
if (!at.has(k)) at.set(k, at.size);
vid[i] = at.get(k);
}
for (let it = 0; it < 2; it++) {
const sum = new Float32Array(at.size), cnt = new Float32Array(at.size);
for (let i = 0; i < idx.length; i++) (sum[vid[i]] += tri[(i / 3) | 0]), cnt[vid[i]]++;
const next = Uint8Array.from(tri);
for (let f = 0; f < F; f++) {
if (fixed[f]) continue;
let s = 0;
for (let j = 0; j < 3; j++) s += sum[vid[f * 3 + j]] / cnt[vid[f * 3 + j]];
next[f] = s / 3 > 0.5 ? 1 : 0;
}
tri.set(next);
}
let dropped = 0;
for (let f = 0; f < F; f++) dropped += tri[f];
return { tri, tone, report: { tone: hsvRgb(tone), faces: F, skin: dropped - face, face, kept: F - dropped } };
}
const hsvRgb = (t) => {
if (!t) return null;
const [h, s, v] = t, c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
return [r, g, b].map((q) => Math.round((q + m) * 255));
};
/** The model's skin tone over all its meshes (its face may be another part than the body). Cached on the rig. */
function fileTone(file, R, opts) {
if (R.tone !== undefined) return R.tone;
const pos = [], uv = [], idx = [], headV = [], groups = [];
for (const m of file.meshes) for (const p of m.prims) {
if (!p.joints) continue;
const o = pos.length / 3, start = idx.length;
for (let v = 0; v < p.pos.length / 3; v++) {
const w = R.weightsAt(p, v);
const q = R.toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]);
const r = R.T && w.ws.length ? fitPoint(R.T, w.ws, q, [0, 1, 0]) : null;
pos.push(...(r ? r[0] : q));
uv.push(p.uv ? p.uv[v * 2] : 0, p.uv ? p.uv[v * 2 + 1] : 0);
headV.push(w.headish ? (w.hair ? 2 : 1) : 0);
}
for (let i = 0; i < p.idx.length; i++) idx.push(o + p.idx[i]);
groups.push({ material: p.material, start, count: idx.length - start });
}
R.tone = idx.length ? outfitDrop(pos, uv, idx, groups, headV, { ...opts, matNames: file.materials.map((m) => m.name), tone: undefined, toneOnly: true }).tone : null;
return R.tone;
}
/** The skin tone [h, s, v]: the commonest colour (hue 10°, saturation / value 0.1 bins) of the faces pick(f) picks — the face's skin. */
function skinTone(F, pick, sample) {
const seen = [];
for (let f = 0; f < F; f++) if (pick(f)) for (let k = 0; k < 4; k++) {
const c = sample(f, k);
if (c && c[1] > 0.04 && c[1] < 0.75 && c[2] > 0.35) seen.push(c);
}
let tone = null;
if (seen.length >= 20) {
const bin = (c) => [Math.floor(c[0] / 10) % 36, Math.min(9, Math.floor(c[1] * 10)), Math.min(9, Math.floor(c[2] * 10))];
const count = new Map();
for (const c of seen) {
const k = bin(c).join();
count.set(k, (count.get(k) || 0) + 1);
}
let best = null, bn = -1;
for (const k of count.keys()) {
const [h, s, v] = k.split(",").map(Number);
let n = 0;
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++) n += count.get(`${(h + a + 36) % 36},${s + b},${v + c}`) || 0;
if (n > bn) (bn = n), (best = [h, s, v]);
}
const near = seen.filter((c) => {
const b = bin(c);
return Math.min(Math.abs(b[0] - best[0]), 36 - Math.abs(b[0] - best[0])) <= 1 && Math.abs(b[1] - best[1]) <= 1 && Math.abs(b[2] - best[2]) <= 1;
});
const med = (k) => near.map((c) => c[k]).sort((a, b) => a - b)[near.length >> 1];
// hue: around the bin's middle (it can wrap at red)
const h0 = best[0] * 10 + 5;
const dh = near.map((c) => ((c[0] - h0 + 540) % 360) - 180).sort((a, b) => a - b)[near.length >> 1];
tone = [(h0 + dh + 360) % 360, med(1), med(2)];
}
return tone;
}
/** The faces of a mesh (not the vertices in skip) for rays: covered(p, n) — a face within 9 out along n (or 4 in). */
function rayGrid(pos, idx, skip = null) {
const cell = 4, map = new Map();
const P = (v) => [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]];
const T = [];
for (let i = 0; i < idx.length; i += 3) {
if (skip && (skip.has(idx[i]) || skip.has(idx[i + 1]) || skip.has(idx[i + 2]))) continue;
const t = [P(idx[i]), P(idx[i + 1]), P(idx[i + 2])];
const ti = T.push(t) - 1;
const lo = [0, 1, 2].map((k) => Math.floor(Math.min(t[0][k], t[1][k], t[2][k]) / cell));
const hi = [0, 1, 2].map((k) => Math.floor(Math.max(t[0][k], t[1][k], t[2][k]) / cell));
for (let x = lo[0]; x <= hi[0]; x++) for (let y = lo[1]; y <= hi[1]; y++) for (let z = lo[2]; z <= hi[2]; z++) {
const k = `${x},${y},${z}`;
if (!map.has(k)) map.set(k, []);
map.get(k).push(ti);
}
}
if (!T.length) return null;
const hit = (o, d, t, max) => {
const e1 = v3.sub(t[1], t[0]), e2 = v3.sub(t[2], t[0]);
const p = v3.cross(d, e2), det = v3.dot(e1, p);
if (Math.abs(det) < 1e-9) return false;
const s = v3.sub(o, t[0]), u = v3.dot(s, p) / det;
if (u < 0 || u > 1) return false;
const q = v3.cross(s, e1), v = v3.dot(d, q) / det;
if (v < 0 || u + v > 1) return false;
const h = v3.dot(e2, q) / det;
return h >= 0 && h <= max;
};
return {
covered(p, n) {
if (!n[0] && !n[1] && !n[2]) return false;
const o = v3.sub(p, v3.mul(n, 4)), max = 13; // from 4 inside (clothes a bit under the hero's skin: it pokes through) to 9 out
const seen = new Set();
for (let s = 0; s <= max; s += cell / 2) {
const q = v3.add(o, v3.mul(n, s));
const k = q.map((x) => Math.floor(x / cell)).join();
for (const ti of map.get(k) || []) {
if (seen.has(ti)) continue;
seen.add(ti);
if (hit(o, n, T[ti], max)) return true;
}
}
return false;
},
};
}
function cover(sk, body, pos, vb, idx, skirt = null, offBody = 1.0, offSkirt = 3.0) {
const keep = new Set(["Pelvis", "Spine", "Spine1", "Spine2", "L Clavicle", "R Clavicle", "L UpperArm", "R UpperArm", "L Forearm", "R Forearm", "L Thigh", "R Thigh", "L Calf", "R Calf", "L Foot", "R Foot", "L Toe0", "R Toe0"].map((n) => sk.byName.get(`bip01 ${n}`.toLowerCase()) ?? -1));
const top = (ws) => ws.reduce((a, c) => (c[1] > a[1] ? c : a), [-1, 0])[0];
const sub = gridOf(body.filter((t) => t.n && t.w.every((ws) => keep.has(top(ws)))));
if (!sub.length) return 0;
const V = vb.length;
let disp = new Float32Array(V * 3);
let n = 0;
for (let v = 0; v < V; v++) {
if (!vb[v].length || !keep.has(top(vb[v]))) continue;
const p = [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]];
const h = nearest(sub, p);
if (!h || h.d > 8) continue;
// a skirt stands further off: the thighs swing forwards inside it
const off = skirt?.has(v) ? offSkirt : offBody;
const depth = v3.dot(v3.sub(p, h.q), h.t.n);
if (depth >= off) continue;
const push = Math.min(8, off - depth);
disp.set(v3.mul(h.t.n, push), v * 3);
n++;
}
if (!n) return 0;
// layers move together: every vertex near a pushed one (any piece: a jacket over a bodysuit)
// takes the biggest push around it, so an outer layer stays outside the inner one instead of
// both landing on the hero's surface and poking through each other
{
const R = 2.5, key = (x, y, z) => `${Math.floor(x / R)},${Math.floor(y / R)},${Math.floor(z / R)}`;
const grid = new Map();
for (let v = 0; v < V; v++) {
if (!disp[v * 3] && !disp[v * 3 + 1] && !disp[v * 3 + 2]) continue;
const k = key(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(v);
}
const big = Float32Array.from(disp);
for (let v = 0; v < V; v++) {
if (!vb[v].length || !keep.has(top(vb[v]))) continue;
const x = pos[v * 3], y = pos[v * 3 + 1], z = pos[v * 3 + 2];
const cx = Math.floor(x / R), cy = Math.floor(y / R), cz = Math.floor(z / R);
let best = Math.hypot(disp[v * 3], disp[v * 3 + 1], disp[v * 3 + 2]), bi = -1;
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++)
for (const u of grid.get(`${cx + a},${cy + b},${cz + c}`) || []) {
if (Math.hypot(pos[u * 3] - x, pos[u * 3 + 1] - y, pos[u * 3 + 2] - z) > R) continue;
const l = Math.hypot(disp[u * 3], disp[u * 3 + 1], disp[u * 3 + 2]);
if (l > best) (best = l), (bi = u);
}
if (bi >= 0) big.set(disp.subarray(bi * 3, bi * 3 + 3), v * 3);
}
disp = big;
}
// spread: a vertex takes its neighbours' push where it is bigger than its own (never less)
const nb = Array.from({ length: V }, () => []);
for (let i = 0; i < idx.length; i += 3) for (const [a, e] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) nb[a].push(e), nb[e].push(a);
for (let it = 0; it < 2; it++) {
const next = Float32Array.from(disp);
for (let v = 0; v < V; v++) {
if (!nb[v].length || !vb[v].length || !keep.has(top(vb[v]))) continue;
const avg = [0, 0, 0];
for (const u of nb[v]) for (let k = 0; k < 3; k++) avg[k] += disp[u * 3 + k] / nb[v].length;
const own = [disp[v * 3], disp[v * 3 + 1], disp[v * 3 + 2]];
if (v3.len(avg) * 0.6 > v3.len(own)) for (let k = 0; k < 3; k++) next[v * 3 + k] = avg[k] * 0.6 + own[k] * 0.4;
}
disp = next;
}
// vertices on the same spot (seams) move together
const at = new Map();
for (let v = 0; v < V; v++) {
const k = `${Math.round(pos[v * 3] * 1000)},${Math.round(pos[v * 3 + 1] * 1000)},${Math.round(pos[v * 3 + 2] * 1000)}`;
if (!at.has(k)) at.set(k, []);
at.get(k).push(v);
}
for (const vs of at.values()) {
if (vs.length < 2) continue;
const m = [0, 1, 2].map((k) => vs.reduce((t, v) => t + disp[v * 3 + k], 0) / vs.length);
for (const v of vs) disp.set(m, v * 3);
}
for (let i = 0; i < V * 3; i++) pos[i] += disp[i];
return n;
}
function smoothNormals(pos, idx, nrm) {
nrm.fill(0);
for (let i = 0; i < idx.length; i += 3) {
const [a, b, c] = [idx[i] * 3, idx[i + 1] * 3, idx[i + 2] * 3];
const e1 = [pos[b] - pos[a], pos[b + 1] - pos[a + 1], pos[b + 2] - pos[a + 2]], e2 = [pos[c] - pos[a], pos[c + 1] - pos[a + 1], pos[c + 2] - pos[a + 2]];
const n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
for (const v of [a, b, c]) for (let k = 0; k < 3; k++) nrm[v + k] += n[k];
}
for (let v = 0; v < nrm.length; v += 3) {
const l = Math.hypot(nrm[v], nrm[v + 1], nrm[v + 2]) || 1;
for (let k = 0; k < 3; k++) nrm[v + k] /= l;
}
}
// =====================================================================
// material script + texture
// =====================================================================
/** One toon material per sub-mesh (the costume look: texture, multiply pass, outline). */
function materialText(textures, { outline = true, outlineColor = [0.133, 0.125, 0.106], thickness = 1.45, twoSided = true } = {}) {
const one = (tex) => {
const cull = twoSided ? "\t\t\tcull none\n" : "";
const t = tex ? `\t\t\t\ttexture ${tex}\n` : "";
let s = `material\n{\n\ttechnique\n\t{\n\t\tpass\n\t\t{\n${cull}\t\t\tshader_group AnimateToonShade\n\t\t\tshadow_cast true\n\t\t\tlight_iterate true\n\t\t\ttexture_unit\n\t\t\t{\n\t\t\t\tuse_light_texture true\n\t\t\t\ttex_address_mode clamp\n\t\t\t\talpha_blend selectarg2 texture diffuse\n\t\t\t}\n\t\t\ttexture_unit\n\t\t\t{\n${t}\t\t\t\ttex_coord_set 1\n\t\t\t\tfiltering none\n\t\t\t\tcolor_blend add current tfactor\n\t\t\t\talpha_blend modulate current texture\n\t\t\t}\n\t\t}\n`;
s += `\t\tpass\n\t\t{\n\t\t\tscene_blend add zero src_color\n\t\t\tdepth_write false\n${cull}\t\t\tlighting false\n\t\t\tshader_group Animate\n\t\t\ttexture_unit\n\t\t\t{\n${t}\t\t\t\tfiltering none\n\t\t\t}\n\t\t}\n`;
if (outline) s += `\t\tpass\n\t\t{\n\t\t\tcull clockwise\n\t\t\tdepth_bias 0.0 0.5\n\t\t\tshader_group AnimateOutLine\n\t\t\tcustom_param OutLineColor colorvalue ${outlineColor.map((v) => v.toFixed(3)).join(" ")} 1.000\n\t\t\tcustom_param OutLineThickness float ${thickness.toFixed(3)}\n\t\t\ttexture_unit\n\t\t\t{\n\t\t\t\tcolor_blend selectarg2 texture diffuse\n\t\t\t\talpha_blend selectarg2 texture diffuse\n\t\t\t}\n\t\t}\n`;
return s + "\t}\n}\n";
};
return textures.map(one).join("").replace(/\n/g, "\r\n");
}
/** RGBA pixels → an uncompressed 32-bit .dds (A8R8G8B8, no mip maps). */
function encodeDds(rgba, w, h) {
const out = new Uint8Array(128 + w * h * 4);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x20534444, true); // "DDS "
dv.setUint32(4, 124, true);
dv.setUint32(8, 0x100f, true); // caps | height | width | pitch | pixel format
dv.setUint32(12, h, true);
dv.setUint32(16, w, true);
dv.setUint32(20, w * 4, true);
dv.setUint32(76, 32, true);
dv.setUint32(80, 0x41, true); // RGB | alpha pixels
dv.setUint32(88, 32, true);
dv.setUint32(92, 0x00ff0000, true);
dv.setUint32(96, 0x0000ff00, true);
dv.setUint32(100, 0x000000ff, true);
dv.setUint32(104, 0xff000000, true);
dv.setUint32(108, 0x1000, true); // texture
for (let i = 0, o = 128; i < w * h * 4; i += 4, o += 4) {
out[o] = rgba[i + 2];
out[o + 1] = rgba[i + 1];
out[o + 2] = rgba[i];
out[o + 3] = rgba[i + 3];
}
return out;
}
return { UNIT, MAX_BONES, restPose, posed, exportGlb, loadGltf, parseObj, parseFbx, surface, transfer, nearestSurface: (t, p) => nearest(t, p), jointMap, autoScale, humanoid, humanoidMap, bodyPoints, prepare, partsGuess, build, materialText, encodeDds };
})();
window.CostumeKit = CostumeKit;