@driftengine/drft 4.5.0 → 4.6.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.
package/src/drftStream.ts CHANGED
@@ -19,9 +19,17 @@ import {
19
19
  CHUNK_ANIM,
20
20
  CHUNK_NODE,
21
21
  CHUNK_INST,
22
+ CHUNK_REGN,
23
+ CHUNK_LVOL,
22
24
  CHUNK_LITE,
23
25
  CHUNK_SDFV,
26
+ CHUNK_COLL,
27
+ CHUNK_NAVM,
28
+ CHUNK_ENTS,
24
29
  CHUNK_MSHQ,
30
+ CHUNK_KITS,
31
+ CHUNK_MSHC,
32
+ isMeshChunk,
25
33
  CHUNK_SKIN,
26
34
  CHUNK_MORP,
27
35
  } from './drftFormat.ts';
@@ -39,6 +47,17 @@ import { readSdfv } from './sdfv.ts';
39
47
  import type { DrftSdfvEntry } from './sdfv.ts';
40
48
  import type { DrftLight } from './drftLights.ts';
41
49
  import { decodeQuantisedMesh } from './drftQuantised.ts';
50
+ import { readColliders } from './drftColliders.ts';
51
+ import { expandAssembly } from './assemble.ts';
52
+ import type { PieceLookup } from './assemble.ts';
53
+ import { checkCopies, readAssembly, readKit } from './drftAssembly.ts';
54
+ import type { DrftAssembly } from './drftAssembly.ts';
55
+ import { RegionLedger, readRegion } from './drftRegions.ts';
56
+ import { readLightVolume } from './drftLightVolume.ts';
57
+ import type { DrftLightVolume } from './drftLightVolume.ts';
58
+ import type { DrftRegion } from './drftRegions.ts';
59
+ import { type NavPolyMesh, readNavm } from './navm.ts';
60
+ import { type EntsScene, readEnts } from './ents.ts';
42
61
 
43
62
  /**
44
63
  * What the file says is coming, known from the first few kilobytes.
@@ -93,8 +112,25 @@ export interface DrftStreamHandlers {
93
112
  * stop drawing it in the same frame it starts drawing the model, or the two are both there.
94
113
  */
95
114
  readonly onLod?: (mesh: MeshData, level: number) => void;
96
- /** One mesh, in the order the file lays them out, with its ordinal in that order. */
115
+ /**
116
+ * One mesh, in the order the file lays them out, with its ordinal in that order. A kit piece is
117
+ * not one — it goes to `onPiece` — and an assembly is one only where `onAssembly` is absent, in
118
+ * which case it arrives here expanded.
119
+ */
97
120
  readonly onMesh?: (mesh: MeshData, ordinal: number) => void;
121
+ /**
122
+ * The ordinals of the kit's pieces, once, when `KITS` lands — ahead of the geometry, so a
123
+ * consumer knows which meshes are drawn only as copies before any of them arrives.
124
+ */
125
+ readonly onKit?: (pieces: readonly number[]) => void;
126
+ /** One kit piece, as it lands. The stream keeps it for the assemblies that copy it. */
127
+ readonly onPiece?: (mesh: MeshData, ordinal: number) => void;
128
+ /**
129
+ * One assembly as the file carried it, with a lookup for its pieces, which have all arrived. A
130
+ * consumer that pages its world keeps it this small and expands it (`expandAssembly`) when it is
131
+ * needed; without this handler the stream expands it and calls `onMesh`.
132
+ */
133
+ readonly onAssembly?: (assembly: DrftAssembly, ordinal: number, piece: PieceLookup) => void;
98
134
  /**
99
135
  * One block of a Gaussian splat capture, with its ordinal.
100
136
  *
@@ -117,6 +153,13 @@ export interface DrftStreamHandlers {
117
153
  * puts it, so a consumer knows a mesh is drawn many times before that mesh arrives.
118
154
  */
119
155
  readonly onInstances?: (groups: readonly DrftInstanceGroup[]) => void;
