diff --git a/README.md b/README.md index 8cd3c3e..4e5bc6c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Earcut -The fastest and smallest JavaScript **polygon triangulation** library. 3.5KB gzipped. +The fastest and smallest JavaScript **polygon triangulation** library. 4KB gzipped. +[![Node](https://github.com/mapbox/earcut/actions/workflows/node.yml/badge.svg)](https://github.com/mapbox/earcut/actions/workflows/node.yml) +[![](https://img.shields.io/badge/simply-awesome-brightgreen.svg)](https://github.com/mourner/projects) + +![Earcut triangulation example](earcut.png) -It is designed to be fast enough for real-time triangulation in the browser, -sacrificing triangulation quality for raw speed and simplicity, -while being robust enough to handle most practical datasets without crashing or producing garbage. +Earcut is designed to be fast enough for real-time [triangulation](https://en.wikipedia.org/wiki/Polygon_triangulation) in the browser, +favoring raw speed and simplicity over triangulation quality, +while being robust enough to handle most practical datasets without crashing or producing garbage, +with an option to refine the result to [Delaunay](https://en.wikipedia.org/wiki/Delaunay_triangulation) quality at a small cost. Originally built for [Mapbox GL JS](https://www.mapbox.com/mapbox-gljs) (WebGL-based interactive maps), it's now also used by [Three.js](https://threejs.org/) and many other projects. -![Earcut triangulation example](earcut.png) - -[![Node](https://github.com/mapbox/earcut/actions/workflows/node.yml/badge.svg)](https://github.com/mapbox/earcut/actions/workflows/node.yml) -[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/mapbox/earcut.svg)](http://isitmaintained.com/project/mapbox/earcut "Average time to resolve an issue") -[![Percentage of issues still open](http://isitmaintained.com/badge/open/mapbox/earcut.svg)](http://isitmaintained.com/project/mapbox/earcut "Percentage of issues still open") -[![](https://img.shields.io/badge/simply-awesome-brightgreen.svg)](https://github.com/mourner/projects) +## [Demo](https://mapbox.github.io/earcut/viz/) ## Usage @@ -39,7 +39,8 @@ and [Triangulation by Ear Clipping](http://www.geometrictools.com/Documentation/ Earcut is heavily optimized for its primary workload — triangulating polygons from [Mapbox Vector Tiles](https://github.com/mapbox/vector-tile-spec). On a representative benchmark of **119,680 real-world polygons** (1.9M vertices) drawn from a window of map tiles -through zooms 4–16, it triangulates the whole set in **~480 ms** on a Macbook Pro M1 Pro (2021). +through zooms 4–16, it triangulates the whole set in **~445 ms** on a Macbook Pro M1 Pro (2021), +with optional Delaunay refinement taking additional **~184 ms**. You can run the benchmark yourself with `npm run bench`. ## Robustness @@ -62,7 +63,7 @@ conforming mesh, [remove T-junctions in a post-process](https://github.com/mapbo Install with NPM: `npm install earcut`, then import as a module: ```js -import earcut, {flatten, deviation} from 'earcut'; +import earcut, {flatten, deviation, refine} from 'earcut'; ``` Or use as a module directly in the browser with [jsDelivr](https://www.jsdelivr.com/esm): @@ -104,6 +105,23 @@ If you pass a single vertex as a hole, Earcut treats it as a Steiner point. Output triangles always have a consistent **winding order** regardless of the input polygon's winding — counter-clockwise in a y-up coordinate system (clockwise in y-down/screen space). If you need the opposite orientation (e.g. for back-face culling or normals in 3D), call `.reverse()` on the result. +### `refine(triangles, vertices[, dimensions = 2])` + +If triangle quality matters, you can run an optional refinement pass after triangulation: + +```js +const triangles = earcut(vertices, holes, dimensions); +refine(triangles, vertices, dimensions); +``` + +This mutates `triangles` in place, legalizing interior edges with Delaunay flips while preserving +the polygon boundary and holes. It keeps the same number of triangles and the same index format, +but usually removes many skinny triangles and reduces total triangle edge length. + +Refinement is a post-process, so it doesn't affect the speed of normal `earcut` calls unless you +explicitly call it. It assumes a valid manifold triangulation, such as the output of `earcut`, and +doesn't repair invalid polygon input or make the mesh conforming. + ### `flatten(data)` If your input is a multi-dimensional array (e.g. [GeoJSON Polygon](http://geojson.org/geojson-spec.html#polygon)), diff --git a/bench/bench-refine.js b/bench/bench-refine.js new file mode 100644 index 0000000..fdd8762 --- /dev/null +++ b/bench/bench-refine.js @@ -0,0 +1,75 @@ +// Benchmark the optional Delaunay refinement post-pass (refine() in src/earcut.js) +// over the same realistic MVT fixture as bench-tiles.js. Measures the added cost of +// refine() relative to earcut alone, and reports the resulting triangle quality. +import earcut, {refine} from '../src/earcut.js'; +import {readTilesFixture} from './tiles-fixture.js'; + +const polys = readTilesFixture(); +const totalVerts = polys.reduce((s, d) => s + d.vertices.length / d.dimensions, 0); + +// pre-triangulate once per poly so the timed paths only measure their own work; +// refine mutates the index array, so each timed pass re-triangulates a fresh copy. +function triOnly(d) { return earcut(d.vertices, d.holes, d.dimensions).length; } +function triRefine(d) { const t = earcut(d.vertices, d.holes, d.dimensions); refine(t, d.vertices, d.dimensions); return t.length; } + +function run(fn) { let s = 0; for (const d of polys) s += fn(d); return s; } + +// interleaved A/B (ABAB): cancels thermal drift instead of attributing it to one side. +function timeAB(a, b) { + let ba = Infinity, bb = Infinity; + for (let i = 0; i < 7; i++) { + let s = performance.now(); run(a); ba = Math.min(ba, performance.now() - s); + s = performance.now(); run(b); bb = Math.min(bb, performance.now() - s); + } + return {a: ba, b: bb}; +} + +// --- triangle quality (normalized q = 4√3·area / Σ edge², in [0,1]) --- +function quality(getIndices) { + let slivers = 0, tinyAngle = 0, perim = 0, sumQ = 0, tris = 0; + for (const d of polys) { + const c = d.vertices, dim = d.dimensions; + const idx = getIndices(d); + for (let i = 0; i < idx.length; i += 3) { + const ax = c[idx[i] * dim], ay = c[idx[i] * dim + 1]; + const bx = c[idx[i + 1] * dim], by = c[idx[i + 1] * dim + 1]; + const cx = c[idx[i + 2] * dim], cy = c[idx[i + 2] * dim + 1]; + const area = Math.abs((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)) / 2; + const e1 = (bx - ax) ** 2 + (by - ay) ** 2; + const e2 = (cx - bx) ** 2 + (cy - by) ** 2; + const e3 = (ax - cx) ** 2 + (ay - cy) ** 2; + const sumSq = e1 + e2 + e3; + const q = sumSq > 0 ? 4 * Math.sqrt(3) * area / sumSq : 0; + if (q < 0.1) slivers++; + // smallest angle via law of cosines on the shortest-opposite side + const la = Math.sqrt(e1), lb = Math.sqrt(e2), lc = Math.sqrt(e3); + const angA = Math.acos(Math.min(1, Math.max(-1, (lb * lb + lc * lc - la * la) / (2 * lb * lc || 1)))); + const angB = Math.acos(Math.min(1, Math.max(-1, (la * la + lc * lc - lb * lb) / (2 * la * lc || 1)))); + const minAng = Math.min(angA, angB, Math.PI - angA - angB) * 180 / Math.PI; + if (minAng < 1) tinyAngle++; + perim += la + lb + lc; + sumQ += q; tris++; + } + } + return {slivers, tinyAngle, perim, meanQ: sumQ / tris, tris}; +} + +// warm both paths +run(triOnly); run(triRefine); + +const {a: tEarcut, b: tRefine} = timeAB(triOnly, triRefine); +const overhead = (tRefine - tEarcut) / tEarcut * 100; + +console.log(`fixture: ${polys.length.toLocaleString()} polygons, ${totalVerts.toLocaleString()} vertices`); +console.log(`\nearcut: ${tEarcut.toFixed(1)} ms`); +console.log(`earcut+refine: ${tRefine.toFixed(1)} ms (+${overhead.toFixed(0)}%, ${(tRefine - tEarcut).toFixed(1)} ms refine)`); + +const qa = quality(d => earcut(d.vertices, d.holes, d.dimensions)); +const qb = quality((d) => { const t = earcut(d.vertices, d.holes, d.dimensions); refine(t, d.vertices, d.dimensions); return t; }); +const pct = (x, y) => `${((y - x) / x * 100).toFixed(0)}%`; +console.log('\nquality: earcut refined delta'); +console.log(` slivers (q<0.1) ${String(qa.slivers).padStart(8)} ${String(qb.slivers).padStart(8)} ${pct(qa.slivers, qb.slivers)}`); +console.log(` min-angle < 1deg ${String(qa.tinyAngle).padStart(8)} ${String(qb.tinyAngle).padStart(8)} ${pct(qa.tinyAngle, qb.tinyAngle)}`); +console.log(` total perimeter ${qa.perim.toExponential(2).padStart(8)} ${qb.perim.toExponential(2).padStart(8)} ${pct(qa.perim, qb.perim)}`); +console.log(` mean q ${qa.meanQ.toFixed(3).padStart(8)} ${qb.meanQ.toFixed(3).padStart(8)} +${pct(qa.meanQ, qb.meanQ)}`); +console.log(` triangles ${qa.tris.toLocaleString()} (invariant: ${qa.tris === qb.tris})`); diff --git a/earcut.png b/earcut.png index cbf10cb..b993289 100644 Binary files a/earcut.png and b/earcut.png differ diff --git a/package-lock.json b/package-lock.json index e66c384..71443b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "earcut", - "version": "3.1.1", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "earcut", - "version": "3.1.1", + "version": "3.2.0", "license": "ISC", "devDependencies": { "@rollup/plugin-terser": "^1.0.0", diff --git a/package.json b/package.json index 3e01a6a..d57e894 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "earcut", - "version": "3.1.1", + "version": "3.2.0", "description": "The fastest and smallest JavaScript polygon triangulation library for your WebGL apps", "main": "src/earcut.js", "type": "module", + "sideEffects": false, "exports": "./src/earcut.js", "types": "src/earcut.d.ts", "files": [ diff --git a/src/earcut.js b/src/earcut.js index 8c76e84..d050444 100644 --- a/src/earcut.js +++ b/src/earcut.js @@ -873,3 +873,124 @@ export function flatten(data) { } return {vertices, holes, dimensions}; } + +// Reusable module-level scratch for refine(): +// he = twin half-edge of each edge, or -1 on the polygon boundary +// hTable = open-addressing hash, slot -> half-edge index, valid iff hStamp[slot] === gen +/** @type {Int32Array} */ let edgeStack; +/** @type {Int32Array} */ let he; +/** @type {Int32Array} */ let hTable; +/** @type {Uint32Array} */ let hStamp; +let hMask = 0, gen = 0; + +/** + * Refine a triangulation toward the constrained Delaunay triangulation by legalizing every + * interior edge in place with Lawson flips — maximizing the minimum angle and removing most + * slivers. An optional post-pass for {@link earcut} output, or any manifold triangle-index array + * indexing into `coords`. Adapted from delaunator's edge legalization. + * + * Uses non-robust predicates: float input is fine, and the worst case is a not-quite-Delaunay + * edge, never an invalid mesh. + * + * @param {number[]} triangles triangle indices, as returned by {@link earcut}; mutated in place + * @param {ArrayLike} coords the flat vertex coordinates passed to {@link earcut} + * @param {number} [dim=2] number of coordinates per vertex in `coords` + * @example refine(earcut(data), data); + */ +export function refine(triangles, coords, dim = 2) { + const t = triangles; + const n = t.length; + const V = coords.length / dim; + ensureScratch(n); + gen++; // bumping the generation logically empties the hash (no clearing) + he.fill(-1, 0, n); + + // Build half-edge twins with an undirected-edge hash; consumed slots mark linked pairs. + for (let e = 0; e < n; e++) { + const a = t[e], b = t[nextHE(e)]; + const lo = a < b ? a : b, hi = a < b ? b : a; + let h = ((hi * V + lo) * 0x9e3779b1 >>> 0) & hMask; + while (hStamp[h] === gen) { + const s = hTable[h]; + // s === -1 marks a consumed slot (a pair already linked) — skip past it + if (s !== -1) { + const sa = t[s], sb = t[nextHE(s)]; + if ((sa === lo && sb === hi) || (sa === hi && sb === lo)) { + he[e] = s; he[s] = e; hTable[h] = -1; // link, then consume the slot + break; + } + } + h = (h + 1) & hMask; + } + if (hStamp[h] !== gen) { hTable[h] = e; hStamp[h] = gen; } // first occurrence: insert + } + + for (let e0 = 0; e0 < n; e0++) { + if (he[e0] === -1) continue; + let i = 0, a = e0; + while (true) { + const b = he[a]; + const a0 = a - a % 3; + const ar = a0 + (a + 2) % 3; + if (b === -1) { if (i === 0) break; a = edgeStack[--i]; continue; } + + const b0 = b - b % 3; + const al = a0 + (a + 1) % 3; + const bl = b0 + (b + 2) % 3; + const p0 = t[ar], pr = t[a], pl = t[al], p1 = t[bl]; + + const x0 = coords[p0 * dim], y0 = coords[p0 * dim + 1]; + const xr = coords[pr * dim], yr = coords[pr * dim + 1]; + const xl = coords[pl * dim], yl = coords[pl * dim + 1]; + const x1 = coords[p1 * dim], y1 = coords[p1 * dim + 1]; + + // Both triangles of the flipped diagonal p0-p1 must be CCW (i.e. the quad is convex). + // Flipping a reflex quad would push a triangle outside the polygon; this guards against + // it. Boundary/hole edges need no guard — they self-protect via he === -1. + const convex = orient(x0, y0, xr, yr, x1, y1) > 0 && orient(x0, y0, x1, y1, xl, yl) > 0; + + if (convex && !inCircle(x0, y0, xr, yr, xl, yl, x1, y1)) { + t[a] = p1; t[b] = p0; + const hbl = he[bl], har = he[ar]; + he[a] = hbl; if (hbl !== -1) he[hbl] = a; + he[b] = har; if (har !== -1) he[har] = b; + he[ar] = bl; he[bl] = ar; + if (i < edgeStack.length) edgeStack[i++] = b0 + (b + 1) % 3; + } else { + if (i === 0) break; + a = edgeStack[--i]; + } + } + } +} + +/** @param {number} ax @param {number} ay @param {number} bx @param {number} by @param {number} cx @param {number} cy */ +function orient(ax, ay, bx, by, cx, cy) { + return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); +} + +// Whether p is inside the circumcircle of triangle (a, b, c). Sign is negated vs the usual +// predicate to match earcut's CCW winding — the standard sign would build the anti-Delaunay mesh. +/** @param {number} ax @param {number} ay @param {number} bx @param {number} by @param {number} cx @param {number} cy @param {number} px @param {number} py */ +function inCircle(ax, ay, bx, by, cx, cy, px, py) { + const dx = ax - px, dy = ay - py, ex = bx - px, ey = by - py, fx = cx - px, fy = cy - py; + const ap = dx * dx + dy * dy, bp = ex * ex + ey * ey, cp = fx * fx + fy * fy; + return dx * (ey * cp - bp * fy) - dy * (ex * cp - bp * fx) + ap * (ex * fy - ey * fx) < 0; +} + +/** @param {number} e */ +function nextHE(e) { // next half-edge within the same triangle + return e - e % 3 + (e + 1) % 3; +} + +// Grow the scratch arrays on demand (like earcut's z-order arrays). Allocating lazily here rather +// than at module load lets the whole refine() block tree-shake away for callers who don't use it. +/** @param {number} n */ +function ensureScratch(n) { + if (!edgeStack) edgeStack = new Int32Array(512); + if (!he || he.length < n) he = new Int32Array(n); + let size = 1; + while (size < n * 4) size <<= 1; // power-of-two table, load factor <= 0.25 + if (!hTable || hTable.length < size) { hTable = new Int32Array(size); hStamp = new Uint32Array(size); } + hMask = size - 1; +} diff --git a/test/expected.json b/test/expected.json index 2588514..fc540a3 100644 --- a/test/expected.json +++ b/test/expected.json @@ -1,5 +1,6 @@ { "triangles": { + "earcut": 548, "building": 13, "dude": 106, "water": 2482, diff --git a/test/fixtures/earcut.json b/test/fixtures/earcut.json new file mode 100644 index 0000000..58d1a92 --- /dev/null +++ b/test/fixtures/earcut.json @@ -0,0 +1 @@ +[[[1400,340],[1633,345],[1867,350],[2100,356],[2333,361],[2567,366],[2800,371],[3033,375],[3267,380],[3500,384],[3733,389],[3967,393],[4200,397],[4433,400],[4667,403],[4900,407],[5133,409],[5367,412],[5600,414],[5833,416],[6067,417],[6300,418],[6533,419],[6767,420],[7000,420],[7233,420],[7467,419],[7700,418],[7933,417],[8167,416],[8400,414],[8633,412],[8867,409],[9100,407],[9333,403],[9567,400],[9800,397],[10033,393],[10267,389],[10500,384],[10733,380],[10967,375],[11200,371],[11433,366],[11667,361],[11900,356],[12133,350],[12367,345],[12600,340],[12737,349],[12873,378],[13006,424],[13136,489],[13260,571],[13378,670],[13488,785],[13590,914],[13682,1057],[13764,1211],[13835,1376],[13893,1550],[13940,1731],[13973,1918],[13993,2108],[14000,2300],[13993,2492],[13973,2682],[13940,2869],[13893,3050],[13835,3224],[13764,3389],[13682,3543],[13590,3686],[13488,3815],[13378,3930],[13260,4029],[13136,4111],[13006,4176],[12873,4222],[12737,4251],[12600,4260],[12367,4255],[12133,4250],[11900,4244],[11667,4239],[11433,4234],[11200,4229],[10967,4225],[10733,4220],[10500,4216],[10267,4211],[10033,4207],[9800,4203],[9567,4200],[9333,4197],[9100,4193],[8867,4191],[8633,4188],[8400,4186],[8167,4184],[7933,4183],[7700,4182],[7467,4181],[7233,4180],[7000,4180],[6767,4180],[6533,4181],[6300,4182],[6067,4183],[5833,4184],[5600,4186],[5367,4188],[5133,4191],[4900,4193],[4667,4197],[4433,4200],[4200,4203],[3967,4207],[3733,4211],[3500,4216],[3267,4220],[3033,4225],[2800,4229],[2567,4234],[2333,4239],[2100,4244],[1867,4250],[1633,4255],[1400,4260],[1263,4251],[1127,4222],[994,4176],[864,4111],[740,4029],[622,3930],[512,3815],[410,3686],[318,3543],[236,3389],[165,3224],[107,3050],[60,2869],[27,2682],[7,2492],[0,2300],[7,2108],[27,1918],[60,1731],[107,1550],[165,1376],[236,1211],[318,1057],[410,914],[512,785],[622,670],[740,571],[864,489],[994,424],[1127,378],[1263,349]],[[1656,1327],[1508,1336],[1368,1364],[1238,1411],[1116,1476],[1003,1561],[899,1664],[808,1780],[733,1905],[675,2038],[633,2180],[608,2329],[600,2487],[608,2642],[676,2926],[736,3056],[812,3177],[905,3290],[1011,3390],[1124,3472],[1245,3536],[1375,3581],[1512,3609],[1656,3618],[1894,3587],[2046,3531],[2190,3449],[2326,3342],[2447,3218],[2543,3088],[2546,3034],[2511,2980],[2472,2976],[2374,3084],[2253,3176],[2113,3250],[1971,3295],[1830,3310],[1697,3302],[1576,3276],[1468,3233],[1373,3173],[1291,3096],[1222,3003],[1169,2895],[1131,2772],[1109,2634],[1101,2482],[1116,2204],[2475,2204],[2532,2193],[2564,2135],[2554,2015],[2522,1899],[2475,1799],[2097,1799],[2113,1917],[2102,1967],[2071,2003],[1980,2034],[1136,2066],[1190,1873],[1228,1789],[1325,1645],[1446,1543],[1582,1492],[1734,1490],[1871,1529],[1985,1608],[2031,1660],[2082,1752],[2445,1752],[2302,1578],[2193,1488],[2073,1417],[1944,1367],[1805,1337]],[[3130,1701],[3061,1781],[3019,1859],[3005,1937],[3013,1995],[3037,2043],[3125,2103],[3184,2110],[3282,2096],[3398,2021],[3467,1889],[3486,1732],[3520,1658],[3628,1570],[3723,1536],[3890,1529],[3988,1563],[4030,1592],[4068,1630],[4124,1726],[4156,1927],[4141,2313],[3742,2434],[3424,2568],[3185,2715],[3025,2874],[2976,2958],[2946,3046],[2941,3216],[2987,3359],[3077,3480],[3137,3530],[3276,3596],[3442,3618],[3607,3607],[3759,3574],[3897,3520],[4023,3443],[4136,3345],[4176,3431],[4276,3530],[4412,3587],[4518,3598],[4720,3560],[4844,3494],[4940,3416],[4988,3348],[4994,3318],[4983,3273],[4947,3237],[4765,3336],[4670,3330],[4623,3298],[4589,3246],[4569,3173],[4565,2964],[4121,2964],[4116,3147],[4067,3219],[3944,3309],[3789,3364],[3685,3375],[3586,3361],[3510,3318],[3455,3248],[3423,3149],[3412,3023],[3427,2919],[3471,2825],[3545,2739],[3648,2661],[3781,2593],[4136,2482],[4124,2893],[4568,2893],[4597,1942],[4590,1809],[4537,1630],[4431,1485],[4282,1384],[4195,1352],[3997,1327],[3876,1335],[3751,1360],[3489,1461],[3227,1619]],[[5104,3541],[5085,3518],[5078,3474],[5085,3433],[5104,3411],[5266,3378],[5364,3333],[5394,3305],[5433,3219],[5453,3078],[5455,2070],[5437,1881],[5423,1840],[5379,1787],[5298,1751],[5152,1718],[5138,1664],[5152,1614],[5360,1553],[5583,1467],[5693,1410],[5840,1308],[5896,1313],[5919,1332],[5902,1659],[6183,1435],[6356,1353],[6526,1327],[6613,1338],[6688,1374],[6775,1464],[6804,1575],[6782,1683],[6743,1743],[6691,1788],[6596,1813],[6509,1801],[6316,1704],[6234,1684],[6169,1691],[6037,1751],[5902,1872],[5902,2988],[5928,3200],[5976,3289],[6012,3321],[6126,3373],[6319,3420],[6333,3474],[6319,3532],[6249,3543],[5627,3514]],[[7519,3473],[7294,3292],[7197,3178],[7118,3055],[7057,2923],[7013,2780],[6987,2629],[6978,2467],[6987,2307],[7015,2157],[7061,2016],[7125,1885],[7208,1763],[7309,1650],[7423,1551],[7543,1470],[7671,1407],[7805,1362],[8094,1327],[8345,1345],[8563,1400],[8744,1490],[8855,1597],[8883,1656],[8892,1718],[8884,1792],[8860,1848],[8819,1889],[8762,1914],[8624,1916],[8546,1884],[8479,1809],[8350,1584],[8291,1531],[8223,1502],[8094,1485],[7979,1495],[7876,1525],[7785,1575],[7705,1645],[7636,1734],[7579,1843],[7535,1971],[7485,2280],[7487,2616],[7510,2757],[7550,2886],[7606,3002],[7677,3106],[7762,3194],[7857,3262],[7963,3311],[8080,3340],[8208,3350],[8352,3341],[8484,3312],[8604,3265],[8711,3199],[8818,3109],[8853,3128],[8879,3174],[8882,3201],[8859,3252],[8737,3372],[8553,3497],[8420,3556],[8205,3608],[8054,3618],[7910,3609],[7772,3582],[7642,3536]],[[11618,3425],[11153,3523],[10906,3638],[10874,3631],[10856,3598],[10876,3286],[10593,3498],[10460,3565],[10333,3604],[10211,3618],[10081,3610],[9858,3549],[9764,3495],[9682,3426],[9613,3343],[9560,3250],[9522,3145],[9500,3030],[9492,1976],[9472,1765],[9435,1684],[9372,1638],[9272,1615],[9124,1612],[9106,1591],[9100,1550],[9115,1483],[9140,1470],[9549,1430],[9894,1359],[9931,1381],[9938,2785],[9943,2891],[9977,3066],[10045,3193],[10150,3274],[10293,3315],[10437,3318],[10543,3301],[10638,3266],[10777,3170],[10876,3072],[10876,1976],[10855,1767],[10817,1685],[10752,1638],[10650,1615],[10503,1612],[10481,1573],[10485,1499],[10519,1470],[10777,1453],[11272,1359],[11301,1369],[11317,1421],[11325,3122],[11348,3204],[11390,3254],[11470,3290],[11618,3326],[11632,3380]],[[12846,3594],[12647,3591],[12457,3536],[12304,3426],[12196,3270],[12162,3179],[12135,2971],[12143,1718],[12107,1676],[11878,1671],[11845,1632],[11850,1573],[11871,1546],[12000,1462],[12210,1290],[12400,1081],[12499,948],[12557,922],[12615,937],[12626,962],[12606,1436],[13336,1436],[13377,1457],[13397,1521],[13390,1655],[13377,1682],[13336,1703],[12597,1684],[12597,2874],[12610,3042],[12650,3172],[12716,3265],[12809,3321],[12929,3340],[13088,3325],[13303,3243],[13333,3260],[13355,3313],[13326,3382],[13171,3493],[13015,3560]]] \ No newline at end of file diff --git a/test/test.js b/test/test.js index 46cdfa6..ec189f1 100644 --- a/test/test.js +++ b/test/test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import earcut, {flatten, deviation} from '../src/earcut.js'; +import earcut, {flatten, deviation, refine} from '../src/earcut.js'; import fs from 'fs'; import expected from './expected.json' with {type: 'json'}; import {readTilesFixture} from '../bench/tiles-fixture.js'; @@ -24,6 +24,23 @@ test('empty', () => { // so we can tell when the errors-with-rotation bound can be tightened const maxRotated = new Map(); +function trianglePerimeter(triangles, vertices, dim = 2) { + let perimeter = 0; + for (let i = 0; i < triangles.length; i += 3) { + const ax = vertices[triangles[i] * dim], + ay = vertices[triangles[i] * dim + 1], + bx = vertices[triangles[i + 1] * dim], + by = vertices[triangles[i + 1] * dim + 1], + cx = vertices[triangles[i + 2] * dim], + cy = vertices[triangles[i + 2] * dim + 1]; + + perimeter += Math.hypot(ax - bx, ay - by) + + Math.hypot(bx - cx, by - cy) + + Math.hypot(cx - ax, cy - ay); + } + return perimeter; +} + for (const id of Object.keys(expected.triangles)) { for (const rotation of [0, 90, 180, 270]) { @@ -79,7 +96,42 @@ test('infinite-loop', () => { earcut([1, 2, 2, 2, 1, 2, 1, 1, 1, 2, 4, 1, 5, 1, 3, 2, 4, 2, 4, 1], [5], 2); }); -test('mvt fixture has zero deviation', () => { +test('refine improves a bad quad diagonal', () => { + const vertices = [0, 0, 3, 0, 10, 1, 0, 2]; + const triangles = [2, 3, 0, 2, 0, 1]; + const beforePerimeter = trianglePerimeter(triangles, vertices); + refine(triangles, vertices); + const afterPerimeter = trianglePerimeter(triangles, vertices); + + assert.deepEqual(triangles, [2, 3, 1, 3, 0, 1]); + assert.ok(afterPerimeter < beforePerimeter * 0.7); + assert.equal(deviation(vertices, null, 2, triangles), 0); +}); + +test('refine leaves a good quad diagonal alone', () => { + const vertices = [0, 0, 5, 0, 4, 1, 0, 4]; + const triangles = [2, 3, 0, 2, 0, 1]; + + refine(triangles, vertices); + + assert.deepEqual(triangles, [2, 3, 0, 2, 0, 1]); + assert.equal(deviation(vertices, null, 2, triangles), 0); +}); + +test('refine preserves a concave polygon', () => { + const vertices = [0, 0, 4, 0, 4, 1, 1, 1, 1, 4, 0, 4]; + const triangles = earcut(vertices); + const length = triangles.length; + const beforePerimeter = trianglePerimeter(triangles, vertices); + refine(triangles, vertices); + const afterPerimeter = trianglePerimeter(triangles, vertices); + + assert.equal(triangles.length, length); + assert.ok(afterPerimeter < beforePerimeter * 0.9); + assert.equal(deviation(vertices, null, 2, triangles), 0); +}); + +test('mvt fixture has zero deviation and refined quality', () => { const polys = readTilesFixture(); let nonzero = 0; let firstIndex = -1; @@ -87,10 +139,21 @@ test('mvt fixture has zero deviation', () => { let worstIndex = -1; let worstDev = 0; let sumDev = 0; + let refinedNonzero = 0; + let refinedFirstIndex = -1; + let refinedFirstDev = 0; + let refinedWorstIndex = -1; + let refinedWorstDev = 0; + let refinedSumDev = 0; + let lengthChanged = 0; + let basePerimeter = 0; + let refinedPerimeter = 0; for (let i = 0; i < polys.length; i++) { const data = polys[i]; const triangles = earcut(data.vertices, data.holes, data.dimensions); + const length = triangles.length; + basePerimeter += trianglePerimeter(triangles, data.vertices, data.dimensions); const dev = deviation(data.vertices, data.holes, data.dimensions, triangles); if (dev !== 0) { if (firstIndex < 0) { @@ -104,12 +167,37 @@ test('mvt fixture has zero deviation', () => { worstDev = dev; } } + + refine(triangles, data.vertices, data.dimensions); + refinedPerimeter += trianglePerimeter(triangles, data.vertices, data.dimensions); + if (triangles.length !== length) lengthChanged++; + + const refinedDev = deviation(data.vertices, data.holes, data.dimensions, triangles); + if (refinedDev !== 0) { + if (refinedFirstIndex < 0) { + refinedFirstIndex = i; + refinedFirstDev = refinedDev; + } + refinedNonzero++; + refinedSumDev += refinedDev; + if (refinedDev > refinedWorstDev) { + refinedWorstIndex = i; + refinedWorstDev = refinedDev; + } + } } assert.equal(polys.length, 119680); assert.equal(nonzero, 0, `${nonzero} polygons with nonzero deviation; first ${firstIndex}: ${firstDev}, ` + `worst ${worstIndex}: ${worstDev}, sum ${sumDev}`); + + assert.equal(lengthChanged, 0, `${lengthChanged} refined triangulations changed triangle count`); + assert.equal(refinedNonzero, 0, + `${refinedNonzero} refined polygons with nonzero deviation; first ${refinedFirstIndex}: ${refinedFirstDev}, ` + + `worst ${refinedWorstIndex}: ${refinedWorstDev}, sum ${refinedSumDev}`); + + assert.ok(refinedPerimeter < basePerimeter * 0.76, `refined perimeter ratio ${refinedPerimeter / basePerimeter} < 0.76`); }); // Regression for the hole-bridge block index (issue #183): a collinear-rich outer ring diff --git a/viz/index.html b/viz/index.html index e569e64..195285f 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1,14 +1,55 @@ - debug page + earcut demo - +
+ +
+
diff --git a/viz/viz.js b/viz/viz.js index a0218ee..0dfd940 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -1,133 +1,255 @@ -/*eslint @stylistic/comma-spacing: 0, no-unused-vars: 0 */ - -import earcut, {flatten, deviation} from '../src/earcut.js'; - -(async function () { - const params = new URLSearchParams(window.location.search.substring(1)); - const fixture = params.get('fixture') || 'water'; - const testPoints = await (await fetch(`../test/fixtures/${fixture}.json`)).json(); - const rotation = params.get('rotation') || 0; - const theta = rotation * Math.PI / 180; - const round = rotation % 90 === 0 ? Math.round : x => x; - const xx = round(Math.cos(theta)); - const xy = round(-Math.sin(theta)); - const yx = round(Math.sin(theta)); - const yy = round(Math.cos(theta)); - for (const ring of testPoints) { - for (const coord of ring) { - const [x, y] = coord; - coord[0] = xx * x + xy * y; - coord[1] = yx * x + yy * y; - } - } - - const canvas = document.getElementById('canvas'); - const ctx = canvas.getContext('2d'); - - let minX = Infinity, - maxX = -Infinity, - minY = Infinity, - maxY = -Infinity; - - for (let i = 0; i < testPoints[0].length; i++) { - minX = Math.min(minX, testPoints[0][i][0]); - maxX = Math.max(maxX, testPoints[0][i][0]); - minY = Math.min(minY, testPoints[0][i][1]); - maxY = Math.max(maxY, testPoints[0][i][1]); +import earcut, {flatten, deviation, refine} from '../src/earcut.js'; + +const params = new URLSearchParams(window.location.search.substring(1)); + +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); +const area = document.getElementById('canvas-area'); +const fixturesEl = document.getElementById('fixtures'); +const refineEl = document.getElementById('refine'); +const rotationEl = document.getElementById('rotation'); +const statsEl = document.getElementById('stats'); + +const defaultFixture = 'earcut'; +const rotations = [0, 90, 180, 270]; +let current = params.get('fixture') || defaultFixture; +let rotation = +params.get('rotation') || 0; +let state = null; // computed data for the current fixture, reused across redraws/toggles +let loadToken = 0; +let frame = 0; + +if (!rotations.includes(rotation)) rotation = 0; + +// build the fixture list from the names in expected.json +const expected = await (await fetch('../test/expected.json')).json(); +const fixtureNames = Object.keys(expected.triangles); +if (!fixtureNames.includes(current)) current = defaultFixture; + +for (const name of fixtureNames) { + const option = document.createElement('option'); + option.textContent = name; + option.value = name; + fixturesEl.appendChild(option); +} +fixturesEl.oninput = () => { + current = fixturesEl.value; + select(); +}; + +refineEl.checked = params.get('refine') === '1'; +refineEl.onchange = () => { + updateURL(); + update(); +}; +for (const value of rotations) { + const option = document.createElement('option'); + option.textContent = `${value}°`; + option.value = value; + rotationEl.appendChild(option); +} +rotationEl.value = rotation; +rotationEl.onchange = () => { + rotation = +rotationEl.value; + select(); +}; +addEventListener('resize', scheduleDraw); + +select(); + +function select() { + fixturesEl.value = current; + updateURL(); + state = null; + drawLoadingStats(); + const token = ++loadToken; + const name = current; + requestAnimationFrame(() => load(name, token)); +} + +function updateURL() { + const url = new URL(location); + if (current === defaultFixture) url.searchParams.delete('fixture'); + else url.searchParams.set('fixture', current); + if (rotation) url.searchParams.set('rotation', rotation); + else url.searchParams.delete('rotation'); + if (refineEl.checked) url.searchParams.set('refine', '1'); + else url.searchParams.delete('refine'); + history.replaceState(null, '', url); +} + +// load + triangulate the current fixture from scratch (base triangulation only) +async function load(name, token) { + const rings = rotate(await (await fetch(`../test/fixtures/${name}.json`)).json(), rotation); + if (token !== loadToken) return; + + const data = flatten(rings); + + const base = earcut(data.vertices, data.holes, data.dimensions); + const baseTime = bench(() => earcut(data.vertices, data.holes, data.dimensions)); + + state = { + rings, data, base, baseTime, + refined: null, refineTime: 0, + deviation: deviation(data.vertices, data.holes, data.dimensions, base), + bounds: ringBounds(rings[0]), + paths: null // {key, outline, baseMesh, refinedMesh} — cached Path2Ds, rebuilt on resize + }; + if (token === loadToken) update(); +} + +// refine on demand and cache it, so toggling back and forth is instant +function update() { + if (!state) { + drawLoadingStats(); + return; } - - const width = maxX - minX; - const height = maxY - minY; - - canvas.width = window.innerWidth; - canvas.height = canvas.width * height / width + 10; - - const ratio = (canvas.width - 10) / width; - - if (devicePixelRatio > 1) { - canvas.style.width = `${canvas.width}px`; - canvas.style.height = `${canvas.height}px`; - canvas.width *= 2; - canvas.height *= 2; - ctx.scale(2, 2); + if (refineEl.checked && !state.refined) { + const {vertices, dimensions} = state.data; + state.refined = state.base.slice(); + refine(state.refined, vertices, dimensions); + // re-refine fresh copies of the base triangulation to time it in isolation + state.refineTime = bench(() => refine(state.base.slice(), vertices, dimensions)); } - - const data = flatten(testPoints); - - console.time('earcut'); - const result = earcut(data.vertices, data.holes, data.dimensions); - console.timeEnd('earcut'); - - console.log(`deviation: ${deviation(data.vertices, data.holes, data.dimensions, result)}`); - - const triangles = []; - for (const index of result) { - triangles.push([data.vertices[index * data.dimensions], data.vertices[index * data.dimensions + 1]]); + drawStats(); + scheduleDraw(); +} + +function scheduleDraw() { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + draw(); + }); +} + +function rotate(rings, deg) { + if (!deg) return rings; + const theta = deg * Math.PI / 180; + const round = deg % 90 === 0 ? Math.round : x => x; + const xx = round(Math.cos(theta)), xy = round(-Math.sin(theta)); + const yx = round(Math.sin(theta)), yy = round(Math.cos(theta)); + return rings.map(ring => ring.map(([x, y]) => [xx * x + xy * y, yx * x + yy * y])); +} + +function ringBounds(ring) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const [x, y] of ring) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; } - - ctx.lineJoin = 'round'; - - for (let i = 0; i < triangles.length; i += 3) { - drawPoly(triangles.slice(i, i + 3), 'rgba(255,0,0,0.2)', 'rgba(255,255,0,0.2)'); - // drawPoly(triangles.slice(i, i + 3), 'rgba(255,0,0,0.0)', 'rgba(255,0,0,0.3)'); + return {minX, minY, w: maxX - minX, h: maxY - minY}; +} + +function draw() { + if (!state) return; + const pad = 15; + const areaW = area.clientWidth, areaH = area.clientHeight; + const {minX, minY, w, h} = state.bounds; + const scale = Math.max(0, Math.min((areaW - 2 * pad) / w, (areaH - 2 * pad) / h)); + const W = Math.ceil(w * scale + 2 * pad); + const H = Math.ceil(h * scale + 2 * pad); + const ox = pad, oy = pad; + + // (re)build the Path2Ds only when the projection changes; toggling refine then just rasterizes + const key = `${W}x${H}`; + if (!state.paths || state.paths.key !== key) state.paths = {key}; + const paths = state.paths; + const which = refineEl.checked ? 'refined' : 'base'; + const meshKey = `${which}Mesh`; + const px = x => (x - minX) * scale + ox; + const py = y => (y - minY) * scale + oy; + + if (!paths[meshKey]) { + paths[meshKey] = meshPath(refineEl.checked ? state.refined : state.base, state.data, px, py); } - - drawPoly(testPoints, 'black', true); - - function drawPoint(p, color) { - const x = (p[0] - minX) * ratio + 5, - y = (p[1] - minY) * ratio + 5; - ctx.fillStyle = color || 'grey'; - ctx.fillRect(x - 3, y - 3, 6, 6); - } - - function drawPoly(rings, color, fill) { - ctx.beginPath(); - - ctx.strokeStyle = color; - if (fill && fill !== true) ctx.fillStyle = fill; - - if (typeof rings[0][0] === 'number') rings = [rings]; - - for (const points of rings) { - for (let i = 0; i < points.length; i++) { - const x = (points[i][0] - minX) * ratio + 5, - y = (points[i][1] - minY) * ratio + 5; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - if (fill) ctx.closePath(); + if (!paths.outline) { + const p = new Path2D(); + for (const ring of state.rings) { + ring.forEach(([x, y], i) => i ? p.lineTo(px(x), py(y)) : p.moveTo(px(x), py(y))); + p.closePath(); } - ctx.stroke(); - - if (fill && fill !== true) ctx.fill('evenodd'); + paths.outline = p; } - function clear() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - } - - function drawNode(node, color) { - drawPoint([node.x, node.y], color); - } - - function drawNodeRing(node, color, fill) { - const start = node; - const points = []; - do { - points.push([node.x, node.y]); - node = node.next; - } while (node !== start); - - console.log(JSON.stringify(points)); - drawPoly(points, color, fill); + const dpr = devicePixelRatio || 1; + const cw = Math.round(W * dpr); + const ch = Math.round(H * dpr); + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw; + canvas.height = ch; + canvas.style.width = `${W}px`; + canvas.style.height = `${H}px`; } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + ctx.lineJoin = 'round'; - function drawNodePolygon(node) { - drawNodeRing(node, 'black', 'rgba(255,255,0,0.2)'); + ctx.fillStyle = '#fffbd6'; + ctx.fill(paths.outline, 'evenodd'); + + ctx.lineWidth = 0.5; + ctx.strokeStyle = '#f24a4a'; + ctx.stroke(paths[meshKey]); + + ctx.lineWidth = 1; + ctx.strokeStyle = 'black'; + ctx.stroke(paths.outline); +} + +function meshPath(result, data, px, py) { + const p = new Path2D(); + const dim = data.dimensions, v = data.vertices; + + for (let i = 0; i < result.length; i += 3) { + const ai = result[i] * dim; + const bi = result[i + 1] * dim; + const ci = result[i + 2] * dim; + p.moveTo(px(v[ai]), py(v[ai + 1])); + p.lineTo(px(v[bi]), py(v[bi + 1])); + p.lineTo(px(v[ci]), py(v[ci + 1])); + p.lineTo(px(v[ai]), py(v[ai + 1])); } - - function drawNodeEdge(node, color) { - drawPoly([[node.x, node.y], [node.next.x, node.next.y]], color); + return p; +} + +// performance.now() is clamped to ~100µs in browsers, so a single run of a fast fixture +// reads 0; repeat until we've accumulated enough wall time and return the mean per run +function bench(fn) { + fn(); // warm up + let runs = 0; + const start = performance.now(); + while (performance.now() - start < 20 && runs < 100000) { + fn(); + runs++; } -})(); + return (performance.now() - start) / runs; +} + +function ms(t) { + return `${Math.round(1e3 * t) / 1e3} ms`; +} + +function statsRow(label, value) { + return `
${label}${value}
`; +} + +function drawLoadingStats() { + statsEl.innerHTML = + statsRow('vertices', '...') + + statsRow('triangles', '...') + + statsRow('earcut', '...') + + statsRow('refine', refineEl.checked ? '...' : '–') + + statsRow('deviation', '...'); +} + +function drawStats() { + const result = refineEl.checked ? state.refined : state.base; + statsEl.innerHTML = + statsRow('vertices', (state.data.vertices.length / state.data.dimensions).toLocaleString()) + + statsRow('triangles', (result.length / 3).toLocaleString()) + + statsRow('earcut', ms(state.baseTime)) + + statsRow('refine', refineEl.checked ? ms(state.refineTime) : '–') + + statsRow('deviation', state.deviation ? state.deviation.toExponential(2) : '0'); +}