Просмотр исходного кода

USDLoader: Added USDC file support. (#32704)

mrdoob 1 месяц назад
Родитель
Сommit
2ce3c15548
2 измененных файлов с 3367 добавлено и 10 удалено
  1. 19 6
      examples/jsm/loaders/USDLoader.js
  2. 3348 4
      examples/jsm/loaders/usd/USDCParser.js

+ 19 - 6
examples/jsm/loaders/USDLoader.js

@@ -8,13 +8,14 @@ import { USDAParser } from './usd/USDAParser.js';
 import { USDCParser } from './usd/USDCParser.js';
 
 /**
- * A loader for the USDZ format.
+ * A loader for the USD format (USDA, USDC, USDZ).
  *
- * USDZ files that use USDC internally are not yet supported, only USDA.
+ * Supports both ASCII (USDA) and binary (USDC) USD files, as well as
+ * USDZ archives containing either format.
  *
  * ```js
- * const loader = new USDZLoader();
- * const model = await loader.loadAsync( 'saeukkang.usdz' );
+ * const loader = new USDLoader();
+ * const model = await loader.loadAsync( 'model.usdz' );
  * scene.add( model );
  * ```
  *
@@ -97,13 +98,18 @@ class USDLoader extends Loader {
 
 			for ( const filename in zip ) {
 
-				if ( filename.endsWith( 'png' ) ) {
+				if ( filename.endsWith( 'png' ) || filename.endsWith( 'jpg' ) || filename.endsWith( 'jpeg' ) ) {
 
-					const blob = new Blob( [ zip[ filename ] ], { type: 'image/png' } );
+					const type = filename.endsWith( 'png' ) ? 'image/png' : 'image/jpeg';
+					const blob = new Blob( [ zip[ filename ] ], { type } );
 					data[ filename ] = URL.createObjectURL( blob );
 
 				}
 
+			}
+
+			for ( const filename in zip ) {
+
 				if ( filename.endsWith( 'usd' ) || filename.endsWith( 'usda' ) || filename.endsWith( 'usdc' ) ) {
 
 					if ( isCrateFile( zip[ filename ] ) ) {
@@ -208,6 +214,13 @@ class USDLoader extends Loader {
 
 		const file = findUSD( zip );
 
+		// Check if the main file is USDC (binary) or USDA (ASCII)
+		if ( isCrateFile( file ) ) {
+
+			return usdc.parse( file.buffer, assets );
+
+		}
+
 		const text = fflate.strFromU8( file );
 
 		return usda.parse( text, assets );

+ 3348 - 4
examples/jsm/loaders/usd/USDCParser.js

@@ -1,14 +1,3358 @@
 import {
-	Group
+	AnimationClip,
+	Bone,
+	BufferAttribute,
+	BufferGeometry,
+	ClampToEdgeWrapping,
+	Group,
+	Matrix4,
+	NoColorSpace,
+	Mesh,
+	MeshPhysicalMaterial,
+	MirroredRepeatWrapping,
+	QuaternionKeyframeTrack,
+	RepeatWrapping,
+	Skeleton,
+	SkinnedMesh,
+	SRGBColorSpace,
+	TextureLoader,
+	Object3D,
+	VectorKeyframeTrack
 } from 'three';
 
+const textDecoder = new TextDecoder();
+
+// Type enum values from crateDataTypes.h
+const TypeEnum = {
+	Invalid: 0,
+	Bool: 1,
+	UChar: 2,
+	Int: 3,
+	UInt: 4,
+	Int64: 5,
+	UInt64: 6,
+	Half: 7,
+	Float: 8,
+	Double: 9,
+	String: 10,
+	Token: 11,
+	AssetPath: 12,
+	Matrix2d: 13,
+	Matrix3d: 14,
+	Matrix4d: 15,
+	Quatd: 16,
+	Quatf: 17,
+	Quath: 18,
+	Vec2d: 19,
+	Vec2f: 20,
+	Vec2h: 21,
+	Vec2i: 22,
+	Vec3d: 23,
+	Vec3f: 24,
+	Vec3h: 25,
+	Vec3i: 26,
+	Vec4d: 27,
+	Vec4f: 28,
+	Vec4h: 29,
+	Vec4i: 30,
+	Dictionary: 31,
+	TokenListOp: 32,
+	StringListOp: 33,
+	PathListOp: 34,
+	ReferenceListOp: 35,
+	IntListOp: 36,
+	Int64ListOp: 37,
+	UIntListOp: 38,
+	UInt64ListOp: 39,
+	PathVector: 40,
+	TokenVector: 41,
+	Specifier: 42,
+	Permission: 43,
+	Variability: 44,
+	VariantSelectionMap: 45,
+	TimeSamples: 46,
+	Payload: 47,
+	DoubleVector: 48,
+	LayerOffsetVector: 49,
+	StringVector: 50,
+	ValueBlock: 51,
+	Value: 52,
+	UnregisteredValue: 53,
+	UnregisteredValueListOp: 54,
+	PayloadListOp: 55,
+	TimeCode: 56,
+	PathExpression: 57,
+	Relocates: 58,
+	Spline: 59,
+	AnimationBlock: 60
+};
+
+// Field set terminator marker
+const FIELD_SET_TERMINATOR = 0xFFFFFFFF;
+
+// Float compression type codes
+const FLOAT_COMPRESSION_INT = 0x69; // 'i' - compressed as integers
+const FLOAT_COMPRESSION_LUT = 0x74; // 't' - lookup table
+
+// Spec types
+const SpecType = {
+	Unknown: 0,
+	Attribute: 1,
+	Connection: 2,
+	Expression: 3,
+	Mapper: 4,
+	MapperArg: 5,
+	Prim: 6,
+	PseudoRoot: 7,
+	Relationship: 8,
+	RelationshipTarget: 9,
+	Variant: 10,
+	VariantSet: 11
+};
+
+// Specifier values
+const Specifier = {
+	Def: 0,
+	Over: 1,
+	Class: 2
+};
+
+// ============================================================================
+// LZ4 Decompression (minimal implementation for USD)
+// Based on LZ4 block format specification
+// ============================================================================
+
+function lz4DecompressBlock( input, inputOffset, inputEnd, output, outputOffset, outputEnd ) {
+
+	while ( inputOffset < inputEnd ) {
+
+		// Read token
+		const token = input[ inputOffset ++ ];
+		if ( inputOffset > inputEnd ) break;
+
+		// Literal length
+		let literalLength = token >> 4;
+		if ( literalLength === 15 ) {
+
+			let b;
+			do {
+
+				if ( inputOffset >= inputEnd ) break;
+				b = input[ inputOffset ++ ];
+				literalLength += b;
+
+			} while ( b === 255 && inputOffset < inputEnd );
+
+		}
+
+		// Copy literals
+		if ( literalLength > 0 ) {
+
+			if ( inputOffset + literalLength > inputEnd ) {
+
+				literalLength = inputEnd - inputOffset;
+
+			}
+
+			for ( let i = 0; i < literalLength; i ++ ) {
+
+				if ( outputOffset >= outputEnd ) break;
+				output[ outputOffset ++ ] = input[ inputOffset ++ ];
+
+			}
+
+		}
+
+		// Check if we're at the end (last sequence has no match)
+		if ( inputOffset >= inputEnd ) break;
+
+		// Read match offset (little-endian 16-bit)
+		if ( inputOffset + 2 > inputEnd ) break;
+		const matchOffset = input[ inputOffset ++ ] | ( input[ inputOffset ++ ] << 8 );
+
+		if ( matchOffset === 0 ) {
+
+			// Invalid offset
+			break;
+
+		}
+
+		// Match length
+		let matchLength = ( token & 0x0F ) + 4;
+		if ( matchLength === 19 ) {
+
+			let b;
+			do {
+
+				if ( inputOffset >= inputEnd ) break;
+				b = input[ inputOffset ++ ];
+				matchLength += b;
+
+			} while ( b === 255 && inputOffset < inputEnd );
+
+		}
+
+		// Copy match (byte-by-byte to handle overlapping)
+		const matchPos = outputOffset - matchOffset;
+		if ( matchPos < 0 ) {
+
+			// Invalid match position
+			break;
+
+		}
+
+		for ( let i = 0; i < matchLength; i ++ ) {
+
+			if ( outputOffset >= outputEnd ) break;
+			output[ outputOffset ++ ] = output[ matchPos + i ];
+
+		}
+
+	}
+
+	return outputOffset;
+
+}
+
+// USD uses TfFastCompression which wraps LZ4 with chunk headers
+function decompressLZ4( input, uncompressedSize ) {
+
+	// USD's TfFastCompression format:
+	// Single chunk (byte 0 == 0): [0] + LZ4 data
+	// Multi chunk (byte 0 > 0): [numChunks] + [compressedSizes...] + [chunkData...]
+
+	const output = new Uint8Array( uncompressedSize );
+	const numChunks = input[ 0 ];
+
+	if ( numChunks === 0 ) {
+
+		// Single chunk - all remaining bytes are LZ4 compressed
+		lz4DecompressBlock( input, 1, input.length, output, 0, uncompressedSize );
+		return output;
+
+	} else {
+
+		// Multiple chunks - each chunk decompresses to max 65536 bytes
+		const CHUNK_SIZE = 65536;
+
+		// First, read all chunk sizes
+		let headerOffset = 1;
+		const compressedSizes = [];
+
+		for ( let i = 0; i < numChunks; i ++ ) {
+
+			const size = ( input[ headerOffset ] |
+						( input[ headerOffset + 1 ] << 8 ) |
+						( input[ headerOffset + 2 ] << 16 ) |
+						( input[ headerOffset + 3 ] << 24 ) ) >>> 0;
+			compressedSizes.push( size );
+			headerOffset += 4;
+
+		}
+
+		// Decompress each chunk
+		let inputOffset = headerOffset;
+		let outputOffset = 0;
+
+		for ( let i = 0; i < numChunks; i ++ ) {
+
+			const chunkCompressedSize = compressedSizes[ i ];
+			const chunkOutputSize = Math.min( CHUNK_SIZE, uncompressedSize - outputOffset );
+
+			lz4DecompressBlock(
+				input, inputOffset, inputOffset + chunkCompressedSize,
+				output, outputOffset, outputOffset + chunkOutputSize
+			);
+
+			inputOffset += chunkCompressedSize;
+			outputOffset += chunkOutputSize;
+
+		}
+
+		return output;
+
+	}
+
+}
+
+// ============================================================================
+// Integer Decompression (USD-specific delta + variable-width encoding)
+// ============================================================================
+
+function decompressIntegers32( compressedData, numInts ) {
+
+	// First decompress with LZ4
+	const encodedSize = numInts * 4 + ( ( numInts * 2 + 7 ) >> 3 ) + 4;
+	const encoded = decompressLZ4( new Uint8Array( compressedData ), encodedSize );
+
+	// Then decode
+	return decodeIntegers32( encoded, numInts );
+
+}
+
+function decodeIntegers32( data, numInts ) {
+
+	const view = new DataView( data.buffer, data.byteOffset, data.byteLength );
+	let offset = 0;
+
+	// Read common value (signed 32-bit)
+	const commonValue = view.getInt32( offset, true );
+	offset += 4;
+
+	const numCodesBytes = ( numInts * 2 + 7 ) >> 3;
+	const codesStart = offset;
+	const vintsStart = offset + numCodesBytes;
+
+	const result = new Int32Array( numInts );
+	let prevVal = 0;
+	let codesOffset = codesStart;
+	let vintsOffset = vintsStart;
+
+	for ( let i = 0; i < numInts; ) {
+
+		const codeByte = data[ codesOffset ++ ];
+
+		for ( let j = 0; j < 4 && i < numInts; j ++, i ++ ) {
+
+			const code = ( codeByte >> ( j * 2 ) ) & 3;
+			let delta = 0;
+
+			switch ( code ) {
+
+				case 0: // Common value
+					delta = commonValue;
+					break;
+				case 1: // 8-bit signed
+					delta = view.getInt8( vintsOffset );
+					vintsOffset += 1;
+					break;
+				case 2: // 16-bit signed
+					delta = view.getInt16( vintsOffset, true );
+					vintsOffset += 2;
+					break;
+				case 3: // 32-bit signed
+					delta = view.getInt32( vintsOffset, true );
+					vintsOffset += 4;
+					break;
+
+			}
+
+			prevVal += delta;
+			result[ i ] = prevVal;
+
+		}
+
+	}
+
+	return result;
+
+}
+
+// ============================================================================
+// Binary Reader Helper
+// ============================================================================
+
+class BinaryReader {
+
+	constructor( buffer ) {
+
+		this.buffer = buffer;
+		this.view = new DataView( buffer );
+		this.offset = 0;
+
+	}
+
+	seek( offset ) {
+
+		this.offset = offset;
+
+	}
+
+	tell() {
+
+		return this.offset;
+
+	}
+
+	readUint8() {
+
+		const value = this.view.getUint8( this.offset );
+		this.offset += 1;
+		return value;
+
+	}
+
+	readInt8() {
+
+		const value = this.view.getInt8( this.offset );
+		this.offset += 1;
+		return value;
+
+	}
+
+	readUint16() {
+
+		const value = this.view.getUint16( this.offset, true );
+		this.offset += 2;
+		return value;
+
+	}
+
+	readInt16() {
+
+		const value = this.view.getInt16( this.offset, true );
+		this.offset += 2;
+		return value;
+
+	}
+
+	readUint32() {
+
+		const value = this.view.getUint32( this.offset, true );
+		this.offset += 4;
+		return value;
+
+	}
+
+	readInt32() {
+
+		const value = this.view.getInt32( this.offset, true );
+		this.offset += 4;
+		return value;
+
+	}
+
+	readUint64() {
+
+		const lo = this.view.getUint32( this.offset, true );
+		const hi = this.view.getUint32( this.offset + 4, true );
+		this.offset += 8;
+		// For values that fit in Number, this is safe
+		return hi * 0x100000000 + lo;
+
+	}
+
+	readInt64() {
+
+		const lo = this.view.getUint32( this.offset, true );
+		const hi = this.view.getInt32( this.offset + 4, true );
+		this.offset += 8;
+		return hi * 0x100000000 + lo;
+
+	}
+
+	readFloat32() {
+
+		const value = this.view.getFloat32( this.offset, true );
+		this.offset += 4;
+		return value;
+
+	}
+
+	readFloat64() {
+
+		const value = this.view.getFloat64( this.offset, true );
+		this.offset += 8;
+		return value;
+
+	}
+
+	readBytes( length ) {
+
+		const bytes = new Uint8Array( this.buffer, this.offset, length );
+		this.offset += length;
+		return bytes;
+
+	}
+
+	readString( length ) {
+
+		const bytes = this.readBytes( length );
+		let end = 0;
+		while ( end < length && bytes[ end ] !== 0 ) end ++;
+		return textDecoder.decode( bytes.subarray( 0, end ) );
+
+	}
+
+}
+
+// ============================================================================
+// ValueRep - 64-bit packed value representation
+// ============================================================================
+
+class ValueRep {
+
+	constructor( lo, hi ) {
+
+		this.lo = lo; // Lower 32 bits
+		this.hi = hi; // Upper 32 bits
+
+	}
+
+	get isArray() {
+
+		return ( this.hi & 0x80000000 ) !== 0;
+
+	}
+
+	get isInlined() {
+
+		return ( this.hi & 0x40000000 ) !== 0;
+
+	}
+
+	get isCompressed() {
+
+		return ( this.hi & 0x20000000 ) !== 0;
+
+	}
+
+	get typeEnum() {
+
+		return ( this.hi >> 16 ) & 0xFF;
+
+	}
+
+	get payload() {
+
+		// 48-bit payload: lo (32 bits) + hi lower 16 bits
+		// Note: JavaScript numbers are IEEE 754 doubles with 53 bits of integer precision,
+		// so 48-bit values are represented exactly without loss of precision.
+		return this.lo + ( ( this.hi & 0xFFFF ) * 0x100000000 );
+
+	}
+
+	getInlinedValue() {
+
+		// For inlined scalars, the value is in the lower 32 bits
+		return this.lo;
+
+	}
+
+}
+
+// ============================================================================
+// USDC Parser
+// ============================================================================
+
 class USDCParser {
 
-	parse( /* buffer */ ) {
+	parse( buffer, assets = {} ) {
+
+		this.buffer = buffer instanceof ArrayBuffer ? buffer : buffer.buffer;
+		this.reader = new BinaryReader( this.buffer );
+		this.assets = assets;
+		this.version = { major: 0, minor: 0, patch: 0 };
+		this.textureLoader = new TextureLoader();
+		this.textureCache = {};
+
+		this._readBootstrap();
+		this._readTOC();
+		this._readTokens();
+		this._readStrings();
+		this._readFields();
+		this._readFieldSets();
+		this._readPaths();
+		this._readSpecs();
+
+		return this._buildScene();
+
+	}
+
+	_readBootstrap() {
+
+		const reader = this.reader;
+		reader.seek( 0 );
+
+		// Read magic "PXR-USDC"
+		const magic = reader.readString( 8 );
+		if ( magic !== 'PXR-USDC' ) {
+
+			throw new Error( 'Not a valid USDC file' );
+
+		}
+
+		// Read version
+		this.version.major = reader.readUint8();
+		this.version.minor = reader.readUint8();
+		this.version.patch = reader.readUint8();
+		reader.readBytes( 5 ); // Skip remaining version bytes
+
+		// Read TOC offset
+		this.tocOffset = reader.readUint64();
+
+		// Skip reserved bytes (rest of 128-byte header)
+		// Already at offset 24, skip to end of bootstrap (88 bytes total for bootstrap struct)
+
+	}
+
+	_readTOC() {
+
+		const reader = this.reader;
+		reader.seek( this.tocOffset );
+
+		// Read number of sections
+		const numSections = reader.readUint64();
+		this.sections = {};
+
+		for ( let i = 0; i < numSections; i ++ ) {
+
+			const name = reader.readString( 16 );
+			const start = reader.readUint64();
+			const size = reader.readUint64();
+
+			this.sections[ name ] = { start, size };
+
+		}
+
+	}
+
+	_readTokens() {
+
+		const section = this.sections[ 'TOKENS' ];
+		if ( ! section ) return;
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		const numTokens = reader.readUint64();
+		this.tokens = [];
+
+		if ( this.version.major === 0 && this.version.minor < 4 ) {
+
+			// Uncompressed tokens (version < 0.4.0)
+			const tokensNumBytes = reader.readUint64();
+			const tokensData = reader.readBytes( tokensNumBytes );
+
+			let strStart = 0;
+			for ( let i = 0; i < numTokens; i ++ ) {
+
+				let strEnd = strStart;
+				while ( strEnd < tokensData.length && tokensData[ strEnd ] !== 0 ) strEnd ++;
+
+				this.tokens.push( textDecoder.decode( tokensData.subarray( strStart, strEnd ) ) );
+				strStart = strEnd + 1;
+
+			}
+
+		} else {
+
+			// Compressed tokens (version >= 0.4.0)
+			const uncompressedSize = reader.readUint64();
+			const compressedSize = reader.readUint64();
+			const compressedData = reader.readBytes( compressedSize );
+
+			const tokensData = decompressLZ4( compressedData, uncompressedSize );
+
+			let strStart = 0;
+			for ( let i = 0; i < numTokens; i ++ ) {
+
+				let strEnd = strStart;
+				while ( strEnd < tokensData.length && tokensData[ strEnd ] !== 0 ) strEnd ++;
+
+				this.tokens.push( textDecoder.decode( tokensData.subarray( strStart, strEnd ) ) );
+				strStart = strEnd + 1;
+
+			}
+
+		}
+
+	}
+
+	_readStrings() {
+
+		const section = this.sections[ 'STRINGS' ];
+		if ( ! section ) {
+
+			this.strings = [];
+			return;
+
+		}
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		// Strings section has an 8-byte count prefix, but string indices stored
+		// elsewhere in the file are relative to the section start (not the data).
+		// So we read the entire section as uint32 values to maintain correct indexing.
+		const numStrings = Math.floor( section.size / 4 );
+		this.strings = [];
+
+		for ( let i = 0; i < numStrings; i ++ ) {
+
+			this.strings.push( reader.readUint32() );
+
+		}
+
+	}
+
+	_readFields() {
+
+		const section = this.sections[ 'FIELDS' ];
+		if ( ! section ) return;
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		this.fields = [];
+
+		if ( this.version.major === 0 && this.version.minor < 4 ) {
+
+			// Uncompressed fields
+			const numFields = Math.floor( section.size / 12 ); // 4 bytes token index + 8 bytes value rep
+
+			for ( let i = 0; i < numFields; i ++ ) {
+
+				const tokenIndex = reader.readUint32();
+				const repLo = reader.readUint32();
+				const repHi = reader.readUint32();
+
+				this.fields.push( {
+					tokenIndex,
+					valueRep: new ValueRep( repLo, repHi )
+				} );
+
+			}
+
+		} else {
+
+			// Compressed fields (version >= 0.4.0)
+			const numFields = reader.readUint64();
+
+			// Read compressed token indices
+			const tokenIndicesCompressedSize = reader.readUint64();
+			const tokenIndicesCompressed = reader.readBytes( tokenIndicesCompressedSize );
+			const tokenIndices = decompressIntegers32(
+				tokenIndicesCompressed.buffer.slice(
+					tokenIndicesCompressed.byteOffset,
+					tokenIndicesCompressed.byteOffset + tokenIndicesCompressedSize
+				),
+				numFields
+			);
+
+			// Read compressed value reps (LZ4 only, no integer encoding)
+			const repsCompressedSize = reader.readUint64();
+			const repsCompressed = reader.readBytes( repsCompressedSize );
+			const repsData = decompressLZ4( repsCompressed, numFields * 8 );
+			const repsView = new DataView( repsData.buffer, repsData.byteOffset, repsData.byteLength );
+
+			for ( let i = 0; i < numFields; i ++ ) {
+
+				const repLo = repsView.getUint32( i * 8, true );
+				const repHi = repsView.getUint32( i * 8 + 4, true );
+
+				this.fields.push( {
+					tokenIndex: tokenIndices[ i ],
+					valueRep: new ValueRep( repLo, repHi )
+				} );
+
+			}
+
+		}
+
+	}
+
+	_readFieldSets() {
+
+		const section = this.sections[ 'FIELDSETS' ];
+		if ( ! section ) return;
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		this.fieldSets = [];
+
+		if ( this.version.major === 0 && this.version.minor < 4 ) {
+
+			// Uncompressed field sets
+			const numFieldSets = Math.floor( section.size / 4 );
+
+			for ( let i = 0; i < numFieldSets; i ++ ) {
+
+				this.fieldSets.push( reader.readUint32() );
+
+			}
+
+		} else {
+
+			// Compressed field sets
+			const numFieldSets = reader.readUint64();
+			const compressedSize = reader.readUint64();
+			const compressed = reader.readBytes( compressedSize );
+
+			const indices = decompressIntegers32(
+				compressed.buffer.slice(
+					compressed.byteOffset,
+					compressed.byteOffset + compressedSize
+				),
+				numFieldSets
+			);
+
+			for ( let i = 0; i < numFieldSets; i ++ ) {
+
+				this.fieldSets.push( indices[ i ] );
+
+			}
+
+		}
+
+	}
+
+	_readPaths() {
+
+		const section = this.sections[ 'PATHS' ];
+		if ( ! section ) return;
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		const numPaths = reader.readUint64();
+		this.paths = new Array( numPaths ).fill( '' );
+
+		if ( this.version.major === 0 && this.version.minor < 4 ) {
+
+			// Uncompressed paths - recursive tree structure
+			this._readPathsRecursive( '' );
+
+		} else {
+
+			// Compressed paths (version >= 0.4.0)
+			// Note: numPaths is stored twice - once for array sizing, once in compressed paths section
+			reader.readUint64(); // Read duplicate numPaths value (matches numPaths above)
+
+			const compressedSize1 = reader.readUint64();
+			const pathIndicesCompressed = reader.readBytes( compressedSize1 );
+			const pathIndices = decompressIntegers32(
+				pathIndicesCompressed.buffer.slice(
+					pathIndicesCompressed.byteOffset,
+					pathIndicesCompressed.byteOffset + compressedSize1
+				),
+				numPaths
+			);
+
+			const compressedSize2 = reader.readUint64();
+			const elementTokenIndicesCompressed = reader.readBytes( compressedSize2 );
+			const elementTokenIndices = decompressIntegers32(
+				elementTokenIndicesCompressed.buffer.slice(
+					elementTokenIndicesCompressed.byteOffset,
+					elementTokenIndicesCompressed.byteOffset + compressedSize2
+				),
+				numPaths
+			);
+
+			const compressedSize3 = reader.readUint64();
+			const jumpsCompressed = reader.readBytes( compressedSize3 );
+			const jumps = decompressIntegers32(
+				jumpsCompressed.buffer.slice(
+					jumpsCompressed.byteOffset,
+					jumpsCompressed.byteOffset + compressedSize3
+				),
+				numPaths
+			);
+
+			// Build paths from compressed data
+			this._buildPathsFromCompressed( pathIndices, elementTokenIndices, jumps );
+
+		}
+
+	}
+
+	_readPathsRecursive( parentPath, depth = 0 ) {
+
+		const reader = this.reader;
+
+		// Prevent infinite recursion
+		if ( depth > 1000 ) return;
+
+		// Read path item header
+		const index = reader.readUint32();
+		const elementTokenIndex = reader.readUint32();
+		const bits = reader.readUint8();
+
+		const hasChild = ( bits & 1 ) !== 0;
+		const hasSibling = ( bits & 2 ) !== 0;
+		const isPrimProperty = ( bits & 4 ) !== 0;
+
+		// Build path
+		let path;
+		if ( parentPath === '' ) {
+
+			path = '/';
+
+		} else {
+
+			const elemToken = this.tokens[ elementTokenIndex ] || '';
+			if ( isPrimProperty ) {
+
+				path = parentPath + '.' + elemToken;
+
+			} else {
+
+				path = parentPath === '/' ? '/' + elemToken : parentPath + '/' + elemToken;
+
+			}
+
+		}
+
+		this.paths[ index ] = path;
+
+		// Process children and siblings
+		if ( hasChild && hasSibling ) {
+
+			// Read sibling offset
+			const siblingOffset = reader.readUint64();
+
+			// Read child
+			this._readPathsRecursive( path, depth + 1 );
+
+			// Read sibling
+			reader.seek( siblingOffset );
+			this._readPathsRecursive( parentPath, depth + 1 );
+
+		} else if ( hasChild ) {
+
+			this._readPathsRecursive( path, depth + 1 );
+
+		} else if ( hasSibling ) {
+
+			this._readPathsRecursive( parentPath, depth + 1 );
+
+		}
+
+	}
+
+	_buildPathsFromCompressed( pathIndices, elementTokenIndices, jumps ) {
+
+		// Jump encoding from USD:
+		// 0 = only sibling (no child), next entry is sibling
+		// -1 = only child (no sibling), next entry is child
+		// -2 = leaf (no child, no sibling)
+		// >0 = has both child and sibling, value is offset to sibling
+
+		const buildPaths = ( startIndex, parentPath ) => {
+
+			let curIndex = startIndex;
+
+			while ( curIndex < pathIndices.length ) {
+
+				const thisIndex = curIndex ++;
+				const pathIndex = pathIndices[ thisIndex ];
+				const elementTokenIndex = elementTokenIndices[ thisIndex ];
+				const jump = jumps[ thisIndex ];
+
+				// Build path
+				let path;
+				if ( parentPath === '' ) {
+
+					path = '/';
+					parentPath = path;
+
+				} else {
+
+					const elemToken = this.tokens[ Math.abs( elementTokenIndex ) ] || '';
+					const isPrimProperty = elementTokenIndex < 0;
+
+					if ( isPrimProperty ) {
+
+						path = parentPath + '.' + elemToken;
+
+					} else {
+
+						path = parentPath === '/' ? '/' + elemToken : parentPath + '/' + elemToken;
+
+					}
+
+				}
+
+				this.paths[ pathIndex ] = path;
+
+				// Determine children and siblings
+				const hasChild = jump > 0 || jump === - 1;
+				const hasSibling = jump >= 0;
+
+				if ( hasChild ) {
+
+					if ( hasSibling ) {
+
+						// Has both child and sibling
+						// Recursively process sibling subtree
+						const siblingIndex = thisIndex + jump;
+						buildPaths( siblingIndex, parentPath );
+
+					}
+
+					// Child is next entry, continue with new parent path
+					parentPath = path;
+
+				} else if ( hasSibling ) {
+
+					// Only sibling, next entry is sibling with same parent
+					// Just continue loop with curIndex and same parentPath
+
+				} else {
+
+					// Leaf node, exit loop
+					break;
+
+				}
+
+			}
+
+		};
+
+		buildPaths( 0, '' );
+
+	}
+
+	_readSpecs() {
+
+		const section = this.sections[ 'SPECS' ];
+		if ( ! section ) return;
+
+		const reader = this.reader;
+		reader.seek( section.start );
+
+		this.specs = [];
+
+		if ( this.version.major === 0 && this.version.minor < 4 ) {
+
+			// Uncompressed specs
+			// Each spec: pathIndex (4), fieldSetIndex (4), specType (4) = 12 bytes
+			// For version 0.0.1 there may be different padding
+			const specSize = ( this.version.minor === 0 && this.version.patch === 1 ) ? 16 : 12;
+			const numSpecs = Math.floor( section.size / specSize );
+
+			for ( let i = 0; i < numSpecs; i ++ ) {
+
+				const pathIndex = reader.readUint32();
+				const fieldSetIndex = reader.readUint32();
+				const specType = reader.readUint32();
+
+				if ( specSize === 16 ) reader.readUint32(); // padding
+
+				this.specs.push( { pathIndex, fieldSetIndex, specType } );
+
+			}
+
+		} else {
+
+			// Compressed specs
+			const numSpecs = reader.readUint64();
+
+			const compressedSize1 = reader.readUint64();
+			const pathIndicesCompressed = reader.readBytes( compressedSize1 );
+			const pathIndices = decompressIntegers32(
+				pathIndicesCompressed.buffer.slice(
+					pathIndicesCompressed.byteOffset,
+					pathIndicesCompressed.byteOffset + compressedSize1
+				),
+				numSpecs
+			);
+
+			const compressedSize2 = reader.readUint64();
+			const fieldSetIndicesCompressed = reader.readBytes( compressedSize2 );
+			const fieldSetIndices = decompressIntegers32(
+				fieldSetIndicesCompressed.buffer.slice(
+					fieldSetIndicesCompressed.byteOffset,
+					fieldSetIndicesCompressed.byteOffset + compressedSize2
+				),
+				numSpecs
+			);
+
+			const compressedSize3 = reader.readUint64();
+			const specTypesCompressed = reader.readBytes( compressedSize3 );
+			const specTypes = decompressIntegers32(
+				specTypesCompressed.buffer.slice(
+					specTypesCompressed.byteOffset,
+					specTypesCompressed.byteOffset + compressedSize3
+				),
+				numSpecs
+			);
+
+			for ( let i = 0; i < numSpecs; i ++ ) {
+
+				this.specs.push( {
+					pathIndex: pathIndices[ i ],
+					fieldSetIndex: fieldSetIndices[ i ],
+					specType: specTypes[ i ]
+				} );
+
+			}
+
+		}
+
+	}
+
+	// ========================================================================
+	// Value Reading
+	// ========================================================================
+
+	_readValue( valueRep ) {
+
+		const type = valueRep.typeEnum;
+		const isArray = valueRep.isArray;
+		const isInlined = valueRep.isInlined;
+
+		// Handle TimeSamples specially - they have their own format
+		if ( type === TypeEnum.TimeSamples ) {
+
+			return this._readTimeSamples( valueRep );
+
+		}
+
+		if ( isInlined ) {
+
+			return this._readInlinedValue( valueRep );
+
+		}
+
+		// Seek to payload offset and read value
+		const offset = valueRep.payload;
+		const savedOffset = this.reader.tell();
+		this.reader.seek( offset );
+
+		let value;
+
+		if ( isArray ) {
+
+			value = this._readArrayValue( valueRep );
+
+		} else {
+
+			value = this._readScalarValue( type );
+
+		}
+
+		this.reader.seek( savedOffset );
+		return value;
+
+	}
+
+	_readInlinedValue( valueRep ) {
+
+		const type = valueRep.typeEnum;
+		const payload = valueRep.getInlinedValue();
+
+		switch ( type ) {
+
+			case TypeEnum.Bool:
+				return payload !== 0;
+			case TypeEnum.UChar:
+				return payload & 0xFF;
+			case TypeEnum.Int:
+			case TypeEnum.UInt:
+				return payload;
+			case TypeEnum.Float: {
+
+				const buf = new ArrayBuffer( 4 );
+				new DataView( buf ).setUint32( 0, payload, true );
+				return new DataView( buf ).getFloat32( 0, true );
+
+			}
+
+			case TypeEnum.Double: {
+
+				// When a double is inlined, it's stored as float32 bits in the payload
+				const buf = new ArrayBuffer( 4 );
+				new DataView( buf ).setUint32( 0, payload, true );
+				return new DataView( buf ).getFloat32( 0, true );
+
+			}
+
+			case TypeEnum.Token:
+				return this.tokens[ payload ] || '';
+			case TypeEnum.String:
+				return this.tokens[ this.strings[ payload ] ] || '';
+			case TypeEnum.AssetPath:
+				return this.tokens[ payload ] || '';
+			case TypeEnum.Specifier:
+				return payload; // 0=def, 1=over, 2=class
+			case TypeEnum.Permission:
+			case TypeEnum.Variability:
+				return payload;
+			default:
+				return payload;
+
+		}
+
+	}
+
+	_readTimeSamples( valueRep ) {
+
+		const reader = this.reader;
+		const offset = valueRep.payload;
+		const savedOffset = reader.tell();
+		reader.seek( offset );
+
+		// TimeSamples format uses RELATIVE offsets (from OpenUSD _RecursiveRead):
+		// _RecursiveRead: read int64 relativeOffset at current position, then seek to start + relativeOffset
+		// After reading timesRep, continue reading from current position (after timesRep)
+		// Layout at TimeSamples location:
+		// - int64 timesOffset (relative from start of this int64)
+		// At (start + timesOffset): timesRep ValueRep, then int64 valuesOffset, then numValues + ValueReps
+
+		// Read times relative offset and resolve
+		const timesStart = reader.tell();
+		const timesRelOffset = reader.readInt64();
+		reader.seek( timesStart + timesRelOffset );
+
+		const timesRepLo = reader.readUint32();
+		const timesRepHi = reader.readUint32();
+		const timesRep = new ValueRep( timesRepLo, timesRepHi );
+
+		// Resolve times array
+		const times = this._readValue( timesRep );
+
+		// Continue reading from current position (after timesRep)
+		// The second _RecursiveRead reads from CURRENT position, not from the beginning
+		const afterTimesRep = timesStart + timesRelOffset + 8;
+		reader.seek( afterTimesRep );
+
+		// Read values relative offset
+		const valuesStart = reader.tell();
+		const valuesRelOffset = reader.readInt64();
+		reader.seek( valuesStart + valuesRelOffset );
+
+		// Read number of values
+		const numValues = reader.readUint64();
+
+		// Read all ValueReps
+		const valueReps = [];
+		for ( let i = 0; i < numValues; i ++ ) {
+
+			const repLo = reader.readUint32();
+			const repHi = reader.readUint32();
+			valueReps.push( new ValueRep( repLo, repHi ) );
+
+		}
+
+		// Resolve each value
+		const values = [];
+		for ( let i = 0; i < numValues; i ++ ) {
+
+			values.push( this._readValue( valueReps[ i ] ) );
+
+		}
+
+		reader.seek( savedOffset );
+
+		// Convert times to array if needed
+		const timesArray = times instanceof Float64Array ? Array.from( times ) : ( Array.isArray( times ) ? times : [ times ] );
+
+		return { times: timesArray, values };
+
+	}
+
+	_readScalarValue( type ) {
+
+		const reader = this.reader;
+
+		switch ( type ) {
+
+			case TypeEnum.Bool:
+				return reader.readUint8() !== 0;
+			case TypeEnum.UChar:
+				return reader.readUint8();
+			case TypeEnum.Int:
+				return reader.readInt32();
+			case TypeEnum.UInt:
+				return reader.readUint32();
+			case TypeEnum.Int64:
+				return reader.readInt64();
+			case TypeEnum.UInt64:
+				return reader.readUint64();
+			case TypeEnum.Half:
+				return this._readHalf();
+			case TypeEnum.Float:
+				return reader.readFloat32();
+			case TypeEnum.Double:
+				return reader.readFloat64();
+			case TypeEnum.String:
+			case TypeEnum.Token: {
+
+				const index = reader.readUint32();
+				return this.tokens[ index ] || '';
+
+			}
+
+			case TypeEnum.AssetPath: {
+
+				const index = reader.readUint32();
+				return this.tokens[ index ] || '';
+
+			}
+
+			case TypeEnum.Vec2f:
+				return [ reader.readFloat32(), reader.readFloat32() ];
+			case TypeEnum.Vec2d:
+				return [ reader.readFloat64(), reader.readFloat64() ];
+			case TypeEnum.Vec2i:
+				return [ reader.readInt32(), reader.readInt32() ];
+			case TypeEnum.Vec3f:
+				return [ reader.readFloat32(), reader.readFloat32(), reader.readFloat32() ];
+			case TypeEnum.Vec3d:
+				return [ reader.readFloat64(), reader.readFloat64(), reader.readFloat64() ];
+			case TypeEnum.Vec3i:
+				return [ reader.readInt32(), reader.readInt32(), reader.readInt32() ];
+			case TypeEnum.Vec4f:
+				return [ reader.readFloat32(), reader.readFloat32(), reader.readFloat32(), reader.readFloat32() ];
+			case TypeEnum.Vec4d:
+				return [ reader.readFloat64(), reader.readFloat64(), reader.readFloat64(), reader.readFloat64() ];
+			case TypeEnum.Quatf:
+				return [ reader.readFloat32(), reader.readFloat32(), reader.readFloat32(), reader.readFloat32() ];
+			case TypeEnum.Quatd:
+				return [ reader.readFloat64(), reader.readFloat64(), reader.readFloat64(), reader.readFloat64() ];
+			case TypeEnum.Matrix4d: {
+
+				const m = [];
+				for ( let i = 0; i < 16; i ++ ) m.push( reader.readFloat64() );
+				return m;
+
+			}
+
+			case TypeEnum.TokenVector: {
+
+				const count = reader.readUint64();
+				const tokens = [];
+				for ( let i = 0; i < count; i ++ ) {
+
+					const index = reader.readUint32();
+					tokens.push( this.tokens[ index ] || '' );
+
+				}
+
+				return tokens;
+
+			}
+
+			case TypeEnum.PathVector: {
+
+				const count = reader.readUint64();
+				const paths = [];
+				for ( let i = 0; i < count; i ++ ) {
+
+					const index = reader.readUint32();
+					paths.push( this.paths[ index ] || '' );
+
+				}
+
+				return paths;
+
+			}
+
+			case TypeEnum.DoubleVector: {
+
+				// DoubleVector is a count-prefixed array of doubles
+				const count = reader.readUint64();
+				const arr = new Float64Array( count );
+				for ( let i = 0; i < count; i ++ ) arr[ i ] = reader.readFloat64();
+				return arr;
+
+			}
+
+			case TypeEnum.Dictionary:
+			case TypeEnum.TokenListOp:
+			case TypeEnum.StringListOp:
+			case TypeEnum.IntListOp:
+			case TypeEnum.Int64ListOp:
+			case TypeEnum.UIntListOp:
+			case TypeEnum.UInt64ListOp:
+				// These complex types are not needed for geometry loading
+				// Skip them silently
+				return null;
+
+			case TypeEnum.PathListOp: {
+
+				// PathListOp format:
+				// Byte 0: flags (bit 0 = hasExplicitItems, bit 1 = hasAddedItems, etc.)
+				// For explicit items: count (uint64) + path indices (uint32 each)
+				const flags = reader.readUint8();
+				const hasExplicitItems = ( flags & 1 ) !== 0;
+
+				if ( hasExplicitItems ) {
+
+					const itemCount = reader.readUint64();
+					const paths = [];
+					for ( let i = 0; i < itemCount; i ++ ) {
+
+						const pathIdx = reader.readUint32();
+						paths.push( this.paths[ pathIdx ] );
+
+					}
+
+					return paths;
+
+				}
+
+				return null;
+
+			}
+
+			default:
+				console.warn( 'USDCParser: Unsupported scalar type', type );
+				return null;
+
+		}
+
+	}
+
+	_readArrayValue( valueRep ) {
+
+		const reader = this.reader;
+		const type = valueRep.typeEnum;
+		const isCompressed = valueRep.isCompressed;
+
+		// Read array size
+		let size;
+		if ( this.version.major === 0 && this.version.minor < 7 ) {
+
+			size = reader.readUint32();
+
+		} else {
+
+			size = reader.readUint64();
+
+		}
+
+		if ( size === 0 ) return [];
+
+		// Handle compressed arrays
+		if ( isCompressed ) {
+
+			return this._readCompressedArray( type, size );
+
+		}
+
+		// Read uncompressed array
+		switch ( type ) {
+
+			case TypeEnum.Int: {
+
+				const arr = new Int32Array( size );
+				for ( let i = 0; i < size; i ++ ) arr[ i ] = reader.readInt32();
+				return arr;
+
+			}
+
+			case TypeEnum.UInt: {
+
+				const arr = new Uint32Array( size );
+				for ( let i = 0; i < size; i ++ ) arr[ i ] = reader.readUint32();
+				return arr;
+
+			}
+
+			case TypeEnum.Float: {
+
+				const arr = new Float32Array( size );
+				for ( let i = 0; i < size; i ++ ) arr[ i ] = reader.readFloat32();
+				return arr;
+
+			}
+
+			case TypeEnum.Double: {
+
+				const arr = new Float64Array( size );
+				for ( let i = 0; i < size; i ++ ) arr[ i ] = reader.readFloat64();
+				return arr;
+
+			}
+
+			case TypeEnum.Vec2f: {
+
+				const arr = new Float32Array( size * 2 );
+				for ( let i = 0; i < size * 2; i ++ ) arr[ i ] = reader.readFloat32();
+				return arr;
+
+			}
+
+			case TypeEnum.Vec3f: {
+
+				const arr = new Float32Array( size * 3 );
+				for ( let i = 0; i < size * 3; i ++ ) arr[ i ] = reader.readFloat32();
+				return arr;
+
+			}
+
+			case TypeEnum.Vec4f: {
+
+				const arr = new Float32Array( size * 4 );
+				for ( let i = 0; i < size * 4; i ++ ) arr[ i ] = reader.readFloat32();
+				return arr;
+
+			}
+
+			case TypeEnum.Vec3h: {
+
+				// Half-precision vec3 array (used for scales in skeletal animation)
+				const arr = new Float32Array( size * 3 );
+				for ( let i = 0; i < size * 3; i ++ ) arr[ i ] = this._readHalf();
+				return arr;
+
+			}
+
+			case TypeEnum.Quatf: {
+
+				const arr = new Float32Array( size * 4 );
+				for ( let i = 0; i < size * 4; i ++ ) arr[ i ] = reader.readFloat32();
+				return arr;
+
+			}
+
+			case TypeEnum.Quath: {
+
+				// Half-precision quaternion array
+				const arr = new Float32Array( size * 4 );
+				for ( let i = 0; i < size * 4; i ++ ) arr[ i ] = this._readHalf();
+				return arr;
+
+			}
+
+			case TypeEnum.Matrix4d: {
+
+				// 4x4 matrix array (16 doubles per matrix, row-major)
+				const arr = new Float64Array( size * 16 );
+				for ( let i = 0; i < size * 16; i ++ ) arr[ i ] = reader.readFloat64();
+				return arr;
+
+			}
+
+			case TypeEnum.Token: {
+
+				const arr = [];
+				for ( let i = 0; i < size; i ++ ) {
+
+					const index = reader.readUint32();
+					arr.push( this.tokens[ index ] || '' );
+
+				}
+
+				return arr;
+
+			}
+
+			case TypeEnum.Half: {
+
+				const arr = new Float32Array( size );
+				for ( let i = 0; i < size; i ++ ) arr[ i ] = this._readHalf();
+				return arr;
+
+			}
+
+			default:
+				console.warn( 'USDCParser: Unsupported array type', type );
+				return [];
+
+		}
+
+	}
+
+	_readCompressedArray( type, size ) {
+
+		const reader = this.reader;
+
+		switch ( type ) {
+
+			case TypeEnum.Int:
+			case TypeEnum.UInt: {
+
+				const compressedSize = reader.readUint64();
+				const compressed = reader.readBytes( compressedSize );
+				return decompressIntegers32(
+					compressed.buffer.slice(
+						compressed.byteOffset,
+						compressed.byteOffset + compressedSize
+					),
+					size
+				);
+
+			}
+
+			case TypeEnum.Float: {
+
+				// Float compression: 'i' = compressed as ints, 't' = lookup table
+				const code = reader.readInt8();
+
+				if ( code === FLOAT_COMPRESSION_INT ) {
+
+					const compressedSize = reader.readUint64();
+					const compressed = reader.readBytes( compressedSize );
+					const ints = decompressIntegers32(
+						compressed.buffer.slice(
+							compressed.byteOffset,
+							compressed.byteOffset + compressedSize
+						),
+						size
+					);
+					const floats = new Float32Array( size );
+					for ( let i = 0; i < size; i ++ ) floats[ i ] = ints[ i ];
+					return floats;
+
+				} else if ( code === FLOAT_COMPRESSION_LUT ) {
+
+					const lutSize = reader.readUint32();
+					const lut = new Float32Array( lutSize );
+					for ( let i = 0; i < lutSize; i ++ ) lut[ i ] = reader.readFloat32();
+
+					const compressedSize = reader.readUint64();
+					const compressed = reader.readBytes( compressedSize );
+					const indices = decompressIntegers32(
+						compressed.buffer.slice(
+							compressed.byteOffset,
+							compressed.byteOffset + compressedSize
+						),
+						size
+					);
+
+					const floats = new Float32Array( size );
+					for ( let i = 0; i < size; i ++ ) floats[ i ] = lut[ indices[ i ] ];
+					return floats;
+
+				}
+
+				console.warn( 'USDCParser: Unknown float compression code', code );
+				return new Float32Array( size );
+
+			}
+
+			default:
+				console.warn( 'USDCParser: Unsupported compressed array type', type );
+				return [];
+
+		}
+
+	}
+
+	_readHalf() {
+
+		const h = this.reader.readUint16();
+		// Convert half to float (IEEE 754 half-precision)
+		const sign = ( h & 0x8000 ) >> 15;
+		const exp = ( h & 0x7C00 ) >> 10;
+		const frac = h & 0x03FF;
+
+		if ( exp === 0 ) {
+
+			// Zero or denormalized number
+			if ( frac === 0 ) {
+
+				return sign ? - 0 : 0;
+
+			}
+
+			// Denormalized: value = ±2^-14 × (frac/1024)
+			return ( sign ? - 1 : 1 ) * Math.pow( 2, - 14 ) * ( frac / 1024 );
+
+		} else if ( exp === 31 ) {
+
+			return frac ? NaN : ( sign ? - Infinity : Infinity );
+
+		}
+
+		return ( sign ? - 1 : 1 ) * Math.pow( 2, exp - 15 ) * ( 1 + frac / 1024 );
+
+	}
+
+	// ========================================================================
+	// Scene Building
+	// ========================================================================
+
+	_buildScene() {
+
+		this.specsByPath = {};
+
+		for ( const spec of this.specs ) {
+
+			const path = this.paths[ spec.pathIndex ];
+			if ( ! path ) continue;
+
+			const fields = this._getFieldsForSpec( spec );
+			this.specsByPath[ path ] = { specType: spec.specType, fields };
+
+		}
+
+		const rootSpec = this.specsByPath[ '/' ];
+		const rootFields = rootSpec ? rootSpec.fields : {};
+		this.fps = rootFields.framesPerSecond || rootFields.timeCodesPerSecond || 30;
+
+		this.skeletons = {};
+		this.skinnedMeshes = [];
+
+		const group = new Group();
+		this._buildHierarchy( group, '/' );
+		this._bindSkeletons();
+
+		group.animations = this._buildAnimations();
+
+		return group;
+
+	}
+
+	_getFieldsForSpec( spec ) {
+
+		const fields = {};
+		let fieldSetIndex = spec.fieldSetIndex;
+
+		// Field sets are terminated by FIELD_SET_TERMINATOR
+		// Limit iterations to prevent infinite loops from malformed data
+		const maxIterations = 10000;
+		let iterations = 0;
+
+		while ( fieldSetIndex < this.fieldSets.length && iterations < maxIterations ) {
+
+			const fieldIndex = this.fieldSets[ fieldSetIndex ];
+
+			// Terminator
+			if ( fieldIndex === FIELD_SET_TERMINATOR || fieldIndex === - 1 ) break;
+
+			const field = this.fields[ fieldIndex ];
+			if ( field ) {
+
+				const name = this.tokens[ field.tokenIndex ];
+				const value = this._readValue( field.valueRep );
+				fields[ name ] = value;
+
+			}
+
+			fieldSetIndex ++;
+			iterations ++;
+
+		}
+
+		return fields;
+
+	}
+
+	_buildHierarchy( parent, parentPath ) {
+
+		const prefix = parentPath === '/' ? '/' : parentPath + '/';
+
+		// Find all direct children of this path
+		for ( const path in this.specsByPath ) {
+
+			const spec = this.specsByPath[ path ];
+
+			// Check if this is a direct child
+			if ( ! this._isDirectChild( parentPath, path, prefix ) ) continue;
+
+			// Only process Prim specs
+			if ( spec.specType !== SpecType.Prim ) continue;
+
+			const specifier = spec.fields.specifier;
+			if ( specifier !== Specifier.Def ) continue;
+
+			const typeName = spec.fields.typeName || '';
+			const name = this._getPathName( path );
+
+			if ( typeName === 'SkelRoot' ) {
+
+				// Skeletal root - treat as transform but track for skeleton binding
+				const obj = this._buildXform( path, spec );
+				obj.name = name;
+				obj.userData.isSkelRoot = true;
+				parent.add( obj );
+
+				// Recursively build children
+				this._buildHierarchy( obj, path );
+
+			} else if ( typeName === 'Skeleton' ) {
+
+				// Build skeleton and store it
+				const skeleton = this._buildSkeleton( path );
+				if ( skeleton ) {
+
+					this.skeletons[ path ] = skeleton;
+
+				}
+
+				// Recursively build children (may contain SkelAnimation)
+				this._buildHierarchy( parent, path );
+
+			} else if ( typeName === 'SkelAnimation' ) {
+
+				// Skip - animations are processed separately in _buildAnimations
+
+			} else if ( typeName === 'Xform' || typeName === 'Scope' || typeName === '' ) {
+
+				// Transform node or group
+				const obj = this._buildXform( path, spec );
+				obj.name = name;
+				parent.add( obj );
+
+				// Recursively build children
+				this._buildHierarchy( obj, path );
+
+			} else if ( typeName === 'Mesh' ) {
+
+				// Mesh (may be skinned)
+				const mesh = this._buildMesh( path, spec );
+				mesh.name = name;
+				parent.add( mesh );
+
+			} else if ( typeName === 'Material' || typeName === 'Shader' ) {
+
+				// Skip materials/shaders, they're referenced by meshes
+
+			} else {
+
+				// Unknown type, create empty object and recurse
+				const obj = new Object3D();
+				obj.name = name;
+				parent.add( obj );
+				this._buildHierarchy( obj, path );
+
+			}
+
+		}
+
+	}
+
+	_isDirectChild( parentPath, childPath, prefix ) {
+
+		if ( parentPath === '/' ) {
+
+			// Root children: /Name (no additional slashes)
+			return childPath.startsWith( '/' ) &&
+				childPath.indexOf( '/', 1 ) === - 1 &&
+				childPath.length > 1;
+
+		}
+
+		// Must start with parent path
+		if ( ! childPath.startsWith( prefix ) ) return false;
+
+		// Must not have additional slashes (direct child only)
+		const remainder = childPath.slice( prefix.length );
+		return remainder.indexOf( '/' ) === - 1 && remainder.length > 0;
+
+	}
+
+	_getPathName( path ) {
+
+		const lastSlash = path.lastIndexOf( '/' );
+		return lastSlash >= 0 ? path.slice( lastSlash + 1 ) : path;
+
+	}
+
+	_buildXform( path, spec ) {
+
+		const obj = new Object3D();
+
+		// Get attribute values from child attribute specs (for transforms)
+		const attrs = this._getAttributeValues( path );
+
+		// Apply transform
+		this._applyTransform( obj, spec.fields, attrs );
+
+		return obj;
+
+	}
+
+	_buildMesh( path, spec ) {
+
+		// Get attribute values from child attribute specs
+		const attrs = this._getAttributeValues( path );
+
+		// Check for skinning data
+		const jointIndices = attrs[ 'primvars:skel:jointIndices' ];
+		const jointWeights = attrs[ 'primvars:skel:jointWeights' ];
+		const hasSkinning = jointIndices && jointWeights &&
+			jointIndices.length > 0 && jointWeights.length > 0;
+
+		// Collect GeomSubsets for multi-material support
+		const geomSubsets = this._getGeomSubsets( path );
+
+		let geometry, material;
+
+		if ( geomSubsets.length > 0 ) {
+
+			// Multi-material mesh: reorder triangles by material group
+			geometry = this._buildGeometryWithSubsets( attrs, geomSubsets, hasSkinning );
+			material = geomSubsets.map( subset => this._buildMaterialForPath( subset.materialPath ) );
+
+		} else {
+
+			// Single material mesh
+			geometry = this._buildGeometry( path, attrs, hasSkinning );
+			material = this._buildMaterial( path, spec.fields );
+
+		}
+
+		let mesh;
+
+		if ( hasSkinning ) {
+
+			mesh = new SkinnedMesh( geometry, material );
+
+			// Find skeleton path from skel:skeleton relationship
+			const skelBindingPath = path + '.skel:skeleton';
+			const skelBindingSpec = this.specsByPath[ skelBindingPath ];
+			let skeletonPath = null;
+
+			if ( skelBindingSpec && skelBindingSpec.fields.targetPaths && skelBindingSpec.fields.targetPaths.length > 0 ) {
+
+				skeletonPath = skelBindingSpec.fields.targetPaths[ 0 ];
+
+			}
+
+			// Get per-mesh joint mapping (local joint names for this mesh)
+			const localJoints = attrs[ 'skel:joints' ];
+
+			// Track for later skeleton binding
+			this.skinnedMeshes.push( { mesh, skeletonPath, path, localJoints } );
+
+		} else {
+
+			mesh = new Mesh( geometry, material );
+
+		}
+
+		// Apply transform from mesh spec fields and attributes
+		this._applyTransform( mesh, spec.fields, attrs );
+
+		return mesh;
+
+	}
+
+	_getGeomSubsets( meshPath ) {
+
+		const subsets = [];
+		const prefix = meshPath + '/';
+
+		for ( const p in this.specsByPath ) {
+
+			if ( ! p.startsWith( prefix ) ) continue;
+
+			const spec = this.specsByPath[ p ];
+			if ( spec.fields.typeName !== 'GeomSubset' ) continue;
+
+			const attrs = this._getAttributeValues( p );
+			const indices = attrs[ 'indices' ];
+			if ( ! indices || indices.length === 0 ) continue;
+
+			// Get material binding
+			const bindingPath = p + '.material:binding';
+			const bindingSpec = this.specsByPath[ bindingPath ];
+			let materialPath = null;
+			if ( bindingSpec && bindingSpec.fields.targetPaths && bindingSpec.fields.targetPaths.length > 0 ) {
+
+				materialPath = bindingSpec.fields.targetPaths[ 0 ];
+
+			}
+
+			subsets.push( {
+				name: this._getPathName( p ),
+				indices: indices, // face indices
+				materialPath: materialPath
+			} );
+
+		}
+
+		return subsets;
+
+	}
+
+	_buildGeometryWithSubsets( fields, geomSubsets, hasSkinning = false ) {
+
+		const geometry = new BufferGeometry();
+
+		const points = fields[ 'points' ];
+		if ( ! points || points.length === 0 ) return geometry;
+
+		const faceVertexIndices = fields[ 'faceVertexIndices' ];
+		const faceVertexCounts = fields[ 'faceVertexCounts' ];
+
+		if ( ! faceVertexCounts || faceVertexCounts.length === 0 ) return geometry;
+
+		const { uvs, uvIndices } = this._findUVPrimvar( fields );
+		const normals = fields[ 'normals' ] || fields[ 'primvars:normals' ];
+
+		const jointIndices = hasSkinning ? fields[ 'primvars:skel:jointIndices' ] : null;
+		const jointWeights = hasSkinning ? fields[ 'primvars:skel:jointWeights' ] : null;
+		const elementSize = fields[ 'primvars:skel:jointIndices:elementSize' ] || 4;
+
+		// Build face-to-triangle mapping (triangles per face and cumulative offset)
+		const faceTriangleOffset = [];
+		let triangleCount = 0;
+
+		for ( let i = 0; i < faceVertexCounts.length; i ++ ) {
+
+			faceTriangleOffset.push( triangleCount );
+			const count = faceVertexCounts[ i ];
+			if ( count >= 3 ) triangleCount += count - 2;
+
+		}
+
+		const triangleToSubset = new Int32Array( triangleCount ).fill( - 1 );
+
+		for ( let si = 0; si < geomSubsets.length; si ++ ) {
+
+			const subset = geomSubsets[ si ];
+
+			for ( let i = 0; i < subset.indices.length; i ++ ) {
+
+				const faceIdx = subset.indices[ i ];
+				if ( faceIdx >= faceVertexCounts.length ) continue;
+
+				const triStart = faceTriangleOffset[ faceIdx ];
+				const triCount = faceVertexCounts[ faceIdx ] - 2;
+
+				for ( let t = 0; t < triCount; t ++ ) {
+
+					triangleToSubset[ triStart + t ] = si;
+
+				}
+
+			}
+
+		}
+
+		// Sort triangles by subset (unassigned first, then by subset index)
+		const sortedTriangles = [];
+
+		for ( let tri = 0; tri < triangleCount; tri ++ ) {
+
+			sortedTriangles.push( { original: tri, subset: triangleToSubset[ tri ] } );
+
+		}
+
+		sortedTriangles.sort( ( a, b ) => a.subset - b.subset );
+		const groups = [];
+		let currentSubset = sortedTriangles.length > 0 ? sortedTriangles[ 0 ].subset : - 1;
+		let groupStart = 0;
+
+		for ( let i = 0; i < sortedTriangles.length; i ++ ) {
+
+			if ( sortedTriangles[ i ].subset !== currentSubset ) {
+
+				if ( currentSubset >= 0 ) {
+
+					groups.push( {
+						start: groupStart * 3,
+						count: ( i - groupStart ) * 3,
+						materialIndex: currentSubset
+					} );
+
+				}
+
+				currentSubset = sortedTriangles[ i ].subset;
+				groupStart = i;
+
+			}
+
+		}
+
+		// Add final group
+		if ( currentSubset >= 0 && sortedTriangles.length > groupStart ) {
+
+			groups.push( {
+				start: groupStart * 3,
+				count: ( sortedTriangles.length - groupStart ) * 3,
+				materialIndex: currentSubset
+			} );
+
+		}
+
+		// Apply groups to geometry
+		for ( const group of groups ) {
+
+			geometry.addGroup( group.start, group.count, group.materialIndex );
+
+		}
+
+		// Triangulate original data
+		const origIndices = this._triangulateIndices( faceVertexIndices, faceVertexCounts );
+		const origUvIndices = uvIndices ? this._triangulateIndices( uvIndices, faceVertexCounts ) : null;
+
+		// Triangulate normals if they are faceVarying (one per face-vertex)
+		const numFaceVertices = faceVertexCounts.reduce( ( a, b ) => a + b, 0 );
+		const hasFaceVaryingNormals = normals && normals.length / 3 === numFaceVertices;
+		const origNormalIndices = hasFaceVaryingNormals
+			? this._triangulateIndices( Array.from( { length: numFaceVertices }, ( _, i ) => i ), faceVertexCounts )
+			: null;
+
+		// Build reordered vertex data
+		const vertexCount = triangleCount * 3;
+		const positions = new Float32Array( vertexCount * 3 );
+		const uvData = uvs ? new Float32Array( vertexCount * 2 ) : null;
+		const normalData = normals ? new Float32Array( vertexCount * 3 ) : null;
+		const skinIndexData = jointIndices ? new Uint16Array( vertexCount * 4 ) : null;
+		const skinWeightData = jointWeights ? new Float32Array( vertexCount * 4 ) : null;
+
+		for ( let i = 0; i < sortedTriangles.length; i ++ ) {
+
+			const origTri = sortedTriangles[ i ].original;
+
+			for ( let v = 0; v < 3; v ++ ) {
+
+				const origIdx = origTri * 3 + v;
+				const newIdx = i * 3 + v;
+
+				// Position
+				const pointIdx = origIndices[ origIdx ];
+				positions[ newIdx * 3 ] = points[ pointIdx * 3 ];
+				positions[ newIdx * 3 + 1 ] = points[ pointIdx * 3 + 1 ];
+				positions[ newIdx * 3 + 2 ] = points[ pointIdx * 3 + 2 ];
+
+				// UVs
+				if ( uvData && uvs ) {
+
+					if ( origUvIndices ) {
+
+						const uvIdx = origUvIndices[ origIdx ];
+						uvData[ newIdx * 2 ] = uvs[ uvIdx * 2 ];
+						uvData[ newIdx * 2 + 1 ] = uvs[ uvIdx * 2 + 1 ];
+
+					} else if ( uvs.length / 2 === points.length / 3 ) {
+
+						// Per-vertex UVs
+						uvData[ newIdx * 2 ] = uvs[ pointIdx * 2 ];
+						uvData[ newIdx * 2 + 1 ] = uvs[ pointIdx * 2 + 1 ];
+
+					}
+
+				}
+
+				// Normals
+				if ( normalData && normals ) {
+
+					if ( origNormalIndices ) {
+
+						// FaceVarying normals
+						const normalIdx = origNormalIndices[ origIdx ];
+						normalData[ newIdx * 3 ] = normals[ normalIdx * 3 ];
+						normalData[ newIdx * 3 + 1 ] = normals[ normalIdx * 3 + 1 ];
+						normalData[ newIdx * 3 + 2 ] = normals[ normalIdx * 3 + 2 ];
+
+					} else if ( normals.length === points.length ) {
+
+						// Per-vertex normals
+						normalData[ newIdx * 3 ] = normals[ pointIdx * 3 ];
+						normalData[ newIdx * 3 + 1 ] = normals[ pointIdx * 3 + 1 ];
+						normalData[ newIdx * 3 + 2 ] = normals[ pointIdx * 3 + 2 ];
+
+					}
+
+				}
+
+				// Skinning data
+				if ( skinIndexData && skinWeightData && jointIndices && jointWeights ) {
+
+					for ( let j = 0; j < 4; j ++ ) {
+
+						if ( j < elementSize ) {
+
+							skinIndexData[ newIdx * 4 + j ] = jointIndices[ pointIdx * elementSize + j ] || 0;
+							skinWeightData[ newIdx * 4 + j ] = jointWeights[ pointIdx * elementSize + j ] || 0;
+
+						} else {
+
+							skinIndexData[ newIdx * 4 + j ] = 0;
+							skinWeightData[ newIdx * 4 + j ] = 0;
+
+						}
+
+					}
+
+				}
+
+			}
+
+		}
+
+		geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) );
+
+		if ( uvData ) {
+
+			geometry.setAttribute( 'uv', new BufferAttribute( uvData, 2 ) );
+
+		}
+
+		if ( normalData ) {
+
+			geometry.setAttribute( 'normal', new BufferAttribute( normalData, 3 ) );
+
+		} else {
+
+			geometry.computeVertexNormals();
+
+		}
+
+		if ( skinIndexData ) {
+
+			geometry.setAttribute( 'skinIndex', new BufferAttribute( skinIndexData, 4 ) );
+
+		}
+
+		if ( skinWeightData ) {
+
+			geometry.setAttribute( 'skinWeight', new BufferAttribute( skinWeightData, 4 ) );
+
+		}
+
+		return geometry;
+
+	}
+
+	_buildMaterialForPath( materialPath ) {
+
+		const material = new MeshPhysicalMaterial();
+
+		if ( materialPath ) {
+
+			this._applyMaterial( material, materialPath );
+
+		}
+
+		return material;
+
+	}
+
+	_getAttributeValues( primPath ) {
+
+		// In USDC, attributes are stored as child specs with paths like /Mesh.points
+		// The attribute value is in the 'default' field of the attribute spec
+		const attrs = {};
+		const prefix = primPath + '.';
+
+		for ( const path in this.specsByPath ) {
+
+			// Check if this is an attribute of the prim (path contains a dot after primPath)
+			if ( ! path.startsWith( prefix ) ) continue;
+
+			const spec = this.specsByPath[ path ];
+
+			// Only process Attribute specs
+			if ( spec.specType !== SpecType.Attribute ) continue;
+
+			// Get attribute name (part after the dot)
+			const attrName = path.slice( prefix.length );
+
+			// Get the value from 'default' field
+			if ( spec.fields.default !== undefined ) {
+
+				attrs[ attrName ] = spec.fields.default;
+
+			}
+
+			// Also include elementSize for skinning attributes
+			if ( spec.fields.elementSize !== undefined ) {
+
+				attrs[ attrName + ':elementSize' ] = spec.fields.elementSize;
+
+			}
+
+			if ( attrName.startsWith( 'primvars:' ) && spec.fields.typeName !== undefined ) {
+
+				attrs[ attrName + ':typeName' ] = spec.fields.typeName;
+
+			}
+
+		}
+
+		return attrs;
+
+	}
+
+	_findUVPrimvar( fields ) {
+
+		for ( const key in fields ) {
+
+			if ( ! key.startsWith( 'primvars:' ) ) continue;
+			if ( key.endsWith( ':typeName' ) || key.endsWith( ':elementSize' ) || key.endsWith( ':indices' ) ) continue;
+			if ( key.includes( 'skel:' ) ) continue;
+
+			const typeName = fields[ key + ':typeName' ];
+			if ( typeName && typeName.includes( 'texCoord' ) ) {
+
+				return {
+					uvs: fields[ key ],
+					uvIndices: fields[ key + ':indices' ]
+				};
+
+			}
+
+		}
+
+		const uvs = fields[ 'primvars:st' ] || fields[ 'primvars:UVMap' ];
+		const uvIndices = fields[ 'primvars:st:indices' ];
+		return { uvs, uvIndices };
+
+	}
+
+	_applyTransform( obj, fields, attrs = {} ) {
+
+		// Merge fields and attrs (attrs take precedence for transforms)
+		const data = { ...fields, ...attrs };
+
+		// Check for transform matrix
+		const xformOpOrder = data[ 'xformOpOrder' ];
+
+		if ( xformOpOrder && xformOpOrder.includes( 'xformOp:transform' ) ) {
+
+			const matrix = data[ 'xformOp:transform' ];
+			if ( matrix && matrix.length === 16 ) {
+
+				obj.matrix.fromArray( matrix );
+				obj.matrix.decompose( obj.position, obj.quaternion, obj.scale );
+
+			}
+
+		}
+
+		// Handle individual transform ops
+		if ( data[ 'xformOp:translate' ] ) {
+
+			const t = data[ 'xformOp:translate' ];
+			obj.position.set( t[ 0 ], t[ 1 ], t[ 2 ] );
+
+		}
+
+		if ( data[ 'xformOp:scale' ] ) {
+
+			const s = data[ 'xformOp:scale' ];
+
+			if ( Array.isArray( s ) ) {
+
+				obj.scale.set( s[ 0 ], s[ 1 ], s[ 2 ] );
+
+			} else {
+
+				obj.scale.set( s, s, s );
+
+			}
+
+		}
+
+		if ( data[ 'xformOp:rotateXYZ' ] ) {
+
+			const r = data[ 'xformOp:rotateXYZ' ];
+			obj.rotation.set(
+				r[ 0 ] * Math.PI / 180,
+				r[ 1 ] * Math.PI / 180,
+				r[ 2 ] * Math.PI / 180
+			);
+
+		}
+
+	}
+
+	_buildGeometry( path, fields, hasSkinning = false ) {
+
+		const geometry = new BufferGeometry();
+
+		const points = fields[ 'points' ];
+		if ( ! points || points.length === 0 ) return geometry;
+
+		const faceVertexIndices = fields[ 'faceVertexIndices' ];
+		const faceVertexCounts = fields[ 'faceVertexCounts' ];
+
+		let indices = faceVertexIndices;
+		if ( faceVertexCounts && faceVertexCounts.length > 0 ) {
+
+			indices = this._triangulateIndices( faceVertexIndices, faceVertexCounts );
+
+		}
+
+		let positions = points;
+		if ( indices && indices.length > 0 ) {
+
+			positions = this._expandAttribute( points, indices, 3 );
+
+		}
+
+		geometry.setAttribute( 'position', new BufferAttribute( new Float32Array( positions ), 3 ) );
+
+		const normals = fields[ 'normals' ] || fields[ 'primvars:normals' ];
+		if ( normals && normals.length > 0 ) {
+
+			let normalData = normals;
+			if ( normals.length === points.length ) {
+
+				// Per-vertex normals
+				if ( indices && indices.length > 0 ) {
+
+					normalData = this._expandAttribute( normals, indices, 3 );
+
+				}
+
+			} else if ( indices ) {
+
+				// Per-face-vertex normals
+				const normalIndices = this._triangulateIndices(
+					Array.from( { length: normals.length / 3 }, ( _, i ) => i ),
+					faceVertexCounts
+				);
+				normalData = this._expandAttribute( normals, normalIndices, 3 );
+
+			}
+
+			geometry.setAttribute( 'normal', new BufferAttribute( new Float32Array( normalData ), 3 ) );
+
+		} else {
+
+			geometry.computeVertexNormals();
+
+		}
+
+		const { uvs, uvIndices } = this._findUVPrimvar( fields );
+
+		if ( uvs && uvs.length > 0 ) {
+
+			let uvData = uvs;
+
+			if ( uvIndices && uvIndices.length > 0 ) {
+
+				// Custom UV indices
+				const triangulatedUvIndices = this._triangulateIndices( uvIndices, faceVertexCounts );
+				uvData = this._expandAttribute( uvs, triangulatedUvIndices, 2 );
+
+			} else if ( indices && uvs.length / 2 === points.length / 3 ) {
+
+				// Per-vertex UVs
+				uvData = this._expandAttribute( uvs, indices, 2 );
+
+			}
+
+			geometry.setAttribute( 'uv', new BufferAttribute( new Float32Array( uvData ), 2 ) );
+
+		}
+
+		// Add skinning attributes
+		if ( hasSkinning ) {
+
+			const jointIndices = fields[ 'primvars:skel:jointIndices' ];
+			const jointWeights = fields[ 'primvars:skel:jointWeights' ];
+			const elementSize = fields[ 'primvars:skel:jointIndices:elementSize' ] || 4;
+
+			if ( jointIndices && jointWeights ) {
+
+				const numVertices = positions.length / 3; // After expansion
+
+				// Expand skinning attributes by the same indices used for positions
+				let skinIndexData, skinWeightData;
+
+				if ( indices && indices.length > 0 ) {
+
+					skinIndexData = this._expandAttribute( jointIndices, indices, elementSize );
+					skinWeightData = this._expandAttribute( jointWeights, indices, elementSize );
+
+				} else {
+
+					skinIndexData = jointIndices;
+					skinWeightData = jointWeights;
+
+				}
+
+				// Three.js expects exactly 4 influences per vertex
+				const skinIndices = new Uint16Array( numVertices * 4 );
+				const skinWeights = new Float32Array( numVertices * 4 );
+
+				for ( let i = 0; i < numVertices; i ++ ) {
+
+					for ( let j = 0; j < 4; j ++ ) {
+
+						if ( j < elementSize ) {
+
+							skinIndices[ i * 4 + j ] = skinIndexData[ i * elementSize + j ] || 0;
+							skinWeights[ i * 4 + j ] = skinWeightData[ i * elementSize + j ] || 0;
+
+						} else {
+
+							skinIndices[ i * 4 + j ] = 0;
+							skinWeights[ i * 4 + j ] = 0;
+
+						}
+
+					}
+
+				}
+
+				geometry.setAttribute( 'skinIndex', new BufferAttribute( skinIndices, 4 ) );
+				geometry.setAttribute( 'skinWeight', new BufferAttribute( skinWeights, 4 ) );
+
+			}
+
+		}
+
+		return geometry;
+
+	}
+
+	_triangulateIndices( indices, counts ) {
+
+		const triangulated = [];
+		let offset = 0;
+
+		for ( let i = 0; i < counts.length; i ++ ) {
+
+			const count = counts[ i ];
+
+			if ( count === 3 ) {
+
+				triangulated.push(
+					indices[ offset ],
+					indices[ offset + 1 ],
+					indices[ offset + 2 ]
+				);
+
+			} else if ( count === 4 ) {
+
+				// Quad to two triangles
+				triangulated.push(
+					indices[ offset ],
+					indices[ offset + 1 ],
+					indices[ offset + 2 ],
+					indices[ offset ],
+					indices[ offset + 2 ],
+					indices[ offset + 3 ]
+				);
+
+			} else if ( count > 4 ) {
+
+				// Fan triangulation for n-gons
+				for ( let j = 1; j < count - 1; j ++ ) {
+
+					triangulated.push(
+						indices[ offset ],
+						indices[ offset + j ],
+						indices[ offset + j + 1 ]
+					);
+
+				}
+
+			}
+
+			offset += count;
+
+		}
+
+		return triangulated;
+
+	}
+
+	_expandAttribute( data, indices, itemSize ) {
+
+		const expanded = new Float32Array( indices.length * itemSize );
+
+		for ( let i = 0; i < indices.length; i ++ ) {
+
+			const srcIndex = indices[ i ] * itemSize;
+			const dstIndex = i * itemSize;
+
+			for ( let j = 0; j < itemSize; j ++ ) {
+
+				expanded[ dstIndex + j ] = data[ srcIndex + j ];
+
+			}
+
+		}
+
+		return expanded;
+
+	}
+
+	_buildMaterial( meshPath, fields ) {
+
+		const material = new MeshPhysicalMaterial();
+
+		// Try to find material binding
+		let materialPath = null;
+
+		// Check for material binding in fields
+		let materialBinding = fields[ 'material:binding' ];
+
+		// Check for relationship spec on mesh directly
+		if ( ! materialBinding ) {
+
+			const bindingPath = meshPath + '.material:binding';
+			const bindingSpec = this.specsByPath[ bindingPath ];
+			if ( bindingSpec && bindingSpec.specType === SpecType.Relationship ) {
+
+				materialBinding = bindingSpec.fields.targetPaths || bindingSpec.fields.default;
+
+			}
+
+		}
+
+		if ( materialBinding ) {
+
+			materialPath = Array.isArray( materialBinding ) ? materialBinding[ 0 ] : materialBinding;
+
+		}
+
+		// If no direct binding, check for GeomSubset children with material bindings
+		if ( ! materialPath ) {
+
+			const materialPaths = [];
+			const prefix = meshPath + '/';
+
+			for ( const path in this.specsByPath ) {
+
+				// Look for material:binding specs under mesh path
+				if ( ! path.startsWith( prefix ) ) continue;
+				if ( ! path.endsWith( '.material:binding' ) ) continue;
+
+				const bindingSpec = this.specsByPath[ path ];
+				if ( ! bindingSpec ) continue;
+
+				const targetPaths = bindingSpec.fields.targetPaths;
+				if ( targetPaths && targetPaths.length > 0 ) {
+
+					materialPaths.push( targetPaths[ 0 ] );
+
+				}
+
+			}
+
+			// Pick a material that has textures if possible
+			if ( materialPaths.length > 0 ) {
+
+				materialPath = this._pickBestMaterial( materialPaths );
+
+			}
+
+		}
+
+		// Fallback: try to find material in Looks hierarchy
+		if ( ! materialPath ) {
+
+			// Get root of mesh hierarchy (e.g., /chair_swan from /chair_swan/RedChairFeet)
+			const meshParts = meshPath.split( '/' );
+			const rootPath = '/' + meshParts[ 1 ];
+
+			// Look for materials in /Root/Looks/ or /Root/Materials/
+			for ( const path in this.specsByPath ) {
+
+				const spec = this.specsByPath[ path ];
+				if ( spec.specType !== SpecType.Prim ) continue;
+				if ( spec.fields.typeName !== 'Material' ) continue;
+
+				// Check if this material is in the same hierarchy
+				if ( path.startsWith( rootPath + '/Looks/' ) ||
+					path.startsWith( rootPath + '/Materials/' ) ) {
+
+					materialPath = path;
+					break;
+
+				}
+
+			}
+
+		}
+
+		if ( materialPath ) {
+
+			this._applyMaterial( material, materialPath );
+
+		}
+
+		return material;
+
+	}
+
+	_pickBestMaterial( materialPaths ) {
+
+		// Prefer materials that have texture files
+		for ( const materialPath of materialPaths ) {
+
+			const prefix = materialPath + '/';
+
+			// Check if this material has any texture shaders
+			for ( const path in this.specsByPath ) {
+
+				if ( ! path.startsWith( prefix ) ) continue;
+
+				const spec = this.specsByPath[ path ];
+				if ( spec.fields.typeName !== 'Shader' ) continue;
+
+				// Check for UsdUVTexture shader
+				const attrs = this._getAttributeValues( path );
+				if ( attrs[ 'info:id' ] === 'UsdUVTexture' && attrs[ 'inputs:file' ] ) {
+
+					return materialPath;
+
+				}
+
+			}
+
+		}
+
+		// Fallback to first material
+		return materialPaths[ 0 ];
+
+	}
+
+	_applyMaterial( material, materialPath ) {
+
+		const materialSpec = this.specsByPath[ materialPath ];
+		if ( ! materialSpec ) return;
+
+		const prefix = materialPath + '/';
+
+		// Look for shader children (UsdPreviewSurface)
+		for ( const path in this.specsByPath ) {
+
+			if ( ! path.startsWith( prefix ) ) continue;
+
+			const spec = this.specsByPath[ path ];
+			const typeName = spec.fields.typeName;
+
+			if ( typeName !== 'Shader' ) continue;
+
+			// Get shader attributes (info:id, inputs:*, etc.)
+			const shaderAttrs = this._getAttributeValues( path );
+
+			// Check for UsdPreviewSurface shader
+			const infoId = shaderAttrs[ 'info:id' ] || spec.fields[ 'info:id' ];
+
+			if ( infoId === 'UsdPreviewSurface' ) {
+
+				this._applyPreviewSurface( material, path );
+
+			}
+
+		}
+
+	}
+
+	_applyPreviewSurface( material, shaderPath ) {
+
+		const fields = this._getAttributeValues( shaderPath );
+
+		// Helper to get attribute spec with connection info
+		const getAttrSpec = ( attrName ) => {
+
+			const attrPath = shaderPath + '.' + attrName;
+			return this.specsByPath[ attrPath ];
+
+		};
+
+		// Helper to apply texture from connection
+		const applyTextureFromConnection = ( attrName, textureProperty, colorSpace, valueCallback ) => {
+
+			const spec = getAttrSpec( attrName );
+
+			if ( spec && spec.fields.connectionPaths && spec.fields.connectionPaths.length > 0 ) {
+
+				// Follow connection to texture shader
+				const connPath = spec.fields.connectionPaths[ 0 ];
+				const texture = this._getTextureFromConnection( connPath );
+
+				if ( texture ) {
+
+					texture.colorSpace = colorSpace;
+					material[ textureProperty ] = texture;
+					return true;
+
+				}
+
+			}
+
+			// No texture connection, use default value if present
+			if ( fields[ attrName ] !== undefined && valueCallback ) {
+
+				valueCallback( fields[ attrName ] );
+
+			}
+
+			return false;
+
+		};
+
+		// Diffuse color / base color map
+		applyTextureFromConnection(
+			'inputs:diffuseColor',
+			'map',
+			SRGBColorSpace,
+			( color ) => {
+
+				if ( Array.isArray( color ) && color.length >= 3 ) {
+
+					material.color.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
+
+				}
+
+			}
+		);
+
+		// Emissive
+		applyTextureFromConnection(
+			'inputs:emissiveColor',
+			'emissiveMap',
+			SRGBColorSpace,
+			( color ) => {
+
+				if ( Array.isArray( color ) && color.length >= 3 ) {
+
+					material.emissive.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
+
+				}
+
+			}
+		);
+
+		if ( material.emissiveMap ) {
+
+			material.emissive.set( 0xffffff );
+
+		}
+
+		// Normal map
+		applyTextureFromConnection( 'inputs:normal', 'normalMap', NoColorSpace, null );
+
+		// Roughness
+		const hasRoughnessMap = applyTextureFromConnection(
+			'inputs:roughness',
+			'roughnessMap',
+			NoColorSpace,
+			( value ) => {
+
+				material.roughness = value;
+
+			}
+		);
+
+		if ( hasRoughnessMap ) {
+
+			material.roughness = 1.0;
+
+		}
+
+		// Metallic
+		const hasMetalnessMap = applyTextureFromConnection(
+			'inputs:metallic',
+			'metalnessMap',
+			NoColorSpace,
+			( value ) => {
+
+				material.metalness = value;
+
+			}
+		);
+
+		if ( hasMetalnessMap ) {
+
+			material.metalness = 1.0;
+
+		}
+
+		// Occlusion
+		applyTextureFromConnection( 'inputs:occlusion', 'aoMap', NoColorSpace, null );
+
+		// IOR
+		if ( fields[ 'inputs:ior' ] !== undefined ) {
+
+			material.ior = fields[ 'inputs:ior' ];
+
+		}
+
+		// Clearcoat
+		if ( fields[ 'inputs:clearcoat' ] !== undefined ) {
+
+			material.clearcoat = fields[ 'inputs:clearcoat' ];
+
+		}
+
+		// Clearcoat roughness
+		if ( fields[ 'inputs:clearcoatRoughness' ] !== undefined ) {
+
+			material.clearcoatRoughness = fields[ 'inputs:clearcoatRoughness' ];
+
+		}
+
+		// Opacity / transparency
+		const opacitySpec = getAttrSpec( 'inputs:opacity' );
+
+		if ( opacitySpec && opacitySpec.fields.connectionPaths && opacitySpec.fields.connectionPaths.length > 0 ) {
+
+			const opacityConn = opacitySpec.fields.connectionPaths[ 0 ];
+
+			// Check if opacity is connected to alpha channel of diffuse texture
+			if ( opacityConn.endsWith( '.outputs:a' ) ) {
+
+				// Alpha is in the diffuse texture - enable transparency with blending
+				material.transparent = true;
+
+			} else {
+
+				// Separate opacity texture
+				const texture = this._getTextureFromConnection( opacityConn );
+				if ( texture ) {
+
+					texture.colorSpace = NoColorSpace;
+					material.alphaMap = texture;
+					material.transparent = true;
+
+				}
+
+			}
+
+		} else if ( fields[ 'inputs:opacity' ] !== undefined ) {
+
+			const opacity = fields[ 'inputs:opacity' ];
+			if ( typeof opacity === 'number' && opacity < 1.0 ) {
+
+				material.opacity = opacity;
+				material.transparent = true;
+
+			}
+
+		}
+
+	}
+
+	_getTextureFromConnection( connectionPath ) {
+
+		// connectionPath is like "/Material/TextureShader.outputs:rgb"
+		// Extract the shader path
+		const dotIdx = connectionPath.lastIndexOf( '.' );
+		if ( dotIdx === - 1 ) return null;
+
+		const textureShaderPath = connectionPath.slice( 0, dotIdx );
+		const textureShaderSpec = this.specsByPath[ textureShaderPath ];
+
+		if ( ! textureShaderSpec || textureShaderSpec.fields.typeName !== 'Shader' ) return null;
+
+		const textureAttrs = this._getAttributeValues( textureShaderPath );
+		const infoId = textureAttrs[ 'info:id' ];
+
+		if ( infoId !== 'UsdUVTexture' ) return null;
+
+		const file = textureAttrs[ 'inputs:file' ];
+		if ( ! file ) return null;
+
+		const texture = this._loadTexture( file );
+		if ( ! texture ) return null;
+
+		// Apply wrap modes
+		const wrapS = textureAttrs[ 'inputs:wrapS' ];
+		const wrapT = textureAttrs[ 'inputs:wrapT' ];
+
+		if ( wrapS ) texture.wrapS = this._getWrapMode( wrapS );
+		if ( wrapT ) texture.wrapT = this._getWrapMode( wrapT );
+
+		return texture;
+
+	}
+
+	_loadTexture( filePath ) {
+
+		// Clean up path
+		let cleanPath = filePath;
+		if ( cleanPath.startsWith( '@' ) ) cleanPath = cleanPath.slice( 1 );
+		if ( cleanPath.endsWith( '@' ) ) cleanPath = cleanPath.slice( 0, - 1 );
+		if ( cleanPath.startsWith( './' ) ) cleanPath = cleanPath.slice( 2 );
+
+		// Check cache first
+		if ( this.textureCache[ cleanPath ] ) {
+
+			return this.textureCache[ cleanPath ];
+
+		}
+
+		// Load from assets
+		const assetUrl = this.assets[ cleanPath ];
+		if ( assetUrl ) {
+
+			const texture = this.textureLoader.load( assetUrl );
+			this.textureCache[ cleanPath ] = texture;
+			return texture;
+
+		}
+
+		return null;
+
+	}
+
+	_getWrapMode( mode ) {
+
+		switch ( mode ) {
+
+			case 'clamp': return ClampToEdgeWrapping;
+			case 'mirror': return MirroredRepeatWrapping;
+			case 'repeat': return RepeatWrapping;
+			default: return RepeatWrapping;
+
+		}
+
+	}
+
+	// ========================================================================
+	// Skeletal Animation
+	// ========================================================================
+
+	_buildSkeleton( path ) {
+
+		const attrs = this._getAttributeValues( path );
+
+		// Get joint names (paths like "root", "root/body_joint", etc.)
+		const joints = attrs[ 'joints' ];
+		if ( ! joints || joints.length === 0 ) return null;
+
+		// Get bind transforms (world-space bind pose matrices)
+		const bindTransforms = attrs[ 'bindTransforms' ];
+		const restTransforms = attrs[ 'restTransforms' ];
+
+		// Build bones
+		const bones = [];
+		const bonesByPath = {};
+		const boneInverses = [];
+
+		for ( let i = 0; i < joints.length; i ++ ) {
+
+			const jointPath = joints[ i ];
+			const jointName = jointPath.split( '/' ).pop();
+
+			const bone = new Bone();
+			bone.name = jointName;
+			bones.push( bone );
+			bonesByPath[ jointPath ] = { bone, index: i };
+
+			// Compute inverse bind matrix
+			if ( bindTransforms && bindTransforms.length >= ( i + 1 ) * 16 ) {
+
+				const bindMatrix = new Matrix4();
+				// USD matrices are row-major. When loaded via fromArray into Three.js
+				// column-major storage, they get automatically transposed.
+				bindMatrix.fromArray( bindTransforms, i * 16 );
+				const inverseBindMatrix = bindMatrix.clone().invert();
+				boneInverses.push( inverseBindMatrix );
+
+			} else {
+
+				boneInverses.push( new Matrix4() );
+
+			}
+
+		}
+
+		// Build parent-child relationships based on joint paths
+		for ( let i = 0; i < joints.length; i ++ ) {
+
+			const jointPath = joints[ i ];
+			const parts = jointPath.split( '/' );
+
+			if ( parts.length > 1 ) {
+
+				const parentPath = parts.slice( 0, - 1 ).join( '/' );
+				const parentData = bonesByPath[ parentPath ];
+
+				if ( parentData ) {
+
+					parentData.bone.add( bones[ i ] );
+
+				}
+
+			}
+
+		}
+
+		// Apply rest transforms to bones (local transforms)
+		if ( restTransforms && restTransforms.length >= joints.length * 16 ) {
+
+			for ( let i = 0; i < joints.length; i ++ ) {
+
+				const matrix = new Matrix4();
+				// USD matrices are row-major. When loaded via fromArray into Three.js
+				// column-major storage, they get automatically transposed.
+				matrix.fromArray( restTransforms, i * 16 );
+				matrix.decompose( bones[ i ].position, bones[ i ].quaternion, bones[ i ].scale );
+
+			}
+
+		}
+
+		// Find root bone(s) - bones without a parent bone
+		const rootBones = bones.filter( bone => ! bone.parent || ! bone.parent.isBone );
+
+		// Get animation source path
+		const animSourceSpec = this.specsByPath[ path + '.skel:animationSource' ];
+		let animationPath = null;
+		if ( animSourceSpec && animSourceSpec.fields.targetPaths && animSourceSpec.fields.targetPaths.length > 0 ) {
+
+			animationPath = animSourceSpec.fields.targetPaths[ 0 ];
+
+		}
+
+		return {
+			skeleton: new Skeleton( bones, boneInverses ),
+			joints: joints,
+			rootBones: rootBones,
+			animationPath: animationPath,
+			path: path
+		};
+
+	}
+
+	_bindSkeletons() {
+
+		for ( const meshData of this.skinnedMeshes ) {
+
+			const { mesh, skeletonPath, localJoints } = meshData;
+
+			let skeletonData = null;
+			for ( const skelPath in this.skeletons ) {
+
+				if ( skeletonPath && skeletonPath.includes( skelPath ) ) {
+
+					skeletonData = this.skeletons[ skelPath ];
+					break;
+
+				}
+
+			}
+
+			// Fallback to first skeleton for single-skeleton files
+			if ( ! skeletonData ) {
+
+				const skeletonPaths = Object.keys( this.skeletons );
+				if ( skeletonPaths.length > 0 ) {
+
+					skeletonData = this.skeletons[ skeletonPaths[ 0 ] ];
+
+				}
+
+			}
+
+			if ( skeletonData ) {
+
+				const { skeleton, rootBones, joints } = skeletonData;
+
+				// Remap local joint indices to global skeleton indices
+				// Each mesh can have its own skel:joints array that defines which
+				// subset of skeleton joints it uses (and in what order)
+				if ( localJoints && localJoints.length > 0 ) {
+
+					const skinIndex = mesh.geometry.attributes.skinIndex;
+					if ( skinIndex ) {
+
+						// Build mapping: local index -> global skeleton index
+						const localToGlobal = [];
+						for ( let i = 0; i < localJoints.length; i ++ ) {
+
+							const jointName = localJoints[ i ];
+							const globalIdx = joints.indexOf( jointName );
+							localToGlobal[ i ] = globalIdx >= 0 ? globalIdx : 0;
+
+						}
+
+						// Remap all joint indices
+						const arr = skinIndex.array;
+						for ( let i = 0; i < arr.length; i ++ ) {
+
+							const localIdx = arr[ i ];
+							if ( localIdx < localToGlobal.length ) {
+
+								arr[ i ] = localToGlobal[ localIdx ];
+
+							}
+
+						}
+
+					}
+
+				}
+
+				// Add root bones to the mesh first
+				for ( const rootBone of rootBones ) {
+
+					mesh.add( rootBone );
+
+				}
+
+				// Bind the skeleton to the mesh with identity bind matrix
+				// We pass a bind matrix to prevent Three.js from overwriting
+				// our carefully computed boneInverses via calculateInverses()
+				mesh.bind( skeleton, new Matrix4() );
+
+			}
+
+		}
+
+	}
+
+	_buildAnimations() {
+
+		const animations = [];
+
+		// Find all SkelAnimation prims
+		for ( const path in this.specsByPath ) {
+
+			const spec = this.specsByPath[ path ];
+			if ( spec.specType !== SpecType.Prim ) continue;
+			if ( spec.fields.typeName !== 'SkelAnimation' ) continue;
+
+			const clip = this._buildAnimationClip( path );
+			if ( clip ) {
+
+				animations.push( clip );
+
+			}
+
+		}
+
+		return animations;
+
+	}
+
+	_buildAnimationClip( path ) {
+
+		const attrs = this._getAttributeValues( path );
+		const joints = attrs[ 'joints' ];
+
+		if ( ! joints || joints.length === 0 ) return null;
+
+		const tracks = [];
+
+		// Get rotation time samples
+		const rotationsAttr = this._getTimeSampledAttribute( path, 'rotations' );
+		if ( rotationsAttr && rotationsAttr.times && rotationsAttr.values ) {
+
+			const { times, values } = rotationsAttr;
+
+			for ( let jointIdx = 0; jointIdx < joints.length; jointIdx ++ ) {
+
+				const jointName = joints[ jointIdx ].split( '/' ).pop();
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let t = 0; t < times.length; t ++ ) {
+
+					const quatData = values[ t ];
+					if ( ! quatData || quatData.length < ( jointIdx + 1 ) * 4 ) continue;
+
+					keyframeTimes.push( times[ t ] / this.fps );
+
+					// USD GfQuatf stores imaginary (x,y,z) first, then real (w)
+					// This matches Three.js quaternion order (x,y,z,w)
+					const x = quatData[ jointIdx * 4 + 0 ];
+					const y = quatData[ jointIdx * 4 + 1 ];
+					const z = quatData[ jointIdx * 4 + 2 ];
+					const w = quatData[ jointIdx * 4 + 3 ];
+					keyframeValues.push( x, y, z, w );
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new QuaternionKeyframeTrack(
+						jointName + '.quaternion',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+		}
+
+		// Get translation time samples
+		const translationsAttr = this._getTimeSampledAttribute( path, 'translations' );
+		if ( translationsAttr && translationsAttr.times && translationsAttr.values ) {
+
+			const { times, values } = translationsAttr;
+
+			for ( let jointIdx = 0; jointIdx < joints.length; jointIdx ++ ) {
+
+				const jointName = joints[ jointIdx ].split( '/' ).pop();
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let t = 0; t < times.length; t ++ ) {
+
+					const transData = values[ t ];
+					if ( ! transData || transData.length < ( jointIdx + 1 ) * 3 ) continue;
+
+					keyframeTimes.push( times[ t ] / this.fps );
+					keyframeValues.push(
+						transData[ jointIdx * 3 + 0 ],
+						transData[ jointIdx * 3 + 1 ],
+						transData[ jointIdx * 3 + 2 ]
+					);
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new VectorKeyframeTrack(
+						jointName + '.position',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+		}
+
+		// Get scale time samples
+		const scalesAttr = this._getTimeSampledAttribute( path, 'scales' );
+		if ( scalesAttr && scalesAttr.times && scalesAttr.values ) {
+
+			const { times, values } = scalesAttr;
+
+			for ( let jointIdx = 0; jointIdx < joints.length; jointIdx ++ ) {
+
+				const jointName = joints[ jointIdx ].split( '/' ).pop();
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let t = 0; t < times.length; t ++ ) {
+
+					const scaleData = values[ t ];
+					if ( ! scaleData || scaleData.length < ( jointIdx + 1 ) * 3 ) continue;
+
+					keyframeTimes.push( times[ t ] / this.fps );
+					keyframeValues.push(
+						scaleData[ jointIdx * 3 + 0 ],
+						scaleData[ jointIdx * 3 + 1 ],
+						scaleData[ jointIdx * 3 + 2 ]
+					);
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new VectorKeyframeTrack(
+						jointName + '.scale',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+		}
+
+		if ( tracks.length === 0 ) return null;
+
+		const clipName = this._getPathName( path );
+		return new AnimationClip( clipName, - 1, tracks );
+
+	}
+
+	_getTimeSampledAttribute( primPath, attrName ) {
+
+		// Look for the attribute spec with time samples
+		const attrPath = primPath + '.' + attrName;
+		const attrSpec = this.specsByPath[ attrPath ];
+
+		if ( attrSpec && attrSpec.fields.timeSamples ) {
+
+			const timeSamples = attrSpec.fields.timeSamples;
+			if ( timeSamples.times && timeSamples.values ) {
+
+				return timeSamples;
+
+			}
 
-		// TODO
+		}
 
-		return new Group();
+		return null;
 
 	}
 

粤ICP备19079148号