156
+ /**
157
+ * One region of a streamed world, when its `REGN` lands — ahead of the meshes it introduces, so
158
+ * a consumer knows a mesh is a region's level or prop before it arrives. See `drftRegions.ts`.
159
+ */
160
+ readonly onRegion?: (region: DrftRegion) => void;
161
+ /** A world's summed lights, once, when `LVOL` lands. See `drftLightVolume.ts`. */
162
+ readonly onLightVolume?: (volume: DrftLightVolume) => void;
120
163
  /** The lights the scene was authored with, once, when `LITE` lands. See `drftLights.ts`. */
121
164
  readonly onLights?: (lights: readonly DrftLight[]) => void;
122
165
  /**
@@ -136,6 +179,18 @@ export interface DrftStreamHandlers {
136
179
  * the geometry so this is usually possible.
137
180
  */
138
181
  readonly onClip?: (clip: AnimationClip, ordinal: number) => void;
182
+ /**
183
+ * The convex hulls the asset collides as, once, when `COLL` lands. See `drftColliders.ts`.
184
+ *
185
+ * These three — collision, the navigation mesh and the entities — were `readDrft`'s alone, so a
186
+ * scene that streamed its geometry had to fetch the file again, whole, to learn where it could
187
+ * walk and what stood in it.
188
+ */
189
+ readonly onColliders?: (hulls: readonly Float32Array[]) => void;
190
+ /** The navigation mesh, once, when `NAVM` lands. See `navm.ts`. */
191
+ readonly onNavigation?: (navigation: NavPolyMesh) => void;
192
+ /** The serialised entities, once, when `ENTS` lands. See `ents.ts`. */
193
+ readonly onEntities?: (entities: EntsScene) => void;
139
194
  readonly onMaterials?: (materials: readonly DrftMaterial[]) => void;
140
195
  readonly onTexture?: (texture: DrftTexture, ordinal: number) => void;
141
196
  }
