Initial commit

This commit is contained in:
2026-04-15 17:08:39 +02:00
parent ae164c47a8
commit 47fd1c2b7a
1819 changed files with 685388 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
import Point from '@mapbox/point-geometry';
import {type IReadonlyTransform, type ITransform} from '../transform_interface';
import {type LngLat, type LngLatLike} from '../lng_lat';
import {type CameraForBoundsOptions, type PointLike} from '../../ui/camera';
import {type PaddingOptions} from '../edge_insets';
import {type LngLatBounds} from '../lng_lat_bounds';
import {degreesToRadians, getRollPitchBearing, type RollPitchBearing, rollPitchBearingToQuat, scaleZoom, warnOnce, zoomScale} from '../../util/util';
import {quat} from 'gl-matrix';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils';
export type MapControlsDeltas = {
panDelta: Point;
zoomDelta: number;
bearingDelta: number;
pitchDelta: number;
rollDelta: number;
around: Point;
};
export type CameraForBoxAndBearingHandlerResult = {
center: LngLat;
zoom: number;
bearing: number;
};
export type EaseToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
around?: LngLat;
aroundPoint?: Point;
center?: LngLatLike;
zoom?: number;
offset?: PointLike;
};
export type EaseToHandlerResult = {
easeFunc: (k: number) => void;
elevationCenter: LngLat;
isZooming: boolean;
};
export type FlyToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
center?: LngLatLike;
locationAtOffset: LngLat;
zoom?: number;
minZoom?: number;
};
export type FlyToHandlerResult = {
easeFunc: (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => void;
scaleOfZoom: number;
scaleOfMinZoom?: number;
targetCenter: LngLat;
pixelPathLength: number;
};
export type UpdateRotationArgs = {
/**
* The starting Euler angles.
*/
startEulerAngles: RollPitchBearing;
/**
* The end Euler angles.
*/
endEulerAngles: RollPitchBearing;
/**
* The transform to be updated
*/
tr: ITransform;
/**
* The interpolation fraction, between 0 and 1.
*/
k: number;
/**
* If true, use spherical linear interpolation. If false, use linear interpolation of Euler angles.
*/
useSlerp: boolean;
};
/**
* @internal
*/
export function cameraBoundsWarning() {
warnOnce(
'Map cannot fit within canvas with the given bounds, padding, and/or offset.'
);
}
/**
* @internal
* Contains projection-specific functions related to camera controls, easeTo, flyTo, inertia, etc.
*/
export interface ICameraHelper {
get useGlobeControls(): boolean;
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
};
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void;
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void;
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult;
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void;
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult;
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult;
}
/**
* @internal
* Set a transform's rotation to a value interpolated between startEulerAngles and endEulerAngles
*/
export function updateRotation(args: UpdateRotationArgs) {
if (args.useSlerp) {
// At pitch ==0, the Euler angle representation is ambiguous. In this case, set the Euler angles
// to the representation requested by the caller
if (args.k < 1) {
const startRotation = rollPitchBearingToQuat(args.startEulerAngles.roll, args.startEulerAngles.pitch, args.startEulerAngles.bearing);
const endRotation = rollPitchBearingToQuat(args.endEulerAngles.roll, args.endEulerAngles.pitch, args.endEulerAngles.bearing);
const rotation: quat = new Float64Array(4) as any;
quat.slerp(rotation, startRotation, endRotation, args.k);
const eulerAngles = getRollPitchBearing(rotation);
args.tr.setRoll(eulerAngles.roll);
args.tr.setPitch(eulerAngles.pitch);
args.tr.setBearing(eulerAngles.bearing);
} else {
args.tr.setRoll(args.endEulerAngles.roll);
args.tr.setPitch(args.endEulerAngles.pitch);
args.tr.setBearing(args.endEulerAngles.bearing);
}
} else {
args.tr.setRoll(interpolates.number(args.startEulerAngles.roll, args.endEulerAngles.roll, args.k));
args.tr.setPitch(interpolates.number(args.startEulerAngles.pitch, args.endEulerAngles.pitch, args.k));
args.tr.setBearing(interpolates.number(args.startEulerAngles.bearing, args.endEulerAngles.bearing, args.k));
}
}
export function cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult {
const edgePadding = tr.padding;
// Consider all corners of the rotated bounding box derived from the given points
// when find the camera position that fits the given points.
const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest());
const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast());
const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast());
const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest());
const bearingRadians = degreesToRadians(-bearing);
const nwRotatedWorld = nwWorld.rotate(bearingRadians);
const neRotatedWorld = neWorld.rotate(bearingRadians);
const seRotatedWorld = seWorld.rotate(bearingRadians);
const swRotatedWorld = swWorld.rotate(bearingRadians);
const upperRight = new Point(
Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
);
const lowerLeft = new Point(
Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
);
// Calculate zoom: consider the original bbox and padding.
const size = upperRight.sub(lowerLeft);
const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right));
const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom));
const scaleX = availableWidth / size.x;
const scaleY = availableHeight / size.y;
if (scaleY < 0 || scaleX < 0) {
cameraBoundsWarning();
return undefined;
}
const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
// Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding.
const offset = Point.convert(options.offset);
const paddingOffsetX = (padding.left - padding.right) / 2;
const paddingOffsetY = (padding.top - padding.bottom) / 2;
const paddingOffset = new Point(paddingOffsetX, paddingOffsetY);
const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing));
const offsetAtInitialZoom = offset.add(rotatedPaddingOffset);
const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom));
const center = unprojectFromWorldCoordinates(
tr.worldSize,
// either world diagonal can be used (NW-SE or NE-SW)
nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom)
);
const result = {
center,
zoom,
bearing
};
return result;
}

View File

@@ -0,0 +1,837 @@
import {beforeEach, describe, expect, test} from 'vitest';
import {GlobeTransform} from './globe_transform';
import {LngLat} from '../lng_lat';
import {coveringTiles, coveringZoomLevel, createCalculateTileZoomFunction, type CoveringTilesOptions} from './covering_tiles';
import {OverscaledTileID} from '../../tile/tile_id';
import {MercatorTransform} from './mercator_transform';
import {globeConstants} from './vertical_perspective_projection';
describe('coveringTiles', () => {
describe('globe', () => {
beforeEach(() => {
// Force faster animations so we can use shorter sleeps when testing them
globeConstants.errorTransitionTimeSeconds = 0.1;
});
test('zoomed out', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(0.0, 0.0));
transform.setZoom(-1);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('zoomed in', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.02, 0.01));
transform.setZoom(3);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(3, 0, 3, 3, 3),
new OverscaledTileID(3, 0, 3, 3, 4),
new OverscaledTileID(3, 0, 3, 4, 3),
new OverscaledTileID(3, 0, 3, 4, 4),
]);
});
test('zoomed in 512x512', () => {
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(-0.02, 0.01));
transform.setZoom(3);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(3, 0, 3, 3, 3),
new OverscaledTileID(3, 0, 3, 3, 4),
new OverscaledTileID(3, 0, 3, 4, 3),
new OverscaledTileID(3, 0, 3, 4, 4),
new OverscaledTileID(3, 0, 3, 2, 3),
new OverscaledTileID(3, 0, 3, 2, 4),
new OverscaledTileID(3, 0, 3, 5, 3),
new OverscaledTileID(3, 0, 3, 5, 4)
]);
});
test('pitched', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.002, 0.001));
transform.setZoom(8);
transform.setMaxPitch(80);
transform.setPitch(80);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(6, 0, 6, 32, 31),
new OverscaledTileID(6, 0, 6, 31, 31),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512),
]);
});
test('pitched+rotated', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.002, 0.001));
transform.setZoom(8);
transform.setMaxPitch(80);
transform.setPitch(80);
transform.setBearing(45);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(7, 0, 7, 64, 64),
new OverscaledTileID(7, 0, 7, 64, 63),
new OverscaledTileID(7, 0, 7, 63, 63),
new OverscaledTileID(10, 0, 10, 510, 512),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 511, 513)
]);
});
test('antimeridian1', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(179.99, -0.001));
transform.setZoom(5);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(5, 0, 5, 31, 16),
new OverscaledTileID(5, 0, 5, 31, 15),
new OverscaledTileID(5, 1, 5, 0, 16),
new OverscaledTileID(5, 1, 5, 0, 15),
]);
});
test('antimeridian2', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-179.99, 0.001));
transform.setZoom(5);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(5, 0, 5, 0, 15),
new OverscaledTileID(5, 0, 5, 0, 16),
new OverscaledTileID(5, -1, 5, 31, 15),
new OverscaledTileID(5, -1, 5, 31, 16),
]);
});
test('zoom < 0', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(0.0, 80.0));
transform.setZoom(-0.5);
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 0,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('zoom = 11', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, -0.087));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1024)
]);
});
test('zoom = 11, mid lat', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, 60.02));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 594)
]);
});
test('zoom = 11, high lat', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, 85.028));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1)
]);
});
test('zoom = 11, mid lat, mid lng', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-58.97, 60.02));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 688, 594)
]);
});
test('zoom = 11, mid lng', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-58.97, -0.087));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 688, 1024)
]);
});
describe('nonzero center elevation', () => {
test('looking down', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(10, 0, 10, 511, 511),
]);
});
describe('high pitch', () => {
test('bearing 0', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(0);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1023, 1023),
new OverscaledTileID(11, 0, 11, 1024, 1022),
new OverscaledTileID(11, 0, 11, 1023, 1022),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
test('bearing 90', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(90);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(9, 0, 9, 256, 256),
new OverscaledTileID(12, 0, 12, 2047, 2046),
new OverscaledTileID(12, 0, 12, 2047, 2047),
]);
});
test('bearing 180', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(180);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1023, 1023),
new OverscaledTileID(8, 0, 8, 128, 128),
new OverscaledTileID(8, 0, 8, 127, 128),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
test('bearing 270', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(270);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(9, 0, 9, 255, 256),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
});
});
});
describe('mercator', () => {
const options = {
minzoom: 1,
maxzoom: 10,
tileSize: 512
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(200, 200);
test('general', () => {
// make slightly off center so that sort order is not subject to precision issues
transform.setCenter(new LngLat(-0.01, 0.01));
transform.setZoom(0);
expect(coveringTiles(transform, options)).toEqual([]);
transform.setZoom(1);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 1, 0),
new OverscaledTileID(1, 0, 1, 0, 1),
new OverscaledTileID(1, 0, 1, 1, 1)]);
transform.setZoom(2.4);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, 0, 2, 1, 1),
new OverscaledTileID(2, 0, 2, 2, 1),
new OverscaledTileID(2, 0, 2, 1, 2),
new OverscaledTileID(2, 0, 2, 2, 2)]);
transform.setZoom(10);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(10, 0, 10, 512, 511),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512)]);
transform.setZoom(11);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(10, 0, 10, 512, 511),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512)]);
transform.resize(2048, 128);
transform.setZoom(9);
transform.setPadding({top: 16});
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(9, 0, 9, 255, 255),
new OverscaledTileID(9, 0, 9, 256, 255),
new OverscaledTileID(9, 0, 9, 255, 256),
new OverscaledTileID(9, 0, 9, 256, 256),
new OverscaledTileID(9, 0, 9, 254, 255),
new OverscaledTileID(9, 0, 9, 254, 256),
new OverscaledTileID(9, 0, 9, 257, 255),
new OverscaledTileID(9, 0, 9, 257, 256),
new OverscaledTileID(9, 0, 9, 253, 255),
new OverscaledTileID(9, 0, 9, 253, 256)]);
transform.setPadding({top: 0});
transform.setZoom(5.1);
transform.setPitch(60.0);
transform.setBearing(32.0);
transform.setCenter(new LngLat(56.90, 48.20));
transform.resize(1024, 768);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(5, 0, 5, 21, 11),
new OverscaledTileID(5, 0, 5, 20, 11),
new OverscaledTileID(5, 0, 5, 21, 10),
new OverscaledTileID(5, 0, 5, 20, 10),
new OverscaledTileID(5, 0, 5, 21, 12),
new OverscaledTileID(5, 0, 5, 22, 11),
new OverscaledTileID(5, 0, 5, 20, 12),
new OverscaledTileID(5, 0, 5, 22, 10),
new OverscaledTileID(5, 0, 5, 21, 9),
new OverscaledTileID(5, 0, 5, 20, 9),
new OverscaledTileID(5, 0, 5, 22, 9),
new OverscaledTileID(5, 0, 5, 23, 10),
new OverscaledTileID(5, 0, 5, 21, 8),
new OverscaledTileID(5, 0, 5, 20, 8),
new OverscaledTileID(5, 0, 5, 23, 9),
new OverscaledTileID(5, 0, 5, 22, 8),
new OverscaledTileID(5, 0, 5, 23, 8),
new OverscaledTileID(5, 0, 5, 21, 7),
new OverscaledTileID(5, 0, 5, 20, 7),
new OverscaledTileID(5, 0, 5, 24, 9),
new OverscaledTileID(5, 0, 5, 22, 7)
]);
transform.setZoom(8);
transform.setPitch(85.0);
transform.setBearing(0.0);
transform.setCenter(new LngLat(20.918, 39.232));
transform.resize(50, 1000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 142, 98),
new OverscaledTileID(7, 0, 7, 71, 48),
new OverscaledTileID(5, 0, 5, 17, 11),
new OverscaledTileID(5, 0, 5, 17, 10),
new OverscaledTileID(9, 0, 9, 285, 198),
new OverscaledTileID(9, 0, 9, 285, 199)
]);
transform.setZoom(8);
transform.setPitch(60);
transform.setBearing(45.0);
transform.setCenter(new LngLat(25.02, 60.15));
transform.resize(300, 50);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 145, 74),
new OverscaledTileID(8, 0, 8, 145, 73),
new OverscaledTileID(8, 0, 8, 146, 74)
]);
transform.resize(50, 300);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 145, 74),
new OverscaledTileID(8, 0, 8, 145, 73),
new OverscaledTileID(8, 0, 8, 146, 74),
new OverscaledTileID(8, 0, 8, 146, 73)
]);
const optionsWithCustomTileLoading = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: (_requestedCenterZoom: number,
_distanceToTile2D: number,
_distanceToTileZ: number,
_distanceToCenter3D: number,
_cameraVerticalFOV: number) => { return 7; }
};
transform.resize(50, 300);
transform.setPitch(70);
expect(coveringTiles(transform, optionsWithCustomTileLoading)).toEqual([
new OverscaledTileID(7, 0, 7, 74, 36),
new OverscaledTileID(7, 0, 7, 73, 37),
new OverscaledTileID(7, 0, 7, 74, 35),
new OverscaledTileID(7, 0, 7, 73, 36),
new OverscaledTileID(7, 0, 7, 72, 37),
new OverscaledTileID(7, 0, 7, 73, 35),
new OverscaledTileID(7, 0, 7, 72, 36)
]);
});
test('calculates tile coverage with low number of zoom levels and low tile count', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(1.0, 1.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(5, 0, 5, 18, 9),
new OverscaledTileID(5, 0, 5, 18, 8)
]);
});
test('calculates tile coverage with low tile count', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(1.0, 10.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(6, 0, 6, 37, 18),
new OverscaledTileID(6, 0, 6, 37, 17),
new OverscaledTileID(6, 0, 6, 36, 18),
new OverscaledTileID(6, 0, 6, 36, 17)
]);
});
test('calculates tile coverage with low number of zoom levels', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(10.0, 1.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(7, 0, 7, 73, 37),
new OverscaledTileID(7, 0, 7, 73, 36),
new OverscaledTileID(7, 0, 7, 72, 36),
new OverscaledTileID(6, 0, 6, 37, 18),
new OverscaledTileID(5, 0, 5, 18, 8),
new OverscaledTileID(9, 0, 9, 290, 148),
new OverscaledTileID(9, 0, 9, 291, 148)
]);
});
test('calculates tile coverage at w > 0', () => {
transform.setZoom(2);
transform.setPitch(0);
transform.setBearing(0);
transform.resize(300, 300);
transform.setCenter(new LngLat(630.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, 2, 2, 1, 1),
new OverscaledTileID(2, 2, 2, 1, 2),
new OverscaledTileID(2, 2, 2, 0, 1),
new OverscaledTileID(2, 2, 2, 0, 2)
]);
});
test('calculates tile coverage at w = -1', () => {
transform.setCenter(new LngLat(-360.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, -1, 2, 1, 1),
new OverscaledTileID(2, -1, 2, 1, 2),
new OverscaledTileID(2, -1, 2, 2, 1),
new OverscaledTileID(2, -1, 2, 2, 2)
]);
});
test('calculates tile coverage across meridian', () => {
transform.setZoom(1);
transform.setCenter(new LngLat(-180.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 0, 1),
new OverscaledTileID(1, -1, 1, 1, 0),
new OverscaledTileID(1, -1, 1, 1, 1)
]);
});
test('only includes tiles for a single world, if renderWorldCopies is set to false', () => {
transform.setZoom(1);
transform.setCenter(new LngLat(-180.01, 0.01));
transform.setRenderWorldCopies(false);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 0, 1)
]);
});
test('overscaledZ', () => {
const options = {
minzoom: 1,
maxzoom: 10,
tileSize: 256,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 10, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(10, 400);
// make slightly off center so that sort order is not subject to precision issues
transform.setCenter(new LngLat(-0.01, 0.01));
transform.setPitch(85);
transform.setFov(10);
transform.setZoom(10);
const tiles = coveringTiles(transform, options);
for (const tile of tiles) {
expect(tile.overscaledZ).toBeGreaterThanOrEqual(tile.canonical.z);
}
});
test('maxzoom-0', () => {
const options = {
minzoom: 0,
maxzoom: 0,
tileSize: 512
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 0, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200, 200);
transform.setCenter(new LngLat(0.01, 0.01));
transform.setZoom(8);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('z11', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, -0.087));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1024)
]);
});
test('z11, mid lat', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, 60.02));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 594)
]);
});
test('z11, high lat', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, 85.028));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1)
]);
});
test('z11, mid lat, mid lng', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-58.97, 60.02));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 688, 594)
]);
});
test('z11, low lat, mid lng', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-58.97, -0.087));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 688, 1024)
]);
});
test('nonzero center elevation', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(0.03, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(11, 0, 11, 1023, 1023)
]);
});
});
});
describe('coveringZoomLevel', () => {
let transform: MercatorTransform;
let options: CoveringTilesOptions;
beforeEach(() => {
transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
options = {
tileSize: 512,
roundZoom: false,
};
});
test('zoom 0', () => {
transform.setZoom(0);
expect(coveringZoomLevel(transform, options)).toBe(0);
});
test('small zoom should be floored to 0', () => {
transform.setZoom(0.1);
expect(coveringZoomLevel(transform, options)).toBe(0);
});
test('zoom 2.7 should be floored to 2', () => {
transform.setZoom(2.7);
expect(coveringZoomLevel(transform, options)).toBe(2);
});
test('zoom 0 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(0);
expect(coveringZoomLevel(transform, options)).toBe(1);
});
test('zoom 0.1 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(0.1);
expect(coveringZoomLevel(transform, options)).toBe(1);
});
test('zoom 1 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(1);
expect(coveringZoomLevel(transform, options)).toBe(2);
});
test('zoom 2.4 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(2.4);
expect(coveringZoomLevel(transform, options)).toBe(3);
});
test('zoom 11.5 with rounded setting and small tile size', () => {
options.tileSize = 256;
options.roundZoom = true;
transform.setZoom(11.5);
expect(coveringZoomLevel(transform, options)).toBe(13);
});
});

View File

