@driftengine/assets 3.62.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,361 @@
1
+ /**
2
+ * Splitting a mesh into clusters: the unit a GPU-driven frame culls, chooses detail for and draws.
3
+ *
4
+ * **A cluster is the unit of three separate decisions**, which is why its size is a parameter
5
+ * rather than a constant. Smaller clusters cull more precisely and pay more per-cluster overhead
6
+ * on every one of them; 128 triangles is the default because it is one workgroup of 128
7
+ * invocations doing one triangle each.
8
+ *
9
+ * **Growth is by shared vertices, and ties break on the lowest index.** That tie-break is not a
10
+ * detail: a baker whose output depends on iteration order produces a different container from the
11
+ * same source, which breaks every content hash downstream. The determinism test in this module's
12
+ * suite is what holds it.
13
+ *
14
+ * Each cluster carries a bounding sphere and a normal cone, because the runtime culls against both
15
+ * and computing them here costs nothing that the bake was not already paying.
16
+ */
17
+ import type { MeshData } from '@driftengine/drft';
18
+
19
+ export interface ClusterSet {
20
+ /** First triangle of each cluster, as an index into `indices` divided by three. */
21
+ triangleOffsets: Uint32Array;
22
+ /** How many triangles each cluster holds. */
23
+ triangleCounts: Uint32Array;
24
+ /** The mesh's indices, reordered so each cluster's triangles are contiguous. */
25
+ indices: Uint32Array;
26
+ /** Three floats per cluster. */
27
+ boundsCentre: Float32Array;
28
+ /** One float per cluster. */
29
+ boundsRadius: Float32Array;
30
+ /** Three floats per cluster: the average face normal, normalised. */
31
+ coneAxis: Float32Array;
32
+ /** One float per cluster: the minimum dot product of any face normal against the axis. */
33
+ coneCutoff: Float32Array;
34
+ count: number;
35
+ }
36
+
37
+ /** Vertices shared between two triangles, each given as three vertex indices. */
38
+ function shared(a: Uint32Array, ai: number, b: Uint32Array, bi: number): number {
39
+ let count = 0;
40
+ for (let i = 0; i < 3; i += 1) {
41
+ const v = a[ai * 3 + i] as number;
42
+ for (let j = 0; j < 3; j += 1) if (v === (b[bi * 3 + j] as number)) count += 1;
43
+ }
44
+ return count;
45
+ }
46
+
47
+ function faceNormal(positions: Float32Array, indices: Uint32Array, tri: number, out: Float32Array) {
48
+ const a = (indices[tri * 3] as number) * 3;
49
+ const b = (indices[tri * 3 + 1] as number) * 3;
50
+ const c = (indices[tri * 3 + 2] as number) * 3;
51
+ const ux = (positions[b] as number) - (positions[a] as number);
52
+ const uy = (positions[b + 1] as number) - (positions[a + 1] as number);
53
+ const uz = (positions[b + 2] as number) - (positions[a + 2] as number);
54
+ const vx = (positions[c] as number) - (positions[a] as number);
55
+ const vy = (positions[c + 1] as number) - (positions[a + 1] as number);
56
+ const vz = (positions[c + 2] as number) - (positions[a + 2] as number);
57
+ let nx = uy * vz - uz * vy;
58
+ let ny = uz * vx - ux * vz;
59
+ let nz = ux * vy - uy * vx;
60
+ const length = Math.hypot(nx, ny, nz);
61
+ if (length > 0) {
62
+ nx /= length;
63
+ ny /= length;
64
+ nz /= length;
65
+ }
66
+ out[0] = nx;
67
+ out[1] = ny;
68
+ out[2] = nz;
69
+ }
70
+
71
+ /**
72
+ * Every triangle touching each vertex, as a compressed row: offsets, then the triangles.
73
+ *
74
+ * **This is what makes the growth linear rather than cubic**, and the difference is not an
75
+ * optimisation. Growth picks the unassigned triangle sharing the most vertices with the cluster so
76
+ * far, and a triangle sharing none can never be picked — the loop stops when the best score is
77
+ * zero. So the only triangles worth considering are the ones reachable through a vertex, and this
78
+ * is the map that says which those are. Scanning the whole mesh to discover that answer is what
79
+ * the first version here did: 128 triangles took 16 ms, 2,048 took 1.9 s, 4,608 took 9.7 s, and a
80
+ * million-triangle asset extrapolated to days.
81
+ *
82
+ * Two passes and no arrays of arrays, because a million-triangle mesh is a million small
83
+ * allocations otherwise.
84
+ */
85
+ function vertexTriangles(
86
+ indices: Uint32Array,
87
+ vertices: number,
88
+ ): { starts: Uint32Array; list: Uint32Array } {
89
+ const triangles = Math.floor(indices.length / 3);
90
+ const starts = new Uint32Array(vertices + 1);
91
+ for (let i = 0; i < triangles * 3; i += 1) starts[(indices[i] as number) + 1] += 1;
92
+ for (let v = 0; v < vertices; v += 1) {
93
+ starts[v + 1] = (starts[v + 1] as number) + (starts[v] as number);
94
+ }
95
+ const cursor = Uint32Array.from(starts.subarray(0, vertices));
96
+ const list = new Uint32Array(triangles * 3);
97
+ for (let t = 0; t < triangles; t += 1) {
98
+ for (let corner = 0; corner < 3; corner += 1) {
99
+ const v = indices[t * 3 + corner] as number;
100
+ list[cursor[v] as number] = t;
101
+ cursor[v] = (cursor[v] as number) + 1;
102
+ }
103
+ }
104
+ return { starts, list };
105
+ }
106
+
107
+ export interface ClusterOptions {
108
+ /**
109
+ * What makes two triangles neighbours while a cluster grows. **`'index'` by default**, which is
110
+ * what every baked container was hashed with and must go on producing.
111
+ *
112
+ * **`'position'` counts two corners as shared when they stand at the same point**, whatever their
113
+ * indices. A mesh that splits its vertices at every seam — a voxel mesher, whose faces each carry
114
+ * their own uv and occlusion, or a box built face by face — shares no index between two faces,
115
+ * so growth by index stops at every face and each quad becomes a cluster of two triangles: a
116
+ * median voxel chunk was 1,012 clusters for 2,024 triangles. The indices written out are still
117
+ * the mesh's own, because welding decides who is a neighbour and nothing else.
118
+ */
119
+ readonly share?: 'index' | 'position';
120
+ }
121
+
122
+ /**
123
+ * Each index replaced by the first vertex standing where it stands.
124
+ *
125
+ * **The first, in vertex order**, so the answer depends on the mesh and not on a hash table's
126
+ * iteration — which is the reproducibility the tie-break above is written for. Keyed on the
127
+ * numbers rather than their bits, so a corner at `-0` meets one at `0`.
128
+ */
129
+ function canonicalIndices(
130
+ indices: Uint32Array,
131
+ positions: Float32Array,
132
+ vertices: number,
133
+ ): Uint32Array {
134
+ const first = new Map<string, number>();
135
+ const canonical = new Uint32Array(vertices);
136
+ for (let v = 0; v < vertices; v += 1) {
137
+ const key = `${positions[v * 3]},${positions[v * 3 + 1]},${positions[v * 3 + 2]}`;
138
+ const known = first.get(key);
139
+ if (known === undefined) {
140
+ first.set(key, v);
141
+ canonical[v] = v;
142
+ } else {
143
+ canonical[v] = known;
144
+ }
145
+ }
146
+ return Uint32Array.from(indices, (index) => canonical[index] as number);
147
+ }
148
+
149
+ export function buildClusters(
150
+ mesh: MeshData,
151
+ target: number,
152
+ options: ClusterOptions = {},
153
+ ): ClusterSet {
154
+ const source = mesh.indices as Uint32Array;
155
+ const triangles = Math.floor(source.length / 3);
156
+ const positions = mesh.positions;
157
+ const vertices = Math.floor(positions.length / 3);
158
+
159
+ const assigned = new Uint8Array(triangles);
160
+ const indices = new Uint32Array(source.length);
161
+ const offsets: number[] = [];
162
+ const counts: number[] = [];
163
+ /*
164
+ * **Who neighbours whom**, which is the source itself unless the caller asked for positions.
165
+ * Growth and scoring read this; what is written out reads `source`.
166
+ */
167
+ const linked =
168
+ options.share === 'position' ? canonicalIndices(source, positions, vertices) : source;
169
+ const adjacency = vertexTriangles(linked, vertices);
170
+
171
+ const member: number[] = [];
172
+ /*
173
+ * The frontier: unassigned triangles that share at least one vertex with a member, and the best
174
+ * score each has against any member so far. `score` is indexed by triangle and is only read for
175
+ * triangles in `frontier`, so it never has to be cleared between clusters.
176
+ */
177
+ const frontier: number[] = [];
178
+ const score = new Uint8Array(triangles);
179
+ const queued = new Uint8Array(triangles);
180
+
181
+ let written = 0;
182
+ let placed = 0;
183
+ /* The lowest unassigned triangle seeds the next cluster, so the walk is reproducible. Monotone,
184
+ because every triangle below it is assigned — scanning from zero each time is a second
185
+ quadratic term hiding behind the first. */
186
+ let nextSeed = 0;
187
+
188
+ while (placed < triangles) {
189
+ while (nextSeed < triangles && assigned[nextSeed] === 1) nextSeed += 1;
190
+ if (nextSeed >= triangles) break;
191
+ const seed = nextSeed;
192
+
193
+ member.length = 0;
194
+ frontier.length = 0;
195
+ member.push(seed);
196
+ assigned[seed] = 1;
197
+ placed += 1;
198
+ offer(seed);
199
+
200
+ while (member.length < target) {
201
+ /*
202
+ * The best score, ties broken by the lowest triangle index — the same answer the whole-mesh
203
+ * scan gave, because it ran in index order and compared strictly greater. The frontier is
204
+ * not in index order, so the tie-break is written out rather than inherited from the loop.
205
+ */
206
+ let best = -1;
207
+ let bestScore = 0;
208
+ let live = 0;
209
+ for (let i = 0; i < frontier.length; i += 1) {
210
+ const t = frontier[i] as number;
211
+ if (assigned[t] === 1) {
212
+ queued[t] = 0;
213
+ continue;
214
+ }
215
+ /* Compacted in place as it is scanned, so a consumed frontier entry is not walked again. */
216
+ frontier[live] = t;
217
+ live += 1;
218
+ const s = score[t] as number;
219
+ if (s > bestScore || (s === bestScore && best >= 0 && t < best)) {
220
+ bestScore = s;
221
+ best = t;
222
+ }
223
+ }
224
+ frontier.length = live;
225
+ if (best === -1 || bestScore <= 0) break;
226
+ member.push(best);
227
+ assigned[best] = 1;
228
+ placed += 1;
229
+ offer(best);
230
+ }
231
+
232
+ /*
233
+ * **`queued` is per cluster and the frontier is where it is recorded.** Leaving a triangle
234
+ * marked from a cluster that never took it makes it invisible to every cluster afterwards —
235
+ * `offer` would update its score and never put it on the new frontier — and what that looks
236
+ * like is growth stopping early everywhere: 25,088 triangles came out as 11,860 clusters
237
+ * rather than 196.
238
+ *
239
+ * **The frontier alone is enough and a second loop over the members is not.** Every member
240
+ * except the seed was offered before it was chosen, so it is in this array; the seed was
241
+ * already assigned when its own `offer` ran and was never marked. A loop over the members
242
+ * stood here and a perturbation survived it.
243
+ */
244
+ for (const t of frontier) queued[t] = 0;
245
+
246
+ offsets.push(written / 3);
247
+ counts.push(member.length);
248
+ for (const t of member) {
249
+ indices[written] = source[t * 3] as number;
250
+ indices[written + 1] = source[t * 3 + 1] as number;
251
+ indices[written + 2] = source[t * 3 + 2] as number;
252
+ written += 3;
253
+ }
254
+ }
255
+
256
+ /** Raise every neighbour of a new member to the score it now has against the cluster. */
257
+ function offer(added: number): void {
258
+ for (let corner = 0; corner < 3; corner += 1) {
259
+ const v = linked[added * 3 + corner] as number;
260
+ const from = adjacency.starts[v] as number;
261
+ const to = adjacency.starts[v + 1] as number;
262
+ for (let i = from; i < to; i += 1) {
263
+ const t = adjacency.list[i] as number;
264
+ /* Already in a cluster. The frontier scan refuses it again, so deleting this changes
265
+ no answer — it is a cost guard, and the cost is 105 ms against 89 ms over a mesh of
266
+ 100,352 triangles, which is an eighteenth of the bake. A perturbation survives it. */
267
+ if (assigned[t] === 1) continue;
268
+ const shares = shared(linked, added, linked, t);
269
+ if (queued[t] === 0) {
270
+ queued[t] = 1;
271
+ score[t] = shares;
272
+ frontier.push(t);
273
+ } else if (shares > (score[t] as number)) {
274
+ score[t] = shares;
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ const count = offsets.length;
281
+ const set: ClusterSet = {
282
+ triangleOffsets: Uint32Array.from(offsets),
283
+ triangleCounts: Uint32Array.from(counts),
284
+ indices,
285
+ boundsCentre: new Float32Array(count * 3),
286
+ boundsRadius: new Float32Array(count),
287
+ coneAxis: new Float32Array(count * 3),
288
+ coneCutoff: new Float32Array(count),
289
+ count,
290
+ };
291
+
292
+ const normal = new Float32Array(3);
293
+ for (let c = 0; c < count; c += 1) {
294
+ const at = set.triangleOffsets[c] as number;
295
+ const n = set.triangleCounts[c] as number;
296
+
297
+ let cx = 0;
298
+ let cy = 0;
299
+ let cz = 0;
300
+ let vertices = 0;
301
+ for (let i = at * 3; i < (at + n) * 3; i += 1) {
302
+ const v = (indices[i] as number) * 3;
303
+ cx += positions[v] as number;
304
+ cy += positions[v + 1] as number;
305
+ cz += positions[v + 2] as number;
306
+ vertices += 1;
307
+ }
308
+ cx /= vertices;
309
+ cy /= vertices;
310
+ cz /= vertices;
311
+
312
+ let radius = 0;
313
+ for (let i = at * 3; i < (at + n) * 3; i += 1) {
314
+ const v = (indices[i] as number) * 3;
315
+ radius = Math.max(
316
+ radius,
317
+ Math.hypot(
318
+ (positions[v] as number) - cx,
319
+ (positions[v + 1] as number) - cy,
320
+ (positions[v + 2] as number) - cz,
321
+ ),
322
+ );
323
+ }
324
+
325
+ let ax = 0;
326
+ let ay = 0;
327
+ let az = 0;
328
+ for (let t = at; t < at + n; t += 1) {
329
+ faceNormal(positions, indices, t, normal);
330
+ ax += normal[0] as number;
331
+ ay += normal[1] as number;
332
+ az += normal[2] as number;
333
+ }
334
+ const axisLength = Math.hypot(ax, ay, az);
335
+ if (axisLength > 0) {
336
+ ax /= axisLength;
337
+ ay /= axisLength;
338
+ az /= axisLength;
339
+ }
340
+
341
+ let cutoff = 1;
342
+ for (let t = at; t < at + n; t += 1) {
343
+ faceNormal(positions, indices, t, normal);
344
+ cutoff = Math.min(
345
+ cutoff,
346
+ (normal[0] as number) * ax + (normal[1] as number) * ay + (normal[2] as number) * az,
347
+ );
348
+ }
349
+
350
+ set.boundsCentre[c * 3] = cx;
351
+ set.boundsCentre[c * 3 + 1] = cy;
352
+ set.boundsCentre[c * 3 + 2] = cz;
353
+ set.boundsRadius[c] = radius;
354
+ set.coneAxis[c * 3] = ax;
355
+ set.coneAxis[c * 3 + 1] = ay;
356
+ set.coneAxis[c * 3 + 2] = az;
357
+ set.coneCutoff[c] = cutoff;
358
+ }
359
+
360
+ return set;
361
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The level-of-detail graph over a mesh's clusters, and the property the whole pipeline rests on.
3
+ *
4
+ * **A cluster's own error must never exceed its parent's.** Given that, a cut chosen independently
5
+ * per cluster — draw this one if its projected error is under the threshold, otherwise ask its
6
+ * parent — is guaranteed crack-free, because two adjacent clusters can never choose levels whose
7
+ * shared edge was simplified differently. Without it the pipeline draws holes, and the holes appear
8
+ * at viewing distances nobody tested. The property holds here **by construction**: each level's
9
+ * error is forced up to at least its children's maximum.
10
+ *
11
+ * **Simplification is vertex clustering on a grid, not edge collapse, and that is a deliberate
12
+ * first implementation.** Grid snapping is deterministic without a tie-break argument, needs no
13
+ * dependency, and — the part that matters — has an error bound that is *known in advance* rather
14
+ * than measured after the fact: no vertex moves further than half a cell diagonal. A quadric edge
15
+ * collapse would give a better-looking result at the same triangle count and would have to
16
+ * measure its error to keep the invariant above. The graph shape, the error discipline and every
17
+ * consumer of both are identical either way, so the better simplifier is a drop-in replacement
18
+ * for `simplifyGroup` and nothing else.
19
+ */
20
+ import type { MeshData } from '@driftengine/drft';
21
+ import { buildClusters } from './cluster.ts';
22
+ import type { ClusterSet } from './cluster.ts';
23
+
24
+ export interface ClusterLevel {
25
+ /** The clusters at this level. Level 0 is the source mesh's. */
26
+ set: ClusterSet;
27
+ /** Positions the level's indices address. Level 0 shares the source mesh's. */
28
+ positions: Float32Array;
29
+ /** Geometric error each cluster at this level carries. Zero at level 0. */
30
+ ownError: Float32Array;
31
+ /** The error of the group that replaces this cluster one level up, or Infinity at the root. */
32
+ parentError: Float32Array;
33
+ /** Which group of this level was simplified together. Clusters sharing one share a parent. */
34
+ groupOf: Uint32Array;
35
+ }
36
+
37
+ export interface ClusterDag {
38
+ levels: ClusterLevel[];
39
+ }
40
+
41
+ /**
42
+ * The mean length of a triangle edge, which is the mesh's own sense of how far apart its detail is.
43
+ *
44
+ * Sampled over every triangle rather than every unique edge: each interior edge is counted twice
45
+ * and each boundary edge once, which biases the mean by a fraction of a percent on any mesh large
46
+ * enough to matter and saves building an edge set.
47
+ */
48
+ function meanEdgeLength(positions: Float32Array, indices: Uint32Array): number {
49
+ let total = 0;
50
+ let count = 0;
51
+ for (let t = 0; t < indices.length / 3; t += 1) {
52
+ for (let e = 0; e < 3; e += 1) {
53
+ const a = (indices[t * 3 + e] as number) * 3;
54
+ const b = (indices[t * 3 + ((e + 1) % 3)] as number) * 3;
55
+ total += Math.hypot(
56
+ (positions[b] as number) - (positions[a] as number),
57
+ (positions[b + 1] as number) - (positions[a + 1] as number),
58
+ (positions[b + 2] as number) - (positions[a + 2] as number),
59
+ );
60
+ count += 1;
61
+ }
62
+ }
63
+ return count === 0 ? 0 : total / count;
64
+ }
65
+
66
+ /** How far a vertex can move when snapped to a grid of this size: half the cell diagonal. */
67
+ function snapError(cell: number): number {
68
+ return (cell * Math.sqrt(3)) / 2;
69
+ }
70
+
71
+ /**
72
+ * Collapse a group's triangles by snapping vertices to a grid and dropping what degenerates.
73
+ *
74
+ * Returns the surviving geometry and the error bound, which is a property of the grid rather than
75
+ * a measurement of the result — see the header for why that matters.
76
+ */
77
+ function simplifyGroup(
78
+ positions: Float32Array,
79
+ indices: Uint32Array,
80
+ cell: number,
81
+ ): { positions: Float32Array; indices: Uint32Array; error: number } {
82
+ const keyOf = new Map<string, number>();
83
+ const remap = new Map<number, number>();
84
+ const out: number[] = [];
85
+
86
+ for (let i = 0; i < indices.length; i += 1) {
87
+ const v = indices[i] as number;
88
+ if (remap.has(v)) continue;
89
+ const at = v * 3;
90
+ const gx = Math.round((positions[at] as number) / cell);
91
+ const gy = Math.round((positions[at + 1] as number) / cell);
92
+ const gz = Math.round((positions[at + 2] as number) / cell);
93
+ const key = `${gx},${gy},${gz}`;
94
+ let slot = keyOf.get(key);
95
+ if (slot === undefined) {
96
+ slot = out.length / 3;
97
+ keyOf.set(key, slot);
98
+ out.push(gx * cell, gy * cell, gz * cell);
99
+ }
100
+ remap.set(v, slot);
101
+ }
102
+
103
+ const kept: number[] = [];
104
+ for (let t = 0; t < indices.length / 3; t += 1) {
105
+ const a = remap.get(indices[t * 3] as number) as number;
106
+ const b = remap.get(indices[t * 3 + 1] as number) as number;
107
+ const c = remap.get(indices[t * 3 + 2] as number) as number;
108
+ /* A triangle whose corners snapped together has no area and is dropped, not kept flat. */
109
+ if (a === b || b === c || a === c) continue;
110
+ kept.push(a, b, c);
111
+ }
112
+
113
+ return {
114
+ positions: Float32Array.from(out),
115
+ indices: Uint32Array.from(kept),
116
+ error: snapError(cell),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Group clusters for simplification, by proximity, `size` at a time.
122
+ *
123
+ * Ordered by cluster index rather than by a spatial sort, then filled greedily from the nearest
124
+ * unassigned centroid — deterministic, and close enough to spatial that groups are contiguous on
125
+ * ordinary geometry. Clusters in one group are simplified together, so edges interior to the group
126
+ * may collapse and edges on its boundary may not.
127
+ */
128
+ function groupClusters(set: ClusterSet, size: number): Uint32Array {
129
+ const groupOf = new Uint32Array(set.count).fill(0xffffffff);
130
+ let group = 0;
131
+ for (let seed = 0; seed < set.count; seed += 1) {
132
+ if (groupOf[seed] !== 0xffffffff) continue;
133
+ groupOf[seed] = group;
134
+ let taken = 1;
135
+ while (taken < size) {
136
+ let best = -1;
137
+ let bestDistance = Infinity;
138
+ for (let c = 0; c < set.count; c += 1) {
139
+ if (groupOf[c] !== 0xffffffff) continue;
140
+ const d = Math.hypot(
141
+ (set.boundsCentre[c * 3] as number) - (set.boundsCentre[seed * 3] as number),
142
+ (set.boundsCentre[c * 3 + 1] as number) - (set.boundsCentre[seed * 3 + 1] as number),
143
+ (set.boundsCentre[c * 3 + 2] as number) - (set.boundsCentre[seed * 3 + 2] as number),
144
+ );
145
+ if (d < bestDistance) {
146
+ bestDistance = d;
147
+ best = c;
148
+ }
149
+ }
150
+ if (best === -1) break;
151
+ groupOf[best] = group;
152
+ taken += 1;
153
+ }
154
+ group += 1;
155
+ }
156
+ return groupOf;
157
+ }
158
+
159
+ const GROUP_SIZE = 4;
160
+
161
+ export function buildClusterDag(
162
+ set: ClusterSet,
163
+ mesh: MeshData,
164
+ options: { target?: number; maxLevels?: number } = {},
165
+ ): ClusterDag {
166
+ const target = options.target ?? 128;
167
+ const maxLevels = options.maxLevels ?? 8;
168
+
169
+ const levels: ClusterLevel[] = [];
170
+ let currentSet = set;
171
+ let currentPositions = mesh.positions;
172
+ let currentError = new Float32Array(set.count);
173
+
174
+ /*
175
+ * The starting cell comes from the mesh's own edge length, not from its extent.
176
+ *
177
+ * Derived from the extent it is arbitrary, and arbitrary here means wrong in a way that looks
178
+ * like the algorithm failing: a cell finer than the vertex spacing merges nothing, the triangle
179
+ * count does not fall, and the build stops at one level believing the mesh cannot be
180
+ * simplified. Starting a little above the mean edge collapses neighbouring vertices on the
181
+ * first pass, which is what the doubling then builds on.
182
+ */
183
+ let cell = Math.max(1e-3, meanEdgeLength(mesh.positions, set.indices) * 1.5);
184
+
185
+ for (let level = 0; level < maxLevels; level += 1) {
186
+ const groupOf = groupClusters(currentSet, GROUP_SIZE);
187
+ const parentError = new Float32Array(currentSet.count);
188
+
189
+ const last = currentSet.count <= 1;
190
+ if (last) {
191
+ parentError.fill(Infinity);
192
+ levels.push({
193
+ set: currentSet,
194
+ positions: currentPositions,
195
+ ownError: currentError,
196
+ parentError,
197
+ groupOf,
198
+ });
199
+ break;
200
+ }
201
+
202
+ /* Every cluster of a group takes the group's error, so two in one group cannot disagree. */
203
+ const error = Math.max(snapError(cell), ...Array.from(currentError));
204
+ for (let c = 0; c < currentSet.count; c += 1) parentError[c] = error;
205
+
206
+ levels.push({
207
+ set: currentSet,
208
+ positions: currentPositions,
209
+ ownError: currentError,
210
+ parentError,
211
+ groupOf,
212
+ });
213
+
214
+ const simplified = simplifyGroup(currentPositions, currentSet.indices, cell);
215
+ if (simplified.indices.length === 0 || simplified.indices.length >= currentSet.indices.length) {
216
+ break;
217
+ }
218
+
219
+ const nextMesh = { ...mesh, positions: simplified.positions, indices: simplified.indices };
220
+ const nextSet = buildClusters(nextMesh as MeshData, target);
221
+ currentSet = nextSet;
222
+ currentPositions = simplified.positions;
223
+ currentError = new Float32Array(nextSet.count).fill(error);
224
+ cell *= 2;
225
+ }
226
+
227
+ return { levels };
228
+ }