usd-gltf 0.2.0 → 0.3.1

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
@@ -37,7 +37,35 @@ USD to glTF composes the stage first (sublayers, references, payloads, inherits,
37
37
 
38
38
  glTF to USD writes `UsdPreviewSurface` materials, UsdSkel skins and blend shapes, time sampled animation, variant sets for material variants and multiple animations, cameras and lights. glTF features that `UsdPreviewSurface` can't express (transmission, sheen, iridescence, ...) are kept in the material's `customData`, so converting back to glTF is lossless. Primitives of a mesh that share their vertex data become one USD Mesh with a GeomSubset per primitive, and each Mesh keeps only the vertices it uses, so vertex data is never repeated per primitive.
39
39
 
40
- glTF compression: meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`) and `KHR_mesh_quantization` are decoded. Draco (`KHR_draco_mesh_compression`) and KTX2 textures (`KHR_texture_basisu`) are not: when an asset lists them in `extensionsRequired` the conversion fails with an error naming them, and when they are optional the uncompressed fallback data is used.
40
+ glTF compression: meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`), `KHR_mesh_quantization` and Draco (`KHR_draco_mesh_compression`, see below) are decoded. KTX2 textures (`KHR_texture_basisu`) are not: an asset that requires them fails with an error naming the extension, and when they are optional the PNG or JPEG fallback is used.
41
+
42
+ ### Draco
43
+
44
+ usd-gltf doesn't bundle a Draco decoder; it loads Google's only when a file is Draco compressed, and uses the uncompressed fallback without loading anything when a file has one.
45
+
46
+ - **Node:** install [`draco3d`](https://www.npmjs.com/package/draco3d) alongside, an optional peer dependency, and Draco files just work: `npm install draco3d`.
47
+ - **Browsers:** the decoder (about 250 KB) is fetched from Google's CDN, `www.gstatic.com/draco/versioned/decoders/1.5.7/`, pinned to that version and checked against its SHA-256 before it runs. This needs a secure context, and a Content Security Policy that allows the fetch, `unsafe-eval` and WebAssembly.
48
+ - **Your own:** pass a decoder module as `dracoDecoder` to use a self hosted copy, or where the CDN isn't allowed:
49
+
50
+ ```js
51
+ import draco3d from 'draco3d';
52
+ const usdz = await glbToUSD( glb, { dracoDecoder: await draco3d.createDecoderModule() } );
53
+ ```
54
+
55
+ The extension requires accessors to describe the decoded data exactly. When they don't, conversion rejects with a `DracoMismatchError`. Its `kind` says what disagrees: `'count'`, `'index-count'`, `'components'`, `'component-type'`, `'attribute'` or `'shared-accessor'`. Its `mesh`, `primitive`, `semantic`, `accessor`, `declared` and `decoded` fields say where and by how much.
56
+
57
+ Some files declare stale counts, for example from metadata written before compression changed the vertex layout. `recoverDracoCounts: true` converts those using the decoded counts and reports each one through `log`. It only does so when everything else that depends on those counts can be checked against the decoded data:
58
+
59
+ - the accessors are used only by Draco primitives decoding the same data;
60
+ - the primitive's uncompressed attributes and morph targets have the decoded vertex count;
61
+ - the accessors aren't sparse;
62
+ - the decoded positions match `POSITION`'s `min` and `max`.
63
+
64
+ Otherwise it still rejects, with `unrecoverable` saying why. Other mismatches, such as a different number of components, are never recovered. The decoder's output is sized by the decoded data, never by the declared counts, and counts against the limits below either way.
65
+
66
+ ```js
67
+ const usdz = await glbToUSD( glb, { recoverDracoCounts: true, log: console.warn } );
68
+ ```
41
69
 
42
70
  ## Limits
43
71
 
@@ -62,7 +90,7 @@ Going over a limit rejects with a `ConversionLimitError` whose `stage`, `resourc
62
90
  Current status:
63
91
 
64
92
  - All 18 USD files in Apple's AR Quick Look gallery convert to GLB. Per mesh triangle counts and world space bounds at the start, middle and end of each animation match OpenUSD with skinning baked, and the Khronos glTF Validator reports no errors. (The gallery's three `.reality` files are RealityKit's format, not USD.)
65
- - All 150 Khronos glTF sample models convert to USDZ that OpenUSD's validators accept with no errors or warnings, with geometry matching the source at several animation times, and convert back to glTF with identical materials, material variants and animations. Each model is checked as `.gltf`, and also as `.glb` (120 models) and embedded `.gltf` (32 models) where the sample set has those versions: 302 conversions in all.
93
+ - All 150 Khronos glTF sample models convert to USDZ that OpenUSD's validators accept with no errors or warnings, with geometry matching the source at several animation times, and convert back to glTF with identical materials, material variants and animations. Each model is checked as `.gltf`, and also as `.glb` (120 models), embedded `.gltf` (32 models) and Draco compressed (16 models) where the sample set has those versions: 318 conversions in all.
66
94
 
67
95
  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.
68
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usd-gltf",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -35,7 +35,8 @@
35
35
  "gltf",
36
36
  "glb",
37
37
  "converter",
38
- "3d"
38
+ "3d",
39
+ "draco"
39
40
  ],
40
41
  "author": "Richard Monette",
41
42
  "license": "MIT",
@@ -47,7 +48,16 @@
47
48
  "url": "https://github.com/richardmonette/usd-gltf/issues"
48
49
  },
49
50
  "homepage": "https://github.com/richardmonette/usd-gltf#readme",
51
+ "peerDependencies": {
52
+ "draco3d": "^1.5.7"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "draco3d": {
56
+ "optional": true
57
+ }
58
+ },
50
59
  "devDependencies": {
60
+ "draco3d": "^1.5.7",
51
61
  "gltf-validator": "^2.0.0-dev.3.10",
52
62
  "meshoptimizer": "^1.3.0"
53
63
  },
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readGLB, writeGLB } from './glb.js';
4
4
  import { decodeMeshoptView } from './meshopt.js';
5
+ import { decodeDracoPrimitive, loadDracoDecoder, needsDraco, resolveDracoAccessors } from './draco.js';
5
6
  import { Budget, checkSize } from '../limits.js';
6
7
 
7
8
  const COMPONENT_ARRAYS = {
@@ -18,13 +19,13 @@ export const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3:
18
19
  // Extensions an asset may require. Material extensions without a UsdPreviewSurface equivalent are
19
20
  // approximated and kept on the USD material for the way back, so every KHR_materials_* is accepted.
20
21
  const SUPPORTED_EXTENSIONS = new Set( [
21
- 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization',
22
+ 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization', 'KHR_draco_mesh_compression',
22
23
  'EXT_texture_webp', 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_animation_pointer', 'KHR_lights_punctual',
23
24
  'KHR_node_visibility', 'KHR_xmp_json_ld',
24
25
  ] );
25
26
 
26
27
  /**
27
- * The asset's required extensions that can't be converted (e.g. KHR_draco_mesh_compression, KHR_texture_basisu).
28
+ * The asset's required extensions that can't be converted (e.g. KHR_texture_basisu).
28
29
  * @param {object} json
29
30
  * @returns {string[]}
30
31
  */
@@ -34,24 +35,6 @@ export function unsupportedExtensions( json ) {
34
35
 
35
36
  }
36
37
 
37
- // Optional Draco needs uncompressed fallback data: without a bufferView or sparse values the
38
- // accessors would read as zeros.
39
- function checkDracoFallbacks( json ) {
40
-
41
- ( json.meshes || [] ).forEach( ( mesh, m ) => mesh.primitives.forEach( ( primitive, p ) => {
42
-
43
- if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return;
44
- const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
45
- if ( used.some( ( a ) => json.accessors[ a ].bufferView === undefined && ! json.accessors[ a ].sparse ) ) {
46
-
47
- throw new Error( `usd-gltf: mesh ${ m } primitive ${ p } is Draco compressed with no uncompressed fallback; KHR_draco_mesh_compression is not supported` );
48
-
49
- }
50
-
51
- } ) );
52
-
53
- }
54
-
55
38
  function decodeDataURI( uri ) {
56
39
 
57
40
  const comma = uri.indexOf( ',' );
@@ -84,6 +67,7 @@ export class GLTFDocument {
84
67
  this.budget = budget;
85
68
  this.accessorCache = new Map();
86
69
  this.decodedViews = new Map(); // bufferView index -> bytes, for compressed views
70
+ this.decodedAccessors = new Map(); // accessor index -> data, for Draco compressed primitives
87
71
 
88
72
  }
89
73
 
@@ -139,8 +123,9 @@ export class GLTFDocument {
139
123
 
140
124
  }
141
125
 
142
- this.budget.decode( size, stage );
143
- let array = new ArrayType( count * itemSize );
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 );
144
129
 
145
130
  if ( read ) {
146
131
 
@@ -230,9 +215,11 @@ export class GLTFDocument {
230
215
  /**
231
216
  * Loads a glTF or GLB.
232
217
  * @param {Uint8Array} bytes
233
- * @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>, recoverDracoCounts?: boolean, log?: ( message: string ) => void }} [options]
219
+ * resolver loads external uris (already decoded from URI escaping); dracoDecoder is a Draco decoder module, loaded when needed if not given;
220
+ * recoverDracoCounts uses decoded Draco counts that disagree with their accessors when that can be checked, and logs it
234
221
  */
235
- export async function readGLTF( bytes, { resolver = null, budget = new Budget() } = {} ) {
222
+ export async function readGLTF( bytes, { resolver = null, budget = new Budget(), dracoDecoder = null, recoverDracoCounts = false, log = () => {} } = {} ) {
236
223
 
237
224
  let json, bin = null;
238
225
  bytes = plainBytes( bytes );
@@ -242,7 +229,6 @@ export async function readGLTF( bytes, { resolver = null, budget = new Budget()
242
229
 
243
230
  const unsupported = unsupportedExtensions( json );
244
231
  if ( unsupported.length ) throw new Error( `usd-gltf: required extension${ unsupported.length > 1 ? 's' : '' } ${ unsupported.join( ', ' ) } not supported` );
245
- checkDracoFallbacks( json );
246
232
 
247
233
  async function load( uri ) {
248
234
 
@@ -283,6 +269,23 @@ export async function readGLTF( bytes, { resolver = null, budget = new Budget()
283
269
 
284
270
  } );
285
271
 
272
+ // Draco primitives without uncompressed fallback data
273
+ const draco = ( json.meshes || [] ).flatMap( ( mesh, m ) => ( mesh.primitives || [] ).map( ( primitive, index ) => ( { mesh: m, index, primitive } ) ) ).filter( ( p ) => needsDraco( json, p.primitive ) );
274
+
275
+ if ( draco.length ) {
276
+
277
+ const decoder = await ( dracoDecoder || loadDracoDecoder() );
278
+ const decoded = draco.map( ( where ) => {
279
+
280
+ budget.check();
281
+ const bytes = doc.bufferViewBytes( where.primitive.extensions.KHR_draco_mesh_compression.bufferView );
282
+ return { ...where, ...decodeDracoPrimitive( decoder, bytes, json, where, budget ) };
283
+
284
+ } );
285
+ doc.decodedAccessors = resolveDracoAccessors( json, decoded, { recoverCounts: recoverDracoCounts, log } );
286
+
287
+ }
288
+
286
289
  doc.images = await Promise.all( ( json.images || [] ).map( async ( image ) => {
287
290
 
288
291
  if ( image.bufferView !== undefined ) return { bytes: doc.bufferViewBytes( image.bufferView ), mimeType: image.mimeType, name: image.name, uri: null };
@@ -0,0 +1,405 @@
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 INDEX_ARRAYS = { 5121: Uint8Array, 5123: Uint16Array, 5125: Uint32Array };
16
+ const INDEX_MAX = { 5121: 0xFF, 5123: 0xFFFF, 5125: 0xFFFFFFFF };
17
+ const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
18
+
19
+ let defaultDecoder = null;
20
+
21
+ /**
22
+ * The default Draco decoder module: draco3d in Node, Google's CDN in browsers. Loaded once.
23
+ * @returns {Promise<object>}
24
+ */
25
+ export function loadDracoDecoder() {
26
+
27
+ defaultDecoder ||= ( isNode() ? loadNode() : loadDracoFromCDN() ).catch( ( e ) => {
28
+
29
+ defaultDecoder = null; // let a later conversion try again
30
+ throw e;
31
+
32
+ } );
33
+
34
+ return defaultDecoder;
35
+
36
+ }
37
+
38
+ function isNode() {
39
+
40
+ return typeof process !== 'undefined' && !! process.versions?.node;
41
+
42
+ }
43
+
44
+ async function loadNode() {
45
+
46
+ const name = 'draco3d'; // not a literal, so bundlers don't try to include it
47
+ let draco3d;
48
+
49
+ try {
50
+
51
+ draco3d = await import( /* webpackIgnore: true */ /* @vite-ignore */ name );
52
+
53
+ } catch {
54
+
55
+ throw new Error( 'usd-gltf: this glTF is Draco compressed; install the decoder with npm install draco3d, or pass a dracoDecoder' );
56
+
57
+ }
58
+
59
+ return ( draco3d.default ?? draco3d ).createDecoderModule();
60
+
61
+ }
62
+
63
+ // Exported for tests; loadDracoDecoder picks the source.
64
+ export async function loadDracoFromCDN() {
65
+
66
+ 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' );
67
+ const [ wrapper, wasm ] = await Promise.all( Object.keys( CDN_FILES ).map( fetchVerified ) );
68
+ // the wrapper is a classic script defining DracoDecoderModule, so it can't be imported as a module
69
+ const factory = new Function( new TextDecoder().decode( wrapper ) + '\nreturn DracoDecoderModule;' )();
70
+ return factory( { wasmBinary: wasm.buffer } );
71
+
72
+ }
73
+
74
+ async function fetchVerified( name ) {
75
+
76
+ const response = await fetch( CDN + name );
77
+ if ( ! response.ok ) throw new Error( `usd-gltf: could not load the Draco decoder (${ response.status } for ${ CDN + name })` );
78
+ const bytes = new Uint8Array( await response.arrayBuffer() );
79
+ const digest = new Uint8Array( await crypto.subtle.digest( 'SHA-256', bytes ) );
80
+ if ( btoa( String.fromCharCode( ...digest ) ) !== CDN_FILES[ name ] ) throw new Error( `usd-gltf: ${ CDN + name } doesn't match its expected hash` );
81
+ return bytes;
82
+
83
+ }
84
+
85
+ /**
86
+ * Whether a primitive's data is only in its Draco extension: Draco is decoded only when the
87
+ * accessors have no uncompressed fallback.
88
+ */
89
+ export function needsDraco( json, primitive ) {
90
+
91
+ if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return false;
92
+ const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
93
+ return used.some( ( a ) => json.accessors[ a ]?.bufferView === undefined && ! json.accessors[ a ]?.sparse );
94
+
95
+ }
96
+
97
+ /**
98
+ * Thrown when decoded Draco data doesn't match the accessors describing it, which the extension
99
+ * requires. kind says what disagrees:
100
+ * - 'count': an attribute decodes to a different number of elements than its accessor's count
101
+ * - 'index-count': the decoded triangles need a different number of indices than declared
102
+ * - 'components': an attribute decodes to a different number of components than its type has
103
+ * - 'component-type': the accessor's componentType can't hold the decoded values
104
+ * - 'attribute': the Draco data has no attribute with the extension's id
105
+ * - 'shared-accessor': one accessor is decoded more than once, to different data
106
+ * With recoverDracoCounts, count mismatches that can be checked are recovered instead; when one
107
+ * can't be, unrecoverable says why.
108
+ */
109
+ export class DracoMismatchError extends Error {
110
+
111
+ constructor( { kind, mesh, primitive, semantic, accessor, declared, decoded, unrecoverable = null } ) {
112
+
113
+ const what = {
114
+ 'count': () => `${ semantic } decodes to ${ decoded } elements, but accessor ${ accessor } declares ${ declared }`,
115
+ 'index-count': () => `the triangles decode to ${ decoded } indices, but accessor ${ accessor } declares ${ declared }`,
116
+ 'components': () => `${ semantic } decodes to ${ decoded } components, but accessor ${ accessor } is ${ declared }`,
117
+ 'component-type': () => `${ semantic } decodes to values accessor ${ accessor } can't hold as componentType ${ declared }`,
118
+ 'attribute': () => `there is no ${ semantic } attribute with id ${ declared }`,
119
+ 'shared-accessor': () => `accessor ${ accessor } (${ semantic }) is decoded more than once, to different data`,
120
+ }[ kind ]();
121
+ const counts = kind === 'count' || kind === 'index-count';
122
+ const hint = unrecoverable ? `; not recovered, as ${ unrecoverable }` : counts ? '; the recoverDracoCounts option can use the decoded count' : '';
123
+ super( `usd-gltf: Draco mesh ${ mesh } primitive ${ primitive }: ${ what }${ hint }` );
124
+ this.name = 'DracoMismatchError';
125
+ this.code = 'USD_GLTF_DRACO_MISMATCH';
126
+ Object.assign( this, { kind, mesh, primitive, semantic, accessor, declared, decoded, unrecoverable } );
127
+
128
+ }
129
+
130
+ }
131
+
132
+ /**
133
+ * Decodes one Draco compressed primitive. Allocations are sized by the decoded data, never by
134
+ * the declared accessor counts, which resolveDracoAccessors checks afterwards.
135
+ * @param {object} draco decoder module
136
+ * @param {Uint8Array} bytes the extension's bufferView
137
+ * @param {object} json
138
+ * @param {{ mesh: number, index: number, primitive: object }} where the primitive and its place in the document
139
+ * @param {import('../limits.js').Budget} budget
140
+ * @returns {{ points: number, indexCount: number|null, data: Map<number, ArrayLike<number>> }} data is accessor index -> values, in the accessor's component type
141
+ */
142
+ export function decodeDracoPrimitive( draco, bytes, json, { mesh, index: primitiveIndex, primitive }, budget ) {
143
+
144
+ const ext = primitive.extensions.KHR_draco_mesh_compression;
145
+ const decoder = new draco.Decoder();
146
+ const array = new Int8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength );
147
+ const mismatch = ( info ) => new DracoMismatchError( { mesh, primitive: primitiveIndex, ...info } );
148
+ const data = new Map();
149
+ let geometry = null, points = 0, indexCount = null;
150
+
151
+ const put = ( semantic, accessor, values ) => {
152
+
153
+ if ( data.has( accessor ) && ! sameValues( data.get( accessor ), values ) ) throw mismatch( { kind: 'shared-accessor', semantic, accessor } );
154
+ data.set( accessor, values );
155
+
156
+ };
157
+
158
+ try {
159
+
160
+ const type = decoder.GetEncodedGeometryType( array );
161
+ let status;
162
+
163
+ if ( type === draco.TRIANGULAR_MESH ) {
164
+
165
+ geometry = new draco.Mesh();
166
+ status = decoder.DecodeArrayToMesh( array, array.byteLength, geometry );
167
+
168
+ } else if ( type === draco.POINT_CLOUD ) {
169
+
170
+ geometry = new draco.PointCloud();
171
+ status = decoder.DecodeArrayToPointCloud( array, array.byteLength, geometry );
172
+
173
+ } else {
174
+
175
+ throw new Error( 'usd-gltf: Draco data is neither a mesh nor a point cloud' );
176
+
177
+ }
178
+
179
+ if ( ! status.ok() || geometry.ptr === 0 ) throw new Error( `usd-gltf: Draco decoding failed: ${ status.error_msg() }` );
180
+
181
+ points = geometry.num_points();
182
+
183
+ for ( const [ semantic, id ] of Object.entries( ext.attributes ) ) {
184
+
185
+ const index = primitive.attributes[ semantic ];
186
+ const accessor = json.accessors[ index ];
187
+ if ( accessor === undefined ) continue;
188
+ const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
189
+ const size = TYPE_SIZES[ accessor.type ];
190
+ if ( ! size ) throw new Error( `usd-gltf: accessor ${ index } has an invalid type` );
191
+ if ( ! ArrayType ) throw mismatch( { kind: 'component-type', semantic, accessor: index, declared: accessor.componentType } );
192
+ const attribute = decoder.GetAttributeByUniqueId( geometry, id );
193
+ if ( ! attribute || attribute.ptr === 0 ) throw mismatch( { kind: 'attribute', semantic, accessor: index, declared: id } );
194
+ if ( attribute.num_components() !== size ) throw mismatch( { kind: 'components', semantic, accessor: index, declared: accessor.type, decoded: attribute.num_components() } );
195
+ put( semantic, index, read( draco, points * size, ArrayType, budget, ( length, ptr ) => decoder.GetAttributeDataArrayForAllPoints( geometry, attribute, draco[ DATA_TYPES[ accessor.componentType ] ], length, ptr ) ) );
196
+
197
+ }
198
+
199
+ if ( primitive.indices !== undefined && type === draco.TRIANGULAR_MESH ) {
200
+
201
+ const accessor = json.accessors[ primitive.indices ];
202
+ if ( ! accessor ) throw new Error( `usd-gltf: accessor ${ primitive.indices } doesn't exist` );
203
+ indexCount = geometry.num_faces() * 3;
204
+ const indices = read( draco, indexCount, Uint32Array, budget, ( length, ptr ) => decoder.GetTrianglesUInt32Array( geometry, length, ptr ) );
205
+ let max = - 1;
206
+ for ( let i = 0; i < indices.length; i ++ ) if ( indices[ i ] > max ) max = indices[ i ];
207
+ if ( max >= points ) throw new Error( 'usd-gltf: Draco indices are out of range' );
208
+ const IndexType = INDEX_ARRAYS[ accessor.componentType ];
209
+ if ( ! IndexType || max > INDEX_MAX[ accessor.componentType ] ) throw mismatch( { kind: 'component-type', semantic: 'indices', accessor: primitive.indices, declared: accessor.componentType } );
210
+ put( 'indices', primitive.indices, IndexType === Uint32Array ? indices : IndexType.from( indices ) );
211
+
212
+ }
213
+
214
+ } finally {
215
+
216
+ if ( geometry ) draco.destroy( geometry );
217
+ draco.destroy( decoder );
218
+
219
+ }
220
+
221
+ return { points, indexCount, data };
222
+
223
+ }
224
+
225
+ /**
226
+ * Checks decoded primitives against their accessors and returns the accessor data. Count
227
+ * mismatches are errors, unless recoverCounts is set and everything else that depends on those
228
+ * counts agrees with the decoded data: then the accessors take the decoded counts, and log says so.
229
+ * @param {object} json changed only by recovered counts
230
+ * @param {Array<{ mesh: number, index: number, primitive: object, points: number, indexCount: number|null, data: Map<number, ArrayLike<number>> }>} decoded
231
+ * @param {{ recoverCounts?: boolean, log?: ( message: string ) => void }} [options]
232
+ * @returns {Map<number, ArrayLike<number>>}
233
+ */
234
+ export function resolveDracoAccessors( json, decoded, { recoverCounts = false, log = () => {} } = {} ) {
235
+
236
+ const out = new Map();
237
+
238
+ for ( const d of decoded ) for ( const [ index, values ] of d.data ) {
239
+
240
+ if ( out.has( index ) && ! sameValues( out.get( index ), values ) ) throw new DracoMismatchError( { kind: 'shared-accessor', mesh: d.mesh, primitive: d.index, semantic: semanticOf( d.primitive, index ), accessor: index } );
241
+ out.set( index, values );
242
+
243
+ }
244
+
245
+ const uses = recoverCounts ? accessorUses( json ) : null;
246
+ const recovered = new Map(); // accessor -> decoded count
247
+
248
+ for ( const d of decoded ) {
249
+
250
+ const { primitive } = d;
251
+ const mismatches = [];
252
+
253
+ for ( const semantic of Object.keys( primitive.extensions.KHR_draco_mesh_compression.attributes ) ) {
254
+
255
+ const index = primitive.attributes[ semantic ];
256
+ const declared = json.accessors[ index ]?.count;
257
+ if ( index !== undefined && declared !== d.points ) mismatches.push( { kind: 'count', semantic, accessor: index, declared, decoded: d.points } );
258
+
259
+ }
260
+
261
+ const declaredIndices = d.indexCount !== null ? json.accessors[ primitive.indices ].count : null;
262
+ if ( declaredIndices !== d.indexCount ) mismatches.push( { kind: 'index-count', semantic: 'indices', accessor: primitive.indices, declared: declaredIndices, decoded: d.indexCount } );
263
+
264
+ if ( ! mismatches.length ) continue;
265
+ const fail = ( unrecoverable ) => new DracoMismatchError( { mesh: d.mesh, primitive: d.index, ...mismatches[ 0 ], unrecoverable } );
266
+ if ( ! recoverCounts ) throw fail( null );
267
+ const reason = unrecoverable( json, d, mismatches, uses, out );
268
+ if ( reason ) throw fail( reason );
269
+
270
+ for ( const m of mismatches ) recovered.set( m.accessor, m.decoded );
271
+ log( `Draco mesh ${ d.mesh } primitive ${ d.index }: using the decoded counts, ${ mismatches.map( ( m ) => `${ m.semantic } ${ m.decoded } where accessor ${ m.accessor } declares ${ m.declared }` ).join( ', ' ) }` );
272
+
273
+ }
274
+
275
+ for ( const [ index, count ] of recovered ) json.accessors[ index ].count = count;
276
+ return out;
277
+
278
+ }
279
+
280
+ // Why a primitive's mismatched counts can't be replaced by the decoded ones, or null when they can.
281
+ function unrecoverable( json, d, mismatches, uses, decodedData ) {
282
+
283
+ const { primitive, points } = d;
284
+ const compressed = primitive.extensions.KHR_draco_mesh_compression.attributes;
285
+
286
+ for ( const m of mismatches ) {
287
+
288
+ if ( json.accessors[ m.accessor ].sparse ) return `accessor ${ m.accessor } is sparse`;
289
+ // other Draco primitives decoding the same data are checked on their own
290
+ const other = uses.get( m.accessor ).find( ( use ) => ! use.draco );
291
+ if ( other ) return `accessor ${ m.accessor } is also used by ${ other.what }`;
292
+
293
+ }
294
+
295
+ for ( const [ semantic, index ] of Object.entries( primitive.attributes ) ) {
296
+
297
+ const count = json.accessors[ index ]?.count;
298
+ if ( ! ( semantic in compressed ) && count !== points ) return `${ semantic } isn't Draco compressed and has ${ count } elements, not ${ points }`;
299
+
300
+ }
301
+
302
+ for ( const [ t, target ] of ( primitive.targets || [] ).entries() ) {
303
+
304
+ for ( const [ semantic, index ] of Object.entries( target ) ) {
305
+
306
+ const count = json.accessors[ index ]?.count;
307
+ if ( count !== points ) return `morph target ${ t } ${ semantic } has ${ count } elements, not ${ points }`;
308
+
309
+ }
310
+
311
+ }
312
+
313
+ const position = primitive.attributes.POSITION;
314
+
315
+ if ( mismatches.some( ( m ) => m.accessor === position ) ) {
316
+
317
+ // the bounds must still describe the decoded positions, allowing for 8 bits of Draco quantization over the largest extent
318
+ const { min, max } = json.accessors[ position ];
319
+ if ( ! ( min?.length === 3 && max?.length === 3 ) ) return `POSITION accessor ${ position } has no min and max to check the decoded positions against`;
320
+ const values = decodedData.get( position );
321
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ - Infinity, - Infinity, - Infinity ];
322
+
323
+ for ( let i = 0; i < values.length; i += 3 ) for ( let c = 0; c < 3; c ++ ) {
324
+
325
+ lo[ c ] = Math.min( lo[ c ], values[ i + c ] );
326
+ hi[ c ] = Math.max( hi[ c ], values[ i + c ] );
327
+
328
+ }
329
+
330
+ const tolerance = Math.max( max[ 0 ] - min[ 0 ], max[ 1 ] - min[ 1 ], max[ 2 ] - min[ 2 ] ) / 256 + 1e-6;
331
+ for ( let c = 0; c < 3; c ++ ) if ( ! ( Math.abs( lo[ c ] - min[ c ] ) <= tolerance && Math.abs( hi[ c ] - max[ c ] ) <= tolerance ) ) return `the decoded positions don't match POSITION accessor ${ position }'s min and max`;
332
+
333
+ }
334
+
335
+ return null;
336
+
337
+ }
338
+
339
+ // Every reference to each accessor, marking those a Draco extension provides.
340
+ function accessorUses( json ) {
341
+
342
+ const uses = new Map();
343
+ const add = ( accessor, what, draco ) => { if ( accessor !== undefined ) uses.set( accessor, [ ...( uses.get( accessor ) || [] ), { what, draco } ] ); };
344
+
345
+ ( json.meshes || [] ).forEach( ( mesh, m ) => ( mesh.primitives || [] ).forEach( ( primitive, p ) => {
346
+
347
+ const draco = primitive.extensions?.KHR_draco_mesh_compression;
348
+ const where = `mesh ${ m } primitive ${ p }`;
349
+ for ( const [ semantic, a ] of Object.entries( primitive.attributes || {} ) ) add( a, `${ where } ${ semantic }`, !! draco && semantic in draco.attributes );
350
+ add( primitive.indices, `${ where } indices`, !! draco );
351
+ ( primitive.targets || [] ).forEach( ( target, t ) => { for ( const [ semantic, a ] of Object.entries( target ) ) add( a, `${ where } morph target ${ t } ${ semantic }`, false ); } );
352
+
353
+ } ) );
354
+
355
+ ( json.skins || [] ).forEach( ( skin, s ) => add( skin.inverseBindMatrices, `skin ${ s }`, false ) );
356
+ ( json.animations || [] ).forEach( ( animation, a ) => ( animation.samplers || [] ).forEach( ( sampler, s ) => {
357
+
358
+ add( sampler.input, `animation ${ a } sampler ${ s }`, false );
359
+ add( sampler.output, `animation ${ a } sampler ${ s }`, false );
360
+
361
+ } ) );
362
+ ( json.nodes || [] ).forEach( ( node, n ) => {
363
+
364
+ for ( const a of Object.values( node.extensions?.EXT_mesh_gpu_instancing?.attributes || {} ) ) add( a, `node ${ n } instancing`, false );
365
+
366
+ } );
367
+
368
+ return uses;
369
+
370
+ }
371
+
372
+ function semanticOf( primitive, accessor ) {
373
+
374
+ if ( primitive.indices === accessor ) return 'indices';
375
+ return Object.keys( primitive.attributes ).find( ( s ) => primitive.attributes[ s ] === accessor );
376
+
377
+ }
378
+
379
+ function sameValues( a, b ) {
380
+
381
+ if ( a.length !== b.length || a.constructor !== b.constructor ) return false;
382
+ for ( let i = 0; i < a.length; i ++ ) if ( ! Object.is( a[ i ], b[ i ] ) ) return false;
383
+ return true;
384
+
385
+ }
386
+
387
+ // Has the decoder fill a buffer on its heap, then copies it out.
388
+ function read( draco, count, ArrayType, budget, fill ) {
389
+
390
+ const length = count * ArrayType.BYTES_PER_ELEMENT;
391
+ budget.decode( length, 'draco' );
392
+ const ptr = draco._malloc( length );
393
+
394
+ try {
395
+
396
+ if ( ! fill( length, ptr ) ) throw new Error( 'usd-gltf: Draco attribute could not be read' );
397
+ return new ArrayType( draco.HEAPF32.buffer, ptr, count ).slice();
398
+
399
+ } finally {
400
+
401
+ draco._free( ptr );
402
+
403
+ }
404
+
405
+ }
package/src/index.d.ts CHANGED
@@ -34,6 +34,34 @@ export class ConversionLimitError extends Error {
34
34
  attempted: number;
35
35
  }
