2427 lines
129 KiB
JavaScript
2427 lines
129 KiB
JavaScript
"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;
|