@@ -0,0 +1,291 @@
import {OverscaledTileID} from '../../tile/tile_id';
import {vec2, type vec4} from 'gl-matrix';
import {MercatorCoordinate} from '../mercator_coordinate';
import {degreesToRadians, scaleZoom} from '../../util/util';
import type {IReadonlyTransform} from '../transform_interface';
import type {Terrain} from '../../render/terrain';
import type {Frustum} from '../../util/primitives/frustum';
import {maxMercatorHorizonAngle} from './mercator_utils';
import {type IBoundingVolume, IntersectionResult} from '../../util/primitives/bounding_volume';
type CoveringTilesResult = {
tileID: OverscaledTileID;
distanceSq: number;
tileDistanceToCamera: number;
};
type CoveringTilesStackEntry = {
zoom: number;
x: number;
y: number;
wrap: number;
fullyVisible: boolean;
};
export type CoveringTilesOptions = {
/**
* Smallest allowed tile zoom.
*/
minzoom?: number;
/**
* Largest allowed tile zoom.
*/
maxzoom?: number;
/**
* Whether to round or floor the target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored.
*/
roundZoom?: boolean;
/**
* Tile size, expressed in screen pixels.
*/
tileSize: number;
};
export type CoveringTilesOptionsInternal = CoveringTilesOptions & {
/**
* `true` if tiles should be sent back to the worker for each overzoomed zoom level, `false` if not.
* Fill this option when computing covering tiles for a source.
* When true, any tile at `maxzoom` level that should be overscaled to a greater zoom will have
* its zoom set to the overscaled greater zoom. When false, such tiles will have zoom set to `maxzoom`.
*/
reparseOverscaled?: boolean;
/**
* When terrain is present, tile visibility will be computed in regards to the min and max elevations for each tile.
*/
terrain?: Terrain;
/**
* Optional function to redefine how tiles are loaded at high pitch angles.
*/
calculateTileZoom?: CalculateTileZoomFunction;
};
/**
* Function to define how tiles are loaded at high pitch angles
* @param requestedCenterZoom - the requested zoom level, valid at the center point.
* @param distanceToTile2D - 2D distance from the camera to the candidate tile, in mercator units.
* @param distanceToTileZ - vertical distance from the camera to the candidate tile, in mercator units.
* @param distanceToCenter3D - distance from camera to center point, in mercator units
* @param cameraVerticalFOV - camera vertical field of view, in degrees
* @return the desired zoom level for this tile. May not be an integer.
*/
export type CalculateTileZoomFunction = (requestedCenterZoom: number,
distanceToTile2D: number,
distanceToTileZ: number,
distanceToCenter3D: number,
cameraVerticalFOV: number) => number;
/**
* A simple/heuristic function that returns whether the tile is visible under the current transform.
* @returns an {@link IntersectionResult}.
*/
export function isTileVisible(frustum: Frustum, tileBoundingVolume: IBoundingVolume, plane?: vec4): IntersectionResult {
const frustumTest = tileBoundingVolume.intersectsFrustum(frustum);
if (!plane || frustumTest === IntersectionResult.None) {
return frustumTest;
}
const planeTest = tileBoundingVolume.intersectsPlane(plane);
if (planeTest === IntersectionResult.None) {
return IntersectionResult.None;
}
if (frustumTest === IntersectionResult.Full && planeTest === IntersectionResult.Full) {
return IntersectionResult.Full;
}
return IntersectionResult.Partial;
}
/**
* Definite integral of cos(x)^p. The analytical solution is described in `developer-guides/covering-tiles.md`,
* but here the integral is evaluated numerically.
* @param p - the power to raise cos(x) to inside the integral
* @param x1 - the starting point of the integral.
* @param x2 - the ending point of the integral.
* @return the integral of cos(x)^p from x=x1 to x=x2
*/
function integralOfCosXByP(p: number, x1: number, x2: number): number {
const numPoints = 10;
let sum = 0;
const dx = (x2 - x1 ) / numPoints;
// Midpoint integration
for( let i = 0; i < numPoints; i++)
{
const x = x1 + (i + 0.5)/numPoints * (x2 - x1);
sum += dx * Math.pow(Math.cos(x), p);
}
return sum;
}
export function createCalculateTileZoomFunction(maxZoomLevelsOnScreen: number, tileCountMaxMinRatio: number): CalculateTileZoomFunction {
return function (requestedCenterZoom: number,
distanceToTile2D: number,
distanceToTileZ: number,
distanceToCenter3D: number,
cameraVerticalFOV: number): number {
/**
* Controls how tiles are loaded at high pitch angles. Higher numbers cause fewer, lower resolution
* tiles to be loaded. Calculate the value that will result in the selected number of zoom levels in
* the worst-case condition (when the horizon is at the top of the screen). For more information, see
* `developer-guides/covering-tiles.md`
*/
const pitchTileLoadingBehavior = 2 * ((maxZoomLevelsOnScreen - 1) /
scaleZoom(Math.cos(degreesToRadians(maxMercatorHorizonAngle - cameraVerticalFOV)) /
Math.cos(degreesToRadians(maxMercatorHorizonAngle))) - 1);
const centerPitch = Math.acos(distanceToTileZ / distanceToCenter3D);
const tileCountPitch0 = 2 * integralOfCosXByP(pitchTileLoadingBehavior - 1, 0, degreesToRadians(cameraVerticalFOV / 2));
const highestPitch = Math.min(degreesToRadians(maxMercatorHorizonAngle), centerPitch + degreesToRadians(cameraVerticalFOV / 2));
const lowestPitch = Math.min(highestPitch, centerPitch - degreesToRadians(cameraVerticalFOV / 2));
const tileCount = integralOfCosXByP(pitchTileLoadingBehavior - 1, lowestPitch, highestPitch);
const thisTilePitch = Math.atan(distanceToTile2D / distanceToTileZ);
const distanceToTile3D = Math.hypot(distanceToTile2D, distanceToTileZ);
let thisTileDesiredZ = requestedCenterZoom;
// if distance to candidate tile is a tiny bit farther than distance to center,
// use the same zoom as the center. This is achieved by the scaling distance ratio by cos(fov/2)
thisTileDesiredZ = thisTileDesiredZ + scaleZoom(distanceToCenter3D / distanceToTile3D / Math.max(0.5, Math.cos(degreesToRadians(cameraVerticalFOV / 2))));
thisTileDesiredZ += pitchTileLoadingBehavior * scaleZoom(Math.cos(thisTilePitch)) / 2;
thisTileDesiredZ -= scaleZoom(Math.max(1, tileCount / tileCountPitch0 / tileCountMaxMinRatio)) / 2;
return thisTileDesiredZ;
};
}
const defaultMaxZoomLevelsOnScreen = 9.314;
const defaultTileCountMaxMinRatio = 3.0;
const defaultCalculateTileZoom = createCalculateTileZoomFunction(defaultMaxZoomLevelsOnScreen, defaultTileCountMaxMinRatio);
/**
* Return what zoom level of a tile source would most closely cover the tiles displayed by this transform.
* @param options - The options, most importantly the source's tile size.
* @returns An integer zoom level at which all tiles will be visible.
*/
export function coveringZoomLevel(transform: IReadonlyTransform, options: CoveringTilesOptions): number {
const z = (options.roundZoom ? Math.round : Math.floor)(
transform.zoom + scaleZoom(transform.tileSize / options.tileSize)
);
// At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist.
return Math.max(0, z);
}
/**
* Returns a list of tiles that optimally covers the screen. Adapted for globe projection.
* Correctly handles LOD when moving over the antimeridian.
* @param transform - The transform instance.
* @param frustum - The covering frustum.
* @param plane - The clipping plane used by globe transform, or null.
* @param cameraCoord - The x, y, z position of the camera in MercatorCoordinates.
* @param centerCoord - The x, y, z position of the center point in MercatorCoordinates.
* @param options - Additional coveringTiles options.
* @param details - Interface to define required helper functions.
* @returns A list of tile coordinates, ordered by ascending distance from camera.
*/
export function coveringTiles(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): OverscaledTileID[] {
const frustum = transform.getCameraFrustum();
const plane = transform.getClippingPlane();
const cameraCoord = transform.screenPointToMercatorCoordinate(transform.getCameraPoint());
const centerCoord = MercatorCoordinate.fromLngLat(transform.center, transform.elevation);
cameraCoord.z = centerCoord.z + Math.cos(transform.pitchInRadians) * transform.cameraToCenterDistance / transform.worldSize;
const detailsProvider = transform.getCoveringTilesDetailsProvider();
const allowVariableZoom = detailsProvider.allowVariableZoom(transform, options);
const desiredZ = coveringZoomLevel(transform, options);
const minZoom = options.minzoom || 0;
const maxZoom = options.maxzoom !== undefined ? options.maxzoom : transform.maxZoom;
const nominalZ = Math.min(Math.max(0, desiredZ), maxZoom);
const numTiles = Math.pow(2, nominalZ);
const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, 0];
const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0];
const distanceToCenter2d = Math.hypot(centerCoord.x - cameraCoord.x, centerCoord.y - cameraCoord.y);
const distanceZ = Math.abs(centerCoord.z - cameraCoord.z);
const distanceToCenter3d = Math.hypot(distanceToCenter2d, distanceZ);
const newRootTile = (wrap: number): CoveringTilesStackEntry => {
return {
zoom: 0,
x: 0,
y: 0,
wrap,
fullyVisible: false
};
};
// Do a depth-first traversal to find visible tiles and proper levels of detail
const stack: Array<CoveringTilesStackEntry> = [];
const result: Array<CoveringTilesResult> = [];
if (transform.renderWorldCopies && detailsProvider.allowWorldCopies()) {
// Render copy of the globe thrice on both sides
for (let i = 1; i <= 3; i++) {
stack.push(newRootTile(-i));
stack.push(newRootTile(i));
}
}
stack.push(newRootTile(0));
while (stack.length > 0) {
const it = stack.pop();
const x = it.x;
const y = it.y;
let fullyVisible = it.fullyVisible;
const tileID = {x, y, z: it.zoom};
const boundingVolume = detailsProvider.getTileBoundingVolume(tileID, it.wrap, transform.elevation, options);
// Visibility of a tile is not required if any of its ancestor is fully visible
if (!fullyVisible) {
const intersectResult = isTileVisible(frustum, boundingVolume, plane);
if (intersectResult === IntersectionResult.None)
continue;
fullyVisible = intersectResult === IntersectionResult.Full;
}
const distToTile2d = detailsProvider.distanceToTile2d(cameraCoord.x, cameraCoord.y, tileID, boundingVolume);
let thisTileDesiredZ = desiredZ;
if (allowVariableZoom) {
const tileZoomFunc = options.calculateTileZoom || defaultCalculateTileZoom;
thisTileDesiredZ = tileZoomFunc(transform.zoom + scaleZoom(transform.tileSize / options.tileSize),
distToTile2d,
distanceZ,
distanceToCenter3d,
transform.fov);
}
thisTileDesiredZ = (options.roundZoom ? Math.round : Math.floor)(thisTileDesiredZ);
thisTileDesiredZ = Math.max(0, thisTileDesiredZ);
const z = Math.min(thisTileDesiredZ, maxZoom);
// We need to compute a valid wrap value for the tile to keep globe compatibility with mercator
it.wrap = detailsProvider.getWrap(centerCoord, tileID, it.wrap);
// Have we reached the target depth?
if (it.zoom >= z) {
if (it.zoom < minZoom) {
continue;
}
const dz = nominalZ - it.zoom;
const dx = cameraPoint[0] - 0.5 - (x << dz);
const dy = cameraPoint[1] - 0.5 - (y << dz);
const overscaledZ = options.reparseOverscaled ? Math.max(it.zoom, thisTileDesiredZ) : it.zoom;
result.push({
tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y),
distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y]),
// this variable is currently not used, but may be important to reduce the amount of loaded tiles
tileDistanceToCamera: Math.sqrt(dx * dx + dy * dy)
});
continue;
}
for (let i = 0; i < 4; i++) {
const childX = (x << 1) + (i % 2);
const childY = (y << 1) + (i >> 1);
const childZ = it.zoom + 1;
stack.push({zoom: childZ, x: childX, y: childY, wrap: it.wrap, fullyVisible});
}
}
return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID);
}

View File

@@ -0,0 +1,44 @@
import {type IBoundingVolume} from '../../util/primitives/bounding_volume';
import {type MercatorCoordinate} from '../mercator_coordinate';
import {type IReadonlyTransform} from '../transform_interface';
import {type CoveringTilesOptionsInternal} from './covering_tiles';
export interface CoveringTilesDetailsProvider {
/**
* Returns the distance from the point to the tile
* @param pointX - point x.
* @param pointY - point y.
* @param tileID - Tile x, y and z for zoom.
* @param boundingVolume - tile bounding volume
*/
distanceToTile2d: (pointX: number, pointY: number, tileID: {x: number; y: number; z: number}, boundingVolume: IBoundingVolume) => number;
/**
* Returns the wrap value for a given tile.
*/
getWrap: (centerCoord: MercatorCoordinate, tileID: {x:number; y: number; z: number}, parentWrap: number) => number;
/**
* Returns the bounding volume of the specified tile.
* @param tileID - Tile x, y and z for zoom.
* @param wrap - wrap number of the tile.
* @param elevation - camera center point elevation.
* @param options - CoveringTilesOptions.
*/
getTileBoundingVolume: (tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal) => IBoundingVolume;
/**
* Whether to allow variable zoom, which is used at high pitch angle to avoid loading an excessive amount of tiles.
*/
allowVariableZoom: (transform: IReadonlyTransform, options: CoveringTilesOptionsInternal) => boolean;
/**
* Whether to allow world copies to be rendered.
*/
allowWorldCopies: () => boolean;
/**
* Prepare cache for the next frame.
*/
prepareNextFrame(): void;
}

View File

@@ -0,0 +1,66 @@
import {MercatorCameraHelper} from './mercator_camera_helper';
import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper';
import type Point from '@mapbox/point-geometry';
import type {CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper';
import type {LngLat, LngLatLike} from '../lng_lat';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {GlobeProjection} from './globe_projection';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {PaddingOptions} from '../edge_insets';
/**
* @internal
*/
export class GlobeCameraHelper implements ICameraHelper {
private _globe: GlobeProjection;
private _mercatorCameraHelper: MercatorCameraHelper;
private _verticalPerspectiveCameraHelper: VerticalPerspectiveCameraHelper;
constructor(globe: GlobeProjection) {
this._globe = globe;
this._mercatorCameraHelper = new MercatorCameraHelper();
this._verticalPerspectiveCameraHelper = new VerticalPerspectiveCameraHelper();
}
get useGlobeControls(): boolean { return this._globe.useGlobeRendering; }
get currentHelper(): ICameraHelper {
return this.useGlobeControls ? this._verticalPerspectiveCameraHelper : this._mercatorCameraHelper;
}
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
return this.currentHelper.handlePanInertia(pan, transform);
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
return this.currentHelper.handleMapControlsRollPitchBearingZoom(deltas, tr);
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void {
this.currentHelper.handleMapControlsPan(deltas, tr, preZoomAroundLoc);
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult {
return this.currentHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
}
/**
* Handles the zoom and center change during camera jumpTo.
*/
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
this.currentHelper.handleJumpToCenterZoom(tr, options);
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
return this.currentHelper.handleEaseTo(tr, options);
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
return this.currentHelper.handleFlyTo(tr, options);
}
}

View File

@@ -0,0 +1,105 @@
import {describe, expect, test} from 'vitest';
import {expectToBeCloseToArray} from '../../util/test/util';
import {GlobeCoveringTilesDetailsProvider} from './globe_covering_tiles_details_provider';
import {ConvexVolume} from '../../util/primitives/convex_volume';
describe('bounding volume creation', () => {
test('z=0', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 0,
y: 0,
z: 0,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[-1, -1, -1],
[1, 1, 1],
));
});
test('z=1,x=0', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 0,
y: 0,
z: 1,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[-1, 0, -1],
[0, 1, 1],
));
});
test('z=1,x=1', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 1,
y: 0,
z: 1,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[0, 0, -1],
[1, 1, 1],
));
});
test('z=5,x=1,y=1', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 1,
y: 1,
z: 5,
}, null, null, null);
const precision = 10;
const expectedMin = [-0.04878262717137475, 0.9918417649235776, -0.1250257487589308];
const expectedMax = [-0.020462724105427713, 0.9944839919477184, -0.09690430455523656];
const expectedPoints = [
[-0.040144275638466294, 0.9946001124628003, -0.09691685469802916],
[-0.04013795776704037, 0.9944589865528525, -0.09690160200714736],
[-0.02046537424682884, 0.9946001124628002, -0.10288638417221826],
[-0.020462153423906553, 0.9944589865528524, -0.10287019200194392],
[-0.04902182691658952, 0.9919123845540323, -0.11834915939433684],
[-0.049015509045163594, 0.9917712586440846, -0.11833390670345505],
[-0.02499111064168652, 0.9919123845540323, -0.1256387974810376],
[-0.02498788981876423, 0.9917712586440844, -0.12562260531076325]
];
const expectedPlanes = [
[0.033568258567807485, -0.9932912960221243, 0.11065971834147033, 1],
[
-0.033568258567807485,
0.9932912960221243,
-0.11065971834147033,
-0.999857920923587
],
[
-0.2883372432854479,
-0.11563909912606864,
-0.9505205062952928,
0.011318113428480242
],
[
0.2883372432854479,
0.11563909912606864,
0.9505205062952928,
0.011924266779254289
],
[
0.9238795325112867,
-3.8143839245115144e-17,
-0.38268343236509017,
0
],
[-0.9807852804032307, 0, 0.19509032201612764, 0]
];
expectToBeCloseToArray([...convex.min], expectedMin, precision);
expectToBeCloseToArray([...convex.max], expectedMax, precision);
expect(convex.points).toHaveLength(expectedPoints.length);
for (let i = 0; i < convex.points.length; i++) {
expectToBeCloseToArray([...convex.points[i]], expectedPoints[i], precision);
}
expect(convex.planes).toHaveLength(expectedPlanes.length);
for (let i = 0; i < convex.planes.length; i++) {
expectToBeCloseToArray([...convex.planes[i]], expectedPlanes[i], precision);
}
});
});

View File

