@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.
1,284 lines (1,278 loc) • 288 kB
JavaScript
import { M as Matrix, co as FBXFileLoaderMetadata, bi as TransformNode, bu as MultiMaterial, aV as Mesh, F as Material, aW as VertexData, V as Vector3, cp as SubMesh, i as Color3, m as Texture, c1 as GetMimeType, aU as Camera, bs as DirectionalLight, aD as Quaternion, aR as Animation, bn as RegisterSceneLoaderPlugin } from './index-MZPybX0H.esm.js';
import { S as StandardMaterial } from './standardMaterial.pure-D4Vk5EeI.esm.js';
import { S as Skeleton } from './skeleton-D7Sw58cv.esm.js';
import { B as Bone } from './bone.pure-CjsCww39.esm.js';
import { A as AnimationGroup } from './animationGroup.pure-CRK8sOkc.esm.js';
import { M as MorphTargetManager, a as MorphTarget, F as FreeCamera } from './morphTargetManager-CK7B3gaT.esm.js';
import { P as PointLight } from './pointLight.pure-CmWfCM4F.esm.js';
import { S as SpotLight } from './spotLight.pure-CRdZC6Ci.esm.js';
import { A as AssetContainer } from './assetContainer-Bm0vsreJ.esm.js';
import './prepass.defines-D50C_zO6.esm.js';
import './material.detailMapConfiguration-CHgbrJ-3.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 (co