usd-gltf 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,12 +137,12 @@ function corrupt( what ) {
137
137
  }
138
138
 
139
139
  // Inflates the raw DEFLATE stream starting at src[ pos ]. Returns the output and the byte offset just past the stream.
140
- function inflateStream( src, pos, sizeHint ) {
140
+ function inflateStream( src, pos, sizeHint, maxSize ) {
141
141
 
142
142
  const srcLen = src.length;
143
143
  const overrun = srcLen + 8;
144
144
 
145
- let out = new Uint8Array( sizeHint > 0 ? sizeHint : Math.max( 1 << 15, srcLen * 4 ) );
145
+ let out = new Uint8Array( Math.min( sizeHint > 0 ? sizeHint : Math.max( 1 << 15, srcLen * 4 ), maxSize ) );
146
146
  let op = 0;
147
147
 
148
148
  let bitBuf = 0;
@@ -154,7 +154,7 @@ function inflateStream( src, pos, sizeHint ) {
154
154
  let clTable = null;
155
155
 
156
156
  let final = 0;
157
- const state = { src, overrun, pos, bitBuf, bitCnt, out, op };
157
+ const state = { src, overrun, pos, bitBuf, bitCnt, out, op, maxSize };
158
158
 
159
159
  do {
160
160
 
@@ -188,7 +188,7 @@ function inflateStream( src, pos, sizeHint ) {
188
188
  pos += 4;
189
189
  if ( pos + len > srcLen ) throw corrupt( 'unexpected end' );
190
190
 
191
- if ( op + len > out.length ) out = grow( out, op + len );
191
+ if ( op + len > out.length ) out = grow( out, op + len, maxSize );
192
192
  out.set( src.subarray( pos, pos + len ), op );
193
193
  op += len;
194
194
  pos += len;
@@ -371,7 +371,7 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
371
371
 
372
372
  if ( op === outLen ) {
373
373
 
374
- out = grow( out, op + 1 );
374
+ out = grow( out, op + 1, state.maxSize );
375
375
  outLen = out.length;
376
376
 
377
377
  }
@@ -435,7 +435,7 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
435
435
 
436
436
  if ( op + len > outLen ) {
437
437
 
438
- out = grow( out, op + len );
438
+ out = grow( out, op + len, state.maxSize );
439
439
  outLen = out.length;
440
440
 
441
441
  }
@@ -464,9 +464,10 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
464
464
 
465
465
  }
466
466
 
467
- function grow( out, need ) {
467
+ function grow( out, need, maxSize ) {
468
468
 
469
- const next = new Uint8Array( Math.max( need, out.length * 2 ) );
469
+ if ( need > maxSize ) throw corrupt( `more than the expected ${ maxSize } bytes` );
470
+ const next = new Uint8Array( Math.min( Math.max( need, out.length * 2 ), maxSize ) );
470
471
  next.set( out );
471
472
  return next;
472
473
 
@@ -482,11 +483,12 @@ function toBytes( data ) {
482
483
  * Decompresses raw DEFLATE data.
483
484
  * @param {Uint8Array} bytes
484
485
  * @param {number} [sizeHint] expected output size, avoids reallocation when known
486
+ * @param {number} [maxSize] fails rather than produce more than this
485
487
  * @returns {Uint8Array}
486
488
  */
487
- export function inflate( bytes, sizeHint = 0 ) {
489
+ export function inflate( bytes, sizeHint = 0, maxSize = Infinity ) {
488
490
 
489
- return inflateStream( toBytes( bytes ), 0, sizeHint ).data;
491
+ return inflateStream( toBytes( bytes ), 0, sizeHint, maxSize ).data;
490
492
 
491
493
  }
492
494
 
@@ -496,7 +498,7 @@ export function inflate( bytes, sizeHint = 0 ) {
496
498
  * @param {number} [sizeHint]
497
499
  * @returns {Uint8Array}
498
500
  */
499
- export function inflateZlib( bytes, sizeHint = 0 ) {
501
+ export function inflateZlib( bytes, sizeHint = 0, maxSize = Infinity ) {
500
502
 
501
503
  bytes = toBytes( bytes );
502
504
  if ( bytes.length < 2 ) throw corrupt( 'missing zlib header' );
@@ -506,7 +508,7 @@ export function inflateZlib( bytes, sizeHint = 0 ) {
506
508
  if ( ( cmf & 15 ) !== 8 || ( cmf >> 4 ) > 7 || ( ( cmf << 8 ) | flg ) % 31 !== 0 ) throw corrupt( 'bad zlib header' );
507
509
  if ( flg & 32 ) throw corrupt( 'zlib preset dictionary not supported' );
508
510
 
509
- const { data, end } = inflateStream( bytes, 2, sizeHint );
511
+ const { data, end } = inflateStream( bytes, 2, sizeHint, maxSize );
510
512
 
511
513
  if ( end + 4 <= bytes.length ) {
512
514
 
package/src/image/png.js CHANGED
@@ -219,7 +219,7 @@ export function decodePNG( bytes ) {
219
219
 
220
220
  }
221
221
 
222
- const raw = inflateZlib( zdata, expected );
222
+ const raw = inflateZlib( zdata, expected, expected * 2 + 1024 ); // tolerates trailing data, not a decompression bomb
223
223
  if ( raw.length < expected ) throw new Error( 'usd-gltf: PNG image data is truncated' );
224
224
 
225
225
  const channels = colorType === 3 ? ( trns !== null ? 4 : 3 ) : samples + ( trns !== null ? 1 : 0 );
package/src/index.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ /** Loads a file referenced by a relative path or URI (glTF buffers and images, USD sublayers and references). */
2
+ export type Resolver = ( path: string ) => Uint8Array | ArrayBuffer | Promise<Uint8Array | ArrayBuffer>;
3
+
4
+ /** A decoded image: 8 or 16 bit samples, interleaved, top row first. */
5
+ export interface DecodedImage {
6
+ width: number;
7
+ height: number;
8
+ channels: 1 | 2 | 3 | 4;
9
+ bitDepth: 8 | 16;
10
+ data: Uint8Array | Uint16Array;
11
+ }
12
+
13
+ /** Replaces the built in image decoding (the platform decoder in browsers, JavaScript decoders in Node). */
14
+ export type ImageDecoder = ( bytes: Uint8Array, mimeType?: string ) => DecodedImage | Promise<DecodedImage>;
15
+
16
+ /** Opt-in resource limits, for both directions. See README "Limits". */
17
+ export interface ConversionLimits {
18
+ /** Decoded data held during the conversion: accessors, USD arrays, decompressed data, images, arrays written to the output layer. */
19
+ maxDecodedBytes?: number;
20
+ /** Largest output buffer: checked as writers grow and before the final usdz or glb is allocated. */
21
+ maxOutputBytes?: number;
22
+ /** Cancels the conversion, which then rejects with the signal's reason. */
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ /** Thrown when a conversion would go over one of its limits. Carries sizes only, never input data or file names. */
27
+ export class ConversionLimitError extends Error {
28
+ name: 'ConversionLimitError';
29
+ code: 'USD_GLTF_LIMIT';
30
+ /** Where the limit was reached, e.g. 'accessor 3', 'usdc', 'image', 'glb'. */
31
+ stage: string;
32
+ resource: 'decoded' | 'output';
33
+ limit: number;
34
+ attempted: number;
35
+ }
36
+
37
+ export interface USDToGLBOptions extends ConversionLimits {
38
+ /** Name of the input file, used to resolve relative paths. */
39
+ identifier?: string;
40
+ /** Needed when the USD references files outside a usdz package. */
41
+ resolver?: Resolver;
42
+ /** Variant selections, by prim path: { '/Root': { shadingVariant: 'red' } }, or by set name for every prim. */
43
+ variants?: Record<string, string | Record<string, string>>;
44
+ /** Write material variant sets as KHR_materials_variants (default true). */
45
+ materialVariants?: boolean;
46
+ imageDecoder?: ImageDecoder;
47
+ /** Receives non fatal problems, such as textures that could not be read. */
48
+ log?: ( message: string ) => void;
49
+ }
50
+
51
+ export interface GLBToUSDOptions extends ConversionLimits {
52
+ /** Output encoding: a usdz package (default), or a single usda / usdc layer without its textures. */
53
+ format?: 'usdz' | 'usda' | 'usdc';
54
+ /** Needed for .gltf files with external buffers or images. */
55
+ resolver?: Resolver;
56
+ imageDecoder?: ImageDecoder;
57
+ log?: ( message: string ) => void;
58
+ }
59
+
60
+ /** Converts USD (usda, usdc or usdz, detected from the bytes) to GLB. */
61
+ export function usdToGLB( data: Uint8Array | ArrayBuffer, options?: USDToGLBOptions ): Promise<Uint8Array>;
62
+
63
+ /** Converts glTF (GLB, or .gltf JSON with a resolver for its files) to USD. */
64
+ export function glbToUSD( data: Uint8Array | ArrayBuffer, options?: GLBToUSDOptions ): Promise<Uint8Array>;
65
+
66
+ /** Like glbToUSD for usda / usdc output, also returning the texture files the layer references. */
67
+ export function glbToUSDFiles( data: Uint8Array | ArrayBuffer, options?: Omit<GLBToUSDOptions, 'format'> & { format?: 'usda' | 'usdc' } ): Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>;
68
+
69
+ /** Identifies a file from its leading bytes. */
70
+ export function detectFormat( data: Uint8Array | ArrayBuffer ): 'glb' | 'gltf' | 'usda' | 'usdc' | 'usdz' | null;
71
+
72
+ /** Splits a GLB into its JSON and binary chunk. */
73
+ export function readGLB( data: Uint8Array | ArrayBuffer ): { json: any, bin: Uint8Array | null };
74
+
75
+ /** Packs glTF JSON (an object, or already UTF-8 encoded) and a binary buffer into a GLB. */
76
+ export function writeGLB( json: object | Uint8Array, bin?: Uint8Array | null ): Uint8Array;
77
+
78
+ /** The uncomposed contents of one USD layer; see src/usd/model.js for the value representation. */
79
+ export interface Layer {
80
+ identifier: string;
81
+ metadata: Record<string, any>;
82
+ root: any;
83
+ getPrimAtPath( path: string ): any;
84
+ }
85
+
86
+ export function readUSDA( source: string | Uint8Array, identifier?: string ): Layer;
87
+ export function writeUSDA( layer: Layer ): string;
88
+ export function readUSDC( data: Uint8Array | ArrayBuffer, identifier?: string ): Layer;
89
+ export function writeUSDC( layer: Layer ): Uint8Array;
90
+
91
+ /** Opens and composes a USD stage (references, payloads, sublayers, inherits, variants). */
92
+ export function openStage( data: Uint8Array, options?: { identifier?: string, resolver?: Resolver, variants?: USDToGLBOptions[ 'variants' ] } ): Promise<any>;
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { readGLTF } from './gltf/document.js';
5
5
  import { writeUSDA } from './usd/usda/writer.js';
6
6
  import { writeUSDC } from './usd/usdc/writer.js';
7
7
  import { writeUSDZ } from './usd/usdz.js';
8
+ import { Budget } from './limits.js';
8
9
 
9
10
  export { readGLB, writeGLB } from './gltf/glb.js';
10
11
  export { detectFormat } from './detect.js';
@@ -13,6 +14,7 @@ export { readUSDA } from './usd/usda/reader.js';
13
14
  export { writeUSDA } from './usd/usda/writer.js';
14
15
  export { readUSDC } from './usd/usdc/reader.js';
15
16
  export { writeUSDC } from './usd/usdc/writer.js';
17
+ export { ConversionLimitError } from './limits.js';
16
18
 
17
19
  function toBytes( data ) {
18
20
 
@@ -20,37 +22,50 @@ function toBytes( data ) {
20
22
 
21
23
  }
22
24
 
25
+ // Limits shared by both directions; see README "Limits".
26
+ function budgetFor( { maxDecodedBytes, maxOutputBytes, signal } ) {
27
+
28
+ return new Budget( { maxDecodedBytes, maxOutputBytes, signal } );
29
+
30
+ }
31
+
23
32
  /**
24
33
  * Converts USD (usda, usdc or usdz, detected from the bytes) to GLB.
25
34
  * @param {ArrayBuffer|Uint8Array} data
26
- * @param {{ identifier?: string, resolver?: ( path: string ) => Promise<Uint8Array>, variants?: object, log?: ( message: string ) => void }} [options]
35
+ * @param {{ identifier?: string, resolver?: ( path: string ) => Promise<Uint8Array>, variants?: object, log?: ( message: string ) => void, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
27
36
  * resolver loads files referenced by relative paths (not needed for self-contained usdz)
28
37
  * @returns {Promise<Uint8Array>}
29
38
  */
30
39
  export async function usdToGLB( data, options = {} ) {
31
40
 
32
- const stage = await openStage( toBytes( data ), options );
33
- const builder = await new USDToGLTF( stage, options ).convert();
34
- return builder.toGLB();
41
+ const budget = budgetFor( options );
42
+ const stage = await openStage( toBytes( data ), { ...options, budget } );
43
+ const builder = await new USDToGLTF( stage, { ...options, budget } ).convert();
44
+ const glb = builder.toGLB();
45
+ budget.check();
46
+ return glb;
35
47
 
36
48
  }
37
49
 
38
50
  /**
39
51
  * Converts GLB (or glTF JSON with embedded or resolvable buffers) to USD.
40
52
  * @param {ArrayBuffer|Uint8Array} data
41
- * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array> }} [options]
53
+ * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
42
54
  * @returns {Promise<Uint8Array>}
43
55
  */
44
- export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder } = {} ) {
56
+ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, ...limits } = {} ) {
45
57
 
46
- const doc = await readGLTF( toBytes( data ), { resolver } );
58
+ const budget = budgetFor( limits );
59
+ const doc = await readGLTF( toBytes( data ), { resolver, budget } );
47
60
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
61
+ let out;
48
62
 
49
- if ( format === 'usda' ) return new TextEncoder().encode( writeUSDA( layer ) );
50
- if ( format === 'usdc' ) return writeUSDC( layer );
63
+ if ( format === 'usda' ) out = new TextEncoder().encode( writeUSDA( layer, { budget } ) );
64
+ else if ( format === 'usdc' ) out = writeUSDC( layer, { budget } );
65
+ else out = writeUSDZ( [ { name: 'root.usdc', data: writeUSDC( layer, { budget } ) }, ...files ], { budget } ); // the root layer must come first
51
66
 
52
- // the root layer must be the first file in the package
53
- return writeUSDZ( [ { name: 'root.usdc', data: writeUSDC( layer ) }, ...files ] );
67
+ budget.check();
68
+ return out;
54
69
 
55
70
  }
56
71
 
@@ -58,10 +73,14 @@ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, i
58
73
  * For usda / usdc output the textures are separate files; this returns them alongside the layer.
59
74
  * @returns {Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>}
60
75
  */
61
- export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder } = {} ) {
76
+ export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, ...limits } = {} ) {
62
77
 
63
- const doc = await readGLTF( toBytes( data ), { resolver } );
78
+ const budget = budgetFor( limits );
79
+ const doc = await readGLTF( toBytes( data ), { resolver, budget } );
64
80
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
65
- return { layer: format === 'usdc' ? writeUSDC( layer ) : new TextEncoder().encode( writeUSDA( layer ) ), files };
81
+ const bytes = format === 'usdc' ? writeUSDC( layer, { budget } ) : new TextEncoder().encode( writeUSDA( layer, { budget } ) );
82
+ budget.output( files.reduce( ( n, f ) => n + f.data.byteLength, bytes.byteLength ), 'usd files' );
83
+ budget.check();
84
+ return { layer: bytes, files };
66
85
 
67
86
  }
package/src/limits.js ADDED
@@ -0,0 +1,101 @@
1
+ // Opt-in resource limits for a conversion: decoded data, serialized output and cancellation.
2
+
3
+ /**
4
+ * Thrown when a conversion would go over one of its limits. The message and fields describe sizes
5
+ * only, never input data or file names.
6
+ */
7
+ export class ConversionLimitError extends Error {
8
+
9
+ /**
10
+ * @param {{ stage: string, resource: 'decoded'|'output', limit: number, attempted: number }} info
11
+ */
12
+ constructor( { stage, resource, limit, attempted } ) {
13
+
14
+ super( `usd-gltf: ${ stage } needs ${ attempted } ${ resource } bytes, over the limit of ${ limit }` );
15
+ this.name = 'ConversionLimitError';
16
+ this.code = 'USD_GLTF_LIMIT';
17
+ this.stage = stage;
18
+ this.resource = resource;
19
+ this.limit = limit;
20
+ this.attempted = attempted;
21
+
22
+ }
23
+
24
+ }
25
+
26
+ /**
27
+ * Tracks one conversion against its limits. Readers and converters charge decoded bytes before
28
+ * allocating them; writers report their size before growing.
29
+ */
30
+ export class Budget {
31
+
32
+ /**
33
+ * @param {{ maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
34
+ */
35
+ constructor( { maxDecodedBytes = Infinity, maxOutputBytes = Infinity, signal = null } = {} ) {
36
+
37
+ this.maxDecodedBytes = maxDecodedBytes;
38
+ this.maxOutputBytes = maxOutputBytes;
39
+ this.signal = signal;
40
+ this.decodedBytes = 0;
41
+
42
+ }
43
+
44
+ /**
45
+ * Adds to the running total of decoded data: accessors, arrays, decompressed sections and images.
46
+ * @param {number} bytes
47
+ * @param {string} stage
48
+ */
49
+ decode( bytes, stage ) {
50
+
51
+ checkSize( bytes, stage );
52
+ this.check();
53
+ this.decodedBytes += bytes;
54
+ if ( this.decodedBytes > this.maxDecodedBytes ) throw new ConversionLimitError( { stage, resource: 'decoded', limit: this.maxDecodedBytes, attempted: this.decodedBytes } );
55
+
56
+ }
57
+
58
+ /**
59
+ * Checks the size an output buffer is about to reach.
60
+ * @param {number} bytes
61
+ * @param {string} stage
62
+ */
63
+ output( bytes, stage ) {
64
+
65
+ checkSize( bytes, stage );
66
+ if ( bytes > this.maxOutputBytes ) throw new ConversionLimitError( { stage, resource: 'output', limit: this.maxOutputBytes, attempted: bytes } );
67
+
68
+ }
69
+
70
+ // Throws the signal's reason once the conversion is cancelled.
71
+ check() {
72
+
73
+ this.signal?.throwIfAborted();
74
+
75
+ }
76
+
77
+ }
78
+
79
+ /**
80
+ * A size computed from counts in a file must be a whole number of bytes that fits in an array.
81
+ * @param {number} bytes
82
+ * @param {string} stage
83
+ */
84
+ export function checkSize( bytes, stage ) {
85
+
86
+ if ( ! Number.isSafeInteger( bytes ) || bytes < 0 ) throw new Error( `usd-gltf: invalid size in ${ stage }` );
87
+
88
+ }
89
+
90
+ /**
91
+ * For catch blocks that skip one failed asset and carry on: rethrows what must end the whole
92
+ * conversion instead, a limit error or cancellation.
93
+ * @param {unknown} error
94
+ * @param {Budget} [budget]
95
+ */
96
+ export function rethrowFatal( error, budget ) {
97
+
98
+ budget?.check();
99
+ if ( error instanceof ConversionLimitError ) throw error;
100
+
101
+ }
package/src/usd/stage.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { AssetPath, ListOp } from './model.js';
6
6
  import { readUSDA } from './usda/reader.js';
7
7
  import { readZip } from './usdz.js';
8
+ import { Budget, rethrowFatal } from '../limits.js';
8
9
 
9
10
  const decoder = new TextDecoder();
10
11
 
@@ -80,9 +81,10 @@ export function resolveAssetPath( anchor, assetPath ) {
80
81
  // Reads bytes for identifiers, opening packages (including nested ones) through the user resolver.
81
82
  export class AssetReader {
82
83
 
83
- constructor( resolver ) {
84
+ constructor( resolver, budget = new Budget() ) {
84
85
 
85
86
  this.resolver = resolver;
87
+ this.budget = budget;
86
88
  this.packages = new Map();
87
89
  this.files = new Map();
88
90
 
@@ -123,7 +125,7 @@ export class AssetReader {
123
125
 
124
126
  async openPackage( identifier ) {
125
127
 
126
- if ( ! this.packages.has( identifier ) ) this.packages.set( identifier, readZip( await this.read( identifier ) ) );
128
+ if ( ! this.packages.has( identifier ) ) this.packages.set( identifier, readZip( await this.read( identifier ), { budget: this.budget } ) );
127
129
  return this.packages.get( identifier );
128
130
 
129
131
  }
@@ -140,7 +142,7 @@ export class AssetReader {
140
142
 
141
143
  }
142
144
 
143
- async function parseLayer( bytes, identifier ) {
145
+ async function parseLayer( bytes, identifier, budget ) {
144
146
 
145
147
  const format = detectLayerFormat( bytes );
146
148
  if ( format === 'usda' ) return readUSDA( bytes, identifier );
@@ -148,7 +150,7 @@ async function parseLayer( bytes, identifier ) {
148
150
  if ( format === 'usdc' ) {
149
151
 
150
152
  const { readUSDC } = await import( './usdc/reader.js' );
151
- return readUSDC( bytes, identifier );
153
+ return readUSDC( bytes, identifier, { budget } );
152
154
 
153
155
  }
154
156
 
@@ -185,14 +187,15 @@ async function loadLayers( reader, rootIdentifier ) {
185
187
 
186
188
  if ( detectLayerFormat( bytes ) === 'usdz' ) {
187
189
 
188
- reader.addPackage( identifier, readZip( bytes ) );
190
+ reader.addPackage( identifier, readZip( bytes, { budget: reader.budget } ) );
189
191
  const root = await load( await reader.packageRoot( identifier ) );
190
192
  layers.set( identifier, root );
191
193
  return root;
192
194
 
193
195
  }
194
196
 
195
- const layer = await parseLayer( bytes, identifier );
197
+ reader.budget.check();
198
+ const layer = await parseLayer( bytes, identifier, reader.budget );
196
199
  layers.set( identifier, layer );
197
200
 
198
201
  const pending = [];
@@ -216,6 +219,7 @@ async function loadLayers( reader, rootIdentifier ) {
216
219
 
217
220
  } catch ( e ) {
218
221
 
222
+ rethrowFatal( e, reader.budget );
219
223
  console.warn( `usd-gltf: ${ e.message }` );
220
224
 
221
225
  }
@@ -862,16 +866,16 @@ export class Stage {
862
866
  /**
863
867
  * Opens and composes a stage.
864
868
  * @param {Uint8Array} bytes usda, usdc or usdz
865
- * @param {{ identifier?: string, resolver?: ( identifier: string ) => Promise<Uint8Array>|Uint8Array, variants?: object }} [options]
869
+ * @param {{ identifier?: string, resolver?: ( identifier: string ) => Promise<Uint8Array>|Uint8Array, variants?: object, budget?: import('../limits.js').Budget }} [options]
866
870
  * @returns {Promise<Stage>}
867
871
  */
868
- export async function openStage( bytes, { identifier, resolver = null, variants = {} } = {} ) {
872
+ export async function openStage( bytes, { identifier, resolver = null, variants = {}, budget = new Budget() } = {} ) {
869
873
 
870
874
  const format = detectLayerFormat( bytes );
871
875
  if ( ! format ) throw new Error( 'usd-gltf: input is not usda, usdc or usdz' );
872
876
 
873
877
  identifier ??= 'root.' + format;
874
- const reader = new AssetReader( resolver );
878
+ const reader = new AssetReader( resolver, budget );
875
879
  reader.files.set( identifier, bytes );
876
880
 
877
881
  const { root, layers } = await loadLayers( reader, identifier );
@@ -358,7 +358,7 @@ function typeValue( raw, typeName ) {
358
358
 
359
359
  if ( typeName === 'asset' ) return new AssetPath( raw instanceof AssetRef ? raw.path : String( raw ) );
360
360
  if ( typeName === 'bool' ) return raw === true || raw === 1;
361
- if ( typeName === 'string' || typeName === 'token' ) return raw instanceof Ident ? raw.name : raw;
361
+ if ( typeName === 'string' || typeName === 'token' || typeName === 'pathExpression' ) return raw instanceof Ident ? raw.name : raw;
362
362
  if ( /^quat[hfd]$/.test( typeName ) ) return [ raw[ 1 ], raw[ 2 ], raw[ 3 ], raw[ 0 ] ];
363
363
  if ( /^matrix[234]d$|^frame4d$/.test( typeName ) ) return raw.flat();
364
364
  if ( raw instanceof Tuple ) return Array.from( raw );
@@ -524,6 +524,22 @@ function parseMetadata( lx, metadata, kind ) {
524
524
  metadata.subLayers = layers.map( ( r ) => r.assetPath );
525
525
  metadata.subLayerOffsets = layers.map( ( r ) => r.layerOffset );
526
526
 
527
+ } else if ( key === 'relocates' ) {
528
+
529
+ // { <source>: <target>, ... }; layer relocates use the layerRelocates field, as in usdc
530
+ const pairs = [];
531
+ lx.expect( '{' );
532
+ while ( ! lx.eat( '}' ) ) {
533
+
534
+ const from = lx.path();
535
+ lx.expect( ':' );
536
+ pairs.push( [ from, lx.path() ] );
537
+ lx.eat( ',' );
538
+
539
+ }
540
+
541
+ metadata[ kind === 'layer' ? 'layerRelocates' : 'relocates' ] = pairs;
542
+
527
543
  } else if ( key === 'variants' ) {
528
544
 
529
545
  metadata.variants = parseDictionary( lx );
@@ -56,7 +56,7 @@ function formatScalar( v, base ) {
56
56
 
57
57
  if ( v === null || v === undefined ) return 'None';
58
58
  if ( base === 'bool' ) return v ? 'true' : 'false';
59
- if ( base === 'string' || base === 'token' ) return quote( String( v ) );
59
+ if ( base === 'string' || base === 'token' || base === 'pathExpression' ) return quote( String( v ) );
60
60
  if ( base === 'asset' ) return formatAsset( v );
61
61
  if ( base === 'dictionary' ) return formatDictionary( v, '' );
62
62
 
@@ -142,12 +142,12 @@ function formatDictionary( dict, indent ) {
142
142
 
143
143
  }
144
144
 
145
- function formatLayerOffset( offset ) {
145
+ function formatLayerOffset( offset, customData = null, indent = '' ) {
146
146
 
147
- if ( ! offset ) return '';
148
147
  const parts = [];
149
- if ( offset.offset ) parts.push( `offset = ${ offset.offset }` );
150
- if ( offset.scale !== undefined && offset.scale !== 1 ) parts.push( `scale = ${ offset.scale }` );
148
+ if ( offset?.offset ) parts.push( `offset = ${ offset.offset }` );
149
+ if ( offset?.scale !== undefined && offset.scale !== 1 ) parts.push( `scale = ${ offset.scale }` );
150
+ if ( customData && Object.keys( customData ).length ) parts.push( `customData = ${ formatDictionary( customData, indent ) }` );
151
151
  return parts.length ? ` (${ parts.join( '; ' ) })` : '';
152
152
 
153
153
  }
@@ -157,7 +157,7 @@ function formatReference( r ) {
157
157
  let s = '';
158
158
  if ( r.assetPath ) s += formatAsset( r.assetPath );
159
159
  if ( r.primPath ) s += `<${ r.primPath }>`;
160
- return s + formatLayerOffset( r.layerOffset );
160
+ return s + formatLayerOffset( r.layerOffset, r.customData );
161
161
 
162
162
  }
163
163
 
@@ -218,6 +218,11 @@ function formatMetadataEntries( metadata, indent, kind ) {
218
218
  const offsets = metadata.subLayerOffsets || [];
219
219
  lines.push( `${ indent }subLayers = [\n${ value.map( ( p, i ) => `${ indent } ${ formatAsset( p ) }${ formatLayerOffset( offsets[ i ] ) }` ).join( ',\n' ) }\n${ indent }]` );
220
220
 
221
+ } else if ( key === 'layerRelocates' || key === 'relocates' ) {
222
+
223
+ const pairs = value.map( ( [ a, b ] ) => `${ indent } <${ a }>: <${ b }>` );
224
+ lines.push( `${ indent }relocates = {\n${ pairs.join( ',\n' ) }\n${ indent }}` );
225
+
221
226
  } else if ( key === 'subLayerOffsets' ) {
222
227
 
223
228
  continue;
@@ -309,6 +314,7 @@ function writePrimContents( out, spec, indent ) {
309
314
 
310
315
  for ( const prop of spec.properties.values() ) writeProperty( out, prop, indent );
311
316
 
317
+ if ( spec.metadata.propertyOrder ) out.push( `${ indent }reorder properties = [${ spec.metadata.propertyOrder.map( quote ).join( ', ' ) }]` );
312
318
  if ( spec.metadata.primOrder ) out.push( `${ indent }reorder nameChildren = [${ spec.metadata.primOrder.map( quote ).join( ', ' ) }]` );
313
319
 
314
320
  for ( const child of spec.children.values() ) {
@@ -351,11 +357,26 @@ function writePrim( out, spec, indent ) {
351
357
 
352
358
  /**
353
359
  * @param {import('../model.js').Layer} layer
360
+ * @param {{ budget?: import('../../limits.js').Budget }} [options] its output limit is checked as lines are written
354
361
  * @returns {string}
355
362
  */
356
- export function writeUSDA( layer ) {
363
+ export function writeUSDA( layer, { budget = null } = {} ) {
357
364
 
358
365
  const out = [ '#usda 1.0' ];
366
+
367
+ if ( budget ) {
368
+
369
+ let size = 0;
370
+ const push = out.push;
371
+ out.push = function ( ...lines ) {
372
+
373
+ for ( const line of lines ) size += line.length + 1;
374
+ budget.output( size, 'usda' );
375
+ return push.apply( this, lines );
376
+
377
+ };
378
+
379
+ }
359
380
  const meta = formatMetadataEntries( layer.metadata, ' ', 'layer' );
360
381
  if ( meta.length ) out.push( '(', ...meta, ')' );
361
382
  out.push( '' );