@@ -0,0 +1,311 @@
import {EXTENT} from '../../data/extent';
import {projectTileCoordinatesToSphere} from './globe_utils';
import {BoundingVolumeCache} from '../../util/primitives/bounding_volume_cache';
import {coveringZoomLevel, type CoveringTilesOptionsInternal} from './covering_tiles';
import {vec3, type vec4} from 'gl-matrix';
import type {IReadonlyTransform} from '../transform_interface';
import type {MercatorCoordinate} from '../mercator_coordinate';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
import {OverscaledTileID} from '../../tile/tile_id';
import {earthRadius} from '../lng_lat';
import {ConvexVolume} from '../../util/primitives/convex_volume';
import {threePlaneIntersection} from '../../util/util';
/**
* Computes distance of a point to a tile in an arbitrary axis.
* World is assumed to have size 1, distance returned is to the nearer tile edge.
* @param point - Point position.
* @param tile - Tile position.
* @param tileSize - Tile size.
*/
function distanceToTileSimple(point: number, tile: number, tileSize: number): number {
const delta = point - tile;
return (delta < 0) ? -delta : Math.max(0, delta - tileSize);
}
function distanceToTileWrapX(pointX: number, pointY: number, tileCornerX: number, tileCornerY: number, tileSize: number): number {
const tileCornerToPointX = pointX - tileCornerX;
let distanceX: number;
if (tileCornerToPointX < 0) {
// Point is left of tile
distanceX = Math.min(-tileCornerToPointX, 1.0 + tileCornerToPointX - tileSize);
} else if (tileCornerToPointX > tileSize) {
// Point is right of tile
distanceX = Math.min(Math.max(tileCornerToPointX - tileSize, 0), 1.0 - tileCornerToPointX);
} else {
// Point is inside tile in the X axis.
distanceX = 0;
}
return Math.max(distanceX, distanceToTileSimple(pointY, tileCornerY, tileSize));
}
export class GlobeCoveringTilesDetailsProvider implements CoveringTilesDetailsProvider {
private _boundingVolumeCache: BoundingVolumeCache<ConvexVolume> = new BoundingVolumeCache(this._computeTileBoundingVolume);
/**
* Prepares the internal bounding volume cache for the next frame.
*/
prepareNextFrame() {
this._boundingVolumeCache.swapBuffers();
}
/**
* Returns the distance of a point to a square tile. If the point is inside the tile, returns 0.
* Assumes the world to be of size 1.
* Handles distances on a sphere correctly: X is wrapped when crossing the antimeridian,
* when crossing the poles Y is mirrored and X is shifted by half world size.
*/
distanceToTile2d(pointX: number, pointY: number, tileID: {x: number; y: number; z: number}, _bv: ConvexVolume): number {
const scale = 1 << tileID.z;
const tileMercatorSize = 1.0 / scale;
const tileCornerX = tileID.x / scale; // In range 0..1
const tileCornerY = tileID.y / scale; // In range 0..1
const worldSize = 1.0;
const halfWorld = 0.5 * worldSize;
let smallestDistance = 2.0 * worldSize;
// Original tile
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX, tileCornerY, tileMercatorSize));
// Up
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, -tileCornerY - tileMercatorSize, tileMercatorSize));
// Down
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, worldSize + worldSize - tileCornerY - tileMercatorSize, tileMercatorSize));
return smallestDistance;
}
/**
* Returns the wrap value for a given tile, computed so that tiles will remain loaded when crossing the antimeridian.
*/
getWrap(centerCoord: MercatorCoordinate, tileID: {x: number; y: number; z: number}, _parentWrap: number): number {
const scale = 1 << tileID.z;
const tileMercatorSize = 1.0 / scale;
const tileX = tileID.x / scale; // In range 0..1
const distanceCurrent = distanceToTileSimple(centerCoord.x, tileX, tileMercatorSize);
const distanceLeft = distanceToTileSimple(centerCoord.x, tileX - 1.0, tileMercatorSize);
const distanceRight = distanceToTileSimple(centerCoord.x, tileX + 1.0, tileMercatorSize);
const distanceSmallest = Math.min(distanceCurrent, distanceLeft, distanceRight);
if (distanceSmallest === distanceRight) {
return 1;
}
if (distanceSmallest === distanceLeft) {
return -1;
}
return 0;
}
allowVariableZoom(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): boolean {
return coveringZoomLevel(transform, options) > 4;
}
allowWorldCopies(): boolean {
return false;
}
getTileBoundingVolume(tileID: { x: number; y: number; z: number }, wrap: number, elevation: number, options: CoveringTilesOptionsInternal) {
return this._boundingVolumeCache.getTileBoundingVolume(tileID, wrap, elevation, options);
}
private _computeTileBoundingVolume(tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal): ConvexVolume {
let minElevation = 0;
let maxElevation = 0;
if (options?.terrain) {
const overscaledTileID = new OverscaledTileID(tileID.z, wrap, tileID.z, tileID.x, tileID.y);
const minMax = options.terrain.getMinMaxElevation(overscaledTileID);
minElevation = minMax.minElevation ?? Math.min(0, elevation);
maxElevation = minMax.maxElevation ?? Math.max(0, elevation);
}
// Convert elevation to distances from center of a unit sphere planet (so that 1 is surface)
minElevation /= earthRadius;
maxElevation /= earthRadius;
minElevation += 1;
maxElevation += 1;
if (tileID.z <= 0) {
// Tile covers the entire sphere.
return ConvexVolume.fromAabb( // We return an AABB in this case.
[-maxElevation, -maxElevation, -maxElevation],
[maxElevation, maxElevation, maxElevation]
);
} else if (tileID.z === 1) {
// Tile covers a quarter of the sphere.
// X is 1 at lng=E90°
// Y is 1 at **north** pole
// Z is 1 at null island
return ConvexVolume.fromAabb( // We also just use AABBs for this zoom level.
[tileID.x === 0 ? -maxElevation : 0, tileID.y === 0 ? 0 : -maxElevation, -maxElevation],
[tileID.x === 0 ? 0 : maxElevation, tileID.y === 0 ? maxElevation : 0, maxElevation]
);
} else {
const corners = [
projectTileCoordinatesToSphere(0, 0, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(EXTENT, 0, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(EXTENT, EXTENT, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(0, EXTENT, tileID.x, tileID.y, tileID.z),
];
const extremesPoints = [];
for (const c of corners) {
extremesPoints.push(vec3.scale([] as any, c, maxElevation));
}
if (maxElevation !== minElevation) {
// Only add additional points if terrain is enabled and is not flat.
for (const c of corners) {
extremesPoints.push(vec3.scale([] as any, c, minElevation));
}
}
// Special handling of poles - we need to extend the tile AABB
// to include the pole for tiles that border mercator north/south edge.
if (tileID.y === 0) {
extremesPoints.push([0, 1, 0]); // North pole
}
if (tileID.y === (1 << tileID.z) - 1) {
extremesPoints.push([0, -1, 0]); // South pole
}
// Compute a best-fit AABB for the frustum rejection test
const aabbMin: vec3 = [1, 1, 1];
const aabbMax: vec3 = [-1, -1, -1];
for (const c of extremesPoints) {
for (let i = 0; i < 3; i++) {
aabbMin[i] = Math.min(aabbMin[i], c[i]);
aabbMax[i] = Math.max(aabbMax[i], c[i]);
}
}
// Now we compute the actual bounding volume.
// The up/down plane will be normal to the tile's center.
// The north/south plane will be used for the tile's north and south edge and will be orthogonal to the up/down plane.
// The left and right planes will be determined by the tile's east/west edges and will differ slightly - we are not creating a box!
// We will find the min and max extents for the up/down and north/south planes using the set of points
// where the extremes are likely to lie.
// Vector "center" (from planet center to tile center) will be our up/down axis.
const center = projectTileCoordinatesToSphere(EXTENT / 2, EXTENT / 2, tileID.x, tileID.y, tileID.z);
// Vector to the east of "center".
const centerEast = vec3.cross([] as any, [0, 1, 0], center);
vec3.normalize(centerEast, centerEast);
// Vector to the north of "center" will be our north/south axis.
const north = vec3.cross([] as any, center, centerEast);
vec3.normalize(north, north);
// Axes for the east and west edge of our bounding volume.
// These axes are NOT opposites of each other, they differ!
// They are also not orthogonal to the up/down and north/south axes.
const axisEast = vec3.cross([] as any, corners[2], corners[1]);
vec3.normalize(axisEast, axisEast);
const axisWest = vec3.cross([] as any, corners[0], corners[3]);
vec3.normalize(axisWest, axisWest);
// Now we will expand the extremes point set for bounding volume creation.
// We will also include the tile center point, since it will always be an extreme for the "center" axis.
extremesPoints.push(vec3.scale([] as any, center, maxElevation));
// No need to include a minElevation-scaled center, since we already have minElevation corners in the set and these will always lie lower than the center.
// The extremes might also lie on the midpoint of the north or south edge.
// For tiles in the north hemisphere, only the south edge can contain an extreme,
// since when we imagine the tile's actual shape projected onto the plane normal to "center" vector,
// the tile's north edge will curve towards the tile center, thus its extremes are accounted for by the
// corners, however the south edge will curve away from the center point, extending beyond the tile's edges,
// thus it must be included.
// The poles are an exception - they must always be included in the extremes, if the tile touches the north/south mercator range edge.
//
// A tile's exaggerated shape on the northern hemisphere, projected onto the normal plane of "center".
// The "c" is the tile's center point. The "m" is the edge mid point we are looking for.
//
// /-- --\
// / ------- \
// / \
// / c \
// / \
// /-- --\
// ----- -----
// ---m---
if (tileID.y >= (1 << tileID.z) / 2) {
// South hemisphere - include the tile's north edge midpoint
extremesPoints.push(vec3.scale([] as any, projectTileCoordinatesToSphere(EXTENT / 2, 0, tileID.x, tileID.y, tileID.z), maxElevation));
// No need to include minElevation variant of this point, for the same reason why we don't include minElevation center.
}
if (tileID.y < (1 << tileID.z) / 2) {
// North hemisphere - include the tile's south edge midpoint
extremesPoints.push(vec3.scale([] as any, projectTileCoordinatesToSphere(EXTENT / 2, EXTENT, tileID.x, tileID.y, tileID.z), maxElevation));
// No need to include minElevation variant of this point, for the same reason why we don't include minElevation center.
}
// Find the min and max extends and the midpoints along each axis,
// using the set of extreme points.
const upDownMinMax = findAxisMinMax(center, extremesPoints);
const northSouthMinMax = findAxisMinMax(north, extremesPoints);
const planeUp = [-center[0], -center[1], -center[2], upDownMinMax.max] as vec4;
const planeDown = [center[0], center[1], center[2], -upDownMinMax.min] as vec4;
const planeNorth = [-north[0], -north[1], -north[2], northSouthMinMax.max] as vec4;
const planeSouth = [north[0], north[1], north[2], -northSouthMinMax.min] as vec4;
const planeEast = [...axisEast, 0] as vec4;
const planeWest = [...axisWest, 0] as vec4;
const points: vec3[] = [];
// North points
if (tileID.y === 0) {
// If the tile borders a pole, then
points.push(
threePlaneIntersection(planeWest, planeEast, planeUp),
threePlaneIntersection(planeWest, planeEast, planeDown),
);
} else {
points.push(
threePlaneIntersection(planeNorth, planeEast, planeUp),
threePlaneIntersection(planeNorth, planeEast, planeDown),
threePlaneIntersection(planeNorth, planeWest, planeUp),
threePlaneIntersection(planeNorth, planeWest, planeDown)
);
}
// South points
if (tileID.y === (1 << tileID.z) - 1) {
points.push(
threePlaneIntersection(planeWest, planeEast, planeUp),
threePlaneIntersection(planeWest, planeEast, planeDown),
);
} else {
points.push(
threePlaneIntersection(planeSouth, planeEast, planeUp),
threePlaneIntersection(planeSouth, planeEast, planeDown),
threePlaneIntersection(planeSouth, planeWest, planeUp),
threePlaneIntersection(planeSouth, planeWest, planeDown)
);
}
return new ConvexVolume(points, [
planeUp,
planeDown,
planeNorth,
planeSouth,
planeEast,
planeWest
], aabbMin, aabbMax);
}
}
}
function findAxisMinMax(axis: vec3, points: vec3[]) {
let min = +Infinity;
let max = -Infinity;
for (const c of points) {
const dot = vec3.dot(axis, c);
min = Math.min(min, dot);
max = Math.max(max, dot);
}
return {
min,
max
};
}

View File

@@ -0,0 +1,139 @@
import {ProjectionDefinition, type ProjectionDefinitionSpecification, type ProjectionSpecification, type StylePropertySpecification, latest as styleSpec} from '@maplibre/maplibre-gl-style-spec';
import {DataConstantProperty, type PossiblyEvaluated, Properties, Transitionable, type Transitioning, type TransitionParameters} from '../../style/properties';
import {Evented} from '../../util/evented';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import {MercatorProjection} from './mercator_projection';
import {VerticalPerspectiveProjection} from './vertical_perspective_projection';
import {type Projection, type ProjectionGPUContext, type TileMeshUsage} from './projection';
import {type PreparedShader} from '../../shaders/shaders';
import {type SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {type Context} from '../../gl/context';
import {type CanonicalTileID} from '../../tile/tile_id';
import {type Mesh} from '../../render/mesh';
type ProjectionProps = {
type: DataConstantProperty<ProjectionDefinition>;
};
type ProjectionPossiblyEvaluated = {
type: ProjectionDefinitionSpecification;
};
const properties: Properties<ProjectionProps> = new Properties({
'type': new DataConstantProperty(styleSpec.projection.type as StylePropertySpecification)
});
export class GlobeProjection extends Evented implements Projection {
properties: PossiblyEvaluated<ProjectionProps, ProjectionPossiblyEvaluated>;
_transitionable: Transitionable<ProjectionProps>;
_transitioning: Transitioning<ProjectionProps>;
_mercatorProjection: MercatorProjection;
_verticalPerspectiveProjection: VerticalPerspectiveProjection;
constructor(projection?: ProjectionSpecification) {
super();
this._transitionable = new Transitionable(properties, undefined);
this.setProjection(projection);
this._transitioning = this._transitionable.untransitioned();
this.recalculate(new EvaluationParameters(0));
this._mercatorProjection = new MercatorProjection();
this._verticalPerspectiveProjection = new VerticalPerspectiveProjection();
}
public get transitionState(): number {
const currentProjectionSpecValue = this.properties.get('type');
if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'mercator') {
return 0;
}
if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'vertical-perspective') {
return 1;
}
if (currentProjectionSpecValue instanceof ProjectionDefinition) {
if (currentProjectionSpecValue.from === 'vertical-perspective' && currentProjectionSpecValue.to === 'mercator') {
return 1 - currentProjectionSpecValue.transition;
}
if (currentProjectionSpecValue.from === 'mercator' && currentProjectionSpecValue.to === 'vertical-perspective') {
return currentProjectionSpecValue.transition;
}
};
return 1;
}
get useGlobeRendering(): boolean {
return this.transitionState > 0;
}
get latitudeErrorCorrectionRadians(): number { return this._verticalPerspectiveProjection.latitudeErrorCorrectionRadians; }
private get currentProjection(): Projection {
return this.useGlobeRendering ? this._verticalPerspectiveProjection : this._mercatorProjection;
}
get name(): ProjectionSpecification['type'] {
return 'globe';
}
get useSubdivision(): boolean {
return this.currentProjection.useSubdivision;
}
get shaderVariantName(): string {
return this.currentProjection.shaderVariantName;
}
get shaderDefine(): string {
return this.currentProjection.shaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return this.currentProjection.shaderPreludeCode;
}
get vertexShaderPreludeCode(): string {
return this.currentProjection.vertexShaderPreludeCode;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return this.currentProjection.subdivisionGranularity;
}
get useGlobeControls(): boolean {
return this.transitionState > 0;
}
public destroy(): void {
this._mercatorProjection.destroy();
this._verticalPerspectiveProjection.destroy();
}
public updateGPUdependent(context: ProjectionGPUContext): void {
this._mercatorProjection.updateGPUdependent(context);
this._verticalPerspectiveProjection.updateGPUdependent(context);
}
public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh {
return this.currentProjection.getMeshFromTileID(context, _tileID, _hasBorder, _allowPoles, _usage);
}
setProjection(projection?: ProjectionSpecification) {
this._transitionable.setValue('type', projection?.type || 'mercator');
}
updateTransitions(parameters: TransitionParameters) {
this._transitioning = this._transitionable.transitioned(parameters, this._transitioning);
}
hasTransition(): boolean {
return this._transitioning.hasTransition() || this.currentProjection.hasTransition();
}
recalculate(parameters: EvaluationParameters) {
this.properties = this._transitioning.possiblyEvaluate(parameters);
}
setErrorQueryLatitudeDegrees(value: number) {
this._verticalPerspectiveProjection.setErrorQueryLatitudeDegrees(value);
this._mercatorProjection.setErrorQueryLatitudeDegrees(value);
}
}

View File

@@ -0,0 +1,243 @@
import {Color} from '@maplibre/maplibre-gl-style-spec';
import {ColorMode} from '../../gl/color_mode';
import {CullFaceMode} from '../../gl/cull_face_mode';
import {DepthMode} from '../../gl/depth_mode';
import {StencilMode} from '../../gl/stencil_mode';
import {warnOnce} from '../../util/util';
import {projectionErrorMeasurementUniformValues} from '../../render/program/projection_error_measurement_program';
import {Mesh} from '../../render/mesh';
import {SegmentVector} from '../../data/segment';
import {PosArray, TriangleIndexArray} from '../../data/array_types.g';
import posAttributes from '../../data/pos_attributes';
import {type Framebuffer} from '../../gl/framebuffer';
import {isWebGL2} from '../../gl/webgl2';
import {type ProjectionGPUContext} from './projection';
/**
* For vector globe the vertex shader projects mercator coordinates to angular coordinates on a sphere.
* This projection requires some inverse trigonometry `atan(exp(...))`, which is inaccurate on some GPUs (mainly on AMD and Nvidia).
* The inaccuracy is severe enough to require a workaround. The uncorrected map is shifted north-south by up to several hundred meters in some latitudes.
* Since the inaccuracy is hardware-dependant and may change in the future, we need to measure the error at runtime.
*
* Our approach relies on several assumptions:
*
* - the error is only present in the "latitude" component (longitude doesn't need any inverse trigonometry)
* - the error is continuous and changes slowly with latitude
* - at zoom levels where the error is noticeable, the error is more-or-less the same across the entire visible map area (and thus can be described with a single number)
*
* Solution:
*
* Every few frames, launch a GPU shader that measures the error for the current map center latitude, and writes it to a 1x1 texture.
* Read back that texture, and offset the globe projection matrix according to the error (interpolating smoothly from old error to new error if needed).
* The texture readback is done asynchronously using Pixel Pack Buffers (WebGL2) when possible, and has a few frames of latency, but that should not be a problem.
*
* General operation of this class each frame is:
*
* - render the error shader into a fbo, read that pixel into a PBO, place a fence
* - wait a few frames to allow the GPU (and driver) to actually execute the shader
* - wait for the fence to be signalled (guaranteeing the shader to actually be executed)
* - read back the PBO's contents
* - wait a few more frames
* - repeat
*/
export class ProjectionErrorMeasurement {
// We wait at least this many frames after measuring until we read back the value.
// After this period, we might wait more frames until a fence is signalled to make sure the rendering is completed.
private readonly _readbackWaitFrames = 4;
// We wait this many frames after *reading back* a measurement until we trigger measure again.
// We could in theory render the measurement pixel immediately, but we wait to make sure
// no pipeline stall happens.
private readonly _measureWaitFrames = 6;
private readonly _texWidth = 1;
private readonly _texHeight = 1;
private readonly _texFormat: number;
private readonly _texType: number;
private _fullscreenTriangle: Mesh;
private _fbo: Framebuffer;
private _resultBuffer: Uint8Array;
private _pbo: WebGLBuffer;
private _cachedRenderContext: ProjectionGPUContext;
private _measuredError: number = 0; // Result of last measurement
private _updateCount: number = 0;
private _lastReadbackFrame: number = -1000;
get awaitingQuery(): boolean {
return !!this._readbackQueue;
}
// There is never more than one readback waiting
private _readbackQueue: {
frameNumberIssued: number; // Frame number when the data was first computed
sync: WebGLSync;
} = null;
public constructor(renderContext: ProjectionGPUContext) {
this._cachedRenderContext = renderContext;
const context = renderContext.context;
const gl = context.gl;
this._texFormat = gl.RGBA;
this._texType = gl.UNSIGNED_BYTE;
const vertexArray = new PosArray();
vertexArray.emplaceBack(-1, -1);
vertexArray.emplaceBack(2, -1);
vertexArray.emplaceBack(-1, 2);
const indexArray = new TriangleIndexArray();
indexArray.emplaceBack(0, 1, 2);
this._fullscreenTriangle = new Mesh(
context.createVertexBuffer(vertexArray, posAttributes.members),
context.createIndexBuffer(indexArray),
SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length)
);
this._resultBuffer = new Uint8Array(4);
context.activeTexture.set(gl.TEXTURE1);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, this._texFormat, this._texWidth, this._texHeight, 0, this._texFormat, this._texType, null);
this._fbo = context.createFramebuffer(this._texWidth, this._texHeight, false, false);
this._fbo.colorAttachment.set(texture);
if (isWebGL2(gl)) {
this._pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.bufferData(gl.PIXEL_PACK_BUFFER, 4, gl.STREAM_READ);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
}
}
public destroy() {
const gl = this._cachedRenderContext.context.gl;
this._fullscreenTriangle.destroy();
this._fbo.destroy();
gl.deleteBuffer(this._pbo);
this._fullscreenTriangle = null;
this._fbo = null;
this._pbo = null;
this._resultBuffer = null;
}
public updateErrorLoop(normalizedMercatorY: number, expectedAngleY: number): number {
const currentFrame = this._updateCount;
if (this._readbackQueue) {
// Try to read back if enough frames elapsed. Otherwise do nothing, just wait another frame.
if (currentFrame >= this._readbackQueue.frameNumberIssued + this._readbackWaitFrames) {
// Try to read back - it is possible that this method does nothing, then
// the readback queue will not be cleared and we will retry next frame.
this._tryReadback();
}
} else {
if (currentFrame >= this._lastReadbackFrame + this._measureWaitFrames) {
this._renderErrorTexture(normalizedMercatorY, expectedAngleY);
}
}
this._updateCount++;
return this._measuredError;
}
private _bindFramebuffer() {
const context = this._cachedRenderContext.context;
const gl = context.gl;
context.activeTexture.set(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._fbo.colorAttachment.get());
context.bindFramebuffer.set(this._fbo.framebuffer);
}
private _renderErrorTexture(input: number, outputExpected: number): void {
const context = this._cachedRenderContext.context;
const gl = context.gl;
// Update framebuffer contents
this._bindFramebuffer();
context.viewport.set([0, 0, this._texWidth, this._texHeight]);
context.clear({color: Color.transparent});
const program = this._cachedRenderContext.useProgram('projectionErrorMeasurement');
program.draw(context, gl.TRIANGLES,
DepthMode.disabled, StencilMode.disabled,
ColorMode.unblended, CullFaceMode.disabled,
projectionErrorMeasurementUniformValues(input, outputExpected), null, null,
'$clipping', this._fullscreenTriangle.vertexBuffer, this._fullscreenTriangle.indexBuffer,
this._fullscreenTriangle.segments);
if (this._pbo && isWebGL2(gl)) {
// Read back into PBO
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.readBuffer(gl.COLOR_ATTACHMENT0);
gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, 0);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.flush();
this._readbackQueue = {
frameNumberIssued: this._updateCount,
sync,
};
} else {
// Read it back later.
this._readbackQueue = {
frameNumberIssued: this._updateCount,
sync: null,
};
}
}
private _tryReadback(): void {
const gl = this._cachedRenderContext.context.gl;
if (this._pbo && this._readbackQueue && isWebGL2(gl)) {
// WebGL 2 path
const waitResult = gl.clientWaitSync(this._readbackQueue.sync, 0, 0);
if (waitResult === gl.WAIT_FAILED) {
warnOnce('WebGL2 clientWaitSync failed.');
this._readbackQueue = null;
this._lastReadbackFrame = this._updateCount;
return;
}
if (waitResult === gl.TIMEOUT_EXPIRED) {
return; // Wait one more frame
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this._resultBuffer, 0, 4);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
} else {
// WebGL1 compatible
this._bindFramebuffer();
gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, this._resultBuffer);
}
// If we made it here, _resultBuffer contains the new measurement
this._readbackQueue = null;
this._measuredError = ProjectionErrorMeasurement._parseRGBA8float(this._resultBuffer);
this._lastReadbackFrame = this._updateCount;
}
private static _parseRGBA8float(buffer: Uint8Array): number {
let result = 0;
result += buffer[0] / 256.0;
result += buffer[1] / 65536.0;
result += buffer[2] / 16777216.0;
if (buffer[3] < 127.0) {
result = -result;
}
return result / 128.0;
}
}

View File

