@driftengine/assets 4.2.1 → 4.4.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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/bake/collapseHeap.d.ts +24 -0
  3. package/dist/bake/collapseHeap.js +80 -0
  4. package/dist/bake/collapseMeasures.d.ts +31 -0
  5. package/dist/bake/collapseMeasures.js +183 -0
  6. package/dist/bake/sceneField.d.ts +6 -0
  7. package/dist/bake/sceneField.js +48 -0
  8. package/dist/bake/sdf.d.ts +5 -5
  9. package/dist/bake/sdf.js +15 -95
  10. package/dist/bake/simplify.d.ts +42 -0
  11. package/dist/bake/simplify.js +350 -0
  12. package/dist/bake/surfaceDepth.d.ts +29 -0
  13. package/dist/bake/surfaceDepth.js +101 -0
  14. package/dist/downscale.d.ts +25 -0
  15. package/dist/downscale.js +50 -0
  16. package/dist/drawKey.d.ts +13 -1
  17. package/dist/drawKey.js +5 -3
  18. package/dist/drftLoader.d.ts +38 -7
  19. package/dist/drftLoader.js +186 -30
  20. package/dist/fieldPlacement.d.ts +22 -0
  21. package/dist/fieldPlacement.js +49 -0
  22. package/dist/gltf.d.ts +46 -1
  23. package/dist/gltf.js +101 -2
  24. package/dist/index.d.ts +9 -1
  25. package/dist/index.js +15 -1
  26. package/dist/loadProgress.d.ts +22 -1
  27. package/dist/orient.d.ts +9 -1
  28. package/dist/orient.js +60 -2
  29. package/dist/partMaterial.d.ts +19 -0
  30. package/dist/partMaterial.js +16 -0
  31. package/dist/readModel.d.ts +3 -1
  32. package/dist/repeats.d.ts +39 -0
  33. package/dist/repeats.js +391 -0
  34. package/dist/weld.d.ts +8 -0
  35. package/dist/weld.js +155 -34
  36. package/package.json +4 -4
  37. package/src/bake/collapseHeap.ts +81 -0
  38. package/src/bake/collapseMeasures.ts +215 -0
  39. package/src/bake/sceneField.ts +50 -0
  40. package/src/bake/sdf.ts +14 -99
  41. package/src/bake/simplify.ts +395 -0
  42. package/src/bake/surfaceDepth.ts +102 -0
  43. package/src/downscale.ts +61 -0
  44. package/src/drawKey.ts +17 -3
  45. package/src/drftLoader.ts +224 -38
  46. package/src/fieldPlacement.ts +74 -0
  47. package/src/gltf.ts +139 -3
  48. package/src/index.ts +24 -1
  49. package/src/loadProgress.ts +23 -1
  50. package/src/orient.ts +62 -3
  51. package/src/partMaterial.ts +42 -0
  52. package/src/readModel.ts +3 -1
  53. package/src/repeats.ts +442 -0
  54. package/src/weld.ts +164 -34
