@driftengine/assets 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 (100) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +9 -0
  4. package/dist/assetPath.d.ts +51 -0
  5. package/dist/assetPath.js +59 -0
  6. package/dist/coarseLevel.d.ts +94 -0
  7. package/dist/coarseLevel.js +606 -0
  8. package/dist/dds.d.ts +41 -0
  9. package/dist/dds.js +425 -0
  10. package/dist/drawKey.d.ts +64 -0
  11. package/dist/drawKey.js +42 -0
  12. package/dist/drftLoader.d.ts +420 -0
  13. package/dist/drftLoader.js +996 -0
  14. package/dist/drftTextures.d.ts +87 -0
  15. package/dist/drftTextures.js +138 -0
  16. package/dist/fbx.d.ts +86 -0
  17. package/dist/fbx.js +792 -0
  18. package/dist/fbxHarness.d.ts +68 -0
  19. package/dist/fbxHarness.js +185 -0
  20. package/dist/fbxInflate.d.ts +60 -0
  21. package/dist/fbxInflate.js +204 -0
  22. package/dist/fbxMaterial.d.ts +87 -0
  23. package/dist/fbxMaterial.js +345 -0
  24. package/dist/fbxRig.d.ts +130 -0
  25. package/dist/fbxRig.js +601 -0
  26. package/dist/gltf.d.ts +242 -0
  27. package/dist/gltf.js +904 -0
  28. package/dist/gltfAccessor.d.ts +2 -0
  29. package/dist/gltfAccessor.js +106 -0
  30. package/dist/gltfSkin.d.ts +32 -0
  31. package/dist/gltfSkin.js +144 -0
  32. package/dist/iesPhotometry.d.ts +44 -0
  33. package/dist/iesPhotometry.js +94 -0
  34. package/dist/imageInfo.d.ts +31 -0
  35. package/dist/imageInfo.js +119 -0
  36. package/dist/index.d.ts +58 -0
  37. package/dist/index.js +132 -0
  38. package/dist/kn5.d.ts +158 -0
  39. package/dist/kn5.js +557 -0
  40. package/dist/loadProgress.d.ts +118 -0
  41. package/dist/loadProgress.js +1 -0
  42. package/dist/localise.d.ts +35 -0
  43. package/dist/localise.js +253 -0
  44. package/dist/obj.d.ts +54 -0
  45. package/dist/obj.js +335 -0
  46. package/dist/orient.d.ts +104 -0
  47. package/dist/orient.js +241 -0
  48. package/dist/radianceHdr.d.ts +36 -0
  49. package/dist/radianceHdr.js +172 -0
  50. package/dist/readModel.d.ts +153 -0
  51. package/dist/readModel.js +386 -0
  52. package/dist/recognise.d.ts +38 -0
  53. package/dist/recognise.js +97 -0
  54. package/dist/stl.d.ts +22 -0
  55. package/dist/stl.js +131 -0
  56. package/dist/tangentOrder.d.ts +24 -0
  57. package/dist/tangentOrder.js +31 -0
  58. package/dist/threemf.d.ts +46 -0
  59. package/dist/threemf.js +254 -0
  60. package/dist/uploadBudget.d.ts +82 -0
  61. package/dist/uploadBudget.js +86 -0
  62. package/dist/usd.d.ts +49 -0
  63. package/dist/usd.js +542 -0
  64. package/dist/weld.d.ts +44 -0
  65. package/dist/weld.js +237 -0
  66. package/dist/zip.d.ts +35 -0
  67. package/dist/zip.js +110 -0
  68. package/package.json +61 -0
  69. package/src/assetPath.ts +79 -0
  70. package/src/coarseLevel.ts +720 -0
  71. package/src/dds.ts +507 -0
  72. package/src/drawKey.ts +91 -0
  73. package/src/drftLoader.ts +1197 -0
  74. package/src/drftTextures.ts +153 -0
  75. package/src/fbx.ts +934 -0
  76. package/src/fbxHarness.ts +215 -0
  77. package/src/fbxInflate.ts +258 -0
  78. package/src/fbxMaterial.ts +410 -0
  79. package/src/fbxRig.ts +652 -0
  80. package/src/gltf.ts +1161 -0
  81. package/src/gltfAccessor.ts +127 -0
  82. package/src/gltfSkin.ts +210 -0
  83. package/src/iesPhotometry.ts +136 -0
  84. package/src/imageInfo.ts +130 -0
  85. package/src/index.ts +162 -0
  86. package/src/kn5.ts +705 -0
  87. package/src/loadProgress.ts +131 -0
  88. package/src/localise.ts +275 -0
  89. package/src/obj.ts +386 -0
  90. package/src/orient.ts +262 -0
  91. package/src/radianceHdr.ts +195 -0
  92. package/src/readModel.ts +515 -0
  93. package/src/recognise.ts +124 -0
  94. package/src/stl.ts +147 -0
  95. package/src/tangentOrder.ts +32 -0
  96. package/src/threemf.ts +303 -0
  97. package/src/uploadBudget.ts +90 -0
  98. package/src/usd.ts +642 -0
  99. package/src/weld.ts +264 -0
  100. package/src/zip.ts +130 -0
