From 7df3760d71a482f9d1053adcb66f0fb54198c3eb Mon Sep 17 00:00:00 2001 From: AlaricBaraou Date: Mon, 2 Feb 2026 14:27:51 +0900 Subject: [PATCH 01/60] Add PMTiles plugin with MVT support --- example/three/pmtiles.html | 21 +++ example/three/pmtiles.js | 176 ++++++++++++++++++ package-lock.json | 84 ++++++++- package.json | 15 ++ src/core/renderer/index.js | 2 + src/core/renderer/loaders/MVTLoaderBase.js | 19 ++ .../renderer/loaders/PMTilesLoaderBase.js | 70 +++++++ src/three/plugins/images/PMTilesPlugin.js | 32 ++++ .../plugins/images/sources/MVTImageSource.js | 33 ++++ .../images/sources/PMTilesImageSource.js | 33 ++++ src/three/plugins/index.js | 1 + .../utils/VectorTileCanvasRenderer.js | 176 ++++++++++++++++++ src/three/renderer/utils/VectorTileStyler.js | 54 ++++++ src/three/renderer/utils/layerColors.js | 34 ++++ 14 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 example/three/pmtiles.html create mode 100644 example/three/pmtiles.js create mode 100644 src/core/renderer/loaders/MVTLoaderBase.js create mode 100644 src/core/renderer/loaders/PMTilesLoaderBase.js create mode 100644 src/three/plugins/images/PMTilesPlugin.js create mode 100644 src/three/plugins/images/sources/MVTImageSource.js create mode 100644 src/three/plugins/images/sources/PMTilesImageSource.js create mode 100644 src/three/renderer/utils/VectorTileCanvasRenderer.js create mode 100644 src/three/renderer/utils/VectorTileStyler.js create mode 100644 src/three/renderer/utils/layerColors.js diff --git a/example/three/pmtiles.html b/example/three/pmtiles.html new file mode 100644 index 000000000..6a380bc5a --- /dev/null +++ b/example/three/pmtiles.html @@ -0,0 +1,21 @@ + + + + + + PMTiles Globe Example + + + + + + + diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js new file mode 100644 index 000000000..09656a43a --- /dev/null +++ b/example/three/pmtiles.js @@ -0,0 +1,176 @@ +import { + Scene, + WebGLRenderer, + PerspectiveCamera, + AmbientLight, + DirectionalLight, +} from 'three'; +import { + TilesRenderer, + GlobeControls, +} from '3d-tiles-renderer'; +import { + UpdateOnChangePlugin, + PMTilesPlugin, +} from '3d-tiles-renderer/plugins'; +import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; + +let scene, renderer, camera, controls, tiles, gui; + +// Layer configuration for Protomaps v4 basemap +const LAYERS = { + water: { enabled: true, color: '#4a90d9' }, + earth: { enabled: true, color: '#f2efe9' }, + landuse: { enabled: false, color: '#e8e4d8' }, + landcover: { enabled: false, color: '#d4e8c2' }, + natural: { enabled: false, color: '#c8d9af' }, + roads: { enabled: false, color: '#ffffff' }, + buildings: { enabled: false, color: '#d9d0c9' }, + transit: { enabled: false, color: '#888888' }, + boundaries: { enabled: true, color: '#ff6b6b' }, + places: { enabled: true, color: '#333333' }, + pois: { enabled: false, color: '#7d4e24' }, +}; + +// Application state +const state = { + layers: {}, + colors: {}, +}; + +// Initialize state from layer config +for ( const key in LAYERS ) { + + state.layers[ key ] = LAYERS[ key ].enabled; + state.colors[ key ] = LAYERS[ key ].color; + +} + +state.colors.default = '#cccccc'; + +init(); +setupGUI(); +createTiles(); + +function init() { + + renderer = new WebGLRenderer( { antialias: true } ); + renderer.setAnimationLoop( render ); + renderer.setPixelRatio( window.devicePixelRatio ); + renderer.setSize( window.innerWidth, window.innerHeight ); + renderer.setClearColor( 0x111111 ); + document.body.appendChild( renderer.domElement ); + + scene = new Scene(); + camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 100, 1e8 ); + + const dirLight = new DirectionalLight( 0xffffff ); + dirLight.position.set( 1, 1, 1 ); + scene.add( dirLight ); + scene.add( new AmbientLight( 0x444444 ) ); + + controls = new GlobeControls( scene, camera, renderer.domElement ); + controls.enableDamping = true; + controls.camera.position.set( 0, 0, 1.5 * 1e7 ); + + window.addEventListener( 'resize', onWindowResize, false ); + +} + +function createFilter() { + + return function ( feature, layerName ) { + + if ( layerName in state.layers ) { + + return state.layers[ layerName ] === true; + + } + + // Unknown layers: hide by default + return false; + + }; + +} + +function createTiles() { + + if ( tiles ) { + + scene.remove( tiles.group ); + tiles.dispose(); + + } + + tiles = new TilesRenderer(); + tiles.registerPlugin( new UpdateOnChangePlugin() ); + tiles.registerPlugin( new PMTilesPlugin( { + url: 'https://demo-bucket.protomaps.com/v4.pmtiles', + center: true, + shape: 'ellipsoid', + levels: 15, + tileDimension: 512, + styles: state.colors, + filter: createFilter() + } ) ); + + tiles.group.rotation.x = - Math.PI / 2; + tiles.setCamera( camera ); + scene.add( tiles.group ); + + if ( controls ) controls.setEllipsoid( tiles.ellipsoid, tiles.group ); + +} + +function setupGUI() { + + gui = new GUI(); + + // Layers folder + const layersFolder = gui.addFolder( 'Layers' ); + for ( const key in LAYERS ) { + + layersFolder.add( state.layers, key ) + .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) + .onChange( createTiles ); + + } + + // Colors folder + const colorsFolder = gui.addFolder( 'Colors' ); + for ( const key in LAYERS ) { + + colorsFolder.addColor( state.colors, key ) + .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) + .onChange( createTiles ); + + } + + colorsFolder.close(); + +} + +function onWindowResize() { + + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize( window.innerWidth, window.innerHeight ); + +} + +function render() { + + controls.update(); + if ( tiles ) { + + camera.updateMatrixWorld(); + tiles.setCamera( camera ); + tiles.setResolutionFromRenderer( camera, renderer ); + tiles.update(); + + } + + renderer.render( scene, camera ); + +} diff --git a/package-lock.json b/package-lock.json index 27955296c..98abcc6fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "3d-tiles-renderer", "version": "0.4.19", "license": "Apache-2.0", + "dependencies": { + "@mapbox/vector-tile": "^2.0.3", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2" + }, "devDependencies": { "@babel/preset-modules": "^0.1.6", "@babel/preset-react": "^7.26.3", @@ -99,6 +104,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -708,7 +714,8 @@ "resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-8.48.0.tgz", "integrity": "sha512-6iJk72ufSdU+ui6BtHPMTyfhrS5EMbKz3568mmOu5Tn85BOdRbwLcZu30EJpSCxZ7tdFs5goBTMcEv19eYuTCQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@babylonjs/loaders": { "version": "8.48.0", @@ -1550,6 +1557,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", @@ -2361,6 +2385,7 @@ "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2859,6 +2884,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2889,6 +2920,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2899,6 +2931,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2926,6 +2959,7 @@ "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", @@ -2995,6 +3029,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3410,6 +3445,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3777,6 +3813,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4584,6 +4621,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4945,7 +4983,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -6568,6 +6605,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6581,6 +6630,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6588,6 +6638,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pmtiles": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.2.tgz", + "integrity": "sha512-Ath2F2U2E37QyNXjN1HOF+oLiNIbdrDYrk/K3C9K4Pgw2anwQX10y4WYWEH9O75vPiu0gBbSWIAbSG19svyvZg==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6702,6 +6761,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6966,6 +7031,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -7574,7 +7648,8 @@ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -7869,6 +7944,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8052,6 +8128,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8127,6 +8204,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index 49ea21e67..d8d976009 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "license": "Apache-2.0", "devDependencies": { "@babel/preset-modules": "^0.1.6", + "@mapbox/vector-tile": "^2.0.3", "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@eslint/js": "^9.0.0", @@ -102,6 +103,8 @@ "globals": "^16.5.0", "leva": "^0.10.0", "lil-gui": "^0.21.0", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2", "postprocessing": "^6.36.4", "three": "^0.170.0", "typescript": "^5.6.0", @@ -110,14 +113,20 @@ "vitest": "^4.0.15" }, "peerDependencies": { + "@mapbox/vector-tile": "^2.0.3", "@react-three/fiber": "^8.17.9 || ^9.0.0", "@babylonjs/core": ">=8.0.0", "@babylonjs/loaders": ">=8.0.0", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "three": ">=0.167.0" }, "peerDependenciesMeta": { + "@mapbox/vector-tile": { + "optional": true + }, "@react-three/fiber": { "optional": true }, @@ -127,6 +136,12 @@ "@babylonjs/loaders": { "optional": true }, + "pbf": { + "optional": true + }, + "pmtiles": { + "optional": true + }, "react": { "optional": true }, diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js index e175b9654..872b78b38 100644 --- a/src/core/renderer/index.js +++ b/src/core/renderer/index.js @@ -4,6 +4,8 @@ export { LoaderBase } from './loaders/LoaderBase.js'; export * from './loaders/B3DMLoaderBase.js'; export * from './loaders/I3DMLoaderBase.js'; export * from './loaders/PNTSLoaderBase.js'; +export * from './loaders/MVTLoaderBase.js'; +export * from './loaders/PMTilesLoaderBase.js'; export * from './loaders/CMPTLoaderBase.js'; export * from './constants.js'; diff --git a/src/core/renderer/loaders/MVTLoaderBase.js b/src/core/renderer/loaders/MVTLoaderBase.js new file mode 100644 index 000000000..d95a0d785 --- /dev/null +++ b/src/core/renderer/loaders/MVTLoaderBase.js @@ -0,0 +1,19 @@ +// MVT File Format +// https://github.com/mapbox/vector-tile-spec/blob/master/2.1/README.md + +import { LoaderBase } from './LoaderBase.js'; +import { VectorTile } from '@mapbox/vector-tile'; +import Protobuf from 'pbf'; + +export class MVTLoaderBase extends LoaderBase { + + parse( buffer ) { + + const pbf = new Protobuf( buffer ); + const vectorTile = new VectorTile( pbf ); + + return Promise.resolve( { vectorTile } ); + + } + +} diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js new file mode 100644 index 000000000..80b951e24 --- /dev/null +++ b/src/core/renderer/loaders/PMTilesLoaderBase.js @@ -0,0 +1,70 @@ +// PMTiles Archive Format +// https://github.com/protomaps/PMTiles + +import { PMTiles } from 'pmtiles'; + +export class PMTilesLoaderBase { + + constructor() { + + this.instance = null; + this.header = null; + this.url = null; + + } + + // Initialize the PMTiles archive and load header + async init( url ) { + + this.url = url; + this.instance = new PMTiles( url ); + this.header = await this.instance.getHeader(); + + return this.header; + + } + + // Fetch a tile from the archive + async getTile( z, x, y, signal ) { + + if ( ! this.instance ) { + + throw new Error( 'PMTilesLoaderBase: Archive not initialized. Call init() first.' ); + + } + + const res = await this.instance.getZxy( z, x, y, signal ); + + if ( ! res || ! res.data ) { + + return null; + + } + + return res.data; + + } + + // Generate a virtual URL for a tile (used by tiling scheme) + getUrl( z, x, y ) { + + return `pmtiles://${z}/${x}/${y}`; + + } + + // Parse tile coordinates from a virtual URL (pmtiles://z/x/y) + static parseUrl( url ) { + + const i2 = url.lastIndexOf( '/' ); + const i1 = url.lastIndexOf( '/', i2 - 1 ); + const i0 = url.lastIndexOf( '/', i1 - 1 ); + + return { + z: parseInt( url.slice( i0 + 1, i1 ) ), + x: parseInt( url.slice( i1 + 1, i2 ) ), + y: parseInt( url.slice( i2 + 1 ) ), + }; + + } + +} diff --git a/src/three/plugins/images/PMTilesPlugin.js b/src/three/plugins/images/PMTilesPlugin.js new file mode 100644 index 000000000..a15229753 --- /dev/null +++ b/src/three/plugins/images/PMTilesPlugin.js @@ -0,0 +1,32 @@ +import { EllipsoidProjectionTilesPlugin } from './EllipsoidProjectionTilesPlugin.js'; +import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; +import { PMTilesLoaderBase } from '../../../core/renderer/loaders/PMTilesLoaderBase.js'; + +export class PMTilesPlugin extends EllipsoidProjectionTilesPlugin { + + constructor( options = {} ) { + + super( options ); + + this.name = 'PMTILES_PLUGIN'; + this.imageSource = new PMTilesImageSource( options ); + + } + + // Intercept pmtiles:// URLs and fetch from the PMTiles archive + fetchData( url, options ) { + + if ( url.startsWith( 'pmtiles://' ) ) { + + const { z, x, y } = PMTilesLoaderBase.parseUrl( url ); + + return this.imageSource.pmtilesLoader.getTile( z, x, y, options?.signal ) + .then( buffer => buffer || new ArrayBuffer( 0 ) ); + + } + + return null; + + } + +} diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js new file mode 100644 index 000000000..6dcb08e3d --- /dev/null +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -0,0 +1,33 @@ +import { XYZImageSource } from './XYZImageSource.js'; +import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js'; +import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js'; +import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; + +export class MVTImageSource extends XYZImageSource { + + constructor( options = {} ) { + + super( options ); + + this.loader = new MVTLoaderBase(); + this.tileDimension = options.tileDimension || 512; + + this._styler = new VectorTileStyler( { + filter: options.filter, + styles: options.styles + } ); + + this._renderer = new VectorTileCanvasRenderer( this._styler, { + tileDimension: this.tileDimension + } ); + + } + + async processBufferToTexture( buffer ) { + + const { vectorTile } = await this.loader.parse( buffer ); + return this._renderer.render( vectorTile ); + + } + +} diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js new file mode 100644 index 000000000..97efe679d --- /dev/null +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -0,0 +1,33 @@ +import { MVTImageSource } from './MVTImageSource.js'; +import { ProjectionScheme } from '../utils/ProjectionScheme.js'; +import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js'; + +export class PMTilesImageSource extends MVTImageSource { + + constructor( options = {} ) { + + super( options ); + + this.pmtilesLoader = new PMTilesLoaderBase(); + this.tiling.flipY = true; + + } + + getUrl( x, y, level ) { + + return this.pmtilesLoader.getUrl( level, x, y ); + + } + + async init() { + + const header = await this.pmtilesLoader.init( this.url ); + this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) ); + this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, { + tilePixelWidth: this.tileDimension, + tilePixelHeight: this.tileDimension, + } ); + + } + +} diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js index 329725c6f..1a4970d59 100644 --- a/src/three/plugins/index.js +++ b/src/three/plugins/index.js @@ -16,6 +16,7 @@ export * from './DebugTilesPlugin.js'; // other formats export * from './images/DeepZoomImagePlugin.js'; export * from './images/EPSGTilesPlugin.js'; +export * from './images/PMTilesPlugin.js'; // gltf extensions export * from './gltf/GLTFCesiumRTCExtension.js'; diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js new file mode 100644 index 000000000..c4cdd24c0 --- /dev/null +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -0,0 +1,176 @@ +import { CanvasTexture, SRGBColorSpace } from 'three'; + +const MVT_EXTENT = 4096; + +export class VectorTileCanvasRenderer { + + constructor( styler, options = {} ) { + + this.styler = styler; + this.tileDimension = options.tileDimension || 512; + + } + + render( vectorTile ) { + + const canvas = this._createCanvas( this.tileDimension, this.tileDimension ); + const ctx = canvas.getContext( '2d' ); + const scale = this.tileDimension / MVT_EXTENT; + + for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { + + const color = this.styler.getColor( layerName, 'css' ); + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + if ( type === 1 ) { + + this._renderPoints( ctx, geometry, layerName, scale ); + + } else if ( type === 2 ) { + + this._renderLines( ctx, geometry, scale ); + + } else if ( type === 3 ) { + + this._renderPolygons( ctx, geometry, scale ); + + } + + } + + return this._createTexture( canvas ); + + } + + _getFeatures( vectorTile ) { + + const results = []; + const layerNames = Object.keys( vectorTile.layers ); + const sortedLayers = this.styler.sortLayers( layerNames ); + + for ( const layerName of sortedLayers ) { + + const layer = vectorTile.layers[ layerName ]; + + for ( let i = 0; i < layer.length; i ++ ) { + + const feature = layer.feature( i ); + + if ( this.styler.shouldIncludeFeature( feature, layerName ) ) { + + results.push( { + layerName, + geometry: feature.loadGeometry(), + type: feature.type, + } ); + + } + + } + + } + + return results; + + } + + _createCanvas( width, height ) { + + if ( typeof OffscreenCanvas !== 'undefined' ) { + + return new OffscreenCanvas( width, height ); + + } else { + + const canvas = document.createElement( 'canvas' ); + canvas.width = width; + canvas.height = height; + return canvas; + + } + + } + + _createTexture( canvas ) { + + const tex = new CanvasTexture( canvas ); + tex.colorSpace = SRGBColorSpace; + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; + + } + + _renderPoints( ctx, geometry, layerName, scale ) { + + const isLabelLayer = ( layerName === 'place_label' ); + + for ( const multiPoint of geometry ) { + + for ( const p of multiPoint ) { + + const x = p.x * scale; + const y = p.y * scale; + + if ( ! isLabelLayer ) { + + const radius = ( layerName === 'poi' ) ? 3 : 2; + + ctx.beginPath(); + ctx.moveTo( x + radius, y ); + ctx.arc( x, y, radius, 0, Math.PI * 2 ); + ctx.fill(); + + } + + } + + } + + } + + _renderLines( ctx, geometry, scale ) { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const p = ring[ k ]; + if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale ); + else ctx.lineTo( p.x * scale, p.y * scale ); + + } + + } + + ctx.stroke(); + + } + + _renderPolygons( ctx, geometry, scale ) { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const p = ring[ k ]; + if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale ); + else ctx.lineTo( p.x * scale, p.y * scale ); + + } + + ctx.closePath(); + + } + + ctx.fill(); + + } + +} diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js new file mode 100644 index 000000000..4f4e76f6d --- /dev/null +++ b/src/three/renderer/utils/VectorTileStyler.js @@ -0,0 +1,54 @@ +import { Color } from 'three'; +import { LAYER_COLORS, DEFAULT_LAYER_ORDER } from './layerColors.js'; + +const _color = /* @__PURE__ */ new Color(); + +export class VectorTileStyler { + + constructor( options = {} ) { + + this.filter = options.filter || ( () => true ); + this._layerOrder = options.layerOrder || DEFAULT_LAYER_ORDER; + this._styles = {}; + + const colorsToSet = Object.assign( {}, LAYER_COLORS, options.styles || {} ); + for ( const key in colorsToSet ) { + + _color.set( colorsToSet[ key ] ); + this._styles[ key ] = { + hex: _color.getHex(), + css: _color.getStyle() + }; + + } + + } + + getColor( layerName, format = 'hex' ) { + + const style = this._styles[ layerName ] || this._styles[ 'default' ]; + return format === 'css' ? style.css : style.hex; + + } + + sortLayers( layerNames ) { + + return [ ...layerNames ].sort( ( a, b ) => { + + let idxA = this._layerOrder.indexOf( a ); + let idxB = this._layerOrder.indexOf( b ); + if ( idxA === - 1 ) idxA = 0; + if ( idxB === - 1 ) idxB = 0; + return idxA - idxB; + + } ); + + } + + shouldIncludeFeature( feature, layerName ) { + + return this.filter( feature, layerName ); + + } + +} diff --git a/src/three/renderer/utils/layerColors.js b/src/three/renderer/utils/layerColors.js new file mode 100644 index 000000000..795a87ad9 --- /dev/null +++ b/src/three/renderer/utils/layerColors.js @@ -0,0 +1,34 @@ +/* non exhaustive list of layer colors */ +export const LAYER_COLORS = { + // Nature & Water + 'water': 0x201f20, + 'waterway': 0x201f20, + 'landuse': 0xcaedc1, + 'landuse_overlay': 0xcaedc1, + 'park': 0x5da859, + + // Infrastructure + 'building': 0xeeeeee, + 'road': 0x444444, + 'transportation': 0x444444, + + // Boundaries & Background + 'boundaries': 0x444545, + 'background': 0x111111, + 'default': 0x222222 +}; + +/* Default layer ordering for vector tiles (bottom to top) */ +export const DEFAULT_LAYER_ORDER = [ + 'landuse', + 'landuse_overlay', + 'park', + 'water', + 'waterway', + 'transportation', + 'road', + 'building', + 'boundaries', + 'poi', + 'place_label' +]; From a1e130899fac74d5a33d3ffe41cca4834c2fd878 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:02:40 +0900 Subject: [PATCH 02/60] Add support for redraw, rearchitecture --- example/three/pmtiles.js | 141 +++++------ .../renderer/loaders/PMTilesLoaderBase.js | 2 +- .../plugins/images/ImageOverlayPlugin.js | 2 +- src/three/plugins/images/MVTOverlay.js | 156 ++++++++++++ src/three/plugins/images/PMTilesPlugin.js | 32 --- .../plugins/images/sources/MVTImageSource.js | 239 +++++++++++++++++- .../images/sources/PMTilesImageSource.js | 29 ++- src/three/plugins/index.js | 2 +- .../utils/VectorTileCanvasRenderer.js | 102 +++----- 9 files changed, 505 insertions(+), 200 deletions(-) create mode 100644 src/three/plugins/images/MVTOverlay.js delete mode 100644 src/three/plugins/images/PMTilesPlugin.js diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 09656a43a..1cae6c9aa 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -1,44 +1,37 @@ -import { - Scene, - WebGLRenderer, - PerspectiveCamera, - AmbientLight, - DirectionalLight, -} from 'three'; +import { Scene, WebGLRenderer, PerspectiveCamera } from 'three'; import { TilesRenderer, GlobeControls, } from '3d-tiles-renderer'; import { UpdateOnChangePlugin, - PMTilesPlugin, + TilesFadePlugin, + XYZTilesPlugin, + ImageOverlayPlugin, + PMTilesOverlay, } from '3d-tiles-renderer/plugins'; -import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; - -let scene, renderer, camera, controls, tiles, gui; +import GUI from 'three/addons/libs/lil-gui.module.min.js'; -// Layer configuration for Protomaps v4 basemap +// Layer config for Protomaps v4 basemap — colors from the Protomaps "Light" theme const LAYERS = { - water: { enabled: true, color: '#4a90d9' }, - earth: { enabled: true, color: '#f2efe9' }, - landuse: { enabled: false, color: '#e8e4d8' }, - landcover: { enabled: false, color: '#d4e8c2' }, - natural: { enabled: false, color: '#c8d9af' }, - roads: { enabled: false, color: '#ffffff' }, - buildings: { enabled: false, color: '#d9d0c9' }, - transit: { enabled: false, color: '#888888' }, - boundaries: { enabled: true, color: '#ff6b6b' }, - places: { enabled: true, color: '#333333' }, - pois: { enabled: false, color: '#7d4e24' }, + earth: { enabled: true, color: '#e2dfda' }, + water: { enabled: true, color: '#80deea' }, + landcover: { enabled: true, color: '#c4e7d2' }, + landuse: { enabled: true, color: '#cfddd5' }, + natural: { enabled: true, color: '#e2e0d7' }, + buildings: { enabled: true, color: '#cccccc' }, + roads: { enabled: true, color: '#ebebeb' }, + transit: { enabled: true, color: '#a7b1b3' }, + boundaries: { enabled: true, color: '#adadad' }, + places: { enabled: true, color: '#5c5c5c' }, + pois: { enabled: true, color: '#1a8cbd' }, }; -// Application state const state = { layers: {}, colors: {}, }; -// Initialize state from layer config for ( const key in LAYERS ) { state.layers[ key ] = LAYERS[ key ].enabled; @@ -48,102 +41,91 @@ for ( const key in LAYERS ) { state.colors.default = '#cccccc'; +let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; + init(); -setupGUI(); -createTiles(); +render(); function init() { renderer = new WebGLRenderer( { antialias: true } ); - renderer.setAnimationLoop( render ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setClearColor( 0x111111 ); + renderer.setAnimationLoop( render ); document.body.appendChild( renderer.domElement ); scene = new Scene(); - camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 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 ); + camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.001, 10000 ); -} - -function createFilter() { - - return function ( feature, layerName ) { + // Base tile layer: XYZ raster tiles provide the globe geometry + tiles = new TilesRenderer(); + tiles.registerPlugin( new UpdateOnChangePlugin() ); + tiles.registerPlugin( new TilesFadePlugin() ); + tiles.registerPlugin( new XYZTilesPlugin( { + center: true, + shape: 'ellipsoid', + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + } ) ); - if ( layerName in state.layers ) { + tiles.setCamera( camera ); + tiles.group.rotation.x = - Math.PI / 2; + tiles.group.updateMatrixWorld(); + scene.add( tiles.group ); - return state.layers[ layerName ] === true; + // PMTiles overlay: vector tile data composited on top of the base geometry + overlay = createOverlay(); + overlayPlugin = new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ); + tiles.registerPlugin( overlayPlugin ); - } + // Controls + controls = new GlobeControls( scene, camera, renderer.domElement ); + controls.setEllipsoid( tiles.ellipsoid, tiles.group ); + controls.enableDamping = true; + controls.camera.position.set( 0, 0, 1.5e7 ); - // Unknown layers: hide by default - return false; + window.addEventListener( 'resize', onWindowResize ); - }; + setupGUI(); } -function createTiles() { - - if ( tiles ) { - - scene.remove( tiles.group ); - tiles.dispose(); - - } +function createOverlay() { - tiles = new TilesRenderer(); - tiles.registerPlugin( new UpdateOnChangePlugin() ); - tiles.registerPlugin( new PMTilesPlugin( { + return new PMTilesOverlay( { url: 'https://demo-bucket.protomaps.com/v4.pmtiles', - center: true, - shape: 'ellipsoid', - levels: 15, - tileDimension: 512, - styles: state.colors, - filter: createFilter() - } ) ); + styles: { ...state.colors }, + filter: ( _feature, layerName ) => state.layers[ layerName ] ?? false, + } ); - tiles.group.rotation.x = - Math.PI / 2; - tiles.setCamera( camera ); - scene.add( tiles.group ); +} - if ( controls ) controls.setEllipsoid( tiles.ellipsoid, tiles.group ); +function updateOverlay() { + + overlay.setStyles( state.colors, ( _feature, layerName ) => state.layers[ layerName ] ?? false ); + overlay.redraw(); } function setupGUI() { - gui = new GUI(); + const gui = new GUI(); - // Layers folder const layersFolder = gui.addFolder( 'Layers' ); for ( const key in LAYERS ) { layersFolder.add( state.layers, key ) .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) - .onChange( createTiles ); + .onChange( updateOverlay ); } - // Colors folder const colorsFolder = gui.addFolder( 'Colors' ); for ( const key in LAYERS ) { colorsFolder.addColor( state.colors, key ) .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) - .onChange( createTiles ); + .onChange( updateOverlay ); } @@ -161,7 +143,8 @@ function onWindowResize() { function render() { - controls.update(); + if ( controls ) controls.update(); + if ( tiles ) { camera.updateMatrixWorld(); diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js index 80b951e24..9374caae7 100644 --- a/src/core/renderer/loaders/PMTilesLoaderBase.js +++ b/src/core/renderer/loaders/PMTilesLoaderBase.js @@ -48,7 +48,7 @@ export class PMTilesLoaderBase { // Generate a virtual URL for a tile (used by tiling scheme) getUrl( z, x, y ) { - return `pmtiles://${z}/${x}/${y}`; + return `pmtiles://${ z }/${ x }/${ y }`; } diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 6ef228d2d..0821a77f7 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1276,7 +1276,7 @@ export class ImageOverlayPlugin { * @param {boolean} [options.alphaInvert=false] If true, inverts the alpha channel before * applying the mask or blend. */ -class ImageOverlay { +export class ImageOverlay { get isPlanarProjection() { diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js new file mode 100644 index 000000000..1eaba3730 --- /dev/null +++ b/src/three/plugins/images/MVTOverlay.js @@ -0,0 +1,156 @@ +import { ImageOverlay } from './ImageOverlayPlugin.js'; +import { MVTImageSource } from './sources/MVTImageSource.js'; +import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; + +/** + * Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry. + * See the {@link https://github.com/mapbox/vector-tile-spec Mapbox Vector Tile specification}. + * @extends ImageOverlay + * @param {Object} [options] + * @param {string} [options.url] URL template with `{x}`, `{y}`, `{z}` placeholders. + * @param {number} [options.levels=20] Number of zoom levels. + * @param {number} [options.tileDimension=256] Tile pixel size. + * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. + * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. + * @param {Object} [options.styles] Per-layer color overrides. + * @param {Function} [options.filter] Feature filter callback `(feature, layerName) => boolean`. + */ +export class MVTOverlay extends ImageOverlay { + + get tiling() { + + return this.imageSource.tiling; + + } + + get projection() { + + return this.tiling.projection; + + } + + get aspectRatio() { + + return this.tiling && this.isReady ? this.tiling.aspectRatio : 1; + + } + + constructor( options = {} ) { + + super( options ); + this.imageSource = options.imageSource ?? new MVTImageSource( options ); + + } + + _init() { + + return this.imageSource.init().then( () => { + + this.imageSource.fetchData = ( ...args ) => this.fetch( ...args ); + + } ); + + } + + calculateLevel( range ) { + + const [ minX, minY, maxX, maxY ] = range; + const w = maxX - minX; + const h = maxY - minY; + const resolution = this.imageSource.resolution; + const maxLevel = this.tiling.maxLevel; + + let level = 0; + for ( ; level < maxLevel; level ++ ) { + + const levelData = this.tiling.getLevel( level ); + if ( levelData === null || levelData === undefined ) { + + continue; + + } + + const { pixelWidth, pixelHeight } = levelData; + if ( pixelWidth >= resolution / w || pixelHeight >= resolution / h ) { + + break; + + } + + } + + return level; + + } + + hasContent( range ) { + + return this.imageSource.hasContent( ...range, this.calculateLevel( range ) ); + + } + + getTexture( range ) { + + return this.imageSource.get( ...range, this.calculateLevel( range ) ); + + } + + lockTexture( range ) { + + return this.imageSource.lock( ...range, this.calculateLevel( range ) ); + + } + + releaseTexture( range ) { + + this.imageSource.release( ...range, this.calculateLevel( range ) ); + + } + + setResolution( resolution ) { + + this.imageSource.resolution = resolution; + + } + + shouldSplit( range ) { + + return this.tiling.maxLevel > this.calculateLevel( range ); + + } + + setStyles( styles, filter ) { + + this.imageSource.setStyles( styles, filter ); + + } + + redraw() { + + this.imageSource.redraw(); + + } + +} + +/** + * Overlay that renders PMTiles (MVT) vector data on top of 3D tile geometry. + * Pass a PMTiles archive URL; the source projection and zoom levels are read + * from the archive header automatically. + * @extends MVTOverlay + * @param {Object} [options] + * @param {string} [options.url] URL to the `.pmtiles` archive. + * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. + * @param {number} [options.tileDimension=256] Tile pixel size used when generating tiling levels. + * @param {Object} [options.styles] Per-layer color overrides. + * @param {Function} [options.filter] Feature filter callback `(feature, layerName) => boolean`. + */ +export class PMTilesOverlay extends MVTOverlay { + + constructor( options = {} ) { + + super( { ...options, imageSource: new PMTilesImageSource( options ) } ); + + } + +} diff --git a/src/three/plugins/images/PMTilesPlugin.js b/src/three/plugins/images/PMTilesPlugin.js deleted file mode 100644 index a15229753..000000000 --- a/src/three/plugins/images/PMTilesPlugin.js +++ /dev/null @@ -1,32 +0,0 @@ -import { EllipsoidProjectionTilesPlugin } from './EllipsoidProjectionTilesPlugin.js'; -import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; -import { PMTilesLoaderBase } from '../../../core/renderer/loaders/PMTilesLoaderBase.js'; - -export class PMTilesPlugin extends EllipsoidProjectionTilesPlugin { - - constructor( options = {} ) { - - super( options ); - - this.name = 'PMTILES_PLUGIN'; - this.imageSource = new PMTilesImageSource( options ); - - } - - // Intercept pmtiles:// URLs and fetch from the PMTiles archive - fetchData( url, options ) { - - if ( url.startsWith( 'pmtiles://' ) ) { - - const { z, x, y } = PMTilesLoaderBase.parseUrl( url ); - - return this.imageSource.pmtilesLoader.getTile( z, x, y, options?.signal ) - .then( buffer => buffer || new ArrayBuffer( 0 ) ); - - } - - return null; - - } - -} diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 6dcb08e3d..c0102e890 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,32 +1,247 @@ -import { XYZImageSource } from './XYZImageSource.js'; +import { CanvasTexture, SRGBColorSpace } from 'three'; +import { RegionImageSource } from './RegionImageSource.js'; +import { DataCache } from '../utils/DataCache.js'; import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js'; import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js'; import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; +import { TilingScheme } from '../utils/TilingScheme.js'; +import { ProjectionScheme } from '../utils/ProjectionScheme.js'; +import { forEachTileInBounds } from '../overlays/utils.js'; -export class MVTImageSource extends XYZImageSource { +const _TILE_KEYS = Symbol( 'TILE_KEYS' ); + +// Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). +export class MVTContentCache extends DataCache { constructor( options = {} ) { - super( options ); + super(); + + const { + url = null, + levels = 20, + tileDimension = 256, + projection = 'EPSG:3857', + } = options; + + this.url = url; + this.levels = levels; + this.tileDimension = tileDimension; + this.projectionId = projection; + this.tiling = new TilingScheme(); this.loader = new MVTLoaderBase(); - this.tileDimension = options.tileDimension || 512; + this.fetchData = ( ...args ) => fetch( ...args ); + this.fetchOptions = {}; + + } + + init() { + + const { tiling, tileDimension, levels, url, projectionId } = this; + tiling.flipY = ! /{\s*reverseY|-\s*y\s*}/g.test( url ); + tiling.setProjection( new ProjectionScheme( projectionId ) ); + tiling.setContentBounds( ...tiling.projection.getBounds() ); + + if ( Array.isArray( levels ) ) { + + levels.forEach( ( info, level ) => { + + if ( info !== null ) { + + tiling.setLevel( level, { + tilePixelWidth: tileDimension, + tilePixelHeight: tileDimension, + ...info, + } ); + + } + + } ); + + } else { + + tiling.generateLevels( levels, tiling.projection.tileCountX, tiling.projection.tileCountY, { + tilePixelWidth: tileDimension, + tilePixelHeight: tileDimension, + } ); + + } + + return Promise.resolve(); + + } + + async fetchItem( [ tx, ty, tl ], signal ) { + + let buffer; + try { + + buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); + + } catch { + + return null; + + } + + if ( ! buffer || buffer.byteLength === 0 ) return null; + + const { vectorTile } = await this.loader.parse( buffer ); + return vectorTile; + + } + + // Parsed JS objects — nothing to dispose + disposeItem() {} + + async fetchTileBuffer( z, x, y, signal ) { + + const url = this.getUrl( x, y, z ); + const res = await this.fetchData( url, { ...this.fetchOptions, signal } ); + return res.arrayBuffer(); + + } + + getUrl( x, y, level ) { + + return this.url + .replace( /{\s*z\s*}/gi, level ) + .replace( /{\s*x\s*}/gi, x ) + .replace( /{\s*(y|reverseY|-\s*y)\s*}/gi, y ); + + } + +} + +export class MVTImageSource extends RegionImageSource { + + get tiling() { + + return this._contentCache.tiling; + + } + + constructor( options = {} ) { + + const { + resolution = 512, + filter, + styles, + contentCache, + ...rest + } = options; + + super(); + + this.resolution = resolution; + this._styler = new VectorTileStyler( { filter, styles } ); + this._renderer = new VectorTileCanvasRenderer( this._styler ); + this._contentCache = contentCache ?? new MVTContentCache( rest ); + + } + + init() { + + return this._contentCache.init(); + + } + + hasContent( minX, minY, maxX, maxY, level ) { + + let count = 0; + forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, () => count ++ ); + return count > 0; + + } + + async fetchItem( [ minX, minY, maxX, maxY, level ], _signal ) { + + const canvas = document.createElement( 'canvas' ); + canvas.width = this.resolution; + canvas.height = this.resolution; + const ctx = canvas.getContext( '2d' ); + + const regionBounds = [ minX, minY, maxX, maxY ]; + const { _contentCache, _renderer } = this; + + const tiles = []; + forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => { + + tiles.push( [ tx, ty, tl ] ); - this._styler = new VectorTileStyler( { - filter: options.filter, - styles: options.styles } ); - this._renderer = new VectorTileCanvasRenderer( this._styler, { - tileDimension: this.tileDimension + const tileKeys = []; + await Promise.all( tiles.map( async ( [ tx, ty, tl ] ) => { + + let vectorTile = _contentCache.lock( tx, ty, tl ); + if ( vectorTile instanceof Promise ) vectorTile = await vectorTile; + if ( ! vectorTile ) return; + + tileKeys.push( [ tx, ty, tl ] ); + const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); + _renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); + + } ) ); + + const tex = new CanvasTexture( canvas ); + tex.colorSpace = SRGBColorSpace; + tex.generateMipmaps = false; + tex.needsUpdate = true; + tex[ _TILE_KEYS ] = tileKeys; + return tex; + + } + + disposeItem( texture ) { + + for ( const [ tx, ty, tl ] of texture[ _TILE_KEYS ] ) { + + this._contentCache.release( tx, ty, tl ); + + } + + texture.dispose(); + + } + + setStyles( styles, filter ) { + + this._styler = new VectorTileStyler( { styles, filter } ); + this._renderer.styler = this._styler; + + } + + redraw() { + + this.forEachItem( ( tex, args ) => { + + const regionBounds = args.slice( 0, 4 ); + const canvas = tex.image; + const ctx = canvas.getContext( '2d' ); + ctx.clearRect( 0, 0, canvas.width, canvas.height ); + + for ( const [ tx, ty, tl ] of tex[ _TILE_KEYS ] ) { + + const vectorTile = this._contentCache.get( tx, ty, tl ); + if ( ! vectorTile ) continue; + + const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); + this._renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); + + } + + tex.needsUpdate = true; + } ); } - async processBufferToTexture( buffer ) { + dispose() { - const { vectorTile } = await this.loader.parse( buffer ); - return this._renderer.render( vectorTile ); + super.dispose(); + this._contentCache.dispose(); } diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 97efe679d..f29d603ee 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -1,27 +1,20 @@ -import { MVTImageSource } from './MVTImageSource.js'; +import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js'; -export class PMTilesImageSource extends MVTImageSource { +class PMTilesContentCache extends MVTContentCache { constructor( options = {} ) { super( options ); - this.pmtilesLoader = new PMTilesLoaderBase(); - this.tiling.flipY = true; - - } - - getUrl( x, y, level ) { - - return this.pmtilesLoader.getUrl( level, x, y ); } async init() { const header = await this.pmtilesLoader.init( this.url ); + this.tiling.flipY = true; this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) ); this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, { tilePixelWidth: this.tileDimension, @@ -30,4 +23,20 @@ export class PMTilesImageSource extends MVTImageSource { } + async fetchTileBuffer( z, x, y, signal ) { + + return this.pmtilesLoader.getTile( z, x, y, signal ); + + } + +} + +export class PMTilesImageSource extends MVTImageSource { + + constructor( options = {} ) { + + super( { ...options, contentCache: new PMTilesContentCache( options ) } ); + + } + } diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js index f01e8f97c..67e7b1eec 100644 --- a/src/three/plugins/index.js +++ b/src/three/plugins/index.js @@ -16,7 +16,7 @@ export * from './DebugTilesPlugin.js'; // other formats export * from './images/DeepZoomImagePlugin.js'; export * from './images/EPSGTilesPlugin.js'; -export * from './images/PMTilesPlugin.js'; +export * from './images/MVTOverlay.js'; // gltf extensions export * from './gltf/GLTFCesiumRTCExtension.js'; diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index c4cdd24c0..0dd138f69 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -1,21 +1,31 @@ -import { CanvasTexture, SRGBColorSpace } from 'three'; - const MVT_EXTENT = 4096; export class VectorTileCanvasRenderer { - constructor( styler, options = {} ) { + constructor( styler ) { this.styler = styler; - this.tileDimension = options.tileDimension || 512; } - render( vectorTile ) { + // Render features from one MVT tile onto an existing canvas context. + // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. + renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, width, height ) { + + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; + const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; + + // Project an MVT tile-local point (px, py ∈ [0, 4096]) into canvas pixel space. + // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. + const projectPoint = ( px, py ) => { - const canvas = this._createCanvas( this.tileDimension, this.tileDimension ); - const ctx = canvas.getContext( '2d' ); - const scale = this.tileDimension / MVT_EXTENT; + const normX = tMinX + ( px / MVT_EXTENT ) * ( tMaxX - tMinX ); + const normY = tMaxY - ( py / MVT_EXTENT ) * ( tMaxY - tMinY ); + const canvasX = Math.round( ( normX - rMinX ) / ( rMaxX - rMinX ) * width ); + const canvasY = Math.round( ( 1 - ( normY - rMinY ) / ( rMaxY - rMinY ) ) * height ); + return [ canvasX, canvasY ]; + + }; for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { @@ -26,22 +36,20 @@ export class VectorTileCanvasRenderer { if ( type === 1 ) { - this._renderPoints( ctx, geometry, layerName, scale ); + this._renderPoints( ctx, geometry, layerName, projectPoint ); } else if ( type === 2 ) { - this._renderLines( ctx, geometry, scale ); + this._renderLines( ctx, geometry, projectPoint ); } else if ( type === 3 ) { - this._renderPolygons( ctx, geometry, scale ); + this._renderPolygons( ctx, geometry, projectPoint ); } } - return this._createTexture( canvas ); - } _getFeatures( vectorTile ) { @@ -76,54 +84,19 @@ export class VectorTileCanvasRenderer { } - _createCanvas( width, height ) { - - if ( typeof OffscreenCanvas !== 'undefined' ) { - - return new OffscreenCanvas( width, height ); - - } else { - - const canvas = document.createElement( 'canvas' ); - canvas.width = width; - canvas.height = height; - return canvas; - - } - - } - - _createTexture( canvas ) { - - const tex = new CanvasTexture( canvas ); - tex.colorSpace = SRGBColorSpace; - tex.generateMipmaps = false; - tex.needsUpdate = true; - return tex; - - } - - _renderPoints( ctx, geometry, layerName, scale ) { - - const isLabelLayer = ( layerName === 'place_label' ); + _renderPoints( ctx, geometry, layerName, projectPoint ) { for ( const multiPoint of geometry ) { for ( const p of multiPoint ) { - const x = p.x * scale; - const y = p.y * scale; - - if ( ! isLabelLayer ) { - - const radius = ( layerName === 'poi' ) ? 3 : 2; + const [ x, y ] = projectPoint( p.x, p.y ); + const radius = ( layerName === 'poi' ) ? 3 : 2; - ctx.beginPath(); - ctx.moveTo( x + radius, y ); - ctx.arc( x, y, radius, 0, Math.PI * 2 ); - ctx.fill(); - - } + ctx.beginPath(); + ctx.moveTo( x + radius, y ); + ctx.arc( x, y, radius, 0, Math.PI * 2 ); + ctx.fill(); } @@ -131,7 +104,7 @@ export class VectorTileCanvasRenderer { } - _renderLines( ctx, geometry, scale ) { + _renderLines( ctx, geometry, projectPoint ) { ctx.beginPath(); @@ -139,9 +112,9 @@ export class VectorTileCanvasRenderer { 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 ); + const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); + if ( k === 0 ) ctx.moveTo( x, y ); + else ctx.lineTo( x, y ); } @@ -151,7 +124,7 @@ export class VectorTileCanvasRenderer { } - _renderPolygons( ctx, geometry, scale ) { + _renderPolygons( ctx, geometry, projectPoint ) { ctx.beginPath(); @@ -159,9 +132,9 @@ export class VectorTileCanvasRenderer { 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 ); + const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); + if ( k === 0 ) ctx.moveTo( x, y ); + else ctx.lineTo( x, y ); } @@ -169,7 +142,8 @@ export class VectorTileCanvasRenderer { } - ctx.fill(); + ctx.fill( 'evenodd' ); + ctx.stroke(); } From b2976070530f8fb0e497e94a89d395394f856f57 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:21:29 +0900 Subject: [PATCH 03/60] Simplify --- .../plugins/images/sources/MVTImageSource.js | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index c0102e890..584945abc 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -8,8 +8,6 @@ import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; -const _TILE_KEYS = Symbol( 'TILE_KEYS' ); - // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -172,14 +170,17 @@ export class MVTImageSource extends RegionImageSource { } ); - const tileKeys = []; await Promise.all( tiles.map( async ( [ tx, ty, tl ] ) => { let vectorTile = _contentCache.lock( tx, ty, tl ); if ( vectorTile instanceof Promise ) vectorTile = await vectorTile; - if ( ! vectorTile ) return; + if ( ! vectorTile ) { + + _contentCache.release( tx, ty, tl ); + return; + + } - tileKeys.push( [ tx, ty, tl ] ); const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); _renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); @@ -189,18 +190,23 @@ export class MVTImageSource extends RegionImageSource { tex.colorSpace = SRGBColorSpace; tex.generateMipmaps = false; tex.needsUpdate = true; - tex[ _TILE_KEYS ] = tileKeys; + tex._regionArgs = [ minX, minY, maxX, maxY, level ]; return tex; } disposeItem( texture ) { - for ( const [ tx, ty, tl ] of texture[ _TILE_KEYS ] ) { + const [ minX, minY, maxX, maxY, level ] = texture._regionArgs; + forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, ( tx, ty, tl ) => { - this._contentCache.release( tx, ty, tl ); + if ( this._contentCache.get( tx, ty, tl ) ) { - } + this._contentCache.release( tx, ty, tl ); + + } + + } ); texture.dispose(); @@ -217,20 +223,21 @@ export class MVTImageSource extends RegionImageSource { this.forEachItem( ( tex, args ) => { - const regionBounds = args.slice( 0, 4 ); + const [ minX, minY, maxX, maxY, level ] = args; + const regionBounds = [ minX, minY, maxX, maxY ]; const canvas = tex.image; const ctx = canvas.getContext( '2d' ); ctx.clearRect( 0, 0, canvas.width, canvas.height ); - for ( const [ tx, ty, tl ] of tex[ _TILE_KEYS ] ) { + forEachTileInBounds( regionBounds, level, this._contentCache.tiling, ( tx, ty, tl ) => { const vectorTile = this._contentCache.get( tx, ty, tl ); - if ( ! vectorTile ) continue; + if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); this._renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); - } + } ); tex.needsUpdate = true; From 20d73ce1f7e046fe4ca17a84ffce2313b94c6278 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:36:57 +0900 Subject: [PATCH 04/60] Update --- src/three/renderer/utils/VectorTileCanvasRenderer.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 0dd138f69..f1a7ea8c0 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -1,4 +1,5 @@ const MVT_EXTENT = 4096; +const _point = [ 0, 0 ]; export class VectorTileCanvasRenderer { @@ -21,9 +22,9 @@ export class VectorTileCanvasRenderer { const normX = tMinX + ( px / MVT_EXTENT ) * ( tMaxX - tMinX ); const normY = tMaxY - ( py / MVT_EXTENT ) * ( tMaxY - tMinY ); - const canvasX = Math.round( ( normX - rMinX ) / ( rMaxX - rMinX ) * width ); - const canvasY = Math.round( ( 1 - ( normY - rMinY ) / ( rMaxY - rMinY ) ) * height ); - return [ canvasX, canvasY ]; + _point[ 0 ] = Math.round( ( normX - rMinX ) / ( rMaxX - rMinX ) * width ); + _point[ 1 ] = Math.round( ( 1 - ( normY - rMinY ) / ( rMaxY - rMinY ) ) * height ); + return _point; }; From 4d14b75f567116cceabb65267fb1cc43b3afb2d3 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:40:28 +0900 Subject: [PATCH 05/60] Consolidate --- .../renderer/loaders/PMTilesLoaderBase.js | 70 ------------------- .../images/sources/PMTilesImageSource.js | 10 +-- 2 files changed, 6 insertions(+), 74 deletions(-) delete mode 100644 src/core/renderer/loaders/PMTilesLoaderBase.js diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js deleted file mode 100644 index 9374caae7..000000000 --- a/src/core/renderer/loaders/PMTilesLoaderBase.js +++ /dev/null @@ -1,70 +0,0 @@ -// PMTiles Archive Format -// https://github.com/protomaps/PMTiles - -import { PMTiles } from 'pmtiles'; - -export class PMTilesLoaderBase { - - constructor() { - - this.instance = null; - this.header = null; - this.url = null; - - } - - // Initialize the PMTiles archive and load header - async init( url ) { - - this.url = url; - this.instance = new PMTiles( url ); - this.header = await this.instance.getHeader(); - - return this.header; - - } - - // Fetch a tile from the archive - async getTile( z, x, y, signal ) { - - if ( ! this.instance ) { - - throw new Error( 'PMTilesLoaderBase: Archive not initialized. Call init() first.' ); - - } - - const res = await this.instance.getZxy( z, x, y, signal ); - - if ( ! res || ! res.data ) { - - return null; - - } - - return res.data; - - } - - // Generate a virtual URL for a tile (used by tiling scheme) - getUrl( z, x, y ) { - - return `pmtiles://${ z }/${ x }/${ y }`; - - } - - // Parse tile coordinates from a virtual URL (pmtiles://z/x/y) - static parseUrl( url ) { - - const i2 = url.lastIndexOf( '/' ); - const i1 = url.lastIndexOf( '/', i2 - 1 ); - const i0 = url.lastIndexOf( '/', i1 - 1 ); - - return { - z: parseInt( url.slice( i0 + 1, i1 ) ), - x: parseInt( url.slice( i1 + 1, i2 ) ), - y: parseInt( url.slice( i2 + 1 ) ), - }; - - } - -} diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index f29d603ee..933f885ef 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -1,19 +1,20 @@ import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; -import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js'; +import { PMTiles } from 'pmtiles'; class PMTilesContentCache extends MVTContentCache { constructor( options = {} ) { super( options ); - this.pmtilesLoader = new PMTilesLoaderBase(); + this._pmtiles = null; } async init() { - const header = await this.pmtilesLoader.init( this.url ); + this._pmtiles = new PMTiles( this.url ); + const header = await this._pmtiles.getHeader(); this.tiling.flipY = true; this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) ); this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, { @@ -25,7 +26,8 @@ class PMTilesContentCache extends MVTContentCache { async fetchTileBuffer( z, x, y, signal ) { - return this.pmtilesLoader.getTile( z, x, y, signal ); + const res = await this._pmtiles.getZxy( z, x, y, signal ); + return res ? res.data : null; } From 4e38cee786d190520455544ec0d51aed2e0fe2c0 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:42:02 +0900 Subject: [PATCH 06/60] Remove export --- src/core/renderer/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js index f45483c46..5993ca657 100644 --- a/src/core/renderer/index.js +++ b/src/core/renderer/index.js @@ -5,7 +5,6 @@ export * from './loaders/B3DMLoaderBase.js'; export * from './loaders/I3DMLoaderBase.js'; export * from './loaders/PNTSLoaderBase.js'; export * from './loaders/MVTLoaderBase.js'; -export * from './loaders/PMTilesLoaderBase.js'; export * from './loaders/CMPTLoaderBase.js'; export * from './constants.js'; From cafdcd4638ab50b9173537f7e089f79bb431e1ff Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 16:54:49 +0900 Subject: [PATCH 07/60] Read PMTiles header --- .../images/sources/PMTilesImageSource.js | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 933f885ef..80c45b225 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -2,31 +2,51 @@ import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { PMTiles } from 'pmtiles'; +const DEG2RAD = Math.PI / 180; + class PMTilesContentCache extends MVTContentCache { constructor( options = {} ) { super( options ); - this._pmtiles = null; + this.instance = null; } async init() { - this._pmtiles = new PMTiles( this.url ); - const header = await this._pmtiles.getHeader(); - this.tiling.flipY = true; - this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) ); - this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, { - tilePixelWidth: this.tileDimension, - tilePixelHeight: this.tileDimension, + const { tiling, tileDimension } = this; + + this.instance = new PMTiles( this.url ); + + const header = await this.instance.getHeader(); + if ( header.tileType !== 1 ) { + + throw new Error( `PMTilesContentCache: expected MVT tile type (1), got ${ header.tileType }` ); + + } + + const projection = new ProjectionScheme( 'EPSG:3857' ); + + tiling.flipY = true; + tiling.setProjection( projection ); + tiling.setContentBounds( + DEG2RAD * header.minLon, + DEG2RAD * header.minLat, + DEG2RAD * header.maxLon, + DEG2RAD * header.maxLat, + ); + tiling.generateLevels( header.maxZoom + 1, projection.tileCountX, projection.tileCountY, { + tilePixelWidth: tileDimension, + tilePixelHeight: tileDimension, + minLevel: header.minZoom, } ); } async fetchTileBuffer( z, x, y, signal ) { - const res = await this._pmtiles.getZxy( z, x, y, signal ); + const res = await this.instance.getZxy( z, x, y, signal ); return res ? res.data : null; } From 45a33e3f31a8586bf86d74ce387cd7c54457e8ea Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 17:30:15 +0900 Subject: [PATCH 08/60] Update demo, simplify --- example/three/pmtiles.html | 28 +++---- example/three/pmtiles.js | 78 ++++++++----------- .../plugins/images/sources/MVTImageSource.js | 41 ++++------ 3 files changed, 55 insertions(+), 92 deletions(-) diff --git a/example/three/pmtiles.html b/example/three/pmtiles.html index 6a380bc5a..e6425fab0 100644 --- a/example/three/pmtiles.html +++ b/example/three/pmtiles.html @@ -1,21 +1,13 @@ - - - - PMTiles Globe Example - - - - - - + + + + + PMTiles Globe Example + + + + + diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 1cae6c9aa..aec39cda9 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -27,24 +27,29 @@ const LAYERS = { pois: { enabled: true, color: '#1a8cbd' }, }; -const state = { - layers: {}, - colors: {}, -}; +let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; + +init(); +render(); + +function getStyles() { + + const styles = { default: '#cccccc' }; + for ( const key in LAYERS ) { -for ( const key in LAYERS ) { + styles[ key ] = LAYERS[ key ].color; - state.layers[ key ] = LAYERS[ key ].enabled; - state.colors[ key ] = LAYERS[ key ].color; + } + + return styles; } -state.colors.default = '#cccccc'; +function getFilter() { -let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; + return ( _feature, layerName ) => LAYERS[ layerName ]?.enabled ?? false; -init(); -render(); +} function init() { @@ -74,7 +79,11 @@ function init() { scene.add( tiles.group ); // PMTiles overlay: vector tile data composited on top of the base geometry - overlay = createOverlay(); + overlay = new PMTilesOverlay( { + url: 'https://demo-bucket.protomaps.com/v4.pmtiles', + styles: getStyles(), + filter: getFilter(), + } ); overlayPlugin = new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ); tiles.registerPlugin( overlayPlugin ); @@ -90,19 +99,9 @@ function init() { } -function createOverlay() { - - return new PMTilesOverlay( { - url: 'https://demo-bucket.protomaps.com/v4.pmtiles', - styles: { ...state.colors }, - filter: ( _feature, layerName ) => state.layers[ layerName ] ?? false, - } ); - -} - function updateOverlay() { - overlay.setStyles( state.colors, ( _feature, layerName ) => state.layers[ layerName ] ?? false ); + overlay.setStyles( getStyles(), getFilter() ); overlay.redraw(); } @@ -111,26 +110,15 @@ function setupGUI() { const gui = new GUI(); - const layersFolder = gui.addFolder( 'Layers' ); - for ( const key in LAYERS ) { - - layersFolder.add( state.layers, key ) - .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) - .onChange( updateOverlay ); - - } - - const colorsFolder = gui.addFolder( 'Colors' ); for ( const key in LAYERS ) { - colorsFolder.addColor( state.colors, key ) - .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ) - .onChange( updateOverlay ); + const folder = gui.addFolder( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ); + folder.add( LAYERS[ key ], 'enabled' ).onChange( updateOverlay ); + folder.addColor( LAYERS[ key ], 'color' ).onChange( updateOverlay ); + folder.close(); } - colorsFolder.close(); - } function onWindowResize() { @@ -143,16 +131,12 @@ function onWindowResize() { function render() { - if ( controls ) controls.update(); + controls.update(); - if ( tiles ) { - - camera.updateMatrixWorld(); - tiles.setCamera( camera ); - tiles.setResolutionFromRenderer( camera, renderer ); - tiles.update(); - - } + camera.updateMatrixWorld(); + tiles.setCamera( camera ); + tiles.setResolutionFromRenderer( camera, renderer ); + tiles.update(); renderer.render( scene, camera ); diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 584945abc..f9a47c758 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -72,19 +72,14 @@ export class MVTContentCache extends DataCache { async fetchItem( [ tx, ty, tl ], signal ) { - let buffer; - try { + let buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); - buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); - - } catch { + if ( ! buffer || buffer.byteLength === 0 ) { return null; } - if ( ! buffer || buffer.byteLength === 0 ) return null; - const { vectorTile } = await this.loader.parse( buffer ); return vectorTile; @@ -158,33 +153,29 @@ export class MVTImageSource extends RegionImageSource { const canvas = document.createElement( 'canvas' ); canvas.width = this.resolution; canvas.height = this.resolution; - const ctx = canvas.getContext( '2d' ); + const ctx = canvas.getContext( '2d' ); const regionBounds = [ minX, minY, maxX, maxY ]; const { _contentCache, _renderer } = this; - const tiles = []; + const promises = []; forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => { - tiles.push( [ tx, ty, tl ] ); - - } ); + promises.push( ( async () => { - await Promise.all( tiles.map( async ( [ tx, ty, tl ] ) => { + const vectorTile = await _contentCache.lock( tx, ty, tl ); + if ( vectorTile ) { - let vectorTile = _contentCache.lock( tx, ty, tl ); - if ( vectorTile instanceof Promise ) vectorTile = await vectorTile; - if ( ! vectorTile ) { + const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); + _renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); - _contentCache.release( tx, ty, tl ); - return; + } - } + } )() ); - const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); + } ); - } ) ); + await Promise.all( promises ); const tex = new CanvasTexture( canvas ); tex.colorSpace = SRGBColorSpace; @@ -200,11 +191,7 @@ export class MVTImageSource extends RegionImageSource { const [ minX, minY, maxX, maxY, level ] = texture._regionArgs; forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, ( tx, ty, tl ) => { - if ( this._contentCache.get( tx, ty, tl ) ) { - - this._contentCache.release( tx, ty, tl ); - - } + this._contentCache.release( tx, ty, tl ); } ); From 8ac47cba7b20ed9c350d9656311740ebe5fef61f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 17:33:26 +0900 Subject: [PATCH 09/60] Remove unnecessary class --- src/core/renderer/index.js | 1 - src/core/renderer/loaders/MVTLoaderBase.js | 19 ------------------- .../plugins/images/sources/MVTImageSource.js | 6 +++--- 3 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 src/core/renderer/loaders/MVTLoaderBase.js diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js index 5993ca657..431a9afae 100644 --- a/src/core/renderer/index.js +++ b/src/core/renderer/index.js @@ -4,7 +4,6 @@ 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.js b/src/core/renderer/loaders/MVTLoaderBase.js deleted file mode 100644 index d95a0d785..000000000 --- a/src/core/renderer/loaders/MVTLoaderBase.js +++ /dev/null @@ -1,19 +0,0 @@ -// MVT File Format -// https://github.com/mapbox/vector-tile-spec/blob/master/2.1/README.md - -import { LoaderBase } from './LoaderBase.js'; -import { VectorTile } from '@mapbox/vector-tile'; -import Protobuf from 'pbf'; - -export class MVTLoaderBase extends LoaderBase { - - parse( buffer ) { - - const pbf = new Protobuf( buffer ); - const vectorTile = new VectorTile( pbf ); - - return Promise.resolve( { vectorTile } ); - - } - -} diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index f9a47c758..5d72eff35 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,7 +1,8 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; +import { VectorTile } from '@mapbox/vector-tile'; +import Protobuf from 'pbf'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js'; import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js'; import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; @@ -28,7 +29,6 @@ export class MVTContentCache extends DataCache { this.projectionId = projection; this.tiling = new TilingScheme(); - this.loader = new MVTLoaderBase(); this.fetchData = ( ...args ) => fetch( ...args ); this.fetchOptions = {}; @@ -80,7 +80,7 @@ export class MVTContentCache extends DataCache { } - const { vectorTile } = await this.loader.parse( buffer ); + const vectorTile = new VectorTile( new Protobuf( buffer ) ); return vectorTile; } From ebdecb70f4235c35b02e5d0ab92db63b6236851c Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 17:35:59 +0900 Subject: [PATCH 10/60] Add export --- src/three/plugins/images/ImageOverlayPlugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 0821a77f7..a95ac7a29 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1389,7 +1389,7 @@ export class ImageOverlay { * multiple source tiles into a single texture per 3D tile region. * @extends ImageOverlay */ -class TiledImageOverlay extends ImageOverlay { +export class TiledImageOverlay extends ImageOverlay { get tiling() { From 5f07ab7b4ae19d28cbc71d28a9ea1ebe8c9e45cf Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 21:32:39 +0900 Subject: [PATCH 11/60] Fix artifacts --- .../renderer/utils/VectorTileCanvasRenderer.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index f1a7ea8c0..cf6395cc4 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -28,6 +28,18 @@ export class VectorTileCanvasRenderer { }; + // Clip to the tile's logical bounds so MVT buffer geometry (vertices outside [0, 4096]) + // doesn't bleed into adjacent tiles and cause evenodd fill cancellation at boundaries. + const clipX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; + const clipY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height; + const clipW = ( tMaxX - tMinX ) / ( rMaxX - rMinX ) * width; + const clipH = ( tMaxY - tMinY ) / ( rMaxY - rMinY ) * height; + + ctx.save(); + ctx.beginPath(); + ctx.rect( clipX, clipY, clipW, clipH ); + ctx.clip(); + for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { const color = this.styler.getColor( layerName, 'css' ); @@ -51,6 +63,8 @@ export class VectorTileCanvasRenderer { } + ctx.restore(); + } _getFeatures( vectorTile ) { From 1abf186d8b686e6d030b84bde3d70f958e2943ed Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 21:37:09 +0900 Subject: [PATCH 12/60] Move functions --- .../utils/VectorTileCanvasRenderer.js | 132 +++++++++--------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index cf6395cc4..a564874ca 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -28,6 +28,69 @@ export class VectorTileCanvasRenderer { }; + const renderPoints = ( geometry, layerName ) => { + + for ( const multiPoint of geometry ) { + + for ( const p of multiPoint ) { + + const [ x, y ] = projectPoint( p.x, p.y ); + const radius = ( layerName === 'poi' ) ? 3 : 2; + + ctx.beginPath(); + ctx.moveTo( x + radius, y ); + ctx.arc( x, y, radius, 0, Math.PI * 2 ); + ctx.fill(); + + } + + } + + }; + + const renderLines = ( geometry ) => { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); + if ( k === 0 ) ctx.moveTo( x, y ); + else ctx.lineTo( x, y ); + + } + + } + + ctx.stroke(); + + }; + + const renderPolygons = ( geometry ) => { + + ctx.beginPath(); + + for ( const ring of geometry ) { + + for ( let k = 0; k < ring.length; k ++ ) { + + const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); + if ( k === 0 ) ctx.moveTo( x, y ); + else ctx.lineTo( x, y ); + + } + + ctx.closePath(); + + } + + ctx.fill( 'evenodd' ); + ctx.stroke(); + + }; + // Clip to the tile's logical bounds so MVT buffer geometry (vertices outside [0, 4096]) // doesn't bleed into adjacent tiles and cause evenodd fill cancellation at boundaries. const clipX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; @@ -49,15 +112,15 @@ export class VectorTileCanvasRenderer { if ( type === 1 ) { - this._renderPoints( ctx, geometry, layerName, projectPoint ); + renderPoints( geometry, layerName ); } else if ( type === 2 ) { - this._renderLines( ctx, geometry, projectPoint ); + renderLines( geometry ); } else if ( type === 3 ) { - this._renderPolygons( ctx, geometry, projectPoint ); + renderPolygons( geometry ); } @@ -99,67 +162,4 @@ export class VectorTileCanvasRenderer { } - _renderPoints( ctx, geometry, layerName, projectPoint ) { - - for ( const multiPoint of geometry ) { - - for ( const p of multiPoint ) { - - const [ x, y ] = projectPoint( p.x, p.y ); - const radius = ( layerName === 'poi' ) ? 3 : 2; - - ctx.beginPath(); - ctx.moveTo( x + radius, y ); - ctx.arc( x, y, radius, 0, Math.PI * 2 ); - ctx.fill(); - - } - - } - - } - - _renderLines( ctx, geometry, projectPoint ) { - - ctx.beginPath(); - - for ( const ring of geometry ) { - - for ( let k = 0; k < ring.length; k ++ ) { - - const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); - if ( k === 0 ) ctx.moveTo( x, y ); - else ctx.lineTo( x, y ); - - } - - } - - ctx.stroke(); - - } - - _renderPolygons( ctx, geometry, projectPoint ) { - - ctx.beginPath(); - - for ( const ring of geometry ) { - - for ( let k = 0; k < ring.length; k ++ ) { - - const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); - if ( k === 0 ) ctx.moveTo( x, y ); - else ctx.lineTo( x, y ); - - } - - ctx.closePath(); - - } - - ctx.fill( 'evenodd' ); - ctx.stroke(); - - } - } From a0984bb8427eef292301e75a40a28c5f09a4e4b0 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 21:54:54 +0900 Subject: [PATCH 13/60] Cleanup --- .../utils/VectorTileCanvasRenderer.js | 113 ++++++++---------- 1 file changed, 51 insertions(+), 62 deletions(-) diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index a564874ca..6111025cc 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -1,5 +1,4 @@ const MVT_EXTENT = 4096; -const _point = [ 0, 0 ]; export class VectorTileCanvasRenderer { @@ -16,39 +15,68 @@ export class VectorTileCanvasRenderer { const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; - // Project an MVT tile-local point (px, py ∈ [0, 4096]) into canvas pixel space. + // Affine transform: MVT tile coords [0, MVT_EXTENT] → canvas pixels. // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. - const projectPoint = ( px, py ) => { + const scaleX = ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ) * width; + const scaleY = ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ) * height; + const offsetX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; + const offsetY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height; + const invScale = 1 / scaleX; - const normX = tMinX + ( px / MVT_EXTENT ) * ( tMaxX - tMinX ); - const normY = tMaxY - ( py / MVT_EXTENT ) * ( tMaxY - tMinY ); - _point[ 0 ] = Math.round( ( normX - rMinX ) / ( rMaxX - rMinX ) * width ); - _point[ 1 ] = Math.round( ( 1 - ( normY - rMinY ) / ( rMaxY - rMinY ) ) * height ); - return _point; + ctx.save(); + ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); + + // Clip to [0, MVT_EXTENT] in tile space — prevents MVT buffer geometry from bleeding + // into adjacent tiles and causing evenodd fill cancellation at boundaries. + ctx.beginPath(); + ctx.rect( 0, 0, MVT_EXTENT, MVT_EXTENT ); + ctx.clip(); + + for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { + + const color = this.styler.getColor( layerName, 'css' ); + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = invScale; + + if ( type === 1 ) { + + renderPoints( geometry, layerName ); + + } else if ( type === 2 ) { + + renderLines( geometry ); + + } else if ( type === 3 ) { - }; + renderPolygons( geometry ); - const renderPoints = ( geometry, layerName ) => { + } + + } + + ctx.restore(); + + function renderPoints( geometry, layerName ) { + + const radius = ( ( layerName === 'poi' ) ? 3 : 2 ) * invScale; for ( const multiPoint of geometry ) { for ( const p of multiPoint ) { - const [ x, y ] = projectPoint( p.x, p.y ); - const radius = ( layerName === 'poi' ) ? 3 : 2; - ctx.beginPath(); - ctx.moveTo( x + radius, y ); - ctx.arc( x, y, radius, 0, Math.PI * 2 ); + ctx.moveTo( p.x + radius, p.y ); + ctx.arc( p.x, p.y, radius, 0, Math.PI * 2 ); ctx.fill(); } } - }; + } - const renderLines = ( geometry ) => { + function renderLines( geometry ) { ctx.beginPath(); @@ -56,9 +84,8 @@ export class VectorTileCanvasRenderer { for ( let k = 0; k < ring.length; k ++ ) { - const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); - if ( k === 0 ) ctx.moveTo( x, y ); - else ctx.lineTo( x, y ); + if ( k === 0 ) ctx.moveTo( ring[ k ].x, ring[ k ].y ); + else ctx.lineTo( ring[ k ].x, ring[ k ].y ); } @@ -66,9 +93,9 @@ export class VectorTileCanvasRenderer { ctx.stroke(); - }; + } - const renderPolygons = ( geometry ) => { + function renderPolygons( geometry ) { ctx.beginPath(); @@ -76,9 +103,8 @@ export class VectorTileCanvasRenderer { for ( let k = 0; k < ring.length; k ++ ) { - const [ x, y ] = projectPoint( ring[ k ].x, ring[ k ].y ); - if ( k === 0 ) ctx.moveTo( x, y ); - else ctx.lineTo( x, y ); + if ( k === 0 ) ctx.moveTo( ring[ k ].x, ring[ k ].y ); + else ctx.lineTo( ring[ k ].x, ring[ k ].y ); } @@ -89,45 +115,8 @@ export class VectorTileCanvasRenderer { ctx.fill( 'evenodd' ); ctx.stroke(); - }; - - // Clip to the tile's logical bounds so MVT buffer geometry (vertices outside [0, 4096]) - // doesn't bleed into adjacent tiles and cause evenodd fill cancellation at boundaries. - const clipX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; - const clipY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height; - const clipW = ( tMaxX - tMinX ) / ( rMaxX - rMinX ) * width; - const clipH = ( tMaxY - tMinY ) / ( rMaxY - rMinY ) * height; - - ctx.save(); - ctx.beginPath(); - ctx.rect( clipX, clipY, clipW, clipH ); - ctx.clip(); - - for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { - - const color = this.styler.getColor( layerName, 'css' ); - ctx.fillStyle = color; - ctx.strokeStyle = color; - ctx.lineWidth = 1; - - if ( type === 1 ) { - - renderPoints( geometry, layerName ); - - } else if ( type === 2 ) { - - renderLines( geometry ); - - } else if ( type === 3 ) { - - renderPolygons( geometry ); - - } - } - ctx.restore(); - } _getFeatures( vectorTile ) { From c0c444c25ef03a9198b7be5f5eb3622da6927b4b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 22:12:03 +0900 Subject: [PATCH 14/60] Simplification --- .../plugins/images/sources/MVTImageSource.js | 8 +- .../utils/VectorTileCanvasRenderer.js | 164 ++++++++++-------- 2 files changed, 98 insertions(+), 74 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 5d72eff35..7e12a5811 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -129,7 +129,7 @@ export class MVTImageSource extends RegionImageSource { this.resolution = resolution; this._styler = new VectorTileStyler( { filter, styles } ); - this._renderer = new VectorTileCanvasRenderer( this._styler ); + this._renderer = new VectorTileCanvasRenderer( { styler: this._styler } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); } @@ -167,7 +167,8 @@ export class MVTImageSource extends RegionImageSource { if ( vectorTile ) { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); + _renderer.setFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + _renderer.renderToCanvas( vectorTile ); } @@ -222,7 +223,8 @@ export class MVTImageSource extends RegionImageSource { if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._renderer.renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, canvas.width, canvas.height ); + this._renderer.setFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + this._renderer.renderToCanvas( vectorTile ); } ); diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 6111025cc..d0ed80447 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -2,15 +2,54 @@ const MVT_EXTENT = 4096; export class VectorTileCanvasRenderer { - constructor( styler ) { + constructor( options = {} ) { + + const { + styler = null, + getX = p => p.x, + getY = p => p.y, + } = options; this.styler = styler; + this._getX = getX; + this._getY = getY; + + } + + renderToCanvas( vectorTile ) { + + const { _ctx, _invScale } = this; + + for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { + + const color = this.styler.getColor( layerName, 'css' ); + _ctx.fillStyle = color; + _ctx.strokeStyle = color; + _ctx.lineWidth = _invScale; + + if ( type === 1 ) { + + this._renderPoints( geometry, layerName ); + + } else if ( type === 2 ) { + + this._renderLines( geometry ); + + } else if ( type === 3 ) { + + this._renderPolygons( geometry ); + + } + + } + + _ctx.restore(); } - // Render features from one MVT tile onto an existing canvas context. + // Sets up the canvas transform and clip for one MVT tile. // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. - renderToCanvas( ctx, vectorTile, tileBounds, regionBounds, width, height ) { + setFrame( ctx, tileBounds, regionBounds, width, height ) { const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; @@ -21,7 +60,6 @@ export class VectorTileCanvasRenderer { const scaleY = ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ) * height; const offsetX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; const offsetY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height; - const invScale = 1 / scaleX; ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); @@ -32,43 +70,32 @@ export class VectorTileCanvasRenderer { ctx.rect( 0, 0, MVT_EXTENT, MVT_EXTENT ); ctx.clip(); - for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { - - const color = this.styler.getColor( layerName, 'css' ); - ctx.fillStyle = color; - ctx.strokeStyle = color; - ctx.lineWidth = invScale; - - if ( type === 1 ) { - - renderPoints( geometry, layerName ); - - } else if ( type === 2 ) { - - renderLines( geometry ); + this._ctx = ctx; + this._invScale = 1 / scaleX; - } else if ( type === 3 ) { - - renderPolygons( geometry ); + } - } + _getFeatures( vectorTile ) { - } + const results = []; + const layerNames = Object.keys( vectorTile.layers ); + const sortedLayers = this.styler.sortLayers( layerNames ); - ctx.restore(); + for ( const layerName of sortedLayers ) { - function renderPoints( geometry, layerName ) { + const layer = vectorTile.layers[ layerName ]; - const radius = ( ( layerName === 'poi' ) ? 3 : 2 ) * invScale; + for ( let i = 0; i < layer.length; i ++ ) { - for ( const multiPoint of geometry ) { + const feature = layer.feature( i ); - for ( const p of multiPoint ) { + if ( this.styler.shouldIncludeFeature( feature, layerName ) ) { - ctx.beginPath(); - ctx.moveTo( p.x + radius, p.y ); - ctx.arc( p.x, p.y, radius, 0, Math.PI * 2 ); - ctx.fill(); + results.push( { + layerName, + geometry: feature.loadGeometry(), + type: feature.type, + } ); } @@ -76,78 +103,73 @@ export class VectorTileCanvasRenderer { } - function renderLines( geometry ) { + return results; - ctx.beginPath(); + } - for ( const ring of geometry ) { + _renderPoints( geometry, layerName ) { - for ( let k = 0; k < ring.length; k ++ ) { + const { _ctx, _invScale, _getX, _getY } = this; + const radius = ( ( layerName === 'poi' ) ? 3 : 2 ) * _invScale; - if ( k === 0 ) ctx.moveTo( ring[ k ].x, ring[ k ].y ); - else ctx.lineTo( ring[ k ].x, ring[ k ].y ); + for ( const multiPoint of geometry ) { - } + for ( const p of multiPoint ) { - } + const x = _getX( p ), y = _getY( p ); + _ctx.beginPath(); + _ctx.moveTo( x + radius, y ); + _ctx.arc( x, y, radius, 0, Math.PI * 2 ); + _ctx.fill(); - ctx.stroke(); + } } - function renderPolygons( geometry ) { + } - ctx.beginPath(); + _renderLines( geometry ) { - for ( const ring of geometry ) { + const { _ctx, _getX, _getY } = this; - for ( let k = 0; k < ring.length; k ++ ) { + _ctx.beginPath(); - if ( k === 0 ) ctx.moveTo( ring[ k ].x, ring[ k ].y ); - else ctx.lineTo( ring[ k ].x, ring[ k ].y ); + for ( const ring of geometry ) { - } + for ( let k = 0; k < ring.length; k ++ ) { - ctx.closePath(); + if ( k === 0 ) _ctx.moveTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); + else _ctx.lineTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); } - ctx.fill( 'evenodd' ); - ctx.stroke(); - } - } - - _getFeatures( vectorTile ) { - - const results = []; - const layerNames = Object.keys( vectorTile.layers ); - const sortedLayers = this.styler.sortLayers( layerNames ); + _ctx.stroke(); - for ( const layerName of sortedLayers ) { + } - const layer = vectorTile.layers[ layerName ]; + _renderPolygons( geometry ) { - for ( let i = 0; i < layer.length; i ++ ) { + const { _ctx, _getX, _getY } = this; - const feature = layer.feature( i ); + _ctx.beginPath(); - if ( this.styler.shouldIncludeFeature( feature, layerName ) ) { + for ( const ring of geometry ) { - results.push( { - layerName, - geometry: feature.loadGeometry(), - type: feature.type, - } ); + for ( let k = 0; k < ring.length; k ++ ) { - } + if ( k === 0 ) _ctx.moveTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); + else _ctx.lineTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); } + _ctx.closePath(); + } - return results; + _ctx.fill( 'evenodd' ); + _ctx.stroke(); } From 66d0d64bd0b4d51369d6a521786b9e38024a979b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 22:45:47 +0900 Subject: [PATCH 15/60] Updates --- example/three/pmtiles.js | 29 +++++----- .../plugins/images/sources/MVTImageSource.js | 17 +++++- .../utils/VectorTileCanvasRenderer.js | 20 ++++--- src/three/renderer/utils/VectorTileStyler.js | 56 ++++++++++--------- src/three/renderer/utils/layerColors.js | 34 ----------- 5 files changed, 73 insertions(+), 83 deletions(-) delete mode 100644 src/three/renderer/utils/layerColors.js diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index aec39cda9..04a0eceef 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -14,17 +14,17 @@ import GUI from 'three/addons/libs/lil-gui.module.min.js'; // Layer config for Protomaps v4 basemap — colors from the Protomaps "Light" theme const LAYERS = { - earth: { enabled: true, color: '#e2dfda' }, - water: { enabled: true, color: '#80deea' }, - landcover: { enabled: true, color: '#c4e7d2' }, - landuse: { enabled: true, color: '#cfddd5' }, - natural: { enabled: true, color: '#e2e0d7' }, - buildings: { enabled: true, color: '#cccccc' }, - roads: { enabled: true, color: '#ebebeb' }, - transit: { enabled: true, color: '#a7b1b3' }, - boundaries: { enabled: true, color: '#adadad' }, - places: { enabled: true, color: '#5c5c5c' }, - pois: { enabled: true, color: '#1a8cbd' }, + earth: { enabled: true, fill: '#e2dfda', order: 0 }, + water: { enabled: true, fill: '#80deea', order: 1 }, + landcover: { enabled: true, fill: '#c4e7d2', order: 2 }, + landuse: { enabled: true, fill: '#cfddd5', order: 3 }, + natural: { enabled: true, fill: '#e2e0d7', order: 4 }, + buildings: { enabled: true, fill: '#cccccc', order: 5 }, + roads: { enabled: true, stroke: '#ebebeb', order: 6 }, + transit: { enabled: true, stroke: '#a7b1b3', order: 7 }, + boundaries: { enabled: true, stroke: '#adadad', order: 8 }, + places: { enabled: true, fill: '#5c5c5c', order: 9 }, + pois: { enabled: true, fill: '#1a8cbd', radius: 3, order: 10 }, }; let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; @@ -34,10 +34,11 @@ render(); function getStyles() { - const styles = { default: '#cccccc' }; + const styles = { default: { fill: '#cccccc' } }; for ( const key in LAYERS ) { - styles[ key ] = LAYERS[ key ].color; + const { fill, stroke, radius, order } = LAYERS[ key ]; + styles[ key ] = { fill, stroke, order, ...( radius !== undefined && { radius } ) }; } @@ -114,7 +115,7 @@ function setupGUI() { const folder = gui.addFolder( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ); folder.add( LAYERS[ key ], 'enabled' ).onChange( updateOverlay ); - folder.addColor( LAYERS[ key ], 'color' ).onChange( updateOverlay ); + folder.addColor( LAYERS[ key ], LAYERS[ key ].fill !== undefined ? 'fill' : 'stroke' ).onChange( updateOverlay ); folder.close(); } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 7e12a5811..6be3b25f2 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -9,6 +9,21 @@ import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; +const DEFAULT_STYLES = { + default: { fill: '#222222', order: Infinity }, + landuse: { fill: '#caedc1', order: 0 }, + landuse_overlay: { fill: '#caedc1', order: 1 }, + park: { fill: '#5da859', order: 2 }, + water: { fill: '#201f20', order: 3 }, + waterway: { fill: '#201f20', order: 4 }, + transportation: { stroke: '#444444', order: 5 }, + road: { stroke: '#444444', order: 6 }, + building: { fill: '#eeeeee', order: 7 }, + boundaries: { stroke: '#444545', order: 8 }, + poi: { fill: '#222222', radius: 3, order: 9 }, + place_label: { fill: '#222222', order: 10 }, +}; + // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -128,7 +143,7 @@ export class MVTImageSource extends RegionImageSource { super(); this.resolution = resolution; - this._styler = new VectorTileStyler( { filter, styles } ); + this._styler = new VectorTileStyler( { filter, styles: { ...DEFAULT_STYLES, ...styles } } ); this._renderer = new VectorTileCanvasRenderer( { styler: this._styler } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index d0ed80447..55aa51173 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -22,14 +22,16 @@ export class VectorTileCanvasRenderer { for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { - const color = this.styler.getColor( layerName, 'css' ); - _ctx.fillStyle = color; - _ctx.strokeStyle = color; - _ctx.lineWidth = _invScale; + const style = this.styler.getStyle( layerName ); + if ( ! style || ! style.visible ) continue; + + _ctx.fillStyle = style.fill ?? 'transparent'; + _ctx.strokeStyle = style.stroke ?? 'transparent'; + _ctx.lineWidth = ( style.strokeWidth ?? 1 ) * _invScale; if ( type === 1 ) { - this._renderPoints( geometry, layerName ); + this._renderPoints( geometry, style.radius ?? 2 ); } else if ( type === 2 ) { @@ -107,10 +109,10 @@ export class VectorTileCanvasRenderer { } - _renderPoints( geometry, layerName ) { + _renderPoints( geometry, radius ) { const { _ctx, _invScale, _getX, _getY } = this; - const radius = ( ( layerName === 'poi' ) ? 3 : 2 ) * _invScale; + const scaledRadius = radius * _invScale; for ( const multiPoint of geometry ) { @@ -118,8 +120,8 @@ export class VectorTileCanvasRenderer { const x = _getX( p ), y = _getY( p ); _ctx.beginPath(); - _ctx.moveTo( x + radius, y ); - _ctx.arc( x, y, radius, 0, Math.PI * 2 ); + _ctx.moveTo( x + scaledRadius, y ); + _ctx.arc( x, y, scaledRadius, 0, Math.PI * 2 ); _ctx.fill(); } diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js index 4f4e76f6d..3884b2b75 100644 --- a/src/three/renderer/utils/VectorTileStyler.js +++ b/src/three/renderer/utils/VectorTileStyler.js @@ -1,45 +1,51 @@ -import { Color } from 'three'; -import { LAYER_COLORS, DEFAULT_LAYER_ORDER } from './layerColors.js'; - -const _color = /* @__PURE__ */ new Color(); - export class VectorTileStyler { constructor( options = {} ) { - this.filter = options.filter || ( () => true ); - this._layerOrder = options.layerOrder || DEFAULT_LAYER_ORDER; - this._styles = {}; + const { styles = {}, filter = () => true } = options; - const colorsToSet = Object.assign( {}, LAYER_COLORS, options.styles || {} ); - for ( const key in colorsToSet ) { + this.filter = filter; + this._styles = { + ...styles, + default: { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true, ...styles.default }, + }; - _color.set( colorsToSet[ key ] ); - this._styles[ key ] = { - hex: _color.getHex(), - css: _color.getStyle() - }; + } - } + getStyle( layerName ) { - } + const styles = this._styles; + const defaultStyle = styles.default; + if ( layerName in styles ) { + + return { ...defaultStyle, ...styles[ layerName ] }; - getColor( layerName, format = 'hex' ) { + } else { - const style = this._styles[ layerName ] || this._styles[ 'default' ]; - return format === 'css' ? style.css : style.hex; + return defaultStyle; + + } } sortLayers( layerNames ) { + const styles = this._styles; + const defaultOrder = styles.default.order; + return [ ...layerNames ].sort( ( a, b ) => { - let idxA = this._layerOrder.indexOf( a ); - let idxB = this._layerOrder.indexOf( b ); - if ( idxA === - 1 ) idxA = 0; - if ( idxB === - 1 ) idxB = 0; - return idxA - idxB; + const orderA = styles[ a ]?.order ?? defaultOrder; + const orderB = styles[ b ]?.order ?? defaultOrder; + if ( orderA !== orderB ) { + + return orderA - orderB; + + } else { + + return a.localeCompare( b ); + + } } ); diff --git a/src/three/renderer/utils/layerColors.js b/src/three/renderer/utils/layerColors.js deleted file mode 100644 index 795a87ad9..000000000 --- a/src/three/renderer/utils/layerColors.js +++ /dev/null @@ -1,34 +0,0 @@ -/* non exhaustive list of layer colors */ -export const LAYER_COLORS = { - // Nature & Water - 'water': 0x201f20, - 'waterway': 0x201f20, - 'landuse': 0xcaedc1, - 'landuse_overlay': 0xcaedc1, - 'park': 0x5da859, - - // Infrastructure - 'building': 0xeeeeee, - 'road': 0x444444, - 'transportation': 0x444444, - - // Boundaries & Background - 'boundaries': 0x444545, - 'background': 0x111111, - 'default': 0x222222 -}; - -/* Default layer ordering for vector tiles (bottom to top) */ -export const DEFAULT_LAYER_ORDER = [ - 'landuse', - 'landuse_overlay', - 'park', - 'water', - 'waterway', - 'transportation', - 'road', - 'building', - 'boundaries', - 'poi', - 'place_label' -]; From ada591fdf3e9bda53dee8d04f54bbd1eea36a10e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Fri, 15 May 2026 23:22:56 +0900 Subject: [PATCH 16/60] Shift to entirely user-defined styles --- example/three/pmtiles.js | 25 ++++------ .../plugins/images/sources/MVTImageSource.js | 9 ++-- .../utils/VectorTileCanvasRenderer.js | 22 ++++----- src/three/renderer/utils/VectorTileStyler.js | 48 ++++--------------- 4 files changed, 31 insertions(+), 73 deletions(-) diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 04a0eceef..cb50367f2 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -32,23 +32,17 @@ let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; init(); render(); -function getStyles() { +function getStyle() { - const styles = { default: { fill: '#cccccc' } }; - for ( const key in LAYERS ) { - - const { fill, stroke, radius, order } = LAYERS[ key ]; - styles[ key ] = { fill, stroke, order, ...( radius !== undefined && { radius } ) }; - - } - - return styles; + return layerName => { -} + const layer = LAYERS[ layerName ]; + if ( ! layer?.enabled ) return null; -function getFilter() { + const { fill, stroke, radius, order } = layer; + return { fill, stroke, order, ...( radius !== undefined && { radius } ) }; - return ( _feature, layerName ) => LAYERS[ layerName ]?.enabled ?? false; + }; } @@ -82,8 +76,7 @@ function init() { // PMTiles overlay: vector tile data composited on top of the base geometry overlay = new PMTilesOverlay( { url: 'https://demo-bucket.protomaps.com/v4.pmtiles', - styles: getStyles(), - filter: getFilter(), + getStyle: getStyle(), } ); overlayPlugin = new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ); tiles.registerPlugin( overlayPlugin ); @@ -102,7 +95,7 @@ function init() { function updateOverlay() { - overlay.setStyles( getStyles(), getFilter() ); + overlay.setStyle( getStyle() ); overlay.redraw(); } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 6be3b25f2..89695330e 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -134,8 +134,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - filter, - styles, + getStyle, contentCache, ...rest } = options; @@ -143,7 +142,7 @@ export class MVTImageSource extends RegionImageSource { super(); this.resolution = resolution; - this._styler = new VectorTileStyler( { filter, styles: { ...DEFAULT_STYLES, ...styles } } ); + this._styler = new VectorTileStyler( { getStyle } ); this._renderer = new VectorTileCanvasRenderer( { styler: this._styler } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); @@ -215,9 +214,9 @@ export class MVTImageSource extends RegionImageSource { } - setStyles( styles, filter ) { + setStyle( getStyle ) { - this._styler = new VectorTileStyler( { styles, filter } ); + this._styler = new VectorTileStyler( { getStyle } ); this._renderer.styler = this._styler; } diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 55aa51173..aac85346c 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -20,10 +20,10 @@ export class VectorTileCanvasRenderer { const { _ctx, _invScale } = this; - for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) { + for ( const { layerName, properties, geometry, type } of this._getFeatures( vectorTile ) ) { - const style = this.styler.getStyle( layerName ); - if ( ! style || ! style.visible ) continue; + const style = this.styler.getStyle( layerName, properties ); + if ( ! style || style.visible === false ) continue; _ctx.fillStyle = style.fill ?? 'transparent'; _ctx.strokeStyle = style.stroke ?? 'transparent'; @@ -90,16 +90,12 @@ export class VectorTileCanvasRenderer { for ( let i = 0; i < layer.length; i ++ ) { const feature = layer.feature( i ); - - if ( this.styler.shouldIncludeFeature( feature, layerName ) ) { - - results.push( { - layerName, - geometry: feature.loadGeometry(), - type: feature.type, - } ); - - } + results.push( { + layerName, + properties: feature.properties, + geometry: feature.loadGeometry(), + type: feature.type, + } ); } diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js index 3884b2b75..f5d1015a1 100644 --- a/src/three/renderer/utils/VectorTileStyler.js +++ b/src/three/renderer/utils/VectorTileStyler.js @@ -1,60 +1,30 @@ +const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; + export class VectorTileStyler { constructor( options = {} ) { - const { styles = {}, filter = () => true } = options; - - this.filter = filter; - this._styles = { - ...styles, - default: { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true, ...styles.default }, - }; + this._getStyle = options.getStyle ?? null; } - getStyle( layerName ) { - - const styles = this._styles; - const defaultStyle = styles.default; - if ( layerName in styles ) { - - return { ...defaultStyle, ...styles[ layerName ] }; + getStyle( layerName, properties ) { - } else { - - return defaultStyle; - - } + return this._getStyle ? this._getStyle( layerName, properties ) : DEFAULT_STYLE; } sortLayers( layerNames ) { - const styles = this._styles; - const defaultOrder = styles.default.order; - return [ ...layerNames ].sort( ( a, b ) => { - const orderA = styles[ a ]?.order ?? defaultOrder; - const orderB = styles[ b ]?.order ?? defaultOrder; - if ( orderA !== orderB ) { - - return orderA - orderB; - - } else { - - return a.localeCompare( b ); - - } + const orderA = this.getStyle( a, null )?.order ?? Infinity; + const orderB = this.getStyle( b, null )?.order ?? Infinity; + if ( orderA !== orderB ) return orderA - orderB; + return a.localeCompare( b ); } ); } - shouldIncludeFeature( feature, layerName ) { - - return this.filter( feature, layerName ); - - } - } From adc9610ebdda10f6351341593561f49d242e44ea Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:03:48 +0900 Subject: [PATCH 17/60] Updats --- example/three/pmtiles.js | 26 ++++------ .../utils/VectorTileCanvasRenderer.js | 50 +++++++++++-------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index cb50367f2..834ff9f5f 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -32,20 +32,6 @@ let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; init(); render(); -function getStyle() { - - return layerName => { - - const layer = LAYERS[ layerName ]; - if ( ! layer?.enabled ) return null; - - const { fill, stroke, radius, order } = layer; - return { fill, stroke, order, ...( radius !== undefined && { radius } ) }; - - }; - -} - function init() { renderer = new WebGLRenderer( { antialias: true } ); @@ -76,7 +62,7 @@ function init() { // PMTiles overlay: vector tile data composited on top of the base geometry overlay = new PMTilesOverlay( { url: 'https://demo-bucket.protomaps.com/v4.pmtiles', - getStyle: getStyle(), + getStyle, } ); overlayPlugin = new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ); tiles.registerPlugin( overlayPlugin ); @@ -93,9 +79,17 @@ function init() { } +function getStyle( layerName, properties ) { + + if ( ! ( layerName in LAYERS ) ) return null; + + const layer = LAYERS[ layerName ]; + return layer.enabled ? layer : null; + +} + function updateOverlay() { - overlay.setStyle( getStyle() ); overlay.redraw(); } diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index aac85346c..f84e232ed 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -1,4 +1,5 @@ const MVT_EXTENT = 4096; +const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; export class VectorTileCanvasRenderer { @@ -11,27 +12,36 @@ export class VectorTileCanvasRenderer { } = options; this.styler = styler; - this._getX = getX; - this._getY = getY; + this.getX = getX; + this.getY = getY; + + this._invScale = 1; + this._ctx = null; } renderToCanvas( vectorTile ) { - const { _ctx, _invScale } = this; + const { _ctx, _invScale, styler } = this; for ( const { layerName, properties, geometry, type } of this._getFeatures( vectorTile ) ) { - const style = this.styler.getStyle( layerName, properties ); - if ( ! style || style.visible === false ) continue; + const style = styler.getStyle( layerName, properties ); + const visible = style?.visible ?? DEFAULT_STYLE.visible; + if ( ! style || visible === false ) { + + continue; - _ctx.fillStyle = style.fill ?? 'transparent'; - _ctx.strokeStyle = style.stroke ?? 'transparent'; - _ctx.lineWidth = ( style.strokeWidth ?? 1 ) * _invScale; + } + + _ctx.fillStyle = style.fill ?? DEFAULT_STYLE.fill; + _ctx.strokeStyle = style.stroke ?? DEFAULT_STYLE.stroke; + _ctx.lineWidth = ( style.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale; + const scaledRadius = ( style.radius ?? DEFAULT_STYLE.radius ) * _invScale; if ( type === 1 ) { - this._renderPoints( geometry, style.radius ?? 2 ); + this._renderPoints( geometry, scaledRadius ); } else if ( type === 2 ) { @@ -107,17 +117,15 @@ export class VectorTileCanvasRenderer { _renderPoints( geometry, radius ) { - const { _ctx, _invScale, _getX, _getY } = this; - const scaledRadius = radius * _invScale; - + const { _ctx, getX, getY } = this; for ( const multiPoint of geometry ) { for ( const p of multiPoint ) { - const x = _getX( p ), y = _getY( p ); + const x = getX( p ), y = getY( p ); _ctx.beginPath(); - _ctx.moveTo( x + scaledRadius, y ); - _ctx.arc( x, y, scaledRadius, 0, Math.PI * 2 ); + _ctx.moveTo( x + radius, y ); + _ctx.arc( x, y, radius, 0, Math.PI * 2 ); _ctx.fill(); } @@ -128,7 +136,7 @@ export class VectorTileCanvasRenderer { _renderLines( geometry ) { - const { _ctx, _getX, _getY } = this; + const { _ctx, getX, getY } = this; _ctx.beginPath(); @@ -136,8 +144,8 @@ export class VectorTileCanvasRenderer { for ( let k = 0; k < ring.length; k ++ ) { - if ( k === 0 ) _ctx.moveTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); - else _ctx.lineTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); + if ( k === 0 ) _ctx.moveTo( getX( ring[ k ] ), getY( ring[ k ] ) ); + else _ctx.lineTo( getX( ring[ k ] ), getY( ring[ k ] ) ); } @@ -149,7 +157,7 @@ export class VectorTileCanvasRenderer { _renderPolygons( geometry ) { - const { _ctx, _getX, _getY } = this; + const { _ctx, getX, getY } = this; _ctx.beginPath(); @@ -157,8 +165,8 @@ export class VectorTileCanvasRenderer { for ( let k = 0; k < ring.length; k ++ ) { - if ( k === 0 ) _ctx.moveTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); - else _ctx.lineTo( _getX( ring[ k ] ), _getY( ring[ k ] ) ); + if ( k === 0 ) _ctx.moveTo( getX( ring[ k ] ), getY( ring[ k ] ) ); + else _ctx.lineTo( getX( ring[ k ] ), getY( ring[ k ] ) ); } From 9a392f8b7934318652a1ddd39723b1658ee1c618 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:08:30 +0900 Subject: [PATCH 18/60] Remove tile styler --- .../plugins/images/sources/MVTImageSource.js | 7 ++-- .../utils/VectorTileCanvasRenderer.js | 34 ++++++++++++++++--- src/three/renderer/utils/VectorTileStyler.js | 30 ---------------- 3 files changed, 31 insertions(+), 40 deletions(-) delete mode 100644 src/three/renderer/utils/VectorTileStyler.js diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 89695330e..81702f113 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -3,7 +3,6 @@ import { VectorTile } from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js'; import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; @@ -142,8 +141,7 @@ export class MVTImageSource extends RegionImageSource { super(); this.resolution = resolution; - this._styler = new VectorTileStyler( { getStyle } ); - this._renderer = new VectorTileCanvasRenderer( { styler: this._styler } ); + this._renderer = new VectorTileCanvasRenderer( { getStyle } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); } @@ -216,8 +214,7 @@ export class MVTImageSource extends RegionImageSource { setStyle( getStyle ) { - this._styler = new VectorTileStyler( { getStyle } ); - this._renderer.styler = this._styler; + this._renderer.getStyle = getStyle; } diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index f84e232ed..3ed60ac07 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -6,12 +6,12 @@ export class VectorTileCanvasRenderer { constructor( options = {} ) { const { - styler = null, + getStyle = null, getX = p => p.x, getY = p => p.y, } = options; - this.styler = styler; + this.getStyle = getStyle; this.getX = getX; this.getY = getY; @@ -22,11 +22,11 @@ export class VectorTileCanvasRenderer { renderToCanvas( vectorTile ) { - const { _ctx, _invScale, styler } = this; + const { _ctx, _invScale, getStyle } = this; for ( const { layerName, properties, geometry, type } of this._getFeatures( vectorTile ) ) { - const style = styler.getStyle( layerName, properties ); + const style = getStyle ? getStyle( layerName, properties ) : DEFAULT_STYLE; const visible = style?.visible ?? DEFAULT_STYLE.visible; if ( ! style || visible === false ) { @@ -87,11 +87,35 @@ export class VectorTileCanvasRenderer { } + _sortLayers( layerNames ) { + + const { getStyle } = this; + + return [ ...layerNames ].sort( ( a, b ) => { + + if ( getStyle ) { + + const orderA = getStyle( a, null )?.order ?? DEFAULT_STYLE.order; + const orderB = getStyle( b, null )?.order ?? DEFAULT_STYLE.order; + if ( orderA !== orderB ) { + + return orderA - orderB; + + } + + } + + return a.localeCompare( b ); + + } ); + + } + _getFeatures( vectorTile ) { const results = []; const layerNames = Object.keys( vectorTile.layers ); - const sortedLayers = this.styler.sortLayers( layerNames ); + const sortedLayers = this._sortLayers( layerNames ); for ( const layerName of sortedLayers ) { diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js deleted file mode 100644 index f5d1015a1..000000000 --- a/src/three/renderer/utils/VectorTileStyler.js +++ /dev/null @@ -1,30 +0,0 @@ -const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; - -export class VectorTileStyler { - - constructor( options = {} ) { - - this._getStyle = options.getStyle ?? null; - - } - - getStyle( layerName, properties ) { - - return this._getStyle ? this._getStyle( layerName, properties ) : DEFAULT_STYLE; - - } - - sortLayers( layerNames ) { - - return [ ...layerNames ].sort( ( a, b ) => { - - const orderA = this.getStyle( a, null )?.order ?? Infinity; - const orderB = this.getStyle( b, null )?.order ?? Infinity; - if ( orderA !== orderB ) return orderA - orderB; - return a.localeCompare( b ); - - } ); - - } - -} From b7cc7784e2c2b002063d8570a94e168a1bb9f81e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:10:31 +0900 Subject: [PATCH 19/60] Simplification --- src/three/renderer/utils/VectorTileCanvasRenderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 3ed60ac07..2f8ec7203 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -24,8 +24,9 @@ export class VectorTileCanvasRenderer { const { _ctx, _invScale, getStyle } = this; - for ( const { layerName, properties, geometry, type } of this._getFeatures( vectorTile ) ) { + for ( const feature of this._getFeatures( vectorTile ) ) { + const { layerName, properties, geometry, type } = feature; const style = getStyle ? getStyle( layerName, properties ) : DEFAULT_STYLE; const visible = style?.visible ?? DEFAULT_STYLE.visible; if ( ! style || visible === false ) { From c36147bc7244ecb3d5e2f5311390a752ec329857 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:20:19 +0900 Subject: [PATCH 20/60] Update --- .../plugins/images/sources/MVTImageSource.js | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 81702f113..cb3708e72 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -9,20 +9,26 @@ import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; const DEFAULT_STYLES = { - default: { fill: '#222222', order: Infinity }, - landuse: { fill: '#caedc1', order: 0 }, + default: { fill: '#222222', order: Infinity }, + landuse: { fill: '#caedc1', order: 0 }, landuse_overlay: { fill: '#caedc1', order: 1 }, - park: { fill: '#5da859', order: 2 }, - water: { fill: '#201f20', order: 3 }, - waterway: { fill: '#201f20', order: 4 }, - transportation: { stroke: '#444444', order: 5 }, - road: { stroke: '#444444', order: 6 }, - building: { fill: '#eeeeee', order: 7 }, - boundaries: { stroke: '#444545', order: 8 }, - poi: { fill: '#222222', radius: 3, order: 9 }, - place_label: { fill: '#222222', order: 10 }, + park: { fill: '#5da859', order: 2 }, + water: { fill: '#201f20', order: 3 }, + waterway: { fill: '#201f20', order: 4 }, + transportation: { stroke: '#444444', order: 5 }, + road: { stroke: '#444444', order: 6 }, + building: { fill: '#eeeeee', order: 7 }, + boundaries: { stroke: '#444545', order: 8 }, + poi: { fill: '#222222', radius: 3, order: 9 }, + place_label: { fill: '#222222', order: 10 }, }; +function defaultGetStyle( layerName ) { + + return DEFAULT_STYLES[ layerName ] ?? DEFAULT_STYLES.default; + +} + // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -133,7 +139,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - getStyle, + getStyle = defaultGetStyle, contentCache, ...rest } = options; From c1ca1f6ae168562026bf123fc7c7841ebee1afde Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:24:35 +0900 Subject: [PATCH 21/60] Remove style --- .../plugins/images/sources/MVTImageSource.js | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index cb3708e72..3e8d8eb98 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -8,27 +8,6 @@ import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; -const DEFAULT_STYLES = { - default: { fill: '#222222', order: Infinity }, - landuse: { fill: '#caedc1', order: 0 }, - landuse_overlay: { fill: '#caedc1', order: 1 }, - park: { fill: '#5da859', order: 2 }, - water: { fill: '#201f20', order: 3 }, - waterway: { fill: '#201f20', order: 4 }, - transportation: { stroke: '#444444', order: 5 }, - road: { stroke: '#444444', order: 6 }, - building: { fill: '#eeeeee', order: 7 }, - boundaries: { stroke: '#444545', order: 8 }, - poi: { fill: '#222222', radius: 3, order: 9 }, - place_label: { fill: '#222222', order: 10 }, -}; - -function defaultGetStyle( layerName ) { - - return DEFAULT_STYLES[ layerName ] ?? DEFAULT_STYLES.default; - -} - // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -139,7 +118,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - getStyle = defaultGetStyle, + getStyle, contentCache, ...rest } = options; From e45e23d8520481242d458fd3af4749e6d466c912 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:47:44 +0900 Subject: [PATCH 22/60] Add support for png, etc pmtiles --- .../images/sources/PMTilesImageSource.js | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 80c45b225..9884bf1ae 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -1,5 +1,7 @@ +import { CanvasTexture, SRGBColorSpace } from 'three'; import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; +import { forEachTileInBounds } from '../overlays/utils.js'; import { PMTiles } from 'pmtiles'; const DEG2RAD = Math.PI / 180; @@ -10,6 +12,7 @@ class PMTilesContentCache extends MVTContentCache { super( options ); this.instance = null; + this.tileType = 1; } @@ -20,11 +23,7 @@ class PMTilesContentCache extends MVTContentCache { this.instance = new PMTiles( this.url ); const header = await this.instance.getHeader(); - if ( header.tileType !== 1 ) { - - throw new Error( `PMTilesContentCache: expected MVT tile type (1), got ${ header.tileType }` ); - - } + this.tileType = header.tileType; const projection = new ProjectionScheme( 'EPSG:3857' ); @@ -44,6 +43,21 @@ class PMTilesContentCache extends MVTContentCache { } + async fetchItem( key, signal ) { + + if ( this.tileType !== 1 ) { + + // Raster: store raw buffer instead of parsing as VectorTile + const [ tx, ty, tl ] = key; + const buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); + return ( buffer && buffer.byteLength > 0 ) ? buffer : null; + + } + + return super.fetchItem( key, signal ); + + } + async fetchTileBuffer( z, x, y, signal ) { const res = await this.instance.getZxy( z, x, y, signal ); @@ -61,4 +75,68 @@ export class PMTilesImageSource extends MVTImageSource { } + async fetchItem( [ minX, minY, maxX, maxY, level ], signal ) { + + const { _contentCache, resolution } = this; + if ( _contentCache.tileType === 1 ) { + + // Vector tile path + return super.fetchItem( [ minX, minY, maxX, maxY, level ], signal ); + + } + + // Raster compositing path if not vector tiles + const canvas = document.createElement( 'canvas' ); + canvas.width = resolution; + canvas.height = resolution; + + const ctx = canvas.getContext( '2d' ); + const regionBounds = [ minX, minY, maxX, maxY ]; + const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; + + const promises = []; + forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => { + + promises.push( ( async () => { + + const buffer = await _contentCache.lock( tx, ty, tl ); + if ( buffer ) { + + const [ tMinX, tMinY, tMaxX, tMaxY ] = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); + const destX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * resolution; + const destY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * resolution; + const destW = ( tMaxX - tMinX ) / ( rMaxX - rMinX ) * resolution; + const destH = ( tMaxY - tMinY ) / ( rMaxY - rMinY ) * resolution; + const bitmap = await createImageBitmap( new Blob( [ buffer ] ) ); + ctx.drawImage( bitmap, destX, destY, destW, destH ); + bitmap.close(); + + } + + } )() ); + + } ); + + await Promise.all( promises ); + + const tex = new CanvasTexture( canvas ); + tex.colorSpace = SRGBColorSpace; + tex.generateMipmaps = false; + tex.needsUpdate = true; + tex._regionArgs = [ minX, minY, maxX, maxY, level ]; + return tex; + + } + + redraw() { + + // Raster tiles have no style to re-apply; only delegate for vector tiles + if ( this._contentCache.tileType === 1 ) { + + super.redraw(); + + } + + } + } From 8eb8e14e6386938d018099345b54a5e0812995c1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 08:54:14 +0900 Subject: [PATCH 23/60] Switch to dynamic imports --- src/three/plugins/images/sources/MVTImageSource.js | 13 +++++++++++-- .../plugins/images/sources/PMTilesImageSource.js | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 3e8d8eb98..8e6e6cf99 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,6 +1,4 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; -import { VectorTile } from '@mapbox/vector-tile'; -import Protobuf from 'pbf'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; @@ -8,6 +6,16 @@ import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; +let _mvtImport = null; +function importMVTDeps() { + + return _mvtImport ??= Promise.all( [ + import( '@mapbox/vector-tile' ), + import( 'pbf' ), + ] ).then( ( [ { VectorTile }, { default: Protobuf } ] ) => ( { VectorTile, Protobuf } ) ); + +} + // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -79,6 +87,7 @@ export class MVTContentCache extends DataCache { } + const { VectorTile, Protobuf } = await importMVTDeps(); const vectorTile = new VectorTile( new Protobuf( buffer ) ); return vectorTile; diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 9884bf1ae..e1cc1b860 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -2,10 +2,15 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; -import { PMTiles } from 'pmtiles'; - const DEG2RAD = Math.PI / 180; +let _pmtilesImport = null; +function importPMTiles() { + + return _pmtilesImport ??= import( 'pmtiles' ).then( m => m.PMTiles ); + +} + class PMTilesContentCache extends MVTContentCache { constructor( options = {} ) { @@ -20,6 +25,7 @@ class PMTilesContentCache extends MVTContentCache { const { tiling, tileDimension } = this; + const PMTiles = await importPMTiles(); this.instance = new PMTiles( this.url ); const header = await this.instance.getHeader(); From 347e1470831a04b467f1a7cbba923f9c4f73c372 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:06:33 +0900 Subject: [PATCH 24/60] Share canvas drawing logic --- .../images/sources/GeoJSONImageSource.js | 171 +++--------------- .../utils/VectorTileCanvasRenderer.js | 31 +++- 2 files changed, 58 insertions(+), 144 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 3b63a5548..13879c19e 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -1,6 +1,7 @@ import { CanvasTexture, MathUtils, Vector3, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; +import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; // TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters. @@ -52,6 +53,7 @@ export class GeoJSONImageSource extends RegionImageSource { this.projection = new ProjectionScheme(); this.fetchData = ( ...args ) => fetch( ...args ); + this._renderer = new VectorTileCanvasRenderer( { getX: p => p[ 0 ], getY: p => p[ 1 ] } ); } @@ -159,7 +161,7 @@ export class GeoJSONImageSource extends RegionImageSource { this._updateCache(); const [ minX, minY, maxX, maxY ] = tokens; - const { projection, resolution, features } = this; + const { projection, resolution, features, _renderer } = this; canvas.width = resolution; canvas.height = resolution; @@ -176,8 +178,9 @@ export class GeoJSONImageSource extends RegionImageSource { maxLatRad * MathUtils.RAD2DEG, ]; - // draw features const ctx = canvas.getContext( '2d' ); + _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg, canvas.width, canvas.height ); + for ( let i = 0; i < features.length; i ++ ) { // TODO: Add support for padding of tiles to avoid clipping "wide" elements that may extend beyond @@ -191,6 +194,8 @@ export class GeoJSONImageSource extends RegionImageSource { } + ctx.restore(); + } // bounding box quick test in projected units @@ -295,179 +300,63 @@ export class GeoJSONImageSource extends RegionImageSource { } // draw feature on canvas ( assumes intersects already ) - _drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, width, height ) { + _drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, height ) { const { geometry = null, properties = {} } = feature; if ( ! geometry ) { - // A feature may have null geometry in GeoJSON return; } - const [ minLonDeg, minLatDeg, maxLonDeg, maxLatDeg ] = tileBoundsDeg; + const [ , minLatDeg, , maxLatDeg ] = tileBoundsDeg; const strokeStyle = properties.strokeStyle || this.strokeStyle; const fillStyle = properties.fillStyle || this.fillStyle; const pointRadius = properties.pointRadius || this.pointRadius; const strokeWidth = properties.strokeWidth || this.strokeWidth; + const { _renderer } = this; + ctx.save(); ctx.strokeStyle = strokeStyle; ctx.fillStyle = fillStyle; - ctx.lineWidth = strokeWidth; - - // Compute pixel from cartographic coordinates and tile bounds - const arr = new Array( 2 ); - const projectPoint = ( lon, lat, target = arr ) => { - - // canvas y origin is top, projection y increases north -> flip - const x = MathUtils.mapLinear( lon, minLonDeg, maxLonDeg, 0, width ); - const y = height - MathUtils.mapLinear( lat, minLatDeg, maxLatDeg, 0, height ); - - // round to integer to gain performance - // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#avoid_floating-point_coordinates_and_use_integers_instead - target[ 0 ] = Math.round( x ); - target[ 1 ] = Math.round( y ); - return target; - - }; - - const calculateAspectRatio = ( lon, lat ) => { - - // calculates the aspect ratio with which to draw points - const latRad = lat * MathUtils.DEG2RAD; - const lonRad = lon * MathUtils.DEG2RAD; - const pxLat = ( maxLatDeg - minLatDeg ) / height; - const pxLon = ( maxLonDeg - minLonDeg ) / width; - const pixelRatio = pxLon / pxLat; - - // TODO: this should use the ellipsoid defined on the relevant tiles renderer - return pixelRatio * calculateArcRatioAtPoint( WGS84_ELLIPSOID, latRad, lonRad ); - - }; + ctx.lineWidth = strokeWidth * _renderer._invScale; const type = geometry.type; - if ( type === 'Point' ) { - - const [ lon, lat ] = geometry.coordinates; - const [ px, py ] = projectPoint( lon, lat ); - const drawRatio = calculateAspectRatio( lon, lat ); - - ctx.beginPath(); - ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 ); - ctx.fill(); - ctx.stroke(); - } else if ( type === 'MultiPoint' ) { + if ( type === 'Point' || type === 'MultiPoint' ) { - geometry.coordinates.forEach( ( [ lon, lat ] ) => { + // Radius in geographic units (degrees) so the canvas transform handles positioning. + const scaledRadius = pointRadius * ( maxLatDeg - minLatDeg ) / height; + const points = type === 'Point' ? [ geometry.coordinates ] : geometry.coordinates; + for ( const point of points ) { - const [ px, py ] = projectPoint( lon, lat ); - const drawRatio = calculateAspectRatio( lon, lat ); + // TODO: this should use the ellipsoid defined on the relevant tiles renderer + const arcRatio = calculateArcRatioAtPoint( + WGS84_ELLIPSOID, + point[ 1 ] * MathUtils.DEG2RAD, + point[ 0 ] * MathUtils.DEG2RAD, + ); + const pointGroup = [ point ]; + _renderer._renderPoints( [ pointGroup ], scaledRadius, arcRatio ); - ctx.beginPath(); - ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 ); - ctx.fill(); - ctx.stroke(); - - } ); + } } else if ( type === 'LineString' ) { - ctx.beginPath(); - geometry.coordinates.forEach( ( [ lon, lat ], i ) => { - - const [ px, py ] = projectPoint( lon, lat ); - if ( i === 0 ) { - - ctx.moveTo( px, py ); - - } else { - - ctx.lineTo( px, py ); - - } - - } ); - - ctx.stroke(); + _renderer._renderLines( [ geometry.coordinates ] ); } else if ( type === 'MultiLineString' ) { - ctx.beginPath(); - geometry.coordinates.forEach( ( line ) => { - - line.forEach( ( [ lon, lat ], i ) => { - - const [ px, py ] = projectPoint( lon, lat ); - if ( i === 0 ) { - - ctx.moveTo( px, py ); - - } else { - - ctx.lineTo( px, py ); - - } - - } ); - - } ); - ctx.stroke(); + _renderer._renderLines( geometry.coordinates ); } else if ( type === 'Polygon' ) { - ctx.beginPath(); - geometry.coordinates.forEach( ( ring, rIndex ) => { - - ring.forEach( ( [ lon, lat ], i ) => { - - const [ px, py ] = projectPoint( lon, lat ); - if ( i === 0 ) { - - ctx.moveTo( px, py ); - - } else { - - ctx.lineTo( px, py ); - - } - - } ); - ctx.closePath(); - - } ); - ctx.fill( 'evenodd' ); - ctx.stroke(); + _renderer._renderPolygons( geometry.coordinates ); } else if ( type === 'MultiPolygon' ) { - geometry.coordinates.forEach( ( polygon ) => { - - ctx.beginPath(); - polygon.forEach( ( ring, rIndex ) => { - - ring.forEach( ( [ lon, lat ], i ) => { - - const [ px, py ] = projectPoint( lon, lat ); - if ( i === 0 ) { - - ctx.moveTo( px, py ); - - } else { - - ctx.lineTo( px, py ); - - } - - } ); - ctx.closePath(); - - } ); - ctx.fill( 'evenodd' ); - ctx.stroke(); - - } ); + geometry.coordinates.forEach( polygon => _renderer._renderPolygons( polygon ) ); } diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 2f8ec7203..a01dcdbc5 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -60,6 +60,32 @@ export class VectorTileCanvasRenderer { } + // Sets up the canvas transform and clip for geographic (Y-up, degree) coordinates. + // tileBoundsDeg and regionBoundsDeg are in the same coordinate space as getX/getY returns. + setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg, width, height ) { + + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBoundsDeg; + const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBoundsDeg; + + // Geographic Y increases northward; canvas Y increases downward — negate scaleY. + const scaleX = width / ( rMaxX - rMinX ); + const scaleY = - height / ( rMaxY - rMinY ); + const offsetX = - rMinX * scaleX; + const offsetY = rMaxY * height / ( rMaxY - rMinY ); + + ctx.save(); + ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); + + ctx.beginPath(); + ctx.rect( tMinX, tMinY, tMaxX - tMinX, tMaxY - tMinY ); + ctx.clip(); + + this._ctx = ctx; + this._invScale = 1 / scaleX; + + + } + // Sets up the canvas transform and clip for one MVT tile. // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. setFrame( ctx, tileBounds, regionBounds, width, height ) { @@ -140,7 +166,7 @@ export class VectorTileCanvasRenderer { } - _renderPoints( geometry, radius ) { + _renderPoints( geometry, radius, aspectRatio = 1 ) { const { _ctx, getX, getY } = this; for ( const multiPoint of geometry ) { @@ -149,8 +175,7 @@ export class VectorTileCanvasRenderer { const x = getX( p ), y = getY( p ); _ctx.beginPath(); - _ctx.moveTo( x + radius, y ); - _ctx.arc( x, y, radius, 0, Math.PI * 2 ); + _ctx.ellipse( x, y, radius / aspectRatio, radius, 0, 0, Math.PI * 2 ); _ctx.fill(); } From 2c12ed8b3549908cd6b87f8eea241e7bc914eae0 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:20:11 +0900 Subject: [PATCH 25/60] Updates --- .../images/sources/GeoJSONImageSource.js | 8 +-- .../plugins/images/sources/MVTImageSource.js | 4 +- .../utils/VectorTileCanvasRenderer.js | 69 ++++++++++++++++--- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 13879c19e..de2c585bc 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -318,16 +318,14 @@ export class GeoJSONImageSource extends RegionImageSource { const { _renderer } = this; ctx.save(); - ctx.strokeStyle = strokeStyle; - ctx.fillStyle = fillStyle; - ctx.lineWidth = strokeWidth * _renderer._invScale; + _renderer.setStyle( { fill: fillStyle, stroke: strokeStyle, strokeWidth } ); const type = geometry.type; if ( type === 'Point' || type === 'MultiPoint' ) { // Radius in geographic units (degrees) so the canvas transform handles positioning. - const scaledRadius = pointRadius * ( maxLatDeg - minLatDeg ) / height; + _renderer.radius = pointRadius * ( maxLatDeg - minLatDeg ) / height; const points = type === 'Point' ? [ geometry.coordinates ] : geometry.coordinates; for ( const point of points ) { @@ -338,7 +336,7 @@ export class GeoJSONImageSource extends RegionImageSource { point[ 0 ] * MathUtils.DEG2RAD, ); const pointGroup = [ point ]; - _renderer._renderPoints( [ pointGroup ], scaledRadius, arcRatio ); + _renderer._renderPoints( [ pointGroup ], arcRatio ); } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 8e6e6cf99..a88f54b1b 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -173,7 +173,7 @@ export class MVTImageSource extends RegionImageSource { if ( vectorTile ) { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.setFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + _renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); _renderer.renderToCanvas( vectorTile ); } @@ -228,7 +228,7 @@ export class MVTImageSource extends RegionImageSource { if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._renderer.setFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + this._renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); this._renderer.renderToCanvas( vectorTile ); } ); diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index a01dcdbc5..83cedb43e 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -3,6 +3,42 @@ const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, export class VectorTileCanvasRenderer { + get fill() { + + return this._ctx.fillStyle; + + } + + set fill( v ) { + + this._ctx.fillStyle = v; + + } + + get stroke() { + + return this._ctx.strokeStyle; + + } + + set stroke( v ) { + + this._ctx.strokeStyle = v; + + } + + get strokeWidth() { + + return this._ctx.lineWidth; + + } + + set strokeWidth( v ) { + + this._ctx.lineWidth = v; + + } + constructor( options = {} ) { const { @@ -15,6 +51,9 @@ export class VectorTileCanvasRenderer { this.getX = getX; this.getY = getY; + // styles + this.radius = DEFAULT_STYLE.radius; + this._invScale = 1; this._ctx = null; @@ -22,7 +61,7 @@ export class VectorTileCanvasRenderer { renderToCanvas( vectorTile ) { - const { _ctx, _invScale, getStyle } = this; + const { _ctx, getStyle } = this; for ( const feature of this._getFeatures( vectorTile ) ) { @@ -35,14 +74,11 @@ export class VectorTileCanvasRenderer { } - _ctx.fillStyle = style.fill ?? DEFAULT_STYLE.fill; - _ctx.strokeStyle = style.stroke ?? DEFAULT_STYLE.stroke; - _ctx.lineWidth = ( style.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale; - const scaledRadius = ( style.radius ?? DEFAULT_STYLE.radius ) * _invScale; + this.setStyle( style ); if ( type === 1 ) { - this._renderPoints( geometry, scaledRadius ); + this._renderPoints( geometry ); } else if ( type === 2 ) { @@ -86,9 +122,22 @@ export class VectorTileCanvasRenderer { } + // Applies a style object (as returned by getStyle) to the current canvas context. + setStyle( style ) { + + const { _invScale } = this; + this.fill = style.fill ?? DEFAULT_STYLE.fill; + this.stroke = style.stroke ?? DEFAULT_STYLE.stroke; + this.strokeWidth = ( style.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale; + this.radius = ( style.radius ?? DEFAULT_STYLE.radius ) * _invScale; + + } + + // TODO: merge setVectorTileFrame and setGeographicFrame into a single method. + // Sets up the canvas transform and clip for one MVT tile. // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. - setFrame( ctx, tileBounds, regionBounds, width, height ) { + setVectorTileFrame( ctx, tileBounds, regionBounds, width, height ) { const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; @@ -166,9 +215,9 @@ export class VectorTileCanvasRenderer { } - _renderPoints( geometry, radius, aspectRatio = 1 ) { + _renderPoints( geometry, aspectRatio = 1 ) { - const { _ctx, getX, getY } = this; + const { _ctx, radius, getX, getY } = this; for ( const multiPoint of geometry ) { for ( const p of multiPoint ) { @@ -182,6 +231,8 @@ export class VectorTileCanvasRenderer { } + _ctx.stroke(); + } _renderLines( geometry ) { From 92883b5ae9bfdd6fd5edbb45a8d48172783225a2 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:27:36 +0900 Subject: [PATCH 26/60] Clean up --- .../plugins/images/sources/GeoJSONImageSource.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index de2c585bc..15582f900 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -6,6 +6,8 @@ import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; // TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters. +const GEOMETRY_TYPES = new Set( [ 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon' ] ); + // function for calculating the the change in arc length at a given cartographic point // in order to preserve a circular look when drawing points const _v0 = /* @__PURE__ */ new Vector3(); @@ -137,7 +139,7 @@ export class GeoJSONImageSource extends RegionImageSource { // extract the relevant features this.features = this._featuresFromGeoJSON( geojson ); - this.features.forEach( feature => { + for ( const feature of this.features ) { // save the feature bounds const bounds = this._getFeatureBounds( feature ); @@ -150,7 +152,7 @@ export class GeoJSONImageSource extends RegionImageSource { maxLon = Math.max( maxLon, fMaxLon ); maxLat = Math.max( maxLat, fMaxLat ); - } ); + } this.contentBounds = [ minLon, minLat, maxLon, maxLat ]; @@ -179,16 +181,15 @@ export class GeoJSONImageSource extends RegionImageSource { ]; const ctx = canvas.getContext( '2d' ); - _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg, canvas.width, canvas.height ); + _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg, resolution, resolution ); - for ( let i = 0; i < features.length; i ++ ) { + for ( const feature of features ) { // TODO: Add support for padding of tiles to avoid clipping "wide" elements that may extend beyond // edge of the bounds like stroke, point size. - const feature = features[ i ]; if ( this._featureIntersectsTile( feature, regionBoundsDeg ) ) { - this._drawFeatureOnCanvas( ctx, feature, regionBoundsDeg, canvas.width, canvas.height ); + this._drawFeatureOnCanvas( ctx, feature, regionBoundsDeg, resolution ); } @@ -273,7 +274,6 @@ export class GeoJSONImageSource extends RegionImageSource { _featuresFromGeoJSON( root ) { const type = root.type; - const geomTypes = new Set( [ 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon' ] ); if ( type === 'FeatureCollection' ) { @@ -287,7 +287,7 @@ export class GeoJSONImageSource extends RegionImageSource { return root.geometries.map( g => ( { type: 'Feature', geometry: g, properties: {} } ) ); - } else if ( geomTypes.has( type ) ) { + } else if ( GEOMETRY_TYPES.has( type ) ) { return [ { type: 'Feature', geometry: root, properties: {} } ]; From 2a316fab2d8f5e7fdc19d174b8f1edc9d62c2628 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:33:23 +0900 Subject: [PATCH 27/60] Add getStyle --- .../images/sources/GeoJSONImageSource.js | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 15582f900..b28c3e394 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -36,6 +36,12 @@ export class GeoJSONImageSource extends RegionImageSource { strokeStyle = 'white', strokeWidth = 2, fillStyle = 'rgba( 255, 255, 255, 0.5 )', + getStyle = ( ( _feature, properties ) => ( { + fill: properties.fillStyle || this.fillStyle, + stroke: properties.strokeStyle || this.strokeStyle, + strokeWidth: properties.strokeWidth || this.strokeWidth, + radius: properties.pointRadius || this.pointRadius, + } ) ), ...rest } = {} ) { @@ -48,6 +54,7 @@ export class GeoJSONImageSource extends RegionImageSource { this.strokeStyle = strokeStyle; this.strokeWidth = strokeWidth; this.fillStyle = fillStyle; + this.getStyle = getStyle; this.features = null; this.featureBounds = new Map(); @@ -55,7 +62,10 @@ export class GeoJSONImageSource extends RegionImageSource { this.projection = new ProjectionScheme(); this.fetchData = ( ...args ) => fetch( ...args ); - this._renderer = new VectorTileCanvasRenderer( { getX: p => p[ 0 ], getY: p => p[ 1 ] } ); + this._renderer = new VectorTileCanvasRenderer( { + getX: p => p[ 0 ], + getY: p => p[ 1 ], + } ); } @@ -310,22 +320,18 @@ export class GeoJSONImageSource extends RegionImageSource { } const [ , minLatDeg, , maxLatDeg ] = tileBoundsDeg; - const strokeStyle = properties.strokeStyle || this.strokeStyle; - const fillStyle = properties.fillStyle || this.fillStyle; - const pointRadius = properties.pointRadius || this.pointRadius; - const strokeWidth = properties.strokeWidth || this.strokeWidth; - const { _renderer } = this; + const style = this.getStyle( feature, properties ); ctx.save(); - _renderer.setStyle( { fill: fillStyle, stroke: strokeStyle, strokeWidth } ); + _renderer.setStyle( style ); const type = geometry.type; if ( type === 'Point' || type === 'MultiPoint' ) { // Radius in geographic units (degrees) so the canvas transform handles positioning. - _renderer.radius = pointRadius * ( maxLatDeg - minLatDeg ) / height; + _renderer.radius = style.radius * ( maxLatDeg - minLatDeg ) / height; const points = type === 'Point' ? [ geometry.coordinates ] : geometry.coordinates; for ( const point of points ) { From ee1486fa9e3bc7242307ce67348952538f8c5a98 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:38:28 +0900 Subject: [PATCH 28/60] Cleanup --- src/three/plugins/images/sources/GeoJSONImageSource.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index b28c3e394..993b96594 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -91,6 +91,8 @@ export class GeoJSONImageSource extends RegionImageSource { // TODO: this won't get "dirtied" - no textures will be generated for those cases // where "false" has already been returned on redraw. How to fix? Return a "false" // target to fill in later if needed? + // TODO: we may want to include the LoD or resolution or something here, as well, since that will + // impact the size of the points, etc. const boundsDeg = [ minX, minY, maxX, maxY ].map( v => v * Math.RAD2DEG ); return this._boundsIntersectBounds( boundsDeg, this.contentBounds ); @@ -199,7 +201,7 @@ export class GeoJSONImageSource extends RegionImageSource { // edge of the bounds like stroke, point size. if ( this._featureIntersectsTile( feature, regionBoundsDeg ) ) { - this._drawFeatureOnCanvas( ctx, feature, regionBoundsDeg, resolution ); + this._drawFeatureOnCanvas( feature, regionBoundsDeg, resolution ); } @@ -310,7 +312,7 @@ export class GeoJSONImageSource extends RegionImageSource { } // draw feature on canvas ( assumes intersects already ) - _drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, height ) { + _drawFeatureOnCanvas( feature, tileBoundsDeg, height ) { const { geometry = null, properties = {} } = feature; if ( ! geometry ) { @@ -323,7 +325,6 @@ export class GeoJSONImageSource extends RegionImageSource { const { _renderer } = this; const style = this.getStyle( feature, properties ); - ctx.save(); _renderer.setStyle( style ); const type = geometry.type; @@ -364,8 +365,6 @@ export class GeoJSONImageSource extends RegionImageSource { } - ctx.restore(); - } } From 32deb88420032aa6d520ccbe58b18f3ad8e04d93 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:49:38 +0900 Subject: [PATCH 29/60] Clean up --- .../images/sources/GeoJSONImageSource.js | 2 - .../plugins/images/sources/MVTImageSource.js | 69 +++++++++- .../utils/VectorTileCanvasRenderer.js | 124 ++++-------------- 3 files changed, 90 insertions(+), 105 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 993b96594..b21d4020a 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -207,8 +207,6 @@ export class GeoJSONImageSource extends RegionImageSource { } - ctx.restore(); - } // bounding box quick test in projected units diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index a88f54b1b..a7d771826 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; +import { VectorTileCanvasRenderer, DEFAULT_STYLE } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; @@ -127,7 +127,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - getStyle, + getStyle = null, contentCache, ...rest } = options; @@ -135,7 +135,8 @@ export class MVTImageSource extends RegionImageSource { super(); this.resolution = resolution; - this._renderer = new VectorTileCanvasRenderer( { getStyle } ); + this.getStyle = getStyle; + this._renderer = new VectorTileCanvasRenderer(); this._contentCache = contentCache ?? new MVTContentCache( rest ); } @@ -174,7 +175,7 @@ export class MVTImageSource extends RegionImageSource { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); _renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); - _renderer.renderToCanvas( vectorTile ); + this._renderVectorTile( vectorTile ); } @@ -208,7 +209,63 @@ export class MVTImageSource extends RegionImageSource { setStyle( getStyle ) { - this._renderer.getStyle = getStyle; + this.getStyle = getStyle; + + } + + _renderVectorTile( vectorTile ) { + + const { _renderer, getStyle } = this; + + // Sort layers by user-defined order, falling back to alphabetical. + const layerNames = [ ...Object.keys( vectorTile.layers ) ].sort( ( a, b ) => { + + if ( getStyle ) { + + const orderA = getStyle( a, null )?.order ?? DEFAULT_STYLE.order; + const orderB = getStyle( b, null )?.order ?? DEFAULT_STYLE.order; + if ( orderA !== orderB ) return orderA - orderB; + + } + + return a.localeCompare( b ); + + } ); + + // render each layer + for ( const layerName of layerNames ) { + + const layer = vectorTile.layers[ layerName ]; + + for ( let i = 0; i < layer.length; i ++ ) { + + const feature = layer.feature( i ); + const { properties, type } = feature; + + // Apply per-feature style; skip invisible features. + const style = getStyle( layerName, properties ); + _renderer.setStyle( style ); + if ( ! _renderer.visible ) continue; + + // Dispatch to the appropriate draw primitive (1=point, 2=line, 3=polygon). + const geometry = feature.loadGeometry(); + if ( type === 1 ) { + + _renderer._renderPoints( geometry ); + + } else if ( type === 2 ) { + + _renderer._renderLines( geometry ); + + } else if ( type === 3 ) { + + _renderer._renderPolygons( geometry ); + + } + + } + + } } @@ -229,7 +286,7 @@ export class MVTImageSource extends RegionImageSource { const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); this._renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); - this._renderer.renderToCanvas( vectorTile ); + this._renderVectorTile( vectorTile ); } ); diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js index 83cedb43e..28c3709ba 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js @@ -1,5 +1,5 @@ const MVT_EXTENT = 4096; -const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; +export const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; export class VectorTileCanvasRenderer { @@ -42,64 +42,28 @@ export class VectorTileCanvasRenderer { constructor( options = {} ) { const { - getStyle = null, getX = p => p.x, getY = p => p.y, } = options; - this.getStyle = getStyle; this.getX = getX; this.getY = getY; // styles this.radius = DEFAULT_STYLE.radius; + this.visible = true; this._invScale = 1; this._ctx = null; } - renderToCanvas( vectorTile ) { - - const { _ctx, getStyle } = this; - - for ( const feature of this._getFeatures( vectorTile ) ) { - - const { layerName, properties, geometry, type } = feature; - const style = getStyle ? getStyle( layerName, properties ) : DEFAULT_STYLE; - const visible = style?.visible ?? DEFAULT_STYLE.visible; - if ( ! style || visible === false ) { - - continue; - - } - - this.setStyle( style ); - - if ( type === 1 ) { - - this._renderPoints( geometry ); - - } else if ( type === 2 ) { - - this._renderLines( geometry ); - - } else if ( type === 3 ) { - - this._renderPolygons( geometry ); - - } - - } - - _ctx.restore(); - - } - // Sets up the canvas transform and clip for geographic (Y-up, degree) coordinates. // tileBoundsDeg and regionBoundsDeg are in the same coordinate space as getX/getY returns. setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg, width, height ) { + if ( ctx === this._ctx ) ctx.restore(); + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBoundsDeg; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBoundsDeg; @@ -119,17 +83,17 @@ export class VectorTileCanvasRenderer { this._ctx = ctx; this._invScale = 1 / scaleX; - } // Applies a style object (as returned by getStyle) to the current canvas context. setStyle( style ) { const { _invScale } = this; - this.fill = style.fill ?? DEFAULT_STYLE.fill; - this.stroke = style.stroke ?? DEFAULT_STYLE.stroke; - this.strokeWidth = ( style.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale; - this.radius = ( style.radius ?? DEFAULT_STYLE.radius ) * _invScale; + this.fill = style?.fill ?? DEFAULT_STYLE.fill; + this.stroke = style?.stroke ?? DEFAULT_STYLE.stroke; + this.strokeWidth = ( style?.strokeWidth ?? DEFAULT_STYLE.strokeWidth ) * _invScale; + this.radius = ( style?.radius ?? DEFAULT_STYLE.radius ) * _invScale; + this.visible = style?.visible ?? DEFAULT_STYLE.visible; } @@ -139,6 +103,8 @@ export class VectorTileCanvasRenderer { // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. setVectorTileFrame( ctx, tileBounds, regionBounds, width, height ) { + if ( ctx === this._ctx ) ctx.restore(); + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; @@ -163,61 +129,15 @@ export class VectorTileCanvasRenderer { } - _sortLayers( layerNames ) { - - const { getStyle } = this; - - return [ ...layerNames ].sort( ( a, b ) => { - - if ( getStyle ) { - - const orderA = getStyle( a, null )?.order ?? DEFAULT_STYLE.order; - const orderB = getStyle( b, null )?.order ?? DEFAULT_STYLE.order; - if ( orderA !== orderB ) { - - return orderA - orderB; - - } - - } - - return a.localeCompare( b ); - - } ); - - } - - _getFeatures( vectorTile ) { - - const results = []; - const layerNames = Object.keys( vectorTile.layers ); - const sortedLayers = this._sortLayers( layerNames ); - - for ( const layerName of sortedLayers ) { - - const layer = vectorTile.layers[ layerName ]; - - for ( let i = 0; i < layer.length; i ++ ) { + _renderPoints( geometry, aspectRatio = 1 ) { - const feature = layer.feature( i ); - results.push( { - layerName, - properties: feature.properties, - geometry: feature.loadGeometry(), - type: feature.type, - } ); + const { _ctx, radius, getX, getY, visible } = this; + if ( ! visible ) { - } + return; } - return results; - - } - - _renderPoints( geometry, aspectRatio = 1 ) { - - const { _ctx, radius, getX, getY } = this; for ( const multiPoint of geometry ) { for ( const p of multiPoint ) { @@ -237,7 +157,12 @@ export class VectorTileCanvasRenderer { _renderLines( geometry ) { - const { _ctx, getX, getY } = this; + const { _ctx, getX, getY, visible } = this; + if ( ! visible ) { + + return; + + } _ctx.beginPath(); @@ -258,7 +183,12 @@ export class VectorTileCanvasRenderer { _renderPolygons( geometry ) { - const { _ctx, getX, getY } = this; + const { _ctx, getX, getY, visible } = this; + if ( ! visible ) { + + return; + + } _ctx.beginPath(); From 3423a1c854224bc53f8d38fc3fe7b6da5f779f41 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:50:50 +0900 Subject: [PATCH 30/60] Cleanup --- src/three/plugins/images/MVTOverlay.js | 6 ------ src/three/plugins/images/sources/MVTImageSource.js | 6 ------ 2 files changed, 12 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 1eaba3730..1a29ba51b 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -119,12 +119,6 @@ export class MVTOverlay extends ImageOverlay { } - setStyles( styles, filter ) { - - this.imageSource.setStyles( styles, filter ); - - } - redraw() { this.imageSource.redraw(); diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index a7d771826..a076471bd 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -207,12 +207,6 @@ export class MVTImageSource extends RegionImageSource { } - setStyle( getStyle ) { - - this.getStyle = getStyle; - - } - _renderVectorTile( vectorTile ) { const { _renderer, getStyle } = this; From fe8455c270a2b4357d26dfaef07071da06a8e7e9 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 10:54:38 +0900 Subject: [PATCH 31/60] Cleanup --- src/three/plugins/images/sources/GeoJSONImageSource.js | 2 +- src/three/plugins/images/sources/MVTImageSource.js | 4 ++-- ...ctorTileCanvasRenderer.js => VectorShapeCanvasRenderer.js} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/three/renderer/utils/{VectorTileCanvasRenderer.js => VectorShapeCanvasRenderer.js} (99%) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index b21d4020a..8c18a8f20 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, MathUtils, Vector3, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; -import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; +import { VectorShapeCanvasRenderer } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; // TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters. diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index a076471bd..a9e3f08f1 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { VectorTileCanvasRenderer, DEFAULT_STYLE } from '../../../renderer/utils/VectorTileCanvasRenderer.js'; +import { VectorShapeCanvasRenderer, DEFAULT_STYLE } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; @@ -136,7 +136,7 @@ export class MVTImageSource extends RegionImageSource { this.resolution = resolution; this.getStyle = getStyle; - this._renderer = new VectorTileCanvasRenderer(); + this._renderer = new VectorShapeCanvasRenderer(); this._contentCache = contentCache ?? new MVTContentCache( rest ); } diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js similarity index 99% rename from src/three/renderer/utils/VectorTileCanvasRenderer.js rename to src/three/renderer/utils/VectorShapeCanvasRenderer.js index 28c3709ba..98b371268 100644 --- a/src/three/renderer/utils/VectorTileCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -1,7 +1,7 @@ const MVT_EXTENT = 4096; export const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; -export class VectorTileCanvasRenderer { +export class VectorShapeCanvasRenderer { get fill() { From 10592d523a96854677d7adf84b2977bbe7000735 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:01:55 +0900 Subject: [PATCH 32/60] Fix naming --- src/three/plugins/images/sources/GeoJSONImageSource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 8c18a8f20..a9152e41d 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -62,7 +62,7 @@ export class GeoJSONImageSource extends RegionImageSource { this.projection = new ProjectionScheme(); this.fetchData = ( ...args ) => fetch( ...args ); - this._renderer = new VectorTileCanvasRenderer( { + this._renderer = new VectorShapeCanvasRenderer( { getX: p => p[ 0 ], getY: p => p[ 1 ], } ); From 534756b449fba24c08927c0443a64d0ef590ac93 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:08:22 +0900 Subject: [PATCH 33/60] Add Path2D support --- .../renderer/utils/VectorShapeCanvasRenderer.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index 98b371268..e1c2bb8c1 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -164,6 +164,13 @@ export class VectorShapeCanvasRenderer { } + if ( geometry instanceof Path2D ) { + + _ctx.stroke( geometry ); + return; + + } + _ctx.beginPath(); for ( const ring of geometry ) { @@ -190,6 +197,14 @@ export class VectorShapeCanvasRenderer { } + if ( geometry instanceof Path2D ) { + + _ctx.fill( geometry, 'evenodd' ); + _ctx.stroke( geometry ); + return; + + } + _ctx.beginPath(); for ( const ring of geometry ) { From f9be430236329bcd0429ad889aeaccf54ca9ace2 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:09:52 +0900 Subject: [PATCH 34/60] Comment --- src/three/plugins/images/sources/GeoJSONImageSource.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index a9152e41d..1e8cb78a3 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -135,6 +135,9 @@ export class GeoJSONImageSource extends RegionImageSource { _updateCache( force = false ) { + // TODO: if we "bake" shapes or geometries to Path2Ds the redraw performance + // can improve by up to 2x. + const { geojson, featureBounds } = this; if ( ! geojson || ( this.features && ! force ) ) { From 928d010b0c5df2914ef1dd5e37fb49d2d994e5e7 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:18:59 +0900 Subject: [PATCH 35/60] Remove outdates docs --- src/three/plugins/images/MVTOverlay.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 1a29ba51b..f5581dccd 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -12,8 +12,6 @@ import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; * @param {number} [options.tileDimension=256] Tile pixel size. * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {Object} [options.styles] Per-layer color overrides. - * @param {Function} [options.filter] Feature filter callback `(feature, layerName) => boolean`. */ export class MVTOverlay extends ImageOverlay { From 153d4d3b9f22289b7456d8a746b9f14d49a91fbd Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 12:09:50 +0900 Subject: [PATCH 36/60] Switch to generated surface plugin --- example/three/pmtiles.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 834ff9f5f..1725573a6 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -9,6 +9,7 @@ import { XYZTilesPlugin, ImageOverlayPlugin, PMTilesOverlay, + GeneratedSurfacePlugin, } from '3d-tiles-renderer/plugins'; import GUI from 'three/addons/libs/lil-gui.module.min.js'; @@ -44,14 +45,24 @@ function init() { scene = new Scene(); camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.001, 10000 ); + // PMTiles overlay: vector tile data composited on top of the base geometry + overlay = new PMTilesOverlay( { + url: 'https://demo-bucket.protomaps.com/v4.pmtiles', + getStyle, + } ); + // Base tile layer: XYZ raster tiles provide the globe geometry tiles = new TilesRenderer(); tiles.registerPlugin( new UpdateOnChangePlugin() ); tiles.registerPlugin( new TilesFadePlugin() ); - tiles.registerPlugin( new XYZTilesPlugin( { + tiles.registerPlugin( new GeneratedSurfacePlugin( { center: true, shape: 'ellipsoid', - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + overlay, + } ) ); + tiles.registerPlugin( new ImageOverlayPlugin( { + overlays: [ overlay ], + renderer, } ) ); tiles.setCamera( camera ); @@ -59,14 +70,6 @@ function init() { tiles.group.updateMatrixWorld(); scene.add( tiles.group ); - // PMTiles overlay: vector tile data composited on top of the base geometry - overlay = new PMTilesOverlay( { - url: 'https://demo-bucket.protomaps.com/v4.pmtiles', - getStyle, - } ); - overlayPlugin = new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ); - tiles.registerPlugin( overlayPlugin ); - // Controls controls = new GlobeControls( scene, camera, renderer.domElement ); controls.setEllipsoid( tiles.ellipsoid, tiles.group ); From 665be9d3860d6edd542336ce1242b384ba5477bf Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 12:12:28 +0900 Subject: [PATCH 37/60] lint fixes --- example/three/pmtiles.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 1725573a6..19e33564a 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -6,7 +6,6 @@ import { import { UpdateOnChangePlugin, TilesFadePlugin, - XYZTilesPlugin, ImageOverlayPlugin, PMTilesOverlay, GeneratedSurfacePlugin, @@ -28,7 +27,7 @@ const LAYERS = { pois: { enabled: true, fill: '#1a8cbd', radius: 3, order: 10 }, }; -let scene, renderer, camera, controls, tiles, overlay, overlayPlugin; +let scene, renderer, camera, controls, tiles, overlay; init(); render(); From 455a1e06c01b078c2bdbf9e7c17c462b93dc0a1f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 12:39:39 +0900 Subject: [PATCH 38/60] Small fix ups --- .../renderer/utils/VectorShapeCanvasRenderer.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index e1c2bb8c1..f8bbf088f 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -62,7 +62,7 @@ export class VectorShapeCanvasRenderer { // tileBoundsDeg and regionBoundsDeg are in the same coordinate space as getX/getY returns. setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg, width, height ) { - if ( ctx === this._ctx ) ctx.restore(); + ctx.restore(); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBoundsDeg; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBoundsDeg; @@ -70,8 +70,8 @@ export class VectorShapeCanvasRenderer { // Geographic Y increases northward; canvas Y increases downward — negate scaleY. const scaleX = width / ( rMaxX - rMinX ); const scaleY = - height / ( rMaxY - rMinY ); - const offsetX = - rMinX * scaleX; - const offsetY = rMaxY * height / ( rMaxY - rMinY ); + const offsetX = Math.round( - rMinX * scaleX ); + const offsetY = Math.round( rMaxY * height / ( rMaxY - rMinY ) ); ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); @@ -103,7 +103,7 @@ export class VectorShapeCanvasRenderer { // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. setVectorTileFrame( ctx, tileBounds, regionBounds, width, height ) { - if ( ctx === this._ctx ) ctx.restore(); + ctx.restore(); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; @@ -112,8 +112,8 @@ export class VectorShapeCanvasRenderer { // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. const scaleX = ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ) * width; const scaleY = ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ) * height; - const offsetX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width; - const offsetY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height; + const offsetX = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); + const offsetY = Math.round( ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height ); ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); From 101080bd35179325102a6cd435ea8d981ed4482f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 13:09:19 +0900 Subject: [PATCH 39/60] Simplification --- .../images/sources/GeoJSONImageSource.js | 2 +- .../plugins/images/sources/MVTImageSource.js | 4 +-- .../utils/VectorShapeCanvasRenderer.js | 28 +++++++++++++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 1e8cb78a3..82640b713 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -196,7 +196,7 @@ export class GeoJSONImageSource extends RegionImageSource { ]; const ctx = canvas.getContext( '2d' ); - _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg, resolution, resolution ); + _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg ); for ( const feature of features ) { diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index a9e3f08f1..e2f261a9d 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -174,7 +174,7 @@ export class MVTImageSource extends RegionImageSource { if ( vectorTile ) { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + _renderer.setVectorTileFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } @@ -279,7 +279,7 @@ export class MVTImageSource extends RegionImageSource { if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._renderer.setVectorTileFrame( ctx, tileBounds, regionBounds, canvas.width, canvas.height ); + this._renderer.setVectorTileFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } ); diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index f8bbf088f..b815dc670 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -60,18 +60,26 @@ export class VectorShapeCanvasRenderer { // Sets up the canvas transform and clip for geographic (Y-up, degree) coordinates. // tileBoundsDeg and regionBoundsDeg are in the same coordinate space as getX/getY returns. - setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg, width, height ) { + setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg ) { ctx.restore(); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBoundsDeg; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBoundsDeg; + const { width, height } = ctx.canvas; // Geographic Y increases northward; canvas Y increases downward — negate scaleY. const scaleX = width / ( rMaxX - rMinX ); const scaleY = - height / ( rMaxY - rMinY ); - const offsetX = Math.round( - rMinX * scaleX ); - const offsetY = Math.round( rMaxY * height / ( rMaxY - rMinY ) ); + + // Canvas position of the tile's top-left corner. + const tileLeft = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); + const tileTop = Math.round( ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) * height ); + + // Offset: tile corner position adjusted for the geographic coordinate at that corner + // (geo data is in global degree space, not tile-local space). + const offsetX = tileLeft - tMinX * scaleX; + const offsetY = tileTop - tMaxY * scaleY; ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); @@ -101,19 +109,27 @@ export class VectorShapeCanvasRenderer { // Sets up the canvas transform and clip for one MVT tile. // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. - setVectorTileFrame( ctx, tileBounds, regionBounds, width, height ) { + setVectorTileFrame( ctx, tileBounds, regionBounds ) { ctx.restore(); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; + const { width, height } = ctx.canvas; // Affine transform: MVT tile coords [0, MVT_EXTENT] → canvas pixels. // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. const scaleX = ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ) * width; const scaleY = ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ) * height; - const offsetX = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); - const offsetY = Math.round( ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * height ); + + // Canvas position of the tile's top-left corner. + const tileLeft = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); + const tileTop = Math.round( ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) * height ); + + // Offset: tile corner position is the transform offset directly + // (MVT data is in tile-local space starting at (0, 0)). + const offsetX = tileLeft; + const offsetY = tileTop; ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); From e0bb3857a18a4e61024df94cec58eee341b5b39e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 13:10:27 +0900 Subject: [PATCH 40/60] Simplification --- .../renderer/utils/VectorShapeCanvasRenderer.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index b815dc670..11ac7ba36 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -73,8 +73,8 @@ export class VectorShapeCanvasRenderer { const scaleY = - height / ( rMaxY - rMinY ); // Canvas position of the tile's top-left corner. - const tileLeft = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); - const tileTop = Math.round( ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) * height ); + const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) ); + const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) ); // Offset: tile corner position adjusted for the geographic coordinate at that corner // (geo data is in global degree space, not tile-local space). @@ -119,12 +119,12 @@ export class VectorShapeCanvasRenderer { // Affine transform: MVT tile coords [0, MVT_EXTENT] → canvas pixels. // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. - const scaleX = ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ) * width; - const scaleY = ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ) * height; + const scaleX = width * ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ); + const scaleY = height * ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ); // Canvas position of the tile's top-left corner. - const tileLeft = Math.round( ( tMinX - rMinX ) / ( rMaxX - rMinX ) * width ); - const tileTop = Math.round( ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) * height ); + const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) ); + const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) ); // Offset: tile corner position is the transform offset directly // (MVT data is in tile-local space starting at (0, 0)). From 63e74187fa4202ae89f45450adb92623864745f6 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 13:18:47 +0900 Subject: [PATCH 41/60] Share "setFrame" functions --- .../images/sources/GeoJSONImageSource.js | 3 +- .../plugins/images/sources/MVTImageSource.js | 6 +- .../utils/VectorShapeCanvasRenderer.js | 84 +++++++------------ 3 files changed, 35 insertions(+), 58 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 82640b713..7a3944b70 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -63,6 +63,7 @@ export class GeoJSONImageSource extends RegionImageSource { this.projection = new ProjectionScheme(); this.fetchData = ( ...args ) => fetch( ...args ); this._renderer = new VectorShapeCanvasRenderer( { + flipY: true, getX: p => p[ 0 ], getY: p => p[ 1 ], } ); @@ -196,7 +197,7 @@ export class GeoJSONImageSource extends RegionImageSource { ]; const ctx = canvas.getContext( '2d' ); - _renderer.setGeographicFrame( ctx, regionBoundsDeg, regionBoundsDeg ); + _renderer.setFrame( ctx, regionBoundsDeg, regionBoundsDeg ); for ( const feature of features ) { diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index e2f261a9d..2daeb6b61 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -136,7 +136,7 @@ export class MVTImageSource extends RegionImageSource { this.resolution = resolution; this.getStyle = getStyle; - this._renderer = new VectorShapeCanvasRenderer(); + this._renderer = new VectorShapeCanvasRenderer( { tileExtent: 4096 } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); } @@ -174,7 +174,7 @@ export class MVTImageSource extends RegionImageSource { if ( vectorTile ) { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.setVectorTileFrame( ctx, tileBounds, regionBounds ); + _renderer.setFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } @@ -279,7 +279,7 @@ export class MVTImageSource extends RegionImageSource { if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._renderer.setVectorTileFrame( ctx, tileBounds, regionBounds ); + this._renderer.setFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } ); diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index 11ac7ba36..62c526ec9 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -1,4 +1,3 @@ -const MVT_EXTENT = 4096; export const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; export class VectorShapeCanvasRenderer { @@ -44,11 +43,19 @@ export class VectorShapeCanvasRenderer { const { getX = p => p.x, getY = p => p.y, + flipY = false, + tileExtent = null, } = options; this.getX = getX; this.getY = getY; + // flipY: true for Y-up coordinate systems (geographic degrees). + // tileExtent: fixed local-space size of each tile (e.g. 4096 for MVT). + // null means the tile's local space spans [tMinX..tMaxX] / [tMinY..tMaxY] directly. + this.flipY = flipY; + this.tileExtent = tileExtent; + // styles this.radius = DEFAULT_STYLE.radius; this.visible = true; @@ -58,34 +65,43 @@ export class VectorShapeCanvasRenderer { } - // Sets up the canvas transform and clip for geographic (Y-up, degree) coordinates. - // tileBoundsDeg and regionBoundsDeg are in the same coordinate space as getX/getY returns. - setGeographicFrame( ctx, tileBoundsDeg, regionBoundsDeg ) { + // Sets up the canvas transform and clip for a tile. + // tileBounds and regionBounds are in the same coordinate space as getX/getY returns. + setFrame( ctx, tileBounds, regionBounds ) { ctx.restore(); - const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBoundsDeg; - const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBoundsDeg; + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; + const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; const { width, height } = ctx.canvas; + const { flipY, tileExtent } = this; + + // Tile span in local coordinate space: either a fixed extent (e.g. 4096 for MVT) + // or the tile's own span in source coords (for geographic). + const spanX = tileExtent ?? ( tMaxX - tMinX ); + const spanY = tileExtent ?? ( tMaxY - tMinY ); - // Geographic Y increases northward; canvas Y increases downward — negate scaleY. - const scaleX = width / ( rMaxX - rMinX ); - const scaleY = - height / ( rMaxY - rMinY ); + // flipY negates scaleY for Y-up coordinate systems (geographic). + const scaleX = width * ( tMaxX - tMinX ) / spanX / ( rMaxX - rMinX ); + const scaleY = ( flipY ? - 1 : 1 ) * height * ( tMaxY - tMinY ) / spanY / ( rMaxY - rMinY ); // Canvas position of the tile's top-left corner. const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) ); const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) ); - // Offset: tile corner position adjusted for the geographic coordinate at that corner - // (geo data is in global degree space, not tile-local space). - const offsetX = tileLeft - tMinX * scaleX; - const offsetY = tileTop - tMaxY * scaleY; + // Tile-local coordinate at the tile's canvas corner. + // Fixed-extent tiles (e.g. MVT) start at (0, 0); geographic tiles start at the tile bounds corner. + // For Y-up (flipY) the canvas top corresponds to tMaxY, not tMinY. + const localOriginX = tileExtent ? 0 : tMinX; + const localOriginY = tileExtent ? 0 : ( flipY ? tMaxY : tMinY ); + const offsetX = tileLeft - localOriginX * scaleX; + const offsetY = tileTop - localOriginY * scaleY; ctx.save(); ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); ctx.beginPath(); - ctx.rect( tMinX, tMinY, tMaxX - tMinX, tMaxY - tMinY ); + ctx.rect( localOriginX, tileExtent ? 0 : tMinY, spanX, spanY ); ctx.clip(); this._ctx = ctx; @@ -105,46 +121,6 @@ export class VectorShapeCanvasRenderer { } - // TODO: merge setVectorTileFrame and setGeographicFrame into a single method. - - // Sets up the canvas transform and clip for one MVT tile. - // tileBounds and regionBounds are normalized [0,1] coordinates, Y increases northward. - setVectorTileFrame( ctx, tileBounds, regionBounds ) { - - ctx.restore(); - - const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; - const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; - const { width, height } = ctx.canvas; - - // Affine transform: MVT tile coords [0, MVT_EXTENT] → canvas pixels. - // MVT Y increases downward; normalized Y increases northward; canvas Y increases downward. - const scaleX = width * ( tMaxX - tMinX ) / MVT_EXTENT / ( rMaxX - rMinX ); - const scaleY = height * ( tMaxY - tMinY ) / MVT_EXTENT / ( rMaxY - rMinY ); - - // Canvas position of the tile's top-left corner. - const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) ); - const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) ); - - // Offset: tile corner position is the transform offset directly - // (MVT data is in tile-local space starting at (0, 0)). - const offsetX = tileLeft; - const offsetY = tileTop; - - ctx.save(); - ctx.setTransform( scaleX, 0, 0, scaleY, offsetX, offsetY ); - - // Clip to [0, MVT_EXTENT] in tile space — prevents MVT buffer geometry from bleeding - // into adjacent tiles and causing evenodd fill cancellation at boundaries. - ctx.beginPath(); - ctx.rect( 0, 0, MVT_EXTENT, MVT_EXTENT ); - ctx.clip(); - - this._ctx = ctx; - this._invScale = 1 / scaleX; - - } - _renderPoints( geometry, aspectRatio = 1 ) { const { _ctx, radius, getX, getY, visible } = this; From 6052650240961df06ebf542380583ba3646aefe7 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 13:23:41 +0900 Subject: [PATCH 42/60] Updates --- .../plugins/images/sources/MVTImageSource.js | 6 +++--- .../renderer/utils/VectorShapeCanvasRenderer.js | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 2daeb6b61..f4f667361 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { VectorShapeCanvasRenderer, DEFAULT_STYLE } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; +import { VectorShapeCanvasRenderer } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; @@ -216,8 +216,8 @@ export class MVTImageSource extends RegionImageSource { if ( getStyle ) { - const orderA = getStyle( a, null )?.order ?? DEFAULT_STYLE.order; - const orderB = getStyle( b, null )?.order ?? DEFAULT_STYLE.order; + const orderA = getStyle( a, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; + const orderB = getStyle( b, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; if ( orderA !== orderB ) return orderA - orderB; } diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index 62c526ec9..279d66789 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -1,7 +1,20 @@ -export const DEFAULT_STYLE = { fill: '#cccccc', stroke: 'transparent', strokeWidth: 1, radius: 2, order: 0, visible: true }; +const DEFAULT_STYLE = Object.freeze( { + fill: '#cccccc', + stroke: 'transparent', + strokeWidth: 1, + radius: 2, + order: 0, + visible: true, +} ); export class VectorShapeCanvasRenderer { + static get DEFAULT_STYLE() { + + return DEFAULT_STYLE; + + } + get fill() { return this._ctx.fillStyle; From 38752a4998dfd19e42a501229d6bdc6be520c881 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 14:14:47 +0900 Subject: [PATCH 43/60] Updates --- src/three/plugins/images/GeneratedSurfacePlugin.js | 2 +- .../renderer/utils/VectorShapeCanvasRenderer.js | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 406a38627..f74eb67d2 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -619,7 +619,7 @@ export class GeneratedSurfacePlugin { const tiling = new TilingScheme(); if ( this.shape === 'ellipsoid' ) { - const projection = new ProjectionScheme(); + const projection = new ProjectionScheme( 'EPSG:3857' ); tiling.setProjection( projection ); tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/renderer/utils/VectorShapeCanvasRenderer.js index 279d66789..4130c4664 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/renderer/utils/VectorShapeCanvasRenderer.js @@ -94,13 +94,16 @@ export class VectorShapeCanvasRenderer { const spanX = tileExtent ?? ( tMaxX - tMinX ); const spanY = tileExtent ?? ( tMaxY - tMinY ); - // flipY negates scaleY for Y-up coordinate systems (geographic). - const scaleX = width * ( tMaxX - tMinX ) / spanX / ( rMaxX - rMinX ); - const scaleY = ( flipY ? - 1 : 1 ) * height * ( tMaxY - tMinY ) / spanY / ( rMaxY - rMinY ); - - // Canvas position of the tile's top-left corner. + // Round all four tile edges to integer pixel positions so adjacent clip rects share + // the exact same boundary pixel — preventing sub-pixel gaps or overlaps at seams. const tileLeft = Math.round( width * ( tMinX - rMinX ) / ( rMaxX - rMinX ) ); + const tileRight = Math.round( width * ( tMaxX - rMinX ) / ( rMaxX - rMinX ) ); const tileTop = Math.round( height * ( rMaxY - tMaxY ) / ( rMaxY - rMinY ) ); + const tileBottom = Math.round( height * ( rMaxY - tMinY ) / ( rMaxY - rMinY ) ); + + // Derive scale from rounded pixel dimensions so geometry fills exactly the rounded clip rect. + const scaleX = ( tileRight - tileLeft ) / spanX; + const scaleY = ( flipY ? - 1 : 1 ) * ( tileBottom - tileTop ) / spanY; // Tile-local coordinate at the tile's canvas corner. // Fixed-extent tiles (e.g. MVT) start at (0, 0); geographic tiles start at the tile bounds corner. From b6f9d9cd0c86569749502ee8d9a41fd234073fad Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 14:33:52 +0900 Subject: [PATCH 44/60] Cleanup --- example/three/pmtiles.js | 1 - src/three/plugins/images/sources/MVTImageSource.js | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 19e33564a..257130e23 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -57,7 +57,6 @@ function init() { tiles.registerPlugin( new GeneratedSurfacePlugin( { center: true, shape: 'ellipsoid', - overlay, } ) ); tiles.registerPlugin( new ImageOverlayPlugin( { overlays: [ overlay ], diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index f4f667361..b66dd547e 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -123,6 +123,18 @@ export class MVTImageSource extends RegionImageSource { } + get fetchData() { + + return this._contentCache.fetchData; + + } + + set fetchData( v ) { + + this._contentCache.fetchData = v; + + } + constructor( options = {} ) { const { From f3f7015dc7f07201da54b64b702be8766055060e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 14:43:58 +0900 Subject: [PATCH 45/60] Cleanup --- .../plugins/images/sources/MVTImageSource.js | 20 +++++++++---------- .../images/sources/PMTilesImageSource.js | 16 +++++---------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index b66dd547e..59c4a1364 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -79,7 +79,14 @@ export class MVTContentCache extends DataCache { async fetchItem( [ tx, ty, tl ], signal ) { - let buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); + const url = this.getUrl( tx, ty, tl ); + const res = await this.fetchData( url, { ...this.fetchOptions, signal } ); + const buffer = await res.arrayBuffer(); + return this._parseVectorTile( buffer ); + + } + + async _parseVectorTile( buffer ) { if ( ! buffer || buffer.byteLength === 0 ) { @@ -88,22 +95,13 @@ export class MVTContentCache extends DataCache { } const { VectorTile, Protobuf } = await importMVTDeps(); - const vectorTile = new VectorTile( new Protobuf( buffer ) ); - return vectorTile; + return new VectorTile( new Protobuf( buffer ) ); } // Parsed JS objects — nothing to dispose disposeItem() {} - async fetchTileBuffer( z, x, y, signal ) { - - const url = this.getUrl( x, y, z ); - const res = await this.fetchData( url, { ...this.fetchOptions, signal } ); - return res.arrayBuffer(); - - } - getUrl( x, y, level ) { return this.url diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index e1cc1b860..27669fa21 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -49,25 +49,19 @@ class PMTilesContentCache extends MVTContentCache { } - async fetchItem( key, signal ) { + async fetchItem( [ tx, ty, tl ], signal ) { + + const res = await this.instance.getZxy( tl, tx, ty, signal ); + const buffer = res ? res.data : null; if ( this.tileType !== 1 ) { // Raster: store raw buffer instead of parsing as VectorTile - const [ tx, ty, tl ] = key; - const buffer = await this.fetchTileBuffer( tl, tx, ty, signal ); return ( buffer && buffer.byteLength > 0 ) ? buffer : null; } - return super.fetchItem( key, signal ); - - } - - async fetchTileBuffer( z, x, y, signal ) { - - const res = await this.instance.getZxy( z, x, y, signal ); - return res ? res.data : null; + return this._parseVectorTile( buffer ); } From 5afa350eedfad1c749ecd9e7bb710b4fa788efcb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 15:13:27 +0900 Subject: [PATCH 46/60] Fix lack of queueing --- .../images/sources/PMTilesImageSource.js | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 27669fa21..77b4cbd83 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -26,7 +26,44 @@ class PMTilesContentCache extends MVTContentCache { const { tiling, tileDimension } = this; const PMTiles = await importPMTiles(); - this.instance = new PMTiles( this.url ); + + // Custom Source routes all PMTiles range requests through fetchData so they + // go through the shared download queue rather than PMTiles' internal fetch. + this.instance = new PMTiles( { + getKey: () => this.url, + getBytes: async ( offset, length, signal ) => { + + const { fetchOptions, url } = this; + const res = await this.fetchData( url, { + ...fetchOptions, + signal, + headers: { + ...fetchOptions.headers, + range: `bytes=${ offset }-${ offset + length - 1 }`, + }, + } ); + + if ( ! res.ok ) { + + throw new Error( `PMTilesImageSource: Bad response code: ${ res.status }` ); + + } + + if ( res.status !== 206 ) { + + throw new Error( 'PMTilesImageSource: Server does not support HTTP Byte Serving.' ); + + } + + return { + data: await res.arrayBuffer(), + etag: res.headers.get( 'ETag' ), + cacheControl: res.headers.get( 'Cache-Control' ), + expires: res.headers.get( 'Expires' ), + }; + + }, + } ); const header = await this.instance.getHeader(); this.tileType = header.tileType; From a67060a64b2c1e01a34307b60d657add8adf28de Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 20:39:17 +0900 Subject: [PATCH 47/60] simplify pmtiles image source --- .../images/sources/PMTilesImageSource.js | 155 ++++++++++++------ 1 file changed, 107 insertions(+), 48 deletions(-) diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 77b4cbd83..a80d59497 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -1,7 +1,8 @@ -import { CanvasTexture, SRGBColorSpace } from 'three'; import { MVTContentCache, MVTImageSource } from './MVTImageSource.js'; +import { TiledImageSource } from './TiledImageSource.js'; +import { RegionImageSource, TiledRegionImageSource } from './RegionImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; -import { forEachTileInBounds } from '../overlays/utils.js'; + const DEG2RAD = Math.PI / 180; let _pmtilesImport = null; @@ -11,6 +12,27 @@ function importPMTiles() { } +// Fetches individual raster tiles from a PMTiles instance and converts them to textures. +class PMTilesRasterTileSource extends TiledImageSource { + + constructor( instance, tiling ) { + + super(); + this.instance = instance; + this.tiling = tiling; + + } + + async fetchItem( [ tx, ty, tl ], signal ) { + + const res = await this.instance.getZxy( tl, tx, ty, signal ); + if ( ! res || ! res.data || res.data.byteLength === 0 ) return null; + return this.processBufferToTexture( res.data ); + + } + +} + class PMTilesContentCache extends MVTContentCache { constructor( options = {} ) { @@ -89,88 +111,125 @@ class PMTilesContentCache extends MVTContentCache { async fetchItem( [ tx, ty, tl ], signal ) { const res = await this.instance.getZxy( tl, tx, ty, signal ); - const buffer = res ? res.data : null; + return this._parseVectorTile( res ? res.data : null ); - if ( this.tileType !== 1 ) { + } + +} - // Raster: store raw buffer instead of parsing as VectorTile - return ( buffer && buffer.byteLength > 0 ) ? buffer : null; +export class PMTilesImageSource extends RegionImageSource { - } + get tiling() { - return this._parseVectorTile( buffer ); + return this._contentCache.tiling; } -} + get fetchData() { + + return this._contentCache.fetchData; + + } + + set fetchData( v ) { -export class PMTilesImageSource extends MVTImageSource { + this._contentCache.fetchData = v; + + } + + get resolution() { + + return this._resolution; + + } + + set resolution( v ) { + + this._resolution = v; + if ( this._deferredSource ) { + + this._deferredSource.resolution = v; + + } + + } constructor( options = {} ) { - super( { ...options, contentCache: new PMTilesContentCache( options ) } ); + super(); + + const { resolution = 512, getStyle = null } = options; + this._resolution = resolution; + this._getStyle = getStyle; + this._contentCache = new PMTilesContentCache( options ); + this._deferredSource = null; } - async fetchItem( [ minX, minY, maxX, maxY, level ], signal ) { + async init() { + + await this._contentCache.init(); + const { _contentCache } = this; - const { _contentCache, resolution } = this; if ( _contentCache.tileType === 1 ) { - // Vector tile path - return super.fetchItem( [ minX, minY, maxX, maxY, level ], signal ); + this._deferredSource = new MVTImageSource( { + resolution: this._resolution, + getStyle: this._getStyle, + contentCache: _contentCache, + } ); + + } else { + + const rasterSource = new PMTilesRasterTileSource( _contentCache.instance, _contentCache.tiling ); + this._deferredSource = new TiledRegionImageSource( rasterSource ); + this._deferredSource.resolution = this._resolution; } - // Raster compositing path if not vector tiles - const canvas = document.createElement( 'canvas' ); - canvas.width = resolution; - canvas.height = resolution; + } - const ctx = canvas.getContext( '2d' ); - const regionBounds = [ minX, minY, maxX, maxY ]; - const [ rMinX, rMinY, rMaxX, rMaxY ] = regionBounds; + hasContent( minX, minY, maxX, maxY, level ) { - const promises = []; - forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => { + return this._deferredSource.hasContent( minX, minY, maxX, maxY, level ); - promises.push( ( async () => { + } - const buffer = await _contentCache.lock( tx, ty, tl ); - if ( buffer ) { + lock( ...args ) { - const [ tMinX, tMinY, tMaxX, tMaxY ] = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - const destX = ( tMinX - rMinX ) / ( rMaxX - rMinX ) * resolution; - const destY = ( 1 - ( tMaxY - rMinY ) / ( rMaxY - rMinY ) ) * resolution; - const destW = ( tMaxX - tMinX ) / ( rMaxX - rMinX ) * resolution; - const destH = ( tMaxY - tMinY ) / ( rMaxY - rMinY ) * resolution; - const bitmap = await createImageBitmap( new Blob( [ buffer ] ) ); - ctx.drawImage( bitmap, destX, destY, destW, destH ); - bitmap.close(); + return this._deferredSource.lock( ...args ); - } + } - } )() ); + release( ...args ) { - } ); + this._deferredSource.release( ...args ); - await Promise.all( promises ); + } + + get( ...args ) { - const tex = new CanvasTexture( canvas ); - tex.colorSpace = SRGBColorSpace; - tex.generateMipmaps = false; - tex.needsUpdate = true; - tex._regionArgs = [ minX, minY, maxX, maxY, level ]; - return tex; + return this._deferredSource.get( ...args ); } redraw() { - // Raster tiles have no style to re-apply; only delegate for vector tiles - if ( this._contentCache.tileType === 1 ) { + if ( this._deferredSource instanceof MVTImageSource ) { + + this._deferredSource.redraw(); + + } + + } + + dispose() { + + super.dispose(); + this._contentCache.dispose(); + if ( this._deferredSource ) { - super.redraw(); + this._deferredSource.dispose(); } From 55778b443d60a1f52ea89394f131dd3250db95ab Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 21:12:20 +0900 Subject: [PATCH 48/60] Adjust --- src/three/plugins/images/MVTOverlay.js | 5 ++--- .../plugins/images/sources/MVTImageSource.js | 15 ++++++++------- .../plugins/images/sources/PMTilesImageSource.js | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index f5581dccd..079097239 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -9,9 +9,9 @@ import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; * @param {Object} [options] * @param {string} [options.url] URL template with `{x}`, `{y}`, `{z}` placeholders. * @param {number} [options.levels=20] Number of zoom levels. - * @param {number} [options.tileDimension=256] Tile pixel size. * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. + * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` that returns per-feature render styles. */ export class MVTOverlay extends ImageOverlay { @@ -134,8 +134,7 @@ export class MVTOverlay extends ImageOverlay { * @param {string} [options.url] URL to the `.pmtiles` archive. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. * @param {number} [options.tileDimension=256] Tile pixel size used when generating tiling levels. - * @param {Object} [options.styles] Per-layer color overrides. - * @param {Function} [options.filter] Feature filter callback `(feature, layerName) => boolean`. + * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` that returns per-feature render styles. */ export class PMTilesOverlay extends MVTOverlay { diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 59c4a1364..fe9c3530a 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -26,13 +26,11 @@ export class MVTContentCache extends DataCache { const { url = null, levels = 20, - tileDimension = 256, projection = 'EPSG:3857', } = options; this.url = url; this.levels = levels; - this.tileDimension = tileDimension; this.projectionId = projection; this.tiling = new TilingScheme(); @@ -43,11 +41,14 @@ export class MVTContentCache extends DataCache { init() { - const { tiling, tileDimension, levels, url, projectionId } = this; + const { tiling, levels, url, projectionId } = this; tiling.flipY = ! /{\s*reverseY|-\s*y\s*}/g.test( url ); tiling.setProjection( new ProjectionScheme( projectionId ) ); tiling.setContentBounds( ...tiling.projection.getBounds() ); + // 512 is used as the reference tile size for zoom level selection, matching the approach + // used by Maplibre GL JS (see coveringZoomLevel in maplibre-gl-js/src/geo/projection/covering_tiles.ts). + const TILE_SIZE = 512; if ( Array.isArray( levels ) ) { levels.forEach( ( info, level ) => { @@ -55,8 +56,8 @@ export class MVTContentCache extends DataCache { if ( info !== null ) { tiling.setLevel( level, { - tilePixelWidth: tileDimension, - tilePixelHeight: tileDimension, + tilePixelWidth: TILE_SIZE, + tilePixelHeight: TILE_SIZE, ...info, } ); @@ -67,8 +68,8 @@ export class MVTContentCache extends DataCache { } else { tiling.generateLevels( levels, tiling.projection.tileCountX, tiling.projection.tileCountY, { - tilePixelWidth: tileDimension, - tilePixelHeight: tileDimension, + tilePixelWidth: TILE_SIZE, + tilePixelHeight: TILE_SIZE, } ); } diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index a80d59497..97d3ddc1c 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -45,7 +45,7 @@ class PMTilesContentCache extends MVTContentCache { async init() { - const { tiling, tileDimension } = this; + const { tiling } = this; const PMTiles = await importPMTiles(); @@ -101,8 +101,8 @@ class PMTilesContentCache extends MVTContentCache { DEG2RAD * header.maxLat, ); tiling.generateLevels( header.maxZoom + 1, projection.tileCountX, projection.tileCountY, { - tilePixelWidth: tileDimension, - tilePixelHeight: tileDimension, + tilePixelWidth: 512, + tilePixelHeight: 512, minLevel: header.minZoom, } ); From ac4a5b0232435859894f57bad3302ee4d6115ab8 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 09:56:44 +0900 Subject: [PATCH 49/60] Variable updates --- .../images/sources/GeoJSONImageSource.js | 22 ++++++++-------- .../plugins/images/sources/MVTImageSource.js | 25 ++++++++++--------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 7a3944b70..422361205 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -62,7 +62,7 @@ export class GeoJSONImageSource extends RegionImageSource { this.projection = new ProjectionScheme(); this.fetchData = ( ...args ) => fetch( ...args ); - this._renderer = new VectorShapeCanvasRenderer( { + this._canvasRenderer = new VectorShapeCanvasRenderer( { flipY: true, getX: p => p[ 0 ], getY: p => p[ 1 ], @@ -179,7 +179,7 @@ export class GeoJSONImageSource extends RegionImageSource { this._updateCache(); const [ minX, minY, maxX, maxY ] = tokens; - const { projection, resolution, features, _renderer } = this; + const { projection, resolution, features, _canvasRenderer } = this; canvas.width = resolution; canvas.height = resolution; @@ -197,7 +197,7 @@ export class GeoJSONImageSource extends RegionImageSource { ]; const ctx = canvas.getContext( '2d' ); - _renderer.setFrame( ctx, regionBoundsDeg, regionBoundsDeg ); + _canvasRenderer.setFrame( ctx, regionBoundsDeg, regionBoundsDeg ); for ( const feature of features ) { @@ -324,17 +324,17 @@ export class GeoJSONImageSource extends RegionImageSource { } const [ , minLatDeg, , maxLatDeg ] = tileBoundsDeg; - const { _renderer } = this; + const { _canvasRenderer } = this; const style = this.getStyle( feature, properties ); - _renderer.setStyle( style ); + _canvasRenderer.setStyle( style ); const type = geometry.type; if ( type === 'Point' || type === 'MultiPoint' ) { // Radius in geographic units (degrees) so the canvas transform handles positioning. - _renderer.radius = style.radius * ( maxLatDeg - minLatDeg ) / height; + _canvasRenderer.radius = style.radius * ( maxLatDeg - minLatDeg ) / height; const points = type === 'Point' ? [ geometry.coordinates ] : geometry.coordinates; for ( const point of points ) { @@ -345,25 +345,25 @@ export class GeoJSONImageSource extends RegionImageSource { point[ 0 ] * MathUtils.DEG2RAD, ); const pointGroup = [ point ]; - _renderer._renderPoints( [ pointGroup ], arcRatio ); + _canvasRenderer._renderPoints( [ pointGroup ], arcRatio ); } } else if ( type === 'LineString' ) { - _renderer._renderLines( [ geometry.coordinates ] ); + _canvasRenderer._renderLines( [ geometry.coordinates ] ); } else if ( type === 'MultiLineString' ) { - _renderer._renderLines( geometry.coordinates ); + _canvasRenderer._renderLines( geometry.coordinates ); } else if ( type === 'Polygon' ) { - _renderer._renderPolygons( geometry.coordinates ); + _canvasRenderer._renderPolygons( geometry.coordinates ); } else if ( type === 'MultiPolygon' ) { - geometry.coordinates.forEach( polygon => _renderer._renderPolygons( polygon ) ); + geometry.coordinates.forEach( polygon => _canvasRenderer._renderPolygons( polygon ) ); } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index fe9c3530a..63e788ce2 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -147,7 +147,7 @@ export class MVTImageSource extends RegionImageSource { this.resolution = resolution; this.getStyle = getStyle; - this._renderer = new VectorShapeCanvasRenderer( { tileExtent: 4096 } ); + this._canvasRenderer = new VectorShapeCanvasRenderer( { tileExtent: 4096 } ); this._contentCache = contentCache ?? new MVTContentCache( rest ); } @@ -168,13 +168,14 @@ export class MVTImageSource extends RegionImageSource { async fetchItem( [ minX, minY, maxX, maxY, level ], _signal ) { + const { resolution } = this; const canvas = document.createElement( 'canvas' ); - canvas.width = this.resolution; - canvas.height = this.resolution; + canvas.width = resolution; + canvas.height = resolution; const ctx = canvas.getContext( '2d' ); const regionBounds = [ minX, minY, maxX, maxY ]; - const { _contentCache, _renderer } = this; + const { _contentCache, _canvasRenderer } = this; const promises = []; forEachTileInBounds( regionBounds, level, _contentCache.tiling, ( tx, ty, tl ) => { @@ -185,7 +186,7 @@ export class MVTImageSource extends RegionImageSource { if ( vectorTile ) { const tileBounds = _contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - _renderer.setFrame( ctx, tileBounds, regionBounds ); + _canvasRenderer.setFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } @@ -220,7 +221,7 @@ export class MVTImageSource extends RegionImageSource { _renderVectorTile( vectorTile ) { - const { _renderer, getStyle } = this; + const { _canvasRenderer, getStyle } = this; // Sort layers by user-defined order, falling back to alphabetical. const layerNames = [ ...Object.keys( vectorTile.layers ) ].sort( ( a, b ) => { @@ -249,22 +250,22 @@ export class MVTImageSource extends RegionImageSource { // Apply per-feature style; skip invisible features. const style = getStyle( layerName, properties ); - _renderer.setStyle( style ); - if ( ! _renderer.visible ) continue; + _canvasRenderer.setStyle( style ); + if ( ! _canvasRenderer.visible ) continue; // Dispatch to the appropriate draw primitive (1=point, 2=line, 3=polygon). const geometry = feature.loadGeometry(); if ( type === 1 ) { - _renderer._renderPoints( geometry ); + _canvasRenderer._renderPoints( geometry ); } else if ( type === 2 ) { - _renderer._renderLines( geometry ); + _canvasRenderer._renderLines( geometry ); } else if ( type === 3 ) { - _renderer._renderPolygons( geometry ); + _canvasRenderer._renderPolygons( geometry ); } @@ -290,7 +291,7 @@ export class MVTImageSource extends RegionImageSource { if ( ! vectorTile ) return; const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._renderer.setFrame( ctx, tileBounds, regionBounds ); + this._canvasRenderer.setFrame( ctx, tileBounds, regionBounds ); this._renderVectorTile( vectorTile ); } ); From ec73aed6383d3bc4c9e6c5434f1141b4211af43c Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:04:39 +0900 Subject: [PATCH 50/60] Cleanup --- src/three/plugins/images/MVTOverlay.js | 7 ++----- src/three/plugins/images/sources/MVTImageSource.js | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 079097239..40a8c9647 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -42,11 +42,8 @@ export class MVTOverlay extends ImageOverlay { _init() { - return this.imageSource.init().then( () => { - - this.imageSource.fetchData = ( ...args ) => this.fetch( ...args ); - - } ); + this.imageSource.fetchData = ( ...args ) => this.fetch( ...args ); + return this.imageSource.init(); } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 63e788ce2..c52717514 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -138,7 +138,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - getStyle = null, + getStyle = () => null, contentCache, ...rest } = options; From 9a2b1cb5e4b7c4a20f3bdffd2b668c18e3c518c2 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:07:08 +0900 Subject: [PATCH 51/60] Update docs --- src/three/plugins/images/MVTOverlay.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 40a8c9647..4a22ff469 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -123,15 +123,13 @@ export class MVTOverlay extends ImageOverlay { } /** - * Overlay that renders PMTiles (MVT) vector data on top of 3D tile geometry. - * Pass a PMTiles archive URL; the source projection and zoom levels are read - * from the archive header automatically. + * Overlay that renders PMTiles vector or raster data on top of 3D tile geometry. + * Projection and zoom levels are read automatically from the PMTiles archive header. * @extends MVTOverlay * @param {Object} [options] * @param {string} [options.url] URL to the `.pmtiles` archive. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {number} [options.tileDimension=256] Tile pixel size used when generating tiling levels. - * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` that returns per-feature render styles. + * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` for per-feature render styles. Only applies to vector archives. */ export class PMTilesOverlay extends MVTOverlay { From ee289b049768344c5b3911d4ebb1dc1c6b3b84a1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:27:15 +0900 Subject: [PATCH 52/60] Always split when zooming in --- src/three/plugins/images/MVTOverlay.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 4a22ff469..312d93097 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -110,7 +110,7 @@ export class MVTOverlay extends ImageOverlay { shouldSplit( range ) { - return this.tiling.maxLevel > this.calculateLevel( range ); + return true; } From 4687a3e38bb6562c0923cab811aebb8a644d3178 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:32:42 +0900 Subject: [PATCH 53/60] Update PMTiles --- src/three/plugins/images/MVTOverlay.js | 8 ++++++++ src/three/plugins/images/sources/PMTilesImageSource.js | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 312d93097..7e9f961d9 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -139,4 +139,12 @@ export class PMTilesOverlay extends MVTOverlay { } + shouldSplit( range ) { + + // Vector archives can always split further for higher-resolution rasterization. + // Raster archives are capped at their max data zoom level. + return this.imageSource.isVectorTile ? true : super.shouldSplit( range ); + + } + } diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 97d3ddc1c..d4dc9fc8e 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -163,6 +163,7 @@ export class PMTilesImageSource extends RegionImageSource { this._getStyle = getStyle; this._contentCache = new PMTilesContentCache( options ); this._deferredSource = null; + this.isVectorTile = false; } @@ -171,7 +172,9 @@ export class PMTilesImageSource extends RegionImageSource { await this._contentCache.init(); const { _contentCache } = this; - if ( _contentCache.tileType === 1 ) { + this.isVectorTile = _contentCache.tileType === 1; + + if ( this.isVectorTile ) { this._deferredSource = new MVTImageSource( { resolution: this._resolution, From aa99c1fe0e8e04138d10c610b3b7840a8898a5b1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:40:26 +0900 Subject: [PATCH 54/60] Code style updates --- src/three/plugins/images/sources/PMTilesImageSource.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index d4dc9fc8e..0531e8f14 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -158,7 +158,11 @@ export class PMTilesImageSource extends RegionImageSource { super(); - const { resolution = 512, getStyle = null } = options; + const { + resolution = 512, + getStyle = () => null, + } = options; + this._resolution = resolution; this._getStyle = getStyle; this._contentCache = new PMTilesContentCache( options ); From 2613f79389f99dcef61c16721326d5e3d39720be Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 10:57:21 +0900 Subject: [PATCH 55/60] Update JSDoc --- src/three/plugins/images/ImageOverlayPlugin.js | 2 ++ src/three/plugins/images/MVTOverlay.js | 8 +++++--- .../images/sources/GeoJSONImageSource.js | 2 +- .../plugins/images/sources/MVTImageSource.js | 2 +- .../images}/utils/VectorShapeCanvasRenderer.js | 17 +++++++++++++++++ 5 files changed, 26 insertions(+), 5 deletions(-) rename src/three/{renderer => plugins/images}/utils/VectorShapeCanvasRenderer.js (87%) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 2363b9c32..e6a0479c6 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1,5 +1,6 @@ /** @import { WebGLRenderer } from 'three' */ /** @import { WMTSTileMatrix } from './WMTSImageSource.js' */ +/** @import { GetStyleCallback } from './utils/VectorShapeCanvasRenderer.js' */ import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three'; import { PriorityQueue, PriorityQueueItemRemovedError } from '3d-tiles-renderer/core'; import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins'; @@ -1583,6 +1584,7 @@ export class DeepZoomOverlay extends TiledImageOverlay { * @param {string} [options.url=null] URL to a GeoJSON file to fetch on initialization (used when * `geojson` is not supplied directly). * @param {number} [options.resolution=256] Canvas resolution (pixels) used when compositing tiles. + * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. When provided, overrides `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius`. * @param {number} [options.pointRadius=6] Radius in pixels used to render Point features. * @param {string} [options.strokeStyle='white'] Canvas stroke style for feature outlines. * @param {number} [options.strokeWidth=2] Stroke line width in pixels. diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 7e9f961d9..9b1a4c12f 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -1,3 +1,4 @@ +/** @import { GetStyleCallback } from './utils/VectorShapeCanvasRenderer.js' */ import { ImageOverlay } from './ImageOverlayPlugin.js'; import { MVTImageSource } from './sources/MVTImageSource.js'; import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; @@ -11,7 +12,7 @@ import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; * @param {number} [options.levels=20] Number of zoom levels. * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` that returns per-feature render styles. + * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. */ export class MVTOverlay extends ImageOverlay { @@ -129,7 +130,7 @@ export class MVTOverlay extends ImageOverlay { * @param {Object} [options] * @param {string} [options.url] URL to the `.pmtiles` archive. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {Function} [options.getStyle] Callback `(layerName, properties) => style` for per-feature render styles. Only applies to vector archives. + * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. Only applies to vector archives. */ export class PMTilesOverlay extends MVTOverlay { @@ -143,7 +144,8 @@ export class PMTilesOverlay extends MVTOverlay { // Vector archives can always split further for higher-resolution rasterization. // Raster archives are capped at their max data zoom level. - return this.imageSource.isVectorTile ? true : super.shouldSplit( range ); + if ( this.imageSource.isVectorTile ) return true; + return this.tiling.maxLevel > this.calculateLevel( range ); } diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index 422361205..020b99e90 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, MathUtils, Vector3, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; -import { VectorShapeCanvasRenderer } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; +import { VectorShapeCanvasRenderer } from '../utils/VectorShapeCanvasRenderer.js'; import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; // TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters. diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index c52717514..77d31c28a 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -1,7 +1,7 @@ import { CanvasTexture, SRGBColorSpace } from 'three'; import { RegionImageSource } from './RegionImageSource.js'; import { DataCache } from '../utils/DataCache.js'; -import { VectorShapeCanvasRenderer } from '../../../renderer/utils/VectorShapeCanvasRenderer.js'; +import { VectorShapeCanvasRenderer } from '../utils/VectorShapeCanvasRenderer.js'; import { TilingScheme } from '../utils/TilingScheme.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { forEachTileInBounds } from '../overlays/utils.js'; diff --git a/src/three/renderer/utils/VectorShapeCanvasRenderer.js b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js similarity index 87% rename from src/three/renderer/utils/VectorShapeCanvasRenderer.js rename to src/three/plugins/images/utils/VectorShapeCanvasRenderer.js index 4130c4664..7f5342391 100644 --- a/src/three/renderer/utils/VectorShapeCanvasRenderer.js +++ b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js @@ -1,3 +1,20 @@ +/** + * @typedef {Object} VectorTileStyle + * @property {string} [fill] CSS fill color. + * @property {string} [stroke] CSS stroke color. + * @property {number} [strokeWidth=1] Stroke width in pixels. + * @property {number} [radius=2] Point radius in pixels. + * @property {number} [order=0] Layer draw order; lower values are drawn first. + * @property {boolean} [visible=true] Whether the feature is rendered. + */ + +/** + * @callback GetStyleCallback + * @param {string|Object} context Layer name (MVT) or feature object (GeoJSON) for the feature being rendered. + * @param {Object|null} properties Feature properties, or `null` when queried for layer-level sort order only. + * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults. + */ + const DEFAULT_STYLE = Object.freeze( { fill: '#cccccc', stroke: 'transparent', From b2f020369a82e2b6bccfe5659605fe21a4020f2b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 11:04:41 +0900 Subject: [PATCH 56/60] Docs update --- src/three/plugins/API.md | 103 ++++++++++++++++++ .../plugins/images/ImageOverlayPlugin.js | 11 +- src/three/plugins/images/MVTOverlay.js | 13 ++- .../images/utils/VectorShapeCanvasRenderer.js | 6 - 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index fd2779294..daa45f154 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -169,6 +169,10 @@ constructor( geojson = null: Object, url = null: string, resolution = 256: number, + getStyle?: ( + feature: Object, + properties: Object + ) => VectorTileStyle | null, pointRadius = 6: number, strokeStyle = 'white': string, strokeWidth = 2: number, @@ -183,6 +187,54 @@ constructor( ) ``` +## MVTOverlay + +_extends [`ImageOverlay`](#imageoverlay)_ + +Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry. +See the [Mapbox Vector Tile specification](https://github.com/mapbox/vector-tile-spec). + + +### .constructor + +```js +constructor( + { + url?: string, + levels = 20: number, + projection = 'EPSG:3857': string, + resolution = 512: number, + getStyle?: ( + layerName: string, + properties: Object | null + ) => VectorTileStyle | null, + } +) +``` + +## PMTilesOverlay + +_extends [`MVTOverlay`](#mvtoverlay)_ + +Overlay that renders PMTiles vector or raster data on top of 3D tile geometry. +Projection and zoom levels are read automatically from the PMTiles archive header. + + +### .constructor + +```js +constructor( + { + url?: string, + resolution = 512: number, + getStyle?: ( + layerName: string, + properties: Object | null + ) => VectorTileStyle | null, + } +) +``` + ## TiledImageOverlay _extends [`ImageOverlay`](#imageoverlay)_ @@ -1208,6 +1260,57 @@ nullFeatureId: number | null texture?: Object ``` +## VectorTileStyle + + +### .fill + +```js +fill?: string +``` + +CSS fill color. + +### .stroke + +```js +stroke?: string +``` + +CSS stroke color. + +### .strokeWidth + +```js +strokeWidth?: number +``` + +Stroke width in pixels. + +### .radius + +```js +radius?: number +``` + +Point radius in pixels. + +### .order + +```js +order?: number +``` + +Layer draw order; lower values are drawn first. + +### .visible + +```js +visible?: boolean +``` + +Whether the feature is rendered. + ## WMTSTileMatrix diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index e6a0479c6..caae9a92a 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1,6 +1,6 @@ /** @import { WebGLRenderer } from 'three' */ /** @import { WMTSTileMatrix } from './WMTSImageSource.js' */ -/** @import { GetStyleCallback } from './utils/VectorShapeCanvasRenderer.js' */ +/** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */ import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three'; import { PriorityQueue, PriorityQueueItemRemovedError } from '3d-tiles-renderer/core'; import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins'; @@ -1572,6 +1572,13 @@ export class DeepZoomOverlay extends TiledImageOverlay { } +/** + * @callback GeoJSONGetStyleCallback + * @param {Object} feature The GeoJSON feature object being rendered. + * @param {Object} properties The feature's properties object. + * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults. + */ + /** * Overlay that rasterizes a GeoJSON dataset onto 3D tile geometry. Features are drawn using the * Canvas 2D API at the tile's native resolution. Per-feature style overrides can be provided via @@ -1584,7 +1591,7 @@ export class DeepZoomOverlay extends TiledImageOverlay { * @param {string} [options.url=null] URL to a GeoJSON file to fetch on initialization (used when * `geojson` is not supplied directly). * @param {number} [options.resolution=256] Canvas resolution (pixels) used when compositing tiles. - * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. When provided, overrides `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius`. + * @param {GeoJSONGetStyleCallback} [options.getStyle] Per-feature style callback. When provided, overrides `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius`. * @param {number} [options.pointRadius=6] Radius in pixels used to render Point features. * @param {string} [options.strokeStyle='white'] Canvas stroke style for feature outlines. * @param {number} [options.strokeWidth=2] Stroke line width in pixels. diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 9b1a4c12f..4caf959e9 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -1,8 +1,15 @@ -/** @import { GetStyleCallback } from './utils/VectorShapeCanvasRenderer.js' */ +/** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */ import { ImageOverlay } from './ImageOverlayPlugin.js'; import { MVTImageSource } from './sources/MVTImageSource.js'; import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; +/** + * @callback MVTGetStyleCallback + * @param {string} layerName Name of the MVT layer the feature belongs to. + * @param {Object|null} properties Feature properties, or `null` when queried for layer-level sort order only. + * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults. + */ + /** * Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry. * See the {@link https://github.com/mapbox/vector-tile-spec Mapbox Vector Tile specification}. @@ -12,7 +19,7 @@ import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; * @param {number} [options.levels=20] Number of zoom levels. * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. + * @param {MVTGetStyleCallback} [options.getStyle] Per-feature style callback. */ export class MVTOverlay extends ImageOverlay { @@ -130,7 +137,7 @@ export class MVTOverlay extends ImageOverlay { * @param {Object} [options] * @param {string} [options.url] URL to the `.pmtiles` archive. * @param {number} [options.resolution=512] Canvas resolution for generated tile textures. - * @param {GetStyleCallback} [options.getStyle] Per-feature style callback. Only applies to vector archives. + * @param {MVTGetStyleCallback} [options.getStyle] Per-feature style callback. Only applies to vector archives. */ export class PMTilesOverlay extends MVTOverlay { diff --git a/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js index 7f5342391..cf33bb357 100644 --- a/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js +++ b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js @@ -8,12 +8,6 @@ * @property {boolean} [visible=true] Whether the feature is rendered. */ -/** - * @callback GetStyleCallback - * @param {string|Object} context Layer name (MVT) or feature object (GeoJSON) for the feature being rendered. - * @param {Object|null} properties Feature properties, or `null` when queried for layer-level sort order only. - * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults. - */ const DEFAULT_STYLE = Object.freeze( { fill: '#cccccc', From d0b4d945e2913daf6172d6486d22df2a9e22ea80 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 11:07:05 +0900 Subject: [PATCH 57/60] Updates to docs --- src/three/plugins/API.md | 12 ++++++------ .../images/utils/VectorShapeCanvasRenderer.js | 4 ++-- utils/docs/RenderDocsUtils.js | 5 +++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index daa45f154..684a6b15b 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -1266,7 +1266,7 @@ texture?: Object ### .fill ```js -fill?: string +fill = '#cccccc': string ``` CSS fill color. @@ -1274,7 +1274,7 @@ CSS fill color. ### .stroke ```js -stroke?: string +stroke = 'transparent': string ``` CSS stroke color. @@ -1282,7 +1282,7 @@ CSS stroke color. ### .strokeWidth ```js -strokeWidth?: number +strokeWidth = 1: number ``` Stroke width in pixels. @@ -1290,7 +1290,7 @@ Stroke width in pixels. ### .radius ```js -radius?: number +radius = 2: number ``` Point radius in pixels. @@ -1298,7 +1298,7 @@ Point radius in pixels. ### .order ```js -order?: number +order = 0: number ``` Layer draw order; lower values are drawn first. @@ -1306,7 +1306,7 @@ Layer draw order; lower values are drawn first. ### .visible ```js -visible?: boolean +visible = true: boolean ``` Whether the feature is rendered. diff --git a/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js index cf33bb357..927abf379 100644 --- a/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js +++ b/src/three/plugins/images/utils/VectorShapeCanvasRenderer.js @@ -1,7 +1,7 @@ /** * @typedef {Object} VectorTileStyle - * @property {string} [fill] CSS fill color. - * @property {string} [stroke] CSS stroke color. + * @property {string} [fill='#cccccc'] CSS fill color. + * @property {string} [stroke='transparent'] CSS stroke color. * @property {number} [strokeWidth=1] Stroke width in pixels. * @property {number} [radius=2] Point radius in pixels. * @property {number} [order=0] Layer draw order; lower values are drawn first. diff --git a/utils/docs/RenderDocsUtils.js b/utils/docs/RenderDocsUtils.js index 089820839..914a484a6 100644 --- a/utils/docs/RenderDocsUtils.js +++ b/utils/docs/RenderDocsUtils.js @@ -410,11 +410,12 @@ export function renderTypedef( typeDoc, callbackMap = {}, resolveLink = null, he for ( const prop of ( typeDoc.properties || [] ) ) { const type = formatType( prop.type, callbackMap ); - const optional = prop.optional ? '?' : ''; + const optional = prop.optional && prop.defaultvalue === undefined ? '?' : ''; + const defStr = prop.defaultvalue !== undefined ? ` = ${ prop.defaultvalue }` : ''; lines.push( `${ hSub } .${ prop.name }` ); lines.push( '' ); lines.push( '```js' ); - lines.push( `${ prop.name }${ optional }: ${ type }` ); + lines.push( `${ prop.name }${ optional }${ defStr }: ${ type }` ); lines.push( '```' ); lines.push( '' ); From e445cf12feec516941b54c6899e15fc9e344ed15 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 11:36:19 +0900 Subject: [PATCH 58/60] Tab fix --- src/three/plugins/images/sources/MVTImageSource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 77d31c28a..8b963bc1a 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -195,7 +195,7 @@ export class MVTImageSource extends RegionImageSource { } ); - await Promise.all( promises ); + await Promise.all( promises ); const tex = new CanvasTexture( canvas ); tex.colorSpace = SRGBColorSpace; From 1087406bb60e44eb60a8b1dedb233e095c9230df Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 12:04:16 +0900 Subject: [PATCH 59/60] update docs --- src/three/plugins/API.md | 13 +++++++++++++ src/three/plugins/images/MVTOverlay.js | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 684a6b15b..2c9cb4bba 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -194,6 +194,12 @@ _extends [`ImageOverlay`](#imageoverlay)_ Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry. See the [Mapbox Vector Tile specification](https://github.com/mapbox/vector-tile-spec). +Requires the optional peer dependencies `@mapbox/vector-tile` and `pbf`, which are +imported dynamically on first use and must be installed separately: +``` +npm install @mapbox/vector-tile pbf +``` + ### .constructor @@ -219,6 +225,13 @@ _extends [`MVTOverlay`](#mvtoverlay)_ Overlay that renders PMTiles vector or raster data on top of 3D tile geometry. Projection and zoom levels are read automatically from the PMTiles archive header. +Requires the optional peer dependency `pmtiles`, which is imported dynamically on first use +and must be installed separately. Vector archives additionally require `@mapbox/vector-tile` +and `pbf`: +``` +npm install pmtiles @mapbox/vector-tile pbf +``` + ### .constructor diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 4caf959e9..606e57122 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -13,6 +13,12 @@ import { PMTilesImageSource } from './sources/PMTilesImageSource.js'; /** * Overlay that renders XYZ-template MVT vector tiles on top of 3D tile geometry. * See the {@link https://github.com/mapbox/vector-tile-spec Mapbox Vector Tile specification}. + * + * Requires the optional peer dependencies `@mapbox/vector-tile` and `pbf`, which are + * imported dynamically on first use and must be installed separately: + * ``` + * npm install @mapbox/vector-tile pbf + * ``` * @extends ImageOverlay * @param {Object} [options] * @param {string} [options.url] URL template with `{x}`, `{y}`, `{z}` placeholders. @@ -133,6 +139,13 @@ export class MVTOverlay extends ImageOverlay { /** * Overlay that renders PMTiles vector or raster data on top of 3D tile geometry. * Projection and zoom levels are read automatically from the PMTiles archive header. + * + * Requires the optional peer dependency `pmtiles`, which is imported dynamically on first use + * and must be installed separately. Vector archives additionally require `@mapbox/vector-tile` + * and `pbf`: + * ``` + * npm install pmtiles @mapbox/vector-tile pbf + * ``` * @extends MVTOverlay * @param {Object} [options] * @param {string} [options.url] URL to the `.pmtiles` archive. From 978d52e4b5fad3e6e7ec5d27434a24a83b13070a Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 17 May 2026 12:20:09 +0900 Subject: [PATCH 60/60] Updates --- src/three/plugins/images/MVTOverlay.js | 11 +++++++++-- .../plugins/images/sources/MVTImageSource.js | 17 +++++++++++------ .../images/sources/PMTilesImageSource.js | 11 +++++++++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/three/plugins/images/MVTOverlay.js b/src/three/plugins/images/MVTOverlay.js index 606e57122..a377fdaae 100644 --- a/src/three/plugins/images/MVTOverlay.js +++ b/src/three/plugins/images/MVTOverlay.js @@ -164,8 +164,15 @@ export class PMTilesOverlay extends MVTOverlay { // Vector archives can always split further for higher-resolution rasterization. // Raster archives are capped at their max data zoom level. - if ( this.imageSource.isVectorTile ) return true; - return this.tiling.maxLevel > this.calculateLevel( range ); + if ( this.imageSource.isVectorTile ) { + + return true; + + } else { + + return this.tiling.maxLevel > this.calculateLevel( range ); + + } } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 8b963bc1a..d1624676a 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -12,7 +12,11 @@ function importMVTDeps() { return _mvtImport ??= Promise.all( [ import( '@mapbox/vector-tile' ), import( 'pbf' ), - ] ).then( ( [ { VectorTile }, { default: Protobuf } ] ) => ( { VectorTile, Protobuf } ) ); + ] ).then( ( [ { VectorTile }, { default: Protobuf } ] ) => { + + return { VectorTile, Protobuf }; + + } ); } @@ -251,7 +255,6 @@ export class MVTImageSource extends RegionImageSource { // Apply per-feature style; skip invisible features. const style = getStyle( layerName, properties ); _canvasRenderer.setStyle( style ); - if ( ! _canvasRenderer.visible ) continue; // Dispatch to the appropriate draw primitive (1=point, 2=line, 3=polygon). const geometry = feature.loadGeometry(); @@ -288,11 +291,13 @@ export class MVTImageSource extends RegionImageSource { forEachTileInBounds( regionBounds, level, this._contentCache.tiling, ( tx, ty, tl ) => { const vectorTile = this._contentCache.get( tx, ty, tl ); - if ( ! vectorTile ) return; + if ( vectorTile ) { - const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); - this._canvasRenderer.setFrame( ctx, tileBounds, regionBounds ); - this._renderVectorTile( vectorTile ); + const tileBounds = this._contentCache.tiling.getTileBounds( tx, ty, tl, true, false ); + this._canvasRenderer.setFrame( ctx, tileBounds, regionBounds ); + this._renderVectorTile( vectorTile ); + + } } ); diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 0531e8f14..06d5ee808 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -26,8 +26,15 @@ class PMTilesRasterTileSource extends TiledImageSource { async fetchItem( [ tx, ty, tl ], signal ) { const res = await this.instance.getZxy( tl, tx, ty, signal ); - if ( ! res || ! res.data || res.data.byteLength === 0 ) return null; - return this.processBufferToTexture( res.data ); + if ( ! res || ! res.data || res.data.byteLength === 0 ) { + + return null; + + } else { + + return this.processBufferToTexture( res.data ); + + } }