@driftengine/drft 3.61.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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +9 -0
  4. package/dist/animationData.d.ts +53 -0
  5. package/dist/animationData.js +13 -0
  6. package/dist/coarseFirst.d.ts +27 -0
  7. package/dist/coarseFirst.js +118 -0
  8. package/dist/drftColliders.d.ts +53 -0
  9. package/dist/drftColliders.js +137 -0
  10. package/dist/drftFormat.d.ts +454 -0
  11. package/dist/drftFormat.js +350 -0
  12. package/dist/drftRead.d.ts +121 -0
  13. package/dist/drftRead.js +487 -0
  14. package/dist/drftSkin.d.ts +40 -0
  15. package/dist/drftSkin.js +270 -0
  16. package/dist/drftStream.d.ts +170 -0
  17. package/dist/drftStream.js +315 -0
  18. package/dist/drftSubs.d.ts +18 -0
  19. package/dist/drftSubs.js +70 -0
  20. package/dist/drftWrite.d.ts +103 -0
  21. package/dist/drftWrite.js +478 -0
  22. package/dist/fixtures/v1-0.d.ts +9 -0
  23. package/dist/fixtures/v1-0.js +9 -0
  24. package/dist/fixtures/v1-1.d.ts +16 -0
  25. package/dist/fixtures/v1-1.js +16 -0
  26. package/dist/fixtures/v1-11.d.ts +14 -0
  27. package/dist/fixtures/v1-11.js +14 -0
  28. package/dist/fixtures/v1-2.d.ts +19 -0
  29. package/dist/fixtures/v1-2.js +19 -0
  30. package/dist/fixtures/v1-3.d.ts +18 -0
  31. package/dist/fixtures/v1-3.js +18 -0
  32. package/dist/fixtures/v1-4.d.ts +13 -0
  33. package/dist/fixtures/v1-4.js +13 -0
  34. package/dist/fixtures/v1-5.d.ts +14 -0
  35. package/dist/fixtures/v1-5.js +14 -0
  36. package/dist/fixtures/v1-6.d.ts +9 -0
  37. package/dist/fixtures/v1-6.js +9 -0
  38. package/dist/fixtures/v1-7.d.ts +9 -0
  39. package/dist/fixtures/v1-7.js +9 -0
  40. package/dist/fixtures/v1-9.d.ts +9 -0
  41. package/dist/fixtures/v1-9.js +9 -0
  42. package/dist/index.d.ts +26 -0
  43. package/dist/index.js +29 -0
  44. package/dist/meshData.d.ts +210 -0
  45. package/dist/meshData.js +105 -0
  46. package/package.json +57 -0
  47. package/src/animationData.ts +58 -0
  48. package/src/coarseFirst.ts +113 -0
  49. package/src/drftColliders.ts +153 -0
  50. package/src/drftFormat.ts +537 -0
  51. package/src/drftRead.ts +652 -0
  52. package/src/drftSkin.ts +326 -0
  53. package/src/drftStream.ts +436 -0
  54. package/src/drftSubs.ts +86 -0
  55. package/src/drftWrite.ts +613 -0
  56. package/src/fixtures/v1-0.ts +10 -0
  57. package/src/fixtures/v1-1.ts +17 -0
  58. package/src/fixtures/v1-11.ts +15 -0
  59. package/src/fixtures/v1-2.ts +20 -0
  60. package/src/fixtures/v1-3.ts +19 -0
  61. package/src/fixtures/v1-4.ts +14 -0
  62. package/src/fixtures/v1-5.ts +15 -0
  63. package/src/fixtures/v1-6.ts +10 -0
  64. package/src/fixtures/v1-7.ts +10 -0
  65. package/src/fixtures/v1-9.ts +10 -0
  66. package/src/index.ts +56 -0
  67. package/src/meshData.ts +301 -0
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Reading a `.drft`.
3
+ *
4
+ * Two rules govern everything here, and they pull in opposite directions on purpose:
5
+ *
6
+ * 1. **Never misread.** Every offset and length is checked against the buffer before it
7
+ * is used. A file that is wrong is refused with the byte that failed, never
8
+ * approximated and never half-returned. A partial asset shows up later as a rendering
9
+ * artefact with no route back to its cause, which is the failure mode this whole
10
+ * format was designed after.
11
+ * 2. **Never copy.** Vertex data is viewed in place — `new Float32Array(buffer, offset,
12
+ * count)` — so loading a mesh costs the bounds checks and nothing else.
13
+ *
14
+ * Compatibility lives here too. See docs/FORMAT.md §4.4: old files open forever, unknown
15
+ * optional chunks are skipped in silence, unknown *required* chunks are a refusal.
16
+ */
17
+ import { readClip, readMorph, readNodes, readSkin } from './drftSkin.js';
18
+ import { validateMeshData } from './meshData.js';
19
+ import { ATTR_EMISSIVE_COLOR, ATTR_GRAIN, ATTR_JOINTS, ATTR_RELIEF, ATTR_TANGENT, ATTR_ROUGHNESS, ATTR_SPECULAR, ATTR_UVS, ATTR_WEIGHTS, CHUNK_ENTRY_BYTES, CHUNK_HEAD, CHUNK_LODM, CHUNK_LODF, CHUNK_SPLT, SPLAT_BLOCK_PREFIX, CHUNK_MESH, CHUNK_REQUIRED, DRFT_MAGIC, DRFT_VERSION_MAJOR, DrftError, HEADER_BYTES, CHUNK_MATL, CHUNK_TEXS, KNOWN_CHUNKS, MATERIAL_ENTRY_BYTES, align, fourCCName, CHUNK_ANIM, CHUNK_NODE, CHUNK_SKIN, CHUNK_MORP, CHUNK_SUBS, CHUNK_COLL, } from './drftFormat.js';
20
+ import { readSubs } from './drftSubs.js';
21
+ import { readColliders } from './drftColliders.js';
22
+ const decoder = new TextDecoder();
23
+ function readString(view, buffer, at, limit) {
24
+ if (at + 4 > limit)
25
+ throw new DrftError(`string length runs past its chunk at ${at}`);
26
+ const length = view.getUint32(at, true);
27
+ if (at + 4 + length > limit)
28
+ throw new DrftError(`string of ${length} bytes runs past its chunk at ${at}`);
29
+ const text = decoder.decode(new Uint8Array(buffer, at + 4, length));
30
+ return [text, align(at + 4 + length)];
31
+ }
32
+ export function readHead(buffer, chunk) {
33
+ const view = new DataView(buffer);
34
+ const limit = chunk.offset + chunk.byteLength;
35
+ if (chunk.byteLength < 28)
36
+ throw new DrftError('HEAD is too short to hold its numbers');
37
+ const numbers = new Float32Array(buffer, chunk.offset, 7);
38
+ const [name, afterName] = readString(view, buffer, chunk.offset + 28, limit);
39
+ const [generator] = readString(view, buffer, afterName, limit);
40
+ return {
41
+ name,
42
+ generator,
43
+ unitScale: numbers[0],
44
+ bounds: [
45
+ numbers[1],
46
+ numbers[2],
47
+ numbers[3],
48
+ numbers[4],
49
+ numbers[5],
50
+ numbers[6],
51
+ ],
52
+ };
53
+ }
54
+ export function readMesh(buffer, chunk) {
55
+ const view = new DataView(buffer);
56
+ if (chunk.byteLength < 16)
57
+ throw new DrftError('MESH is too short to hold its header');
58
+ const vertices = view.getUint32(chunk.offset, true);
59
+ const indexCount = view.getUint32(chunk.offset + 4, true);
60
+ const attributes = view.getUint32(chunk.offset + 8, true);
61
+ const limit = chunk.offset + chunk.byteLength;
62
+ let at = chunk.offset + 16;
63
+ /** A float view over the next `count` floats, checked before it is built. */
64
+ const floats = (count, what) => {
65
+ const bytes = count * 4;
66
+ if (at + bytes > limit) {
67
+ throw new DrftError(`${what} needs ${bytes} bytes but only ${limit - at} remain at ${at}`);
68
+ }
69
+ const array = new Float32Array(buffer, at, count);
70
+ at += bytes;
71
+ return array;
72
+ };
73
+ const positions = floats(vertices * 3, 'positions');
74
+ const normals = floats(vertices * 3, 'normals');
75
+ const colors = floats(vertices * 3, 'colors');
76
+ const emissive = floats(vertices, 'emissive');
77
+ const specular = (attributes & ATTR_SPECULAR) !== 0 ? floats(vertices, 'specular') : undefined;
78
+ const uvs = (attributes & ATTR_UVS) !== 0 ? floats(vertices * 2, 'uvs') : undefined;
79
+ const emissiveColor = (attributes & ATTR_EMISSIVE_COLOR) !== 0 ? floats(vertices * 3, 'emissiveColor') : undefined;
80
+ const roughness = (attributes & ATTR_ROUGHNESS) !== 0 ? floats(vertices, 'roughness') : undefined;
81
+ const grain = (attributes & ATTR_GRAIN) !== 0 ? floats(vertices, 'grain') : undefined;
82
+ const relief = (attributes & ATTR_RELIEF) !== 0 ? floats(vertices, 'relief') : undefined;
83
+ /* Four floats a vertex, which is what makes it the widest optional array in the format. */
84
+ const tangents = (attributes & ATTR_TANGENT) !== 0 ? floats(vertices * 4, 'tangents') : undefined;
85
+ /* A skin's per-vertex half, four floats each and read in that order because that is the order
86
+ the bits are in. `validateMeshData` below refuses one without the other, so a file whose
87
+ writer set only one bit is rejected rather than half-skinned. See ATTR_JOINTS. */
88
+ const joints = (attributes & ATTR_JOINTS) !== 0 ? floats(vertices * 4, 'joints') : undefined;
89
+ const weights = (attributes & ATTR_WEIGHTS) !== 0 ? floats(vertices * 4, 'weights') : undefined;
90
+ const indexBytes = indexCount * 4;
91
+ if (at + indexBytes > limit) {
92
+ throw new DrftError(`indices need ${indexBytes} bytes but only ${limit - at} remain at ${at}`);
93
+ }
94
+ const indices = new Uint32Array(buffer, at, indexCount);
95
+ const mesh = {
96
+ positions,
97
+ normals,
98
+ colors,
99
+ emissive,
100
+ indices,
101
+ ...(specular === undefined ? {} : { specular }),
102
+ ...(uvs === undefined ? {} : { uvs }),
103
+ ...(emissiveColor === undefined ? {} : { emissiveColor }),
104
+ ...(roughness === undefined ? {} : { roughness }),
105
+ ...(grain === undefined ? {} : { grain }),
106
+ ...(relief === undefined ? {} : { relief }),
107
+ ...(tangents === undefined ? {} : { tangents }),
108
+ ...(joints === undefined ? {} : { joints }),
109
+ ...(weights === undefined ? {} : { weights }),
110
+ };
111
+ /*
112
+ * Checked again on the way in, even though the writer checked on the way out. The file
113
+ * may not have come from this writer, and the cost of trusting it is a mesh that draws
114
+ * on one GPU and vanishes on another.
115
+ */
116
+ validateMeshData(mesh);
117
+ return mesh;
118
+ }
119
+ /** Decode a `.drft` from a buffer. Throws `DrftError` on anything it cannot read. */
120
+ export function readDrft(buffer) {
121
+ if (buffer.byteLength < HEADER_BYTES) {
122
+ throw new DrftError(`a file is at least ${HEADER_BYTES} bytes, got ${buffer.byteLength}`);
123
+ }
124
+ const view = new DataView(buffer);
125
+ if (view.getUint32(0, true) !== DRFT_MAGIC) {
126
+ throw new DrftError('not a drft file — the magic does not match');
127
+ }
128
+ const versionMajor = view.getUint16(4, true);
129
+ const versionMinor = view.getUint16(6, true);
130
+ const minReaderMajor = view.getUint16(8, true);
131
+ /*
132
+ * The one direction compatibility may fail, and it fails by saying so. A file needing a
133
+ * newer generation is refused; a file from an older one always opens, which is the
134
+ * promise the format is built around.
135
+ */
136
+ if (minReaderMajor > DRFT_VERSION_MAJOR) {
137
+ throw new DrftError(`this file needs a reader of version ${minReaderMajor} or newer and this one is ` +
138
+ `${DRFT_VERSION_MAJOR}. Its own version is ${versionMajor}.${versionMinor}.`);
139
+ }
140
+ const chunkCount = view.getUint32(12, true);
141
+ const totalBytes = view.getUint32(16, true);
142
+ if (totalBytes > buffer.byteLength) {
143
+ throw new DrftError(`the header claims ${totalBytes} bytes and the buffer holds ${buffer.byteLength}`);
144
+ }
145
+ const tableEnd = HEADER_BYTES + chunkCount * CHUNK_ENTRY_BYTES;
146
+ if (tableEnd > buffer.byteLength) {
147
+ throw new DrftError(`a table of ${chunkCount} chunks runs past the end of the file`);
148
+ }
149
+ const meshes = [];
150
+ /** Held with their level ordinals, because the table's order is a bake-time choice. */
151
+ const levels = [];
152
+ const discreteLevels = [];
153
+ const textures = [];
154
+ let materials = [];
155
+ const splatBlocks = [];
156
+ let nodes = [];
157
+ const skins = [];
158
+ const clips = [];
159
+ const morphs = [];
160
+ let subs = null;
161
+ let colliders = [];
162
+ const skipped = [];
163
+ let head = null;
164
+ for (let i = 0; i < chunkCount; i++) {
165
+ const entry = HEADER_BYTES + i * CHUNK_ENTRY_BYTES;
166
+ const chunk = {
167
+ code: view.getUint32(entry, true),
168
+ offset: view.getUint32(entry + 4, true),
169
+ byteLength: view.getUint32(entry + 8, true),
170
+ flags: view.getUint16(entry + 12, true),
171
+ index: view.getUint16(entry + 14, true),
172
+ };
173
+ if (chunk.offset + chunk.byteLength > buffer.byteLength) {
174
+ throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) spans ${chunk.offset}..` +
175
+ `${chunk.offset + chunk.byteLength} past the end of a ${buffer.byteLength} byte file`);
176
+ }
177
+ if (chunk.offset % 4 !== 0) {
178
+ throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) is not 4-byte aligned`);
179
+ }
180
+ if (!KNOWN_CHUNKS.has(chunk.code)) {
181
+ /*
182
+ * Rule 2 and rule 3 of §4.4, and the whole of forward compatibility. A writer that
183
+ * marks a chunk required is saying the asset is wrong without it, so a reader that
184
+ * does not know it must refuse rather than quietly produce something else.
185
+ */
186
+ if ((chunk.flags & CHUNK_REQUIRED) !== 0) {
187
+ throw new DrftError(`this file requires chunk "${fourCCName(chunk.code)}", which this reader does not ` +
188
+ `understand. It was written by version ${versionMajor}.${versionMinor}.`);
189
+ }
190
+ skipped.push(fourCCName(chunk.code));
191
+ continue;
192
+ }
193
+ if (chunk.code === CHUNK_HEAD)
194
+ head = readHead(buffer, chunk);
195
+ else if (chunk.code === CHUNK_MESH)
196
+ meshes.push(readMesh(buffer, chunk));
197
+ /* A level of detail is a mesh payload under another code, so it takes the same reader. */
198
+ else if (chunk.code === CHUNK_LODM)
199
+ levels.push({ level: chunk.index, mesh: readMesh(buffer, chunk) });
200
+ else if (chunk.code === CHUNK_LODF) {
201
+ /* A whole nested file, copied out and left unparsed. See `DrftAsset.levels`. */
202
+ discreteLevels.push(new Uint8Array(buffer, chunk.offset, chunk.byteLength).slice());
203
+ }
204
+ else if (chunk.code === CHUNK_TEXS)
205
+ textures.push(readTexture(buffer, chunk));
206
+ else if (chunk.code === CHUNK_MATL)
207
+ materials = readMaterials(buffer, chunk);
208
+ else if (chunk.code === CHUNK_SPLT)
209
+ splatBlocks.push({ block: readSplatBlock(buffer, chunk), at: chunk.index });
210
+ else if (chunk.code === CHUNK_NODE)
211
+ nodes = readNodes(buffer, chunk.offset, chunk.byteLength);
212
+ else if (chunk.code === CHUNK_SKIN)
213
+ skins.push(readSkin(buffer, chunk.offset, chunk.byteLength));
214
+ else if (chunk.code === CHUNK_ANIM)
215
+ clips.push(readClip(buffer, chunk.offset, chunk.byteLength));
216
+ else if (chunk.code === CHUNK_MORP)
217
+ morphs.push(readMorph(buffer, chunk.offset, chunk.byteLength));
218
+ else if (chunk.code === CHUNK_SUBS)
219
+ subs = readSubs(buffer, chunk.offset, chunk.byteLength);
220
+ else if (chunk.code === CHUNK_COLL)
221
+ colliders = readColliders(buffer, chunk.offset, chunk.byteLength);
222
+ /* Every other known chunk is defined but not yet carried; see docs/FORMAT.md phase table. */
223
+ }
224
+ if (head === null)
225
+ throw new DrftError('no HEAD chunk — every asset must describe itself');
226
+ /*
227
+ * **A capture counts as content, which is why this is not simply a `MESH` check any more.** A
228
+ * file holding one Gaussian splat capture and no geometry is an ordinary asset, and refusing it
229
+ * for the absence of a chunk kind it never needed would be the format telling a consumer what
230
+ * its scene may be made of.
231
+ */
232
+ if (meshes.length === 0 && splatBlocks.length === 0) {
233
+ throw new DrftError('no MESH and no SPLT chunk — an asset with neither geometry nor a capture');
234
+ }
235
+ /*
236
+ * Checked here rather than trusted, because the pairing is by ordinal and a mismatch is
237
+ * silent: every material would describe the wrong mesh, and the asset would draw as a
238
+ * scene whose surfaces had been shuffled. That reads as a baker bug anywhere but here.
239
+ */
240
+ if (materials.length > 0 && materials.length !== meshes.length) {
241
+ throw new DrftError(`MATL carries ${materials.length} materials for ${meshes.length} meshes; they pair by ordinal`);
242
+ }
243
+ /*
244
+ * **All four map indices, not just the albedo.** The check existed for one of them because one
245
+ * was all there was; a material naming texture 5 in a file carrying two is a corrupt file
246
+ * whichever field names it, and the reason to catch it here rather than at the draw is the same
247
+ * reason `drftTextures.ts` refuses an unknown name: an index that resolves to the wrong thing
248
+ * fails silently at some later frame, and one that resolves to nothing fails at none.
249
+ */
250
+ for (const material of materials) {
251
+ for (const [field, index] of [
252
+ ['albedo', material.albedo],
253
+ ['normal', material.normalMap],
254
+ ['ORM', material.ormMap],
255
+ ['emissive', material.emissiveMap],
256
+ ]) {
257
+ if (index >= textures.length) {
258
+ throw new DrftError(`a material names ${field} texture ${index} and the file carries ${textures.length}`);
259
+ }
260
+ }
261
+ }
262
+ /*
263
+ * Deltas onto the meshes they name, once every chunk is in hand.
264
+ *
265
+ * Attached here rather than as they arrive, because a `MORP` chunk can precede the `MESH` it
266
+ * belongs to — a caller writing them in another order, or a range-fetching reader, both make
267
+ * that possible, and the mesh ordinal in the payload is what makes the pairing independent of
268
+ * arrival order.
269
+ */
270
+ for (const morph of morphs) {
271
+ const mesh = meshes[morph.mesh];
272
+ if (mesh === undefined) {
273
+ throw new DrftError(`MORP names mesh ${morph.mesh}, and this file has ${meshes.length}`);
274
+ }
275
+ meshes[morph.mesh] = {
276
+ ...mesh,
277
+ morphTargets: morph.deltas,
278
+ morphTargetCount: morph.targetCount,
279
+ };
280
+ }
281
+ /* By the ordinal the file gave them rather than by where they sat, coarsest first. */
282
+ levels.sort((a, b) => a.level - b.level);
283
+ const lods = levels.map((entry) => entry.mesh);
284
+ return {
285
+ head,
286
+ meshes,
287
+ lods,
288
+ levels: discreteLevels,
289
+ nodes,
290
+ skins,
291
+ clips,
292
+ materials,
293
+ textures,
294
+ substances: subs?.entries ?? [],
295
+ colliders,
296
+ splats: joinSplatBlocks(splatBlocks),
297
+ skipped,
298
+ versionMajor,
299
+ versionMinor,
300
+ };
301
+ }
302
+ /**
303
+ * One `SPLT` block, viewed in place.
304
+ *
305
+ * **Zero-copy, like every other payload here.** The records are a `Uint32Array` over the file's
306
+ * own bytes, which is what the format's four-byte alignment exists for and what lets a capture of
307
+ * tens of megabytes reach the GPU without being duplicated on the way.
308
+ */
309
+ export function readSplatBlock(buffer, chunk) {
310
+ if (chunk.byteLength < SPLAT_BLOCK_PREFIX) {
311
+ throw new DrftError('SPLT is too short to hold its header');
312
+ }
313
+ const view = new DataView(buffer, chunk.offset, chunk.byteLength);
314
+ const count = view.getUint32(0, true);
315
+ const totalCount = view.getUint32(4, true);
316
+ const wordsPerSplat = view.getUint32(8, true);
317
+ const sphericalHarmonics = view.getUint32(12, true);
318
+ if (wordsPerSplat === 0) {
319
+ throw new DrftError('a SPLT block says its records are zero words, which stores nothing');
320
+ }
321
+ const words = count * wordsPerSplat;
322
+ if (SPLAT_BLOCK_PREFIX + words * 4 > chunk.byteLength) {
323
+ throw new DrftError(`a SPLT block of ${count} splats at ${wordsPerSplat} words runs past its own chunk`);
324
+ }
325
+ if (count > totalCount) {
326
+ throw new DrftError(`a SPLT block holds ${count} splats of a capture that says it has ${totalCount}`);
327
+ }
328
+ const bounds = new Float32Array(buffer, chunk.offset + 16, 6);
329
+ return {
330
+ count,
331
+ totalCount,
332
+ wordsPerSplat,
333
+ sphericalHarmonics,
334
+ boundsMin: bounds.subarray(0, 3),
335
+ boundsMax: bounds.subarray(3, 6),
336
+ records: new Uint32Array(buffer, chunk.offset + SPLAT_BLOCK_PREFIX, words),
337
+ };
338
+ }
339
+ /**
340
+ * Join every block of a capture into one, in the ordinal order the file gave them.
341
+ *
342
+ * The records are copied here and nowhere else in this reader, because joining is the one thing
343
+ * that cannot be a view: the blocks are separate chunks at separate offsets. A consumer that
344
+ * wants to avoid the copy takes the blocks as they arrive, through `DrftStream.onSplats`.
345
+ */
346
+ function joinSplatBlocks(blocks) {
347
+ const first = blocks[0]?.block;
348
+ if (first === undefined)
349
+ return null;
350
+ blocks.sort((a, b) => a.at - b.at);
351
+ let words = 0;
352
+ for (const entry of blocks) {
353
+ if (entry.block.wordsPerSplat !== first.wordsPerSplat) {
354
+ throw new DrftError(`one SPLT block records ${entry.block.wordsPerSplat} words a splat and another records ` +
355
+ `${first.wordsPerSplat}; a capture has one record layout`);
356
+ }
357
+ words += entry.block.records.length;
358
+ }
359
+ const records = new Uint32Array(words);
360
+ let at = 0;
361
+ let count = 0;
362
+ for (const entry of blocks) {
363
+ records.set(entry.block.records, at);
364
+ at += entry.block.records.length;
365
+ count += entry.block.count;
366
+ }
367
+ return {
368
+ count,
369
+ totalCount: first.totalCount,
370
+ wordsPerSplat: first.wordsPerSplat,
371
+ sphericalHarmonics: first.sphericalHarmonics,
372
+ boundsMin: first.boundsMin,
373
+ boundsMax: first.boundsMax,
374
+ records,
375
+ };
376
+ }
377
+ export function readTexture(buffer, chunk) {
378
+ if (chunk.byteLength < 16)
379
+ throw new DrftError('TEXS is too short to hold its header');
380
+ const view = new DataView(buffer, chunk.offset, chunk.byteLength);
381
+ const byteLength = view.getUint32(12, true);
382
+ if (16 + byteLength > chunk.byteLength) {
383
+ throw new DrftError(`a TEXS payload of ${byteLength} bytes runs past its own chunk`);
384
+ }
385
+ /* Absent in a file written before the name existed, which reads as empty rather than as
386
+ a malformed chunk. That is rule 4 of the compatibility contract in practice. */
387
+ const nameAt = chunk.offset + align(16 + byteLength);
388
+ const limit = chunk.offset + chunk.byteLength;
389
+ const name = nameAt + 4 <= limit ? readString(new DataView(buffer), buffer, nameAt, limit)[0] : '';
390
+ return {
391
+ name,
392
+ codec: view.getUint32(0, true),
393
+ width: view.getUint32(4, true),
394
+ height: view.getUint32(8, true),
395
+ bytes: new Uint8Array(buffer, chunk.offset + 16, byteLength),
396
+ };
397
+ }
398
+ export function readMaterials(buffer, chunk) {
399
+ if (chunk.byteLength < 8)
400
+ throw new DrftError('MATL is too short to hold its header');
401
+ const view = new DataView(buffer, chunk.offset, chunk.byteLength);
402
+ const count = view.getUint32(0, true);
403
+ /*
404
+ * The writer's stride, which may exceed this reader's if the file is newer. Stepping by it
405
+ * rather than by our own is the whole of forward compatibility for this chunk: a later
406
+ * field is skipped instead of shifting every entry after the first.
407
+ */
408
+ const stride = view.getUint32(4, true);
409
+ /*
410
+ * A stride *shorter* than this version's is an older file, not a broken one, and it must
411
+ * open. This threw at first, which had the rule exactly half right: stepping by the file's
412
+ * stride handles a newer writer, and defaulting the fields that predate it handles an
413
+ * older one. Refusing the second direction breaks the promise in §4.4 rule 1 that a file
414
+ * written today opens in every future reader, which is the whole point of the field.
415
+ */
416
+ if (stride < 4)
417
+ throw new DrftError(`MATL declares a ${stride} byte entry, which cannot hold a material`);
418
+ /** Whether a field at this offset is present in the stride the file actually used. */
419
+ const has = (offset, bytes) => offset + bytes <= stride;
420
+ if (8 + count * stride > chunk.byteLength) {
421
+ throw new DrftError(`MATL claims ${count} materials and its chunk cannot hold them`);
422
+ }
423
+ const out = [];
424
+ let namesAt = chunk.offset + 8 + count * stride;
425
+ const limit = chunk.offset + chunk.byteLength;
426
+ for (let i = 0; i < count; i++) {
427
+ const at = 8 + i * stride;
428
+ /*
429
+ * A name that cannot be read costs the name and nothing else.
430
+ *
431
+ * Every other field in this chunk is load-bearing and a wrong one draws a wrong picture,
432
+ * so those are checked and refused. A material's name is how a *consumer* addresses the
433
+ * surface, and losing it degrades a lookup rather than the render — so a truncated or
434
+ * misaligned name block leaves the remaining names empty instead of taking a whole asset
435
+ * down. Geometry that draws correctly must not be lost to metadata that does not.
436
+ */
437
+ let name = '';
438
+ if (namesAt + 4 <= limit) {
439
+ try {
440
+ const [text, next] = readString(new DataView(buffer), buffer, namesAt, limit);
441
+ name = text;
442
+ namesAt = next;
443
+ }
444
+ catch {
445
+ namesAt = limit;
446
+ }
447
+ }
448
+ out.push({
449
+ name,
450
+ color: has(8, 4)
451
+ ? [view.getFloat32(at, true), view.getFloat32(at + 4, true), view.getFloat32(at + 8, true)]
452
+ : [0.8, 0.8, 0.8],
453
+ specular: has(12, 4) ? view.getFloat32(at + 12, true) : 0,
454
+ roughness: has(16, 4) ? view.getFloat32(at + 16, true) : 0.4277,
455
+ emissive: has(20, 4) ? view.getFloat32(at + 20, true) : 0,
456
+ emissiveColor: has(32, 4)
457
+ ? [
458
+ view.getFloat32(at + 24, true),
459
+ view.getFloat32(at + 28, true),
460
+ view.getFloat32(at + 32, true),
461
+ ]
462
+ : [-1, -1, -1],
463
+ /* Each default is what the engine assumes for a material that never stated it: opaque,
464
+ untextured, and not mirroring anything. */
465
+ opacity: has(36, 4) ? view.getFloat32(at + 36, true) : 1,
466
+ albedo: has(40, 4) ? view.getInt32(at + 40, true) : -1,
467
+ reflectivity: has(44, 4) ? view.getFloat32(at + 44, true) : 0,
468
+ /* Each defaults to "no map", which is exactly what a file written before these meant. */
469
+ normalMap: has(48, 4) ? view.getInt32(at + 48, true) : -1,
470
+ ormMap: has(52, 4) ? view.getInt32(at + 52, true) : -1,
471
+ emissiveMap: has(56, 4) ? view.getInt32(at + 56, true) : -1,
472
+ /*
473
+ * 1 and 1 and **0**. The two scales default to the identity, because a file that never
474
+ * stated them meant its map as authored. The strength defaults to zero for the opposite
475
+ * reason: nothing in an older file establishes that its ORM map carries occlusion in R at
476
+ * all, and glTF leaves that channel undefined unless an occlusionTexture names the same
477
+ * image. Defaulting it to 1 would read whatever the exporter left as a shadow.
478
+ */
479
+ roughnessScale: has(60, 4) ? view.getFloat32(at + 60, true) : 1,
480
+ metallicScale: has(64, 4) ? view.getFloat32(at + 64, true) : 1,
481
+ occlusionStrength: has(68, 4) ? view.getFloat32(at + 68, true) : 0,
482
+ /* Discard nothing, which is exactly what a file written before this field meant. */
483
+ cutout: has(72, 4) ? view.getFloat32(at + 72, true) : 0,
484
+ });
485
+ }
486
+ return out;
487
+ }
@@ -0,0 +1,40 @@
1
+ import type { AnimationClip, DrftSkin } from './animationData.ts';
2
+ /**
3
+ * `NODE`, `SKIN` and `ANIM`: reading and writing the three chunks a rig needs.
4
+ *
5
+ * **All three are optional, which is what makes this additive.** Rule 2 of docs/FORMAT.md §4.4
6
+ * says an optional chunk a reader does not understand is skipped in silence, so a 1.6 file opens
7
+ * in a 1.5 reader as the static geometry it also holds — the right degradation, since a reader
8
+ * that has never heard of a skin cannot deform anything.
9
+ *
10
+ * **`NODE` was specified in §4.3 and implemented nowhere.** `CHUNK_NODE` has been declared and in
11
+ * `KNOWN_CHUNKS` since v1 with no writer and no reader; rigid TRS animation is the first thing to
12
+ * need a hierarchy, so this is a chunk the format has claimed for its whole life finally being
13
+ * built. Its payload is what §4.3 already promised — parent index, TRS, mesh index, name — rather
14
+ * than whatever would be convenient now.
15
+ */
16
+ /** A node in the asset's own hierarchy. `mesh` is -1 for a node that draws nothing. */
17
+ export interface DrftNode {
18
+ readonly parent: number;
19
+ readonly translation: readonly [number, number, number];
20
+ readonly rotation: readonly [number, number, number, number];
21
+ readonly scale: readonly [number, number, number];
22
+ readonly mesh: number;
23
+ readonly name: string;
24
+ }
25
+ export declare function buildNodes(nodes: readonly DrftNode[]): Uint8Array;
26
+ export declare function readNodes(buffer: ArrayBuffer, offset: number, byteLength: number): DrftNode[];
27
+ export declare function buildSkin(skin: DrftSkin): Uint8Array;
28
+ export declare function readSkin(buffer: ArrayBuffer, offset: number, byteLength: number): DrftSkin;
29
+ export declare function buildClip(clip: AnimationClip): Uint8Array;
30
+ export declare function readClip(buffer: ArrayBuffer, offset: number, byteLength: number): AnimationClip;
31
+ /** One mesh's morph deltas, and which mesh they belong to. */
32
+ export interface DrftMorph {
33
+ /** The ordinal of the mesh these deform. */
34
+ readonly mesh: number;
35
+ readonly targetCount: number;
36
+ /** Three floats a vertex per target, interleaved by vertex. See `MeshData.morphTargets`. */
37
+ readonly deltas: Float32Array;
38
+ }
39
+ export declare function buildMorph(morph: DrftMorph): Uint8Array;
40
+ export declare function readMorph(buffer: ArrayBuffer, offset: number, byteLength: number): DrftMorph;