@@ -0,0 +1,606 @@
import {describe, expect, test} from 'vitest';
import {EXTENT} from '../../data/extent';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {GlobeTransform} from './globe_transform';
import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../tile/tile_id';
import {angularCoordinatesRadiansToVector, mercatorCoordinatesToAngularCoordinatesRadians, sphereSurfacePointToCoordinates} from './globe_utils';
import {expectToBeCloseToArray} from '../../util/test/util';
import {MercatorCoordinate} from '../mercator_coordinate';
import {tileCoordinatesToLocation} from './mercator_utils';
import {MercatorTransform} from './mercator_transform';
import {globeConstants} from './vertical_perspective_projection';
function testPlaneAgainstLngLat(lngDegrees: number, latDegrees: number, plane: Array<number>) {
const lat = latDegrees / 180.0 * Math.PI;
const lng = lngDegrees / 180.0 * Math.PI;
const len = Math.cos(lat);
const pointOnSphere = [
Math.sin(lng) * len,
Math.sin(lat),
Math.cos(lng) * len
];
return planeDistance(pointOnSphere, plane);
}
function planeDistance(point: Array<number>, plane: Array<number>) {
return point[0] * plane[0] + point[1] * plane[1] + point[2] * plane[2] + plane[3];
}
function createGlobeTransform() {
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
globeTransform.setFov(45);
return globeTransform;
}
describe('GlobeTransform', () => {
// Force faster animations so we can use shorter sleeps when testing them
globeConstants.errorTransitionTimeSeconds = 0.1;
describe('getProjectionData', () => {
const globeTransform = createGlobeTransform();
test('mercator tile extents are set', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
});
test('Globe transition is 0 when not applying the globe matrix', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
expect(projectionData.projectionTransition).toBe(0);
});
test('Applying the globe matrix sets transition to something different than 0', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0), applyGlobeMatrix: true});
expect(projectionData.projectionTransition).not.toBe(0);
});
});
describe('clipping plane', () => {
const globeTransform = createGlobeTransform();
describe('general plane properties', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0)});
test('plane vector length <= 1 so they are not clipped by the near plane.', () => {
const len = Math.sqrt(
projectionData.clippingPlane[0] * projectionData.clippingPlane[0] +
projectionData.clippingPlane[1] * projectionData.clippingPlane[1] +
projectionData.clippingPlane[2] * projectionData.clippingPlane[2]
);
expect(len).toBeLessThanOrEqual(1);
});
test('camera is in positive halfspace', () => {
expect(planeDistance(globeTransform.cameraPosition as [number, number, number], projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 0E,0N are in positive halfspace', () => {
expect(testPlaneAgainstLngLat(0, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 40E,0N are in positive halfspace', () => {
expect(testPlaneAgainstLngLat(40, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 0E,90N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(0, 90, projectionData.clippingPlane)).toBeLessThan(0);
});
test('coordinates 90E,0N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(90, 0, projectionData.clippingPlane)).toBeLessThan(0);
});
test('coordinates 180E,0N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(180, 0, projectionData.clippingPlane)).toBeLessThan(0);
});
});
});
describe('projection', () => {
test('mercator coordinate to sphere point', () => {
const precisionDigits = 10;
let projectedAngles;
let projected;
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0.5);
expectToBeCloseToArray(projectedAngles, [0, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0, 1], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0, 0.5);
expectToBeCloseToArray(projectedAngles, [Math.PI, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0, -1], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.75, 0.5);
expectToBeCloseToArray(projectedAngles, [Math.PI / 2.0, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [1, 0, 0], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0);
expectToBeCloseToArray(projectedAngles, [0, 1.4844222297453324], precisionDigits); // ~0.47pi
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0.99627207622075, 0.08626673833405434], precisionDigits);
});
test('camera position', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [0, 0, 8.110445867263898], precisionDigits);
globeTransform.resize(512, 512);
globeTransform.setZoom(-0.5);
globeTransform.setCenter(new LngLat(0, 80));
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [0, 2.2818294674820794, 0.40234810049271963], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
globeTransform.setRoll(40);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
globeTransform.setRoll(180);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setCenter(new LngLat(-10, 42));
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-3.8450970996236364, 2.9368285470351516, 4.311953269048194], precisionDigits);
});
test('sphere point to coordinate', () => {
const precisionDigits = 10;
let unprojected = sphereSurfacePointToCoordinates([0, 0, 1]) as LngLat;
expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
unprojected = sphereSurfacePointToCoordinates([0, 1, 0]) as LngLat;
expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
expect(unprojected.lat).toBeCloseTo(90, precisionDigits);
unprojected = sphereSurfacePointToCoordinates([1, 0, 0]) as LngLat;
expect(unprojected.lng).toBeCloseTo(90, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
});
const screenCenter = new Point(640 / 2, 480 / 2); // We need the exact screen center
const screenTopEdgeCenter = new Point(640 / 2, 0);
describe('project location to coordinates', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
test('basic test', () => {
globeTransform.setCenter(new LngLat(0, 0));
let projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
globeTransform.setCenter(new LngLat(70, 50));
projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
globeTransform.setCenter(new LngLat(0, 84));
projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
});
test('project a location that is slightly above and below map\'s center point', () => {
globeTransform.setCenter(new LngLat(0, 0));
let projected = globeTransform.locationToScreenPoint(new LngLat(0, 1));
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeLessThan(screenCenter.y);
projected = globeTransform.locationToScreenPoint(new LngLat(0, -1));
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeGreaterThan(screenCenter.y);
});
});
describe('unproject', () => {
test('unproject screen center', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
globeTransform.setCenter(new LngLat(90.0, 0.0));
unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
globeTransform.setCenter(new LngLat(0.0, 60.0));
unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
});
test('unproject point to the side', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let coords: LngLat;
let projected: Point;
let unprojected: LngLat;
coords = new LngLat(0, 0);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
coords = new LngLat(10, 20);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
coords = new LngLat(15, -2);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
});
test('unproject behind the pole', () => {
// This test tries to unproject a point that is beyond the north pole
// from the camera's point of view.
// This particular case turned out to be problematic, hence this test.
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
// Transform settings from the render test projection/globe/fill-planet-pole
// See the expected result for how the globe should look with this transform.
globeTransform.resize(512, 512);
globeTransform.setZoom(-0.5);
globeTransform.setCenter(new LngLat(0, 80));
let coords: LngLat;
let projected: Point;
let unprojected: LngLat;
coords = new LngLat(179.9, 71);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(projected.x).toBeCloseTo(256.2434702034287, precisionDigits);
expect(projected.y).toBeCloseTo(48.27080146399297, precisionDigits);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
// Near the pole
coords = new LngLat(179.9, 89.0);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(projected.x).toBeCloseTo(256.0140972925064, precisionDigits);
expect(projected.y).toBeCloseTo(167.69159699932908, precisionDigits);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
});
test('unproject outside of sphere', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
// Try unprojection a point somewhere above the western horizon
globeTransform.setPitch(60);
globeTransform.setBearing(-90);
const unprojected = globeTransform.screenPointToLocation(screenTopEdgeCenter);
expect(unprojected.lng).toBeCloseTo(-28.990298145461963, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0.0, precisionDigits);
});
test('unproject further outside of sphere clamps to horizon', () => {
const globeTransform = createGlobeTransform();
globeTransform.setPitch(60);
globeTransform.setBearing(-90);
const screenPointAboveWesternHorizon = screenTopEdgeCenter;
const screenPointFurtherAboveWesternHorizon = screenTopEdgeCenter.sub(new Point(0, -100));
const unprojected = globeTransform.screenPointToLocation(screenPointAboveWesternHorizon);
const unprojected2 = globeTransform.screenPointToLocation(screenPointFurtherAboveWesternHorizon);
expect(unprojected.lat).toBeCloseTo(unprojected2.lat, 10);
expect(unprojected.lng).toBeCloseTo(unprojected2.lng, 10);
});
});
describe('setLocationAtPoint', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
globeTransform.setZoom(1);
let coords: LngLat;
let point: Point;
let projected: Point;
let unprojected: LngLat;
test('identity', () => {
// Should do nothing
coords = new LngLat(0, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset lnglat', () => {
coords = new LngLat(5, 10);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset pixel + lnglat', () => {
coords = new LngLat(5, 10);
point = new Point(330, 240); // 10 pixels to the right
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('larger offset', () => {
coords = new LngLat(30, -2);
point = new Point(250, 180);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
describe('rotated', () => {
globeTransform.setBearing(90);
test('identity', () => {
// Should do nothing
coords = new LngLat(0, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset lnglat', () => {
coords = new LngLat(5, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset pixel + lnglat', () => {
coords = new LngLat(0, 10);
point = new Point(350, 240); // 30 pixels to the right
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
expect(globeTransform.center.lat).toBeCloseTo(20.659450722109348, precisionDigits);
});
});
});
});
describe('isPointOnMapSurface', () => {
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
globeTransform.setZoom(1);
test('Top screen edge', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 0))).toBe(false);
});
test('Screen center', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 240))).toBe(true);
});
test('Top', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 104))).toBe(false);
expect(globeTransform.isPointOnMapSurface(new Point(320, 105))).toBe(true);
});
test('Bottom', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 105))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 104))).toBe(false);
});
test('Left', () => {
expect(globeTransform.isPointOnMapSurface(new Point(184, 240))).toBe(false);
expect(globeTransform.isPointOnMapSurface(new Point(185, 240))).toBe(true);
});
test('Right', () => {
expect(globeTransform.isPointOnMapSurface(new Point(640 - 185, 240))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(640 - 184, 240))).toBe(false);
});
test('Diagonal', () => {
expect(globeTransform.isPointOnMapSurface(new Point(223, 147))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(221, 144))).toBe(false);
});
});
test('pointCoordinate', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let coords: LngLat;
let coordsMercator: MercatorCoordinate;
let projected: Point;
let unprojectedCoordinates: MercatorCoordinate;
coords = new LngLat(0, 0);
coordsMercator = MercatorCoordinate.fromLngLat(coords);
projected = globeTransform.locationToScreenPoint(coords);
unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);
coords = new LngLat(10, 20);
coordsMercator = MercatorCoordinate.fromLngLat(coords);
projected = globeTransform.locationToScreenPoint(coords);
unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);
});
describe('getBounds', () => {
const precisionDigits = 10;
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
test('basic', () => {
globeTransform.setCenter(new LngLat(0, 0));
globeTransform.setZoom(1);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(79.3636705287052, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(79.36367052870514, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-79.3636705287052, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-79.3636705287052, precisionDigits);
});
test('zoomed in', () => {
globeTransform.setCenter(new LngLat(0, 0));
globeTransform.setZoom(4);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(11.76627084591695, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(16.124697669965144, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-11.76627084591695, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-16.124697669965144, precisionDigits);
});
test('looking at south pole', () => {
globeTransform.setCenter(new LngLat(0, -84));
globeTransform.setZoom(-2);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(-6.299534770946991, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(180, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-90, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-180, precisionDigits);
});
test('looking at south edge of mercator', () => {
globeTransform.setCenter(new LngLat(-163, -83));
globeTransform.setZoom(3);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(-79.75570418234764, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(-124.19771985801174, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-85.59109073899032, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-201.80228014198985, precisionDigits);
});
});
describe('projectTileCoordinates', () => {
const precisionDigits = 10;
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(-1);
test('basic', () => {
const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.008635590705360347, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.16970500709841846, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(781.0549201758624, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('rotated', () => {
transform.setBearing(12);
transform.setPitch(10);
const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(-0.026585319983152694, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.15506884411121183, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(788.4423931260653, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('occluded by planet', () => {
transform.setBearing(-90);
transform.setPitch(60);
const projection = transform.projectTileCoordinates(8192, 8192, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.22428309892086878, precisionDigits);
expect(projection.point.y).toBeCloseTo(-0.4462620847133465, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(822.280942015371, precisionDigits);
expect(projection.isOccluded).toBe(true);
});
});
describe('isLocationOccluded', () => {
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(0.0, 0.0));
transform.setZoom(-1);
test('center', () => {
expect(transform.isLocationOccluded(new LngLat(0, 0))).toBe(false);
});
test('center from tile', () => {
expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 1, 1)))).toBe(false);
});
test('backside', () => {
expect(transform.isLocationOccluded(new LngLat(179.9, 0))).toBe(true);
});
test('backside from tile', () => {
expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 0, 1)))).toBe(true);
});
test('barely visible', () => {
expect(transform.isLocationOccluded(new LngLat(84.49, 0))).toBe(false);
});
test('barely hidden', () => {
expect(transform.isLocationOccluded(new LngLat(84.50, 0))).toBe(true);
});
});
describe('render world copies', () => {
test('change projection and make sure render world copies is kept', () => {
const globeTransform = createGlobeTransform();
globeTransform.setRenderWorldCopies(true);
expect(globeTransform.renderWorldCopies).toBeTruthy();
});
test('change transform and make sure render world copies is kept', () => {
const globeTransform = createGlobeTransform();
globeTransform.setRenderWorldCopies(true);
const mercator = new MercatorTransform({minZoom: 0, maxZoom: 1, minPitch: 2, maxPitch: 3, renderWorldCopies: false});
mercator.apply(globeTransform, false);
expect(mercator.renderWorldCopies).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,471 @@
import type {mat2, mat4, vec3, vec4} from 'gl-matrix';
import {TransformHelper} from '../transform_helper';
import {MercatorTransform} from './mercator_transform';
import {VerticalPerspectiveTransform} from './vertical_perspective_transform';
import {type LngLat, type LngLatLike,} from '../lng_lat';
import {lerp} from '../../util/util';
import type {OverscaledTileID, UnwrappedTileID, CanonicalTileID} from '../../tile/tile_id';
import type Point from '@mapbox/point-geometry';
import type {MercatorCoordinate} from '../mercator_coordinate';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {Frustum} from '../../util/primitives/frustum';
import type {Terrain} from '../../render/terrain';
import type {PointProjection} from '../../symbol/projection';
import type {IReadonlyTransform, ITransform, TransformConstrainFunction} from '../transform_interface';
import type {TransformOptions} from '../transform_helper';
import type {PaddingOptions} from '../edge_insets';
import type {ProjectionData, ProjectionDataParams} from './projection_data';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
/**
* Globe transform is a transform that moves between vertical perspective and mercator projections.
*/
export class GlobeTransform implements ITransform {
private _helper: TransformHelper;
//
// Implementation of transform getters and setters
//
get pixelsToClipSpaceMatrix(): mat4 {
return this._helper.pixelsToClipSpaceMatrix;
}
get clipSpaceToPixelsMatrix(): mat4 {
return this._helper.clipSpaceToPixelsMatrix;
}
get pixelsToGLUnits(): [number, number] {
return this._helper.pixelsToGLUnits;
}
get centerOffset(): Point {
return this._helper.centerOffset;
}
get size(): Point {
return this._helper.size;
}
get rotationMatrix(): mat2 {
return this._helper.rotationMatrix;
}
get centerPoint(): Point {
return this._helper.centerPoint;
}
get pixelsPerMeter(): number {
return this._helper.pixelsPerMeter;
}
setMinZoom(zoom: number): void {
this._helper.setMinZoom(zoom);
}
setMaxZoom(zoom: number): void {
this._helper.setMaxZoom(zoom);
}
setMinPitch(pitch: number): void {
this._helper.setMinPitch(pitch);
}
setMaxPitch(pitch: number): void {
this._helper.setMaxPitch(pitch);
}
setRenderWorldCopies(renderWorldCopies: boolean): void {
this._helper.setRenderWorldCopies(renderWorldCopies);
}
setBearing(bearing: number): void {
this._helper.setBearing(bearing);
}
setPitch(pitch: number): void {
this._helper.setPitch(pitch);
}
setRoll(roll: number): void {
this._helper.setRoll(roll);
}
setFov(fov: number): void {
this._helper.setFov(fov);
}
setZoom(zoom: number): void {
this._helper.setZoom(zoom);
}
setCenter(center: LngLat): void {
this._helper.setCenter(center);
}
setElevation(elevation: number): void {
this._helper.setElevation(elevation);
}
setMinElevationForCurrentTile(elevation: number): void {
this._helper.setMinElevationForCurrentTile(elevation);
}
setPadding(padding: PaddingOptions): void {
this._helper.setPadding(padding);
}
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
return this._helper.interpolatePadding(start, target, t);
}
isPaddingEqual(padding: PaddingOptions): boolean {
return this._helper.isPaddingEqual(padding);
}
resize(width: number, height: number, constrainTransform: boolean = true): void {
this._helper.resize(width, height, constrainTransform);
}
getMaxBounds(): LngLatBounds {
return this._helper.getMaxBounds();
}
setMaxBounds(bounds?: LngLatBounds): void {
this._helper.setMaxBounds(bounds);
}
setConstrainOverride(constrain?: TransformConstrainFunction | null): void {
this._helper.setConstrainOverride(constrain);
}
overrideNearFarZ(nearZ: number, farZ: number): void {
this._helper.overrideNearFarZ(nearZ, farZ);
}
clearNearFarZOverride(): void {
this._helper.clearNearFarZOverride();
}
getCameraQueryGeometry(queryGeometry: Point[]): Point[] {
return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry);
}
get tileSize(): number {
return this._helper.tileSize;
}
get tileZoom(): number {
return this._helper.tileZoom;
}
get scale(): number {
return this._helper.scale;
}
get worldSize(): number {
return this._helper.worldSize;
}
get width(): number {
return this._helper.width;
}
get height(): number {
return this._helper.height;
}
get lngRange(): [number, number] {
return this._helper.lngRange;
}
get latRange(): [number, number] {
return this._helper.latRange;
}
get minZoom(): number {
return this._helper.minZoom;
}
get maxZoom(): number {
return this._helper.maxZoom;
}
get zoom(): number {
return this._helper.zoom;
}
get center(): LngLat {
return this._helper.center;
}
get minPitch(): number {
return this._helper.minPitch;
}
get maxPitch(): number {
return this._helper.maxPitch;
}
get pitch(): number {
return this._helper.pitch;
}
get pitchInRadians(): number {
return this._helper.pitchInRadians;
}
get roll(): number {
return this._helper.roll;
}
get rollInRadians(): number {
return this._helper.rollInRadians;
}
get bearing(): number {
return this._helper.bearing;
}
get bearingInRadians(): number {
return this._helper.bearingInRadians;
}
get fov(): number {
return this._helper.fov;
}
get fovInRadians(): number {
return this._helper.fovInRadians;
}
get elevation(): number {
return this._helper.elevation;
}
get minElevationForCurrentTile(): number {
return this._helper.minElevationForCurrentTile;
}
get padding(): PaddingOptions {
return this._helper.padding;
}
get unmodified(): boolean {
return this._helper.unmodified;
}
get renderWorldCopies(): boolean {
return this._helper.renderWorldCopies;
}
get cameraToCenterDistance(): number {
return this._helper.cameraToCenterDistance;
}
get constrainOverride(): TransformConstrainFunction {
return this._helper.constrainOverride;
}
public get nearZ(): number {
return this._helper.nearZ;
}
public get farZ(): number {
return this._helper.farZ;
}
public get autoCalculateNearFarZ(): boolean {
return this._helper.autoCalculateNearFarZ;
}
//
// Implementation of globe transform
//
private _globeLatitudeErrorCorrectionRadians: number = 0;
/**
* True when globe render path should be used instead of the old but simpler mercator rendering.
* Globe automatically transitions to mercator at high zoom levels, which causes a switch from
* globe to mercator render path.
*/
get isGlobeRendering(): boolean {
return this._globeness > 0;
}
setTransitionState(globeness: number, errorCorrectionValue: number): void {
this._globeness = globeness;
this._globeLatitudeErrorCorrectionRadians = errorCorrectionValue;
this._calcMatrices();
this._verticalPerspectiveTransform.getCoveringTilesDetailsProvider().prepareNextFrame();
this._mercatorTransform.getCoveringTilesDetailsProvider().prepareNextFrame();
}
private get currentTransform(): ITransform {
return this.isGlobeRendering ? this._verticalPerspectiveTransform : this._mercatorTransform;
}
/**
* Globe projection can smoothly interpolate between globe view and mercator. This variable controls this interpolation.
* Value 0 is mercator, value 1 is globe, anything between is an interpolation between the two projections.
*/
private _globeness: number = 1.0;
private _mercatorTransform: MercatorTransform;
private _verticalPerspectiveTransform: VerticalPerspectiveTransform;
public constructor(options?: TransformOptions) {
this._helper = new TransformHelper({
calcMatrices: () => { this._calcMatrices(); },
defaultConstrain: (center, zoom) => { return this.defaultConstrain(center, zoom); }
}, options);
this._globeness = 1; // When transform is cloned for use in symbols, `_updateAnimation` function which usually sets this value never gets called.
this._mercatorTransform = new MercatorTransform();
this._verticalPerspectiveTransform = new VerticalPerspectiveTransform();
}
clone(): ITransform {
const clone = new GlobeTransform();
clone._globeness = this._globeness;
clone._globeLatitudeErrorCorrectionRadians = this._globeLatitudeErrorCorrectionRadians;
clone.apply(this, false);
return clone;
}
public apply(that: IReadonlyTransform, constrain: boolean): void {
this._helper.apply(that, constrain);
this._mercatorTransform.apply(this, false);
this._verticalPerspectiveTransform.apply(this, false, this._globeLatitudeErrorCorrectionRadians);
}
public get projectionMatrix(): mat4 { return this.currentTransform.projectionMatrix; }
public get modelViewProjectionMatrix(): mat4 { return this.currentTransform.modelViewProjectionMatrix; }
public get inverseProjectionMatrix(): mat4 { return this.currentTransform.inverseProjectionMatrix; }
public get cameraPosition(): vec3 { return this.currentTransform.cameraPosition; }
getProjectionData(params: ProjectionDataParams): ProjectionData {
const mercatorProjectionData = this._mercatorTransform.getProjectionData(params);
const verticalPerspectiveProjectionData = this._verticalPerspectiveTransform.getProjectionData(params);
return {
mainMatrix: this.isGlobeRendering ? verticalPerspectiveProjectionData.mainMatrix : mercatorProjectionData.mainMatrix,
clippingPlane: verticalPerspectiveProjectionData.clippingPlane,
tileMercatorCoords: verticalPerspectiveProjectionData.tileMercatorCoords,
projectionTransition: params.applyGlobeMatrix ? this._globeness : 0,
fallbackMatrix: mercatorProjectionData.fallbackMatrix,
};
}
public isLocationOccluded(location: LngLat): boolean {
return this.currentTransform.isLocationOccluded(location);
}
public transformLightDirection(dir: vec3): vec3 {
return this.currentTransform.transformLightDirection(dir);
}
public getPixelScale(): number {
return lerp(this._mercatorTransform.getPixelScale(), this._verticalPerspectiveTransform.getPixelScale(), this._globeness);
}
public getCircleRadiusCorrection(): number {
return lerp(this._mercatorTransform.getCircleRadiusCorrection(), this._verticalPerspectiveTransform.getCircleRadiusCorrection(), this._globeness);
}
public getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number {
const mercatorCorrection = this._mercatorTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID);
const verticalCorrection = this._verticalPerspectiveTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID);
return lerp(mercatorCorrection, verticalCorrection, this._globeness);
}
public projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection {
return this.currentTransform.projectTileCoordinates(x, y, unwrappedTileID, getElevation);
}
private _calcMatrices(): void {
if (!this._helper._width || !this._helper._height) {
return;
}
// VerticalPerspective reads our near/farZ values and autoCalculateNearFarZ:
// - if autoCalculateNearFarZ is true then it computes globe Z values
// - if autoCalculateNearFarZ is false then it inherits our Z values
// In either case, its Z values are consistent with out settings and we want to copy its Z values to our helper.
this._verticalPerspectiveTransform.apply(this, false, this._globeLatitudeErrorCorrectionRadians);
this._helper._nearZ = this._verticalPerspectiveTransform.nearZ;
this._helper._farZ = this._verticalPerspectiveTransform.farZ;
// When transitioning between globe and mercator, we need to synchronize the depth values in both transforms.
// For this reason we first update vertical perspective and then sync our Z values to its result.
// Now if globe rendering, we always want to force mercator transform to adapt our Z values.
// If not, it will either compute its own (autoCalculateNearFarZ=false) or adapt our (autoCalculateNearFarZ=true).
// In either case we want to (again) sync our Z values, this time with
this._mercatorTransform.apply(this, true, this.isGlobeRendering);
this._helper._nearZ = this._mercatorTransform.nearZ;
this._helper._farZ = this._mercatorTransform.farZ;
}
calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 {
return this.currentTransform.calculateFogMatrix(unwrappedTileID);
}
getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): UnwrappedTileID[] {
return this.currentTransform.getVisibleUnwrappedCoordinates(tileID);
}
getCameraFrustum(): Frustum {
return this.currentTransform.getCameraFrustum();
}
getClippingPlane(): vec4 | null {
return this.currentTransform.getClippingPlane();
}
getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider {
return this.currentTransform.getCoveringTilesDetailsProvider();
}
recalculateZoomAndCenter(terrain?: Terrain): void {
this._mercatorTransform.recalculateZoomAndCenter(terrain);
this._verticalPerspectiveTransform.recalculateZoomAndCenter(terrain);
}
maxPitchScaleFactor(): number {
// Using mercator version of this should be good enough approximation for globe.
return this._mercatorTransform.maxPitchScaleFactor();
}
getCameraPoint(): Point {
return this._helper.getCameraPoint();
}
getCameraAltitude(): number {
return this._helper.getCameraAltitude();
}
getCameraLngLat(): LngLat {
return this._helper.getCameraLngLat();
}
lngLatToCameraDepth(lngLat: LngLat, elevation: number): number {
return this.currentTransform.lngLatToCameraDepth(lngLat, elevation);
}
populateCache(coords: OverscaledTileID[]): void {
this._mercatorTransform.populateCache(coords);
this._verticalPerspectiveTransform.populateCache(coords);
}
getBounds(): LngLatBounds {
return this.currentTransform.getBounds();
}
defaultConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this.currentTransform.defaultConstrain(lngLat, zoom);
};
applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this._helper.applyConstrain(lngLat, zoom);
};
calculateCenterFromCameraLngLatAlt(lngLat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
return this._helper.calculateCenterFromCameraLngLatAlt(lngLat, alt, bearing, pitch);
}
/**
* Note: automatically adjusts zoom to keep planet size consistent
* (same size before and after a {@link setLocationAtPoint} call).
*/
setLocationAtPoint(lnglat: LngLat, point: Point): void {
if (!this.isGlobeRendering) {
this._mercatorTransform.setLocationAtPoint(lnglat, point);
this.apply(this._mercatorTransform, false);
return;
}
this._verticalPerspectiveTransform.setLocationAtPoint(lnglat, point);
this.apply(this._verticalPerspectiveTransform, false);
return;
}
locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point {
return this.currentTransform.locationToScreenPoint(lnglat, terrain);
}
screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate {
return this.currentTransform.screenPointToMercatorCoordinate(p, terrain);
}
screenPointToLocation(p: Point, terrain?: Terrain): LngLat {
return this.currentTransform.screenPointToLocation(p, terrain);
}
isPointOnMapSurface(p: Point, terrain?: Terrain): boolean {
return this.currentTransform.isPointOnMapSurface(p, terrain);
}
/**
* Computes normalized direction of a ray from the camera to the given screen pixel.
*/
getRayDirectionFromPixel(p: Point): vec3 {
return this._verticalPerspectiveTransform.getRayDirectionFromPixel(p);
}
getMatrixForModel(location: LngLatLike, altitude?: number): mat4 {
return this.currentTransform.getMatrixForModel(location, altitude);
}
getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData {
const mercatorData = this._mercatorTransform.getProjectionDataForCustomLayer(applyGlobeMatrix);
if (!this.isGlobeRendering) {
return mercatorData;
}
const globeData = this._verticalPerspectiveTransform.getProjectionDataForCustomLayer(applyGlobeMatrix);
globeData.fallbackMatrix = mercatorData.mainMatrix;
return globeData;
}
getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 {
return this.currentTransform.getFastPathSimpleProjectionMatrix(tileID);
}
}

View File

@@ -0,0 +1,50 @@
import {describe, expect, test} from 'vitest';
import {LngLat} from '../lng_lat';
import {getGlobeCircumferencePixels, getZoomAdjustment, globeDistanceOfLocationsPixels} from './globe_utils';
describe('globe utils', () => {
const digitsPrecision = 10;
test('getGlobeCircumferencePixels', () => {
expect(getGlobeCircumferencePixels({
worldSize: 1,
center: {
lat: 0
}
})).toBeCloseTo(1, digitsPrecision);
expect(getGlobeCircumferencePixels({
worldSize: 1,
center: {
lat: 60
}
})).toBeCloseTo(2, digitsPrecision);
});
test('globeDistanceOfLocationsPixels', () => {
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, 0), new LngLat(90, 0))).toBeCloseTo(0.25, digitsPrecision);
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, -45), new LngLat(0, 45))).toBeCloseTo(0.25, digitsPrecision);
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, 0), new LngLat(45, 45))).toBeCloseTo(0.16666666666666666, digitsPrecision);
});
test('getZoomAdjustment', () => {
expect(getZoomAdjustment(0, 60)).toBeCloseTo(-1, digitsPrecision);
expect(getZoomAdjustment(60, 0)).toBeCloseTo(1, digitsPrecision);
});
});

View File

