earcut 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/earcut.js CHANGED
@@ -1,16 +1,53 @@
1
1
 
2
+ /**
3
+ * A vertex in a circular doubly linked list representing a polygon ring.
4
+ * `prev`/`next` are always linked (set immediately after {@link createNode}), so they're typed
5
+ * non-null; `prevZ`/`nextZ` are the z-order list links and are null at the ends.
6
+ *
7
+ * @typedef {object} Node
8
+ * @property {number} i vertex index in the coordinates array
9
+ * @property {number} x vertex x coordinate
10
+ * @property {number} y vertex y coordinate
11
+ * @property {Node} prev previous vertex node in the polygon ring
12
+ * @property {Node} next next vertex node in the polygon ring
13
+ * @property {number} z z-order curve value; doubles as the owning block index during eliminateHoles
14
+ * @property {Node | null} prevZ previous node in z-order
15
+ * @property {Node | null} nextZ next node in z-order
16
+ */
17
+
18
+ // single-vertex holes to preserve through filterPoints (steiner points); kept off the Node
19
+ // shape since they're rare — the empty-set fast path means non-steiner inputs pay nothing
20
+ /** @type {Set<Node>} */
21
+ const steiners = new Set();
22
+
23
+ /**
24
+ * Triangulate a polygon given as a flat array of vertex coordinates.
25
+ *
26
+ * @param {ArrayLike<number>} data flat array of vertex coordinates
27
+ * @param {ArrayLike<number> | null} [holeIndices] indices (in vertices, not coordinates) where each hole ring starts
28
+ * @param {number} [dim=2] number of coordinates per vertex in `data`
29
+ * @returns {number[]} triangles as triplets of vertex indices into `data`
30
+ * @example earcut([10,0, 0,50, 60,60, 70,10]); // [1,0,3, 3,2,1]
31
+ */
2
32
  export default function earcut(data, holeIndices, dim = 2) {
3
33
 
4
34
  const hasHoles = holeIndices && holeIndices.length;
5
35
  const outerLen = hasHoles ? holeIndices[0] * dim : data.length;
36
+ if (steiners.size) steiners.clear();
37
+
6
38
  let outerNode = linkedList(data, 0, outerLen, dim, true);
39
+ /** @type {number[]} */
7
40
  const triangles = [];
8
41
 
9
42
  if (!outerNode || outerNode.next === outerNode.prev) return triangles;
10
43
 
11
- let minX, minY, invSize;
44
+ let minX = 0, minY = 0, invSize = 0;
12
45
 
13
- if (hasHoles) outerNode = eliminateHoles(data, holeIndices, outerNode, dim);
46
+ if (hasHoles) {
47
+ outerNode = eliminateHoles(data, holeIndices, outerNode, dim);
48
+ // collapse collinear/coincident points across the whole merged ring once before clipping
49
+ outerNode = filterPoints(outerNode);
50
+ }
14
51
 
15
52
  // if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox
16
53
  if (data.length > 80 * dim) {
@@ -39,8 +76,10 @@ export default function earcut(data, holeIndices, dim = 2) {
39
76
  }
40
77
 
41
78
  // create a circular doubly linked list from polygon points in the specified winding order
79
+ /** @param {ArrayLike<number>} data @param {number} start @param {number} end @param {number} dim @param {boolean} clockwise @returns {Node | null} */
42
80
  function linkedList(data, start, end, dim, clockwise) {
43
- let last;
81
+ /** @type {Node | null} */
82
+ let last = null;
44
83
 
45
84
  if (clockwise === (signedArea(data, start, end, dim) > 0)) {
46
85
  for (let i = start; i < end; i += dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
@@ -56,24 +95,27 @@ function linkedList(data, start, end, dim, clockwise) {
56
95
  return last;
57
96
  }
58
97
 
59
- // eliminate colinear or duplicate points
60
- function filterPoints(start, end) {
61
- if (!start) return start;
62
- if (!end) end = start;
98
+ // Remove collinear or coincident points; removability depends only on a node's immediate
99
+ // neighbors, so we sweep forward and re-check the predecessor after each removal. With no `end`
100
+ // we sweep the whole ring, lapping until nothing is removable (the fixpoint the clipper needs).
101
+ // With an explicit `end` we heal only the dirty window around a bridge/diagonal cut, stopping at
102
+ // `end` rather than lapping — O(window) instead of O(ring).
103
+ /** @param {Node} start @param {Node} [end] @returns {Node} */
104
+ function filterPoints(start, end = start) {
105
+ const full = end === start;
63
106
 
64
- let p = start,
65
- again;
107
+ let p = start, again;
66
108
  do {
67
109
  again = false;
68
-
69
- if (!p.steiner && (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
110
+ if (p !== p.next && (steiners.size === 0 || !steiners.has(p)) &&
111
+ (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
112
+ if (full || p === end) end = p.prev; // pull the stop bound back past the removal
70
113
  removeNode(p);
71
- p = end = p.prev;
72
- if (p === p.next) break;
114
+ p = p.prev; // re-check the predecessor
73
115
  again = true;
74
-
75
- } else {
116
+ } else if (full || p !== end) {
76
117
  p = p.next;
118
+ again = !full; // local heal: keep looping until the sweep reaches end
77
119
  }
78
120
  } while (again || p !== end);
79
121
 
@@ -81,6 +123,7 @@ function filterPoints(start, end) {
81
123
  }
82
124
 
83
125
  // main ear slicing loop which triangulates a polygon (given as a linked list)
126
+ /** @param {Node | null} ear @param {number[]} triangles @param {number} dim @param {number} minX @param {number} minY @param {number} invSize @param {number} pass */
84
127
  function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
85
128
  if (!ear) return;
86
129
 
@@ -92,16 +135,16 @@ function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
92
135
  // iterate through ears, slicing them one by one
93
136
  while (ear.prev !== ear.next) {
94
137
  const prev = ear.prev;
138
+ /** @type {Node} */
95
139
  const next = ear.next;
96
140
 
97
- if (invSize ? isEarHashed(ear, minX, minY, invSize) : isEar(ear)) {
141
+ if (area(prev, ear, next) < 0 && (invSize ? isEarHashed(ear, minX, minY, invSize) : isEar(ear))) {
98
142
  triangles.push(prev.i, ear.i, next.i); // cut off the triangle
99
143
 
100
144
  removeNode(ear);
101
145
 
102
- // skipping the next vertex leads to less sliver triangles
103
- ear = next.next;
104
- stop = next.next;
146
+ ear = next;
147
+ stop = next;
105
148
 
106
149
  continue;
107
150
  }
@@ -130,14 +173,17 @@ function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
130
173
  }
131
174
 
132
175
  // check whether a polygon node forms a valid ear with adjacent nodes
176
+ /** @param {Node} ear @returns {boolean} */
133
177
  function isEar(ear) {
134
178
  const a = ear.prev,
135
179
  b = ear,
136
180
  c = ear.next;
137
181
 
138
- if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
182
+ // reflex check (area(a, b, c) >= 0) is hoisted into the earcutLinked caller to avoid non-inlined call here
139
183
 
140
- // now make sure we don't have other points inside the potential ear
184
+ // make sure we don't have other points inside the potential ear; the point-in-triangle
185
+ // test (false when the point coincides with the first vertex a) is inlined here and in
186
+ // isEarHashed rather than called — V8 doesn't inline it and the call sits in the hot loop
141
187
  const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
142
188
 
143
189
  // triangle bbox
@@ -149,7 +195,7 @@ function isEar(ear) {
149
195
  let p = c.next;
150
196
  while (p !== a) {
151
197
  if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 &&
152
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) &&
198
+ !(ax === p.x && ay === p.y) && (cx - p.x) * (ay - p.y) >= (ax - p.x) * (cy - p.y) && (ax - p.x) * (by - p.y) >= (bx - p.x) * (ay - p.y) && (bx - p.x) * (cy - p.y) >= (cx - p.x) * (by - p.y) &&
153
199
  area(p.prev, p, p.next) >= 0) return false;
154
200
  p = p.next;
155
201
  }
@@ -157,12 +203,13 @@ function isEar(ear) {
157
203
  return true;
158
204
  }
159
205
 
206
+ /** @param {Node} ear @param {number} minX @param {number} minY @param {number} invSize @returns {boolean} */
160
207
  function isEarHashed(ear, minX, minY, invSize) {
161
208
  const a = ear.prev,
162
209
  b = ear,
163
210
  c = ear.next;
164
211
 
165
- if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
212
+ // reflex check is hoisted into the earcutLinked caller (see isEar)
166
213
 
167
214
  const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
168
215
 
@@ -181,26 +228,26 @@ function isEarHashed(ear, minX, minY, invSize) {
181
228
 
182
229
  // look for points inside the triangle in both directions
183
230
  while (p && p.z >= minZ && n && n.z <= maxZ) {
184
- if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
185
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
231
+ if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== c &&
232
+ !(ax === p.x && ay === p.y) && (cx - p.x) * (ay - p.y) >= (ax - p.x) * (cy - p.y) && (ax - p.x) * (by - p.y) >= (bx - p.x) * (ay - p.y) && (bx - p.x) * (cy - p.y) >= (cx - p.x) * (by - p.y) && area(p.prev, p, p.next) >= 0) return false;
186
233
  p = p.prevZ;
187
234
 
188
- if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
189
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
235
+ if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== c &&
236
+ !(ax === n.x && ay === n.y) && (cx - n.x) * (ay - n.y) >= (ax - n.x) * (cy - n.y) && (ax - n.x) * (by - n.y) >= (bx - n.x) * (ay - n.y) && (bx - n.x) * (cy - n.y) >= (cx - n.x) * (by - n.y) && area(n.prev, n, n.next) >= 0) return false;
190
237
  n = n.nextZ;
191
238
  }
192
239
 
193
240
  // look for remaining points in decreasing z-order
194
241
  while (p && p.z >= minZ) {
195
- if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
196
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
242
+ if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== c &&
243
+ !(ax === p.x && ay === p.y) && (cx - p.x) * (ay - p.y) >= (ax - p.x) * (cy - p.y) && (ax - p.x) * (by - p.y) >= (bx - p.x) * (ay - p.y) && (bx - p.x) * (cy - p.y) >= (cx - p.x) * (by - p.y) && area(p.prev, p, p.next) >= 0) return false;
197
244
  p = p.prevZ;
198
245
  }
199
246
 
200
247
  // look for remaining points in increasing z-order
201
248
  while (n && n.z <= maxZ) {
202
- if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
203
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
249
+ if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== c &&
250
+ !(ax === n.x && ay === n.y) && (cx - n.x) * (ay - n.y) >= (ax - n.x) * (cy - n.y) && (ax - n.x) * (by - n.y) >= (bx - n.x) * (ay - n.y) && (bx - n.x) * (cy - n.y) >= (cx - n.x) * (by - n.y) && area(n.prev, n, n.next) >= 0) return false;
204
251
  n = n.nextZ;
205
252
  }
206
253
 
@@ -208,13 +255,15 @@ function isEarHashed(ear, minX, minY, invSize) {
208
255
  }
209
256
 
210
257
  // go through all polygon nodes and cure small local self-intersections
258
+ /** @param {Node} start @param {number[]} triangles @returns {Node} */
211
259
  function cureLocalIntersections(start, triangles) {
212
260
  let p = start;
261
+ let cured = false;
213
262
  do {
214
263
  const a = p.prev,
215
264
  b = p.next.next;
216
265
 
217
- if (!equals(a, b) && intersects(a, p, p.next, b) && locallyInside(a, b) && locallyInside(b, a)) {
266
+ if (intersects(a, p, p.next, b, false) && locallyInside(a, b) && locallyInside(b, a)) {
218
267
 
219
268
  triangles.push(a.i, p.i, b.i);
220
269
 
@@ -223,14 +272,16 @@ function cureLocalIntersections(start, triangles) {
223
272
  removeNode(p.next);
224
273
 
225
274
  p = start = b;
275
+ cured = true;
226
276
  }
227
277
  p = p.next;
228
278
  } while (p !== start);
229
279
 
230
- return filterPoints(p);
280
+ return cured ? filterPoints(p) : p;
231
281
  }
232
282
 
233
283
  // try splitting polygon into two and triangulate them independently
284
+ /** @param {Node} start @param {number[]} triangles @param {number} dim @param {number} minX @param {number} minY @param {number} invSize */
234
285
  function splitEarcut(start, triangles, dim, minX, minY, invSize) {
235
286
  // look for a valid diagonal that divides the polygon into two
236
287
  let a = start;
@@ -256,44 +307,51 @@ function splitEarcut(start, triangles, dim, minX, minY, invSize) {
256
307
  } while (a !== start);
257
308
  }
258
309
 
310
+ // true only while eliminateHoles merges holes, so removeNode keeps the block index live (growBlock)
311
+ let indexActive = false;
312
+
259
313
  // link every hole into the outer loop, producing a single-ring polygon without holes
314
+ /** @param {ArrayLike<number>} data @param {ArrayLike<number>} holeIndices @param {Node} outerNode @param {number} dim @returns {Node} */
260
315
  function eliminateHoles(data, holeIndices, outerNode, dim) {
261
316
  const queue = [];
262
317
 
263
318
  for (let i = 0, len = holeIndices.length; i < len; i++) {
264
319
  const start = holeIndices[i] * dim;
265
320
  const end = i < len - 1 ? holeIndices[i + 1] * dim : data.length;
266
- const list = linkedList(data, start, end, dim, false);
267
- if (list === list.next) list.steiner = true;
321
+ const list = /** @type {Node} */ (linkedList(data, start, end, dim, false));
322
+ if (list === list.next) steiners.add(list);
268
323
  queue.push(getLeftmost(list));
269
324
  }
270
325
 
271
326
  queue.sort(compareXYSlope);
272
327
 
273
- // process holes from left to right
328
+ // block-bbox index for findHoleBridge, grown append-only as holes merge (see notes
329
+ // above buildBlockIndex). Seed it with the outer ring, then append each merged hole.
330
+ buildBlockIndex(data.length / dim, holeIndices.length);
331
+ indexSegment(outerNode, outerNode);
332
+
333
+ // process holes from left to right; indexActive lets removeNode keep block bboxes live as
334
+ // filterPoints heals edges during merges (see growBlock)
335
+ indexActive = true;
274
336
  for (let i = 0; i < queue.length; i++) {
275
337
  outerNode = eliminateHole(queue[i], outerNode);
276
338
  }
339
+ indexActive = false;
277
340
 
278
341
  return outerNode;
279
342
  }
280
343
 
344
+ /** @param {Node} a @param {Node} b @returns {number} */
281
345
  function compareXYSlope(a, b) {
282
- let result = a.x - b.x;
283
346
  // when the left-most point of 2 holes meet at a vertex, sort the holes counterclockwise so that when we find
284
347
  // the bridge to the outer shell is always the point that they meet at.
285
- if (result === 0) {
286
- result = a.y - b.y;
287
- if (result === 0) {
288
- const aSlope = (a.next.y - a.y) / (a.next.x - a.x);
289
- const bSlope = (b.next.y - b.y) / (b.next.x - b.x);
290
- result = aSlope - bSlope;
291
- }
292
- }
293
- return result;
348
+ return a.x - b.x || a.y - b.y ||
349
+ (a.next.y - a.y) / (a.next.x - a.x) -
350
+ (b.next.y - b.y) / (b.next.x - b.x);
294
351
  }
295
352
 
296
353
  // find a bridge between vertices that connects hole with an outer ring and link it
354
+ /** @param {Node} hole @param {Node} outerNode @returns {Node} */
297
355
  function eliminateHole(hole, outerNode) {
298
356
  const bridge = findHoleBridge(hole, outerNode);
299
357
  if (!bridge) {
@@ -302,35 +360,143 @@ function eliminateHole(hole, outerNode) {
302
360
 
303
361
  const bridgeReverse = splitPolygon(bridge, hole);
304
362
 
305
- // filter collinear points around the cuts
363
+ // index the merged-in segment before filtering: in ring order the splice runs
364
+ // bridge -> hole -> bridgeReverse -> bridge2 -> (bridge's old next), covering the
365
+ // hole's edges and both new slit edges. filterPoints below only drops collinear /
366
+ // coincident points, so these bboxes stay valid (conservative) supersets.
367
+ const bridge2 = bridgeReverse.next;
368
+ indexSegment(bridge, bridge2.next);
369
+
370
+ // heal collinear/coincident points around the two new slit edges
306
371
  filterPoints(bridgeReverse, bridgeReverse.next);
307
372
  return filterPoints(bridge, bridge.next);
308
373
  }
309
374
 
375
+ // Block-bbox index for findHoleBridge (issue #183): one [minX,minY,maxX,maxY] bbox per K
376
+ // consecutive ring edges, in a flat Float64Array, so the leftward-ray scan can skip whole
377
+ // blocks in O(1) instead of walking the entire merged ring. Grown append-only — the outer
378
+ // ring seeds it, then each merged hole appends a segment (head node, stop node, K-blocks
379
+ // over head..stop); independent segments, not a ring tiling, since splices land mid-ring.
380
+ // Buffers are sized once from the input upper bound and reused across calls.
381
+ //
382
+ // filterPoints only drops collinear/coincident points, so a stale bbox stays a conservative
383
+ // superset of its live edges (never a false skip); the scan skips dead nodes (p.prev.next !==
384
+ // p) and lazily advances a dead stop. Blocks are scanned in append (not ring) order, so the
385
+ // chosen bridge can differ from the un-indexed code — a different but equally valid result.
386
+ const K = 16; // edges per block
387
+
388
+ let blockBBox = new Float64Array(0); // [minX,minY,maxX,maxY] per block
389
+ let numBlocks = 0;
390
+ /** @type {Node[]} */
391
+ const blockHead = []; // first node of each block's segment
392
+ /** @type {Node[]} */
393
+ const blockStop = []; // node just past each block's segment (exclusive walk bound)
394
+
395
+ /** @param {number} maxNodes @param {number} numHoles */
396
+ function buildBlockIndex(maxNodes, numHoles) {
397
+ // upper bound: every input node indexed once, +2 bridge nodes per hole, plus a partial
398
+ // trailing block per appended segment (outer ring + one per hole)
399
+ const maxBlocks = Math.ceil((maxNodes + 2 * numHoles) / K) + numHoles + 2;
400
+ if (blockBBox.length < maxBlocks * 4) blockBBox = new Float64Array(maxBlocks * 4);
401
+ numBlocks = 0;
402
+ }
403
+
404
+ // index the ring run head..stop (exclusive) as ceil(len / K) blocks; head === stop means
405
+ // the whole ring. each block's bbox covers both endpoints of every edge it owns.
406
+ /** @param {Node} head @param {Node} stop */
407
+ function indexSegment(head, stop) {
408
+ let p = head;
409
+ do {
410
+ const b = numBlocks++;
411
+ blockHead[b] = p;
412
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
413
+ let k = 0;
414
+ do {
415
+ const c = p.next; // edge p->c; bbox must bound both endpoints
416
+ p.z = b; // reuse z as the owning block during eliminateHoles (see growBlock)
417
+ if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
418
+ if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
419
+ if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x;
420
+ if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y;
421
+ p = c;
422
+ } while (++k < K && p !== stop);
423
+ blockStop[b] = p;
424
+ const g = b * 4;
425
+ blockBBox[g] = minX; blockBBox[g + 1] = minY; blockBBox[g + 2] = maxX; blockBBox[g + 3] = maxY;
426
+ } while (p !== stop);
427
+ }
428
+
429
+ // when filterPoints heals an edge head->tail (removing the collinear node between them), the
430
+ // healed edge can extend past head's frozen block bbox if its old far endpoint lived in another
431
+ // block; grow head's block bbox to cover tail so the leftward-ray prune can't false-skip it.
432
+ /** @param {Node} head @param {Node} tail */
433
+ function growBlock(head, tail) {
434
+ const g = head.z * 4;
435
+ if (tail.x < blockBBox[g]) blockBBox[g] = tail.x;
436
+ if (tail.y < blockBBox[g + 1]) blockBBox[g + 1] = tail.y;
437
+ if (tail.x > blockBBox[g + 2]) blockBBox[g + 2] = tail.x;
438
+ if (tail.y > blockBBox[g + 3]) blockBBox[g + 3] = tail.y;
439
+ }
440
+
441
+ /** @param {number} b @returns {Node} */
442
+ function liveBlockStop(b) {
443
+ let stop = blockStop[b];
444
+ while (stop.prev.next !== stop) stop = stop.next;
445
+ blockStop[b] = stop;
446
+ return stop;
447
+ }
448
+
449
+ // the block's head node can be removed by filterPoints during merges; advance it to the next
450
+ // live node so the walk doesn't start on (and immediately terminate at) a dead node. For the
451
+ // single full-ring seed block (head === stop) the same forward advance keeps them equal, so the
452
+ // do-while still laps the whole ring instead of collapsing to an empty walk.
453
+ /** @param {number} b @returns {Node} */
454
+ function liveBlockHead(b) {
455
+ let head = blockHead[b];
456
+ while (head.prev.next !== head) head = head.next;
457
+ blockHead[b] = head;
458
+ return head;
459
+ }
460
+
310
461
  // David Eberly's algorithm for finding a bridge between hole and outer polygon
462
+ /** @param {Node} hole @param {Node} outerNode @returns {Node | null} */
311
463
  function findHoleBridge(hole, outerNode) {
312
464
  let p = outerNode;
313
465
  const hx = hole.x;
314
466
  const hy = hole.y;
315
467
  let qx = -Infinity;
468
+ /** @type {Node | undefined} */
316
469
  let m;
317
470
 
318
471
  // find a segment intersected by a ray from the hole's leftmost point to the left;
319
472
  // segment's endpoint with lesser x will be potential connection point
320
473
  // unless they intersect at a vertex, then choose the vertex
321
474
  if (equals(hole, p)) return p;
322
- do {
323
- if (equals(hole, p.next)) return p.next;
324
- else if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
325
- const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
326
- if (x <= hx && x > qx) {
327
- qx = x;
328
- m = p.x < p.next.x ? p : p.next;
329
- if (x === hx) return m; // hole touches outer segment; pick leftmost endpoint
475
+
476
+ // scan blocks; skip any whose bbox can't hold a crossing that beats qx and lies left
477
+ // of hx (the prune Morton order can't express explicit per-axis [minY,maxY]/[minX,maxX])
478
+ for (let b = 0, g = 0; b < numBlocks; b++, g += 4) {
479
+ if (hy < blockBBox[g + 1] || hy > blockBBox[g + 3] || blockBBox[g] > hx || blockBBox[g + 2] <= qx) continue;
480
+
481
+ // ensure the walk's exclusive bound is live so we don't overrun into other blocks
482
+ const stop = liveBlockStop(b);
483
+
484
+ p = liveBlockHead(b);
485
+ do {
486
+ if (p.prev.next === p) { // skip nodes removed by filterPoints (stale in the index)
487
+ if (equals(hole, p.next)) return p.next;
488
+ else if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
489
+ const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
490
+ if (x <= hx && x > qx) {
491
+ qx = x;
492
+ m = p.x < p.next.x ? p : p.next;
493
+ if (x === hx) return m; // hole touches outer segment; pick leftmost endpoint
494
+ }
495
+ }
330
496
  }
331
- }
332
- p = p.next;
333
- } while (p !== outerNode);
497
+ p = p.next;
498
+ } while (p !== stop);
499
+ }
334
500
 
335
501
  if (!m) return null;
336
502
 
@@ -338,108 +504,110 @@ function findHoleBridge(hole, outerNode) {
338
504
  // if there are no points found, we have a valid connection;
339
505
  // otherwise choose the point of the minimum angle with the ray as connection point
340
506
 
341
- const stop = m;
342
507
  const mx = m.x;
343
508
  const my = m.y;
509
+ const tminY = Math.min(hy, my); // the triangle's y span; x span is [mx, hx]
510
+ const tmaxY = Math.max(hy, my);
344
511
  let tanMin = Infinity;
345
512
 
346
- p = m;
513
+ // scan the same blocks; skip any whose bbox can't overlap the triangle's [mx,hx]×[tminY,tmaxY] box
514
+ for (let b = 0, g = 0; b < numBlocks; b++, g += 4) {
515
+ if (blockBBox[g + 2] < mx || blockBBox[g] > hx || blockBBox[g + 3] < tminY || blockBBox[g + 1] > tmaxY) continue;
347
516
 
348
- do {
349
- if (hx >= p.x && p.x >= mx && hx !== p.x &&
350
- pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y)) {
517
+ const stop = liveBlockStop(b);
518
+
519
+ p = liveBlockHead(b);
520
+ do {
521
+ if (p.prev.next === p && hx >= p.x && p.x >= mx && hx !== p.x && // skip dead nodes
522
+ pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y)) {
351
523
 
352
- const tan = Math.abs(hy - p.y) / (hx - p.x); // tangential
524
+ const tan = Math.abs(hy - p.y) / (hx - p.x); // tangential
353
525
 
354
- if (locallyInside(p, hole) &&
355
- (tan < tanMin || (tan === tanMin && (p.x > m.x || (p.x === m.x && sectorContainsSector(m, p)))))) {
356
- m = p;
357
- tanMin = tan;
526
+ // if hole point sits on p's horizontal edge (T-junction touch): the bridge runs
527
+ // along that edge locallyInside rejects it as collinear, but it's valid
528
+ if ((locallyInside(p, hole) || (p.y === hy && p.next.y === hy && p.next.x > hx)) &&
529
+ (tan < tanMin || (tan === tanMin && (p.x > m.x || (p.x === m.x && sectorContainsSector(m, p)))))) {
530
+ m = p;
531
+ tanMin = tan;
532
+ }
358
533
  }
359
- }
360
534
 
361
- p = p.next;
362
- } while (p !== stop);
535
+ p = p.next;
536
+ } while (p !== stop);
537
+ }
363
538
 
364
539
  return m;
365
540
  }
366
541
 
367
542
  // whether sector in vertex m contains sector in vertex p in the same coordinates
543
+ /** @param {Node} m @param {Node} p @returns {boolean} */
368
544
  function sectorContainsSector(m, p) {
369
545
  return area(m.prev, m, p.prev) < 0 && area(p.next, m, m.next) < 0;
370
546
  }
371
547
 
372
- // interlink polygon nodes in z-order
548
+ // scratch array of node refs, reused across calls and grown on demand
549
+ /** @type {Node[]} */
550
+ const sortArr = [];
551
+
552
+ // interlink polygon nodes in z-order: collect into an array, quicksort by z, relink
553
+ /** @param {Node} start @param {number} minX @param {number} minY @param {number} invSize */
373
554
  function indexCurve(start, minX, minY, invSize) {
374
555
  let p = start;
556
+ let n = 0;
375
557
  do {
376
- if (p.z === 0) p.z = zOrder(p.x, p.y, minX, minY, invSize);
377
- p.prevZ = p.prev;
378
- p.nextZ = p.next;
558
+ // always (re)compute: z may still hold a block index left over from eliminateHoles
559
+ p.z = zOrder(p.x, p.y, minX, minY, invSize);
560
+ sortArr[n++] = p;
379
561
  p = p.next;
380
562
  } while (p !== start);
381
563
 
382
- p.prevZ.nextZ = null;
383
- p.prevZ = null;
384
-
385
- sortLinked(p);
386
- }
564
+ quicksortNodes(sortArr, 0, n - 1);
387
565
 
388
- // Simon Tatham's linked list merge sort algorithm
389
- // http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
390
- function sortLinked(list) {
391
- let numMerges;
392
- let inSize = 1;
393
-
394
- do {
395
- let p = list;
396
- let e;
397
- list = null;
398
- let tail = null;
399
- numMerges = 0;
400
-
401
- while (p) {
402
- numMerges++;
403
- let q = p;
404
- let pSize = 0;
405
- for (let i = 0; i < inSize; i++) {
406
- pSize++;
407
- q = q.nextZ;
408
- if (!q) break;
409
- }
410
- let qSize = inSize;
411
-
412
- while (pSize > 0 || (qSize > 0 && q)) {
413
-
414
- if (pSize !== 0 && (qSize === 0 || !q || p.z <= q.z)) {
415
- e = p;
416
- p = p.nextZ;
417
- pSize--;
418
- } else {
419
- e = q;
420
- q = q.nextZ;
421
- qSize--;
422
- }
423
-
424
- if (tail) tail.nextZ = e;
425
- else list = e;
426
-
427
- e.prevZ = tail;
428
- tail = e;
429
- }
430
-
431
- p = q;
566
+ /** @type {Node | null} */
567
+ let prev = null;
568
+ for (let i = 0; i < n; i++) {
569
+ const node = sortArr[i];
570
+ node.prevZ = prev;
571
+ if (prev) prev.nextZ = node;
572
+ prev = node;
573
+ }
574
+ /** @type {Node} */ (prev).nextZ = null;
575
+ }
576
+
577
+ // quicksort an array of nodes by z; middle-element pivot + insertion sort for small ranges
578
+ /** @param {Node[]} arr @param {number} left @param {number} right */
579
+ function quicksortNodes(arr, left, right) {
580
+ while (right - left > 20) {
581
+ // middle pivot splits already-sorted/reversed runs evenly; real ring-order-by-z data
582
+ // is non-adversarial, so the median-of-three guard isn't needed
583
+ const pivot = arr[(left + right) >> 1].z;
584
+
585
+ let i = left, j = right, t;
586
+ while (i <= j) {
587
+ while (arr[i].z < pivot) i++;
588
+ while (arr[j].z > pivot) j--;
589
+ if (i <= j) { t = arr[i]; arr[i] = arr[j]; arr[j] = t; i++; j--; }
432
590
  }
433
-
434
- tail.nextZ = null;
435
- inSize *= 2;
436
-
437
- } while (numMerges > 1);
438
-
439
- return list;
591
+ // recurse into the smaller half, loop on the larger to bound stack depth
592
+ if (j - left < right - i) {
593
+ quicksortNodes(arr, left, j);
594
+ left = i;
595
+ } else {
596
+ quicksortNodes(arr, i, right);
597
+ right = j;
598
+ }
599
+ }
600
+ // insertion sort the small remaining range
601
+ for (let i = left + 1; i <= right; i++) {
602
+ const node = arr[i], z = node.z;
603
+ let j = i - 1;
604
+ while (j >= left && arr[j].z > z) { arr[j + 1] = arr[j]; j--; }
605
+ arr[j + 1] = node;
606
+ }
440
607
  }
441
608
 
442
609
  // z-order of a point given coords and inverse of the longer side of data bbox
610
+ /** @param {number} x @param {number} y @param {number} minX @param {number} minY @param {number} invSize @returns {number} */
443
611
  function zOrder(x, y, minX, minY, invSize) {
444
612
  // coords are transformed into non-negative 15-bit integer range
445
613
  x = (x - minX) * invSize | 0;
@@ -459,6 +627,7 @@ function zOrder(x, y, minX, minY, invSize) {
459
627
  }
460
628
 
461
629
  // find the leftmost node of a polygon ring
630
+ /** @param {Node} start @returns {Node} */
462
631
  function getLeftmost(start) {
463
632
  let p = start,
464
633
  leftmost = start;
@@ -471,43 +640,45 @@ function getLeftmost(start) {
471
640
  }
472
641
 
473
642
  // check if a point lies within a convex triangle
643
+ /** @param {number} ax @param {number} ay @param {number} bx @param {number} by @param {number} cx @param {number} cy @param {number} px @param {number} py @returns {boolean} */
474
644
  function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
475
645
  return (cx - px) * (ay - py) >= (ax - px) * (cy - py) &&
476
646
  (ax - px) * (by - py) >= (bx - px) * (ay - py) &&
477
647
  (bx - px) * (cy - py) >= (cx - px) * (by - py);
478
648
  }
479
649
 
480
- // check if a point lies within a convex triangle but false if its equal to the first point of the triangle
481
- function pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, px, py) {
482
- return !(ax === px && ay === py) && pointInTriangle(ax, ay, bx, by, cx, cy, px, py);
483
- }
484
-
485
650
  // check if a diagonal between two polygon nodes is valid (lies in polygon interior)
651
+ /** @param {Node} a @param {Node} b @returns {boolean} true when the diagonal is valid */
486
652
  function isValidDiagonal(a, b) {
487
- return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // doesn't intersect other edges
653
+ return a.next.i !== b.i && !intersectsPolygon(a, b) && // doesn't intersect other edges
488
654
  (locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b) && // locally visible
489
- (area(a.prev, a, b.prev) || area(a, b.prev, b)) || // does not create opposite-facing sectors
655
+ (area(a.prev, a, b.prev) !== 0 || area(a, b.prev, b) !== 0) || // does not create opposite-facing sectors
490
656
  equals(a, b) && area(a.prev, a, a.next) > 0 && area(b.prev, b, b.next) > 0); // special zero-length case
491
657
  }
492
658
 
493
659
  // signed area of a triangle
660
+ /** @param {Node} p @param {Node} q @param {Node} r @returns {number} */
494
661
  function area(p, q, r) {
495
662
  return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
496
663
  }
497
664
 
498
665
  // check if two points are equal
666
+ /** @param {Node} p1 @param {Node} p2 @returns {boolean} */
499
667
  function equals(p1, p2) {
500
668
  return p1.x === p2.x && p1.y === p2.y;
501
669
  }
502
670
 
503
- // check if two segments intersect
504
- function intersects(p1, q1, p2, q2) {
505
- const o1 = sign(area(p1, q1, p2));
506
- const o2 = sign(area(p1, q1, q2));
507
- const o3 = sign(area(p2, q2, p1));
508
- const o4 = sign(area(p2, q2, q1));
671
+ // check if two segments intersect; by default includes collinear boundary touches
672
+ /** @param {Node} p1 @param {Node} q1 @param {Node} p2 @param {Node} q2 @param {boolean} [includeBoundary] @returns {boolean} */
673
+ function intersects(p1, q1, p2, q2, includeBoundary = true) {
674
+ const o1 = area(p1, q1, p2);
675
+ const o2 = area(p1, q1, q2);
676
+ const o3 = area(p2, q2, p1);
677
+ const o4 = area(p2, q2, q1);
678
+
679
+ if (((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0))) return true;
509
680
 
510
- if (o1 !== o2 && o3 !== o4) return true; // general case
681
+ if (!includeBoundary) return false;
511
682
 
512
683
  if (o1 === 0 && onSegment(p1, p2, q1)) return true; // p1, q1 and p2 are collinear and p2 lies on p1q1
513
684
  if (o2 === 0 && onSegment(p1, q2, q1)) return true; // p1, q1 and q2 are collinear and q2 lies on p1q1
@@ -518,27 +689,39 @@ function intersects(p1, q1, p2, q2) {
518
689
  }
519
690
 
520
691
  // for collinear points p, q, r, check if point q lies on segment pr
692
+ /** @param {Node} p @param {Node} q @param {Node} r @returns {boolean} */
521
693
  function onSegment(p, q, r) {
522
694
  return q.x <= Math.max(p.x, r.x) && q.x >= Math.min(p.x, r.x) && q.y <= Math.max(p.y, r.y) && q.y >= Math.min(p.y, r.y);
523
695
  }
524
696
 
525
- function sign(num) {
526
- return num > 0 ? 1 : num < 0 ? -1 : 0;
527
- }
528
-
529
697
  // check if a polygon diagonal intersects any polygon segments
698
+ /** @param {Node} a @param {Node} b @returns {boolean} */
530
699
  function intersectsPolygon(a, b) {
700
+ // diagonal bbox; an edge whose bbox can't overlap it can't intersect it, so
701
+ // skip the orientation test for those (the common case — the diagonal is short)
702
+ const minX = Math.min(a.x, b.x);
703
+ const maxX = Math.max(a.x, b.x);
704
+ const minY = Math.min(a.y, b.y);
705
+ const maxY = Math.max(a.y, b.y);
706
+
531
707
  let p = a;
532
708
  do {
533
- if (p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i &&
534
- intersects(p, p.next, a, b)) return true;
535
- p = p.next;
709
+ const n = p.next;
710
+ if ((p.x > maxX && n.x > maxX) || (p.x < minX && n.x < minX) ||
711
+ (p.y > maxY && n.y > maxY) || (p.y < minY && n.y < minY)) {
712
+ p = n;
713
+ continue;
714
+ }
715
+ if (p.i !== a.i && n.i !== a.i && p.i !== b.i && n.i !== b.i &&
716
+ intersects(p, n, a, b)) return true;
717
+ p = n;
536
718
  } while (p !== a);
537
719
 
538
720
  return false;
539
721
  }
540
722
 
541
723
  // check if a polygon diagonal is locally inside the polygon
724
+ /** @param {Node} a @param {Node} b @returns {boolean} */
542
725
  function locallyInside(a, b) {
543
726
  return area(a.prev, a, a.next) < 0 ?
544
727
  area(a, b, a.next) >= 0 && area(a, a.prev, b) >= 0 :
@@ -546,16 +729,17 @@ function locallyInside(a, b) {
546
729
  }
547
730
 
548
731
  // check if the middle point of a polygon diagonal is inside the polygon
732
+ /** @param {Node} a @param {Node} b @returns {boolean} */
549
733
  function middleInside(a, b) {
550
734
  let p = a;
551
735
  let inside = false;
552
736
  const px = (a.x + b.x) / 2;
553
737
  const py = (a.y + b.y) / 2;
554
738
  do {
555
- if (((p.y > py) !== (p.next.y > py)) && p.next.y !== p.y &&
556
- (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x))
739
+ const n = p.next;
740
+ if (((p.y > py) !== (n.y > py)) && (px < (n.x - p.x) * (py - p.y) / (n.y - p.y) + p.x))
557
741
  inside = !inside;
558
- p = p.next;
742
+ p = n;
559
743
  } while (p !== a);
560
744
 
561
745
  return inside;
@@ -563,6 +747,7 @@ function middleInside(a, b) {
563
747
 
564
748
  // link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two;
565
749
  // if one belongs to the outer ring and another to a hole, it merges it into a single ring
750
+ /** @param {Node} a @param {Node} b @returns {Node} */
566
751
  function splitPolygon(a, b) {
567
752
  const a2 = createNode(a.i, a.x, a.y),
568
753
  b2 = createNode(b.i, b.x, b.y),
@@ -585,6 +770,7 @@ function splitPolygon(a, b) {
585
770
  }
586
771
 
587
772
  // create a node and optionally link it with previous one (in a circular doubly linked list)
773
+ /** @param {number} i @param {number} x @param {number} y @param {Node | null} last @returns {Node} */
588
774
  function insertNode(i, x, y, last) {
589
775
  const p = createNode(i, x, y);
590
776
 
@@ -601,29 +787,43 @@ function insertNode(i, x, y, last) {
601
787
  return p;
602
788
  }
603
789
 
790
+ /** @param {Node} p */
604
791
  function removeNode(p) {
605
792
  p.next.prev = p.prev;
606
793
  p.prev.next = p.next;
607
794
 
608
795
  if (p.prevZ) p.prevZ.nextZ = p.nextZ;
609
796
  if (p.nextZ) p.nextZ.prevZ = p.prevZ;
797
+
798
+ // keep the hole-bridge index's block bboxes covering the healed prev->next edge
799
+ if (indexActive) growBlock(p.prev, p.next);
610
800
  }
611
801
 
802
+ /** @param {number} i @param {number} x @param {number} y @returns {Node} */
612
803
  function createNode(i, x, y) {
613
- return {
804
+ // prev/next are assigned by the caller before any read, so the null init is cast away here
805
+ return /** @type {Node} */ (/** @type {unknown} */ ({
614
806
  i, // vertex index in coordinates array
615
807
  x, y, // vertex coordinates
616
808
  prev: null, // previous and next vertex nodes in a polygon ring
617
809
  next: null,
618
- z: 0, // z-order curve value
810
+ z: 0, // z-order curve value; doubles as owning block in the hole-bridge index during eliminateHoles
619
811
  prevZ: null, // previous and next nodes in z-order
620
- nextZ: null,
621
- steiner: false // indicates whether this is a steiner point
622
- };
623
- }
624
-
625
- // return a percentage difference between the polygon area and its triangulation area;
626
- // used to verify correctness of triangulation
812
+ nextZ: null
813
+ }));
814
+ }
815
+
816
+ /**
817
+ * Return the relative difference between the polygon area and the area of its triangulation
818
+ * a value near 0 means a correct triangulation. Useful for verifying output in tests.
819
+ *
820
+ * @param {ArrayLike<number>} data
821
+ * @param {ArrayLike<number> | null} holeIndices
822
+ * @param {number} dim number of coordinates per vertex in `data`
823
+ * @param {ArrayLike<number>} triangles output of {@link earcut}
824
+ * @returns {number}
825
+ * @example deviation(data, holes, dim, earcut(data, holes, dim)); // ~0 if correct
826
+ */
627
827
  export function deviation(data, holeIndices, dim, triangles) {
628
828
  const hasHoles = holeIndices && holeIndices.length;
629
829
  const outerLen = hasHoles ? holeIndices[0] * dim : data.length;
@@ -651,6 +851,7 @@ export function deviation(data, holeIndices, dim, triangles) {
651
851
  Math.abs((trianglesArea - polygonArea) / polygonArea);
652
852
  }
653
853
 
854
+ /** @param {ArrayLike<number>} data @param {number} start @param {number} end @param {number} dim @returns {number} */
654
855
  function signedArea(data, start, end, dim) {
655
856
  let sum = 0;
656
857
  for (let i = start, j = end - dim; i < end; i += dim) {
@@ -660,7 +861,13 @@ function signedArea(data, start, end, dim) {
660
861
  return sum;
661
862
  }
662
863
 
663
- // turn a polygon in a multi-dimensional array form (e.g. as in GeoJSON) into a form Earcut accepts
864
+ /**
865
+ * Turn a polygon in multi-dimensional array form (e.g. as in GeoJSON) into the flat form Earcut accepts.
866
+ *
867
+ * @param {ReadonlyArray<ReadonlyArray<ArrayLike<number>>>} data array of rings; the first ring is the outer contour, the rest are holes
868
+ * @returns {{vertices: number[], holes: number[], dimensions: number}}
869
+ * @example const {vertices, holes, dimensions} = flatten(geojson.coordinates);
870
+ */
664
871
  export function flatten(data) {
665
872
  const vertices = [];
666
873
  const holes = [];