package/src/repeats.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Copies merged into one mesh, found again: a prototype and a rigid placement for each copy.
3
+ *
4
+ * **What an export does to repeated objects, undone.** A modelling tool that flattens a scene writes
5
+ * N copies of an object as one mesh: N runs of the same triangles, one after another, each run's
6
+ * vertices in the same order, each run moved. A bought scene measured here shipped ten thousand
7
+ * candles that way — nineteen million triangles for one candle's worth of shape. Finding the runs
8
+ * turns that back into one mesh and ten thousand matrices.
9
+ *
10
+ * **Strict, and the strictness is the design.** A count N is accepted only when:
11
+ * - the triangle and vertex counts both divide by N;
12
+ * - every run's indices are run 0's, offset by a whole run of vertices;
13
+ * - every run is a rigid transform of run 0 within a tolerance tied to the run's own size, with its
14
+ * normals and tangents turned by the same rotation;
15
+ * - every other attribute is exactly equal.
16
+ *
17
+ * One vertex out refuses the count. What that gives up is copies a tool reordered or re-triangulated,
18
+ * which this cannot see. What would make that wrong is a scene where such copies dominate; finding
19
+ * them needs shape matching, not run matching.
20
+ *
21
+ * **Rigid only.** A scaled copy is refused rather than instanced with a scale, because a scale
22
+ * changes what the normals mean and nothing downstream is asked to renormalise them. Skinned and
23
+ * morphed meshes are refused outright: their copies are not placements of one shape.
24
+ */
25
+ import type { MeshData } from '@driftengine/drft';
26
+
27
+ /** The attributes that must be equal between copies, exactly. Directions are compared turned. */
28
+ const EQUAL = [
29
+ 'colors',
30
+ 'emissive',
31
+ 'uvs',
32
+ 'specular',
33
+ 'roughness',
34
+ 'grain',
35
+ 'relief',
36
+ 'emissiveColor',
37
+ 'channel',
38
+ ] as const;
39
+
40
+ /**
41
+ * How far a copy's normal may sit from the prototype's once turned, as a chord on the unit sphere.
42
+ *
43
+ * **An exporter recomputes normals per copy, and they come out noisy.** Measured on ten thousand
44
+ * copies of one candle: a median worst of 3e-4 and a worst of 2e-3 per copy, about a tenth of a
45
+ * degree, 75 copies over 1e-3. A hundredth, about half a degree, changes shading by well under one
46
+ * 8-bit level, and the prototype's normal is what every copy is then drawn with. What would make it
47
+ * wrong is a copy whose normals were really turned, a softened edge on one of them, which this would
48
+ * flatten into the prototype's.
49
+ */
50
+ const DIRECTION_TOLERANCE = 1e-2;
51
+
52
+ export interface Repeats {
53
+ /** Run 0: one copy's geometry, with its own indices from zero. */
54
+ readonly prototype: MeshData;
55
+ readonly count: number;
56
+ /** One column-major 4×4 matrix a copy, taking the prototype to where that copy was. Copy 0 is identity. */
57
+ readonly transforms: Float32Array;
58
+ }
59
+
60
+ /** How many connected pieces the indices make of the mesh. Unreferenced vertices count as their own. */
61
+ export function componentCount(mesh: MeshData): number {
62
+ const vertices = mesh.positions.length / 3;
63
+ const parent = new Int32Array(vertices);
64
+ for (let i = 0; i < vertices; i++) parent[i] = i;
65
+ const find = (x: number): number => {
66
+ let r = x;
67
+ while ((parent[r] as number) !== r) r = parent[r] as number;
68
+ while ((parent[x] as number) !== r) {
69
+ const next = parent[x] as number;
70
+ parent[x] = r;
71
+ x = next;
72
+ }
73
+ return r;
74
+ };
75
+ const indices = mesh.indices;
76
+ for (let t = 0; t + 2 < indices.length; t += 3) {
77
+ const a = find(indices[t] as number);
78
+ const b = find(indices[t + 1] as number);
79
+ const c = find(indices[t + 2] as number);
80
+ parent[b] = a;
81
+ parent[find(c)] = a;
82
+ }
83
+ let roots = 0;
84
+ for (let i = 0; i < vertices; i++) if (find(i) === i) roots++;
85
+ return roots;
86
+ }
87
+
88
+ /**
89
+ * The prototype and placements for the largest candidate count that `mesh` is exactly that many
90
+ * rigid copies of, or null when none is.
91
+ */
92
+ export function findRepeats(mesh: MeshData, candidates: readonly number[]): Repeats | null {
93
+ if (mesh.joints !== undefined || mesh.weights !== undefined || mesh.morphTargets !== undefined) {
94
+ return null;
95
+ }
96
+ const vertices = mesh.positions.length / 3;
97
+ const triangles = mesh.indices.length / 3;
98
+ const counts = [...new Set(candidates)].filter((n) => n > 1).sort((a, b) => b - a);
99
+ /* Built once, the first time a count divides the triangles and not the shared vertices. */
100
+ let corners: MeshData | null = null;
101
+ for (const n of counts) {
102
+ if (triangles % n !== 0) continue;
103
+ if (vertices % n === 0 && sameTopology(mesh.indices, n, vertices / n)) {
104
+ const per = vertices / n;
105
+ const transforms = fitRuns(mesh, n, per);
106
+ if (transforms !== null) return { prototype: runZero(mesh, per, n), count: n, transforms };
107
+ continue;
108
+ }
109
+ /*
110
+ * **Through the triangles, when the vertices do not line up.** An exporter can share corners
111
+ * differently from copy to copy, so the copies' vertex counts differ while their triangles do
112
+ * not: a bought scene's candle wicks were 128 triangles each over a vertex count ten thousand
113
+ * did not divide. One vertex a corner makes every copy the same run. The cost is a copy of the
114
+ * mesh that size, and a prototype that needs welding again, which the baker does anyway.
115
+ */
116
+ corners ??= expandCorners(mesh);
117
+ const per = (triangles * 3) / n;
118
+ const transforms = fitRuns(corners, n, per);
119
+ if (transforms !== null) return { prototype: runZero(corners, per, n), count: n, transforms };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** One vertex a triangle corner, in triangle order, so run k of the triangles is run k of the vertices. */
125
+ function expandCorners(mesh: MeshData): MeshData {
126
+ const vertices = mesh.positions.length / 3;
127
+ const indices = mesh.indices;
128
+ const out: Record<string, unknown> = {};
129
+ for (const [key, value] of Object.entries(mesh)) {
130
+ if (!(value instanceof Float32Array)) {
131
+ out[key] = value;
132
+ continue;
133
+ }
134
+ const width = value.length / vertices;
135
+ const expanded = new Float32Array(indices.length * width);
136
+ for (let c = 0; c < indices.length; c++) {
137
+ const from = (indices[c] as number) * width;
138
+ for (let k = 0; k < width; k++) expanded[c * width + k] = value[from + k] as number;
139
+ }
140
+ out[key] = expanded;
141
+ }
142
+ const sequential = new Uint32Array(indices.length);
143
+ for (let c = 0; c < sequential.length; c++) sequential[c] = c;
144
+ out['indices'] = sequential;
145
+ return out as unknown as MeshData;
146
+ }
147
+
148
+ function sameTopology(indices: Uint32Array | Uint16Array, n: number, per: number): boolean {
149
+ const stride = indices.length / n;
150
+ for (let j = 0; j < stride; j++) if ((indices[j] as number) >= per) return false;
151
+ for (let k = 1; k < n; k++) {
152
+ const base = k * stride;
153
+ const offset = k * per;
154
+ for (let j = 0; j < stride; j++) {
155
+ if ((indices[base + j] as number) - offset !== indices[j]) return false;
156
+ }
157
+ }
158
+ return true;
159
+ }
160
+
161
+ function runZero(mesh: MeshData, per: number, n: number): MeshData {
162
+ const out: Record<string, unknown> = {};
163
+ for (const [key, value] of Object.entries(mesh)) {
164
+ if (value instanceof Float32Array) {
165
+ out[key] = value.slice(0, (value.length / (mesh.positions.length / 3)) * per);
166
+ } else {
167
+ out[key] = value;
168
+ }
169
+ }
170
+ out['indices'] = mesh.indices.slice(0, mesh.indices.length / n);
171
+ return out as unknown as MeshData;
172
+ }
173
+
174
+ /** Every run's placement, or null the moment one run is not a rigid copy of run 0. */
175
+ function fitRuns(mesh: MeshData, n: number, per: number): Float32Array | null {
176
+ const p = mesh.positions;
177
+ /* The tolerance, from run 0's own size and the magnitude of the coordinates it sits at. */
178
+ let min0 = Infinity;
179
+ let min1 = Infinity;
180
+ let min2 = Infinity;
181
+ let max0 = -Infinity;
182
+ let max1 = -Infinity;
183
+ let max2 = -Infinity;
184
+ for (let v = 0; v < per; v++) {
185
+ const x = p[v * 3] as number;
186
+ const y = p[v * 3 + 1] as number;
187
+ const z = p[v * 3 + 2] as number;
188
+ min0 = Math.min(min0, x);
189
+ min1 = Math.min(min1, y);
190
+ min2 = Math.min(min2, z);
191
+ max0 = Math.max(max0, x);
192
+ max1 = Math.max(max1, y);
193
+ max2 = Math.max(max2, z);
194
+ }
195
+ const diagonal = Math.hypot(max0 - min0, max1 - min1, max2 - min2);
196
+
197
+ const transforms = new Float32Array(n * 16);
198
+ const r = new Float64Array(9);
199
+ const t = new Float64Array(3);
200
+ for (let k = 0; k < n; k++) {
201
+ const base = k * per;
202
+ fitRigid(p, 0, p, base, per, r, t);
203
+ let magnitude = 0;
204
+ for (let i = base * 3; i < (base + per) * 3; i++)
205
+ magnitude = Math.max(magnitude, Math.abs(p[i] as number));
206
+ /*
207
+ * A ten-thousandth of the shape, plus a few float32 steps at the coordinates it sits at. The
208
+ * second term is what lets a copy thirty metres from the origin match at all; the first is what
209
+ * refuses a vertex a modeller actually moved.
210
+ */
211
+ const tolerance = 1e-4 * diagonal + 4e-7 * magnitude + 1e-7;
212
+ if (!placedWithin(mesh, per, base, r, t, tolerance)) return null;
213
+ writeMatrix(transforms, k * 16, r, t);
214
+ }
215
+ return transforms;
216
+ }
217
+
218
+ function placedWithin(
219
+ mesh: MeshData,
220
+ per: number,
221
+ base: number,
222
+ r: Float64Array,
223
+ t: Float64Array,
224
+ tolerance: number,
225
+ ): boolean {
226
+ const p = mesh.positions;
227
+ const nrm = mesh.normals;
228
+ for (let v = 0; v < per; v++) {
229
+ const a = v * 3;
230
+ const b = (base + v) * 3;
231
+ const x = p[a] as number;
232
+ const y = p[a + 1] as number;
233
+ const z = p[a + 2] as number;
234
+ const px =
235
+ (r[0] as number) * x + (r[1] as number) * y + (r[2] as number) * z + (t[0] as number);
236
+ const py =
237
+ (r[3] as number) * x + (r[4] as number) * y + (r[5] as number) * z + (t[1] as number);
238
+ const pz =
239
+ (r[6] as number) * x + (r[7] as number) * y + (r[8] as number) * z + (t[2] as number);
240
+ if (
241
+ Math.hypot(px - (p[b] as number), py - (p[b + 1] as number), pz - (p[b + 2] as number)) >
242
+ tolerance
243
+ ) {
244
+ return false;
245
+ }
246
+ if (!turned(nrm, a, b, r, DIRECTION_TOLERANCE)) return false;
247
+ }
248
+ const tangents = mesh.tangents;
249
+ if (tangents !== undefined) {
250
+ for (let v = 0; v < per; v++) {
251
+ const a = v * 4;
252
+ const b = (base + v) * 4;
253
+ if (!turned(tangents, a, b, r, DIRECTION_TOLERANCE)) return false;
254
+ if (tangents[a + 3] !== tangents[b + 3]) return false;
255
+ }
256
+ }
257
+ for (const name of EQUAL) {
258
+ const array = mesh[name];
259
+ if (array === undefined) continue;
260
+ const width = array.length / (mesh.positions.length / 3);
261
+ for (let i = 0; i < per * width; i++) {
262
+ if (Math.abs((array[i] as number) - (array[base * width + i] as number)) > 1e-6) return false;
263
+ }
264
+ }
265
+ return true;
266
+ }
267
+
268
+ /** Whether direction `a` in `from`, turned by `r`, is direction `b` in the same array. */
269
+ function turned(
270
+ values: Float32Array,
271
+ a: number,
272
+ b: number,
273
+ r: Float64Array,
274
+ tolerance: number,
275
+ ): boolean {
276
+ const x = values[a] as number;
277
+ const y = values[a + 1] as number;
278
+ const z = values[a + 2] as number;
279
+ const dx =
280
+ (r[0] as number) * x + (r[1] as number) * y + (r[2] as number) * z - (values[b] as number);
281
+ const dy =
282
+ (r[3] as number) * x + (r[4] as number) * y + (r[5] as number) * z - (values[b + 1] as number);
283
+ const dz =
284
+ (r[6] as number) * x + (r[7] as number) * y + (r[8] as number) * z - (values[b + 2] as number);
285
+ return Math.hypot(dx, dy, dz) <= tolerance;
286
+ }
287
+
288
+ /**
289
+ * The rotation and translation taking `count` points of `a` onto `b`, in the least-squares sense:
290
+ * Horn's closed form, the rotation being the quaternion that is the top eigenvector of a symmetric
291
+ * 4×4 built from the two sets' cross-covariance. Row-major 3×3 into `r`.
292
+ */
293
+ function fitRigid(
294
+ a: Float32Array,
295
+ aStart: number,
296
+ b: Float32Array,
297
+ bStart: number,
298
+ count: number,
299
+ r: Float64Array,
300
+ t: Float64Array,
301
+ ): void {
302
+ let ax = 0;
303
+ let ay = 0;
304
+ let az = 0;
305
+ let bx = 0;
306
+ let by = 0;
307
+ let bz = 0;
308
+ for (let v = 0; v < count; v++) {
309
+ ax += a[(aStart + v) * 3] as number;
310
+ ay += a[(aStart + v) * 3 + 1] as number;
311
+ az += a[(aStart + v) * 3 + 2] as number;
312
+ bx += b[(bStart + v) * 3] as number;
313
+ by += b[(bStart + v) * 3 + 1] as number;
314
+ bz += b[(bStart + v) * 3 + 2] as number;
315
+ }
316
+ ax /= count;
317
+ ay /= count;
318
+ az /= count;
319
+ bx /= count;
320
+ by /= count;
321
+ bz /= count;
322
+ let sxx = 0;
323
+ let sxy = 0;
324
+ let sxz = 0;
325
+ let syx = 0;
326
+ let syy = 0;
327
+ let syz = 0;
328
+ let szx = 0;
329
+ let szy = 0;
330
+ let szz = 0;
331
+ for (let v = 0; v < count; v++) {
332
+ const px = (a[(aStart + v) * 3] as number) - ax;
333
+ const py = (a[(aStart + v) * 3 + 1] as number) - ay;
334
+ const pz = (a[(aStart + v) * 3 + 2] as number) - az;
335
+ const qx = (b[(bStart + v) * 3] as number) - bx;
336
+ const qy = (b[(bStart + v) * 3 + 1] as number) - by;
337
+ const qz = (b[(bStart + v) * 3 + 2] as number) - bz;
338
+ sxx += px * qx;
339
+ sxy += px * qy;
340
+ sxz += px * qz;
341
+ syx += py * qx;
342
+ syy += py * qy;
343
+ syz += py * qz;
344
+ szx += pz * qx;
345
+ szy += pz * qy;
346
+ szz += pz * qz;
347
+ }
348
+ const n = [
349
+ [sxx + syy + szz, syz - szy, szx - sxz, sxy - syx],
350
+ [syz - szy, sxx - syy - szz, sxy + syx, szx + sxz],
351
+ [szx - sxz, sxy + syx, -sxx + syy - szz, syz + szy],
352
+ [sxy - syx, szx + sxz, syz + szy, -sxx - syy + szz],
353
+ ];
354
+ const [w, x, y, z] = topEigenvector(n);
355
+ r[0] = 1 - 2 * (y * y + z * z);
356
+ r[1] = 2 * (x * y - w * z);
357
+ r[2] = 2 * (x * z + w * y);
358
+ r[3] = 2 * (x * y + w * z);
359
+ r[4] = 1 - 2 * (x * x + z * z);
360
+ r[5] = 2 * (y * z - w * x);
361
+ r[6] = 2 * (x * z - w * y);
362
+ r[7] = 2 * (y * z + w * x);
363
+ r[8] = 1 - 2 * (x * x + y * y);
364
+ t[0] = bx - ((r[0] as number) * ax + (r[1] as number) * ay + (r[2] as number) * az);
365
+ t[1] = by - ((r[3] as number) * ax + (r[4] as number) * ay + (r[5] as number) * az);
366
+ t[2] = bz - ((r[6] as number) * ax + (r[7] as number) * ay + (r[8] as number) * az);
367
+ }
368
+
369
+ /** The unit eigenvector of a symmetric 4×4's largest eigenvalue, by cyclic Jacobi rotations. */
370
+ function topEigenvector(m: number[][]): [number, number, number, number] {
371
+ const a = m.map((row) => row.slice());
372
+ const v = [
373
+ [1, 0, 0, 0],
374
+ [0, 1, 0, 0],
375
+ [0, 0, 1, 0],
376
+ [0, 0, 0, 1],
377
+ ];
378
+ for (let sweep = 0; sweep < 50; sweep++) {
379
+ let off = 0;
380
+ for (let i = 0; i < 4; i++) for (let j = i + 1; j < 4; j++) off += (a[i]?.[j] ?? 0) ** 2;
381
+ if (off < 1e-30) break;
382
+ for (let p = 0; p < 4; p++) {
383
+ for (let q = p + 1; q < 4; q++) {
384
+ const apq = a[p]?.[q] ?? 0;
385
+ if (Math.abs(apq) < 1e-300) continue;
386
+ const app = a[p]?.[p] ?? 0;
387
+ const aqq = a[q]?.[q] ?? 0;
388
+ const theta = (aqq - app) / (2 * apq);
389
+ const tt = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
390
+ const c = 1 / Math.sqrt(tt * tt + 1);
391
+ const s = tt * c;
392
+ for (let k = 0; k < 4; k++) {
393
+ const akp = a[k]?.[p] ?? 0;
394
+ const akq = a[k]?.[q] ?? 0;
395
+ (a[k] as number[])[p] = c * akp - s * akq;
396
+ (a[k] as number[])[q] = s * akp + c * akq;
397
+ }
398
+ for (let k = 0; k < 4; k++) {
399
+ const apk = a[p]?.[k] ?? 0;
400
+ const aqk = a[q]?.[k] ?? 0;
401
+ (a[p] as number[])[k] = c * apk - s * aqk;
402
+ (a[q] as number[])[k] = s * apk + c * aqk;
403
+ }
404
+ for (let k = 0; k < 4; k++) {
405
+ const vkp = v[k]?.[p] ?? 0;
406
+ const vkq = v[k]?.[q] ?? 0;
407
+ (v[k] as number[])[p] = c * vkp - s * vkq;
408
+ (v[k] as number[])[q] = s * vkp + c * vkq;
409
+ }
410
+ }
411
+ }
412
+ }
413
+ let best = 0;
414
+ for (let i = 1; i < 4; i++) if ((a[i]?.[i] ?? 0) > (a[best]?.[best] ?? 0)) best = i;
415
+ const e: [number, number, number, number] = [
416
+ v[0]?.[best] ?? 0,
417
+ v[1]?.[best] ?? 0,
418
+ v[2]?.[best] ?? 0,
419
+ v[3]?.[best] ?? 0,
420
+ ];
421
+ const len = Math.hypot(...e);
422
+ return [e[0] / len, e[1] / len, e[2] / len, e[3] / len];
423
+ }
424
+
425
+ function writeMatrix(out: Float32Array, at: number, r: Float64Array, t: Float64Array): void {
426
+ out[at] = r[0] as number;
427
+ out[at + 1] = r[3] as number;
428
+ out[at + 2] = r[6] as number;
429
+ out[at + 3] = 0;
430
+ out[at + 4] = r[1] as number;
431
+ out[at + 5] = r[4] as number;
432
+ out[at + 6] = r[7] as number;
433
+ out[at + 7] = 0;
434
+ out[at + 8] = r[2] as number;
435
+ out[at + 9] = r[5] as number;
436
+ out[at + 10] = r[8] as number;
437
+ out[at + 11] = 0;
438
+ out[at + 12] = t[0] as number;
439
+ out[at + 13] = t[1] as number;
440
+ out[at + 14] = t[2] as number;
441
+ out[at + 15] = 1;
442
+ }
package/src/weld.ts CHANGED
@@ -87,6 +87,60 @@ type PerVertexAttribute = Exclude<
87
87
 
88
88
  const NAMES = Object.keys(ATTRIBUTES) as readonly PerVertexAttribute[];
89
89
 
90
+ /**
91
+ * Corner `i`'s identity, quantised, into `out`.
92
+ *
93
+ * `+ 0` folds a negative zero into zero, which the string key did by printing both as `0`.
94
+ */
95
+ function quantisedInto(
96
+ sources: readonly { array: Float32Array; stride: number }[],
97
+ i: number,
98
+ out: Float64Array,
99
+ ): void {
100
+ let at = 0;
101
+ for (const { array, stride } of sources) {
102
+ for (let c = 0; c < stride; c++) out[at++] = quantise(array[i * stride + c] as number) + 0;
103
+ }
104
+ }
105
+
106
+ /** Equal lane for lane, with NaN equal to NaN as the string key had it. */
107
+ function sameLanes(a: Float64Array, b: Float64Array): boolean {
108
+ for (let k = 0; k < a.length; k++) {
109
+ const x = a[k] as number;
110
+ const y = b[k] as number;
111
+ if (x !== y && !(x !== x && y !== y)) return false;
112
+ }
113
+ return true;
114
+ }
115
+
116
+ const HASH_VIEW = new Float64Array(1);
117
+ const HASH_WORDS = new Int32Array(HASH_VIEW.buffer);
118
+
119
+ /**
120
+ * FNV-1a over each lane's two 32-bit words, then murmur3's finaliser. NaN hashes to one value,
121
+ * since it compares equal.
122
+ *
123
+ * **The finaliser is not optional.** FNV folds a word in by XOR and a multiply, and a multiply
124
+ * carries a difference only upward, so two lanes differing in their top bit — a sign, an exponent —
125
+ * differed only in the hash's top bit and always shared a slot once masked. That merged correctly,
126
+ * because the compare decides, but it is also a probe chain for every such family of values.
127
+ */
128
+ function hashLanes(lanes: Float64Array): number {
129
+ let h = 0x811c9dc5 | 0;
130
+ for (let k = 0; k < lanes.length; k++) {
131
+ const v = lanes[k] as number;
132
+ HASH_VIEW[0] = v === v ? v : Number.NaN;
133
+ h = Math.imul(h ^ (HASH_WORDS[0] as number), 0x01000193);
134
+ h = Math.imul(h ^ (HASH_WORDS[1] as number), 0x01000193);
135
+ }
136
+ h ^= h >>> 16;
137
+ h = Math.imul(h, 0x85ebca6b);
138
+ h ^= h >>> 13;
139
+ h = Math.imul(h, 0xc2b2ae35);
140
+ h ^= h >>> 16;
141
+ return h >>> 0;
142
+ }
143
+
90
144
  /**
91
145
  * Weld one mesh. Returns the same mesh when nothing can be merged.
92
146
  *
@@ -116,7 +170,6 @@ export function weldMesh(mesh: MeshData): MeshData {
116
170
  const vertices = mesh.positions.length / 3;
117
171
  if (vertices === 0) return mesh;
118
172
 
119
- const map = new Map<string, number>();
120
173
  const remap = new Uint32Array(vertices);
121
174
  let unique = 0;
122
175
 
@@ -125,40 +178,61 @@ export function weldMesh(mesh: MeshData): MeshData {
125
178
  /* Three floats per target per vertex, interleaved by vertex: `MeshData.morphTargets`' layout. */
126
179
  const morphStride = mesh.morphTargets === undefined ? 0 : (mesh.morphTargetCount ?? 0) * 3;
127
180
 
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
- }
181
+ /*
182
+ * Every number that decides identity, in one order, for one corner.
183
+ *
184
+ * The key names every attribute, because two corners differing in any of them are genuinely
185
+ * different vertices — a shared position with a different normal is a hard edge, and with a
186
+ * different colour is a material boundary.
187
+ */
188
+ const sources: { array: Float32Array; stride: number }[] = [
189
+ { array: mesh.positions, stride: 3 },
190
+ { array: mesh.normals, stride: 3 },
191
+ { array: mesh.colors, stride: 3 },
192
+ { array: mesh.emissive, stride: 1 },
193
+ ];
194
+ for (const name of present) {
195
+ sources.push({ array: mesh[name] as Float32Array, stride: ATTRIBUTES[name] });
196
+ }
197
+ if (mesh.morphTargets !== undefined && morphStride > 0) {
198
+ sources.push({ array: mesh.morphTargets, stride: morphStride });
199
+ }
200
+ let lanes = 0;
201
+ for (const source of sources) lanes += source.stride;
202
+ const key = new Float64Array(lanes);
203
+ const other = new Float64Array(lanes);
154
204
 
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;
205
+ /*
206
+ * **An open-addressed table of vertex indices, not a map of strings.** The key used to be every
207
+ * quantised number above joined into a string, which cost about 700 bytes of heap a corner and
208
+ * ran a five-million-triangle mesh out of memory. The table holds the first corner of each
209
+ * vertex, and a probe compares the candidate's quantised values with this corner's, so the rule
210
+ * for merging is exactly the string's. The cost is a hash and a compare per corner instead of a
211
+ * string build; what would make it wrong is a hash so poor that probes run long, which the table
212
+ * being at most half full bounds.
213
+ */
214
+ let capacity = 1;
215
+ while (capacity < vertices * 2) capacity *= 2;
216
+ const mask = capacity - 1;
217
+ const table = new Int32Array(capacity).fill(-1);
218
+
219
+ for (let i = 0; i < vertices; i++) {
220
+ quantisedInto(sources, i, key);
221
+ let slot = hashLanes(key) & mask;
222
+ for (;;) {
223
+ const held = table[slot] as number;
224
+ if (held === -1) {
225
+ table[slot] = i;
226
+ remap[i] = unique;
227
+ unique++;
228
+ break;
229
+ }
230
+ quantisedInto(sources, held, other);
231
+ if (sameLanes(key, other)) {
232
+ remap[i] = remap[held] as number;
233
+ break;
234
+ }
235
+ slot = (slot + 1) & mask;
162
236
  }
163
237
  }
164
238
 
@@ -216,6 +290,62 @@ export function weldMesh(mesh: MeshData): MeshData {
216
290
  };
217
291
  }