@@ -0,0 +1,253 @@
import {type ReadonlyVec4, vec3} from 'gl-matrix';
import {clamp, createVec3f64, lerp, MAX_VALID_LATITUDE, mod, remapSaturate, scaleZoom, wrap} from '../../util/util';
import {LngLat} from '../lng_lat';
import {EXTENT} from '../../data/extent';
import type Point from '@mapbox/point-geometry';
export function getGlobeCircumferencePixels(transform: {worldSize: number; center: {lat: number}}): number {
const radius = getGlobeRadiusPixels(transform.worldSize, transform.center.lat);
const circumference = 2.0 * Math.PI * radius;
return circumference;
}
export function globeDistanceOfLocationsPixels(transform: {worldSize: number; center: {lat: number}}, a: LngLat, b: LngLat): number {
const vecA = angularCoordinatesToSurfaceVector(a);
const vecB = angularCoordinatesToSurfaceVector(b);
const dot = vec3.dot(vecA, vecB);
const radians = Math.acos(dot);
const circumference = getGlobeCircumferencePixels(transform);
return radians / (2.0 * Math.PI) * circumference;
}
/**
* For given mercator coordinates in range 0..1, returns the angular coordinates on the sphere's surface, in radians.
*/
export function mercatorCoordinatesToAngularCoordinatesRadians(mercatorX: number, mercatorY: number): [number, number] {
const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2);
const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
return [sphericalX, sphericalY];
}
/**
* For a given longitude and latitude (note: in radians) returns the normalized vector from the planet center to the specified place on the surface.
* @param lngRadians - Longitude in radians.
* @param latRadians - Latitude in radians.
*/
export function angularCoordinatesRadiansToVector(lngRadians: number, latRadians: number): vec3 {
const len = Math.cos(latRadians);
const vec = new Float64Array(3) as any;
vec[0] = Math.sin(lngRadians) * len;
vec[1] = Math.sin(latRadians);
vec[2] = Math.cos(lngRadians) * len;
return vec;
}
/**
* Projects a point within a tile to the surface of the unit sphere globe.
* @param inTileX - X coordinate inside the tile in range [0 .. 8192].
* @param inTileY - Y coordinate inside the tile in range [0 .. 8192].
* @param tileIdX - Tile's X coordinate in range [0 .. 2^zoom - 1].
* @param tileIdY - Tile's Y coordinate in range [0 .. 2^zoom - 1].
* @param tileIdZ - Tile's zoom.
* @returns A 3D vector - coordinates of the projected point on a unit sphere.
*/
export function projectTileCoordinatesToSphere(inTileX: number, inTileY: number, tileIdX: number, tileIdY: number, tileIdZ: number): vec3 {
// This code could be assembled from 3 functions, but this is a hot path for symbol placement,
// so for optimization purposes everything is inlined by hand.
//
// Non-inlined variant of this function would be this:
// const mercator = tileCoordinatesToMercatorCoordinates(inTileX, inTileY, tileID);
// const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y);
// const sphere = angularCoordinatesRadiansToVector(angular[0], angular[1]);
// return sphere;
const scale = 1.0 / (1 << tileIdZ);
const mercatorX = inTileX / EXTENT * scale + tileIdX * scale;
const mercatorY = inTileY / EXTENT * scale + tileIdY * scale;
const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2);
const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
const len = Math.cos(sphericalY);
const vec = new Float64Array(3) as any;
vec[0] = Math.sin(sphericalX) * len;
vec[1] = Math.sin(sphericalY);
vec[2] = Math.cos(sphericalX) * len;
return vec;
}
/**
* For a given longitude and latitude (note: in degrees) returns the normalized vector from the planet center to the specified place on the surface.
*/
export function angularCoordinatesToSurfaceVector(lngLat: LngLat): vec3 {
return angularCoordinatesRadiansToVector(lngLat.lng * Math.PI / 180, lngLat.lat * Math.PI / 180);
}
export function getGlobeRadiusPixels(worldSize: number, latitudeDegrees: number) {
// We want zoom levels to be consistent between globe and flat views.
// This means that the pixel size of features at the map center point
// should be the same for both globe and flat view.
// For this reason we scale the globe up when map center is nearer to the poles.
return worldSize / (2.0 * Math.PI) / Math.cos(latitudeDegrees * Math.PI / 180);
}
/**
* Given a 3D point on the surface of a unit sphere, returns its angular coordinates in degrees.
* The input vector must be normalized.
*/
export function sphereSurfacePointToCoordinates(surface: vec3): LngLat {
const latRadians = Math.asin(surface[1]);
const latDegrees = latRadians / Math.PI * 180.0;
const lengthXZ = Math.sqrt(surface[0] * surface[0] + surface[2] * surface[2]);
if (lengthXZ > 1e-6) {
const projX = surface[0] / lengthXZ;
const projZ = surface[2] / lengthXZ;
const acosZ = Math.acos(projZ);
const lngRadians = (projX > 0) ? acosZ : -acosZ;
const lngDegrees = lngRadians / Math.PI * 180.0;
return new LngLat(wrap(lngDegrees, -180, 180), latDegrees);
} else {
return new LngLat(0.0, latDegrees);
}
}
/**
* Given a normalized horizon plane in Ax+By+Cz+D=0 format, compute the center and radius of
* the circle in that plain that contains the entire visible portion of the unit sphere from horizon
* to horizon.
* @param horizonPlane - The plane that passes through visible horizon in Ax + By + Cz + D = 0 format where mag(A,B,C)=1
* @returns the center point and radius of the disc that passes through the entire visible horizon
*/
export function horizonPlaneToCenterAndRadius(horizonPlane: ReadonlyVec4): { center: vec3; radius: number } {
const center = createVec3f64();
center[0] = horizonPlane[0] * -horizonPlane[3];
center[1] = horizonPlane[1] * -horizonPlane[3];
center[2] = horizonPlane[2] * -horizonPlane[3];
/*
.*******
****|\
** | \
** | 1
* radius | \
* | \
* center +--D--+(0,0,0)
*/
const radius = Math.sqrt(1 - horizonPlane[3] * horizonPlane[3]);
return {center, radius};
}
/**
* Computes the closest point on a sphere to `point`.
* @param center - Center of the sphere
* @param radius - Radius of the sphere
* @param point - Point inside or outside the sphere
* @returns A 3d vector of the point on the sphere closest to `point`
*/
export function clampToSphere(center: vec3, radius: number, point: vec3) {
const relativeToCenter = createVec3f64();
vec3.sub(relativeToCenter, point, center);
const clamped = createVec3f64();
vec3.scaleAndAdd(clamped, center, relativeToCenter, radius / vec3.len(relativeToCenter));
return clamped;
}
function planetScaleAtLatitude(latitudeDegrees: number): number {
return Math.cos(latitudeDegrees * Math.PI / 180);
}
/**
* Computes how much to modify zoom to keep the globe size constant when changing latitude.
* @param transform - An instance of any transform. Does not have any relation on the computed values.
* @param oldLat - Latitude before change, in degrees.
* @param newLat - Latitude after change, in degrees.
* @returns A value to add to zoom level used for old latitude to keep same planet radius at new latitude.
*/
export function getZoomAdjustment(oldLat: number, newLat: number): number {
const oldCircumference = planetScaleAtLatitude(oldLat);
const newCircumference = planetScaleAtLatitude(newLat);
return scaleZoom(newCircumference / oldCircumference);
}
export function getDegreesPerPixel(worldSize: number, lat: number): number {
return 360.0 / getGlobeCircumferencePixels({worldSize, center: {lat}});
}
/**
* Returns transform's new center rotation after applying panning.
* @param panDelta - Panning delta, in same units as what is supplied to {@link HandlerManager}.
* @param tr - Current transform. This object is not modified by the function.
* @returns New center location to set to the map's transform to apply the specified panning.
*/
export function computeGlobePanCenter(panDelta: Point, tr: {
readonly bearingInRadians: number;
readonly worldSize: number;
readonly center: LngLat;
readonly zoom: number;
}): LngLat {
// Apply map bearing to the panning vector
const rotatedPanDelta = panDelta.rotate(tr.bearingInRadians);
// Compute what the current zoom would be if the transform center would be moved to latitude 0.
const normalizedGlobeZoom = tr.zoom + getZoomAdjustment(tr.center.lat, 0);
// Note: we divide longitude speed by planet width at the given latitude. But we diminish this effect when the globe is zoomed out a lot.
const lngSpeed = lerp(
1.0 / planetScaleAtLatitude(tr.center.lat), // speed adjusted by latitude
1.0 / planetScaleAtLatitude(Math.min(Math.abs(tr.center.lat), 60)), // also adjusted, but latitude is clamped to 60° to avoid too large speeds near poles
remapSaturate(normalizedGlobeZoom, 7, 3, 0, 1.0) // Values chosen so that globe interactions feel good. Not scientific by any means.
);
const panningDegreesPerPixel = getDegreesPerPixel(tr.worldSize, tr.center.lat);
return new LngLat(
tr.center.lng - rotatedPanDelta.x * panningDegreesPerPixel * lngSpeed,
clamp(tr.center.lat + rotatedPanDelta.y * panningDegreesPerPixel, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE)
);
}
/**
* Integration of `1 / cos(x)`.
*/
function integrateSecX(x: number): number {
const xHalf = 0.5 * x;
const sin = Math.sin(xHalf);
const cos = Math.cos(xHalf);
return Math.log(sin + cos) - Math.log(cos - sin);
}
/**
* Interpolates globe center between two locations while preserving apparent rotation speed during interpolation.
* @param start - The starting location of the interpolation.
* @param deltaLng - Longitude delta to the end of the interpolation.
* @param deltaLat - Latitude delta to the end of the interpolation.
* @param t - The interpolation point in [0..1], where 0 is starting location, 1 is end location and other values are in between.
* @returns The interpolated location.
*/
export function interpolateLngLatForGlobe(start: LngLat, deltaLng: number, deltaLat: number, t: number): LngLat {
// Rate of change of longitude when moving the globe should be roughly 1/cos(latitude)
// We want to use this rate of change, even for interpolation during easing.
// Thus we know the derivative of our interpolation function: 1/cos(x)
// To get our interpolation function, we need to integrate that.
const interpolatedLat = start.lat + deltaLat * t;
if (Math.abs(deltaLat) > 1) {
const endLat = start.lat + deltaLat;
const onDifferentHemispheres = Math.sign(endLat) !== Math.sign(start.lat);
// Where do we sample the integrated speed curve?
const samplePointStart = (onDifferentHemispheres ? -Math.abs(start.lat) : Math.abs(start.lat)) * Math.PI / 180;
const samplePointEnd = Math.abs(start.lat + deltaLat) * Math.PI / 180;
// Read the integrated speed curve at those points, and at the interpolation value "t".
const valueT = integrateSecX(samplePointStart + t * (samplePointEnd - samplePointStart));
const valueStart = integrateSecX(samplePointStart);
const valueEnd = integrateSecX(samplePointEnd);
// Compute new interpolation factor based on the speed curve
const newT = (valueT - valueStart) / (valueEnd - valueStart);
// Interpolate using that factor
const interpolatedLng = start.lng + deltaLng * newT;
return new LngLat(
interpolatedLng,
interpolatedLat
);
} else {
// Fall back to simple interpolation when latitude doesn't change much.
const interpolatedLng = start.lng + deltaLng * t;
return new LngLat(
interpolatedLng,
interpolatedLat
);
}
}

View File

@@ -0,0 +1,187 @@
import type Point from '@mapbox/point-geometry';
import {LngLat, type LngLatLike} from '../lng_lat';
import {cameraForBoxAndBearing, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs} from './camera_helper';
import {normalizeCenter} from '../transform_helper';
import {rollPitchBearingEqual, scaleZoom, zoomScale} from '../../util/util';
import {getMercatorHorizon, projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {PaddingOptions} from '../edge_insets';
import type {LngLatBounds} from '../lng_lat_bounds';
/**
* @internal
*/
export class MercatorCameraHelper implements ICameraHelper {
get useGlobeControls(): boolean { return false; }
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
// Reduce the offset so that it never goes past the horizon. If it goes past
// the horizon, the pan direction is opposite of the intended direction.
const offsetLength = pan.mag();
const pixelsToHorizon = Math.abs(getMercatorHorizon(transform));
const horizonFactor = 0.75; // Must be < 1 to prevent the offset from crossing the horizon
const offsetAsPoint = pan.mult(Math.min(pixelsToHorizon * horizonFactor / offsetLength, 1.0));
return {
easingOffset: offsetAsPoint,
easingCenter: transform.center,
};
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta);
if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta);
if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta);
if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta);
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void {
// If we are rotating about the center point, there is no need to update the transform center. Doing so causes
// a small amount of drift of the center point, especially when pitch is close to 90 degrees.
// In this case, return early.
if (deltas.around.distSqr(tr.centerPoint) < 1.0e-2) {
return;
}
tr.setLocationAtPoint(preZoomAroundLoc, deltas.around);
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult {
return cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
}
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
// Mercator zoom & center handling.
const optionsZoom = typeof options.zoom !== 'undefined';
const zoom = optionsZoom ? +options.zoom : tr.zoom;
if (tr.zoom !== zoom) {
tr.setZoom(+options.zoom);
}
if (options.center !== undefined) {
tr.setCenter(LngLat.convert(options.center));
}
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
const startZoom = tr.zoom;
const startPadding = tr.padding;
const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing};
const endRoll = options.roll === undefined ? tr.roll : options.roll;
const endPitch = options.pitch === undefined ? tr.pitch : options.pitch;
const endBearing = options.bearing === undefined ? tr.bearing : options.bearing;
const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing};
const optionsZoom = typeof options.zoom !== 'undefined';
const doPadding = !tr.isPaddingEqual(options.padding);
let isZooming = false;
const zoom = optionsZoom ? +options.zoom : tr.zoom;
let pointAtOffset = tr.centerPoint.add(options.offsetAsPoint);
const locationAtOffset = tr.screenPointToLocation(pointAtOffset);
const {center, zoom: endZoom} = tr.applyConstrain(
LngLat.convert(options.center || locationAtOffset),
zoom ?? startZoom
);
normalizeCenter(tr, center);
const from = projectToWorldCoordinates(tr.worldSize, locationAtOffset);
const delta = projectToWorldCoordinates(tr.worldSize, center).sub(from);
const finalScale = zoomScale(endZoom - startZoom);
isZooming = (endZoom !== startZoom);
const easeFunc = (k: number) => {
if (isZooming) {
tr.setZoom(interpolates.number(startZoom, endZoom, k));
}
if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) {
updateRotation({
startEulerAngles,
endEulerAngles,
tr,
k,
useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs);
}
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding, k);
// When padding is being applied, Transform.centerPoint is changing continuously,
// thus we need to recalculate offsetPoint every frame
pointAtOffset = tr.centerPoint.add(options.offsetAsPoint);
}
if (options.around) {
tr.setLocationAtPoint(options.around, options.aroundPoint);
} else {
const scale = zoomScale(tr.zoom - startZoom);
const base = endZoom > startZoom ?
Math.min(2, finalScale) :
Math.max(0.5, finalScale);
const speedup = Math.pow(base, 1 - k);
const newCenter = unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(k * speedup)).mult(scale));
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
}
};
return {
easeFunc,
isZooming,
elevationCenter: center,
};
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
const optionsZoom = typeof options.zoom !== 'undefined';
const startZoom = tr.zoom;
// Obtain target center and zoom
const constrained = tr.applyConstrain(
LngLat.convert(options.center || options.locationAtOffset),
optionsZoom ? +options.zoom : startZoom
);
const targetCenter = constrained.center;
const targetZoom = constrained.zoom;
normalizeCenter(tr, targetCenter);
const from = projectToWorldCoordinates(tr.worldSize, options.locationAtOffset);
const delta = projectToWorldCoordinates(tr.worldSize, targetCenter).sub(from);
const pixelPathLength = delta.mag();
const scaleOfZoom = zoomScale(targetZoom - startZoom);
const optionsMinZoom = typeof options.minZoom !== 'undefined';
let scaleOfMinZoom: number;
if (optionsMinZoom) {
const minZoomPreConstrain = Math.min(+options.minZoom, startZoom, targetZoom);
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
scaleOfMinZoom = zoomScale(minZoom - startZoom);
}
const easeFunc = (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => {
tr.setZoom(k === 1 ? targetZoom : startZoom + scaleZoom(scale));
const newCenter = k === 1 ? targetCenter : unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(centerFactor)).mult(scale));
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
};
return {
easeFunc,
scaleOfZoom,
targetCenter,
scaleOfMinZoom,
pixelPathLength,
};
}
}

View File

