@babylonjs/viewer
Version:
The Babylon Viewer aims to simplify a specific but common Babylon.js use case: loading, viewing, and interacting with a 3D model.
6,756 lines • 288 kB
JavaScript
import { M as Matrix, co as FBXFileLoaderMetadata, bi as TransformNode, bu as MultiMaterial, aV as Mesh, F as Material, aW as VertexData, V as Vector3, cp as SubMesh, i as Color3, m as Texture, c1 as GetMimeType, aU as Camera, bs as DirectionalLight, aD as Quaternion, aR as Animation, bn as RegisterSceneLoaderPlugin } from './index-HyNDfLMI.esm.js';
import { S as StandardMaterial } from './standardMaterial.pure-B45jJn5x.esm.js';
import { S as Skeleton } from './skeleton-CK10BdK4.esm.js';
import { B as Bone } from './bone.pure-CwnvxLJv.esm.js';
import { A as AnimationGroup } from './animationGroup.pure-DdYfp_5u.esm.js';
import { M as MorphTargetManager, a as MorphTarget, F as FreeCamera } from './morphTargetManager-BcCzrruC.esm.js';
import { P as PointLight } from './pointLight.pure-CgvNQakl.esm.js';
import { S as SpotLight } from './spotLight.pure-C65PiYTZ.esm.js';
import { A as AssetContainer } from './assetContainer-BhW2gcI4.esm.js';
import './prepass.defines-NGwPAvhm.esm.js';
import './material.detailMapConfiguration-C33hA3Hm.esm.js';
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
const ADLER_MOD = 65521;
const MAX_BITS = 15;
const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LENGTH_EXTRA_BITS = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DISTANCE_EXTRA_BITS = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
/**
* Inflate a zlib-wrapped deflate stream.
*
* This implementation is intentionally scoped to FBX binary array payloads: one-shot,
* synchronous zlib streams with the exact uncompressed length known up front.
*/
function inflateZlib(input, expectedLength) {
if (!Number.isInteger(expectedLength) || expectedLength < 0) {
throw new Error("zlib: invalid expected length");
}
if (input.byteLength < 6) {
throw new Error("zlib: unexpected end of input");
}
const cmf = input[0];
const flg = input[1];
if ((cmf & 0x0f) !== 8 || cmf >> 4 > 7 || ((cmf << 8) + flg) % 31 !== 0) {
throw new Error("zlib: invalid header");
}
if ((flg & 0x20) !== 0) {
throw new Error("zlib: preset dictionary not supported");
}
const reader = new BitReader(input, 2, input.byteLength - 4);
const output = new OutputWriter(expectedLength);
let isFinalBlock = false;
while (!isFinalBlock) {
isFinalBlock = reader.readBits(1) === 1;
const blockType = reader.readBits(2);
switch (blockType) {
case 0:
inflateStoredBlock(reader, output);
break;
case 1:
inflateCompressedBlock(reader, output, getFixedLiteralLengthTree(), getFixedDistanceTree());
break;
case 2: {
const { literalLengthTree, distanceTree } = readDynamicTrees(reader);
inflateCompressedBlock(reader, output, literalLengthTree, distanceTree);
break;
}
default:
throw new Error("deflate: invalid block type");
}
}
if (reader.byteOffset < input.byteLength - 4) {
throw new Error("zlib: trailing deflate data");
}
output.finish();
const expectedAdler = ((input[input.byteLength - 4] << 24) | (input[input.byteLength - 3] << 16) | (input[input.byteLength - 2] << 8) | input[input.byteLength - 1]) >>> 0;
if (output.adler32() !== expectedAdler) {
throw new Error("zlib: adler32 mismatch");
}
return output.bytes;
}
class BitReader {
constructor(input, byteOffset, endOffset) {
this.input = input;
this.byteOffset = byteOffset;
this.endOffset = endOffset;
this.bitBuffer = 0;
this.bitCount = 0;
}
readBits(count) {
this.ensureBits(count);
const value = this.bitBuffer & ((1 << count) - 1);
this.bitBuffer >>>= count;
this.bitCount -= count;
return value;
}
readBit() {
if (this.bitCount === 0) {
if (this.byteOffset >= this.endOffset) {
throw new Error("zlib: unexpected end of input");
}
this.bitBuffer = this.input[this.byteOffset++];
this.bitCount = 8;
}
const bit = this.bitBuffer & 1;
this.bitBuffer >>>= 1;
this.bitCount--;
return bit;
}
alignToByte() {
this.bitBuffer = 0;
this.bitCount = 0;
}
readUint16LE() {
this.ensureByteAligned();
if (this.byteOffset + 2 > this.endOffset) {
throw new Error("zlib: unexpected end of input");
}
const value = this.input[this.byteOffset] | (this.input[this.byteOffset + 1] << 8);
this.byteOffset += 2;
return value;
}
readByte() {
this.ensureByteAligned();
if (this.byteOffset >= this.endOffset) {
throw new Error("zlib: unexpected end of input");
}
return this.input[this.byteOffset++];
}
ensureByteAligned() {
if (this.bitCount !== 0) {
throw new Error("deflate: expected byte alignment");
}
}
ensureBits(count) {
while (this.bitCount < count) {
if (this.byteOffset >= this.endOffset) {
throw new Error("zlib: unexpected end of input");
}
this.bitBuffer |= this.input[this.byteOffset++] << this.bitCount;
this.bitCount += 8;
}
}
}
class OutputWriter {
constructor(expectedLength) {
this.offset = 0;
this.adlerA = 1;
this.adlerB = 0;
this.bytes = new Uint8Array(expectedLength);
}
writeByte(value) {
if (this.offset >= this.bytes.byteLength) {
throw new Error("zlib: output length mismatch");
}
const byte = value & 0xff;
this.bytes[this.offset++] = byte;
this.adlerA += byte;
this.adlerB += this.adlerA;
this.adlerA %= ADLER_MOD;
this.adlerB %= ADLER_MOD;
}
copy(distance, length) {
if (distance <= 0 || distance > this.offset) {
throw new Error("deflate: distance out of range");
}
for (let i = 0; i < length; i++) {
this.writeByte(this.bytes[this.offset - distance]);
}
}
finish() {
if (this.offset !== this.bytes.byteLength) {
throw new Error("zlib: output length mismatch");
}
}
adler32() {
return ((this.adlerB << 16) | this.adlerA) >>> 0;
}
}
class HuffmanTree {
constructor(codeLengths, options = {}) {
const counts = new Array(MAX_BITS + 1).fill(0);
let nonZeroCount = 0;
let maxCodeLength = 0;
for (const length of codeLengths) {
if (!Number.isInteger(length) || length < 0 || length > MAX_BITS) {
throw new Error("deflate: invalid huffman code lengths");
}
if (length > 0) {
counts[length]++;
nonZeroCount++;
maxCodeLength = Math.max(maxCodeLength, length);
}
}
if (nonZeroCount === 0) {
if (options.allowEmpty) {
this.symbolsByLength = [];
this.maxCodeLength = 0;
return;
}
throw new Error("deflate: invalid huffman code lengths");
}
let remaining = 1;
for (let bits = 1; bits <= MAX_BITS; bits++) {
remaining = (remaining << 1) - counts[bits];
if (remaining < 0) {
throw new Error("deflate: invalid huffman code lengths");
}
}
if (remaining !== 0 && nonZeroCount !== 1) {
throw new Error("deflate: invalid huffman code lengths");
}
const nextCode = new Array(MAX_BITS + 1).fill(0);
let code = 0;
for (let bits = 1; bits <= MAX_BITS; bits++) {
code = (code + counts[bits - 1]) << 1;
nextCode[bits] = code;
}
this.symbolsByLength = Array.from({ length: MAX_BITS + 1 }, (_, length) => {
if (counts[length] === 0) {
return undefined;
}
const symbols = new Int16Array(1 << length);
symbols.fill(-1);
return symbols;
});
for (let symbol = 0; symbol < codeLengths.length; symbol++) {
const length = codeLengths[symbol];
if (length === 0) {
continue;
}
this.symbolsByLength[length][nextCode[length]++] = symbol;
}
this.maxCodeLength = maxCodeLength;
}
decode(reader) {
if (this.maxCodeLength === 0) {
throw new Error("deflate: invalid huffman code");
}
let code = 0;
for (let length = 1; length <= this.maxCodeLength; length++) {
code = (code << 1) | reader.readBit();
const symbol = this.symbolsByLength[length]?.[code] ?? -1;
if (symbol >= 0) {
return symbol;
}
}
throw new Error("deflate: invalid huffman code");
}
}
let fixedLiteralLengthTree;
let fixedDistanceTree;
function getFixedLiteralLengthTree() {
if (!fixedLiteralLengthTree) {
const lengths = new Array(288);
for (let symbol = 0; symbol <= 143; symbol++) {
lengths[symbol] = 8;
}
for (let symbol = 144; symbol <= 255; symbol++) {
lengths[symbol] = 9;
}
for (let symbol = 256; symbol <= 279; symbol++) {
lengths[symbol] = 7;
}
for (let symbol = 280; symbol <= 287; symbol++) {
lengths[symbol] = 8;
}
fixedLiteralLengthTree = new HuffmanTree(lengths);
}
return fixedLiteralLengthTree;
}
function getFixedDistanceTree() {
if (!fixedDistanceTree) {
fixedDistanceTree = new HuffmanTree(new Array(32).fill(5));
}
return fixedDistanceTree;
}
function inflateStoredBlock(reader, output) {
reader.alignToByte();
const length = reader.readUint16LE();
const inverseLength = reader.readUint16LE();
if (((length ^ inverseLength) & 0xffff) !== 0xffff) {
throw new Error("deflate: invalid stored block length");
}
for (let i = 0; i < length; i++) {
output.writeByte(reader.readByte());
}
}
function inflateCompressedBlock(reader, output, literalLengthTree, distanceTree) {
while (true) {
const symbol = literalLengthTree.decode(reader);
if (symbol < 256) {
output.writeByte(symbol);
continue;
}
if (symbol === 256) {
return;
}
if (symbol > 285) {
throw new Error("deflate: invalid literal/length symbol");
}
const lengthIndex = symbol - 257;
const length = LENGTH_BASE[lengthIndex] + reader.readBits(LENGTH_EXTRA_BITS[lengthIndex]);
const distanceSymbol = distanceTree.decode(reader);
if (distanceSymbol > 29) {
throw new Error("deflate: invalid distance symbol");
}
const distance = DISTANCE_BASE[distanceSymbol] + reader.readBits(DISTANCE_EXTRA_BITS[distanceSymbol]);
output.copy(distance, length);
}
}
function readDynamicTrees(reader) {
const literalLengthCount = reader.readBits(5) + 257;
const distanceCount = reader.readBits(5) + 1;
const codeLengthCount = reader.readBits(4) + 4;
const codeLengthLengths = new Array(19).fill(0);
for (let i = 0; i < codeLengthCount; i++) {
codeLengthLengths[CODE_LENGTH_ORDER[i]] = reader.readBits(3);
}
const codeLengthTree = new HuffmanTree(codeLengthLengths);
const lengths = readCodeLengths(reader, codeLengthTree, literalLengthCount + distanceCount);
const literalLengthLengths = lengths.slice(0, literalLengthCount);
const distanceLengths = lengths.slice(literalLengthCount);
if (literalLengthLengths[256] === 0) {
throw new Error("deflate: missing end-of-block code");
}
return {
literalLengthTree: new HuffmanTree(literalLengthLengths),
distanceTree: new HuffmanTree(distanceLengths, { allowEmpty: true }),
};
}
function readCodeLengths(reader, codeLengthTree, count) {
const lengths = [];
while (lengths.length < count) {
const symbol = codeLengthTree.decode(reader);
if (symbol <= 15) {
lengths.push(symbol);
continue;
}
let repeatLength;
let repeatedValue;
switch (symbol) {
case 16:
if (lengths.length === 0) {
throw new Error("deflate: invalid code length repeat");
}
repeatedValue = lengths[lengths.length - 1];
repeatLength = reader.readBits(2) + 3;
break;
case 17:
repeatedValue = 0;
repeatLength = reader.readBits(3) + 3;
break;
case 18:
repeatedValue = 0;
repeatLength = reader.readBits(7) + 11;
break;
default:
throw new Error("deflate: invalid code length symbol");
}
if (lengths.length + repeatLength > count) {
throw new Error("deflate: invalid code length repeat");
}
for (let i = 0; i < repeatLength; i++) {
lengths.push(repeatedValue);
}
}
return lengths;
}
const FBX_MAGIC = "Kaydara FBX Binary \0";
const HEADER_SIZE = 27; // 21 magic + 2 padding + 4 version uint32
/**
* Parse a binary FBX file into an FBXDocument.
* Supports FBX versions 7.0–7.7 (v7.5+ uses 64-bit node headers).
*/
function parseBinaryFBX(buffer) {
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
// Validate magic
const magic = decodeASCII(bytes, 0, 21);
if (magic !== FBX_MAGIC) {
throw new Error("Not a valid binary FBX file");
}
if (buffer.byteLength < HEADER_SIZE) {
throw new Error("Truncated binary FBX header");
}
const version = view.getUint32(23, true);
// v7.5+ uses 64-bit offsets in node records
const is64Bit = version >= 7500;
const nodes = [];
let offset = HEADER_SIZE;
while (offset < buffer.byteLength) {
const result = parseNode(view, bytes, offset, is64Bit, buffer.byteLength);
if (result === null) {
break; // null sentinel node
}
nodes.push(result.node);
offset = result.endOffset;
}
return { version, nodes };
}
function parseNode(view, bytes, offset, is64Bit, limit) {
// Read node header
let endOffset;
let numProperties;
let propertyListLen;
let headerSize;
if (is64Bit) {
ensureRange(bytes, offset, 25, limit, "FBX node header");
endOffset = readUint64AsNumber(view, offset);
numProperties = readUint64AsNumber(view, offset + 8);
propertyListLen = readUint64AsNumber(view, offset + 16);
headerSize = 25; // 8+8+8+1 (nameLen byte)
}
else {
ensureRange(bytes, offset, 13, limit, "FBX node header");
endOffset = view.getUint32(offset, true);
numProperties = view.getUint32(offset + 4, true);
propertyListLen = view.getUint32(offset + 8, true);
headerSize = 13; // 4+4+4+1 (nameLen byte)
}
// Null sentinel: all header fields are zero
if (endOffset === 0) {
return null;
}
if (endOffset <= offset || endOffset > limit) {
throw new Error(`Invalid FBX node end offset ${endOffset} at offset ${offset}`);
}
const nameLen = bytes[offset + headerSize - 1];
ensureRange(bytes, offset + headerSize, nameLen, endOffset, "FBX node name");
const name = decodeASCII(bytes, offset + headerSize, nameLen);
let cursor = offset + headerSize + nameLen;
const propertiesStart = cursor;
const propertiesEnd = propertiesStart + propertyListLen;
if (propertiesEnd > endOffset) {
throw new Error(`Invalid FBX property list length for node '${name}' at offset ${offset}`);
}
// Parse properties
const properties = [];
for (let i = 0; i < numProperties; i++) {
const result = parseProperty(view, bytes, cursor, propertiesEnd);
properties.push(result.property);
cursor = result.nextOffset;
}
if (cursor !== propertiesEnd) {
throw new Error(`Invalid FBX property list length for node '${name}' at offset ${offset}`);
}
// Parse nested child nodes (between end of properties and endOffset)
const children = [];
if (cursor < endOffset) {
while (cursor < endOffset) {
const child = parseNode(view, bytes, cursor, is64Bit, endOffset);
if (child === null) {
break;
}
if (child.endOffset <= cursor || child.endOffset > endOffset) {
throw new Error(`Invalid FBX child node end offset ${child.endOffset} at offset ${cursor}`);
}
children.push(child.node);
cursor = child.endOffset;
}
}
return {
node: { name, properties, children },
endOffset,
};
}
function parseProperty(view, bytes, offset, limit) {
ensureRange(bytes, offset, 1, limit, "FBX property type");
const typeCode = String.fromCharCode(bytes[offset]);
offset += 1;
switch (typeCode) {
case "C": {
// Boolean (1 byte)
ensureRange(bytes, offset, 1, limit, "FBX boolean property");
const value = bytes[offset] !== 0;
return { property: { type: "boolean", value }, nextOffset: offset + 1 };
}
case "Y": {
// Int16
ensureRange(bytes, offset, 2, limit, "FBX int16 property");
const value = view.getInt16(offset, true);
return { property: { type: "int16", value }, nextOffset: offset + 2 };
}
case "I": {
// Int32
ensureRange(bytes, offset, 4, limit, "FBX int32 property");
const value = view.getInt32(offset, true);
return { property: { type: "int32", value }, nextOffset: offset + 4 };
}
case "F": {
// Float32
ensureRange(bytes, offset, 4, limit, "FBX float32 property");
const value = view.getFloat32(offset, true);
return { property: { type: "float32", value }, nextOffset: offset + 4 };
}
case "D": {
// Float64
ensureRange(bytes, offset, 8, limit, "FBX float64 property");
const value = view.getFloat64(offset, true);
return { property: { type: "float64", value }, nextOffset: offset + 8 };
}
case "L": {
// Int64
ensureRange(bytes, offset, 8, limit, "FBX int64 property");
const value = readInt64AsNumber(view, offset);
return { property: { type: "int64", value }, nextOffset: offset + 8 };
}
case "S": {
// String (uint32 length + data)
ensureRange(bytes, offset, 4, limit, "FBX string property length");
const len = view.getUint32(offset, true);
ensureRange(bytes, offset + 4, len, limit, "FBX string property data");
const value = decodeUTF8(bytes, offset + 4, len);
return { property: { type: "string", value }, nextOffset: offset + 4 + len };
}
case "R": {
// Raw binary data (uint32 length + data)
ensureRange(bytes, offset, 4, limit, "FBX raw property length");
const len = view.getUint32(offset, true);
ensureRange(bytes, offset + 4, len, limit, "FBX raw property data");
const value = bytes.slice(offset + 4, offset + 4 + len);
return { property: { type: "raw", value }, nextOffset: offset + 4 + len };
}
// Array types
case "f":
return parseArrayProperty(view, bytes, offset, "float32[]", 4, limit);
case "d":
return parseArrayProperty(view, bytes, offset, "float64[]", 8, limit);
case "i":
return parseArrayProperty(view, bytes, offset, "int32[]", 4, limit);
case "l":
return parseArrayProperty(view, bytes, offset, "int64[]", 8, limit);
case "b":
return parseArrayProperty(view, bytes, offset, "boolean[]", 1, limit);
default:
throw new Error(`Unknown FBX property type: '${typeCode}' at offset ${offset - 1}`);
}
}
function parseArrayProperty(view, bytes, offset, type, elementSize, limit) {
ensureRange(bytes, offset, 12, limit, `FBX array property header for ${type}`);
const arrayLength = view.getUint32(offset, true);
const encoding = view.getUint32(offset + 4, true); // 0=raw, 1=zlib
const compressedLength = view.getUint32(offset + 8, true);
offset += 12;
const expectedByteLength = arrayLength * elementSize;
ensureRange(bytes, offset, compressedLength, limit, `FBX array property data for ${type}`);
let arrayData;
if (encoding === 1) {
// zlib compressed
const compressed = bytes.subarray(offset, offset + compressedLength);
arrayData = inflateZlib(compressed, expectedByteLength);
}
else {
if (encoding !== 0) {
throw new Error(`Unsupported FBX array encoding: ${encoding}`);
}
if (compressedLength !== expectedByteLength) {
throw new Error(`Invalid FBX array byte length for ${type}`);
}
arrayData = bytes.slice(offset, offset + compressedLength);
}
const arrayBuffer = arrayData.buffer.slice(arrayData.byteOffset, arrayData.byteOffset + arrayData.byteLength);
let value;
switch (type) {
case "float32[]":
value = new Float32Array(arrayBuffer);
break;
case "float64[]":
value = new Float64Array(arrayBuffer);
break;
case "int32[]":
value = new Int32Array(arrayBuffer);
break;
case "boolean[]":
value = arrayData;
break;
case "int64[]":
value = readInt64ArrayData(arrayData);
break;
default:
throw new Error(`Unexpected array type: ${type}`);
}
return {
property: { type, value },
nextOffset: offset + compressedLength,
};
}
function ensureRange(bytes, offset, byteLength, limit, context) {
if (offset < 0 || byteLength < 0 || offset + byteLength > limit || offset + byteLength > bytes.byteLength) {
throw new Error(`${context}: unexpected end of input`);
}
}
function readUint64AsNumber(view, offset) {
const low = view.getUint32(offset, true);
const high = view.getUint32(offset + 4, true);
return high * 0x100000000 + low;
}
function readInt64AsNumber(view, offset) {
const low = view.getUint32(offset, true);
const high = view.getInt32(offset + 4, true);
return high * 0x100000000 + low;
}
function readInt64ArrayData(arrayData) {
const view = new DataView(arrayData.buffer, arrayData.byteOffset, arrayData.byteLength);
const values = new Float64Array(arrayData.byteLength / 8);
for (let i = 0; i < values.length; i++) {
values[i] = readInt64AsNumber(view, i * 8);
}
return values;
}
function decodeASCII(bytes, offset, length) {
let result = "";
for (let i = 0; i < length; i++) {
result += String.fromCharCode(bytes[offset + i]);
}
return result;
}
function decodeUTF8(bytes, offset, length) {
const decoder = new TextDecoder("utf-8");
return decoder.decode(bytes.subarray(offset, offset + length));
}
/**
* Parse an ASCII FBX file into an FBXDocument.
*/
function parseAsciiFBX(text) {
const tokenizer = new Tokenizer(text);
const version = parseVersion(text);
const nodes = [];
while (!tokenizer.isEOF()) {
tokenizer.skipWhitespaceAndComments();
if (tokenizer.isEOF()) {
break;
}
const node = parseNodeFromTokens(tokenizer);
if (node) {
nodes.push(node);
}
}
return { version, nodes };
}
/** Extract FBX version from the header comment (e.g. "; FBX 7.7.0 project file") */
function parseVersion(text) {
const match = text.match(/;\s*FBX\s+(\d+)\.(\d+)\.(\d+)/);
if (!match) {
throw new Error("Cannot determine FBX version from ASCII header");
}
return parseInt(match[1]) * 1000 + parseInt(match[2]) * 100 + parseInt(match[3]);
}
// ── Tokenizer ──────────────────────────────────────────────────────────────────
var TokenType;
(function (TokenType) {
TokenType[TokenType["Identifier"] = 0] = "Identifier";
TokenType[TokenType["Number"] = 1] = "Number";
TokenType[TokenType["String"] = 2] = "String";
TokenType[TokenType["OpenBrace"] = 3] = "OpenBrace";
TokenType[TokenType["CloseBrace"] = 4] = "CloseBrace";
TokenType[TokenType["Colon"] = 5] = "Colon";
TokenType[TokenType["Comma"] = 6] = "Comma";
TokenType[TokenType["Star"] = 7] = "Star";
TokenType[TokenType["EOF"] = 8] = "EOF";
})(TokenType || (TokenType = {}));
class Tokenizer {
constructor(text) {
this.text = text;
this.pos = 0;
this.len = text.length;
}
isEOF() {
this.skipWhitespaceAndComments();
return this.pos >= this.len;
}
peek() {
const saved = this.pos;
const tok = this.next();
this.pos = saved;
return tok;
}
next() {
this.skipWhitespaceAndComments();
if (this.pos >= this.len) {
return { type: 8 /* TokenType.EOF */, value: "", pos: this.pos };
}
const ch = this.text[this.pos];
const startPos = this.pos;
switch (ch) {
case "{":
this.pos++;
return { type: 3 /* TokenType.OpenBrace */, value: "{", pos: startPos };
case "}":
this.pos++;
return { type: 4 /* TokenType.CloseBrace */, value: "}", pos: startPos };
case ":":
this.pos++;
return { type: 5 /* TokenType.Colon */, value: ":", pos: startPos };
case ",":
this.pos++;
return { type: 6 /* TokenType.Comma */, value: ",", pos: startPos };
case "*":
this.pos++;
return { type: 7 /* TokenType.Star */, value: "*", pos: startPos };
case '"':
return this.readString();
default:
if (this.isNumberStart(ch)) {
return this.readNumber();
}
if (this.isIdentStart(ch)) {
return this.readIdentifier();
}
throw new Error(`Unexpected character '${ch}' at position ${this.pos}`);
}
}
expect(type) {
const tok = this.next();
if (tok.type !== type) {
throw new Error(`Expected token type ${type} but got ${tok.type} ('${tok.value}') at pos ${tok.pos}`);
}
return tok;
}
/** Look ahead to see if the next identifier + colon is a child node start */
isNextNodeStart() {
const saved = this.pos;
this.skipWhitespaceAndComments();
// Read the identifier
if (this.pos < this.len && this.isIdentStart(this.text[this.pos])) {
while (this.pos < this.len && this.isIdentChar(this.text[this.pos])) {
this.pos++;
}
// Skip whitespace between identifier and potential colon
while (this.pos < this.len && (this.text[this.pos] === " " || this.text[this.pos] === "\t")) {
this.pos++;
}
const isNode = this.pos < this.len && this.text[this.pos] === ":";
this.pos = saved;
return isNode;
}
this.pos = saved;
return false;
}
skipWhitespaceAndComments() {
while (this.pos < this.len) {
const ch = this.text[this.pos];
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
this.pos++;
}
else if (ch === ";") {
// Skip comment to end of line
while (this.pos < this.len && this.text[this.pos] !== "\n") {
this.pos++;
}
}
else {
break;
}
}
}
readString() {
const startPos = this.pos;
this.pos++; // skip opening quote
let value = "";
while (this.pos < this.len && this.text[this.pos] !== '"') {
if (this.text[this.pos] === "\\" && this.pos + 1 < this.len) {
this.pos++;
value += this.text[this.pos];
}
else {
value += this.text[this.pos];
}
this.pos++;
}
if (this.pos < this.len) {
this.pos++; // skip closing quote
}
return { type: 2 /* TokenType.String */, value, pos: startPos };
}
readNumber() {
const startPos = this.pos;
// Handle leading sign
if (this.text[this.pos] === "-" || this.text[this.pos] === "+") {
this.pos++;
}
while (this.pos < this.len && this.isDigit(this.text[this.pos])) {
this.pos++;
}
if (this.pos < this.len && this.text[this.pos] === ".") {
this.pos++;
while (this.pos < this.len && this.isDigit(this.text[this.pos])) {
this.pos++;
}
}
// Scientific notation
if (this.pos < this.len && (this.text[this.pos] === "e" || this.text[this.pos] === "E")) {
this.pos++;
if (this.pos < this.len && (this.text[this.pos] === "+" || this.text[this.pos] === "-")) {
this.pos++;
}
while (this.pos < this.len && this.isDigit(this.text[this.pos])) {
this.pos++;
}
}
return { type: 1 /* TokenType.Number */, value: this.text.substring(startPos, this.pos), pos: startPos };
}
readIdentifier() {
const startPos = this.pos;
while (this.pos < this.len && this.isIdentChar(this.text[this.pos])) {
this.pos++;
}
return { type: 0 /* TokenType.Identifier */, value: this.text.substring(startPos, this.pos), pos: startPos };
}
isDigit(ch) {
return ch >= "0" && ch <= "9";
}
isNumberStart(ch) {
if (this.isDigit(ch)) {
return true;
}
if ((ch === "-" || ch === "+") && this.pos + 1 < this.len) {
return this.isDigit(this.text[this.pos + 1]) || this.text[this.pos + 1] === ".";
}
return false;
}
isIdentStart(ch) {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
}
isIdentChar(ch) {
return this.isIdentStart(ch) || this.isDigit(ch) || ch === "|";
}
}
// ── Node Parsing ───────────────────────────────────────────────────────────────
function parseNodeFromTokens(tokenizer) {
const nameTok = tokenizer.peek();
if (nameTok.type === 4 /* TokenType.CloseBrace */ || nameTok.type === 8 /* TokenType.EOF */) {
return null;
}
// Node name
const identTok = tokenizer.next();
if (identTok.type !== 0 /* TokenType.Identifier */) {
throw new Error(`Expected identifier for node name, got '${identTok.value}' at pos ${identTok.pos}`);
}
const name = identTok.value;
tokenizer.expect(5 /* TokenType.Colon */);
// Parse properties until we hit '{' or end-of-line content
const properties = [];
const children = [];
// Check for array shorthand: *count { a: ... }
let peek = tokenizer.peek();
if (peek.type === 7 /* TokenType.Star */) {
// Array node like "Vertices: *25959 {"
tokenizer.next(); // consume *
const countTok = tokenizer.expect(1 /* TokenType.Number */);
const count = parseInt(countTok.value);
tokenizer.expect(3 /* TokenType.OpenBrace */);
// Expect "a:" followed by comma-separated values
const aTok = tokenizer.next();
if (aTok.type === 0 /* TokenType.Identifier */ && aTok.value === "a") {
tokenizer.expect(5 /* TokenType.Colon */);
const values = parseArrayValues(tokenizer, count);
properties.push({ type: "float64[]", value: new Float64Array(values) });
}
tokenizer.expect(4 /* TokenType.CloseBrace */);
return { name, properties, children };
}
// Parse inline properties (comma-separated values on the same logical line)
// Values can be: numbers, strings, or bare identifiers (e.g. "T", "Y", "CullingOff")
peek = tokenizer.peek();
while (peek.type !== 3 /* TokenType.OpenBrace */ && peek.type !== 4 /* TokenType.CloseBrace */ && peek.type !== 8 /* TokenType.EOF */) {
if (peek.type === 1 /* TokenType.Number */) {
const tok = tokenizer.next();
const numVal = parseNumericValue(tok.value);
if (Number.isInteger(numVal) && !tok.value.includes(".") && !tok.value.includes("e") && !tok.value.includes("E")) {
properties.push({ type: isInt32(numVal) ? "int32" : "int64", value: numVal });
}
else {
properties.push({ type: "float64", value: numVal });
}
}
else if (peek.type === 2 /* TokenType.String */) {
const tok = tokenizer.next();
properties.push({ type: "string", value: tok.value });
}
else if (peek.type === 0 /* TokenType.Identifier */) {
// Check if this is a property value or the start of a new child node.
// If the next non-whitespace after the identifier is ':', it's a child node name — stop.
if (tokenizer.isNextNodeStart()) {
break;
}
// Bare identifier as a property value (e.g. "T", "Y", "CullingOff")
const tok = tokenizer.next();
properties.push({ type: "string", value: tok.value });
}
else if (peek.type === 6 /* TokenType.Comma */) {
tokenizer.next(); // consume comma
}
else {
break;
}
peek = tokenizer.peek();
}
// Check for block body { ... }
peek = tokenizer.peek();
if (peek.type === 3 /* TokenType.OpenBrace */) {
tokenizer.next(); // consume '{'
// Parse child nodes
while (true) {
peek = tokenizer.peek();
if (peek.type === 4 /* TokenType.CloseBrace */ || peek.type === 8 /* TokenType.EOF */) {
break;
}
const child = parseNodeFromTokens(tokenizer);
if (child) {
children.push(child);
}
else {
break;
}
}
tokenizer.expect(4 /* TokenType.CloseBrace */);
}
return { name, properties, children };
}
function parseArrayValues(tokenizer, count) {
const values = [];
while (true) {
const peek = tokenizer.peek();
if (peek.type === 4 /* TokenType.CloseBrace */ || peek.type === 8 /* TokenType.EOF */) {
break;
}
if (peek.type === 6 /* TokenType.Comma */) {
tokenizer.next();
continue;
}
if (peek.type === 1 /* TokenType.Number */) {
const tok = tokenizer.next();
values.push(Number(tok.value));
}
else {
break;
}
}
if (values.length !== count) {
throw new Error(`ASCII FBX array declared ${count} values but parsed ${values.length}`);
}
return values;
}
function parseNumericValue(str) {
return Number(str);
}
function isInt32(value) {
return value >= -2147483648 && value <= 2147483647;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Intermediate representation for parsed FBX data.
* Both binary and ASCII parsers produce this same structure.
*/
/** Helper to find a child node by name */
function findChildByName(node, name) {
return node.children.find((c) => c.name === name);
}
/** Helper to find all children with a given name */
function findChildrenByName(node, name) {
return node.children.filter((c) => c.name === name);
}
/** Helper to find a top-level node in a document */
function findDocumentNode(doc, name) {
return doc.nodes.find((n) => n.name === name);
}
/** Extract a property value by index, with type narrowing */
function getPropertyValue(node, index) {
if (index < node.properties.length) {
return node.properties[index].value;
}
return undefined;
}
/**
* Converts an FBX object ID value to a safe JavaScript number.
* @param value - Parsed FBX object ID value
* @returns The object ID, or undefined when the value is not numeric
*/
function getSafeFBXObjectId(value) {
if (typeof value !== "number") {
return undefined;
}
if (!Number.isSafeInteger(value)) {
throw new Error(`Unsafe FBX object ID ${value.toString()}: object IDs must be safe integers.`);
}
return value;
}
/**
* Clean FBX object names.
* FBX names may contain:
* - A "Class::" prefix (e.g. "Model::valkyrie_mesh") — strip it
* - A binary null/control-character class suffix — strip it
*/
function cleanFBXName(fbxName) {
// Strip \x00\x01 suffix (binary FBX name/class separator)
const nullIdx = fbxName.indexOf("\0");
if (nullIdx >= 0) {
fbxName = fbxName.substring(0, nullIdx);
}
// Strip "ClassName::" prefix (ASCII FBX)
const colonIdx = fbxName.indexOf("::");
if (colonIdx >= 0) {
fbxName = fbxName.substring(colonIdx + 2);
}
return fbxName;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Build a connection graph from a parsed FBX document.
* Maps object IDs to their FBXNode and resolves parent-child relationships.
*/
function resolveConnections(doc) {
const objects = new Map();
const objectEntries = [];
const childrenOf = new Map();
const parentOf = new Map();
const connections = [];
const connectionEntries = [];
const diagnostics = [];
const legacyIds = new Map();
const syntheticLegacyIds = new Map();
let nextLegacyId = -1;
const getLegacyId = (name) => {
let id = legacyIds.get(name);
if (id === undefined) {
id = nextLegacyId--;
legacyIds.set(name, id);
}
return id;
};
const getSyntheticLegacyId = (role, name) => {
let idsByName = syntheticLegacyIds.get(role);
if (!idsByName) {
idsByName = new Map();
syntheticLegacyIds.set(role, idsByName);
}
let id = idsByName.get(name);
if (id === undefined) {
id = nextLegacyId--;
idsByName.set(name, id);
}
return id;
};
// Build object map from Objects section
const objectsNode = findDocumentNode(doc, "Objects");
if (objectsNode) {
for (const obj of objectsNode.children) {
const idProp = obj.properties[0];
if (idProp) {
const id = toObjectNumber(idProp.value);
if (id !== undefined) {
objects.set(id, obj);
objectEntries.push({ id, node: obj, source: "Objects", synthetic: false });
}
else if (typeof idProp.value === "string") {
const legacyName = cleanFBXName(idProp.value);
const id = getLegacyId(legacyName);
const normalized = normalizeLegacyObject(obj, id);
objects.set(id, normalized);
objectEntries.push({ id, node: normalized, source: "Objects", legacyName, synthetic: false });
if (obj.name === "Model" && getPropertyValue(obj, 1) === "Mesh") {
const geometryId = getSyntheticLegacyId("Geometry", legacyName);
const geometry = createLegacyGeometry(obj, geometryId);
objects.set(geometryId, geometry);
objectEntries.push({ id: geometryId, node: geometry, source: "legacySyntheticGeometry", legacyName, synthetic: true });
addConnection(connections, childrenOf, parentOf, diagnostics, "OO", geometryId, id);
}
}
}
}
}
// Parse connections
const connectionsNode = findDocumentNode(doc, "Connections");
if (connectionsNode) {
for (const c of connectionsNode.children) {
if (c.name !== "C" && c.name !== "Connect") {
continue;
}
const connectionIndex = connectionEntries.length;
const type = getPropertyValue(c, 0);
const childIdRaw = c.properties[1]?.value;
const parentIdRaw = c.properties[2]?.value;
const entry = {
source: c.name,
rawType: type,
accepted: false,
};
connectionEntries.push(entry);
if (type !== "OO" && type !== "OP") {
const childId = childIdRaw === undefined ? undefined : toObjectId(childIdRaw, legacyIds);
const parentId = parentIdRaw === undefined ? undefined : toObjectId(parentIdRaw, legacyIds);
diagnostics.push({
reason: "unsupported-connection-type",
message: `Unsupported FBX connection type '${type ?? ""}' was not added to the graph.`,
connectionIndex,
type,
childId,
parentId,
});
continue;
}
if (childIdRaw === undefined || parentIdRaw === undefined) {
diagnostics.push({
reason: "missing-connection-endpoint",
message: "FBX connection is missing a child or parent endpoint.",
connectionIndex,
type,
});
continue;
}
const childId = toObjectId(childIdRaw, legacyIds);
const parentId = toObjectId(parentIdRaw, legacyIds);
if (childId === undefined || parentId === undefined) {
diagnostics.push({
reason: "unresolved-legacy-endpoint",
message: "FBX connection references a legacy string endpoint that is not present in the object table.",
connectionIndex,
type,
});
continue;
}
const propertyName = type === "OP" && c.properties.length > 3 ? getPropertyValue(c, 3) : undefined;
entry.childId = childId;
entry.parentId = parentId;
entry.propertyName = propertyName;
if (childId === parentId) {
diagnostics.push({
reason: "self-loop",
message: "FBX connection references the same object as child and parent.",
connectionIndex,
type,
childId,
parentId,
propertyName,
});
}
if (!objects.has(childId)) {
diagnostics.push({
reason: "unresolved-object-reference",
message: "FBX connection child ID is not present in the object table.",
connectionIndex,
type,
childId,
parentId,
propertyName,
});
}
if (parentId !== 0 && !objects.has(parentId)) {
diagnostics.push({
reason: "unresolved-object-reference",
message: "FBX connection parent ID is not present in the object table.",
connectionIndex,
type,
childId,
parentId,
propertyName,
});
}
addConnection(connections, childrenOf, parentOf, diagnostics, type, childId, parentId, propertyName, connectionIndex);
entry.accepted = true;
}
}
return { objects, objectEntries, childrenOf, parentOf, connections, connectionEntries, diagnostics };
}
/** Get all child objects of a given parent ID, optionally filtered by node name */
function getChildren(map, parentId, nodeName) {
const children = map.childrenOf.get(parentId) ?? [];
const result = [];
for (const child of children) {
const node = map.objects.get(child.id);
if (node && (!nodeName || node.name === nodeName)) {
result.push({ id: child.id, node, propertyName: child.propertyName });
}
}
return result;
}
function toObjectNumber(value) {
return getSafeFBXObjectId(value);
}
function toObjectId(value, legacyIds) {
const numericId = toObjectNumber(value);
if (numericId !== undefined) {
return numericId;
}
if (typeof value !== "string") {
return undefined;
}
const legacyName = cleanFBXName(value);
if (legacyName === "Scene") {
return 0;
}
return legacyIds.get(legacyName);
}
function addConnection(connections, childrenOf, parentOf, diagnostics, type, childId, parentId, propertyName, connectionIndex) {
connections.push({ type, childId, parentId, propertyName });
if (!childrenOf.has(parentId)) {
childrenOf.set(parentId, []);
}
childrenOf.get(parentId).push({ id: childId, propertyName });
const existingParent = parentOf.get(childId);
if (existingParent) {
diagnostics.push({
reason: "duplicate-parent",
message: "FBX object has multiple parents; preserving the existing last-parent behavior.",
connectionIndex,
type,
childId,
parentId,
propertyName,
});
}
parentOf.set(childId, { id: parentId, propertyName });
}
function normalizeLegacyObject(node, id) {
const name = cleanFBXName(getPropertyValue(node, 0) ?? node.name);
const subType = getPropertyValue(node, 1) ?? "";
return {
...node,
properties: [
{ type: "int64", value: id },
{ type: "string", value: name },
{ type: "string", value: subType },
],
};
}
function createLegacyGeometry(modelNode, geometryId) {
const name = cleanFBXName(getPropertyValue(modelNode, 0) ?? "Geometry");
return {
name: "Geometry",
properties: [
{ type: "int64", value: geometryId },
{ type: "string", value: name },
{ type: "string", value: "Mesh" },
],
children: modelNode.children,
};
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Extract geometry data from an FBX Geometry node.
* Handles polygon triangulation and layer element expansion.
*/
function extractGeometry(geometryNode, nodeId) {
const name = cleanFBXName(getPropertyValue(geometryNode, 1) ?? "Geometry");
// Extract raw vertices
const verticesNode = findChildByName(geometryNode, "Vertices");
if (!verticesNode) {
throw new Error(`Geometry '${name}' has no Vertices node`);
}
const rawPositions = toFloat64Array$1(getNodeArrayValue(verticesNode));
// Extract polygon vertex indices
const pviNode = findChildByName(geometryNode, "PolygonVertexIndex");
if (!pviNode) {
throw new Error(`Geometry '${name}' has no PolygonVertexIndex node`);
}
const rawIndices = toInt32Array$2(getNodeArrayValue(pviNode));
const diagnostics = [];
// Parse polygons from the FBX negative-index convention
const polygons = parsePolygons(rawIndices);
// Triangulate polygons while preserving polygon-vertex indices for layer data.
const triangles = triangulatePolygons(polygons, rawPositions, diagnostics);
// Build the list of polygon-vertex pairs for layer element expansion
const polyVertexList = buildPolygonVertexList(polygons);
// Extract normals
const normalNode = findChildByName(geometryNode, "LayerElementNormal");
let normals = null;
if (normalNode) {
normals = expandLayerElement(normalNode, "Normals", "NormalsIndex", polyVertexList, rawPositions.length / 3, 3, diagnostics);
}
// Extract all UV sets
const uvNodes = findChildrenByName(geometryNode, "LayerElementUV");
const uvSets = [];
for (const uvNode of uvNodes) {
const nameNode = findChildByName(uvNode, "Name");
const setName = nameNode ? (getPropertyValue(nameNode, 0) ?? `UVSet${uvSets.length}`) : `UVSet${uvSets.length}`;
const data = expandLayerElement(uvNode, "UV", "UVIndex", polyVertexList, rawPositions.length / 3, 2, diagnostics);
if (data) {
uvSets.push({ name: setName, data });
}
}
const uvs = uvSets.length > 0 ? uvSets[0].data : null;
// Extract vertex colors
const colorNode = findChildByName(geometryNode, "LayerElementColor");
let colors = null;
if (colorNode) {
const colorData = expandLayerElement(colorNode, "Colors", "ColorIndex", polyVertexList, rawPositions.length / 3, 4, diagnostics);
if (colorData) {
colors = new Float32Array(colorData.length);
for (let i = 0; i < colorData.length; i++) {
colors[i] = colorData[i];
}
}
}
const tangentNode = findChildByName(geometryNode, "LayerElementTangent");
const binormalNode = findChildByName(geometryNode, "LayerElementBinormal");
const binormals = binormalNode ? expandLayerElement(binormalNode, "Binormals", "BinormalsIndex", polyVertexList, rawPositions.length / 3, 3, diagnostics) : null;
const tangents = tangentNode ? expandTangentLayer(tangentNode, polyVertexList, rawPositions.length / 3, normals, binormals, diagnostics) : null;
// Extract per-polygon material indices
const matNode = findChildByName(geometryNode, "LayerElementMaterial");
let polyMaterialIndices = null;
if (matNode) {
polyMaterialIndices = extractMaterialIndices(matNode, polygons.length);
}
// Build final indexed mesh with expanded per-triangle-vertex attributes
const result = buildTriangleMesh(rawPositions, triangles, polyVertexList, normals, uvs, uvSets, colors, tangents, binormals);
// Expand per-polygon material indices to per-triangle
let materialIndices = null;
if (polyMaterialIndices) {
// Check if all polygons use the same material (optimization)
let allSame = true;
const firstMat = polyMaterialIndices[0];
for (let i = 1; i < polyMaterialIndices.length; i++) {
if (polyMaterialIndices[i] !== firstMat) {
allSame = false;
break;
}
}
if (!allSame || firstMat !== 0) {
const triCount = result.indices.length / 3;
materialIndices = new Int32Array(triCount);
for (let ti = 0; ti < triangles.length; ti++) {
materialIndices[ti] = polyMaterialIndices[triangles[ti].polyIndex] ?? 0;
}
}
}
return {
id: nodeId,
name,
positions: result.positions,
indices: result.indices,
normals: result.normals,
uvs: result.uvs,
uvSets: result.uvSets,
colors: result.colors,
tangents: result.tangents,
binormals: result.binormals,
controlPointIndices: result.controlPointIndices,
materialIndices,
diagnostics,
};
}
function parsePolygons(rawIndices) {
const polygons = [];
let currentPoly = [];
let startIndex = 0;
for (let i = 0; i < rawIndices.length; i++) {
const idx = rawIndices[i];
if (idx < 0) {
// End of polygon: actual index is -(idx + 1)
currentPoly.push(-(idx + 1));
polygons.push({ indices: currentPoly, startIndex });
currentPoly = [];
startIndex = i + 1;
}
else {
currentPoly.push(idx);
}
}
return polygons;
}
function triangulatePolygons(polygons, rawPositions, diagnostics) {
const triangles = [];
for (let polyIndex = 0; polyIndex < polygons.length; polyIndex++) {
const poly = polygons[polyIndex];
triangles.push(...triangulatePolygon(poly, polyIndex, rawPositions, diagnostics));
}
return triangles;
}
function triangulatePolygon(poly, polyIndex, rawPositions, diagnostics) {
if (poly.indices.length < 3) {
diagnostics.push({
type: "degenerate-polygon",
message: `Polygon ${polyIndex} has fewer than three vertices.`,
polygonIndex: polyIndex,
});
return [];
}
if (poly.indices.length === 3) {
return [{ vertices: [poly.startIndex, poly.startIndex + 1, poly.startIndex + 2], polyIndex }];
}
const projected = projectPolygonTo2D(poly, rawPositions);
if (!projected) {
diagnostics.push({
type: "degenerate-polygon",
message: `Polygon ${polyIndex} has a near-zero normal; using fan triangulation.`,
polygonIndex: polyIndex,
});
return fanTriangulate(poly, polyIndex);
}
const polygonArea = signedArea2D(projected);
if (Math.abs(polygonArea) < 1e-12) {
diagnostics.push({
type: "degenerate-polygon",
message: `Polygon ${polyIndex} projects to near-zero area; using fan triangulation.`,
polygonIndex: polyIndex,
});
return fanTriangulate(poly, polyIndex);
}
const isCCW = polygonArea > 0;
const remaining = poly.indices.map((_, i) => i);
const clipped = [];
let guard = 0;
while (remaining.length > 3 && guard++ < poly.indices.length * poly.indices.length) {
let clippedEar = false;
for (let i = 0; i < remaining.length; i++) {
const prev = remaining[(i + remaining.length - 1) % remaining.length];
const curr = remaining[i];
const next = remaining[(i + 1) % remaining.length];
if (!isConvex(projected[prev], projected[curr], projected[next], isCCW)) {
continue;
}
if (containsAnyPoint(projected, remaining, prev, curr, next)) {
continue;
}
clipped.push({
vertices: [poly.startIndex + prev, poly.startIndex + curr, poly.startIndex + next],
polyIndex,
});
remaining.splice(i, 1);
clippedEar = true;
break;
}
if (!clippedEar) {
diagnostics.push({
type: "triangulation-fallback",
message: `Polygon ${polyIndex} could not be fully ear-clipped; using fan triangulation.`,
polygonIndex: polyIndex,
});
return fanTriangulate(poly, polyIndex);
}
}
clipped.push({
vertices: [poly.startIndex + remaining[0], poly.startIndex + remaining[1], poly.startIndex + remaining[2]],
polyIndex,
});
return clipped;
}
function fanTriangulate(poly, polyIndex) {
const triangles = [];
for (let i = 1; i < poly.indices.length - 1; i++) {
triangles.push({
vertices: [poly.startIndex, poly.startIndex + i, poly.startIndex + i + 1],
polyIndex,
});
}
return triangles;
}
function projectPolygonTo2D(poly, rawPositions) {
const normal = computeNewellNormal(poly, rawPositions);
const ax = Math.abs(normal[0]);
const ay = Math.abs(normal[1]);
const az = Math.abs(normal[2]);
if (ax + ay + az < 1e-12) {
return null;
}
const dropAxis = ax > ay && ax > az ? 0 : ay > az ? 1 : 2;
return poly.indices.map((cp) => {
const x = rawPositions[cp * 3];
const y = rawPositions[cp * 3 + 1];
const z = rawPositions[cp * 3 + 2];
if (dropAxis === 0) {
return normal[0] >= 0 ? [y, z] : [z, y];
}
if (dropAxis === 1) {
return normal[1] >= 0 ? [z, x] : [x, z];
}
return normal[2] >= 0 ? [x, y] : [y, x];
});
}
function computeNewellNormal(poly, rawPositions) {
let nx = 0;
let ny = 0;
let nz = 0;
for (let i = 0; i < poly.indices.length; i++) {
const current = poly.indices[i] * 3;
const next = poly.indices[(i + 1) % poly.indices.length] * 3;
const x0 = rawPositions[current];
const y0 = rawPositions[current + 1];
const z0 = rawPositions[current + 2];
const x1 = rawPositions[next];
const y1 = rawPositions[next + 1];
const z1 = rawPositions[next + 2];
nx += (y0 - y1) * (z0 + z1);
ny += (z0 - z1) * (x0 + x1);
nz += (x0 - x1) * (y0 + y1);
}
return [nx, ny, nz];
}
function signedArea2D(points) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
area += a[0] * b[1] - b[0] * a[1];
}
return area / 2;
}
function isConvex(a, b, c, isCCW) {
const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
return isCCW ? cross > 1e-12 : cross < -1e-12;
}
function containsAnyPoint(points, remaining, prev, curr, next) {
for (const index of remaining) {
if (index === prev || index === curr || index === next) {
continue;
}
if (pointInTriangle(points[index], points[prev], points[curr], points[next])) {
return true;
}
}
return false;
}
function pointInTriangle(p, a, b, c) {
const area = Math.abs(cross2D(a, b, c));
const area1 = Math.abs(cross2D(p, a, b));
const area2 = Math.abs(cross2D(p, b, c));
const area3 = Math.abs(cross2D(p, c, a));
return Math.abs(area - (area1 + area2 + area3)) < 1e-10;
}
function cross2D(a, b, c) {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
function buildPolygonVertexList(polygons) {
const list = [];
for (let pi = 0; pi < polygons.length; pi++) {
const poly = polygons[pi];
for (let vi = 0; vi < poly.indices.length; vi++) {
list.push({
polyIndex: pi,
vertexInPoly: vi,
controlPointIndex: poly.indices[vi],
globalIndex: poly.startIndex + vi,
});
}
}
return list;
}
// ── Layer Element Expansion ────────────────────────────────────────────────────
/**
* Extract per-polygon material indices from LayerElementMaterial.
* Returns an Int32Array with one material index per polygon.
*/
function extractMaterialIndices(matNode, polygonCount) {
const mappingNode = findChildByName(matNode, "MappingInformationType");
const referenceNode = findChildByName(matNode, "ReferenceInformationType");
if (!mappingNode || !referenceNode) {
return null;
}
const mapping = getPropertyValue(mappingNode, 0) ?? "";
const reference = getPropertyValue(referenceNode, 0) ?? "";
if (mapping === "AllSame") {
const materialsNode = findChildByName(matNode, "Materials");
const rawIndices = materialsNode ? toInt32Array$2(getNodeArrayValue(materialsNode)) : null;
const materialIndex = rawIndices && rawIndices.length > 0 ? rawIndices[0] : 0;
const indices = new Int32Array(polygonCount);
if (materialIndex !== 0) {
indices.fill(materialIndex);
}
return indices;
}
if (mapping === "ByPolygon") {
const materialsNode = findChildByName(matNode, "Materials");
if (!materialsNode) {
return null;
}
const rawIndices = toInt32Array$2(getNodeArrayValue(materialsNode));
// For Direct reference, the Materials array has one index per polygon
if (reference === "Direct" || reference === "IndexToDirect") {
return rawIndices;
}
}
return null;
}
function expandLayerElement(layerNode, dataChildName, indexChildName, polyVertexList, controlPointCount, stride, diagnostics) {
const mappingNode = findChildByName(layerNode, "MappingInformationType");
const referenceNode = findChildByName(layerNode, "ReferenceInformationType");
if (!mappingNode || !referenceNode) {
return null;
}
const mapping = getPropertyValue(mappingNode, 0) ?? "";
const reference = getPropertyValue(referenceNode, 0) ?? "";
const dataNode = findChildByName(layerNode, dataChildName);
if (!dataNode) {
return null;
}
const data = toFloat64Array$1(getNodeArrayValue(dataNode));
let indexData = null;
if (reference === "IndexToDirect") {
const indexNode = findChildByName(layerNode, indexChildName);
if (indexNode) {
indexData = toInt32Array$2(getNodeArrayValue(indexNode));
}
}
// Expand to per-polygon-vertex
const result = new Float64Array(polyVertexList.length * stride);
for (let i = 0; i < polyVertexList.length; i++) {
const pv = polyVertexList[i];
let dataIndex;
if (mapping === "ByPolygonVertex") {
if (reference === "IndexToDirect" && indexData) {
dataIndex = indexData[pv.globalIndex];
}
else {
// Direct
dataIndex = pv.globalIndex;
}
}
else if (mapping === "ByControlPoint" || mapping === "ByVertice") {
if (reference === "IndexToDirect" && indexData) {
dataIndex = indexData[pv.controlPointIndex];
}
else {
dataIndex = pv.controlPointIndex;
}
}
else if (mapping === "ByPolygon") {
if (reference === "IndexToDirect" && indexData) {
dataIndex = indexData[pv.polyIndex];
}
else {
dataIndex = pv.polyIndex;
}
}
else if (mapping === "AllSame") {
dataIndex = 0;
}
else {
dataIndex = pv.globalIndex;
}
for (let s = 0; s < stride; s++) {
const sourceIndex = dataIndex * stride + s;
if (dataIndex < 0 || sourceIndex >= data.length) {
diagnostics.push({
type: sourceIndex >= data.length ? "layer-data-too-short" : "layer-index-out-of-bounds",
message: `Layer '${layerNode.name}' references unavailable element ${dataIndex}.`,
layerName: layerNode.name,
index: dataIndex,
});
result[i * stride + s] = 0;
}
else {
result[i * stride + s] = data[sourceIndex];
}
}
}
return result;
}
function expandTangentLayer(tangentNode, polyVertexList, controlPointCount, normals, binormals, diagnostics) {
const sourceStride = inferLayerElementStride(tangentNode, "Tangents", "TangentsIndex", polyVertexList, controlPointCount, diagnostics);
const expanded = expandLayerElement(tangentNode, "Tangents", "TangentsIndex", polyVertexList, controlPointCount, sourceStride, diagnostics);
if (!expanded) {
return null;
}
const tangents = new Float64Array(polyVertexList.length * 4);
for (let i = 0; i < polyVertexList.length; i++) {
const sourceOffset = i * sourceStride;
const destOffset = i * 4;
tangents[destOffset] = expanded[sourceOffset];
tangents[destOffset + 1] = expanded[sourceOffset + 1];
tangents[destOffset + 2] = expanded[sourceOffset + 2];
tangents[destOffset + 3] = sourceStride >= 4 ? expanded[sourceOffset + 3] : computeTangentHandedness$1(i, tangents, normals, binormals);
}
return tangents;
}
function inferLayerElementStride(layerNode, dataChildName, indexChildName, polyVertexList, controlPointCount, diagnostics) {
const dataNode = findChildByName(layerNode, dataChildName);
if (!dataNode) {
return 3;
}
const data = toFloat64Array$1(getNodeArrayValue(dataNode));
const mapping = getPropertyValue(findChildByName(layerNode, "MappingInformationType") ?? { properties: []}, 0) ?? "";
const reference = getPropertyValue(findChildByName(layerNode, "ReferenceInformationType") ?? { properties: []}, 0) ?? "";
const indexNode = findChildByName(layerNode, indexChildName);
const indexData = indexNode ? toInt32Array$2(getNodeArrayValue(indexNode)) : null;
const directCount = reference === "IndexToDirect" && indexData
? Math.max(...Array.from(indexData), 0) + 1
: mapping === "ByControlPoint" || mapping === "ByVertice"
? controlPointCount
: mapping === "AllSame"
? 1
: polyVertexList.length;
if (directCount > 0 && data.length % directCount === 0) {
const stride = data.length / directCount;
if (stride === 3 || stride === 4) {
return stride;
}
}
diagnostics.push({
type: "layer-data-too-short",
message: `Could not infer stride for layer '${layerNode.name}', defaulting to 3.`,
layerName: layerNode.name,
});
return 3;
}
function computeTangentHandedness$1(vertexIndex, tangents, normals, binormals) {
if (!normals || !binormals) {
return 1;
}
const to = vertexIndex * 4;
const no = vertexIndex * 3;
const nx = normals[no];
const ny = normals[no + 1];
const nz = normals[no + 2];
const tx = tangents[to];
const ty = tangents[to + 1];
const tz = tangents[to + 2];
const bx = binormals[no];
const by = binormals[no + 1];
const bz = binormals[no + 2];
const cx = ny * tz - nz * ty;
const cy = nz * tx - nx * tz;
const cz = nx * ty - ny * tx;
return cx * bx + cy * by + cz * bz < 0 ? -1 : 1;
}
/**
* Build the final triangle mesh. Since normals/UVs are per-polygon-vertex,
* we need to create unique vertices for each polygon-vertex combination.
*/
function buildTriangleMesh(rawPositions, triangles, polyVertexList, expandedNormals, expandedUVs, expandedUVSets, expandedColors, expandedTangents, expandedBinormals) {
// Each polygon-vertex becomes a unique vertex in the output
const vertexCount = polyVertexList.length;
const positions = new Float64Array(vertexCount * 3);
const controlPointIndices = new Uint32Array(vertexCount);
// Copy positions — keep in original RH space (root node handles RH→LH conversion)
for (let i = 0; i < polyVertexList.length; i++) {
const cp = polyVertexList[i].controlPointIndex;
positions[i * 3] = rawPositions[cp * 3];
positions[i * 3 + 1] = rawPositions[cp * 3 + 1];
positions[i * 3 + 2] = rawPositions[cp * 3 + 2];
controlPointIndices[i] = cp;
}
// Keep original winding order — Z negation handles handedness
const indexCount = triangles.length * 3;
const indices = new Uint32Array(indexCount);
for (let i = 0; i < triangles.length; i++) {
indices[i * 3] = triangles[i].vertices[0];
indices[i * 3 + 1] = triangles[i].vertices[1];
indices[i * 3 + 2] = triangles[i].vertices[2];
}
return {
positions,
indices,
normals: expandedNormals,
uvs: expandedUVs,
uvSets: expandedUVSets,
colors: expandedColors,
tangents: expandedTangents,
binormals: expandedBinormals,
controlPointIndices,
};
}
// ── Utilities ──────────────────────────────────────────────────────────────────
function toFloat64Array$1(value) {
if (value instanceof Float64Array) {
return value;
}
if (value instanceof Float32Array) {
return new Float64Array(value);
}
if (value instanceof Int32Array) {
return new Float64Array(value);
}
if (Array.isArray(value)) {
const result = new Float64Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = Number(value[i]);
}
return result;
}
throw new Error(`Cannot convert ${typeof value} to Float64Array`);
}
function toInt32Array$2(value) {
if (value instanceof Int32Array) {
return value;
}
if (value instanceof Float64Array) {
const result = new Int32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = Math.round(value[i]);
}
return result;
}
if (value instanceof Float32Array) {
const result = new Int32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = Math.round(value[i]);
}
return result;
}
if (Array.isArray(value)) {
const result = new Int32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = Math.round(Number(value[i]));
}
return result;
}
throw new Error(`Cannot convert ${typeof value} to Int32Array`);
}
function getNodeArrayValue(node) {
if (node.properties.length === 1) {
return node.properties[0].value;
}
return node.properties.map((property) => property.value);
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
function extractPropertyTemplates(doc) {
const templates = new Map();
const definitions = findDocumentNode(doc, "Definitions");
if (!definitions) {
return templates;
}
for (const objectTypeNode of definitions.children) {
if (objectTypeNode.name !== "ObjectType") {
continue;
}
const objectType = getPropertyValue(objectTypeNode, 0);
if (!objectType) {
continue;
}
for (const templateNode of objectTypeNode.children) {
if (templateNode.name !== "PropertyTemplate") {
continue;
}
const templateName = getPropertyValue(templateNode, 0);
if (!templateName) {
continue;
}
const template = extractPropertyTemplate(objectType, templateName, templateNode);
let templatesByName = templates.get(objectType);
if (!templatesByName) {
templatesByName = new Map();
templates.set(objectType, templatesByName);
}
templatesByName.set(templateName, template);
}
}
return templates;
}
function getPropertyTemplate(templates, objectType, templateName) {
const templatesByName = templates.get(objectType);
if (!templatesByName) {
return undefined;
}
if (templateName) {
return templatesByName.get(templateName);
}
return templatesByName.values().next().value;
}
function resolvePropertyValue(node, template, propertyName, valueIndex = 0) {
return resolvePropertyValues(node, template, propertyName)?.[valueIndex];
}
function resolveNumberProperty(node, template, propertyName, fallback) {
return toNumber$4(resolvePropertyValue(node, template, propertyName)) ?? fallback;
}
function resolveVector3Property(node, template, propertyName, fallback) {
const values = resolvePropertyValues(node, template, propertyName);
if (!values) {
return fallback;
}
const x = toNumber$4(values[0]);
const y = toNumber$4(values[1]);
const z = toNumber$4(values[2]);
return x !== undefined && y !== undefined && z !== undefined ? [x, y, z] : fallback;
}
function resolvePropertyValues(node, template, propertyName) {
return findLocalPropertyValues(node, propertyName) ?? template?.properties.get(propertyName)?.values;
}
function toNumber$4(value) {
if (typeof value === "number") {
return value;
}
return undefined;
}
function extractPropertyTemplate(objectType, templateName, templateNode) {
const properties = new Map();
const properties70 = findChildByName(templateNode, "Properties70");
for (const propertyNode of properties70?.children ?? []) {
if (propertyNode.name !== "P") {
continue;
}
const property = extractPropertyNode(propertyNode);
if (property) {
properties.set(property.name, property);
}
}
return { objectType, templateName, properties };
}
function findLocalPropertyValues(node, propertyName) {
const propertyContainers = [findChildByName(node, "Properties70"), findChildByName(node, "Properties60")].filter((child) => child !== undefined);
for (const container of propertyContainers) {
for (const propertyNode of container.children) {
if (propertyNode.name !== "P" && propertyNode.name !== "Property") {
continue;
}
if (getPropertyValue(propertyNode, 0) !== propertyName) {
continue;
}
return propertyNode.properties.slice(propertyNode.name === "Property" ? 3 : 4).map((property) => property.value);
}
}
return undefined;
}
function extractPropertyNode(node) {
const name = getPropertyValue(node, 0);
if (!name) {
return null;
}
return {
name,
propertyType: getPropertyValue(node, 1) ?? "",
label: getPropertyValue(node, 2) ?? "",
flags: getPropertyValue(node, 3) ?? "",
values: node.properties.slice(4).map((property) => property.value),
};
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Extract material data from an FBX Material node.
*/
function extractMaterial(materialNode, materialId, objectMap, templates) {
const name = cleanFBXName(getPropertyValue(materialNode, 1) ?? "Material");
const template = getMaterialTemplate(materialNode, templates);
// Determine Lambert vs Phong from ShadingModel property
const shadingModel = findChildByName(materialNode, "ShadingModel");
const shadingType = shadingModel
? (getPropertyValue(shadingModel, 0) ?? "Lambert")
: (resolvePropertyValue(materialNode, template, "ShadingModel") ?? "Lambert");
const type = shadingType.toLowerCase() === "phong" ? "Phong" : "Lambert";
// Extract properties from Properties70
const properties = extractMaterialProperties(materialNode, template);
// Find connected textures
const textureTemplate = templates ? (getPropertyTemplate(templates, "Texture", "FbxFileTexture") ?? getPropertyTemplate(templates, "Texture")) : undefined;
const textures = extractTextures(materialId, objectMap, textureTemplate);
return { id: materialId, name, type, properties, textures };
}
function extractMaterialProperties(materialNode, template) {
const props = {};
props.diffuseColor = getColorProperty(materialNode, template, "DiffuseColor") ?? getColorProperty(materialNode, template, "Diffuse");
props.diffuseFactor = getNumberProperty(materialNode, template, "DiffuseFactor");
props.ambientColor = getColorProperty(materialNode, template, "AmbientColor") ?? getColorProperty(materialNode, template, "Ambient");
props.ambientFactor = getNumberProperty(materialNode, template, "AmbientFactor");
props.specularColor = getColorProperty(materialNode, template, "SpecularColor") ?? getColorProperty(materialNode, template, "Specular");
props.specularFactor = getNumberProperty(materialNode, template, "SpecularFactor");
props.shininess = getNumberProperty(materialNode, template, "Shininess") ?? getNumberProperty(materialNode, template, "ShininessExponent");
props.emissiveColor = getColorProperty(materialNode, template, "EmissiveColor") ?? getColorProperty(materialNode, template, "Emissive");
props.emissiveFactor = getNumberProperty(materialNode, template, "EmissiveFactor");
props.opacity = getNumberProperty(materialNode, template, "Opacity");
props.transparencyFactor = getNumberProperty(materialNode, template, "TransparencyFactor");
return props;
}
function extractTextures(materialId, objectMap, template) {
const textures = [];
const textureChildren = getChildren(objectMap, materialId, "Texture");
for (const { id, node, propertyName } of textureChildren) {
const fileNameNode = findChildByName(node, "FileName");
const relFileNameNode = findChildByName(node, "RelativeFilename");
const fileName = fileNameNode ? (getPropertyValue(fileNameNode, 0) ?? "") : "";
const relativeFileName = relFileNameNode ? (getPropertyValue(relFileNameNode, 0) ?? "") : "";
// Extract UV transform properties
let uvTranslation;
let uvScaling;
const uvRotation = getNumberProperty(node, template, "UVRotation") ?? getNumberProperty(node, template, "Rotation");
let uvSetName;
uvTranslation = getTextureVector2(node, template, "UVTranslation") ?? getTextureVector2(node, template, "Translation");
uvScaling = getTextureVector2(node, template, "UVScaling") ?? getTextureVector2(node, template, "Scaling");
const uvSet = resolvePropertyValue(node, template, "UVSet");
if (uvSet && uvSet.length > 0) {
uvSetName = uvSet;
}
uvTranslation ??= getNumberPairChild(node, "ModelUVTranslation");
uvScaling ??= getNumberPairChild(node, "ModelUVScaling");
// Check for embedded texture data in connected Video node
let embeddedData = null;
const videoChildren = getChildren(objectMap, id, "Video");
for (const { node: videoNode } of videoChildren) {
const contentNode = findChildByName(videoNode, "Content");
if (contentNode && contentNode.properties.length > 0) {
const content = contentNode.properties[0].value;
if (content instanceof Uint8Array && content.length > 0) {
embeddedData = content;
}
else if (content instanceof ArrayBuffer && content.byteLength > 0) {
embeddedData = new Uint8Array(content);
}
}
}
textures.push({
propertyName: propertyName ?? "DiffuseColor",
fileName,
relativeFileName,
id,
embeddedData,
uvTranslation,
uvScaling,
uvRotation,
uvSetName,
});
}
return textures;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function getMaterialTemplate(materialNode, templates) {
if (!templates) {
return undefined;
}
const shadingModel = findChildByName(materialNode, "ShadingModel");
const shadingType = shadingModel ? getPropertyValue(shadingModel, 0) : undefined;
if (shadingType?.toLowerCase() === "phong") {
return getPropertyTemplate(templates, "Material", "FbxSurfacePhong") ?? getPropertyTemplate(templates, "Material");
}
if (shadingType?.toLowerCase() === "lambert") {
return getPropertyTemplate(templates, "Material", "FbxSurfaceLambert") ?? getPropertyTemplate(templates, "Material");
}
return getPropertyTemplate(templates, "Material");
}
function getColorProperty(node, template, propertyName) {
const values = resolvePropertyValues(node, template, propertyName);
if (!values || values.length < 3) {
return undefined;
}
const r = toNumber$3(values[0]);
const g = toNumber$3(values[1]);
const b = toNumber$3(values[2]);
if (r === undefined || g === undefined || b === undefined) {
return undefined;
}
return [r, g, b];
}
function getNumberProperty(node, template, propertyName) {
return toNumber$3(resolvePropertyValue(node, template, propertyName));
}
function getTextureVector2(node, template, propertyName) {
const values = resolvePropertyValues(node, template, propertyName);
if (!values) {
return undefined;
}
const u = toNumber$3(values[0]);
const v = toNumber$3(values[1]);
return u !== undefined && v !== undefined ? [u, v] : undefined;
}
function toNumber$3(value) {
if (typeof value === "number") {
return value;
}
return undefined;
}
function getNumberPairChild(node, childName) {
const child = findChildByName(node, childName);
if (!child) {
return undefined;
}
const u = toNumber$3(child.properties[0]?.value);
const v = toNumber$3(child.properties[1]?.value);
return u !== undefined && v !== undefined ? [u, v] : undefined;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
const MAX_BONE_INFLUENCES = 8;
/**
* Extract all skin deformers from the FBX scene.
* Returns skin data including bone hierarchy and vertex weights.
*/
function extractSkins(objectMap) {
const skins = [];
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name === "Deformer" && getPropertyValue(node, 2) === "Skin") {
const skin = extractSkin(id, node, objectMap);
if (skin) {
skins.push(skin);
}
}
}
return skins;
}
function extractSkin(skinId, _skinNode, objectMap) {
// Find the geometry this skin is attached to
// Skin is a child of the geometry in FBX connection graph
const skinParent = objectMap.parentOf.get(skinId);
if (!skinParent) {
return null;
}
const geometryId = skinParent.id;
const geometryNode = objectMap.objects.get(geometryId);
if (!geometryNode || geometryNode.name !== "Geometry") {
return null;
}
const modelParent = objectMap.parentOf.get(geometryId);
const modelParentNode = modelParent ? objectMap.objects.get(modelParent.id) : undefined;
const meshModelId = modelParentNode?.name === "Model" ? modelParent.id : undefined;
// Find all clusters (children of this skin)
const clusterEntries = getChildren(objectMap, skinId, "Deformer");
if (clusterEntries.length === 0) {
return null;
}
// For each cluster, find the connected bone Model
// Connection graph: BoneModel → Cluster (bone is child of cluster)
const boneModelMap = new Map();
for (const { id: clusterId, node: clusterNode } of clusterEntries) {
const subType = getPropertyValue(clusterNode, 2);
if (subType !== "Cluster") {
continue;
}
// The bone Model is a child of the Cluster
const boneChildren = getChildren(objectMap, clusterId, "Model");
if (boneChildren.length > 0) {
boneModelMap.set(boneChildren[0].id, { clusterId, clusterNode });
}
}
// Build bone hierarchy from Model parent-child relationships. Include
// skeleton-like ancestors even when they are not weighted clusters; some
// rigs (for example 3ds Max Biped) animate a non-cluster root above the
// clustered bones.
const bindPoseMatrices = extractBindPoseMatrices(geometryId, objectMap);
const skinDiagnostics = [];
const bones = buildBoneHierarchy(boneModelMap, bindPoseMatrices, objectMap, skinDiagnostics);
if (bones.length === 0) {
return null;
}
// Extract per-vertex weights from clusters
const { boneIndices, boneWeights } = extractVertexWeights(bones, boneModelMap);
return {
id: skinId,
geometryId,
meshBindPoseMatrix: meshModelId !== undefined ? (bindPoseMatrices.get(meshModelId) ?? null) : null,
bones,
boneIndices,
boneWeights,
diagnostics: skinDiagnostics,
};
}
/**
* Build a flat ordered bone list with parent indices from the FBX Model hierarchy.
*/
function buildBoneHierarchy(boneModelMap, bindPoseMatrices, objectMap, skinDiagnostics) {
const bones = [];
const visited = new Set();
const skeletonModelIds = collectSkeletonModelIds(boneModelMap, objectMap);
const parentByModelId = buildSkeletonParentMap(skeletonModelIds, objectMap);
const childrenByModelId = buildSkeletonChildrenMap(skeletonModelIds, parentByModelId);
const rootBoneIds = Array.from(skeletonModelIds).filter((modelId) => !parentByModelId.has(modelId));
// BFS to build ordered list
const queue = rootBoneIds.map((id) => ({
modelId: id,
parentIndex: -1,
}));
while (queue.length > 0) {
const { modelId, parentIndex } = queue.shift();
if (visited.has(modelId)) {
continue;
}
visited.add(modelId);
const modelNode = objectMap.objects.get(modelId);
if (!modelNode) {
continue;
}
const boneIndex = bones.length;
const clusterInfo = boneModelMap.get(modelId);
const transform = extractBoneTransform(modelNode);
const { bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, clusterMode } = clusterInfo
? extractClusterMatrices(clusterInfo.clusterNode)
: { bindPoseMatrix: null, transformLinkMatrix: null, transformAssociateModelMatrix: null, clusterMode: "Unknown" };
const diagnostics = createBoneDiagnostics(modelId, cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${boneIndex}`), clusterInfo !== undefined, clusterMode, bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, bindPoseMatrices.get(modelId) ?? null);
skinDiagnostics.push(...diagnostics);
bones.push({
modelId,
name: cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${boneIndex}`),
index: boneIndex,
parentIndex,
isCluster: clusterInfo !== undefined,
translation: transform.translation,
rotation: transform.rotation,
preRotation: transform.preRotation,
postRotation: transform.postRotation,
rotationPivot: transform.rotationPivot,
scalingPivot: transform.scalingPivot,
rotationOffset: transform.rotationOffset,
scalingOffset: transform.scalingOffset,
scale: transform.scale,
rotationOrder: transform.rotationOrder,
inheritType: transform.inheritType,
clusterMode,
bindPoseMatrix,
transformLinkMatrix,
transformAssociateModelMatrix,
modelBindPoseMatrix: bindPoseMatrices.get(modelId) ?? null,
diagnostics,
});
for (const childId of childrenByModelId.get(modelId) ?? []) {
if (!visited.has(childId)) {
queue.push({ modelId: childId, parentIndex: boneIndex });
}
}
}
return bones;
}
function extractBindPoseMatrices(geometryId, objectMap) {
const modelParent = objectMap.parentOf.get(geometryId);
const modelParentNode = modelParent ? objectMap.objects.get(modelParent.id) : undefined;
const modelId = modelParentNode?.name === "Model" ? modelParent.id : undefined;
if (modelId === undefined) {
return new Map();
}
for (const [, poseNode] of Array.from(objectMap.objects)) {
if (poseNode.name !== "Pose" || getPropertyValue(poseNode, 2) !== "BindPose") {
continue;
}
const matrices = new Map();
for (const poseChild of poseNode.children) {
if (poseChild.name !== "PoseNode") {
continue;
}
const nodeChild = findChildByName(poseChild, "Node");
const matrixChild = findChildByName(poseChild, "Matrix");
const nodeId = nodeChild?.properties[0]?.value;
const matrixValue = matrixChild?.properties[0]?.value;
if (typeof nodeId !== "number") {
continue;
}
const matrix = toFloat64Array(matrixValue);
if (matrix?.length === 16) {
matrices.set(nodeId, matrix);
}
}
if (matrices.has(modelId)) {
return matrices;
}
}
return new Map();
}
function buildSkeletonChildrenMap(skeletonModelIds, parentByModelId) {
const childrenByModelId = new Map();
for (const modelId of Array.from(skeletonModelIds)) {
const parentId = parentByModelId.get(modelId);
if (parentId === undefined) {
continue;
}
if (!childrenByModelId.has(parentId)) {
childrenByModelId.set(parentId, []);
}
childrenByModelId.get(parentId).push(modelId);
}
return childrenByModelId;
}
function collectSkeletonModelIds(boneModelMap, objectMap) {
const skeletonModelIds = new Set(Array.from(boneModelMap.keys()));
for (const modelId of Array.from(boneModelMap.keys())) {
let parentId = findModelParentId$1(modelId, objectMap);
while (parentId !== undefined) {
const parentNode = objectMap.objects.get(parentId);
if (!parentNode || parentNode.name !== "Model") {
break;
}
skeletonModelIds.add(parentId);
parentId = findModelParentId$1(parentId, objectMap);
}
}
return skeletonModelIds;
}
function buildSkeletonParentMap(skeletonModelIds, objectMap) {
const parentByModelId = new Map();
for (const modelId of Array.from(skeletonModelIds)) {
let parentId = findModelParentId$1(modelId, objectMap);
while (parentId !== undefined) {
if (skeletonModelIds.has(parentId)) {
parentByModelId.set(modelId, parentId);
break;
}
parentId = findModelParentId$1(parentId, objectMap);
}
}
return parentByModelId;
}
function findModelParentId$1(modelId, objectMap) {
const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model");
return parentConnection?.parentId;
}
function isSkeletonModel(modelNode) {
const subType = getPropertyValue(modelNode, 2);
return subType === "Root" || subType === "LimbNode";
}
function extractBoneTransform(modelNode) {
const translation = [0, 0, 0];
const rotation = [0, 0, 0];
const preRotation = [0, 0, 0];
const postRotation = [0, 0, 0];
const rotationPivot = [0, 0, 0];
const scalingPivot = [0, 0, 0];
const rotationOffset = [0, 0, 0];
const scalingOffset = [0, 0, 0];
const scale = [1, 1, 1];
let rotationOrder = 0;
let inheritType = 1;
const props70 = findChildByName(modelNode, "Properties70");
if (!props70) {
return { translation, rotation, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, scale, rotationOrder, inheritType };
}
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const propName = getPropertyValue(p, 0);
if (!propName) {
continue;
}
switch (propName) {
case "Lcl Translation":
translation[0] = toNumber$2(p.properties[4]?.value) ?? 0;
translation[1] = toNumber$2(p.properties[5]?.value) ?? 0;
translation[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "Lcl Rotation":
rotation[0] = toNumber$2(p.properties[4]?.value) ?? 0;
rotation[1] = toNumber$2(p.properties[5]?.value) ?? 0;
rotation[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "PreRotation":
preRotation[0] = toNumber$2(p.properties[4]?.value) ?? 0;
preRotation[1] = toNumber$2(p.properties[5]?.value) ?? 0;
preRotation[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "PostRotation":
postRotation[0] = toNumber$2(p.properties[4]?.value) ?? 0;
postRotation[1] = toNumber$2(p.properties[5]?.value) ?? 0;
postRotation[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "RotationPivot":
rotationPivot[0] = toNumber$2(p.properties[4]?.value) ?? 0;
rotationPivot[1] = toNumber$2(p.properties[5]?.value) ?? 0;
rotationPivot[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "ScalingPivot":
scalingPivot[0] = toNumber$2(p.properties[4]?.value) ?? 0;
scalingPivot[1] = toNumber$2(p.properties[5]?.value) ?? 0;
scalingPivot[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "RotationOffset":
rotationOffset[0] = toNumber$2(p.properties[4]?.value) ?? 0;
rotationOffset[1] = toNumber$2(p.properties[5]?.value) ?? 0;
rotationOffset[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "ScalingOffset":
scalingOffset[0] = toNumber$2(p.properties[4]?.value) ?? 0;
scalingOffset[1] = toNumber$2(p.properties[5]?.value) ?? 0;
scalingOffset[2] = toNumber$2(p.properties[6]?.value) ?? 0;
break;
case "Lcl Scaling":
scale[0] = toNumber$2(p.properties[4]?.value) ?? 1;
scale[1] = toNumber$2(p.properties[5]?.value) ?? 1;
scale[2] = toNumber$2(p.properties[6]?.value) ?? 1;
break;
case "RotationOrder":
rotationOrder = toNumber$2(p.properties[4]?.value) ?? 0;
break;
case "InheritType":
inheritType = toNumber$2(p.properties[4]?.value) ?? 1;
break;
}
}
return { translation, rotation, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, scale, rotationOrder, inheritType };
}
function extractClusterMatrices(clusterNode) {
let bindPoseMatrix = null;
let transformLinkMatrix = null;
let transformAssociateModelMatrix = null;
let clusterMode = "Normalize";
const transformNode = findChildByName(clusterNode, "Transform");
if (transformNode && transformNode.properties[0]) {
const val = transformNode.properties[0].value;
if (val instanceof Float64Array && val.length === 16) {
bindPoseMatrix = val;
}
else if (val instanceof Float32Array && val.length === 16) {
bindPoseMatrix = new Float64Array(val);
}
}
const transformLinkNode = findChildByName(clusterNode, "TransformLink");
if (transformLinkNode && transformLinkNode.properties[0]) {
const val = transformLinkNode.properties[0].value;
if (val instanceof Float64Array && val.length === 16) {
transformLinkMatrix = val;
}
else if (val instanceof Float32Array && val.length === 16) {
transformLinkMatrix = new Float64Array(val);
}
}
const transformAssociateModelNode = findChildByName(clusterNode, "TransformAssociateModel");
if (transformAssociateModelNode && transformAssociateModelNode.properties[0]) {
const val = transformAssociateModelNode.properties[0].value;
if (val instanceof Float64Array && val.length === 16) {
transformAssociateModelMatrix = val;
}
else if (val instanceof Float32Array && val.length === 16) {
transformAssociateModelMatrix = new Float64Array(val);
}
}
const modeNode = findChildByName(clusterNode, "Mode");
const mode = modeNode ? getPropertyValue(modeNode, 0) : undefined;
if (mode === "Normalize" || mode === "Additive" || mode === "TotalOne") {
clusterMode = mode;
}
else if (mode) {
clusterMode = "Unknown";
}
return { bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, clusterMode };
}
function createBoneDiagnostics(modelId, boneName, isCluster, clusterMode, bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, modelBindPoseMatrix) {
if (!isCluster) {
return [];
}
const diagnostics = [];
if (clusterMode === "Additive" || clusterMode === "TotalOne") {
diagnostics.push({
type: "cluster-mode-runtime-unsupported",
message: `Cluster mode '${clusterMode}' is preserved but not applied by Babylon linear blend skinning.`,
boneModelId: modelId,
boneName,
clusterMode,
});
}
if (!bindPoseMatrix) {
diagnostics.push({
type: "missing-cluster-transform",
message: "Cluster is missing Transform matrix; falling back to rest/bind-pose data.",
boneModelId: modelId,
boneName,
clusterMode,
});
}
if (!transformLinkMatrix) {
diagnostics.push({
type: "missing-cluster-transform-link",
message: "Cluster is missing TransformLink matrix; falling back to model bind pose or rest transform.",
boneModelId: modelId,
boneName,
clusterMode,
});
}
if (!modelBindPoseMatrix) {
diagnostics.push({
type: "missing-bind-pose-matrix",
message: "No BindPose matrix was found for this bone model.",
boneModelId: modelId,
boneName,
clusterMode,
});
}
if (transformAssociateModelMatrix) {
diagnostics.push({
type: "associate-model-present",
message: "TransformAssociateModel is preserved for future associate-model skinning semantics.",
boneModelId: modelId,
boneName,
clusterMode,
});
}
return diagnostics;
}
/**
* Extract per-vertex bone indices and weights from cluster data.
* Returns arrays indexed by control point index.
*/
function extractVertexWeights(bones, boneModelMap, objectMap) {
// We need to find the max vertex index to size our arrays
let maxVertexIndex = 0;
// First pass: find max vertex index
for (const bone of bones) {
const clusterInfo = boneModelMap.get(bone.modelId);
if (!clusterInfo) {
continue;
}
const indexesNode = findChildByName(clusterInfo.clusterNode, "Indexes");
if (!indexesNode) {
continue;
}
const indexes = toInt32Array$1(indexesNode.properties[0]?.value);
if (!indexes) {
continue;
}
for (let i = 0; i < indexes.length; i++) {
if (indexes[i] > maxVertexIndex) {
maxVertexIndex = indexes[i];
}
}
}
// Initialize arrays
const vertexCount = maxVertexIndex + 1;
const boneIndices = new Array(vertexCount);
const boneWeights = new Array(vertexCount);
for (let i = 0; i < vertexCount; i++) {
boneIndices[i] = [];
boneWeights[i] = [];
}
// Second pass: collect influences
for (const bone of bones) {
const clusterInfo = boneModelMap.get(bone.modelId);
if (!clusterInfo) {
continue;
}
const indexesNode = findChildByName(clusterInfo.clusterNode, "Indexes");
const weightsNode = findChildByName(clusterInfo.clusterNode, "Weights");
if (!indexesNode || !weightsNode) {
continue;
}
const indexes = toInt32Array$1(indexesNode.properties[0]?.value);
const weights = toFloat64Array(weightsNode.properties[0]?.value);
if (!indexes || !weights) {
continue;
}
for (let i = 0; i < indexes.length; i++) {
const vertIdx = indexes[i];
boneIndices[vertIdx].push(bone.index);
boneWeights[vertIdx].push(weights[i]);
}
}
// Sort by weight descending and cap to Babylon's primary + extra influence buffers.
for (let i = 0; i < vertexCount; i++) {
if (boneIndices[i].length === 0) {
continue;
}
const pairs = boneIndices[i].map((bi, idx) => ({
index: bi,
weight: boneWeights[i][idx],
}));
pairs.sort((a, b) => b.weight - a.weight);
const cappedPairs = pairs.slice(0, MAX_BONE_INFLUENCES);
boneIndices[i] = cappedPairs.map((p) => p.index);
boneWeights[i] = cappedPairs.map((p) => p.weight);
}
// Normalize weights to sum to 1.0
for (let i = 0; i < vertexCount; i++) {
const sum = boneWeights[i].reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let j = 0; j < boneWeights[i].length; j++) {
boneWeights[i][j] /= sum;
}
}
}
return { boneIndices, boneWeights };
}
// ── Utilities ──────────────────────────────────────────────────────────────────
function toNumber$2(value) {
if (typeof value === "number") {
return value;
}
return undefined;
}
function toInt32Array$1(value) {
if (value instanceof Int32Array) {
return value;
}
if (value instanceof Float64Array) {
const result = new Int32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = Math.round(value[i]);
}
return result;
}
return null;
}
function toFloat64Array(value) {
if (value instanceof Float64Array) {
return value;
}
if (value instanceof Float32Array) {
return new Float64Array(value);
}
return null;
}
function resolveRigs(objectMap, skins) {
if (skins.length === 0) {
return [];
}
const groupByRoot = new Map();
for (const skin of skins) {
const clusterModelIds = skin.bones.filter((bone) => bone.isCluster).map((bone) => bone.modelId);
if (clusterModelIds.length === 0) {
continue;
}
const rootModelId = findRigGroupingRoot(clusterModelIds, objectMap);
const group = groupByRoot.get(rootModelId);
if (group) {
group.push(skin);
}
else {
groupByRoot.set(rootModelId, [skin]);
}
}
return Array.from(groupByRoot.entries())
.sort(([a], [b]) => compareNumber(a, b))
.map(([rootModelId, groupSkins]) => buildRig(rootModelId, groupSkins, objectMap));
}
function buildRig(rootModelId, skins, objectMap) {
const clusterModelIds = new Set();
const rigModelIds = new Set();
const sourceBonesByModelId = new Map();
const sourceOrderByModelId = new Map();
for (const skin of skins) {
for (const bone of skin.bones) {
if (!sourceOrderByModelId.has(bone.modelId)) {
sourceOrderByModelId.set(bone.modelId, sourceOrderByModelId.size);
}
let sources = sourceBonesByModelId.get(bone.modelId);
if (!sources) {
sources = [];
sourceBonesByModelId.set(bone.modelId, sources);
}
sources.push(bone);
if (!bone.isCluster) {
continue;
}
clusterModelIds.add(bone.modelId);
for (const ancestorId of getModelAncestorChain(bone.modelId, objectMap)) {
rigModelIds.add(ancestorId);
}
}
}
const warnings = collectTransformLinkWarnings(sourceBonesByModelId);
const preferredBoneByModelId = new Map();
for (const [modelId, sources] of Array.from(sourceBonesByModelId)) {
preferredBoneByModelId.set(modelId, choosePreferredBoneSource(sources));
}
const parentByModelId = buildParentMap(rigModelIds, objectMap);
const orderedModelIds = orderParentsBeforeChildren(rigModelIds, parentByModelId, sourceOrderByModelId);
const bones = [];
const modelIdToBoneIndex = new Map();
for (const modelId of orderedModelIds) {
const sourceBone = preferredBoneByModelId.get(modelId) ?? createFallbackBone(modelId, objectMap);
if (!sourceBone) {
continue;
}
const parentModelId = parentByModelId.get(modelId);
const parentIndex = parentModelId === undefined ? -1 : (modelIdToBoneIndex.get(parentModelId) ?? -1);
const index = bones.length;
const bone = {
...sourceBone,
index,
parentIndex,
isCluster: clusterModelIds.has(modelId),
};
bones.push(bone);
modelIdToBoneIndex.set(modelId, index);
}
const skinBindings = skins.map((skin) => buildSkinBinding(skin, `rig_${rootModelId.toString()}`, modelIdToBoneIndex));
return {
id: `rig_${rootModelId.toString()}`,
rootModelIds: bones.filter((bone) => bone.parentIndex < 0).map((bone) => bone.modelId),
bones,
modelIdToBoneIndex,
clusterModelIds,
skinBindings,
warnings,
};
}
function buildSkinBinding(skin, rigId, modelIdToBoneIndex) {
const skinBoneIndexToRigBoneIndex = skin.bones.map((bone) => {
const rigBoneIndex = modelIdToBoneIndex.get(bone.modelId);
if (rigBoneIndex === undefined && bone.isCluster) {
throw new Error(`FBX rig resolver: cluster bone ${bone.name} is missing from resolved rig ${rigId}`);
}
return rigBoneIndex ?? -1;
});
return {
skinId: skin.id,
geometryId: skin.geometryId,
rigId,
skinBoneIndexToRigBoneIndex,
clusterModelIds: new Set(skin.bones.filter((bone) => bone.isCluster).map((bone) => bone.modelId)),
};
}
function findRigGroupingRoot(clusterModelIds, objectMap) {
const lca = findLowestCommonAncestor(clusterModelIds, objectMap) ?? clusterModelIds[0];
let root = lca;
let parentId = findModelParentId(root, objectMap);
while (parentId !== undefined) {
const parentNode = objectMap.objects.get(parentId);
if (!parentNode || parentNode.name !== "Model" || !isSkeletonModel(parentNode)) {
break;
}
root = parentId;
parentId = findModelParentId(parentId, objectMap);
}
return root;
}
function findLowestCommonAncestor(modelIds, objectMap) {
if (modelIds.length === 0) {
return undefined;
}
const chains = modelIds.map((modelId) => getModelAncestorChain(modelId, objectMap));
const common = new Set(chains[0]);
for (const chain of chains.slice(1)) {
for (const modelId of Array.from(common)) {
if (!chain.includes(modelId)) {
common.delete(modelId);
}
}
}
return chains[0].find((modelId) => common.has(modelId));
}
function getModelAncestorChain(modelId, objectMap) {
const chain = [];
let currentId = modelId;
while (currentId !== undefined) {
const node = objectMap.objects.get(currentId);
if (!node || node.name !== "Model") {
break;
}
chain.push(currentId);
currentId = findModelParentId(currentId, objectMap);
}
return chain;
}
function buildParentMap(modelIds, objectMap) {
const parentByModelId = new Map();
for (const modelId of Array.from(modelIds)) {
const parentId = findModelParentId(modelId, objectMap);
if (parentId !== undefined && modelIds.has(parentId)) {
parentByModelId.set(modelId, parentId);
}
}
return parentByModelId;
}
function orderParentsBeforeChildren(modelIds, parentByModelId, sourceOrderByModelId) {
const childrenByModelId = new Map();
for (const modelId of Array.from(modelIds)) {
const parentId = parentByModelId.get(modelId);
if (parentId === undefined) {
continue;
}
let children = childrenByModelId.get(parentId);
if (!children) {
children = [];
childrenByModelId.set(parentId, children);
}
children.push(modelId);
}
for (const children of Array.from(childrenByModelId.values())) {
children.sort((a, b) => compareSourceOrder(a, b, sourceOrderByModelId));
}
const roots = Array.from(modelIds)
.filter((modelId) => !parentByModelId.has(modelId))
.sort((a, b) => compareSourceOrder(a, b, sourceOrderByModelId));
const ordered = [];
const queue = [...roots];
while (queue.length > 0) {
const modelId = queue.shift();
ordered.push(modelId);
queue.push(...(childrenByModelId.get(modelId) ?? []));
}
return ordered;
}
function findModelParentId(modelId, objectMap) {
const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model");
return parentConnection?.parentId;
}
function choosePreferredBoneSource(sources) {
return (sources.find((bone) => bone.isCluster && bone.transformLinkMatrix) ??
sources.find((bone) => bone.isCluster) ??
sources.find((bone) => bone.modelBindPoseMatrix) ??
sources[0]);
}
function collectTransformLinkWarnings(sourceBonesByModelId) {
const warnings = [];
for (const [modelId, sources] of Array.from(sourceBonesByModelId)) {
const matrices = sources.filter((bone) => bone.isCluster && bone.transformLinkMatrix).map((bone) => bone.transformLinkMatrix);
if (matrices.length < 2) {
continue;
}
const first = matrices[0];
if (matrices.some((matrix) => !areMatricesEquivalent(first, matrix, 1e-5))) {
warnings.push(`Model ${modelId.toString()} has differing Cluster.TransformLink matrices across skins`);
}
}
return warnings;
}
function areMatricesEquivalent(a, b, epsilon) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > epsilon) {
return false;
}
}
return true;
}
function createFallbackBone(modelId, objectMap) {
const modelNode = objectMap.objects.get(modelId);
if (!modelNode || modelNode.name !== "Model") {
return null;
}
const transform = extractBoneTransform(modelNode);
return {
modelId,
name: cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${modelId.toString()}`),
index: -1,
parentIndex: -1,
isCluster: false,
translation: transform.translation,
rotation: transform.rotation,
preRotation: transform.preRotation,
postRotation: transform.postRotation,
rotationPivot: transform.rotationPivot,
scalingPivot: transform.scalingPivot,
rotationOffset: transform.rotationOffset,
scalingOffset: transform.scalingOffset,
scale: transform.scale,
rotationOrder: transform.rotationOrder,
inheritType: transform.inheritType,
clusterMode: "Unknown",
bindPoseMatrix: null,
transformLinkMatrix: null,
transformAssociateModelMatrix: null,
modelBindPoseMatrix: null,
diagnostics: [],
};
}
function compareNumber(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
function compareSourceOrder(a, b, sourceOrderByModelId) {
const aOrder = sourceOrderByModelId.get(a) ?? Number.MAX_SAFE_INTEGER;
const bOrder = sourceOrderByModelId.get(b) ?? Number.MAX_SAFE_INTEGER;
return aOrder - bOrder || compareNumber(a, b);
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/** FBX time units: 46186158000 ticks per second */
const FBX_TIME_UNIT = 46186158000;
const KEY_ATTR_DATA_STRIDE = 4;
const SAMPLED_CURVE_MIN_KEY_COUNT = 8;
const SAMPLED_CURVE_MAX_INTERVAL_SECONDS = 1 / 23;
const SAMPLED_CURVE_UNIFORM_TOLERANCE_RATIO = 0.05;
const SAMPLED_CURVE_LINEAR_DEVIATION_RATIO = 0.01;
const SAMPLED_CURVE_LINEAR_DEVIATION_ABSOLUTE = 1e-4;
const SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE = 1e-5;
const SAMPLED_CURVE_COMMON_FPS = [24, 25, 30, 48, 50, 60, 100, 120];
/**
* Extract all animation stacks from the FBX scene.
*/
function extractAnimations(objectMap) {
const stacks = [];
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name === "AnimationStack") {
const stack = extractAnimStack(id, node, objectMap);
if (stack) {
stacks.push(stack);
}
}
}
return stacks;
}
function extractAnimStack(stackId, stackNode, objectMap) {
const name = cleanFBXName(getPropertyValue(stackNode, 1) ?? "Animation");
const declaredTimeSpan = extractAnimationStackTimeSpan(stackNode);
// Find AnimationLayer children of this stack
const layerEntries = getChildren(objectMap, stackId, "AnimationLayer");
if (layerEntries.length === 0) {
return null;
}
// Collect all CurveNodes from all layers
const allCurveNodes = [];
const allUnsupportedCurveNodes = [];
const layers = [];
const diagnostics = [];
let minTime = Infinity;
let maxTime = 0;
for (const { id: layerId, node: layerNode } of layerEntries) {
// Extract layer properties
const layerName = cleanFBXName(getPropertyValue(layerNode, 1) ?? "Layer");
let weight = 100;
let blendMode = 0;
const props70 = findChildByName(layerNode, "Properties70");
if (props70) {
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const pName = getPropertyValue(p, 0);
if (pName === "Weight") {
const v = p.properties[4]?.value;
if (typeof v === "number") {
weight = v;
}
}
else if (pName === "BlendMode") {
const v = p.properties[4]?.value;
if (typeof v === "number") {
blendMode = v;
}
}
}
}
// AnimationCurveNodes are children of the layer
const curveNodeEntries = getChildren(objectMap, layerId, "AnimationCurveNode");
const layerCurveNodes = [];
const layerUnsupportedCurveNodes = [];
const layerDiagnostics = [];
for (const { id: curveNodeId, node: curveNodeNode } of curveNodeEntries) {
const curveNodeData = extractCurveNode(curveNodeId, curveNodeNode, objectMap);
if (!curveNodeData) {
const unsupported = extractUnsupportedCurveNode(curveNodeId, curveNodeNode, objectMap);
if (unsupported) {
scanCurveTimes(unsupported.curves, (time) => {
if (time < minTime) {
minTime = time;
}
if (time > maxTime) {
maxTime = time;
}
});
layerUnsupportedCurveNodes.push(unsupported);
allUnsupportedCurveNodes.push(unsupported);
const diagnostic = {
type: "unsupported-curve-node",
message: `AnimationCurveNode '${unsupported.type}' is preserved as diagnostic data but not evaluated at runtime.`,
layerName,
curveNodeId,
curveNodeType: unsupported.type,
targetId: unsupported.targetId,
propertyName: unsupported.propertyName,
};
layerDiagnostics.push(diagnostic);
diagnostics.push(diagnostic);
}
continue;
}
for (const curve of curveNodeData.curves) {
for (const key of curve.keys) {
if (key.time < minTime) {
minTime = key.time;
}
if (key.time > maxTime) {
maxTime = key.time;
}
}
}
layerCurveNodes.push(curveNodeData);
allCurveNodes.push(curveNodeData);
}
layers.push({
name: layerName,
weight,
normalizedWeight: weight / 100,
blendMode,
curveNodes: layerCurveNodes,
unsupportedCurveNodes: layerUnsupportedCurveNodes,
diagnostics: layerDiagnostics,
});
}
if (allCurveNodes.length === 0 && allUnsupportedCurveNodes.length === 0) {
return null;
}
if (layers.length > 1) {
diagnostics.push({
type: "multiple-animation-layers",
message: "Multiple animation layers are preserved, but runtime blending is not yet evaluated.",
});
}
for (const layer of layers) {
if (layer.blendMode !== 0) {
const diagnostic = {
type: "unsupported-layer-blend-mode",
message: `Animation layer blend mode ${layer.blendMode} is preserved but not yet blended at runtime.`,
layerName: layer.name,
};
layer.diagnostics.push(diagnostic);
diagnostics.push(diagnostic);
}
if (layer.weight !== 100) {
const diagnostic = {
type: "partial-layer-weight",
message: `Animation layer weight ${layer.weight} is preserved but not yet applied at runtime.`,
layerName: layer.name,
};
layer.diagnostics.push(diagnostic);
diagnostics.push(diagnostic);
}
}
const timeOffset = minTime > 0 && isFinite(minTime) ? minTime : 0;
// Rebase all keyframe times so the animation starts at 0
if (timeOffset > 0) {
for (const cn of allCurveNodes) {
for (const curve of cn.curves) {
for (const key of curve.keys) {
key.time -= timeOffset;
}
}
}
for (const cn of allUnsupportedCurveNodes) {
for (const curve of cn.curves) {
for (const key of curve.keys) {
key.time -= timeOffset;
}
}
}
maxTime -= timeOffset;
}
const declaredStart = declaredTimeSpan ? Math.max(declaredTimeSpan.start - timeOffset, 0) : 0;
const declaredStop = declaredTimeSpan ? Math.max(declaredTimeSpan.stop - timeOffset, declaredStart) : 0;
const hasDeclaredDuration = declaredStop > declaredStart;
const startTime = hasDeclaredDuration ? declaredStart : 0;
const stopTime = hasDeclaredDuration ? declaredStop : maxTime;
return {
name,
startTime,
stopTime,
duration: Math.max(stopTime - startTime, 0),
curveNodes: allCurveNodes,
layers,
unsupportedCurveNodes: allUnsupportedCurveNodes,
diagnostics,
};
}
function extractAnimationStackTimeSpan(stackNode) {
const props70 = findChildByName(stackNode, "Properties70");
if (!props70) {
return null;
}
let start = 0;
let stop = null;
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const pName = getPropertyValue(p, 0);
if (pName === "LocalStart" || pName === "ReferenceStart") {
start = fbxTimeToSeconds(p.properties[4]?.value) ?? start;
}
else if (pName === "LocalStop" || pName === "ReferenceStop") {
stop = fbxTimeToSeconds(p.properties[4]?.value) ?? stop;
}
}
return stop !== null ? { start, stop } : null;
}
function extractCurveNode(curveNodeId, curveNodeNode, objectMap) {
const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? "");
// Handle T (translation), R (rotation), S (scale) targeting Models
if (typeName === "T" || typeName === "R" || typeName === "S") {
const targetModelId = findCurveNodeTarget(curveNodeId, objectMap);
if (targetModelId === null) {
return null;
}
const curves = extractCurves(curveNodeId, objectMap);
if (curves.length === 0) {
return null;
}
return {
type: typeName,
targetModelId,
curves,
};
}
// Handle DeformPercent targeting BlendShapeChannels
if (typeName === "DeformPercent") {
const targetId = findCurveNodeBlendShapeTarget(curveNodeId, objectMap);
if (targetId === null) {
return null;
}
const curves = extractCurves(curveNodeId, objectMap);
if (curves.length === 0) {
return null;
}
return {
type: "DeformPercent",
targetModelId: targetId,
curves,
};
}
return null;
}
function extractUnsupportedCurveNode(curveNodeId, curveNodeNode, objectMap) {
const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? "");
const curves = extractCurves(curveNodeId, objectMap);
const defaultValues = extractCurveNodeDefaultValues(curveNodeNode);
if (curves.length === 0 && Object.keys(defaultValues).length === 0) {
return null;
}
let targetId = null;
let propertyName;
for (const conn of objectMap.connections) {
if (conn.childId === curveNodeId && conn.type === "OP") {
targetId = conn.parentId;
propertyName = conn.propertyName;
break;
}
}
return {
type: typeName,
id: curveNodeId,
targetId,
propertyName,
curveCount: curves.length,
curves,
defaultValues,
};
}
function scanCurveTimes(curves, visit) {
for (const curve of curves) {
for (const key of curve.keys) {
visit(key.time);
}
}
}
/**
* Find the Model that an AnimationCurveNode targets.
* The CurveNode connects to the Model via OP connection with a property name.
*/
function findCurveNodeTarget(curveNodeId, objectMap) {
// Look for connections where this curveNode is a child (going up to parent)
// The OP connection from curveNode → Model has the property name (e.g. "Lcl Translation")
for (const conn of objectMap.connections) {
if (conn.childId === curveNodeId && conn.type === "OP") {
const parentNode = objectMap.objects.get(conn.parentId);
if (parentNode && parentNode.name === "Model") {
return conn.parentId;
}
}
}
return null;
}
/**
* Find the BlendShapeChannel that a DeformPercent AnimationCurveNode targets.
*/
function findCurveNodeBlendShapeTarget(curveNodeId, objectMap) {
for (const conn of objectMap.connections) {
if (conn.childId === curveNodeId && conn.type === "OP") {
const parentNode = objectMap.objects.get(conn.parentId);
if (parentNode && parentNode.name === "Deformer") {
const subType = getPropertyValue(parentNode, 2);
if (subType === "BlendShapeChannel") {
return conn.parentId;
}
}
}
}
// Also check OO connections
for (const conn of objectMap.connections) {
if (conn.childId === curveNodeId && conn.type === "OO") {
const parentNode = objectMap.objects.get(conn.parentId);
if (parentNode && parentNode.name === "Deformer") {
const subType = getPropertyValue(parentNode, 2);
if (subType === "BlendShapeChannel") {
return conn.parentId;
}
}
}
}
return null;
}
/**
* Extract AnimationCurves connected to a CurveNode.
* Each curve connects via OP with channel "d|X", "d|Y", or "d|Z".
*/
function extractCurves(curveNodeId, objectMap) {
const curves = [];
// Find AnimationCurve children of this CurveNode
for (const conn of objectMap.connections) {
if (conn.parentId === curveNodeId && conn.type === "OP") {
const curveNode = objectMap.objects.get(conn.childId);
if (!curveNode || curveNode.name !== "AnimationCurve") {
continue;
}
const channel = conn.propertyName ?? "d|X";
const keys = extractKeyframes(curveNode);
if (keys.length > 0) {
const isSampled = isSampledAnimationCurve(curveNode, keys);
curves.push({ channel, keys: isSampled ? makeLinearSampleKeys(keys) : keys, isSampled });
}
}
}
// Also check OO connections (some exporters use OO for curve→curveNode)
if (curves.length === 0) {
const ooChildren = getChildren(objectMap, curveNodeId, "AnimationCurve");
// For OO connections, infer channel from order (X, Y, Z)
const channelNames = ["d|X", "d|Y", "d|Z"];
for (let i = 0; i < ooChildren.length && i < 3; i++) {
const keys = extractKeyframes(ooChildren[i].node);
if (keys.length > 0) {
const isSampled = isSampledAnimationCurve(ooChildren[i].node, keys);
curves.push({ channel: channelNames[i], keys: isSampled ? makeLinearSampleKeys(keys) : keys, isSampled });
}
}
}
return curves;
}
function extractCurveNodeDefaultValues(curveNodeNode) {
const defaults = {};
const props70 = findChildByName(curveNodeNode, "Properties70");
for (const p of props70?.children ?? []) {
if (p.name !== "P") {
continue;
}
const propName = getPropertyValue(p, 0);
if (!propName?.startsWith("d|")) {
continue;
}
const value = toNumber$1(p.properties[4]?.value);
if (value !== null) {
defaults[propName] = value;
}
}
return defaults;
}
/**
* Extract keyframes from an AnimationCurve node.
*/
function extractKeyframes(curveNode) {
const keyTimeNode = findChildByName(curveNode, "KeyTime");
const keyValueNode = findChildByName(curveNode, "KeyValueFloat");
if (!keyTimeNode || !keyValueNode) {
return [];
}
const keyTimes = toInt64Array(keyTimeNode.properties[0]?.value);
const keyValues = toFloat32Array(keyValueNode.properties[0]?.value);
const keyAttrFlags = toInt32Array(findChildByName(curveNode, "KeyAttrFlags")?.properties[0]?.value);
const keyAttrData = toFloat32Array(findChildByName(curveNode, "KeyAttrDataFloat")?.properties[0]?.value);
const keyAttrRefCount = toInt32Array(findChildByName(curveNode, "KeyAttrRefCount")?.properties[0]?.value);
if (!keyTimes || !keyValues) {
return [];
}
if (keyTimes.length !== keyValues.length) {
return [];
}
const keyAttributeIndices = buildKeyAttributeIndices(keyTimes.length, keyAttrFlags, keyAttrRefCount);
const keys = [];
for (let i = 0; i < keyTimes.length; i++) {
const attrIndex = keyAttributeIndices[i];
const flag = attrIndex >= 0 ? (keyAttrFlags?.[attrIndex] ?? 0) : 0;
const dataOffset = attrIndex * KEY_ATTR_DATA_STRIDE;
keys.push({
time: Number(keyTimes[i]) / FBX_TIME_UNIT,
value: keyValues[i],
interpolation: getInterpolationType(flag),
constantMode: (flag & 0x00000100) !== 0 ? "next" : "standard",
rightSlope: getFiniteKeyAttrData(keyAttrData, dataOffset),
nextLeftSlope: getFiniteKeyAttrData(keyAttrData, dataOffset + 1),
});
}
return keys;
}
function isSampledAnimationCurve(curveNode, keys) {
const rawName = getPropertyValue(curveNode, 1) ?? "";
return cleanFBXName(rawName) === "FbxMayaSample Curve" || isFrameBakedSampledCurve(keys);
}
/**
* Determines whether a key sequence appears to be a uniformly frame-baked sampled curve.
* @param keys - Keyframes to inspect
* @returns true if the keys look like sampled frame data rather than authored interpolation
*/
function isFrameBakedSampledCurve(keys) {
if (keys.length < SAMPLED_CURVE_MIN_KEY_COUNT) {
return false;
}
const deltas = [];
for (let i = 1; i < keys.length; i++) {
const delta = keys[i].time - keys[i - 1].time;
if (!(delta > 0)) {
return false;
}
deltas.push(delta);
}
const averageDelta = deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length;
if (averageDelta > SAMPLED_CURVE_MAX_INTERVAL_SECONDS) {
return false;
}
const uniformTolerance = Math.max(1e-6, averageDelta * SAMPLED_CURVE_UNIFORM_TOLERANCE_RATIO);
if (deltas.some((delta) => Math.abs(delta - averageDelta) > uniformTolerance)) {
return false;
}
const sampledFps = 1 / averageDelta;
const matchesCommonFps = SAMPLED_CURVE_COMMON_FPS.some((fps) => Math.abs(sampledFps - fps) <= Math.max(0.25, fps * 0.02));
if (!matchesCommonFps) {
return false;
}
return !hasMeaningfulCubicTangents(keys);
}
function makeLinearSampleKeys(keys) {
return keys.map((key) => ({
time: key.time,
value: key.value,
interpolation: "linear",
}));
}
function hasMeaningfulCubicTangents(keys) {
let hasCubicSegment = false;
let hasCompleteTangents = true;
let allSlopesDegenerate = true;
let minValue = Number.POSITIVE_INFINITY;
let maxValue = Number.NEGATIVE_INFINITY;
let maxLinearDeviation = 0;
for (const key of keys) {
minValue = Math.min(minValue, key.value);
maxValue = Math.max(maxValue, key.value);
}
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const nextKey = keys[i + 1];
if (key.interpolation !== "cubic") {
continue;
}
hasCubicSegment = true;
const segmentDuration = nextKey.time - key.time;
if (!(segmentDuration > 0)) {
continue;
}
const linearSlope = (nextKey.value - key.value) / segmentDuration;
const rightSlope = key.rightSlope;
const nextLeftSlope = key.nextLeftSlope;
if (rightSlope === undefined || nextLeftSlope === undefined) {
hasCompleteTangents = false;
continue;
}
if (Math.abs(rightSlope) > SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE || Math.abs(nextLeftSlope) > SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE) {
allSlopesDegenerate = false;
}
for (const t of [0.25, 0.5, 0.75]) {
const cubic = cubicHermite(key.value, nextKey.value, rightSlope, nextLeftSlope, segmentDuration, t);
const linear = key.value + t * segmentDuration * linearSlope;
maxLinearDeviation = Math.max(maxLinearDeviation, Math.abs(cubic - linear));
}
}
if (!hasCubicSegment || !hasCompleteTangents || allSlopesDegenerate) {
return false;
}
const range = maxValue - minValue;
const deviationTolerance = Math.max(SAMPLED_CURVE_LINEAR_DEVIATION_ABSOLUTE, range * SAMPLED_CURVE_LINEAR_DEVIATION_RATIO);
return maxLinearDeviation > deviationTolerance;
}
/**
* Samples an FBX animation curve at a specific time.
* @param curveData - Curve data to sample
* @param time - Time in seconds
* @returns The sampled value, or null when the curve has no keys
*/
function sampleFBXCurveAtTime(curveData, time) {
if (!curveData || curveData.keys.length === 0) {
return null;
}
const keys = curveData.keys;
if (time <= keys[0].time) {
return keys[0].value;
}
if (time >= keys[keys.length - 1].time) {
return keys[keys.length - 1].value;
}
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const nextKey = keys[i + 1];
if (time < key.time || time > nextKey.time) {
continue;
}
if (nextKey.time === key.time) {
return key.value;
}
if (key.interpolation === "constant") {
return key.constantMode === "next" ? nextKey.value : key.value;
}
const segmentDuration = nextKey.time - key.time;
const t = (time - key.time) / segmentDuration;
if (key.interpolation === "cubic" && !curveData.isSampled) {
const linearSlope = (nextKey.value - key.value) / segmentDuration;
const rightSlope = key.rightSlope ?? linearSlope;
const nextLeftSlope = key.nextLeftSlope ?? linearSlope;
return cubicHermite(key.value, nextKey.value, rightSlope, nextLeftSlope, segmentDuration, t);
}
return key.value + t * (nextKey.value - key.value);
}
return keys[keys.length - 1].value;
}
// ── Utilities ──────────────────────────────────────────────────────────────────
function toInt64Array(value) {
if (value instanceof Float64Array) {
return value;
}
return null;
}
function toInt32Array(value) {
if (value instanceof Int32Array) {
return value;
}
if (value instanceof Float32Array || value instanceof Float64Array) {
const result = new Int32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = value[i];
}
return result;
}
return null;
}
function fbxTimeToSeconds(value) {
if (typeof value === "number") {
return value / FBX_TIME_UNIT;
}
return null;
}
function toNumber$1(value) {
if (typeof value === "number") {
return value;
}
return null;
}
function toFloat32Array(value) {
if (value instanceof Float32Array) {
return value;
}
if (value instanceof Float64Array) {
const result = new Float32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = value[i];
}
return result;
}
return null;
}
function buildKeyAttributeIndices(keyCount, keyAttrFlags, keyAttrRefCount) {
if (!keyAttrFlags || keyAttrFlags.length === 0) {
return new Array(keyCount).fill(-1);
}
if (keyAttrRefCount && keyAttrRefCount.length > 0) {
let total = 0;
for (const count of keyAttrRefCount) {
total += count;
}
if (total === keyCount) {
const indices = [];
for (let attrIndex = 0; attrIndex < keyAttrRefCount.length; attrIndex++) {
const count = keyAttrRefCount[attrIndex];
for (let i = 0; i < count; i++) {
indices.push(attrIndex);
}
}
return indices;
}
}
if (keyAttrFlags.length === keyCount) {
return Array.from({ length: keyCount }, (_, i) => i);
}
if (keyAttrFlags.length === 1) {
return new Array(keyCount).fill(0);
}
return Array.from({ length: keyCount }, (_, i) => Math.min(i, keyAttrFlags.length - 1));
}
function getInterpolationType(flag) {
if ((flag & 0x00000008) !== 0) {
return "cubic";
}
if ((flag & 0x00000004) !== 0) {
return "linear";
}
if ((flag & 0x00000002) !== 0) {
return "constant";
}
return "linear";
}
function getFiniteKeyAttrData(keyAttrData, index) {
if (!keyAttrData || index < 0 || index >= keyAttrData.length) {
return undefined;
}
const value = keyAttrData[index];
return Number.isFinite(value) ? value : undefined;
}
function cubicHermite(value0, value1, slope0, slope1, segmentDuration, t) {
const t2 = t * t;
const t3 = t2 * t;
const h00 = 2 * t3 - 3 * t2 + 1;
const h10 = t3 - 2 * t2 + t;
const h01 = -2 * t3 + 3 * t2;
const h11 = t3 - t2;
return h00 * value0 + h10 * segmentDuration * slope0 + h01 * value1 + h11 * segmentDuration * slope1;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Extract all blend shape deformers from the FBX scene.
*/
function extractBlendShapes(objectMap) {
const blendShapes = [];
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name === "Deformer" && getPropertyValue(node, 2) === "BlendShape") {
const bs = extractBlendShape(id, node, objectMap);
if (bs) {
blendShapes.push(bs);
}
}
}
return blendShapes;
}
function extractBlendShape(deformerId, _deformerNode, objectMap) {
// Find the geometry this blend shape is attached to
const parent = objectMap.parentOf.get(deformerId);
if (!parent) {
return null;
}
const parentNode = objectMap.objects.get(parent.id);
if (!parentNode || parentNode.name !== "Geometry") {
return null;
}
const geometryId = parent.id;
// Find BlendShapeChannel children
const channels = [];
const channelChildren = getChildren(objectMap, deformerId, "Deformer");
for (const { id: channelId, node: channelNode } of channelChildren) {
const subType = getPropertyValue(channelNode, 2);
if (subType !== "BlendShapeChannel") {
continue;
}
const channelName = cleanFBXName(getPropertyValue(channelNode, 1) ?? "MorphTarget");
// Read DeformPercent from Properties70
let deformPercent = 0;
const props70 = findChildByName(channelNode, "Properties70");
if (props70) {
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const pName = getPropertyValue(p, 0);
if (pName === "DeformPercent") {
const val = p.properties[4]?.value;
if (typeof val === "number") {
deformPercent = val;
}
}
}
}
const rawFullWeights = extractFullWeights(channelNode);
// Find connected Shape geometries
const shapes = [];
const shapeChildren = getChildren(objectMap, channelId, "Geometry");
for (const { node: shapeNode } of shapeChildren) {
const shapeSubType = getPropertyValue(shapeNode, 2);
if (shapeSubType !== "Shape") {
continue;
}
const shape = extractShape(shapeNode);
if (shape) {
shapes.push(shape);
}
}
if (shapes.length > 0) {
const diagnostics = [];
const fullWeights = normalizeFullWeights(rawFullWeights, shapes, channelId, channelName, diagnostics);
channels.push({
name: channelName,
id: channelId,
deformPercent,
shapes: sortShapesByFullWeight(shapes, fullWeights),
fullWeights: fullWeights ? [...fullWeights].sort((a, b) => a - b) : null,
diagnostics,
});
}
}
if (channels.length === 0) {
return null;
}
return {
id: deformerId,
geometryId,
channels,
};
}
function extractFullWeights(channelNode) {
const fullWeightsNode = findChildByName(channelNode, "FullWeights");
const rawFullWeights = fullWeightsNode?.properties[0]?.value;
if (!rawFullWeights) {
return null;
}
if (rawFullWeights instanceof Float64Array || rawFullWeights instanceof Float32Array || rawFullWeights instanceof Int32Array) {
return Array.from(rawFullWeights, (value) => Number(value));
}
return null;
}
function normalizeFullWeights(fullWeights, shapes, channelId, channelName, diagnostics) {
if (!fullWeights) {
if (shapes.length > 1) {
diagnostics.push({
type: "missing-full-weights",
message: "Blend shape channel has multiple shapes but no FullWeights; using the first shape for compatibility.",
channelId,
channelName,
});
}
return null;
}
if (fullWeights.length !== shapes.length) {
if (shapes.length === 1) {
return null;
}
diagnostics.push({
type: "full-weights-mismatch",
message: `FullWeights length ${fullWeights.length} does not match shape count ${shapes.length}; using the first shape for compatibility.`,
channelId,
channelName,
});
return null;
}
return fullWeights;
}
function sortShapesByFullWeight(shapes, fullWeights) {
if (!fullWeights || fullWeights.length !== shapes.length) {
return shapes.length > 1 ? [shapes[0]] : shapes;
}
return shapes
.map((shape, index) => ({ shape, weight: fullWeights[index] }))
.sort((a, b) => a.weight - b.weight)
.map((entry) => entry.shape);
}
function extractShape(shapeNode) {
// Shape has: Indexes (sparse vertex indices), Vertices (delta offsets from base), Normals (optional delta)
const indexesNode = findChildByName(shapeNode, "Indexes");
const verticesNode = findChildByName(shapeNode, "Vertices");
if (!indexesNode || !verticesNode) {
return null;
}
const rawIndices = indexesNode.properties[0]?.value;
const rawVertices = verticesNode.properties[0]?.value;
if (!rawIndices || !rawVertices) {
return null;
}
const indices = toUint32Array(rawIndices);
if (!indices) {
return null;
}
// Convert vertices
let vertices;
if (rawVertices instanceof Float64Array) {
vertices = rawVertices;
}
else if (rawVertices instanceof Float32Array) {
vertices = new Float64Array(rawVertices);
}
else {
return null;
}
// Optional normals
let normals = null;
const normalsNode = findChildByName(shapeNode, "Normals");
if (normalsNode) {
const rawNormals = normalsNode.properties[0]?.value;
if (rawNormals instanceof Float64Array) {
normals = rawNormals;
}
else if (rawNormals instanceof Float32Array) {
normals = new Float64Array(rawNormals);
}
}
return { indices, vertices, normals };
}
function toUint32Array(value) {
if (value instanceof Uint32Array) {
return value;
}
if (value instanceof Int32Array || value instanceof Float32Array || value instanceof Float64Array) {
const result = new Uint32Array(value.length);
for (let i = 0; i < value.length; i++) {
result[i] = value[i];
}
return result;
}
return null;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
const HELPER_NODE_NAMES = new Set(["Character", "CharacterPose", "ControlSet", "ControlSetPlug", "SelectionSet", "CollectionExclusive"]);
function extractSceneDiagnostics(objectMap) {
const diagnostics = objectMap.diagnostics.map((diagnostic) => ({
type: "connection-graph",
message: diagnostic.message,
objectId: diagnostic.childId,
subType: diagnostic.reason,
parentCount: diagnostic.childId === undefined ? undefined : objectMap.connections.filter((connection) => connection.childId === diagnostic.childId).length,
}));
for (const [id, node] of Array.from(objectMap.objects)) {
const subType = getPropertyValue(node, 2) ?? "";
if (node.name === "Constraint") {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-constraint", `Constraint '${subType || cleanFBXName(getPropertyValue(node, 1) ?? "")}' is preserved as diagnostic data but not evaluated at runtime.`));
continue;
}
if (HELPER_NODE_NAMES.has(node.name)) {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-helper", `${node.name} helper data is preserved as diagnostic data but not evaluated at runtime.`));
continue;
}
if (node.name === "LayeredTexture") {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-layered-texture", "LayeredTexture is preserved as diagnostic data; runtime texture layer blending is not implemented."));
continue;
}
if (node.name === "Pose" && subType !== "BindPose") {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-pose", `Pose subtype '${subType}' is preserved as diagnostic data but not evaluated at runtime.`));
continue;
}
if (node.name === "Deformer" && !isSupportedDeformer(subType)) {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-deformer", `Deformer subtype '${subType}' is preserved as diagnostic data but not evaluated at runtime.`));
continue;
}
if (node.name === "NodeAttribute" && subType && subType !== "Camera" && subType !== "Light") {
diagnostics.push(createObjectDiagnostic(objectMap, id, node, "unsupported-node-attribute", `NodeAttribute subtype '${subType}' is preserved as diagnostic data but not converted to a Babylon object.`));
}
}
return diagnostics;
}
function isSupportedDeformer(subType) {
return subType === "Skin" || subType === "Cluster" || subType === "BlendShape" || subType === "BlendShapeChannel";
}
function createObjectDiagnostic(objectMap, id, node, type, message) {
return {
type,
message,
objectId: id,
objectName: cleanFBXName(getPropertyValue(node, 1) ?? node.name),
nodeName: node.name,
subType: getPropertyValue(node, 2) ?? "",
parentCount: objectMap.connections.filter((connection) => connection.childId === id).length,
childCount: objectMap.childrenOf.get(id)?.length ?? 0,
};
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
/**
* Interpret a parsed FBX document into scene data.
*/
function interpretFBX(doc) {
const objectMap = resolveConnections(doc);
const propertyTemplates = extractPropertyTemplates(doc);
// Extract global settings
const globalSettings = extractGlobalSettings(doc);
// Extract all materials
const materials = [];
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name === "Material") {
materials.push(extractMaterial(node, id, objectMap, propertyTemplates));
}
}
// Extract all geometries
const geometries = [];
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name === "Geometry") {
const subType = getPropertyValue(node, 2);
if (subType === "Mesh") {
geometries.push(extractGeometry(node, id));
}
}
}
// Extract skeleton/skinning data
const skins = extractSkins(objectMap);
const rigs = resolveRigs(objectMap, skins);
// Extract blend shape data
const blendShapes = extractBlendShapes(objectMap);
// Extract animation data
const animations = extractAnimations(objectMap);
// Extract cameras and lights from NodeAttribute objects
const cameras = extractCameras(objectMap, propertyTemplates);
const lights = extractLights(objectMap, propertyTemplates);
const diagnostics = extractSceneDiagnostics(objectMap);
// Build model hierarchy
const rootModels = buildModelHierarchy(objectMap, geometries, materials, propertyTemplates);
return {
rootModels,
geometries,
materials,
skins,
rigs,
blendShapes,
animations,
cameras,
lights,
diagnostics,
...globalSettings,
};
}
// ── Model Hierarchy ────────────────────────────────────────────────────────────
function buildModelHierarchy(objectMap, geometries, materials, propertyTemplates) {
const geometryMap = new Map();
for (const g of geometries) {
geometryMap.set(g.id, g);
}
const materialMap = new Map();
for (const m of materials) {
materialMap.set(m.id, m);
}
// Find root models (those connected to ID 0, which is the scene root)
const rootChildren = objectMap.childrenOf.get(0) ?? [];
const rootModels = [];
for (const { id } of rootChildren) {
const node = objectMap.objects.get(id);
if (node && node.name === "Model") {
rootModels.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates));
}
}
return rootModels;
}
function buildModel(modelId, modelNode, objectMap, geometryMap, materialMap, propertyTemplates) {
const name = cleanFBXName(getPropertyValue(modelNode, 1) ?? "Model");
const subType = getPropertyValue(modelNode, 2) ?? "Null";
// Find attached geometry
const geomChildren = getChildren(objectMap, modelId, "Geometry");
const geometry = geomChildren.length > 0 ? geometryMap.get(geomChildren[0].id) : undefined;
// Find attached materials
const matChildren = getChildren(objectMap, modelId, "Material");
const modelMaterials = [];
for (const { id } of matChildren) {
const mat = materialMap.get(id);
if (mat) {
modelMaterials.push(mat);
}
}
// Extract transform
const transform = extractTransform(modelNode, getPropertyTemplate(propertyTemplates, "Model", "FbxNode") ?? getPropertyTemplate(propertyTemplates, "Model"));
// Recursively build child models
const childModelNodes = getChildren(objectMap, modelId, "Model");
const children = [];
for (const { id, node } of childModelNodes) {
children.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates));
}
// Extract culling
const cullingNode = modelNode.children.find((c) => c.name === "Culling");
const cullingOff = cullingNode ? getPropertyValue(cullingNode, 0) === "CullingOff" : false;
// Extract user-defined custom properties
const customProperties = extractCustomProperties(modelNode);
return {
id: modelId,
name,
subType,
geometry,
materials: modelMaterials,
children,
cullingOff,
customProperties,
...transform,
};
}
function extractTransform(modelNode, template) {
const translation = resolveVector3Property(modelNode, template, "Lcl Translation", [0, 0, 0]);
const rotation = resolveVector3Property(modelNode, template, "Lcl Rotation", [0, 0, 0]);
const scale = resolveVector3Property(modelNode, template, "Lcl Scaling", [1, 1, 1]);
const preRotation = resolveVector3Property(modelNode, template, "PreRotation", [0, 0, 0]);
const postRotation = resolveVector3Property(modelNode, template, "PostRotation", [0, 0, 0]);
const rotationPivot = resolveVector3Property(modelNode, template, "RotationPivot", [0, 0, 0]);
const scalingPivot = resolveVector3Property(modelNode, template, "ScalingPivot", [0, 0, 0]);
const rotationOffset = resolveVector3Property(modelNode, template, "RotationOffset", [0, 0, 0]);
const scalingOffset = resolveVector3Property(modelNode, template, "ScalingOffset", [0, 0, 0]);
const geometricTranslation = resolveVector3Property(modelNode, template, "GeometricTranslation", [0, 0, 0]);
const geometricRotation = resolveVector3Property(modelNode, template, "GeometricRotation", [0, 0, 0]);
const geometricScaling = resolveVector3Property(modelNode, template, "GeometricScaling", [1, 1, 1]);
const rotationOrder = resolveNumberProperty(modelNode, template, "RotationOrder", 0);
const inheritType = resolveNumberProperty(modelNode, template, "InheritType", 1);
const diagnostics = inheritType !== 1 && inheritType !== 2
? [
`InheritType ${inheritType} is parsed and preserved; runtime parent-scale inheritance remains gated to avoid changing existing visual behavior without a fixture-specific baseline.`,
]
: [];
return {
translation,
rotation,
scale,
preRotation,
postRotation,
rotationPivot,
scalingPivot,
rotationOffset,
scalingOffset,
geometricTranslation,
geometricRotation,
geometricScaling,
rotationOrder,
inheritType,
diagnostics,
};
}
function extractGlobalSettings(doc) {
const defaults = {
upAxis: 1,
upAxisSign: 1,
frontAxis: 2,
frontAxisSign: 1,
coordAxis: 0,
coordAxisSign: 1,
unitScaleFactor: 1,
};
const gsNode = findDocumentNode(doc, "GlobalSettings");
if (!gsNode) {
return defaults;
}
const props70 = gsNode.children.find((c) => c.name === "Properties70");
if (!props70) {
return defaults;
}
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const propName = getPropertyValue(p, 0);
const value = toNumber(p.properties[4]?.value);
if (propName && value !== undefined) {
switch (propName) {
case "UpAxis":
defaults.upAxis = value;
break;
case "UpAxisSign":
defaults.upAxisSign = value;
break;
case "FrontAxis":
defaults.frontAxis = value;
break;
case "FrontAxisSign":
defaults.frontAxisSign = value;
break;
case "CoordAxis":
defaults.coordAxis = value;
break;
case "CoordAxisSign":
defaults.coordAxisSign = value;
break;
case "UnitScaleFactor":
defaults.unitScaleFactor = value;
break;
}
}
}
return defaults;
}
// ── Cameras & Lights ──────────────────────────────────────────────────────────
const SYSTEM_PROPERTIES = new Set([
"Lcl Translation",
"Lcl Rotation",
"Lcl Scaling",
"PreRotation",
"PostRotation",
"RotationPivot",
"ScalingPivot",
"RotationOffset",
"ScalingOffset",
"RotationOrder",
"GeometricTranslation",
"GeometricRotation",
"GeometricScaling",
"Visibility",
"InheritType",
"ScalingMax",
"DefaultAttributeIndex",
"currentUVSet",
"lockInfluenceWeights",
]);
function extractCustomProperties(modelNode) {
const props70 = findChildByName(modelNode, "Properties70");
if (!props70) {
return undefined;
}
const custom = {};
let hasAny = false;
for (const p of props70.children) {
if (p.name !== "P") {
continue;
}
const propName = getPropertyValue(p, 0);
if (!propName || SYSTEM_PROPERTIES.has(propName)) {
continue;
}
// Accept user-defined properties (type starts with something other than standard types)
// Standard FBX types: "KString", "Number", "double", "int", "bool", "Lcl"...
// User properties often have types like "KString", but are in the UDP (User Defined Properties) section
// Heuristic: if not in SYSTEM_PROPERTIES set, it's user-defined
const val = p.properties[4]?.value;
if (val === undefined) {
continue;
}
if (typeof val === "string") {
custom[propName] = val;
hasAny = true;
}
else if (typeof val === "number") {
custom[propName] = val;
hasAny = true;
}
else if (typeof val === "boolean") {
custom[propName] = val;
hasAny = true;
}
}
return hasAny ? custom : undefined;
}
const CAMERA_PROPERTIES = new Set([
"FieldOfView",
"FieldOfViewX",
"FieldOfViewY",
"NearPlane",
"FarPlane",
"AspectWidth",
"AspectHeight",
"FilmAspectRatio",
"FocalLength",
"FilmWidth",
"FilmHeight",
"ApertureWidth",
"ApertureHeight",
"CameraProjectionType",
"ProjectionType",
"OrthoZoom",
"Roll",
"ApertureMode",
]);
const LIGHT_PROPERTIES = new Set([
"LightType",
"Color",
"Intensity",
"InnerAngle",
"OuterAngle",
"ConeAngle",
"DecayType",
"DecayStart",
"EnableNearAttenuation",
"EnableFarAttenuation",
"CastShadow",
"Shadow",
]);
function extractCameras(objectMap, templates) {
const cameras = [];
const cameraTemplate = getPropertyTemplate(templates, "NodeAttribute", "FbxCamera") ?? getPropertyTemplate(templates, "NodeAttribute");
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name !== "NodeAttribute") {
continue;
}
const subType = getPropertyValue(node, 2);
if (subType !== "Camera") {
continue;
}
// Find the model this camera is attached to (parent)
const parent = objectMap.parentOf.get(id);
if (!parent) {
continue;
}
const parentNode = objectMap.objects.get(parent.id);
if (!parentNode || parentNode.name !== "Model") {
continue;
}
const name = cleanFBXName(getPropertyValue(parentNode, 1) ?? "Camera");
const nearPlane = resolveNumberProperty(node, cameraTemplate, "NearPlane", 0.1);
const farPlane = resolveNumberProperty(node, cameraTemplate, "FarPlane", 10000);
const aspectRatio = resolveCameraAspectRatio(node, cameraTemplate);
const projectionType = resolveNumberProperty(node, cameraTemplate, "CameraProjectionType", 0) === 1 || resolveNumberProperty(node, cameraTemplate, "ProjectionType", 0) === 1
? "orthographic"
: "perspective";
const focalLength = toNumber(resolvePropertyValue(node, cameraTemplate, "FocalLength"));
const filmWidth = toNumber(resolvePropertyValue(node, cameraTemplate, "FilmWidth")) ?? toNumber(resolvePropertyValue(node, cameraTemplate, "ApertureWidth"));
const filmHeight = toNumber(resolvePropertyValue(node, cameraTemplate, "FilmHeight")) ?? toNumber(resolvePropertyValue(node, cameraTemplate, "ApertureHeight"));
const orthoZoom = toNumber(resolvePropertyValue(node, cameraTemplate, "OrthoZoom"));
const roll = toNumber(resolvePropertyValue(node, cameraTemplate, "Roll"));
const fieldOfView = resolveCameraFieldOfView(node, cameraTemplate, aspectRatio, focalLength, filmHeight);
const diagnostics = [];
if (projectionType === "orthographic" && orthoZoom === undefined) {
diagnostics.push("Orthographic camera has no OrthoZoom; runtime orthographic bounds use a fallback.");
}
if (focalLength !== undefined && filmHeight === undefined && resolvePropertyValue(node, cameraTemplate, "FieldOfView") === undefined) {
diagnostics.push("FocalLength is present without FilmHeight; default field of view fallback may be used.");
}
cameras.push({
modelId: parent.id,
name,
fieldOfView,
nearPlane,
farPlane,
aspectRatio,
projectionType,
focalLength,
filmWidth,
filmHeight,
orthoZoom,
roll,
unknownProperties: collectUnknownLocalProperties(node, CAMERA_PROPERTIES),
diagnostics,
});
}
return cameras;
}
function extractLights(objectMap, templates) {
const lights = [];
const lightTemplate = getPropertyTemplate(templates, "NodeAttribute", "FbxLight") ?? getPropertyTemplate(templates, "NodeAttribute");
for (const [id, node] of Array.from(objectMap.objects)) {
if (node.name !== "NodeAttribute") {
continue;
}
const subType = getPropertyValue(node, 2);
if (subType !== "Light") {
continue;
}
// Find the model this light is attached to
const parent = objectMap.parentOf.get(id);
if (!parent) {
continue;
}
const parentNode = objectMap.objects.get(parent.id);
if (!parentNode || parentNode.name !== "Model") {
continue;
}
const name = cleanFBXName(getPropertyValue(parentNode, 1) ?? "Light");
const lightType = resolveNumberProperty(node, lightTemplate, "LightType", 0);
const color = resolveVector3Property(node, lightTemplate, "Color", [1, 1, 1]);
const intensity = resolveNumberProperty(node, lightTemplate, "Intensity", 100) / 100;
const outerAngle = toNumber(resolvePropertyValue(node, lightTemplate, "OuterAngle")) ?? toNumber(resolvePropertyValue(node, lightTemplate, "ConeAngle"));
const innerAngle = toNumber(resolvePropertyValue(node, lightTemplate, "InnerAngle"));
const coneAngle = outerAngle ?? 45;
const decayType = resolveNumberProperty(node, lightTemplate, "DecayType", 2);
const decayStart = toNumber(resolvePropertyValue(node, lightTemplate, "DecayStart"));
const enableNearAttenuation = toBoolean(resolvePropertyValue(node, lightTemplate, "EnableNearAttenuation"));
const enableFarAttenuation = toBoolean(resolvePropertyValue(node, lightTemplate, "EnableFarAttenuation"));
const castShadows = toBoolean(resolvePropertyValue(node, lightTemplate, "CastShadow")) ??
toBoolean(resolvePropertyValue(parentNode, undefined, "CastShadow")) ??
toBoolean(resolvePropertyValue(parentNode, undefined, "Shadow"));
const diagnostics = [];
if (decayType !== 2) {
diagnostics.push(`DecayType ${decayType} is preserved as metadata; Babylon falloff is not remapped in this pass.`);
}
if (decayStart !== undefined) {
diagnostics.push("DecayStart is preserved as metadata and is not mapped to Babylon light range.");
}
lights.push({
modelId: parent.id,
name,
lightType,
color,
intensity,
coneAngle,
decayType,
innerAngle,
outerAngle,
decayStart,
enableNearAttenuation,
enableFarAttenuation,
castShadows,
unknownProperties: collectUnknownLocalProperties(node, LIGHT_PROPERTIES),
diagnostics,
});
}
return lights;
}
// ── Utilities ──────────────────────────────────────────────────────────────────
function toNumber(value) {
if (typeof value === "number") {
return value;
}
return undefined;
}
function toBoolean(value) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0;
}
return undefined;
}
function resolveCameraAspectRatio(node, template) {
const filmAspectRatio = toNumber(resolvePropertyValue(node, template, "FilmAspectRatio"));
if (filmAspectRatio !== undefined && filmAspectRatio > 0) {
return filmAspectRatio;
}
const aspectWidth = toNumber(resolvePropertyValue(node, template, "AspectWidth"));
const aspectHeight = toNumber(resolvePropertyValue(node, template, "AspectHeight"));
if (aspectWidth !== undefined && aspectHeight !== undefined && aspectWidth > 0 && aspectHeight > 0) {
return aspectWidth / aspectHeight;
}
return 0;
}
function resolveCameraFieldOfView(node, template, aspectRatio, focalLength, filmHeight) {
const verticalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewY")) ?? toNumber(resolvePropertyValue(node, template, "FieldOfView"));
if (verticalFov !== undefined) {
return verticalFov;
}
const horizontalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewX"));
if (horizontalFov !== undefined) {
if (aspectRatio > 0) {
return radiansToDegrees(2 * Math.atan(Math.tan(degreesToRadians(horizontalFov) / 2) / aspectRatio));
}
return horizontalFov;
}
if (focalLength !== undefined && focalLength > 0 && filmHeight !== undefined && filmHeight > 0) {
return radiansToDegrees(2 * Math.atan((filmHeight * 25.4) / (2 * focalLength)));
}
return 45;
}
function collectUnknownLocalProperties(node, known) {
const unknown = new Set();
for (const containerName of ["Properties70", "Properties60"]) {
const container = findChildByName(node, containerName);
for (const propertyNode of container?.children ?? []) {
if (propertyNode.name !== "P" && propertyNode.name !== "Property") {
continue;
}
const propertyName = getPropertyValue(propertyNode, 0);
if (propertyName && !known.has(propertyName)) {
unknown.add(propertyName);
}
}
}
return Array.from(unknown).sort();
}
function degreesToRadians(degrees) {
return (degrees * Math.PI) / 180;
}
function radiansToDegrees(radians) {
return (radians * 180) / Math.PI;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
function eulerToMatrixXYZ(rx, ry, rz) {
const mx = Matrix.RotationX(rx);
const my = Matrix.RotationY(ry);
const mz = Matrix.RotationZ(rz);
return mx.multiply(my).multiply(mz);
}
function eulerToMatrix(rx, ry, rz, order) {
const mx = Matrix.RotationX(rx);
const my = Matrix.RotationY(ry);
const mz = Matrix.RotationZ(rz);
switch (order) {
case 0:
return mx.multiply(my).multiply(mz); // XYZ
case 1:
return mx.multiply(mz).multiply(my); // XZY
case 2:
return my.multiply(mz).multiply(mx); // YZX
case 3:
return my.multiply(mx).multiply(mz); // YXZ
case 4:
return mz.multiply(mx).multiply(my); // ZXY
case 5:
return mz.multiply(my).multiply(mx); // ZYX
default:
return mx.multiply(my).multiply(mz); // fallback to XYZ
}
}
function computeFBXGeometricMatrix(translation, rotation, scale) {
const translationM = Matrix.Translation(translation[0], translation[1], translation[2]);
return computeFBXGeometricDeltaMatrix(rotation, scale).multiply(translationM);
}
function computeFBXGeometricDeltaMatrix(rotation, scale) {
const d2r = Math.PI / 180;
const scaleM = Matrix.Scaling(scale[0], scale[1], scale[2]);
const rotationM = eulerToMatrixXYZ(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r);
return scaleM.multiply(rotationM);
}
function computeFBXGeometricNormalMatrix(rotation, scale) {
const d2r = Math.PI / 180;
const inverseScaleM = Matrix.Scaling(scale[0] === 0 ? 0 : 1 / scale[0], scale[1] === 0 ? 0 : 1 / scale[1], scale[2] === 0 ? 0 : 1 / scale[2]);
const rotationM = eulerToMatrixXYZ(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r);
return inverseScaleM.multiply(rotationM);
}
function computeFBXLocalMatrix(components) {
const { translation, rotation, scale, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, rotationOrder } = components;
const d2r = Math.PI / 180;
const hasPivots = rotationPivot[0] !== 0 || rotationPivot[1] !== 0 || rotationPivot[2] !== 0 || scalingPivot[0] !== 0 || scalingPivot[1] !== 0 || scalingPivot[2] !== 0;
const hasOffsets = rotationOffset[0] !== 0 || rotationOffset[1] !== 0 || rotationOffset[2] !== 0 || scalingOffset[0] !== 0 || scalingOffset[1] !== 0 || scalingOffset[2] !== 0;
const hasPostRot = postRotation[0] !== 0 || postRotation[1] !== 0 || postRotation[2] !== 0;
if (!hasPivots && !hasOffsets && !hasPostRot) {
const preRotM = eulerToMatrixXYZ(preRotation[0] * d2r, preRotation[1] * d2r, preRotation[2] * d2r);
const lclRotM = eulerToMatrix(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r, rotationOrder);
const translationM = Matrix.Translation(translation[0], translation[1], translation[2]);
const rotationM = lclRotM.multiply(preRotM);
const scaleM = Matrix.Scaling(scale[0], scale[1], scale[2]);
return scaleM.multiply(rotationM).multiply(translationM);
}
const T = Matrix.Translation(translation[0], translation[1], translation[2]);
const Roff = Matrix.Translation(rotationOffset[0], rotationOffset[1], rotationOffset[2]);
const Rp = Matrix.Translation(rotationPivot[0], rotationPivot[1], rotationPivot[2]);
const RpInv = Matrix.Translation(-rotationPivot[0], -rotationPivot[1], -rotationPivot[2]);
const Soff = Matrix.Translation(scalingOffset[0], scalingOffset[1], scalingOffset[2]);
const Sp = Matrix.Translation(scalingPivot[0], scalingPivot[1], scalingPivot[2]);
const SpInv = Matrix.Translation(-scalingPivot[0], -scalingPivot[1], -scalingPivot[2]);
const Rpre = eulerToMatrixXYZ(preRotation[0] * d2r, preRotation[1] * d2r, preRotation[2] * d2r);
const R = eulerToMatrix(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r, rotationOrder);
const S = Matrix.Scaling(scale[0], scale[1], scale[2]);
let RpostInv;
if (hasPostRot) {
const Rpost = eulerToMatrixXYZ(postRotation[0] * d2r, postRotation[1] * d2r, postRotation[2] * d2r);
RpostInv = new Matrix();
Rpost.invertToRef(RpostInv);
}
else {
RpostInv = Matrix.Identity();
}
let result = SpInv;
result = result.multiply(S);
result = result.multiply(Sp);
result = result.multiply(Soff);
result = result.multiply(RpInv);
result = result.multiply(RpostInv);
result = result.multiply(R);
result = result.multiply(Rpre);
result = result.multiply(Rp);
result = result.multiply(Roff);
result = result.multiply(T);
return result;
}
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
const FBX_ASCII_MAGIC = "; FBX";
const FBX_BINARY_MAGIC = "Kaydara FBX Binary";
const BIND_REST_SCALE_RATIO_THRESHOLD = 10;
/**
* FBX file loader plugin for Babylon.js.
* Pure TypeScript implementation — no Autodesk FBX SDK dependency.
*/
class FBXFileLoader {
/**
* Creates a new FBX loader.
* @param options - Options controlling FBX loading behavior
*/
constructor(options = {}) {
/**
* Defines the name of the plugin.
*/
this.name = FBXFileLoaderMetadata.name;
/**
* Defines the extension the plugin is able to load.
*/
this.extensions = FBXFileLoaderMetadata.extensions;
this._bindRestBones = new WeakSet();
this._sourceBonesBySkeleton = new WeakMap();
this._scaleCompensationHelpersBySkeleton = new WeakMap();
this._options = {
normalMapCoordinateSystem: options.normalMapCoordinateSystem ?? "y-up",
};
}
/**
* Creates an FBX loader plugin instance with options from SceneLoader.
* @param options - Scene loader plugin options
* @returns The configured FBX loader
*/
createPlugin(options) {
return new FBXFileLoader(options[FBXFileLoaderMetadata.name]);
}
/**
* Imports meshes from an FBX file and adds them to the scene.
* @param meshesNames - A string or array of mesh names to import, or null/undefined to import all meshes
* @param scene - The scene to add imported meshes to
* @param data - The FBX data to load
* @param rootUrl - Root URL used to resolve external resources
* @param _onProgress - Callback called while the file is loading
* @param _fileName - Name of the file being loaded
* @returns A promise containing the loaded meshes, particle systems, skeletons, animation groups, transform nodes, geometries, and lights
*/
async importMeshAsync(meshesNames, scene, data, rootUrl, _onProgress, _fileName) {
const doc = this._parse(data);
const fbxScene = interpretFBX(doc);
return this._buildScene(fbxScene, scene, rootUrl, meshesNames);
}
/**
* Loads all FBX content into the scene.
* @param scene - The scene to load the FBX content into
* @param data - The FBX data to load
* @param rootUrl - Root URL used to resolve external resources
* @param _onProgress - Callback called while the file is loading
* @param _fileName - Name of the file being loaded
* @returns A promise that resolves when loading is complete
*/
async loadAsync(scene, data, rootUrl, _onProgress, _fileName) {
const doc = this._parse(data);
const fbxScene = interpretFBX(doc);
this._buildScene(fbxScene, scene, rootUrl, null);
}
/**
* Loads all FBX content into an asset container.
* @param scene - The scene used to create the asset container
* @param data - The FBX data to load
* @param rootUrl - Root URL used to resolve external resources
* @param _onProgress - Callback called while the file is loading
* @param _fileName - Name of the file being loaded
* @returns A promise containing the loaded asset container
*/
async loadAssetContainerAsync(scene, data, rootUrl, _onProgress, _fileName) {
const doc = this._parse(data);
const fbxScene = interpretFBX(doc);
const container = new AssetContainer(scene);
// Build the scene into a temporary holder, then move results to container
const result = this._buildScene(fbxScene, scene, rootUrl, null);
for (const mesh of result.meshes) {
container.meshes.push(mesh);
}
for (const skeleton of result.skeletons) {
container.skeletons.push(skeleton);
}
for (const ag of result.animationGroups) {
container.animationGroups.push(ag);
}
for (const tn of result.transformNodes) {
container.transformNodes.push(tn);
}
for (const light of result.lights) {
container.lights.push(light);
}
for (const camera of result.cameras) {
container.cameras.push(camera);
}
for (const material of result.materials) {
this._addMaterialToContainer(material, container);
}
for (const texture of result.textures) {
this._addTextureToContainer(texture, container);
}
for (const mesh of result.meshes) {
this._addMaterialToContainer(mesh.material, container);
}
// Remove all added objects from the scene (container owns them)
this._setAssetContainer(container);
container.removeAllFromScene();
return container;
}
// ── Parsing ────────────────────────────────────────────────────────────
_parse(data) {
if (data instanceof ArrayBuffer) {
return this._parseFromArrayBuffer(data);
}
if (ArrayBuffer.isView(data)) {
const view = data;
const buffer = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
return this._parseFromArrayBuffer(buffer);
}
if (typeof data === "string") {
return parseAsciiFBX(data);
}
throw new Error("FBXFileLoader: unsupported data type");
}
_parseFromArrayBuffer(buffer) {
// Check magic bytes to determine binary vs ASCII
const headerBytes = new Uint8Array(buffer, 0, Math.min(21, buffer.byteLength));
const header = String.fromCharCode(...headerBytes);
if (header.startsWith(FBX_BINARY_MAGIC)) {
return parseBinaryFBX(buffer);
}
// Try ASCII
const text = new TextDecoder("utf-8").decode(buffer);
if (text.trimStart().startsWith(FBX_ASCII_MAGIC)) {
return parseAsciiFBX(text);
}
throw new Error("FBXFileLoader: unrecognized FBX format");
}
// ── Scene Building ─────────────────────────────────────────────────────
_buildScene(fbxScene, scene, rootUrl, meshesNames) {
const nameFilter = this._buildNameFilter(meshesNames);
// Create materials
const materialCache = new Map();
for (const matData of fbxScene.materials) {
const material = this._createMaterial(matData, scene, rootUrl);
materialCache.set(matData.id, material);
}
// Create one Babylon skeleton per resolved deformation rig.
const skeletons = [];
const skeletonByRigId = new Map();
const skeletonByGeometryId = new Map();
const skinByGeometryId = new Map();
const skinBindingByGeometryId = new Map();
const skinById = new Map();
for (const skin of fbxScene.skins) {
skinById.set(skin.id, skin);
}
for (const rig of fbxScene.rigs) {
const skeleton = this._createSkeleton(rig.id, rig.bones, scene);
skeletons.push(skeleton);
skeletonByRigId.set(rig.id, skeleton);
for (const binding of rig.skinBindings) {
const skin = skinById.get(binding.skinId);
if (!skin) {
continue;
}
skeletonByGeometryId.set(binding.geometryId, skeleton);
skinByGeometryId.set(binding.geometryId, skin);
skinBindingByGeometryId.set(binding.geometryId, binding);
}
}
// Collect model data for animation sampling.
const modelIdToData = new Map();
const collectModelData = (models) => {
for (const m of models) {
modelIdToData.set(m.id, m);
collectModelData(m.children);
}
};
collectModelData(fbxScene.rootModels);
const cullingConflictMaterialIds = FBXFileLoader._collectCullingConflictMaterialIds(fbxScene.rootModels);
const cullingMaterialCloneCache = new Map();
// Build the FBX hierarchy under the same handedness conversion root that
// Babylon's glTF loader uses when loading right-handed assets into a
// left-handed scene. If the FBX file declares a non-Y-up scene basis,
// add a child axis-conversion root so model/bind math stays in FBX space.
const rootNode = new TransformNode("__fbx_root__", scene);
if (!scene.useRightHandedSystem) {
rootNode.rotation.y = Math.PI;
rootNode.scaling.z = -1;
}
const meshes = [];
const transformNodes = [rootNode];
let assetRoot = rootNode;
const axisConversion = FBXFileLoader._computeFBXAxisConversionMatrix(fbxScene);
if (!axisConversion.equals(Matrix.Identity())) {
assetRoot = new TransformNode("__fbx_axis_conversion__", scene);
assetRoot.parent = rootNode;
FBXFileLoader._applyMatrixToTransform(assetRoot, axisConversion);
transformNodes.push(assetRoot);
}
const modelIdToNode = new Map();
const fbxWorldIdentity = Matrix.Identity();
for (const model of fbxScene.rootModels) {
this._buildModel(model, scene, assetRoot, assetRoot, fbxWorldIdentity, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
}
this._linkSkeletonsToTransformNodes(fbxScene.rigs, skeletonByRigId, modelIdToNode, transformNodes, scene);
// Link non-skinned child meshes/nodes to their parent bones so they
// follow skeletal animation. Preserve their current world matrix when
// switching from the FBX model hierarchy to Babylon's bone parent.
for (const rig of fbxScene.rigs) {
const skeleton = skeletonByRigId.get(rig.id);
if (!skeleton) {
continue;
}
const skinnedMesh = meshes.find((m) => m.skeleton === skeleton) ?? null;
const boneReferenceNode = skinnedMesh ?? rootNode;
const boneTransformNodes = new Set();
for (const skeletonBone of skeleton.bones) {
const transformNode = skeletonBone.getTransformNode();
if (transformNode) {
boneTransformNodes.add(transformNode);
}
}
for (const boneData of rig.bones) {
if (!boneData.isCluster) {
continue;
}
const boneNode = modelIdToNode.get(boneData.modelId);
const bone = this._getSourceBone(skeleton, boneData.index);
if (!boneNode || !bone) {
continue;
}
// Find direct children of this bone's TransformNode that aren't bones themselves
for (const child of [...boneNode.getChildren()]) {
const childTransform = child;
if (!boneTransformNodes.has(childTransform)) {
const childWorld = childTransform.computeWorldMatrix(true).clone();
const boneReferenceWorld = FBXFileLoader._getBoneReferenceWorldMatrix(skeleton, bone, boneReferenceNode, skinnedMesh);
const boneReferenceWorldInv = new Matrix();
boneReferenceWorld.invertToRef(boneReferenceWorldInv);
const childLocalToBone = childWorld.multiply(boneReferenceWorldInv);
childTransform.parent = null;
childTransform.attachToBone(bone, boneReferenceNode);
FBXFileLoader._applyMatrixToTransform(childTransform, childLocalToBone);
}
}
}
}
// Apply blend shapes (morph targets) to meshes
if (fbxScene.blendShapes.length > 0) {
this._applyBlendShapes(fbxScene.blendShapes, meshes, scene);
}
// Create animation groups
const animationGroups = [];
for (const animStack of fbxScene.animations) {
const group = this._createAnimationGroup(animStack, fbxScene.rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes);
if (group) {
animationGroups.push(group);
}
}
// Create cameras
const cameras = [];
for (const camData of fbxScene.cameras) {
const cam = this._createCamera(camData, modelIdToNode, scene);
if (cam) {
cameras.push(cam);
}
}
// Create lights
const sceneLights = [];
for (const lightData of fbxScene.lights) {
const light = this._createLight(lightData, modelIdToNode, scene);
if (light) {
sceneLights.push(light);
}
}
return {
meshes,
particleSystems: [],
skeletons,
animationGroups,
transformNodes,
geometries: [],
lights: sceneLights,
spriteManagers: [],
materials: Array.from(materialCache.values()),
textures: Array.from(new Set(Array.from(materialCache.values()).flatMap((material) => material.getActiveTextures()))),
cameras,
};
}
_addMaterialToContainer(material, container) {
if (!material) {
return;
}
if (material instanceof MultiMaterial) {
if (!container.multiMaterials.includes(material)) {
container.multiMaterials.push(material);
}
for (const subMaterial of material.subMaterials) {
this._addMaterialToContainer(subMaterial, container);
}
}
else if (!container.materials.includes(material)) {
container.materials.push(material);
}
for (const texture of material.getActiveTextures()) {
this._addTextureToContainer(texture, container);
}
}
_addTextureToContainer(texture, container) {
if (!container.textures.includes(texture)) {
container.textures.push(texture);
}
}
_setAssetContainer(container) {
for (const asset of container.meshes) {
asset._parentContainer = container;
}
for (const asset of container.transformNodes) {
asset._parentContainer = container;
}
for (const asset of container.skeletons) {
asset._parentContainer = container;
}
for (const asset of container.animationGroups) {
asset._parentContainer = container;
}
for (const asset of container.lights) {
asset._parentContainer = container;
}
for (const asset of container.cameras) {
asset._parentContainer = container;
}
for (const asset of container.materials) {
asset._parentContainer = container;
}
for (const asset of container.multiMaterials) {
asset._parentContainer = container;
}
for (const asset of container.textures) {
asset._parentContainer = container;
}
}
static _computeFBXAxisConversionMatrix(fbxScene) {
const basisRows = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
];
const assignAxis = (sourceAxis, sourceSign, targetAxis) => {
if (sourceAxis < 0 || sourceAxis > 2) {
return;
}
const row = [0, 0, 0];
row[targetAxis] = sourceSign >= 0 ? 1 : -1;
basisRows[sourceAxis] = row;
};
assignAxis(fbxScene.coordAxis, fbxScene.coordAxisSign, 0);
assignAxis(fbxScene.upAxis, fbxScene.upAxisSign, 1);
assignAxis(fbxScene.frontAxis, fbxScene.frontAxisSign, 2);
if (basisRows.some((row) => row.every((value) => value === 0))) {
return Matrix.Identity();
}
return Matrix.FromValues(basisRows[0][0], basisRows[0][1], basisRows[0][2], 0, basisRows[1][0], basisRows[1][1], basisRows[1][2], 0, basisRows[2][0], basisRows[2][1], basisRows[2][2], 0, 0, 0, 0, 1);
}
_buildModel(model, scene, parent, assetRoot, parentFBXWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache) {
const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
const fbxWorldMatrix = localMatrix.multiply(parentFBXWorldMatrix);
if (model.geometry && model.subType === "Mesh" && (!nameFilter || nameFilter(model.name))) {
// Create mesh
const skeleton = skeletonByGeometryId.get(model.geometry.id);
const skin = skinByGeometryId.get(model.geometry.id);
const skinBinding = skinBindingByGeometryId.get(model.geometry.id);
if (skeleton && skin) {
skeleton.needInitialSkinMatrix = true;
}
const mesh = this._createMesh(model, model.geometry, scene, skeleton, skin, skinBinding);
// For skinned meshes: keep bind/pose math in FBX space, but parent
// the rendered mesh under the same conversion root as non-skinned
// meshes. The pose matrix cancels the real FBX mesh transform only;
// the root handedness conversion remains applied once at render time.
if (skeleton && skin) {
const meshBindMatrix = skin.meshBindPoseMatrix ? Matrix.FromArray(skin.meshBindPoseMatrix) : fbxWorldMatrix;
mesh.parent = assetRoot;
FBXFileLoader._applyMatrixToTransform(mesh, meshBindMatrix);
mesh.computeWorldMatrix(true);
mesh.updatePoseMatrix(Matrix.Invert(meshBindMatrix));
mesh.alwaysSelectAsActiveMesh = true;
}
else {
if (parent) {
mesh.parent = parent;
}
FBXFileLoader._applyFBXTransform(mesh, model);
}
// Apply material(s)
if (model.materials.length > 1 && model.geometry?.materialIndices) {
// Multi-material: create sub-meshes for each material
this._applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache);
}
else if (model.materials.length > 0) {
const mat = materialCache.get(model.materials[0].id);
if (mat) {
mesh.material = FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(model.materials[0].id));
}
}
if (model.geometry?.colors) {
this._useUnmodulatedVertexColorMaterials(mesh, scene);
}
this._applyMaterialUVSetCoordinates(mesh.material, model.geometry);
meshes.push(mesh);
modelIdToNode.set(model.id, mesh);
FBXFileLoader._applyModelMetadata(mesh, model);
// Recurse children
for (const child of model.children) {
this._buildModel(child, scene, mesh, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
}
}
else {
if (model.geometry && model.subType === "Mesh" && nameFilter && !FBXFileLoader._modelSubtreeMatchesNameFilter(model, nameFilter)) {
return;
}
// Transform node (Null type or no geometry)
const transformNode = new TransformNode(model.name, scene);
if (parent) {
transformNode.parent = parent;
}
// Apply full FBX transform chain
FBXFileLoader._applyFBXTransform(transformNode, model);
transformNodes.push(transformNode);
modelIdToNode.set(model.id, transformNode);
FBXFileLoader._applyModelMetadata(transformNode, model);
// Recurse children
for (const child of model.children) {
this._buildModel(child, scene, transformNode, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
}
}
}
_linkSkeletonsToTransformNodes(rigs, skeletonByRigId, modelIdToNode, transformNodes, scene) {
for (const rig of rigs) {
const skeleton = skeletonByRigId.get(rig.id);
if (!skeleton) {
continue;
}
for (const boneData of rig.bones) {
const bone = this._getSourceBone(skeleton, boneData.index);
const boneNode = modelIdToNode.get(boneData.modelId);
if (!bone || !boneNode) {
continue;
}
const scaleCompensationHelper = this._getScaleCompensationHelper(skeleton, boneData.index);
if (scaleCompensationHelper) {
const helperNode = new TransformNode(scaleCompensationHelper.name, scene);
helperNode.parent = boneNode.parent;
boneNode.parent = helperNode;
FBXFileLoader._applyMatrixToTransform(helperNode, scaleCompensationHelper.getLocalMatrix());
FBXFileLoader._applyMatrixToTransform(boneNode, bone.getLocalMatrix());
scaleCompensationHelper.linkTransformNode(helperNode);
transformNodes.push(helperNode);
}
else {
FBXFileLoader._applyMatrixToTransform(boneNode, bone.getLocalMatrix());
}
bone.linkTransformNode(boneNode);
}
}
}
static _modelSubtreeMatchesNameFilter(model, nameFilter) {
for (const child of model.children) {
if (child.geometry && child.subType === "Mesh" && nameFilter(child.name)) {
return true;
}
if (FBXFileLoader._modelSubtreeMatchesNameFilter(child, nameFilter)) {
return true;
}
}
return false;
}
static _applyModelMetadata(node, model) {
if (!model.customProperties && model.diagnostics.length === 0) {
return;
}
node.metadata = {
...(node.metadata ?? {}),
...(model.customProperties ? { fbxCustomProperties: model.customProperties } : {}),
...(model.diagnostics.length > 0 ? { fbxDiagnostics: model.diagnostics } : {}),
};
}
_createMesh(model, geomData, scene, skeleton, skin, skinBinding) {
const mesh = new Mesh(model.name, scene);
mesh.sideOrientation = scene.useRightHandedSystem ? Material.CounterClockWiseSideOrientation : Material.ClockWiseSideOrientation;
const vertexData = new VertexData();
// Convert Float64Array to Float32Array for Babylon
const positions = float64To32(geomData.positions);
const gt = model.geometricTranslation;
const gr = model.geometricRotation;
const gs = model.geometricScaling;
// Geometric transforms affect only this mesh's geometry, not children.
// Blender composes them as T * R * S; Babylon's row-vector equivalent is S * R * T.
const geometricPositionMatrix = FBXFileLoader._computeFBXGeometricMatrix(gt, gr, gs);
const geometricDeltaMatrix = FBXFileLoader._computeFBXGeometricDeltaMatrix(gr, gs);
const geometricNormalMatrix = FBXFileLoader._computeFBXGeometricNormalMatrix(gr, gs);
const hasGeometricPositionTransform = !geometricPositionMatrix.equals(Matrix.Identity());
const hasGeometricDeltaTransform = !geometricDeltaMatrix.equals(Matrix.Identity());
const hasGeometricNormalTransform = !geometricNormalMatrix.equals(Matrix.Identity());
if (hasGeometricPositionTransform) {
for (let i = 0; i < positions.length; i += 3) {
const v = Vector3.TransformCoordinates(new Vector3(positions[i], positions[i + 1], positions[i + 2]), geometricPositionMatrix);
positions[i] = v.x;
positions[i + 1] = v.y;
positions[i + 2] = v.z;
}
}
// For skinned meshes: do NOT bake mesh local transform into vertices.
// Vertices remain in their original mesh-local space, keeping the mesh data
// clean for retargeting. The mesh node carries its FBX transform as an
// initial pose, while TransformLink bind matrices handle skinning.
vertexData.positions = positions;
vertexData.indices = Array.from(geomData.indices);
let normals;
if (geomData.normals) {
normals = float64To32(geomData.normals);
if (hasGeometricNormalTransform) {
for (let i = 0; i < normals.length; i += 3) {
const n = Vector3.TransformNormal(new Vector3(normals[i], normals[i + 1], normals[i + 2]), geometricNormalMatrix);
if (n.lengthSquared() > 0) {
n.normalize();
}
normals[i] = n.x;
normals[i + 1] = n.y;
normals[i + 2] = n.z;
}
}
vertexData.normals = normals;
}
if (geomData.uvs) {
vertexData.uvs = float64To32(geomData.uvs);
}
if (geomData.uvSets.length > 1) {
vertexData.uvs2 = float64To32(geomData.uvSets[1].data);
}
if (geomData.uvSets.length > 2) {
vertexData.uvs3 = float64To32(geomData.uvSets[2].data);
}
if (geomData.uvSets.length > 3) {
vertexData.uvs4 = float64To32(geomData.uvSets[3].data);
}
if (geomData.uvSets.length > 4) {
vertexData.uvs5 = float64To32(geomData.uvSets[4].data);
}
if (geomData.uvSets.length > 5) {
vertexData.uvs6 = float64To32(geomData.uvSets[5].data);
}
if (geomData.tangents) {
const tangents = float64To32(geomData.tangents);
if (hasGeometricNormalTransform) {
for (let i = 0; i < tangents.length; i += 4) {
const t = Vector3.TransformNormal(new Vector3(tangents[i], tangents[i + 1], tangents[i + 2]), geometricNormalMatrix);
if (t.lengthSquared() > 0) {
t.normalize();
}
tangents[i] = t.x;
tangents[i + 1] = t.y;
tangents[i + 2] = t.z;
}
}
applyTangentHandednessScale(tangents, this._getNormalMapTangentHandednessScale());
vertexData.tangents = tangents;
}
else if (normals && vertexData.uvs) {
vertexData.tangents = generateTangents(positions, normals, vertexData.uvs, geomData.indices, this._getNormalMapTangentHandednessScale(), geomData.controlPointIndices, geomData.materialIndices);
}
if (geomData.colors) {
// Force alpha to 1.0 — FBX vertex color alpha is often unreliable
// (e.g. zeroed out by exporters) and would cause transparency sorting issues.
const colors = new Float32Array(geomData.colors.length);
for (let i = 0; i < colors.length; i += 4) {
colors[i] = geomData.colors[i];
colors[i + 1] = geomData.colors[i + 1];
colors[i + 2] = geomData.colors[i + 2];
colors[i + 3] = 1.0;
}
vertexData.colors = colors;
mesh.hasVertexAlpha = false;
}
// Apply bone weights if we have a skin
if (skeleton && skin) {
const { matricesIndices, matricesWeights, matricesIndicesExtra, matricesWeightsExtra, numBoneInfluencers } = this._buildSkinningData(geomData, skin, skinBinding);
vertexData.matricesIndices = matricesIndices;
vertexData.matricesWeights = matricesWeights;
if (matricesIndicesExtra && matricesWeightsExtra) {
vertexData.matricesIndicesExtra = matricesIndicesExtra;
vertexData.matricesWeightsExtra = matricesWeightsExtra;
}
mesh.numBoneInfluencers = numBoneInfluencers;
}
vertexData.applyToMesh(mesh);
// Store geometry metadata for blend shape matching
mesh.metadata = {
...(mesh.metadata ?? {}),
fbxGeometryId: geomData.id,
fbxControlPointIndices: geomData.controlPointIndices,
fbxGeometryDeltaMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null,
fbxGeometryNormalMatrix: hasGeometricNormalTransform ? geometricNormalMatrix : null,
// Back-compat for existing morph delta handling metadata.
fbxPreRotMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null,
};
if (skeleton) {
mesh.skeleton = skeleton;
}
return mesh;
}
/**
* Apply multi-material to a mesh by creating sub-meshes grouped by material index.
* Reorders the index buffer so that triangles sharing the same material are contiguous.
*/
_applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache) {
const matIndices = model.geometry.materialIndices;
const indices = mesh.getIndices();
if (!indices) {
return;
}
const triCount = indices.length / 3;
// Group triangles by material index
const groups = new Map(); // matIdx -> triangle indices
for (let ti = 0; ti < triCount; ti++) {
const matIdx = ti < matIndices.length ? matIndices[ti] : 0;
let group = groups.get(matIdx);
if (!group) {
group = [];
groups.set(matIdx, group);
}
group.push(ti);
}
// Sort group keys to ensure consistent ordering
const sortedMatIndices = Array.from(groups.keys()).sort((a, b) => a - b);
// Reorder index buffer so triangles are grouped by material
const newIndices = [];
const subMeshRanges = [];
for (const matIdx of sortedMatIndices) {
const tris = groups.get(matIdx);
const start = newIndices.length;
for (const ti of tris) {
newIndices.push(indices[ti * 3], indices[ti * 3 + 1], indices[ti * 3 + 2]);
}
subMeshRanges.push({ start, count: tris.length * 3, matIdx });
}
// Update the mesh's index buffer
mesh.setIndices(newIndices);
// Create MultiMaterial
const multiMat = new MultiMaterial(model.name + "_multi", scene);
for (const range of subMeshRanges) {
const fbxMat = model.materials[range.matIdx];
if (fbxMat) {
const mat = materialCache.get(fbxMat.id);
if (mat) {
multiMat.subMaterials.push(FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(fbxMat.id)));
}
else {
multiMat.subMaterials.push(null);
}
}
else {
multiMat.subMaterials.push(null);
}
}
mesh.material = multiMat;
// Clear existing sub-meshes and create new ones
mesh.subMeshes = [];
const vertexCount = mesh.getTotalVertices();
for (let i = 0; i < subMeshRanges.length; i++) {
const range = subMeshRanges[i];
new SubMesh(i, 0, vertexCount, range.start, range.count, mesh);
}
}
static _collectCullingConflictMaterialIds(models) {
// Deliberately scan the full scene, not just name-filtered models. This
// can over-clone for filtered imports, but avoids shared culling state.
const usage = new Map();
const collect = (model) => {
for (const material of model.materials) {
const state = usage.get(material.id) ?? { cullingOff: false, cullingOn: false };
if (model.cullingOff) {
state.cullingOff = true;
}
else {
state.cullingOn = true;
}
usage.set(material.id, state);
}
for (const child of model.children) {
collect(child);
}
};
for (const model of models) {
collect(model);
}
const conflicts = new Set();
for (const [materialId, state] of Array.from(usage)) {
if (state.cullingOff && state.cullingOn) {
conflicts.add(materialId);
}
}
return conflicts;
}
static _getModelMaterial(material, model, cullingCloneCache, cloneCullingOffMaterial = true) {
if (!model.cullingOff || !material.backFaceCulling) {
return material;
}
if (!cloneCullingOffMaterial) {
material.backFaceCulling = false;
return material;
}
const cached = cullingCloneCache?.get(material);
if (cached) {
return cached;
}
const clone = material.clone(`${material.name}_CullingOff`);
clone.backFaceCulling = false;
cullingCloneCache?.set(material, clone);
return clone;
}
_applyMaterialUVSetCoordinates(material, geometry) {
if (!material) {
return;
}
if (material instanceof MultiMaterial) {
for (const subMaterial of material.subMaterials) {
if (subMaterial instanceof StandardMaterial) {
this._applyStandardMaterialUVSetCoordinates(subMaterial, geometry);
}
}
return;
}
if (material instanceof StandardMaterial) {
this._applyStandardMaterialUVSetCoordinates(material, geometry);
}
}
_applyStandardMaterialUVSetCoordinates(material, geometry) {
for (const texture of [
material.diffuseTexture,
material.bumpTexture,
material.emissiveTexture,
material.ambientTexture,
material.specularTexture,
material.opacityTexture,
material.reflectionTexture,
]) {
if (!texture) {
continue;
}
const uvSetName = texture.metadata?.fbxUVSetName;
if (!uvSetName) {
continue;
}
const uvSetIndex = geometry.uvSets.findIndex((uvSet) => uvSet.name === uvSetName);
if (uvSetIndex >= 0) {
texture.coordinatesIndex = uvSetIndex;
}
}
}
/**
* Babylon multiplies vertex colors by material diffuse color. Use per-mesh
* material clones so vertex-colored geometry can render unmodulated without
* changing shared materials used by non-vertex-colored meshes.
*/
_useUnmodulatedVertexColorMaterials(mesh, scene) {
const assignedMat = mesh.material;
if (!assignedMat) {
return;
}
if (assignedMat instanceof StandardMaterial) {
if (!assignedMat.diffuseTexture) {
const clone = assignedMat.clone(`${assignedMat.name}_VertexColor`);
clone.diffuseColor = new Color3(1, 1, 1);
mesh.material = clone;
}
return;
}
if (assignedMat instanceof MultiMaterial) {
const multiMat = new MultiMaterial(`${assignedMat.name}_VertexColor`, scene);
multiMat.subMaterials = assignedMat.subMaterials.map((sub) => {
if (sub instanceof StandardMaterial && !sub.diffuseTexture) {
const clone = sub.clone(`${sub.name}_VertexColor`);
clone.diffuseColor = new Color3(1, 1, 1);
return clone;
}
return sub;
});
mesh.material = multiMat;
}
}
/**
* Build per-polygon-vertex bone indices and weights from the control-point-based skin data.
* The geometry expands control points to per-polygon-vertex, so we need to look up
* each polygon-vertex's control point index.
*/
_buildSkinningData(geomData, skin, skinBinding) {
// The positions array is per-polygon-vertex (already expanded).
// We need to figure out the control point index for each polygon vertex.
// The geometry stores positions per polygon-vertex, so geomData.positions.length/3
// = number of polygon vertices. We stored control point indices during expansion,
// but they aren't exported. Instead, we can use the fact that skin data is indexed
// by control point, and the geometry's _controlPointIndices stores this mapping.
//
// Since we don't have direct access to the control point mapping from FBXGeometryData,
// we'll use the vertex positions to build the skinning buffer. But actually,
// we should extend geometry to export control point indices per polygon-vertex.
//
// For now, use the approach of matching positions to control points.
// Actually, let's look at this differently - the indices/weights in the skin
// are per control point. The geometry already expanded to per polygon-vertex
// with positions copied from control points. We need to know which control point
// each polygon-vertex came from.
//
// We'll use geomData.controlPointIndices if available.
const vertexCount = geomData.positions.length / 3;
const matricesIndices = new Float32Array(vertexCount * 4);
const matricesWeights = new Float32Array(vertexCount * 4);
let matricesIndicesExtra = null;
let matricesWeightsExtra = null;
let numBoneInfluencers = 0;
if (geomData.controlPointIndices) {
for (let i = 0; i < vertexCount; i++) {
const cpIdx = geomData.controlPointIndices[i];
const boneIdx = skin.boneIndices[cpIdx] ?? [];
numBoneInfluencers = Math.max(numBoneInfluencers, Math.min(boneIdx.length, 8));
}
if (numBoneInfluencers > 4) {
matricesIndicesExtra = new Float32Array(vertexCount * 4);
matricesWeightsExtra = new Float32Array(vertexCount * 4);
}
for (let i = 0; i < vertexCount; i++) {
const cpIdx = geomData.controlPointIndices[i];
const boneIdx = skin.boneIndices[cpIdx] ?? [];
const boneWts = skin.boneWeights[cpIdx] ?? [];
for (let j = 0; j < 8; j++) {
const indicesBuffer = j < 4 ? matricesIndices : matricesIndicesExtra;
const weightsBuffer = j < 4 ? matricesWeights : matricesWeightsExtra;
if (!indicesBuffer || !weightsBuffer) {
continue;
}
const bufferIndex = i * 4 + (j % 4);
if (j < boneIdx.length) {
const skinBoneIndex = boneIdx[j];
const rigBoneIndex = skinBinding ? skinBinding.skinBoneIndexToRigBoneIndex[skinBoneIndex] : skinBoneIndex;
if (rigBoneIndex === undefined || rigBoneIndex < 0) {
throw new Error(`FBXFileLoader: missing rig bone mapping for skin bone index ${skinBoneIndex}`);
}
indicesBuffer[bufferIndex] = rigBoneIndex;
}
else {
indicesBuffer[bufferIndex] = 0;
}
weightsBuffer[bufferIndex] = j < boneWts.length ? boneWts[j] : 0;
}
}
}
return {
matricesIndices,
matricesWeights,
matricesIndicesExtra,
matricesWeightsExtra,
numBoneInfluencers: Math.max(numBoneInfluencers, 1),
};
}
_createMaterial(matData, scene, rootUrl) {
const material = new StandardMaterial(matData.name, scene);
const props = matData.properties;
const hasTexture = (...slots) => matData.textures.some((texture) => slots.includes(texture.propertyName));
if (matData.type === "Lambert") {
material.specularColor = Color3.Black();
}
if (props.diffuseColor) {
const diffuseFactor = hasTexture("DiffuseColor", "Diffuse") ? 1 : (props.diffuseFactor ?? 1);
material.diffuseColor = new Color3(props.diffuseColor[0] * diffuseFactor, props.diffuseColor[1] * diffuseFactor, props.diffuseColor[2] * diffuseFactor);
}
if (props.ambientColor) {
const ambientFactor = hasTexture("AmbientColor", "Ambient") ? 1 : (props.ambientFactor ?? 1);
material.ambientColor = new Color3(props.ambientColor[0] * ambientFactor, props.ambientColor[1] * ambientFactor, props.ambientColor[2] * ambientFactor);
}
if (matData.type === "Phong" && props.specularColor) {
const specularFactor = hasTexture("SpecularColor", "Specular", "Shininess", "ShininessExponent") ? 1 : (props.specularFactor ?? 1);
material.specularColor = new Color3(props.specularColor[0] * specularFactor, props.specularColor[1] * specularFactor, props.specularColor[2] * specularFactor);
}
if (props.emissiveColor) {
const emissiveFactor = hasTexture("EmissiveColor", "Emissive") ? 1 : (props.emissiveFactor ?? 1);
material.emissiveColor = new Color3(props.emissiveColor[0] * emissiveFactor, props.emissiveColor[1] * emissiveFactor, props.emissiveColor[2] * emissiveFactor);
}
if (props.opacity !== undefined) {
material.alpha = props.opacity;
}
else if (props.transparencyFactor !== undefined) {
material.alpha = 1 - props.transparencyFactor;
}
if (material.alpha < 1) {
material.transparencyMode = Material.MATERIAL_ALPHABLEND;
}
if (props.shininess !== undefined) {
material.specularPower = props.shininess;
}
// Apply textures
for (const tex of matData.textures) {
if (!FBXFileLoader._isSupportedMaterialTextureSlot(tex.propertyName)) {
continue;
}
const texture = FBXFileLoader._createTexture(tex, scene, rootUrl, FBXFileLoader._isNormalMapTextureSlot(tex.propertyName));
if (!texture) {
continue;
}
switch (tex.propertyName) {
case "DiffuseColor":
material.diffuseTexture = texture;
// In FBX, a connected diffuse texture provides the color.
// Set diffuseColor to white so the texture isn't darkened by
// the material's base color (many FBX exports set it near-black).
material.diffuseColor = new Color3(1, 1, 1);
break;
case "NormalMap":
case "NormalMapTexture":
case "normalCamera":
material.bumpTexture = texture;
this._configureNormalTexture(texture, material);
break;
case "Bump":
case "BumpFactor":
material.bumpTexture = texture;
this._configureNormalTexture(texture, material);
break;
case "EmissiveColor":
material.emissiveTexture = texture;
break;
case "AmbientColor":
material.ambientTexture = texture;
break;
case "SpecularColor":
material.specularTexture = texture;
break;
case "TransparencyFactor":
case "TransparentColor":
material.opacityTexture = texture;
material.transparencyMode = Material.MATERIAL_ALPHATESTANDBLEND;
break;
case "ReflectionColor":
case "ReflectionFactor":
material.reflectionTexture = texture;
break;
}
// Apply UV transforms
if (tex.uvTranslation) {
texture.uOffset = tex.uvTranslation[0];
texture.vOffset = tex.uvTranslation[1];
}
if (tex.uvScaling) {
texture.uScale = tex.uvScaling[0];
texture.vScale = tex.uvScaling[1];
}
if (tex.uvRotation !== undefined) {
texture.wAng = tex.uvRotation * (Math.PI / 180);
}
if (tex.uvSetIndex !== undefined) {
texture.coordinatesIndex = tex.uvSetIndex;
}
if (tex.uvSetName) {
texture.metadata = {
...(texture.metadata ?? {}),
fbxUVSetName: tex.uvSetName,
};
}
}
return material;
}
_configureNormalTexture(texture, material) {
texture.gammaSpace = false;
material.invertNormalMapX = false;
material.invertNormalMapY = this._options.normalMapCoordinateSystem === "y-down";
}
_getNormalMapTangentHandednessScale() {
return this._options.normalMapCoordinateSystem === "y-down" ? -1 : 1;
}
static _isSupportedMaterialTextureSlot(propertyName) {
switch (propertyName) {
case "DiffuseColor":
case "NormalMap":
case "NormalMapTexture":
case "normalCamera":
case "Bump":
case "BumpFactor":
case "EmissiveColor":
case "AmbientColor":
case "SpecularColor":
case "TransparencyFactor":
case "TransparentColor":
case "ReflectionColor":
case "ReflectionFactor":
case "DisplacementColor":
case "Displacement":
case "DisplacementFactor":
case "ShininessExponent":
case "Shininess":
return true;
default:
return false;
}
}
static _isNormalMapTextureSlot(propertyName) {
switch (propertyName) {
case "NormalMap":
case "NormalMapTexture":
case "normalCamera":
case "Bump":
case "BumpFactor":
return true;
default:
return false;
}
}
static _createTexture(tex, scene, rootUrl, isDataTexture) {
const sourceName = FBXFileLoader._getTextureSourceName(tex);
const creationOptions = FBXFileLoader._getTextureCreationOptions(sourceName, isDataTexture, tex.embeddedData);
if (tex.embeddedData) {
const texture = new Texture(null, scene, creationOptions);
const embeddedTextureName = sourceName ?? `embeddedTexture_${tex.id.toString()}`;
texture.updateURL(`data:fbx-embedded-texture/${encodeURIComponent(embeddedTextureName)}`, new Uint8Array(tex.embeddedData), undefined, creationOptions.forcedExtension);
texture.name = embeddedTextureName;
return texture;
}
const textureUrls = FBXFileLoader._getExternalTextureUrls(tex, rootUrl);
const textureUrl = textureUrls.shift();
if (!textureUrl) {
return null;
}
return FBXFileLoader._createExternalTexture(textureUrl, textureUrls, scene, creationOptions);
}
static _createExternalTexture(texturePath, fallbackUrls, scene, creationOptions) {
fallbackUrls.push(...FBXFileLoader._buildTextureFallbackUrls(texturePath));
let fallbackIndex = 0;
const texture = new Texture(texturePath, scene, {
...creationOptions,
onError: () => {
const fallbackUrl = fallbackUrls[fallbackIndex++];
if (fallbackUrl && texture.getScene()) {
texture.updateURL(fallbackUrl, null, undefined, FBXFileLoader._getForcedExtension(fallbackUrl));
}
},
});
return texture;
}
static _buildTextureFallbackUrls(texturePath) {
const slashIndex = Math.max(texturePath.lastIndexOf("/"), texturePath.lastIndexOf("\\"));
const dotIndex = texturePath.lastIndexOf(".");
if (dotIndex <= slashIndex) {
return [];
}
const basePath = texturePath.slice(0, dotIndex);
const currentExtension = texturePath.slice(dotIndex + 1).toLowerCase();
const extensionFallbacks = ["png", "jpg", "jpeg", "webp", "bmp", "tga"];
return extensionFallbacks.filter((extension) => extension !== currentExtension).map((extension) => `${basePath}.${extension}`);
}
static _getTextureCreationOptions(sourceName, isDataTexture, embeddedData) {
const mimeType = embeddedData ? (sourceName ? FBXFileLoader._getMimeType(sourceName) : "image/png") : undefined;
return {
buffer: embeddedData ? new Uint8Array(embeddedData) : undefined,
forcedExtension: sourceName ? FBXFileLoader._getForcedExtension(sourceName, mimeType) : embeddedData ? ".png" : undefined,
gammaSpace: !isDataTexture,
mimeType,
};
}
static _getExternalTextureUrls(tex, rootUrl) {
const textureNames = [tex.relativeFileName, tex.fileName].filter((name) => !!name);
const urls = [];
for (const textureName of textureNames) {
const normalized = textureName.replace(/\\/g, "/");
if (FBXFileLoader._isSafeRelativeTexturePath(normalized)) {
urls.push(rootUrl + normalized);
}
const basename = FBXFileLoader._getTextureSourceNameFromPath(normalized);
if (basename) {
urls.push(rootUrl + basename);
}
}
return Array.from(new Set(urls));
}
static _getTextureSourceName(tex) {
const textureName = tex.relativeFileName || tex.fileName;
if (!textureName) {
return null;
}
const normalized = textureName.replace(/\\/g, "/");
return FBXFileLoader._getTextureSourceNameFromPath(normalized);
}
static _getTextureSourceNameFromPath(texturePath) {
return texturePath.split("/").pop() ?? texturePath;
}
static _isSafeRelativeTexturePath(texturePath) {
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(texturePath) || texturePath.startsWith("/") || texturePath.startsWith("//")) {
return false;
}
return !texturePath.split("/").some((part) => part === "..");
}
static _getForcedExtension(fileName, mimeType) {
const slashIndex = Math.max(fileName.lastIndexOf("/"), fileName.lastIndexOf("\\"));
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex > slashIndex) {
return fileName.slice(dotIndex).toLowerCase();
}
switch (mimeType) {
case "image/png":
return ".png";
case "image/jpeg":
return ".jpg";
case "image/webp":
return ".webp";
case "image/bmp":
return ".bmp";
case "image/gif":
return ".gif";
case "image/x-tga":
return ".tga";
default:
return undefined;
}
}
static _getMimeType(fileName) {
const mimeType = GetMimeType(fileName);
if (mimeType) {
return mimeType;
}
const extension = FBXFileLoader._getForcedExtension(fileName);
switch (extension) {
case ".tga":
return "image/x-tga";
case ".bmp":
return "image/bmp";
case ".gif":
return "image/gif";
default:
return "image/png";
}
}
/**
* Apply blend shape (morph target) deformers to meshes.
* FBX Shape vertices are stored as absolute positions for sparse control points.
* We compute deltas relative to the base mesh positions.
*/
_applyBlendShapes(blendShapes, meshes, scene) {
// Build a map from geometry ID to mesh (using the mesh metadata we'll need to store)
// The mesh's geometry ID is tracked through the model hierarchy during _buildModel.
// We need to match blendShape.geometryId to the correct mesh.
// Strategy: match by examining which meshes have positions matching the geometry.
for (const bs of blendShapes) {
// Find the mesh that uses this geometry
const mesh = meshes.find((m) => {
const geomId = m.metadata?.fbxGeometryId;
return geomId === bs.geometryId;
});
if (!mesh) {
continue;
}
const morphTargetManager = new MorphTargetManager(scene);
morphTargetManager.optimizeInfluencers = false;
// Get preRotation matrix if the mesh had its positions baked
const deltaMatrix = mesh.metadata?.fbxGeometryDeltaMatrix ??
mesh.metadata?.fbxPreRotMatrix ??
null;
const normalMatrix = mesh.metadata?.fbxGeometryNormalMatrix ?? deltaMatrix;
for (const channel of bs.channels) {
// Get the control point indices for this mesh (stored as metadata)
const cpIndices = mesh.metadata?.fbxControlPointIndices;
if (!cpIndices) {
continue;
}
const basePositions = mesh.getVerticesData("position");
const baseNormals = mesh.getVerticesData("normal");
if (!basePositions) {
continue;
}
const initialInfluences = calculateBlendShapeInfluences(channel.deformPercent, channel.fullWeights, channel.shapes.length);
const targetIndices = [];
for (let shapeIndex = 0; shapeIndex < channel.shapes.length; shapeIndex++) {
const shape = channel.shapes[shapeIndex];
if (!shape) {
continue;
}
const targetData = buildMorphTargetData(shape, cpIndices, basePositions, baseNormals, deltaMatrix, normalMatrix);
if (!targetData) {
continue;
}
const targetName = channel.fullWeights && channel.shapes.length > 1 ? `${channel.name}_${channel.fullWeights[shapeIndex]}` : channel.name;
const morphTarget = new MorphTarget(targetName, initialInfluences[shapeIndex] ?? 0, scene);
morphTarget.setPositions(targetData.positions);
if (targetData.normals) {
morphTarget.setNormals(targetData.normals);
}
targetIndices.push(morphTargetManager.numTargets);
morphTargetManager.addTarget(morphTarget);
}
if (targetIndices.length === 0) {
continue;
}
// Store channel ID mapping on the mesh for animation targeting.
// Keep the legacy single-target map for existing consumers and add
// richer in-between metadata for FullWeights-aware animation baking.
if (!mesh.metadata) {
mesh.metadata = {};
}
if (!mesh.metadata.fbxBlendShapeChannelIds) {
mesh.metadata.fbxBlendShapeChannelIds = new Map();
}
mesh.metadata.fbxBlendShapeChannelIds.set(channel.id, targetIndices[0]);
if (!mesh.metadata.fbxBlendShapeChannelTargets) {
mesh.metadata.fbxBlendShapeChannelTargets = new Map();
}
mesh.metadata.fbxBlendShapeChannelTargets.set(channel.id, {
targetIndices,
fullWeights: channel.fullWeights,
});
}
if (morphTargetManager.numTargets > 0) {
morphTargetManager.numMaxInfluencers = morphTargetManager.numTargets;
mesh.morphTargetManager = morphTargetManager;
}
}
}
_createCamera(camData, modelIdToNode, scene) {
const parentNode = modelIdToNode.get(camData.modelId);
const worldMatrix = parentNode ? parentNode.computeWorldMatrix(true) : Matrix.Identity();
const position = Vector3.TransformCoordinates(Vector3.Zero(), worldMatrix);
const camera = new FreeCamera(camData.name, position, scene);
camera.fov = camData.fieldOfView * (Math.PI / 180);
camera.minZ = camData.nearPlane;
camera.maxZ = camData.farPlane;
camera.metadata = {
...(camera.metadata ?? {}),
fbxCamera: {
projectionType: camData.projectionType,
focalLength: camData.focalLength,
filmWidth: camData.filmWidth,
filmHeight: camData.filmHeight,
orthoZoom: camData.orthoZoom,
roll: camData.roll,
aspectRatio: camData.aspectRatio,
unknownProperties: camData.unknownProperties,
diagnostics: camData.diagnostics,
},
};
if (camData.projectionType === "orthographic") {
const orthoHeight = camData.orthoZoom && camData.orthoZoom > 0 ? camData.orthoZoom : 1;
const aspect = camData.aspectRatio > 0 ? camData.aspectRatio : 1;
camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
camera.orthoTop = orthoHeight / 2;
camera.orthoBottom = -orthoHeight / 2;
camera.orthoRight = (orthoHeight * aspect) / 2;
camera.orthoLeft = -(orthoHeight * aspect) / 2;
}
// FBX cameras look down their local +X axis. Derive the world-space look-at target from the
// node's world matrix using point transforms so the file's handedness conversion (the
// left-handed root applies scaling.z = -1) is reproduced correctly. Transforming a direction
// with the rotation alone would mirror it under that reflection and aim the camera wrongly.
const target = Vector3.TransformCoordinates(new Vector3(1, 0, 0), worldMatrix);
camera.setTarget(target);
return camera;
}
_createLight(lightData, modelIdToNode, scene) {
const parentNode = modelIdToNode.get(lightData.modelId);
const worldMatrix = parentNode ? parentNode.computeWorldMatrix(true) : Matrix.Identity();
const position = Vector3.TransformCoordinates(Vector3.Zero(), worldMatrix);
const color = new Color3(lightData.color[0], lightData.color[1], lightData.color[2]);
// FBX lights point down their local -Z axis. Derive the world-space direction from two points
// transformed by the node's world matrix so the handedness conversion (the left-handed root
// applies scaling.z = -1) is reproduced correctly; transforming the direction as a normal
// would mirror it under that reflection and point the light the wrong way.
const forwardPoint = Vector3.TransformCoordinates(new Vector3(0, 0, -1), worldMatrix);
const direction = forwardPoint.subtract(position).normalize();
let light;
switch (lightData.lightType) {
case 1: // Directional
light = new DirectionalLight(lightData.name, direction, scene);
light.diffuse = color;
light.intensity = lightData.intensity;
break;
case 2: {
// Spot
const angle = lightData.coneAngle * (Math.PI / 180);
light = new SpotLight(lightData.name, position, direction, angle, 2, scene);
light.diffuse = color;
light.intensity = lightData.intensity;
break;
}
default: // Point (0)
light = new PointLight(lightData.name, position, scene);
light.diffuse = color;
light.intensity = lightData.intensity;
break;
}
light.metadata = {
...(light.metadata ?? {}),
fbxLight: {
lightType: lightData.lightType,
decayType: lightData.decayType,
decayStart: lightData.decayStart,
innerAngle: lightData.innerAngle,
outerAngle: lightData.outerAngle,
enableNearAttenuation: lightData.enableNearAttenuation,
enableFarAttenuation: lightData.enableFarAttenuation,
castShadows: lightData.castShadows,
unknownProperties: lightData.unknownProperties,
diagnostics: lightData.diagnostics,
},
};
return light;
}
_createSkeleton(skeletonId, bones, scene) {
const skeleton = new Skeleton("Skeleton", `skeleton_${skeletonId}`, scene);
const sourceBones = [];
const scaleCompensationHelpers = new Map();
const authoredLocalMatrices = [];
const authoredAbsoluteMatrices = [];
const authoredRuntimeLocalMatrices = [];
// Compute authored Lcl matrices for bones that do not carry FBX bind data.
for (let i = 0; i < bones.length; i++) {
const boneData = bones[i];
const authoredLocal = FBXFileLoader._computeFBXLocalMatrix(boneData.translation, boneData.rotation, boneData.scale, boneData.preRotation, boneData.postRotation, boneData.rotationPivot, boneData.scalingPivot, boneData.rotationOffset, boneData.scalingOffset, boneData.rotationOrder);
authoredLocalMatrices[i] = authoredLocal;
authoredRuntimeLocalMatrices[i] = FBXFileLoader._computeFBXRuntimeLocalMatrix(bones, authoredLocal, i);
}
authoredAbsoluteMatrices.push(...FBXFileLoader._computeFBXAbsoluteMatrices(bones, authoredRuntimeLocalMatrices));
const absoluteBindMatrices = bones.map((boneData, index) => boneData.transformLinkMatrix
? Matrix.FromArray(boneData.transformLinkMatrix)
: boneData.modelBindPoseMatrix
? Matrix.FromArray(boneData.modelBindPoseMatrix)
: authoredAbsoluteMatrices[index]);
const localBindMatrices = absoluteBindMatrices.map((absoluteBind, index) => {
const parentIndex = bones[index].parentIndex;
if (parentIndex < 0) {
return absoluteBind;
}
const parentAbsoluteBindInv = new Matrix();
absoluteBindMatrices[parentIndex].invertToRef(parentAbsoluteBindInv);
return absoluteBind.multiply(parentAbsoluteBindInv);
});
const useBindAsRest = FBXFileLoader._shouldUseBindMatricesAsRest(bones, authoredLocalMatrices, localBindMatrices);
// Most animation curves naturally target authored Lcl transforms. Use
// bind matrices as live rest pose only for rigs with severe bind/local
// scale disagreement, which otherwise produce invalid skin matrices.
// Only bones with that scale disagreement need their animation curves
// remapped into bind-rest space; ordinary child curves are already in
// the expected local animation space.
for (let i = 0; i < bones.length; i++) {
let localMatrix = useBindAsRest ? localBindMatrices[i] : authoredRuntimeLocalMatrices[i];
let parentBone = bones[i].parentIndex >= 0 ? sourceBones[bones[i].parentIndex] : null;
if (!useBindAsRest && bones[i].inheritType === 2 && bones[i].parentIndex >= 0 && parentBone) {
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(authoredLocalMatrices[i], bones[bones[i].parentIndex].scale);
const helper = new Bone(`${bones[i].name}__fbx_scaleCompensation`, skeleton, parentBone, split.helperLocalMatrix, split.helperLocalMatrix.clone(), Matrix.Identity(), -1);
helper.metadata = {
...(helper.metadata ?? {}),
fbxScaleCompensationForBoneIndex: i,
fbxScaleCompensationForBoneName: bones[i].name,
};
scaleCompensationHelpers.set(i, helper);
parentBone = helper;
localMatrix = split.boneLocalMatrix;
}
const bone = new Bone(bones[i].name, skeleton, parentBone, localMatrix, useBindAsRest ? localMatrix.clone() : null, useBindAsRest ? localMatrix.clone() : null, i);
if (useBindAsRest && bones[i].isCluster && FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[i], localBindMatrices[i]) >= BIND_REST_SCALE_RATIO_THRESHOLD) {
this._bindRestBones.add(bone);
}
sourceBones.push(bone);
}
this._sourceBonesBySkeleton.set(skeleton, sourceBones);
this._scaleCompensationHelpersBySkeleton.set(skeleton, scaleCompensationHelpers);
if (!useBindAsRest) {
for (let i = 0; i < bones.length; i++) {
const bone = sourceBones[i];
bone.updateMatrix(localBindMatrices[i], false, false);
}
for (const helper of Array.from(scaleCompensationHelpers.values())) {
helper.updateMatrix(Matrix.Identity(), false, false);
}
for (const bone of skeleton.bones) {
if (!bone.getParent()) {
bone._updateAbsoluteBindMatrices(undefined, true);
}
}
}
return skeleton;
}
_getSourceBone(skeleton, sourceIndex) {
return this._sourceBonesBySkeleton.get(skeleton)?.[sourceIndex] ?? skeleton.bones[sourceIndex];
}
_getScaleCompensationHelper(skeleton, sourceIndex) {
return this._scaleCompensationHelpersBySkeleton.get(skeleton)?.get(sourceIndex);
}
static _computeFBXAbsoluteMatrices(bones, localMatrices) {
const absoluteMatrices = [];
for (let i = 0; i < bones.length; i++) {
const parentIndex = bones[i].parentIndex;
if (parentIndex < 0) {
absoluteMatrices[i] = localMatrices[i].clone();
continue;
}
absoluteMatrices[i] = localMatrices[i].multiply(absoluteMatrices[parentIndex]);
}
return absoluteMatrices;
}
static _computeFBXRuntimeLocalMatrix(bones, localMatrix, index, parentScaleOverride) {
const parentIndex = bones[index].parentIndex;
if (bones[index].inheritType !== 2 || parentIndex < 0) {
return localMatrix;
}
const parentScale = parentScaleOverride ?? bones[parentIndex].scale;
return FBXFileLoader._applyParentScaleCompensation(localMatrix, parentScale);
}
static _applyParentScaleCompensation(localMatrix, parentScale) {
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale);
return split.boneLocalMatrix.multiply(split.helperLocalMatrix);
}
static _splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale) {
const translation = localMatrix.getTranslation();
const boneLocalMatrix = localMatrix.clone();
boneLocalMatrix.setTranslation(Vector3.Zero());
const helperLocalMatrix = Matrix.Compose(FBXFileLoader._getInverseScaleVector(parentScale), Quaternion.Identity(), translation);
return { boneLocalMatrix, helperLocalMatrix };
}
static _safeInverseScale(value) {
return Math.abs(value) > 1e-8 ? 1 / value : 1;
}
static _getInverseScaleVector(scale) {
return new Vector3(FBXFileLoader._safeInverseScale(scale[0]), FBXFileLoader._safeInverseScale(scale[1]), FBXFileLoader._safeInverseScale(scale[2]));
}
static _shouldUseBindMatricesAsRest(bones, authoredLocalMatrices, localBindMatrices) {
return bones.some((bone, index) => {
if (!bone.isCluster) {
return false;
}
return FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[index], localBindMatrices[index]) >= BIND_REST_SCALE_RATIO_THRESHOLD;
});
}
static _getMaxScaleRatio(a, b) {
const scaleA = new Vector3();
const rotationA = new Quaternion();
const translationA = new Vector3();
const scaleB = new Vector3();
const rotationB = new Quaternion();
const translationB = new Vector3();
a.decompose(scaleA, rotationA, translationA);
b.decompose(scaleB, rotationB, translationB);
return Math.max(FBXFileLoader._getScaleRatio(scaleA.x, scaleB.x), FBXFileLoader._getScaleRatio(scaleA.y, scaleB.y), FBXFileLoader._getScaleRatio(scaleA.z, scaleB.z));
}
static _getScaleRatio(a, b) {
const absA = Math.abs(a);
const absB = Math.abs(b);
if (absA < 1e-6 || absB < 1e-6) {
return absA < 1e-6 && absB < 1e-6 ? 1 : Number.POSITIVE_INFINITY;
}
return Math.max(absA / absB, absB / absA);
}
static _computeFBXGeometricMatrix(translation, rotation, scale) {
return computeFBXGeometricMatrix(translation, rotation, scale);
}
static _computeFBXGeometricDeltaMatrix(rotation, scale) {
return computeFBXGeometricDeltaMatrix(rotation, scale);
}
static _computeFBXGeometricNormalMatrix(rotation, scale) {
return computeFBXGeometricNormalMatrix(rotation, scale);
}
/**
* Compute the full FBX local transform matrix:
* M = T * Roff * Rp * Rpre * R * Rpost^-1 * Rp^-1 * Soff * Sp * S * Sp^-1
*
* In row-vector convention: v' = v * M
*/
static _computeFBXLocalMatrix(translation, rotation, scale, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, rotationOrder = 0) {
return computeFBXLocalMatrix({
translation,
rotation,
scale,
preRotation,
postRotation,
rotationPivot,
scalingPivot,
rotationOffset,
scalingOffset,
rotationOrder,
});
}
/**
* Apply the FBX transform chain to a Babylon TransformNode or Mesh.
* Decomposes the full local matrix into position/rotation/scale.
*/
static _applyFBXTransform(node, model) {
const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
// Decompose into TRS
const s = new Vector3();
const r = new Quaternion();
const t = new Vector3();
localMatrix.decompose(s, r, t);
node.position = t;
node.rotationQuaternion = r;
node.scaling = s;
}
static _computeFBXModelLocalMatrix(model) {
return FBXFileLoader._computeFBXLocalMatrix(model.translation, model.rotation, model.scale, model.preRotation, model.postRotation, model.rotationPivot, model.scalingPivot, model.rotationOffset, model.scalingOffset, model.rotationOrder);
}
static _getBoneReferenceWorldMatrix(skeleton, bone, referenceNode, skinnedMesh) {
if (skinnedMesh) {
skeleton.getTransformMatrices(skinnedMesh);
}
else {
skeleton.prepare(true);
}
referenceNode.computeWorldMatrix(true);
return bone.getFinalMatrix().multiply(referenceNode.getWorldMatrix());
}
static _applyMatrixToTransform(node, matrix) {
const s = new Vector3();
const r = new Quaternion();
const t = new Vector3();
matrix.decompose(s, r, t);
node.position = t;
node.rotationQuaternion = r;
node.scaling = s;
}
_createAnimationGroup(animStack, rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes) {
if (animStack.curveNodes.length === 0) {
return null;
}
const animGroup = new AnimationGroup(animStack.name, scene);
const animatedBoneTargetProperties = new Map();
const addBoneAnimation = (animation, bone) => {
const target = bone.getTransformNode() ?? bone;
let targetProperties = animatedBoneTargetProperties.get(target);
if (!targetProperties) {
targetProperties = new Set();
animatedBoneTargetProperties.set(target, targetProperties);
}
else if (targetProperties.has(animation.targetProperty)) {
return;
}
targetProperties.add(animation.targetProperty);
animGroup.addTargetedAnimation(animation, target);
};
// Build a map from model ID to resolved rig bones. A single FBX model ID
// should only appear once per resolved rig, but keeping an array preserves
// the previous animation fan-out behavior for any future duplicate rigs.
const modelIdToBones = new Map();
for (const rig of rigs) {
const skeleton = skeletonByRigId.get(rig.id);
if (!skeleton) {
continue;
}
for (const boneData of rig.bones) {
const bone = this._getSourceBone(skeleton, boneData.index);
if (!bone) {
continue;
}
const bones = modelIdToBones.get(boneData.modelId);
if (bones) {
bones.push(bone);
}
else {
modelIdToBones.set(boneData.modelId, [bone]);
}
}
}
// Group curve nodes by target
const boneCurves = new Map();
const nonBoneCurves = new Map();
const blendShapeCurves = [];
for (const curveNode of animStack.curveNodes) {
if (curveNode.type === "DeformPercent") {
blendShapeCurves.push(curveNode);
continue;
}
if (modelIdToBones.has(curveNode.targetModelId)) {
if (!boneCurves.has(curveNode.targetModelId)) {
boneCurves.set(curveNode.targetModelId, []);
}
boneCurves.get(curveNode.targetModelId).push(curveNode);
}
else {
if (!nonBoneCurves.has(curveNode.targetModelId)) {
nonBoneCurves.set(curveNode.targetModelId, []);
}
nonBoneCurves.get(curveNode.targetModelId).push(curveNode);
}
}
// Process bone targets: compute full FBX local matrix per frame, decompose to TRS.
// For bind-rest rigs, only the bones recorded in _bindRestBones need their
// authored Lcl curves remapped onto the bind-rest local space.
const inheritedRigModelIds = new Set();
for (const rig of rigs) {
const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType === 2).map((bone) => bone.modelId));
if (inheritType2ModelIds.size === 0) {
continue;
}
const skeleton = skeletonByRigId.get(rig.id);
if (!skeleton) {
continue;
}
if (skeleton.bones.some((bone) => this._bindRestBones.has(bone))) {
continue;
}
for (const modelId of Array.from(inheritType2ModelIds)) {
inheritedRigModelIds.add(modelId);
}
for (const { bone, animations } of this._buildInheritedRigBoneAnimations(rig, skeleton, boneCurves, modelIdToData, inheritType2ModelIds, animStack.startTime, animStack.stopTime)) {
for (const animation of animations) {
addBoneAnimation(animation, bone);
}
}
}
for (const [targetId, curveNodes] of Array.from(boneCurves)) {
if (inheritedRigModelIds.has(targetId)) {
continue;
}
const bones = modelIdToBones.get(targetId);
const modelData = modelIdToData.get(targetId);
if (!bones || bones.length === 0 || !modelData) {
continue;
}
for (const bone of bones) {
const animations = this._buildBoneAnimations(curveNodes, bone.name, modelData, animStack.startTime, animStack.stopTime, this._bindRestBones.has(bone) ? bone.getBindMatrix() : undefined);
for (const animation of animations) {
addBoneAnimation(animation, bone);
}
}
}
// Process non-bone targets: bake full transform matrix per frame
for (const [targetId, curveNodes] of Array.from(nonBoneCurves)) {
const node = modelIdToNode.get(targetId);
if (!node) {
continue;
}
const modelData = modelIdToData.get(targetId);
if (!modelData) {
continue;
}
const animations = this._buildNodeAnimations(curveNodes, node.name, modelData, animStack.startTime, animStack.stopTime);
for (const animation of animations) {
animGroup.addTargetedAnimation(animation, node);
}
}
// Process blend shape (morph target) animations
for (const curveNode of blendShapeCurves) {
const targetChannelId = curveNode.targetModelId;
// Find the morph target with matching channel ID across all meshes
let targetFound = false;
for (const mesh of meshes) {
if (!mesh.morphTargetManager || targetFound) {
continue;
}
const metadata = mesh.metadata;
const channelTargets = metadata?.fbxBlendShapeChannelTargets;
const targetInfo = channelTargets?.get(targetChannelId);
if (targetInfo && curveNode.curves.length > 0) {
const fps = 30;
for (let shapeIndex = 0; shapeIndex < targetInfo.targetIndices.length; shapeIndex++) {
const target = mesh.morphTargetManager.getTarget(targetInfo.targetIndices[shapeIndex]);
if (!target) {
continue;
}
const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => calculateBlendShapeInfluences(value, targetInfo.fullWeights, targetInfo.targetIndices.length)[shapeIndex] ?? 0);
anim.setKeys(keys);
animGroup.addTargetedAnimation(anim, target);
}
targetFound = true;
continue;
}
const channelMap = metadata?.fbxBlendShapeChannelIds;
if (!channelMap) {
continue;
}
const targetIndex = channelMap.get(targetChannelId);
if (targetIndex === undefined) {
continue;
}
const target = mesh.morphTargetManager.getTarget(targetIndex);
if (target && curveNode.curves.length > 0) {
const fps = 30;
const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => value / 100);
anim.setKeys(keys);
animGroup.addTargetedAnimation(anim, target);
targetFound = true;
}
}
}
// Normalize the animation group
if (animGroup.targetedAnimations.length > 0) {
animGroup.normalize(animStack.startTime * 30, animStack.stopTime * 30);
return animGroup;
}
animGroup.dispose();
return null;
}
_buildInheritedRigBoneAnimations(rig, skeleton, boneCurves, modelIdToData, compensatedModelIds, startTime, stopTime) {
const fps = 30;
const sampledModelIds = new Set();
for (let i = 0; i < rig.bones.length; i++) {
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
continue;
}
for (let parentIndex = i; parentIndex >= 0; parentIndex = rig.bones[parentIndex].parentIndex) {
sampledModelIds.add(rig.bones[parentIndex].modelId);
}
}
const rigCurveNodes = rig.bones.filter((bone) => sampledModelIds.has(bone.modelId)).flatMap((bone) => boneCurves.get(bone.modelId) ?? []);
const times = collectAnimationSampleTimes(rigCurveNodes, fps, startTime, stopTime);
if (times.length === 0) {
return [];
}
const keysByBone = rig.bones.map(() => ({
posKeys: [],
rotKeys: [],
sclKeys: [],
prevQuat: null,
}));
const keysByHelper = rig.bones.map(() => ({
posKeys: [],
rotKeys: [],
sclKeys: [],
prevQuat: null,
}));
const restLocalInverses = rig.bones.map((boneData, index) => {
const bone = this._getSourceBone(skeleton, index);
const modelData = modelIdToData.get(boneData.modelId);
if (!bone || !modelData || !this._bindRestBones.has(bone)) {
return null;
}
const restLocalMatrix = FBXFileLoader._computeFBXModelLocalMatrix(modelData);
const restLocalInverse = new Matrix();
restLocalMatrix.invertToRef(restLocalInverse);
return restLocalInverse;
});
for (const time of times) {
const localMatrices = rig.bones.map((boneData, index) => {
const modelData = modelIdToData.get(boneData.modelId);
const curveNodes = boneCurves.get(boneData.modelId) ?? [];
let localMatrix = modelData ? this._sampleModelLocalMatrix(modelData, curveNodes, time) : Matrix.Identity();
const restLocalInverse = restLocalInverses[index];
if (restLocalInverse) {
const sourceBone = this._getSourceBone(skeleton, index);
localMatrix = (sourceBone?.getBindMatrix() ?? Matrix.Identity()).multiply(restLocalInverse).multiply(localMatrix);
}
return localMatrix;
});
const sampledScales = rig.bones.map((boneData) => {
const modelData = modelIdToData.get(boneData.modelId);
const curveNodes = boneCurves.get(boneData.modelId) ?? [];
return modelData ? this._sampleModelScale(modelData, curveNodes, time) : boneData.scale;
});
const frame = time * fps;
for (let i = 0; i < localMatrices.length; i++) {
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
continue;
}
const parentIndex = rig.bones[i].parentIndex;
const parentScale = parentIndex >= 0 ? sampledScales[parentIndex] : rig.bones[i].scale;
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrices[i], parentScale);
FBXFileLoader._pushMatrixKeys(keysByBone[i], frame, split.boneLocalMatrix);
FBXFileLoader._pushMatrixKeys(keysByHelper[i], frame, split.helperLocalMatrix);
}
}
const result = [];
for (let i = 0; i < rig.bones.length; i++) {
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
continue;
}
const bone = this._getSourceBone(skeleton, i);
if (!bone) {
continue;
}
const { posKeys, rotKeys, sclKeys } = keysByBone[i];
const animations = [];
if (!this._isVector3KeysConstant(posKeys)) {
const posAnim = new Animation(`${bone.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
posAnim.setKeys(posKeys);
animations.push(posAnim);
}
if (!areQuaternionKeysConstant(rotKeys)) {
const rotAnim = new Animation(`${bone.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
rotAnim.setKeys(rotKeys);
animations.push(rotAnim);
}
if (!this._isVector3KeysConstant(sclKeys)) {
const sclAnim = new Animation(`${bone.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
sclAnim.setKeys(sclKeys);
animations.push(sclAnim);
}
if (animations.length > 0) {
result.push({ bone, animations });
}
const helper = this._getScaleCompensationHelper(skeleton, i);
if (!helper) {
continue;
}
const helperAnimations = [];
const { posKeys: helperPosKeys, rotKeys: helperRotKeys, sclKeys: helperSclKeys } = keysByHelper[i];
if (!this._isVector3KeysConstant(helperPosKeys)) {
const posAnim = new Animation(`${helper.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
posAnim.setKeys(helperPosKeys);
helperAnimations.push(posAnim);
}
if (!areQuaternionKeysConstant(helperRotKeys)) {
const rotAnim = new Animation(`${helper.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
rotAnim.setKeys(helperRotKeys);
helperAnimations.push(rotAnim);
}
if (!this._isVector3KeysConstant(helperSclKeys)) {
const sclAnim = new Animation(`${helper.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
sclAnim.setKeys(helperSclKeys);
helperAnimations.push(sclAnim);
}
if (helperAnimations.length > 0) {
result.push({ bone: helper, animations: helperAnimations });
}
}
return result;
}
static _pushMatrixKeys(keySet, frame, matrix) {
const s = new Vector3();
const r = new Quaternion();
const t = new Vector3();
matrix.decompose(s, r, t);
if (keySet.prevQuat && Quaternion.Dot(keySet.prevQuat, r) < 0) {
r.scaleInPlace(-1);
}
keySet.prevQuat = r;
keySet.posKeys.push({ frame, value: t });
keySet.rotKeys.push({ frame, value: r });
keySet.sclKeys.push({ frame, value: s });
}
/**
* Build animations for a non-bone node, correctly handling pivots.
* Computes the full FBX transform matrix at each keyframe and decomposes into TRS.
*/
_buildNodeAnimations(curveNodes, nodeName, modelData, startTime, stopTime) {
const fps = 30;
// Separate curves by type
const tNode = curveNodes.find((cn) => cn.type === "T");
const rNode = curveNodes.find((cn) => cn.type === "R");
const sNode = curveNodes.find((cn) => cn.type === "S");
const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
if (times.length === 0) {
return [];
}
// Get curve accessors
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
// Build keyframes by computing the full matrix at each time
const posKeys = [];
const rotKeys = [];
const sclKeys = [];
let prevQuat = null;
for (const time of times) {
const frame = time * fps;
// Sample animated values, falling back to model's base values
const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
// Compute the full FBX local transform matrix with pivots
const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
// Decompose into TRS
const s = new Vector3();
const r = new Quaternion();
const t = new Vector3();
localMatrix.decompose(s, r, t);
// Ensure quaternion continuity
if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) {
r.scaleInPlace(-1);
}
prevQuat = r;
posKeys.push({ frame, value: t });
rotKeys.push({ frame, value: r });
sclKeys.push({ frame, value: s });
}
const animations = [];
// Only create position animation if it's not constant
if (!this._isVector3KeysConstant(posKeys)) {
const posAnim = new Animation(`${nodeName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
posAnim.setKeys(posKeys);
animations.push(posAnim);
}
// Always create rotation animation (if there are rotation curves)
if (rNode) {
const rotAnim = new Animation(`${nodeName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
rotAnim.setKeys(rotKeys);
animations.push(rotAnim);
}
// Only create scale animation if it's not constant
if (!this._isVector3KeysConstant(sclKeys)) {
const sclAnim = new Animation(`${nodeName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
sclAnim.setKeys(sclKeys);
animations.push(sclAnim);
}
return animations;
}
_isVector3KeysConstant(keys) {
if (keys.length < 2) {
return true;
}
const first = keys[0].value;
for (let i = 1; i < keys.length; i++) {
const v = keys[i].value;
if (Math.abs(v.x - first.x) > 0.0001 || Math.abs(v.y - first.y) > 0.0001 || Math.abs(v.z - first.z) > 0.0001) {
return false;
}
}
return true;
}
_sampleModelLocalMatrix(modelData, curveNodes, time, scaleOverride) {
const tNode = curveNodes.find((cn) => cn.type === "T");
const rNode = curveNodes.find((cn) => cn.type === "R");
const sNode = curveNodes.find((cn) => cn.type === "S");
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
return FBXFileLoader._computeFBXLocalMatrix([
sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0],
sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1],
sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2],
], [
sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0],
sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1],
sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2],
], scaleOverride ?? [
sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
}
_sampleModelScale(modelData, curveNodes, time) {
const sNode = curveNodes.find((cn) => cn.type === "S");
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
return [
sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
];
}
/**
* Build matrix-baked bone animation from full FBX local transforms.
* The bind matrix carries the skinning offset, so animation curves drive
* the same FBX local transform chain as the source skeleton.
*/
_buildBoneAnimations(curveNodes, boneName, modelData, startTime, stopTime, bindLocalMatrix) {
const fps = 30;
// Separate curves by type
const tNode = curveNodes.find((cn) => cn.type === "T");
const rNode = curveNodes.find((cn) => cn.type === "R");
const sNode = curveNodes.find((cn) => cn.type === "S");
const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
if (times.length === 0) {
return [];
}
// Get curve accessors
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
const posKeys = [];
const rotKeys = [];
const sclKeys = [];
let prevQuat = null;
let restLocalInverse = null;
if (bindLocalMatrix) {
const restLocalMatrix = FBXFileLoader._computeFBXLocalMatrix(modelData.translation, modelData.rotation, modelData.scale, modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
restLocalInverse = new Matrix();
restLocalMatrix.invertToRef(restLocalInverse);
}
for (const time of times) {
const frame = time * fps;
// Sample animated values, falling back to model's base values
const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
// Compute the full FBX local matrix from animated Lcl values
const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
const correctedLocalMatrix = restLocalInverse && bindLocalMatrix ? bindLocalMatrix.multiply(restLocalInverse).multiply(localMatrix) : localMatrix;
const s = new Vector3();
const r = new Quaternion();
const t = new Vector3();
correctedLocalMatrix.decompose(s, r, t);
if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) {
r.scaleInPlace(-1);
}
prevQuat = r;
posKeys.push({ frame, value: t });
rotKeys.push({ frame, value: r });
sclKeys.push({ frame, value: s });
}
const animations = [];
if (!this._isVector3KeysConstant(posKeys)) {
const posAnim = new Animation(`${boneName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
posAnim.setKeys(posKeys);
animations.push(posAnim);
}
if (rNode) {
const rotAnim = new Animation(`${boneName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
rotAnim.setKeys(rotKeys);
animations.push(rotAnim);
}
if (!this._isVector3KeysConstant(sclKeys)) {
const sclAnim = new Animation(`${boneName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
sclAnim.setKeys(sclKeys);
animations.push(sclAnim);
}
return animations;
}
_buildNameFilter(meshesNames) {
if (!meshesNames) {
return null;
}
if (typeof meshesNames === "string") {
if (meshesNames === "") {
return null;
}
return (name) => name === meshesNames;
}
if (meshesNames.length === 0) {
return null;
}
const nameSet = new Set(meshesNames);
return (name) => nameSet.has(name);
}
}
function float64To32(arr) {
const result = new Float32Array(arr.length);
for (let i = 0; i < arr.length; i++) {
result[i] = arr[i];
}
return result;
}
function applyTangentHandednessScale(tangents, scale) {
if (scale === 1) {
return;
}
for (let i = 3; i < tangents.length; i += 4) {
tangents[i] *= scale;
}
}
function generateTangents(positions, normals, uvs, indices, normalMapTangentHandednessScale = 1, controlPointIndices = null, materialIndices = null) {
const vertexCount = positions.length / 3;
const groups = new Map();
const vertexGroupKeys = new Array(vertexCount).fill(null);
for (let i = 0; i + 2 < indices.length; i += 3) {
const materialIndex = materialIndices ? materialIndices[i / 3] : 0;
const i1 = indices[i];
const i2 = indices[i + 1];
const i3 = indices[i + 2];
const p1 = i1 * 3;
const p2 = i2 * 3;
const p3 = i3 * 3;
const uv1 = i1 * 2;
const uv2 = i2 * 2;
const uv3 = i3 * 2;
const x1 = positions[p2] - positions[p1];
const x2 = positions[p3] - positions[p1];
const y1 = positions[p2 + 1] - positions[p1 + 1];
const y2 = positions[p3 + 1] - positions[p1 + 1];
const z1 = positions[p2 + 2] - positions[p1 + 2];
const z2 = positions[p3 + 2] - positions[p1 + 2];
const s1 = uvs[uv2] - uvs[uv1];
const s2 = uvs[uv3] - uvs[uv1];
const t1 = uvs[uv2 + 1] - uvs[uv1 + 1];
const t2 = uvs[uv3 + 1] - uvs[uv1 + 1];
const denominator = s1 * t2 - s2 * t1;
if (Math.abs(denominator) < 1e-8) {
continue;
}
const r = 1 / denominator;
const sx = (t2 * x1 - t1 * x2) * r;
const sy = (t2 * y1 - t1 * y2) * r;
const sz = (t2 * z1 - t1 * z2) * r;
const bx = (s1 * x2 - s2 * x1) * r;
const by = (s1 * y2 - s2 * y1) * r;
const bz = (s1 * z2 - s2 * z1) * r;
accumulateTangentContribution(i1, i2, i3, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
accumulateTangentContribution(i2, i3, i1, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
accumulateTangentContribution(i3, i1, i2, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
}
const tangents = new Float32Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
const no = i * 3;
const to = i * 4;
const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]);
const group = vertexGroupKeys[i] ? groups.get(vertexGroupKeys[i]) : undefined;
const tx = group?.tx ?? 0;
const ty = group?.ty ?? 0;
const tz = group?.tz ?? 0;
const normalDotTangent = nx * tx + ny * ty + nz * tz;
let ox = tx - nx * normalDotTangent;
let oy = ty - ny * normalDotTangent;
let oz = tz - nz * normalDotTangent;
const tangentLength = Math.hypot(ox, oy, oz);
if (tangentLength > 1e-8) {
ox /= tangentLength;
oy /= tangentLength;
oz /= tangentLength;
}
else {
[ox, oy, oz] = buildFallbackTangent(nx, ny, nz);
}
const bx = group?.bx ?? 0;
const by = group?.by ?? 0;
const bz = group?.bz ?? 0;
const cx = ny * oz - nz * oy;
const cy = nz * ox - nx * oz;
const cz = nx * oy - ny * ox;
const bitangentLength = Math.hypot(bx, by, bz);
const handedness = bitangentLength > 1e-8 && cx * bx + cy * by + cz * bz < 0 ? -1 : 1;
tangents[to] = ox;
tangents[to + 1] = oy;
tangents[to + 2] = oz;
tangents[to + 3] = handedness * normalMapTangentHandednessScale;
}
return tangents;
}
function accumulateTangentContribution(vertexIndex, nextIndex, prevIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys) {
const weight = computeCornerAngle(positions, vertexIndex, nextIndex, prevIndex);
if (weight <= 1e-8) {
return;
}
const key = buildTangentGroupKey(vertexIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex);
let group = groups.get(key);
if (!group) {
group = { tx: 0, ty: 0, tz: 0, bx: 0, by: 0, bz: 0 };
groups.set(key, group);
}
group.tx += tx * weight;
group.ty += ty * weight;
group.tz += tz * weight;
group.bx += bx * weight;
group.by += by * weight;
group.bz += bz * weight;
vertexGroupKeys[vertexIndex] ??= key;
}
function buildTangentGroupKey(vertexIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex) {
const po = vertexIndex * 3;
const no = vertexIndex * 3;
const uo = vertexIndex * 2;
const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]);
const handedness = computeTangentHandedness(nx, ny, nz, tx, ty, tz, bx, by, bz);
const positionKey = controlPointIndices
? `cp:${controlPointIndices[vertexIndex]}`
: `p:${quantizeTangentKey(positions[po])},${quantizeTangentKey(positions[po + 1])},${quantizeTangentKey(positions[po + 2])}`;
return [
positionKey,
quantizeTangentKey(nx),
quantizeTangentKey(ny),
quantizeTangentKey(nz),
quantizeTangentKey(uvs[uo]),
quantizeTangentKey(uvs[uo + 1]),
handedness,
materialIndex,
].join("|");
}
function computeTangentHandedness(nx, ny, nz, tx, ty, tz, bx, by, bz) {
const cx = ny * tz - nz * ty;
const cy = nz * tx - nx * tz;
const cz = nx * ty - ny * tx;
return cx * bx + cy * by + cz * bz < 0 ? -1 : 1;
}
function computeCornerAngle(positions, vertexIndex, nextIndex, prevIndex) {
const vo = vertexIndex * 3;
const no = nextIndex * 3;
const po = prevIndex * 3;
const ax = positions[no] - positions[vo];
const ay = positions[no + 1] - positions[vo + 1];
const az = positions[no + 2] - positions[vo + 2];
const bx = positions[po] - positions[vo];
const by = positions[po + 1] - positions[vo + 1];
const bz = positions[po + 2] - positions[vo + 2];
const aLength = Math.hypot(ax, ay, az);
const bLength = Math.hypot(bx, by, bz);
if (aLength <= 1e-8 || bLength <= 1e-8) {
return 0;
}
const dot = (ax * bx + ay * by + az * bz) / (aLength * bLength);
return Math.acos(Math.max(-1, Math.min(1, dot)));
}
function normalizeVector(x, y, z) {
const length = Math.hypot(x, y, z);
return length > 1e-8 ? [x / length, y / length, z / length] : [0, 0, 1];
}
function quantizeTangentKey(value) {
const quantized = Math.round(value * 1e6);
return Object.is(quantized, -0) ? 0 : quantized;
}
function buildFallbackTangent(nx, ny, nz) {
const ax = Math.abs(nx) < 0.9 ? 1 : 0;
const ay = ax === 1 ? 0 : 1;
const dot = nx * ax + ny * ay;
let tx = ax - nx * dot;
let ty = ay - ny * dot;
let tz = -nz * dot;
const length = Math.hypot(tx, ty, tz);
if (length <= 1e-8) {
return [1, 0, 0];
}
tx /= length;
ty /= length;
tz /= length;
return [tx, ty, tz];
}
function buildMorphTargetData(shape, cpIndices, basePositions, baseNormals, deltaMatrix, normalMatrix) {
const vertexCount = basePositions.length / 3;
const targetPositions = new Float32Array(vertexCount * 3);
const hasNormals = shape.normals !== null && baseNormals !== null;
const targetNormals = hasNormals ? new Float32Array(vertexCount * 3) : null;
for (let i = 0; i < targetPositions.length; i++) {
targetPositions[i] = basePositions[i];
}
if (targetNormals && baseNormals) {
for (let i = 0; i < targetNormals.length; i++) {
targetNormals[i] = baseNormals[i];
}
}
const cpToShapeIdx = new Map();
for (let i = 0; i < shape.indices.length; i++) {
cpToShapeIdx.set(shape.indices[i], i);
}
for (let vi = 0; vi < vertexCount; vi++) {
const cpIdx = cpIndices[vi];
const shapeIdx = cpToShapeIdx.get(cpIdx);
if (shapeIdx === undefined) {
continue;
}
let dx = shape.vertices[shapeIdx * 3];
let dy = shape.vertices[shapeIdx * 3 + 1];
let dz = shape.vertices[shapeIdx * 3 + 2];
if (deltaMatrix) {
const rv = Vector3.TransformNormal(new Vector3(dx, dy, dz), deltaMatrix);
dx = rv.x;
dy = rv.y;
dz = rv.z;
}
targetPositions[vi * 3] += dx;
targetPositions[vi * 3 + 1] += dy;
targetPositions[vi * 3 + 2] += dz;
if (targetNormals && shape.normals) {
let nx = shape.normals[shapeIdx * 3];
let ny = shape.normals[shapeIdx * 3 + 1];
let nz = shape.normals[shapeIdx * 3 + 2];
if (normalMatrix) {
const rn = Vector3.TransformNormal(new Vector3(nx, ny, nz), normalMatrix);
if (rn.lengthSquared() > 0) {
rn.normalize();
}
nx = rn.x;
ny = rn.y;
nz = rn.z;
}
targetNormals[vi * 3] += nx;
targetNormals[vi * 3 + 1] += ny;
targetNormals[vi * 3 + 2] += nz;
}
}
return { positions: targetPositions, normals: targetNormals };
}
function calculateBlendShapeInfluences(deformPercent, fullWeights, shapeCount) {
if (shapeCount <= 0) {
return [];
}
if (!fullWeights || fullWeights.length !== shapeCount || shapeCount === 1) {
const denominator = fullWeights?.[0] && fullWeights[0] !== 0 ? fullWeights[0] : 100;
return [clamp01(deformPercent / denominator)];
}
const influences = new Array(shapeCount).fill(0);
if (deformPercent <= fullWeights[0]) {
influences[0] = fullWeights[0] === 0 ? (deformPercent <= 0 ? 1 : 0) : clamp01(deformPercent / fullWeights[0]);
return influences;
}
for (let i = 1; i < fullWeights.length; i++) {
const previousWeight = fullWeights[i - 1];
const nextWeight = fullWeights[i];
if (deformPercent > nextWeight) {
continue;
}
const range = nextWeight - previousWeight;
if (Math.abs(range) < 1e-6) {
influences[i] = 1;
return influences;
}
const t = clamp01((deformPercent - previousWeight) / range);
influences[i - 1] = 1 - t;
influences[i] = t;
return influences;
}
influences[shapeCount - 1] = 1;
return influences;
}
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime) {
let minTime = Number.POSITIVE_INFINITY;
let maxTime = Number.NEGATIVE_INFINITY;
const sourceTimes = new Set();
for (const curveNode of curveNodes) {
for (const curve of curveNode.curves) {
for (const key of curve.keys) {
minTime = Math.min(minTime, key.time);
maxTime = Math.max(maxTime, key.time);
if (key.time >= startTime && key.time <= stopTime) {
sourceTimes.add(key.time);
}
}
}
}
if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) {
return [];
}
const rangeStart = stopTime > startTime ? startTime : minTime;
const rangeStop = stopTime > startTime ? stopTime : maxTime;
const times = new Set([rangeStart, rangeStop, ...Array.from(sourceTimes)]);
const startFrame = Math.ceil(rangeStart * fps);
const stopFrame = Math.floor(rangeStop * fps);
for (let frame = startFrame; frame <= stopFrame; frame++) {
times.add(frame / fps);
}
return Array.from(times).sort((a, b) => a - b);
}
function areQuaternionKeysConstant(keys) {
if (keys.length < 2) {
return true;
}
const first = keys[0].value;
for (let i = 1; i < keys.length; i++) {
const value = keys[i].value;
if (Math.abs(value.x - first.x) > 0.0001 || Math.abs(value.y - first.y) > 0.0001 || Math.abs(value.z - first.z) > 0.0001 || Math.abs(value.w - first.w) > 0.0001) {
return false;
}
}
return true;
}
function buildScalarAnimationKeys(curve, fps, startTime, stopTime, mapValue) {
const range = getCurveSampleRange(curve, startTime, stopTime);
const keys = curve.keys
.filter((key) => key.time >= range.start && key.time <= range.stop)
.map((key) => ({
source: key,
frame: key.time * fps,
value: mapValue(key.value),
}));
if (!keys.some((key) => Math.abs(key.source.time - range.start) < 1e-6)) {
keys.unshift({
source: {
time: range.start,
value: sampleFBXCurveAtTime(curve, range.start) ?? 0,
interpolation: "linear",
},
frame: range.start * fps,
value: mapValue(sampleFBXCurveAtTime(curve, range.start) ?? 0),
});
}
if (!keys.some((key) => Math.abs(key.source.time - range.stop) < 1e-6)) {
keys.push({
source: {
time: range.stop,
value: sampleFBXCurveAtTime(curve, range.stop) ?? 0,
interpolation: "linear",
},
frame: range.stop * fps,
value: mapValue(sampleFBXCurveAtTime(curve, range.stop) ?? 0),
});
}
const animationKeys = keys.map((key) => ({
frame: key.frame,
value: key.value,
}));
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i].source;
const nextAnimationKey = animationKeys[i + 1];
if (key.interpolation === "constant") {
animationKeys[i].interpolation = 1 /* AnimationKeyInterpolation.STEP */;
continue;
}
if (key.interpolation !== "cubic") {
continue;
}
const nextKey = keys[i + 1].source;
const duration = Math.max(nextKey.time - key.time, 1e-6);
const linearSlope = (nextKey.value - key.value) / duration;
animationKeys[i].outTangent = mapSlope(key.rightSlope ?? linearSlope, mapValue) / fps;
nextAnimationKey.inTangent = mapSlope(key.nextLeftSlope ?? linearSlope, mapValue) / fps;
}
return animationKeys;
}
function mapSlope(slope, mapValue) {
return mapValue(slope) - mapValue(0);
}
function getCurveSampleRange(curve, startTime, stopTime) {
if (stopTime > startTime) {
return { start: startTime, stop: stopTime };
}
return {
start: curve.keys[0]?.time ?? 0,
stop: curve.keys[curve.keys.length - 1]?.time ?? 0,
};
}
let _Registered = false;
/**
* Registers the FBXFileLoader scene loader plugin.
* Safe to call multiple times; only the first call has an effect.
*/
function RegisterFBXFileLoader() {
if (_Registered) {
return;
}
_Registered = true;
RegisterSceneLoaderPlugin(new FBXFileLoader());
}
/**
* Re-exports the pure implementation and applies the runtime registration side effect.
* Import "./fbxFileLoader.pure" for tree-shakeable, side-effect-free usage.
*/
RegisterFBXFileLoader();
export { FBXFileLoader, RegisterFBXFileLoader };
//# sourceMappingURL=fbxFileLoader-ByOrYvCB.esm.js.map