Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 78 additions & 18 deletions modules/geo-layers/src/terrain-layer/terrain-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,6 +114,7 @@ type TerrainLoadProps = {
elevationData: string | null;
elevationDecoder: ElevationDecoder;
meshMaxError: number;
shouldRemapTerrainMeshToWebMercatorTile?: boolean;
signal?: AbortSignal;
};

Expand Down Expand Up @@ -204,6 +210,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
bounds,
elevationDecoder,
meshMaxError,
shouldRemapTerrainMeshToWebMercatorTile,
signal
}: TerrainLoadProps): Promise<MeshAttributes> | null {
if (!elevationData) {
Expand All @@ -222,7 +229,16 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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<MeshAndTexture> {
Expand All @@ -244,19 +260,20 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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)
Expand Down Expand Up @@ -321,10 +338,10 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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;
Expand Down Expand Up @@ -421,3 +438,46 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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.
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
const attributes = (mesh as any).attributes as MeshAttributes | undefined;
const positionAttribute = attributes?.POSITION;
const textureCoordinateAttribute = attributes?.TEXCOORD_0;
const positions = positionAttribute?.value;
const textureCoordinates = textureCoordinateAttribute?.value;
if (!positions || !textureCoordinates) {
return mesh;
}

const [, south, , north] = bounds;
const northY = lngLatToMercatorWorldY(north);
const southY = lngLatToMercatorWorldY(south);
const remappedPositions = new Float32Array(positions);

for (let vertexIndex = 0; vertexIndex < textureCoordinates.length / 2; vertexIndex++) {
const textureV = textureCoordinates[vertexIndex * 2 + 1];
const mercatorY = northY + (southY - northY) * textureV;
remappedPositions[vertexIndex * 3 + 1] = worldToLngLat([0, mercatorY])[1];
}

return {
...(mesh as any),
attributes: {
...attributes,
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];
}
64 changes: 57 additions & 7 deletions modules/geo-layers/src/tile-layer/tile-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -195,13 +197,15 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> extends
state!: {
tileset: Tileset2D | null;
isLoaded: boolean;
isGlobeView: boolean;
frameNumber?: number;
};

initializeState() {
this.state = {
tileset: null,
isLoaded: false
isLoaded: false,
isGlobeView: this.context.viewport instanceof _GlobeViewport
};
}

Expand All @@ -228,6 +232,7 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> extends

updateState({changeFlags}: UpdateParameters<this>) {
let {tileset} = this.state;
const isGlobeView = this.context.viewport instanceof _GlobeViewport;
const propsChanged = changeFlags.propsOrDataChanged || changeFlags.updateTriggersChanged;
const dataChanged =
changeFlags.dataChanged ||
Expand All @@ -252,6 +257,15 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> extends
}
}

if (tileset && isGlobeView !== this.state.isGlobeView) {
// Bitmap image coordinates depend on the projection. Recreate cached sublayers when the
// view switches between GlobeView and another projection so the override is not stale.
tileset.tiles.forEach(tile => {
tile.layers = null;
});
this.setState({isGlobeView});
}

this._updateTileset();
}

Expand Down Expand Up @@ -421,12 +435,14 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> 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] &&
Expand All @@ -440,6 +456,27 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> extends
});
}

private _getGlobeBitmapLayerProps(layer: Layer): Record<string, unknown> | null {
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
// BitmapLayer and subclasses draw URL-template tile imagery over lng/lat bounds. XYZ/TMS
// imagery is encoded in WebMercator, so default GlobeView bitmap sublayers need UV
// reprojection. Custom getTileData imagery is not assumed to use that projection.
if (
!(this.context.viewport instanceof _GlobeViewport) ||
!(layer instanceof BitmapLayer) ||
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
!isWebMercatorTileData(this.props.data) ||
!Number.isFinite(layer.props.bounds[0]) ||
(layer.props as Record<string, unknown>)._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;
Expand All @@ -450,3 +487,16 @@ export default class TileLayer<DataT = any, ExtraPropsT extends {} = {}> extends
);
}
}

function isWebMercatorTileData(data: URLTemplate): boolean {
const urlTemplates = Array.isArray(data) ? data : [data];
return (
urlTemplates.length > 0 &&
urlTemplates.every(
urlTemplate =>
typeof urlTemplate === 'string' &&
urlTemplate.includes('{x}') &&
(urlTemplate.includes('{y}') || urlTemplate.includes('{-y}'))
)
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
);
}
86 changes: 86 additions & 0 deletions test/modules/geo-layers/terrain-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import {test, expect} from 'vitest';
import {generateLayerTests, testLayerAsync} from '@deck.gl/test-utils/vitest';
import {LayerManager, MapView} from '@deck.gl/core';
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';
import {getWebGPUTestDevice} from '@luma.gl/test-utils';

const TEST_TERRAIN_MESH = {
Expand Down Expand Up @@ -114,3 +120,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];
}
Loading