@@ -0,0 +1,55 @@
import {OverscaledTileID} from '../../tile/tile_id';
import {Aabb} from '../../util/primitives/aabb';
import {clamp} from '../../util/util';
import {type MercatorCoordinate} from '../mercator_coordinate';
import {type IReadonlyTransform} from '../transform_interface';
import {type CoveringTilesOptionsInternal} from './covering_tiles';
import {type CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
export class MercatorCoveringTilesDetailsProvider implements CoveringTilesDetailsProvider {
distanceToTile2d(pointX: number, pointY: number, _tileID: {x: number; y: number; z: number}, aabb: Aabb): number {
const distanceX = aabb.distanceX([pointX, pointY]);
const distanceY = aabb.distanceY([pointX, pointY]);
return Math.hypot(distanceX, distanceY);
}
/**
* Returns the wrap value for a given tile, computed so that tiles will remain loaded when crossing the antimeridian.
*/
getWrap(centerCoord: MercatorCoordinate, tileID: {x:number; y: number; z: number}, parentWrap: number): number {
return parentWrap;
}
/**
* Returns the AABB of the specified tile.
* @param tileID - Tile x, y and z for zoom.
*/
getTileBoundingVolume(tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal): Aabb {
let minElevation = 0;
let maxElevation = 0;
if (options?.terrain) {
const overscaledTileID = new OverscaledTileID(tileID.z, wrap, tileID.z, tileID.x, tileID.y);
const minMax = options.terrain.getMinMaxElevation(overscaledTileID);
minElevation = minMax.minElevation ?? Math.min(0, elevation);
maxElevation = minMax.maxElevation ?? Math.max(0, elevation);
}
const numTiles = 1 << tileID.z;
return new Aabb([wrap + tileID.x / numTiles, tileID.y / numTiles, minElevation],
[wrap + (tileID.x + 1) / numTiles, (tileID.y + 1) / numTiles, maxElevation]);
}
allowVariableZoom(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): boolean {
const zfov = transform.fov * (Math.abs(Math.cos(transform.rollInRadians)) * transform.height + Math.abs(Math.sin(transform.rollInRadians)) * transform.width) / transform.height;
const maxConstantZoomPitch = clamp(78.5 - zfov / 2, 0.0, 60.0);
return (!!options.terrain || transform.pitch > maxConstantZoomPitch);
}
allowWorldCopies(): boolean {
return true;
}
prepareNextFrame(): void {
// Do nothing
}
}

View File

@@ -0,0 +1,102 @@
import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection';
import type {CanonicalTileID} from '../../tile/tile_id';
import {EXTENT} from '../../data/extent';
import {type PreparedShader, shaders} from '../../shaders/shaders';
import type {Context} from '../../gl/context';
import {Mesh} from '../../render/mesh';
import {PosArray, TriangleIndexArray} from '../../data/array_types.g';
import {SegmentVector} from '../../data/segment';
import posAttributes from '../../data/pos_attributes';
import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
export const MercatorShaderDefine = '#define PROJECTION_MERCATOR';
export const MercatorShaderVariantKey = 'mercator';
export class MercatorProjection implements Projection {
private _cachedMesh: Mesh = null;
get name(): 'mercator' {
return 'mercator';
}
get useSubdivision(): boolean {
// Mercator never uses subdivision.
return false;
}
get shaderVariantName(): string {
return MercatorShaderVariantKey;
}
get shaderDefine(): string {
return MercatorShaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return shaders.projectionMercator;
}
get vertexShaderPreludeCode(): string {
return shaders.projectionMercator.vertexSource;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return SubdivisionGranularitySetting.noSubdivision;
}
get useGlobeControls(): boolean {
return false;
}
get transitionState(): number {
return 0;
}
get latitudeErrorCorrectionRadians(): number {
return 0;
}
public destroy(): void {
// Do nothing.
}
public updateGPUdependent(_: ProjectionGPUContext): void {
// Do nothing.
}
public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh {
if (this._cachedMesh) {
return this._cachedMesh;
}
// The parameters tileID, hasBorder and allowPoles are all ignored on purpose for mercator meshes.
const tileExtentArray = new PosArray();
tileExtentArray.emplaceBack(0, 0);
tileExtentArray.emplaceBack(EXTENT, 0);
tileExtentArray.emplaceBack(0, EXTENT);
tileExtentArray.emplaceBack(EXTENT, EXTENT);
const tileExtentBuffer = context.createVertexBuffer(tileExtentArray, posAttributes.members);
const tileExtentSegments = SegmentVector.simpleSegment(0, 0, 4, 2);
const quadTriangleIndices = new TriangleIndexArray();
quadTriangleIndices.emplaceBack(1, 0, 2);
quadTriangleIndices.emplaceBack(1, 2, 3);
const quadTriangleIndexBuffer = context.createIndexBuffer(quadTriangleIndices);
this._cachedMesh = new Mesh(tileExtentBuffer, quadTriangleIndexBuffer, tileExtentSegments);
return this._cachedMesh;
}
public recalculate(): void {
// Do nothing.
}
public hasTransition(): boolean {
return false;
}
setErrorQueryLatitudeDegrees(_value: number) {
// Do nothing.
}
}

View File

@@ -0,0 +1,619 @@
import {describe, test, expect} from 'vitest';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {CanonicalTileID, UnwrappedTileID} from '../../tile/tile_id';
import {fixedLngLat, fixedCoord} from '../../../test/unit/lib/fixed';
import type {Terrain} from '../../render/terrain';
import {MercatorTransform} from './mercator_transform';
import {LngLatBounds} from '../lng_lat_bounds';
import {getMercatorHorizon} from './mercator_utils';
import {mat4} from 'gl-matrix';
import {expectToBeCloseToArray} from '../../util/test/util';
describe('transform', () => {
test('creates a transform', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
expect(transform.unmodified).toBe(true);
expect(transform.tileSize).toBe(512);
expect(transform.worldSize).toBe(512);
expect(transform.width).toBe(500);
expect(transform.minZoom).toBe(0);
expect(transform.minPitch).toBe(0);
// Support signed zero
expect(transform.bearing === 0 ? 0 : transform.bearing).toBe(0);
transform.setBearing(1);
expect(transform.bearing).toBe(1);
expect([...transform.rotationMatrix]).toEqual([0.9998477101325989, -0.017452405765652657, 0.017452405765652657, 0.9998477101325989]);
transform.setBearing(0);
expect(transform.bearing).toBe(0);
expect(transform.unmodified).toBe(false);
transform.setMinZoom(10);
expect(transform.minZoom).toBe(10);
transform.setMaxZoom(10);
expect(transform.maxZoom).toBe(10);
expect(transform.minZoom).toBe(10);
expect(transform.center).toEqual({lng: 0, lat: 0});
expect(transform.maxZoom).toBe(10);
transform.setMinPitch(10);
expect(transform.minPitch).toBe(10);
transform.setMaxPitch(10);
expect(transform.maxPitch).toBe(10);
expect(transform.size.equals(new Point(500, 500))).toBe(true);
expect(transform.centerPoint.equals(new Point(250, 250))).toBe(true);
expect(transform.height).toBe(500);
expect(transform.nearZ).toBe(10);
expect(transform.farZ).toBe(804.8028169246645);
expect([...transform.projectionMatrix]).toEqual([3, 0, 0, 0, 0, 3, 0, 0, -0, 0, -1.0251635313034058, -1, 0, 0, -20.25163459777832, 0]);
expectToBeCloseToArray([...transform.inverseProjectionMatrix], [0.3333333333333333, 0, 0, 0, 0, 0.3333333333333333, 0, 0, 0, 0, 0, -0.04937872980873673, 0, 0, -1, 0.05062127019126326], 10);
expectToBeCloseToArray([...mat4.multiply(new Float64Array(16) as any, transform.projectionMatrix, transform.inverseProjectionMatrix)], [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1], 6);
expect([...transform.modelViewProjectionMatrix]).toEqual([3, 0, 0, 0, 0, -2.954423259036624, -0.1780177690666898, -0.17364817766693033, -0, 0.006822967915294533, -0.013222891287479163, -0.012898324631281611, -786432, 774484.3308168967, 47414.91102496082, 46270.827886319785]);
expect(fixedLngLat(transform.screenPointToLocation(new Point(250, 250)))).toEqual({lng: 0, lat: 0});
expect(fixedCoord(transform.screenPointToMercatorCoordinate(new Point(250, 250)))).toEqual({x: 0.5, y: 0.5, z: 0});
expect(fixedCoord(transform.screenPointToMercatorCoordinateAtZ(new Point(250, 250), 1))).toEqual({x: 0.5, y: 0.5000000044, z: 1});
expect(transform.locationToScreenPoint(new LngLat(0, 0))).toEqual({x: 250, y: 250});
});
test('does not throw on bad center', () => {
expect(() => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(50, -90));
}).not.toThrow();
});
test('setLocationAt', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('setLocationAt tilted', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
transform.setPitch(50);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('setLocationAt tilted rolled', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
transform.setPitch(50);
transform.setRoll(50);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('has a default zoom', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
expect(transform.tileZoom).toBe(0);
expect(transform.tileZoom).toBe(transform.zoom);
});
test('set zoom inits tileZoom with zoom value', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60});
transform.setZoom(5);
expect(transform.tileZoom).toBe(5);
});
test('set zoom clamps tileZoom to non negative value ', () => {
const transform = new MercatorTransform({minZoom: -2, maxZoom: 22, minPitch: 0, maxPitch: 60});
transform.setZoom(-2);
expect(transform.tileZoom).toBe(0);
});
test('set fov', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setFov(10);
expect(transform.fov).toBe(10);
transform.setFov(10);
expect(transform.fov).toBe(10);
});
test('lngRange & latRange constrain zoom and center', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setCenter(new LngLat(0, 0));
transform.setZoom(10);
transform.resize(500, 500);
transform.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582));
});
test('lngRange & latRange constrain zoom and center after cloning', () => {
const old = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
old.setCenter(new LngLat(0, 0));
old.setZoom(10);
old.resize(500, 500);
old.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
const transform = old.clone();
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582));
});
test('lngRange can constrain zoom and center across meridian', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setCenter(new LngLat(180, 0));
transform.setZoom(10);
transform.resize(500, 500);
// equivalent ranges
const lngRanges: [number, number][] = [
[175, -175], [175, 185], [-185, -175], [-185, 185]
];
for (const lngRange of lngRanges) {
transform.setMaxBounds(new LngLatBounds([lngRange[0], -5, lngRange[1], 5]));
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(180, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-175.171661376953125, -4.828969771321582));
transform.setCenter(new LngLat(230, 0));
expect(transform.center).toEqual(new LngLat(-175.171661376953125, 0));
transform.setCenter(new LngLat(130, 0));
expect(transform.center).toEqual(new LngLat(175.171661376953125, 0));
}
});
test('clamps pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setPitch(45);
expect(transform.pitch).toBe(45);
transform.setPitch(-10);
expect(transform.pitch).toBe(0);
transform.setPitch(90);
expect(transform.pitch).toBe(60);
});
test('visibleUnwrappedCoordinates', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200, 200);
transform.setZoom(0);
transform.setCenter(new LngLat(-170.01, 0.01));
let unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0));
expect(unwrappedCoords).toHaveLength(4);
//getVisibleUnwrappedCoordinates should honor _renderWorldCopies
transform.setRenderWorldCopies(false);
unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0));
expect(unwrappedCoords).toHaveLength(1);
});
test('maintains high float precision when calculating matrices', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200.25, 200.25);
transform.setZoom(20.25);
transform.setPitch(67.25);
transform.setCenter(new LngLat(0.0, 0.0));
const customLayerMatrix = transform.getProjectionDataForCustomLayer().mainMatrix;
expect(customLayerMatrix[0].toString().length).toBeGreaterThan(9);
expect(transform.pixelsToClipSpaceMatrix[0].toString().length).toBeGreaterThan(9);
expect(transform.maxPitchScaleFactor()).toBeCloseTo(2.366025418080343, 5);
});
test('recalculateZoomAndCenter: no change', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect same values because of no elevation change
const terrain = {
getElevationForLngLatZoom: () => 200,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBe(14);
});
test('recalculateZoomAndCenter: small elevation change at extreme latitude does not drastically shift center', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setPitch(60);
transform.setZoom(3);
transform.setCenter(new LngLat(0, 82));
transform.resize(512, 512);
expect(transform.center.lat).toBeCloseTo(82, 10);
const terrain = {
getElevationForLngLatZoom: () => 200 + 1,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.center.lat).toBeCloseTo(82, 4);
});
test('recalculateZoomAndCenter: elevation increase', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect new zoom and center because of elevation change
const terrain = {
getElevationForLngLatZoom: () => 400,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.elevation).toBe(400);
expect(transform.center.lng).toBeCloseTo(10, 10);
expect(transform.center.lat).toBeCloseTo(49.998201325627264, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(14.184585871638795, 10);
});
test('recalculateZoomAndCenter: elevation decrease', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect new zoom because of elevation change to point below sea level
const terrain = {
getElevationForLngLatZoom: () => -200,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.elevation).toBe(-200);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(13.68939960698451, 10);
});
test('recalculateZoomAndCenterNoTerrain', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect same values because of no elevation change
transform.recalculateZoomAndCenter();
expect(transform.elevation).toBeCloseTo(0, 10);
expect(transform.center.lng).toBeCloseTo(10, 10);
expect(transform.center.lat).toBeCloseTo(50.00179860708241, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(13.836362970131438, 10);
});
test('pointCoordinate with terrain when returning null should fall back to 2D', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
const terrain = {
pointCoordinate: () => null
} as any as Terrain;
const coordinate = transform.screenPointToMercatorCoordinate(new Point(0, 0), terrain);
expect(coordinate).toBeDefined();
});
test('getBounds with horizon', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(60);
expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, 0)).toArray());
transform.setPitch(75);
const top = Math.max(0, transform.height / 2 - getMercatorHorizon(transform));
expect(top).toBeCloseTo(79.1823898251593, 10);
expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, top)).toArray());
});
test('lngLatToCameraDepth', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(10.0, 50.0));
expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9997324396231673);
transform.setPitch(60);
expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9865782165762236);
});
test('projectTileCoordinates', () => {
const precisionDigits = 10;
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(10.0, 50.0));
let projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.07111111111111101, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.8719999854792714, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(750, precisionDigits);
expect(projection.isOccluded).toBe(false);
transform.setBearing(12);
transform.setPitch(10);
projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(-0.10639783257205901, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.8136784996777623, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(787.6699126802941, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('getCameraLngLat', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(15.0, 55.0));
transform.setZoom(14);
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
expect(transform.getCameraAltitude()).toBeCloseTo(1405.7075926414002, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(14.973921529405033, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(54.99599181678275, 10);
transform.setRoll(31);
expect(transform.getCameraAltitude()).toBeCloseTo(1405.7075926414002, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(14.973921529405033, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(54.99599181678275, 10);
});
test('calculateCenterFromCameraLngLatAlt no pitch no bearing', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt no pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 20;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 30;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 89 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 88;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 89.99 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 89.99;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 90 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 90;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 95 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 95;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 180 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 180;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
});

View File

@@ -0,0 +1,845 @@
import {LngLat, type LngLatLike} from '../lng_lat';
import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate';
import Point from '@mapbox/point-geometry';
import {wrap, clamp, createIdentityMat4f64, createMat4f64, degreesToRadians, createIdentityMat4f32, zoomScale, scaleZoom} from '../../util/util';
import {type mat2, mat4, vec3, vec4} from 'gl-matrix';
import {UnwrappedTileID, OverscaledTileID, type CanonicalTileID, calculateTileKey} from '../../tile/tile_id';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import {type PointProjection, xyTransformMat4} from '../../symbol/projection';
import {LngLatBounds} from '../lng_lat_bounds';
import {getMercatorHorizon, projectToWorldCoordinates, unprojectFromWorldCoordinates, calculateTileMatrix, maxMercatorHorizonAngle, cameraMercatorCoordinateFromCenterAndRotation} from './mercator_utils';
import {EXTENT} from '../../data/extent';
import {TransformHelper} from '../transform_helper';
import {MercatorCoveringTilesDetailsProvider} from './mercator_covering_tiles_details_provider';
import {Frustum} from '../../util/primitives/frustum';
import type {Terrain} from '../../render/terrain';
import type {IReadonlyTransform, ITransform, TransformConstrainFunction} from '../transform_interface';
import type {TransformOptions} from '../transform_helper';
import type {PaddingOptions} from '../edge_insets';
import type {ProjectionData, ProjectionDataParams} from './projection_data';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
export class MercatorTransform implements ITransform {
private _helper: TransformHelper;
//
// Implementation of transform getters and setters
//
get pixelsToClipSpaceMatrix(): mat4 {
return this._helper.pixelsToClipSpaceMatrix;
}
get clipSpaceToPixelsMatrix(): mat4 {
return this._helper.clipSpaceToPixelsMatrix;
}
get pixelsToGLUnits(): [number, number] {
return this._helper.pixelsToGLUnits;
}
get centerOffset(): Point {
return this._helper.centerOffset;
}
get size(): Point {
return this._helper.size;
}
get rotationMatrix(): mat2 {
return this._helper.rotationMatrix;
}
get centerPoint(): Point {
return this._helper.centerPoint;
}
get pixelsPerMeter(): number {
return this._helper.pixelsPerMeter;
}
setMinZoom(zoom: number): void {
this._helper.setMinZoom(zoom);
}
setMaxZoom(zoom: number): void {
this._helper.setMaxZoom(zoom);
}
setMinPitch(pitch: number): void {
this._helper.setMinPitch(pitch);
}
setMaxPitch(pitch: number): void {
this._helper.setMaxPitch(pitch);
}
setRenderWorldCopies(renderWorldCopies: boolean): void {
this._helper.setRenderWorldCopies(renderWorldCopies);
}
setBearing(bearing: number): void {
this._helper.setBearing(bearing);
}
setPitch(pitch: number): void {
this._helper.setPitch(pitch);
}
setRoll(roll: number): void {
this._helper.setRoll(roll);
}
setFov(fov: number): void {
this._helper.setFov(fov);
}
setZoom(zoom: number): void {
this._helper.setZoom(zoom);
}
setCenter(center: LngLat): void {
this._helper.setCenter(center);
}
setElevation(elevation: number): void {
this._helper.setElevation(elevation);
}
setMinElevationForCurrentTile(elevation: number): void {
this._helper.setMinElevationForCurrentTile(elevation);
}
setPadding(padding: PaddingOptions): void {
this._helper.setPadding(padding);
}
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
return this._helper.interpolatePadding(start, target, t);
}
isPaddingEqual(padding: PaddingOptions): boolean {
return this._helper.isPaddingEqual(padding);
}
resize(width: number, height: number, constrain: boolean = true): void {
this._helper.resize(width, height, constrain);
}
getMaxBounds(): LngLatBounds {
return this._helper.getMaxBounds();
}
setMaxBounds(bounds?: LngLatBounds): void {
this._helper.setMaxBounds(bounds);
}
setConstrainOverride(constrain?: TransformConstrainFunction | null): void {
this._helper.setConstrainOverride(constrain);
}
overrideNearFarZ(nearZ: number, farZ: number): void {
this._helper.overrideNearFarZ(nearZ, farZ);
}
clearNearFarZOverride(): void {
this._helper.clearNearFarZOverride();
}
getCameraQueryGeometry(queryGeometry: Point[]): Point[] {
return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry);
}
get tileSize(): number {
return this._helper.tileSize;
}
get tileZoom(): number {
return this._helper.tileZoom;
}
get scale(): number {
return this._helper.scale;
}
get worldSize(): number {
return this._helper.worldSize;
}
get width(): number {
return this._helper.width;
}
get height(): number {
return this._helper.height;
}
get lngRange(): [number, number] {
return this._helper.lngRange;
}
get latRange(): [number, number] {
return this._helper.latRange;
}
get minZoom(): number {
return this._helper.minZoom;
}
get maxZoom(): number {
return this._helper.maxZoom;
}
get zoom(): number {
return this._helper.zoom;
}
get center(): LngLat {
return this._helper.center;
}
get minPitch(): number {
return this._helper.minPitch;
}
get maxPitch(): number {
return this._helper.maxPitch;
}
get pitch(): number {
return this._helper.pitch;
}
get pitchInRadians(): number {
return this._helper.pitchInRadians;
}
get roll(): number {
return this._helper.roll;
}
get rollInRadians(): number {
return this._helper.rollInRadians;
}
get bearing(): number {
return this._helper.bearing;
}
get bearingInRadians(): number {
return this._helper.bearingInRadians;
}
get fov(): number {
return this._helper.fov;
}
get fovInRadians(): number {
return this._helper.fovInRadians;
}
get elevation(): number {
return this._helper.elevation;
}
get minElevationForCurrentTile(): number {
return this._helper.minElevationForCurrentTile;
}
get padding(): PaddingOptions {
return this._helper.padding;
}
get unmodified(): boolean {
return this._helper.unmodified;
}
get renderWorldCopies(): boolean {
return this._helper.renderWorldCopies;
}
get cameraToCenterDistance(): number {
return this._helper.cameraToCenterDistance;
}
get constrainOverride(): TransformConstrainFunction {
return this._helper.constrainOverride;
}
public get nearZ(): number {
return this._helper.nearZ;
}
public get farZ(): number {
return this._helper.farZ;
}
public get autoCalculateNearFarZ(): boolean {
return this._helper.autoCalculateNearFarZ;
}
setTransitionState(_value: number, _error: number): void {
// Do nothing
}
//
// Implementation of mercator transform
//
private _cameraPosition: vec3;
private _mercatorMatrix: mat4;
private _projectionMatrix: mat4;
private _viewProjMatrix: mat4;
private _invViewProjMatrix: mat4;
private _invProjMatrix: mat4;
private _alignedProjMatrix: mat4;
private _pixelMatrix: mat4;
private _pixelMatrix3D: mat4;
private _pixelMatrixInverse: mat4;
private _fogMatrix: mat4;
private _posMatrixCache: Map<string, {f64: mat4; f32: mat4}> = new Map();
private _alignedPosMatrixCache: Map<string, {f64: mat4; f32: mat4}> = new Map();
private _fogMatrixCacheF32: Map<string, mat4> = new Map();
private _coveringTilesDetailsProvider;
constructor(options?: TransformOptions) {
this._helper = new TransformHelper({
calcMatrices: () => { this._calcMatrices(); },
defaultConstrain: (center, zoom) => { return this.defaultConstrain(center, zoom); }
}, options);
this._coveringTilesDetailsProvider = new MercatorCoveringTilesDetailsProvider();
}
public clone(): ITransform {
const clone = new MercatorTransform();
clone.apply(this, false);
return clone;
}
public apply(that: IReadonlyTransform, constrain: boolean, forceOverrideZ?: boolean): void {
this._helper.apply(that, constrain, forceOverrideZ);
}
public get cameraPosition(): vec3 { return this._cameraPosition; }
public get projectionMatrix(): mat4 { return this._projectionMatrix; }
public get modelViewProjectionMatrix(): mat4 { return this._viewProjMatrix; }
public get inverseProjectionMatrix(): mat4 { return this._invProjMatrix; }
public get mercatorMatrix(): mat4 { return this._mercatorMatrix; } // Not part of ITransform interface
getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array<UnwrappedTileID> {
const result = [new UnwrappedTileID(0, tileID)];
if (this._helper._renderWorldCopies) {
const utl = this.screenPointToMercatorCoordinate(new Point(0, 0));
const utr = this.screenPointToMercatorCoordinate(new Point(this._helper._width, 0));
const ubl = this.screenPointToMercatorCoordinate(new Point(this._helper._width, this._helper._height));
const ubr = this.screenPointToMercatorCoordinate(new Point(0, this._helper._height));
const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x));
const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x));
// Add an extra copy of the world on each side to properly render ImageSources and CanvasSources.
// Both sources draw outside the tile boundaries of the tile that "contains them" so we need
// to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones.
const extraWorldCopy = 1;
for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) {
if (w === 0) continue;
result.push(new UnwrappedTileID(w, tileID));
}
}
return result;
}
getCameraFrustum(): Frustum {
return Frustum.fromInvProjectionMatrix(this._invViewProjMatrix, this.worldSize);
}
getClippingPlane(): vec4 | null {
return null;
}
getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider {
return this._coveringTilesDetailsProvider;
}
recalculateZoomAndCenter(terrain?: Terrain): void {
// find position the camera is looking on
const center = this.screenPointToLocation(this.centerPoint, terrain);
const elevation = terrain ? terrain.getElevationForLngLatZoom(center, this._helper._tileZoom) : 0;
this._helper.recalculateZoomAndCenter(elevation);
}
setLocationAtPoint(lnglat: LngLat, point: Point) {
const z = mercatorZfromAltitude(this.elevation, this.center.lat);
const a = this.screenPointToMercatorCoordinateAtZ(point, z);
const b = this.screenPointToMercatorCoordinateAtZ(this.centerPoint, z);
const loc = MercatorCoordinate.fromLngLat(lnglat);
const newCenter = new MercatorCoordinate(
loc.x - (a.x - b.x),
loc.y - (a.y - b.y));
this.setCenter(newCenter?.toLngLat());
if (this._helper._renderWorldCopies) {
this.setCenter(this.center.wrap());
}
}
locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point {
return terrain ?
this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat), terrain.getElevationForLngLat(lnglat, this), this._pixelMatrix3D) :
this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat));
}
screenPointToLocation(p: Point, terrain?: Terrain): LngLat {
return this.screenPointToMercatorCoordinate(p, terrain)?.toLngLat();
}
screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate {
// get point-coordinate from terrain coordinates framebuffer
if (terrain) {
const coordinate = terrain.pointCoordinate(p);
if (coordinate != null) {
return coordinate;
}
}
return this.screenPointToMercatorCoordinateAtZ(p);
}
screenPointToMercatorCoordinateAtZ(p: Point, mercatorZ?: number): MercatorCoordinate {
// calculate point-coordinate on flat earth
const targetZ = mercatorZ ? mercatorZ : 0;
// since we don't know the correct projected z value for the point,
// unproject two points to get a line and then find the point on that
// line with z=0
const coord0 = [p.x, p.y, 0, 1] as vec4;
const coord1 = [p.x, p.y, 1, 1] as vec4;
vec4.transformMat4(coord0, coord0, this._pixelMatrixInverse);
vec4.transformMat4(coord1, coord1, this._pixelMatrixInverse);
const w0 = coord0[3];
const w1 = coord1[3];
const x0 = coord0[0] / w0;
const x1 = coord1[0] / w1;
const y0 = coord0[1] / w0;
const y1 = coord1[1] / w1;
const z0 = coord0[2] / w0;
const z1 = coord1[2] / w1;
const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0);
return new MercatorCoordinate(
interpolates.number(x0, x1, t) / this.worldSize,
interpolates.number(y0, y1, t) / this.worldSize,
targetZ);
}
/**
* Given a coordinate, return the screen point that corresponds to it
* @param coord - the coordinates
* @param elevation - the elevation
* @param pixelMatrix - the pixel matrix
* @returns screen point
*/
coordinatePoint(coord: MercatorCoordinate, elevation: number = 0, pixelMatrix: mat4 = this._pixelMatrix): Point {
const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4;
vec4.transformMat4(p, p, pixelMatrix);
return new Point(p[0] / p[3], p[1] / p[3]);
}
getBounds(): LngLatBounds {
const top = Math.max(0, this._helper._height / 2 - getMercatorHorizon(this));
return new LngLatBounds()
.extend(this.screenPointToLocation(new Point(0, top)))
.extend(this.screenPointToLocation(new Point(this._helper._width, top)))
.extend(this.screenPointToLocation(new Point(this._helper._width, this._helper._height)))
.extend(this.screenPointToLocation(new Point(0, this._helper._height)));
}
isPointOnMapSurface(p: Point, terrain?: Terrain): boolean {
if (terrain) {
const coordinate = terrain.pointCoordinate(p);
return coordinate != null;
}
return (p.y > this.height / 2 - getMercatorHorizon(this));
}
/**
* Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map.
* This function is specific to the mercator projection.
* @param tileID - the tile ID
* @param aligned - whether to use a pixel-aligned matrix variant, intended for rendering raster tiles
* @param useFloat32 - when true, returns a float32 matrix instead of float64. Use float32 for matrices that are passed to shaders, use float64 for everything else.
*/
calculatePosMatrix(tileID: UnwrappedTileID | OverscaledTileID, aligned: boolean = false, useFloat32?: boolean): mat4 {
const posMatrixKey = tileID.key ?? calculateTileKey(tileID.wrap, tileID.canonical.z, tileID.canonical.z, tileID.canonical.x, tileID.canonical.y);
const cache = aligned ? this._alignedPosMatrixCache : this._posMatrixCache;
if (cache.has(posMatrixKey)) {
const matrices = cache.get(posMatrixKey);
return useFloat32 ? matrices.f32 : matrices.f64;
}
const tileMatrix = calculateTileMatrix(tileID, this.worldSize);
mat4.multiply(tileMatrix, aligned ? this._alignedProjMatrix : this._viewProjMatrix, tileMatrix);
const matrices = {
f64: tileMatrix,
f32: new Float32Array(tileMatrix), // Must have a 32 bit float version for WebGL, otherwise WebGL calls in Chrome get very slow.
};
cache.set(posMatrixKey, matrices);
// Make sure to return the correct precision
return useFloat32 ? matrices.f32 : matrices.f64;
}
calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 {
const posMatrixKey = unwrappedTileID.key;
const cache = this._fogMatrixCacheF32;
if (cache.has(posMatrixKey)) {
return cache.get(posMatrixKey);
}
const fogMatrix = calculateTileMatrix(unwrappedTileID, this.worldSize);
mat4.multiply(fogMatrix, this._fogMatrix, fogMatrix);
cache.set(posMatrixKey, new Float32Array(fogMatrix)); // Must be 32 bit floats, otherwise WebGL calls in Chrome get very slow.
return cache.get(posMatrixKey);
}
/**
* This mercator implementation returns center lngLat and zoom to ensure that:
*
* 1) everything beyond the bounds is excluded
* 2) a given lngLat is as near the center as possible
*
* Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
*/
defaultConstrain: TransformConstrainFunction = (lngLat, zoom) => {
zoom = clamp(+zoom, this.minZoom, this.maxZoom);
const result = {
center: new LngLat(lngLat.lng, lngLat.lat),
zoom
};
let lngRange = this._helper._lngRange;
if (!this._helper._renderWorldCopies && lngRange === null) {
const almost180 = 180 - 1e-10;
lngRange = [-almost180, almost180];
}
const worldSize = this.tileSize * zoomScale(result.zoom); // A world size for the requested zoom level, not the current world size
let minY = 0;
let maxY = worldSize;
let minX = 0;
let maxX = worldSize;
let scaleY = 0;
let scaleX = 0;
const {x: screenWidth, y: screenHeight} = this.size;
if (this._helper._latRange) {
const latRange = this._helper._latRange;
minY = mercatorYfromLat(latRange[1]) * worldSize;
maxY = mercatorYfromLat(latRange[0]) * worldSize;
const shouldZoomIn = maxY - minY < screenHeight;
if (shouldZoomIn) scaleY = screenHeight / (maxY - minY);
}
if (lngRange) {
minX = wrap(
mercatorXfromLng(lngRange[0]) * worldSize,
0,
worldSize
);
maxX = wrap(
mercatorXfromLng(lngRange[1]) * worldSize,
0,
worldSize
);
if (maxX < minX) maxX += worldSize;
const shouldZoomIn = maxX - minX < screenWidth;
if (shouldZoomIn) scaleX = screenWidth / (maxX - minX);
}
const {x: originalX, y: originalY} = projectToWorldCoordinates(worldSize, lngLat);
let modifiedX, modifiedY;
const scale = Math.max(scaleX || 0, scaleY || 0);
if (scale) {
// zoom in to exclude all beyond the given lng/lat ranges
const newPoint = new Point(
scaleX ? (maxX + minX) / 2 : originalX,
scaleY ? (maxY + minY) / 2 : originalY);
result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap();
result.zoom += scaleZoom(scale);
return result;
}
if (this._helper._latRange) {
const h2 = screenHeight / 2;
if (originalY - h2 < minY) modifiedY = minY + h2;
if (originalY + h2 > maxY) modifiedY = maxY - h2;
}
if (lngRange) {
const centerX = (minX + maxX) / 2;
let wrappedX = originalX;
if (this._helper._renderWorldCopies) {
wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2);
}
const w2 = screenWidth / 2;
if (wrappedX - w2 < minX) modifiedX = minX + w2;
if (wrappedX + w2 > maxX) modifiedX = maxX - w2;
}
// pan the map if the screen goes off the range
if (modifiedX !== undefined || modifiedY !== undefined) {
const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY);
result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap();
}
return result;
};
applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this._helper.applyConstrain(lngLat, zoom);
};
calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
return this._helper.calculateCenterFromCameraLngLatAlt(lnglat, alt, bearing, pitch);
}
_calculateNearFarZIfNeeded(cameraToSeaLevelDistance: number, limitedPitchRadians: number, offset: Point): void {
if (!this._helper.autoCalculateNearFarZ) {
return;
}
// In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation
const minRenderDistanceBelowCameraInMeters = 100;
const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile, this.getCameraAltitude() - minRenderDistanceBelowCameraInMeters);
const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians);
const lowestPlane = minElevation < 0 ? cameraToLowestPointDistance : cameraToSeaLevelDistance;
// Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the
// center top point [width/2 + offset.x, 0] in Z units, using the law of sines.
// 1 Z unit is equivalent to 1 horizontal px at the center of the map
// (the distance between[width/2, height/2] and [width/2 + 1, height/2])
const groundAngle = Math.PI / 2 + this.pitchInRadians;
const zfov = degreesToRadians(this.fov) * (Math.abs(Math.cos(degreesToRadians(this.roll))) * this.height + Math.abs(Math.sin(degreesToRadians(this.roll))) * this.width) / this.height;
const fovAboveCenter = zfov * (0.5 + offset.y / this.height);
const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01));
// Find the distance from the center point to the horizon
const horizon = getMercatorHorizon(this);
const horizonAngle = Math.atan(horizon / this._helper.cameraToCenterDistance);
const minFovCenterToHorizonRadians = degreesToRadians(90 - maxMercatorHorizonAngle);
const fovCenterToHorizon = horizonAngle > minFovCenterToHorizonRadians ? 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)) : minFovCenterToHorizonRadians;
const topHalfSurfaceDistanceHorizon = Math.sin(fovCenterToHorizon) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01));
// Calculate z distance of the farthest fragment that should be rendered.
// Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance`
const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon);
this._helper._farZ = (Math.cos(Math.PI / 2 - limitedPitchRadians) * topHalfMinDistance + lowestPlane) * 1.01;
// The larger the value of nearZ is
// - the more depth precision is available for features (good)
// - clipping starts appearing sooner when the camera is close to 3d features (bad)
//
// Other values work for mapbox-gl-js but deck.gl was encountering precision issues
// when rendering custom layers. This value was experimentally chosen and
// seems to solve z-fighting issues in deck.gl while not clipping buildings too close to the camera.
this._helper._nearZ = this._helper._height / 50;
}
_calcMatrices(): void {
if (!this._helper._height) return;
const offset = this.centerOffset;
const point = projectToWorldCoordinates(this.worldSize, this.center);
const x = point.x, y = point.y;
this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
// Calculate the camera to sea-level distance in pixel in respect of terrain
const limitedPitchRadians = degreesToRadians(Math.min(this.pitch, maxMercatorHorizonAngle));
const cameraToSeaLevelDistance = Math.max(this._helper.cameraToCenterDistance / 2, this._helper.cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians));
this._calculateNearFarZIfNeeded(cameraToSeaLevelDistance, limitedPitchRadians, offset);
// matrix for conversion from location to clip space(-1 .. 1)
let m: mat4;
m = new Float64Array(16) as any;
mat4.perspective(m, this.fovInRadians, this._helper._width / this._helper._height, this._helper._nearZ, this._helper._farZ);
this._invProjMatrix = new Float64Array(16) as any as mat4;
mat4.invert(this._invProjMatrix, m);
// Apply center of perspective offset
m[8] = -offset.x * 2 / this._helper._width;
m[9] = offset.y * 2 / this._helper._height;
this._projectionMatrix = mat4.clone(m);
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [0, 0, -this._helper.cameraToCenterDistance]);
mat4.rotateZ(m, m, -this.rollInRadians);
mat4.rotateX(m, m, this.pitchInRadians);
mat4.rotateZ(m, m, -this.bearingInRadians);
mat4.translate(m, m, [-x, -y, 0]);
// The mercatorMatrix can be used to transform points from mercator coordinates
// ([0, 0] nw, [1, 1] se) to clip space.
this._mercatorMatrix = mat4.scale([] as any, m, [this.worldSize, this.worldSize, this.worldSize]);
// scale vertically to meters per pixel (inverse of ground resolution):
mat4.scale(m, m, [1, 1, this._helper._pixelPerMeter]);
// matrix for conversion from world space to screen coordinates in 2D
this._pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m);
// matrix for conversion from world space to clip space (-1 .. 1)
mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain
this._viewProjMatrix = m;
this._invViewProjMatrix = mat4.invert([] as any, m);
const cameraPos: vec4 = [0, 0, -1, 1];
vec4.transformMat4(cameraPos, cameraPos, this._invViewProjMatrix);
this._cameraPosition = [
cameraPos[0] / cameraPos[3],
cameraPos[1] / cameraPos[3],
cameraPos[2] / cameraPos[3]
];
// create a fog matrix, same es proj-matrix but with near clipping-plane in mapcenter
// needed to calculate a correct z-value for fog calculation, because projMatrix z value is not
this._fogMatrix = new Float64Array(16) as any;
mat4.perspective(this._fogMatrix, this.fovInRadians, this.width / this.height, cameraToSeaLevelDistance, this._helper._farZ);
this._fogMatrix[8] = -offset.x * 2 / this.width;
this._fogMatrix[9] = offset.y * 2 / this.height;
mat4.scale(this._fogMatrix, this._fogMatrix, [1, -1, 1]);
mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.cameraToCenterDistance]);
mat4.rotateZ(this._fogMatrix, this._fogMatrix, -this.rollInRadians);
mat4.rotateX(this._fogMatrix, this._fogMatrix, this.pitchInRadians);
mat4.rotateZ(this._fogMatrix, this._fogMatrix, -this.bearingInRadians);
mat4.translate(this._fogMatrix, this._fogMatrix, [-x, -y, 0]);
mat4.scale(this._fogMatrix, this._fogMatrix, [1, 1, this._helper._pixelPerMeter]);
mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.elevation]); // elevate camera over terrain
// matrix for conversion from world space to screen coordinates in 3D
this._pixelMatrix3D = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m);
// Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles.
// We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional
// coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension
// is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle
// of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that
// it is always <= 0.5 pixels.
const xShift = (this._helper._width % 2) / 2, yShift = (this._helper._height % 2) / 2,
angleCos = Math.cos(this.bearingInRadians), angleSin = Math.sin(-this.bearingInRadians),
dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift,
dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift;
const alignedM = new Float64Array(m) as any as mat4;
mat4.translate(alignedM, alignedM, [dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0]);
this._alignedProjMatrix = alignedM;
// inverse matrix for conversion from screen coordinates to location
m = mat4.invert(new Float64Array(16) as any, this._pixelMatrix);
if (!m) throw new Error('failed to invert matrix');
this._pixelMatrixInverse = m;
this._clearMatrixCaches();
}
private _clearMatrixCaches(): void {
this._posMatrixCache.clear();
this._alignedPosMatrixCache.clear();
this._fogMatrixCacheF32.clear();
}
maxPitchScaleFactor(): number {
// calcMatrices hasn't run yet
if (!this._pixelMatrixInverse) return 1;
const coord = this.screenPointToMercatorCoordinate(new Point(0, 0));
const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4;
const topPoint = vec4.transformMat4(p, p, this._pixelMatrix);
return topPoint[3] / this._helper.cameraToCenterDistance;
}
getCameraPoint(): Point {
return this._helper.getCameraPoint();
}
getCameraAltitude(): number {
return this._helper.getCameraAltitude();
}
getCameraLngLat(): LngLat {
const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
const cameraToCenterDistanceMeters = this._helper.cameraToCenterDistance / pixelPerMeter;
const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters);
return camMercator.toLngLat();
}
lngLatToCameraDepth(lngLat: LngLat, elevation: number) {
const coord = MercatorCoordinate.fromLngLat(lngLat);
const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4;
vec4.transformMat4(p, p, this._viewProjMatrix);
return (p[2] / p[3]);
}
getProjectionData(params: ProjectionDataParams): ProjectionData {
const {overscaledTileID, aligned, applyTerrainMatrix} = params;
const mercatorTileCoordinates = this._helper.getMercatorTileCoordinates(overscaledTileID);
const tilePosMatrix = overscaledTileID ? this.calculatePosMatrix(overscaledTileID, aligned, true) : null;
let mainMatrix: mat4;
if (overscaledTileID && overscaledTileID.terrainRttPosMatrix32f && applyTerrainMatrix) {
mainMatrix = overscaledTileID.terrainRttPosMatrix32f;
} else if (tilePosMatrix) {
mainMatrix = tilePosMatrix; // This matrix should be float32
} else {
mainMatrix = createIdentityMat4f32();
}
return {
mainMatrix, // Might be set to a custom matrix by different projections.
tileMercatorCoords: mercatorTileCoordinates,
clippingPlane: [0, 0, 0, 0],
projectionTransition: 0.0, // Range 0..1, where 0 is mercator, 1 is another projection, mostly globe.
fallbackMatrix: mainMatrix,
};
}
isLocationOccluded(_: LngLat): boolean {
return false;
}
getPixelScale(): number {
return 1.0;
}
getCircleRadiusCorrection(): number {
return 1.0;
}
getPitchedTextCorrection(_textAnchorX: number, _textAnchorY: number, _tileID: UnwrappedTileID): number {
return 1.0;
}
transformLightDirection(dir: vec3): vec3 {
return vec3.clone(dir);
}
getRayDirectionFromPixel(_p: Point): vec3 {
throw new Error('Not implemented.'); // No need for this in mercator transform
}
projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection {
const matrix = this.calculatePosMatrix(unwrappedTileID);
let pos;
if (getElevation) { // slow because of handle z-index
pos = [x, y, getElevation(x, y), 1] as vec4;
vec4.transformMat4(pos, pos, matrix);
} else { // fast because of ignore z-index
pos = [x, y, 0, 1] as vec4;
xyTransformMat4(pos, pos, matrix);
}
const w = pos[3];
return {
point: new Point(pos[0] / w, pos[1] / w),
signedDistanceFromCamera: w,
isOccluded: false
};
}
populateCache(coords: Array<OverscaledTileID>): void {
for (const coord of coords) {
// Return value is thrown away, but this function will still
// place the pos matrix into the transform's internal cache.
this.calculatePosMatrix(coord);
}
}
getMatrixForModel(location: LngLatLike, altitude?: number): mat4 {
const modelAsMercatorCoordinate = MercatorCoordinate.fromLngLat(
location,
altitude
);
const scale = modelAsMercatorCoordinate.meterInMercatorCoordinateUnits();
const m = createIdentityMat4f64();
mat4.translate(m, m, [modelAsMercatorCoordinate.x, modelAsMercatorCoordinate.y, modelAsMercatorCoordinate.z]);
mat4.rotateZ(m, m, Math.PI);
mat4.rotateX(m, m, Math.PI / 2);
mat4.scale(m, m, [-scale, scale, scale]);
return m;
}
getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
const projectionData = this.getProjectionData({overscaledTileID: tileID, applyGlobeMatrix});
const tileMatrix = calculateTileMatrix(tileID, this.worldSize);
mat4.multiply(tileMatrix, this._viewProjMatrix, tileMatrix);
projectionData.tileMercatorCoords = [0, 0, 1, 1];
// Even though we requested projection data for the mercator base tile which covers the entire mercator range,
// the shader projection machinery still expects inputs to be in tile units range [0..EXTENT].
// Since custom layers are expected to supply mercator coordinates [0..1], we need to rescale
// both matrices by EXTENT. We also need to rescale Z.
const scale: vec3 = [EXTENT, EXTENT, this.worldSize / this._helper.pixelsPerMeter];
// We pass full-precision 64bit float matrices to custom layers to prevent precision loss in case the user wants to do further transformations.
// Otherwise we get very visible precision-artifacts and twitching for objects that are bulding-scale.
const projectionMatrixScaled = createMat4f64();
mat4.scale(projectionMatrixScaled, tileMatrix, scale);
projectionData.fallbackMatrix = projectionMatrixScaled;
projectionData.mainMatrix = projectionMatrixScaled;
return projectionData;
}
getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 {
return this.calculatePosMatrix(tileID);
}
}

