From 2ce3c15548adf31c673d6442fe0ad891e85d6bb3 Mon Sep 17 00:00:00 2001 From: mrdoob Date: Mon, 12 Jan 2026 22:52:49 -0800 Subject: [PATCH 1/4] USDLoader: Added USDC file support. (#32704) --- examples/jsm/loaders/USDLoader.js | 25 +- examples/jsm/loaders/usd/USDCParser.js | 3352 +++++++++++++++++++++++- 2 files changed, 3367 insertions(+), 10 deletions(-) diff --git a/examples/jsm/loaders/USDLoader.js b/examples/jsm/loaders/USDLoader.js index 81eae8cad34d2a..1cb497fcc262fc 100644 --- a/examples/jsm/loaders/USDLoader.js +++ b/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 ); diff --git a/examples/jsm/loaders/usd/USDCParser.js b/examples/jsm/loaders/usd/USDCParser.js index bbde70cfe3fec7..0c4acb6de80fc9 100644 --- a/examples/jsm/loaders/usd/USDCParser.js +++ b/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; } From 8084de7640e5b7794754f8bad3b53b9641840493 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Tue, 13 Jan 2026 16:27:56 +0900 Subject: [PATCH 2/4] Editor: Debounce Resources panel updates. --- editor/js/Sidebar.Project.Resources.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/editor/js/Sidebar.Project.Resources.js b/editor/js/Sidebar.Project.Resources.js index 95c9592713b710..6a91277963ad69 100644 --- a/editor/js/Sidebar.Project.Resources.js +++ b/editor/js/Sidebar.Project.Resources.js @@ -166,12 +166,22 @@ function SidebarProjectResources( editor ) { } - signals.editorCleared.add( refreshUI ); - signals.sceneGraphChanged.add( refreshUI ); - signals.geometryChanged.add( refreshGeometriesUI ); - signals.materialAdded.add( refreshMaterialsUI ); - signals.materialChanged.add( refreshMaterialsUI ); - signals.materialRemoved.add( refreshMaterialsUI ); + let timeout; + + function refreshUIDelayed() { + + clearTimeout( timeout ); + + timeout = setTimeout( refreshUI, 100 ); + + } + + signals.editorCleared.add( refreshUIDelayed ); + signals.sceneGraphChanged.add( refreshUIDelayed ); + signals.geometryChanged.add( refreshUIDelayed ); + signals.materialAdded.add( refreshUIDelayed ); + signals.materialChanged.add( refreshUIDelayed ); + signals.materialRemoved.add( refreshUIDelayed ); signals.objectSelected.add( function ( object ) { From 1cf237818ec9b3dc857b4de2c9a53c4353bb8e96 Mon Sep 17 00:00:00 2001 From: Nathan Bierema Date: Tue, 13 Jan 2026 02:53:43 -0500 Subject: [PATCH 3/4] Examples: Add missing UnrealBloomPass parameters (#32736) --- examples/webgl_shaders_ocean.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webgl_shaders_ocean.html b/examples/webgl_shaders_ocean.html index 94688032e6852d..7f9df7a91e1047 100644 --- a/examples/webgl_shaders_ocean.html +++ b/examples/webgl_shaders_ocean.html @@ -54,7 +54,7 @@ renderer.toneMappingExposure = 0.1; container.appendChild( renderer.domElement ); - bloomPass = new UnrealBloomPass( new THREE.Vector2( window.innerWidth, window.innerHeight ) ); + bloomPass = new UnrealBloomPass( new THREE.Vector2( window.innerWidth, window.innerHeight ), 1.5, 0.4, 0.85 ); bloomPass.threshold = 0; bloomPass.strength = 0.1; bloomPass.radius = 0; From 8380531b26c4e2b79ebf027a450e7c37a80ee673 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Tue, 13 Jan 2026 09:46:49 +0100 Subject: [PATCH 4/4] PMREM: Simplify GGX VNDF importance sampling. (#32737) --- src/extras/PMREMGenerator.js | 12 ++++-------- src/nodes/pmrem/PMREMUtils.js | 24 +++++++++--------------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/extras/PMREMGenerator.js b/src/extras/PMREMGenerator.js index 3f2c48910abf01..05ac5d6eae2db8 100644 --- a/src/extras/PMREMGenerator.js +++ b/src/extras/PMREMGenerator.js @@ -845,24 +845,20 @@ function _getGGXShader( lodMax, width, height ) { vec3 importanceSampleGGX_VNDF(vec2 Xi, vec3 V, float roughness) { float alpha = roughness * roughness; - // Section 3.2: Transform view direction to hemisphere configuration - vec3 Vh = normalize(vec3(alpha * V.x, alpha * V.y, V.z)); - // Section 4.1: Orthonormal basis - float lensq = Vh.x * Vh.x + Vh.y * Vh.y; - vec3 T1 = lensq > 0.0 ? vec3(-Vh.y, Vh.x, 0.0) / sqrt(lensq) : vec3(1.0, 0.0, 0.0); - vec3 T2 = cross(Vh, T1); + vec3 T1 = vec3(1.0, 0.0, 0.0); + vec3 T2 = cross(V, T1); // Section 4.2: Parameterization of projected area float r = sqrt(Xi.x); float phi = 2.0 * PI * Xi.y; float t1 = r * cos(phi); float t2 = r * sin(phi); - float s = 0.5 * (1.0 + Vh.z); + float s = 0.5 * (1.0 + V.z); t2 = (1.0 - s) * sqrt(1.0 - t1 * t1) + s * t2; // Section 4.3: Reprojection onto hemisphere - vec3 Nh = t1 * T1 + t2 * T2 + sqrt(max(0.0, 1.0 - t1 * t1 - t2 * t2)) * Vh; + vec3 Nh = t1 * T1 + t2 * T2 + sqrt(max(0.0, 1.0 - t1 * t1 - t2 * t2)) * V; // Section 3.4: Transform back to ellipsoid configuration return normalize(vec3(alpha * Nh.x, alpha * Nh.y, max(0.0, Nh.z))); diff --git a/src/nodes/pmrem/PMREMUtils.js b/src/nodes/pmrem/PMREMUtils.js index 12a7765e80d00a..65f00447e1090a 100644 --- a/src/nodes/pmrem/PMREMUtils.js +++ b/src/nodes/pmrem/PMREMUtils.js @@ -312,30 +312,24 @@ const hammersley = /*@__PURE__*/ Fn( ( [ i, N ] ) => { // GGX VNDF importance sampling (Eric Heitz 2018) // "Sampling the GGX Distribution of Visible Normals" // https://jcgt.org/published/0007/04/01/ -const importanceSampleGGX_VNDF = /*@__PURE__*/ Fn( ( [ Xi, V_immutable, roughness_immutable ] ) => { +const importanceSampleGGX_VNDF = /*@__PURE__*/ Fn( ( [ Xi, V, roughness ] ) => { - const V = vec3( V_immutable ).toVar(); - const roughness = float( roughness_immutable ); - const alpha = roughness.mul( roughness ).toVar(); - - // Section 3.2: Transform view direction to hemisphere configuration - const Vh = normalize( vec3( alpha.mul( V.x ), alpha.mul( V.y ), V.z ) ).toVar(); + const alpha = roughness.mul( roughness ).toConst(); // Section 4.1: Orthonormal basis - const lensq = Vh.x.mul( Vh.x ).add( Vh.y.mul( Vh.y ) ); - const T1 = select( lensq.greaterThan( 0.0 ), vec3( Vh.y.negate(), Vh.x, 0.0 ).div( sqrt( lensq ) ), vec3( 1.0, 0.0, 0.0 ) ).toVar(); - const T2 = cross( Vh, T1 ).toVar(); + const T1 = vec3( 1.0, 0.0, 0.0 ).toConst(); + const T2 = cross( V, T1 ).toConst(); // Section 4.2: Parameterization of projected area - const r = sqrt( Xi.x ); - const phi = mul( 2.0, 3.14159265359 ).mul( Xi.y ); - const t1 = r.mul( cos( phi ) ).toVar(); + const r = sqrt( Xi.x ).toConst(); + const phi = mul( 2.0, 3.14159265359 ).mul( Xi.y ).toConst(); + const t1 = r.mul( cos( phi ) ).toConst(); const t2 = r.mul( sin( phi ) ).toVar(); - const s = mul( 0.5, Vh.z.add( 1.0 ) ); + const s = mul( 0.5, V.z.add( 1.0 ) ).toConst(); t2.assign( s.oneMinus().mul( sqrt( t1.mul( t1 ).oneMinus() ) ).add( s.mul( t2 ) ) ); // Section 4.3: Reprojection onto hemisphere - const Nh = T1.mul( t1 ).add( T2.mul( t2 ) ).add( Vh.mul( sqrt( max( 0.0, t1.mul( t1 ).add( t2.mul( t2 ) ).oneMinus() ) ) ) ); + const Nh = T1.mul( t1 ).add( T2.mul( t2 ) ).add( V.mul( sqrt( max( 0.0, t1.mul( t1 ).add( t2.mul( t2 ) ).oneMinus() ) ) ) ); // Section 3.4: Transform back to ellipsoid configuration return normalize( vec3( alpha.mul( Nh.x ), alpha.mul( Nh.y ), max( 0.0, Nh.z ) ) );