@manycore/aholo-splat-transform 1.2.8 → 1.2.9

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 (97) hide show
  1. package/CHANGELOG.md +120 -113
  2. package/README.md +39 -39
  3. package/THIRD_PARTY_LICENSES.txt +1373 -1373
  4. package/bin/cli.js +125 -118
  5. package/dist/SplatData.d.ts +67 -67
  6. package/dist/SplatData.js +167 -150
  7. package/dist/constant.d.ts +3 -3
  8. package/dist/constant.js +13 -13
  9. package/dist/file/IFile.d.ts +5 -5
  10. package/dist/file/IFile.js +1 -1
  11. package/dist/file/esz.d.ts +11 -11
  12. package/dist/file/esz.js +337 -322
  13. package/dist/file/index.d.ts +8 -8
  14. package/dist/file/index.js +7 -7
  15. package/dist/file/ksplat.d.ts +12 -12
  16. package/dist/file/ksplat.js +293 -231
  17. package/dist/file/lcc.d.ts +11 -11
  18. package/dist/file/lcc.js +161 -158
  19. package/dist/file/ply.d.ts +13 -13
  20. package/dist/file/ply.js +439 -390
  21. package/dist/file/sog.d.ts +80 -80
  22. package/dist/file/sog.js +525 -494
  23. package/dist/file/splat.d.ts +6 -6
  24. package/dist/file/splat.js +119 -99
  25. package/dist/file/spz.d.ts +11 -11
  26. package/dist/file/spz.js +597 -583
  27. package/dist/file/voxel.d.ts +43 -37
  28. package/dist/file/voxel.js +411 -280
  29. package/dist/index.d.ts +33 -33
  30. package/dist/index.js +54 -54
  31. package/dist/native/index.d.ts +54 -54
  32. package/dist/native/index.js +122 -129
  33. package/dist/native/utils.d.ts +1 -0
  34. package/dist/native/utils.js +54 -0
  35. package/dist/tasks/AutoChunkLodTask.d.ts +13 -13
  36. package/dist/tasks/AutoChunkLodTask.js +117 -117
  37. package/dist/tasks/AutoLodTask.d.ts +10 -10
  38. package/dist/tasks/AutoLodTask.js +20 -20
  39. package/dist/tasks/BaseTask.d.ts +15 -15
  40. package/dist/tasks/BaseTask.js +5 -5
  41. package/dist/tasks/FlexLodTask.d.ts +12 -12
  42. package/dist/tasks/FlexLodTask.js +54 -44
  43. package/dist/tasks/ModifyTask.d.ts +9 -9
  44. package/dist/tasks/ModifyTask.js +166 -156
  45. package/dist/tasks/ReadTask.d.ts +9 -9
  46. package/dist/tasks/ReadTask.js +29 -29
  47. package/dist/tasks/SkeletonLodTask.d.ts +10 -10
  48. package/dist/tasks/SkeletonLodTask.js +176 -156
  49. package/dist/tasks/VoxelTask.d.ts +35 -30
  50. package/dist/tasks/VoxelTask.js +40 -37
  51. package/dist/tasks/WriteTask.d.ts +12 -12
  52. package/dist/tasks/WriteTask.js +70 -70
  53. package/dist/utils/BufferReader.d.ts +12 -12
  54. package/dist/utils/BufferReader.js +45 -45
  55. package/dist/utils/Logger.d.ts +11 -11
  56. package/dist/utils/Logger.js +40 -40
  57. package/dist/utils/StreamChunkDecoder.d.ts +16 -16
  58. package/dist/utils/StreamChunkDecoder.js +31 -31
  59. package/dist/utils/index.d.ts +27 -27
  60. package/dist/utils/index.js +101 -101
  61. package/dist/utils/k-means.d.ts +4 -4
  62. package/dist/utils/k-means.js +340 -341
  63. package/dist/utils/math.d.ts +46 -46
  64. package/dist/utils/math.js +350 -346
  65. package/dist/utils/quantize-1d.d.ts +4 -4
  66. package/dist/utils/quantize-1d.js +164 -164
  67. package/dist/utils/sh-rotate.d.ts +2 -2
  68. package/dist/utils/sh-rotate.js +236 -175
  69. package/dist/utils/splat.d.ts +21 -21
  70. package/dist/utils/splat.js +397 -387
  71. package/dist/utils/voxel/binary.d.ts +8 -0
  72. package/dist/utils/voxel/binary.js +176 -0
  73. package/dist/utils/voxel/common.d.ts +178 -162
  74. package/dist/utils/voxel/common.js +1752 -1682
  75. package/dist/utils/voxel/coplanar-merge.d.ts +63 -63
  76. package/dist/utils/voxel/coplanar-merge.js +818 -819
  77. package/dist/utils/voxel/filter-cluster.d.ts +20 -0
  78. package/dist/utils/voxel/filter-cluster.js +628 -0
  79. package/dist/utils/voxel/gpu-dilation.d.ts +2 -2
  80. package/dist/utils/voxel/gpu-dilation.js +677 -656
  81. package/dist/utils/voxel/marching-cubes.d.ts +42 -42
  82. package/dist/utils/voxel/marching-cubes.js +1645 -1657
  83. package/dist/utils/voxel/mesh.d.ts +3 -3
  84. package/dist/utils/voxel/mesh.js +130 -130
  85. package/dist/utils/voxel/nav.d.ts +29 -29
  86. package/dist/utils/voxel/nav.js +1068 -1043
  87. package/dist/utils/voxel/postprocess.d.ts +23 -23
  88. package/dist/utils/voxel/postprocess.js +408 -375
  89. package/dist/utils/voxel/voxel-faces.d.ts +18 -18
  90. package/dist/utils/voxel/voxel-faces.js +662 -663
  91. package/dist/utils/voxel/voxelize.d.ts +34 -33
  92. package/dist/utils/voxel/voxelize.js +1208 -1193
  93. package/dist/utils/webgpu.d.ts +8 -8
  94. package/dist/utils/webgpu.js +122 -122
  95. package/package.json +37 -39
  96. package/dist/native/cpp/bin/linux/binding.node +0 -0
  97. package/dist/native/cpp/bin/windows/binding.node +0 -0