View File

@@ -0,0 +1,113 @@
import {describe, expect, test} from 'vitest';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {getMercatorHorizon, projectToWorldCoordinates, tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils';
import {MercatorTransform} from './mercator_transform';
import {CanonicalTileID} from '../../tile/tile_id';
import {EXTENT} from '../../data/extent';
import {createIdentityMat4f32, MAX_VALID_LATITUDE} from '../../util/util';
describe('mercator utils', () => {
test('projectToWorldCoordinates basic', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setZoom(10);
expect(projectToWorldCoordinates(transform.worldSize, transform.center)).toEqual(new Point(262144, 262144));
});
test('projectToWorldCoordinates clamps latitude', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -MAX_VALID_LATITUDE)));
expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, 90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, MAX_VALID_LATITUDE)));
});
test('getMercatorHorizon', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(75);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(170.8176101748407, 10);
});
test('getMercatorHorizon90', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(90);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(-9.818037813626313, 10);
});
test('getMercatorHorizon95', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(95);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(-75.52102888757743, 10);
});
describe('getProjectionData', () => {
test('return identity matrix when not passing overscaledTileID', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
const projectionData = transform.getProjectionData({overscaledTileID: null});
expect(projectionData.fallbackMatrix).toEqual(createIdentityMat4f32());
});
});
describe('tileCoordinatesToMercatorCoordinates', () => {
const precisionDigits = 10;
test('Test 0,0', () => {
const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(0, 0, 0));
expect(result.x).toBe(0);
expect(result.y).toBe(0);
});
test('Test tile center', () => {
const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0));
expect(result.x).toBeCloseTo(0.5, precisionDigits);
expect(result.y).toBeCloseTo(0.5, precisionDigits);
});
test('Test higher zoom 0,0', () => {
const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(3, 0, 0));
expect(result.x).toBe(0);
expect(result.y).toBe(0);
});
test('Test higher zoom tile center', () => {
const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(3, 0, 0));
expect(result.x).toBeCloseTo(1 / 16, precisionDigits);
expect(result.y).toBeCloseTo(1 / 16, precisionDigits);
});
});
describe('tileCoordinatesToLocation', () => {
const precisionDigits = 5;
test('Test 0,0', () => {
const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(0, 0, 0));
expect(result.lng).toBeCloseTo(-180, precisionDigits);
expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits);
});
test('Test tile center', () => {
const result = tileCoordinatesToLocation(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0));
expect(result.lng).toBeCloseTo(0, precisionDigits);
expect(result.lat).toBeCloseTo(0, precisionDigits);
});
test('Test higher zoom 0,0', () => {
const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(3, 0, 0));
expect(result.lng).toBeCloseTo(-180, precisionDigits);
expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits);
});
test('Test higher zoom mercator center', () => {
const result = tileCoordinatesToLocation(EXTENT, EXTENT, new CanonicalTileID(3, 3, 3));
expect(result.lng).toBeCloseTo(0, precisionDigits);
expect(result.lat).toBeCloseTo(0, precisionDigits);
});
});
});

View File

@@ -0,0 +1,106 @@
import {mat4} from 'gl-matrix';
import {EXTENT} from '../../data/extent';
import {clamp, degreesToRadians, MAX_VALID_LATITUDE, zoomScale} from '../../util/util';
import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate';
import Point from '@mapbox/point-geometry';
import type {UnwrappedTileIDType} from '../transform_helper';
import type {LngLat} from '../lng_lat';
/*
* The maximum angle to use for the Mercator horizon. This must be less than 90
* to prevent errors in `MercatorTransform::_calcMatrices()`. It shouldn't be too close
* to 90, or the distance to the horizon will become very large, unnecessarily increasing
* the number of tiles needed to render the map.
*/
export const maxMercatorHorizonAngle = 89.25;
/**
* Returns mercator coordinates in range 0..1 for given coordinates inside a specified tile.
* @param inTileX - X coordinate in tile units - range [0..EXTENT].
* @param inTileY - Y coordinate in tile units - range [0..EXTENT].
* @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom.
* @returns Mercator coordinates of the specified point in range [0..1].
*/
export function tileCoordinatesToMercatorCoordinates(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): MercatorCoordinate {
const scale = 1.0 / (1 << canonicalTileID.z);
return new MercatorCoordinate(
inTileX / EXTENT * scale + canonicalTileID.x * scale,
inTileY / EXTENT * scale + canonicalTileID.y * scale
);
}
/**
* Returns LngLat for given in-tile coordinates and tile ID.
* @param inTileX - X coordinate in tile units - range [0..EXTENT].
* @param inTileY - Y coordinate in tile units - range [0..EXTENT].
* @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom.
*/
export function tileCoordinatesToLocation(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): LngLat {
return tileCoordinatesToMercatorCoordinates(inTileX, inTileY, canonicalTileID).toLngLat();
}
/**
* Convert from LngLat to world coordinates (Mercator coordinates scaled by world size).
* @param worldSize - Mercator world size computed from zoom level and tile size.
* @param lnglat - The location to convert.
* @returns Point
*/
export function projectToWorldCoordinates(worldSize: number, lnglat: LngLat): Point {
const lat = clamp(lnglat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE);
return new Point(
mercatorXfromLng(lnglat.lng) * worldSize,
mercatorYfromLat(lat) * worldSize);
}
/**
* Convert from world coordinates (mercator coordinates scaled by world size) to LngLat.
* @param worldSize - Mercator world size computed from zoom level and tile size.
* @param point - World coordinate.
* @returns LngLat
*/
export function unprojectFromWorldCoordinates(worldSize: number, point: Point): LngLat {
return new MercatorCoordinate(point.x / worldSize, point.y / worldSize).toLngLat();
}
/**
* Calculate pixel height of the visible horizon in relation to map-center (e.g. height/2),
* multiplied by a static factor to simulate the earth-radius.
* The calculated value is the horizontal line from the camera-height to sea-level.
* @returns Horizon above center in pixels.
*/
export function getMercatorHorizon(transform: {pitch: number; cameraToCenterDistance: number}): number {
return transform.cameraToCenterDistance * Math.min(Math.tan(degreesToRadians(90 - transform.pitch)) * 0.85,
Math.tan(degreesToRadians(maxMercatorHorizonAngle - transform.pitch)));
}
export function calculateTileMatrix(unwrappedTileID: UnwrappedTileIDType, worldSize: number): mat4 {
const canonical = unwrappedTileID.canonical;
const scale = worldSize / zoomScale(canonical.z);
const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap;
const worldMatrix = mat4.identity(new Float64Array(16) as any);
mat4.translate(worldMatrix, worldMatrix, [unwrappedX * scale, canonical.y * scale, 0]);
mat4.scale(worldMatrix, worldMatrix, [scale / EXTENT, scale / EXTENT, 1]);
return worldMatrix;
}
export function cameraMercatorCoordinateFromCenterAndRotation(center: LngLat, elevation: number, pitch: number, bearing: number, distance: number): MercatorCoordinate {
const centerMercator = MercatorCoordinate.fromLngLat(center, elevation);
const mercUnitsPerMeter = mercatorZfromAltitude(1, center.lat);
const dMercator = distance * mercUnitsPerMeter;
const {x, y, z} = cameraDirectionFromPitchBearing(pitch, bearing);
const dxMercator = dMercator * -x;
const dyMercator = dMercator * -y;
const dzMercator = dMercator * -z;
return new MercatorCoordinate(centerMercator.x + dxMercator, centerMercator.y + dyMercator, centerMercator.z + dzMercator);
}
export function cameraDirectionFromPitchBearing(pitch: number, bearing: number): {x: number; y: number; z: number} {
const pitchRadians = degreesToRadians(pitch);
const bearingRadians = degreesToRadians(bearing);
const z = Math.cos(-pitchRadians);
const h = Math.sin(pitchRadians);
const x = h * Math.sin(bearingRadians);
const y = -h * Math.cos(bearingRadians);
return {x, y, z};
}

View File

