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.

1,284 lines (1,278 loc) 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-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