From 7df3760d71a482f9d1053adcb66f0fb54198c3eb Mon Sep 17 00:00:00 2001 From: AlaricBaraou Date: Mon, 2 Feb 2026 14:27:51 +0900 Subject: [PATCH] Add PMTiles plugin with MVT support --- example/three/pmtiles.html | 21 +++ example/three/pmtiles.js | 176 ++++++++++++++++++ package-lock.json | 84 ++++++++- package.json | 15 ++ src/core/renderer/index.js | 2 + src/core/renderer/loaders/MVTLoaderBase.js | 19 ++ .../renderer/loaders/PMTilesLoaderBase.js | 70 +++++++ src/three/plugins/images/PMTilesPlugin.js | 32 ++++ .../plugins/images/sources/MVTImageSource.js | 33 ++++ .../images/sources/PMTilesImageSource.js | 33 ++++ src/three/plugins/index.js | 1 + .../utils/VectorTileCanvasRenderer.js | 176 ++++++++++++++++++ src/three/renderer/utils/VectorTileStyler.js | 54 ++++++ src/three/renderer/utils/layerColors.js | 34 ++++ 14 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 example/three/pmtiles.html create mode 100644 example/three/pmtiles.js create mode 100644 src/core/renderer/loaders/MVTLoaderBase.js create mode 100644 src/core/renderer/loaders/PMTilesLoaderBase.js create mode 100644 src/three/plugins/images/PMTilesPlugin.js create mode 100644 src/three/plugins/images/sources/MVTImageSource.js create mode 100644 src/three/plugins/images/sources/PMTilesImageSource.js create mode 100644 src/three/renderer/utils/VectorTileCanvasRenderer.js create mode 100644 src/three/renderer/utils/VectorTileStyler.js create mode 100644 src/three/renderer/utils/layerColors.js diff --git a/example/three/pmtiles.html b/example/three/pmtiles.html new file mode 100644 index 000000000..6a380bc5a --- /dev/null +++ b/example/three/pmtiles.html @@ -0,0 +1,21 @@ + + + + + + PMTiles Globe Example + + + + + + + diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js new file mode 100644 index 000000000..09656a43a --- /dev/null +++ b/example/three/pmtiles.js @@ -0,0 +1,176 @@ +import { + Scene, + WebGLRenderer, + PerspectiveCamera, + AmbientLight, + DirectionalLight, +} from 'three'; +import { + TilesRenderer, + GlobeControls, +} from '3d-tiles-renderer'; +import { + UpdateOnChangePlugin, + PMTilesPlugin, +} from '3d-tiles-renderer/plugins'; +import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; + +let scene, renderer, camera, controls, tiles, gui; + +// Layer configuration for Protomaps v4 basemap +const LAYERS = { + water: { enabled: true, color: '#4a90d9' }, + earth: { enabled: true, color: '#f2efe9' }, + landuse: { enabled: false, color: '#e8e4d8' }, + landcover: { enabled: false, color: '#d4e8c2' }, + natural: { enabled: false, color: '#c8d9af' }, + roads: { enabled: false, color: '#ffffff' }, + buildings: { enabled: false, color: '#d9d0c9' }, + transit: { enabled: false, color: '#888888' }, + boundaries: { enabled: true, color: '#ff6b6b' }, + places: { enabled: true, color: '#333333' }, + pois: { enabled: false, color: '#7d4e24' }, +}; + +// Application state +const state = { + layers: {}, + colors: {}, +}; + +// Initialize state from layer config +for ( const key in LAYERS ) { + + state.layers[ key ] = LAYERS[ key ].enabled; + state.colors[ key ] = LAYERS[ key ].color; + +} + +state.colors.default = '#cccccc'; + +init(); +setupGUI(); +createTiles(); + +function init() { + + renderer = new WebGLRenderer( { antialias: true } ); + renderer.setAnimationLoop( render ); + renderer.setPixelRatio( window.devicePixelRatio ); + renderer.setSize( window.innerWidth, window.innerHeight ); + renderer.setClearColor( 0x111111 ); + document.body.appendChild( renderer.domElement ); + + scene = new Scene(); + camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 100, 1e8 ); + + const dirLight = new DirectionalLight( 0xffffff ); + dirLight.position.set( 1, 1, 1 ); + scene.add( dirLight ); + scene.add( new AmbientLight( 0x444444 ) ); + + controls = new GlobeControls( scene, camera, renderer.domElement ); + controls.enableDamping = true; + controls.camera.position.set( 0, 0, 1.5 * 1e7 ); + + window.addEventListener( 'resize', onWindowResize, false ); + +} + +function createFilter() { + + return function ( feature, layerName ) { + + if ( layerName in state.layers ) { + + return state.layers[ layerName ] === true; + + } + + // Unknown layers: hide by default + return false; + + }; + +} + +function createTiles() { + + if ( tiles ) { + + scene.remove( tiles.group ); + tiles.dispose(); + + } + + tiles = new TilesRenderer(); + tiles.registerPlugin( new UpdateOnChangePlugin() ); + tiles.registerPlugin( new PMTilesPlugin( { + url: 'https://demo-bucket.protomaps.com/v4.pmtiles', + center: true, + shape: 'ellipsoid', + levels: 15, + tileDimension: 512, + styles: state.colors, + filter: createFilter() + } ) ); + + tiles.group.rotation.x = - Math.PI / 2; + tiles.setCamera( camera ); + scene.add( tiles.group ); + + if ( controls ) controls.setEllipsoid( tiles.ellipsoid, tiles.group ); + +} + +function setupGUI() { + + gui = new GUI(); + + // Layers folder + const layersFolder = gui.addFolder( 'Layers' ); + for ( const key in LAYERS ) { + + layersFolder.add( state.layers, key ) + .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) + .onChange( createTiles ); + + } + + // Colors folder + const colorsFolder = gui.addFolder( 'Colors' ); + for ( const key in LAYERS ) { + + colorsFolder.addColor( state.colors, key ) + .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) + .onChange( createTiles ); + + } + + colorsFolder.close(); + +} + +function onWindowResize() { + + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize( window.innerWidth, window.innerHeight ); + +} + +function render() { + + controls.update(); + if ( tiles ) { + + camera.updateMatrixWorld(); + tiles.setCamera( camera ); + tiles.setResolutionFromRenderer( camera, renderer ); + tiles.update(); + + } + + renderer.render( scene, camera ); + +} diff --git a/package-lock.json b/package-lock.json index 27955296c..98abcc6fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "3d-tiles-renderer", "version": "0.4.19", "license": "Apache-2.0", + "dependencies": { + "@mapbox/vector-tile": "^2.0.3", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2" + }, "devDependencies": { "@babel/preset-modules": "^0.1.6", "@babel/preset-react": "^7.26.3", @@ -99,6 +104,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -708,7 +714,8 @@ "resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-8.48.0.tgz", "integrity": "sha512-6iJk72ufSdU+ui6BtHPMTyfhrS5EMbKz3568mmOu5Tn85BOdRbwLcZu30EJpSCxZ7tdFs5goBTMcEv19eYuTCQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@babylonjs/loaders": { "version": "8.48.0", @@ -1550,6 +1557,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", @@ -2361,6 +2385,7 @@ "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2859,6 +2884,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2889,6 +2920,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2899,6 +2931,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2926,6 +2959,7 @@ "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", @@ -2995,6 +3029,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3410,6 +3445,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3777,6 +3813,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4584,6 +4621,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4945,7 +4983,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -6568,6 +6605,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6581,6 +6630,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6588,6 +6638,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pmtiles": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.2.tgz", + "integrity": "sha512-Ath2F2U2E37QyNXjN1HOF+oLiNIbdrDYrk/K3C9K4Pgw2anwQX10y4WYWEH9O75vPiu0gBbSWIAbSG19svyvZg==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6702,6 +6761,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6966,6 +7031,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -7574,7 +7648,8 @@ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -7869,6 +7944,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8052,6 +8128,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8127,6 +8204,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index 49ea21e67..d8d976009 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "license": "Apache-2.0", "devDependencies": { "@babel/preset-modules": "^0.1.6", + "@mapbox/vector-tile": "^2.0.3", "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@eslint/js": "^9.0.0", @@ -102,6 +103,8 @@ "globals": "^16.5.0", "leva": "^0.10.0", "lil-gui": "^0.21.0", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2", "postprocessing": "^6.36.4", "three": "^0.170.0", "typescript": "^5.6.0", @@ -110,14 +113,20 @@ "vitest": "^4.0.15" }, "peerDependencies": { + "@mapbox/vector-tile": "^2.0.3", "@react-three/fiber": "^8.17.9 || ^9.0.0", "@babylonjs/core": ">=8.0.0", "@babylonjs/loaders": ">=8.0.0", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "three": ">=0.167.0" }, "peerDependenciesMeta": { + "@mapbox/vector-tile": { + "optional": true + }, "@react-three/fiber": { "optional": true }, @@ -127,6 +136,12 @@ "@babylonjs/loaders": { "optional": true }, + "pbf": { + "optional": true + }, + "pmtiles": { + "optional": true + }, "react": { "optional": true }, diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js index e175b9654..872b78b38 100644 --- a/src/core/renderer/index.js +++ b/src/core/renderer/index.js @@ -4,6 +4,8 @@ export { LoaderBase } from './loaders/LoaderBase.js'; export * from './loaders/B3DMLoaderBase.js'; export * from './loaders/I3DMLoaderBase.js'; export * from './loaders/PNTSLoaderBase.js'; +export * from './loaders/MVTLoaderBase.js'; +export * from './loaders/PMTilesLoaderBase.js'; export * from './loaders/CMPTLoaderBase.js'; export * from './constants.js'; diff --git a/src/core/renderer/loaders/MVTLoaderBase.js b/src/core/renderer/loaders/MVTLoaderBase.js new file mode 100644 index 000000000..d95a0d785 --- /dev/null +++ b/src/core/renderer/loaders/MVTLoaderBase.js @@ -0,0 +1,19 @@ +// MVT File Format +// https://github.com/mapbox/vector-tile-spec/blob/master/2.1/README.md + +import { LoaderBase } from './LoaderBase.js'; +import { VectorTile } from '@mapbox/vector-tile'; +import Protobuf from 'pbf'; + +export class MVTLoaderBase extends LoaderBase { + + parse( buffer ) { + + const pbf = new Protobuf( buffer ); + const vectorTile = new VectorTile( pbf ); + + return Promise.resolve( { vectorTile } ); + + } + +} diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js new file mode 100644 index 000000000..80b951e24 --- /dev/null +++ b/src/core/renderer/loaders/PMTilesLoaderBase.js @@ -0,0 +1,70 @@ +// PMTiles Archive Format +// https://github.com/protomaps/PMTiles + +import { PMTiles } from 'pmtiles'; + +export class PMTilesLoaderBase { + + constructor() { + + this.instance = null; + this.header = null; + this.url = null; + + } + + // Initialize the PMTiles archive and load header + async init( url ) { + + this.url = url; + this.instance = new PMTiles( url ); + this.header = await this.instance.getHeader(); + + return this.header; + + } + + // Fetch a tile from the archive + async getTile( z, x, y, signal ) { + + if ( ! this.instance ) { + + throw new Error( 'PMTilesLoaderBase: Archive not initialized. Call init() first.' ); + + } + + const res = await this.instance.getZxy( z, x, y, signal ); + + if ( ! res || ! res.data ) { + + return null; + + } + + return res.data; + + } + + // Generate a virtual URL for a tile (used by tiling scheme) + getUrl( z, x, y ) { + + return `pmtiles://${z}/${x}/${y}`; + + } + + // Parse tile coordinates from a virtual URL (pmtiles://z/x/y) + static parseUrl( url ) { + + const i2 = url.lastIndexOf( '/' ); + const i1 = url.lastIndexOf( '/', i2 - 1 ); + const i0 = url.lastIndexOf( '/', i1 - 1 ); + + return { + z: parseInt( url.slice( i0 + 1, i1 ) ), + x: parseInt( url.slice( i1 + 1, i2 ) ), + y: parseInt( url.slice( i2 + 1 ) ), + }; + + } + +} diff --git a/src/three/plugins/images/PMTilesPlugin.js b/src/three/plugins/images/PMTilesPlugin.js new file mode 100644 index 000000000..a15229753 --- /dev/null +++ b/src/three/plugins/images/PMTilesPlugin.js @@ -0,0 +1,32 @@ +import { EllipsoidProjectionTilesPlugin } from './EllipsoidProjectionTilesPlugin.js'; +import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; +import { PMTilesLoaderBase } from '../../../core/renderer/loaders/PMTilesLoaderBase.js'; + +export class PMTilesPlugin extends EllipsoidProjectionTilesPlugin { + + constructor( options = {} ) { + + super( options ); + + this.name = 'PMTILES_PLUGIN'; + this.imageSource = new PMTilesImageSource( options ); + + } + + // Intercept pmtiles:// URLs and fetch from the PMTiles archive + fetchData( url, options ) { + + if ( url.startsWith( 'pmtiles://' ) ) { + + const { z, x, y } = PMTilesLoaderBase.parseUrl( url ); + + return this.imageSource.pmtilesLoader.getTile( z, x, y, options?.signal ) + .then( buffer => buffer || new ArrayBuffer( 0 ) ); + + } + + return null; + + } + +} diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js new file mode 100644 index 000000000..6dcb08e3d --- /dev/null +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -0,0 +1,33 @@ +import { XYZImageSource } from './XYZImageSource.js'; +import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js'; +import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js'; +import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; + +export class MVTImageSource extends XYZImageSource { + + constructor( options = {} ) { + + super( options ); + + this.loader = new MVTLoaderBase(); + this.tileDimension = options.tileDimension || 512; + + this._styler = new VectorTileStyler( { + filter: options.filter, + styles: options.styles + } ); + + this._renderer = new VectorTileCanvasRenderer( this._styler, { + tileDimension: this.tileDimension + } ); + + } + + async processBufferToTexture( buffer ) { + + const { vectorTile } = await this.loader.parse( buffer ); + return this._renderer.render( vectorTile ); + + } + +} diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js new file mode 100644 index 000000000..97efe679d --- /dev/null +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -0,0 +1,33 @@ +import { MVTImageSource } from './MVTImageSource.js'; +import { ProjectionScheme } from '../utils/ProjectionScheme.js'; +import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js'; + +export class PMTilesImageSource extends MVTImageSource { + + constructor( options = {} ) { + + super( options ); + + this.pmtilesLoader = new PMTilesLoaderBase(); + this.tiling.flipY = true; + + } + + getUrl( x, y, level ) { + + return this.pmtilesLoader.getUrl( level, x, y ); + + } + + async init() { + + const header = await this.pmtilesLoader.init( this.url ); + this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) ); + this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, { + tilePixelWidth: this.tileDimension, + tilePixelHeight: this.tileDimension, + } ); + + } + +} diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js index 329725c6f..1a4970d59 100644 --- a/src/three/plugins/index.js +++ b/src/three/plugins/index.js @@ -16,6 +16,7 @@ export * from './DebugTilesPlugin.js'; // other formats export * from './images/DeepZoomImagePlugin.js'; export * from './images/EPSGTilesPlugin.js'; +export * from './images/PMTilesPlugin.js'; // gltf extensions export * from './gltf/GLTFCesiumRTCExtension.js'; diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js new file mode 100644 index 000000000..c4cdd24c0 --- /dev/null +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -0,0 +1,176 @@ +import { CanvasTexture, SRGBColorSpace } from 'three'; + +const MVT_EXTENT = 4096; + +export class VectorTileCanvasRenderer { + + constructor( styler, options = {} ) { + + this.styler = styler; + this.tileDimension = options.tileDimension || 512; + + } + + render( vectorTile ) { + + const canvas = this._createCanvas( this.tileDimension, this.tileDimension ); + const ctx = canvas.getContext( '2d' ); + const scale = this.tileDimension / MVT_EXTENT; + + for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { + + const color = this.styler.getColor( layerName, 'css' ); + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + if ( type === 1 ) { + + this._renderPoints( ctx, geometry, layerName, scale ); + + } else if ( type === 2 ) { + + this._renderLines( ctx, geometry, scale ); + + } else if ( type === 3 ) { + + this._renderPolygons( ctx, geometry, scale ); + + } + + } + + return this._createTexture( canvas ); + + } + + _getFeatures( vectorTile ) { + + const results = []; + const layerNames = Object.keys( vectorTile.layers ); + const sortedLayers = this.styler.sortLayers( layerNames ); + + for ( const layerName of sortedLayers ) { + + const layer = vectorTile.layers[ layerName ]; + + for ( let i = 0; i < layer.length; i ++ ) { + + const feature = layer.feature( i ); + + if ( this.styler.shouldIncludeFeature( feature, layerName ) ) { + + results.push( { + layerName, + geometry: feature.loadGeometry(), + type: feature.type, + } ); + + } + + } + + } + + return results; + + } + + _createCanvas( width, height ) { + + if ( typeof OffscreenCanvas !== 'undefined' ) { + + return new OffscreenCanvas( width, height ); + + } else { + + const canvas = document.createElement( 'canvas' ); + canvas.width = width; + canvas.height = height; + return canvas; + + } + + } + + _createTexture( canvas ) { + + const tex = new CanvasTexture( canvas ); + tex.colorSpace = SRGBColorSpace; + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + + } + + _renderPoints( ctx, geometry, layerName, scale ) { + + const isLabelLayer = ( layerName === 'place_label' ); + + for ( const multiPoint of geometry ) { + + for ( const p of multiPoint ) { + + const x = p.x * scale; + const y = p.y * scale; + + if ( ! isLabelLayer ) { + + const radius = ( layerName === 'poi' ) ? 3 : 2; + + ctx.beginPath(); + ctx.moveTo( x + radius, y ); + ctx.arc( x, y, radius, 0, Math.PI * 2 ); + ctx.fill(); + + } + + } + + } + + } + + _renderLines( ctx, geometry, scale ) { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const p = ring[ k ]; + if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale ); + else ctx.lineTo( p.x * scale, p.y * scale ); + + } + + } + + ctx.stroke(); + + } + + _renderPolygons( ctx, geometry, scale ) { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const p = ring[ k ]; + if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale ); + else ctx.lineTo( p.x * scale, p.y * scale ); + + } + + ctx.closePath(); + + } + + ctx.fill(); + + } + +} diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js new file mode 100644 index 000000000..4f4e76f6d --- /dev/null +++ b/src/three/renderer/utils/VectorTileStyler.js @@ -0,0 +1,54 @@ +import { Color } from 'three'; +import { LAYER_COLORS, DEFAULT_LAYER_ORDER } from './layerColors.js'; + +const _color = /* @__PURE__ */ new Color(); + +export class VectorTileStyler { + + constructor( options = {} ) { + + this.filter = options.filter || ( () => true ); + this._layerOrder = options.layerOrder || DEFAULT_LAYER_ORDER; + this._styles = {}; + + const colorsToSet = Object.assign( {}, LAYER_COLORS, options.styles || {} ); + for ( const key in colorsToSet ) { + + _color.set( colorsToSet[ key ] ); + this._styles[ key ] = { + hex: _color.getHex(), + css: _color.getStyle() + }; + + } + + } + + getColor( layerName, format = 'hex' ) { + + const style = this._styles[ layerName ] || this._styles[ 'default' ]; + return format === 'css' ? style.css : style.hex; + + } + + sortLayers( layerNames ) { + + return [ ...layerNames ].sort( ( a, b ) => { + + let idxA = this._layerOrder.indexOf( a ); + let idxB = this._layerOrder.indexOf( b ); + if ( idxA === - 1 ) idxA = 0; + if ( idxB === - 1 ) idxB = 0; + return idxA - idxB; + + } ); + + } + + shouldIncludeFeature( feature, layerName ) { + + return this.filter( feature, layerName ); + + } + +} diff --git a/src/three/renderer/utils/layerColors.js b/src/three/renderer/utils/layerColors.js new file mode 100644 index 000000000..795a87ad9 --- /dev/null +++ b/src/three/renderer/utils/layerColors.js @@ -0,0 +1,34 @@ +/* non exhaustive list of layer colors */ +export const LAYER_COLORS = { + // Nature & Water + 'water': 0x201f20, + 'waterway': 0x201f20, + 'landuse': 0xcaedc1, + 'landuse_overlay': 0xcaedc1, + 'park': 0x5da859, + + // Infrastructure + 'building': 0xeeeeee, + 'road': 0x444444, + 'transportation': 0x444444, + + // Boundaries & Background + 'boundaries': 0x444545, + 'background': 0x111111, + 'default': 0x222222 +}; + +/* Default layer ordering for vector tiles (bottom to top) */ +export const DEFAULT_LAYER_ORDER = [ + 'landuse', + 'landuse_overlay', + 'park', + 'water', + 'waterway', + 'transportation', + 'road', + 'building', + 'boundaries', + 'poi', + 'place_label' +];