usd-gltf 0.1.0 → 0.3.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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { readGLB, writeGLB } from './glb.js';
4
4
  import { decodeMeshoptView } from './meshopt.js';
5
+ import { decodeDracoPrimitive, loadDracoDecoder, needsDraco } from './draco.js';
6
+ import { Budget, checkSize } from '../limits.js';
5
7
 
6
8
  const COMPONENT_ARRAYS = {
7
9
  5120: Int8Array,
@@ -14,6 +16,25 @@ const COMPONENT_ARRAYS = {
14
16
 
15
17
  export const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
16
18
 
19
+ // Extensions an asset may require. Material extensions without a UsdPreviewSurface equivalent are
20
+ // approximated and kept on the USD material for the way back, so every KHR_materials_* is accepted.
21
+ const SUPPORTED_EXTENSIONS = new Set( [
22
+ 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization', 'KHR_draco_mesh_compression',
23
+ 'EXT_texture_webp', 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_animation_pointer', 'KHR_lights_punctual',
24
+ 'KHR_node_visibility', 'KHR_xmp_json_ld',
25
+ ] );
26
+
27
+ /**
28
+ * The asset's required extensions that can't be converted (e.g. KHR_texture_basisu).
29
+ * @param {object} json
30
+ * @returns {string[]}
31
+ */
32
+ export function unsupportedExtensions( json ) {
33
+
34
+ return ( json.extensionsRequired || [] ).filter( ( name ) => ! SUPPORTED_EXTENSIONS.has( name ) && ! name.startsWith( 'KHR_materials_' ) );
35
+
36
+ }
37
+
17
38
  function decodeDataURI( uri ) {
18
39
 
19
40
  const comma = uri.indexOf( ',' );
@@ -38,13 +59,15 @@ function decodeDataURI( uri ) {
38
59
  */
39
60
  export class GLTFDocument {
40
61
 
41
- constructor( json, buffers, images ) {
62
+ constructor( json, buffers, images, budget = new Budget() ) {
42
63
 
43
64
  this.json = json;
44
65
  this.buffers = buffers; // Uint8Array per json.buffers entry
45
66
  this.images = images; // { bytes, mimeType, name } per json.images entry
67
+ this.budget = budget;
46
68
  this.accessorCache = new Map();
47
69
  this.decodedViews = new Map(); // bufferView index -> bytes, for compressed views
70
+ this.decodedAccessors = new Map(); // accessor index -> data, for Draco compressed primitives
48
71
 
49
72
  }
50
73
 
@@ -52,9 +75,11 @@ export class GLTFDocument {
52
75
 
53
76
  const decoded = this.decodedViews.get( index );
54
77
  if ( decoded ) return decoded;
55
- const view = this.json.bufferViews[ index ];
56
- const buffer = this.buffers[ view.buffer ];
57
- return buffer.subarray( view.byteOffset || 0, ( view.byteOffset || 0 ) + view.byteLength );
78
+ const view = this.json.bufferViews?.[ index ];
79
+ const buffer = view && this.buffers[ view.buffer ];
80
+ const start = view?.byteOffset || 0;
81
+ if ( ! buffer || ! ( start + view.byteLength <= buffer.length ) ) throw new Error( `usd-gltf: bufferView ${ index } is outside its buffer` );
82
+ return buffer.subarray( start, start + view.byteLength );
58
83
 
59
84
  }
60
85
 
@@ -68,11 +93,16 @@ export class GLTFDocument {
68
93
  const key = index + ( normalize ? 'n' : '' );
69
94
  if ( this.accessorCache.has( key ) ) return this.accessorCache.get( key );
70
95
 
71
- const accessor = this.json.accessors[ index ];
72
- const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
73
- const itemSize = TYPE_SIZES[ accessor.type ];
96
+ const accessor = this.json.accessors?.[ index ];
97
+ const ArrayType = COMPONENT_ARRAYS[ accessor?.componentType ];
98
+ const itemSize = TYPE_SIZES[ accessor?.type ];
99
+ if ( ! ArrayType || ! itemSize ) throw new Error( `usd-gltf: accessor ${ index } is missing or has an invalid type` );
74
100
  const count = accessor.count;
75
- let array = new ArrayType( count * itemSize );
101
+ const stage = `accessor ${ index }`;
102
+ const size = count * itemSize * ArrayType.BYTES_PER_ELEMENT;
103
+ checkSize( size, stage );
104
+
105
+ let read = null;
76
106
 
77
107
  if ( accessor.bufferView !== undefined ) {
78
108
 
@@ -88,6 +118,18 @@ export class GLTFDocument {
88
118
  const columnStride = Math.ceil( rows * elementBytes / 4 ) * 4;
89
119
  const packed = columns === 1 ? itemSize * elementBytes : columnStride * columns;
90
120
  const stride = view.byteStride || packed;
121
+ if ( count > 0 && ! ( offset + stride * ( count - 1 ) + packed <= bytes.length ) ) throw new Error( `usd-gltf: ${ stage } reads past the end of its bufferView` );
122
+ read = { view, bytes, elementBytes, offset, columns, rows, columnStride, stride };
123
+
124
+ }
125
+
126
+ const decoded = accessor.bufferView === undefined ? this.decodedAccessors.get( index ) : undefined; // charged when decoded
127
+ if ( ! decoded ) this.budget.decode( size, stage );
128
+ let array = decoded || new ArrayType( count * itemSize );
129
+
130
+ if ( read ) {
131
+
132
+ const { bytes, elementBytes, offset, columns, rows, columnStride, stride } = read;
91
133
 
92
134
  const dv = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength );
93
135
  const getters = { 5120: 'getInt8', 5121: 'getUint8', 5122: 'getInt16', 5123: 'getUint16', 5125: 'getUint32', 5126: 'getFloat32' };
@@ -120,9 +162,16 @@ export class GLTFDocument {
120
162
  const IndexType = COMPONENT_ARRAYS[ indices.componentType ];
121
163
  const ib = this.bufferViewBytes( indices.bufferView );
122
164
  const vb = this.bufferViewBytes( values.bufferView );
165
+ if ( ! IndexType || ! Number.isSafeInteger( n ) || n < 0 || n > count ) throw new Error( `usd-gltf: ${ stage } has an invalid sparse count` );
166
+ if ( ! ( ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT <= ib.length && ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT <= vb.length ) ) throw new Error( `usd-gltf: ${ stage } sparse data is outside its bufferViews` );
123
167
  const idx = new IndexType( ib.slice( indices.byteOffset || 0, ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT ).buffer );
124
168
  const vals = new ArrayType( vb.slice( values.byteOffset || 0, ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT ).buffer );
125
- for ( let i = 0; i < n; i ++ ) for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
169
+ for ( let i = 0; i < n; i ++ ) {
170
+
171
+ if ( idx[ i ] >= count ) throw new Error( `usd-gltf: ${ stage } sparse index is out of range` );
172
+ for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
173
+
174
+ }
126
175
 
127
176
  }
128
177
 
@@ -130,6 +179,7 @@ export class GLTFDocument {
130
179
 
131
180
  if ( normalize && normalized ) {
132
181
 
182
+ this.budget.decode( array.length * 4, stage );
133
183
  const out = new Float32Array( array.length );
134
184
  const ct = accessor.componentType;
135
185
  for ( let i = 0; i < array.length; i ++ ) {
@@ -154,7 +204,9 @@ export class GLTFDocument {
154
204
  getFloats( index ) {
155
205
 
156
206
  const { array, itemSize, count } = this.getAccessor( index );
157
- return { array: array instanceof Float32Array ? array : Float32Array.from( array ), itemSize, count };
207
+ if ( array instanceof Float32Array ) return { array, itemSize, count };
208
+ this.budget.decode( array.length * 4, `accessor ${ index }` );
209
+ return { array: Float32Array.from( array ), itemSize, count };
158
210
 
159
211
  }
160
212
 
@@ -163,15 +215,19 @@ export class GLTFDocument {
163
215
  /**
164
216
  * Loads a glTF or GLB.
165
217
  * @param {Uint8Array} bytes
166
- * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array }} [options] loads external uris (already decoded from URI escaping)
218
+ * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array, dracoDecoder?: object|Promise<object> }} [options] resolver loads external uris (already decoded from URI escaping); dracoDecoder is a Draco decoder module, loaded when needed if not given
167
219
  */
168
- export async function readGLTF( bytes, { resolver = null } = {} ) {
220
+ export async function readGLTF( bytes, { resolver = null, budget = new Budget(), dracoDecoder = null } = {} ) {
169
221
 
170
222
  let json, bin = null;
223
+ bytes = plainBytes( bytes );
171
224
 
172
225
  if ( bytes[ 0 ] === 0x67 && bytes[ 1 ] === 0x6C && bytes[ 2 ] === 0x54 && bytes[ 3 ] === 0x46 ) ( { json, bin } = readGLB( bytes ) );
173
226
  else json = JSON.parse( new TextDecoder().decode( bytes ).replace( /^/, '' ) );
174
227
 
228
+ const unsupported = unsupportedExtensions( json );
229
+ if ( unsupported.length ) throw new Error( `usd-gltf: required extension${ unsupported.length > 1 ? 's' : '' } ${ unsupported.join( ', ' ) } not supported` );
230
+
175
231
  async function load( uri ) {
176
232
 
177
233
  if ( uri.startsWith( 'data:' ) ) return decodeDataURI( uri ).bytes;
@@ -179,7 +235,7 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
179
235
  let path = uri;
180
236
  try { path = decodeURIComponent( uri ); } catch { /* keep as is */ }
181
237
  const data = await resolver( path );
182
- return data instanceof Uint8Array ? data : new Uint8Array( data );
238
+ return plainBytes( data );
183
239
 
184
240
  }
185
241
 
@@ -188,8 +244,9 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
188
244
  if ( buffer.uri === undefined ) {
189
245
 
190
246
  if ( i === 0 && bin ) return bin;
191
- // e.g. EXT_meshopt_compression fallback buffers have no data
192
- return new Uint8Array( buffer.byteLength );
247
+ // e.g. EXT_meshopt_compression fallback buffers: only read through decoded views, so they
248
+ // need no storage, and any other view into one fails its bounds check
249
+ return new Uint8Array( 0 );
193
250
 
194
251
  }
195
252
 
@@ -197,15 +254,36 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
197
254
 
198
255
  } ) );
199
256
 
200
- const doc = new GLTFDocument( json, buffers, [] );
257
+ const doc = new GLTFDocument( json, buffers, [], budget );
201
258
 
202
259
  ( json.bufferViews || [] ).forEach( ( view, i ) => {
203
260
 
204
261
  const ext = view.extensions?.KHR_meshopt_compression || view.extensions?.EXT_meshopt_compression;
205
- if ( ext ) doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
262
+ if ( ! ext ) return;
263
+ const start = ext.byteOffset || 0;
264
+ if ( ! buffers[ ext.buffer ] || ! ( start + ext.byteLength <= buffers[ ext.buffer ].length ) ) throw new Error( `usd-gltf: bufferView ${ i } compressed data is outside its buffer` );
265
+ budget.decode( ext.count * ext.byteStride, `bufferView ${ i }` );
266
+ doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
206
267
 
207
268
  } );
208
269
 
270
+ // Draco primitives without uncompressed fallback data
271
+ const draco = ( json.meshes || [] ).flatMap( ( mesh ) => mesh.primitives ).filter( ( p ) => needsDraco( json, p ) );
272
+
273
+ if ( draco.length ) {
274
+
275
+ const decoder = await ( dracoDecoder || loadDracoDecoder() );
276
+
277
+ for ( const primitive of draco ) {
278
+
279
+ budget.check();
280
+ const bytes = doc.bufferViewBytes( primitive.extensions.KHR_draco_mesh_compression.bufferView );
281
+ for ( const [ index, data ] of decodeDracoPrimitive( decoder, bytes, json, primitive, budget ) ) doc.decodedAccessors.set( index, data );
282
+
283
+ }
284
+
285
+ }
286
+
209
287
  doc.images = await Promise.all( ( json.images || [] ).map( async ( image ) => {
210
288
 
211
289
  if ( image.bufferView !== undefined ) return { bytes: doc.bufferViewBytes( image.bufferView ), mimeType: image.mimeType, name: image.name, uri: null };
@@ -228,6 +306,14 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
228
306
 
229
307
  }
230
308
 
309
+ // A plain Uint8Array view: a Node Buffer's slice() aliases its memory instead of copying, so data
310
+ // read through .slice().buffer would be the wrong bytes.
311
+ function plainBytes( data ) {
312
+
313
+ return ArrayBuffer.isView( data ) ? new Uint8Array( data.buffer, data.byteOffset, data.byteLength ) : new Uint8Array( data );
314
+
315
+ }
316
+
231
317
  function guessMime( uri ) {
232
318
 
233
319
  const ext = uri.split( '?' )[ 0 ].split( '.' ).pop().toLowerCase();
@@ -240,8 +326,9 @@ function guessMime( uri ) {
240
326
  */
241
327
  export class GLTFBuilder {
242
328
 
243
- constructor( generator = 'usd-gltf' ) {
329
+ constructor( generator = 'usd-gltf', budget = new Budget() ) {
244
330
 
331
+ this.budget = budget;
245
332
  this.json = { asset: { version: '2.0', generator }, scenes: [], nodes: [], meshes: [], materials: [], textures: [], images: [], samplers: [], accessors: [], bufferViews: [], buffers: [] };
246
333
  this.chunks = [];
247
334
  this.byteLength = 0;
@@ -262,6 +349,7 @@ export class GLTFBuilder {
262
349
  const pad = ( 4 - ( this.byteLength % 4 ) ) % 4;
263
350
  if ( pad ) { this.chunks.push( new Uint8Array( pad ) ); this.byteLength += pad; }
264
351
 
352
+ this.budget.output( this.byteLength + bytes.byteLength, 'glb buffer' );
265
353
  const view = { buffer: 0, byteOffset: this.byteLength, byteLength: bytes.byteLength };
266
354
  if ( target ) view.target = target;
267
355
  this.chunks.push( bytes instanceof Uint8Array ? bytes : new Uint8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength ) );
@@ -323,7 +411,9 @@ export class GLTFBuilder {
323
411
 
324
412
  toGLB() {
325
413
 
326
- const json = this.finalize();
414
+ const json = new TextEncoder().encode( JSON.stringify( this.finalize() ) );
415
+ const pad4 = ( n ) => Math.ceil( n / 4 ) * 4;
416
+ this.budget.output( 20 + pad4( json.byteLength ) + ( this.byteLength ? 8 + pad4( this.byteLength ) : 0 ), 'glb' );
327
417
  const bin = new Uint8Array( this.byteLength );
328
418
  let offset = 0;
329
419
  for ( const chunk of this.chunks ) {
@@ -0,0 +1,191 @@
1
+ // KHR_draco_mesh_compression, decoded with Google's Draco decoder, which usd-gltf doesn't bundle.
2
+ // The decoder is the dracoDecoder option when given; otherwise the draco3d package in Node (an
3
+ // optional peer dependency), or in browsers Google's CDN build, pinned to one version and checked
4
+ // against its hash before it runs. Only loaded when a file needs it.
5
+ // https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_draco_mesh_compression
6
+
7
+ const CDN = 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/';
8
+ const CDN_FILES = {
9
+ 'draco_wasm_wrapper_gltf.js': 'i7KVLSun1n4UFPjfgZQQywQ0pma+U/Zx//dfaIQ9dvY=',
10
+ 'draco_decoder_gltf.wasm': 'cS2zRJriBB1uiiJMOVvabO20nlEyL644t9ub64s4GIk=',
11
+ };
12
+
13
+ const DATA_TYPES = { 5120: 'DT_INT8', 5121: 'DT_UINT8', 5122: 'DT_INT16', 5123: 'DT_UINT16', 5125: 'DT_UINT32', 5126: 'DT_FLOAT32' };
14
+ const COMPONENT_ARRAYS = { 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array };
15
+ const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
16
+
17
+ let defaultDecoder = null;
18
+
19
+ /**
20
+ * The default Draco decoder module: draco3d in Node, Google's CDN in browsers. Loaded once.
21
+ * @returns {Promise<object>}
22
+ */
23
+ export function loadDracoDecoder() {
24
+
25
+ defaultDecoder ||= ( isNode() ? loadNode() : loadDracoFromCDN() ).catch( ( e ) => {
26
+
27
+ defaultDecoder = null; // let a later conversion try again
28
+ throw e;
29
+
30
+ } );
31
+
32
+ return defaultDecoder;
33
+
34
+ }
35
+
36
+ function isNode() {
37
+
38
+ return typeof process !== 'undefined' && !! process.versions?.node;
39
+
40
+ }
41
+
42
+ async function loadNode() {
43
+
44
+ const name = 'draco3d'; // not a literal, so bundlers don't try to include it
45
+ let draco3d;
46
+
47
+ try {
48
+
49
+ draco3d = await import( /* webpackIgnore: true */ /* @vite-ignore */ name );
50
+
51
+ } catch {
52
+
53
+ throw new Error( 'usd-gltf: this glTF is Draco compressed; install the decoder with npm install draco3d, or pass a dracoDecoder' );
54
+
55
+ }
56
+
57
+ return ( draco3d.default ?? draco3d ).createDecoderModule();
58
+
59
+ }
60
+
61
+ // Exported for tests; loadDracoDecoder picks the source.
62
+ export async function loadDracoFromCDN() {
63
+
64
+ if ( ! globalThis.crypto?.subtle ) throw new Error( 'usd-gltf: loading the Draco decoder needs a secure context (https) to check it; pass a dracoDecoder instead' );
65
+ const [ wrapper, wasm ] = await Promise.all( Object.keys( CDN_FILES ).map( fetchVerified ) );
66
+ // the wrapper is a classic script defining DracoDecoderModule, so it can't be imported as a module
67
+ const factory = new Function( new TextDecoder().decode( wrapper ) + '\nreturn DracoDecoderModule;' )();
68
+ return factory( { wasmBinary: wasm.buffer } );
69
+
70
+ }
71
+
72
+ async function fetchVerified( name ) {
73
+
74
+ const response = await fetch( CDN + name );
75
+ if ( ! response.ok ) throw new Error( `usd-gltf: could not load the Draco decoder (${ response.status } for ${ CDN + name })` );
76
+ const bytes = new Uint8Array( await response.arrayBuffer() );
77
+ const digest = new Uint8Array( await crypto.subtle.digest( 'SHA-256', bytes ) );
78
+ if ( btoa( String.fromCharCode( ...digest ) ) !== CDN_FILES[ name ] ) throw new Error( `usd-gltf: ${ CDN + name } doesn't match its expected hash` );
79
+ return bytes;
80
+
81
+ }
82
+
83
+ /**
84
+ * Whether a primitive's data is only in its Draco extension: Draco is decoded only when the
85
+ * accessors have no uncompressed fallback.
86
+ */
87
+ export function needsDraco( json, primitive ) {
88
+
89
+ if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return false;
90
+ const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
91
+ return used.some( ( a ) => json.accessors[ a ]?.bufferView === undefined && ! json.accessors[ a ]?.sparse );
92
+
93
+ }
94
+
95
+ /**
96
+ * Decodes one Draco compressed primitive into the data of the accessors it describes.
97
+ * @param {object} draco decoder module
98
+ * @param {Uint8Array} bytes the extension's bufferView
99
+ * @param {object} json
100
+ * @param {object} primitive
101
+ * @param {import('../limits.js').Budget} budget
102
+ * @returns {Map<number, ArrayLike<number>>} accessor index -> data, in the accessor's component type
103
+ */
104
+ export function decodeDracoPrimitive( draco, bytes, json, primitive, budget ) {
105
+
106
+ const ext = primitive.extensions.KHR_draco_mesh_compression;
107
+ const decoder = new draco.Decoder();
108
+ const array = new Int8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength );
109
+ const out = new Map();
110
+ let geometry = null;
111
+
112
+ try {
113
+
114
+ const type = decoder.GetEncodedGeometryType( array );
115
+ let status;
116
+
117
+ if ( type === draco.TRIANGULAR_MESH ) {
118
+
119
+ geometry = new draco.Mesh();
120
+ status = decoder.DecodeArrayToMesh( array, array.byteLength, geometry );
121
+
122
+ } else if ( type === draco.POINT_CLOUD ) {
123
+
124
+ geometry = new draco.PointCloud();
125
+ status = decoder.DecodeArrayToPointCloud( array, array.byteLength, geometry );
126
+
127
+ } else {
128
+
129
+ throw new Error( 'usd-gltf: Draco data is neither a mesh nor a point cloud' );
130
+
131
+ }
132
+
133
+ if ( ! status.ok() || geometry.ptr === 0 ) throw new Error( `usd-gltf: Draco decoding failed: ${ status.error_msg() }` );
134
+
135
+ const points = geometry.num_points();
136
+
137
+ for ( const [ name, id ] of Object.entries( ext.attributes ) ) {
138
+
139
+ const index = primitive.attributes[ name ];
140
+ const accessor = json.accessors[ index ];
141
+ if ( accessor === undefined ) continue;
142
+ const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
143
+ const size = TYPE_SIZES[ accessor.type ];
144
+ const attribute = decoder.GetAttributeByUniqueId( geometry, id );
145
+ if ( ! ArrayType || ! size || ! attribute || attribute.ptr === 0 ) throw new Error( `usd-gltf: Draco data has no ${ name } attribute ${ id }` );
146
+ if ( points !== accessor.count || attribute.num_components() !== size ) throw new Error( `usd-gltf: Draco ${ name } doesn't match accessor ${ index }` );
147
+ out.set( index, read( draco, points * size, ArrayType, budget, ( length, ptr ) => decoder.GetAttributeDataArrayForAllPoints( geometry, attribute, draco[ DATA_TYPES[ accessor.componentType ] ], length, ptr ) ) );
148
+
149
+ }
150
+
151
+ if ( primitive.indices !== undefined && type === draco.TRIANGULAR_MESH ) {
152
+
153
+ const accessor = json.accessors[ primitive.indices ];
154
+ const count = geometry.num_faces() * 3;
155
+ if ( ! accessor || accessor.count !== count ) throw new Error( `usd-gltf: Draco indices don't match accessor ${ primitive.indices }` );
156
+ const indices = read( draco, count, Uint32Array, budget, ( length, ptr ) => decoder.GetTrianglesUInt32Array( geometry, length, ptr ) );
157
+ const IndexType = COMPONENT_ARRAYS[ accessor.componentType ] || Uint32Array;
158
+ out.set( primitive.indices, IndexType === Uint32Array ? indices : IndexType.from( indices ) );
159
+
160
+ }
161
+
162
+ } finally {
163
+
164
+ if ( geometry ) draco.destroy( geometry );
165
+ draco.destroy( decoder );
166
+
167
+ }
168
+
169
+ return out;
170
+
171
+ }
172
+
173
+ // Has the decoder fill a buffer on its heap, then copies it out.
174
+ function read( draco, count, ArrayType, budget, fill ) {
175
+
176
+ const length = count * ArrayType.BYTES_PER_ELEMENT;
177
+ budget.decode( length, 'draco' );
178
+ const ptr = draco._malloc( length );
179
+
180
+ try {
181
+
182
+ if ( ! fill( length, ptr ) ) throw new Error( 'usd-gltf: Draco attribute could not be read' );
183
+ return new ArrayType( draco.HEAPF32.buffer, ptr, count ).slice();
184
+
185
+ } finally {
186
+
187
+ draco._free( ptr );
188
+
189
+ }
190
+
191
+ }
package/src/gltf/glb.js CHANGED
@@ -68,13 +68,13 @@ export function readGLB( data ) {
68
68
 
69
69
  /**
70
70
  * Packs glTF JSON and an optional binary buffer into a GLB.
71
- * @param {object} json
71
+ * @param {object|Uint8Array} json the glTF JSON, or it already encoded as UTF-8
72
72
  * @param {Uint8Array|null} [bin]
73
73
  * @returns {Uint8Array}
74
74
  */
75
75
  export function writeGLB( json, bin = null ) {
76
76
 
77
- const jsonBytes = encoder.encode( JSON.stringify( json ) );
77
+ const jsonBytes = json instanceof Uint8Array ? json : encoder.encode( JSON.stringify( json ) );
78
78
  const jsonLength = pad4( jsonBytes.byteLength );
79
79
  const binLength = bin ? pad4( bin.byteLength ) : 0;
80
80
 
@@ -65,6 +65,54 @@ export function isGreyscaleImage( bytes ) {
65
65
 
66
66
  }
67
67
 
68
+ /**
69
+ * Width, height and bytes per pixel of a PNG, JPEG or WebP from its header, or null.
70
+ * @param {Uint8Array} bytes
71
+ * @returns {{ width: number, height: number, pixelBytes: number }|null}
72
+ */
73
+ export function imageSize( bytes ) {
74
+
75
+ bytes = toBytes( bytes );
76
+ const type = imageMimeType( bytes );
77
+ const be16 = ( o ) => ( bytes[ o ] << 8 ) | bytes[ o + 1 ];
78
+ const le16 = ( o ) => bytes[ o ] | ( bytes[ o + 1 ] << 8 );
79
+ const le24 = ( o ) => le16( o ) | ( bytes[ o + 2 ] << 16 );
80
+
81
+ if ( type === 'image/png' && bytes.length >= 26 ) {
82
+
83
+ const width = ( be16( 16 ) * 65536 ) + be16( 18 ), height = ( be16( 20 ) * 65536 ) + be16( 22 );
84
+ return { width, height, pixelBytes: bytes[ 24 ] === 16 ? 8 : 4 };
85
+
86
+ }
87
+
88
+ if ( type === 'image/jpeg' ) {
89
+
90
+ for ( let o = 2; o + 9 < bytes.length; ) {
91
+
92
+ if ( bytes[ o ] !== 0xFF ) return null;
93
+ const marker = bytes[ o + 1 ];
94
+ if ( marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC ) return { width: be16( o + 7 ), height: be16( o + 5 ), pixelBytes: 4 };
95
+ o += 2 + be16( o + 2 );
96
+
97
+ }
98
+
99
+ return null;
100
+
101
+ }
102
+
103
+ if ( type === 'image/webp' && bytes.length >= 30 ) {
104
+
105
+ const chunk = String.fromCharCode( bytes[ 12 ], bytes[ 13 ], bytes[ 14 ], bytes[ 15 ] );
106
+ if ( chunk === 'VP8X' ) return { width: le24( 24 ) + 1, height: le24( 27 ) + 1, pixelBytes: 4 };
107
+ if ( chunk === 'VP8 ' ) return { width: le16( 26 ) & 0x3FFF, height: le16( 28 ) & 0x3FFF, pixelBytes: 4 };
108
+ if ( chunk === 'VP8L' ) { const b = bytes[ 21 ] | ( bytes[ 22 ] << 8 ) | ( bytes[ 23 ] << 16 ) | ( bytes[ 24 ] << 24 ); return { width: ( b & 0x3FFF ) + 1, height: ( ( b >>> 14 ) & 0x3FFF ) + 1, pixelBytes: 4 }; }
109
+
110
+ }
111
+
112
+ return null;
113
+
114
+ }
115
+
68
116
  /**
69
117
  * Decodes a PNG, JPEG or WebP. The format is sniffed from the bytes; mimeType is only a fallback.
70
118
  * @param {Uint8Array|ArrayBuffer} bytes
@@ -141,10 +189,13 @@ export async function encodePNGAsync( image ) {
141
189
  * @param {Uint8Array} bytes
142
190
  * @param {string} [mimeType]
143
191
  * @param {( bytes: Uint8Array, mimeType?: string ) => Promise<object>|object} [decoder] overrides both
192
+ * @param {import('../limits.js').Budget} [budget] charged with the decoded size, from the header, before decoding
144
193
  */
145
- export async function decodeImageAsync( bytes, mimeType, decoder = null ) {
194
+ export async function decodeImageAsync( bytes, mimeType, decoder = null, budget = null ) {
146
195
 
147
196
  bytes = toBytes( bytes );
197
+ const size = budget && imageSize( bytes );
198
+ if ( size ) budget.decode( size.width * size.height * size.pixelBytes, 'image' );
148
199
  if ( decoder ) return decoder( bytes, mimeType );
149
200
  const type = imageMimeType( bytes ) || mimeType;
150
201
 
@@ -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 );