@@ -171,10 +226,20 @@ export class DrftStream {
171
226
  private preludeBytes = 0;
172
227
  private received = 0;
173
228
  private chunks: DrftChunk[] = [];
229
+ /** What the regions so far claim, so a second claim on a mesh is refused as `readDrft` refuses it. */
230
+ private readonly regions = new RegionLedger();
174
231
  /** How many table entries have been reported complete, in table order. */
175
232
  private settled = 0;
176
233
  private meshOrdinal = 0;
177
234
  private textureOrdinal = 0;
235
+ /** The kit's pieces once `KITS` lands, and each piece as it arrives. */
236
+ private kit: Set<number> | null = null;
237
+ private readonly pieces = new Map<number, MeshData>();
238
+ private readonly pieceAt: PieceLookup = (o) => {
239
+ const piece = this.pieces.get(o);
240
+ if (piece === undefined) throw new DrftError(`kit piece ${o} has not arrived`);
241
+ return piece;
242
+ };
178
243
  private head: DrftHead | null = null;
179
244
  private meshCount = 0;
180
245
  private materialCount = 0;
@@ -350,7 +415,7 @@ export class DrftStream {
350
415
  * counted in `meshCount`, which is the number of parts — an outline is not a part, and
351
416
  * a readout saying "1 of 188 parts" would be counting the model twice.
352
417
  */
353
- if (chunk.code === CHUNK_MESH || chunk.code === CHUNK_MSHQ || chunk.code === CHUNK_LODM) {
418
+ if (isMeshChunk(chunk.code) || chunk.code === CHUNK_LODM) {
354
419
  meshBytes += chunk.byteLength;
355
420
  }
356
421
  if (chunk.code === CHUNK_TEXS) textureBytes += chunk.byteLength;
@@ -371,7 +436,7 @@ export class DrftStream {
371
436
  this.manifestSent = true;
372
437
  this.handlers.onManifest?.({
373
438
  totalBytes: this.buffer.byteLength,
374
- meshCount: this.chunks.filter((c) => c.code === CHUNK_MESH || c.code === CHUNK_MSHQ).length,
439
+ meshCount: this.chunks.filter((c) => isMeshChunk(c.code)).length,
375
440
  textureCount: this.chunks.filter((c) => c.code === CHUNK_TEXS).length,
376
441
  lodCount: this.chunks.filter((c) => c.code === CHUNK_LODM).length,
377
442
  splatBlockCount: this.chunks.filter((c) => c.code === CHUNK_SPLT).length,
@@ -400,7 +465,36 @@ export class DrftStream {
400
465
  ? readMesh(this.buffer, chunk)
401
466
  : decodeQuantisedMesh(this.buffer, chunk.offset, chunk.byteLength);
402
467
  this.meshCount++;
403
- this.handlers.onMesh?.(mesh, this.meshOrdinal++);
468
+ const ordinal = this.meshOrdinal++;
469
+ if (this.kit?.has(ordinal) === true) {
470
+ this.pieces.set(ordinal, mesh);
471
+ this.handlers.onPiece?.(mesh, ordinal);
472
+ } else {
473
+ this.handlers.onMesh?.(mesh, ordinal);
474
+ }
475
+ } else if (chunk.code === CHUNK_MSHC) {
476
+ const assembly = readAssembly(this.buffer, chunk.offset, chunk.byteLength);
477
+ this.meshCount++;
478
+ const ordinal = this.meshOrdinal++;
479
+ if (this.kit?.has(ordinal) === true) {
480
+ throw new DrftError(`mesh ${ordinal} is a kit piece and an assembly`);
481
+ }
482
+ checkCopies(assembly, ordinal, this.kit ?? new Set(), (o) => this.pieces.get(o));
483
+ if (this.handlers.onAssembly !== undefined) {
484
+ this.handlers.onAssembly(assembly, ordinal, this.pieceAt);
485
+ } else {
486
+ this.handlers.onMesh?.(expandAssembly(assembly, this.pieceAt), ordinal);
487
+ }
488
+ } else if (chunk.code === CHUNK_KITS) {
489
+ const meshes = this.chunks.filter((c) => isMeshChunk(c.code)).length;
490
+ const kit = readKit(this.buffer, chunk.offset, chunk.byteLength, meshes);
491
+ /* A piece already handed over as a mesh has been drawn; the writer puts the kit first. */
492
+ const early = kit.find((o) => o < this.meshOrdinal);
493
+ if (early !== undefined) {
494
+ throw new DrftError(`KITS arrives after mesh ${early}, one of its pieces`);
495
+ }
496
+ this.kit = new Set(kit);
497
+ this.handlers.onKit?.(kit);
404
498
  } else if (chunk.code === CHUNK_SPLT) {
405
499
  /* The ordinal the file gave it, so blocks append in the writer's order however they
406
500
  arrived — which for a sequential fetch is the same order, and for a range-fetching
@@ -417,12 +511,23 @@ export class DrftStream {
417
511
  } else if (chunk.code === CHUNK_NODE) {
418
512
  this.handlers.onNodes?.(readNodes(this.buffer, chunk.offset, chunk.byteLength));
419
513
  } else if (chunk.code === CHUNK_INST) {
420
- const meshes = this.chunks.filter(
421
- (c) => c.code === CHUNK_MESH || c.code === CHUNK_MSHQ,
422
- ).length;
514
+ const meshes = this.chunks.filter((c) => isMeshChunk(c.code)).length;
423
515
  this.handlers.onInstances?.(
424
516
  readInstances(this.buffer, chunk.offset, chunk.byteLength, meshes),
425
517
  );
518
+ } else if (chunk.code === CHUNK_REGN) {
519
+ const meshes = this.chunks.filter((c) => isMeshChunk(c.code)).length;
520
+ const region = readRegion(this.buffer, chunk.offset, chunk.byteLength, meshes);
521
+ this.regions.admit(region);
522
+ this.handlers.onRegion?.(region);
523
+ } else if (chunk.code === CHUNK_LVOL) {
524
+ this.handlers.onLightVolume?.(readLightVolume(this.buffer, chunk.offset, chunk.byteLength));
525
+ } else if (chunk.code === CHUNK_COLL) {
526
+ this.handlers.onColliders?.(readColliders(this.buffer, chunk.offset, chunk.byteLength));
527
+ } else if (chunk.code === CHUNK_NAVM) {
528
+ this.handlers.onNavigation?.(readNavm(this.buffer, chunk.offset, chunk.byteLength));
529
+ } else if (chunk.code === CHUNK_ENTS) {
530
+ this.handlers.onEntities?.(readEnts(this.buffer, chunk.offset, chunk.byteLength));
426
531
  } else if (chunk.code === CHUNK_LITE) {
427
532
  this.handlers.onLights?.(readLights(this.buffer, chunk.offset, chunk.byteLength));
428
533
  } else if (chunk.code === CHUNK_SDFV) {
package/src/drftWrite.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  ATTR_SPECULAR,
26
26
  ATTR_UVS,
27
27
  ATTR_WEIGHTS,
28
+ ATTR_LAYERS,
29
+ ATTR_CHANNEL,
28
30
  CHUNK_ENTRY_BYTES,
29
31
  CHUNK_HEAD,
30
32
  CHUNK_ANIM,
@@ -36,8 +38,12 @@ import {
36
38
  CHUNK_MORP,
37
39
  CHUNK_NODE,
38
40
  CHUNK_INST,
41
+ CHUNK_REGN,
42
+ CHUNK_LVOL,
39
43
  CHUNK_LITE,
40
44
  CHUNK_MSHQ,
45
+ CHUNK_KITS,
46
+ CHUNK_MSHC,
41
47
  CHUNK_SKIN,
42
48
  CHUNK_SPLT,
43
49
  SPLAT_BLOCK_PREFIX,
@@ -70,10 +76,32 @@ import { type NavPolyMesh, buildNavm } from './navm.ts';
70
76
  import { type DrftGraph, buildNgrf } from './ngrf.ts';
71
77
  import { buildColliders } from './drftColliders.ts';
72
78
  import { buildInstances } from './drftInstances.ts';
79
+ import { buildRegion, planRegions } from './drftRegions.ts';
80
+ import { buildLightVolume } from './drftLightVolume.ts';
81
+ import type { DrftLightVolume } from './drftLightVolume.ts';
82
+ import type { DrftRegion } from './drftRegions.ts';
83
+
84
+ /** Whether a region names a mesh, as a level or a prop. */
85
+ function regionNames(region: DrftRegion, mesh: number): boolean {
86
+ return (
87
+ region.levels.some((level) => level.meshes.includes(mesh)) ||
88
+ region.instances.some((group) => group.mesh === mesh)
89
+ );
90
+ }
73
91
  import type { DrftInstanceGroup } from './drftInstances.ts';
74
92
  import { buildLights } from './drftLights.ts';
75
93
  import type { DrftLight } from './drftLights.ts';
76
94
  import { encodeQuantisedMesh } from './drftQuantised.ts';
95
+ import { assemblyBounds } from './assemble.ts';
96
+ import {
97
+ buildAssembly,
98
+ buildKit,
99
+ checkAssembly,
100
+ checkCopies,
101
+ checkKit,
102
+ isAssembly,
103
+ } from './drftAssembly.ts';
104
+ import type { DrftAssembly } from './drftAssembly.ts';
77
105
 
78
106
  /** An image to embed, already compressed, with what its own header said about it. */
79
107
  export interface DrftTextureSource {
@@ -94,7 +122,18 @@ export interface DrftTextureSource {
94
122
  /** What a caller hands over to be baked. */
95
123
  export interface DrftSource {
96
124
  readonly head?: Partial<DrftHead>;
97
- readonly meshes: readonly MeshData[];
125
+ /**
126
+ * The meshes by ordinal. An assembly — painted copies of kit pieces — stands in a slot like any
127
+ * mesh and is written as one required `MSHC` chunk; see `drftAssembly.ts`.
128
+ */
129
+ readonly meshes: readonly (MeshData | DrftAssembly)[];
130
+ /**
131
+ * The ordinals of the meshes that are kit pieces, ascending: drawn only as copies inside the
132
+ * assemblies, never on their own. One required `KITS` chunk, ahead of the geometry. A piece
133
+ * comes before every assembly that copies it, is never an assembly itself, and is neither placed
134
+ * by `INST` nor named by a region.
135
+ */
136
+ readonly kit?: readonly number[];
98
137
  /**
99
138
  * One material per mesh, by ordinal, or absent for an asset that names none.
100
139
  *
@@ -168,6 +207,19 @@ export interface DrftSource {
168
207
  * a group names holds one copy; see `drftInstances.ts`.
169
208
  */
170
209
  readonly instances?: readonly DrftInstanceGroup[];
210
+ /**
211
+ * The regions of a streamed world: one required `REGN` chunk each, written just ahead of the
212
+ * meshes it introduces. The meshes no region names come first, then each region's new meshes in
213
+ * region order — the writer refuses any other order rather than renumbering what the regions and
214
+ * the materials point at. See `drftRegions.ts`.
215
+ */
216
+ readonly regions?: readonly DrftRegion[];
217
+ /**
218
+ * A world's fixed lights summed offline into one dense volume: one optional `LVOL` chunk, after
219
+ * the geometry, since the world draws lit by its exact lights until it lands. See
220
+ * `drftLightVolume.ts`.
221
+ */
222
+ readonly lightVolume?: DrftLightVolume;
171
223
  /** The lights the scene was authored with: one `LITE` chunk, optional. See `drftLights.ts`. */
172
224
  readonly lights?: readonly DrftLight[];
173
225
  /**
@@ -211,6 +263,13 @@ export interface DrftSource {
211
263
  * carry geometry with no scene at all — which is every baked model this format has ever held.
212
264
  */
213
265
  readonly entities?: EntsScene;
266
+ /**
267
+ * The textures ahead of the geometry rather than after it. A model wants its outline before its
268
+ * pictures, so a viewer sees a shape early; a world whose every region wears the same texture
269
+ * arrays wants the pictures before its first region, or that region waits for the file's end.
270
+ * What it costs a model that asks: its first part arrives later by the size of its pictures.
271
+ */
272
+ readonly texturesFirst?: boolean;
214
273
  }
215
274
 
216
275
  /**
@@ -315,8 +374,9 @@ function encodeString(value: string): Uint8Array {
315
374
 
316
375
  function buildHead(
317
376
  head: Partial<DrftHead> | undefined,
318
- meshes: readonly MeshData[],
377
+ meshes: readonly (MeshData | DrftAssembly)[],
319
378
  instances: readonly DrftInstanceGroup[] = [],
379
+ kit: ReadonlySet<number> = new Set(),
320
380
  ): PendingChunk {
321
381
  /*
322
382
  * Bounds are computed rather than taken on trust. A caller that gets them wrong would
@@ -331,8 +391,23 @@ function buildHead(
331
391
  let maxZ = -Infinity;
332
392
  const placed = new Map<number, Float32Array>();
333
393
  for (const group of instances) placed.set(group.mesh, group.transforms);
394
+ const pieceAt = (o: number): MeshData => meshes[o] as MeshData;
334
395
  for (let m = 0; m < meshes.length; m++) {
335
- const mesh = meshes[m] as MeshData;
396
+ /* A kit piece is drawn only where it is copied, and its copies are counted with them. */
397
+ if (kit.has(m)) continue;
398
+ const slot = meshes[m] as MeshData | DrftAssembly;
399
+ if (isAssembly(slot)) {
400
+ const b = assemblyBounds(slot, pieceAt);
401
+ if (!((b[0] as number) <= (b[3] as number))) continue;
402
+ minX = Math.min(minX, b[0] as number);
403
+ minY = Math.min(minY, b[1] as number);
404
+ minZ = Math.min(minZ, b[2] as number);
405
+ maxX = Math.max(maxX, b[3] as number);
406
+ maxY = Math.max(maxY, b[4] as number);
407
+ maxZ = Math.max(maxZ, b[5] as number);
408
+ continue;
409
+ }
410
+ const mesh = slot;
336
411
  const transforms = placed.get(m);
337
412
  if (transforms === undefined) {
338
413
  for (let at = 0; at + 2 < mesh.positions.length; at += 3) {
@@ -446,6 +521,8 @@ function buildMesh(mesh: MeshData, code = CHUNK_MESH, flags = CHUNK_REQUIRED): P
446
521
  these two bits are always set together and the reader may rely on it. */
447
522
  if (mesh.joints !== undefined) attributes |= ATTR_JOINTS;
448
523
  if (mesh.weights !== undefined) attributes |= ATTR_WEIGHTS;
524
+ if (mesh.layers !== undefined) attributes |= ATTR_LAYERS;
525
+ if (mesh.channel !== undefined) attributes |= ATTR_CHANNEL;
449
526
 
450
527
  /* Order is frozen: mandatory arrays, then optional ones by ascending bit. */
451
528
  const arrays: (Float32Array | Uint32Array)[] = [
@@ -465,6 +542,10 @@ function buildMesh(mesh: MeshData, code = CHUNK_MESH, flags = CHUNK_REQUIRED): P
465
542
  container validated this pair for four minor versions while writing neither of them. */
466
543
  if (mesh.joints !== undefined) arrays.push(mesh.joints);
467
544
  if (mesh.weights !== undefined) arrays.push(mesh.weights);
545
+ /* Newest of all, 1.20, so last. See ATTR_LAYERS. */
546
+ if (mesh.layers !== undefined) arrays.push(mesh.layers);
547
+ /* And newer still, 1.23. See ATTR_CHANNEL. */
548
+ if (mesh.channel !== undefined) arrays.push(mesh.channel);
468
549
  arrays.push(mesh.indices);
469
550
 
470
551
  const PREFIX = 16;
@@ -628,7 +709,30 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
628
709
  * same asset less its textures rather than a refusal. Moving it earlier in the file changes
629
710
  * nothing about that, because a chunk is found through the table rather than by position.
630
711
  */
631
- const chunks: PendingChunk[] = [buildHead(source.head, source.meshes, source.instances)];
712
+ const kit = new Set(source.kit ?? []);
713
+ checkKit(source.kit ?? [], source.meshes.length);
714
+ source.meshes.forEach((slot, ordinal) => {
715
+ if (!isAssembly(slot)) return;
716
+ checkAssembly(slot, `mesh ${ordinal}`);
717
+ checkCopies(slot, ordinal, kit, (o) => {
718
+ const piece = source.meshes[o];
719
+ return piece === undefined || isAssembly(piece) ? undefined : piece;
720
+ });
721
+ });
722
+ for (const o of kit) {
723
+ if (isAssembly(source.meshes[o] as MeshData | DrftAssembly)) {
724
+ throw new DrftError(`mesh ${o} is a kit piece and an assembly; a piece is vertices`);
725
+ }
726
+ if ((source.instances ?? []).some((g) => g.mesh === o)) {
727
+ throw new DrftError(`mesh ${o} is a kit piece and is placed by INST`);
728
+ }
729
+ if ((source.regions ?? []).some((r) => regionNames(r, o))) {
730
+ throw new DrftError(
731
+ `mesh ${o} is a kit piece and a region's; a piece is drawn only as copies`,
732
+ );
733
+ }
734
+ }
735
+ const chunks: PendingChunk[] = [buildHead(source.head, source.meshes, source.instances, kit)];
632
736
  /*
633
737
  * The outline first, ahead of even the paint. It is the one thing that puts a recognisable
634
738
  * whole object on screen, it is a few hundred kilobytes against tens of megabytes, and the
@@ -677,6 +781,11 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
677
781
  bytes: buildInstances(source.instances as readonly DrftInstanceGroup[]),
678
782
  });
679
783
  }
784
+ /* The kit ahead of the geometry, for the placements' reason: a streaming consumer must know a
785
+ mesh is a piece before it lands, or it has already drawn it at the origin. */
786
+ if (kit.size > 0) {
787
+ chunks.push({ code: CHUNK_KITS, flags: CHUNK_REQUIRED, bytes: buildKit(source.kit ?? []) });
788
+ }
680
789
  /* Small, and ahead of the geometry, so a streaming consumer can place its lamps early. */
681
790
  if ((source.lights?.length ?? 0) > 0) {
682
791
  chunks.push({ code: CHUNK_LITE, flags: 0, bytes: buildLights(source.lights ?? []) });
@@ -707,7 +816,22 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
707
816
  if (source.entities !== undefined) {
708
817
  chunks.push({ code: CHUNK_ENTS, flags: 0, bytes: buildEnts(source.entities) });
709
818
  }
819
+ if (source.texturesFirst === true) {
820
+ for (const texture of source.textures ?? []) chunks.push(buildTexture(texture));
821
+ }
822
+ const regionsBefore = planRegions(source.regions ?? [], source.meshes.length, (m) => {
823
+ const mesh = source.meshes[m];
824
+ return mesh !== undefined && isAssembly(mesh) ? mesh.pieces : undefined;
825
+ });
826
+ for (const group of source.instances ?? []) {
827
+ if ((source.regions ?? []).some((r) => regionNames(r, group.mesh))) {
828
+ throw new DrftError(`mesh ${group.mesh} is placed by INST and is also a region's`);
829
+ }
830
+ }
710
831
  source.meshes.forEach((mesh, ordinal) => {
832
+ for (const region of regionsBefore[ordinal] ?? []) {
833
+ chunks.push({ code: CHUNK_REGN, flags: CHUNK_REQUIRED, bytes: buildRegion(region) });
834
+ }
711
835
  /*
712
836
  * **Its deltas immediately before it, not after.** A streaming consumer uploads a mesh the
713
837
  * moment its chunk lands, so deltas arriving afterwards are deltas for geometry already on the
@@ -715,6 +839,10 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
715
839
  * them in hand when it builds. The mesh ordinal in the payload still does the pairing, so a
716
840
  * reader meeting them in another order is unaffected.
717
841
  */
842
+ if (isAssembly(mesh)) {
843
+ chunks.push({ code: CHUNK_MSHC, flags: CHUNK_REQUIRED, bytes: buildAssembly(mesh) });
844
+ return;
845
+ }
718
846
  if (mesh.morphTargets !== undefined && mesh.morphTargetCount !== undefined) {
719
847
  chunks.push({
720
848
  code: CHUNK_MORP,
@@ -733,7 +861,15 @@ export function writeDrft(source: DrftSource): ArrayBuffer {
733
861
  chunks.push(buildMesh(mesh));
734
862
  }
735
863
  });
736
- for (const texture of source.textures ?? []) chunks.push(buildTexture(texture));
864
+ for (const region of regionsBefore[source.meshes.length] ?? []) {
865
+ chunks.push({ code: CHUNK_REGN, flags: CHUNK_REQUIRED, bytes: buildRegion(region) });
866
+ }
867
+ if (source.lightVolume !== undefined) {
868
+ chunks.push({ code: CHUNK_LVOL, flags: 0, bytes: buildLightVolume(source.lightVolume) });
869
+ }
870
+ if (source.texturesFirst !== true) {
871
+ for (const texture of source.textures ?? []) chunks.push(buildTexture(texture));
872
+ }
737
873
  /* One `SUBS` for the file, and only where something was labelled — an empty chunk would cost
738
874
  sixteen bytes to say nothing, and rule 2 already covers its absence. */
739
875
  if ((source.substances?.length ?? 0) > 0) {
package/src/index.ts CHANGED
@@ -70,6 +70,46 @@ export { buildInstances, readInstances } from './drftInstances.ts';
70
70
  export { buildLights, readLights } from './drftLights.ts';
71
71
  export type { DrftLight, DrftLightKind } from './drftLights.ts';
72
72
  export type { DrftInstanceGroup } from './drftInstances.ts';
73
+ export {
74
+ REGION_MAX_LEVELS,
75
+ RegionLedger,
76
+ buildRegion,
77
+ planRegions,
78
+ readRegion,
79
+ } from './drftRegions.ts';
80
+ /* A mesh carried as painted copies of kit pieces, expanded where it is needed. The attribute bits
81
+ are a MESH's, which is what an assembly declares its optional arrays with. */
82
+ export {
83
+ ATTR_CHANNEL,
84
+ ATTR_EMISSIVE_COLOR,
85
+ ATTR_GRAIN,
86
+ ATTR_LAYERS,
87
+ ATTR_RELIEF,
88
+ ATTR_ROUGHNESS,
89
+ ATTR_SPECULAR,
90
+ ATTR_TANGENT,
91
+ ATTR_UVS,
92
+ } from './drftFormat.ts';
93
+ export {
94
+ ASSEMBLY_ATTRIBUTES,
95
+ COPY_MATRIX_FLOATS,
96
+ COPY_UV_FLOATS,
97
+ SURFACE,
98
+ SURFACE_FLOATS,
99
+ buildAssembly,
100
+ buildKit,
101
+ checkAssembly,
102
+ checkCopies,
103
+ isAssembly,
104
+ readAssembly,
105
+ readKit,
106
+ } from './drftAssembly.ts';
107
+ export type { DrftAssembly } from './drftAssembly.ts';
108
+ export { assemblyBounds, expandAssembly } from './assemble.ts';
109
+ export type { PieceLookup } from './assemble.ts';
110
+ export type { DrftRegion, DrftRegionCollision, DrftRegionLevel } from './drftRegions.ts';
111
+ export { buildLightVolume, readLightVolume } from './drftLightVolume.ts';
112
+ export type { DrftLightVolume } from './drftLightVolume.ts';
73
113
  /*
74
114
  * **`DTEX`: a material as a decode program over a latent, rather than as pictures.** The chunk
75
115
  * carries no semantics — this package is the container and does not know what a channel means —
package/src/meshData.ts CHANGED
@@ -107,6 +107,25 @@ export interface MeshData {
107
107
  * separates asphalt from plaster, and the pass states it: see `Renderer.setSurfaceRelief`.
108
108
  */
109
109
  relief?: Float32Array;
110
+ /**
111
+ * Which image of a texture array each vertex's face wears: one whole number a vertex, 0 the first
112
+ * layer. Absent means layer 0, which is the only layer a plain texture has.
113
+ *
114
+ * **What it is for is a merged mesh wearing many images in one draw.** A block of forty buildings
115
+ * with forty facades is forty materials — forty draws, forty material changes — when each image
116
+ * is its own texture; with the facades as the layers of one array and each face naming its layer,
117
+ * it is one. `Renderer.createSurfaceTextureArray` builds the array, and every map of a material
118
+ * (albedo, normal, ORM, emissive) is read at the same layer, so an albedo and its emissive twin
119
+ * share an index.
120
+ *
121
+ * **It travels in the texture-coordinate attribute, as its third component**, because all
122
+ * sixteen of WebGL2's guaranteed vertex locations are spent in the instanced variant. So it needs
123
+ * `uvs` beside it and is refused without them: a layer with no coordinates addresses nothing.
124
+ *
125
+ * Whole numbers, checked, because the shader rounds to the nearest layer and a 1.5 would land on
126
+ * whichever side rounding favoured on that device.
127
+ */
128
+ layers?: Float32Array;
110
129
  /**
111
130
  * Four floats per vertex, and the only attribute whose lanes mean four different things.
112
131
  *
@@ -235,6 +254,25 @@ export function validateMeshData(data: MeshData): void {
235
254
  check('grain', data.grain, 1);
236
255
  check('relief', data.relief, 1);
237
256
  check('channel', data.channel, 4);
257
+ check('layers', data.layers, 1);
258
+ if (data.layers !== undefined) {
259
+ if (data.uvs === undefined) {
260
+ throw new Error(
261
+ 'Mesh: layers without uvs — a texture-array layer travels as the third texture coordinate, ' +
262
+ 'so a mesh that names layers has to supply the two it rides with',
263
+ );
264
+ }
265
+ const layers = data.layers;
266
+ for (let v = 0; v < layers.length; v++) {
267
+ const layer = layers[v] as number;
268
+ if (!(layer >= 0) || Math.floor(layer) !== layer) {
269
+ throw new Error(
270
+ `Mesh: layer ${layer} at vertex ${v} — a texture-array layer is a whole number at or ` +
271
+ 'above zero, and anything else lands on whichever layer the device rounds it to',
272
+ );
273
+ }
274
+ }
275
+ }
238
276
  /*
239
277
  * `tangents` was declared optional on 2026-08-22 and was not checked here until 2026-08-25, which
240
278
  * made the widest optional attribute in the format the one a short buffer could reach a driver