Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ISC License

Copyright (c) 2024, Mapbox
Copyright (c) 2026, Mapbox

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
Expand Down
2 changes: 1 addition & 1 deletion bench/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const {vertices, holes} = flatten(data);

const start = performance.now();
let ops = 0;
let passed = 0;
let passed;

do {
earcut(vertices, holes);
Expand Down
78 changes: 78 additions & 0 deletions bench/bench-tiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Benchmark earcut over a realistic set of MVT polygons. The fixture was generated
// from a local tile cache by a one-off, author-machine-specific script; regenerating
// it is intentionally out of scope for this repo.
import earcut from '../src/earcut.js';
import {readTilesFixture} from './tiles-fixture.js';

const polys = readTilesFixture(); // each: {vertices, holes, dimensions, z} ready for earcut

// --- distribution (representativeness check) ---
const vc = polys.map(p => p.vertices.length / p.dimensions).sort((a, b) => a - b);
const q = f => vc[Math.min(vc.length - 1, Math.floor(f * vc.length))];
const withHoles = polys.filter(p => p.holes.length > 0).length;
const totalVerts = vc.reduce((a, b) => a + b, 0);

console.log(`fixture: ${polys.length.toLocaleString()} polygons, ${totalVerts.toLocaleString()} vertices`);
console.log(` ${(100 * withHoles / polys.length).toFixed(1)}% with holes`);
console.log(` verts/poly: median ${q(.5)}, p90 ${q(.9)}, p99 ${q(.99)}, max ${vc[vc.length - 1]}`);

// --- timing ---
// lean timed path: just triangulate. kept free of any extra work so timing measures
// earcut alone (the checksum is computed separately, outside the timing loop).
function run(set) {
let tris = 0;
for (const d of set) tris += earcut(d.vertices, d.holes, d.dimensions).length;
return tris;
}

// position-weighted fold over all indices: a sensitive drift signal (catches reordered/
// changed triangulations, not just gross breakage). Run once, never inside timing.
function checksum(set) {
let sum = 0;
for (const d of set) {
const idx = earcut(d.vertices, d.holes, d.dimensions);
for (let i = 0; i < idx.length; i++) sum = (sum + idx[i] * (i + 1)) >>> 0;
}
return sum;
}
// median wall time over a set. Assumes the JIT/caches are already primed: the overall
// pass is warmed by the tris + checksum runs before it, and the per-zoom slices are
// warmed by that overall pass (they're subsets of the same polygons).
function timeSet(set) {
const t = [];
for (let i = 0; i < 5; i++) { const s = performance.now(); run(set); t.push(performance.now() - s); }
t.sort((a, b) => a - b);
return {median: t[2], lo: t[0], hi: t[4]};
}

const tris = run(polys);
const sum = checksum(polys);
const overall = timeSet(polys);

console.log(`\nresult: ${(tris / 3).toLocaleString()} triangles (checksum ${sum})`);
console.log(`time: ${overall.median.toFixed(0)} ms (${overall.lo.toFixed(0)}–${overall.hi.toFixed(0)} ms over 5 runs)`);
console.log(`speed: ${Math.round(polys.length / (overall.median / 1000)).toLocaleString()} polygons/s, ` +
`${(totalVerts / (overall.median / 1000) / 1e6).toFixed(1)}M verts/s`);

// --- per-zoom breakdown ---
const byZoom = new Map();
for (const d of polys) {
if (!byZoom.has(d.z)) byZoom.set(d.z, []);
byZoom.get(d.z).push(d);
}
const zooms = [...byZoom.keys()].sort((a, b) => a - b);
console.log('\nper zoom:');
console.log(' z polygons verts med.v time %time polys/s');
for (const z of zooms) {
const set = byZoom.get(z);
const v = set.map(d => d.vertices.length / d.dimensions).sort((a, b) => a - b);
const verts = v.reduce((a, b) => a + b, 0);
const med = v[Math.floor(v.length / 2)];
const {median} = timeSet(set); // already warm from the overall pass
const pct = 100 * median / overall.median;
console.log(
` ${String(z).padStart(2)} ${set.length.toLocaleString().padStart(8)} ` +
`${verts.toLocaleString().padStart(9)} ${String(med).padStart(5)} ` +
`${median.toFixed(0).padStart(5)} ms ${pct.toFixed(1).padStart(5)}% ` +
`${Math.round(set.length / (median / 1000)).toLocaleString().padStart(8)}`);
}
Binary file added bench/tiles-fixture.bin
Binary file not shown.
119 changes: 119 additions & 0 deletions bench/tiles-fixture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {readFileSync} from 'fs';
import {flatten} from '../src/earcut.js';

// Reads tiles-fixture.bin: length-delimited packed-varint MVT geometry blobs,
// one per polygon feature. Decodes them with a small varint reader (no pbf
// dependency), reconstructs polygons by splitting multipolygons and classifying
// holes by signed area, per the MVT spec.
//
// tiles-fixture.bin format (little-endian LEB128 unsigned varints throughout):
//
// file := tile* (repeated until EOF)
// tile := zoom featureCount feature*
// feature := geomLen geom (geomLen = number of varints in geom)
// geom := uint32* (raw MVT command/parameter integers)
//
// The geom integers are the native MVT polygon encoding: MoveTo/LineTo/ClosePath
// command-integers interleaved with zigzag delta-encoded coordinate pairs.
export function readTilesFixture() {
const buf = readFileSync(new URL('./tiles-fixture.bin', import.meta.url));
const cursor = {pos: 0};
const polys = [];

while (cursor.pos < buf.length) {
const z = readVarint(buf, cursor);
const features = readVarint(buf, cursor);

for (let feature = 0; feature < features; feature++) {
const count = readVarint(buf, cursor);

const geom = new Array(count);
for (let i = 0; i < count; i++) geom[i] = readVarint(buf, cursor);

let current = null;
const push = (rings) => {
const data = flatten(rings);
data.z = z;
polys.push(data);
};

for (const ring of decodeMvtRings(geom)) {
if (ring.length < 3) continue;

const area = ringArea(ring);
if (area === 0) continue;

if (area > 0) {
if (current) push(current);
current = [ring];
} else if (current) {
current.push(ring);
}
}
if (current) push(current);
}
}

return polys;
}

function readVarint(buf, cursor) {
let val = 0;
let shift = 0;
let b;
do {
let pos = cursor.pos;
b = buf[pos++];
cursor.pos = pos;
val |= (b & 0x7f) << shift;
shift += 7;
} while (b & 0x80);
return val >>> 0;
}

function decodeMvtRings(geom) {
const rings = [];
let x = 0;
let y = 0;
let ring = null;
let i = 0;

while (i < geom.length) {
const cmd = geom[i] & 0x7;
const count = geom[i] >> 3;
i++;

if (cmd === 1) {
for (let k = 0; k < count; k++) {
x += zigZagDecode(geom[i++]);
y += zigZagDecode(geom[i++]);
if (ring) rings.push(ring);
ring = [[x, y]];
}
} else if (cmd === 2) {
for (let k = 0; k < count; k++) {
x += zigZagDecode(geom[i++]);
y += zigZagDecode(geom[i++]);
ring.push([x, y]);
}
} else if (cmd === 7 && ring) {
rings.push(ring);
ring = null;
}
}

if (ring) rings.push(ring);
return rings;
}

function zigZagDecode(n) {
return (n >> 1) ^ (-(n & 1));
}

function ringArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
sum += (ring[j][0] - ring[i][0]) * (ring[i][1] + ring[j][1]);
}
return sum / 2;
}
Loading
Loading