@driftengine/drft 3.63.0 → 4.0.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.
@@ -0,0 +1,185 @@
1
+ import { DrftError, align } from './drftFormat.ts';
2
+
3
+ /**
4
+ * `MSHL`: the clusters a mesh draws as, and the level-of-detail graph over them.
5
+ *
6
+ * **One chunk per level, not one for the whole graph.** A level is a complete alternative mesh —
7
+ * its own positions, its own indices, its own clusters — so folding every level into one chunk
8
+ * would mean one payload whose parts are only meaningful together. Separate chunks let a loader
9
+ * stream the coarsest level first and refine, which is the same argument `coarseFirst.ts` already
10
+ * makes for splats.
11
+ *
12
+ * ```
13
+ * u32 clusterCount
14
+ * u32 indexTotal
15
+ * u32 triangleOffsets[clusterCount] first triangle of each cluster
16
+ * u32 triangleCounts[clusterCount]
17
+ * f32 boundsCentre[clusterCount * 3]
18
+ * f32 boundsRadius[clusterCount]
19
+ * f32 coneAxis[clusterCount * 3] average face normal, normalised
20
+ * f32 coneCutoff[clusterCount] minimum dot of any face against the axis
21
+ * f32 ownError[clusterCount] this cluster's geometric error
22
+ * f32 parentError[clusterCount] the error of what replaces it one level up
23
+ * u32 indices[indexTotal]
24
+ * ```
25
+ *
26
+ * **No per-chunk version field**, matching `COLL` and every other chunk here: the file's own
27
+ * version covers the format and a second one would be a second thing to keep in step.
28
+ *
29
+ * **`parentError` may be infinite**, which is how the coarsest level says it has no parent. A
30
+ * float32 carries that exactly, so a reader needs no sentinel and a comparison against it needs
31
+ * no special case.
32
+ */
33
+
34
+ /** How many clusters one level may carry. Well past what a streaming budget permits. */
35
+ export const MAX_CLUSTERS = 1 << 20;
36
+
37
+ export interface MeshletLevel {
38
+ triangleOffsets: Uint32Array;
39
+ triangleCounts: Uint32Array;
40
+ boundsCentre: Float32Array;
41
+ boundsRadius: Float32Array;
42
+ coneAxis: Float32Array;
43
+ coneCutoff: Float32Array;
44
+ ownError: Float32Array;
45
+ parentError: Float32Array;
46
+ indices: Uint32Array;
47
+ count: number;
48
+ }
49
+
50
+ const HEADER = 8;
51
+
52
+ function layoutOf(clusterCount: number, indexTotal: number) {
53
+ const triangleOffsets = HEADER;
54
+ const triangleCounts = triangleOffsets + clusterCount * 4;
55
+ const boundsCentre = triangleCounts + clusterCount * 4;
56
+ const boundsRadius = boundsCentre + clusterCount * 12;
57
+ const coneAxis = boundsRadius + clusterCount * 4;
58
+ const coneCutoff = coneAxis + clusterCount * 12;
59
+ const ownError = coneCutoff + clusterCount * 4;
60
+ const parentError = ownError + clusterCount * 4;
61
+ const indices = parentError + clusterCount * 4;
62
+ return {
63
+ triangleOffsets,
64
+ triangleCounts,
65
+ boundsCentre,
66
+ boundsRadius,
67
+ coneAxis,
68
+ coneCutoff,
69
+ ownError,
70
+ parentError,
71
+ indices,
72
+ size: indices + indexTotal * 4,
73
+ };
74
+ }
75
+
76
+ export function buildMeshlets(level: MeshletLevel): Uint8Array {
77
+ const { count } = level;
78
+ if (count > MAX_CLUSTERS) {
79
+ throw new DrftError(`${count} clusters exceeds the ${MAX_CLUSTERS} cap`);
80
+ }
81
+ if (level.triangleOffsets.length < count || level.triangleCounts.length < count) {
82
+ throw new DrftError('MSHL was given fewer cluster offsets or counts than clusters');
83
+ }
84
+ if (level.indices.length % 3 !== 0) {
85
+ throw new DrftError(
86
+ `MSHL was given ${level.indices.length} indices, which is not whole triangles`,
87
+ );
88
+ }
89
+
90
+ /*
91
+ * Every cluster's triangles are checked to lie inside the index array before anything is
92
+ * written. A cluster running past the end would otherwise be a subarray of arbitrary geometry on
93
+ * the way back in, which is the same failure `COLL` checks its starts table for.
94
+ */
95
+ const triangles = level.indices.length / 3;
96
+ for (let c = 0; c < count; c += 1) {
97
+ const at = level.triangleOffsets[c] as number;
98
+ const n = level.triangleCounts[c] as number;
99
+ if (at + n > triangles) {
100
+ throw new DrftError(`MSHL cluster ${c} runs past the ${triangles} triangles it was given`);
101
+ }
102
+ }
103
+
104
+ const layout = layoutOf(count, level.indices.length);
105
+ const bytes = new Uint8Array(align(layout.size));
106
+ const view = new DataView(bytes.buffer);
107
+ view.setUint32(0, count, true);
108
+ view.setUint32(4, level.indices.length, true);
109
+
110
+ new Uint32Array(bytes.buffer, layout.triangleOffsets, count).set(
111
+ level.triangleOffsets.subarray(0, count),
112
+ );
113
+ new Uint32Array(bytes.buffer, layout.triangleCounts, count).set(
114
+ level.triangleCounts.subarray(0, count),
115
+ );
116
+ new Float32Array(bytes.buffer, layout.boundsCentre, count * 3).set(
117
+ level.boundsCentre.subarray(0, count * 3),
118
+ );
119
+ new Float32Array(bytes.buffer, layout.boundsRadius, count).set(
120
+ level.boundsRadius.subarray(0, count),
121
+ );
122
+ new Float32Array(bytes.buffer, layout.coneAxis, count * 3).set(
123
+ level.coneAxis.subarray(0, count * 3),
124
+ );
125
+ new Float32Array(bytes.buffer, layout.coneCutoff, count).set(level.coneCutoff.subarray(0, count));
126
+ new Float32Array(bytes.buffer, layout.ownError, count).set(level.ownError.subarray(0, count));
127
+ new Float32Array(bytes.buffer, layout.parentError, count).set(
128
+ level.parentError.subarray(0, count),
129
+ );
130
+ new Uint32Array(bytes.buffer, layout.indices, level.indices.length).set(level.indices);
131
+ return bytes;
132
+ }
133
+
134
+ /**
135
+ * The level an `MSHL` chunk carries, as views over the fetched buffer.
136
+ *
137
+ * Views rather than copies, like every other chunk here, so a load allocates nothing beyond the
138
+ * object holding them.
139
+ */
140
+ export function readMeshlets(
141
+ buffer: ArrayBuffer,
142
+ offset: number,
143
+ byteLength: number,
144
+ ): MeshletLevel {
145
+ if (byteLength < HEADER) throw new DrftError('MSHL is too short to hold its counts');
146
+ const view = new DataView(buffer, offset, byteLength);
147
+ const count = view.getUint32(0, true);
148
+ const indexTotal = view.getUint32(4, true);
149
+
150
+ if (count > MAX_CLUSTERS) {
151
+ throw new DrftError(`MSHL claims ${count} clusters, over the ${MAX_CLUSTERS} cap`);
152
+ }
153
+ if (indexTotal % 3 !== 0) {
154
+ throw new DrftError(`MSHL claims ${indexTotal} indices, which is not whole triangles`);
155
+ }
156
+
157
+ const layout = layoutOf(count, indexTotal);
158
+ if (byteLength < layout.size) {
159
+ throw new DrftError(
160
+ `MSHL claims ${count} clusters and ${indexTotal} indices, which runs past the chunk`,
161
+ );
162
+ }
163
+
164
+ const triangleOffsets = new Uint32Array(buffer, offset + layout.triangleOffsets, count);
165
+ const triangleCounts = new Uint32Array(buffer, offset + layout.triangleCounts, count);
166
+ const triangles = indexTotal / 3;
167
+ for (let c = 0; c < count; c += 1) {
168
+ if ((triangleOffsets[c] as number) + (triangleCounts[c] as number) > triangles) {
169
+ throw new DrftError(`MSHL cluster ${c} runs past the ${triangles} triangles the chunk holds`);
170
+ }
171
+ }
172
+
173
+ return {
174
+ triangleOffsets,
175
+ triangleCounts,
176
+ boundsCentre: new Float32Array(buffer, offset + layout.boundsCentre, count * 3),
177
+ boundsRadius: new Float32Array(buffer, offset + layout.boundsRadius, count),
178
+ coneAxis: new Float32Array(buffer, offset + layout.coneAxis, count * 3),
179
+ coneCutoff: new Float32Array(buffer, offset + layout.coneCutoff, count),
180
+ ownError: new Float32Array(buffer, offset + layout.ownError, count),
181
+ parentError: new Float32Array(buffer, offset + layout.parentError, count),
182
+ indices: new Uint32Array(buffer, offset + layout.indices, indexTotal),
183
+ count,
184
+ };
185
+ }
package/src/drftRead.ts CHANGED
@@ -35,6 +35,9 @@ import {
35
35
  CHUNK_HEAD,
36
36
  CHUNK_LODM,
37
37
  CHUNK_LODF,
38
+ CHUNK_DTEX,
39
+ CHUNK_ENTS,
40
+ CHUNK_NAVM,
38
41
  CHUNK_SPLT,
39
42
  SPLAT_BLOCK_PREFIX,
40
43
  CHUNK_MESH,
@@ -53,11 +56,20 @@ import {
53
56
  CHUNK_NODE,
54
57
  CHUNK_SKIN,
55
58
  CHUNK_MORP,
59
+ CHUNK_NNET,
60
+ CHUNK_NGRF,
61
+ CHUNK_SDFV,
56
62
  CHUNK_SUBS,
57
63
  CHUNK_COLL,
58
64
  } from './drftFormat.ts';
59
65
  import type { DrftChunk, DrftHead, DrftMaterial, DrftSplatBlock } from './drftFormat.ts';
60
66
  import { type DrftSubs, readSubs } from './drftSubs.ts';
67
+ import { type DrftSdfv, type DrftSdfvEntry, readSdfv } from './sdfv.ts';
68
+ import { type DtexEntry, readDtex } from './dtex.ts';
69
+ import { type EntsScene, readEnts } from './ents.ts';
70
+ import { type NavPolyMesh, readNavm } from './navm.ts';
71
+ import { type DrftNetwork, type DrftNnet, readNnet } from './nnet.ts';
72
+ import { type DrftGraph, readNgrf } from './ngrf.ts';
61
73
  import { readColliders } from './drftColliders.ts';
62
74
 
63
75
  /** An embedded image, still compressed. Decoding is the consumer's, through `createImageBitmap`. */
@@ -108,6 +120,24 @@ export interface DrftAsset {
108
120
  * file's coarse-first order, so a consumer that draws a prefix of them draws a sparse whole.
109
121
  */
110
122
  readonly splats: DrftSplatBlock | null;
123
+ /**
124
+ * A decode program per material, or empty for a file that carries none.
125
+ *
126
+ * Each says which `MATL` entry it belongs to. **A reader that does not know this chunk skips it
127
+ * by its length** and loses only the material it could not have decoded, which is what keeps the
128
+ * format's freeze intact.
129
+ */
130
+ readonly dtex: readonly DtexEntry[];
131
+ /**
132
+ * A way across the scene, or `null` for a file that carries none.
133
+ *
134
+ * **`null` rather than an empty mesh**, because *nobody built one* and *there is nowhere to walk*
135
+ * are different facts, and a consumer that cannot tell them apart cannot decide whether to build
136
+ * one itself.
137
+ */
138
+ readonly navigation: NavPolyMesh | null;
139
+ /** The things in the scene, or `null` for a file that is an asset rather than a scene. */
140
+ readonly entities: EntsScene | null;
111
141
  /**
112
142
  * The asset's own hierarchy, or empty for a file carrying no `NODE`.
113
143
  *
@@ -129,6 +159,29 @@ export interface DrftAsset {
129
159
  * **exactly** and reports what it could not, rather than guessing.
130
160
  */
131
161
  readonly substances: readonly { readonly material: number; readonly substance: string }[];
162
+ /**
163
+ * The signed distance field of each mesh that carries one, or empty for a file with none.
164
+ *
165
+ * The `SDFV` chunk, added at 1.13. Views over the fetched buffer, so a file that carries fields
166
+ * for geometry a consumer never traces through costs one view apiece and no copy. Hand them to
167
+ * `composeGlobalField`, which is what turns per-object fields into a world around the camera.
168
+ */
169
+ readonly fields: readonly DrftSdfvEntry[];
170
+ /**
171
+ * The networks the file carries, each with the role a consumer asks for it by, or empty.
172
+ *
173
+ * The `NNET` chunk, added at 1.14. Weights are views over the fetched buffer, in the precision
174
+ * they were written in — half-precision bits stay bits, because the device uploads them as such.
175
+ */
176
+ readonly networks: readonly DrftNetwork[];
177
+ /**
178
+ * The graphs the file carries — networks built from operators — each with its role, or empty.
179
+ *
180
+ * The `NGRF` chunk, added at 1.15. Tensors are views over the fetched buffer where its alignment
181
+ * allows, in the precision they were written in. `@driftengine/texture` validates one before it
182
+ * runs, naming any operator it lacks.
183
+ */
184
+ readonly graphs: readonly DrftGraph[];
132
185
  /**
133
186
  * The convex hulls this asset collides as, or empty for a file carrying no `COLL`.
134
187
  *
@@ -300,11 +353,17 @@ export function readDrft(buffer: ArrayBuffer): DrftAsset {
300
353
  const textures: DrftTexture[] = [];
301
354
  let materials: DrftMaterial[] = [];
302
355
  const splatBlocks: { block: DrftSplatBlock; at: number }[] = [];
356
+ const dtex: DtexEntry[] = [];
357
+ let navigation: NavPolyMesh | null = null;
358
+ let entities: EntsScene | null = null;
303
359
  let nodes: DrftNode[] = [];
304
360
  const skins: DrftSkin[] = [];
305
361
  const clips: AnimationClip[] = [];
306
362
  const morphs: DrftMorph[] = [];
307
363
  let subs: DrftSubs | null = null;
364
+ let fields: DrftSdfv | null = null;
365
+ let nnet: DrftNnet | null = null;
366
+ let graphs: DrftGraph[] = [];
308
367
  let colliders: readonly Float32Array[] = [];
309
368
  const skipped: string[] = [];
310
369
  let head: DrftHead | null = null;
@@ -357,6 +416,10 @@ export function readDrft(buffer: ArrayBuffer): DrftAsset {
357
416
  else if (chunk.code === CHUNK_MATL) materials = readMaterials(buffer, chunk);
358
417
  else if (chunk.code === CHUNK_SPLT)
359
418
  splatBlocks.push({ block: readSplatBlock(buffer, chunk), at: chunk.index });
419
+ else if (chunk.code === CHUNK_DTEX) dtex.push(readDtex(buffer, chunk.offset, chunk.byteLength));
420
+ else if (chunk.code === CHUNK_NAVM)
421
+ navigation = readNavm(buffer, chunk.offset, chunk.byteLength);
422
+ else if (chunk.code === CHUNK_ENTS) entities = readEnts(buffer, chunk.offset, chunk.byteLength);
360
423
  else if (chunk.code === CHUNK_NODE) nodes = readNodes(buffer, chunk.offset, chunk.byteLength);
361
424
  else if (chunk.code === CHUNK_SKIN)
362
425
  skins.push(readSkin(buffer, chunk.offset, chunk.byteLength));
@@ -365,6 +428,9 @@ export function readDrft(buffer: ArrayBuffer): DrftAsset {
365
428
  else if (chunk.code === CHUNK_MORP)
366
429
  morphs.push(readMorph(buffer, chunk.offset, chunk.byteLength));
367
430
  else if (chunk.code === CHUNK_SUBS) subs = readSubs(buffer, chunk.offset, chunk.byteLength);
431
+ else if (chunk.code === CHUNK_SDFV) fields = readSdfv(buffer, chunk.offset, chunk.byteLength);
432
+ else if (chunk.code === CHUNK_NNET) nnet = readNnet(buffer, chunk.offset, chunk.byteLength);
433
+ else if (chunk.code === CHUNK_NGRF) graphs = readNgrf(buffer, chunk.offset, chunk.byteLength);
368
434
  else if (chunk.code === CHUNK_COLL)
369
435
  colliders = readColliders(buffer, chunk.offset, chunk.byteLength);
370
436
  /* Every other known chunk is defined but not yet carried; see docs/FORMAT.md phase table. */
@@ -377,8 +443,16 @@ export function readDrft(buffer: ArrayBuffer): DrftAsset {
377
443
  * for the absence of a chunk kind it never needed would be the format telling a consumer what
378
444
  * its scene may be made of.
379
445
  */
380
- if (meshes.length === 0 && splatBlocks.length === 0) {
381
- throw new DrftError('no MESH and no SPLT chunk — an asset with neither geometry nor a capture');
446
+ /* And networks, since 1.15: a converted model is weights and nothing else. */
447
+ if (
448
+ meshes.length === 0 &&
449
+ splatBlocks.length === 0 &&
450
+ (nnet?.networks.length ?? 0) === 0 &&
451
+ graphs.length === 0
452
+ ) {
453
+ throw new DrftError(
454
+ 'no MESH, SPLT, NNET or NGRF chunk — an asset with no geometry, capture or network',
455
+ );
382
456
  }
383
457
 
384
458
  /*
@@ -448,7 +522,13 @@ export function readDrft(buffer: ArrayBuffer): DrftAsset {
448
522
  materials,
449
523
  textures,
450
524
  substances: subs?.entries ?? [],
525
+ fields: fields?.entries ?? [],
526
+ networks: nnet?.networks ?? [],
527
+ graphs,
451
528
  colliders,
529
+ dtex,
530
+ navigation,
531
+ entities,
452
532
  splats: joinSplatBlocks(splatBlocks),
453
533
  skipped,
454
534
  versionMajor,
package/src/drftWrite.ts CHANGED
@@ -28,6 +28,9 @@ import {
28
28
  CHUNK_ENTRY_BYTES,
29
29
  CHUNK_HEAD,
30
30
  CHUNK_ANIM,
31
+ CHUNK_DTEX,
32
+ CHUNK_ENTS,
33
+ CHUNK_NAVM,
31
34
  CHUNK_LODM,
32
35
  CHUNK_LODF,
33
36
  CHUNK_MORP,
@@ -39,6 +42,9 @@ import {
39
42
  CHUNK_MESH,
40
43
  CHUNK_REQUIRED,
41
44
  CHUNK_COLL,
45
+ CHUNK_NNET,
46
+ CHUNK_NGRF,
47
+ CHUNK_SDFV,
42
48
  CHUNK_SUBS,
43
49
  CHUNK_TEXS,
44
50
  MATERIAL_ENTRY_BYTES,
@@ -53,6 +59,12 @@ import {
53
59
  import type { DrftHead, DrftMaterial, DrftSplats } from './drftFormat.ts';
54
60
  import { coarseFirstOrder } from './coarseFirst.ts';
55
61
  import { type DrftSubstanceEntry, buildSubs } from './drftSubs.ts';
62
+ import { type DrftSdfvEntry, buildSdfv } from './sdfv.ts';
63
+ import { type DrftNetwork, buildNnet } from './nnet.ts';
64
+ import { type DtexEntry, buildDtex } from './dtex.ts';
65
+ import { type EntsScene, buildEnts } from './ents.ts';
66
+ import { type NavPolyMesh, buildNavm } from './navm.ts';
67
+ import { type DrftGraph, buildNgrf } from './ngrf.ts';
56
68
  import { buildColliders } from './drftColliders.ts';
57
69
 
58
70
  /** An image to embed, already compressed, with what its own header said about it. */
@@ -91,6 +103,25 @@ export interface DrftSource {
91
103
  * than by position, so a file labelling only its fourth material says so.
92
104
  */
93
105
  readonly substances?: readonly DrftSubstanceEntry[];
106
+ /**
107
+ * The signed distance field of each mesh that has one, or absent for a file carrying none.
108
+ *
109
+ * Written as one `SDFV` chunk at 1.13. It pairs by the mesh ordinal carried per entry rather
110
+ * than by position, so a file with a field for only its fourth mesh says so — `SUBS`'s rule,
111
+ * and `MORP` is the chunk that found out why it is a rule.
112
+ */
113
+ readonly fields?: readonly DrftSdfvEntry[];
114
+ /**
115
+ * The networks this file carries, or absent for a file carrying none.
116
+ *
117
+ * Written as one `NNET` chunk at 1.14, each network found by its role rather than by position.
118
+ */
119
+ readonly networks?: readonly DrftNetwork[];
120
+ /**
121
+ * The graphs this file carries — networks built from operators rather than dense layers — or
122
+ * absent for a file carrying none. Written as one `NGRF` chunk at 1.15, each found by its role.
123
+ */
124
+ readonly graphs?: readonly DrftGraph[];
94
125
  /**
95
126
  * The convex hulls this asset collides as, or absent for a file that carries none.
96
127
  *
@@ -136,6 +167,29 @@ export interface DrftSource {
136
167
  * `CHUNK_SPLT` for why it is several chunks rather than one.
137
168
  */
138
169
  readonly splats?: DrftSplats;
170
+ /**
171
+ * A decode program per material, or absent for a file whose materials are ordinary.
172
+ *
173
+ * One `DTEX` chunk each, **paired to a `MATL` entry by the index inside the chunk** rather than
174
+ * by the order they appear. A file may carry one for some of its materials and not others — a
175
+ * scene where one surface came from a capture and the rest were authored — and a reader pairing
176
+ * by position would hand the wrong material the wrong texture with nothing to fail on.
177
+ */
178
+ readonly dtex?: readonly DtexEntry[];
179
+ /**
180
+ * A way across the scene, or absent for a file nobody navigates.
181
+ *
182
+ * The mesh carries the origin and cell size it was built at, because the numbers in it are cell
183
+ * indices and a reader without those has a mesh in the wrong units at the wrong place.
184
+ */
185
+ readonly navigation?: NavPolyMesh;
186
+ /**
187
+ * The things in the scene, as `serializeWorld` wrote them.
188
+ *
189
+ * A *scene*, not an asset: this is what a capture proposes or an editor saves, and a file may
190
+ * carry geometry with no scene at all — which is every baked model this format has ever held.
191
+ */
192
+ readonly entities?: EntsScene;
139
193
  }
140
194
 
141
195
  /**
@@ -442,8 +496,20 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
442
496
  * ordinary asset and the reader accepts it, so refusing to write one would be the two halves of
443
497
  * the format disagreeing about what a file may contain.
444
498
  */
445
- if (source.meshes.length === 0 && (source.splats === undefined || source.splats.count === 0)) {
446
- throw new DrftError('an asset must contain at least one mesh or a capture with splats in it');
499
+ /*
500
+ * **And networks, since 1.15**, on the same argument: a converted model is weights and nothing
501
+ * else, and refusing it for the absence of geometry would be the format telling a consumer what
502
+ * an asset may be made of.
503
+ */
504
+ if (
505
+ source.meshes.length === 0 &&
506
+ (source.splats === undefined || source.splats.count === 0) &&
507
+ (source.networks?.length ?? 0) === 0 &&
508
+ (source.graphs?.length ?? 0) === 0
509
+ ) {
510
+ throw new DrftError(
511
+ 'an asset must contain at least one mesh, a capture with splats in it, or a network',
512
+ );
447
513
  }
448
514
 
449
515
  const materials = source.materials;
@@ -508,6 +574,26 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
508
574
  for (const clip of source.clips ?? []) {
509
575
  chunks.push({ code: CHUNK_ANIM, flags: 0, bytes: buildClip(clip) });
510
576
  }
577
+ for (const entry of source.dtex ?? []) {
578
+ /*
579
+ * **Refused here rather than at the reader**, because a decode program for a material the file
580
+ * does not carry is a bake that went wrong, and the writer is where the author can still be
581
+ * told. A reader meeting one has nothing useful to do with it.
582
+ */
583
+ if (entry.material >= (source.materials?.length ?? 0)) {
584
+ throw new DrftError(
585
+ `drft: DTEX names material ${entry.material} and the file carries ` +
586
+ `${source.materials?.length ?? 0}`,
587
+ );
588
+ }
589
+ chunks.push({ code: CHUNK_DTEX, flags: 0, bytes: buildDtex(entry) });
590
+ }
591
+ if (source.navigation !== undefined) {
592
+ chunks.push({ code: CHUNK_NAVM, flags: 0, bytes: buildNavm(source.navigation) });
593
+ }
594
+ if (source.entities !== undefined) {
595
+ chunks.push({ code: CHUNK_ENTS, flags: 0, bytes: buildEnts(source.entities) });
596
+ }
511
597
  source.meshes.forEach((mesh, ordinal) => {
512
598
  /*
513
599
  * **Its deltas immediately before it, not after.** A streaming consumer uploads a mesh the
@@ -539,6 +625,30 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
539
625
  bytes: buildSubs({ entries: source.substances as readonly DrftSubstanceEntry[] }),
540
626
  });
541
627
  }
628
+ /* And one `SDFV`, on exactly the same terms. */
629
+ if ((source.fields?.length ?? 0) > 0) {
630
+ chunks.push({
631
+ code: CHUNK_SDFV,
632
+ flags: 0,
633
+ bytes: buildSdfv({ entries: source.fields as readonly DrftSdfvEntry[] }),
634
+ });
635
+ }
636
+ /* And one `NNET`, on the same terms again. */
637
+ if ((source.networks?.length ?? 0) > 0) {
638
+ chunks.push({
639
+ code: CHUNK_NNET,
640
+ flags: 0,
641
+ bytes: buildNnet({ networks: source.networks as readonly DrftNetwork[] }),
642
+ });
643
+ }
644
+ /* And one `NGRF`, for the networks a perceptron's table cannot describe. */
645
+ if ((source.graphs?.length ?? 0) > 0) {
646
+ chunks.push({
647
+ code: CHUNK_NGRF,
648
+ flags: 0,
649
+ bytes: buildNgrf(source.graphs as readonly DrftGraph[]),
650
+ });
651
+ }
542
652
 
543
653
  /*
544
654
  * `COLL` last among the small chunks, because collision is the one thing a viewer never needs and