36
36
 
37
+ /**
38
+ * Thrown when decoded Draco data doesn't match the accessors describing it. With recoverDracoCounts,
39
+ * count mismatches that can't be checked still throw, with unrecoverable saying why.
40
+ */
41
+ export class DracoMismatchError extends Error {
42
+ name: 'DracoMismatchError';
43
+ code: 'USD_GLTF_DRACO_MISMATCH';
44
+ /**
45
+ * count / index-count: a different number of elements or indices than declared;
46
+ * components: a different number of components than the accessor type has;
47
+ * component-type: values the accessor's componentType can't hold;
48
+ * attribute: no Draco attribute with the extension's id;
49
+ * shared-accessor: one accessor decoded more than once to different data.
50
+ */
51
+ kind: 'count' | 'index-count' | 'components' | 'component-type' | 'attribute' | 'shared-accessor';
52
+ mesh: number;
53
+ primitive: number;
54
+ /** The attribute semantic, e.g. 'POSITION', or 'indices'. */
55
+ semantic: string;
56
+ accessor: number;
57
+ /** What the accessor declares: its count, type, componentType, or the Draco attribute id. */
58
+ declared?: number | string;
59
+ /** What the Draco data has: a count of elements, indices or components. */
60
+ decoded?: number;
61
+ /** With recoverDracoCounts, why the decoded counts couldn't be used. */
62
+ unrecoverable: string | null;
63
+ }
64
+
37
65
  export interface USDToGLBOptions extends ConversionLimits {
38
66
  /** Name of the input file, used to resolve relative paths. */
39
67
  identifier?: string;
@@ -53,7 +81,19 @@ export interface GLBToUSDOptions extends ConversionLimits {
53
81
  format?: 'usdz' | 'usda' | 'usdc';
54
82
  /** Needed for .gltf files with external buffers or images. */
55
83
  resolver?: Resolver;
84
+ /**
85
+ * A Draco decoder module (from draco3d's createDecoderModule, or Google's decoder build), or a
86
+ * promise of one. Without it, Draco files load draco3d in Node and Google's CDN build in browsers.
87
+ */
88
+ dracoDecoder?: object | Promise<object>;
89
+ /**
90
+ * Use decoded Draco counts that disagree with their accessors' counts, which the extension
91
+ * doesn't allow, when everything else depending on them can be checked against the decoded data.
92
+ * Each recovery is reported through log. Default false: such files throw a DracoMismatchError.
93
+ */
94
+ recoverDracoCounts?: boolean;
56
95
  imageDecoder?: ImageDecoder;
96
+ /** Receives non fatal problems, such as textures that could not be read or recovered Draco counts. */
57
97
  log?: ( message: string ) => void;
58
98
  }
59
99
 
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ export { writeUSDA } from './usd/usda/writer.js';
15
15
  export { readUSDC } from './usd/usdc/reader.js';
16
16
  export { writeUSDC } from './usd/usdc/writer.js';
17
17
  export { ConversionLimitError } from './limits.js';
18
+ export { DracoMismatchError } from './gltf/draco.js';
18
19
 
19
20
  function toBytes( data ) {
20
21
 
@@ -50,13 +51,13 @@ export async function usdToGLB( data, options = {} ) {
50
51
  /**
51
52
  * Converts GLB (or glTF JSON with embedded or resolvable buffers) to USD.
52
53
  * @param {ArrayBuffer|Uint8Array} data
53
- * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
54
+ * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, dracoDecoder?: object, recoverDracoCounts?: boolean, log?: ( message: string ) => void, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
54
55
  * @returns {Promise<Uint8Array>}
55
56
  */
56
- export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, ...limits } = {} ) {
57
+ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, dracoDecoder, recoverDracoCounts, ...limits } = {} ) {
57
58
 
58
59
  const budget = budgetFor( limits );
59
- const doc = await readGLTF( toBytes( data ), { resolver, budget } );
60
+ const doc = await readGLTF( toBytes( data ), { resolver, budget, dracoDecoder, recoverDracoCounts, log } );
60
61
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
61
62
  let out;
62
63
 
@@ -73,10 +74,10 @@ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, i
73
74
  * For usda / usdc output the textures are separate files; this returns them alongside the layer.
74
75
  * @returns {Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>}
75
76
  */
76
- export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, ...limits } = {} ) {
77
+ export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, dracoDecoder, recoverDracoCounts, ...limits } = {} ) {
77
78
 
78
79
  const budget = budgetFor( limits );
79
- const doc = await readGLTF( toBytes( data ), { resolver, budget } );
80
+ const doc = await readGLTF( toBytes( data ), { resolver, budget, dracoDecoder, recoverDracoCounts, log } );
80
81
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
81
82
  const bytes = format === 'usdc' ? writeUSDC( layer, { budget } ) : new TextEncoder().encode( writeUSDA( layer, { budget } ) );
82
83
  budget.output( files.reduce( ( n, f ) => n + f.data.byteLength, bytes.byteLength ), 'usd files' );