Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
101 changes: 77 additions & 24 deletions modules/geo-layers/src/terrain-layer/terrain-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ 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 {
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 @@ -108,12 +113,13 @@ type TerrainLoadProps = {
elevationData: string | null;
elevationDecoder: ElevationDecoder;
meshMaxError: number;
remapToWebMercatorTile?: boolean;
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;
};
Expand Down Expand Up @@ -165,7 +171,7 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite

state!: {
isTiled?: boolean;
terrain?: MeshAttributes;
terrain?: Mesh;
zRange?: ZRange | null;
};

Expand Down Expand Up @@ -203,8 +209,9 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> extends Composite
bounds,
elevationDecoder,
meshMaxError,
remapToWebMercatorTile,
signal
}: TerrainLoadProps): Promise<MeshAttributes> | null {
}: TerrainLoadProps): Promise<Mesh> | null {
if (!elevationData) {
return null;
}
Expand All @@ -221,7 +228,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 remapToWebMercatorTile
? terrain.then(mesh => (mesh ? remapMeshToWebMercatorTile(mesh, bounds) : mesh))
: terrain;
}

getTiledTerrainData(tile: TileLoadProps): Promise<MeshAndTexture> {
Expand All @@ -243,19 +259,18 @@ 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,
Boolean(viewport.resolution && viewport.resolution > 0)
);

const terrain = this.loadTerrain({
elevationData: dataUrl,
bounds: overlappedBounds,
elevationDecoder,
meshMaxError,
signal
});
const isGlobe = Boolean(viewport.resolution && viewport.resolution > 0);
Comment thread
charlieforward9 marked this conversation as resolved.
Outdated
const overlappedBounds = getOverlappedBounds(bounds, this.props.tileSize, isGlobe);

const terrain =
this.loadTerrain({
elevationData: dataUrl,
bounds: overlappedBounds,
elevationDecoder,
meshMaxError,
remapToWebMercatorTile: isGlobe,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All tile sublayers rendered on a globe trigger the remap. Is it true that only the built-in bitmap layer gets modified by this, or would other sublayers get remapped too?

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 @@ -319,11 +334,9 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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;
Expand Down Expand Up @@ -417,3 +430,43 @@ export default class TerrainLayer<ExtraPropsT extends {} = {}> 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 = 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,
attributes: {
...mesh.attributes,
POSITION: {
...positionAttribute,
value: remappedPositions
}
}
};
}

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];
}
29 changes: 25 additions & 4 deletions modules/geo-layers/src/tile-layer/tile-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
GetPickingInfoParams,
DefaultProps,
FilterContext,
COORDINATE_SYSTEM,
_flatten as flatten
} from '@deck.gl/core';
import {GeoJsonLayer} from '@deck.gl/layers';
Expand Down Expand Up @@ -55,6 +56,8 @@ const defaultProps: DefaultProps<TileLayerProps> = {
visibleMaxZoom: null
};

const BITMAP_LAYER_NAME = 'BitmapLayer';

/** All props supported by the TileLayer */
export type TileLayerProps<DataT = unknown> = CompositeLayerProps & _TileLayerProps<DataT>;

Expand Down Expand Up @@ -421,12 +424,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 +445,22 @@ 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.
if (
!this.context.viewport.resolution ||
(layer.constructor as typeof Layer).layerName !== BITMAP_LAYER_NAME ||
(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 Down
78 changes: 78 additions & 0 deletions test/modules/geo-layers/terrain-layer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -47,3 +53,75 @@ 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 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: 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 = {
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,
zoom: 1
});
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 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
Loading