218
292
 
293
+ /**
294
+ * `mesh` with only the vertices `keep` names, renumbered, and `indices` already in the new numbers.
295
+ *
296
+ * `keep[old]` is the vertex's new index, or -1 where it goes; `count` is how many stay. Every
297
+ * attribute the mesh carries is carried, through the same table the weld keys on, so a vertex
298
+ * leaves with everything it arrived with. For a pass that removes vertices without merging them.
299
+ */
300
+ export function keepVertices(
301
+ mesh: MeshData,
302
+ keep: Int32Array,
303
+ count: number,
304
+ indices: Uint32Array,
305
+ ): MeshData {
306
+ const present = NAMES.filter((name) => mesh[name] !== undefined);
307
+ const morphStride = mesh.morphTargets === undefined ? 0 : (mesh.morphTargetCount ?? 0) * 3;
308
+ const positions = new Float32Array(count * 3);
309
+ const normals = new Float32Array(count * 3);
310
+ const colors = new Float32Array(count * 3);
311
+ const emissive = new Float32Array(count);
312
+ const copies: Partial<Record<PerVertexAttribute, Float32Array>> = {};
313
+ for (const name of present) copies[name] = new Float32Array(count * ATTRIBUTES[name]);
314
+ const morphTargets = morphStride === 0 ? undefined : new Float32Array(count * morphStride);
315
+ for (let i = 0; i < keep.length; i++) {
316
+ const to = keep[i] as number;
317
+ if (to < 0) continue;
318
+ for (let c = 0; c < 3; c++) {
319
+ positions[to * 3 + c] = mesh.positions[i * 3 + c] as number;
320
+ normals[to * 3 + c] = mesh.normals[i * 3 + c] as number;
321
+ colors[to * 3 + c] = mesh.colors[i * 3 + c] as number;
322
+ }
323
+ emissive[to] = mesh.emissive[i] as number;
324
+ for (const name of present) {
325
+ const from = mesh[name] as Float32Array;
326
+ const into = copies[name] as Float32Array;
327
+ const stride = ATTRIBUTES[name];
328
+ for (let c = 0; c < stride; c++) into[to * stride + c] = from[i * stride + c] as number;
329
+ }
330
+ if (morphTargets !== undefined) {
331
+ for (let c = 0; c < morphStride; c++) {
332
+ morphTargets[to * morphStride + c] = mesh.morphTargets?.[i * morphStride + c] as number;
333
+ }
334
+ }
335
+ }
336
+ return {
337
+ positions,
338
+ normals,
339
+ colors,
340
+ emissive,
341
+ indices,
342
+ ...copies,
343
+ ...(morphTargets === undefined
344
+ ? {}
345
+ : { morphTargets, morphTargetCount: mesh.morphTargetCount }),
346
+ };
347
+ }
348
+
219
349
  /**
220
350
  * Drop optional attributes that never vary from the engine's own default.
221
351
  *