Initial commit
This commit is contained in:
221
node_modules/maplibre-gl/src/geo/projection/camera_helper.ts
generated
vendored
Normal file
221
node_modules/maplibre-gl/src/geo/projection/camera_helper.ts
generated
vendored
Normal 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;
|
||||
}
|
||||
837
node_modules/maplibre-gl/src/geo/projection/covering_tiles.test.ts
generated
vendored
Normal file
837
node_modules/maplibre-gl/src/geo/projection/covering_tiles.test.ts
generated
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
291
node_modules/maplibre-gl/src/geo/projection/covering_tiles.ts
generated
vendored
Normal file
291
node_modules/maplibre-gl/src/geo/projection/covering_tiles.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
44
node_modules/maplibre-gl/src/geo/projection/covering_tiles_details_provider.ts
generated
vendored
Normal file
44
node_modules/maplibre-gl/src/geo/projection/covering_tiles_details_provider.ts
generated
vendored
Normal 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;
|
||||
}
|
||||
66
node_modules/maplibre-gl/src/geo/projection/globe_camera_helper.ts
generated
vendored
Normal file
66
node_modules/maplibre-gl/src/geo/projection/globe_camera_helper.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
105
node_modules/maplibre-gl/src/geo/projection/globe_covering_tiles.test.ts
generated
vendored
Normal file
105
node_modules/maplibre-gl/src/geo/projection/globe_covering_tiles.test.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
311
node_modules/maplibre-gl/src/geo/projection/globe_covering_tiles_details_provider.ts
generated
vendored
Normal file
311
node_modules/maplibre-gl/src/geo/projection/globe_covering_tiles_details_provider.ts
generated
vendored
Normal 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
|
||||
};
|
||||
}
|
||||
139
node_modules/maplibre-gl/src/geo/projection/globe_projection.ts
generated
vendored
Normal file
139
node_modules/maplibre-gl/src/geo/projection/globe_projection.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
243
node_modules/maplibre-gl/src/geo/projection/globe_projection_error_measurement.ts
generated
vendored
Normal file
243
node_modules/maplibre-gl/src/geo/projection/globe_projection_error_measurement.ts
generated
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
606
node_modules/maplibre-gl/src/geo/projection/globe_transform.test.ts
generated
vendored
Normal file
606
node_modules/maplibre-gl/src/geo/projection/globe_transform.test.ts
generated
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
471
node_modules/maplibre-gl/src/geo/projection/globe_transform.ts
generated
vendored
Normal file
471
node_modules/maplibre-gl/src/geo/projection/globe_transform.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
50
node_modules/maplibre-gl/src/geo/projection/globe_utils.test.ts
generated
vendored
Normal file
50
node_modules/maplibre-gl/src/geo/projection/globe_utils.test.ts
generated
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
253
node_modules/maplibre-gl/src/geo/projection/globe_utils.ts
generated
vendored
Normal file
253
node_modules/maplibre-gl/src/geo/projection/globe_utils.ts
generated
vendored
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
187
node_modules/maplibre-gl/src/geo/projection/mercator_camera_helper.ts
generated
vendored
Normal file
187
node_modules/maplibre-gl/src/geo/projection/mercator_camera_helper.ts
generated
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
55
node_modules/maplibre-gl/src/geo/projection/mercator_covering_tiles_details_provider.ts
generated
vendored
Normal file
55
node_modules/maplibre-gl/src/geo/projection/mercator_covering_tiles_details_provider.ts
generated
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
102
node_modules/maplibre-gl/src/geo/projection/mercator_projection.ts
generated
vendored
Normal file
102
node_modules/maplibre-gl/src/geo/projection/mercator_projection.ts
generated
vendored
Normal 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.
|
||||
}
|
||||
}
|
||||
619
node_modules/maplibre-gl/src/geo/projection/mercator_transform.test.ts
generated
vendored
Normal file
619
node_modules/maplibre-gl/src/geo/projection/mercator_transform.test.ts
generated
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
845
node_modules/maplibre-gl/src/geo/projection/mercator_transform.ts
generated
vendored
Normal file
845
node_modules/maplibre-gl/src/geo/projection/mercator_transform.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
113
node_modules/maplibre-gl/src/geo/projection/mercator_utils.test.ts
generated
vendored
Normal file
113
node_modules/maplibre-gl/src/geo/projection/mercator_utils.test.ts
generated
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
node_modules/maplibre-gl/src/geo/projection/mercator_utils.ts
generated
vendored
Normal file
106
node_modules/maplibre-gl/src/geo/projection/mercator_utils.ts
generated
vendored
Normal 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};
|
||||
}
|
||||
149
node_modules/maplibre-gl/src/geo/projection/projection.ts
generated
vendored
Normal file
149
node_modules/maplibre-gl/src/geo/projection/projection.ts
generated
vendored
Normal 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);
|
||||
}
|
||||
70
node_modules/maplibre-gl/src/geo/projection/projection_data.ts
generated
vendored
Normal file
70
node_modules/maplibre-gl/src/geo/projection/projection_data.ts
generated
vendored
Normal 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;
|
||||
};
|
||||
75
node_modules/maplibre-gl/src/geo/projection/projection_factory.ts
generated
vendored
Normal file
75
node_modules/maplibre-gl/src/geo/projection/projection_factory.ts
generated
vendored
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
458
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_camera_helper.ts
generated
vendored
Normal file
458
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_camera_helper.ts
generated
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_projection.ts
generated
vendored
Normal file
167
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_projection.ts
generated
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
1002
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_transform.ts
generated
vendored
Normal file
1002
node_modules/maplibre-gl/src/geo/projection/vertical_perspective_transform.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user