package/src/weld.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Collapse a triangle soup into shared vertices.
3
+ *
4
+ * Importers produce one vertex per triangle *corner*, because that is how the source
5
+ * formats index their attributes: FBX and OBJ address positions, normals and UVs through
6
+ * separate index lists, and the only way to flatten that into one array per attribute is
7
+ * to give every corner its own vertex. A real model comes out five to eight times larger
8
+ * than it is — a BMW measured 4.57M corners for perhaps 600k distinct vertices.
9
+ *
10
+ * **Corners are only merged when everything about them matches**, not just the position.
11
+ * Two corners at the same point with different normals are a hard edge, and welding them
12
+ * would round it off — the seam down a car's bonnet would soften into a dent. That is a
13
+ * change to the model, made silently, which is exactly what a reader must not do.
14
+ *
15
+ * Offline work, so it may allocate. Nothing here runs per frame.
16
+ */
17
+
18
+ import type { MeshData } from '@driftengine/drft';
19
+
20
+ /**
21
+ * How finely positions are compared, in units of the model.
22
+ *
23
+ * Exact bit equality is the wrong test: two corners meant to be the same point routinely
24
+ * differ in the last place after a transform, so exact matching welds almost nothing on
25
+ * exactly the files that need it most. Quantising to a millionth is far below anything a
26
+ * model expresses and far above float noise.
27
+ */
28
+ const QUANTUM = 1e-6;
29
+
30
+ function quantise(value: number): number {
31
+ return Math.round(value / QUANTUM);
32
+ }
33
+
34
+ /**
35
+ * Every optional attribute, and how many floats each spends on a vertex.
36
+ *
37
+ * **One table, read by both halves of the weld** — the key that decides whether two corners are
38
+ * the same vertex, and the copy that writes the survivors. They were two lists written out by
39
+ * hand, and what that cost is the reason this exists: `MeshData` gained a tangent frame, relief,
40
+ * a rig and morph deltas over four days in August and neither list was told about any of them,
41
+ * so for five releases a merged mesh came back **without** its rig, its tangents and its morph
42
+ * targets, and with corners merged across the bones they were weighted to. Nothing raised: an
43
+ * unskinned mesh is a valid mesh, and it draws at its bind pose.
44
+ *
45
+ * **The `satisfies` is the load-bearing part and not decoration.** It names every optional
46
+ * per-vertex attribute the format has, so the day a new one is added to `MeshData` this file
47
+ * stops compiling until it is listed here. A table that can be forgotten is exactly the thing
48
+ * that was already forgotten twice.
49
+ */
50
+ const ATTRIBUTES = {
51
+ uvs: 2,
52
+ specular: 1,
53
+ roughness: 1,
54
+ grain: 1,
55
+ relief: 1,
56
+ emissiveColor: 3,
57
+ tangents: 4,
58
+ joints: 4,
59
+ weights: 4,
60
+ /*
61
+ * The four-lane per-vertex channel. It joins the key above as well as the copy below, which is
62
+ * the behaviour that matters: two vertices at one position whose sway or sky exposure differ
63
+ * are not the same vertex, and welding them would silently keep whichever came first. A leaf
64
+ * tip merged into the trunk it grows from would stop moving.
65
+ */
66
+ channel: 4,
67
+ } as const satisfies Record<PerVertexAttribute, number>;
68
+
69
+ /**
70
+ * Every optional `Float32Array` on `MeshData`, except the morph deltas.
71
+ *
72
+ * Morph targets are excluded because they are the one attribute whose stride is not fixed by the
73
+ * format — it is three floats times the mesh's own `morphTargetCount` — so they are keyed and
74
+ * copied a few lines below instead of through the table. `morphTargetCount` itself is a number
75
+ * and drops out of this type on its own.
76
+ */
77
+ type PerVertexAttribute = Exclude<
78
+ {
79
+ [K in keyof MeshData]-?: undefined extends MeshData[K]
80
+ ? MeshData[K] extends Float32Array | undefined
81
+ ? K
82
+ : never
83
+ : never;
84
+ }[keyof MeshData],
85
+ 'morphTargets'
86
+ >;
87
+
88
+ const NAMES = Object.keys(ATTRIBUTES) as readonly PerVertexAttribute[];
89
+
90
+ /**
91
+ * Weld one mesh. Returns the same mesh when nothing can be merged.
92
+ *
93
+ * **Every attribute decides identity, and that is a cost as well as a correctness rule.** Two
94
+ * corners alike in position, normal and UV but weighted to different bones are two vertices, and
95
+ * merging them moves geometry with the wrong limb; two alike in everything including their UV but
96
+ * opposite in the bitangent's sign are a mirrored UV shell, and merging them lights one side of
97
+ * the model inside out. Neither is visible in any count a bake prints.
98
+ *
99
+ * What it costs is a file whose tangent frame was *derived* per corner instead of authored: a
100
+ * corner soup gets one triangle's frame per corner, so no two corners agree and the weld that
101
+ * would have merged them cannot. **The way out is not a looser key — it is to derive the frame
102
+ * from the welded mesh**, which is what the baker does as of 3.30.0: it passes
103
+ * `deriveTangents: false` to the reader and calls `deriveTangentsFor` here, afterwards.
104
+ *
105
+ * **How much it costs depends on the unwrap, and two figures in this repository disagreed about
106
+ * it.** The frame is normalised, so two triangles produce different frames only where the
107
+ * *direction* u increases in differs between them. On a separable unwrap — u from x, v from y —
108
+ * every triangle on a flat surface agrees and a derived frame costs almost nothing; a 253-mesh CAD
109
+ * export measured 728,168 vertices against 728,161 with no frame, seven vertices, and that file is
110
+ * not on this machine. On a rotational unwrap the direction turns with position and almost nothing
111
+ * merges: 9,600 corners to 9,482 vertices against 1,681, six different frames at one point at
112
+ * worst, measured in `tangentOrder.test.ts` and reproducible. The order is fixed for the second
113
+ * case; the first is why it went unnoticed.
114
+ */
115
+ export function weldMesh(mesh: MeshData): MeshData {
116
+ const vertices = mesh.positions.length / 3;
117
+ if (vertices === 0) return mesh;
118
+
119
+ const map = new Map<string, number>();
120
+ const remap = new Uint32Array(vertices);
121
+ let unique = 0;
122
+
123
+ /* The attributes this mesh actually carries, resolved once and not per vertex. */
124
+ const present = NAMES.filter((name) => mesh[name] !== undefined);
125
+ /* Three floats per target per vertex, interleaved by vertex: `MeshData.morphTargets`' layout. */
126
+ const morphStride = mesh.morphTargets === undefined ? 0 : (mesh.morphTargetCount ?? 0) * 3;
127
+
128
+ for (let i = 0; i < vertices; i++) {
129
+ /*
130
+ * The key names every attribute, because two corners differing in any of them are
131
+ * genuinely different vertices — a shared position with a different normal is a hard
132
+ * edge, and with a different colour is a material boundary.
133
+ */
134
+ let key =
135
+ `${quantise(mesh.positions[i * 3] as number)},` +
136
+ `${quantise(mesh.positions[i * 3 + 1] as number)},` +
137
+ `${quantise(mesh.positions[i * 3 + 2] as number)}|` +
138
+ `${quantise(mesh.normals[i * 3] as number)},` +
139
+ `${quantise(mesh.normals[i * 3 + 1] as number)},` +
140
+ `${quantise(mesh.normals[i * 3 + 2] as number)}|` +
141
+ `${quantise(mesh.colors[i * 3] as number)},` +
142
+ `${quantise(mesh.colors[i * 3 + 1] as number)},` +
143
+ `${quantise(mesh.colors[i * 3 + 2] as number)}|` +
144
+ `${quantise(mesh.emissive[i] as number)}`;
145
+ for (const name of present) {
146
+ const array = mesh[name] as Float32Array;
147
+ const stride = ATTRIBUTES[name];
148
+ key += '|';
149
+ for (let c = 0; c < stride; c++) key += `${quantise(array[i * stride + c] as number)},`;
150
+ }
151
+ for (let c = 0; c < morphStride; c++) {
152
+ key += `${quantise(mesh.morphTargets?.[i * morphStride + c] as number)},`;
153
+ }
154
+
155
+ const found = map.get(key);
156
+ if (found === undefined) {
157
+ map.set(key, unique);
158
+ remap[i] = unique;
159
+ unique++;
160
+ } else {
161
+ remap[i] = found;
162
+ }
163
+ }
164
+
165
+ if (unique === vertices) return mesh;
166
+
167
+ const positions = new Float32Array(unique * 3);
168
+ const normals = new Float32Array(unique * 3);
169
+ const colors = new Float32Array(unique * 3);
170
+ const emissive = new Float32Array(unique);
171
+ const copies: Partial<Record<PerVertexAttribute, Float32Array>> = {};
172
+ for (const name of present) copies[name] = new Float32Array(unique * ATTRIBUTES[name]);
173
+ const morphTargets = morphStride === 0 ? undefined : new Float32Array(unique * morphStride);
174
+
175
+ const written = new Uint8Array(unique);
176
+ for (let i = 0; i < vertices; i++) {
177
+ const to = remap[i] as number;
178
+ if (written[to] === 1) continue;
179
+ written[to] = 1;
180
+ for (let c = 0; c < 3; c++) {
181
+ positions[to * 3 + c] = mesh.positions[i * 3 + c] as number;
182
+ normals[to * 3 + c] = mesh.normals[i * 3 + c] as number;
183
+ colors[to * 3 + c] = mesh.colors[i * 3 + c] as number;
184
+ }
185
+ emissive[to] = mesh.emissive[i] as number;
186
+ for (const name of present) {
187
+ const from = mesh[name] as Float32Array;
188
+ const into = copies[name] as Float32Array;
189
+ const stride = ATTRIBUTES[name];
190
+ for (let c = 0; c < stride; c++) into[to * stride + c] = from[i * stride + c] as number;
191
+ }
192
+ if (morphTargets !== undefined) {
193
+ for (let c = 0; c < morphStride; c++) {
194
+ morphTargets[to * morphStride + c] = mesh.morphTargets?.[i * morphStride + c] as number;
195
+ }
196
+ }
197
+ }
198
+
199
+ const indices = new Uint32Array(mesh.indices.length);
200
+ for (let i = 0; i < mesh.indices.length; i++) {
201
+ indices[i] = remap[mesh.indices[i] as number] as number;
202
+ }
203
+
204
+ return {
205
+ positions,
206
+ normals,
207
+ colors,
208
+ emissive,
209
+ indices,
210
+ ...copies,
211
+ /* Both or neither: an array of deltas with no count moves nothing, and a count with no
212
+ array is a promise about a buffer that is not there. */
213
+ ...(morphTargets === undefined
214
+ ? {}
215
+ : { morphTargets, morphTargetCount: mesh.morphTargetCount }),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Drop optional attributes that never vary from the engine's own default.
221
+ *
222
+ * An importer that has no material information should not invent one, and a writer should
223
+ * not spend a megabyte storing the same number four million times. `Mesh` already reads an
224
+ * absent attribute as a constant, so leaving it out is not a loss — it is the same shading
225
+ * through a cheaper path, with no buffer, no upload and no per-vertex fetch.
226
+ *
227
+ * The defaults are `Mesh`'s, and they are duplicated here on purpose rather than imported:
228
+ * this is a *bake-time* judgement about what is worth storing, and tying it to a rendering
229
+ * constant would make a change to one silently rewrite files produced by the other.
230
+ */
231
+ const DEFAULT_SPECULAR = 0;
232
+ const DEFAULT_ROUGHNESS = 0.4277;
233
+ const DEFAULT_EMISSIVE_COLOR = -1;
234
+ /** Absent grain means none, so an asset that is nowhere mineral stores nothing about it. */
235
+ const DEFAULT_GRAIN = 0;
236
+ /**
237
+ * Absent relief means a perfectly smooth surface, which is what every reader produces.
238
+ *
239
+ * Listed late and for the same reason the weld's table exists: relief landed on `MeshData` on
240
+ * 2026-08-21 and this function was not told, so a producer filling an array of zeroes stored four
241
+ * bytes a vertex saying "no relief" — the one thing an absent attribute already says.
242
+ */
243
+ const DEFAULT_RELIEF = 0;
244
+
245
+ export function dropDefaultAttributes(mesh: MeshData): MeshData {
246
+ const constant = (array: Float32Array | undefined, value: number): boolean => {
247
+ if (array === undefined) return false;
248
+ for (let i = 0; i < array.length; i++) {
249
+ if (Math.abs((array[i] as number) - value) > 1e-6) return false;
250
+ }
251
+ return true;
252
+ };
253
+
254
+ const out: MeshData = { ...mesh };
255
+ if (constant(mesh.specular, DEFAULT_SPECULAR)) delete (out as { specular?: unknown }).specular;
256
+ if (constant(mesh.roughness, DEFAULT_ROUGHNESS))
257
+ delete (out as { roughness?: unknown }).roughness;
258
+ if (constant(mesh.grain, DEFAULT_GRAIN)) delete (out as { grain?: unknown }).grain;
259
+ if (constant(mesh.relief, DEFAULT_RELIEF)) delete (out as { relief?: unknown }).relief;
260
+ if (constant(mesh.emissiveColor, DEFAULT_EMISSIVE_COLOR)) {
261
+ delete (out as { emissiveColor?: unknown }).emissiveColor;
262
+ }
263
+ return out;
264
+ }
package/src/zip.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Reading a zip container. One responsibility: name a file inside an archive, get its bytes.
3
+ *
4
+ * Shared by `.usdz` and `.3mf`, which is why it exists as its own module rather than inside
5
+ * either: both are zip archives of a documented layout, and a reader written twice is a
6
+ * reader that disagrees with itself once.
7
+ *
8
+ * **The central directory is read, not the local headers.** A zip can be scanned from the
9
+ * front by walking local file headers, and almost every naive reader does. It is wrong: a
10
+ * local header may declare a size of zero and defer the real figures to a data descriptor
11
+ * *after* the payload, which cannot be found without already knowing where the payload ends.
12
+ * The central directory at the back of the file is the authority, always carries the sizes,
13
+ * and is what the specification says to use.
14
+ *
15
+ * **Inflate is injected**, for the same reason as in `fbx.ts`: this module is part of an
16
+ * engine that runs in a browser and must not reach for Node's `zlib`. `.usdz` never needs it,
17
+ * because the format *requires* its entries to be stored uncompressed so they can be memory
18
+ * mapped, and that is why a USDZ can be read with no decompressor at all. `.3mf` is ordinary
19
+ * deflate and needs one.
20
+ */
21
+
22
+ import { DrftError } from '@driftengine/drft';
23
+ import type { Inflate } from './fbx.ts';
24
+
25
+ /** One file in the archive. `bytes` is decompressed and ready to read. */
26
+ export interface ZipEntry {
27
+ readonly name: string;
28
+ readonly bytes: Uint8Array;
29
+ }
30
+
31
+ /** Signature of a central directory record, little-endian: `PK`. */
32
+ const CENTRAL_SIGNATURE = 0x02014b50;
33
+ /** Signature of the end-of-central-directory record: `PK`. */
34
+ const END_SIGNATURE = 0x06054b50;
35
+ /** The two compression methods that appear in practice. Anything else is refused by number. */
36
+ const STORED = 0;
37
+ const DEFLATED = 8;
38
+
39
+ /**
40
+ * Find the end-of-central-directory record, which is the only fixed point in a zip.
41
+ *
42
+ * It sits at the very back, except that it carries a trailing comment of arbitrary length, so
43
+ * it has to be searched for backwards. The comment is at most 65535 bytes by definition, so
44
+ * the search is bounded rather than a scan of the whole file.
45
+ */
46
+ function findEnd(view: DataView, length: number): number {
47
+ const earliest = Math.max(0, length - 22 - 0xffff);
48
+ for (let at = length - 22; at >= earliest; at--) {
49
+ if (view.getUint32(at, true) === END_SIGNATURE) return at;
50
+ }
51
+ throw new DrftError('zip: no end-of-central-directory record, so this is not a zip archive');
52
+ }
53
+
54
+ /**
55
+ * Every file in an archive, decompressed.
56
+ *
57
+ * Directory entries are skipped: they are zero-length records whose names end in a slash, and
58
+ * a caller asking for files does not want them.
59
+ */
60
+ export function readZip(buffer: ArrayBuffer, inflate: Inflate | null = null): ZipEntry[] {
61
+ const bytes = new Uint8Array(buffer);
62
+ const view = new DataView(buffer);
63
+ if (buffer.byteLength < 22) throw new DrftError('zip: shorter than an empty archive');
64
+
65
+ const end = findEnd(view, buffer.byteLength);
66
+ const count = view.getUint16(end + 10, true);
67
+ let at = view.getUint32(end + 16, true);
68
+
69
+ const out: ZipEntry[] = [];
70
+ const decoder = new TextDecoder();
71
+ for (let i = 0; i < count; i++) {
72
+ if (at + 46 > buffer.byteLength || view.getUint32(at, true) !== CENTRAL_SIGNATURE) {
73
+ throw new DrftError(`zip: the central directory entry ${i} is malformed at byte ${at}`);
74
+ }
75
+ const method = view.getUint16(at + 10, true);
76
+ const compressedSize = view.getUint32(at + 20, true);
77
+ const uncompressedSize = view.getUint32(at + 24, true);
78
+ const nameLength = view.getUint16(at + 28, true);
79
+ const extraLength = view.getUint16(at + 30, true);
80
+ const commentLength = view.getUint16(at + 32, true);
81
+ const localAt = view.getUint32(at + 42, true);
82
+ const name = decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength));
83
+ at += 46 + nameLength + extraLength + commentLength;
84
+
85
+ /* A directory, which carries no payload and is not a file anybody asked for. */
86
+ if (name.endsWith('/')) continue;
87
+
88
+ /*
89
+ * The local header's *own* name and extra lengths, which differ from the central
90
+ * directory's. Reusing the central figures here is the classic zip bug: the extra field
91
+ * is routinely a different length in the two places, so the payload starts somewhere
92
+ * else and the entry decompresses to noise.
93
+ */
94
+ if (localAt + 30 > buffer.byteLength || view.getUint32(localAt, true) !== 0x04034b50) {
95
+ throw new DrftError(`zip: "${name}" points at byte ${localAt}, which is not a local header`);
96
+ }
97
+ const localNameLength = view.getUint16(localAt + 26, true);
98
+ const localExtraLength = view.getUint16(localAt + 28, true);
99
+ const from = localAt + 30 + localNameLength + localExtraLength;
100
+ if (from + compressedSize > buffer.byteLength) {
101
+ throw new DrftError(
102
+ `zip: "${name}" claims ${compressedSize} bytes and runs past the archive`,
103
+ );
104
+ }
105
+ const payload = bytes.subarray(from, from + compressedSize);
106
+
107
+ if (method === STORED) {
108
+ out.push({ name, bytes: payload });
109
+ continue;
110
+ }
111
+ if (method === DEFLATED) {
112
+ if (inflate === null) {
113
+ throw new DrftError(
114
+ `zip: "${name}" is deflate-compressed and this reader was given no decompressor`,
115
+ );
116
+ }
117
+ out.push({ name, bytes: inflate(payload, uncompressedSize) });
118
+ continue;
119
+ }
120
+ throw new DrftError(
121
+ `zip: "${name}" uses compression method ${method}, and only stored and deflate are read`,
122
+ );
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** The first entry whose name matches, or null. Case-insensitive, since archives disagree. */
128
+ export function findEntry(entries: readonly ZipEntry[], match: RegExp): ZipEntry | null {
129
+ return entries.find((entry) => match.test(entry.name)) ?? null;
130
+ }