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 +};