| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828 |
- // Pre-compiled regex patterns for performance
- const DEF_MATCH_REGEX = /^def\s+(?:(\w+)\s+)?"?([^"]+)"?$/;
- const VARIANT_STRING_REGEX = /^string\s+(\w+)$/;
- const ATTR_MATCH_REGEX = /^(?:uniform\s+)?(\w+(?:\[\])?)\s+(.+)$/;
- class USDAParser {
- parseText( text ) {
- // Preprocess: strip comments and normalize multiline values
- text = this._preprocess( text );
- const root = {};
- const lines = text.split( '\n' );
- let string = null;
- let target = root;
- const stack = [ root ];
- for ( const line of lines ) {
- if ( line.includes( '=' ) ) {
- // Find the first '=' that's not inside quotes
- const eqIdx = this._findAssignmentOperator( line );
- if ( eqIdx === - 1 ) {
- string = line.trim();
- continue;
- }
- const lhs = line.slice( 0, eqIdx ).trim();
- const rhs = line.slice( eqIdx + 1 ).trim();
- if ( rhs.endsWith( '{' ) ) {
- const group = {};
- stack.push( group );
- target[ lhs ] = group;
- target = group;
- } else if ( rhs.endsWith( '(' ) ) {
- // see #28631
- const values = rhs.slice( 0, - 1 );
- target[ lhs ] = values;
- const meta = {};
- stack.push( meta );
- target = meta;
- } else {
- target[ lhs ] = rhs;
- }
- } 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 ] || {};
- stack.push( group );
- target[ string ] = group;
- target = group;
- } else if ( line.endsWith( '}' ) ) {
- stack.pop();
- if ( stack.length === 0 ) continue;
- target = stack[ stack.length - 1 ];
- } else if ( line.endsWith( '(' ) ) {
- const meta = {};
- stack.push( meta );
- string = line.split( '(' )[ 0 ].trim() || string;
- target[ string ] = meta;
- target = meta;
- } else if ( line.endsWith( ')' ) ) {
- stack.pop();
- target = stack[ stack.length - 1 ];
- } else if ( line.trim() ) {
- string = line.trim();
- }
- }
- return root;
- }
- _preprocess( text ) {
- // Remove block comments /* ... */
- text = this._stripBlockComments( text );
- // Collapse triple-quoted strings into single lines
- text = this._collapseTripleQuotedStrings( text );
- // Remove line comments # ... (but preserve #usda header)
- // Only remove # comments that aren't at the start of a line or after whitespace
- const lines = text.split( '\n' );
- const processed = [];
- let inMultilineValue = false;
- let bracketDepth = 0;
- let parenDepth = 0;
- let accumulated = '';
- for ( let i = 0; i < lines.length; i ++ ) {
- let line = lines[ i ];
- // Strip inline comments (but not inside strings)
- line = this._stripInlineComment( line );
- // Track bracket/paren depth for multiline values
- const trimmed = line.trim();
- if ( inMultilineValue ) {
- // Continue accumulating multiline value
- accumulated += ' ' + trimmed;
- // Update depths
- for ( const ch of trimmed ) {
- if ( ch === '[' ) bracketDepth ++;
- else if ( ch === ']' ) bracketDepth --;
- else if ( ch === '(' && bracketDepth > 0 ) parenDepth ++;
- else if ( ch === ')' && bracketDepth > 0 ) parenDepth --;
- }
- // Check if multiline value is complete
- if ( bracketDepth === 0 && parenDepth === 0 ) {
- processed.push( accumulated );
- accumulated = '';
- inMultilineValue = false;
- }
- } else {
- // Check if this line starts a multiline array value
- // Look for patterns like "attr = [" or "attr = @path@[" without closing ]
- if ( trimmed.includes( '=' ) ) {
- const eqIdx = this._findAssignmentOperator( trimmed );
- if ( eqIdx !== - 1 ) {
- const rhs = trimmed.slice( eqIdx + 1 ).trim();
- // Count brackets in the value part
- let openBrackets = 0;
- let closeBrackets = 0;
- for ( const ch of rhs ) {
- if ( ch === '[' ) openBrackets ++;
- else if ( ch === ']' ) closeBrackets ++;
- }
- if ( openBrackets > closeBrackets ) {
- // Multiline array detected
- inMultilineValue = true;
- bracketDepth = openBrackets - closeBrackets;
- parenDepth = 0;
- accumulated = trimmed;
- continue;
- }
- }
- }
- processed.push( trimmed );
- }
- }
- return processed.join( '\n' );
- }
- _stripBlockComments( text ) {
- // Iteratively remove /* ... */ comments without regex backtracking
- let result = '';
- let i = 0;
- while ( i < text.length ) {
- // Check for block comment start
- if ( text[ i ] === '/' && i + 1 < text.length && text[ i + 1 ] === '*' ) {
- // Find the closing */
- let j = i + 2;
- while ( j < text.length ) {
- if ( text[ j ] === '*' && j + 1 < text.length && text[ j + 1 ] === '/' ) {
- // Found closing, skip past it
- j += 2;
- break;
- }
- j ++;
- }
- // Move past the comment (or to end if unclosed)
- i = j;
- } else {
- result += text[ i ];
- i ++;
- }
- }
- return result;
- }
- _collapseTripleQuotedStrings( text ) {
- let result = '';
- let i = 0;
- while ( i < text.length ) {
- if ( i + 2 < text.length ) {
- const triple = text.slice( i, i + 3 );
- if ( triple === '\'\'\'' || triple === '"""' ) {
- const quoteChar = triple;
- result += quoteChar;
- i += 3;
- while ( i < text.length ) {
- if ( i + 2 < text.length && text.slice( i, i + 3 ) === quoteChar ) {
- result += quoteChar;
- i += 3;
- break;
- } else {
- if ( text[ i ] === '\n' ) {
- result += '\\n';
- } else if ( text[ i ] !== '\r' ) {
- result += text[ i ];
- }
- i ++;
- }
- }
- continue;
- }
- }
- result += text[ i ];
- i ++;
- }
- return result;
- }
- _stripInlineComment( line ) {
- // Don't strip if line starts with #usda
- if ( line.trim().startsWith( '#usda' ) ) return line;
- // Find # that's not inside a string
- let inString = false;
- let stringChar = null;
- let escaped = false;
- for ( let i = 0; i < line.length; i ++ ) {
- const ch = line[ i ];
- if ( escaped ) {
- escaped = false;
- continue;
- }
- if ( ch === '\\' ) {
- escaped = true;
- continue;
- }
- if ( ! inString && ( ch === '"' || ch === '\'' ) ) {
- inString = true;
- stringChar = ch;
- } else if ( inString && ch === stringChar ) {
- inString = false;
- stringChar = null;
- } else if ( ! inString && ch === '#' ) {
- // Found comment start outside of string
- return line.slice( 0, i ).trimEnd();
- }
- }
- return line;
- }
- _findAssignmentOperator( line ) {
- // Find the first '=' that's not inside quotes
- let inString = false;
- let stringChar = null;
- let escaped = false;
- for ( let i = 0; i < line.length; i ++ ) {
- const ch = line[ i ];
- if ( escaped ) {
- escaped = false;
- continue;
- }
- if ( ch === '\\' ) {
- escaped = true;
- continue;
- }
- if ( ! inString && ( ch === '"' || ch === '\'' ) ) {
- inString = true;
- stringChar = ch;
- } else if ( inString && ch === stringChar ) {
- inString = false;
- stringChar = null;
- } else if ( ! inString && ch === '=' ) {
- return i;
- }
- }
- return - 1;
- }
- /**
- * 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 = {};
- // Spec types (must match USDCParser/USDComposer)
- const SpecType = {
- Attribute: 1,
- Prim: 6,
- Relationship: 8
- };
- // Parse root metadata
- const rootFields = {};
- if ( '#usda 1.0' in root ) {
- const header = root[ '#usda 1.0' ];
- if ( header.upAxis ) {
- rootFields.upAxis = header.upAxis.replace( /"/g, '' );
- }
- if ( header.defaultPrim ) {
- rootFields.defaultPrim = header.defaultPrim.replace( /"/g, '' );
- }
- if ( header.metersPerUnit !== undefined ) {
- rootFields.metersPerUnit = parseFloat( header.metersPerUnit );
- }
- }
- specsByPath[ '/' ] = { specType: SpecType.Prim, fields: rootFields };
- // Walk the tree and build specsByPath
- const walkTree = ( data, parentPath ) => {
- const primChildren = [];
- for ( const key in data ) {
- // Skip metadata
- if ( key === '#usda 1.0' ) continue;
- if ( key === 'variants' ) continue;
- // Check for primitive definitions
- // Matches both 'def TypeName "name"' and 'def "name"' (no type)
- const defMatch = key.match( DEF_MATCH_REGEX );
- if ( defMatch ) {
- const typeName = defMatch[ 1 ] || '';
- const name = defMatch[ 2 ];
- const path = parentPath === '/' ? '/' + name : parentPath + '/' + name;
- primChildren.push( name );
- const primFields = { typeName };
- const primData = data[ key ];
- // Extract attributes and relationships from this prim
- this._extractPrimData( primData, path, primFields, specsByPath, SpecType );
- specsByPath[ path ] = { specType: SpecType.Prim, fields: primFields };
- // Recurse into children
- walkTree( primData, path );
- }
- }
- // Add primChildren to parent spec
- if ( primChildren.length > 0 && specsByPath[ parentPath ] ) {
- specsByPath[ parentPath ].fields.primChildren = primChildren;
- }
- };
- walkTree( root, '/' );
- return { specsByPath };
- }
- _extractPrimData( data, path, primFields, specsByPath, SpecType ) {
- if ( ! data || typeof data !== 'object' ) return;
- for ( const key in data ) {
- // Skip nested defs (handled by walkTree)
- if ( key.startsWith( 'def ' ) ) continue;
- if ( key === 'prepend references' ) {
- primFields.references = [ data[ key ] ];
- continue;
- }
- if ( key === 'payload' ) {
- primFields.payload = data[ key ];
- continue;
- }
- if ( key === 'variants' ) {
- const variantSelection = {};
- const variants = data[ key ];
- for ( const vKey in variants ) {
- const match = vKey.match( VARIANT_STRING_REGEX );
- if ( match ) {
- const variantSetName = match[ 1 ];
- const variantValue = variants[ vKey ].replace( /"/g, '' );
- variantSelection[ variantSetName ] = variantValue;
- }
- }
- if ( Object.keys( variantSelection ).length > 0 ) {
- primFields.variantSelection = variantSelection;
- }
- continue;
- }
- if ( key.startsWith( 'rel ' ) ) {
- const relName = key.slice( 4 );
- const relPath = path + '.' + relName;
- const target = data[ key ].replace( /[<>]/g, '' );
- specsByPath[ relPath ] = {
- specType: SpecType.Relationship,
- fields: { targetPaths: [ target ] }
- };
- continue;
- }
- // Handle xformOpOrder
- if ( key.includes( 'xformOpOrder' ) ) {
- const ops = data[ key ]
- .replace( /[\[\]]/g, '' )
- .split( ',' )
- .map( s => s.trim().replace( /"/g, '' ) );
- primFields.xformOpOrder = ops;
- continue;
- }
- // Handle typed attributes
- // Format: [qualifier] type attrName (e.g., "uniform token[] joints", "float3 position")
- const attrMatch = key.match( ATTR_MATCH_REGEX );
- if ( attrMatch ) {
- const valueType = attrMatch[ 1 ];
- const attrName = attrMatch[ 2 ];
- const rawValue = data[ key ];
- // Handle connection attributes (e.g., "inputs:normal.connect = </path>")
- if ( attrName.endsWith( '.connect' ) ) {
- 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 );
- // Get or create the attribute spec
- if ( ! specsByPath[ attrPath ] ) {
- specsByPath[ attrPath ] = {
- specType: SpecType.Attribute,
- fields: { typeName: valueType }
- };
- }
- specsByPath[ attrPath ].fields.connectionPaths = [ connPath ];
- continue;
- }
- // Handle timeSamples attributes specially
- if ( attrName.endsWith( '.timeSamples' ) && typeof rawValue === 'object' ) {
- const baseAttrName = attrName.slice( 0, - 12 ); // Remove '.timeSamples'
- const attrPath = path + '.' + baseAttrName;
- // Parse timeSamples dictionary into times and values arrays
- const times = [];
- const values = [];
- for ( const frameKey in rawValue ) {
- const frame = parseFloat( frameKey );
- if ( isNaN( frame ) ) continue;
- times.push( frame );
- values.push( this._parseAttributeValue( valueType, rawValue[ frameKey ] ) );
- }
- // 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 {
- // 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 }
- };
- }
- }
- }
- }
- _parseAttributeValue( valueType, rawValue ) {
- if ( rawValue === undefined || rawValue === null ) return undefined;
- const str = String( rawValue ).trim();
- // Array types
- if ( valueType.endsWith( '[]' ) ) {
- // Parse JSON-like arrays
- try {
- // 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 );
- // Flatten nested arrays for types like point3f[]
- if ( Array.isArray( parsed ) && Array.isArray( parsed[ 0 ] ) ) {
- return parsed.flat();
- }
- return parsed;
- } catch ( e ) {
- // Try simple array parsing
- const cleaned = str.replace( /[\[\]]/g, '' );
- return cleaned.split( ',' ).map( s => {
- const trimmed = s.trim();
- const num = parseFloat( trimmed );
- return isNaN( num ) ? trimmed.replace( /"/g, '' ) : num;
- } );
- }
- }
- // Vector types (double3, float3, point3f, etc.)
- if ( valueType.includes( '3' ) || valueType.includes( '2' ) || valueType.includes( '4' ) ) {
- // Parse (x, y, z) format
- const cleaned = str.replace( /[()]/g, '' );
- const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
- return values;
- }
- // Quaternion types (quatf, quatd, quath)
- // Text format is (w, x, y, z), convert to (x, y, z, w)
- if ( valueType.startsWith( 'quat' ) ) {
- const cleaned = str.replace( /[()]/g, '' );
- const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
- return [ values[ 1 ], values[ 2 ], values[ 3 ], values[ 0 ] ];
- }
- // Matrix types
- if ( valueType.includes( 'matrix' ) ) {
- const cleaned = str.replace( /[()]/g, '' );
- const values = cleaned.split( ',' ).map( s => parseFloat( s.trim() ) );
- return values;
- }
- // Scalar numeric types
- if ( valueType === 'float' || valueType === 'double' || valueType === 'int' ) {
- return parseFloat( str );
- }
- // String/token types
- if ( valueType === 'string' || valueType === 'token' ) {
- return this._parseString( str );
- }
- // Asset path
- if ( valueType === 'asset' ) {
- return str.replace( /@/g, '' ).replace( /"/g, '' );
- }
- // Default: return as string with quotes removed
- return this._parseString( str );
- }
- _parseString( str ) {
- // Remove surrounding quotes
- if ( ( str.startsWith( '"' ) && str.endsWith( '"' ) ) ||
- ( str.startsWith( '\'' ) && str.endsWith( '\'' ) ) ) {
- str = str.slice( 1, - 1 );
- }
- // Handle escape sequences
- let result = '';
- let i = 0;
- while ( i < str.length ) {
- if ( str[ i ] === '\\' && i + 1 < str.length ) {
- const next = str[ i + 1 ];
- switch ( next ) {
- case 'n': result += '\n'; break;
- case 't': result += '\t'; break;
- case 'r': result += '\r'; break;
- case '\\': result += '\\'; break;
- case '"': result += '"'; break;
- case '\'': result += '\''; break;
- default: result += next; break;
- }
- i += 2;
- } else {
- result += str[ i ];
- i ++;
- }
- }
- return result;
- }
- }
- export { USDAParser };
|