usd-gltf 0.0.1 → 0.1.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.
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  Convert between [OpenUSD](https://openusd.org/) and [glTF](https://www.khronos.org/gltf/) in both directions, in pure JavaScript with no dependencies. Runs in Node and the browser, [live demo](https://richardmonette.github.io/usd-gltf/examples/). Reads and writes all three USD encodings: text `.usda`, binary crate `.usdc`, and the `.usdz` package.
4
4
 
5
- > Work in progress, not yet published to npm.
6
-
7
5
  ```
8
6
  npm install usd-gltf
9
7
  ```
@@ -48,10 +46,15 @@ Current status:
48
46
 
49
47
  The acceptance corpora are every model in [Apple's AR Quick Look gallery](https://developer.apple.com/augmented-reality/quick-look/) and every model in the [Khronos glTF Sample Assets](https://github.com/KhronosGroup/glTF-Sample-Assets). Conversions are checked against Pixar's OpenUSD (`usd-core`) and the Khronos glTF Validator: per mesh triangle counts and world space bounds at several animation times, with skinning baked by OpenUSD, must match.
50
48
 
49
+ To run it locally (needs Python with `pip install usd-core`, in a `.venv` here or on `PATH`):
50
+
51
51
  ```
52
- node scripts/fetch-corpus.js
52
+ npm run fetch-corpus
53
+ npm run conformance
53
54
  ```
54
55
 
56
+ The [Conformance workflow](.github/workflows/conformance.yml) runs the same check in CI.
57
+
55
58
  ## License
56
59
 
57
60
  MIT
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "usd-gltf",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Convert between USD (usda, usdc, usdz) and glTF / GLB, pure JavaScript with no dependencies",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
8
8
  "exports": {
9
- ".": "./src/index.js"
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ }
10
13
  },
11
14
  "bin": {
12
15
  "usd-gltf": "bin/usd-gltf.js"
@@ -16,7 +19,9 @@
16
19
  "bin"
17
20
  ],
18
21
  "scripts": {
19
- "test": "node --test test/*.test.js"
22
+ "test": "node --test test/*.test.js",
23
+ "conformance": "node scripts/conformance.js",
24
+ "fetch-corpus": "node scripts/fetch-corpus.js"
20
25
  },
21
26
  "engines": {
22
27
  "node": ">=18"
@@ -45,5 +50,6 @@
45
50
  "devDependencies": {
46
51
  "gltf-validator": "^2.0.0-dev.3.10",
47
52
  "meshoptimizer": "^1.3.0"
48
- }
53
+ },
54
+ "types": "src/index.d.ts"
49
55
  }
@@ -6,7 +6,7 @@
6
6
  // and lets textures be referenced directly when glTF can express the result, or be repacked
7
7
  // (baked) into a new image when it can't, e.g. separate roughness and metallic maps.
8
8
 
9
- import { decodeImageAsync, encodePNG, imageMimeType } from '../image/index.js';
9
+ import { decodeImageAsync, encodePNG, encodePNGAsync, imageMimeType, isGreyscaleImage } from '../image/index.js';
10
10
  import { multiply } from '../util/math.js';
11
11
  import { resolveAssetPath } from '../usd/stage.js';
12
12
 
@@ -476,6 +476,20 @@ export class MaterialConverter {
476
476
 
477
477
  }
478
478
 
