Skip to content

Commit 38bccee

Browse files
authored
Fix edge cases when inner rings are touching (#177)
* fix all except touching-holes4 * other * fix benchmarks * viz tweaks, test rotations, and fix other bugs * rm diffs * rm extra diff * perf tweak * comment * perf tweaks
1 parent 32493e1 commit 38bccee

File tree

11 files changed

+224
-163
lines changed

11 files changed

+224
-163
lines changed

bench/basic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {earcut, flatten} from '../src/earcut.js';
1+
import earcut, {flatten} from '../src/earcut.js';
22
import {readFileSync} from 'fs';
33

44
const data = JSON.parse(readFileSync(new URL('../test/fixtures/building.json', import.meta.url)));

bench/bench.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {earcut, flatten} from '../src/earcut.js';
1+
import earcut, {flatten} from '../src/earcut.js';
22
import Benchmark from 'benchmark';
33
import {readFileSync} from 'fs';
44

src/earcut.js

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,16 +140,16 @@ function isEar(ear) {
140140
// now make sure we don't have other points inside the potential ear
141141
const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
142142

143-
// triangle bbox; min & max are calculated like this for speed
144-
const x0 = ax < bx ? (ax < cx ? ax : cx) : (bx < cx ? bx : cx),
145-
y0 = ay < by ? (ay < cy ? ay : cy) : (by < cy ? by : cy),
146-
x1 = ax > bx ? (ax > cx ? ax : cx) : (bx > cx ? bx : cx),
147-
y1 = ay > by ? (ay > cy ? ay : cy) : (by > cy ? by : cy);
143+
// triangle bbox
144+
const x0 = Math.min(ax, bx, cx),
145+
y0 = Math.min(ay, by, cy),
146+
x1 = Math.max(ax, bx, cx),
147+
y1 = Math.max(ay, by, cy);
148148

149149
let p = c.next;
150150
while (p !== a) {
151151
if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 &&
152-
pointInTriangle(ax, ay, bx, by, cx, cy, p.x, p.y) &&
152+
pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) &&
153153
area(p.prev, p, p.next) >= 0) return false;
154154
p = p.next;
155155
}
@@ -166,11 +166,11 @@ function isEarHashed(ear, minX, minY, invSize) {
166166

167167
const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
168168

169-
// triangle bbox; min & max are calculated like this for speed
170-
const x0 = ax < bx ? (ax < cx ? ax : cx) : (bx < cx ? bx : cx),
171-
y0 = ay < by ? (ay < cy ? ay : cy) : (by < cy ? by : cy),
172-
x1 = ax > bx ? (ax > cx ? ax : cx) : (bx > cx ? bx : cx),
173-
y1 = ay > by ? (ay > cy ? ay : cy) : (by > cy ? by : cy);
169+
// triangle bbox
170+
const x0 = Math.min(ax, bx, cx),
171+
y0 = Math.min(ay, by, cy),
172+
x1 = Math.max(ax, bx, cx),
173+
y1 = Math.max(ay, by, cy);
174174

175175
// z-order range for the current triangle bbox;
176176
const minZ = zOrder(x0, y0, minX, minY, invSize),
@@ -182,25 +182,25 @@ function isEarHashed(ear, minX, minY, invSize) {
182182
// look for points inside the triangle in both directions
183183
while (p && p.z >= minZ && n && n.z <= maxZ) {
184184
if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
185-
pointInTriangle(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
185+
pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
186186
p = p.prevZ;
187187

188188
if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
189-
pointInTriangle(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
189+
pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
190190
n = n.nextZ;
191191
}
192192

193193
// look for remaining points in decreasing z-order
194194
while (p && p.z >= minZ) {
195195
if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
196-
pointInTriangle(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
196+
pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
197197
p = p.prevZ;
198198
}
199199

200200
// look for remaining points in increasing z-order
201201
while (n && n.z <= maxZ) {
202202
if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
203-
pointInTriangle(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
203+
pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
204204
n = n.nextZ;
205205
}
206206

@@ -268,7 +268,7 @@ function eliminateHoles(data, holeIndices, outerNode, dim) {
268268
queue.push(getLeftmost(list));
269269
}
270270

271-
queue.sort(compareX);
271+
queue.sort(compareXYSlope);
272272

273273
// process holes from left to right
274274
for (let i = 0; i < queue.length; i++) {
@@ -278,8 +278,19 @@ function eliminateHoles(data, holeIndices, outerNode, dim) {
278278
return outerNode;
279279
}
280280

281-
function compareX(a, b) {
282-
return a.x - b.x;
281+
function compareXYSlope(a, b) {
282+
let result = a.x - b.x;
283+
// when the left-most point of 2 holes meet at a vertex, sort the holes counterclockwise so that when we find
284+
// 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;
283294
}
284295

285296
// find a bridge between vertices that connects hole with an outer ring and and link it
@@ -306,8 +317,11 @@ function findHoleBridge(hole, outerNode) {
306317

307318
// find a segment intersected by a ray from the hole's leftmost point to the left;
308319
// segment's endpoint with lesser x will be potential connection point
320+
// unless they intersect at a vertex, then choose the vertex
321+
if (equals(hole, p)) return p;
309322
do {
310-
if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
323+
if (equals(hole, p.next)) return p.next;
324+
else if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
311325
const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
312326
if (x <= hx && x > qx) {
313327
qx = x;
@@ -463,6 +477,11 @@ function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
463477
(bx - px) * (cy - py) >= (cx - px) * (by - py);
464478
}
465479

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+
466485
// check if a diagonal between two polygon nodes is valid (lies in polygon interior)
467486
function isValidDiagonal(a, b) {
468487
return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // dones't intersect other edges

test/expected.json

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"water3": 197,
88
"water3b": 25,
99
"water4": 705,
10-
"water-huge": 5177,
10+
"water-huge": 5176,
1111
"water-huge2": 4462,
1212
"degenerate": 0,
1313
"bad-hole": 42,
@@ -22,6 +22,11 @@
2222
"outside-ring": 64,
2323
"simplified-us-border": 120,
2424
"touching-holes": 57,
25+
"touching-holes2": 10,
26+
"touching-holes3": 82,
27+
"touching-holes4": 55,
28+
"touching-holes5": 133,
29+
"touching-holes6": 3098,
2530
"hole-touching-outer": 77,
2631
"hilbert": 1024,
2732
"issue45": 10,
@@ -32,32 +37,38 @@
3237
"bad-diagonals": 7,
3338
"issue83": 0,
3439
"issue107": 0,
35-
"issue111": 19,
36-
"boxy": 57,
40+
"issue111": 18,
41+
"boxy": 58,
3742
"collinear-diagonal": 14,
3843
"issue119": 18,
3944
"hourglass": 2,
4045
"touching2": 8,
4146
"touching3": 15,
42-
"touching4": 20,
47+
"touching4": 19,
4348
"rain": 2681,
4449
"issue131": 12,
45-
"infinite-loop-jhl" : 0,
46-
"filtered-bridge-jhl" : 25,
50+
"infinite-loop-jhl": 0,
51+
"filtered-bridge-jhl": 25,
4752
"issue149": 2,
4853
"issue142": 4
4954
},
5055
"errors": {
5156
"dude": 2e-15,
5257
"water": 0.0008,
5358
"water-huge": 0.0011,
54-
"water-huge2": 0.0028,
59+
"water-huge2": 0.004,
5560
"bad-hole": 0.019,
5661
"issue16": 4e-16,
5762
"issue17": 2e-16,
5863
"issue29": 2e-15,
5964
"self-touching": 2e-13,
6065
"eberly-6": 2e-14,
6166
"issue142": 0.13
67+
},
68+
"errors-with-rotation": {
69+
"water-huge": 0.0035,
70+
"water-huge2": 0.061,
71+
"bad-hole": 0.04,
72+
"issue16": 8e-16
6273
}
63-
}
74+
}

test/fixtures/touching-holes2.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[[[0,0],[20,0],[20,25],[0,25],[0,0]],[[3,3],[2,12],[9,15],[3,3]],[[9,21],[2,12],[7,22],[9,21]]]

test/fixtures/touching-holes3.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[[[0,0],[20,0],[20,25],[0,25],[0,0]],[[2,12],[4,23],[5,23],[2,12]],[[2,12],[6,23],[7,23],[2,12]],[[2,12],[8,23],[9,23],[2,12]],[[2,12],[10,23],[11,23],[2,12]],[[2,12],[12,23],[13,23],[2,12]],[[2,12],[14,23],[15,23],[2,12]],[[2,12],[16,23],[17,23],[2,12]],[[2,12],[18,23],[18,22],[2,12]],[[2,12],[18,21],[18,20],[2,12]],[[2,12],[18,19],[18,18],[2,12]],[[2,12],[18,17],[18,16],[2,12]],[[2,12],[18,15],[18,14],[2,12]],[[2,12],[18,13],[18,12],[2,12]],[[2,12],[18,11],[18,10],[2,12]],[[2,12],[18,9],[18,8],[2,12]],[[2,12],[18,7],[18,6],[2,12]],[[2,12],[18,5],[18,4],[2,12]],[[2,12],[18,3],[18,2],[2,12]],[[2,12],[18,1],[17,1],[2,12]],[[2,12],[16,1],[15,1],[2,12]],[[2,12],[14,1],[13,1],[2,12]],[[2,12],[12,1],[11,1],[2,12]],[[2,12],[10,1],[9,1],[2,12]],[[2,12],[8,1],[7,1],[2,12]],[[2,12],[6,1],[5,1],[2,12]],[[2,12],[4,1],[3,1],[2,12]]]

test/fixtures/touching-holes4.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[[[-20,0],[20,0],[20,25],[-20,25],[-20,0]],[[2,12],[-1,23],[0,23],[2,12]],[[2,12],[-3,23],[-2,23],[2,12]],[[2,12],[-5,23],[-4,23],[2,12]],[[2,12],[-7,23],[-6,23],[2,12]],[[2,12],[-9,23],[-8,23],[2,12]],[[2,12],[-11,23],[-10,23],[2,12]],[[2,12],[-13,23],[-12,23],[2,12]],[[2,12],[-14,22],[-14,23],[2,12]],[[2,12],[-14,20],[-14,21],[2,12]],[[2,12],[-14,18],[-14,19],[2,12]],[[2,12],[-14,16],[-14,17],[2,12]],[[2,12],[-14,14],[-14,15],[2,12]],[[2,12],[-14,12],[-14,13],[2,12]],[[2,12],[-14,10],[-14,11],[2,12]],[[2,12],[-14,8],[-14,9],[2,12]],[[2,12],[-14,6],[-14,7],[2,12]],[[2,12],[-14,4],[-14,5],[2,12]],[[2,12],[-14,2],[-14,3],[2,12]],[[2,12],[-13,1],[-14,1],[2,12]],[[2,12],[-11,1],[-12,1],[2,12]],[[2,12],[-9,1],[-10,1],[2,12]],[[2,12],[-7,1],[-8,1],[2,12]],[[2,12],[-5,1],[-6,1],[2,12]],[[2,12],[-3,1],[-4,1],[2,12]],[[2,12],[-1,1],[-2,1],[2,12]],[[2,12],[1,1],[0,1],[2,12]]]

test/fixtures/touching-holes5.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[[[-20,0],[20,0],[20,25],[-20,25],[-20,0]],[[2,12],[4,23],[5,23],[2,12]],[[2,12],[6,23],[7,23],[2,12]],[[2,12],[8,23],[9,23],[2,12]],[[2,12],[10,23],[11,23],[2,12]],[[2,12],[12,23],[13,23],[2,12]],[[2,12],[14,23],[15,23],[2,12]],[[2,12],[16,23],[17,23],[2,12]],[[2,12],[18,23],[18,22],[2,12]],[[2,12],[18,21],[18,20],[2,12]],[[2,12],[18,19],[18,18],[2,12]],[[2,12],[18,17],[18,16],[2,12]],[[2,12],[18,15],[18,14],[2,12]],[[2,12],[18,13],[18,12],[2,12]],[[2,12],[18,11],[18,10],[2,12]],[[2,12],[18,9],[18,8],[2,12]],[[2,12],[18,7],[18,6],[2,12]],[[2,12],[18,5],[18,4],[2,12]],[[2,12],[18,3],[18,2],[2,12]],[[2,12],[18,1],[17,1],[2,12]],[[2,12],[16,1],[15,1],[2,12]],[[2,12],[14,1],[13,1],[2,12]],[[2,12],[12,1],[11,1],[2,12]],[[2,12],[10,1],[9,1],[2,12]],[[2,12],[8,1],[7,1],[2,12]],[[2,12],[6,1],[5,1],[2,12]],[[2,12],[4,1],[3,1],[2,12]],[[2,12],[-1,23],[0,23],[2,12]],[[2,12],[-3,23],[-2,23],[2,12]],[[2,12],[-5,23],[-4,23],[2,12]],[[2,12],[-7,23],[-6,23],[2,12]],[[2,12],[-9,23],[-8,23],[2,12]],[[2,12],[-11,23],[-10,23],[2,12]],[[2,12],[-13,23],[-12,23],[2,12]],[[2,12],[-14,22],[-14,23],[2,12]],[[2,12],[-14,20],[-14,21],[2,12]],[[2,12],[-14,18],[-14,19],[2,12]],[[2,12],[-14,16],[-14,17],[2,12]],[[2,12],[-14,14],[-14,15],[2,12]],[[2,12],[-14,12],[-14,13],[2,12]],[[2,12],[-14,10],[-14,11],[2,12]],[[2,12],[-14,8],[-14,9],[2,12]],[[2,12],[-14,6],[-14,7],[2,12]],[[2,12],[-14,4],[-14,5],[2,12]],[[2,12],[-14,2],[-14,3],[2,12]],[[2,12],[-13,1],[-14,1],[2,12]],[[2,12],[-11,1],[-12,1],[2,12]],[[2,12],[-9,1],[-10,1],[2,12]],[[2,12],[-7,1],[-8,1],[2,12]],[[2,12],[-5,1],[-6,1],[2,12]],[[2,12],[-3,1],[-4,1],[2,12]],[[2,12],[-1,1],[-2,1],[2,12]],[[2,12],[1,1],[0,1],[2,12]]]

test/fixtures/touching-holes6.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

test/test.js

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,39 @@ test('empty', () => {
2222

2323
for (const id of Object.keys(expected.triangles)) {
2424

25-
test(id, () => {
26-
const data = flatten(JSON.parse(fs.readFileSync(new URL(`fixtures/${id}.json`, import.meta.url)))),
27-
indices = earcut(data.vertices, data.holes, data.dimensions),
28-
err = deviation(data.vertices, data.holes, data.dimensions, indices),
29-
expectedTriangles = expected.triangles[id],
30-
expectedDeviation = expected.errors[id] || 0;
31-
32-
const numTriangles = indices.length / 3;
33-
assert.ok(numTriangles === expectedTriangles, `${numTriangles} triangles when expected ${expectedTriangles}`);
34-
35-
if (expectedTriangles > 0) {
36-
assert.ok(err <= expectedDeviation, `deviation ${err} <= ${expectedDeviation}`);
37-
}
38-
});
25+
for (const rotation of [0, 90, 180, 270]) {
26+
test(`${id} rotation ${rotation}`, () => {
27+
const coords = JSON.parse(fs.readFileSync(new URL(`fixtures/${id}.json`, import.meta.url)));
28+
const theta = rotation * Math.PI / 180;
29+
const xx = Math.round(Math.cos(theta));
30+
const xy = Math.round(-Math.sin(theta));
31+
const yx = Math.round(Math.sin(theta));
32+
const yy = Math.round(Math.cos(theta));
33+
if (rotation) {
34+
for (const ring of coords) {
35+
for (const coord of ring) {
36+
const [x, y] = coord;
37+
coord[0] = xx * x + xy * y;
38+
coord[1] = yx * x + yy * y;
39+
}
40+
}
41+
}
42+
const data = flatten(coords),
43+
indices = earcut(data.vertices, data.holes, data.dimensions),
44+
err = deviation(data.vertices, data.holes, data.dimensions, indices),
45+
expectedTriangles = expected.triangles[id],
46+
expectedDeviation = (rotation !== 0 && expected['errors-with-rotation'][id]) || expected.errors[id] || 0;
47+
48+
const numTriangles = indices.length / 3;
49+
if (rotation === 0) {
50+
assert.ok(numTriangles === expectedTriangles, `${numTriangles} triangles when expected ${expectedTriangles}`);
51+
}
52+
53+
if (expectedTriangles > 0) {
54+
assert.ok(err <= expectedDeviation, `deviation ${err} <= ${expectedDeviation}`);
55+
}
56+
});
57+
}
3958
}
4059

4160
test('infinite-loop', () => {

0 commit comments

Comments
 (0)