Repository: mapbox/martini Branch: main Commit: f9a7a96b583a Files: 11 Total size: 13.4 KB Directory structure: gitextract_e46fi7nx/ ├── .github/ │ └── workflows/ │ └── node.yml ├── .gitignore ├── LICENSE ├── README.md ├── bench.js ├── eslint.config.js ├── index.d.ts ├── index.js ├── package.json └── test/ ├── test.js └── util.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/node.yml ================================================ name: Node on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci - name: Run tests run: npm test ================================================ FILE: .gitignore ================================================ node_modules yarn.lock *.log martini.js martini.min.js ================================================ FILE: LICENSE ================================================ ISC License Copyright (c) 2019, 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 and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================ FILE: README.md ================================================ # MARTINI [![Simply Awesome](https://img.shields.io/badge/simply-awesome-brightgreen.svg)](https://github.com/mourner/projects) MARTINI stands for **Mapbox's Awesome Right-Triangulated Irregular Networks, Improved**. It's an experimental JavaScript library for **real-time terrain mesh generation** from height data. Given a (2k+1) × (2k+1) terrain grid, it generates a hierarchy of triangular meshes of varying level of detail in milliseconds. _A work in progress._ See the algorithm in action and read more about how it works in [this interactive Observable notebook](https://observablehq.com/@mourner/martin-real-time-rtin-terrain-mesh). Based on the paper ["Right-Triangulated Irregular Networks" by Will Evans et. al. (1997)](https://www.cs.ubc.ca/~will/papers/rtin.pdf). ![MARTINI terrain demo](martini.gif) ## Example ```js import Martini from '@mapbox/martini'; // set up mesh generator for a certain 2^k+1 grid size const martini = new Martini(257); // generate RTIN hierarchy from terrain data (an array of size^2 length) const tile = martini.createTile(terrain); // get a mesh (vertices and triangles indices) for a 10m error const mesh = tile.getMesh(10); ``` ## Install ```bash npm install @mapbox/martini ``` ### Ports to other languages - [pymartini](https://github.com/kylebarron/pymartini) (Python) ================================================ FILE: bench.js ================================================ import fs from 'fs'; import {PNG} from 'pngjs'; import Martini from './index.js'; import {mapboxTerrainToGrid} from './test/util.js'; const png = PNG.sync.read(fs.readFileSync('./test/fixtures/fuji.png')); const terrain = mapboxTerrainToGrid(png); console.time('init tileset'); const martini = new Martini(png.width + 1); console.timeEnd('init tileset'); console.time('create tile'); const tile = martini.createTile(terrain); console.timeEnd('create tile'); console.time('mesh'); const mesh = tile.getMesh(30); console.timeEnd('mesh'); console.log(`vertices: ${mesh.vertices.length / 2}, triangles: ${mesh.triangles.length / 3}`); console.time('20 meshes total'); for (let i = 0; i <= 20; i++) { console.time(`mesh ${i}`); tile.getMesh(i); console.timeEnd(`mesh ${i}`); } console.timeEnd('20 meshes total'); ================================================ FILE: eslint.config.js ================================================ import config from "eslint-config-mourner"; export default [ ...config, { rules: { 'no-use-before-define': 0 } } ]; ================================================ FILE: index.d.ts ================================================ export default class Martini { constructor(gridSize?: number); createTile(terrain: ArrayLike): Tile; } export class Tile { constructor(terrain: ArrayLike, martini: Martini); update(): void; getMesh(maxError?: number): { vertices: Uint16Array; triangles: Uint32Array; }; } ================================================ FILE: index.js ================================================ export default class Martini { constructor(gridSize = 257) { this.gridSize = gridSize; const tileSize = gridSize - 1; if (tileSize & (tileSize - 1)) throw new Error( `Expected grid size to be 2^n+1, got ${gridSize}.`); this.numTriangles = tileSize * tileSize * 2 - 2; this.numParentTriangles = this.numTriangles - tileSize * tileSize; this.indices = new Uint32Array(this.gridSize * this.gridSize); // coordinates for all possible triangles in an RTIN tile this.coords = new Uint16Array(this.numTriangles * 4); // get triangle coordinates from its index in an implicit binary tree for (let i = 0; i < this.numTriangles; i++) { let id = i + 2; let ax = 0, ay = 0, bx = 0, by = 0, cx = 0, cy = 0; if (id & 1) { bx = by = cx = tileSize; // bottom-left triangle } else { ax = ay = cy = tileSize; // top-right triangle } while ((id >>= 1) > 1) { const mx = (ax + bx) >> 1; const my = (ay + by) >> 1; if (id & 1) { // left half bx = ax; by = ay; ax = cx; ay = cy; } else { // right half ax = bx; ay = by; bx = cx; by = cy; } cx = mx; cy = my; } const k = i * 4; this.coords[k + 0] = ax; this.coords[k + 1] = ay; this.coords[k + 2] = bx; this.coords[k + 3] = by; } } createTile(terrain) { return new Tile(terrain, this); } } class Tile { constructor(terrain, martini) { const size = martini.gridSize; if (terrain.length !== size * size) throw new Error( `Expected terrain data of length ${size * size} (${size} x ${size}), got ${terrain.length}.`); this.terrain = terrain; this.martini = martini; this.errors = new Float32Array(terrain.length); this.update(); } update() { const {numTriangles, numParentTriangles, coords, gridSize: size} = this.martini; const {terrain, errors} = this; // iterate over all possible triangles, starting from the smallest level for (let i = numTriangles - 1; i >= 0; i--) { const k = i * 4; const ax = coords[k + 0]; const ay = coords[k + 1]; const bx = coords[k + 2]; const by = coords[k + 3]; const mx = (ax + bx) >> 1; const my = (ay + by) >> 1; const cx = mx + my - ay; const cy = my + ax - mx; // calculate error in the middle of the long edge of the triangle const interpolatedHeight = (terrain[ay * size + ax] + terrain[by * size + bx]) / 2; const middleIndex = my * size + mx; const middleError = Math.abs(interpolatedHeight - terrain[middleIndex]); errors[middleIndex] = Math.max(errors[middleIndex], middleError); if (i < numParentTriangles) { // bigger triangles; accumulate error with children const leftChildIndex = ((ay + cy) >> 1) * size + ((ax + cx) >> 1); const rightChildIndex = ((by + cy) >> 1) * size + ((bx + cx) >> 1); errors[middleIndex] = Math.max(errors[middleIndex], errors[leftChildIndex], errors[rightChildIndex]); } } } getMesh(maxError = 0) { const {gridSize: size, indices} = this.martini; const {errors} = this; let numVertices = 0; let numTriangles = 0; const max = size - 1; // use an index grid to keep track of vertices that were already used to avoid duplication indices.fill(0); // retrieve mesh in two stages that both traverse the error map: // - countElements: find used vertices (and assign each an index), and count triangles (for minimum allocation) // - processTriangle: fill the allocated vertices & triangles typed arrays function countElements(ax, ay, bx, by, cx, cy) { const mx = (ax + bx) >> 1; const my = (ay + by) >> 1; if (Math.abs(ax - cx) + Math.abs(ay - cy) > 1 && errors[my * size + mx] > maxError) { countElements(cx, cy, ax, ay, mx, my); countElements(bx, by, cx, cy, mx, my); } else { indices[ay * size + ax] = indices[ay * size + ax] || ++numVertices; indices[by * size + bx] = indices[by * size + bx] || ++numVertices; indices[cy * size + cx] = indices[cy * size + cx] || ++numVertices; numTriangles++; } } countElements(0, 0, max, max, max, 0); countElements(max, max, 0, 0, 0, max); const vertices = new Uint16Array(numVertices * 2); const triangles = new Uint32Array(numTriangles * 3); let triIndex = 0; function processTriangle(ax, ay, bx, by, cx, cy) { const mx = (ax + bx) >> 1; const my = (ay + by) >> 1; if (Math.abs(ax - cx) + Math.abs(ay - cy) > 1 && errors[my * size + mx] > maxError) { // triangle doesn't approximate the surface well enough; drill down further processTriangle(cx, cy, ax, ay, mx, my); processTriangle(bx, by, cx, cy, mx, my); } else { // add a triangle const a = indices[ay * size + ax] - 1; const b = indices[by * size + bx] - 1; const c = indices[cy * size + cx] - 1; vertices[2 * a] = ax; vertices[2 * a + 1] = ay; vertices[2 * b] = bx; vertices[2 * b + 1] = by; vertices[2 * c] = cx; vertices[2 * c + 1] = cy; triangles[triIndex++] = a; triangles[triIndex++] = b; triangles[triIndex++] = c; } } processTriangle(0, 0, max, max, max, 0); processTriangle(max, max, 0, 0, 0, max); return {vertices, triangles}; } } ================================================ FILE: package.json ================================================ { "name": "@mapbox/martini", "version": "0.2.0", "type": "module", "description": "A JavaScript library for real-time terrain mesh generation", "main": "index.js", "exports": "./index.js", "types": "index.d.ts", "scripts": { "pretest": "eslint index.js bench.js test", "test": "node test/test.js", "bench": "node bench.js", "prepublishOnly": "npm run test" }, "keywords": [ "terrain", "rtin", "mesh", "3d", "webgl" ], "author": "Vladimir Agafonkin", "license": "ISC", "devDependencies": { "eslint": "^9.31.0", "eslint-config-mourner": "^4.0.2", "pngjs": "^7.0.0" }, "files": [ "index.js", "index.d.ts" ], "eslintConfig": { "extends": "mourner", "rules": { "no-use-before-define": 0 } } } ================================================ FILE: test/test.js ================================================ import fs from 'fs'; import {PNG} from 'pngjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import Martini from '../index.js'; import {mapboxTerrainToGrid} from './util.js'; const fuji = PNG.sync.read(fs.readFileSync('./test/fixtures/fuji.png')); const terrain = mapboxTerrainToGrid(fuji); test('generates a mesh', () => { const martini = new Martini(fuji.width + 1); const tile = martini.createTile(terrain); const mesh = tile.getMesh(500); assert.deepEqual([ 320, 64, 256, 128, 320, 128, 384, 128, 256, 0, 288, 160, 256, 192, 288, 192, 320, 192, 304, 176, 256, 256, 288, 224, 352, 160, 320, 160, 512, 0, 384, 0, 128, 128, 128, 0, 64, 64, 64, 0, 0, 0, 32, 32, 192, 192, 384, 384, 512, 256, 384, 256, 320, 320, 320, 256, 512, 512, 512, 128, 448, 192, 384, 192, 128, 384, 256, 512, 256, 384, 0, 512, 128, 256, 64, 192, 0, 256, 64, 128, 32, 96, 0, 128, 32, 64, 16, 48, 0, 64, 0, 32 ], Array.from(mesh.vertices), 'correct vertices'); assert.deepEqual([ 0, 1, 2, 3, 0, 2, 4, 1, 0, 5, 6, 7, 7, 8, 9, 5, 7, 9, 1, 6, 5, 6, 10, 11, 11, 8, 7, 6, 11, 7, 12, 2, 13, 8, 12, 13, 3, 2, 12, 2, 1, 5, 13, 5, 9, 8, 13, 9, 2, 5, 13, 3, 14, 15, 15, 4, 0, 3, 15, 0, 16, 4, 17, 18, 17, 19, 19, 20, 21, 18, 19, 21, 16, 17, 18, 1, 16, 22, 22, 10, 6, 1, 22, 6, 4, 16, 1, 23, 24, 25, 26, 25, 27, 10, 26, 27, 23, 25, 26, 28, 24, 23, 29, 3, 30, 24, 29, 30, 14, 3, 29, 8, 25, 31, 31, 3, 12, 8, 31, 12, 27, 8, 11, 10, 27, 11, 25, 8, 27, 25, 24, 30, 30, 3, 31, 25, 30, 31, 32, 33, 34, 10, 32, 34, 35, 33, 32, 33, 28, 23, 34, 23, 26, 10, 34, 26, 33, 23, 34, 36, 16, 37, 38, 36, 37, 36, 10, 22, 16, 36, 22, 39, 18, 40, 41, 39, 40, 16, 18, 39, 42, 21, 43, 44, 42, 43, 18, 21, 42, 21, 20, 45, 45, 44, 43, 21, 45, 43, 44, 41, 40, 40, 18, 42, 44, 40, 42, 41, 38, 37, 37, 16, 39, 41, 37, 39, 38, 35, 32, 32, 10, 36, 38, 32, 36 ], Array.from(mesh.triangles), 'correct triangles'); }); ================================================ FILE: test/util.js ================================================ export function mapboxTerrainToGrid(png) { const gridSize = png.width + 1; const terrain = new Float32Array(gridSize * gridSize); const tileSize = png.width; // decode terrain values for (let y = 0; y < tileSize; y++) { for (let x = 0; x < tileSize; x++) { const k = (y * tileSize + x) * 4; const r = png.data[k + 0]; const g = png.data[k + 1]; const b = png.data[k + 2]; terrain[y * gridSize + x] = (r * 256 * 256 + g * 256.0 + b) / 10.0 - 10000.0; } } // backfill right and bottom borders for (let x = 0; x < gridSize - 1; x++) { terrain[gridSize * (gridSize - 1) + x] = terrain[gridSize * (gridSize - 2) + x]; } for (let y = 0; y < gridSize; y++) { terrain[gridSize * y + gridSize - 1] = terrain[gridSize * y + gridSize - 2]; } return terrain; }