@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,436 @@
1
+ /** Reading a `.drft` while it is still arriving, chunk by chunk. */
2
+
3
+ import type { MeshData } from './meshData.ts';
4
+ import {
5
+ CHUNK_ENTRY_BYTES,
6
+ CHUNK_HEAD,
7
+ CHUNK_LODM,
8
+ CHUNK_SPLT,
9
+ CHUNK_MATL,
10
+ CHUNK_MESH,
11
+ CHUNK_REQUIRED,
12
+ CHUNK_TEXS,
13
+ DRFT_MAGIC,
14
+ DRFT_VERSION_MAJOR,
15
+ DrftError,
16
+ HEADER_BYTES,
17
+ KNOWN_CHUNKS,
18
+ fourCCName,
19
+ CHUNK_ANIM,
20
+ CHUNK_NODE,
21
+ CHUNK_SKIN,
22
+ CHUNK_MORP,
23
+ } from './drftFormat.ts';
24
+ import type { DrftChunk, DrftHead, DrftMaterial, DrftSplatBlock } from './drftFormat.ts';
25
+ import { readHead, readMaterials, readMesh, readSplatBlock, readTexture } from './drftRead.ts';
26
+ import type { DrftTexture } from './drftRead.ts';
27
+ import type { AnimationClip, DrftSkin } from './animationData.ts';
28
+ import type { DrftNode } from './drftSkin.ts';
29
+ import type { DrftMorph } from './drftSkin.ts';
30
+ import { readClip, readMorph, readNodes, readSkin } from './drftSkin.ts';
31
+
32
+ /**
33
+ * What the file says is coming, known from the first few kilobytes.
34
+ *
35
+ * **The chunk table is a manifest and always was**: a fixed 32-byte header, then
36
+ * `chunkCount x 16` bytes naming every chunk's kind, offset and length. For a 187-mesh car
37
+ * that is about 3 KB, so a reader holding the first three kilobytes of a 74 MB file knows
38
+ * exactly what the rest of it contains and how big each piece is. That is what lets a loading
39
+ * bar say "142 of 187 parts" rather than counting bytes and hoping.
40
+ */
41
+ export interface DrftManifest {
42
+ readonly totalBytes: number;
43
+ readonly meshCount: number;
44
+ readonly textureCount: number;
45
+ /** Bytes of geometry, of images, and of everything else, from the table alone. */
46
+ readonly meshBytes: number;
47
+ readonly textureBytes: number;
48
+ /**
49
+ * How many coarse levels of detail this file carries, which is normally one or none.
50
+ *
51
+ * Worth reporting rather than leaving to be discovered, because it is the difference between
52
+ * a load that opens on an outline and one that opens on an empty room, and a caller composing
53
+ * words for a progress readout wants to know that before the first stage rather than after it.
54
+ */
55
+ readonly lodCount: number;
56
+ /**
57
+ * How many `SPLT` blocks this file carries, and how many bytes they are between them.
58
+ *
59
+ * **Blocks, not splats**, because the number a progress readout wants is how many refinements
60
+ * are still coming — the splat count is in every block's own header and a caller has it from
61
+ * the first one. Zero for a file with no capture, which is most of them.
62
+ */
63
+ readonly splatBlockCount: number;
64
+ readonly splatBytes: number;
65
+ }
66
+
67
+ /**
68
+ * Called as each kind of thing finishes arriving. Every one is optional.
69
+ *
70
+ * A consumer that wants the whole asset at once should use `readDrft`; these exist so a scene
71
+ * can put a coarse thing on screen and improve it, which is the point of streaming at all.
72
+ */
73
+ export interface DrftStreamHandlers {
74
+ /** The table has arrived, so what is coming is now known. Fires once, before anything else. */
75
+ readonly onManifest?: (manifest: DrftManifest) => void;
76
+ readonly onHead?: (head: DrftHead) => void;
77
+ /**
78
+ * A coarse whole-model level, with its level ordinal: 0 is the coarsest.
79
+ *
80
+ * Fires before any part on a file the baker laid out, which is the whole point of it — a
81
+ * complete object on screen while the parts are still arriving. A caller that draws it must
82
+ * stop drawing it in the same frame it starts drawing the model, or the two are both there.
83
+ */
84
+ readonly onLod?: (mesh: MeshData, level: number) => void;
85
+ /** One mesh, in the order the file lays them out, with its ordinal in that order. */
86
+ readonly onMesh?: (mesh: MeshData, ordinal: number) => void;
87
+ /**
88
+ * One block of a Gaussian splat capture, with its ordinal.
89
+ *
90
+ * **Every block is a sparse version of the whole capture, so the first one is already worth
91
+ * drawing** — that is what the writer's coarse-first ordering buys and it is the reason splats
92
+ * are in this container rather than in a file of their own. A consumer allocates from
93
+ * `block.totalCount` on the first call and appends after that; the drawn count rises and
94
+ * nothing is re-allocated or re-sorted.
95
+ */
96
+ readonly onSplats?: (block: DrftSplatBlock, ordinal: number) => void;
97
+ /**
98
+ * The asset's hierarchy, once, when its `NODE` chunk lands.
99
+ *
100
+ * Handed over whole rather than a node at a time: a hierarchy is one chunk and a partial one is
101
+ * not useful — a child whose parent has not arrived cannot be placed.
102
+ */
103
+ readonly onNodes?: (nodes: readonly DrftNode[]) => void;
104
+ /** One mesh's morph deltas, naming the mesh ordinal they belong to. */
105
+ readonly onMorph?: (morph: DrftMorph) => void;
106
+ /** One skin, as its `SKIN` chunk lands. Several arrive for an asset with several. */
107
+ readonly onSkin?: (skin: DrftSkin, ordinal: number) => void;
108
+ /**
109
+ * One clip, as its `ANIM` chunk lands.
110
+ *
111
+ * Per chunk rather than collected, for the reason `onSplats` is: a consumer that can start a
112
+ * character on the first clip should not wait for the last. The writer puts all three ahead of
113
+ * the geometry so this is usually possible.
114
+ */
115
+ readonly onClip?: (clip: AnimationClip, ordinal: number) => void;
116
+ readonly onMaterials?: (materials: readonly DrftMaterial[]) => void;
117
+ readonly onTexture?: (texture: DrftTexture, ordinal: number) => void;
118
+ }
119
+
120
+ /**
121
+ * An incremental `.drft` reader: feed it bytes, it reports each chunk as that chunk completes.
122
+ *
123
+ * **Its own entry point rather than a flag on `readDrft`**, which is what docs/FORMAT.md §4.6
124
+ * asks for and the reason is the strictness. `readDrft` requires a `HEAD` and at least one
125
+ * `MESH` and refuses anything partial, which is right for a file on disk and wrong for a
126
+ * stream. The rules do not relax here, they *move*: a chunk is validated when its last byte
127
+ * lands, the table's own rules apply the moment the table is readable, and the whole-asset
128
+ * rules apply at `end`. A malformed file still throws, naming the chunk.
129
+ *
130
+ * **Zero-copy survives, and that is not obvious.** The header carries `totalBytes`, so this
131
+ * allocates one buffer of the final size the moment it knows that number and writes arriving
132
+ * bytes into it. Every completed chunk is then viewed in place, exactly as a whole-file read
133
+ * views it. Accumulating chunks into their own buffers instead would cost a copy per chunk and
134
+ * give up the one property this format exists for.
135
+ *
136
+ * Sequential, because that is the property worth designing for: with the payloads laid out in
137
+ * priority order by the baker, a *plain* fetch refines the model as bytes arrive, with no range
138
+ * requests and no server support beyond serving a file. Range requests would be an
139
+ * optimisation on top, and would need an offset per push rather than a different reader.
140
+ */
141
+ export class DrftStream {
142
+ private readonly handlers: DrftStreamHandlers;
143
+ /** The final buffer, once `totalBytes` is known. Written into at the arrival offset. */
144
+ private buffer: ArrayBuffer | null = null;
145
+ private bytes: Uint8Array | null = null;
146
+ /** The header and table, before the real buffer exists. At most a few arrivals. */
147
+ private prelude: Uint8Array[] = [];
148
+ private preludeBytes = 0;
149
+ private received = 0;
150
+ private chunks: DrftChunk[] = [];
151
+ /** How many table entries have been reported complete, in table order. */
152
+ private settled = 0;
153
+ private meshOrdinal = 0;
154
+ private textureOrdinal = 0;
155
+ private head: DrftHead | null = null;
156
+ private meshCount = 0;
157
+ private materialCount = 0;
158
+ private textureCount = 0;
159
+ private splatBlockCount = 0;
160
+ private manifestSent = false;
161
+ private versionMajor = 0;
162
+ private versionMinor = 0;
163
+ private ended = false;
164
+
165
+ constructor(handlers: DrftStreamHandlers = {}) {
166
+ this.handlers = handlers;
167
+ }
168
+
169
+ /** Bytes arrived so far, and how many the file says there are. Zero until the header lands. */
170
+ get progress(): { readonly received: number; readonly total: number } {
171
+ return { received: this.received, total: this.buffer?.byteLength ?? 0 };
172
+ }
173
+
174
+ /**
175
+ * Hand over the next bytes of the file, in order.
176
+ *
177
+ * Reports whatever those bytes completed before returning, so a caller that draws between
178
+ * pushes sees every intermediate state.
179
+ */
180
+ push(part: Uint8Array): void {
181
+ if (this.ended) throw new DrftError('bytes pushed after the stream ended');
182
+
183
+ if (this.bytes === null) {
184
+ /*
185
+ * Before the buffer exists there is nowhere to write, so the first arrivals are held as
186
+ * they came and joined once.
187
+ *
188
+ * **Held as slices rather than as bytes**, which is not a micro-optimisation: the first
189
+ * thing a fetch hands over is tens of kilobytes, and pushing that a byte at a time into a
190
+ * plain array is a five-figure loop before a single pixel of the model exists. It was
191
+ * measurable as a hitch at the very start of a load, which is the worst place to have one
192
+ * because nothing is on screen yet to explain it.
193
+ */
194
+ this.prelude.push(part);
195
+ this.preludeBytes += part.length;
196
+ if (this.preludeBytes < HEADER_BYTES) return;
197
+ this.openBuffer();
198
+ if (this.bytes === null) return;
199
+ } else {
200
+ const target = this.bytes;
201
+ if (this.received + part.length > target.length) {
202
+ throw new DrftError(
203
+ `the file said it was ${target.length} bytes and more than that has arrived`,
204
+ );
205
+ }
206
+ target.set(part, this.received);
207
+ this.received += part.length;
208
+ }
209
+
210
+ this.readTable();
211
+ this.settleChunks();
212
+ }
213
+
214
+ /**
215
+ * No more bytes. Applies the rules that are about the asset as a whole rather than about one
216
+ * chunk, which is where `readDrft`'s refusals live.
217
+ */
218
+ end(): void {
219
+ if (this.ended) return;
220
+ this.ended = true;
221
+ if (this.buffer === null) {
222
+ throw new DrftError(`a file is at least ${HEADER_BYTES} bytes, got ${this.preludeBytes}`);
223
+ }
224
+ if (this.received < this.buffer.byteLength) {
225
+ throw new DrftError(
226
+ `the file said it was ${this.buffer.byteLength} bytes and ${this.received} arrived`,
227
+ );
228
+ }
229
+ if (this.head === null) throw new DrftError('no HEAD chunk — every asset must describe itself');
230
+ /* Geometry *or* a capture, since 1.5 — the same rule `readDrft` and `writeDrft` apply, kept
231
+ in step here because a stream that refused what a whole-file read accepts would fail only
232
+ for the consumer that had chosen to show a load. */
233
+ if (this.meshCount === 0 && this.splatBlockCount === 0) {
234
+ throw new DrftError(
235
+ 'no MESH and no SPLT chunk — an asset with neither geometry nor a capture',
236
+ );
237
+ }
238
+ /*
239
+ * The pairing check `readDrft` makes, and for the same reason: materials pair with meshes
240
+ * by ordinal, so a count mismatch means every surface describes a different mesh. That is
241
+ * silent, and it draws as a scene whose materials have been shuffled.
242
+ */
243
+ if (this.materialCount > 0 && this.materialCount !== this.meshCount) {
244
+ throw new DrftError(
245
+ `MATL carries ${this.materialCount} materials for ${this.meshCount} meshes; ` +
246
+ `they pair by ordinal`,
247
+ );
248
+ }
249
+ }
250
+
251
+ /** Everything the header and the table say, once they have arrived. */
252
+ private openBuffer(): void {
253
+ const prelude = new Uint8Array(this.preludeBytes);
254
+ let at = 0;
255
+ for (const part of this.prelude) {
256
+ prelude.set(part, at);
257
+ at += part.length;
258
+ }
259
+ const view = new DataView(prelude.buffer, prelude.byteOffset, prelude.byteLength);
260
+ if (view.getUint32(0, true) !== DRFT_MAGIC) {
261
+ throw new DrftError('not a drft file — the magic does not match');
262
+ }
263
+ this.versionMajor = view.getUint16(4, true);
264
+ this.versionMinor = view.getUint16(6, true);
265
+ const minReaderMajor = view.getUint16(8, true);
266
+ if (minReaderMajor > DRFT_VERSION_MAJOR) {
267
+ throw new DrftError(
268
+ `this file needs a reader of version ${minReaderMajor} or newer and this one is ` +
269
+ `${DRFT_VERSION_MAJOR}. Its own version is ${this.versionMajor}.${this.versionMinor}.`,
270
+ );
271
+ }
272
+ const chunkCount = view.getUint32(12, true);
273
+ const totalBytes = view.getUint32(16, true);
274
+ const tableEnd = HEADER_BYTES + chunkCount * CHUNK_ENTRY_BYTES;
275
+ if (tableEnd > totalBytes) {
276
+ throw new DrftError(`a table of ${chunkCount} chunks runs past the end of the file`);
277
+ }
278
+
279
+ this.buffer = new ArrayBuffer(totalBytes);
280
+ this.bytes = new Uint8Array(this.buffer);
281
+ this.bytes.set(prelude.subarray(0, Math.min(prelude.length, totalBytes)));
282
+ this.received = Math.min(prelude.length, totalBytes);
283
+ this.prelude = [];
284
+ this.preludeBytes = 0;
285
+ }
286
+
287
+ /** Parse the table once all of it has arrived, and announce what is coming. */
288
+ private readTable(): void {
289
+ if (this.manifestSent || this.buffer === null) return;
290
+ const view = new DataView(this.buffer);
291
+ const chunkCount = view.getUint32(12, true);
292
+ const tableEnd = HEADER_BYTES + chunkCount * CHUNK_ENTRY_BYTES;
293
+ if (this.received < tableEnd) return;
294
+
295
+ let meshBytes = 0;
296
+ let textureBytes = 0;
297
+ let splatBytes = 0;
298
+ for (let i = 0; i < chunkCount; i++) {
299
+ const entry = HEADER_BYTES + i * CHUNK_ENTRY_BYTES;
300
+ const chunk: DrftChunk = {
301
+ code: view.getUint32(entry, true),
302
+ offset: view.getUint32(entry + 4, true),
303
+ byteLength: view.getUint32(entry + 8, true),
304
+ flags: view.getUint16(entry + 12, true),
305
+ index: view.getUint16(entry + 14, true),
306
+ };
307
+ if (chunk.offset + chunk.byteLength > this.buffer.byteLength) {
308
+ throw new DrftError(
309
+ `chunk ${i} (${fourCCName(chunk.code)}) spans ${chunk.offset}..` +
310
+ `${chunk.offset + chunk.byteLength} past the end of a ${this.buffer.byteLength} ` +
311
+ `byte file`,
312
+ );
313
+ }
314
+ if (chunk.offset % 4 !== 0) {
315
+ throw new DrftError(`chunk ${i} (${fourCCName(chunk.code)}) is not 4-byte aligned`);
316
+ }
317
+ if (!KNOWN_CHUNKS.has(chunk.code) && (chunk.flags & CHUNK_REQUIRED) !== 0) {
318
+ throw new DrftError(
319
+ `this file requires chunk "${fourCCName(chunk.code)}", which this reader does not ` +
320
+ `understand. It was written by version ${this.versionMajor}.${this.versionMinor}.`,
321
+ );
322
+ }
323
+ /*
324
+ * A level of detail counts as geometry for the weighting, because that is what it is:
325
+ * the bar is weighted by what the file is made of, and a few hundred kilobytes of
326
+ * outline is part of the geometry a viewer is waiting for. It is deliberately *not*
327
+ * counted in `meshCount`, which is the number of parts — an outline is not a part, and
328
+ * a readout saying "1 of 188 parts" would be counting the model twice.
329
+ */
330
+ if (chunk.code === CHUNK_MESH || chunk.code === CHUNK_LODM) meshBytes += chunk.byteLength;
331
+ if (chunk.code === CHUNK_TEXS) textureBytes += chunk.byteLength;
332
+ /* Its own weight rather than geometry's: a capture is not made of parts, so folding it into
333
+ `meshBytes` would make a "parts" readout weigh something that is not one. */
334
+ if (chunk.code === CHUNK_SPLT) splatBytes += chunk.byteLength;
335
+ this.chunks.push(chunk);
336
+ }
337
+
338
+ /*
339
+ * Sorted by where the payload sits rather than by table order, because completion is
340
+ * decided by arriving bytes and the two orders are allowed to differ: the table is a
341
+ * manifest and the payload order is a bake-time priority decision. Sorting here means the
342
+ * settle loop can stop at the first chunk that is not yet complete.
343
+ */
344
+ this.chunks.sort((a, b) => a.offset - b.offset);
345
+
346
+ this.manifestSent = true;
347
+ this.handlers.onManifest?.({
348
+ totalBytes: this.buffer.byteLength,
349
+ meshCount: this.chunks.filter((c) => c.code === CHUNK_MESH).length,
350
+ textureCount: this.chunks.filter((c) => c.code === CHUNK_TEXS).length,
351
+ lodCount: this.chunks.filter((c) => c.code === CHUNK_LODM).length,
352
+ splatBlockCount: this.chunks.filter((c) => c.code === CHUNK_SPLT).length,
353
+ meshBytes,
354
+ textureBytes,
355
+ splatBytes,
356
+ });
357
+ }
358
+
359
+ /** Report every chunk whose last byte has now landed, in file order. */
360
+ private settleChunks(): void {
361
+ if (!this.manifestSent || this.buffer === null) return;
362
+ while (this.settled < this.chunks.length) {
363
+ const chunk = this.chunks[this.settled];
364
+ if (chunk === undefined) break;
365
+ if (chunk.offset + chunk.byteLength > this.received) break;
366
+ this.settled++;
367
+
368
+ if (!KNOWN_CHUNKS.has(chunk.code)) continue;
369
+ if (chunk.code === CHUNK_HEAD) {
370
+ this.head = readHead(this.buffer, chunk);
371
+ this.handlers.onHead?.(this.head);
372
+ } else if (chunk.code === CHUNK_MESH) {
373
+ const mesh = readMesh(this.buffer, chunk);
374
+ this.meshCount++;
375
+ this.handlers.onMesh?.(mesh, this.meshOrdinal++);
376
+ } else if (chunk.code === CHUNK_SPLT) {
377
+ /* The ordinal the file gave it, so blocks append in the writer's order however they
378
+ arrived — which for a sequential fetch is the same order, and for a range-fetching
379
+ caller need not be. */
380
+ this.splatBlockCount++;
381
+ this.handlers.onSplats?.(readSplatBlock(this.buffer, chunk), chunk.index);
382
+ } else if (chunk.code === CHUNK_MORP) {
383
+ /*
384
+ * Reported as its own event rather than folded into the mesh, because a `MORP` chunk can
385
+ * land before or after the `MESH` it names — the writer puts it after, a range-fetching
386
+ * caller need not — and a stream cannot rewrite a mesh it has already handed over.
387
+ */
388
+ this.handlers.onMorph?.(readMorph(this.buffer, chunk.offset, chunk.byteLength));
389
+ } else if (chunk.code === CHUNK_NODE) {
390
+ this.handlers.onNodes?.(readNodes(this.buffer, chunk.offset, chunk.byteLength));
391
+ } else if (chunk.code === CHUNK_SKIN) {
392
+ this.handlers.onSkin?.(readSkin(this.buffer, chunk.offset, chunk.byteLength), chunk.index);
393
+ } else if (chunk.code === CHUNK_ANIM) {
394
+ this.handlers.onClip?.(readClip(this.buffer, chunk.offset, chunk.byteLength), chunk.index);
395
+ } else if (chunk.code === CHUNK_LODM) {
396
+ /* The level the file gave it, not the order it happened to arrive in. */
397
+ this.handlers.onLod?.(readMesh(this.buffer, chunk), chunk.index);
398
+ } else if (chunk.code === CHUNK_MATL) {
399
+ const materials = readMaterials(this.buffer, chunk);
400
+ this.materialCount = materials.length;
401
+ this.handlers.onMaterials?.(materials);
402
+ } else if (chunk.code === CHUNK_TEXS) {
403
+ const texture = readTexture(this.buffer, chunk);
404
+ this.textureCount++;
405
+ this.handlers.onTexture?.(texture, this.textureOrdinal++);
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Read a `.drft` from a fetch response as it arrives.
413
+ *
414
+ * The whole point of the sequential design in one function: a plain `fetch` of a plain file,
415
+ * and the model improves as the bytes land. A response with no body — an error page, a
416
+ * cache miss served as a redirect — throws rather than resolving to an empty asset.
417
+ */
418
+ export async function streamDrft(
419
+ response: Response,
420
+ handlers: DrftStreamHandlers = {},
421
+ ): Promise<void> {
422
+ if (!response.ok) {
423
+ throw new DrftError(`fetching the asset returned ${response.status} ${response.statusText}`);
424
+ }
425
+ const body = response.body;
426
+ if (body === null) throw new DrftError('the response carried no body to stream');
427
+
428
+ const stream = new DrftStream(handlers);
429
+ const reader = body.getReader();
430
+ for (;;) {
431
+ const next = await reader.read();
432
+ if (next.done) break;
433
+ if (next.value !== undefined) stream.push(next.value);
434
+ }
435
+ stream.end();
436
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `SUBS` — which substance each material is made of. Container 1.8.
3
+ *
4
+ * **`§16`'s second mechanism, and the whole point of it is that there is no code in between**: the
5
+ * baker reads `extras.substance` off a glTF material, this writes it, a consumer reads it back and
6
+ * hands the ids to `installChemistry().match`. An artist labels the oak in Blender and the log
7
+ * burns like oak in the game.
8
+ *
9
+ * **Additive, so it is free.** `FORMAT.md` rule 2: an optional chunk a reader does not know is
10
+ * skipped in silence, so a 1.7 reader opens a 1.8 file as the geometry it also holds. A game with
11
+ * no chemistry in it pays nothing for a file that carries labels.
12
+ *
13
+ * **One chunk for the whole file rather than one per material.** The chunk table's `index` is the
14
+ * ordinal *within a FourCC*, which `MORP` already found the hard way — a file where only the fourth
15
+ * material is labelled would carry a chunk at index 0, and a reader pairing by it would set the
16
+ * wrong material on fire. Carrying the ordinal per entry makes the pairing explicit and makes one
17
+ * chunk enough.
18
+ */
19
+ import { DrftError, align } from './drftFormat.ts';
20
+
21
+ export interface DrftSubstanceEntry {
22
+ /** The material ordinal this labels, as `MATL` numbers them. */
23
+ readonly material: number;
24
+ /** The substance id, matched **exactly** — see `installChemistry`, which never guesses. */
25
+ readonly substance: string;
26
+ }
27
+
28
+ export interface DrftSubs {
29
+ readonly entries: readonly DrftSubstanceEntry[];
30
+ }
31
+
32
+ const encoder = new TextEncoder();
33
+ const decoder = new TextDecoder();
34
+
35
+ /**
36
+ * `u32` count, then per entry: `u32` material, `u32` name bytes, then the UTF-8 name.
37
+ *
38
+ * Byte length rather than character count, because a name is text and text is not one byte a
39
+ * character — `chêne` is five characters and six bytes, and a reader counting characters would
40
+ * walk off the end of one entry into the next.
41
+ */
42
+ export function buildSubs(subs: DrftSubs): Uint8Array {
43
+ const names = subs.entries.map((entry) => encoder.encode(entry.substance));
44
+ let size = 4;
45
+ for (const name of names) size += 8 + name.byteLength;
46
+
47
+ const bytes = new Uint8Array(align(size));
48
+ const view = new DataView(bytes.buffer);
49
+ view.setUint32(0, subs.entries.length, true);
50
+ let at = 4;
51
+ for (let i = 0; i < subs.entries.length; i++) {
52
+ const name = names[i] as Uint8Array;
53
+ view.setUint32(at, (subs.entries[i] as DrftSubstanceEntry).material, true);
54
+ view.setUint32(at + 4, name.byteLength, true);
55
+ bytes.set(name, at + 8);
56
+ at += 8 + name.byteLength;
57
+ }
58
+ return bytes;
59
+ }
60
+
61
+ export function readSubs(buffer: ArrayBuffer, offset: number, byteLength: number): DrftSubs {
62
+ if (byteLength < 4) throw new DrftError('SUBS is too short to hold its count');
63
+ const view = new DataView(buffer, offset, byteLength);
64
+ const count = view.getUint32(0, true);
65
+
66
+ const entries: DrftSubstanceEntry[] = [];
67
+ let at = 4;
68
+ for (let i = 0; i < count; i++) {
69
+ if (at + 8 > byteLength) {
70
+ throw new DrftError(`SUBS declares ${count} entries and ends inside entry ${i}`);
71
+ }
72
+ const material = view.getUint32(at, true);
73
+ const length = view.getUint32(at + 4, true);
74
+ if (at + 8 + length > byteLength) {
75
+ throw new DrftError(
76
+ `SUBS entry ${i} names ${length} bytes and the chunk has ${byteLength - at - 8} left`,
77
+ );
78
+ }
79
+ entries.push({
80
+ material,
81
+ substance: decoder.decode(new Uint8Array(buffer, offset + at + 8, length)),
82
+ });
83
+ at += 8 + length;
84
+ }
85
+ return { entries };
86
+ }