Skip to content

Commit 3d39142

Browse files
yhahnlucaswoj
authored andcommitted
Drop smallest inner rings for earcut performance (#2622)
* Add unit tests for existing classifyRings functionality * Add maxRings argument to classifyRings * Winding order fix, set a max ring threshold to 100 (for now) * Satisfy the eslint beast * Bump EARCUT_MAX_RINGS to 500 * Use quickselect instead of sort in classifyRings * Refactoring * Respond to PR review Thanks to @yhahn, @jakepruitt and @mourner for all the help on this effort!
1 parent 8a7f936 commit 3d39142

File tree

4 files changed

+174
-6
lines changed

4 files changed

+174
-6
lines changed

js/data/bucket/fill_bucket.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ var util = require('../../util/util');
55
var loadGeometry = require('../load_geometry');
66
var earcut = require('earcut');
77
var classifyRings = require('../../util/classify_rings');
8+
var EARCUT_MAX_RINGS = 500;
89

910
module.exports = FillBucket;
1011

@@ -32,7 +33,7 @@ FillBucket.prototype.programInterfaces = {
3233

3334
FillBucket.prototype.addFeature = function(feature) {
3435
var lines = loadGeometry(feature);
35-
var polygons = classifyRings(lines);
36+
var polygons = classifyRings(lines, EARCUT_MAX_RINGS);
3637
for (var i = 0; i < polygons.length; i++) {
3738
this.addPolygon(polygons[i]);
3839
}

js/util/classify_rings.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use strict';
22

3-
module.exports = classifyRings;
3+
var quickselect = require('quickselect');
44

55
// classifies an array of rings into polygons with outer rings and holes
6-
7-
function classifyRings(rings) {
6+
module.exports = function classifyRings(rings, maxRings) {
87
var len = rings.length;
98

109
if (len <= 1) return [rings];
@@ -14,9 +13,11 @@ function classifyRings(rings) {
1413
ccw;
1514

1615
for (var i = 0; i < len; i++) {
17-
var area = signedArea(rings[i]);
16+
var area = calculateSignedArea(rings[i]);
1817
if (area === 0) continue;
1918

19+
rings[i].area = Math.abs(area);
20+
2021
if (ccw === undefined) ccw = area < 0;
2122

2223
if (ccw === area < 0) {
@@ -29,10 +30,24 @@ function classifyRings(rings) {
2930
}
3031
if (polygon) polygons.push(polygon);
3132

33+
// Earcut performance degrages with the # of rings in a polygon. For this
34+
// reason, we limit strip out all but the `maxRings` largest rings.
35+
if (maxRings > 1) {
36+
for (var j = 0; j < polygons.length; j++) {
37+
if (polygons[j].length <= maxRings) continue;
38+
quickselect(polygons[j], maxRings, 1, polygon.length - 1, compareAreas);
39+
polygons[j] = polygon.slice(0, maxRings);
40+
}
41+
}
42+
3243
return polygons;
44+
};
45+
46+
function compareAreas(a, b) {
47+
return b.area - a.area;
3348
}
3449

35-
function signedArea(ring) {
50+
function calculateSignedArea(ring) {
3651
var sum = 0;
3752
for (var i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) {
3853
p1 = ring[i];

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"pbf": "^1.3.2",
2626
"pngjs": "^2.2.0",
2727
"point-geometry": "^0.0.0",
28+
"quickselect": "^1.0.0",
2829
"request": "^2.39.0",
2930
"resolve-url": "^0.2.1",
3031
"shelf-pack": "^1.0.0",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
'use strict';
2+
3+
var test = require('tap').test;
4+
var fs = require('fs');
5+
var path = require('path');
6+
var Protobuf = require('pbf');
7+
var VectorTile = require('vector-tile').VectorTile;
8+
var classifyRings = require('../../../js/util/classify_rings');
9+
10+
// Load a fill feature from fixture tile.
11+
var vt = new VectorTile(new Protobuf(new Uint8Array(fs.readFileSync(path.join(__dirname, '/../../fixtures/mbsv5-6-18-23.vector.pbf')))));
12+
var feature = vt.layers.water.feature(0);
13+
14+
test('classifyRings', function(assert) {
15+
var geometry;
16+
var classified;
17+
18+
geometry = [
19+
[
20+
{x:0, y:0},
21+
{x:0, y:40},
22+
{x:40, y:40},
23+
{x:40, y:0},
24+
{x:0, y:0}
25+
]
26+
];
27+
classified = classifyRings(geometry);
28+
assert.equal(classified.length, 1, '1 polygon');
29+
assert.equal(classified[0].length, 1, 'polygon 1 has 1 exterior');
30+
31+
geometry = [
32+
[
33+
{x:0, y:0},
34+
{x:0, y:40},
35+
{x:40, y:40},
36+
{x:40, y:0},
37+
{x:0, y:0}
38+
],
39+
[
40+
{x:60, y:0},
41+
{x:60, y:40},
42+
{x:100, y:40},
43+
{x:100, y:0},
44+
{x:60, y:0}
45+
]
46+
];
47+
classified = classifyRings(geometry);
48+
assert.equal(classified.length, 2, '2 polygons');
49+
assert.equal(classified[0].length, 1, 'polygon 1 has 1 exterior');
50+
assert.equal(classified[1].length, 1, 'polygon 2 has 1 exterior');
51+
52+
geometry = [
53+
[
54+
{x:0, y:0},
55+
{x:0, y:40},
56+
{x:40, y:40},
57+
{x:40, y:0},
58+
{x:0, y:0}
59+
],
60+
[
61+
{x:10, y:10},
62+
{x:20, y:10},
63+
{x:20, y:20},
64+
{x:10, y:10}
65+
]
66+
];
67+
classified = classifyRings(geometry);
68+
assert.equal(classified.length, 1, '1 polygon');
69+
assert.equal(classified[0].length, 2, 'polygon 1 has 1 exterior, 1 interior');
70+
71+
geometry = feature.loadGeometry();
72+
classified = classifyRings(geometry);
73+
assert.equal(classified.length, 2, '2 polygons');
74+
assert.equal(classified[0].length, 1, 'polygon 1 has 1 exterior');
75+
assert.equal(classified[1].length, 10, 'polygon 2 has 1 exterior, 9 interior');
76+
77+
assert.end();
78+
});
79+
80+
test('classifyRings + maxRings', function(t) {
81+
82+
function createGeometry(options) {
83+
var geometry = [
84+
// Outer ring, area = 3200
85+
[ {x:0, y:0}, {x:0, y:40}, {x:40, y:40}, {x:40, y:0}, {x:0, y:0} ],
86+
// Inner ring, area = 100
87+
[ {x:30, y:30}, {x:32, y:30}, {x:32, y:32}, {x:30, y:30} ],
88+
// Inner ring, area = 4
89+
[ {x:10, y:10}, {x:20, y:10}, {x:20, y:20}, {x:10, y:10} ]
90+
];
91+
if (options && options.reverse) {
92+
geometry[0].reverse();
93+
geometry[1].reverse();
94+
geometry[2].reverse();
95+
}
96+
return geometry;
97+
}
98+
99+
100+
t.test('maxRings=undefined', function(t) {
101+
var geometry = sortRings(classifyRings(createGeometry()));
102+
t.equal(geometry.length, 1);
103+
t.equal(geometry[0].length, 3);
104+
t.equal(geometry[0][0].area, 3200);
105+
t.equal(geometry[0][1].area, 100);
106+
t.equal(geometry[0][2].area, 4);
107+
t.end();
108+
});
109+
110+
t.test('maxRings=2', function(t) {
111+
var geometry = sortRings(classifyRings(createGeometry(), 2));
112+
t.equal(geometry.length, 1);
113+
t.equal(geometry[0].length, 2);
114+
t.equal(geometry[0][0].area, 3200);
115+
t.equal(geometry[0][1].area, 100);
116+
t.end();
117+
});
118+
119+
t.test('maxRings=2, reversed geometry', function(t) {
120+
var geometry = sortRings(classifyRings(createGeometry({reverse: true}), 2));
121+
t.equal(geometry.length, 1);
122+
t.equal(geometry[0].length, 2);
123+
t.equal(geometry[0][0].area, 3200);
124+
t.equal(geometry[0][1].area, 100);
125+
t.end();
126+
});
127+
128+
t.test('maxRings=5, geometry from fixture', function(t) {
129+
var geometry = sortRings(classifyRings(feature.loadGeometry(), 5));
130+
t.equal(geometry.length, 2);
131+
t.equal(geometry[0].length, 1);
132+
t.equal(geometry[1].length, 5);
133+
134+
var areas = geometry[1].map(function(ring) { return ring.area; });
135+
t.deepEqual(areas, [2763951, 21600, 8298, 4758, 3411]);
136+
t.end();
137+
});
138+
139+
t.end();
140+
});
141+
142+
function sortRings(geometry) {
143+
for (var i = 0; i < geometry.length; i++) {
144+
geometry[i] = geometry[i].sort(compareAreas);
145+
}
146+
return geometry;
147+
}
148+
149+
function compareAreas(a, b) {
150+
return b.area - a.area;
151+
}

0 commit comments

Comments
 (0)