UNPKG

@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
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