@@ -0,0 +1,149 @@
import type {CanonicalTileID} from '../../tile/tile_id';
import type {PreparedShader} from '../../shaders/shaders';
import type {Context} from '../../gl/context';
import type {Mesh} from '../../render/mesh';
import type {Program} from '../../render/program';
import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {EvaluationParameters} from '../../style/evaluation_parameters';
/**
* Custom projections are handled both by a class which implements this `Projection` interface,
* and a class that is derived from the `Transform` base class. What is the difference?
*
* The transform-derived class:
* - should do all the heavy lifting for the projection - implement all the `project*` and `unproject*` functions, etc.
* - must store the map's state - center, pitch, etc. - this is handled in the `Transform` base class
* - must be cloneable - it should not create any heavy resources
*
* The projection-implementing class:
* - must provide basic information and data about the projection, which is *independent of the map's state* - name, shader functions, subdivision settings, etc.
* - must be a "singleton" - no matter how many copies of the matching Transform class exist, the Projection should always exist as a single instance (per Map)
* - may create heavy resources that should not exist in multiple copies (projection is never cloned) - for example, see the GPU inaccuracy mitigation for globe projection
* - must be explicitly disposed of after usage using the `destroy` function - this allows the implementing class to free any allocated resources
*/
/**
* @internal
*/
export type ProjectionGPUContext = {
context: Context;
useProgram: (name: string) => Program<any>;
};
/**
* @internal
* Specifies the usage for a square tile mesh:
* - 'stencil' for drawing stencil masks
* - 'raster' for drawing raster tiles, hillshade, etc.
*/
export type TileMeshUsage = 'stencil' | 'raster';
/**
* An interface the implementations of which are used internally by MapLibre to handle different projections.
*/
export interface Projection {
/**
* @internal
* A short, descriptive name of this projection, such as 'mercator' or 'globe'.
*/
get name(): ProjectionSpecification['type'];
/**
* @internal
* True if this projection needs to render subdivided geometry.
* Optimized rendering paths for non-subdivided geometry might be used throughout MapLibre.
* The value of this property may change during runtime, for example in globe projection depending on zoom.
*/
get useSubdivision(): boolean;
/**
* Name of the shader projection variant that should be used for this projection.
* Note that this value may change dynamically, for example when globe projection internally transitions to mercator.
* Then globe projection might start reporting the mercator shader variant name to make MapLibre use faster mercator shaders.
*/
get shaderVariantName(): string;
/**
* A `#define` macro that is injected into every MapLibre shader that uses this projection.
* @example
* `const define = projection.shaderDefine; // '#define GLOBE'`
*/
get shaderDefine(): string;
/**
* @internal
* A preprocessed prelude code for both vertex and fragment shaders.
*/
get shaderPreludeCode(): PreparedShader;
/**
* Vertex shader code that is injected into every MapLibre vertex shader that uses this projection.
*/
get vertexShaderPreludeCode(): string;
/**
* @internal
* An object describing how much subdivision should be applied to rendered geometry.
* The subdivision settings should be a constant for a given projection.
* Projections that do not require subdivision should return {@link SubdivisionGranularitySetting.noSubdivision}.
*/
get subdivisionGranularity(): SubdivisionGranularitySetting;
/**
* @internal
* A number representing the current transition state of the projection.
* The return value should be a number between 0 and 1,
* where 0 means the projection is fully in the initial state,
* and 1 means the projection is fully in the final state.
*/
get transitionState(): number;
/**
* @internal
* Gets the error correction latitude in radians.
*/
get latitudeErrorCorrectionRadians(): number;
/**
* @internal
* Cleans up any resources the projection created, especially GPU buffers.
*/
destroy(): void;
/**
* @internal
* Runs any GPU-side tasks this projection required. Called at the beginning of every frame.
*/
updateGPUdependent(renderContext: ProjectionGPUContext): void;
/**
* @internal
* Returns a subdivided mesh for a given tile ID, covering 0..EXTENT range.
* @param context - WebGL context.
* @param tileID - The tile coordinates for which to return a mesh. Meshes for tiles that border the top/bottom mercator edge might include extra geometry for the north/south pole.
* @param hasBorder - When true, the mesh will also include a small border beyond the 0..EXTENT range.
* @param allowPoles - When true, the mesh will also include geometry to cover the north (south) pole, if the given tileID borders the mercator range's top (bottom) edge.
* @param usage - Specify the usage of the tile mesh, as different usages might use different levels of subdivision.
*/
getMeshFromTileID(context: Context, tileID: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh;
/**
* @internal
* Recalculates the projection state based on the current evaluation parameters.
* @param params - Evaluation parameters.
*/
recalculate(params: EvaluationParameters): void;
/**
* @internal
* Returns true if the projection is currently transitioning between two states.
*/
hasTransition(): boolean;
/**
* @internal
* Sets the error query latidude in degrees
*/
setErrorQueryLatitudeDegrees(value: number);
}

View File

@@ -0,0 +1,70 @@
import type {mat4} from 'gl-matrix';
import type {OverscaledTileID} from '../../tile/tile_id';
/**
* This type contains all data necessary to project a tile to screen in MapLibre's shader system.
* Contains data used for both mercator and globe projection.
*/
export type ProjectionData = {
/**
* The main projection matrix. For mercator projection, it usually projects in-tile coordinates 0..EXTENT to screen,
* for globe projection, it projects a unit sphere planet to screen.
* Uniform name: `u_projection_matrix`.
*/
mainMatrix: mat4;
/**
* The extent of current tile in the mercator square.
* Used by globe projection.
* First two components are X and Y offset, last two are X and Y scale.
* Uniform name: `u_projection_tile_mercator_coords`.
*
* Conversion from in-tile coordinates in range 0..EXTENT is done as follows:
* @example
* ```
* vec2 mercator_coords = u_projection_tile_mercator_coords.xy + in_tile.xy * u_projection_tile_mercator_coords.zw;
* ```
*/
tileMercatorCoords: [number, number, number, number];
/**
* The plane equation for a plane that intersects the planet's horizon.
* Assumes the planet to be a unit sphere.
* Used by globe projection for clipping.
* Uniform name: `u_projection_clipping_plane`.
*/
clippingPlane: [number, number, number, number];
/**
* A value in range 0..1 indicating interpolation between mercator (0) and globe (1) projections.
* Used by globe projection to hide projection transition at high zooms.
* Uniform name: `u_projection_transition`.
*/
projectionTransition: number;
/**
* Fallback matrix that projects the current tile according to mercator projection.
* Used by globe projection to fall back to mercator projection in an animated way.
* Uniform name: `u_projection_fallback_matrix`.
*/
fallbackMatrix: mat4;
};
/**
* Parameters object for the transform's `getProjectionData` function.
* Contains the requested tile ID and more.
*/
export type ProjectionDataParams = {
/**
* The ID of the current tile
*/
overscaledTileID: OverscaledTileID | null;
/**
* Set to true if a pixel-aligned matrix should be used, if possible (mostly used for raster tiles under mercator projection)
*/
aligned?: boolean;
/**
* Set to true if the terrain matrix should be applied (i.e. when rendering terrain)
*/
applyTerrainMatrix?: boolean;
/**
* Set to true if the globe matrix should be applied (i.e. when rendering globe)
*/
applyGlobeMatrix?: boolean;
};

View File

@@ -0,0 +1,75 @@
import {warnOnce} from '../../util/util';
import {MercatorProjection} from './mercator_projection';
import {MercatorTransform} from './mercator_transform';
import {MercatorCameraHelper} from './mercator_camera_helper';
import {GlobeProjection} from './globe_projection';
import {GlobeTransform} from './globe_transform';
import {GlobeCameraHelper} from './globe_camera_helper';
import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper';
import {VerticalPerspectiveTransform} from './vertical_perspective_transform';
import {VerticalPerspectiveProjection} from './vertical_perspective_projection';
import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {Projection} from './projection';
import type {ITransform, TransformConstrainFunction} from '../transform_interface';
import type {ICameraHelper} from './camera_helper';
export function createProjectionFromName(name: ProjectionSpecification['type'], transformConstrain?: TransformConstrainFunction): {
projection: Projection;
transform: ITransform;
cameraHelper: ICameraHelper;
} {
const transformOptions = {constrainOverride: transformConstrain};
if (Array.isArray(name)) {
const globeProjection = new GlobeProjection({type: name});
return {
projection: globeProjection,
transform: new GlobeTransform(transformOptions),
cameraHelper: new GlobeCameraHelper(globeProjection),
};
}
switch (name) {
case 'mercator':
{
return {
projection: new MercatorProjection(),
transform: new MercatorTransform(transformOptions),
cameraHelper: new MercatorCameraHelper(),
};
}
case 'globe':
{
const globeProjection = new GlobeProjection({type: [
'interpolate',
['linear'],
['zoom'],
11,
'vertical-perspective',
12,
'mercator'
]});
return {
projection: globeProjection,
transform: new GlobeTransform(transformOptions),
cameraHelper: new GlobeCameraHelper(globeProjection),
};
}
case 'vertical-perspective':
{
return {
projection: new VerticalPerspectiveProjection(),
transform: new VerticalPerspectiveTransform(transformOptions),
cameraHelper: new VerticalPerspectiveCameraHelper(),
};
}
default:
{
warnOnce(`Unknown projection name: ${name}. Falling back to mercator projection.`);
return {
projection: new MercatorProjection(),
transform: new MercatorTransform(transformOptions),
cameraHelper: new MercatorCameraHelper(),
};
}
}
}

View File

@@ -0,0 +1,458 @@
import Point from '@mapbox/point-geometry';
import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs, cameraForBoxAndBearing} from './camera_helper';
import {LngLat, type LngLatLike} from '../lng_lat';
import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils';
import {clamp, createVec3f64, differenceOfAnglesDegrees, MAX_VALID_LATITUDE, remapSaturate, rollPitchBearingEqual, scaleZoom, warnOnce, zoomScale} from '../../util/util';
import {type mat4, vec3} from 'gl-matrix';
import {normalizeCenter} from '../transform_helper';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {PaddingOptions} from '../edge_insets';
/**
* @internal
*/
export class VerticalPerspectiveCameraHelper implements ICameraHelper {
get useGlobeControls(): boolean { return true; }
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
const panCenter = computeGlobePanCenter(pan, transform);
if (Math.abs(panCenter.lng - transform.center.lng) > 180) {
// If easeTo target would be over 180° distant, the animation would move
// in the opposite direction that what the user intended.
// Thus we clamp the movement to 179.5°.
panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng);
}
return {
easingCenter: panCenter,
easingOffset: new Point(0, 0),
};
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
const zoomPixel = deltas.around;
const zoomLoc = tr.screenPointToLocation(zoomPixel);
if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta);
if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta);
if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta);
const oldZoomPreZoomDelta = tr.zoom;
if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta);
const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta;
if (actualZoomDelta === 0) {
return;
}
// Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming.
// - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out
// - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out
// But otherwise works fine at higher zooms, or when the target is somewhat near the current map center.
// Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible.
// Magic numbers that control:
// - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements)
// - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center
// - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen
// - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot)
const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface).
const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur.
const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming.
const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends.
const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother.
const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming.
const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends.
const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming
const slowingRadiusStop = 0.5;
const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small
const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng);
const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole).
const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat);
// Slow zoom movement down if the mouse ray is far from the planet.
const rayDirection = tr.getRayDirectionFromPixel(zoomPixel);
const rayOrigin = tr.cameraPosition;
const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection).
const closestPoint = createVec3f64();
vec3.add(closestPoint, rayOrigin, [
rayDirection[0] * distanceToClosestPoint,
rayDirection[1] * distanceToClosestPoint,
rayDirection[2] * distanceToClosestPoint
]);
const distanceFromSurface = vec3.length(closestPoint) - 1;
const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier);
// Slow zoom movement down if the globe is too small on viewport
const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension
const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor);
// Compute how much to move towards the zoom location
const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor);
const oldCenterLat = tr.center.lat;
const oldZoom = tr.zoom;
const heuristicCenter = new LngLat(
tr.center.lng + dLng * factor,
clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE)
);
// Now compute the map center exact zoom
tr.setLocationAtPoint(zoomLoc, zoomPixel);
const exactCenter = tr.center;
// Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location.
const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1);
const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1);
const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent);
const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng);
const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat);
tr.setCenter(new LngLat(
exactCenter.lng + lngExactToHeuristic * heuristicFactor,
exactCenter.lat + latExactToHeuristic * heuristicFactor
).wrap());
tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat));
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, _preZoomAroundLoc: LngLat): void {
if (!deltas.panDelta) {
return;
}
// These are actually very similar to mercator controls, and should converge to them at high zooms.
// We avoid using the "grab a place and move it around" approach from mercator here,
// since it is not a very pleasant way to pan a globe.
const oldLat = tr.center.lat;
const oldZoom = tr.zoom;
tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap());
// Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time
tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat));
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult {
const result = cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
// If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds.
// Get clip space bounds including padding
const xLeft = (padding.left) / tr.width * 2.0 - 1.0;
const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0;
const yTop = (padding.top) / tr.height * -2.0 + 1.0;
const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0;
// Get camera bounds
const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0;
const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest();
const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast();
const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north...
const latSouth = Math.min(bounds.getNorth(), bounds.getSouth());
// Additional vectors will be tested for the rectangle midpoints
const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5;
const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5;
// Obtain a globe projection matrix that does not include pitch (unsupported)
const clonedTr = tr.clone();
clonedTr.setCenter(result.center);
clonedTr.setBearing(result.bearing);
clonedTr.setPitch(0);
clonedTr.setRoll(0);
clonedTr.setZoom(result.zoom);
const matrix = clonedTr.modelViewProjectionMatrix;
// Vectors to test - the bounds' corners and edge midpoints
const testVectors = [
angularCoordinatesToSurfaceVector(bounds.getNorthWest()),
angularCoordinatesToSurfaceVector(bounds.getNorthEast()),
angularCoordinatesToSurfaceVector(bounds.getSouthWest()),
angularCoordinatesToSurfaceVector(bounds.getSouthEast()),
// Also test edge midpoints
angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)),
angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)),
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)),
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth))
];
const vecToCenter = angularCoordinatesToSurfaceVector(result.center);
// Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space.
let smallestNeededScale = Number.POSITIVE_INFINITY;
for (const vec of testVectors) {
if (xLeft < 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft));
if (xRight > 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight));
if (yTop > 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop));
if (yBottom < 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom));
}
if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) {
cameraBoundsWarning();
return undefined;
}
// Compute target zoom from the obtained scale.
result.zoom = Math.min(clonedTr.zoom + scaleZoom(smallestNeededScale), options.maxZoom);
return result;
}
/**
* Handles the zoom and center change during camera jumpTo.
*/
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
// Special zoom & center handling for globe:
// Globe constrained center isn't dependent on zoom level
const startingLat = tr.center.lat;
const constrainedCenter = tr.applyConstrain(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center;
tr.setCenter(constrainedCenter.wrap());
// Make sure to compute correct target zoom level if no zoom is specified
const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat));
if (tr.zoom !== targetZoom) {
tr.setZoom(targetZoom);
}
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
const startZoom = tr.zoom;
const startCenter = tr.center;
const startPadding = tr.padding;
const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing};
const endRoll = options.roll === undefined ? tr.roll : options.roll;
const endPitch = options.pitch === undefined ? tr.pitch : options.pitch;
const endBearing = options.bearing === undefined ? tr.bearing : options.bearing;
const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing};
const optionsZoom = typeof options.zoom !== 'undefined';
const doPadding = !tr.isPaddingEqual(options.padding);
let isZooming = false;
// Globe needs special handling for how zoom should be animated.
// 1) if zoom is set, ease to the given mercator zoom
// 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept
const preConstrainCenter = options.center ?
LngLat.convert(options.center) :
startCenter;
const constrainedCenter = tr.applyConstrain(
preConstrainCenter,
startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled
).center;
normalizeCenter(tr, constrainedCenter);
const clonedTr = tr.clone();
clonedTr.setCenter(constrainedCenter);
clonedTr.setZoom(optionsZoom ?
+options.zoom :
startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat));
clonedTr.setBearing(options.bearing);
const clampedPoint = new Point(
clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width),
clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height)
);
clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint);
// Find final animation targets
const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter;
const endZoomWithShift = optionsZoom ?
+options.zoom :
startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat);
// Planet radius for a given zoom level differs according to latitude
// Convert zooms to what they would be at equator for the given planet radius
const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0);
const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0);
const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng);
const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat);
const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom);
isZooming = (endZoomWithShift !== startZoom);
const easeFunc = (k: number) => {
if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) {
updateRotation({
startEulerAngles,
endEulerAngles,
tr,
k,
useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs);
}
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding,k);
}
if (options.around) {
warnOnce('Easing around a point is not supported under globe projection.');
tr.setLocationAtPoint(options.around, options.aroundPoint);
} else {
const base = normalizedEndZoom > normalizedStartZoom ?
Math.min(2, finalScale) :
Math.max(0.5, finalScale);
const speedup = Math.pow(base, 1 - k);
const factor = k * speedup;
// Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles.
// Instead we interpolate LngLat almost directly, but taking into account that
// one degree of longitude gets progressively smaller relative to latitude towards the poles.
const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor);
tr.setCenter(newCenter.wrap());
}
if (isZooming) {
const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k);
const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat);
tr.setZoom(interpolatedZoom);
}
};
return {
easeFunc,
isZooming,
elevationCenter: endCenterWithShift,
};
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
const optionsZoom = typeof options.zoom !== 'undefined';
const startCenter = tr.center;
const startZoom = tr.zoom;
const startPadding = tr.padding;
const doPadding = !tr.isPaddingEqual(options.padding);
// Obtain target center and zoom
const constrainedCenter = tr.applyConstrain(
LngLat.convert(options.center || options.locationAtOffset),
startZoom
).center;
const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat);
// Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`.
const clonedTr = tr.clone();
clonedTr.setCenter(constrainedCenter);
clonedTr.setZoom(targetZoom);
clonedTr.setBearing(options.bearing);
const clampedPoint = new Point(
clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width),
clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height)
);
clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint);
const targetCenter = clonedTr.center;
normalizeCenter(tr, targetCenter);
const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter);
const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0);
const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0);
const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom);
const optionsMinZoom = typeof options.minZoom === 'number';
let scaleOfMinZoom: number;
if (optionsMinZoom) {
const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0);
const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom);
const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat);
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0);
scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom);
}
const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng);
const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat);
const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => {
const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor);
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding,k);
}
const newCenter = k === 1 ? targetCenter : interpolatedCenter;
tr.setCenter(newCenter.wrap());
const interpolatedZoom = normalizedStartZoom + scaleZoom(scale);
tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat)));
};
return {
easeFunc,
scaleOfZoom,
targetCenter,
scaleOfMinZoom,
pixelPathLength,
};
}
/**
* Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis.
* @param vector - Position of the queried location on the surface of the unit sphere globe.
* @param toCenter - Position of current transform center on the surface of the unit sphere globe.
* This is needed because zooming the globe not only changes its scale,
* but also moves the camera closer or further away along this vector (pitch is disregarded).
* @param projection - The globe projection matrix.
* @param targetDimension - The dimension in which the scaled vector must match the target value in clip space.
* @param targetValue - The target clip space value in the specified dimension to which the queried vector must project.
* @returns How much to scale the globe.
*/
private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null {
// We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y).
const k = targetValue;
const columnXorY = targetDimension === 'x' ?
[projection[0], projection[4], projection[8], projection[12]] : // X
[projection[1], projection[5], projection[9], projection[13]]; // Y
const columnZ = [projection[3], projection[7], projection[11], projection[15]];
const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2];
const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2];
const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2];
const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2];
// The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t".
// Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C".
const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ);
if (
toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ ||
columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ
) {
// The computed result is invalid.
return null;
}
return t;
}
/**
* Returns `newValue` if it is:
*
* - not null AND
* - not negative AND
* - smaller than `newValue`,
*
* ...otherwise returns `oldValue`.
*/
private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number {
if (newValue !== null && newValue >= 0 && newValue < oldValue) {
return newValue;
} else {
return oldValue;
}
}
}

View File

@@ -0,0 +1,167 @@
import type {Context} from '../../gl/context';
import type {CanonicalTileID} from '../../tile/tile_id';
import {type Mesh} from '../../render/mesh';
import {now} from '../../util/time_control';
import {easeCubicInOut, lerp} from '../../util/util';
import {mercatorYfromLat} from '../mercator_coordinate';
import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection';
import {type PreparedShader, shaders} from '../../shaders/shaders';
import {ProjectionErrorMeasurement} from './globe_projection_error_measurement';
import {createTileMeshWithBuffers, type CreateTileMeshOptions} from '../../util/create_tile_mesh';
import {type EvaluationParameters} from '../../style/evaluation_parameters';
export const VerticalPerspectiveShaderDefine = '#define GLOBE';
export const VerticalPerspectiveShaderVariantKey = 'globe';
export const globeConstants = {
errorTransitionTimeSeconds: 0.5
};
const granularitySettingsGlobe: SubdivisionGranularitySetting = new SubdivisionGranularitySetting({
fill: new SubdivisionGranularityExpression(128, 2),
line: new SubdivisionGranularityExpression(512, 0),
// Always keep at least some subdivision on raster tiles, etc,
// otherwise they will be visibly warped at high zooms (before mercator transition).
// This si not needed on fill, because fill geometry tends to already be
// highly tessellated and granular at high zooms.
tile: new SubdivisionGranularityExpression(128, 32),
// Stencil granularity must never be higher than fill granularity,
// otherwise we would get seams in the oceans at zoom levels where
// stencil has higher granularity than fill.
stencil: new SubdivisionGranularityExpression(128, 1),
circle: 3
});
export class VerticalPerspectiveProjection implements Projection {
private _tileMeshCache: {[_: string]: Mesh} = {};
// GPU atan() error correction
private _errorMeasurement: ProjectionErrorMeasurement;
private _errorQueryLatitudeDegrees: number;
private _errorCorrectionUsable: number = 0.0;
private _errorMeasurementLastValue: number = 0.0;
private _errorCorrectionPreviousValue: number = 0.0;
private _errorMeasurementLastChangeTime: number = -1000.0;
get name(): 'vertical-perspective' {
return 'vertical-perspective';
}
get transitionState(): number {
return 1;
}
get useSubdivision(): boolean {
return true;
}
get shaderVariantName(): string {
return VerticalPerspectiveShaderVariantKey;
}
get shaderDefine(): string {
return VerticalPerspectiveShaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return shaders.projectionGlobe;
}
get vertexShaderPreludeCode(): string {
return shaders.projectionMercator.vertexSource;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return granularitySettingsGlobe;
}
get useGlobeControls(): boolean {
return true;
}
/**
* @internal
* Globe projection periodically measures the error of the GPU's
* projection from mercator to globe and computes how much to correct
* the globe's latitude alignment.
* This stores the correction that should be applied to the projection matrix.
*/
get latitudeErrorCorrectionRadians(): number { return this._errorCorrectionUsable; }
public destroy() {
if (this._errorMeasurement) {
this._errorMeasurement.destroy();
}
}
public updateGPUdependent(renderContext: ProjectionGPUContext): void {
if (!this._errorMeasurement) {
this._errorMeasurement = new ProjectionErrorMeasurement(renderContext);
}
const mercatorY = mercatorYfromLat(this._errorQueryLatitudeDegrees);
const expectedResult = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
const newValue = this._errorMeasurement.updateErrorLoop(mercatorY, expectedResult);
const currentTime = now();
if (newValue !== this._errorMeasurementLastValue) {
this._errorCorrectionPreviousValue = this._errorCorrectionUsable; // store the interpolated value
this._errorMeasurementLastValue = newValue;
this._errorMeasurementLastChangeTime = currentTime;
}
const sinceUpdateSeconds = (currentTime - this._errorMeasurementLastChangeTime) / 1000.0;
const mix = Math.min(Math.max(sinceUpdateSeconds / globeConstants.errorTransitionTimeSeconds, 0.0), 1.0);
const newCorrection = -this._errorMeasurementLastValue; // Note the negation
this._errorCorrectionUsable = lerp(this._errorCorrectionPreviousValue, newCorrection, easeCubicInOut(mix));
}
private _getMeshKey(options: CreateTileMeshOptions): string {
return `${options.granularity.toString(36)}_${options.generateBorders ? 'b' : ''}${options.extendToNorthPole ? 'n' : ''}${options.extendToSouthPole ? 's' : ''}`;
}
public getMeshFromTileID(context: Context, canonical: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh {
// Stencil granularity must match fill granularity
const granularityConfig = usage === 'stencil' ? granularitySettingsGlobe.stencil : granularitySettingsGlobe.tile;
const granularity = granularityConfig.getGranularityForZoomLevel(canonical.z);
const north = (canonical.y === 0) && allowPoles;
const south = (canonical.y === (1 << canonical.z) - 1) && allowPoles;
return this._getMesh(context, {
granularity,
generateBorders: hasBorder,
extendToNorthPole: north,
extendToSouthPole: south,
});
}
private _getMesh(context: Context, options: CreateTileMeshOptions): Mesh {
const key = this._getMeshKey(options);
if (key in this._tileMeshCache) {
return this._tileMeshCache[key];
}
const mesh = createTileMeshWithBuffers(context, options);
this._tileMeshCache[key] = mesh;
return mesh;
}
recalculate(_params: EvaluationParameters): void {
// Do nothing.
}
hasTransition(): boolean {
const currentTime = now();
let dirty = false;
// Error correction transition
dirty = dirty || (currentTime - this._errorMeasurementLastChangeTime) / 1000.0 < (globeConstants.errorTransitionTimeSeconds + 0.2);
// Error correction query in flight
dirty = dirty || (this._errorMeasurement && this._errorMeasurement.awaitingQuery);
return dirty;
}
setErrorQueryLatitudeDegrees(value: number) {
this._errorQueryLatitudeDegrees = value;
}
}

File diff suppressed because it is too large Load Diff