diff --git a/example/data/14-8801-5371.vector.pbf b/example/data/14-8801-5371.vector.pbf
new file mode 100644
index 000000000..820865545
Binary files /dev/null and b/example/data/14-8801-5371.vector.pbf differ
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/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..18c54067e
--- /dev/null
+++ b/example/three/mvt_globe.js
@@ -0,0 +1,212 @@
+import {
+ Scene,
+ WebGLRenderer,
+ PerspectiveCamera,
+ AmbientLight,
+ DirectionalLight,
+} from 'three';
+import {
+ TilesRenderer,
+ GlobeControls,
+} from '3d-tiles-renderer';
+import {
+ UpdateOnChangePlugin,
+ MVTTilesPlugin,
+ MVTTilesMeshPlugin
+} from '3d-tiles-renderer/plugins';
+
+import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
+
+let scene, renderer, camera, controls, tiles, gui;
+
+const apiKey = localStorage.getItem( 'mapbox_key' ) || prompt( 'Enter Mapbox API Key' );
+if ( apiKey ) localStorage.setItem( 'mapbox_key', apiKey );
+
+// --- Dynamic Filter State ---
+const state = {
+ pluginType: 'Mesh',
+ // Layer Toggles
+ showWater: true,
+ showBuildings: false,
+ showRoads: false,
+ showTransit: false,
+ showLanduse: false,
+ showAdmin: true,
+ showLabels: true,
+ // Property Filters
+ maxAdminLevel: 1,
+ maxSymbolRank: 3,
+ colors: {
+ water: '#201f20',
+ landuse: '#caedc1',
+ building: '#eeeeee',
+ road: '#444444',
+ admin: '#ff0000',
+ poi: '#ffcc00',
+ default: '#222222'
+ }
+};
+
+const MVT_URL = `https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=${apiKey}`;
+
+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 mvtFilter( feature, layerName ) {
+
+ const props = feature.properties;
+
+ // 1. Layer Visibility Checks
+ if ( layerName === 'water' && ! state.showWater ) return false;
+ if ( layerName === 'building' && ! state.showBuildings ) return false;
+ if ( layerName === 'road' && ! state.showRoads ) return false;
+ if ( layerName === 'transit' && ! state.showTransit ) return false;
+ if ( layerName === 'landuse' && ! state.showLanduse ) return false;
+
+ // 2. Advanced Admin Filtering
+ if ( layerName === 'admin' ) {
+
+ if ( ! state.showAdmin ) return false;
+ return props.admin_level <= state.maxAdminLevel;
+
+ }
+
+ // 3. Label Filtering
+ if ( layerName === 'place_label' ) {
+
+ if ( ! state.showLabels ) return false;
+ return props.symbolrank <= state.maxSymbolRank;
+
+ }
+
+ // Default: Only return true if it's one of our toggled layers
+ const activeLayers = [ 'water', 'building', 'road', 'transit', 'landuse' ];
+ return activeLayers.includes( layerName ) && state[ `show${layerName.charAt( 0 ).toUpperCase() + layerName.slice( 1 )}` ];
+
+}
+
+function recreateTiles() {
+
+ if ( tiles ) {
+
+ scene.remove( tiles.group );
+ tiles.dispose();
+
+ }
+
+ tiles = new TilesRenderer();
+ tiles.registerPlugin( new UpdateOnChangePlugin() );
+
+ const pluginOptions = {
+ center: true,
+ shape: 'ellipsoid',
+ levels: 15,
+ tileDimension: 512,
+ url: MVT_URL,
+ styles: state.colors,
+ filter: mvtFilter
+ };
+
+ if ( state.pluginType === '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 setupGUI() {
+
+ gui = new GUI();
+
+ // Plugin Toggle
+ gui.add( state, 'pluginType', [ 'Mesh', 'Texture' ] ).name( 'Renderer Mode' ).onChange( recreateTiles );
+
+ // Layers Folder
+ const layers = gui.addFolder( 'Layers' );
+ const trigger = () => recreateTiles();
+
+ layers.add( state, 'showWater' ).name( 'Water' ).onChange( trigger );
+ layers.add( state, 'showBuildings' ).name( 'Buildings' ).onChange( trigger );
+ layers.add( state, 'showRoads' ).name( 'Roads' ).onChange( trigger );
+ layers.add( state, 'showTransit' ).name( 'Transit' ).onChange( trigger );
+ layers.add( state, 'showLanduse' ).name( 'Landuse' ).onChange( trigger );
+ layers.add( state, 'showAdmin' ).name( 'Admin Borders' ).onChange( trigger );
+ layers.add( state, 'showLabels' ).name( 'Labels' ).onChange( trigger );
+
+ // Details Folder
+ const details = gui.addFolder( 'Filter Settings' );
+ details.add( state, 'maxAdminLevel', 0, 4, 1 ).name( 'Admin Detail' ).onChange( trigger );
+ details.add( state, 'maxSymbolRank', 1, 10, 1 ).name( 'Label Density' ).onChange( trigger );
+
+ // Style Folder
+ const styleFolder = gui.addFolder( 'Map Styles' );
+ for ( let key in state.colors ) {
+
+ styleFolder.addColor( state.colors, key )
+ .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) )
+ .onChange( () => recreateTiles() );
+
+ }
+
+}
+
+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 ea90eb0dc..314959213 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@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",
@@ -23,6 +24,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-react": "^7.37.1",
@@ -37,15 +39,23 @@
"vitest": "^4.0.15"
},
"peerDependencies": {
+ "@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"
},
"peerDependenciesMeta": {
+ "@mapbox/vector-tile": {
+ "optional": true
+ },
"@react-three/fiber": {
"optional": true
},
+ "earcut": {
+ "optional": true
+ },
"react": {
"optional": true
},
@@ -99,6 +109,7 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1538,6 +1549,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "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",
@@ -1699,6 +1729,7 @@
"integrity": "sha512-myPe3YL/C8+Eq939/4qIVEPBW/uxV0iiUbmjfwrs9sGKYDG8ib8Dz3Okq7BQt8P+0k4igedONbjXMQy84aDFmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -2157,6 +2188,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",
@@ -2187,6 +2225,7 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2224,6 +2263,7 @@
"integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
@@ -2284,6 +2324,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2699,6 +2740,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3048,6 +3090,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -3859,6 +3902,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6240,6 +6284,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",
@@ -6368,6 +6425,13 @@
"node": ">=12.0.0"
}
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6436,8 +6500,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-dropzone": {
"version": "12.1.0",
@@ -6644,6 +6707,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.50.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz",
@@ -7216,7 +7289,8 @@
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -7311,6 +7385,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7542,6 +7617,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7740,6 +7816,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7833,6 +7910,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7846,6 +7924,7 @@
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.15",
"@vitest/mocker": "4.0.15",
diff --git a/package.json b/package.json
index d0e6a0bec..2c89798d8 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@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",
@@ -89,6 +90,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-react": "^7.37.1",
@@ -106,7 +108,9 @@
"@react-three/fiber": "^8.17.9 || ^9.0.0",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
- "three": ">=0.167.0"
+ "three": ">=0.167.0",
+ "@mapbox/vector-tile": "^2.0.3",
+ "earcut": "^3.0.2"
},
"peerDependenciesMeta": {
"@react-three/fiber": {
@@ -117,6 +121,12 @@
},
"react-dom": {
"optional": true
+ },
+ "@mapbox/vector-tile": {
+ "optional": true
+ },
+ "earcut": {
+ "optional": true
}
}
}
diff --git a/src/core/renderer/index.d.ts b/src/core/renderer/index.d.ts
index 9dfb59d41..b6a3aea57 100644
--- a/src/core/renderer/index.d.ts
+++ b/src/core/renderer/index.d.ts
@@ -6,6 +6,7 @@ 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/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 0a01e8942..842865d1f 100644
--- a/src/core/renderer/index.js
+++ b/src/core/renderer/index.js
@@ -4,6 +4,7 @@ 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/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/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/images/EPSGTilesPlugin.js b/src/three/plugins/images/EPSGTilesPlugin.js
index 08c45b832..66f41ed56 100644
--- a/src/three/plugins/images/EPSGTilesPlugin.js
+++ b/src/three/plugins/images/EPSGTilesPlugin.js
@@ -6,6 +6,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';
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
export class XYZTilesPlugin extends EllipsoidProjectionTilesPlugin {
@@ -28,6 +29,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 } );
+
+ }
+
+}
+
// Support for TMS tiles
// https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification
// NOTE: Most, if not all, TMS generation implementations do not correctly support the Origin tag
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/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js
new file mode 100644
index 000000000..c5fb09c90
--- /dev/null
+++ b/src/three/plugins/images/sources/MVTImageSource.js
@@ -0,0 +1,224 @@
+import { CanvasTexture, SRGBColorSpace, Color } from 'three';
+import { XYZImageSource } from './XYZImageSource.js';
+import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js';
+import { LAYER_COLORS } from '../../../renderer/utils/layerColors.js';
+
+const _color = new Color();
+
+export class MVTImageSource extends XYZImageSource {
+
+ constructor( options = {} ) {
+
+ super( options );
+
+ this.loader = new MVTLoaderBase();
+ this.filter = options.filter || ( () => true );
+ this.tileDimension = options.tileDimension || 512;
+
+ this._styles = {};
+ const colorsToSet = Object.assign( {}, LAYER_COLORS, options.styles || {} );
+ for ( const key in colorsToSet ) {
+
+ _color.set( colorsToSet[ key ] );
+ this._styles[ key ] = _color.getStyle();
+
+ }
+
+ }
+
+ _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;
+
+ }
+
+ }
+
+ async processBufferToTexture( buffer ) {
+
+ const { vectorTile } = await this.loader.parse( buffer );
+
+ const canvas = this._createCanvas( this.tileDimension, this.tileDimension );
+ const ctx = canvas.getContext( '2d' );
+
+ const scale = this.tileDimension / 4096;
+
+ // Draw Order
+ const layerOrder = [
+ 'landuse', 'park', 'water', 'waterway',
+ 'transportation', 'road', 'building',
+ 'admin', 'poi', 'place_label'
+ ];
+
+ const layersToDraw = Object.keys( vectorTile.layers ).sort( ( a, b ) => {
+
+ let idxA = layerOrder.indexOf( a );
+ let idxB = layerOrder.indexOf( b );
+ if ( idxA === - 1 ) idxA = 0;
+ if ( idxB === - 1 ) idxB = 0;
+ return idxA - idxB;
+
+ } );
+
+ for ( const layerName of layersToDraw ) {
+
+ const layer = vectorTile.layers[ layerName ];
+ const color = this._styles[ layerName ] || this._styles[ 'default' ];
+
+ ctx.fillStyle = color;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+
+ for ( let i = 0; i < layer.length; i ++ ) {
+
+ const feature = layer.feature( i );
+
+ if ( ! this.filter( feature, layerName ) ) continue;
+
+ const geometry = feature.loadGeometry();
+ const type = feature.type;
+
+ ctx.beginPath();
+
+ // --- TYPE 1: POINTS (POIs & LABELS) ---
+ if ( type === 1 ) {
+
+ const isLabelLayer = ( layerName === 'place_label' );
+
+ // 1. Setup Text Styles if this is a label layer
+ // if ( isLabelLayer ) {
+
+ // ctx.fillStyle = '#ffffff'; // White text
+ // ctx.font = '12px sans-serif'; // Adjust size/font as needed
+ // ctx.textAlign = 'center';
+ // ctx.textBaseline = 'middle';
+
+ // // Optional: Add a stroke/shadow so text is readable on busy backgrounds
+ // ctx.strokeStyle = '#000000';
+ // ctx.lineWidth = 3;
+
+ // }
+
+ for ( const multiPoint of geometry ) {
+
+ for ( const p of multiPoint ) {
+
+ const x = p.x * scale;
+ const y = p.y * scale;
+
+ if ( isLabelLayer ) {
+
+ // 2. Render Text for places
+ // "name" is the standard property key for labels in MVT
+ // const labelText = feature.properties.name;
+
+ // Simple collision check (optional/rudimentary):
+ // if ( labelText ) {
+
+ // ctx.strokeText( labelText, x, y );
+ // ctx.fillText( labelText, x, y );
+
+ // }
+
+ } else {
+
+ // 3. Render Dots for standard POIs
+ const radius = ( layerName === 'poi' ) ? 3 : 2;
+
+ ctx.beginPath();
+ ctx.moveTo( x + radius, y );
+ ctx.arc( x, y, radius, 0, Math.PI * 2 );
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ }
+
+ }
+
+ }
+
+ } else if ( type === 2 ) {
+
+ // --- TYPE 2: LINES ---
+
+ 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();
+
+ } else if ( type === 3 ) {
+
+ // --- TYPE 3: POLYGONS ---
+
+ 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();
+
+ }
+
+ }
+
+ }
+
+ const tex = new CanvasTexture( canvas );
+ tex.colorSpace = SRGBColorSpace;
+ tex.generateMipmaps = false;
+ tex.needsUpdate = true;
+ return tex;
+
+ } catch( err ) {
+
+ if ( ! signal.aborted ) {
+
+ console.error( '[MVTImageSource] Error:', err );
+
+ }
+
+ return this._createEmptyTexture();
+
+ }
+
+ _createEmptyTexture() {
+
+ // Use helper
+ const canvas = this._createCanvas( this.tileDimension, this.tileDimension );
+
+ const tex = new CanvasTexture( canvas );
+ tex.colorSpace = SRGBColorSpace;
+ tex.generateMipmaps = false;
+ return tex;
+
+ }
+
+
+}
diff --git a/src/three/plugins/index.d.ts b/src/three/plugins/index.d.ts
index efb4ce02c..8ef48a058 100644
--- a/src/three/plugins/index.d.ts
+++ b/src/three/plugins/index.d.ts
@@ -17,6 +17,7 @@ export * from './DebugTilesPlugin.js';
// other formats
export * from './images/DeepZoomImagePlugin.js';
export * from './images/EPSGTilesPlugin.js';
+export * from './MVTTilesMeshPlugin.js';
// gltf extensions
export * from './gltf/GLTFCesiumRTCExtension.js';
diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js
index 329725c6f..ead735f75 100644
--- a/src/three/plugins/index.js
+++ b/src/three/plugins/index.js
@@ -10,12 +10,14 @@ 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 './MVTTilesMeshPlugin.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..68e68b7a0
--- /dev/null
+++ b/src/three/renderer/loaders/MVTLoader.js
@@ -0,0 +1,633 @@
+import { MVTLoaderBase } from '3d-tiles-renderer/core';
+import {
+ Group,
+ Points,
+ PointsMaterial,
+ BufferGeometry,
+ Float32BufferAttribute,
+ DefaultLoadingManager,
+ LineBasicMaterial,
+ LineSegments,
+ Mesh,
+ MeshBasicMaterial,
+ FrontSide,
+ Color
+} from 'three';
+import earcut from 'earcut';
+import { LAYER_COLORS } from '../utils/layerColors';
+import { MVT_EXTENT } from '../../../core/renderer/loaders/MVTLoaderBase';
+
+// --- 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)
+ // Note: MVT Y axis is technically positive down, but the math for the box [0,4096] is the same.
+ 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
+ // Calculate new vertex index based on current newPositions length
+ 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
+ };
+
+}
+
+const ENABLE_DENSIFICATION = true;
+
+const LAYER_STACK = [
+ 'place_label',
+ 'poi',
+ 'admin',
+ 'building',
+ 'road',
+ 'transportation',
+ 'park',
+ 'landuse',
+ 'waterway',
+ 'water'
+];
+const _color = new Color();
+
+export class MVTLoader extends MVTLoaderBase {
+
+ constructor( manager = DefaultLoadingManager, styles = {} ) {
+
+ super();
+ this.manager = manager;
+
+ this._styles = {};
+ const colorsToSet = Object.assign( {}, LAYER_COLORS, styles || {} );
+ for ( const key in colorsToSet ) {
+
+ _color.set( colorsToSet[ key ] );
+ this._styles[ key ] = _color.getHex();
+
+ }
+
+ this.defaultPointsMaterial = new PointsMaterial( { color: 0xff0000, size: 4, sizeAttenuation: false, depthTest: false, transparent: true } );
+ this.defaultLineMaterial = new LineBasicMaterial( { color: 0x44aaff, linewidth: 2, depthTest: false } );
+ this.defaultMeshMaterial = new MeshBasicMaterial( { color: 0x44aaff, side: FrontSide, wireframe: false, depthTest: false } );
+
+ this.filter = ( feature, layerName ) => true;
+
+ }
+
+ parse( buffer ) {
+
+ return super.parse( buffer ).then( async ( result ) => {
+
+ const { vectorTile } = result;
+ const group = new Group();
+ group.name = 'MVTScene';
+
+ const flatCoordinates = [];
+ const holeIndices = [];
+ const polygons = [];
+
+ for ( const layerName in vectorTile.layers ) {
+
+ let isTransparent = false;
+ let layerIndex = 0;
+
+ const layer = vectorTile.layers[ layerName ];
+
+ if ( layerName.endsWith( '_overlay' ) ) {
+
+ isTransparent = true;
+ layerIndex = 0.12; // draw just before labels, on top of the rest
+
+ } else if ( layerName.endsWith( '_label' ) ) {
+
+ layerIndex = 0.1; // draw on top of everything else (labels)
+
+ } else {
+
+ let layerIndex = LAYER_STACK.indexOf( layerName );
+ if ( layerIndex === - 1 ) {
+
+ console.log( 'layerName not found in LAYER_STACK:', layerName );
+ layerIndex = 0; // draw on top if not found to debug
+
+ }
+
+ }
+
+ const pointsPositions = [];
+ const linePositions = [];
+ const meshPositions = [];
+ const meshIndices = [];
+
+ // Track meshVertexCount implicitly via meshPositions.length / 3
+
+ for ( let i = 0; i < layer.length; i ++ ) {
+
+ const feature = layer.feature( i );
+ if ( ! this.filter( feature, layerName ) ) continue;
+
+ const geometry = feature.loadGeometry();
+ const type = feature.type;
+
+ if ( type === 1 ) {
+
+ // --- TYPE 1: POINTS ---
+ 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 );
+
+ }
+
+ }
+
+ } else if ( type === 2 ) {
+
+ // --- TYPE 2: LINES ---
+ 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 line is completely outside, clipped is null
+ if ( clipped ) {
+
+ linePositions.push( clipped[ 0 ].x, - clipped[ 0 ].y, 0 );
+ linePositions.push( clipped[ 1 ].x, - clipped[ 1 ].y, 0 );
+
+ }
+
+ }
+
+ }
+
+ } else if ( type === 3 ) {
+
+ // --- TYPE 3: POLYGONS ---
+ polygons.length = 0;
+ const clippedRings = [];
+
+ for ( const ring of geometry ) {
+
+ const clipped = clipPolygonRing( ring );
+ // Only keep rings that still have area
+ 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;
+
+ // 1. Flatten Data
+ 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;
+
+ }
+
+ // 2. Run Earcut
+ const triangles = earcut( flatCoordinates, holeIndices );
+
+ // 3. Process Result
+ // If densification is OFF, we write directly to the main buffer to avoid double-looping and copying.
+ const currentOffset = meshPositions.length / 3;
+
+ if ( ! ENABLE_DENSIFICATION ) {
+
+ // --- FAST PATH (Direct Write) ---
+
+ // Push Positions: Convert 2D flat coords to 3D (x, -y, 0)
+ for ( let k = 0; k < flatCoordinates.length; k += 2 ) {
+
+ meshPositions.push( flatCoordinates[ k ], - flatCoordinates[ k + 1 ], 0 );
+
+ }
+
+ // Push Indices: Adjust by currentOffset
+ for ( let k = 0; k < triangles.length; k += 3 ) {
+
+ // And we SWAP the last two (k+2, k+1) to flip the normal outward
+ meshIndices.push(
+ triangles[ k ] + currentOffset,
+ triangles[ k + 2 ] + currentOffset,
+ triangles[ k + 1 ] + currentOffset
+ );
+
+ }
+
+ } else {
+
+ // --- DENSIFICATION PATH ---
+
+ // Reconstruct 3D points for the densifier
+ const rawPos = [];
+ for ( let k = 0; k < flatCoordinates.length; k += 2 ) {
+
+ rawPos.push( flatCoordinates[ k ], - flatCoordinates[ k + 1 ], 0 );
+
+ }
+
+ // Threshold: ~100-200 ensures roughly 20-40 segments across the tile (4096 extent)
+ const DENSITY_THRESHOLD = 1400; // arbitrary number, tweak as needed
+ const densified = densifyGeometry( rawPos, triangles, DENSITY_THRESHOLD );
+
+ // Copy densified result to main buffer
+ for ( let k = 0; k < densified.positions.length; k ++ ) {
+
+ meshPositions.push( densified.positions[ k ] );
+
+ }
+
+ for ( let k = 0; k < densified.indices.length; k += 3 ) {
+
+ meshIndices.push(
+ densified.indices[ k ] + currentOffset,
+ densified.indices[ k + 2 ] + currentOffset,
+ densified.indices[ k + 1 ] + currentOffset
+ );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ // --- BUILD MESHES ---
+
+ // 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 ) );
+
+ // CLONE material so we can set a unique color, can be optimised later
+ const lines = new LineSegments( geometry, this.defaultLineMaterial.clone() );
+
+ // RENDER ORDER:
+ // Polygons are at (-layerIndex - 1) which is usually < -1.
+ // We put lines at 1 to ensure they sit ON TOP of all land/water polygons.
+ lines.renderOrder = 1;
+
+ lines.name = layerName + '_lines';
+ lines.raycast = () => false;
+
+ // --- COLOR LOGIC FOR LINES ---
+ let layerColor = this._styles[ layerName ];
+
+ if ( ! layerColor ) {
+
+ layerColor = this._styles[ 'default' ];
+
+
+ }
+
+ 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;
+ let layerColor = this._styles[ layerName ];
+
+ if ( ! layerColor ) {
+
+ layerColor = this._styles[ 'default' ];
+
+ }
+
+ if ( isTransparent ) {
+
+ mesh.material.transparent = true;
+ mesh.material.opacity = 0.6;
+
+ }
+
+ mesh.material.color.setHex( layerColor );
+ group.add( mesh );
+
+ }
+
+ }
+
+ result.scene = group;
+ result.scene.vectorTile = vectorTile;
+ return result;
+
+ } );
+
+ }
+
+}
diff --git a/src/three/renderer/utils/layerColors.js b/src/three/renderer/utils/layerColors.js
new file mode 100644
index 000000000..ba12289c3
--- /dev/null
+++ b/src/three/renderer/utils/layerColors.js
@@ -0,0 +1,19 @@
+/* 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
+ 'admin': 0x444545,
+ 'background': 0x111111,
+ 'default': 0x222222
+};