479
+ async isGrey( file ) {
480
+
481
+ try {
482
+
483
+ return isGreyscaleImage( await this.stage.readAsset( file ) );
484
+
485
+ } catch {
486
+
487
+ return false;
488
+
489
+ }
490
+
491
+ }
492
+
479
493
  decode( file ) {
480
494
 
481
495
  if ( ! this.decoded.has( file ) ) {
@@ -562,8 +576,10 @@ export class MaterialConverter {
562
576
  const tex = textured[ 0 ].term.tex;
563
577
  const sameTexture = textured.every( ( s ) => s.term.tex.file === tex.file && s.term.tex.uv.name === tex.uv.name && JSON.stringify( s.term.tex.uv.transform ) === JSON.stringify( tex.uv.transform ) );
564
578
 
565
- // direct use: each textured slot reads its own channel, no bias, scale expressible as a factor
566
- const direct = sameTexture && textured.every( ( s ) => s.term.channel === s.out && Math.abs( s.term.bias ) < 1e-6 && ( allowFactor || Math.abs( s.term.scale - 1 ) < 1e-6 ) ) &&
579
+ // direct use: each textured slot reads its own channel (any colour channel of a grey image
580
+ // reads the same value), no bias, and a scale expressible as a factor
581
+ const grey = sameTexture && textured.some( ( s ) => s.term.channel !== s.out ) && await this.isGrey( tex.file );
582
+ const direct = sameTexture && textured.every( ( s ) => ( s.term.channel === s.out || ( grey && s.term.channel < 3 && s.out < 3 ) ) && Math.abs( s.term.bias ) < 1e-6 && ( allowFactor || Math.abs( s.term.scale - 1 ) < 1e-6 ) ) &&
567
583
  slots.every( ( s ) => ! s || s.term.tex || s.ignoreConst || ( allowFactor && s.constOk ) );
568
584
 
569
585
  if ( direct ) {
@@ -588,47 +604,64 @@ export class MaterialConverter {
588
604
 
589
605
  }
590
606
 
607
+ const key = 'baked:' + slots.map( ( s ) => s ? `${ s.term.tex?.file }#${ s.term.channel }*${ s.term.scale }+${ s.term.bias }` : '-' ).join( ',' );
608
+ let imageIndex = this.images.get( key )?.index;
609
+
610
+ if ( imageIndex === undefined ) {
611
+
612
+ const image = this.bake( slots, textured, sources );
613
+ imageIndex = this.builder.addImage( await encodePNGAsync( image ), 'image/png', imageName( tex.file ) + '_packed' );
614
+ this.images.set( key, { index: imageIndex, mimeType: 'image/png' } );
615
+
616
+ }
617
+
618
+ return { info: this.textureInfo( imageIndex, tex, key ), factors: slots.map( () => 1 ) };
619
+
620
+ }
621
+
622
+ // Writes each slot's term into its output channel, resampling sources to the largest size.
623
+ // Only the channels glTF reads are stored: grey for a lone R, RGB, or RGBA when alpha is used.
624
+ bake( slots, textured, sources ) {
625
+
591
626
  const width = Math.max( ...sources.map( ( s ) => s.width ) );
592
627
  const height = Math.max( ...sources.map( ( s ) => s.height ) );
593
- const out = new Uint8Array( width * height * 4 );
628
+ const used = slots.map( ( s, c ) => ( s ? c : - 1 ) ).filter( ( c ) => c >= 0 );
629
+ const channels = used.length === 1 && used[ 0 ] === 0 ? 1 : used.includes( 3 ) ? 4 : 3;
630
+ const out = new Uint8Array( width * height * channels );
594
631
  const srcOf = new Map( textured.map( ( s, i ) => [ s, sources[ i ] ] ) );
595
632
 
596
- for ( let c = 0; c < 4; c ++ ) {
633
+ for ( let c = 0; c < channels; c ++ ) {
597
634
 
598
635
  const slot = slots.find( ( s ) => s && s.out === c );
599
636
 
600
- if ( ! slot ) {
637
+ if ( ! slot || ! slot.term.tex ) {
601
638
 
602
- for ( let i = c; i < out.length; i += 4 ) out[ i ] = 255;
639
+ const v = slot ? Math.round( clamp01( slot.term.bias ) * 255 ) : 255;
640
+ for ( let i = c; i < out.length; i += channels ) out[ i ] = v;
603
641
  continue;
604
642
 
605
643
  }
606
644
 
607
645
  const { term } = slot;
608
-
609
- if ( ! term.tex ) {
610
-
611
- const v = Math.round( clamp01( term.bias ) * 255 );
612
- for ( let i = c; i < out.length; i += 4 ) out[ i ] = v;
613
- continue;
614
-
615
- }
616
-
617
646
  const src = srcOf.get( slot );
618
647
  const max = src.bitDepth === 16 ? 65535 : 255;
619
- const sc = Math.min( term.channel, src.channels - 1 );
620
648
  const alphaMissing = term.channel === 3 && src.channels < 4 && src.channels !== 2;
621
- const channel = src.channels === 2 && term.channel === 3 ? 1 : src.channels <= 2 && term.channel < 3 ? 0 : sc;
649
+ const channel = src.channels === 2 && term.channel === 3 ? 1 : src.channels <= 2 && term.channel < 3 ? 0 : Math.min( term.channel, src.channels - 1 );
622
650
 
623
- for ( let y = 0; y < height; y ++ ) {
651
+ // output = clamp( raw / max * scale + bias ) * 255, as one multiply and add per pixel
652
+ const k = term.scale * 255 / max, b = term.bias * 255 + 0.5;
653
+ const xs = new Int32Array( width );
654
+ for ( let x = 0; x < width; x ++ ) xs[ x ] = Math.min( src.width - 1, Math.floor( x * src.width / width ) ) * src.channels + channel;
655
+ const data = src.data, sw = src.width * src.channels;
624
656
 
625
- const sy = Math.min( src.height - 1, Math.floor( y * src.height / height ) );
657
+ for ( let y = 0, o = c; y < height; y ++ ) {
626
658
 
627
- for ( let x = 0; x < width; x ++ ) {
659
+ const row = Math.min( src.height - 1, Math.floor( y * src.height / height ) ) * sw;
628
660
 
629
- const sx = Math.min( src.width - 1, Math.floor( x * src.width / width ) );
630
- const raw = alphaMissing ? 1 : src.data[ ( sy * src.width + sx ) * src.channels + channel ] / max;
631
- out[ ( y * width + x ) * 4 + c ] = Math.round( clamp01( raw * term.scale + term.bias ) * 255 );
661
+ for ( let x = 0; x < width; x ++, o += channels ) {
662
+
663
+ const v = alphaMissing ? 255 * term.scale + b : data[ row + xs[ x ] ] * k + b;
664
+ out[ o ] = v <= 0 ? 0 : v >= 255 ? 255 : v | 0;
632
665
 
633
666
  }
634
667
 
@@ -636,16 +669,7 @@ export class MaterialConverter {
636
669
 
637
670
  }
638
671
 
639
- const key = 'baked:' + slots.map( ( s ) => s ? `${ s.term.tex?.file }#${ s.term.channel }*${ s.term.scale }+${ s.term.bias }` : '-' ).join( ',' );
640
- let imageIndex = this.images.get( key )?.index;
641
- if ( imageIndex === undefined ) {
642
-
643
- imageIndex = this.builder.addImage( encodePNG( { width, height, channels: 4, data: out } ), 'image/png', imageName( tex.file ) + '_packed' );
644
- this.images.set( key, { index: imageIndex, mimeType: 'image/png' } );
645
-
646
- }
647
-
648
- return { info: this.textureInfo( imageIndex, tex, key ), factors: slots.map( () => 1 ) };
672
+ return { width, height, channels, data: out };
649
673
 
650
674
  }
651
675
 
@@ -37,6 +37,34 @@ export function imageMimeType( bytes ) {
37
37
 
38
38
  }
39
39
 
40
+ /**
41
+ * Whether a PNG or JPEG stores a single grey channel, from its header.
42
+ * @param {Uint8Array} bytes
43
+ */
44
+ export function isGreyscaleImage( bytes ) {
45
+
46
+ bytes = toBytes( bytes );
47
+ const type = imageMimeType( bytes );
48
+ if ( type === 'image/png' ) return bytes[ 25 ] === 0 || bytes[ 25 ] === 4; // IHDR colour type
49
+
50
+ if ( type === 'image/jpeg' ) {
51
+
52
+ // walk the markers to the frame header, whose component count follows the dimensions
53
+ for ( let o = 2; o + 9 < bytes.length; ) {
54
+
55
+ if ( bytes[ o ] !== 0xFF ) return false;
56
+ const marker = bytes[ o + 1 ];
57
+ if ( marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC ) return bytes[ o + 9 ] === 1;
58
+ o += 2 + ( ( bytes[ o + 2 ] << 8 ) | bytes[ o + 3 ] );
59
+
60
+ }
61
+
62
+ }
63
+
64
+ return false;
65
+
66
+ }
67
+
40
68
  /**
41
69
  * Decodes a PNG, JPEG or WebP. The format is sniffed from the bytes; mimeType is only a fallback.
42
70
  * @param {Uint8Array|ArrayBuffer} bytes
@@ -57,6 +85,55 @@ export function decodeImage( bytes, mimeType ) {
57
85
 
58
86
  const hasNativeDecoder = typeof createImageBitmap === 'function' && typeof OffscreenCanvas === 'function';
59
87
 
88
+ /**
89
+ * Encodes a PNG, with the platform's encoder where there is one (much faster) unless the image has
90
+ * partial transparency, which canvases store premultiplied.
91
+ * @param {{ width: number, height: number, channels: number, data: Uint8Array }} image 8 bit
92
+ * @returns {Promise<Uint8Array>}
93
+ */
94
+ export async function encodePNGAsync( image ) {
95
+
96
+ const { width, height, channels, data } = image;
97
+ let opaque = channels !== 2 && channels !== 4;
98
+ if ( ! opaque ) {
99
+
100
+ opaque = true;
101
+ for ( let i = channels - 1; i < data.length; i += channels ) if ( data[ i ] !== 255 ) { opaque = false; break; }
102
+
103
+ }
104
+
105
+ if ( hasNativeDecoder && opaque && ( image.bitDepth ?? 8 ) === 8 ) {
106
+
107
+ try {
108
+
109
+ const rgba = new Uint8ClampedArray( width * height * 4 );
110
+ for ( let i = 0, n = width * height; i < n; i ++ ) {
111
+
112
+ const s = i * channels;
113
+ rgba[ i * 4 ] = data[ s ];
114
+ rgba[ i * 4 + 1 ] = data[ channels >= 3 ? s + 1 : s ];
115
+ rgba[ i * 4 + 2 ] = data[ channels >= 3 ? s + 2 : s ];
116
+ rgba[ i * 4 + 3 ] = 255;
117
+
118
+ }
119
+
120
+ const canvas = new OffscreenCanvas( width, height );
121
+ canvas.getContext( '2d' ).putImageData( new ImageData( rgba, width, height ), 0, 0 );
122
+ const blob = await canvas.convertToBlob( { type: 'image/png' } );
123
+ return new Uint8Array( await blob.arrayBuffer() );
124
+
125
+ } catch {
126
+
127
+ // fall through to the JavaScript encoder
128
+
129
+ }
130
+
131
+ }
132
+
133
+ return encodePNG( image );
134
+
135
+ }
136
+
60
137
  /**
61
138
  * Decodes with the platform's image decoder where there is one (browsers and workers), which is
62
139
  * faster and supports more formats, falling back to the JavaScript decoders (Node).
package/src/index.d.ts ADDED
@@ -0,0 +1,71 @@
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
+ export interface USDToGLBOptions {
17
+ /** Name of the input file, used to resolve relative paths. */
18
+ identifier?: string;
19
+ /** Needed when the USD references files outside a usdz package. */
20
+ resolver?: Resolver;
21
+ /** Variant selections, by prim path: { '/Root': { shadingVariant: 'red' } }, or by set name for every prim. */
22
+ variants?: Record<string, string | Record<string, string>>;
23
+ /** Write material variant sets as KHR_materials_variants (default true). */
24
+ materialVariants?: boolean;
25
+ imageDecoder?: ImageDecoder;
26
+ /** Receives non fatal problems, such as textures that could not be read. */
27
+ log?: ( message: string ) => void;
28
+ }
29
+
30
+ export interface GLBToUSDOptions {
31
+ /** Output encoding: a usdz package (default), or a single usda / usdc layer without its textures. */
32
+ format?: 'usdz' | 'usda' | 'usdc';
33
+ /** Needed for .gltf files with external buffers or images. */
34
+ resolver?: Resolver;
35
+ imageDecoder?: ImageDecoder;
36
+ log?: ( message: string ) => void;
37
+ }
38
+
39
+ /** Converts USD (usda, usdc or usdz, detected from the bytes) to GLB. */
40
+ export function usdToGLB( data: Uint8Array | ArrayBuffer, options?: USDToGLBOptions ): Promise<Uint8Array>;
41
+
42
+ /** Converts glTF (GLB, or .gltf JSON with a resolver for its files) to USD. */
43
+ export function glbToUSD( data: Uint8Array | ArrayBuffer, options?: GLBToUSDOptions ): Promise<Uint8Array>;
44
+
45
+ /** Like glbToUSD for usda / usdc output, also returning the texture files the layer references. */
46
+ export function glbToUSDFiles( data: Uint8Array | ArrayBuffer, options?: Omit<GLBToUSDOptions, 'format'> & { format?: 'usda' | 'usdc' } ): Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>;
47
+
48
+ /** Identifies a file from its leading bytes. */
49
+ export function detectFormat( data: Uint8Array | ArrayBuffer ): 'glb' | 'gltf' | 'usda' | 'usdc' | 'usdz' | null;
50
+
51
+ /** Splits a GLB into its JSON and binary chunk. */
52
+ export function readGLB( data: Uint8Array | ArrayBuffer ): { json: any, bin: Uint8Array | null };
53
+
54
+ /** Packs glTF JSON and a binary buffer into a GLB. */
55
+ export function writeGLB( json: object, bin?: Uint8Array | null ): Uint8Array;
56
+
57
+ /** The uncomposed contents of one USD layer; see src/usd/model.js for the value representation. */
58
+ export interface Layer {
59
+ identifier: string;
60
+ metadata: Record<string, any>;
61
+ root: any;
62
+ getPrimAtPath( path: string ): any;
63
+ }
64
+
65
+ export function readUSDA( source: string | Uint8Array, identifier?: string ): Layer;
66
+ export function writeUSDA( layer: Layer ): string;
67
+ export function readUSDC( data: Uint8Array | ArrayBuffer, identifier?: string ): Layer;
68
+ export function writeUSDC( layer: Layer ): Uint8Array;
69
+
70
+ /** Opens and composes a USD stage (references, payloads, sublayers, inherits, variants). */
71
+ export function openStage( data: Uint8Array, options?: { identifier?: string, resolver?: Resolver, variants?: USDToGLBOptions[ 'variants' ] } ): Promise<any>;
@@ -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() ) {