From 6bf36a5fa1980f34db0843c65d8f17a02e48a77a Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Tue, 12 May 2026 16:07:18 -0400 Subject: [PATCH 1/2] Fix terrain globe Mercator tile warping --- .../src/terrain-layer/terrain-layer.ts | 79 +++++++++++++++---- test/modules/geo-layers/terrain-layer.spec.ts | 42 ++++++++++ 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 74eefb62696..dfb78cc4411 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -16,7 +16,7 @@ import { } from '@deck.gl/core'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {COORDINATE_SYSTEM} from '@deck.gl/core'; -import type {MeshAttributes} from '@loaders.gl/schema'; +import type {Mesh} from '@loaders.gl/schema'; import {TerrainWorkerLoader} from '@loaders.gl/terrain'; import TileLayer, {TileLayerProps} from '../tile-layer/tile-layer'; import type { @@ -33,6 +33,8 @@ const TILE_OVERLAP_PIXELS = 1; const MIN_TERRAIN_MESH_MAX_ERROR = 1; const MAX_LATITUDE = 90; const MAX_LONGITUDE = 180; +const DEGREES_TO_RADIANS = Math.PI / 180; +const RADIANS_TO_DEGREES = 180 / Math.PI; const defaultProps: DefaultProps = { ...TileLayer.defaultProps, @@ -111,9 +113,9 @@ type TerrainLoadProps = { signal?: AbortSignal; }; -type MeshAndTexture = [MeshAttributes | null, TextureSource | null]; +type MeshAndTexture = [Mesh | null, TextureSource | null]; type MeshBoundingBox = [min: number[], max: number[]]; -type MeshWithBoundingBox = MeshAttributes & { +type MeshWithBoundingBox = Mesh & { header?: { boundingBox?: MeshBoundingBox; }; @@ -165,7 +167,7 @@ export default class TerrainLayer extends Composite state!: { isTiled?: boolean; - terrain?: MeshAttributes; + terrain?: Mesh; zRange?: ZRange | null; }; @@ -204,7 +206,7 @@ export default class TerrainLayer extends Composite elevationDecoder, meshMaxError, signal - }: TerrainLoadProps): Promise | null { + }: TerrainLoadProps): Promise | null { if (!elevationData) { return null; } @@ -249,13 +251,16 @@ export default class TerrainLayer extends Composite Boolean(viewport.resolution && viewport.resolution > 0) ); - const terrain = this.loadTerrain({ - elevationData: dataUrl, - bounds: overlappedBounds, - elevationDecoder, - meshMaxError, - signal - }); + const terrain = + this.loadTerrain({ + elevationData: dataUrl, + bounds: overlappedBounds, + elevationDecoder, + meshMaxError, + signal + })?.then(mesh => + viewport.resolution && mesh ? remapMeshToWebMercatorTile(mesh, overlappedBounds) : mesh + ) ?? 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) @@ -319,11 +324,9 @@ export default class TerrainLayer extends Composite const {zRange} = this.state; 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 => { + const bounds = arr?.[0]?.header?.boundingBox; + return bounds ? [bounds.map(bound => bound[2])] : []; }); if (ranges.length === 0) { return; @@ -417,3 +420,45 @@ export default class TerrainLayer extends Composite const isTileSetURL = (url: string): boolean => url.includes('{x}') && (url.includes('{y}') || url.includes('{-y}')); + +function remapMeshToWebMercatorTile(mesh: Mesh, bounds: Bounds): Mesh { + const positionAttribute = mesh.attributes.POSITION; + const texCoordAttribute = mesh.attributes.TEXCOORD_0; + const positions = positionAttribute?.value; + const texCoords = texCoordAttribute?.value; + if (!positions || !texCoords) { + return mesh; + } + + const [, south, , north] = bounds; + const northY = lngLatToMercatorY(north); + const southY = lngLatToMercatorY(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] = mercatorYToLat(mercatorY); + } + + return { + ...mesh, + attributes: { + ...mesh.attributes, + POSITION: { + ...positionAttribute, + value: remappedPositions + } + } + }; +} + +function lngLatToMercatorY(latitude: number): number { + const clampedLatitude = Math.max(-85.051129, Math.min(85.051129, latitude)); + const sin = Math.sin(clampedLatitude * DEGREES_TO_RADIANS); + return 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); +} + +function mercatorYToLat(y: number): number { + return Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) * RADIANS_TO_DEGREES; +} diff --git a/test/modules/geo-layers/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index 8777e0ad562..afc250413c7 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -5,6 +5,7 @@ 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'; @@ -47,3 +48,44 @@ test('TerrainLayer', async () => { onError: err => expect(err).toBeFalsy() }); }); + +test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions', async () => { + const sourceMesh = { + attributes: { + POSITION: {value: new Float32Array([0, 80, 0, 0.5, 40, 0, 1, 0, 0]), size: 3}, + TEXCOORD_0: {value: new Float32Array([0, 0, 0.5, 0.5, 1, 1]), size: 2} + } + }; + const layer = new TerrainLayer({ + id: 'terrain-globe-mercator', + elevationData: 'terrain/{z}/{x}/{y}.png', + fetch: () => Promise.resolve(sourceMesh) + }); + layer.context = { + viewport: new GlobeView().makeViewport({ + width: 512, + height: 512, + viewState: { + longitude: 0, + latitude: 0, + zoom: 1 + } + }) + }; + layer.state = {isTiled: true}; + + const [mesh] = await layer.getTiledTerrainData({ + index: {x: 0, y: 0, z: 1}, + id: '0-0-1', + bbox: {west: 0, south: 0, east: 1, north: 80}, + zoom: 1 + }); + const positions = mesh.attributes.POSITION.value; + + expect(positions[1], 'top row latitude is preserved').toBeGreaterThan(80); + expect( + positions[4], + 'middle row uses Mercator latitude instead of linear latitude' + ).toBeGreaterThan(40); + expect(positions[7], 'bottom row latitude is preserved').toBeLessThan(0); +}); From f67ba1138833abbbff06daebd6fe87d2846fbc74 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 14 May 2026 21:57:58 -0400 Subject: [PATCH 2/2] Fix bitmap tile warping in GlobeView --- .../geo-layers/src/tile-layer/tile-layer.ts | 29 ++++++- .../geo-layers/tile-layer/tile-layer.spec.ts | 87 ++++++++++++++++++- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..3136081bf5a 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -12,6 +12,7 @@ import { GetPickingInfoParams, DefaultProps, FilterContext, + COORDINATE_SYSTEM, _flatten as flatten } from '@deck.gl/core'; import {GeoJsonLayer} from '@deck.gl/layers'; @@ -55,6 +56,8 @@ const defaultProps: DefaultProps = { visibleMaxZoom: null }; +const BITMAP_LAYER_NAME = 'BitmapLayer'; + /** All props supported by the TileLayer */ export type TileLayerProps = CompositeLayerProps & _TileLayerProps; @@ -421,12 +424,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 +445,22 @@ export default class TileLayer extends }); } + private _getGlobeBitmapLayerProps(layer: Layer): Record | null { + if ( + !this.context.viewport.resolution || + (layer.constructor as typeof Layer).layerName !== BITMAP_LAYER_NAME || + (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/tile-layer/tile-layer.spec.ts b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts index 379373ca3bf..a303affadf6 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,89 @@ 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: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;