diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 09e1e27ae3f..14f861ab95f 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -19,6 +19,11 @@ import { import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import type {MeshAttributes} from '@loaders.gl/schema'; import {TerrainWorkerLoader} from '@loaders.gl/terrain'; +import { + MAX_LATITUDE as MAX_WEB_MERCATOR_LATITUDE, + lngLatToWorld, + worldToLngLat +} from '@math.gl/web-mercator'; import TileLayer, {TileLayerProps} from '../tile-layer/tile-layer'; import type { Bounds, @@ -109,6 +114,7 @@ type TerrainLoadProps = { elevationData: string | null; elevationDecoder: ElevationDecoder; meshMaxError: number; + shouldRemapTerrainMeshToWebMercatorTile?: boolean; signal?: AbortSignal; }; @@ -204,6 +210,7 @@ export default class TerrainLayer extends Composite bounds, elevationDecoder, meshMaxError, + shouldRemapTerrainMeshToWebMercatorTile, signal }: TerrainLoadProps): Promise | null { if (!elevationData) { @@ -222,7 +229,16 @@ export default class TerrainLayer extends Composite } }; const {fetch} = this.props; - return fetch(elevationData, {propName: 'elevationData', layer: this, loadOptions, signal}); + const terrain = fetch(elevationData, { + propName: 'elevationData', + layer: this, + loadOptions, + signal + }); + + return shouldRemapTerrainMeshToWebMercatorTile + ? terrain.then(mesh => (mesh ? remapTerrainMeshToWebMercatorTile(mesh, bounds) : mesh)) + : terrain; } getTiledTerrainData(tile: TileLoadProps): Promise { @@ -244,19 +260,20 @@ export default class TerrainLayer extends Composite topRight = [bbox.right, bbox.top]; } const bounds: Bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; - const overlappedBounds = getOverlappedBounds( - bounds, - this.props.tileSize, - viewport instanceof GlobeViewport - ); - - const terrain = this.loadTerrain({ - elevationData: dataUrl, - bounds: overlappedBounds, - elevationDecoder, - meshMaxError, - signal - }); + const isGlobe = viewport instanceof GlobeViewport; + const overlappedBounds = getOverlappedBounds(bounds, this.props.tileSize, isGlobe); + + const terrain = + this.loadTerrain({ + elevationData: dataUrl, + bounds: overlappedBounds, + elevationDecoder, + meshMaxError, + // The terrain surface keeps its original texture and UVs; only mesh row positions are + // remapped from WebMercator tile spacing to lng/lat for GlobeView. + shouldRemapTerrainMeshToWebMercatorTile: isGlobe, + signal + }) ?? Promise.resolve(null); const surface = textureUrl ? // If surface image fails to load, the tile should still be displayed fetch(textureUrl, {propName: 'texture', layer: this, loaders: [], signal}).catch(_ => null) @@ -321,10 +338,10 @@ export default class TerrainLayer extends Composite const ranges = tiles .map(tile => tile.content) .filter(Boolean) - .map(arr => { - // @ts-ignore - const bounds = arr[0].header.boundingBox; - return bounds.map(bound => bound[2]); + .flatMap(arr => { + // @ts-ignore - terrain loader returns {attributes, header} shape; header is not in MeshAttributes type + const bounds = (arr[0] as MeshWithBoundingBox | null)?.header?.boundingBox; + return bounds ? [bounds.map(bound => bound[2])] : []; }); if (ranges.length === 0) { return; @@ -421,3 +438,46 @@ export default class TerrainLayer extends Composite const isTileSetURL = (url: string): boolean => url.includes('{x}') && (url.includes('{y}') || url.includes('{-y}')); + +function remapTerrainMeshToWebMercatorTile(mesh: MeshAttributes, bounds: Bounds): MeshAttributes { + // The terrain loader returns {attributes: MeshAttributes, header?: ...} at runtime. + // MeshAttributes is typed as an index type so we use a cast to access the nested fields. + const attrs = (mesh as any).attributes as MeshAttributes | undefined; + const positionAttribute = attrs?.POSITION; + const texCoordAttribute = attrs?.TEXCOORD_0; + const positions = positionAttribute?.value; + const texCoords = texCoordAttribute?.value; + if (!positions || !texCoords) { + return mesh; + } + + const [, south, , north] = bounds; + const northY = lngLatToMercatorWorldY(north); + const southY = lngLatToMercatorWorldY(south); + const remappedPositions = new Float32Array(positions); + + for (let i = 0; i < texCoords.length / 2; i++) { + const v = texCoords[i * 2 + 1]; + const mercatorY = northY + (southY - northY) * v; + remappedPositions[i * 3 + 1] = worldToLngLat([0, mercatorY])[1]; + } + + return { + ...(mesh as any), + attributes: { + ...attrs, + POSITION: { + ...positionAttribute, + value: remappedPositions + } + } + } as MeshAttributes; +} + +function lngLatToMercatorWorldY(latitude: number): number { + const clampedLatitude = Math.max( + -MAX_WEB_MERCATOR_LATITUDE, + Math.min(MAX_WEB_MERCATOR_LATITUDE, latitude) + ); + return lngLatToWorld([0, clampedLatitude])[1]; +} diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..52f888f2d7e 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -12,9 +12,11 @@ import { GetPickingInfoParams, DefaultProps, FilterContext, - _flatten as flatten + COORDINATE_SYSTEM, + _flatten as flatten, + _GlobeViewport } from '@deck.gl/core'; -import {GeoJsonLayer} from '@deck.gl/layers'; +import {BitmapLayer, GeoJsonLayer} from '@deck.gl/layers'; import {LayersList} from '@deck.gl/core'; import type {TileLoadProps, ZRange} from '../tileset-2d/index'; @@ -421,12 +423,14 @@ export default class TileLayer extends _offset: 0, tile }); - tile.layers = (flatten(layers, Boolean) as Layer<{tile?: Tile2DHeader}>[]).map(layer => - layer.clone({ + tile.layers = (flatten(layers, Boolean) as Layer<{tile?: Tile2DHeader}>[]).map(layer => { + const globeBitmapProps = this._getGlobeBitmapLayerProps(layer); + return layer.clone({ tile, + ...globeBitmapProps, ...subLayerProps - }) - ); + }); + }); } else if ( subLayerProps && tile.layers[0] && @@ -440,6 +444,25 @@ export default class TileLayer extends }); } + private _getGlobeBitmapLayerProps(layer: Layer): Record | null { + // BitmapLayer and subclasses draw tile imagery over lng/lat bounds. XYZ imagery is encoded + // in WebMercator, so default GlobeView bitmap sublayers need UV reprojection; other layer + // types do not share this image-coordinate contract and are left unchanged. + if ( + !(this.context.viewport instanceof _GlobeViewport) || + !(layer instanceof BitmapLayer) || + (layer.props as Record)._imageCoordinateSystem !== 'default' + ) { + return null; + } + + return { + // XYZ/slippy tile imagery is Web Mercator encoded. In GlobeView, BitmapLayer + // positions the mesh in lng/lat, so the image needs Mercator-to-lnglat UV conversion. + _imageCoordinateSystem: COORDINATE_SYSTEM.CARTESIAN + }; + } + filterSubLayer({layer, cullRect}: FilterContext) { const {tile} = (layer as Layer<{tile: Tile2DHeader}>).props; const {modelMatrix} = this.props; diff --git a/test/modules/geo-layers/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index 8777e0ad562..be614ccfb64 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -5,8 +5,14 @@ import {test, expect} from 'vitest'; import {generateLayerTests, testLayerAsync} from '@deck.gl/test-utils/vitest'; import {TerrainLayer, TileLayer} from '@deck.gl/geo-layers'; +import {_GlobeView as GlobeView} from '@deck.gl/core'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {TerrainLoader} from '@loaders.gl/terrain'; +import { + MAX_LATITUDE as MAX_WEB_MERCATOR_LATITUDE, + lngLatToWorld, + worldToLngLat +} from '@math.gl/web-mercator'; test('TerrainLayer', async () => { const testCases = generateLayerTests({ @@ -47,3 +53,83 @@ test('TerrainLayer', async () => { onError: err => expect(err).toBeFalsy() }); }); + +test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions', async () => { + const sourcePositions = new Float32Array([0, 80, 0, 0.5, 40, 0, 1, 0, 0]); + const sourceTexCoords = new Float32Array([0, 0, 0.5, 0.5, 1, 1]); + const sourceTexture = {id: 'source-texture'}; + const tileSize = 512; + const bbox = {west: 0, south: 0, east: 1, north: 80}; + const yPad = ((bbox.north - bbox.south) / tileSize) * 1; + const overlappedSouth = bbox.south - yPad; + const overlappedNorth = bbox.north + yPad; + const expectedMiddleLatitude = worldToLngLat([ + 0, + (lngLatToMercatorWorldY(overlappedNorth) + lngLatToMercatorWorldY(overlappedSouth)) / 2 + ])[1]; + + const sourceMesh = { + attributes: { + POSITION: {value: sourcePositions, size: 3}, + TEXCOORD_0: {value: sourceTexCoords, size: 2} + } + }; + const layer = new TerrainLayer({ + id: 'terrain-globe-mercator', + elevationData: 'terrain/{z}/{x}/{y}.png', + texture: 'texture/{z}/{x}/{y}.png', + tileSize, + fetch: (_url, context) => + Promise.resolve(context.propName === 'texture' ? sourceTexture : sourceMesh) + }); + layer.context = { + viewport: new GlobeView().makeViewport({ + width: 512, + height: 512, + viewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }) + }; + layer.state = {isTiled: true}; + + const [mesh, texture] = await layer.getTiledTerrainData({ + index: {x: 0, y: 0, z: 1}, + id: '0-0-1', + bbox, + zoom: 1 + }); + const positions = mesh!.attributes.POSITION.value; + + expect(positions, 'remap copies the loader positions').not.toBe(sourcePositions); + expect(mesh!.attributes.TEXCOORD_0.value, 'remap preserves source texture coordinates').toBe( + sourceTexCoords + ); + expect(texture, 'terrain surface texture is passed through').toBe(sourceTexture); + expect(sourcePositions[1], 'source top row is unchanged').toBe(80); + expect(sourcePositions[4], 'source middle row is unchanged').toBe(40); + expect(sourcePositions[7], 'source bottom row is unchanged').toBe(0); + + expect(positions[1], 'top row latitude follows the overlapped tile north').toBeCloseTo( + overlappedNorth, + 5 + ); + expect(positions[4], 'middle row uses Mercator latitude instead of linear latitude').toBeCloseTo( + expectedMiddleLatitude, + 5 + ); + expect(positions[7], 'bottom row latitude follows the overlapped tile south').toBeCloseTo( + overlappedSouth, + 5 + ); +}); + +function lngLatToMercatorWorldY(latitude: number): number { + const clampedLatitude = Math.max( + -MAX_WEB_MERCATOR_LATITUDE, + Math.min(MAX_WEB_MERCATOR_LATITUDE, latitude) + ); + return lngLatToWorld([0, clampedLatitude])[1]; +} diff --git a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts index 379373ca3bf..396c87deacd 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts @@ -3,8 +3,8 @@ // Copyright (c) vis.gl contributors import {test, expect} from 'vitest'; -import {WebMercatorViewport} from '@deck.gl/core'; -import {ScatterplotLayer} from '@deck.gl/layers'; +import {COORDINATE_SYSTEM, WebMercatorViewport, _GlobeView as GlobeView} from '@deck.gl/core'; +import {BitmapLayer, ScatterplotLayer} from '@deck.gl/layers'; import {generateLayerTests, testLayerAsync, testLayer} from '@deck.gl/test-utils/vitest'; import {TileLayer} from '@deck.gl/geo-layers'; @@ -208,6 +208,174 @@ test('TileLayer#MapView:repeat', async () => { }); }); +test('TileLayer#GlobeView:BitmapLayer image coordinate system', async () => { + const testViewport = new GlobeView().makeViewport({ + width: 100, + height: 100, + viewState: { + longitude: 0, + latitude: 0, + zoom: 2 + } + }); + + const renderSubLayers = props => { + const {west, south, east, north} = props.tile.bbox; + return new BitmapLayer(props, { + id: `${props.id}-bitmap`, + image: '/test/data/icon-atlas.png', + bounds: [west, south, east, north] + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: testViewport, + testCases: [ + { + title: 'defaults BitmapLayer image coordinates to Web Mercator', + props: { + getTileData: () => ({}), + renderSubLayers + }, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers[0].props._imageCoordinateSystem).toBe(COORDINATE_SYSTEM.CARTESIAN); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + +test('TileLayer#GlobeView:custom BitmapLayer image coordinate system', async () => { + class CustomBitmapLayer extends BitmapLayer { + static layerName = 'CustomBitmapLayer'; + } + + const testViewport = new GlobeView().makeViewport({ + width: 100, + height: 100, + viewState: { + longitude: 0, + latitude: 0, + zoom: 2 + } + }); + + const renderSubLayers = props => { + const {west, south, east, north} = props.tile.bbox; + return new CustomBitmapLayer(props, { + id: `${props.id}-custom-bitmap`, + image: '/test/data/icon-atlas.png', + bounds: [west, south, east, north] + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: testViewport, + testCases: [ + { + title: 'defaults custom BitmapLayer image coordinates to Web Mercator', + props: { + getTileData: () => ({}), + renderSubLayers + }, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers[0].props._imageCoordinateSystem).toBe(COORDINATE_SYSTEM.CARTESIAN); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + +test('TileLayer#GlobeView:leaves non-Bitmap sublayers unchanged', async () => { + const testViewport = new GlobeView().makeViewport({ + width: 100, + height: 100, + viewState: { + longitude: 0, + latitude: 0, + zoom: 2 + } + }); + + const renderSubLayers = props => { + return new ScatterplotLayer(props, { + id: `${props.id}-points`, + getPosition: d => d.position + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: testViewport, + testCases: [ + { + title: 'does not add image coordinates to non-Bitmap sublayers', + props: { + getTileData: () => [{position: [0, 0]}], + renderSubLayers + }, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers[0] instanceof ScatterplotLayer).toBeTruthy(); + expect(subLayers[0].props._imageCoordinateSystem).toBeUndefined(); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + +test('TileLayer#GlobeView:preserves explicit BitmapLayer image coordinate system', async () => { + const testViewport = new GlobeView().makeViewport({ + width: 100, + height: 100, + viewState: { + longitude: 0, + latitude: 0, + zoom: 2 + } + }); + + const renderSubLayersWithExplicitImageCoordinateSystem = props => { + const {west, south, east, north} = props.tile.bbox; + return new BitmapLayer(props, { + id: `${props.id}-bitmap`, + image: '/test/data/icon-atlas.png', + bounds: [west, south, east, north], + _imageCoordinateSystem: COORDINATE_SYSTEM.LNGLAT + }); + }; + + await testLayerAsync({ + Layer: TileLayer, + viewport: testViewport, + testCases: [ + { + title: 'preserves explicit BitmapLayer image coordinate system', + props: { + getTileData: () => ({}), + renderSubLayers: renderSubLayersWithExplicitImageCoordinateSystem + }, + onAfterUpdate: ({layer, subLayers}) => { + if (layer.isLoaded) { + expect(subLayers[0].props._imageCoordinateSystem).toBe(COORDINATE_SYSTEM.LNGLAT); + } + } + } + ], + onError: err => expect(err).toBeFalsy() + }); +}); + test('TileLayer#error tiles do not block isLoaded', async () => { let tileErrorCalled = 0;