Skip to content

Commit

Permalink
Fix edge cases when inner rings are touching (#177)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
msbarry authored Dec 18, 2024
1 parent 32493e1 commit 38bccee
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 163 deletions.
2 changes: 1 addition & 1 deletion bench/basic.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {earcut, flatten} from '../src/earcut.js';
import earcut, {flatten} from '../src/earcut.js';
import {readFileSync} from 'fs';

const data = JSON.parse(readFileSync(new URL('../test/fixtures/building.json', import.meta.url)));
Expand Down
2 changes: 1 addition & 1 deletion bench/bench.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {earcut, flatten} from '../src/earcut.js';
import earcut, {flatten} from '../src/earcut.js';
import Benchmark from 'benchmark';
import {readFileSync} from 'fs';

Expand Down
57 changes: 38 additions & 19 deletions src/earcut.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,16 @@ function isEar(ear) {
// now make sure we don't have other points inside the potential ear
const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;

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

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

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

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

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

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

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

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

Expand Down Expand Up @@ -268,7 +268,7 @@ function eliminateHoles(data, holeIndices, outerNode, dim) {
queue.push(getLeftmost(list));
}

queue.sort(compareX);
queue.sort(compareXYSlope);

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

function compareX(a, b) {
return a.x - b.x;
function compareXYSlope(a, b) {
let result = a.x - b.x;
// when the left-most point of 2 holes meet at a vertex, sort the holes counterclockwise so that when we find
// the bridge to the outer shell is always the point that they meet at.
if (result === 0) {
result = a.y - b.y;
if (result === 0) {
const aSlope = (a.next.y - a.y) / (a.next.x - a.x);
const bSlope = (b.next.y - b.y) / (b.next.x - b.x);
result = aSlope - bSlope;
}
}
return result;
}

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

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

// check if a point lies within a convex triangle but false if its equal to the first point of the triangle
function pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, px, py) {
return !(ax === px && ay === py) && pointInTriangle(ax, ay, bx, by, cx, cy, px, py);
}

// check if a diagonal between two polygon nodes is valid (lies in polygon interior)
function isValidDiagonal(a, b) {
return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // dones't intersect other edges
Expand Down
27 changes: 19 additions & 8 deletions test/expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"water3": 197,
"water3b": 25,
"water4": 705,
"water-huge": 5177,
"water-huge": 5176,
"water-huge2": 4462,
"degenerate": 0,
"bad-hole": 42,
Expand All @@ -22,6 +22,11 @@
"outside-ring": 64,
"simplified-us-border": 120,
"touching-holes": 57,
"touching-holes2": 10,
"touching-holes3": 82,
"touching-holes4": 55,
"touching-holes5": 133,
"touching-holes6": 3098,
"hole-touching-outer": 77,
"hilbert": 1024,
"issue45": 10,
Expand All @@ -32,32 +37,38 @@
"bad-diagonals": 7,
"issue83": 0,
"issue107": 0,
"issue111": 19,
"boxy": 57,
"issue111": 18,
"boxy": 58,
"collinear-diagonal": 14,
"issue119": 18,
"hourglass": 2,
"touching2": 8,
"touching3": 15,
"touching4": 20,
"touching4": 19,
"rain": 2681,
"issue131": 12,
"infinite-loop-jhl" : 0,
"filtered-bridge-jhl" : 25,
"infinite-loop-jhl": 0,
"filtered-bridge-jhl": 25,
"issue149": 2,
"issue142": 4
},
"errors": {
"dude": 2e-15,
"water": 0.0008,
"water-huge": 0.0011,
"water-huge2": 0.0028,
"water-huge2": 0.004,
"bad-hole": 0.019,
"issue16": 4e-16,
"issue17": 2e-16,
"issue29": 2e-15,
"self-touching": 2e-13,
"eberly-6": 2e-14,
"issue142": 0.13
},
"errors-with-rotation": {
"water-huge": 0.0035,
"water-huge2": 0.061,
"bad-hole": 0.04,
"issue16": 8e-16
}
}
}
1 change: 1 addition & 0 deletions test/fixtures/touching-holes2.json
Original file line number Diff line number Diff line change
@@ -0,0 +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]]]
1 change: 1 addition & 0 deletions test/fixtures/touching-holes3.json
Original file line number Diff line number Diff line change
@@ -0,0 +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]]]
1 change: 1 addition & 0 deletions test/fixtures/touching-holes4.json
Original file line number Diff line number Diff line change
@@ -0,0 +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]]]
1 change: 1 addition & 0 deletions test/fixtures/touching-holes5.json
Original file line number Diff line number Diff line change
@@ -0,0 +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]]]
1 change: 1 addition & 0 deletions test/fixtures/touching-holes6.json

Large diffs are not rendered by default.

47 changes: 33 additions & 14 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,39 @@ test('empty', () => {

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

test(id, () => {
const data = flatten(JSON.parse(fs.readFileSync(new URL(`fixtures/${id}.json`, import.meta.url)))),
indices = earcut(data.vertices, data.holes, data.dimensions),
err = deviation(data.vertices, data.holes, data.dimensions, indices),
expectedTriangles = expected.triangles[id],
expectedDeviation = expected.errors[id] || 0;

const numTriangles = indices.length / 3;
assert.ok(numTriangles === expectedTriangles, `${numTriangles} triangles when expected ${expectedTriangles}`);

if (expectedTriangles > 0) {
assert.ok(err <= expectedDeviation, `deviation ${err} <= ${expectedDeviation}`);
}
});
for (const rotation of [0, 90, 180, 270]) {
test(`${id} rotation ${rotation}`, () => {
const coords = JSON.parse(fs.readFileSync(new URL(`fixtures/${id}.json`, import.meta.url)));
const theta = rotation * Math.PI / 180;
const xx = Math.round(Math.cos(theta));
const xy = Math.round(-Math.sin(theta));
const yx = Math.round(Math.sin(theta));
const yy = Math.round(Math.cos(theta));
if (rotation) {
for (const ring of coords) {
for (const coord of ring) {
const [x, y] = coord;
coord[0] = xx * x + xy * y;
coord[1] = yx * x + yy * y;
}
}
}
const data = flatten(coords),
indices = earcut(data.vertices, data.holes, data.dimensions),
err = deviation(data.vertices, data.holes, data.dimensions, indices),
expectedTriangles = expected.triangles[id],
expectedDeviation = (rotation !== 0 && expected['errors-with-rotation'][id]) || expected.errors[id] || 0;

const numTriangles = indices.length / 3;
if (rotation === 0) {
assert.ok(numTriangles === expectedTriangles, `${numTriangles} triangles when expected ${expectedTriangles}`);
}

if (expectedTriangles > 0) {
assert.ok(err <= expectedDeviation, `deviation ${err} <= ${expectedDeviation}`);
}
});
}
}

test('infinite-loop', () => {
Expand Down
247 changes: 127 additions & 120 deletions viz/viz.js

Large diffs are not rendered by default.

0 comments on commit 38bccee

Please sign in to comment.