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

USDLoader: Refactored into USDComposer and animation support. (#32740)

mrdoob 1 месяц назад
Родитель
Сommit
53dbc52c29

+ 38 - 17
examples/jsm/loaders/USDLoader.js

@@ -6,6 +6,7 @@ import {
 import * as fflate from '../libs/fflate.module.js';
 import { USDAParser } from './usd/USDAParser.js';
 import { USDCParser } from './usd/USDCParser.js';
+import { USDComposer } from './usd/USDComposer.js';
 
 /**
  * A loader for the USD format (USDA, USDC, USDZ).
@@ -114,12 +115,19 @@ class USDLoader extends Loader {
 
 					if ( isCrateFile( zip[ filename ] ) ) {
 
-						data[ filename ] = usdc.parse( zip[ filename ].buffer, data );
+						// Store parsed data (specsByPath) for on-demand composition
+						const parsedData = usdc.parseData( zip[ filename ].buffer );
+						data[ filename ] = parsedData;
+						// Store raw buffer for re-parsing with variant selections
+						data[ filename + ':buffer' ] = zip[ filename ].buffer;
 
 					} else {
 
 						const text = fflate.strFromU8( zip[ filename ] );
-						data[ filename ] = usda.parseText( text );
+						// Store parsed data (specsByPath) for on-demand composition
+						data[ filename ] = usda.parseData( text );
+						// Store raw text for re-parsing with variant selections
+						data[ filename + ':text' ] = text;
 
 					}
 
@@ -151,15 +159,18 @@ class USDLoader extends Loader {
 
 		function findUSD( zip ) {
 
-			if ( zip.length < 1 ) return undefined;
+			if ( zip.length < 1 ) return { file: undefined, basePath: '' };
 
 			const firstFileName = Object.keys( zip )[ 0 ];
 			let isCrate = false;
 
+			const lastSlash = firstFileName.lastIndexOf( '/' );
+			const basePath = lastSlash >= 0 ? firstFileName.slice( 0, lastSlash ) : '';
+
 			// As per the USD specification, the first entry in the zip archive is used as the main file ("UsdStage").
 			// ASCII files can end in either .usda or .usd.
 			// See https://openusd.org/release/spec_usdz.html#layout
-			if ( firstFileName.endsWith( 'usda' ) ) return zip[ firstFileName ];
+			if ( firstFileName.endsWith( 'usda' ) ) return { file: zip[ firstFileName ], basePath };
 
 			if ( firstFileName.endsWith( 'usdc' ) ) {
 
@@ -170,7 +181,7 @@ class USDLoader extends Loader {
 				// If this is not a crate file, we assume it is a plain USDA file.
 				if ( ! isCrateFile( zip[ firstFileName ] ) ) {
 
-					return zip[ firstFileName ];
+					return { file: zip[ firstFileName ], basePath };
 
 				} else {
 
@@ -182,25 +193,31 @@ class USDLoader extends Loader {
 
 			if ( isCrate ) {
 
-				return zip[ firstFileName ];
+				return { file: zip[ firstFileName ], basePath };
 
 			}
 
+			return { file: undefined, basePath: '' };
+
 		}
 
-		// USDA
+		// USDA (standalone)
 
 		if ( typeof buffer === 'string' ) {
 
-			return usda.parse( buffer, {} );
+			const composer = new USDComposer();
+			const data = usda.parseData( buffer );
+			return composer.compose( data, {} );
 
 		}
 
-		// USDC
+		// USDC (standalone)
 
 		if ( isCrateFile( buffer ) ) {
 
-			return usdc.parse( buffer );
+			const composer = new USDComposer();
+			const data = usdc.parseData( buffer );
+			return composer.compose( data, {} );
 
 		}
 
@@ -210,20 +227,24 @@ class USDLoader extends Loader {
 
 		const assets = parseAssets( zip );
 
-		// console.log( assets );
+		const { file, basePath } = findUSD( zip );
 
-		const file = findUSD( zip );
+		// Compose the main file using USDComposer (works for both USDC and USDA)
+		const composer = new USDComposer();
+		let data;
 
-		// Check if the main file is USDC (binary) or USDA (ASCII)
 		if ( isCrateFile( file ) ) {
 
-			return usdc.parse( file.buffer, assets );
+			data = usdc.parseData( file.buffer );
 
-		}
+		} else {
 
-		const text = fflate.strFromU8( file );
+			const text = fflate.strFromU8( file );
+			data = usda.parseData( text );
+
+		}
 
-		return usda.parse( text, assets );
+		return composer.compose( data, assets, {}, basePath );
 
 	}
 

+ 205 - 515
examples/jsm/loaders/usd/USDAParser.js

@@ -1,19 +1,3 @@
-import {
-	BufferAttribute,
-	BufferGeometry,
-	ClampToEdgeWrapping,
-	Group,
-	NoColorSpace,
-	Mesh,
-	MeshPhysicalMaterial,
-	MirroredRepeatWrapping,
-	RepeatWrapping,
-	SRGBColorSpace,
-	TextureLoader,
-	Object3D,
-	Vector2
-} from 'three';
-
 class USDAParser {
 
 	parseText( text ) {
@@ -27,12 +11,8 @@ class USDAParser {
 
 		const stack = [ root ];
 
-		// Parse USDA file
-
 		for ( const line of lines ) {
 
-			// console.log( line );
-
 			if ( line.includes( '=' ) ) {
 
 				const assignment = line.split( '=' );
@@ -66,6 +46,20 @@ class USDAParser {
 
 				}
 
+			} else if ( line.includes( ':' ) && ! line.includes( '=' ) ) {
+
+				// Handle dictionary entries like "0: [(...)...]" for timeSamples
+				const colonIdx = line.indexOf( ':' );
+				const key = line.slice( 0, colonIdx ).trim();
+				const value = line.slice( colonIdx + 1 ).trim();
+
+				// Only process if key looks like a number (timeSamples frame)
+				if ( /^[\d.]+$/.test( key ) ) {
+
+					target[ key ] = value;
+
+				}
+
 			} else if ( line.endsWith( '{' ) ) {
 
 				const group = target[ string ] || {};
@@ -110,629 +104,325 @@ class USDAParser {
 
 	}
 
-	parse( text, assets ) {
+	/**
+	 * Parse USDA text and return raw spec data in specsByPath format.
+	 * Used by USDComposer for unified scene composition.
+	 */
+	parseData( text ) {
 
 		const root = this.parseText( text );
+		const specsByPath = {};
 
-		// Build scene graph
-
-		function findMeshGeometry( data ) {
+		// Spec types (must match USDCParser/USDComposer)
+		const SpecType = {
+			Attribute: 1,
+			Prim: 6,
+			Relationship: 8
+		};
 
-			if ( ! data ) return undefined;
+		// Parse root metadata
+		const rootFields = {};
+		if ( '#usda 1.0' in root ) {
 
-			if ( 'prepend references' in data ) {
+			const header = root[ '#usda 1.0' ];
 
-				const reference = data[ 'prepend references' ];
-				const parts = reference.split( '@' );
-				const path = parts[ 1 ].replace( /^.\//, '' );
-				const id = parts[ 2 ].replace( /^<\//, '' ).replace( />$/, '' );
+			if ( header.upAxis ) {
 
-				return findGeometry( assets[ path ], id );
+				rootFields.upAxis = header.upAxis.replace( /"/g, '' );
 
 			}
 
-			return findGeometry( data );
-
-		}
-
-		function findGeometry( data, id ) {
-
-			if ( ! data ) return undefined;
-
-			if ( id !== undefined ) {
-
-				const def = `def Mesh "${id}"`;
+			if ( header.defaultPrim ) {
 
-				if ( def in data ) {
-
-					return data[ def ];
-
-				}
-
-			}
-
-			for ( const name in data ) {
-
-				const object = data[ name ];
-
-				if ( name.startsWith( 'def Mesh' ) ) {
-
-					return object;
-
-				}
-
-
-				if ( typeof object === 'object' ) {
-
-					const geometry = findGeometry( object );
-
-					if ( geometry ) return geometry;
-
-				}
+				rootFields.defaultPrim = header.defaultPrim.replace( /"/g, '' );
 
 			}
 
 		}
 
-		function buildGeometry( data ) {
-
-			if ( ! data ) return undefined;
-
-			const geometry = new BufferGeometry();
-			let indices = null;
-			let counts = null;
-			let uvs = null;
-
-			let positionsLength = - 1;
-
-			// index
-
-			if ( 'int[] faceVertexIndices' in data ) {
-
-				indices = JSON.parse( data[ 'int[] faceVertexIndices' ] );
-
-			}
-
-			// face count
-
-			if ( 'int[] faceVertexCounts' in data ) {
-
-				counts = JSON.parse( data[ 'int[] faceVertexCounts' ] );
-				indices = toTriangleIndices( indices, counts );
+		specsByPath[ '/' ] = { specType: SpecType.Prim, fields: rootFields };
 
-			}
-
-			// position
+		// Walk the tree and build specsByPath
+		const walkTree = ( data, parentPath ) => {
 
-			if ( 'point3f[] points' in data ) {
+			const primChildren = [];
 
-				const positions = JSON.parse( data[ 'point3f[] points' ].replace( /[()]*/g, '' ) );
-				positionsLength = positions.length;
-				let attribute = new BufferAttribute( new Float32Array( positions ), 3 );
+			for ( const key in data ) {
 
-				if ( indices !== null ) attribute = toFlatBufferAttribute( attribute, indices );
+				// Skip metadata
+				if ( key === '#usda 1.0' ) continue;
+				if ( key === 'variants' ) continue;
 
-				geometry.setAttribute( 'position', attribute );
-
-			}
+				// Check for primitive definitions
+				// Matches both 'def TypeName "name"' and 'def "name"' (no type)
+				const defMatch = key.match( /^def\s+(?:(\w+)\s+)?"?([^"]+)"?$/ );
+				if ( defMatch ) {
 
-			// uv
-
-			if ( 'float2[] primvars:st' in data ) {
-
-				data[ 'texCoord2f[] primvars:st' ] = data[ 'float2[] primvars:st' ];
-
-			}
-
-			if ( 'texCoord2f[] primvars:st' in data ) {
-
-				uvs = JSON.parse( data[ 'texCoord2f[] primvars:st' ].replace( /[()]*/g, '' ) );
-				let attribute = new BufferAttribute( new Float32Array( uvs ), 2 );
-
-				if ( indices !== null ) attribute = toFlatBufferAttribute( attribute, indices );
-
-				geometry.setAttribute( 'uv', attribute );
-
-			}
-
-			if ( 'int[] primvars:st:indices' in data && uvs !== null ) {
-
-				// custom uv index, overwrite uvs with new data
-
-				const attribute = new BufferAttribute( new Float32Array( uvs ), 2 );
-				let indices = JSON.parse( data[ 'int[] primvars:st:indices' ] );
-				indices = toTriangleIndices( indices, counts );
-				geometry.setAttribute( 'uv', toFlatBufferAttribute( attribute, indices ) );
-
-			}
+					const typeName = defMatch[ 1 ] || '';
+					const name = defMatch[ 2 ];
+					const path = parentPath === '/' ? '/' + name : parentPath + '/' + name;
 
-			// normal
+					primChildren.push( name );
 
-			if ( 'normal3f[] normals' in data ) {
+					const primFields = { typeName };
+					const primData = data[ key ];
 
-				const normals = JSON.parse( data[ 'normal3f[] normals' ].replace( /[()]*/g, '' ) );
-				let attribute = new BufferAttribute( new Float32Array( normals ), 3 );
+					// Extract attributes and relationships from this prim
+					this._extractPrimData( primData, path, primFields, specsByPath, SpecType );
 
-				// normals require a special treatment in USD
+					specsByPath[ path ] = { specType: SpecType.Prim, fields: primFields };
 
-				if ( normals.length === positionsLength ) {
-
-					// raw normal and position data have equal length (like produced by USDZExporter)
-
-					if ( indices !== null ) attribute = toFlatBufferAttribute( attribute, indices );
-
-				} else {
-
-					// unequal length, normals are independent of faceVertexIndices
-
-					let indices = Array.from( Array( normals.length / 3 ).keys() ); // [ 0, 1, 2, 3 ... ]
-					indices = toTriangleIndices( indices, counts );
-					attribute = toFlatBufferAttribute( attribute, indices );
-
-				}
-
-				geometry.setAttribute( 'normal', attribute );
-
-			} else {
-
-				// compute flat vertex normals
-
-				geometry.computeVertexNormals();
-
-			}
-
-			return geometry;
-
-		}
-
-		function toTriangleIndices( rawIndices, counts ) {
-
-			const indices = [];
-
-			for ( let i = 0; i < counts.length; i ++ ) {
-
-				const count = counts[ i ];
-
-				const stride = i * count;
-
-				if ( count === 3 ) {
-
-					const a = rawIndices[ stride + 0 ];
-					const b = rawIndices[ stride + 1 ];
-					const c = rawIndices[ stride + 2 ];
-
-					indices.push( a, b, c );
-
-				} else if ( count === 4 ) {
-
-					const a = rawIndices[ stride + 0 ];
-					const b = rawIndices[ stride + 1 ];
-					const c = rawIndices[ stride + 2 ];
-					const d = rawIndices[ stride + 3 ];
-
-					indices.push( a, b, c );
-					indices.push( a, c, d );
-
-				} else {
-
-					console.warn( 'THREE.USDZLoader: Face vertex count of %s unsupported.', count );
+					// Recurse into children
+					walkTree( primData, path );
 
 				}
 
 			}
 
-			return indices;
-
-		}
-
-		function toFlatBufferAttribute( attribute, indices ) {
-
-			const array = attribute.array;
-			const itemSize = attribute.itemSize;
-
-			const array2 = new array.constructor( indices.length * itemSize );
-
-			let index = 0, index2 = 0;
-
-			for ( let i = 0, l = indices.length; i < l; i ++ ) {
+			// Add primChildren to parent spec
+			if ( primChildren.length > 0 && specsByPath[ parentPath ] ) {
 
-				index = indices[ i ] * itemSize;
-
-				for ( let j = 0; j < itemSize; j ++ ) {
-
-					array2[ index2 ++ ] = array[ index ++ ];
-
-				}
+				specsByPath[ parentPath ].fields.primChildren = primChildren;
 
 			}
 
-			return new BufferAttribute( array2, itemSize );
-
-		}
-
-		function findMeshMaterial( data ) {
-
-			if ( ! data ) return undefined;
-
-			if ( 'rel material:binding' in data ) {
-
-				const reference = data[ 'rel material:binding' ];
-				const id = reference.replace( /^<\//, '' ).replace( />$/, '' );
-				const parts = id.split( '/' );
-
-				return findMaterial( root, ` "${ parts[ 1 ] }"` );
-
-			}
-
-			return findMaterial( data );
-
-		}
-
-		function findMaterial( data, id = '' ) {
-
-			for ( const name in data ) {
-
-				const object = data[ name ];
-
-				if ( name.startsWith( 'def Material' + id ) ) {
-
-					return object;
-
-				}
+		};
 
-				if ( typeof object === 'object' ) {
+		walkTree( root, '/' );
 
-					const material = findMaterial( object, id );
+		return { specsByPath };
 
-					if ( material ) return material;
-
-				}
+	}
 
-			}
+	_extractPrimData( data, path, primFields, specsByPath, SpecType ) {
 
-		}
+		if ( ! data || typeof data !== 'object' ) return;
 
-		function setTextureParams( map, data_value ) {
+		for ( const key in data ) {
 
-			// rotation, scale and translation
+			// Skip nested defs (handled by walkTree)
+			if ( key.startsWith( 'def ' ) ) continue;
 
-			if ( data_value[ 'float inputs:rotation' ] ) {
+			// Handle references/payloads
+			if ( key === 'prepend references' || key === 'payload' ) {
 
-				map.rotation = parseFloat( data_value[ 'float inputs:rotation' ] );
+				// Store as relationship
+				const relPath = path + '.' + key.replace( ' ', ':' );
+				specsByPath[ relPath ] = {
+					specType: SpecType.Relationship,
+					fields: { default: data[ key ] }
+				};
+				continue;
 
 			}
 
-			if ( data_value[ 'float2 inputs:scale' ] ) {
+			// Handle material binding
+			if ( key.startsWith( 'rel ' ) ) {
 
-				map.repeat = new Vector2().fromArray( JSON.parse( '[' + data_value[ 'float2 inputs:scale' ].replace( /[()]*/g, '' ) + ']' ) );
+				const relName = key.slice( 4 ); // Remove 'rel '
+				const relPath = path + '.' + relName;
+				const target = data[ key ].replace( /[<>]/g, '' );
+				specsByPath[ relPath ] = {
+					specType: SpecType.Relationship,
+					fields: { targetPaths: [ target ] }
+				};
+				continue;
 
 			}
 
-			if ( data_value[ 'float2 inputs:translation' ] ) {
+			// Handle xformOpOrder
+			if ( key.includes( 'xformOpOrder' ) ) {
 
-				map.offset = new Vector2().fromArray( JSON.parse( '[' + data_value[ 'float2 inputs:translation' ].replace( /[()]*/g, '' ) + ']' ) );
+				const ops = data[ key ]
+					.replace( /[\[\]]/g, '' )
+					.split( ',' )
+					.map( s => s.trim().replace( /"/g, '' ) );
+				primFields.xformOpOrder = ops;
+				continue;
 
 			}
 
-		}
-
-		function buildMaterial( data ) {
-
-			const material = new MeshPhysicalMaterial();
-
-			if ( data !== undefined ) {
-
-				let surface = undefined;
-
-				const surfaceConnection = data[ 'token outputs:surface.connect' ];
-
-				if ( surfaceConnection ) {
-
-					const match = /(\w+)\.output/.exec( surfaceConnection );
-
-					if ( match ) {
-
-						const surfaceName = match[ 1 ];
-						surface = data[ `def Shader "${surfaceName}"` ];
-
-					}
-
-				}
-
-				if ( surface !== undefined ) {
-
-					if ( 'color3f inputs:diffuseColor.connect' in surface ) {
-
-						const path = surface[ 'color3f inputs:diffuseColor.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.map = buildTexture( sampler );
-						material.map.colorSpace = SRGBColorSpace;
-
-						if ( 'def Shader "Transform2d_diffuse"' in data ) {
-
-							setTextureParams( material.map, data[ 'def Shader "Transform2d_diffuse"' ] );
-
-						}
-
-					} else if ( 'color3f inputs:diffuseColor' in surface ) {
-
-						const color = surface[ 'color3f inputs:diffuseColor' ].replace( /[()]*/g, '' );
-						material.color.fromArray( JSON.parse( '[' + color + ']' ) );
-
-					}
-
-					if ( 'color3f inputs:emissiveColor.connect' in surface ) {
-
-						const path = surface[ 'color3f inputs:emissiveColor.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.emissiveMap = buildTexture( sampler );
-						material.emissiveMap.colorSpace = SRGBColorSpace;
-						material.emissive.set( 0xffffff );
-
-						if ( 'def Shader "Transform2d_emissive"' in data ) {
-
-							setTextureParams( material.emissiveMap, data[ 'def Shader "Transform2d_emissive"' ] );
-
-						}
-
-					} else if ( 'color3f inputs:emissiveColor' in surface ) {
-
-						const color = surface[ 'color3f inputs:emissiveColor' ].replace( /[()]*/g, '' );
-						material.emissive.fromArray( JSON.parse( '[' + color + ']' ) );
-
-					}
-
-					if ( 'normal3f inputs:normal.connect' in surface ) {
-
-						const path = surface[ 'normal3f inputs:normal.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.normalMap = buildTexture( sampler );
-						material.normalMap.colorSpace = NoColorSpace;
-
-						if ( 'def Shader "Transform2d_normal"' in data ) {
-
-							setTextureParams( material.normalMap, data[ 'def Shader "Transform2d_normal"' ] );
-
-						}
-
-					}
-
-					if ( 'float inputs:roughness.connect' in surface ) {
-
-						const path = surface[ 'float inputs:roughness.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.roughness = 1.0;
-						material.roughnessMap = buildTexture( sampler );
-						material.roughnessMap.colorSpace = NoColorSpace;
-
-						if ( 'def Shader "Transform2d_roughness"' in data ) {
-
-							setTextureParams( material.roughnessMap, data[ 'def Shader "Transform2d_roughness"' ] );
-
-						}
-
-					} else if ( 'float inputs:roughness' in surface ) {
-
-						material.roughness = parseFloat( surface[ 'float inputs:roughness' ] );
+			// Handle typed attributes
+			// Format: [qualifier] type attrName (e.g., "uniform token[] joints", "float3 position")
+			const attrMatch = key.match( /^(?:uniform\s+)?(\w+(?:\[\])?)\s+(.+)$/ );
+			if ( attrMatch ) {
 
-					}
-
-					if ( 'float inputs:metallic.connect' in surface ) {
-
-						const path = surface[ 'float inputs:metallic.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.metalness = 1.0;
-						material.metalnessMap = buildTexture( sampler );
-						material.metalnessMap.colorSpace = NoColorSpace;
+				const valueType = attrMatch[ 1 ];
+				const attrName = attrMatch[ 2 ];
+				const rawValue = data[ key ];
 
-						if ( 'def Shader "Transform2d_metallic"' in data ) {
+				// Handle connection attributes (e.g., "inputs:normal.connect = </path>")
+				if ( attrName.endsWith( '.connect' ) ) {
 
-							setTextureParams( material.metalnessMap, data[ 'def Shader "Transform2d_metallic"' ] );
+					const baseAttrName = attrName.slice( 0, - 8 ); // Remove '.connect'
+					const attrPath = path + '.' + baseAttrName;
 
-						}
+					// Parse connection path - extract from <path> format
+					let connPath = String( rawValue ).trim();
+					if ( connPath.startsWith( '<' ) ) connPath = connPath.slice( 1 );
+					if ( connPath.endsWith( '>' ) ) connPath = connPath.slice( 0, - 1 );
 
-					} else if ( 'float inputs:metallic' in surface ) {
+					// Get or create the attribute spec
+					if ( ! specsByPath[ attrPath ] ) {
 
-						material.metalness = parseFloat( surface[ 'float inputs:metallic' ] );
+						specsByPath[ attrPath ] = {
+							specType: SpecType.Attribute,
+							fields: { typeName: valueType }
+						};
 
 					}
 
-					if ( 'float inputs:clearcoat.connect' in surface ) {
+					specsByPath[ attrPath ].fields.connectionPaths = [ connPath ];
+					continue;
 
-						const path = surface[ 'float inputs:clearcoat.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
+				}
 
-						material.clearcoat = 1.0;
-						material.clearcoatMap = buildTexture( sampler );
-						material.clearcoatMap.colorSpace = NoColorSpace;
+				// Handle timeSamples attributes specially
+				if ( attrName.endsWith( '.timeSamples' ) && typeof rawValue === 'object' ) {
 
-						if ( 'def Shader "Transform2d_clearcoat"' in data ) {
+					const baseAttrName = attrName.slice( 0, - 12 ); // Remove '.timeSamples'
+					const attrPath = path + '.' + baseAttrName;
 
-							setTextureParams( material.clearcoatMap, data[ 'def Shader "Transform2d_clearcoat"' ] );
+					// Parse timeSamples dictionary into times and values arrays
+					const times = [];
+					const values = [];
 
-						}
+					for ( const frameKey in rawValue ) {
 
-					} else if ( 'float inputs:clearcoat' in surface ) {
+						const frame = parseFloat( frameKey );
+						if ( isNaN( frame ) ) continue;
 
-						material.clearcoat = parseFloat( surface[ 'float inputs:clearcoat' ] );
+						times.push( frame );
+						values.push( this._parseAttributeValue( valueType, rawValue[ frameKey ] ) );
 
 					}
 
-					if ( 'float inputs:clearcoatRoughness.connect' in surface ) {
-
-						const path = surface[ 'float inputs:clearcoatRoughness.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.clearcoatRoughness = 1.0;
-						material.clearcoatRoughnessMap = buildTexture( sampler );
-						material.clearcoatRoughnessMap.colorSpace = NoColorSpace;
-
-						if ( 'def Shader "Transform2d_clearcoatRoughness"' in data ) {
-
-							setTextureParams( material.clearcoatRoughnessMap, data[ 'def Shader "Transform2d_clearcoatRoughness"' ] );
+					// Sort by time
+					const sorted = times.map( ( t, i ) => ( { t, v: values[ i ] } ) ).sort( ( a, b ) => a.t - b.t );
 
+					specsByPath[ attrPath ] = {
+						specType: SpecType.Attribute,
+						fields: {
+							timeSamples: { times: sorted.map( s => s.t ), values: sorted.map( s => s.v ) },
+							typeName: valueType
 						}
+					};
 
-					} else if ( 'float inputs:clearcoatRoughness' in surface ) {
-
-						material.clearcoatRoughness = parseFloat( surface[ 'float inputs:clearcoatRoughness' ] );
-
-					}
-
-					if ( 'float inputs:ior' in surface ) {
-
-						material.ior = parseFloat( surface[ 'float inputs:ior' ] );
-
-					}
-
-					if ( 'float inputs:occlusion.connect' in surface ) {
-
-						const path = surface[ 'float inputs:occlusion.connect' ];
-						const sampler = findTexture( root, /(\w+).output/.exec( path )[ 1 ] );
-
-						material.aoMap = buildTexture( sampler );
-						material.aoMap.colorSpace = NoColorSpace;
-
-						if ( 'def Shader "Transform2d_occlusion"' in data ) {
+				} else {
 
-							setTextureParams( material.aoMap, data[ 'def Shader "Transform2d_occlusion"' ] );
+					// Parse value based on type
+					const parsedValue = this._parseAttributeValue( valueType, rawValue );
 
-						}
-
-					}
+					// Store as attribute spec
+					const attrPath = path + '.' + attrName;
+					specsByPath[ attrPath ] = {
+						specType: SpecType.Attribute,
+						fields: { default: parsedValue, typeName: valueType }
+					};
 
 				}
 
 			}
 
-			return material;
-
 		}
 
-		function findTexture( data, id ) {
+	}
 
-			for ( const name in data ) {
+	_parseAttributeValue( valueType, rawValue ) {
 
-				const object = data[ name ];
+		if ( rawValue === undefined || rawValue === null ) return undefined;
 
-				if ( name.startsWith( `def Shader "${ id }"` ) ) {
+		const str = String( rawValue ).trim();
 
-					return object;
+		// Array types
+		if ( valueType.endsWith( '[]' ) ) {
 
-				}
+			// Parse JSON-like arrays
+			try {
 
-				if ( typeof object === 'object' ) {
+				// Handle arrays with parentheses like [(1,2,3), (4,5,6)]
+				// Remove trailing comma (valid in USDA but not JSON)
+				let cleaned = str.replace( /\(/g, '[' ).replace( /\)/g, ']' );
+				if ( cleaned.endsWith( ',' ) ) cleaned = cleaned.slice( 0, - 1 );
+				const parsed = JSON.parse( cleaned );
 
-					const texture = findTexture( object, id );
+				// Flatten nested arrays for types like point3f[]
+				if ( Array.isArray( parsed ) && Array.isArray( parsed[ 0 ] ) ) {
 
-					if ( texture ) return texture;
+					return parsed.flat();
 
 				}
 
-			}
-
-		}
-
-		function buildTexture( data ) {
-
-			if ( 'asset inputs:file' in data ) {
-
-				const path = data[ 'asset inputs:file' ].replace( /@*/g, '' ).trim();
-
-				const loader = new TextureLoader();
-
-				const texture = loader.load( assets[ path ] );
-
-				const map = {
-					'"clamp"': ClampToEdgeWrapping,
-					'"mirror"': MirroredRepeatWrapping,
-					'"repeat"': RepeatWrapping
-				};
+				return parsed;
 
-				if ( 'token inputs:wrapS' in data ) {
+			} catch ( e ) {
 
-					texture.wrapS = map[ data[ 'token inputs:wrapS' ] ];
+				// Try simple array parsing
+				const cleaned = str.replace( /[\[\]]/g, '' );
+				return cleaned.split( ',' ).map( s => {
 
-				}
-
-				if ( 'token inputs:wrapT' in data ) {
-
-					texture.wrapT = map[ data[ 'token inputs:wrapT' ] ];
-
-				}
+					const trimmed = s.trim();
+					const num = parseFloat( trimmed );
+					return isNaN( num ) ? trimmed.replace( /"/g, '' ) : num;
 
-				return texture;
+				} );
 
 			}
 
-			return null;
-
 		}
 
-		function buildObject( data ) {
-
-			const geometry = buildGeometry( findMeshGeometry( data ) );
-			const material = buildMaterial( findMeshMaterial( data ) );
-
-			const mesh = geometry ? new Mesh( geometry, material ) : new Object3D();
+		// Vector types (double3, float3, point3f, etc.)
+		if ( valueType.includes( '3' ) || valueType.includes( '2' ) || valueType.includes( '4' ) ) {
 
-			if ( 'matrix4d xformOp:transform' in data ) {
-
-				const array = JSON.parse( '[' + data[ 'matrix4d xformOp:transform' ].replace( /[()]*/g, '' ) + ']' );
-
-				mesh.matrix.fromArray( array );
-				mesh.matrix.decompose( mesh.position, mesh.quaternion, mesh.scale );
-
-			}
-
-			return mesh;
+			// Parse (x, y, z) format
+			const cleaned = str.replace( /[()]/g, '' );
+			const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
+			return values;
 
 		}
 
-		function buildHierarchy( data, group ) {
-
-			for ( const name in data ) {
+		// Quaternion types (quatf, quatd, quath)
+		if ( valueType.startsWith( 'quat' ) ) {
 
-				if ( name.startsWith( 'def Scope' ) ) {
+			// Parse (w, x, y, z) format
+			const cleaned = str.replace( /[()]/g, '' );
+			const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
+			return values;
 
-					buildHierarchy( data[ name ], group );
-
-				} else if ( name.startsWith( 'def Xform' ) ) {
+		}
 
-					const mesh = buildObject( data[ name ] );
+		// Matrix types
+		if ( valueType.includes( 'matrix' ) ) {
 
-					if ( /def Xform "(\w+)"/.test( name ) ) {
+			const cleaned = str.replace( /[()]/g, '' );
+			const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
+			return values;
 
-						mesh.name = /def Xform "(\w+)"/.exec( name )[ 1 ];
+		}
 
-					}
+		// Scalar numeric types
+		if ( valueType === 'float' || valueType === 'double' || valueType === 'int' ) {
 
-					group.add( mesh );
+			return parseFloat( str );
 
-					buildHierarchy( data[ name ], mesh );
+		}
 
-				}
+		// String/token types
+		if ( valueType === 'string' || valueType === 'token' ) {
 
-			}
+			return str.replace( /"/g, '' );
 
 		}
 
-		function buildGroup( data ) {
-
-			const group = new Group();
-
-			buildHierarchy( data, group );
+		// Asset path
+		if ( valueType === 'asset' ) {
 
-			return group;
+			return str.replace( /@/g, '' );
 
 		}
 
-		return buildGroup( root );
+		// Default: return as string with quotes removed
+		return str.replace( /"/g, '' );
 
 	}
 

+ 87 - 1719
examples/jsm/loaders/usd/USDCParser.js

@@ -1,25 +1,3 @@
-import {
-	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
@@ -94,29 +72,6 @@ const FIELD_SET_TERMINATOR = 0xFFFFFFFF;
 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
@@ -537,14 +492,15 @@ class ValueRep {
 
 class USDCParser {
 
-	parse( buffer, assets = {} ) {
+	/**
+	 * Parse USDC file and return raw spec data without building Three.js scene.
+	 * Used by USDComposer for unified scene composition.
+	 */
+	parseData( buffer ) {
 
 		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();
@@ -555,7 +511,20 @@ class USDCParser {
 		this._readPaths();
 		this._readSpecs();
 
-		return this._buildScene();
+		// Build specsByPath without building scene
+		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 };
+
+		}
+
+		return { specsByPath: this.specsByPath };
 
 	}
 
@@ -1170,6 +1139,49 @@ class USDCParser {
 			case TypeEnum.Permission:
 			case TypeEnum.Variability:
 				return payload;
+
+			// Vec2h: Two half-floats fit in 4 bytes, stored directly
+			case TypeEnum.Vec2h: {
+
+				const buf = new ArrayBuffer( 4 );
+				const view = new DataView( buf );
+				view.setUint32( 0, payload, true );
+				return [ this._halfToFloat( view.getUint16( 0, true ) ), this._halfToFloat( view.getUint16( 2, true ) ) ];
+
+			}
+
+			// Inlined vectors that don't fit in 4 bytes are encoded as signed 8-bit integers
+			// Vec2f = 8 bytes (2x float32), Vec3f = 12 bytes, Vec4f = 16 bytes, etc.
+			case TypeEnum.Vec2f:
+			case TypeEnum.Vec2i: {
+
+				const buf = new ArrayBuffer( 4 );
+				const view = new DataView( buf );
+				view.setUint32( 0, payload, true );
+				return [ view.getInt8( 0 ), view.getInt8( 1 ) ];
+
+			}
+
+			case TypeEnum.Vec3f:
+			case TypeEnum.Vec3i: {
+
+				const buf = new ArrayBuffer( 4 );
+				const view = new DataView( buf );
+				view.setUint32( 0, payload, true );
+				return [ view.getInt8( 0 ), view.getInt8( 1 ), view.getInt8( 2 ) ];
+
+			}
+
+			case TypeEnum.Vec4f:
+			case TypeEnum.Vec4i: {
+
+				const buf = new ArrayBuffer( 4 );
+				const view = new DataView( buf );
+				view.setUint32( 0, payload, true );
+				return [ view.getInt8( 0 ), view.getInt8( 1 ), view.getInt8( 2 ), view.getInt8( 3 ) ];
+
+			}
+
 			default:
 				return payload;
 
@@ -1388,6 +1400,25 @@ class USDCParser {
 
 			}
 
+			case TypeEnum.VariantSelectionMap: {
+
+				const elementCount = reader.readUint64();
+				const map = {};
+
+				for ( let i = 0; i < elementCount; i ++ ) {
+
+					const keyIdx = reader.readUint32();
+					const valueIdx = reader.readUint32();
+					const key = this.tokens[ this.strings[ keyIdx ] ];
+					const value = this.tokens[ this.strings[ valueIdx ] ];
+					if ( key && value ) map[ key ] = value;
+
+				}
+
+				return map;
+
+			}
+
 			default:
 				console.warn( 'USDCParser: Unsupported scalar type', type );
 				return null;
@@ -1625,7 +1656,12 @@ class USDCParser {
 
 	_readHalf() {
 
-		const h = this.reader.readUint16();
+		return this._halfToFloat( this.reader.readUint16() );
+
+	}
+
+	_halfToFloat( h ) {
+
 		// Convert half to float (IEEE 754 half-precision)
 		const sign = ( h & 0x8000 ) >> 15;
 		const exp = ( h & 0x7C00 ) >> 10;
@@ -1653,41 +1689,6 @@ class USDCParser {
 
 	}
 
-	// ========================================================================
-	// 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 = {};
@@ -1723,1639 +1724,6 @@ class USDCParser {
 
 	}
 
-	_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;
-
-			}
-
-		}
-
-		return null;
-
-	}
-
 }
 
 export { USDCParser };

+ 2502 - 0
examples/jsm/loaders/usd/USDComposer.js

@@ -0,0 +1,2502 @@
+import {
+	AnimationClip,
+	BufferAttribute,
+	BufferGeometry,
+	ClampToEdgeWrapping,
+	Euler,
+	Group,
+	Matrix4,
+	Mesh,
+	MeshPhysicalMaterial,
+	MirroredRepeatWrapping,
+	NoColorSpace,
+	Object3D,
+	Quaternion,
+	QuaternionKeyframeTrack,
+	RepeatWrapping,
+	SkinnedMesh,
+	Skeleton,
+	Bone,
+	SRGBColorSpace,
+	Texture,
+	VectorKeyframeTrack
+} from 'three';
+
+// Spec types (must match USDCParser)
+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
+};
+
+/**
+ * USDComposer handles scene composition from parsed USD data.
+ * This includes reference resolution, variant selection, transform handling,
+ * and building the Three.js scene graph.
+ *
+ * Works with specsByPath format from USDCParser.
+ */
+class USDComposer {
+
+	constructor() {
+
+		this.textureCache = {};
+		this.skinnedMeshes = [];
+
+	}
+
+	/**
+	 * Compose a Three.js scene from parsed USD data.
+	 * @param {Object} parsedData - Data from USDCParser or USDAParser
+	 * @param {Object} assets - Dictionary of referenced assets (specsByPath or blob URLs)
+	 * @param {Object} variantSelections - External variant selections
+	 * @param {string} basePath - Base path for resolving relative references
+	 * @returns {Group} Three.js scene graph
+	 */
+	compose( parsedData, assets = {}, variantSelections = {}, basePath = '' ) {
+
+		this.specsByPath = parsedData.specsByPath;
+		this.assets = assets;
+		this.externalVariantSelections = variantSelections;
+		this.basePath = basePath;
+		this.skinnedMeshes = [];
+		this.skeletons = {};
+
+		// Get FPS from root spec
+		const rootSpec = this.specsByPath[ '/' ];
+		const rootFields = rootSpec ? rootSpec.fields : {};
+		this.fps = rootFields.framesPerSecond || rootFields.timeCodesPerSecond || 30;
+
+		const group = new Group();
+		this._buildHierarchy( group, '/' );
+
+		// Bind skeletons to skinned meshes
+		this._bindSkeletons();
+
+		// Build animations
+		group.animations = this._buildAnimations();
+
+		// Handle Z-up to Y-up conversion
+		if ( rootSpec && rootSpec.fields && rootSpec.fields.upAxis === 'Z' ) {
+
+			group.rotation.x = - Math.PI / 2;
+
+		}
+
+		return group;
+
+	}
+
+	/**
+	 * Apply USD transforms to a Three.js object.
+	 * Handles xformOpOrder with proper matrix composition.
+	 * USD uses row-vector convention, Three.js uses column-vector.
+	 */
+	applyTransform( obj, fields, attrs = {} ) {
+
+		const data = { ...fields, ...attrs };
+		const xformOpOrder = data[ 'xformOpOrder' ];
+
+		// If we have xformOpOrder, apply transforms using matrices
+		if ( xformOpOrder && xformOpOrder.length > 0 ) {
+
+			const matrix = new Matrix4();
+			const tempMatrix = new Matrix4();
+
+			// Iterate FORWARD for Three.js column-vector convention
+			for ( let i = 0; i < xformOpOrder.length; i ++ ) {
+
+				const op = xformOpOrder[ i ];
+				const isInverse = op.startsWith( '!invert!' );
+				const opName = isInverse ? op.slice( 8 ) : op;
+
+				if ( opName === 'xformOp:transform' ) {
+
+					const m = data[ 'xformOp:transform' ];
+					if ( m && m.length === 16 ) {
+
+						tempMatrix.fromArray( m );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:translate' ) {
+
+					const t = data[ 'xformOp:translate' ];
+					if ( t ) {
+
+						tempMatrix.makeTranslation( t[ 0 ], t[ 1 ], t[ 2 ] );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:translate:pivot' ) {
+
+					const t = data[ 'xformOp:translate:pivot' ];
+					if ( t ) {
+
+						tempMatrix.makeTranslation( t[ 0 ], t[ 1 ], t[ 2 ] );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:scale' ) {
+
+					const s = data[ 'xformOp:scale' ];
+					if ( s ) {
+
+						if ( Array.isArray( s ) ) {
+
+							tempMatrix.makeScale( s[ 0 ], s[ 1 ], s[ 2 ] );
+
+						} else {
+
+							tempMatrix.makeScale( s, s, s );
+
+						}
+
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:rotateXYZ' ) {
+
+					const r = data[ 'xformOp:rotateXYZ' ];
+					if ( r ) {
+
+						// USD rotateXYZ: matrix = Rx * Ry * Rz
+						// Three.js Euler 'ZYX' order produces same result
+						const euler = new Euler(
+							r[ 0 ] * Math.PI / 180,
+							r[ 1 ] * Math.PI / 180,
+							r[ 2 ] * Math.PI / 180,
+							'ZYX'
+						);
+						tempMatrix.makeRotationFromEuler( euler );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:rotateX' ) {
+
+					const r = data[ 'xformOp:rotateX' ];
+					if ( r !== undefined ) {
+
+						tempMatrix.makeRotationX( r * Math.PI / 180 );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:rotateY' ) {
+
+					const r = data[ 'xformOp:rotateY' ];
+					if ( r !== undefined ) {
+
+						tempMatrix.makeRotationY( r * Math.PI / 180 );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:rotateZ' ) {
+
+					const r = data[ 'xformOp:rotateZ' ];
+					if ( r !== undefined ) {
+
+						tempMatrix.makeRotationZ( r * Math.PI / 180 );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				} else if ( opName === 'xformOp:orient' ) {
+
+					const q = data[ 'xformOp:orient' ];
+					if ( q && q.length === 4 ) {
+
+						// USD quaternion format is (w, x, y, z) - real part first
+						// Three.js Quaternion is (x, y, z, w)
+						const quat = new Quaternion( q[ 1 ], q[ 2 ], q[ 3 ], q[ 0 ] );
+						tempMatrix.makeRotationFromQuaternion( quat );
+						if ( isInverse ) tempMatrix.invert();
+						matrix.multiply( tempMatrix );
+
+					}
+
+				}
+
+			}
+
+			obj.matrix.copy( matrix );
+			obj.matrix.decompose( obj.position, obj.quaternion, obj.scale );
+			return;
+
+		}
+
+		// Fallback: handle individual transform ops without order
+		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
+			);
+
+		}
+
+		if ( data[ 'xformOp:orient' ] ) {
+
+			const q = data[ 'xformOp:orient' ];
+			if ( q.length === 4 ) {
+
+				// USD quaternion format is (w, x, y, z) - real part first
+				// Three.js Quaternion is (x, y, z, w)
+				obj.quaternion.set( q[ 1 ], q[ 2 ], q[ 3 ], q[ 0 ] );
+
+			}
+
+		}
+
+	}
+
+	/**
+	 * Check if a path is a direct child of parentPath.
+	 */
+	_isDirectChild( parentPath, path, prefix ) {
+
+		if ( ! path.startsWith( prefix ) ) return false;
+
+		const remainder = path.slice( prefix.length );
+		if ( remainder.length === 0 ) return false;
+
+		// Check for variant paths or simple names
+		if ( remainder.startsWith( '{' ) ) {
+
+			return false; // Variant paths are not direct children
+
+		}
+
+		return ! remainder.includes( '/' );
+
+	}
+
+	/**
+	 * Build the scene hierarchy recursively.
+	 */
+	_buildHierarchy( parent, parentPath ) {
+
+		const prefix = parentPath === '/' ? '/' : parentPath + '/';
+
+		// Get variant paths to search
+		const variantPaths = this._getVariantPaths( parentPath );
+
+		for ( const path in this.specsByPath ) {
+
+			const spec = this.specsByPath[ path ];
+
+			// Check if direct child of parent or variant paths
+			let isChild = this._isDirectChild( parentPath, path, prefix );
+
+			if ( ! isChild ) {
+
+				for ( const vp of variantPaths ) {
+
+					const vpPrefix = vp + '/';
+					if ( this._isDirectChild( vp, path, vpPrefix ) ) {
+
+						isChild = true;
+						break;
+
+					}
+
+				}
+
+			}
+
+			if ( ! isChild ) continue;
+			if ( spec.specType !== SpecType.Prim ) continue;
+
+			const name = path.split( '/' ).pop();
+			const typeName = spec.fields.typeName;
+
+			// Check for references/payloads
+			const refValue = this._getReference( path, spec );
+			if ( refValue ) {
+
+				// Get local variant selections from this prim
+				const localVariants = this._getLocalVariantSelections( spec.fields );
+
+				// Resolve the reference
+				const referencedGroup = this._resolveReference( refValue, localVariants );
+				if ( referencedGroup ) {
+
+					const attrs = this._getAttributes( path );
+
+					// Check if the referenced content is a single mesh (or container with single mesh)
+					// This handles the USDZExporter pattern: Xform references geometry file
+					const singleMesh = this._findSingleMesh( referencedGroup );
+
+					if ( singleMesh && ( typeName === 'Xform' || ! typeName ) ) {
+
+						// Merge the mesh into this prim
+						singleMesh.name = name;
+						this.applyTransform( singleMesh, spec.fields, attrs );
+
+						// Apply material binding from the referencing prim if present
+						this._applyMaterialBinding( singleMesh, path );
+
+						parent.add( singleMesh );
+
+						// Still build local children (overrides)
+						this._buildHierarchy( singleMesh, path );
+
+					} else {
+
+						// Create a container for the referenced content
+						const obj = new Object3D();
+						obj.name = name;
+						this.applyTransform( obj, spec.fields, attrs );
+
+						// Add all children from the referenced group
+						while ( referencedGroup.children.length > 0 ) {
+
+							obj.add( referencedGroup.children[ 0 ] );
+
+						}
+
+						parent.add( obj );
+
+						// Still build local children (overrides)
+						this._buildHierarchy( obj, path );
+
+					}
+
+					continue;
+
+				}
+
+			}
+
+			// Build appropriate object based on type
+			if ( typeName === 'SkelRoot' ) {
+
+				// Skeletal root - treat as transform but track for skeleton binding
+				const obj = new Object3D();
+				obj.name = name;
+				obj.userData.isSkelRoot = true;
+				const attrs = this._getAttributes( path );
+				this.applyTransform( obj, spec.fields, attrs );
+				parent.add( obj );
+				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 === 'Mesh' ) {
+
+				const obj = this._buildMesh( path, spec );
+				if ( obj ) {
+
+					parent.add( obj );
+
+				}
+
+			} else if ( typeName === 'Material' || typeName === 'Shader' ) {
+
+				// Skip materials/shaders, they're referenced by meshes
+
+			} else {
+
+				// Transform node, group, or unknown type
+				const obj = new Object3D();
+				obj.name = name;
+				const attrs = this._getAttributes( path );
+				this.applyTransform( obj, spec.fields, attrs );
+				parent.add( obj );
+				this._buildHierarchy( obj, path );
+
+			}
+
+		}
+
+	}
+
+	/**
+	 * Get variant paths for a parent path based on variant selections.
+	 */
+	_getVariantPaths( parentPath ) {
+
+		const parentSpec = this.specsByPath[ parentPath ];
+		const variantSetChildren = parentSpec?.fields?.variantSetChildren;
+		const variantPaths = [];
+
+		if ( ! variantSetChildren || variantSetChildren.length === 0 ) {
+
+			return variantPaths;
+
+		}
+
+		for ( const variantSetName of variantSetChildren ) {
+
+			// External selections take priority
+			let selectedVariant = this.externalVariantSelections[ variantSetName ] || null;
+
+			// Fall back to file's internal selection
+			if ( ! selectedVariant ) {
+
+				const variantSelection = parentSpec.fields.variantSelection;
+				selectedVariant = variantSelection ? variantSelection[ variantSetName ] : null;
+
+			}
+
+			// Fall back to first variant child
+			if ( ! selectedVariant ) {
+
+				const variantSetPath = parentPath + '/{' + variantSetName + '=}';
+				const variantSetSpec = this.specsByPath[ variantSetPath ];
+				if ( variantSetSpec?.fields?.variantChildren ) {
+
+					selectedVariant = variantSetSpec.fields.variantChildren[ 0 ];
+
+				}
+
+			}
+
+			if ( selectedVariant ) {
+
+				const variantPath = parentPath + '/{' + variantSetName + '=' + selectedVariant + '}';
+				variantPaths.push( variantPath );
+
+			}
+
+		}
+
+		return variantPaths;
+
+	}
+
+	/**
+	 * Resolve a file path relative to basePath.
+	 */
+	_resolveFilePath( refPath ) {
+
+		let cleanPath = refPath;
+
+		// Remove ./ prefix
+		if ( cleanPath.startsWith( './' ) ) {
+
+			cleanPath = cleanPath.slice( 2 );
+
+		}
+
+		// Combine with base path
+		if ( this.basePath ) {
+
+			return this.basePath + '/' + cleanPath;
+
+		}
+
+		return cleanPath;
+
+	}
+
+	/**
+	 * Resolve a USD reference and return the composed content.
+	 * @param {string} refValue - Reference value like "@./path/to/file.usdc@"
+	 * @param {Object} localVariants - Variant selections to apply
+	 * @returns {Group|null} Composed content or null
+	 */
+	_resolveReference( refValue, localVariants = {} ) {
+
+		if ( ! refValue ) return null;
+
+		const match = refValue.match( /@([^@]+)@(?:<([^>]+)>)?/ );
+		if ( ! match ) return null;
+
+		const filePath = match[ 1 ];
+		const primPath = match[ 2 ]; // e.g., "/Geometry"
+
+		const resolvedPath = this._resolveFilePath( filePath );
+
+		// Merge variant selections - external takes priority, then local
+		const mergedVariants = { ...localVariants, ...this.externalVariantSelections };
+
+		// Look up pre-parsed data in assets
+		const referencedData = this.assets[ resolvedPath ];
+		if ( ! referencedData ) return null;
+
+		// If it's specsByPath data, compose it
+		if ( referencedData.specsByPath ) {
+
+			const composer = new USDComposer();
+			const newBasePath = this._getBasePath( resolvedPath );
+			const composedGroup = composer.compose( referencedData, this.assets, mergedVariants, newBasePath );
+
+			// If a primPath is specified, find and return just that subtree
+			if ( primPath ) {
+
+				const primName = primPath.split( '/' ).pop();
+
+				// Find the direct child with this name (not a deep search)
+				// This is important because there may be multiple objects with the same name
+				let targetObject = null;
+				for ( const child of composedGroup.children ) {
+
+					if ( child.name === primName ) {
+
+						targetObject = child;
+						break;
+
+					}
+
+				}
+
+				if ( targetObject ) {
+
+					// Detach from parent for re-parenting
+					composedGroup.remove( targetObject );
+
+					// Wrap in a group to maintain consistent return type
+					const wrapper = new Group();
+					wrapper.add( targetObject );
+					return wrapper;
+
+				}
+
+			}
+
+			return composedGroup;
+
+		}
+
+		// If it's already a Three.js Group (legacy support), clone it
+		if ( referencedData.isGroup || referencedData.isObject3D ) {
+
+			return referencedData.clone();
+
+		}
+
+		return null;
+
+	}
+
+	/**
+	 * Find a single mesh in the group's shallow hierarchy.
+	 * Only returns a mesh if it's at depth 0 or 1, not deeply nested.
+	 * This preserves transforms in complex hierarchies like Kitchen Set
+	 * while supporting USDZExporter round-trip (Xform > Xform > Mesh pattern).
+	 */
+	_findSingleMesh( group ) {
+
+		// Check direct children first
+		for ( const child of group.children ) {
+
+			if ( child.isMesh ) {
+
+				group.remove( child );
+				return child;
+
+			}
+
+		}
+
+		// Check grandchildren (USDZExporter pattern: Xform > Geometry > Mesh)
+		// Only if there's exactly one child with exactly one grandchild
+		if ( group.children.length === 1 ) {
+
+			const child = group.children[ 0 ];
+
+			if ( child.children && child.children.length === 1 ) {
+
+				const grandchild = child.children[ 0 ];
+
+				if ( grandchild.isMesh && ! this._hasNonIdentityTransform( child ) ) {
+
+					// Safe to merge - intermediate has identity transform
+					child.remove( grandchild );
+					return grandchild;
+
+				}
+
+			}
+
+		}
+
+		return null;
+
+	}
+
+	/**
+	 * Check if an object has a non-identity local transform.
+	 */
+	_hasNonIdentityTransform( obj ) {
+
+		const pos = obj.position;
+		const rot = obj.rotation;
+		const scale = obj.scale;
+
+		const hasPosition = pos.x !== 0 || pos.y !== 0 || pos.z !== 0;
+		const hasRotation = rot.x !== 0 || rot.y !== 0 || rot.z !== 0;
+		const hasScale = scale.x !== 1 || scale.y !== 1 || scale.z !== 1;
+
+		return hasPosition || hasRotation || hasScale;
+
+	}
+
+	/**
+	 * Get the base path (directory) from a file path.
+	 */
+	_getBasePath( filePath ) {
+
+		const lastSlash = filePath.lastIndexOf( '/' );
+		return lastSlash >= 0 ? filePath.slice( 0, lastSlash ) : '';
+
+	}
+
+	/**
+	 * Extract variant selections from a spec's fields.
+	 */
+	_getLocalVariantSelections( fields ) {
+
+		const variants = {};
+
+		if ( fields.variantSelection ) {
+
+			for ( const key in fields.variantSelection ) {
+
+				variants[ key ] = fields.variantSelection[ key ];
+
+			}
+
+		}
+
+		return variants;
+
+	}
+
+	/**
+	 * Get reference value from a prim spec.
+	 * Checks for references in USDC format (fields.references) and
+	 * USDA format (relationship specs).
+	 */
+	_getReference( path, spec ) {
+
+		// USDC format: references stored in fields
+		if ( spec.fields.references && spec.fields.references.length > 0 ) {
+
+			const ref = spec.fields.references[ 0 ];
+			// Reference can be string or object with assetPath
+			if ( typeof ref === 'string' ) return ref;
+			if ( ref.assetPath ) return '@' + ref.assetPath + '@';
+
+		}
+
+		// USDC format: payload stored in fields
+		if ( spec.fields.payload ) {
+
+			const payload = spec.fields.payload;
+			if ( typeof payload === 'string' ) return payload;
+			if ( payload.assetPath ) return '@' + payload.assetPath + '@';
+
+		}
+
+		// USDA format: check relationship specs
+		const prependRefPath = path + '.prepend:references';
+		const prependRefSpec = this.specsByPath[ prependRefPath ];
+		if ( prependRefSpec && prependRefSpec.fields.default ) {
+
+			return prependRefSpec.fields.default;
+
+		}
+
+		const payloadPath = path + '.payload';
+		const payloadSpec = this.specsByPath[ payloadPath ];
+		if ( payloadSpec && payloadSpec.fields.default ) {
+
+			return payloadSpec.fields.default;
+
+		}
+
+		return null;
+
+	}
+
+	/**
+	 * Get attributes for a path from attribute specs.
+	 */
+	_getAttributes( path ) {
+
+		const attrs = {};
+		const prefix = path + '.';
+
+		for ( const attrPath in this.specsByPath ) {
+
+			if ( ! attrPath.startsWith( prefix ) ) continue;
+
+			const attrSpec = this.specsByPath[ attrPath ];
+			let attrName = attrPath.slice( prefix.length );
+
+			// USDA includes type annotations like "token[] joints" or "matrix4d[] bindTransforms"
+			// Extract the actual attribute name after the type annotation
+			const typeMatch = attrName.match( /^[a-zA-Z0-9]+(?:\[\])?\s+(.+)$/ );
+			if ( typeMatch ) {
+
+				attrName = typeMatch[ 1 ];
+
+			}
+
+			if ( attrSpec.fields?.default !== undefined ) {
+
+				attrs[ attrName ] = attrSpec.fields.default;
+
+			}
+
+			// Include elementSize for skinning attributes
+			if ( attrSpec.fields?.elementSize !== undefined ) {
+
+				attrs[ attrName + ':elementSize' ] = attrSpec.fields.elementSize;
+
+			}
+
+			if ( attrName.startsWith( 'primvars:' ) && attrSpec.fields?.typeName !== undefined ) {
+
+				attrs[ attrName + ':typeName' ] = attrSpec.fields.typeName;
+
+			}
+
+		}
+
+		return attrs;
+
+	}
+
+	/**
+	 * Build a mesh from a Mesh spec.
+	 */
+	_buildMesh( path, spec ) {
+
+		const attrs = this._getAttributes( 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 ) {
+
+			geometry = this._buildGeometryWithSubsets( attrs, geomSubsets, hasSkinning );
+			material = geomSubsets.map( subset => this._buildMaterialForPath( subset.materialPath ) );
+
+		} else {
+
+			geometry = this._buildGeometry( path, attrs, hasSkinning );
+			material = this._buildMaterial( path, spec.fields );
+
+		}
+
+		// Apply displayColor if no texture/color was set
+		const displayColor = attrs[ 'primvars:displayColor' ];
+		if ( displayColor && displayColor.length >= 3 ) {
+
+			const applyDisplayColor = ( mat ) => {
+
+				if ( mat.color && mat.color.r === 1 && mat.color.g === 1 && mat.color.b === 1 && ! mat.map ) {
+
+					mat.color.setRGB( displayColor[ 0 ], displayColor[ 1 ], displayColor[ 2 ] );
+
+				}
+
+			};
+
+			if ( Array.isArray( material ) ) {
+
+				material.forEach( applyDisplayColor );
+
+			} else {
+
+				applyDisplayColor( material );
+
+			}
+
+		}
+
+		let mesh;
+
+		if ( hasSkinning ) {
+
+			mesh = new SkinnedMesh( geometry, material );
+
+			// Find skeleton path from skel:skeleton relationship
+			// USDC uses ".skel:skeleton", USDA uses ".rel skel:skeleton"
+			let skelBindingSpec = this.specsByPath[ path + '.skel:skeleton' ];
+			if ( ! skelBindingSpec ) {
+
+				skelBindingSpec = this.specsByPath[ path + '.rel skel:skeleton' ];
+
+			}
+
+			let skeletonPath = null;
+
+			if ( skelBindingSpec ) {
+
+				if ( skelBindingSpec.fields.targetPaths && skelBindingSpec.fields.targetPaths.length > 0 ) {
+
+					skeletonPath = skelBindingSpec.fields.targetPaths[ 0 ];
+
+				} else if ( skelBindingSpec.fields.default ) {
+
+					// USDA stores relationship targets in default field with angle brackets
+					skeletonPath = skelBindingSpec.fields.default.replace( /<|>/g, '' );
+
+				}
+
+			}
+
+			// Get per-mesh joint mapping
+			const localJoints = attrs[ 'skel:joints' ];
+
+			this.skinnedMeshes.push( { mesh, skeletonPath, path, localJoints } );
+
+		} else {
+
+			mesh = new Mesh( geometry, material );
+
+		}
+
+		mesh.name = path.split( '/' ).pop();
+		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._getAttributes( 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: p.split( '/' ).pop(),
+				indices: indices,
+				materialPath: materialPath
+			} );
+
+		}
+
+		return subsets;
+
+	}
+
+	_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 ) {
+
+				const triangulatedUvIndices = this._triangulateIndices( uvIndices, faceVertexCounts );
+				uvData = this._expandAttribute( uvs, triangulatedUvIndices, 2 );
+
+			} else if ( indices && uvs.length / 2 === points.length / 3 ) {
+
+				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;
+
+				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;
+
+				}
+
+				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;
+
+	}
+
+	_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
+		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
+		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;
+
+			}
+
+		}
+
+		if ( currentSubset >= 0 && sortedTriangles.length > groupStart ) {
+
+			groups.push( {
+				start: groupStart * 3,
+				count: ( sortedTriangles.length - groupStart ) * 3,
+				materialIndex: currentSubset
+			} );
+
+		}
+
+		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;
+
+		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;
+
+				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 ];
+
+				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 ) {
+
+						uvData[ newIdx * 2 ] = uvs[ pointIdx * 2 ];
+						uvData[ newIdx * 2 + 1 ] = uvs[ pointIdx * 2 + 1 ];
+
+					}
+
+				}
+
+				if ( normalData && normals ) {
+
+					if ( origNormalIndices ) {
+
+						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 ) {
+
+						normalData[ newIdx * 3 ] = normals[ pointIdx * 3 ];
+						normalData[ newIdx * 3 + 1 ] = normals[ pointIdx * 3 + 1 ];
+						normalData[ newIdx * 3 + 2 ] = normals[ pointIdx * 3 + 2 ];
+
+					}
+
+				}
+
+				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;
+
+	}
+
+	_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 };
+
+	}
+
+	_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 ) {
+
+				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 Array( indices.length * itemSize );
+
+		for ( let i = 0; i < indices.length; i ++ ) {
+
+			const srcIdx = indices[ i ];
+
+			for ( let j = 0; j < itemSize; j ++ ) {
+
+				expanded[ i * itemSize + j ] = data[ srcIdx * itemSize + j ];
+
+			}
+
+		}
+
+		return expanded;
+
+	}
+
+	_buildMaterial( meshPath, fields ) {
+
+		const material = new MeshPhysicalMaterial();
+
+		let materialPath = null;
+		let materialBinding = fields[ 'material:binding' ];
+
+		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 ( ! materialPath ) {
+
+			const materialPaths = [];
+			const prefix = meshPath + '/';
+
+			for ( const path in this.specsByPath ) {
+
+				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 ] );
+
+				}
+
+			}
+
+			if ( materialPaths.length > 0 ) {
+
+				materialPath = this._pickBestMaterial( materialPaths );
+
+			}
+
+		}
+
+		if ( ! materialPath ) {
+
+			const meshParts = meshPath.split( '/' );
+			const rootPath = '/' + meshParts[ 1 ];
+
+			for ( const path in this.specsByPath ) {
+
+				const spec = this.specsByPath[ path ];
+				if ( spec.specType !== SpecType.Prim ) continue;
+				if ( spec.fields.typeName !== 'Material' ) continue;
+
+				if ( path.startsWith( rootPath + '/Looks/' ) ||
+					path.startsWith( rootPath + '/Materials/' ) ) {
+
+					materialPath = path;
+					break;
+
+				}
+
+			}
+
+		}
+
+		if ( materialPath ) {
+
+			this._applyMaterial( material, materialPath );
+
+		}
+
+		return material;
+
+	}
+
+	_buildMaterialForPath( materialPath ) {
+
+		const material = new MeshPhysicalMaterial();
+
+		if ( materialPath ) {
+
+			this._applyMaterial( material, materialPath );
+
+		}
+
+		return material;
+
+	}
+
+	/**
+	 * Apply material binding from a prim path to a mesh.
+	 * Used when merging referenced geometry into a prim that has material binding.
+	 */
+	_applyMaterialBinding( mesh, primPath ) {
+
+		// Look for material:binding on this prim
+		const bindingPath = primPath + '.material:binding';
+		const bindingSpec = this.specsByPath[ bindingPath ];
+
+		if ( ! bindingSpec ) return;
+
+		let materialPath = null;
+		const targetPaths = bindingSpec.fields?.targetPaths || bindingSpec.fields?.default;
+
+		if ( targetPaths ) {
+
+			materialPath = Array.isArray( targetPaths ) ? targetPaths[ 0 ] : targetPaths;
+
+		}
+
+		if ( ! materialPath ) return;
+
+		// Clean the material path
+		materialPath = String( materialPath ).replace( /^<|>$/g, '' );
+
+		// Build and apply the material
+		const material = new MeshPhysicalMaterial();
+		this._applyMaterial( material, materialPath );
+		mesh.material = material;
+
+	}
+
+	_pickBestMaterial( materialPaths ) {
+
+		for ( const materialPath of materialPaths ) {
+
+			const prefix = materialPath + '/';
+
+			for ( const path in this.specsByPath ) {
+
+				if ( ! path.startsWith( prefix ) ) continue;
+
+				const spec = this.specsByPath[ path ];
+				if ( spec.fields.typeName !== 'Shader' ) continue;
+
+				const attrs = this._getAttributes( path );
+				if ( attrs[ 'info:id' ] === 'UsdUVTexture' && attrs[ 'inputs:file' ] ) {
+
+					return materialPath;
+
+				}
+
+			}
+
+		}
+
+		return materialPaths[ 0 ];
+
+	}
+
+	_applyMaterial( material, materialPath ) {
+
+		const materialSpec = this.specsByPath[ materialPath ];
+		if ( ! materialSpec ) return;
+
+		const prefix = materialPath + '/';
+
+		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;
+
+			const shaderAttrs = this._getAttributes( path );
+			const infoId = shaderAttrs[ 'info:id' ] || spec.fields[ 'info:id' ];
+
+			if ( infoId === 'UsdPreviewSurface' ) {
+
+				this._applyPreviewSurface( material, path );
+
+			}
+
+		}
+
+	}
+
+	_applyPreviewSurface( material, shaderPath ) {
+
+		const fields = this._getAttributes( shaderPath );
+
+		const getAttrSpec = ( attrName ) => {
+
+			const attrPath = shaderPath + '.' + attrName;
+			return this.specsByPath[ attrPath ];
+
+		};
+
+		const applyTextureFromConnection = ( attrName, textureProperty, colorSpace, valueCallback ) => {
+
+			const spec = getAttrSpec( attrName );
+
+			if ( spec && spec.fields.connectionPaths && spec.fields.connectionPaths.length > 0 ) {
+
+				const connPath = spec.fields.connectionPaths[ 0 ];
+				const texture = this._getTextureFromConnection( connPath );
+
+				if ( texture ) {
+
+					texture.colorSpace = colorSpace;
+					material[ textureProperty ] = texture;
+					return true;
+
+				}
+
+			}
+
+			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
+		if ( fields[ 'inputs:opacity' ] !== undefined ) {
+
+			const opacity = fields[ 'inputs:opacity' ];
+			if ( opacity < 1.0 ) {
+
+				material.transparent = true;
+				material.opacity = opacity;
+
+			}
+
+		}
+
+	}
+
+	_getTextureFromConnection( connPath ) {
+
+		// connPath is like /Material/Shader.outputs:rgb
+		const shaderPath = connPath.split( '.' )[ 0 ];
+		const shaderSpec = this.specsByPath[ shaderPath ];
+
+		if ( ! shaderSpec ) return null;
+
+		const attrs = this._getAttributes( shaderPath );
+		const infoId = attrs[ 'info:id' ] || shaderSpec.fields[ 'info:id' ];
+
+		if ( infoId !== 'UsdUVTexture' ) return null;
+
+		const filePath = attrs[ 'inputs:file' ];
+		if ( ! filePath ) return null;
+
+		// Check for UsdTransform2d connection via inputs:st
+		let transformAttrs = null;
+		const stAttrPath = shaderPath + '.inputs:st';
+		const stAttrSpec = this.specsByPath[ stAttrPath ];
+
+		if ( stAttrSpec?.fields?.connectionPaths?.length > 0 ) {
+
+			const stConnPath = stAttrSpec.fields.connectionPaths[ 0 ];
+			const stPath = stConnPath.replace( /<|>/g, '' ).split( '.' )[ 0 ];
+			const stSpec = this.specsByPath[ stPath ];
+
+			if ( stSpec ) {
+
+				const stAttrs = this._getAttributes( stPath );
+				const stInfoId = stAttrs[ 'info:id' ] || stSpec.fields[ 'info:id' ];
+
+				if ( stInfoId === 'UsdTransform2d' ) {
+
+					transformAttrs = stAttrs;
+
+				}
+
+			}
+
+		}
+
+		if ( this.textureCache[ filePath ] ) {
+
+			return this.textureCache[ filePath ];
+
+		}
+
+		const texture = this._loadTexture( filePath, attrs, transformAttrs );
+
+		if ( texture ) {
+
+			this.textureCache[ filePath ] = texture;
+
+		}
+
+		return texture;
+
+	}
+
+	_applyTextureTransforms( texture, attrs ) {
+
+		if ( ! attrs ) return;
+
+		const scale = attrs[ 'inputs:scale' ];
+		if ( scale && Array.isArray( scale ) && scale.length >= 2 ) {
+
+			texture.repeat.set( scale[ 0 ], scale[ 1 ] );
+
+		}
+
+		const translation = attrs[ 'inputs:translation' ];
+		if ( translation && Array.isArray( translation ) && translation.length >= 2 ) {
+
+			texture.offset.set( translation[ 0 ], translation[ 1 ] );
+
+		}
+
+		const rotation = attrs[ 'inputs:rotation' ];
+		if ( typeof rotation === 'number' ) {
+
+			texture.rotation = rotation * Math.PI / 180;
+
+		}
+
+	}
+
+	_loadTexture( filePath, textureAttrs, transformAttrs ) {
+
+		let cleanPath = filePath;
+		if ( cleanPath.startsWith( '@' ) ) cleanPath = cleanPath.slice( 1 );
+		if ( cleanPath.endsWith( '@' ) ) cleanPath = cleanPath.slice( 0, - 1 );
+
+		const assetData = this.assets[ cleanPath ];
+
+		if ( ! assetData ) {
+
+			const baseName = cleanPath.split( '/' ).pop();
+
+			for ( const key in this.assets ) {
+
+				if ( key.endsWith( baseName ) || key.endsWith( '/' + baseName ) ) {
+
+					return this._createTextureFromData( this.assets[ key ], textureAttrs, transformAttrs );
+
+				}
+
+			}
+
+			return null;
+
+		}
+
+		return this._createTextureFromData( assetData, textureAttrs, transformAttrs );
+
+	}
+
+	_createTextureFromData( data, textureAttrs, transformAttrs ) {
+
+		if ( ! data ) return null;
+
+		const scope = this;
+		const texture = new Texture();
+
+		let url;
+
+		if ( typeof data === 'string' ) {
+
+			url = data;
+
+		} else if ( data instanceof Uint8Array || data instanceof ArrayBuffer ) {
+
+			const blob = new Blob( [ data ] );
+			url = URL.createObjectURL( blob );
+
+		} else {
+
+			return null;
+
+		}
+
+		const image = new Image();
+		image.onload = function () {
+
+			texture.image = image;
+
+			if ( textureAttrs ) {
+
+				texture.wrapS = scope._getWrapMode( textureAttrs[ 'inputs:wrapS' ] );
+				texture.wrapT = scope._getWrapMode( textureAttrs[ 'inputs:wrapT' ] );
+
+			}
+
+			scope._applyTextureTransforms( texture, transformAttrs );
+			texture.needsUpdate = true;
+
+			if ( typeof data !== 'string' ) {
+
+				URL.revokeObjectURL( url );
+
+			}
+
+		};
+		image.src = url;
+
+		return texture;
+
+	}
+
+	_getWrapMode( wrapValue ) {
+
+		if ( wrapValue === 'repeat' ) return RepeatWrapping;
+		if ( wrapValue === 'mirror' ) return MirroredRepeatWrapping;
+		if ( wrapValue === 'clamp' ) return ClampToEdgeWrapping;
+		return RepeatWrapping;
+
+	}
+
+	// ========================================================================
+	// Skeletal Animation
+	// ========================================================================
+
+	_buildSkeleton( path ) {
+
+		const attrs = this._getAttributes( 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)
+		// These can be nested arrays (USDA) or flat arrays (USDC)
+		const rawBindTransforms = attrs[ 'bindTransforms' ];
+		const rawRestTransforms = attrs[ 'restTransforms' ];
+
+		const bindTransforms = this._flattenMatrixArray( rawBindTransforms, joints.length );
+		const restTransforms = this._flattenMatrixArray( rawRestTransforms, joints.length );
+
+		// 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();
+				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();
+				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;
+
+			// Try exact match first
+			if ( skeletonPath && this.skeletons[ skeletonPath ] ) {
+
+				skeletonData = this.skeletons[ skeletonPath ];
+
+			}
+
+			// Try includes match as fallback
+			if ( ! skeletonData ) {
+
+				for ( const skelPath in this.skeletons ) {
+
+					if ( skeletonPath && ( skeletonPath.includes( skelPath ) || skelPath.includes( skeletonPath ) ) ) {
+
+						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 ) {
+
+				console.warn( 'USDComposer: No skeleton found for skinned mesh', mesh.name );
+				continue;
+
+			}
+
+			const { skeleton, rootBones, joints } = skeletonData;
+
+			if ( localJoints && localJoints.length > 0 ) {
+
+				const skinIndex = mesh.geometry.attributes.skinIndex;
+				if ( skinIndex ) {
+
+					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;
+
+					}
+
+					const arr = skinIndex.array;
+					for ( let i = 0; i < arr.length; i ++ ) {
+
+						const localIdx = arr[ i ];
+						if ( localIdx < localToGlobal.length ) {
+
+							arr[ i ] = localToGlobal[ localIdx ];
+
+						}
+
+					}
+
+				}
+
+			}
+
+			for ( const rootBone of rootBones ) {
+
+				mesh.add( rootBone );
+
+			}
+
+			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 );
+
+			}
+
+		}
+
+		// Build transform animations from time-sampled xformOps
+		const transformTracks = this._buildTransformAnimations();
+		if ( transformTracks.length > 0 ) {
+
+			animations.push( new AnimationClip( 'TransformAnimation', - 1, transformTracks ) );
+
+		}
+
+		return animations;
+
+	}
+
+	_buildTransformAnimations() {
+
+		const tracks = [];
+
+		for ( const path in this.specsByPath ) {
+
+			const spec = this.specsByPath[ path ];
+			if ( spec.specType !== SpecType.Prim ) continue;
+
+			const typeName = spec.fields?.typeName;
+			if ( typeName !== 'Xform' && typeName !== 'Scope' && typeName !== 'Mesh' ) continue;
+
+			const objectName = path.split( '/' ).pop();
+
+			// Check for animated xformOp:orient
+			const orientPath = path + '.xformOp:orient';
+			const orientSpec = this.specsByPath[ orientPath ];
+			if ( orientSpec?.fields?.timeSamples ) {
+
+				const { times, values } = orientSpec.fields.timeSamples;
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let i = 0; i < times.length; i ++ ) {
+
+					keyframeTimes.push( times[ i ] / this.fps );
+
+					// Convert USD quaternion (w, x, y, z) to Three.js (x, y, z, w)
+					const q = values[ i ];
+					keyframeValues.push( q[ 1 ], q[ 2 ], q[ 3 ], q[ 0 ] );
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new QuaternionKeyframeTrack(
+						objectName + '.quaternion',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+			// Check for animated xformOp:translate
+			const translatePath = path + '.xformOp:translate';
+			const translateSpec = this.specsByPath[ translatePath ];
+			if ( translateSpec?.fields?.timeSamples ) {
+
+				const { times, values } = translateSpec.fields.timeSamples;
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let i = 0; i < times.length; i ++ ) {
+
+					keyframeTimes.push( times[ i ] / this.fps );
+
+					const t = values[ i ];
+					keyframeValues.push( t[ 0 ], t[ 1 ], t[ 2 ] );
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new VectorKeyframeTrack(
+						objectName + '.position',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+			// Check for animated xformOp:scale
+			const scalePath = path + '.xformOp:scale';
+			const scaleSpec = this.specsByPath[ scalePath ];
+			if ( scaleSpec?.fields?.timeSamples ) {
+
+				const { times, values } = scaleSpec.fields.timeSamples;
+				const keyframeTimes = [];
+				const keyframeValues = [];
+
+				for ( let i = 0; i < times.length; i ++ ) {
+
+					keyframeTimes.push( times[ i ] / this.fps );
+
+					const s = values[ i ];
+					keyframeValues.push( s[ 0 ], s[ 1 ], s[ 2 ] );
+
+				}
+
+				if ( keyframeTimes.length > 0 ) {
+
+					tracks.push( new VectorKeyframeTrack(
+						objectName + '.scale',
+						new Float32Array( keyframeTimes ),
+						new Float32Array( keyframeValues )
+					) );
+
+				}
+
+			}
+
+		}
+
+		return tracks;
+
+	}
+
+	_buildAnimationClip( path ) {
+
+		const attrs = this._getAttributes( 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 = path.split( '/' ).pop();
+		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;
+
+			}
+
+		}
+
+		return null;
+
+	}
+
+	_flattenMatrixArray( matrices, numMatrices ) {
+
+		if ( ! matrices || matrices.length === 0 ) return null;
+
+		if ( typeof matrices[ 0 ] === 'number' ) return matrices;
+
+		const flatArray = [];
+
+		for ( let m = 0; m < numMatrices; m ++ ) {
+
+			for ( let row = 0; row < 4; row ++ ) {
+
+				const rowData = matrices[ m * 4 + row ];
+
+				if ( rowData && rowData.length === 4 ) {
+
+					flatArray.push( rowData[ 0 ], rowData[ 1 ], rowData[ 2 ], rowData[ 3 ] );
+
+				} else {
+
+					flatArray.push( row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0, row === 3 ? 1 : 0 );
+
+				}
+
+			}
+
+		}
+
+		return flatArray;
+
+	}
+
+}
+
+export { USDComposer, SpecType };

粤ICP备19079148号