diff --git a/example/images/14-8801-5371.vector-expected.png b/example/images/14-8801-5371.vector-expected.png
new file mode 100644
index 000000000..77a9128f0
Binary files /dev/null and b/example/images/14-8801-5371.vector-expected.png differ
diff --git a/example/public/data/14-8801-5371.vector.pbf b/example/public/data/14-8801-5371.vector.pbf
new file mode 100644
index 000000000..820865545
Binary files /dev/null and b/example/public/data/14-8801-5371.vector.pbf differ
diff --git a/example/three/geojson.js b/example/three/geojson.js
index d92ac8277..f02a37428 100644
--- a/example/three/geojson.js
+++ b/example/three/geojson.js
@@ -60,7 +60,7 @@ const geojson = {
],
},
- properties: { name: 'Berlin Polygon' },
+ properties: { name: 'Genoa Polygon' },
}
diff --git a/example/three/mvt.html b/example/three/mvt.html
new file mode 100644
index 000000000..f8450ade7
--- /dev/null
+++ b/example/three/mvt.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ MVT Loader Debug
+
+
+
+
+ MVT Loader Debug
+ Visualizing Raw Tile Coordinates
+
+
+
+
\ No newline at end of file
diff --git a/example/three/mvt.js b/example/three/mvt.js
new file mode 100644
index 000000000..07687fcd7
--- /dev/null
+++ b/example/three/mvt.js
@@ -0,0 +1,181 @@
+import {
+ Scene,
+ WebGLRenderer,
+ PerspectiveCamera,
+ GridHelper,
+ AxesHelper,
+ TextureLoader,
+ PlaneGeometry,
+ MeshBasicMaterial,
+ Mesh,
+ SRGBColorSpace,
+ FrontSide
+} from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { MVTLoader } from '../../src/three/renderer/loaders/MVTLoader.js';
+import { MVTImageSource } from '../../src/three/plugins/images/sources/MVTImageSource.js';
+import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
+
+// --- Configuration & State ---
+const CONFIG = {
+ TILE_SIZE: 4096,
+ PBF_PATH: '../data/14-8801-5371.vector.pbf',
+ EXPECTED_PNG_PATH: '/images/14-8801-5371.vector-expected.png'
+};
+
+const state = {
+ showExpected: true,
+ showGeneratedTexture: false,
+ showMeshScene: true,
+};
+
+const layers = {
+ expectedPlane: null,
+ generatedPlane: null,
+ meshGroup: null
+};
+
+let scene, renderer, camera, controls, gui;
+
+// --- Initialization ---
+init();
+setupGUI();
+loadData();
+render();
+
+function init() {
+
+ renderer = new WebGLRenderer( { antialias: true } );
+ 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, 1, 100000 );
+ camera.position.set( 2048, 4000, 4000 );
+
+ const grid = new GridHelper( CONFIG.TILE_SIZE, 8, 0xff0000, 0x444444 );
+ grid.position.set( 2048, 0, 2048 );
+ scene.add( grid );
+
+ scene.add( new AxesHelper( 500 ) );
+
+ controls = new OrbitControls( camera, renderer.domElement );
+ controls.target.set( 2048, 0, 2048 );
+ controls.update();
+
+ window.addEventListener( 'resize', onWindowResize, false );
+
+}
+
+// --- Loading Logic ---
+async function loadData() {
+
+ const textureLoader = new TextureLoader();
+ const mvtLoader = new MVTLoader();
+ const imageSource = new MVTImageSource();
+
+ // 1. Load Expected PNG Reference
+ textureLoader.load( CONFIG.EXPECTED_PNG_PATH, ( texture ) => {
+
+ texture.colorSpace = SRGBColorSpace;
+ layers.expectedPlane = createDisplayPlane( texture, - 5 );
+ layers.expectedPlane.visible = state.showExpected;
+ scene.add( layers.expectedPlane );
+
+ } );
+
+ // 2. Load Generated Texture from PBF
+ try {
+
+ const res = await imageSource.fetchData( CONFIG.PBF_PATH );
+ const buffer = await res.arrayBuffer();
+ const texture = await imageSource.processBufferToTexture( buffer );
+
+ layers.generatedPlane = createDisplayPlane( texture, - 2 );
+ layers.generatedPlane.visible = state.showGeneratedTexture;
+ scene.add( layers.generatedPlane );
+
+ } catch ( err ) {
+
+ console.error( 'Error generating texture:', err );
+
+ }
+
+ // 3. Load 3D Mesh Scene
+ try {
+
+ const result = await mvtLoader.loadAsync( CONFIG.PBF_PATH );
+ layers.meshGroup = result.scene;
+ layers.meshGroup.rotation.x = - Math.PI / 2;
+ layers.meshGroup.visible = state.showMeshScene;
+ scene.add( layers.meshGroup );
+
+ } catch ( err ) {
+
+ console.error( 'Error loading MVT Mesh:', err );
+
+ }
+
+}
+
+/**
+ * Helper to create a flat plane for textures
+ */
+function createDisplayPlane( texture, yOffset ) {
+
+ const geometry = new PlaneGeometry( CONFIG.TILE_SIZE, CONFIG.TILE_SIZE );
+ const material = new MeshBasicMaterial( {
+ map: texture,
+ side: FrontSide,
+ transparent: true,
+ opacity: 0.7
+ } );
+ const plane = new Mesh( geometry, material );
+ plane.rotation.x = - Math.PI / 2;
+ plane.position.set( CONFIG.TILE_SIZE / 2, yOffset, CONFIG.TILE_SIZE / 2 );
+ return plane;
+
+}
+
+// --- GUI Setup ---
+function setupGUI() {
+
+ gui = new GUI();
+
+ gui.add( state, 'showExpected' ).name( 'Expected PNG' ).onChange( v => {
+
+ if ( layers.expectedPlane ) layers.expectedPlane.visible = v;
+
+ } );
+
+ gui.add( state, 'showGeneratedTexture' ).name( 'Generated Texture' ).onChange( v => {
+
+ if ( layers.generatedPlane ) layers.generatedPlane.visible = v;
+
+ } );
+
+ gui.add( state, 'showMeshScene' ).name( 'MVT Mesh Scene' ).onChange( v => {
+
+ if ( layers.meshGroup ) layers.meshGroup.visible = v;
+
+ } );
+
+}
+
+// --- Standard Boilerplate ---
+function onWindowResize() {
+
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize( window.innerWidth, window.innerHeight );
+
+}
+
+function render() {
+
+ requestAnimationFrame( render );
+ renderer.render( scene, camera );
+
+}
diff --git a/example/three/mvt_globe.html b/example/three/mvt_globe.html
new file mode 100644
index 000000000..f0c287e32
--- /dev/null
+++ b/example/three/mvt_globe.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ MVT Globe Debug
+
+
+
+
+ MVT Globe Debug
+ Switch between mesh or textured globe with MVT tiles.
+
+
+
+
\ No newline at end of file
diff --git a/example/three/mvt_globe.js b/example/three/mvt_globe.js
new file mode 100644
index 000000000..af3060bfb
--- /dev/null
+++ b/example/three/mvt_globe.js
@@ -0,0 +1,338 @@
+import {
+ Scene,
+ WebGLRenderer,
+ PerspectiveCamera,
+ AmbientLight,
+ DirectionalLight,
+} from 'three';
+import {
+ TilesRenderer,
+ GlobeControls,
+} from '3d-tiles-renderer';
+import {
+ UpdateOnChangePlugin,
+ MVTTilesPlugin,
+ MVTTilesMeshPlugin,
+ PMTilesPlugin,
+ PMTilesMeshPlugin
+} from '3d-tiles-renderer/plugins';
+
+import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
+
+let scene, renderer, camera, controls, tiles, gui;
+let layersFolder = null;
+let colorsFolder = null;
+
+// --- Source Presets ---
+// Each provider has different layer names and styling conventions
+const SOURCE_PRESETS = {
+ PMTiles: {
+ name: 'Protomaps PMTiles',
+ url: 'https://demo-bucket.protomaps.com/v4.pmtiles',
+ requiresApiKey: false,
+ // Protomaps layer names (v4 basemap)
+ layers: {
+ water: { name: 'water', enabled: true, color: '#4a90d9' },
+ earth: { name: 'earth', enabled: true, color: '#f2efe9' },
+ landuse: { name: 'landuse', enabled: false, color: '#e8e4d8' },
+ landcover: { name: 'landcover', enabled: false, color: '#d4e8c2' },
+ natural: { name: 'natural', enabled: false, color: '#c8d9af' },
+ roads: { name: 'roads', enabled: false, color: '#ffffff' },
+ buildings: { name: 'buildings', enabled: false, color: '#d9d0c9' },
+ transit: { name: 'transit', enabled: false, color: '#888888' },
+ boundaries: { name: 'boundaries', enabled: true, color: '#ff6b6b' },
+ places: { name: 'places', enabled: true, color: '#333333' },
+ pois: { name: 'pois', enabled: false, color: '#7d4e24' },
+ },
+ defaultColor: '#cccccc'
+ },
+ MVT: {
+ name: 'Mapbox Streets',
+ urlTemplate: 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=',
+ requiresApiKey: true,
+ // Mapbox Streets v8 layer names
+ layers: {
+ water: { name: 'water', enabled: true, color: '#4a90d9' },
+ waterway: { name: 'waterway', enabled: true, color: '#4a90d9' },
+ landuse: { name: 'landuse', enabled: false, color: '#e8e4d8' },
+ landuse_overlay: { name: 'landuse_overlay', enabled: false, color: '#d4e8c2' },
+ park: { name: 'park', enabled: false, color: '#c8d9af' },
+ natural_label: { name: 'natural_label', enabled: false, color: '#5d8a3e' },
+ road: { name: 'road', enabled: false, color: '#ffffff' },
+ building: { name: 'building', enabled: false, color: '#d9d0c9' },
+ transit: { name: 'transit', enabled: false, color: '#888888' },
+ boundaries: { name: 'admin', enabled: true, color: '#ff6b6b' },
+ place_label: { name: 'place_label', enabled: true, color: '#333333' },
+ poi_label: { name: 'poi_label', enabled: false, color: '#7d4e24' },
+ },
+ defaultColor: '#cccccc'
+ }
+};
+
+// Mapbox API key - stored in localStorage for convenience
+let apiKey = localStorage.getItem( 'mapbox_key' ) || '';
+
+// --- Application State ---
+const state = {
+ sourceType: 'PMTiles',
+ renderMode: 'Texture',
+ // Layer visibility (populated from preset)
+ layers: {},
+ // Layer colors (populated from preset)
+ colors: {},
+ // Filter settings
+ maxSymbolRank: 3,
+};
+
+// Initialize state from default preset
+function initStateFromPreset( presetName ) {
+
+ const preset = SOURCE_PRESETS[ presetName ];
+ state.layers = {};
+ state.colors = {};
+
+ for ( const key in preset.layers ) {
+
+ const layer = preset.layers[ key ];
+ state.layers[ key ] = layer.enabled;
+ state.colors[ layer.name ] = layer.color;
+
+ }
+
+ state.colors.default = preset.defaultColor;
+
+}
+
+initStateFromPreset( state.sourceType );
+
+init();
+setupGUI();
+recreateTiles();
+
+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( preset ) {
+
+ const layerNameToKey = {};
+ for ( const key in preset.layers ) {
+
+ layerNameToKey[ preset.layers[ key ].name ] = key;
+
+ }
+
+ return function ( feature, layerName ) {
+
+ const key = layerNameToKey[ layerName ];
+
+ // If layer is known, check if enabled
+ if ( key !== undefined ) {
+
+ return state.layers[ key ] === true;
+
+ }
+
+ // Unknown layers: hide by default (log for debugging)
+ console.log( 'Unknown layer:', layerName );
+ return false;
+
+ };
+
+}
+
+function recreateTiles() {
+
+ if ( tiles ) {
+
+ scene.remove( tiles.group );
+ tiles.dispose();
+
+ }
+
+ const preset = SOURCE_PRESETS[ state.sourceType ];
+
+ // Check if API key is needed
+ if ( preset.requiresApiKey && ! apiKey ) {
+
+ apiKey = prompt( `Enter API Key for ${preset.name}:` );
+ if ( apiKey ) {
+
+ localStorage.setItem( 'mapbox_key', apiKey );
+
+ } else {
+
+ // Fall back to PMTiles if no key provided
+ state.sourceType = 'PMTiles';
+ initStateFromPreset( 'PMTiles' );
+ rebuildGUI();
+ recreateTiles();
+ return;
+
+ }
+
+ }
+
+ tiles = new TilesRenderer();
+ tiles.registerPlugin( new UpdateOnChangePlugin() );
+
+ const pluginOptions = {
+ center: true,
+ shape: 'ellipsoid',
+ levels: 15,
+ tileDimension: 512,
+ styles: state.colors,
+ filter: createFilter( preset )
+ };
+
+ // Select plugin based on source type and render mode
+ if ( state.sourceType === 'PMTiles' ) {
+
+ pluginOptions.url = preset.url;
+
+ if ( state.renderMode === 'Mesh' ) {
+
+ tiles.registerPlugin( new PMTilesMeshPlugin( pluginOptions ) );
+
+ } else {
+
+ tiles.registerPlugin( new PMTilesPlugin( pluginOptions ) );
+
+ }
+
+ } else {
+
+ pluginOptions.url = preset.urlTemplate + apiKey;
+
+ if ( state.renderMode === 'Mesh' ) {
+
+ tiles.registerPlugin( new MVTTilesMeshPlugin( pluginOptions ) );
+
+ } else {
+
+ tiles.registerPlugin( new MVTTilesPlugin( pluginOptions ) );
+
+ }
+
+ }
+
+ tiles.group.rotation.x = - Math.PI / 2;
+ tiles.setCamera( camera );
+ scene.add( tiles.group );
+
+ if ( controls ) controls.setEllipsoid( tiles.ellipsoid, tiles.group );
+
+}
+
+function rebuildGUI() {
+
+ // Remove old folders if they exist
+ if ( layersFolder ) {
+
+ layersFolder.destroy();
+ layersFolder = null;
+
+ }
+
+ if ( colorsFolder ) {
+
+ colorsFolder.destroy();
+ colorsFolder = null;
+
+ }
+
+ const preset = SOURCE_PRESETS[ state.sourceType ];
+
+ // Rebuild layers folder
+ layersFolder = gui.addFolder( 'Layers' );
+ for ( const key in preset.layers ) {
+
+ const layer = preset.layers[ key ];
+ layersFolder.add( state.layers, key )
+ .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) )
+ .onChange( recreateTiles );
+
+ }
+
+ // Rebuild colors folder
+ colorsFolder = gui.addFolder( 'Colors' );
+ for ( const key in preset.layers ) {
+
+ const layer = preset.layers[ key ];
+ colorsFolder.addColor( state.colors, layer.name )
+ .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) )
+ .onChange( recreateTiles );
+
+ }
+
+}
+
+function setupGUI() {
+
+ gui = new GUI();
+
+ // Source & Renderer Settings
+ const sourceFolder = gui.addFolder( 'Source & Renderer' );
+ sourceFolder.add( state, 'sourceType', Object.keys( SOURCE_PRESETS ) )
+ .name( 'Data Source' )
+ .onChange( ( value ) => {
+
+ initStateFromPreset( value );
+ rebuildGUI();
+ recreateTiles();
+
+ } );
+ sourceFolder.add( state, 'renderMode', [ 'Mesh', 'Texture' ] )
+ .name( 'Render Mode' )
+ .onChange( recreateTiles );
+ sourceFolder.open();
+
+ // Initial layer and color folders
+ rebuildGUI();
+
+}
+
+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 576af0912..cd8928d4e 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",
@@ -25,6 +26,7 @@
"@vitest/eslint-plugin": "^1.5.1",
"cesium": "^1.132.0",
"concurrently": "^6.2.1",
+ "earcut": "^3.0.2",
"eslint": "^9.0.0",
"eslint-config-mdcs": "^5.0.0",
"eslint-plugin-jsdoc": "^62.8.0",
@@ -34,6 +36,7 @@
"jsdoc": "^4.0.5",
"leva": "^0.10.0",
"lil-gui": "^0.21.0",
+ "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",
+ "earcut": "^3.0.2",
+ "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
},
+ "earcut": {
+ "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..ef891faf9 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,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",
@@ -98,6 +99,7 @@
"@vitest/eslint-plugin": "^1.5.1",
"cesium": "^1.132.0",
"concurrently": "^6.2.1",
+ "earcut": "^3.0.2",
"eslint": "^9.0.0",
"eslint-config-mdcs": "^5.0.0",
"eslint-plugin-jsdoc": "^62.8.0",
@@ -108,6 +110,7 @@
"leva": "^0.10.0",
"lil-gui": "^0.21.0",
"postprocessing": "^6.36.4",
+ "pmtiles": "^4.3.2",
"three": "^0.170.0",
"typescript": "^5.6.0",
"typescript-eslint": "^8.48.1",
@@ -117,10 +120,13 @@
"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",
+ "earcut": "^3.0.2",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
- "three": ">=0.167.0"
+ "three": ">=0.167.0",
+ "pmtiles": "^4.3.2"
},
"peerDependenciesMeta": {
"@react-three/fiber": {
@@ -138,6 +144,15 @@
"react-dom": {
"optional": true
},
+ "@mapbox/vector-tile": {
+ "optional": true
+ },
+ "earcut": {
+ "optional": true
+ },
+ "pmtiles": {
+ "optional": true
+ },
"three": {
"optional": true
}
diff --git a/src/core/renderer/index.d.ts b/src/core/renderer/index.d.ts
index 7025dc03a..1be28a49d 100644
--- a/src/core/renderer/index.d.ts
+++ b/src/core/renderer/index.d.ts
@@ -5,6 +5,8 @@ export { Tileset } from './tiles/Tileset.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 './loaders/LoaderBase.js';
export * from './constants.js';
diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js
index 431a9afae..f45483c46 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.d.ts b/src/core/renderer/loaders/MVTLoaderBase.d.ts
new file mode 100644
index 000000000..bdf4efbda
--- /dev/null
+++ b/src/core/renderer/loaders/MVTLoaderBase.d.ts
@@ -0,0 +1,12 @@
+import { VectorTile } from '@mapbox/vector-tile';
+
+export type MVTBaseResult = {
+ vectorTile: VectorTile
+};
+
+export class MVTLoaderBase {
+
+ load( url: string ): Promise;
+ parse( buffer: ArrayBuffer ): Promise;
+
+}
diff --git a/src/core/renderer/loaders/MVTLoaderBase.js b/src/core/renderer/loaders/MVTLoaderBase.js
new file mode 100644
index 000000000..2e8a3f28b
--- /dev/null
+++ b/src/core/renderer/loaders/MVTLoaderBase.js
@@ -0,0 +1,31 @@
+// 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';
+import { DefaultLoadingManager } from 'three';
+
+export const MVT_EXTENT = 4096;
+export class MVTLoaderBase extends LoaderBase {
+
+ constructor( manager = DefaultLoadingManager ) {
+
+ super();
+ this.manager = manager;
+
+ }
+
+ parse( buffer ) {
+
+ const pbf = new Protobuf( buffer );
+ const vectorTile = new VectorTile( pbf );
+
+ // Return a structure consistent with PNTSLoaderBase/B3DMLoaderBase
+ return Promise.resolve( {
+ vectorTile
+ } );
+
+ }
+
+}
diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.d.ts b/src/core/renderer/loaders/PMTilesLoaderBase.d.ts
new file mode 100644
index 000000000..2fe47a72c
--- /dev/null
+++ b/src/core/renderer/loaders/PMTilesLoaderBase.d.ts
@@ -0,0 +1,17 @@
+import { PMTiles, Header } from 'pmtiles';
+
+export class PMTilesLoaderBase {
+
+ instance: PMTiles | null;
+ header: Header | null;
+ url: string | null;
+
+ constructor();
+
+ init( url: string ): Promise;
+ getTile( z: number, x: number, y: number, signal?: AbortSignal ): Promise;
+ getUrl( z: number, x: number, y: number ): string;
+
+ static parseUrl( url: string ): { z: number, x: number, y: number };
+
+}
diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js
new file mode 100644
index 000000000..1c377c7d1
--- /dev/null
+++ b/src/core/renderer/loaders/PMTilesLoaderBase.js
@@ -0,0 +1,67 @@
+// 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.replace( /^pmtiles:\/\//, '' );
+ this.instance = new PMTiles( this.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
+ static parseUrl( url ) {
+
+ const parts = url.split( '/' );
+ const y = parseInt( parts.pop() );
+ const x = parseInt( parts.pop() );
+ const z = parseInt( parts.pop() );
+
+ return { z, x, y };
+
+ }
+
+}
diff --git a/src/core/renderer/utilities/VectorTileIterator.d.ts b/src/core/renderer/utilities/VectorTileIterator.d.ts
new file mode 100644
index 000000000..9c9a1c3fc
--- /dev/null
+++ b/src/core/renderer/utilities/VectorTileIterator.d.ts
@@ -0,0 +1,19 @@
+import { VectorTileStyler } from '../../../three/renderer/utils/VectorTileStyler.js';
+
+export interface FeatureIteratorResult {
+ feature: any;
+ layerName: string;
+ layer: any;
+ geometry: any[];
+ type: number;
+}
+
+export class VectorTileIterator {
+
+ styler: VectorTileStyler;
+
+ constructor( styler: VectorTileStyler );
+
+ iterateFeatures( vectorTile: any ): Generator;
+
+}
diff --git a/src/core/renderer/utilities/VectorTileIterator.js b/src/core/renderer/utilities/VectorTileIterator.js
new file mode 100644
index 000000000..7b53d7549
--- /dev/null
+++ b/src/core/renderer/utilities/VectorTileIterator.js
@@ -0,0 +1,41 @@
+// Iterates over vector tile features in sorted layer order
+export class VectorTileIterator {
+
+ constructor( styler ) {
+
+ this.styler = styler;
+
+ }
+
+ *iterateFeatures( vectorTile ) {
+
+ 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 ) ) {
+
+ yield {
+ feature,
+ layerName,
+ layer,
+ geometry: feature.loadGeometry(),
+ type: feature.type, // 1=Point, 2=Line, 3=Polygon
+ };
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/src/three/plugins/MVTTilesMeshPlugin.d.ts b/src/three/plugins/MVTTilesMeshPlugin.d.ts
new file mode 100644
index 000000000..b108f3a14
--- /dev/null
+++ b/src/three/plugins/MVTTilesMeshPlugin.d.ts
@@ -0,0 +1,6 @@
+//todo: add types
+export class MVTTilesMeshPlugin {
+
+ constructor( options?: {} );
+
+}
diff --git a/src/three/plugins/MVTTilesMeshPlugin.js b/src/three/plugins/MVTTilesMeshPlugin.js
new file mode 100644
index 000000000..91261d4bb
--- /dev/null
+++ b/src/three/plugins/MVTTilesMeshPlugin.js
@@ -0,0 +1,150 @@
+import { XYZTilesPlugin } from './images/EPSGTilesPlugin.js';
+import { MVTLoader } from '../renderer/loaders/MVTLoader.js';
+import { Mesh, MeshBasicMaterial, Vector3, MathUtils, SphereGeometry, FrontSide } from 'three';
+import { TILE_X, TILE_Y, TILE_LEVEL } from './images/ImageFormatPlugin.js';
+import { WGS84_RADIUS } from '../../core/renderer/constants.js';
+import { MVT_EXTENT } from '../../core/renderer/loaders/MVTLoaderBase.js';
+
+const _pos = new Vector3();
+const _tileCenter = new Vector3();
+
+export class MVTTilesMeshPlugin extends XYZTilesPlugin {
+
+ constructor( options = {} ) {
+
+ super( options );
+ this.name = 'VECTOR_TILES_PLUGIN';
+
+ this.loader = new MVTLoader( undefined, options.styles );
+
+ if ( options.filter ) {
+
+ this.loader.filter = options.filter;
+
+ }
+
+ this.globeMesh = new Mesh(
+ new SphereGeometry( WGS84_RADIUS, 64, 64 ),
+ new MeshBasicMaterial( { color: 0x292929, side: FrontSide } )
+ );
+ this.globeMesh.renderOrder = - 9999;
+ this.globeMesh.raycast = () => false;
+
+ }
+
+ init( tiles ) {
+
+ super.init( tiles );
+
+ this.tiles = tiles;
+ this.tiles.group.add( this.globeMesh );
+
+ }
+
+ async parseToMesh( buffer, tile, extension, uri, abortSignal ) {
+
+ if ( abortSignal.aborted ) {
+
+ return null;
+
+ }
+
+ if ( extension === 'pbf' || extension === 'mvt' ) {
+
+ const result = await this.loader.parse( buffer );
+ const group = result.scene;
+
+ this._projectGroupToGlobe( group, tile );
+
+ return group;
+
+ }
+
+ return null;
+
+ }
+
+ _projectGroupToGlobe( group, tile ) {
+
+ const { tiling, projection, tiles } = this;
+ const ellipsoid = tiles.ellipsoid;
+
+ const x = tile[ TILE_X ];
+ const y = tile[ TILE_Y ];
+ const level = tile[ TILE_LEVEL ];
+ const extents = MVT_EXTENT;
+
+ // 1. Calculate the Tile Center (RTC Origin)
+ // We place the Group at the cartesian center of the tile.
+ // All vertex positions will be relative to this point.
+
+ // Get bounds in Projection (UV) space
+ const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true, true );
+
+ // Calculate center UV
+ const centerU = ( minU + maxU ) / 2;
+ const centerV = ( minV + maxV ) / 2;
+
+ // Convert UV -> Lat/Lon -> Cartesian
+ const centerLon = projection.convertNormalizedToLongitude( centerU );
+ const centerLat = projection.convertNormalizedToLatitude( centerV );
+ ellipsoid.getCartographicToPosition( centerLat, centerLon, 0, _tileCenter );
+
+ group.position.copy( _tileCenter );
+ group.updateMatrixWorld( true );
+
+ // 2. Iterate over all meshes and project their vertices
+ group.traverse( child => {
+
+ if ( child.isMesh || child.isLineSegments || child.isPoints ) {
+
+ const geometry = child.geometry;
+ const positionAttribute = geometry.getAttribute( 'position' );
+ const count = positionAttribute.count;
+
+ for ( let i = 0; i < count; i ++ ) {
+
+ // A. Read Local MVT Coordinate
+ // Note: Your MVTLoader stores Y as negative (-4096 to 0) to match Three.js formatting,
+ // but MVT data logically goes from 0 (Top) to 4096 (Bottom).
+ // We invert Y back to positive to get the normalized 0-1 range relative to the tile top.
+ const localX = positionAttribute.getX( i );
+ const localY = - positionAttribute.getY( i );
+
+ // B. Normalize to 0..1 (UV local to tile)
+ const uLocal = localX / extents;
+ const vLocal = localY / extents;
+
+ // C. Map to Global Projection UV
+ // Interpolate between the tile bounds we calculated earlier
+ const uGlobal = MathUtils.lerp( minU, maxU, uLocal );
+ const vGlobal = MathUtils.lerp( maxV, minV, vLocal );
+
+ // D. Convert Global UV -> Lat/Lon
+ const lon = projection.convertNormalizedToLongitude( uGlobal );
+ const lat = projection.convertNormalizedToLatitude( vGlobal );
+
+ // E. Convert Lat/Lon -> World Cartesian
+ // Assuming altitude 0 for now
+ ellipsoid.getCartographicToPosition( lat, lon, 0, _pos );
+
+ // F. Convert to RTC (Relative To Center)
+ // Subtract the group position so the vertex is local to the group
+ _pos.sub( _tileCenter );
+
+ // G. Update the vertex
+ positionAttribute.setXYZ( i, _pos.x, _pos.y, _pos.z );
+
+ }
+
+ geometry.computeBoundingSphere();
+ geometry.computeBoundingBox();
+ positionAttribute.needsUpdate = true;
+
+ }
+
+ } );
+
+ }
+
+}
diff --git a/src/three/plugins/PMTilesMeshPlugin.d.ts b/src/three/plugins/PMTilesMeshPlugin.d.ts
new file mode 100644
index 000000000..365976159
--- /dev/null
+++ b/src/three/plugins/PMTilesMeshPlugin.d.ts
@@ -0,0 +1,20 @@
+import { ColorRepresentation } from 'three';
+import { PMTilesLoaderBase } from '../../core/renderer/loaders/PMTilesLoaderBase.js';
+
+export class PMTilesMeshPlugin {
+
+ readonly pmtilesLoader: PMTilesLoaderBase;
+
+ constructor( options: {
+ url: string,
+ tileDimension?: number,
+ filter?: ( feature: any, layerName: string ) => boolean,
+ styles?: { [ layerName: string ]: ColorRepresentation },
+
+ center?: boolean,
+ shape?: 'ellipsoid' | 'planar',
+ endCaps?: boolean,
+ useRecommendedSettings?: boolean,
+ } );
+
+}
diff --git a/src/three/plugins/PMTilesMeshPlugin.js b/src/three/plugins/PMTilesMeshPlugin.js
new file mode 100644
index 000000000..df17dbc8e
--- /dev/null
+++ b/src/three/plugins/PMTilesMeshPlugin.js
@@ -0,0 +1,83 @@
+import { MVTTilesMeshPlugin } from './MVTTilesMeshPlugin.js';
+import { PMTilesLoaderBase } from '../../core/renderer/loaders/PMTilesLoaderBase.js';
+import { ProjectionScheme } from './images/utils/ProjectionScheme.js';
+
+export class PMTilesMeshPlugin extends MVTTilesMeshPlugin {
+
+ constructor( options = {} ) {
+
+ super( options );
+
+ this.name = 'PMTILES_MESH_PLUGIN';
+ this.pmtilesLoader = new PMTilesLoaderBase();
+ this._pmtilesUrl = options.url;
+
+ }
+
+ async loadRootTileset() {
+
+ // Initialize PMTiles and get header
+ const header = await this.pmtilesLoader.init( this._pmtilesUrl );
+
+ // Configure tiling from header
+ this.imageSource.tiling.flipY = true;
+ this.imageSource.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) );
+ this.imageSource.tiling.generateLevels(
+ header.maxZoom,
+ this.imageSource.tiling.projection.tileCountX,
+ this.imageSource.tiling.projection.tileCountY,
+ {
+ tilePixelWidth: this.imageSource.tileDimension,
+ tilePixelHeight: this.imageSource.tileDimension,
+ }
+ );
+
+ // Override getUrl to use pmtiles:// scheme
+ this.imageSource.getUrl = ( x, y, level ) => this.pmtilesLoader.getUrl( level, x, y );
+
+ return this.getTileset( this._pmtilesUrl );
+
+ }
+
+ // 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.pmtilesLoader.getTile( z, x, y, options?.signal )
+ .then( buffer => buffer || new ArrayBuffer( 0 ) );
+
+ }
+
+ return null;
+
+ }
+
+ // Override to handle pmtiles:// URLs (no file extension)
+ async parseToMesh( buffer, tile, extension, uri, abortSignal ) {
+
+ if ( abortSignal.aborted ) {
+
+ return null;
+
+ }
+
+ // Handle pmtiles:// URLs OR standard .pbf/.mvt extensions
+ if ( uri.startsWith( 'pmtiles://' ) || extension === 'pbf' || extension === 'mvt' ) {
+
+ const result = await this.loader.parse( buffer );
+ const group = result.scene;
+
+ this._projectGroupToGlobe( group, tile );
+
+ return group;
+
+ }
+
+ return null;
+
+ }
+
+}
diff --git a/src/three/plugins/images/EPSGTilesPlugin.js b/src/three/plugins/images/EPSGTilesPlugin.js
index 331d3fea3..ffa458006 100644
--- a/src/three/plugins/images/EPSGTilesPlugin.js
+++ b/src/three/plugins/images/EPSGTilesPlugin.js
@@ -5,6 +5,7 @@ import { XYZImageSource } from './sources/XYZImageSource.js';
import { TMSImageSource } from './sources/TMSImageSource.js';
import { WMTSImageSource } from './sources/WMTSImageSource.js';
import { WMSImageSource } from './sources/WMSImageSource.js';
+import { MVTImageSource } from './sources/MVTImageSource.js';
/**
* Plugin that renders XYZ/Slippy-map image tiles (e.g. OpenStreetMap) projected onto
@@ -37,6 +38,21 @@ export class XYZTilesPlugin extends EllipsoidProjectionTilesPlugin {
}
+export class MVTTilesPlugin extends EllipsoidProjectionTilesPlugin {
+
+ constructor( options = {} ) {
+
+ const { url, filter, levels, tileDimension, styles, ...rest } = options;
+
+ super( rest );
+
+ this.name = 'MVT_TILES_PLUGIN';
+ this.imageSource = new MVTImageSource( { url, filter, levels, tileDimension, styles } );
+
+ }
+
+}
+
/**
* Plugin that renders TMS (Tile Map Service) image tiles projected onto 3D tile geometry.
* See the {@link https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification TMS specification}.
diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js
new file mode 100644
index 000000000..097d3c886
--- /dev/null
+++ b/src/three/plugins/images/MVTOverlay.js
@@ -0,0 +1,14 @@
+import { XYZTilesOverlay } from './ImageOverlayPlugin.js';
+import { MVTImageSource } from './sources/MVTImageSource.js';
+
+export class MVTOverlay extends XYZTilesOverlay {
+
+ constructor( options = {} ) {
+
+ super( options );
+ // Replace the default XYZ source with our custom MVT source
+ this.imageSource = new MVTImageSource( options );
+
+ }
+
+}
diff --git a/src/three/plugins/images/PMTilesPlugin.d.ts b/src/three/plugins/images/PMTilesPlugin.d.ts
new file mode 100644
index 000000000..48f6a81fc
--- /dev/null
+++ b/src/three/plugins/images/PMTilesPlugin.d.ts
@@ -0,0 +1,17 @@
+import { ColorRepresentation } from 'three';
+
+export class PMTilesPlugin {
+
+ constructor( options: {
+ url: string,
+ tileDimension?: number,
+ filter?: ( feature: any, layerName: string ) => boolean,
+ styles?: { [ layerName: string ]: ColorRepresentation },
+
+ center?: boolean,
+ shape?: 'ellipsoid' | 'planar',
+ endCaps?: boolean,
+ useRecommendedSettings?: boolean,
+ } );
+
+}
diff --git a/src/three/plugins/images/PMTilesPlugin.js b/src/three/plugins/images/PMTilesPlugin.js
new file mode 100644
index 000000000..589894f91
--- /dev/null
+++ b/src/three/plugins/images/PMTilesPlugin.js
@@ -0,0 +1,33 @@
+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 = {} ) {
+
+ const { ...rest } = options;
+ super( rest );
+
+ 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..e7367e124
--- /dev/null
+++ b/src/three/plugins/images/sources/MVTImageSource.js
@@ -0,0 +1,59 @@
+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;
+
+ // Use composed styler and renderer
+ this._styler = new VectorTileStyler( {
+ filter: options.filter,
+ styles: options.styles
+ } );
+
+ this._renderer = new VectorTileCanvasRenderer( this._styler, {
+ tileDimension: this.tileDimension
+ } );
+
+ }
+
+ // Legacy API: expose filter for backward compatibility
+ get filter() {
+
+ return this._styler.filter;
+
+ }
+
+ set filter( fn ) {
+
+ this._styler.filter = fn;
+
+ }
+
+ async processBufferToTexture( buffer ) {
+
+ const { vectorTile } = await this.loader.parse( buffer );
+ return this._renderer.render( vectorTile );
+
+ }
+
+ _createCanvas( width, height ) {
+
+ return this._renderer._createCanvas( width, height );
+
+ }
+
+ _createEmptyTexture() {
+
+ return this._renderer.createEmptyTexture();
+
+ }
+
+}
diff --git a/src/three/plugins/images/sources/PMTilesImageSource.d.ts b/src/three/plugins/images/sources/PMTilesImageSource.d.ts
new file mode 100644
index 000000000..5debd0bca
--- /dev/null
+++ b/src/three/plugins/images/sources/PMTilesImageSource.d.ts
@@ -0,0 +1,23 @@
+import { ColorRepresentation, Texture } from 'three';
+import { PMTiles } from 'pmtiles';
+import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js';
+
+export class PMTilesImageSource {
+
+ readonly pmtilesLoader: PMTilesLoaderBase;
+ readonly pmtilesUrl: string;
+ readonly instance: PMTiles;
+
+ constructor( options: {
+ url: string,
+ tileDimension?: number,
+ filter?: ( feature: any, layerName: string ) => boolean,
+ styles?: { [ layerName: string ]: ColorRepresentation },
+ fetchOptions?: RequestInit,
+ } );
+
+ init(): Promise;
+ getUrl( x: number, y: number, level: number ): string;
+ fetchItem( tokens: [ number, number, number ], signal?: AbortSignal ): Promise;
+
+}
diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js
new file mode 100644
index 000000000..5b7e22b7d
--- /dev/null
+++ b/src/three/plugins/images/sources/PMTilesImageSource.js
@@ -0,0 +1,66 @@
+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;
+
+ }
+
+ // Expose for backward compatibility
+ get pmtilesUrl() {
+
+ return this.pmtilesLoader.url;
+
+ }
+
+ get instance() {
+
+ return this.pmtilesLoader.instance;
+
+ }
+
+ 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,
+ } );
+
+ }
+
+ // Override fetchItem to fetch directly from PMTiles archive (bypasses plugin fetchData chain)
+ fetchItem( tokens, signal ) {
+
+ const [ x, y, level ] = tokens;
+
+ return this.pmtilesLoader.getTile( level, x, y, signal )
+ .then( buffer => {
+
+ if ( ! buffer ) {
+
+ return this._createEmptyTexture();
+
+ }
+
+ return this.processBufferToTexture( buffer );
+
+ } );
+
+ }
+
+}
diff --git a/src/three/plugins/index.d.ts b/src/three/plugins/index.d.ts
index efb4ce02c..88933dd8e 100644
--- a/src/three/plugins/index.d.ts
+++ b/src/three/plugins/index.d.ts
@@ -17,6 +17,9 @@ export * from './DebugTilesPlugin.js';
// other formats
export * from './images/DeepZoomImagePlugin.js';
export * from './images/EPSGTilesPlugin.js';
+export * from './images/PMTilesPlugin.js';
+export * from './MVTTilesMeshPlugin.js';
+export * from './PMTilesMeshPlugin.js';
// gltf extensions
export * from './gltf/GLTFCesiumRTCExtension.js';
diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js
index 55a450335..fb4cf4c9c 100644
--- a/src/three/plugins/index.js
+++ b/src/three/plugins/index.js
@@ -10,12 +10,16 @@ export * from './batched/BatchedTilesPlugin.js';
export * from './TileFlatteningPlugin.js';
export * from './QuantizedMeshPlugin.js';
export * from './images/ImageOverlayPlugin.js';
+export * from './images/MVTOverlay.js';
export * from './LoadRegionPlugin.js';
export * from './DebugTilesPlugin.js';
// other formats
export * from './images/DeepZoomImagePlugin.js';
export * from './images/EPSGTilesPlugin.js';
+export * from './images/PMTilesPlugin.js';
+export * from './MVTTilesMeshPlugin.js';
+export * from './PMTilesMeshPlugin.js';
// gltf extensions
export * from './gltf/GLTFCesiumRTCExtension.js';
diff --git a/src/three/renderer/loaders/MVTLoader.d.ts b/src/three/renderer/loaders/MVTLoader.d.ts
new file mode 100644
index 000000000..0e4f9c2e5
--- /dev/null
+++ b/src/three/renderer/loaders/MVTLoader.d.ts
@@ -0,0 +1,21 @@
+import { MVTBaseResult, MVTLoaderBase } from '../../base/loaders/MVTLoaderBase';
+import { ColorRepresentation, Group, LoadingManager } from 'three';
+
+interface MVTScene extends Group {
+
+ vectorTile: VectorTile
+
+}
+
+export interface MVTResult extends MVTBaseResult {
+
+ scene: MVTScene;
+
+}
+
+export class MVTLoader extends MVTLoaderBase {
+
+ constructor( manager: LoadingManager, styles?: { [ layer: string ]: ColorRepresentation } );
+ parse( buffer: ArrayBuffer ): Promise;
+
+}
diff --git a/src/three/renderer/loaders/MVTLoader.js b/src/three/renderer/loaders/MVTLoader.js
new file mode 100644
index 000000000..4630db502
--- /dev/null
+++ b/src/three/renderer/loaders/MVTLoader.js
@@ -0,0 +1,48 @@
+import { MVTLoaderBase } from '3d-tiles-renderer/core';
+import { DefaultLoadingManager } from 'three';
+import { VectorTileStyler } from '../utils/VectorTileStyler.js';
+import { VectorTileMeshRenderer } from '../utils/VectorTileMeshRenderer.js';
+
+export class MVTLoader extends MVTLoaderBase {
+
+ constructor( manager = DefaultLoadingManager, styles = {} ) {
+
+ super();
+ this.manager = manager;
+
+ // Use composed styler and renderer
+ this._styler = new VectorTileStyler( { styles } );
+ this._renderer = new VectorTileMeshRenderer( this._styler );
+
+ // Expose default materials from renderer for backward compatibility
+ this.defaultPointsMaterial = this._renderer.defaultPointsMaterial;
+ this.defaultLineMaterial = this._renderer.defaultLineMaterial;
+ this.defaultMeshMaterial = this._renderer.defaultMeshMaterial;
+
+ }
+
+ // Legacy API: expose filter for backward compatibility
+ get filter() {
+
+ return this._styler.filter;
+
+ }
+
+ set filter( fn ) {
+
+ this._styler.filter = fn;
+
+ }
+
+ parse( buffer ) {
+
+ return super.parse( buffer ).then( result => {
+
+ result.scene = this._renderer.render( result.vectorTile );
+ return result;
+
+ } );
+
+ }
+
+}
diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.d.ts b/src/three/renderer/utils/VectorTileCanvasRenderer.d.ts
new file mode 100644
index 000000000..4e0448fe5
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileCanvasRenderer.d.ts
@@ -0,0 +1,16 @@
+import { Texture } from 'three';
+import { VectorTileStyler } from './VectorTileStyler.js';
+
+export class VectorTileCanvasRenderer {
+
+ styler: VectorTileStyler;
+ tileDimension: number;
+
+ constructor( styler: VectorTileStyler, options?: {
+ tileDimension?: number,
+ } );
+
+ render( vectorTile: any ): Texture;
+ createEmptyTexture(): Texture;
+
+}
diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js
new file mode 100644
index 000000000..1cf2af26b
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js
@@ -0,0 +1,153 @@
+import { CanvasTexture, SRGBColorSpace } from 'three';
+import { VectorTileIterator } from '../../../core/renderer/utilities/VectorTileIterator.js';
+
+const MVT_EXTENT = 4096;
+
+export class VectorTileCanvasRenderer {
+
+ constructor( styler, options = {} ) {
+
+ this.styler = styler;
+ this.tileDimension = options.tileDimension || 512;
+ this._iterator = new VectorTileIterator( styler );
+
+ }
+
+ 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._iterator.iterateFeatures( 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 );
+
+ }
+
+ _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();
+
+ }
+
+ createEmptyTexture() {
+
+ const canvas = this._createCanvas( this.tileDimension, this.tileDimension );
+ return this._createTexture( canvas );
+
+ }
+
+}
diff --git a/src/three/renderer/utils/VectorTileMeshRenderer.d.ts b/src/three/renderer/utils/VectorTileMeshRenderer.d.ts
new file mode 100644
index 000000000..0eb175657
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileMeshRenderer.d.ts
@@ -0,0 +1,23 @@
+import { Group, PointsMaterial, LineBasicMaterial, MeshBasicMaterial } from 'three';
+import { VectorTileStyler } from './VectorTileStyler.js';
+
+export class VectorTileMeshRenderer {
+
+ styler: VectorTileStyler;
+ enableDensification: boolean;
+ densityThreshold: number;
+ defaultPointsMaterial: PointsMaterial;
+ defaultLineMaterial: LineBasicMaterial;
+ defaultMeshMaterial: MeshBasicMaterial;
+
+ constructor( styler: VectorTileStyler, options?: {
+ enableDensification?: boolean,
+ densityThreshold?: number,
+ pointsMaterial?: PointsMaterial,
+ lineMaterial?: LineBasicMaterial,
+ meshMaterial?: MeshBasicMaterial,
+ } );
+
+ render( vectorTile: any ): Group;
+
+}
diff --git a/src/three/renderer/utils/VectorTileMeshRenderer.js b/src/three/renderer/utils/VectorTileMeshRenderer.js
new file mode 100644
index 000000000..7ec74b631
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileMeshRenderer.js
@@ -0,0 +1,613 @@
+import {
+ Group,
+ Points,
+ PointsMaterial,
+ BufferGeometry,
+ Float32BufferAttribute,
+ LineBasicMaterial,
+ LineSegments,
+ Mesh,
+ MeshBasicMaterial,
+ FrontSide
+} from 'three';
+import earcut from 'earcut';
+import { VectorTileIterator } from '../../../core/renderer/utilities/VectorTileIterator.js';
+
+const MVT_EXTENT = 4096;
+
+// --- CLIPPING UTILS ---
+
+function inside( p, edge, val ) {
+
+ if ( edge === 0 ) return p.x >= val; // Left
+ if ( edge === 1 ) return p.x <= val; // Right
+ if ( edge === 2 ) return p.y >= val; // Top
+ if ( edge === 3 ) return p.y <= val; // Bottom
+ return false;
+
+}
+
+// Helper to calculate signed area of a ring to determine winding order
+// Returns true if CW (Exterior), false if CCW (Interior/Hole)
+function isExterior( ring ) {
+
+ let area = 0;
+ const len = ring.length;
+ for ( let i = 0; i < len; i ++ ) {
+
+ const j = ( i + 1 ) % len;
+ area += ring[ i ].x * ring[ j ].y;
+ area -= ring[ j ].x * ring[ i ].y;
+
+ }
+
+ // In MVT (Y down), Positive Area = Clockwise = Exterior
+ return area > 0;
+
+}
+
+function intersect( p1, p2, edge, val ) {
+
+ const p = { x: 0, y: 0 };
+ if ( edge === 0 || edge === 1 ) { // Vertical line (Left/Right)
+
+ p.x = val;
+ p.y = p1.y + ( p2.y - p1.y ) * ( val - p1.x ) / ( p2.x - p1.x );
+
+ } else { // Horizontal line (Top/Bottom)
+
+ p.y = val;
+ p.x = p1.x + ( p2.x - p1.x ) * ( val - p1.y ) / ( p2.y - p1.y );
+
+ }
+
+ return p;
+
+}
+
+// Sutherland-Hodgman Polygon Clipping
+function clipPolygonRing( subjectPolygon ) {
+
+ let outputList = subjectPolygon;
+
+ // We clip against 4 edges: Left(0), Right(EXTENT), Top(0), Bottom(EXTENT)
+ const edges = [
+ { edge: 0, val: 0 }, // Min X
+ { edge: 1, val: MVT_EXTENT }, // Max X
+ { edge: 2, val: 0 }, // Min Y
+ { edge: 3, val: MVT_EXTENT } // Max Y
+ ];
+
+ for ( let i = 0; i < edges.length; i ++ ) {
+
+ const edge = edges[ i ].edge;
+ const val = edges[ i ].val;
+ const inputList = outputList;
+ outputList = [];
+
+ if ( inputList.length === 0 ) break;
+
+ let S = inputList[ inputList.length - 1 ];
+
+ for ( let j = 0; j < inputList.length; j ++ ) {
+
+ const E = inputList[ j ];
+ if ( inside( E, edge, val ) ) {
+
+ if ( ! inside( S, edge, val ) ) {
+
+ outputList.push( intersect( S, E, edge, val ) );
+
+ }
+
+ outputList.push( E );
+
+ } else if ( inside( S, edge, val ) ) {
+
+ outputList.push( intersect( S, E, edge, val ) );
+
+ }
+
+ S = E;
+
+ }
+
+ }
+
+ return outputList;
+
+}
+
+// Simple Line Segment Clipping (Cohen-Sutherland-ish)
+function clipLinePoints( p1, p2 ) {
+
+ // Bounding box check
+ if ( p1.x >= 0 && p1.x <= MVT_EXTENT && p1.y >= 0 && p1.y <= MVT_EXTENT &&
+ p2.x >= 0 && p2.x <= MVT_EXTENT && p2.y >= 0 && p2.y <= MVT_EXTENT ) {
+
+ return [ p1, p2 ];
+
+ }
+
+ // dropping segments completely outside
+ let t0 = 0, t1 = 1;
+ const dx = p2.x - p1.x;
+ const dy = p2.y - p1.y;
+ const p = [ - dx, dx, - dy, dy ];
+ const q = [ p1.x, MVT_EXTENT - p1.x, p1.y, MVT_EXTENT - p1.y ];
+
+ for ( let i = 0; i < 4; i ++ ) {
+
+ if ( p[ i ] === 0 ) {
+
+ if ( q[ i ] < 0 ) return null; // Parallel and outside
+
+ } else {
+
+ const t = q[ i ] / p[ i ];
+ if ( p[ i ] < 0 ) {
+
+ if ( t > t1 ) return null;
+ if ( t > t0 ) t0 = t;
+
+ } else {
+
+ if ( t < t0 ) return null;
+ if ( t < t1 ) t1 = t;
+
+ }
+
+ }
+
+ }
+
+ if ( t0 > t1 ) return null;
+
+ return [
+ { x: p1.x + t0 * dx, y: p1.y + t0 * dy },
+ { x: p1.x + t1 * dx, y: p1.y + t1 * dy }
+ ];
+
+}
+
+// Helper: Subdivides triangles until they are smaller than maxEdgeLength
+function densifyGeometry( positions, indices, maxEdgeLength ) {
+
+ const newPositions = positions.slice();
+ const newIndices = [];
+ const stack = [];
+
+ // Initialize stack with initial triangles
+ for ( let i = 0; i < indices.length; i += 3 ) {
+
+ stack.push( indices[ i ], indices[ i + 1 ], indices[ i + 2 ] );
+
+ }
+
+ const thresholdSq = maxEdgeLength * maxEdgeLength;
+
+ while ( stack.length > 0 ) {
+
+ const ic = stack.pop();
+ const ib = stack.pop();
+ const ia = stack.pop();
+
+ const ia3 = ia * 3;
+ const ib3 = ib * 3;
+ const ic3 = ic * 3;
+
+ const ax = newPositions[ ia3 ], ay = newPositions[ ia3 + 1 ];
+ const bx = newPositions[ ib3 ], by = newPositions[ ib3 + 1 ];
+ const cx = newPositions[ ic3 ], cy = newPositions[ ic3 + 1 ];
+
+ const dAB = ( ax - bx ) * ( ax - bx ) + ( ay - by ) * ( ay - by );
+ const dBC = ( bx - cx ) * ( bx - cx ) + ( by - cy ) * ( by - cy );
+ const dCA = ( cx - ax ) * ( cx - ax ) + ( cy - ay ) * ( cy - ay );
+
+ let maxSq = dAB;
+ let edge = 0; // 0: AB, 1: BC, 2: CA
+
+ if ( dBC > maxSq ) {
+
+ maxSq = dBC;
+ edge = 1;
+
+ }
+
+ if ( dCA > maxSq ) {
+
+ maxSq = dCA;
+ edge = 2;
+
+ }
+
+ if ( maxSq <= thresholdSq ) {
+
+ newIndices.push( ia, ib, ic );
+ continue;
+
+ }
+
+ // Split the longest edge
+ const iMid = newPositions.length / 3;
+ let mx, my;
+
+ if ( edge === 0 ) { // AB
+
+ mx = ( ax + bx ) * 0.5;
+ my = ( ay + by ) * 0.5;
+ newPositions.push( mx, my, 0 );
+ stack.push( ia, iMid, ic );
+ stack.push( iMid, ib, ic );
+
+ } else if ( edge === 1 ) { // BC
+
+ mx = ( bx + cx ) * 0.5;
+ my = ( by + cy ) * 0.5;
+ newPositions.push( mx, my, 0 );
+ stack.push( ia, ib, iMid );
+ stack.push( ia, iMid, ic );
+
+ } else { // CA
+
+ mx = ( cx + ax ) * 0.5;
+ my = ( cy + ay ) * 0.5;
+ newPositions.push( mx, my, 0 );
+ stack.push( ia, ib, iMid );
+ stack.push( iMid, ib, ic );
+
+ }
+
+ }
+
+ return {
+ positions: newPositions,
+ indices: newIndices
+ };
+
+}
+
+// Layer stack for render ordering (reversed from canvas order)
+const LAYER_STACK = [
+ 'place_label',
+ 'poi',
+ 'boundaries',
+ 'building',
+ 'road',
+ 'transportation',
+ 'park',
+ 'landuse',
+ 'waterway',
+ 'water'
+];
+
+export class VectorTileMeshRenderer {
+
+ constructor( styler, options = {} ) {
+
+ this.styler = styler;
+ this.enableDensification = options.enableDensification !== false;
+ this.densityThreshold = options.densityThreshold || 1400;
+ this._iterator = new VectorTileIterator( styler );
+
+ // Default materials (can be customized)
+ this.defaultPointsMaterial = options.pointsMaterial ||
+ new PointsMaterial( { color: 0xff0000, size: 4, sizeAttenuation: false, depthTest: false, transparent: true } );
+ this.defaultLineMaterial = options.lineMaterial ||
+ new LineBasicMaterial( { color: 0x44aaff, linewidth: 2, depthTest: false } );
+ this.defaultMeshMaterial = options.meshMaterial ||
+ new MeshBasicMaterial( { color: 0x44aaff, side: FrontSide, wireframe: false, depthTest: false } );
+
+ }
+
+ render( vectorTile ) {
+
+ const group = new Group();
+ group.name = 'MVTScene';
+
+ // Accumulate geometry per layer
+ const layerData = new Map();
+
+ // Process all features
+ for ( const layerName in vectorTile.layers ) {
+
+ const layer = vectorTile.layers[ layerName ];
+
+ let isTransparent = false;
+ let layerIndex = 0;
+
+ if ( layerName.endsWith( '_overlay' ) ) {
+
+ isTransparent = true;
+ layerIndex = 0.12;
+
+ } else if ( layerName.endsWith( '_label' ) ) {
+
+ layerIndex = 0.1;
+
+ } else {
+
+ layerIndex = LAYER_STACK.indexOf( layerName );
+ if ( layerIndex === - 1 ) {
+
+ layerIndex = 0;
+
+ }
+
+ }
+
+ const data = {
+ pointsPositions: [],
+ linePositions: [],
+ meshPositions: [],
+ meshIndices: [],
+ isTransparent,
+ layerIndex
+ };
+
+ // Temporary arrays for polygon processing
+ const flatCoordinates = [];
+ const holeIndices = [];
+ const polygons = [];
+
+ for ( let i = 0; i < layer.length; i ++ ) {
+
+ const feature = layer.feature( i );
+ if ( ! this.styler.shouldIncludeFeature( feature, layerName ) ) continue;
+
+ const geometry = feature.loadGeometry();
+ const type = feature.type;
+
+ if ( type === 1 ) {
+
+ this._processPoints( geometry, data.pointsPositions );
+
+ } else if ( type === 2 ) {
+
+ this._processLines( geometry, data.linePositions );
+
+ } else if ( type === 3 ) {
+
+ this._processPolygons( geometry, data, flatCoordinates, holeIndices, polygons );
+
+ }
+
+ }
+
+ if ( data.pointsPositions.length > 0 || data.linePositions.length > 0 || data.meshPositions.length > 0 ) {
+
+ layerData.set( layerName, data );
+
+ }
+
+ }
+
+ // Build meshes from accumulated geometry
+ for ( const [ layerName, data ] of layerData ) {
+
+ this._buildLayerMeshes( group, layerName, data );
+
+ }
+
+ group.vectorTile = vectorTile;
+ return group;
+
+ }
+
+ _processPoints( geometry, pointsPositions ) {
+
+ for ( const multiPoint of geometry ) {
+
+ for ( const p of multiPoint ) {
+
+ if ( p.x < 0 || p.x > MVT_EXTENT || p.y < 0 || p.y > MVT_EXTENT ) continue;
+ pointsPositions.push( p.x, - p.y, 0 );
+
+ }
+
+ }
+
+ }
+
+ _processLines( geometry, linePositions ) {
+
+ for ( const ring of geometry ) {
+
+ const len = ring.length;
+ for ( let j = 0; j < len - 1; j ++ ) {
+
+ const clipped = clipLinePoints( ring[ j ], ring[ j + 1 ] );
+
+ if ( clipped ) {
+
+ linePositions.push( clipped[ 0 ].x, - clipped[ 0 ].y, 0 );
+ linePositions.push( clipped[ 1 ].x, - clipped[ 1 ].y, 0 );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ _processPolygons( geometry, data, flatCoordinates, holeIndices, polygons ) {
+
+ polygons.length = 0;
+ const clippedRings = [];
+
+ for ( const ring of geometry ) {
+
+ const clipped = clipPolygonRing( ring );
+ if ( clipped.length >= 3 ) {
+
+ clippedRings.push( clipped );
+
+ }
+
+ }
+
+ // Group rings
+ let currentPoly = null;
+
+ for ( const ring of clippedRings ) {
+
+ if ( isExterior( ring ) ) {
+
+ currentPoly = { exterior: ring, holes: [] };
+ polygons.push( currentPoly );
+
+ } else {
+
+ if ( currentPoly ) {
+
+ currentPoly.holes.push( ring );
+
+ }
+
+ }
+
+ }
+
+ // Triangulate
+ for ( const poly of polygons ) {
+
+ flatCoordinates.length = 0;
+ holeIndices.length = 0;
+
+ const exterior = poly.exterior;
+ for ( let k = 0; k < exterior.length; k ++ ) {
+
+ flatCoordinates.push( exterior[ k ].x, exterior[ k ].y );
+
+ }
+
+ let indexOffset = flatCoordinates.length / 2;
+
+ for ( const hole of poly.holes ) {
+
+ holeIndices.push( indexOffset );
+ for ( let k = 0; k < hole.length; k ++ ) {
+
+ flatCoordinates.push( hole[ k ].x, hole[ k ].y );
+
+ }
+
+ indexOffset += hole.length;
+
+ }
+
+ const triangles = earcut( flatCoordinates, holeIndices );
+ const currentOffset = data.meshPositions.length / 3;
+
+ if ( ! this.enableDensification ) {
+
+ for ( let k = 0; k < flatCoordinates.length; k += 2 ) {
+
+ data.meshPositions.push( flatCoordinates[ k ], - flatCoordinates[ k + 1 ], 0 );
+
+ }
+
+ for ( let k = 0; k < triangles.length; k += 3 ) {
+
+ data.meshIndices.push(
+ triangles[ k ] + currentOffset,
+ triangles[ k + 2 ] + currentOffset,
+ triangles[ k + 1 ] + currentOffset
+ );
+
+ }
+
+ } else {
+
+ const rawPos = [];
+ for ( let k = 0; k < flatCoordinates.length; k += 2 ) {
+
+ rawPos.push( flatCoordinates[ k ], - flatCoordinates[ k + 1 ], 0 );
+
+ }
+
+ const densified = densifyGeometry( rawPos, triangles, this.densityThreshold );
+
+ for ( let k = 0; k < densified.positions.length; k ++ ) {
+
+ data.meshPositions.push( densified.positions[ k ] );
+
+ }
+
+ for ( let k = 0; k < densified.indices.length; k += 3 ) {
+
+ data.meshIndices.push(
+ densified.indices[ k ] + currentOffset,
+ densified.indices[ k + 2 ] + currentOffset,
+ densified.indices[ k + 1 ] + currentOffset
+ );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ _buildLayerMeshes( group, layerName, data ) {
+
+ const { pointsPositions, linePositions, meshPositions, meshIndices, isTransparent, layerIndex } = data;
+ const layerColor = this.styler.getColor( layerName, 'hex' );
+
+ // 1. Points
+ if ( pointsPositions.length > 0 ) {
+
+ const geometry = new BufferGeometry();
+ geometry.setAttribute( 'position', new Float32BufferAttribute( pointsPositions, 3 ) );
+ const points = new Points( geometry, this.defaultPointsMaterial );
+ points.renderOrder = - 0.1;
+ points.name = layerName + '_points';
+ points.raycast = () => false;
+ group.add( points );
+
+ }
+
+ // 2. Lines
+ if ( linePositions.length > 0 ) {
+
+ const geometry = new BufferGeometry();
+ geometry.setAttribute( 'position', new Float32BufferAttribute( linePositions, 3 ) );
+
+ const lines = new LineSegments( geometry, this.defaultLineMaterial.clone() );
+ lines.renderOrder = 1;
+ lines.name = layerName + '_lines';
+ lines.raycast = () => false;
+ lines.material.color.setHex( layerColor );
+ group.add( lines );
+
+ }
+
+ // 3. Polygons
+ if ( meshPositions.length > 0 ) {
+
+ const geometry = new BufferGeometry();
+ geometry.setAttribute( 'position', new Float32BufferAttribute( meshPositions, 3 ) );
+ geometry.setIndex( meshIndices );
+
+ const mesh = new Mesh( geometry, this.defaultMeshMaterial.clone() );
+ mesh.renderOrder = - layerIndex - 1;
+ mesh.name = layerName + '_mesh';
+ mesh.raycast = () => false;
+
+ if ( isTransparent ) {
+
+ mesh.material.transparent = true;
+ mesh.material.opacity = 0.6;
+
+ }
+
+ mesh.material.color.setHex( layerColor );
+ group.add( mesh );
+
+ }
+
+ }
+
+}
diff --git a/src/three/renderer/utils/VectorTileStyler.d.ts b/src/three/renderer/utils/VectorTileStyler.d.ts
new file mode 100644
index 000000000..7a9efed64
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileStyler.d.ts
@@ -0,0 +1,18 @@
+import { ColorRepresentation } from 'three';
+
+export class VectorTileStyler {
+
+ filter: ( feature: any, layerName: string ) => boolean;
+
+ constructor( options?: {
+ filter?: ( feature: any, layerName: string ) => boolean,
+ styles?: { [ layerName: string ]: ColorRepresentation },
+ layerOrder?: string[],
+ } );
+
+ getColor( layerName: string, format?: 'hex' | 'css' ): number | string;
+ getLayerOrder(): string[];
+ sortLayers( layerNames: string[] ): string[];
+ shouldIncludeFeature( feature: any, layerName: string ): boolean;
+
+}
diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js
new file mode 100644
index 000000000..a91ba8c5e
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileStyler.js
@@ -0,0 +1,60 @@
+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;
+
+ }
+
+ getLayerOrder() {
+
+ return this._layerOrder;
+
+ }
+
+ 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'
+];