diff --git a/example/three/pmtiles.html b/example/three/pmtiles.html
new file mode 100644
index 000000000..e6425fab0
--- /dev/null
+++ b/example/three/pmtiles.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ PMTiles Globe Example
+
+
+
+
+
+
diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js
new file mode 100644
index 000000000..257130e23
--- /dev/null
+++ b/example/three/pmtiles.js
@@ -0,0 +1,132 @@
+import { Scene, WebGLRenderer, PerspectiveCamera } from 'three';
+import {
+ TilesRenderer,
+ GlobeControls,
+} from '3d-tiles-renderer';
+import {
+ UpdateOnChangePlugin,
+ TilesFadePlugin,
+ ImageOverlayPlugin,
+ PMTilesOverlay,
+ GeneratedSurfacePlugin,
+} from '3d-tiles-renderer/plugins';
+import GUI from 'three/addons/libs/lil-gui.module.min.js';
+
+// Layer config for Protomaps v4 basemap — colors from the Protomaps "Light" theme
+const LAYERS = {
+ earth: { enabled: true, fill: '#e2dfda', order: 0 },
+ water: { enabled: true, fill: '#80deea', order: 1 },
+ landcover: { enabled: true, fill: '#c4e7d2', order: 2 },
+ landuse: { enabled: true, fill: '#cfddd5', order: 3 },
+ natural: { enabled: true, fill: '#e2e0d7', order: 4 },
+ buildings: { enabled: true, fill: '#cccccc', order: 5 },
+ roads: { enabled: true, stroke: '#ebebeb', order: 6 },
+ transit: { enabled: true, stroke: '#a7b1b3', order: 7 },
+ boundaries: { enabled: true, stroke: '#adadad', order: 8 },
+ places: { enabled: true, fill: '#5c5c5c', order: 9 },
+ pois: { enabled: true, fill: '#1a8cbd', radius: 3, order: 10 },
+};
+
+let scene, renderer, camera, controls, tiles, overlay;
+
+init();
+render();
+
+function init() {
+
+ renderer = new WebGLRenderer( { antialias: true } );
+ renderer.setPixelRatio( window.devicePixelRatio );
+ renderer.setSize( window.innerWidth, window.innerHeight );
+ renderer.setClearColor( 0x111111 );
+ renderer.setAnimationLoop( render );
+ document.body.appendChild( renderer.domElement );
+
+ scene = new Scene();
+ camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.001, 10000 );
+
+ // PMTiles overlay: vector tile data composited on top of the base geometry
+ overlay = new PMTilesOverlay( {
+ url: 'https://demo-bucket.protomaps.com/v4.pmtiles',
+ getStyle,
+ } );
+
+ // Base tile layer: XYZ raster tiles provide the globe geometry
+ tiles = new TilesRenderer();
+ tiles.registerPlugin( new UpdateOnChangePlugin() );
+ tiles.registerPlugin( new TilesFadePlugin() );
+ tiles.registerPlugin( new GeneratedSurfacePlugin( {
+ center: true,
+ shape: 'ellipsoid',
+ } ) );
+ tiles.registerPlugin( new ImageOverlayPlugin( {
+ overlays: [ overlay ],
+ renderer,
+ } ) );
+
+ tiles.setCamera( camera );
+ tiles.group.rotation.x = - Math.PI / 2;
+ tiles.group.updateMatrixWorld();
+ scene.add( tiles.group );
+
+ // Controls
+ controls = new GlobeControls( scene, camera, renderer.domElement );
+ controls.setEllipsoid( tiles.ellipsoid, tiles.group );
+ controls.enableDamping = true;
+ controls.camera.position.set( 0, 0, 1.5e7 );
+
+ window.addEventListener( 'resize', onWindowResize );
+
+ setupGUI();
+
+}
+
+function getStyle( layerName, properties ) {
+
+ if ( ! ( layerName in LAYERS ) ) return null;
+
+ const layer = LAYERS[ layerName ];
+ return layer.enabled ? layer : null;
+
+}
+
+function updateOverlay() {
+
+ overlay.redraw();
+
+}
+
+function setupGUI() {
+
+ const gui = new GUI();
+
+ for ( const key in LAYERS ) {
+
+ const folder = gui.addFolder( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) );
+ folder.add( LAYERS[ key ], 'enabled' ).onChange( updateOverlay );
+ folder.addColor( LAYERS[ key ], LAYERS[ key ].fill !== undefined ? 'fill' : 'stroke' ).onChange( updateOverlay );
+ folder.close();
+
+ }
+
+}
+
+function onWindowResize() {
+
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize( window.innerWidth, window.innerHeight );
+
+}
+
+function render() {
+
+ controls.update();
+
+ 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 576af0912..750f19a1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@babylonjs/core": "^8.47.2",
"@babylonjs/loaders": "^8.47.2",
"@eslint/js": "^9.0.0",
+ "@mapbox/vector-tile": "^2.0.3",
"@react-three/drei": "^10.0.0",
"@react-three/fiber": "^9.0.0",
"@types/node": "^24.3.0",
@@ -34,6 +35,8 @@
"jsdoc": "^4.0.5",
"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",
@@ -44,7 +47,10 @@
"peerDependencies": {
"@babylonjs/core": ">=8.0.0",
"@babylonjs/loaders": ">=8.0.0",
+ "@mapbox/vector-tile": "^2.0.3",
"@react-three/fiber": "^8.17.9 || ^9.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"
@@ -56,9 +62,18 @@
"@babylonjs/loaders": {
"optional": true
},
+ "@mapbox/vector-tile": {
+ "optional": true
+ },
"@react-three/fiber": {
"optional": true
},
+ "pbf": {
+ "optional": true
+ },
+ "pmtiles": {
+ "optional": true
+ },
"react": {
"optional": true
},
@@ -1592,6 +1607,25 @@
"node": ">=v12.0.0"
}
},
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "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",
@@ -2854,6 +2888,13 @@
"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==",
+ "dev": true,
+ "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",
@@ -6904,6 +6945,19 @@
"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==",
+ "dev": true,
+ "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",
@@ -6924,6 +6978,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pmtiles": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz",
+ "integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==",
+ "dev": true,
+ "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",
@@ -7028,6 +7092,13 @@
"node": ">=12.0.0"
}
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
+ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7325,6 +7396,16 @@
"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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
diff --git a/package.json b/package.json
index e0010f293..0efdcd63d 100644
--- a/package.json
+++ b/package.json
@@ -83,6 +83,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",
"@babylonjs/core": "^8.47.2",
@@ -107,6 +108,8 @@
"jsdoc": "^4.0.5",
"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",
@@ -117,12 +120,18 @@
"peerDependencies": {
"@babylonjs/core": ">=8.0.0",
"@babylonjs/loaders": ">=8.0.0",
+ "@mapbox/vector-tile": "^2.0.3",
"@react-three/fiber": "^8.17.9 || ^9.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
},
@@ -132,6 +141,12 @@
"@babylonjs/loaders": {
"optional": true
},
+ "pbf": {
+ "optional": true
+ },
+ "pmtiles": {
+ "optional": true
+ },
"react": {
"optional": true
},
diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md
index fd2779294..2c9cb4bba 100644
--- a/src/three/plugins/API.md
+++ b/src/three/plugins/API.md
@@ -169,6 +169,10 @@ constructor(
geojson = null: Object,
url = null: string,
resolution = 256: number,
+ getStyle?: (
+ feature: Object,
+ properties: Object
+ ) => VectorTileStyle | null,
pointRadius = 6: number,
strokeStyle = 'white': string,
strokeWidth = 2: number,
@@ -183,6 +187,67 @@ constructor(
)
```
+## MVTOverlay
+
+_extends [`ImageOverlay`](#imageoverlay)_
+
+Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry.
+See the [Mapbox Vector Tile specification](https://github.com/mapbox/vector-tile-spec).
+
+Requires the optional peer dependencies `@mapbox/vector-tile` and `pbf`, which are
+imported dynamically on first use and must be installed separately:
+```
+npm install @mapbox/vector-tile pbf
+```
+
+
+### .constructor
+
+```js
+constructor(
+ {
+ url?: string,
+ levels = 20: number,
+ projection = 'EPSG:3857': string,
+ resolution = 512: number,
+ getStyle?: (
+ layerName: string,
+ properties: Object | null
+ ) => VectorTileStyle | null,
+ }
+)
+```
+
+## PMTilesOverlay
+
+_extends [`MVTOverlay`](#mvtoverlay)_
+
+Overlay that renders PMTiles vector or raster data on top of 3D tile geometry.
+Projection and zoom levels are read automatically from the PMTiles archive header.
+
+Requires the optional peer dependency `pmtiles`, which is imported dynamically on first use
+and must be installed separately. Vector archives additionally require `@mapbox/vector-tile`
+and `pbf`:
+```
+npm install pmtiles @mapbox/vector-tile pbf
+```
+
+
+### .constructor
+
+```js
+constructor(
+ {
+ url?: string,
+ resolution = 512: number,
+ getStyle?: (
+ layerName: string,
+ properties: Object | null
+ ) => VectorTileStyle | null,
+ }
+)
+```
+
## TiledImageOverlay
_extends [`ImageOverlay`](#imageoverlay)_
@@ -1208,6 +1273,57 @@ nullFeatureId: number | null
texture?: Object
```
+## VectorTileStyle
+
+
+### .fill
+
+```js
+fill = '#cccccc': string
+```
+
+CSS fill color.
+
+### .stroke
+
+```js
+stroke = 'transparent': string
+```
+
+CSS stroke color.
+
+### .strokeWidth
+
+```js
+strokeWidth = 1: number
+```
+
+Stroke width in pixels.
+
+### .radius
+
+```js
+radius = 2: number
+```
+
+Point radius in pixels.
+
+### .order
+
+```js
+order = 0: number
+```
+
+Layer draw order; lower values are drawn first.
+
+### .visible
+
+```js
+visible = true: boolean
+```
+
+Whether the feature is rendered.
+
## WMTSTileMatrix
diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js
index 406a38627..f74eb67d2 100644
--- a/src/three/plugins/images/GeneratedSurfacePlugin.js
+++ b/src/three/plugins/images/GeneratedSurfacePlugin.js
@@ -619,7 +619,7 @@ export class GeneratedSurfacePlugin {
const tiling = new TilingScheme();
if ( this.shape === 'ellipsoid' ) {
- const projection = new ProjectionScheme();
+ const projection = new ProjectionScheme( 'EPSG:3857' );
tiling.setProjection( projection );
tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY );
diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js
index 2363b9c32..caae9a92a 100644
--- a/src/three/plugins/images/ImageOverlayPlugin.js
+++ b/src/three/plugins/images/ImageOverlayPlugin.js
@@ -1,5 +1,6 @@
/** @import { WebGLRenderer } from 'three' */
/** @import { WMTSTileMatrix } from './WMTSImageSource.js' */
+/** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */
import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three';
import { PriorityQueue, PriorityQueueItemRemovedError } from '3d-tiles-renderer/core';
import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins';
@@ -1571,6 +1572,13 @@ export class DeepZoomOverlay extends TiledImageOverlay {
}
+/**
+ * @callback GeoJSONGetStyleCallback
+ * @param {Object} feature The GeoJSON feature object being rendered.
+ * @param {Object} properties The feature's properties object.
+ * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults.
+ */
+
/**
* Overlay that rasterizes a GeoJSON dataset onto 3D tile geometry. Features are drawn using the
* Canvas 2D API at the tile's native resolution. Per-feature style overrides can be provided via
@@ -1583,6 +1591,7 @@ export class DeepZoomOverlay extends TiledImageOverlay {
* @param {string} [options.url=null] URL to a GeoJSON file to fetch on initialization (used when
* `geojson` is not supplied directly).
* @param {number} [options.resolution=256] Canvas resolution (pixels) used when compositing tiles.
+ * @param {GeoJSONGetStyleCallback} [options.getStyle] Per-feature style callback. When provided, overrides `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius`.
* @param {number} [options.pointRadius=6] Radius in pixels used to render Point features.
* @param {string} [options.strokeStyle='white'] Canvas stroke style for feature outlines.
* @param {number} [options.strokeWidth=2] Stroke line width in pixels.
diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js
new file mode 100644
index 000000000..a377fdaae
--- /dev/null
+++ b/src/three/plugins/images/MVTOverlay.js
@@ -0,0 +1,179 @@
+/** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */
+import { ImageOverlay } from './ImageOverlayPlugin.js';
+import { MVTImageSource } from './sources/MVTImageSource.js';
+import { PMTilesImageSource } from './sources/PMTilesImageSource.js';
+
+/**
+ * @callback MVTGetStyleCallback
+ * @param {string} layerName Name of the MVT layer the feature belongs to.
+ * @param {Object|null} properties Feature properties, or `null` when queried for layer-level sort order only.
+ * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults.
+ */
+
+/**
+ * Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry.
+ * See the {@link https://github.com/mapbox/vector-tile-spec Mapbox Vector Tile specification}.
+ *
+ * Requires the optional peer dependencies `@mapbox/vector-tile` and `pbf`, which are
+ * imported dynamically on first use and must be installed separately:
+ * ```
+ * npm install @mapbox/vector-tile pbf
+ * ```
+ * @extends ImageOverlay
+ * @param {Object} [options]
+ * @param {string} [options.url] URL template with `{x}`, `{y}`, `{z}` placeholders.
+ * @param {number} [options.levels=20] Number of zoom levels.
+ * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier.
+ * @param {number} [options.resolution=512] Canvas resolution for generated tile textures.
+ * @param {MVTGetStyleCallback} [options.getStyle] Per-feature style callback.
+ */
+export class MVTOverlay extends ImageOverlay {
+
+ get tiling() {
+
+ return this.imageSource.tiling;
+
+ }
+
+ get projection() {
+
+ return this.tiling.projection;
+
+ }
+
+ get aspectRatio() {
+
+ return this.tiling && this.isReady ? this.tiling.aspectRatio : 1;
+
+ }
+
+ constructor( options = {} ) {
+
+ super( options );
+ this.imageSource = options.imageSource ?? new MVTImageSource( options );
+
+ }
+
+ _init() {
+
+ this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
+ return this.imageSource.init();
+
+ }
+
+ calculateLevel( range ) {
+
+ const [ minX, minY, maxX, maxY ] = range;
+ const w = maxX - minX;
+ const h = maxY - minY;
+ const resolution = this.imageSource.resolution;
+ const maxLevel = this.tiling.maxLevel;
+
+ let level = 0;
+ for ( ; level < maxLevel; level ++ ) {
+
+ const levelData = this.tiling.getLevel( level );
+ if ( levelData === null || levelData === undefined ) {
+
+ continue;
+
+ }
+
+ const { pixelWidth, pixelHeight } = levelData;
+ if ( pixelWidth >= resolution / w || pixelHeight >= resolution / h ) {
+
+ break;
+
+ }
+
+ }
+
+ return level;
+
+ }
+
+ hasContent( range ) {
+
+ return this.imageSource.hasContent( ...range, this.calculateLevel( range ) );
+
+ }
+
+ getTexture( range ) {
+
+ return this.imageSource.get( ...range, this.calculateLevel( range ) );
+
+ }
+
+ lockTexture( range ) {
+
+ return this.imageSource.lock( ...range, this.calculateLevel( range ) );
+
+ }
+
+ releaseTexture( range ) {
+
+ this.imageSource.release( ...range, this.calculateLevel( range ) );
+
+ }
+
+ setResolution( resolution ) {
+
+ this.imageSource.resolution = resolution;
+
+ }
+
+ shouldSplit( range ) {
+
+ return true;
+
+ }
+
+ redraw() {
+
+ this.imageSource.redraw();
+
+ }
+
+}
+
+/**
+ * Overlay that renders PMTiles vector or raster data on top of 3D tile geometry.
+ * Projection and zoom levels are read automatically from the PMTiles archive header.
+ *
+ * Requires the optional peer dependency `pmtiles`, which is imported dynamically on first use
+ * and must be installed separately. Vector archives additionally require `@mapbox/vector-tile`
+ * and `pbf`:
+ * ```
+ * npm install pmtiles @mapbox/vector-tile pbf
+ * ```
+ * @extends MVTOverlay
+ * @param {Object} [options]
+ * @param {string} [options.url] URL to the `.pmtiles` archive.
+ * @param {number} [options.resolution=512] Canvas resolution for generated tile textures.
+ * @param {MVTGetStyleCallback} [options.getStyle] Per-feature style callback. Only applies to vector archives.
+ */
+export class PMTilesOverlay extends MVTOverlay {
+
+ constructor( options = {} ) {
+
+ super( { ...options, imageSource: new PMTilesImageSource( options ) } );
+
+ }
+
+ shouldSplit( range ) {
+
+ // Vector archives can always split further for higher-resolution rasterization.
+ // Raster archives are capped at their max data zoom level.
+ if ( this.imageSource.isVectorTile ) {
+
+ return true;
+
+ } else {
+
+ return this.tiling.maxLevel > this.calculateLevel( range );
+
+ }
+
+ }
+
+}
diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js
index 3b63a5548..020b99e90 100644
--- a/src/three/plugins/images/sources/GeoJSONImageSource.js
+++ b/src/three/plugins/images/sources/GeoJSONImageSource.js
@@ -1,10 +1,13 @@
import { CanvasTexture, MathUtils, Vector3, SRGBColorSpace } from 'three';
import { RegionImageSource } from './RegionImageSource.js';
import { ProjectionScheme } from '../utils/ProjectionScheme.js';
+import { VectorShapeCanvasRenderer } from '../utils/VectorShapeCanvasRenderer.js';
import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three';
// TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters.
+const GEOMETRY_TYPES = new Set( [ 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon' ] );
+
// function for calculating the the change in arc length at a given cartographic point
// in order to preserve a circular look when drawing points
const _v0 = /* @__PURE__ */ new Vector3();
@@ -33,6 +36,12 @@ export class GeoJSONImageSource extends RegionImageSource {
strokeStyle = 'white',
strokeWidth = 2,
fillStyle = 'rgba( 255, 255, 255, 0.5 )',
+ getStyle = ( ( _feature, properties ) => ( {
+ fill: properties.fillStyle || this.fillStyle,
+ stroke: properties.strokeStyle || this.strokeStyle,
+ strokeWidth: properties.strokeWidth || this.strokeWidth,
+ radius: properties.pointRadius || this.pointRadius,
+ } ) ),
...rest
} = {} ) {
@@ -45,6 +54,7 @@ export class GeoJSONImageSource extends RegionImageSource {
this.strokeStyle = strokeStyle;
this.strokeWidth = strokeWidth;
this.fillStyle = fillStyle;
+ this.getStyle = getStyle;
this.features = null;
this.featureBounds = new Map();
@@ -52,6 +62,11 @@ export class GeoJSONImageSource extends RegionImageSource {
this.projection = new ProjectionScheme();
this.fetchData = ( ...args ) => fetch( ...args );
+ this._canvasRenderer = new VectorShapeCanvasRenderer( {
+ flipY: true,
+ getX: p => p[ 0 ],
+ getY: p => p[ 1 ],
+ } );
}
@@ -77,6 +92,8 @@ export class GeoJSONImageSource extends RegionImageSource {
// TODO: this won't get "dirtied" - no textures will be generated for those cases
// where "false" has already been returned on redraw. How to fix? Return a "false"
// target to fill in later if needed?
+ // TODO: we may want to include the LoD or resolution or something here, as well, since that will
+ // impact the size of the points, etc.
const boundsDeg = [ minX, minY, maxX, maxY ].map( v => v * Math.RAD2DEG );
return this._boundsIntersectBounds( boundsDeg, this.contentBounds );
@@ -119,6 +136,9 @@ export class GeoJSONImageSource extends RegionImageSource {
_updateCache( force = false ) {
+ // TODO: if we "bake" shapes or geometries to Path2Ds the redraw performance
+ // can improve by up to 2x.
+
const { geojson, featureBounds } = this;
if ( ! geojson || ( this.features && ! force ) ) {
@@ -135,7 +155,7 @@ export class GeoJSONImageSource extends RegionImageSource {
// extract the relevant features
this.features = this._featuresFromGeoJSON( geojson );
- this.features.forEach( feature => {
+ for ( const feature of this.features ) {
// save the feature bounds
const bounds = this._getFeatureBounds( feature );
@@ -148,7 +168,7 @@ export class GeoJSONImageSource extends RegionImageSource {
maxLon = Math.max( maxLon, fMaxLon );
maxLat = Math.max( maxLat, fMaxLat );
- } );
+ }
this.contentBounds = [ minLon, minLat, maxLon, maxLat ];
@@ -159,7 +179,7 @@ export class GeoJSONImageSource extends RegionImageSource {
this._updateCache();
const [ minX, minY, maxX, maxY ] = tokens;
- const { projection, resolution, features } = this;
+ const { projection, resolution, features, _canvasRenderer } = this;
canvas.width = resolution;
canvas.height = resolution;
@@ -176,16 +196,16 @@ export class GeoJSONImageSource extends RegionImageSource {
maxLatRad * MathUtils.RAD2DEG,
];
- // draw features
const ctx = canvas.getContext( '2d' );
- for ( let i = 0; i < features.length; i ++ ) {
+ _canvasRenderer.setFrame( ctx, regionBoundsDeg, regionBoundsDeg );
+
+ for ( const feature of features ) {
// TODO: Add support for padding of tiles to avoid clipping "wide" elements that may extend beyond
// edge of the bounds like stroke, point size.
- const feature = features[ i ];
if ( this._featureIntersectsTile( feature, regionBoundsDeg ) ) {
- this._drawFeatureOnCanvas( ctx, feature, regionBoundsDeg, canvas.width, canvas.height );
+ this._drawFeatureOnCanvas( feature, regionBoundsDeg, resolution );
}
@@ -268,7 +288,6 @@ export class GeoJSONImageSource extends RegionImageSource {
_featuresFromGeoJSON( root ) {
const type = root.type;
- const geomTypes = new Set( [ 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon' ] );
if ( type === 'FeatureCollection' ) {
@@ -282,7 +301,7 @@ export class GeoJSONImageSource extends RegionImageSource {
return root.geometries.map( g => ( { type: 'Feature', geometry: g, properties: {} } ) );
- } else if ( geomTypes.has( type ) ) {
+ } else if ( GEOMETRY_TYPES.has( type ) ) {
return [ { type: 'Feature', geometry: root, properties: {} } ];
@@ -295,184 +314,59 @@ export class GeoJSONImageSource extends RegionImageSource {
}
// draw feature on canvas ( assumes intersects already )
- _drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, width, height ) {
+ _drawFeatureOnCanvas( feature, tileBoundsDeg, height ) {
const { geometry = null, properties = {} } = feature;
if ( ! geometry ) {
- // A feature may have null geometry in GeoJSON
return;
}
- const [ minLonDeg, minLatDeg, maxLonDeg, maxLatDeg ] = tileBoundsDeg;
- const strokeStyle = properties.strokeStyle || this.strokeStyle;
- const fillStyle = properties.fillStyle || this.fillStyle;
- const pointRadius = properties.pointRadius || this.pointRadius;
- const strokeWidth = properties.strokeWidth || this.strokeWidth;
-
- ctx.save();
- ctx.strokeStyle = strokeStyle;
- ctx.fillStyle = fillStyle;
- ctx.lineWidth = strokeWidth;
-
- // Compute pixel from cartographic coordinates and tile bounds
- const arr = new Array( 2 );
- const projectPoint = ( lon, lat, target = arr ) => {
-
- // canvas y origin is top, projection y increases north -> flip
- const x = MathUtils.mapLinear( lon, minLonDeg, maxLonDeg, 0, width );
- const y = height - MathUtils.mapLinear( lat, minLatDeg, maxLatDeg, 0, height );
-
- // round to integer to gain performance
- // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#avoid_floating-point_coordinates_and_use_integers_instead
- target[ 0 ] = Math.round( x );
- target[ 1 ] = Math.round( y );
- return target;
-
- };
-
- const calculateAspectRatio = ( lon, lat ) => {
-
- // calculates the aspect ratio with which to draw points
- const latRad = lat * MathUtils.DEG2RAD;
- const lonRad = lon * MathUtils.DEG2RAD;
- const pxLat = ( maxLatDeg - minLatDeg ) / height;
- const pxLon = ( maxLonDeg - minLonDeg ) / width;
- const pixelRatio = pxLon / pxLat;
+ const [ , minLatDeg, , maxLatDeg ] = tileBoundsDeg;
+ const { _canvasRenderer } = this;
+ const style = this.getStyle( feature, properties );
- // TODO: this should use the ellipsoid defined on the relevant tiles renderer
- return pixelRatio * calculateArcRatioAtPoint( WGS84_ELLIPSOID, latRad, lonRad );
-
- };
+ _canvasRenderer.setStyle( style );
const type = geometry.type;
- if ( type === 'Point' ) {
-
- const [ lon, lat ] = geometry.coordinates;
- const [ px, py ] = projectPoint( lon, lat );
- const drawRatio = calculateAspectRatio( lon, lat );
- ctx.beginPath();
- ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 );
- ctx.fill();
- ctx.stroke();
+ if ( type === 'Point' || type === 'MultiPoint' ) {
- } else if ( type === 'MultiPoint' ) {
+ // Radius in geographic units (degrees) so the canvas transform handles positioning.
+ _canvasRenderer.radius = style.radius * ( maxLatDeg - minLatDeg ) / height;
+ const points = type === 'Point' ? [ geometry.coordinates ] : geometry.coordinates;
+ for ( const point of points ) {
- geometry.coordinates.forEach( ( [ lon, lat ] ) => {
+ // TODO: this should use the ellipsoid defined on the relevant tiles renderer
+ const arcRatio = calculateArcRatioAtPoint(
+ WGS84_ELLIPSOID,
+ point[ 1 ] * MathUtils.DEG2RAD,
+ point[ 0 ] * MathUtils.DEG2RAD,
+ );
+ const pointGroup = [ point ];
+ _canvasRenderer._renderPoints( [ pointGroup ], arcRatio );
- const [ px, py ] = projectPoint( lon, lat );
- const drawRatio = calculateAspectRatio( lon, lat );
-
- ctx.beginPath();
- ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 );
- ctx.fill();
- ctx.stroke();
-
- } );
+ }
} else if ( type === 'LineString' ) {
- ctx.beginPath();
- geometry.coordinates.forEach( ( [ lon, lat ], i ) => {
-
- const [ px, py ] = projectPoint( lon, lat );
- if ( i === 0 ) {
-
- ctx.moveTo( px, py );
-
- } else {
-
- ctx.lineTo( px, py );
-
- }
-
- } );
-
- ctx.stroke();
+ _canvasRenderer._renderLines( [ geometry.coordinates ] );
} else if ( type === 'MultiLineString' ) {
- ctx.beginPath();
- geometry.coordinates.forEach( ( line ) => {
-
- line.forEach( ( [ lon, lat ], i ) => {
-
- const [ px, py ] = projectPoint( lon, lat );
- if ( i === 0 ) {
-
- ctx.moveTo( px, py );
-
- } else {
-
- ctx.lineTo( px, py );
-
- }
-
- } );
-
- } );
- ctx.stroke();
+ _canvasRenderer._renderLines( geometry.coordinates );
} else if ( type === 'Polygon' ) {
- ctx.beginPath();
- geometry.coordinates.forEach( ( ring, rIndex ) => {
-
- ring.forEach( ( [ lon, lat ], i ) => {
-
- const [ px, py ] = projectPoint( lon, lat );
- if ( i === 0 ) {
-
- ctx.moveTo( px, py );
-
- } else {
-
- ctx.lineTo( px, py );
-
- }
-
- } );
- ctx.closePath();
-
- } );
- ctx.fill( 'evenodd' );
- ctx.stroke();
+ _canvasRenderer._renderPolygons( geometry.coordinates );
} else if ( type === 'MultiPolygon' ) {
- geometry.coordinates.forEach( ( polygon ) => {
-
- ctx.beginPath();
- polygon.forEach( ( ring, rIndex ) => {
-
- ring.forEach( ( [ lon, lat ], i ) => {
-
- const [ px, py ] = projectPoint( lon, lat );
- if ( i === 0 ) {
-
- ctx.moveTo( px, py );
-
- } else {
-
- ctx.lineTo( px, py );
-
- }
-
- } );
- ctx.closePath();
-
- } );
- ctx.fill( 'evenodd' );
- ctx.stroke();
-
- } );
+ geometry.coordinates.forEach( polygon => _canvasRenderer._renderPolygons( polygon ) );
}
- ctx.restore();
-
}
}
diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js
new file mode 100644
index 000000000..d1624676a
--- /dev/null
+++ b/src/three/plugins/images/sources/MVTImageSource.js
@@ -0,0 +1,317 @@
+import { CanvasTexture, SRGBColorSpace } from 'three';
+import { RegionImageSource } from './RegionImageSource.js';
+import { DataCache } from '../utils/DataCache.js';
+import { VectorShapeCanvasRenderer } from '../utils/VectorShapeCanvasRenderer.js';
+import { TilingScheme } from '../utils/TilingScheme.js';
+import { ProjectionScheme } from '../utils/ProjectionScheme.js';
+import { forEachTileInBounds } from '../overlays/utils.js';
+
+let _mvtImport = null;
+function importMVTDeps() {
+
+ return _mvtImport ??= Promise.all( [
+ import( '@mapbox/vector-tile' ),
+ import( 'pbf' ),
+ ] ).then( ( [ { VectorTile }, { default: Protobuf } ] ) => {
+
+ return { VectorTile, Protobuf };
+
+ } );
+
+}
+
+// Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl).
+export class MVTContentCache extends DataCache {
+
+ constructor( options = {} ) {
+
+ super();
+
+ const {
+ url = null,
+ levels = 20,
+ projection = 'EPSG:3857',
+ } = options;
+
+ this.url = url;
+ this.levels = levels;
+ this.projectionId = projection;
+
+ this.tiling = new TilingScheme();
+ this.fetchData = ( ...args ) => fetch( ...args );
+ this.fetchOptions = {};
+
+ }
+
+ init() {
+
+ const { tiling, levels, url, projectionId } = this;
+ tiling.flipY = ! /{\s*reverseY|-\s*y\s*}/g.test( url );
+ tiling.setProjection( new ProjectionScheme( projectionId ) );
+ tiling.setContentBounds( ...tiling.projection.getBounds() );
+
+ // 512 is used as the reference tile size for zoom level selection, matching the approach
+ // used by Maplibre GL JS (see coveringZoomLevel in maplibre-gl-js/src/geo/projection/covering_tiles.ts).
+ const TILE_SIZE = 512;
+ if ( Array.isArray( levels ) ) {
+
+ levels.forEach( ( info, level ) => {
+
+ if ( info !== null ) {
+
+ tiling.setLevel( level, {
+ tilePixelWidth: TILE_SIZE,
+ tilePixelHeight: TILE_SIZE,
+ ...info,
+ } );
+
+ }
+
+ } );
+
+ } else {
+
+ tiling.generateLevels( levels, tiling.projection.tileCountX, tiling.projection.tileCountY, {
+ tilePixelWidth: TILE_SIZE,
+ tilePixelHeight: TILE_SIZE,
+ } );
+
+ }
+
+ return Promise.resolve();
+
+ }
+
+ async fetchItem( [ tx, ty, tl ], signal ) {
+
+ const url = this.getUrl( tx, ty, tl );
+ const res = await this.fetchData( url, { ...this.fetchOptions, signal } );
+ const buffer = await res.arrayBuffer();
+ return this._parseVectorTile( buffer );
+
+ }
+
+ async _parseVectorTile( buffer ) {
+
+ if ( ! buffer || buffer.byteLength === 0 ) {
+
+ return null;
+
+ }
+
+ const { VectorTile, Protobuf } = await importMVTDeps();
+ return new VectorTile( new Protobuf( buffer ) );
+
+ }
+
+ // Parsed JS objects — nothing to dispose
+ disposeItem() {}
+
+ getUrl( x, y, level ) {
+
+ return this.url
+ .replace( /{\s*z\s*}/gi, level )
+ .replace( /{\s*x\s*}/gi, x )
+ .replace( /{\s*(y|reverseY|-\s*y)\s*}/gi, y );
+
+ }
+
+}
+
+export class MVTImageSource extends RegionImageSource {
+
+ get tiling() {
+
+ return this._contentCache.tiling;
+
+ }
+
+ get fetchData() {
+
+ return this._contentCache.fetchData;
+
+ }
+
+ set fetchData( v ) {
+
+ this._contentCache.fetchData = v;
+
+ }
+
+ constructor( options = {} ) {
+
+ const {
+ resolution = 512,
+ getStyle = () => null,
+ contentCache,
+ ...rest
+ } = options;
+
+ super();
+
+ this.resolution = resolution;
+ this.getStyle = getStyle;
+ this._canvasRenderer = new VectorShapeCanvasRenderer( { tileExtent: 4096 } );
+ this._contentCache = contentCache ?? new MVTContentCache( rest );
+
+ }
+
+ init() {
+
+ return this._contentCache.init();
+
+ }
+
+ hasContent( minX, minY, maxX, maxY, level ) {
+
+ let count = 0;
+ forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, () => count ++ );
+ return count > 0;
+
+ }
+
+ async fetchItem( [ minX, minY, maxX, maxY, level ], _signal ) {
+
+ const { resolution } = this;
+ const canvas = document.createElement( 'canvas' );
+ canvas.width = resolution;
+ canvas.height = resolution;
+
+ const ctx = canvas.getContext( '2d' );
+ const regionBounds = [ minX, minY, maxX, maxY ];
+ const { _contentCache, _canvasRenderer } = this;
+
+ const promises = [];
+ forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => {
+
+ promises.push( ( async () => {
+
+ const vectorTile = await _contentCache.lock( tx, ty, tl );
+ if ( vectorTile ) {
+
+ const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false );
+ _canvasRenderer.setFrame( ctx, tileBounds, regionBounds );
+ this._renderVectorTile( vectorTile );
+
+ }
+
+ } )() );
+
+ } );
+
+ await Promise.all( promises );
+
+ const tex = new CanvasTexture( canvas );
+ tex.colorSpace = SRGBColorSpace;
+ tex.generateMipmaps = false;
+ tex.needsUpdate = true;
+ tex._regionArgs = [ minX, minY, maxX, maxY, level ];
+ return tex;
+
+ }
+
+ disposeItem( texture ) {
+
+ const [ minX, minY, maxX, maxY, level ] = texture._regionArgs;
+ forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, ( tx, ty, tl ) => {
+
+ this._contentCache.release( tx, ty, tl );
+
+ } );
+
+ texture.dispose();
+
+ }
+
+ _renderVectorTile( vectorTile ) {
+
+ const { _canvasRenderer, getStyle } = this;
+
+ // Sort layers by user-defined order, falling back to alphabetical.
+ const layerNames = [ ...Object.keys( vectorTile.layers ) ].sort( ( a, b ) => {
+
+ if ( getStyle ) {
+
+ const orderA = getStyle( a, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order;
+ const orderB = getStyle( b, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order;
+ if ( orderA !== orderB ) return orderA - orderB;
+
+ }
+
+ return a.localeCompare( b );
+
+ } );
+
+ // render each layer
+ for ( const layerName of layerNames ) {
+
+ const layer = vectorTile.layers[ layerName ];
+
+ for ( let i = 0; i < layer.length; i ++ ) {
+
+ const feature = layer.feature( i );
+ const { properties, type } = feature;
+
+ // Apply per-feature style; skip invisible features.
+ const style = getStyle( layerName, properties );
+ _canvasRenderer.setStyle( style );
+
+ // Dispatch to the appropriate draw primitive (1=point, 2=line, 3=polygon).
+ const geometry = feature.loadGeometry();
+ if ( type === 1 ) {
+
+ _canvasRenderer._renderPoints( geometry );
+
+ } else if ( type === 2 ) {
+
+ _canvasRenderer._renderLines( geometry );
+
+ } else if ( type === 3 ) {
+
+ _canvasRenderer._renderPolygons( geometry );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ redraw() {
+
+ this.forEachItem( ( tex, args ) => {
+
+ const [ minX, minY, maxX, maxY, level ] = args;
+ const regionBounds = [ minX, minY, maxX, maxY ];
+ const canvas = tex.image;
+ const ctx = canvas.getContext( '2d' );
+ ctx.clearRect( 0, 0, canvas.width, canvas.height );
+
+ forEachTileInBounds( regionBounds, level, this._contentCache.tiling, ( tx, ty, tl ) => {
+
+ const vectorTile = this._contentCache.get( tx, ty, tl );
+ if ( vectorTile ) {
+
+ const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false );
+ this._canvasRenderer.setFrame( ctx, tileBounds, regionBounds );
+ this._renderVectorTile( vectorTile );
+
+ }
+
+ } );
+
+ tex.needsUpdate = true;
+
+ } );
+
+ }
+
+ dispose() {
+
+ super.dispose();
+ this._contentCache.dispose();
+
+ }
+
+}
diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js
new file mode 100644
index 000000000..06d5ee808
--- /dev/null
+++ b/src/three/plugins/images/sources/PMTilesImageSource.js
@@ -0,0 +1,252 @@
+import { MVTContentCache, MVTImageSource } from './MVTImageSource.js';
+import { TiledImageSource } from './TiledImageSource.js';
+import { RegionImageSource, TiledRegionImageSource } from './RegionImageSource.js';
+import { ProjectionScheme } from '../utils/ProjectionScheme.js';
+
+const DEG2RAD = Math.PI / 180;
+
+let _pmtilesImport = null;
+function importPMTiles() {
+
+ return _pmtilesImport ??= import( 'pmtiles' ).then( m => m.PMTiles );
+
+}
+
+// Fetches individual raster tiles from a PMTiles instance and converts them to textures.
+class PMTilesRasterTileSource extends TiledImageSource {
+
+ constructor( instance, tiling ) {
+
+ super();
+ this.instance = instance;
+ this.tiling = tiling;
+
+ }
+
+ async fetchItem( [ tx, ty, tl ], signal ) {
+
+ const res = await this.instance.getZxy( tl, tx, ty, signal );
+ if ( ! res || ! res.data || res.data.byteLength === 0 ) {
+
+ return null;
+
+ } else {
+
+ return this.processBufferToTexture( res.data );
+
+ }
+
+ }
+
+}
+
+class PMTilesContentCache extends MVTContentCache {
+
+ constructor( options = {} ) {
+
+ super( options );
+ this.instance = null;
+ this.tileType = 1;
+
+ }
+
+ async init() {
+
+ const { tiling } = this;
+
+ const PMTiles = await importPMTiles();
+
+ // Custom Source routes all PMTiles range requests through fetchData so they
+ // go through the shared download queue rather than PMTiles' internal fetch.
+ this.instance = new PMTiles( {
+ getKey: () => this.url,
+ getBytes: async ( offset, length, signal ) => {
+
+ const { fetchOptions, url } = this;
+ const res = await this.fetchData( url, {
+ ...fetchOptions,
+ signal,
+ headers: {
+ ...fetchOptions.headers,
+ range: `bytes=${ offset }-${ offset + length - 1 }`,
+ },
+ } );
+
+ if ( ! res.ok ) {
+
+ throw new Error( `PMTilesImageSource: Bad response code: ${ res.status }` );
+
+ }
+
+ if ( res.status !== 206 ) {
+
+ throw new Error( 'PMTilesImageSource: Server does not support HTTP Byte Serving.' );
+
+ }
+
+ return {
+ data: await res.arrayBuffer(),
+ etag: res.headers.get( 'ETag' ),
+ cacheControl: res.headers.get( 'Cache-Control' ),
+ expires: res.headers.get( 'Expires' ),
+ };
+
+ },
+ } );
+
+ const header = await this.instance.getHeader();
+ this.tileType = header.tileType;
+
+ const projection = new ProjectionScheme( 'EPSG:3857' );
+
+ tiling.flipY = true;
+ tiling.setProjection( projection );
+ tiling.setContentBounds(
+ DEG2RAD * header.minLon,
+ DEG2RAD * header.minLat,
+ DEG2RAD * header.maxLon,
+ DEG2RAD * header.maxLat,
+ );
+ tiling.generateLevels( header.maxZoom + 1, projection.tileCountX, projection.tileCountY, {
+ tilePixelWidth: 512,
+ tilePixelHeight: 512,
+ minLevel: header.minZoom,
+ } );
+
+ }
+
+ async fetchItem( [ tx, ty, tl ], signal ) {
+
+ const res = await this.instance.getZxy( tl, tx, ty, signal );
+ return this._parseVectorTile( res ? res.data : null );
+
+ }
+
+}
+
+export class PMTilesImageSource extends RegionImageSource {
+
+ get tiling() {
+
+ return this._contentCache.tiling;
+
+ }
+
+ get fetchData() {
+
+ return this._contentCache.fetchData;
+
+ }
+
+ set fetchData( v ) {
+
+ this._contentCache.fetchData = v;
+
+ }
+
+ get resolution() {
+
+ return this._resolution;
+
+ }
+
+ set resolution( v ) {
+
+ this._resolution = v;
+ if ( this._deferredSource ) {
+
+ this._deferredSource.resolution = v;
+
+ }
+
+ }
+
+ constructor( options = {} ) {
+
+ super();
+
+ const {
+ resolution = 512,
+ getStyle = () => null,
+ } = options;
+
+ this._resolution = resolution;
+ this._getStyle = getStyle;
+ this._contentCache = new PMTilesContentCache( options );
+ this._deferredSource = null;
+ this.isVectorTile = false;
+
+ }
+
+ async init() {
+
+ await this._contentCache.init();
+ const { _contentCache } = this;
+
+ this.isVectorTile = _contentCache.tileType === 1;
+
+ if ( this.isVectorTile ) {
+
+ this._deferredSource = new MVTImageSource( {
+ resolution: this._resolution,
+ getStyle: this._getStyle,
+ contentCache: _contentCache,
+ } );
+
+ } else {
+
+ const rasterSource = new PMTilesRasterTileSource( _contentCache.instance, _contentCache.tiling );
+ this._deferredSource = new TiledRegionImageSource( rasterSource );
+ this._deferredSource.resolution = this._resolution;
+
+ }
+
+ }
+
+ hasContent( minX, minY, maxX, maxY, level ) {
+
+ return this._deferredSource.hasContent( minX, minY, maxX, maxY, level );
+
+ }
+
+ lock( ...args ) {
+
+ return this._deferredSource.lock( ...args );
+
+ }
+
+ release( ...args ) {
+
+ this._deferredSource.release( ...args );
+
+ }
+
+ get( ...args ) {
+
+ return this._deferredSource.get( ...args );
+
+ }
+
+ redraw() {
+
+ if ( this._deferredSource instanceof MVTImageSource ) {
+
+ this._deferredSource.redraw();
+
+ }
+
+ }
+
+ dispose() {
+
+ super.dispose();
+ this._contentCache.dispose();
+ if ( this._deferredSource ) {
+
+ this._deferredSource.dispose();
+
+ }
+
+ }
+
+}
diff --git a/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js
new file mode 100644
index 000000000..927abf379
--- /dev/null
+++ b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js
@@ -0,0 +1,247 @@
+/**
+ * @typedef {Object} VectorTileStyle
+ * @property {string} [fill='#cccccc'] CSS fill color.
+ * @property {string} [stroke='transparent'] CSS stroke color.
+ * @property {number} [strokeWidth=1] Stroke width in pixels.
+ * @property {number} [radius=2] Point radius in pixels.
+ * @property {number} [order=0] Layer draw order; lower values are drawn first.
+ * @property {boolean} [visible=true] Whether the feature is rendered.
+ */
+
+
+const DEFAULT_STYLE = Object.freeze( {
+ fill: '#cccccc',
+ stroke: 'transparent',
+ strokeWidth: 1,
+ radius: 2,
+ order: 0,
+ visible: true,
+} );
+
+export class VectorShapeCanvasRenderer {
+
+ static get DEFAULT_STYLE() {
+
+ return DEFAULT_STYLE;
+
+ }
+
+ get fill() {
+
+ return this._ctx.fillStyle;
+
+ }
+
+ set fill( v ) {
+
+ this._ctx.fillStyle = v;
+
+ }
+
+ get stroke() {
+
+ return this._ctx.strokeStyle;
+
+ }
+
+ set stroke( v ) {
+
+ this._ctx.strokeStyle = v;
+
+ }
+
+ get strokeWidth() {
+
+ return this._ctx.lineWidth;
+
+ }
+
+ set strokeWidth( v ) {
+
+ this._ctx.lineWidth = v;
+
+ }
+
+ constructor( options = {} ) {
+
+ const {
+ getX = p => p.x,
+ getY = p => p.y,
+ flipY = false,
+ tileExtent = null,
+ } = options;
+
+ this.getX = getX;
+ this.getY = getY;
+
+ // flipY: true for Y-up coordinate systems (geographic degrees).
+ // tileExtent: fixed local-space size of each tile (e.g. 4096 for MVT).
+ // null means the tile's local space spans [tMinX..tMaxX] / [tMinY..tMaxY] directly.
+ this.flipY = flipY;
+ this.tileExtent = tileExtent;
+
+ // styles
+ this.radius = DEFAULT_STYLE.radius;
+ this.visible = true;
+
+ this._invScale = 1;
+ this._ctx = null;
+
+ }
+
+ // Sets up the canvas transform and clip for a tile.
+ // tileBounds and regionBounds are in the same coordinate space as getX/getY returns.
+ setFrame( ctx, tileBounds, regionBounds ) {
+
+ ctx.restore();
+
+ const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds;
+ const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds;
+ const { width, height } = ctx.canvas;
+ const { flipY, tileExtent } = this;
+
+ // Tile span in local coordinate space: either a fixed extent (e.g. 4096 for MVT)
+ // or the tile's own span in source coords (for geographic).
+ const spanX = tileExtent ?? ( tMaxX - tMinX );
+ const spanY = tileExtent ?? ( tMaxY - tMinY );
+
+ // Round all four tile edges to integer pixel positions so adjacent clip rects share
+ // the exact same boundary pixel — preventing sub-pixel gaps or overlaps at seams.
+ const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) );
+ const tileRight = Math.round( width * ( tMaxX - rMinX ) / ( rMaxX - rMinX ) );
+ const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) );
+ const tileBottom = Math.round( height * ( rMaxY - tMinY ) / ( rMaxY - rMinY ) );
+
+ // Derive scale from rounded pixel dimensions so geometry fills exactly the rounded clip rect.
+ const scaleX = ( tileRight - tileLeft ) / spanX;
+ const scaleY = ( flipY ? - 1 : 1 ) * ( tileBottom - tileTop ) / spanY;
+
+ // Tile-local coordinate at the tile's canvas corner.
+ // Fixed-extent tiles (e.g. MVT) start at (0, 0); geographic tiles start at the tile bounds corner.
+ // For Y-up (flipY) the canvas top corresponds to tMaxY, not tMinY.
+ const localOriginX = tileExtent ? 0 : tMinX;
+ const localOriginY = tileExtent ? 0 : ( flipY ? tMaxY : tMinY );
+ const offsetX = tileLeft - localOriginX * scaleX;
+ const offsetY = tileTop - localOriginY * scaleY;
+
+ ctx.save();
+ ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY );
+
+ ctx.beginPath();
+ ctx.rect( localOriginX, tileExtent ? 0 : tMinY, spanX, spanY );
+ ctx.clip();
+
+ this._ctx = ctx;
+ this._invScale = 1 / scaleX;
+
+ }
+
+ // Applies a style object (as returned by getStyle) to the current canvas context.
+ setStyle( style ) {
+
+ const { _invScale } = this;
+ this.fill = style?.fill ?? DEFAULT_STYLE.fill;
+ this.stroke = style?.stroke ?? DEFAULT_STYLE.stroke;
+ this.strokeWidth = ( style?.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale;
+ this.radius = ( style?.radius ?? DEFAULT_STYLE.radius ) * _invScale;
+ this.visible = style?.visible ?? DEFAULT_STYLE.visible;
+
+ }
+
+ _renderPoints( geometry, aspectRatio = 1 ) {
+
+ const { _ctx, radius, getX, getY, visible } = this;
+ if ( ! visible ) {
+
+ return;
+
+ }
+
+ for ( const multiPoint of geometry ) {
+
+ for ( const p of multiPoint ) {
+
+ const x = getX( p ), y = getY( p );
+ _ctx.beginPath();
+ _ctx.ellipse( x, y, radius / aspectRatio, radius, 0, 0, Math.PI * 2 );
+ _ctx.fill();
+
+ }
+
+ }
+
+ _ctx.stroke();
+
+ }
+
+ _renderLines( geometry ) {
+
+ const { _ctx, getX, getY, visible } = this;
+ if ( ! visible ) {
+
+ return;
+
+ }
+
+ if ( geometry instanceof Path2D ) {
+
+ _ctx.stroke( geometry );
+ return;
+
+ }
+
+ _ctx.beginPath();
+
+ for ( const ring of geometry ) {
+
+ for ( let k = 0; k < ring.length; k ++ ) {
+
+ if ( k === 0 ) _ctx.moveTo( getX( ring[ k ] ), getY( ring[ k ] ) );
+ else _ctx.lineTo( getX( ring[ k ] ), getY( ring[ k ] ) );
+
+ }
+
+ }
+
+ _ctx.stroke();
+
+ }
+
+ _renderPolygons( geometry ) {
+
+ const { _ctx, getX, getY, visible } = this;
+ if ( ! visible ) {
+
+ return;
+
+ }
+
+ if ( geometry instanceof Path2D ) {
+
+ _ctx.fill( geometry, 'evenodd' );
+ _ctx.stroke( geometry );
+ return;
+
+ }
+
+ _ctx.beginPath();
+
+ for ( const ring of geometry ) {
+
+ for ( let k = 0; k < ring.length; k ++ ) {
+
+ if ( k === 0 ) _ctx.moveTo( getX( ring[ k ] ), getY( ring[ k ] ) );
+ else _ctx.lineTo( getX( ring[ k ] ), getY( ring[ k ] ) );
+
+ }
+
+ _ctx.closePath();
+
+ }
+
+ _ctx.fill( 'evenodd' );
+ _ctx.stroke();
+
+ }
+
+}
diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js
index 3ce6fb8ac..92992263f 100644
--- a/src/three/plugins/index.js
+++ b/src/three/plugins/index.js
@@ -17,6 +17,7 @@ export * from './DebugTilesPlugin.js';
export * from './images/GeneratedSurfacePlugin.js';
export * from './images/DeepZoomImagePlugin.js';
export * from './images/EPSGTilesPlugin.js';
+export * from './images/MVTOverlay.js';
// gltf extensions
export * from './gltf/GLTFCesiumRTCExtension.js';
diff --git a/utils/docs/RenderDocsUtils.js b/utils/docs/RenderDocsUtils.js
index 089820839..914a484a6 100644
--- a/utils/docs/RenderDocsUtils.js
+++ b/utils/docs/RenderDocsUtils.js
@@ -410,11 +410,12 @@ export function renderTypedef( typeDoc, callbackMap = {}, resolveLink = null, he
for ( const prop of ( typeDoc.properties || [] ) ) {
const type = formatType( prop.type, callbackMap );
- const optional = prop.optional ? '?' : '';
+ const optional = prop.optional && prop.defaultvalue === undefined ? '?' : '';
+ const defStr = prop.defaultvalue !== undefined ? ` = ${ prop.defaultvalue }` : '';
lines.push( `${ hSub } .${ prop.name }` );
lines.push( '' );
lines.push( '```js' );
- lines.push( `${ prop.name }${ optional }: ${ type }` );
+ lines.push( `${ prop.name }${ optional }${ defStr }: ${ type }` );
lines.push( '```' );
lines.push( '' );