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