@@ -1,819 +1,818 @@
1
- const NORMAL_EPS = 1e-3;
2
- const PLANE_REL_EPS = 1e-3;
3
- const COLLINEAR_REL_EPS = 1e-3;
4
- /**
5
- * Losslessly reduce coplanar regions of a marching-cubes mesh by
6
- * topology-preserving vertex removal.
7
- *
8
- * For a closed manifold MC mesh, a vertex `v` is "lossless-removable" iff
9
- * its incident-tri fan, walked in cyclic order, falls into one of:
10
- *
11
- * 1. K=1 coplanar fan. Every triangle in v's fan lies on the same plane
12
- * (same unit normal and same plane offset, within tolerance). Removing
13
- * v is the inverse of vertex split: re-triangulate the boundary
14
- * polygon in the same plane.
15
- *
16
- * 2. K=2 collinear seam. The fan splits into exactly two contiguous
17
- * coplanar arcs (different planes). The two crease vertices `a` and
18
- * `b` (the boundary points where the plane changes around v) are
19
- * collinear with v in 3D, with v between them. Removing v collapses
20
- * the two crease edges (v-a, v-b) into a single straight edge (a-b)
21
- * that lies in both planes; each arc's polygon re-triangulates
22
- * without v.
23
- *
24
- * Vertices with K >= 3 (multi-way corners) are kept.
25
- *
26
- * Removing a removable v is exact-lossless: the surface footprint is
27
- * identical, no vertex moves and none are created. The transformation
28
- * is the inverse of vertex split, so it is topology-preserving by
29
- * construction:
30
- *
31
- * - No T-junctions. Every old vertex on the polygon boundary remains a
32
- * vertex of every triangle that previously touched it. Adjacent fused
33
- * regions and verbatim regions stay coupled at every shared vertex.
34
- * - Watertight. The closed manifold structure is preserved across
35
- * removal. Both the K=1 and K=2 cases preserve the K=2 seam edge as
36
- * a single shared edge between the two plane groups.
37
- * - Bit-exact. Every output position is a verbatim copy of an input
38
- * position; no vertex is fabricated.
39
- *
40
- * Algorithm:
41
- *
42
- * 1. Build per-vertex incident-tri lists and per-tri normalized normals
43
- * and plane offsets.
44
- * 2. Process vertices via a dirty-flag worklist. Initially queue every
45
- * vertex; after a successful removal, re-queue the ring neighbours
46
- * so chains of K=1 / K=2 vertices collapse in one run.
47
- * 3. For each dequeued vertex `v`:
48
- * a. Walk the fan to extract the cyclic ring vertices and the
49
- * cyclic ordered tris (each tri (v, ring[i], ring[(i+1)%k]) is
50
- * the i-th tri in fan order).
51
- * b. Decide K. If all tris share a plane: K=1. Otherwise count
52
- * transitions in cyclic order; K=2 if exactly two arcs.
53
- * c. K>=3: skip. K=2: verify ring[i1], v, ring[i2] are collinear.
54
- * d. For each arc, project its polygon to 2D using the arc's
55
- * plane basis, ear-clip, and append the new tris.
56
- * e. Mark v's old tris dead, register the new tris in each polygon
57
- * vertex's incident list, and re-queue the ring.
58
- * 4. Compact: drop dead tris and unused vertices, remap indices.
59
- *
60
- * @param mesh - Input triangle mesh from {@link marchingCubes}.
61
- * @param voxelResolution - Size of one voxel in world units. Used to scale the plane-offset tolerance. (The K=2 collinearity check is purely angular and has no voxel-scaled term.)
62
- * @returns A new mesh with the same surface geometry, no T-junctions, and far fewer triangles.
63
- */
64
- const coplanarMerge = (mesh, voxelResolution) => {
65
- const { positions, indices } = mesh;
66
- const inputTriCount = (indices.length / 3) | 0;
67
- const vertCount = (positions.length / 3) | 0;
68
- if (inputTriCount === 0) {
69
- return { positions: new Float32Array(0), indices: new Uint32Array(0) };
70
- }
71
- // Plane-offset tolerance for the coplanarity test. An absolute floor
72
- // (voxelResolution * PLANE_REL_EPS) handles near-origin planes, while
73
- // a relative term scaled by max(|da|, |db|) handles large-offset
74
- // planes where Float32 position precision in d = n . p causes
75
- // relative error proportional to |d|. Without the relative term,
76
- // coplanar fans far from the origin would be seen as distinct planes.
77
- const planeEps = (da, db) => {
78
- const absA = da < 0 ? -da : da;
79
- const absB = db < 0 ? -db : db;
80
- return PLANE_REL_EPS * (voxelResolution + (absA > absB ? absA : absB));
81
- };
82
- // Mutable triangle table. Flat typed arrays so we can append new tris
83
- // (from ear-clipping) without per-tri allocations and without hitting
84
- // V8's regular-Array backing-store size cap on large meshes.
85
- // `triVertsArr` indexes are 3*t, everything else is t.
86
- //
87
- // Normals are stored as Float32 (unit vectors; ample precision for the
88
- // dot-product coplanarity test against `1 - NORMAL_EPS`). The plane
89
- // offset stays Float64 since it's an absolute world-space scalar that
90
- // can be large for distant scenes.
91
- let triCap = inputTriCount;
92
- let triVertsArr = new Uint32Array(triCap * 3);
93
- let triNxArr = new Float32Array(triCap);
94
- let triNyArr = new Float32Array(triCap);
95
- let triNzArr = new Float32Array(triCap);
96
- let triDArr = new Float64Array(triCap);
97
- let triAliveArr = new Uint8Array(triCap);
98
- let triCount = inputTriCount;
99
- // Capacity-doubling appenders for new tris generated by ear-clipping.
100
- const ensureTriCap = () => {
101
- if (triCount < triCap) {
102
- return;
103
- }
104
- const newCap = triCap * 2;
105
- const growF32 = (src) => {
106
- const out = new Float32Array(newCap);
107
- out.set(src);
108
- return out;
109
- };
110
- const growF64 = (src) => {
111
- const out = new Float64Array(newCap);
112
- out.set(src);
113
- return out;
114
- };
115
- const newVerts = new Uint32Array(newCap * 3);
116
- newVerts.set(triVertsArr);
117
- triVertsArr = newVerts;
118
- triNxArr = growF32(triNxArr);
119
- triNyArr = growF32(triNyArr);
120
- triNzArr = growF32(triNzArr);
121
- triDArr = growF64(triDArr);
122
- const aliveOut = new Uint8Array(newCap);
123
- aliveOut.set(triAliveArr);
124
- triAliveArr = aliveOut;
125
- triCap = newCap;
126
- };
127
- // Pass 0: compute per-tri normalized normal and plane offset.
128
- for (let t = 0; t < inputTriCount; t++) {
129
- const ia = indices[t * 3];
130
- const ib = indices[t * 3 + 1];
131
- const ic = indices[t * 3 + 2];
132
- triVertsArr[t * 3] = ia;
133
- triVertsArr[t * 3 + 1] = ib;
134
- triVertsArr[t * 3 + 2] = ic;
135
- const ax = positions[ia * 3];
136
- const ay = positions[ia * 3 + 1];
137
- const az = positions[ia * 3 + 2];
138
- const bx = positions[ib * 3];
139
- const by = positions[ib * 3 + 1];
140
- const bz = positions[ib * 3 + 2];
141
- const cx = positions[ic * 3];
142
- const cy = positions[ic * 3 + 1];
143
- const cz = positions[ic * 3 + 2];
144
- const ex = bx - ax, ey = by - ay, ez = bz - az;
145
- const fx = cx - ax, fy = cy - ay, fz = cz - az;
146
- let nx = ey * fz - ez * fy;
147
- let ny = ez * fx - ex * fz;
148
- let nz = ex * fy - ey * fx;
149
- const nLen = Math.sqrt(nx * nx + ny * ny + nz * nz);
150
- if (nLen < 1e-12) {
151
- // Degenerate input tri; drop it from the active set.
152
- triAliveArr[t] = 0;
153
- continue;
154
- }
155
- const inv = 1 / nLen;
156
- nx *= inv;
157
- ny *= inv;
158
- nz *= inv;
159
- triNxArr[t] = nx;
160
- triNyArr[t] = ny;
161
- triNzArr[t] = nz;
162
- triDArr[t] = nx * ax + ny * ay + nz * az;
163
- triAliveArr[t] = 1;
164
- }
165
- // Per-vertex incident-tri lists, stored as a singly-linked free-listed
166
- // node pool backed by typed arrays. Each pool node `n` holds:
167
- // poolTri[n] - the incident tri index
168
- // poolNext[n] - next node in the same vertex's list (or -1 = end)
169
- // `vertHead[v]` is the head node for vertex v (or -1 if v has no
170
- // incident tris). Removed nodes are pushed onto the `freeHead` chain
171
- // for reuse, so the pool's max occupancy is the initial fan-mention
172
- // count (3 * inputTriCount): the worklist's K=1/K=2 collapses are
173
- // monotonically tri-reducing, so the free list serves all subsequent
174
- // ear-clip allocations without ever growing the backing arrays.
175
- //
176
- // Footprint: 8 B/node * 3 * inputTriCount + 4 B/vert * vertCount.
177
- // For 25.7M raw tris / 12.9M raw verts that's ~615 MB pool +
178
- // ~52 MB heads, vs ~1.4 GB for the prior `number[][]` adjacency
179
- // (V8 Array headers, FixedArray headers, hidden classes per inner
180
- // array all inflate the boxed-SMI payload).
181
- const poolCap = inputTriCount * 3;
182
- const poolTri = new Int32Array(poolCap);
183
- const poolNext = new Int32Array(poolCap);
184
- let poolLen = 0;
185
- let freeHead = -1;
186
- const vertHead = new Int32Array(vertCount).fill(-1);
187
- const allocNode = () => {
188
- if (freeHead !== -1) {
189
- const n = freeHead;
190
- freeHead = poolNext[n];
191
- return n;
192
- }
193
- return poolLen++;
194
- };
195
- const freeNode = (n) => {
196
- poolNext[n] = freeHead;
197
- freeHead = n;
198
- };
199
- const addTriToVert = (v, tri) => {
200
- const n = allocNode();
201
- poolTri[n] = tri;
202
- poolNext[n] = vertHead[v];
203
- vertHead[v] = n;
204
- };
205
- // O(degree) removal of `tri` from `v`'s incident list. Returns true
206
- // if found and removed.
207
- const removeTriFromVert = (v, tri) => {
208
- let prev = -1;
209
- let cur = vertHead[v];
210
- while (cur !== -1) {
211
- if (poolTri[cur] === tri) {
212
- const nxt = poolNext[cur];
213
- if (prev === -1) {
214
- vertHead[v] = nxt;
215
- }
216
- else {
217
- poolNext[prev] = nxt;
218
- }
219
- freeNode(cur);
220
- return true;
221
- }
222
- prev = cur;
223
- cur = poolNext[cur];
224
- }
225
- return false;
226
- };
227
- for (let t = 0; t < inputTriCount; t++) {
228
- if (triAliveArr[t] === 0) {
229
- continue;
230
- }
231
- addTriToVert(triVertsArr[t * 3], t);
232
- addTriToVert(triVertsArr[t * 3 + 1], t);
233
- addTriToVert(triVertsArr[t * 3 + 2], t);
234
- }
235
- // Build a right-handed tangent / bitangent basis (t, b) on the plane
236
- // with normal n, picking the cardinal axis least aligned with n to
237
- // avoid precision loss in the cross product. By construction
238
- // t x b = n, so a polygon traced CCW around +n in 3D projects to a
239
- // CCW polygon in (t, b) coordinates (positive 2D signed area).
240
- const buildBasis = (nx, ny, nz) => {
241
- const ax = Math.abs(nx);
242
- const ay = Math.abs(ny);
243
- const az = Math.abs(nz);
244
- let tx, ty, tz;
245
- if (ax <= ay && ax <= az) {
246
- // X axis least aligned: t = (1, 0, 0) x n
247
- tx = 0;
248
- ty = -nz;
249
- tz = ny;
250
- }
251
- else if (ay <= az) {
252
- // Y axis least aligned: t = (0, 1, 0) x n
253
- tx = nz;
254
- ty = 0;
255
- tz = -nx;
256
- }
257
- else {
258
- // Z axis least aligned: t = (0, 0, 1) x n
259
- tx = -ny;
260
- ty = nx;
261
- tz = 0;
262
- }
263
- const tlen = Math.sqrt(tx * tx + ty * ty + tz * tz);
264
- const inv = 1 / tlen;
265
- tx *= inv;
266
- ty *= inv;
267
- tz *= inv;
268
- // bitangent = n x t (unit length because n and t are unit and
269
- // perpendicular).
270
- const bx = ny * tz - nz * ty;
271
- const by = nz * tx - nx * tz;
272
- const bz = nx * ty - ny * tx;
273
- return [tx, ty, tz, bx, by, bz];
274
- };
275
- // Reusable scratch buffers for extractFan. With typical MC fan sizes
276
- // (k <= ~12), a linear scan over parallel typed arrays is cheaper than
277
- // the Map-of-Map-of-Set allocations the previous version paid per
278
- // vertex, and saves ~3 allocations per worklist iteration on meshes
279
- // with tens of millions of vertices.
280
- let fanScratchCap = 16;
281
- let fanFromScratch = new Int32Array(fanScratchCap);
282
- let fanToScratch = new Int32Array(fanScratchCap);
283
- let fanTriScratch = new Int32Array(fanScratchCap);
284
- let fanRingScratch = new Int32Array(fanScratchCap);
285
- let fanTrisScratch = new Int32Array(fanScratchCap);
286
- const growFanScratch = (need) => {
287
- if (need <= fanScratchCap) {
288
- return;
289
- }
290
- let c = fanScratchCap;
291
- while (c < need) {
292
- c *= 2;
293
- }
294
- fanFromScratch = new Int32Array(c);
295
- fanToScratch = new Int32Array(c);
296
- fanTriScratch = new Int32Array(c);
297
- fanRingScratch = new Int32Array(c);
298
- fanTrisScratch = new Int32Array(c);
299
- fanScratchCap = c;
300
- };
301
- // Walk v's fan to extract its cyclic boundary polygon AND the matching
302
- // cyclic ordered tris. ring[i] is the "from" vertex of the i-th tri
303
- // (in fan order), and tris[i] = (v, ring[i], ring[(i+1) % k]) is the
304
- // tri whose two non-v vertices are ring[i] and ring[(i+1) % k].
305
- // Returns null when the fan is non-manifold (duplicate from-vertex,
306
- // closes prematurely, or fails to close).
307
- //
308
- // Output aliases the module-level `fanRingScratch` / `fanTrisScratch`
309
- // buffers; the caller must consume them before the next extractFan
310
- // call. `k` is returned explicitly since the scratch buffers may be
311
- // oversized.
312
- const extractFan = (v) => {
313
- let k = 0;
314
- for (let n = vertHead[v]; n !== -1; n = poolNext[n]) {
315
- k++;
316
- }
317
- if (k < 3) {
318
- return -1;
319
- }
320
- growFanScratch(k);
321
- // Collect (from, to, tri) triples into parallel scratch arrays.
322
- // The O(k^2) duplicate-from scan here and the O(k^2) cyclic walk
323
- // below are cheap for small k and avoid per-vertex Map allocs.
324
- let i = 0;
325
- for (let n = vertHead[v]; n !== -1; n = poolNext[n]) {
326
- const t = poolTri[n];
327
- const a = triVertsArr[t * 3];
328
- const b = triVertsArr[t * 3 + 1];
329
- const c = triVertsArr[t * 3 + 2];
330
- let from, to;
331
- if (a === v) {
332
- from = b;
333
- to = c;
334
- }
335
- else if (b === v) {
336
- from = c;
337
- to = a;
338
- }
339
- else {
340
- from = a;
341
- to = b;
342
- }
343
- for (let j = 0; j < i; j++) {
344
- if (fanFromScratch[j] === from) {
345
- return -1;
346
- }
347
- }
348
- fanFromScratch[i] = from;
349
- fanToScratch[i] = to;
350
- fanTriScratch[i] = t;
351
- i++;
352
- }
353
- const start = fanFromScratch[0];
354
- let cur = start;
355
- for (let step = 0; step < k; step++) {
356
- fanRingScratch[step] = cur;
357
- let found = -1;
358
- for (let j = 0; j < k; j++) {
359
- if (fanFromScratch[j] === cur) {
360
- found = j;
361
- break;
362
- }
363
- }
364
- if (found === -1) {
365
- return -1;
366
- }
367
- fanTrisScratch[step] = fanTriScratch[found];
368
- const next = fanToScratch[found];
369
- // Premature cycle close => fan is non-manifold (multi-component).
370
- if (next === start && step < k - 1) {
371
- return -1;
372
- }
373
- cur = next;
374
- }
375
- if (cur !== start) {
376
- return -1;
377
- }
378
- return k;
379
- };
380
- // Reusable scratch for earClip's doubly-linked polygon traversal.
381
- // Grows to the largest polygon seen and is reused across every
382
- // worklist iteration.
383
- let earPrevScratch = new Int32Array(16);
384
- let earNextScratch = new Int32Array(16);
385
- const growEarInternalScratch = (n) => {
386
- if (n <= earPrevScratch.length) {
387
- return;
388
- }
389
- let c = earPrevScratch.length;
390
- while (c < n) {
391
- c *= 2;
392
- }
393
- earPrevScratch = new Int32Array(c);
394
- earNextScratch = new Int32Array(c);
395
- };
396
- // Ear-clip a planar simple polygon. `px, py` are the projected 2D
397
- // coordinates of the first `n` polygon vertices (in CCW order); the
398
- // arrays may be larger than `n` (scratch-buffer aliasing is fine,
399
- // only [0, n) is read). Writes (n - 2) * 3 polygon-relative vertex
400
- // indices into `out[outOffset..]` and returns the number of indices
401
- // written on success, or -1 if the polygon is degenerate / not
402
- // simple.
403
- //
404
- // Strictly-collinear interior vertices (cross product == 0) are not
405
- // considered convex ear apices and so produce slivers as side
406
- // vertices of neighbouring ears. These are transient: any such
407
- // vertex is itself K=1 in the same plane and the worklist removes
408
- // it in a subsequent iteration, replacing the slivers with a clean
409
- // re-triangulation of its updated fan. An in-earClip pre-pass that
410
- // drops the vertex from this polygon would create a T-junction
411
- // with the vertex's other incident tris (which still reference it),
412
- // so we leave the cleanup to the worklist.
413
- const earClip = (px, py, n, out, outOffset) => {
414
- if (n < 3) {
415
- return -1;
416
- }
417
- if (n === 3) {
418
- out[outOffset] = 0;
419
- out[outOffset + 1] = 1;
420
- out[outOffset + 2] = 2;
421
- return 3;
422
- }
423
- // Verify CCW orientation. By construction (right-handed basis)
424
- // valid input polygons are CCW with positive signed area.
425
- let area2 = 0;
426
- for (let i = 0; i < n; i++) {
427
- const j = (i + 1) % n;
428
- area2 += px[i] * py[j] - px[j] * py[i];
429
- }
430
- if (area2 <= 0) {
431
- return -1;
432
- }
433
- growEarInternalScratch(n);
434
- const prev = earPrevScratch;
435
- const next = earNextScratch;
436
- for (let i = 0; i < n; i++) {
437
- prev[i] = (i - 1 + n) % n;
438
- next[i] = (i + 1) % n;
439
- }
440
- const isConvex = (a, b, c) => {
441
- return (px[b] - px[a]) * (py[c] - py[a]) -
442
- (py[b] - py[a]) * (px[c] - px[a]) > 0;
443
- };
444
- const inTri = (p, a, b, c) => {
445
- const x = px[p], y = py[p];
446
- const d1 = (x - px[b]) * (py[a] - py[b]) - (px[a] - px[b]) * (y - py[b]);
447
- const d2 = (x - px[c]) * (py[b] - py[c]) - (px[b] - px[c]) * (y - py[c]);
448
- const d3 = (x - px[a]) * (py[c] - py[a]) - (px[c] - px[a]) * (y - py[a]);
449
- const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
450
- const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
451
- return !(hasNeg && hasPos);
452
- };
453
- const isEar = (a, b, c) => {
454
- if (!isConvex(a, b, c)) {
455
- return false;
456
- }
457
- let p = next[c];
458
- while (p !== a) {
459
- if (inTri(p, a, b, c)) {
460
- return false;
461
- }
462
- p = next[p];
463
- }
464
- return true;
465
- };
466
- let resultLen = 0;
467
- let count = n;
468
- let i = 0;
469
- let stalls = 0;
470
- while (count > 3) {
471
- if (stalls > count) {
472
- return -1;
473
- }
474
- const p = prev[i];
475
- const nxt = next[i];
476
- if (isEar(p, i, nxt)) {
477
- out[outOffset + resultLen++] = p;
478
- out[outOffset + resultLen++] = i;
479
- out[outOffset + resultLen++] = nxt;
480
- next[p] = nxt;
481
- prev[nxt] = p;
482
- count--;
483
- i = nxt;
484
- stalls = 0;
485
- }
486
- else {
487
- i = next[i];
488
- stalls++;
489
- }
490
- }
491
- out[outOffset + resultLen++] = prev[i];
492
- out[outOffset + resultLen++] = i;
493
- out[outOffset + resultLen++] = next[i];
494
- return resultLen;
495
- };
496
- // Worklist: iterative dirty-flag scheduler. Initially queue every
497
- // vertex; on each successful removal, re-queue the ring neighbours so
498
- // chains of K=1 / K=2 vertices collapse in a single run.
499
- const inQueue = new Uint8Array(vertCount);
500
- let queue = new Int32Array(Math.max(vertCount, 16));
501
- let queueLen = 0;
502
- let queueHead = 0;
503
- const pushQueue = (u) => {
504
- if (inQueue[u]) {
505
- return;
506
- }
507
- inQueue[u] = 1;
508
- if (queueLen >= queue.length) {
509
- const grown = new Int32Array(queue.length * 2);
510
- grown.set(queue);
511
- queue = grown;
512
- }
513
- queue[queueLen++] = u;
514
- };
515
- const compactQueue = () => {
516
- // Reclaim consumed prefix when slack exceeds 50% (and is large
517
- // enough to be worth the copy). Bounded by O(total pushes).
518
- if (queueHead > 4096 && queueHead * 2 > queueLen) {
519
- queue.copyWithin(0, queueHead, queueLen);
520
- queueLen -= queueHead;
521
- queueHead = 0;
522
- }
523
- };
524
- for (let v = 0; v < vertCount; v++) {
525
- inQueue[v] = 1;
526
- queue[queueLen++] = v;
527
- }
528
- // Tolerance for K=2 seam collinearity: cosine of the angle between
529
- // (v -> a) and (v -> b) must be <= -(1 - COLLINEAR_REL_EPS), i.e.
530
- // the two seam edges through v are nearly antiparallel (v lies on
531
- // the segment from a to b in 3D).
532
- const cosineMax = -1 + COLLINEAR_REL_EPS;
533
- // Reusable scratch for the per-arc plane descriptor.
534
- const arcStartIdx = new Int32Array(2);
535
- const arcPolySize = new Int32Array(2);
536
- const arcPlaneT = new Int32Array(2);
537
- // Reusable per-arc scratch buffers (arcCount is always 1 or 2).
538
- // Each slot holds the polygon vertex indices, the 2D-projected
539
- // coordinates, and the ear-clip triangle indices for one arc. All
540
- // grow monotonically to the largest polygon ever seen and are
541
- // reused for every worklist iteration, so the hot loop is mostly
542
- // allocation-free.
543
- const arcPolyScratch = [new Int32Array(16), new Int32Array(16)];
544
- const arcPxScratch = [new Float64Array(16), new Float64Array(16)];
545
- const arcPyScratch = [new Float64Array(16), new Float64Array(16)];
546
- const arcEarScratch = [new Int32Array(16), new Int32Array(16)];
547
- const arcEarLen = new Int32Array(2);
548
- const ensureArcScratch = (g, polySize) => {
549
- if (polySize > arcPolyScratch[g].length) {
550
- let c = arcPolyScratch[g].length;
551
- while (c < polySize) {
552
- c *= 2;
553
- }
554
- arcPolyScratch[g] = new Int32Array(c);
555
- arcPxScratch[g] = new Float64Array(c);
556
- arcPyScratch[g] = new Float64Array(c);
557
- }
558
- const earNeed = (polySize - 2) * 3;
559
- if (earNeed > arcEarScratch[g].length) {
560
- let c = arcEarScratch[g].length;
561
- while (c < earNeed) {
562
- c *= 2;
563
- }
564
- arcEarScratch[g] = new Int32Array(c);
565
- }
566
- };
567
- while (queueHead < queueLen) {
568
- const v = queue[queueHead++];
569
- inQueue[v] = 0;
570
- compactQueue();
571
- // Cheap "fan size < 3" early-out without traversing the full
572
- // linked list.
573
- const h0 = vertHead[v];
574
- if (h0 === -1) {
575
- continue;
576
- }
577
- const h1 = poolNext[h0];
578
- if (h1 === -1) {
579
- continue;
580
- }
581
- if (poolNext[h1] === -1) {
582
- continue;
583
- }
584
- const k = extractFan(v);
585
- if (k === -1) {
586
- continue;
587
- }
588
- const ring = fanRingScratch;
589
- const fanTris = fanTrisScratch;
590
- // Decide K. First check if all tris are coplanar with fanTris[0]
591
- // (K=1 fast path).
592
- const t0 = fanTris[0];
593
- const n0x = triNxArr[t0];
594
- const n0y = triNyArr[t0];
595
- const n0z = triNzArr[t0];
596
- const d0 = triDArr[t0];
597
- let allCoplanar = true;
598
- for (let i = 1; i < k; i++) {
599
- const t = fanTris[i];
600
- const dT = triDArr[t];
601
- const dotN = triNxArr[t] * n0x + triNyArr[t] * n0y + triNzArr[t] * n0z;
602
- if (dotN < 1 - NORMAL_EPS || Math.abs(dT - d0) > planeEps(dT, d0)) {
603
- allCoplanar = false;
604
- break;
605
- }
606
- }
607
- let arcCount = 0;
608
- if (allCoplanar) {
609
- // K=1: single arc covering the whole ring (k vertices).
610
- arcStartIdx[0] = 0;
611
- arcPolySize[0] = k;
612
- arcPlaneT[0] = t0;
613
- arcCount = 1;
614
- }
615
- else {
616
- // Walk cyclically; mark transitions where plane changes vs.
617
- // the previous tri in fan order. Exactly 2 transitions =>
618
- // K=2 candidate.
619
- let nTransitions = 0;
620
- let i1 = -1;
621
- let i2 = -1;
622
- for (let i = 0; i < k; i++) {
623
- const t = fanTris[i];
624
- const prevT = fanTris[(i - 1 + k) % k];
625
- const nx1 = triNxArr[t];
626
- const ny1 = triNyArr[t];
627
- const nz1 = triNzArr[t];
628
- const d1 = triDArr[t];
629
- const nx0 = triNxArr[prevT];
630
- const ny0 = triNyArr[prevT];
631
- const nz0 = triNzArr[prevT];
632
- const dPrev = triDArr[prevT];
633
- const dotN = nx1 * nx0 + ny1 * ny0 + nz1 * nz0;
634
- if (dotN < 1 - NORMAL_EPS || Math.abs(d1 - dPrev) > planeEps(d1, dPrev)) {
635
- nTransitions++;
636
- if (nTransitions === 1) {
637
- i1 = i;
638
- }
639
- else if (nTransitions === 2) {
640
- i2 = i;
641
- }
642
- else {
643
- break;
644
- }
645
- }
646
- }
647
- if (nTransitions !== 2) {
648
- continue;
649
- }
650
- // K=2 collinearity: ring[i1], v, ring[i2] must be collinear
651
- // with v between (cosine of va-vb angle near -1).
652
- const a = ring[i1];
653
- const b = ring[i2];
654
- const ax = positions[a * 3] - positions[v * 3];
655
- const ay = positions[a * 3 + 1] - positions[v * 3 + 1];
656
- const az = positions[a * 3 + 2] - positions[v * 3 + 2];
657
- const bx = positions[b * 3] - positions[v * 3];
658
- const by = positions[b * 3 + 1] - positions[v * 3 + 1];
659
- const bz = positions[b * 3 + 2] - positions[v * 3 + 2];
660
- const lenA = Math.sqrt(ax * ax + ay * ay + az * az);
661
- const lenB = Math.sqrt(bx * bx + by * by + bz * bz);
662
- if (lenA < 1e-12 || lenB < 1e-12) {
663
- continue;
664
- }
665
- const cosine = (ax * bx + ay * by + az * bz) / (lenA * lenB);
666
- if (cosine > cosineMax) {
667
- continue;
668
- }
669
- // Two arcs: arc 0 covers tris[i1..i2-1] (mA tris, mA+1 polygon
670
- // verts ring[i1..i2]); arc 1 covers tris[i2..i1-1] cyclically
671
- // (mB tris, mB+1 polygon verts).
672
- const mA = i2 - i1;
673
- const mB = k - mA;
674
- arcStartIdx[0] = i1;
675
- arcPolySize[0] = mA + 1;
676
- arcPlaneT[0] = fanTris[i1];
677
- arcStartIdx[1] = i2;
678
- arcPolySize[1] = mB + 1;
679
- arcPlaneT[1] = fanTris[i2];
680
- arcCount = 2;
681
- }
682
- // Build polygons and triangulations into the reusable per-arc
683
- // scratch slots. Bail (without committing) if any arc fails to
684
- // triangulate.
685
- let allArcsOk = true;
686
- for (let g = 0; g < arcCount; g++) {
687
- const polySize = arcPolySize[g];
688
- const startIdx = arcStartIdx[g];
689
- const planeT = arcPlaneT[g];
690
- ensureArcScratch(g, polySize);
691
- const poly = arcPolyScratch[g];
692
- for (let j = 0; j < polySize; j++) {
693
- poly[j] = ring[(startIdx + j) % k];
694
- }
695
- const nx = triNxArr[planeT];
696
- const ny = triNyArr[planeT];
697
- const nz = triNzArr[planeT];
698
- const [tx, ty, tz, bx, by, bz] = buildBasis(nx, ny, nz);
699
- const px = arcPxScratch[g];
700
- const py = arcPyScratch[g];
701
- for (let j = 0; j < polySize; j++) {
702
- const u = poly[j];
703
- const x = positions[u * 3];
704
- const y = positions[u * 3 + 1];
705
- const z = positions[u * 3 + 2];
706
- px[j] = x * tx + y * ty + z * tz;
707
- py[j] = x * bx + y * by + z * bz;
708
- }
709
- const earCount = earClip(px, py, polySize, arcEarScratch[g], 0);
710
- if (earCount !== (polySize - 2) * 3) {
711
- allArcsOk = false;
712
- break;
713
- }
714
- arcEarLen[g] = earCount;
715
- }
716
- if (!allArcsOk) {
717
- continue;
718
- }
719
- // Commit: walk v's linked-list of incident tris in place. For each
720
- // tri, mark it dead, unlink it from each non-v vertex's list, and
721
- // free v's own node. Iteration is safe because we only mutate
722
- // OTHER vertices' lists during the walk.
723
- let cur = vertHead[v];
724
- while (cur !== -1) {
725
- const t = poolTri[cur];
726
- triAliveArr[t] = 0;
727
- for (let j = 0; j < 3; j++) {
728
- const u = triVertsArr[t * 3 + j];
729
- if (u === v) {
730
- continue;
731
- }
732
- removeTriFromVert(u, t);
733
- }
734
- const nxt = poolNext[cur];
735
- freeNode(cur);
736
- cur = nxt;
737
- }
738
- vertHead[v] = -1;
739
- // Append new tris per arc, each carrying its arc's plane.
740
- for (let g = 0; g < arcCount; g++) {
741
- const planeT = arcPlaneT[g];
742
- const nx = triNxArr[planeT];
743
- const ny = triNyArr[planeT];
744
- const nz = triNzArr[planeT];
745
- const d = triDArr[planeT];
746
- const earIdx = arcEarScratch[g];
747
- const earIdxLen = arcEarLen[g];
748
- const poly = arcPolyScratch[g];
749
- for (let i = 0; i < earIdxLen; i += 3) {
750
- const ua = poly[earIdx[i]];
751
- const ub = poly[earIdx[i + 1]];
752
- const uc = poly[earIdx[i + 2]];
753
- ensureTriCap();
754
- const newT = triCount++;
755
- triVertsArr[newT * 3] = ua;
756
- triVertsArr[newT * 3 + 1] = ub;
757
- triVertsArr[newT * 3 + 2] = uc;
758
- triNxArr[newT] = nx;
759
- triNyArr[newT] = ny;
760
- triNzArr[newT] = nz;
761
- triDArr[newT] = d;
762
- triAliveArr[newT] = 1;
763
- addTriToVert(ua, newT);
764
- addTriToVert(ub, newT);
765
- addTriToVert(uc, newT);
766
- }
767
- }
768
- // Re-queue every ring vertex: each may now satisfy K=1 or K=2 in
769
- // its updated fan, allowing the collapse to propagate along long
770
- // collinear chains in a single sweep.
771
- for (let i = 0; i < k; i++) {
772
- pushQueue(ring[i]);
773
- }
774
- }
775
- // Compact: drop dead tris and unused vertices, remap indices.
776
- const usedVerts = new Uint8Array(vertCount);
777
- let outTriCount = 0;
778
- for (let t = 0; t < triCount; t++) {
779
- if (triAliveArr[t] === 0) {
780
- continue;
781
- }
782
- outTriCount++;
783
- usedVerts[triVertsArr[t * 3]] = 1;
784
- usedVerts[triVertsArr[t * 3 + 1]] = 1;
785
- usedVerts[triVertsArr[t * 3 + 2]] = 1;
786
- }
787
- let outVertCount = 0;
788
- const vertRemap = new Int32Array(vertCount);
789
- for (let v = 0; v < vertCount; v++) {
790
- if (usedVerts[v] === 1) {
791
- vertRemap[v] = outVertCount++;
792
- }
793
- else {
794
- vertRemap[v] = -1;
795
- }
796
- }
797
- const outPositions = new Float32Array(outVertCount * 3);
798
- for (let v = 0; v < vertCount; v++) {
799
- if (usedVerts[v] === 0) {
800
- continue;
801
- }
802
- const o = vertRemap[v] * 3;
803
- outPositions[o] = positions[v * 3];
804
- outPositions[o + 1] = positions[v * 3 + 1];
805
- outPositions[o + 2] = positions[v * 3 + 2];
806
- }
807
- const outIndices = new Uint32Array(outTriCount * 3);
808
- let oi = 0;
809
- for (let t = 0; t < triCount; t++) {
810
- if (triAliveArr[t] === 0) {
811
- continue;
812
- }
813
- outIndices[oi++] = vertRemap[triVertsArr[t * 3]];
814
- outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 1]];
815
- outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 2]];
816
- }
817
- return { positions: outPositions, indices: outIndices };
818
- };
819
- export { coplanarMerge };
1
+ const NORMAL_EPS = 1e-3;
2
+ const PLANE_REL_EPS = 1e-3;
3
+ const COLLINEAR_REL_EPS = 1e-3;
4
+ /**
5
+ * Losslessly reduce coplanar regions of a marching-cubes mesh by
6
+ * topology-preserving vertex removal.
7
+ *
8
+ * For a closed manifold MC mesh, a vertex `v` is "lossless-removable" iff
9
+ * its incident-tri fan, walked in cyclic order, falls into one of:
10
+ *
11
+ * 1. K=1 coplanar fan. Every triangle in v's fan lies on the same plane
12
+ * (same unit normal and same plane offset, within tolerance). Removing
13
+ * v is the inverse of vertex split: re-triangulate the boundary
14
+ * polygon in the same plane.
15
+ *
16
+ * 2. K=2 collinear seam. The fan splits into exactly two contiguous
17
+ * coplanar arcs (different planes). The two crease vertices `a` and
18
+ * `b` (the boundary points where the plane changes around v) are
19
+ * collinear with v in 3D, with v between them. Removing v collapses
20
+ * the two crease edges (v-a, v-b) into a single straight edge (a-b)
21
+ * that lies in both planes; each arc's polygon re-triangulates
22
+ * without v.
23
+ *
24
+ * Vertices with K >= 3 (multi-way corners) are kept.
25
+ *
26
+ * Removing a removable v is exact-lossless: the surface footprint is
27
+ * identical, no vertex moves and none are created. The transformation
28
+ * is the inverse of vertex split, so it is topology-preserving by
29
+ * construction:
30
+ *
31
+ * - No T-junctions. Every old vertex on the polygon boundary remains a
32
+ * vertex of every triangle that previously touched it. Adjacent fused
33
+ * regions and verbatim regions stay coupled at every shared vertex.
34
+ * - Watertight. The closed manifold structure is preserved across
35
+ * removal. Both the K=1 and K=2 cases preserve the K=2 seam edge as
36
+ * a single shared edge between the two plane groups.
37
+ * - Bit-exact. Every output position is a verbatim copy of an input
38
+ * position; no vertex is fabricated.
39
+ *
40
+ * Algorithm:
41
+ *
42
+ * 1. Build per-vertex incident-tri lists and per-tri normalized normals
43
+ * and plane offsets.
44
+ * 2. Process vertices via a dirty-flag worklist. Initially queue every
45
+ * vertex; after a successful removal, re-queue the ring neighbours
46
+ * so chains of K=1 / K=2 vertices collapse in one run.
47
+ * 3. For each dequeued vertex `v`:
48
+ * a. Walk the fan to extract the cyclic ring vertices and the
49
+ * cyclic ordered tris (each tri (v, ring[i], ring[(i+1)%k]) is
50
+ * the i-th tri in fan order).
51
+ * b. Decide K. If all tris share a plane: K=1. Otherwise count
52
+ * transitions in cyclic order; K=2 if exactly two arcs.
53
+ * c. K>=3: skip. K=2: verify ring[i1], v, ring[i2] are collinear.
54
+ * d. For each arc, project its polygon to 2D using the arc's
55
+ * plane basis, ear-clip, and append the new tris.
56
+ * e. Mark v's old tris dead, register the new tris in each polygon
57
+ * vertex's incident list, and re-queue the ring.
58
+ * 4. Compact: drop dead tris and unused vertices, remap indices.
59
+ *
60
+ * @param mesh - Input triangle mesh from {@link marchingCubes}.
61
+ * @param voxelResolution - Size of one voxel in world units. Used to scale the plane-offset tolerance. (The K=2 collinearity check is purely angular and has no voxel-scaled term.)
62
+ * @returns A new mesh with the same surface geometry, no T-junctions, and far fewer triangles.
63
+ */
64
+ function coplanarMerge(mesh, voxelResolution) {
65
+ const { positions, indices } = mesh;
66
+ const inputTriCount = (indices.length / 3) | 0;
67
+ const vertCount = (positions.length / 3) | 0;
68
+ if (inputTriCount === 0) {
69
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
70
+ }
71
+ // Plane-offset tolerance for the coplanarity test. An absolute floor
72
+ // (voxelResolution * PLANE_REL_EPS) handles near-origin planes, while
73
+ // a relative term scaled by max(|da|, |db|) handles large-offset
74
+ // planes where Float32 position precision in d = n . p causes
75
+ // relative error proportional to |d|. Without the relative term,
76
+ // coplanar fans far from the origin would be seen as distinct planes.
77
+ function planeEps(da, db) {
78
+ const absA = da < 0 ? -da : da;
79
+ const absB = db < 0 ? -db : db;
80
+ return PLANE_REL_EPS * (voxelResolution + (absA > absB ? absA : absB));
81
+ }
82
+ // Mutable triangle table. Flat typed arrays so we can append new tris
83
+ // (from ear-clipping) without per-tri allocations and without hitting
84
+ // V8's regular-Array backing-store size cap on large meshes.
85
+ // `triVertsArr` indexes are 3*t, everything else is t.
86
+ //
87
+ // Normals are stored as Float32 (unit vectors; ample precision for the
88
+ // dot-product coplanarity test against `1 - NORMAL_EPS`). The plane
89
+ // offset stays Float64 since it's an absolute world-space scalar that
90
+ // can be large for distant scenes.
91
+ let triCap = inputTriCount;
92
+ let triVertsArr = new Uint32Array(triCap * 3);
93
+ let triNxArr = new Float32Array(triCap);
94
+ let triNyArr = new Float32Array(triCap);
95
+ let triNzArr = new Float32Array(triCap);
96
+ let triDArr = new Float64Array(triCap);
97
+ let triAliveArr = new Uint8Array(triCap);
98
+ let triCount = inputTriCount;
99
+ // Capacity-doubling appenders for new tris generated by ear-clipping.
100
+ function ensureTriCap() {
101
+ if (triCount < triCap) {
102
+ return;
103
+ }
104
+ const newCap = triCap * 2;
105
+ function growF32(src) {
106
+ const out = new Float32Array(newCap);
107
+ out.set(src);
108
+ return out;
109
+ }
110
+ function growF64(src) {
111
+ const out = new Float64Array(newCap);
112
+ out.set(src);
113
+ return out;
114
+ }
115
+ const newVerts = new Uint32Array(newCap * 3);
116
+ newVerts.set(triVertsArr);
117
+ triVertsArr = newVerts;
118
+ triNxArr = growF32(triNxArr);
119
+ triNyArr = growF32(triNyArr);
120
+ triNzArr = growF32(triNzArr);
121
+ triDArr = growF64(triDArr);
122
+ const aliveOut = new Uint8Array(newCap);
123
+ aliveOut.set(triAliveArr);
124
+ triAliveArr = aliveOut;
125
+ triCap = newCap;
126
+ }
127
+ // Pass 0: compute per-tri normalized normal and plane offset.
128
+ for (let t = 0; t < inputTriCount; t++) {
129
+ const ia = indices[t * 3];
130
+ const ib = indices[t * 3 + 1];
131
+ const ic = indices[t * 3 + 2];
132
+ triVertsArr[t * 3] = ia;
133
+ triVertsArr[t * 3 + 1] = ib;
134
+ triVertsArr[t * 3 + 2] = ic;
135
+ const ax = positions[ia * 3];
136
+ const ay = positions[ia * 3 + 1];
137
+ const az = positions[ia * 3 + 2];
138
+ const bx = positions[ib * 3];
139
+ const by = positions[ib * 3 + 1];
140
+ const bz = positions[ib * 3 + 2];
141
+ const cx = positions[ic * 3];
142
+ const cy = positions[ic * 3 + 1];
143
+ const cz = positions[ic * 3 + 2];
144
+ const ex = bx - ax, ey = by - ay, ez = bz - az;
145
+ const fx = cx - ax, fy = cy - ay, fz = cz - az;
146
+ let nx = ey * fz - ez * fy;
147
+ let ny = ez * fx - ex * fz;
148
+ let nz = ex * fy - ey * fx;
149
+ const nLen = Math.sqrt(nx * nx + ny * ny + nz * nz);
150
+ if (nLen < 1e-12) {
151
+ // Degenerate input tri; drop it from the active set.
152
+ triAliveArr[t] = 0;
153
+ continue;
154
+ }
155
+ const inv = 1 / nLen;
156
+ nx *= inv;
157
+ ny *= inv;
158
+ nz *= inv;
159
+ triNxArr[t] = nx;
160
+ triNyArr[t] = ny;
161
+ triNzArr[t] = nz;
162
+ triDArr[t] = nx * ax + ny * ay + nz * az;
163
+ triAliveArr[t] = 1;
164
+ }
165
+ // Per-vertex incident-tri lists, stored as a singly-linked free-listed
166
+ // node pool backed by typed arrays. Each pool node `n` holds:
167
+ // poolTri[n] - the incident tri index
168
+ // poolNext[n] - next node in the same vertex's list (or -1 = end)
169
+ // `vertHead[v]` is the head node for vertex v (or -1 if v has no
170
+ // incident tris). Removed nodes are pushed onto the `freeHead` chain
171
+ // for reuse, so the pool's max occupancy is the initial fan-mention
172
+ // count (3 * inputTriCount): the worklist's K=1/K=2 collapses are
173
+ // monotonically tri-reducing, so the free list serves all subsequent
174
+ // ear-clip allocations without ever growing the backing arrays.
175
+ //
176
+ // Footprint: 8 B/node * 3 * inputTriCount + 4 B/vert * vertCount.
177
+ // For 25.7M raw tris / 12.9M raw verts that's ~615 MB pool +
178
+ // ~52 MB heads, vs ~1.4 GB for the prior `number[][]` adjacency
179
+ // (V8 Array headers, FixedArray headers, hidden classes per inner
180
+ // array all inflate the boxed-SMI payload).
181
+ const poolCap = inputTriCount * 3;
182
+ const poolTri = new Int32Array(poolCap);
183
+ const poolNext = new Int32Array(poolCap);
184
+ let poolLen = 0;
185
+ let freeHead = -1;
186
+ const vertHead = new Int32Array(vertCount).fill(-1);
187
+ function allocNode() {
188
+ if (freeHead !== -1) {
189
+ const n = freeHead;
190
+ freeHead = poolNext[n];
191
+ return n;
192
+ }
193
+ return poolLen++;
194
+ }
195
+ function freeNode(n) {
196
+ poolNext[n] = freeHead;
197
+ freeHead = n;
198
+ }
199
+ function addTriToVert(v, tri) {
200
+ const n = allocNode();
201
+ poolTri[n] = tri;
202
+ poolNext[n] = vertHead[v];
203
+ vertHead[v] = n;
204
+ }
205
+ // O(degree) removal of `tri` from `v`'s incident list. Returns true
206
+ // if found and removed.
207
+ function removeTriFromVert(v, tri) {
208
+ let prev = -1;
209
+ let cur = vertHead[v];
210
+ while (cur !== -1) {
211
+ if (poolTri[cur] === tri) {
212
+ const nxt = poolNext[cur];
213
+ if (prev === -1) {
214
+ vertHead[v] = nxt;
215
+ }
216
+ else {
217
+ poolNext[prev] = nxt;
218
+ }
219
+ freeNode(cur);
220
+ return true;
221
+ }
222
+ prev = cur;
223
+ cur = poolNext[cur];
224
+ }
225
+ return false;
226
+ }
227
+ for (let t = 0; t < inputTriCount; t++) {
228
+ if (triAliveArr[t] === 0) {
229
+ continue;
230
+ }
231
+ addTriToVert(triVertsArr[t * 3], t);
232
+ addTriToVert(triVertsArr[t * 3 + 1], t);
233
+ addTriToVert(triVertsArr[t * 3 + 2], t);
234
+ }
235
+ // Build a right-handed tangent / bitangent basis (t, b) on the plane
236
+ // with normal n, picking the cardinal axis least aligned with n to
237
+ // avoid precision loss in the cross product. By construction
238
+ // t x b = n, so a polygon traced CCW around +n in 3D projects to a
239
+ // CCW polygon in (t, b) coordinates (positive 2D signed area).
240
+ function buildBasis(nx, ny, nz) {
241
+ const ax = Math.abs(nx);
242
+ const ay = Math.abs(ny);
243
+ const az = Math.abs(nz);
244
+ let tx, ty, tz;
245
+ if (ax <= ay && ax <= az) {
246
+ // X axis least aligned: t = (1, 0, 0) x n
247
+ tx = 0;
248
+ ty = -nz;
249
+ tz = ny;
250
+ }
251
+ else if (ay <= az) {
252
+ // Y axis least aligned: t = (0, 1, 0) x n
253
+ tx = nz;
254
+ ty = 0;
255
+ tz = -nx;
256
+ }
257
+ else {
258
+ // Z axis least aligned: t = (0, 0, 1) x n
259
+ tx = -ny;
260
+ ty = nx;
261
+ tz = 0;
262
+ }
263
+ const tlen = Math.sqrt(tx * tx + ty * ty + tz * tz);
264
+ const inv = 1 / tlen;
265
+ tx *= inv;
266
+ ty *= inv;
267
+ tz *= inv;
268
+ // bitangent = n x t (unit length because n and t are unit and
269
+ // perpendicular).
270
+ const bx = ny * tz - nz * ty;
271
+ const by = nz * tx - nx * tz;
272
+ const bz = nx * ty - ny * tx;
273
+ return [tx, ty, tz, bx, by, bz];
274
+ }
275
+ // Reusable scratch buffers for extractFan. With typical MC fan sizes
276
+ // (k <= ~12), a linear scan over parallel typed arrays is cheaper than
277
+ // the Map-of-Map-of-Set allocations the previous version paid per
278
+ // vertex, and saves ~3 allocations per worklist iteration on meshes
279
+ // with tens of millions of vertices.
280
+ let fanScratchCap = 16;
281
+ let fanFromScratch = new Int32Array(fanScratchCap);
282
+ let fanToScratch = new Int32Array(fanScratchCap);
283
+ let fanTriScratch = new Int32Array(fanScratchCap);
284
+ let fanRingScratch = new Int32Array(fanScratchCap);
285
+ let fanTrisScratch = new Int32Array(fanScratchCap);
286
+ function growFanScratch(need) {
287
+ if (need <= fanScratchCap) {
288
+ return;
289
+ }
290
+ let c = fanScratchCap;
291
+ while (c < need) {
292
+ c *= 2;
293
+ }
294
+ fanFromScratch = new Int32Array(c);
295
+ fanToScratch = new Int32Array(c);
296
+ fanTriScratch = new Int32Array(c);
297
+ fanRingScratch = new Int32Array(c);
298
+ fanTrisScratch = new Int32Array(c);
299
+ fanScratchCap = c;
300
+ }
301
+ // Walk v's fan to extract its cyclic boundary polygon AND the matching
302
+ // cyclic ordered tris. ring[i] is the "from" vertex of the i-th tri
303
+ // (in fan order), and tris[i] = (v, ring[i], ring[(i+1) % k]) is the
304
+ // tri whose two non-v vertices are ring[i] and ring[(i+1) % k].
305
+ // Returns null when the fan is non-manifold (duplicate from-vertex,
306
+ // closes prematurely, or fails to close).
307
+ //
308
+ // Output aliases the module-level `fanRingScratch` / `fanTrisScratch`
309
+ // buffers; the caller must consume them before the next extractFan
310
+ // call. `k` is returned explicitly since the scratch buffers may be
311
+ // oversized.
312
+ function extractFan(v) {
313
+ let k = 0;
314
+ for (let n = vertHead[v]; n !== -1; n = poolNext[n]) {
315
+ k++;
316
+ }
317
+ if (k < 3) {
318
+ return -1;
319
+ }
320
+ growFanScratch(k);
321
+ // Collect (from, to, tri) triples into parallel scratch arrays.
322
+ // The O(k^2) duplicate-from scan here and the O(k^2) cyclic walk
323
+ // below are cheap for small k and avoid per-vertex Map allocs.
324
+ let i = 0;
325
+ for (let n = vertHead[v]; n !== -1; n = poolNext[n]) {
326
+ const t = poolTri[n];
327
+ const a = triVertsArr[t * 3];
328
+ const b = triVertsArr[t * 3 + 1];
329
+ const c = triVertsArr[t * 3 + 2];
330
+ let from, to;
331
+ if (a === v) {
332
+ from = b;
333
+ to = c;
334
+ }
335
+ else if (b === v) {
336
+ from = c;
337
+ to = a;
338
+ }
339
+ else {
340
+ from = a;
341
+ to = b;
342
+ }
343
+ for (let j = 0; j < i; j++) {
344
+ if (fanFromScratch[j] === from) {
345
+ return -1;
346
+ }
347
+ }
348
+ fanFromScratch[i] = from;
349
+ fanToScratch[i] = to;
350
+ fanTriScratch[i] = t;
351
+ i++;
352
+ }
353
+ const start = fanFromScratch[0];
354
+ let cur = start;
355
+ for (let step = 0; step < k; step++) {
356
+ fanRingScratch[step] = cur;
357
+ let found = -1;
358
+ for (let j = 0; j < k; j++) {
359
+ if (fanFromScratch[j] === cur) {
360
+ found = j;
361
+ break;
362
+ }
363
+ }
364
+ if (found === -1) {
365
+ return -1;
366
+ }
367
+ fanTrisScratch[step] = fanTriScratch[found];
368
+ const next = fanToScratch[found];
369
+ // Premature cycle close => fan is non-manifold (multi-component).
370
+ if (next === start && step < k - 1) {
371
+ return -1;
372
+ }
373
+ cur = next;
374
+ }
375
+ if (cur !== start) {
376
+ return -1;
377
+ }
378
+ return k;
379
+ }
380
+ // Reusable scratch for earClip's doubly-linked polygon traversal.
381
+ // Grows to the largest polygon seen and is reused across every
382
+ // worklist iteration.
383
+ let earPrevScratch = new Int32Array(16);
384
+ let earNextScratch = new Int32Array(16);
385
+ function growEarInternalScratch(n) {
386
+ if (n <= earPrevScratch.length) {
387
+ return;
388
+ }
389
+ let c = earPrevScratch.length;
390
+ while (c < n) {
391
+ c *= 2;
392
+ }
393
+ earPrevScratch = new Int32Array(c);
394
+ earNextScratch = new Int32Array(c);
395
+ }
396
+ // Ear-clip a planar simple polygon. `px, py` are the projected 2D
397
+ // coordinates of the first `n` polygon vertices (in CCW order); the
398
+ // arrays may be larger than `n` (scratch-buffer aliasing is fine,
399
+ // only [0, n) is read). Writes (n - 2) * 3 polygon-relative vertex
400
+ // indices into `out[outOffset..]` and returns the number of indices
401
+ // written on success, or -1 if the polygon is degenerate / not
402
+ // simple.
403
+ //
404
+ // Strictly-collinear interior vertices (cross product == 0) are not
405
+ // considered convex ear apices and so produce slivers as side
406
+ // vertices of neighbouring ears. These are transient: any such
407
+ // vertex is itself K=1 in the same plane and the worklist removes
408
+ // it in a subsequent iteration, replacing the slivers with a clean
409
+ // re-triangulation of its updated fan. An in-earClip pre-pass that
410
+ // drops the vertex from this polygon would create a T-junction
411
+ // with the vertex's other incident tris (which still reference it),
412
+ // so we leave the cleanup to the worklist.
413
+ function earClip(px, py, n, out, outOffset) {
414
+ if (n < 3) {
415
+ return -1;
416
+ }
417
+ if (n === 3) {
418
+ out[outOffset] = 0;
419
+ out[outOffset + 1] = 1;
420
+ out[outOffset + 2] = 2;
421
+ return 3;
422
+ }
423
+ // Verify CCW orientation. By construction (right-handed basis)
424
+ // valid input polygons are CCW with positive signed area.
425
+ let area2 = 0;
426
+ for (let i = 0; i < n; i++) {
427
+ const j = (i + 1) % n;
428
+ area2 += px[i] * py[j] - px[j] * py[i];
429
+ }
430
+ if (area2 <= 0) {
431
+ return -1;
432
+ }
433
+ growEarInternalScratch(n);
434
+ const prev = earPrevScratch;
435
+ const next = earNextScratch;
436
+ for (let i = 0; i < n; i++) {
437
+ prev[i] = (i - 1 + n) % n;
438
+ next[i] = (i + 1) % n;
439
+ }
440
+ function isConvex(a, b, c) {
441
+ return (px[b] - px[a]) * (py[c] - py[a]) - (py[b] - py[a]) * (px[c] - px[a]) > 0;
442
+ }
443
+ function inTri(p, a, b, c) {
444
+ const x = px[p], y = py[p];
445
+ const d1 = (x - px[b]) * (py[a] - py[b]) - (px[a] - px[b]) * (y - py[b]);
446
+ const d2 = (x - px[c]) * (py[b] - py[c]) - (px[b] - px[c]) * (y - py[c]);
447
+ const d3 = (x - px[a]) * (py[c] - py[a]) - (px[c] - px[a]) * (y - py[a]);
448
+ const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
449
+ const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
450
+ return !(hasNeg && hasPos);
451
+ }
452
+ function isEar(a, b, c) {
453
+ if (!isConvex(a, b, c)) {
454
+ return false;
455
+ }
456
+ let p = next[c];
457
+ while (p !== a) {
458
+ if (inTri(p, a, b, c)) {
459
+ return false;
460
+ }
461
+ p = next[p];
462
+ }
463
+ return true;
464
+ }
465
+ let resultLen = 0;
466
+ let count = n;
467
+ let i = 0;
468
+ let stalls = 0;
469
+ while (count > 3) {
470
+ if (stalls > count) {
471
+ return -1;
472
+ }
473
+ const p = prev[i];
474
+ const nxt = next[i];
475
+ if (isEar(p, i, nxt)) {
476
+ out[outOffset + resultLen++] = p;
477
+ out[outOffset + resultLen++] = i;
478
+ out[outOffset + resultLen++] = nxt;
479
+ next[p] = nxt;
480
+ prev[nxt] = p;
481
+ count--;
482
+ i = nxt;
483
+ stalls = 0;
484
+ }
485
+ else {
486
+ i = next[i];
487
+ stalls++;
488
+ }
489
+ }
490
+ out[outOffset + resultLen++] = prev[i];
491
+ out[outOffset + resultLen++] = i;
492
+ out[outOffset + resultLen++] = next[i];
493
+ return resultLen;
494
+ }
495
+ // Worklist: iterative dirty-flag scheduler. Initially queue every
496
+ // vertex; on each successful removal, re-queue the ring neighbours so
497
+ // chains of K=1 / K=2 vertices collapse in a single run.
498
+ const inQueue = new Uint8Array(vertCount);
499
+ let queue = new Int32Array(Math.max(vertCount, 16));
500
+ let queueLen = 0;
501
+ let queueHead = 0;
502
+ function pushQueue(u) {
503
+ if (inQueue[u]) {
504
+ return;
505
+ }
506
+ inQueue[u] = 1;
507
+ if (queueLen >= queue.length) {
508
+ const grown = new Int32Array(queue.length * 2);
509
+ grown.set(queue);
510
+ queue = grown;
511
+ }
512
+ queue[queueLen++] = u;
513
+ }
514
+ function compactQueue() {
515
+ // Reclaim consumed prefix when slack exceeds 50% (and is large
516
+ // enough to be worth the copy). Bounded by O(total pushes).
517
+ if (queueHead > 4096 && queueHead * 2 > queueLen) {
518
+ queue.copyWithin(0, queueHead, queueLen);
519
+ queueLen -= queueHead;
520
+ queueHead = 0;
521
+ }
522
+ }
523
+ for (let v = 0; v < vertCount; v++) {
524
+ inQueue[v] = 1;
525
+ queue[queueLen++] = v;
526
+ }
527
+ // Tolerance for K=2 seam collinearity: cosine of the angle between
528
+ // (v -> a) and (v -> b) must be <= -(1 - COLLINEAR_REL_EPS), i.e.
529
+ // the two seam edges through v are nearly antiparallel (v lies on
530
+ // the segment from a to b in 3D).
531
+ const cosineMax = -1 + COLLINEAR_REL_EPS;
532
+ // Reusable scratch for the per-arc plane descriptor.
533
+ const arcStartIdx = new Int32Array(2);
534
+ const arcPolySize = new Int32Array(2);
535
+ const arcPlaneT = new Int32Array(2);
536
+ // Reusable per-arc scratch buffers (arcCount is always 1 or 2).
537
+ // Each slot holds the polygon vertex indices, the 2D-projected
538
+ // coordinates, and the ear-clip triangle indices for one arc. All
539
+ // grow monotonically to the largest polygon ever seen and are
540
+ // reused for every worklist iteration, so the hot loop is mostly
541
+ // allocation-free.
542
+ const arcPolyScratch = [new Int32Array(16), new Int32Array(16)];
543
+ const arcPxScratch = [new Float64Array(16), new Float64Array(16)];
544
+ const arcPyScratch = [new Float64Array(16), new Float64Array(16)];
545
+ const arcEarScratch = [new Int32Array(16), new Int32Array(16)];
546
+ const arcEarLen = new Int32Array(2);
547
+ function ensureArcScratch(g, polySize) {
548
+ if (polySize > arcPolyScratch[g].length) {
549
+ let c = arcPolyScratch[g].length;
550
+ while (c < polySize) {
551
+ c *= 2;
552
+ }
553
+ arcPolyScratch[g] = new Int32Array(c);
554
+ arcPxScratch[g] = new Float64Array(c);
555
+ arcPyScratch[g] = new Float64Array(c);
556
+ }
557
+ const earNeed = (polySize - 2) * 3;
558
+ if (earNeed > arcEarScratch[g].length) {
559
+ let c = arcEarScratch[g].length;
560
+ while (c < earNeed) {
561
+ c *= 2;
562
+ }
563
+ arcEarScratch[g] = new Int32Array(c);
564
+ }
565
+ }
566
+ while (queueHead < queueLen) {
567
+ const v = queue[queueHead++];
568
+ inQueue[v] = 0;
569
+ compactQueue();
570
+ // Cheap "fan size < 3" early-out without traversing the full
571
+ // linked list.
572
+ const h0 = vertHead[v];
573
+ if (h0 === -1) {
574
+ continue;
575
+ }
576
+ const h1 = poolNext[h0];
577
+ if (h1 === -1) {
578
+ continue;
579
+ }
580
+ if (poolNext[h1] === -1) {
581
+ continue;
582
+ }
583
+ const k = extractFan(v);
584
+ if (k === -1) {
585
+ continue;
586
+ }
587
+ const ring = fanRingScratch;
588
+ const fanTris = fanTrisScratch;
589
+ // Decide K. First check if all tris are coplanar with fanTris[0]
590
+ // (K=1 fast path).
591
+ const t0 = fanTris[0];
592
+ const n0x = triNxArr[t0];
593
+ const n0y = triNyArr[t0];
594
+ const n0z = triNzArr[t0];
595
+ const d0 = triDArr[t0];
596
+ let allCoplanar = true;
597
+ for (let i = 1; i < k; i++) {
598
+ const t = fanTris[i];
599
+ const dT = triDArr[t];
600
+ const dotN = triNxArr[t] * n0x + triNyArr[t] * n0y + triNzArr[t] * n0z;
601
+ if (dotN < 1 - NORMAL_EPS || Math.abs(dT - d0) > planeEps(dT, d0)) {
602
+ allCoplanar = false;
603
+ break;
604
+ }
605
+ }
606
+ let arcCount = 0;
607
+ if (allCoplanar) {
608
+ // K=1: single arc covering the whole ring (k vertices).
609
+ arcStartIdx[0] = 0;
610
+ arcPolySize[0] = k;
611
+ arcPlaneT[0] = t0;
612
+ arcCount = 1;
613
+ }
614
+ else {
615
+ // Walk cyclically; mark transitions where plane changes vs.
616
+ // the previous tri in fan order. Exactly 2 transitions =>
617
+ // K=2 candidate.
618
+ let nTransitions = 0;
619
+ let i1 = -1;
620
+ let i2 = -1;
621
+ for (let i = 0; i < k; i++) {
622
+ const t = fanTris[i];
623
+ const prevT = fanTris[(i - 1 + k) % k];
624
+ const nx1 = triNxArr[t];
625
+ const ny1 = triNyArr[t];
626
+ const nz1 = triNzArr[t];
627
+ const d1 = triDArr[t];
628
+ const nx0 = triNxArr[prevT];
629
+ const ny0 = triNyArr[prevT];
630
+ const nz0 = triNzArr[prevT];
631
+ const dPrev = triDArr[prevT];
632
+ const dotN = nx1 * nx0 + ny1 * ny0 + nz1 * nz0;
633
+ if (dotN < 1 - NORMAL_EPS || Math.abs(d1 - dPrev) > planeEps(d1, dPrev)) {
634
+ nTransitions++;
635
+ if (nTransitions === 1) {
636
+ i1 = i;
637
+ }
638
+ else if (nTransitions === 2) {
639
+ i2 = i;
640
+ }
641
+ else {
642
+ break;
643
+ }
644
+ }
645
+ }
646
+ if (nTransitions !== 2) {
647
+ continue;
648
+ }
649
+ // K=2 collinearity: ring[i1], v, ring[i2] must be collinear
650
+ // with v between (cosine of va-vb angle near -1).
651
+ const a = ring[i1];
652
+ const b = ring[i2];
653
+ const ax = positions[a * 3] - positions[v * 3];
654
+ const ay = positions[a * 3 + 1] - positions[v * 3 + 1];
655
+ const az = positions[a * 3 + 2] - positions[v * 3 + 2];
656
+ const bx = positions[b * 3] - positions[v * 3];
657
+ const by = positions[b * 3 + 1] - positions[v * 3 + 1];
658
+ const bz = positions[b * 3 + 2] - positions[v * 3 + 2];
659
+ const lenA = Math.sqrt(ax * ax + ay * ay + az * az);
660
+ const lenB = Math.sqrt(bx * bx + by * by + bz * bz);
661
+ if (lenA < 1e-12 || lenB < 1e-12) {
662
+ continue;
663
+ }
664
+ const cosine = (ax * bx + ay * by + az * bz) / (lenA * lenB);
665
+ if (cosine > cosineMax) {
666
+ continue;
667
+ }
668
+ // Two arcs: arc 0 covers tris[i1..i2-1] (mA tris, mA+1 polygon
669
+ // verts ring[i1..i2]); arc 1 covers tris[i2..i1-1] cyclically
670
+ // (mB tris, mB+1 polygon verts).
671
+ const mA = i2 - i1;
672
+ const mB = k - mA;
673
+ arcStartIdx[0] = i1;
674
+ arcPolySize[0] = mA + 1;
675
+ arcPlaneT[0] = fanTris[i1];
676
+ arcStartIdx[1] = i2;
677
+ arcPolySize[1] = mB + 1;
678
+ arcPlaneT[1] = fanTris[i2];
679
+ arcCount = 2;
680
+ }
681
+ // Build polygons and triangulations into the reusable per-arc
682
+ // scratch slots. Bail (without committing) if any arc fails to
683
+ // triangulate.
684
+ let allArcsOk = true;
685
+ for (let g = 0; g < arcCount; g++) {
686
+ const polySize = arcPolySize[g];
687
+ const startIdx = arcStartIdx[g];
688
+ const planeT = arcPlaneT[g];
689
+ ensureArcScratch(g, polySize);
690
+ const poly = arcPolyScratch[g];
691
+ for (let j = 0; j < polySize; j++) {
692
+ poly[j] = ring[(startIdx + j) % k];
693
+ }
694
+ const nx = triNxArr[planeT];
695
+ const ny = triNyArr[planeT];
696
+ const nz = triNzArr[planeT];
697
+ const [tx, ty, tz, bx, by, bz] = buildBasis(nx, ny, nz);
698
+ const px = arcPxScratch[g];
699
+ const py = arcPyScratch[g];
700
+ for (let j = 0; j < polySize; j++) {
701
+ const u = poly[j];
702
+ const x = positions[u * 3];
703
+ const y = positions[u * 3 + 1];
704
+ const z = positions[u * 3 + 2];
705
+ px[j] = x * tx + y * ty + z * tz;
706
+ py[j] = x * bx + y * by + z * bz;
707
+ }
708
+ const earCount = earClip(px, py, polySize, arcEarScratch[g], 0);
709
+ if (earCount !== (polySize - 2) * 3) {
710
+ allArcsOk = false;
711
+ break;
712
+ }
713
+ arcEarLen[g] = earCount;
714
+ }
715
+ if (!allArcsOk) {
716
+ continue;
717
+ }
718
+ // Commit: walk v's linked-list of incident tris in place. For each
719
+ // tri, mark it dead, unlink it from each non-v vertex's list, and
720
+ // free v's own node. Iteration is safe because we only mutate
721
+ // OTHER vertices' lists during the walk.
722
+ let cur = vertHead[v];
723
+ while (cur !== -1) {
724
+ const t = poolTri[cur];
725
+ triAliveArr[t] = 0;
726
+ for (let j = 0; j < 3; j++) {
727
+ const u = triVertsArr[t * 3 + j];
728
+ if (u === v) {
729
+ continue;
730
+ }
731
+ removeTriFromVert(u, t);
732
+ }
733
+ const nxt = poolNext[cur];
734
+ freeNode(cur);
735
+ cur = nxt;
736
+ }
737
+ vertHead[v] = -1;
738
+ // Append new tris per arc, each carrying its arc's plane.
739
+ for (let g = 0; g < arcCount; g++) {
740
+ const planeT = arcPlaneT[g];
741
+ const nx = triNxArr[planeT];
742
+ const ny = triNyArr[planeT];
743
+ const nz = triNzArr[planeT];
744
+ const d = triDArr[planeT];
745
+ const earIdx = arcEarScratch[g];
746
+ const earIdxLen = arcEarLen[g];
747
+ const poly = arcPolyScratch[g];
748
+ for (let i = 0; i < earIdxLen; i += 3) {
749
+ const ua = poly[earIdx[i]];
750
+ const ub = poly[earIdx[i + 1]];
751
+ const uc = poly[earIdx[i + 2]];
752
+ ensureTriCap();
753
+ const newT = triCount++;
754
+ triVertsArr[newT * 3] = ua;
755
+ triVertsArr[newT * 3 + 1] = ub;
756
+ triVertsArr[newT * 3 + 2] = uc;
757
+ triNxArr[newT] = nx;
758
+ triNyArr[newT] = ny;
759
+ triNzArr[newT] = nz;
760
+ triDArr[newT] = d;
761
+ triAliveArr[newT] = 1;
762
+ addTriToVert(ua, newT);
763
+ addTriToVert(ub, newT);
764
+ addTriToVert(uc, newT);
765
+ }
766
+ }
767
+ // Re-queue every ring vertex: each may now satisfy K=1 or K=2 in
768
+ // its updated fan, allowing the collapse to propagate along long
769
+ // collinear chains in a single sweep.
770
+ for (let i = 0; i < k; i++) {
771
+ pushQueue(ring[i]);
772
+ }
773
+ }
774
+ // Compact: drop dead tris and unused vertices, remap indices.
775
+ const usedVerts = new Uint8Array(vertCount);
776
+ let outTriCount = 0;
777
+ for (let t = 0; t < triCount; t++) {
778
+ if (triAliveArr[t] === 0) {
779
+ continue;
780
+ }
781
+ outTriCount++;
782
+ usedVerts[triVertsArr[t * 3]] = 1;
783
+ usedVerts[triVertsArr[t * 3 + 1]] = 1;
784
+ usedVerts[triVertsArr[t * 3 + 2]] = 1;
785
+ }
786
+ let outVertCount = 0;
787
+ const vertRemap = new Int32Array(vertCount);
788
+ for (let v = 0; v < vertCount; v++) {
789
+ if (usedVerts[v] === 1) {
790
+ vertRemap[v] = outVertCount++;
791
+ }
792
+ else {
793
+ vertRemap[v] = -1;
794
+ }
795
+ }
796
+ const outPositions = new Float32Array(outVertCount * 3);
797
+ for (let v = 0; v < vertCount; v++) {
798
+ if (usedVerts[v] === 0) {
799
+ continue;
800
+ }
801
+ const o = vertRemap[v] * 3;
802
+ outPositions[o] = positions[v * 3];
803
+ outPositions[o + 1] = positions[v * 3 + 1];
804
+ outPositions[o + 2] = positions[v * 3 + 2];
805
+ }
806
+ const outIndices = new Uint32Array(outTriCount * 3);
807
+ let oi = 0;
808
+ for (let t = 0; t < triCount; t++) {
809
+ if (triAliveArr[t] === 0) {
810
+ continue;
811
+ }
812
+ outIndices[oi++] = vertRemap[triVertsArr[t * 3]];
813
+ outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 1]];
814
+ outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 2]];
815
+ }
816
+ return { positions: outPositions, indices: outIndices };
817
+ }
818
+ export { coplanarMerge };