From e18ee8de7dc736dda90f0c32a054c22b92c36598 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Tue, 12 May 2026 16:07:18 -0400 Subject: [PATCH 1/5] 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 8c3cac8cd50edb09cecc30629033083d1fe607c5 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Thu, 14 May 2026 21:57:58 -0400 Subject: [PATCH 2/5] 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; From dd962f86f46a49529fb48076485413587075bab6 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sat, 6 Jun 2026 14:59:57 -0400 Subject: [PATCH 3/5] Address terrain globe Mercator review comments --- .../src/terrain-layer/terrain-layer.ts | 52 ++++++++++-------- test/modules/geo-layers/terrain-layer.spec.ts | 54 +++++++++++++++---- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index dfb78cc4411..49553575328 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -18,6 +18,11 @@ import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {COORDINATE_SYSTEM} from '@deck.gl/core'; import type {Mesh} 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, @@ -33,8 +38,6 @@ 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, @@ -110,6 +113,7 @@ type TerrainLoadProps = { elevationData: string | null; elevationDecoder: ElevationDecoder; meshMaxError: number; + remapToWebMercatorTile?: boolean; signal?: AbortSignal; }; @@ -205,6 +209,7 @@ export default class TerrainLayer extends Composite bounds, elevationDecoder, meshMaxError, + remapToWebMercatorTile, signal }: TerrainLoadProps): Promise | null { if (!elevationData) { @@ -223,7 +228,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 remapToWebMercatorTile + ? terrain.then(mesh => (mesh ? remapMeshToWebMercatorTile(mesh, bounds) : mesh)) + : terrain; } getTiledTerrainData(tile: TileLoadProps): Promise { @@ -245,11 +259,8 @@ 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, - Boolean(viewport.resolution && viewport.resolution > 0) - ); + const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0); + const overlappedBounds = getOverlappedBounds(bounds, this.props.tileSize, isGlobe); const terrain = this.loadTerrain({ @@ -257,10 +268,9 @@ export default class TerrainLayer extends Composite bounds: overlappedBounds, elevationDecoder, meshMaxError, + remapToWebMercatorTile: isGlobe, signal - })?.then(mesh => - viewport.resolution && mesh ? remapMeshToWebMercatorTile(mesh, overlappedBounds) : mesh - ) ?? Promise.resolve(null); + }) ?? 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) @@ -431,14 +441,14 @@ function remapMeshToWebMercatorTile(mesh: Mesh, bounds: Bounds): Mesh { } const [, south, , north] = bounds; - const northY = lngLatToMercatorY(north); - const southY = lngLatToMercatorY(south); + 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] = mercatorYToLat(mercatorY); + remappedPositions[i * 3 + 1] = worldToLngLat([0, mercatorY])[1]; } return { @@ -453,12 +463,10 @@ function remapMeshToWebMercatorTile(mesh: Mesh, bounds: Bounds): Mesh { }; } -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; +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/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index afc250413c7..356350b0500 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -8,6 +8,11 @@ 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({ @@ -50,15 +55,27 @@ test('TerrainLayer', async () => { }); 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 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: new Float32Array([0, 80, 0, 0.5, 40, 0, 1, 0, 0]), size: 3}, + POSITION: {value: sourcePositions, 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', + tileSize, fetch: () => Promise.resolve(sourceMesh) }); layer.context = { @@ -77,15 +94,34 @@ test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions' 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}, + bbox, zoom: 1 }); - const positions = mesh.attributes.POSITION.value; + const positions = mesh!.attributes.POSITION.value; + + expect(positions, 'remap copies the loader positions').not.toBe(sourcePositions); + 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 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); + 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]; +} From e1d2fc5c6dc60f3ed6cccad39173f09f4d8b5f78 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sat, 6 Jun 2026 18:26:52 -0400 Subject: [PATCH 4/5] Address GlobeView review feedback --- .../src/terrain-layer/terrain-layer.ts | 7 +-- .../geo-layers/src/tile-layer/tile-layer.ts | 11 +++-- .../geo-layers/tile-layer/tile-layer.spec.ts | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 49553575328..e595ef8584a 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -12,7 +12,8 @@ import { log, Material, TextureSource, - UpdateParameters + UpdateParameters, + _GlobeViewport } from '@deck.gl/core'; import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; import {COORDINATE_SYSTEM} from '@deck.gl/core'; @@ -259,7 +260,7 @@ export default class TerrainLayer extends Composite topRight = [bbox.right, bbox.top]; } const bounds: Bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; - const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0); + const isGlobe = viewport instanceof _GlobeViewport; const overlappedBounds = getOverlappedBounds(bounds, this.props.tileSize, isGlobe); const terrain = @@ -301,7 +302,7 @@ export default class TerrainLayer extends Composite // Bounds are baked with projectFlat. In GlobeView projectFlat is identity, // so tiled terrain meshes are in lng/lat degrees instead of common-space // web-mercator units. - const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0); + const isGlobe = viewport instanceof _GlobeViewport; const boundingBox = (mesh as MeshWithBoundingBox | null)?.header?.boundingBox; const hasLngLatBounds = boundingBox && diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index 3136081bf5a..806a397f4d3 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -13,9 +13,10 @@ import { DefaultProps, FilterContext, COORDINATE_SYSTEM, - _flatten as flatten + _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'; @@ -56,8 +57,6 @@ const defaultProps: DefaultProps = { visibleMaxZoom: null }; -const BITMAP_LAYER_NAME = 'BitmapLayer'; - /** All props supported by the TileLayer */ export type TileLayerProps = CompositeLayerProps & _TileLayerProps; @@ -447,8 +446,8 @@ 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 || + !(this.context.viewport instanceof _GlobeViewport) || + !(layer instanceof BitmapLayer) || (layer.props as Record)._imageCoordinateSystem !== 'default' ) { return null; 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 a303affadf6..180a0d706a0 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts @@ -249,6 +249,51 @@ test('TileLayer#GlobeView:BitmapLayer image coordinate system', async () => { }); }); +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:preserves explicit BitmapLayer image coordinate system', async () => { const testViewport = new GlobeView().makeViewport({ width: 100, From ec2e1a5a61e7cd73cf3fa7d9b95a15c70aa7ee40 Mon Sep 17 00:00:00 2001 From: Charles Richardson Date: Thu, 11 Jun 2026 13:31:04 -0400 Subject: [PATCH 5/5] Clarify GlobeView terrain remap scope --- .../src/terrain-layer/terrain-layer.ts | 14 ++++--- .../geo-layers/src/tile-layer/tile-layer.ts | 3 ++ test/modules/geo-layers/terrain-layer.spec.ts | 14 +++++-- .../geo-layers/tile-layer/tile-layer.spec.ts | 40 +++++++++++++++++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index e595ef8584a..8816a0d1f21 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -114,7 +114,7 @@ type TerrainLoadProps = { elevationData: string | null; elevationDecoder: ElevationDecoder; meshMaxError: number; - remapToWebMercatorTile?: boolean; + shouldRemapTerrainMeshToWebMercatorTile?: boolean; signal?: AbortSignal; }; @@ -210,7 +210,7 @@ export default class TerrainLayer extends Composite bounds, elevationDecoder, meshMaxError, - remapToWebMercatorTile, + shouldRemapTerrainMeshToWebMercatorTile, signal }: TerrainLoadProps): Promise | null { if (!elevationData) { @@ -236,8 +236,8 @@ export default class TerrainLayer extends Composite signal }); - return remapToWebMercatorTile - ? terrain.then(mesh => (mesh ? remapMeshToWebMercatorTile(mesh, bounds) : mesh)) + return shouldRemapTerrainMeshToWebMercatorTile + ? terrain.then(mesh => (mesh ? remapTerrainMeshToWebMercatorTile(mesh, bounds) : mesh)) : terrain; } @@ -269,7 +269,9 @@ export default class TerrainLayer extends Composite bounds: overlappedBounds, elevationDecoder, meshMaxError, - remapToWebMercatorTile: isGlobe, + // 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 @@ -432,7 +434,7 @@ 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 { +function remapTerrainMeshToWebMercatorTile(mesh: Mesh, bounds: Bounds): Mesh { const positionAttribute = mesh.attributes.POSITION; const texCoordAttribute = mesh.attributes.TEXCOORD_0; const positions = positionAttribute?.value; diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index 806a397f4d3..52f888f2d7e 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -445,6 +445,9 @@ 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) || diff --git a/test/modules/geo-layers/terrain-layer.spec.ts b/test/modules/geo-layers/terrain-layer.spec.ts index 356350b0500..be614ccfb64 100644 --- a/test/modules/geo-layers/terrain-layer.spec.ts +++ b/test/modules/geo-layers/terrain-layer.spec.ts @@ -56,6 +56,8 @@ test('TerrainLayer', async () => { 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; @@ -69,14 +71,16 @@ test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions' const sourceMesh = { attributes: { POSITION: {value: sourcePositions, size: 3}, - TEXCOORD_0: {value: new Float32Array([0, 0, 0.5, 0.5, 1, 1]), size: 2} + 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: () => Promise.resolve(sourceMesh) + fetch: (_url, context) => + Promise.resolve(context.propName === 'texture' ? sourceTexture : sourceMesh) }); layer.context = { viewport: new GlobeView().makeViewport({ @@ -91,7 +95,7 @@ test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions' }; layer.state = {isTiled: true}; - const [mesh] = await layer.getTiledTerrainData({ + const [mesh, texture] = await layer.getTiledTerrainData({ index: {x: 0, y: 0, z: 1}, id: '0-0-1', bbox, @@ -100,6 +104,10 @@ test('TerrainLayer#globe remaps WebMercator tile rows to lng/lat mesh positions' 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); 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 180a0d706a0..396c87deacd 100644 --- a/test/modules/geo-layers/tile-layer/tile-layer.spec.ts +++ b/test/modules/geo-layers/tile-layer/tile-layer.spec.ts @@ -294,6 +294,46 @@ test('TileLayer#GlobeView:custom BitmapLayer image coordinate system', async () }); }); +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,