From 09bbf4817eef7807f4ef04dc1a9ff6812391484a Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 13:09:24 +0300 Subject: [PATCH 01/10] implement optional Delaunay refinement --- bench/bench-refine.js | 75 ++++++++++++++++++++++++++ src/earcut.js | 119 ++++++++++++++++++++++++++++++++++++++++++ viz/viz.js | 6 ++- 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 bench/bench-refine.js 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/src/earcut.js b/src/earcut.js index 8c76e84..9089fca 100644 --- a/src/earcut.js +++ b/src/earcut.js @@ -873,3 +873,122 @@ export function flatten(data) { } return {vertices, holes, dimensions}; } + +// Optional Delaunay refinement: legalize every interior edge of a triangulation in place via +// Lawson flips, maximizing the min angle and removing most slivers. Works on any manifold mesh, +// not just earcut output. The scratch arrays are allocated lazily so this whole block tree-shakes +// away for callers who don't import `refine`. Adapted from delaunator's _legalize, with two +// polygon-specific tweaks: inCircle is negated to match earcut's CCW winding (delaunator's sign +// builds the anti-Delaunay mesh), and a `> 0` convexity guard prevents flipping across a reflex +// quad (which would push triangles outside the polygon). Predicates are non-robust: float input +// is fine, a near-degenerate test just means "not perfectly Delaunay", never an invalid mesh. + +/** @param {number} ax @param {number} ay @param {number} bx @param {number} by @param {number} cx @param {number} cy */ +const orient = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + +/** @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 */ +const nextHE = e => e - e % 3 + (e + 1) % 3; // next half-edge within the same triangle + +// reusable module-level scratch, grown on demand (like earcut's z-order arrays): +// 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 EDGE_STACK; +/** @type {Int32Array} */ let he; +/** @type {Int32Array} */ let hTable; +/** @type {Int32Array} */ let hStamp; +let hMask = 0, gen = 0; + +/** @param {number} n */ +function ensureScratch(n) { + if (!EDGE_STACK) EDGE_STACK = 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 Int32Array(size); } + hMask = size - 1; +} + +/** + * Refine a triangulation in place toward the constrained Delaunay triangulation, maximizing the + * minimum angle and removing most slivers. Optional post-pass for {@link earcut} output (or any + * manifold triangle-index array indexing into `coords`). + * + * @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: key each edge on its UNDIRECTED endpoints (min, max), so a + // single hash walk both finds an existing entry and ends on the free insert slot. + // The two half-edges of an interior edge share a key: the first inserts, the second + // finds it and links the pair (manifold input => at most two per undirected edge). + 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 = EDGE_STACK[--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]; + + // the two triangles formed by the new diagonal p0-p1 must both be CCW (convex quad) + 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 < EDGE_STACK.length) EDGE_STACK[i++] = b0 + (b + 1) % 3; + } else { + if (i === 0) break; + a = EDGE_STACK[--i]; + } + } + } +} diff --git a/viz/viz.js b/viz/viz.js index a0218ee..6102ecc 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -1,6 +1,6 @@ /*eslint @stylistic/comma-spacing: 0, no-unused-vars: 0 */ -import earcut, {flatten, deviation} from '../src/earcut.js'; +import earcut, {flatten, deviation, refine} from '../src/earcut.js'; (async function () { const params = new URLSearchParams(window.location.search.substring(1)); @@ -58,6 +58,10 @@ import earcut, {flatten, deviation} from '../src/earcut.js'; const result = earcut(data.vertices, data.holes, data.dimensions); console.timeEnd('earcut'); + console.time('refine'); + refine(result, data.vertices, data.dimensions); + console.timeEnd('refine'); + console.log(`deviation: ${deviation(data.vertices, data.holes, data.dimensions, result)}`); const triangles = []; From f83752c0aab49cf05c56dd2411f1f1d80116d4c2 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 13:57:14 +0300 Subject: [PATCH 02/10] cleanup refine, reorder things a bit --- src/earcut.js | 94 ++++++++++++++++++++++++++------------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/earcut.js b/src/earcut.js index 9089fca..d050444 100644 --- a/src/earcut.js +++ b/src/earcut.js @@ -874,51 +874,23 @@ export function flatten(data) { return {vertices, holes, dimensions}; } -// Optional Delaunay refinement: legalize every interior edge of a triangulation in place via -// Lawson flips, maximizing the min angle and removing most slivers. Works on any manifold mesh, -// not just earcut output. The scratch arrays are allocated lazily so this whole block tree-shakes -// away for callers who don't import `refine`. Adapted from delaunator's _legalize, with two -// polygon-specific tweaks: inCircle is negated to match earcut's CCW winding (delaunator's sign -// builds the anti-Delaunay mesh), and a `> 0` convexity guard prevents flipping across a reflex -// quad (which would push triangles outside the polygon). Predicates are non-robust: float input -// is fine, a near-degenerate test just means "not perfectly Delaunay", never an invalid mesh. - -/** @param {number} ax @param {number} ay @param {number} bx @param {number} by @param {number} cx @param {number} cy */ -const orient = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); - -/** @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 */ -const nextHE = e => e - e % 3 + (e + 1) % 3; // next half-edge within the same triangle - -// reusable module-level scratch, grown on demand (like earcut's z-order arrays): +// 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 EDGE_STACK; +/** @type {Int32Array} */ let edgeStack; /** @type {Int32Array} */ let he; /** @type {Int32Array} */ let hTable; -/** @type {Int32Array} */ let hStamp; +/** @type {Uint32Array} */ let hStamp; let hMask = 0, gen = 0; -/** @param {number} n */ -function ensureScratch(n) { - if (!EDGE_STACK) EDGE_STACK = 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 Int32Array(size); } - hMask = size - 1; -} - /** - * Refine a triangulation in place toward the constrained Delaunay triangulation, maximizing the - * minimum angle and removing most slivers. Optional post-pass for {@link earcut} output (or any - * manifold triangle-index array indexing into `coords`). + * 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} @@ -933,10 +905,7 @@ export function refine(triangles, coords, dim = 2) { gen++; // bumping the generation logically empties the hash (no clearing) he.fill(-1, 0, n); - // build half-edge twins: key each edge on its UNDIRECTED endpoints (min, max), so a - // single hash walk both finds an existing entry and ends on the free insert slot. - // The two half-edges of an interior edge share a key: the first inserts, the second - // finds it and links the pair (manifold input => at most two per undirected edge). + // 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; @@ -963,7 +932,7 @@ export function refine(triangles, coords, dim = 2) { const b = he[a]; const a0 = a - a % 3; const ar = a0 + (a + 2) % 3; - if (b === -1) { if (i === 0) break; a = EDGE_STACK[--i]; continue; } + if (b === -1) { if (i === 0) break; a = edgeStack[--i]; continue; } const b0 = b - b % 3; const al = a0 + (a + 1) % 3; @@ -975,7 +944,9 @@ export function refine(triangles, coords, dim = 2) { const xl = coords[pl * dim], yl = coords[pl * dim + 1]; const x1 = coords[p1 * dim], y1 = coords[p1 * dim + 1]; - // the two triangles formed by the new diagonal p0-p1 must both be CCW (convex quad) + // 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)) { @@ -984,11 +955,42 @@ export function refine(triangles, coords, dim = 2) { 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 < EDGE_STACK.length) EDGE_STACK[i++] = b0 + (b + 1) % 3; + if (i < edgeStack.length) edgeStack[i++] = b0 + (b + 1) % 3; } else { if (i === 0) break; - a = EDGE_STACK[--i]; + 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; +} From a96f6596c38a935258004b608c42d5938319f89c Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 14:34:38 +0300 Subject: [PATCH 03/10] improve viz page, make it a proper fixture browser --- viz/index.html | 35 +++++- viz/viz.js | 297 ++++++++++++++++++++++++++++--------------------- 2 files changed, 202 insertions(+), 130 deletions(-) diff --git a/viz/index.html b/viz/index.html index e569e64..a11f138 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1,14 +1,43 @@ - debug page + earcut demo - +
+ +
+
diff --git a/viz/viz.js b/viz/viz.js index 6102ecc..ffcc7d6 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -1,137 +1,180 @@ -/*eslint @stylistic/comma-spacing: 0, no-unused-vars: 0 */ - import earcut, {flatten, deviation, refine} 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]); +const params = new URLSearchParams(window.location.search.substring(1)); +const rotation = +(params.get('rotation') || 0); + +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 statsEl = document.getElementById('stats'); + +let current = params.get('fixture') || 'water'; +let state = null; // computed data for the current fixture, reused across redraws/toggles + +// build the fixture list from the names in expected.json +const expected = await (await fetch('../test/expected.json')).json(); +for (const name of Object.keys(expected.triangles)) { + const li = document.createElement('li'); + li.textContent = name; + li.dataset.name = name; + fixturesEl.appendChild(li); +} +fixturesEl.onclick = (e) => { + if (!e.target.dataset.name) return; + current = e.target.dataset.name; + select(); +}; + +refineEl.checked = params.get('refine') === '1'; +refineEl.onchange = update; +addEventListener('resize', draw); + +select(); + +function select() { + for (const li of fixturesEl.children) li.classList.toggle('active', li.dataset.name === current); + const url = new URL(location); + url.searchParams.set('fixture', current); + history.replaceState(null, '', url); + load(); +} + +// load + triangulate the current fixture from scratch (base triangulation only) +async function load() { + const rings = rotate(await (await fetch(`../test/fixtures/${current}.json`)).json(), rotation); + 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, base, refined} — cached Path2Ds, rebuilt on resize + }; + update(); +} + +// refine on demand and cache it, so toggling back and forth is instant +function update() { + 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 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); + drawStats(); + 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; } - - const data = flatten(testPoints); - - console.time('earcut'); - const result = earcut(data.vertices, data.holes, data.dimensions); - console.timeEnd('earcut'); - - console.time('refine'); - refine(result, data.vertices, data.dimensions); - console.timeEnd('refine'); - - 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]]); - } - - 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)'); - } - - 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(); + return {minX, minY, w: maxX - minX, h: maxY - minY}; +} + +function draw() { + if (!state) return; + const pad = 10; + const W = area.clientWidth, H = area.clientHeight; + const {minX, minY, w, h} = state.bounds; + const scale = Math.min((W - 2 * pad) / w, (H - 2 * pad) / h); + // center within the canvas area + const ox = (W - w * scale) / 2, oy = (H - h * scale) / 2; + + // (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 px = x => (x - minX) * scale + ox; + const py = y => (y - minY) * scale + oy; + + if (!paths[which]) { + const p = new Path2D(); + const result = refineEl.checked ? state.refined : state.base; + const dim = state.data.dimensions, v = state.data.vertices; + for (let i = 0; i < result.length; i += 3) { + const a = result[i], b = result[i + 1], c = result[i + 2]; + p.moveTo(px(v[a * dim]), py(v[a * dim + 1])); + p.lineTo(px(v[b * dim]), py(v[b * dim + 1])); + p.lineTo(px(v[c * dim]), py(v[c * dim + 1])); + p.closePath(); } - ctx.stroke(); - - if (fill && fill !== true) ctx.fill('evenodd'); + paths[which] = 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); + 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(); + } + paths.outline = p; } - function drawNodePolygon(node) { - drawNodeRing(node, 'black', 'rgba(255,255,0,0.2)'); - } + const dpr = devicePixelRatio || 1; + canvas.width = W * dpr; + canvas.height = H * dpr; + 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 drawNodeEdge(node, color) { - drawPoly([[node.x, node.y], [node.next.x, node.next.y]], color); + ctx.fillStyle = 'rgba(255,255,0,0.2)'; + ctx.strokeStyle = 'rgba(255,0,0,0.4)'; + ctx.fill(paths[which]); + ctx.stroke(paths[which]); + + ctx.strokeStyle = 'black'; + ctx.stroke(paths.outline); +} + +// 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; +} + +// ~3 significant figures across scales +function ms(t) { + const digits = t >= 100 ? 0 : t >= 10 ? 1 : t >= 1 ? 2 : t >= 0.1 ? 3 : 4; + return `${t.toFixed(digits)} ms`; +} + +function drawStats() { + const result = refineEl.checked ? state.refined : state.base; + const row = (label, value) => `
${label}${value}
`; + statsEl.innerHTML = + row('vertices', (state.data.vertices.length / state.data.dimensions).toLocaleString()) + + row('triangles', (result.length / 3).toLocaleString()) + + row('earcut', ms(state.baseTime)) + + row('refine', refineEl.checked ? ms(state.refineTime) : '–') + + row('deviation', state.deviation ? state.deviation.toExponential(2) : '0'); +} From 3873a20ed50cf18981e68af73c2c1d5ebe51da87 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 14:49:26 +0300 Subject: [PATCH 04/10] faster drawing in viz --- viz/viz.js | 49 ++++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/viz/viz.js b/viz/viz.js index ffcc7d6..3ad5e52 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -54,7 +54,7 @@ async function load() { refined: null, refineTime: 0, deviation: deviation(data.vertices, data.holes, data.dimensions, base), bounds: ringBounds(rings[0]), - paths: null // {key, outline, base, refined} — cached Path2Ds, rebuilt on resize + paths: null // {key, outline, baseMesh, refinedMesh} — cached Path2Ds, rebuilt on resize }; update(); } @@ -106,21 +106,12 @@ function draw() { 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[which]) { - const p = new Path2D(); - const result = refineEl.checked ? state.refined : state.base; - const dim = state.data.dimensions, v = state.data.vertices; - for (let i = 0; i < result.length; i += 3) { - const a = result[i], b = result[i + 1], c = result[i + 2]; - p.moveTo(px(v[a * dim]), py(v[a * dim + 1])); - p.lineTo(px(v[b * dim]), py(v[b * dim + 1])); - p.lineTo(px(v[c * dim]), py(v[c * dim + 1])); - p.closePath(); - } - paths[which] = p; + if (!paths[meshKey]) { + paths[meshKey] = meshPath(refineEl.checked ? state.refined : state.base, state.data, px, py); } if (!paths.outline) { const p = new Path2D(); @@ -132,23 +123,43 @@ function draw() { } const dpr = devicePixelRatio || 1; - canvas.width = W * dpr; - canvas.height = H * dpr; - canvas.style.width = `${W}px`; - canvas.style.height = `${H}px`; + 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'; ctx.fillStyle = 'rgba(255,255,0,0.2)'; ctx.strokeStyle = 'rgba(255,0,0,0.4)'; - ctx.fill(paths[which]); - ctx.stroke(paths[which]); + ctx.fill(paths.outline, 'evenodd'); + ctx.stroke(paths[meshKey]); 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])); + } + 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) { From 08088a9413bd56e6a7f65cba607824af7a561350 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 17:12:53 +0300 Subject: [PATCH 05/10] viz improvements --- viz/index.html | 13 ++++--- viz/viz.js | 97 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/viz/index.html b/viz/index.html index a11f138..290bfdf 100644 --- a/viz/index.html +++ b/viz/index.html @@ -16,12 +16,15 @@ header a:visited { color: #0060A0; } .control { display: flex; align-items: center; gap: 6px; margin: 10px 0; user-select: none; } .control input { margin: 0; } + #stats { min-height: 7em; } #stats div { display: flex; justify-content: space-between; } #stats .label { color: #888; } - #fixtures { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 6px 0; } - #fixtures li { padding: 2px 10px; cursor: pointer; white-space: nowrap; } - #fixtures li:hover { background: #eee; } - #fixtures li.active { background: #0076DD; color: #fff; } + #fixtures { + flex: 1; overflow-y: auto; margin: 0; padding: 6px 0; border: 0; + font: inherit; scrollbar-width: none; + } + #fixtures::-webkit-scrollbar { display: none; } + #fixtures option { padding: 2px 10px; cursor: pointer; white-space: nowrap; } #canvas-area { flex: 1; min-width: 0; position: relative; } #canvas { display: block; } @@ -34,7 +37,7 @@
-
    +
    diff --git a/viz/viz.js b/viz/viz.js index 3ad5e52..9db70df 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -10,40 +10,61 @@ const fixturesEl = document.getElementById('fixtures'); const refineEl = document.getElementById('refine'); const statsEl = document.getElementById('stats'); -let current = params.get('fixture') || 'water'; +const defaultFixture = 'water-huge3'; +let current = params.get('fixture') || defaultFixture; let state = null; // computed data for the current fixture, reused across redraws/toggles +let loadToken = 0; +let frame = 0; // build the fixture list from the names in expected.json const expected = await (await fetch('../test/expected.json')).json(); -for (const name of Object.keys(expected.triangles)) { - const li = document.createElement('li'); - li.textContent = name; - li.dataset.name = name; - fixturesEl.appendChild(li); +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.onclick = (e) => { - if (!e.target.dataset.name) return; - current = e.target.dataset.name; +fixturesEl.oninput = () => { + current = fixturesEl.value; select(); }; refineEl.checked = params.get('refine') === '1'; -refineEl.onchange = update; -addEventListener('resize', draw); +refineEl.onchange = () => { + updateURL(); + update(); +}; +addEventListener('resize', scheduleDraw); select(); function select() { - for (const li of fixturesEl.children) li.classList.toggle('active', li.dataset.name === current); + fixturesEl.value = current; + updateURL(); + state = null; + drawLoadingStats(); + const token = ++loadToken; + const name = current; + requestAnimationFrame(() => load(name, token)); +} + +function updateURL() { const url = new URL(location); - url.searchParams.set('fixture', current); + if (current === defaultFixture) url.searchParams.delete('fixture'); + else url.searchParams.set('fixture', current); + if (refineEl.checked) url.searchParams.set('refine', '1'); + else url.searchParams.delete('refine'); history.replaceState(null, '', url); - load(); } // load + triangulate the current fixture from scratch (base triangulation only) -async function load() { - const rings = rotate(await (await fetch(`../test/fixtures/${current}.json`)).json(), rotation); +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); @@ -56,11 +77,15 @@ async function load() { bounds: ringBounds(rings[0]), paths: null // {key, outline, baseMesh, refinedMesh} — cached Path2Ds, rebuilt on resize }; - update(); + if (token === loadToken) update(); } // refine on demand and cache it, so toggling back and forth is instant function update() { + if (!state) { + drawLoadingStats(); + return; + } if (refineEl.checked && !state.refined) { const {vertices, dimensions} = state.data; state.refined = state.base.slice(); @@ -69,7 +94,15 @@ function update() { state.refineTime = bench(() => refine(state.base.slice(), vertices, dimensions)); } drawStats(); - draw(); + scheduleDraw(); +} + +function scheduleDraw() { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + draw(); + }); } function rotate(rings, deg) { @@ -173,19 +206,29 @@ function bench(fn) { return (performance.now() - start) / runs; } -// ~3 significant figures across scales function ms(t) { - const digits = t >= 100 ? 0 : t >= 10 ? 1 : t >= 1 ? 2 : t >= 0.1 ? 3 : 4; - return `${t.toFixed(digits)} ms`; + 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; - const row = (label, value) => `
    ${label}${value}
    `; statsEl.innerHTML = - row('vertices', (state.data.vertices.length / state.data.dimensions).toLocaleString()) + - row('triangles', (result.length / 3).toLocaleString()) + - row('earcut', ms(state.baseTime)) + - row('refine', refineEl.checked ? ms(state.refineTime) : '–') + - row('deviation', state.deviation ? state.deviation.toExponential(2) : '0'); + 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'); } From 2ade21a6e14343b89381699830d4d1a6a90b4cb1 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 17:42:33 +0300 Subject: [PATCH 06/10] add unit test coverage for refine --- test/test.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 2 deletions(-) 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 From cb80d5f497844c2f58e28d854cc9fd5b2a69d883 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 17:54:32 +0300 Subject: [PATCH 07/10] document refine in readme --- README.md | 19 ++++++++++++++++++- package.json | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8cd3c3e..2e274f8 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,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 +104,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/package.json b/package.json index 3e01a6a..b39ea0b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "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": [ From aeeb55ba018d9d5474da368b258e3955071f486c Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 18:58:30 +0300 Subject: [PATCH 08/10] add a demo link, viz polishing --- README.md | 2 ++ viz/index.html | 12 +++++++++--- viz/viz.js | 30 +++++++++++++++++++++++++----- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2e274f8..5d69961 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ import earcut from 'earcut'; const triangles = earcut([10,0, 0,50, 60,60, 70,10]); // returns [1,0,3, 3,2,1] ``` +## [Demo](https://mapbox.github.io/earcut/viz/) + ## Algorithm The library implements a modified ear slicing algorithm, diff --git a/viz/index.html b/viz/index.html index 290bfdf..46b79be 100644 --- a/viz/index.html +++ b/viz/index.html @@ -6,6 +6,7 @@ diff --git a/viz/viz.js b/viz/viz.js index 1dd64bd..0dfd940 100644 --- a/viz/viz.js +++ b/viz/viz.js @@ -10,7 +10,7 @@ const refineEl = document.getElementById('refine'); const rotationEl = document.getElementById('rotation'); const statsEl = document.getElementById('stats'); -const defaultFixture = 'water-huge3'; +const defaultFixture = 'earcut'; const rotations = [0, 90, 180, 270]; let current = params.get('fixture') || defaultFixture; let rotation = +params.get('rotation') || 0; @@ -145,11 +145,12 @@ function ringBounds(ring) { function draw() { if (!state) return; const pad = 15; - const W = area.clientWidth, H = area.clientHeight; + const areaW = area.clientWidth, areaH = area.clientHeight; const {minX, minY, w, h} = state.bounds; - const scale = Math.min((W - 2 * pad) / w, (H - 2 * pad) / h); - // center within the canvas area - const ox = (W - w * scale) / 2, oy = (H - h * scale) / 2; + 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}`; @@ -193,7 +194,7 @@ function draw() { ctx.stroke(paths[meshKey]); ctx.lineWidth = 1; - ctx.strokeStyle = '#333'; + ctx.strokeStyle = 'black'; ctx.stroke(paths.outline); } From 9ee4c2d51d2db690c42d5522213594dd4f277d12 Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Tue, 30 Jun 2026 23:42:18 +0300 Subject: [PATCH 10/10] v3.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 b39ea0b..d57e894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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",