UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

1,488 lines • 77.4 kB
import {describe, test, expect, vi} from 'vitest';
import Point from '@mapbox/point-geometry';
import {LngLat, earthRadius} from '../lng_lat.ts';
import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../tile/tile_id.ts';
import {fixedLngLat, fixedCoord} from '../../../test/unit/lib/fixed.ts';
import {MercatorTransform, createMercatorTransform} from './mercator_transform.ts';
import {LngLatBounds} from '../lng_lat_bounds.ts';
import {getMercatorHorizon} from './mercator_utils.ts';
import {mat4} from 'gl-matrix';
import {createCoverageIndex, createDEM, createDEMTerrain, createPainter, createRasterDEMTileManager, createTerrain, expectToBeCloseToArray, createIdentityCrsTransform, createWrappingWorldCrs} from '../../util/test/util.ts';
import {CRSWorldCoordinateHelper} from './crs.ts';
import {Terrain} from '../../render/terrain.ts';
import {EXTENT} from '../../data/extent.ts';
import {MercatorCoordinate, mercatorWorldCoordinateHelper, mercatorZfromAltitude} from '../mercator_coordinate.ts';

import type {Tile} from '../../tile/tile.ts';

describe('transform', () => {
    test('creates a transform', () => {
        const transform = createMercatorTransform({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), 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('setMinElevationForCurrentTile below sea level moves the far plane', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(500, 500);
        transform.setZoom(11.46);
        transform.setPitch(60);

        transform.setMinElevationForCurrentTile(-455.5);

        expect(transform.farZ).toBe(1870.705545073004);
    });

    test('does not throw on bad center', () => {
        expect(() => {
            const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
            transform.resize(500, 500);
            transform.setCenter(new LngLat(50, -90));
        }).not.toThrow();
    });

    test('does not throw on a zero size', () => {
        for (const [width, height] of [[0, 500], [500, 0], [0, 0]]) {
            const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
            expect(() => transform.resize(width, height)).not.toThrow();
            expect(transform.width).toBe(width);
            expect(transform.height).toBe(height);
        }
    });

    test('does not throw when a sized transform is resized to a zero width', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(500, 500);
        expect(() => transform.resize(0, 500)).not.toThrow();
        expect(() => transform.setZoom(3)).not.toThrow();
    });

    test('calculates matrices again once a zero width becomes a real size', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(0, 500);
        transform.setZoom(5);
        transform.setPitch(30);
        transform.setBearing(45);
        transform.setCenter(new LngLat(10, 20));
        transform.resize(500, 500);

        const expected = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        expected.resize(500, 500);
        expected.setZoom(5);
        expected.setPitch(30);
        expected.setBearing(45);
        expected.setCenter(new LngLat(10, 20));

        expect([...transform.modelViewProjectionMatrix]).toEqual([...expected.modelViewProjectionMatrix]);
        expect([...transform.cameraPosition]).toEqual([...expected.cameraPosition]);
        expect(fixedLngLat(transform.screenPointToLocation(new Point(250, 250)))).toEqual({lng: 10, lat: 20});
        expect(fixedCoord(transform.screenPointToMercatorCoordinate(new Point(250, 250)))).toEqual(fixedCoord(expected.screenPointToMercatorCoordinate(new Point(250, 250))));
    });

    test('setLocationAt', () => {
        const transform = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({minZoom: -2, maxZoom: 22, minPitch: 0, maxPitch: 60});
        transform.setZoom(-2);
        expect(transform.tileZoom).toBe(0);
    });

    test('set fov', () => {
        const transform = createMercatorTransform({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 = createMercatorTransform({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('constrains center to padded viewport', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(1000, 1000);
        transform.setZoom(7);
        transform.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
        transform.setPadding({top: 0, right: 500, bottom: 500, left: 0});

        transform.setCenter(new LngLat(50, -50));

        const southeast = transform.locationToScreenPoint(new LngLat(5, -5));
        expect(southeast.x).toBeCloseTo(500, 6);
        expect(southeast.y).toBeCloseTo(500, 6);
    });

    test('re-constrains center when padding changes', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(1000, 1000);
        transform.setZoom(7);
        transform.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
        transform.setPadding({top: 0, right: 500, bottom: 500, left: 0});
        transform.setCenter(new LngLat(50, -50));

        transform.setPadding({top: 0, right: 0, bottom: 0, left: 0});

        const southeast = transform.locationToScreenPoint(new LngLat(5, -5));
        expect(southeast.x).toBeCloseTo(1000, 6);
        expect(southeast.y).toBeCloseTo(1000, 6);
    });

    test('lngRange & latRange constrain zoom and center after cloning', () => {
        const old = createMercatorTransform({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 = createMercatorTransform({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: Array<[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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = {
            ...createTerrain(),
            getElevationForLngLat: () => 200,
        };
        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 = createMercatorTransform({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 = {
            ...createTerrain(),
            getElevationForLngLat: () => 200 + 1,
        };
        transform.recalculateZoomAndCenter(terrain as any);
        expect(transform.center.lat).toBeCloseTo(82, 4);
    });

    test('recalculateZoomAndCenter: elevation increase', () => {
        const transform = createMercatorTransform({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 = {
            ...createTerrain(),
            getElevationForLngLat: () => 400,
        };
        transform.recalculateZoomAndCenter(terrain as any);
        expect(transform.elevation).toBe(400);
        expect(transform.center.lng).toBeCloseTo(10, 10);
        expect(transform.center.lat).toBeCloseTo(49.99820083233257, 10);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 10);
        expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
        expect(transform.zoom).toBeCloseTo(14.184585886440683, 10);
    });

    test('recalculateZoomAndCenter solves at the rendered terrain surface, not the tile-zoom DEM sample', () => {
        const transform = createMercatorTransform({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);
        const expectedAltitude = transform.getCameraAltitude();
        const terrain = {
            ...createTerrain(),
            getElevationForLngLat: () => 400,
            getElevationForLngLatZoom: () => 1000,
        };

        transform.recalculateZoomAndCenter(terrain as any);

        expect(transform.elevation).toBe(400);
        expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
    });

    test('recalculateZoomAndCenter: elevation decrease', () => {
        const transform = createMercatorTransform({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 = {
            ...createTerrain(),
            getElevationForLngLat: () => -200,
        };
        transform.recalculateZoomAndCenter(terrain as any);
        expect(transform.elevation).toBe(-200);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 10);
        expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
        expect(transform.zoom).toBeCloseTo(13.689399565250616, 10);
    });

    test('recalculateZoomAndCenter looks past terrain nearer than maxZoom allows once the center ray has passed over it', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 12.5, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.setCenter(new LngLat(0.1758, -0.03));
        transform.setZoom(12);
        transform.setPitch(80);
        transform.resize(512, 512);
        const plateauUnderTheCameraThenAPlain = createDEM((_x, y) => y >= 26 ? 2400 : 0, 64);
        const terrain = createDEMTerrain([new OverscaledTileID(10, 0, 10, 512, 512)], plateauUnderTheCameraThenAPlain);
        const cameraLngLat = transform.getCameraLngLat();
        const cameraAltitude = transform.getCameraAltitude();

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.zoom).toBeCloseTo(12, 6);
        expect(transform.elevation).toBeCloseTo(0, 6);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(cameraLngLat.lat, 9);
        expect(transform.getCameraAltitude()).toBeCloseTo(cameraAltitude, 3);
    });

    test('recalculateZoomAndCenter leaves out terrain between the camera and the near clipping plane when maxZoom lets the camera nearer than that plane', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.setCenter(new LngLat(0, 0));
        transform.setZoom(12);
        transform.setPitch(80);
        transform.resize(512, 512);
        const metersPerDegree = 2 * Math.PI * earthRadius / 360;
        const wallLat = transform.getCameraLngLat().lat + 100 / metersPerDegree;
        const wallTop = transform.getCameraAltitude() - 12;
        const wallBeforeTheNearPlane = (_lng: number, lat: number) => Math.abs(lat - wallLat) < 15 / metersPerDegree ? wallTop : 0;
        const terrain = createDEMTerrain([], null);
        vi.spyOn(terrain, 'getCoverageIndex').mockReturnValue(createCoverageIndex(wallBeforeTheNearPlane, 0, wallTop));
        vi.spyOn(terrain, 'getElevationForLngLat').mockImplementation(lngLat => wallBeforeTheNearPlane(lngLat.lng, lngLat.lat));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.zoom).toBeCloseTo(12, 6);
        expect(transform.center.lat).toBeCloseTo(0, 6);
    });

    test('recalculateZoomAndCenter does not look past terrain the center ray is still inside at the distance maxZoom allows', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 12.5, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.setCenter(new LngLat(0.1758, -0.03));
        transform.setZoom(12);
        transform.setPitch(80);
        transform.resize(512, 512);
        const blockBetweenTheCameraAndTheCenter = createDEM((_x, y) => y >= 8 && y < 22 ? 1500 : 0, 64);
        const terrain = createDEMTerrain([new OverscaledTileID(10, 0, 10, 512, 512)], blockBetweenTheCameraAndTheCenter);

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.elevation).toBeCloseTo(1500, 6);
        expect(transform.zoom).toBe(12.5);
    });

    test('recalculateZoomAndCenter at maxZoom points the camera at the new center', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 12, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.setCenter(new LngLat(0.1758, -0.03));
        transform.setZoom(12);
        transform.setPitch(80);
        transform.resize(512, 512);
        const terrain = createDEMTerrain([new OverscaledTileID(10, 0, 10, 512, 512)], createDEM(() => 2400, 64));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.center.lat).toBeCloseTo(-0.152407, 6);
        expect(transform.screenPointToLocation(transform.centerPoint).lat).toBeCloseTo(-0.152407, 6);
    });

    test('recalculateZoomAndCenter puts the center on sloped terrain with the camera where it was', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(512, 512);
        transform.setZoom(11);
        transform.setPitch(85);
        transform.setCenter(new LngLat(0.7, 60));
        const cameraBefore = transform.getCameraLngLat();
        const altitudeBefore = transform.getCameraAltitude();
        const demTile = new OverscaledTileID(8, 0, 8, 128, 74);
        const risingNorthward = createDEM((_x, y) => (64 - y) * 30, 64);
        const terrain = createDEMTerrain([demTile], risingNorthward);
        terrain.tileManager.getSourceTile = () => ({tileID: demTile, dem: risingNorthward} as Tile);

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.elevation).toBeCloseTo(997.526, 2);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(cameraBefore.lat, 8);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(cameraBefore.lng, 8);
        expect(transform.getCameraAltitude()).toBeCloseTo(altitudeBefore, 6);
    });

    test('recalculateZoomAndCenter keeps the camera where it is when the center moves to another latitude', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(512, 512);
        transform.setCenter(new LngLat(10, 60));
        transform.setZoom(11);
        transform.setPitch(85);
        const cameraBefore = transform.getCameraLngLat();
        const altitudeBefore = transform.getCameraAltitude();
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 1000));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.elevation).toBe(1000);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(cameraBefore.lat, 8);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(cameraBefore.lng, 8);
        expect(transform.getCameraAltitude()).toBeCloseTo(altitudeBefore, 6);
    });

    test('recalculateZoomAndCenter keeps the camera where it is at an extreme latitude', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(512, 512);
        transform.setCenter(new LngLat(-150, 80));
        transform.setZoom(10);
        transform.setPitch(70);
        transform.setBearing(135);
        const cameraBefore = transform.getCameraLngLat();
        const altitudeBefore = transform.getCameraAltitude();
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 2500));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.elevation).toBe(2500);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(cameraBefore.lat, 8);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(cameraBefore.lng, 8);
        expect(transform.getCameraAltitude()).toBeCloseTo(altitudeBefore, 6);
    });

    test('recalculateZoomAndCenter leaves the center where it is while the terrain under it is above the camera', () => {
        const transform = createTransformAt(new LngLat(0, 0), 15, 60);
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 5000));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.zoom).toBe(15);
        expect(transform.center).toEqual(new LngLat(0, 0));
        expect(transform.elevation).toBe(0);
    });

    test('recalculateZoomAndCenter looking up at terrain above the camera puts the center on it', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 150, renderWorldCopies: true});
        transform.resize(512, 512);
        transform.setCenter(new LngLat(0, 0));
        transform.setZoom(15);
        transform.setPitch(120);
        transform.setElevation(3000);
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 2500));

        transform.recalculateZoomAndCenter(terrain);

        expect(transform.elevation).toBe(2500);
        expect(transform.zoom).toBeCloseTo(16.1383, 4);
    });

    test('recalculateZoomAndCenterNoTerrain', () => {
        const transform = createMercatorTransform({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.00179923503546, 10);
        expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
        expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 10);
        expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
        expect(transform.zoom).toBeCloseTo(13.836362951286565, 10);
    });

    test('screenPointToMercatorCoordinate with terrain that covers nothing should fall back to 2D', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(500, 500);
        const coordinate = transform.screenPointToMercatorCoordinate(new Point(0, 0), createTerrain());

        expect(coordinate).toBeDefined();
    });

    test('getBounds with horizon', () => {
        const transform = createMercatorTransform({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('isPointOnMapSurface with padding', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(500, 500);
        transform.setPitch(80);

        transform.setPadding({top: 200, bottom: 0, left: 0, right: 0});
        expect(transform.isPointOnMapSurface(new Point(250, 200))).toBe(false);

        transform.setPadding({top: 0, bottom: 200, left: 0, right: 0});
        expect(transform.isPointOnMapSurface(new Point(250, 100))).toBe(true);
    });

    test('isPointOnMapSurface is false past the edge of a world projection', () => {
        const transform = createMercatorTransform({minZoom: -2, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.setWorldCoordinateHelper(new CRSWorldCoordinateHelper(createWrappingWorldCrs()));
        transform.resize(500, 500);
        transform.setZoom(-1);

        expect(transform.isPointOnMapSurface(new Point(300, 250))).toBe(true);
        expect(transform.isPointOnMapSurface(new Point(400, 250))).toBe(false);
    });

    test('projectTileCoordinates', () => {
        const precisionDigits = 10;
        const transform = createMercatorTransform({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)), 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)), 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);
    });

    function createPitchedTransform(): MercatorTransform {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
        transform.resize(512, 512);
        transform.setZoom(10);
        transform.setCenter(new LngLat(0, 0));
        transform.setPitch(80);
        return transform;
    }

    test('locationToScreenPoint projects a location in front of the camera', () => {
        const transform = createPitchedTransform();

        const inFrontOfCamera = transform.locationToScreenPoint(new LngLat(0, 0.05));
        expect(inFrontOfCamera.x).toBeCloseTo(256, 1);
        expect(inFrontOfCamera.y).toBeCloseTo(244.4, 1);
    });

    test('locationToScreenPoint puts a location behind the camera outside the edge it left through', () => {
        const transform = createPitchedTransform();

        // The camera looks north, so a location south of it left the screen through the bottom edge.
        const behindCamera = transform.locationToScreenPoint(new LngLat(0, -2));
        expect(behindCamera.x).toBeCloseTo(256, 6);
        expect(behindCamera.y).toBeCloseTo(1024, 6);

        // West of a north-facing camera is the left side of the screen.
        const behindLeft = transform.locationToScreenPoint(new LngLat(-2, -2));
        expect(behindLeft.x).toBeCloseTo(-512, 6);
        expect(behindLeft.y).toBeGreaterThan(256);
        expect(behindLeft.y).toBeLessThan(1024);

        const behindRight = transform.locationToScreenPoint(new LngLat(2, -2));
        expect(behindRight.x).toBeCloseTo(1024, 6);
        expect(behindRight.y).toBeCloseTo(behindLeft.y, 6);

        // A location only slightly west still leaves through the bottom edge, left of centre.
        const behindSlightlyLeft = transform.locationToScreenPoint(new LngLat(-0.2, -2));
        expect(behindSlightlyLeft.y).toBeCloseTo(1024, 6);
        expect(behindSlightlyLeft.x).toBeLessThan(256);
        expect(behindSlightlyLeft.x).toBeGreaterThan(-512);
    });

    test('getCameraLngLat', () => {
        const transform = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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 = createMercatorTransform({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);
    });

    describe('getProjectionData', () => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
        transform.resize(512, 512);
        test('parses OverscaledTileID', () => {
            const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
            transform.resize(512, 512);
            const projectionData = transform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
            expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
        });
        test('parses null', () => {
            const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
            transform.resize(512, 512);
            const projectionData = transform.getProjectionData({overscaledTileID: null});
            expectToBeCloseToArray(projectionData.tileMercatorCoords, [0, 0, 1, 1]);
        });
    });
});

function createTransformAt(center: LngLat, zoom: number, pitch: number = 0): MercatorTransform {
    const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
    transform.resize(512, 512);
    transform.setCenter(center);
    transform.setZoom(zoom);
    transform.setPitch(pitch);
    return transform;
}

function createRayTransform(near: number[], far: number[], worldSize: number): MercatorTransform {
    const transform = Object.create(MercatorTransform.prototype);
    Object.defineProperty(transform, 'worldSize', {value: worldSize});
    Object.defineProperty(transform, 'worldCoordinateHelper', {value: mercatorWorldCoordinateHelper});
    transform.getRaySegmentFromPixel = () => ({near, far});
    return transform as MercatorTransform;
}

function expectWorldPixelsClose(actual: MercatorCoordinate, expected: MercatorCoordinate, worldSize: number): void {
    expect(Math.abs(actual.x - expected.x) * worldSize).toBeLessThan(1e-3);
    expect(Math.abs(actual.y - expected.y) * worldSize).toBeLessThan(1e-3);
}

describe('MercatorTransform.calculateCameraOptionsFromTo', () => {
    const transform = createMercatorTransform();
    transform.resize(512, 512);
    transform.setZoom(1); // avoid the center being constrained by the mercator latitude limits

    test('look at north', () => {
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 0, {lng: 1, lat: 1}, 0);
        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.center).toBeDefined();
        expect(cameraOptions.bearing).toBeCloseTo(0);
    });

    test('look at west', () => {
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 0, {lng: 0, lat: 0}, 0);
        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.bearing).toBeCloseTo(-90);
    });

    test('pitch 45', () => {
        // altitude same as grounddistance => 45°
        // distance between lng x and lng x+1 is 111.2km at same lat
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 111200, {lng: 0, lat: 0}, 0);
        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.pitch).toBeCloseTo(45);
    });

    test('pitch 90', () => {
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 0, {lng: 0, lat: 0}, 0);
        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.pitch).toBeCloseTo(90);
    });

    test('pitch 153.435', () => {

        // distance between lng x and lng x+1 is 111.2km at same lat
        // (elevation difference of cam and center) / 2 = grounddistance =>
        // acos(111.2 / sqrt(111.2² + (111.2 * 2)²)) = acos(1/sqrt(5)) => 63.435 + 90 (looking up) = 153.435
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 111200, {lng: 0, lat: 0}, 111200 * 3);
        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.pitch).toBeCloseTo(153.435);
    });

    test('zoom distance 1000', () => {
        const expectedZoom = Math.log2(transform.cameraToCenterDistance / mercatorZfromAltitude(1000, 0) / transform.tileSize);
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 0, {lng: 0, lat: 0}, 1000);

        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.zoom).toBeCloseTo(expectedZoom);
    });

    test('zoom distance 1 lng (111.2km), 111.2km altitude away', () => {
        const expectedZoom = Math.log2(transform.cameraToCenterDistance / mercatorZfromAltitude(Math.hypot(111200, 111200), 0) / transform.tileSize);
        const cameraOptions = transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 0, {lng: 1, lat: 0}, 111200);

        expect(cameraOptions).toBeDefined();
        expect(cameraOptions.zoom).toBeCloseTo(expectedZoom);
    });

    test('same To as From error', () => {
        expect(() => transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 0, {lng: 0, lat: 0}, 0)).toThrow('Can\'t calculate camera options with same From and To');
    });
});

describe('MercatorTransform.screenTerrainPointToMercatorCoordinate', () => {
    test('matches the plane intersection for a flat DEM', () => {
        const height = 500;
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => height));
        const transform = createTransformAt(new LngLat(0, 0), 4, 45);

        for (const p of [new Point(256, 256), new Point(100, 400), new Point(400, 300)]) {
            const result = transform.screenTerrainPointToMercatorCoordinate(p, terrain);
            expect(result).not.toBeNull();
            expectWorldPixelsClose(result, transform.screenPointToMercatorCoordinateAtZ(p, height), transform.worldSize);
            expect(result.z).toBeCloseTo(height, 10);
        }
    });

    test('hits the terrain between the near clipping plane and twice its distance from the camera', () => {
        const height = 449;
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => height));
        const transform = createTransformAt(new LngLat(0, 0), 16, 60);
        const center = new Point(256, 256);

        const result = transform.screenTerrainPointToMercatorCoordinate(center, terrain);

        expect(result).not.toBeNull();
        expect(result.z).toBeCloseTo(height, 6);
        expectWorldPixelsClose(result, transform.screenPointToMercatorCoordinateAtZ(center, height), transform.worldSize);
    });

    test('applies the terrain exaggeration to the hit elevation', () => {
        const height = 300;
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => height), 2.5);
        const transform = createTransformAt(new LngLat(0, 0), 4, 30);

        const result = transform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), terrain);

        expect(result.z).toBeCloseTo(height * 2.5, 10);
        expectWorldPixelsClose(result, transform.screenPointToMercatorCoordinateAtZ(new Point(256, 256), height * 2.5), transform.worldSize);
    });

    test('the hit elevation matches the terrain elevation at the hit position', () => {
        const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
        const terrain = createDEMTerrain([tileID], createDEM((x) => x * 400));
        const transform = createTransformAt(new LngLat(0, 0), 3, 60);

        for (const p of [new Point(200, 300), new Point(256, 350), new Point(330, 420)]) {
            const result = transform.screenTerrainPointToMercatorCoordinate(p, terrain);
            expect(result).not.toBeNull();
            const elevation = terrain.getElevation(tileID, result.x * EXTENT, result.y * EXTENT, EXTENT);
            expect(elevation).toBeCloseTo(result.z, 6);
        }
    });

    test('follows a ramp across a tile boundary', () => {
        const tileIDs = [[0, 0], [1, 0], [0, 1], [1, 1]].map(([x, y]) => new OverscaledTileID(1, 0, 1, x, y));
        const terrain = createDEMTerrain(tileIDs, createDEM((x) => x * 200));
        const transform = createTransformAt(new LngLat(0, 40), 2, 50);
        const crossed = new Set<number>();

        for (const p of [new Point(180, 300), new Point(256, 300), new Point(340, 300)]) {
            const result = transform.screenTerrainPointToMercatorCoordinate(p, terrain);
            expect(result).not.toBeNull();
            const tileX = Math.floor(result.x * 2);
            const tileY = Math.floor(result.y * 2);
            crossed.add(tileX);
            const tileID = new OverscaledTileID(1, 0, 1, tileX, tileY);
            const elevation = terrain.getElevation(tileID, (result.x * 2 - tileX) * EXTENT, (result.y * 2 - tileY) * EXTENT, EXTENT);
            expect(elevation).toBeCloseTo(result.z, 6);
        }

        expect(crossed.size).toBe(2);
    });

    test('returns null when the ray never crosses the terrain surface', () => {
        // Into the sky.
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 0));
        const skyTransform = createTransformAt(new LngLat(0, 0), 4, 80);
        expect(skyTransform.screenTerrainPointToMercatorCoordinate(new Point(256, 0), terrain)).toBeNull();

        // Leaving the world vertically past the covered tiles.
        const edgeTerrain = createDEMTerrain([new OverscaledTileID(1, 0, 1, 0, 0)], createDEM(() => 0));
        const edgeTransform = createTransformAt(new LngLat(0, 0), 1, 0);
        expect(edgeTransform.screenTerrainPointToMercatorCoordinate(new Point(400, 400), edgeTerrain)).toBeNull();

        // Staying below the terrain surface the whole way: at zoom 22 the camera sits about 28 meters up, under a 100 meter plateau.
        const submergedTerrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 100));
        const submergedTransform = createTransformAt(new LngLat(0, 0), 22, 0);
        expect(submergedTransform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), submergedTerrain)).toBeNull();
    });

    test('returns null when the terrain has no renderable tiles', () => {
        const terrain = createDEMTerrain([], null);
        const transform = createTransformAt(new LngLat(0, 0), 4);

        expect(transform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), terrain)).toBeNull();
    });

    test('hits a renderable tile whose DEM has not loaded at elevation zero', () => {
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], null);
        const transform = createTransformAt(new LngLat(0, 0), 4, 40);
        const p = new Point(256, 300);

        const result = transform.screenTerrainPointToMercatorCoordinate(p, terrain);

        expect(result.z).toBe(0);
        expectWorldPixelsClose(result, transform.screenPointToMercatorCoordinateAtZ(p, 0), transform.worldSize);
    });

    test('returns coordinates outside the central world for wrapped copies', () => {
        const tileIDs = [-1, 0, 1, 2].map(wrap => new OverscaledTileID(0, wrap, 0, 0, 0));
        const terrain = createDEMTerrain(tileIDs, createDEM(() => 0));
        const transform = createTransformAt(new LngLat(0, 0), 0);
        transform.resize(2048, 512);

        const leftPoint = new Point(600, 256);
        const rightPoint = new Point(1500, 256);
        const left = transform.screenTerrainPointToMercatorCoordinate(leftPoint, terrain);
        const right = transform.screenTerrainPointToMercatorCoordinate(rightPoint, terrain);

        expect(left.x).toBeLessThan(0);
        expect(right.x).toBeGreaterThan(1);
        expectWorldPixelsClose(left, transform.screenPointToMercatorCoordinateAtZ(leftPoint, 0), transform.worldSize);
        expectWorldPixelsClose(right, transform.screenPointToMercatorCoordinateAtZ(rightPoint, 0), transform.worldSize);
    });

    test('samples overscaled tiles from their parent DEM', () => {
        const tileID = new OverscaledTileID(15, 0, 14, 8192, 8192);
        const parent = new OverscaledTileID(13, 0, 13, 4096, 4096);
        const dem = createDEM((x) => x * 100);
        const terrain = createDEMTerrain([tileID], dem);
        terrain.tileManager.getSourceTile = () => ({tileID: parent, dem}) as Tile;
        const scale = 1 << tileID.canonical.z;
        const center = new MercatorCoordinate((tileID.canonical.x + 0.5) / scale, (tileID.canonical.y + 0.5) / scale).toLngLat();
        const transform = createTransformAt(center, 15, 30);

        const result = transform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), terrain);

        expect(result).not.toBeNull();
        expect(result.z).toBeGreaterThan(0);
    });

    test('samples the far tile edge without throwing', () => {
        const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
        const terrain = createDEMTerrain([tileID], createDEM(() => 100));

        expect(() => terrain.getElevation(tileID, EXTENT - 1e-9, EXTENT - 1e-9, EXTENT)).not.toThrow();

        const transform = createTransformAt(new LngLat(179.999, -85), 6, 0);
        expect(() => transform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), terrain)).not.toThrow();
    });

    test('skips renderable tiles that have no terrain tile yet', () => {
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 100));
        const tiles = terrain.tileManager.getRenderableTiles();
        terrain.tileManager.getRenderableTiles = () => [null, ...tiles];
        const transform = createTransformAt(new LngLat(0, 0), 4, 30);

        expect(transform.screenTerrainPointToMercatorCoordinate(new Point(256, 256), terrain).z).toBeCloseTo(100, 6);
    });

    test('samples the highest zoom tile covering the position', () => {
        const parentID = new OverscaledTileID(0, 0, 0, 0, 0);
        const childID = new OverscaledTileID(1, 0, 1, 0, 0);
        const parentDEM = createDEM(() => 100);
        const childDEM = createDEM(() => 900);
        const terrain = createDEMTerrain([parentID, childID], parentDEM);
        terrain.tileManager.getSourceTile = (tileID) => ({tileID, dem: tileID.canonical.z === 1 ? childDEM : parentDEM}) as Tile;
        const transform = createTransformAt(new LngLat(0, 0), 1, 0);

        expect(transform.screenTerrainPointToMercatorCoordinate(new Point(128, 128), terrain).z).toBeCloseTo(900, 6);
        expect(transform.screenTerrainPointToMercatorCoordinate(new Point(384, 384), terrain).z).toBeCloseTo(100, 6);
    });

    test('handles a ray with no vertical component', () => {
        const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
        const terrain = createDEMTerrain([tileID], createDEM((x) => x * 200));
        const worldSize = 512;

        const crossing = createRayTransform([0, 256, 500], [512, 256, 500], worldSize);
        expect(crossing.screenTerrainPointToMercatorCoordinate(new Point(0, 0), terrain)).not.toBeNull();

        const aboveEverything = createRayTransform([0, 256, 5000], [512, 256, 5000], worldSize);
        expect(aboveEverything.screenTerrainPointToMercatorCoordinate(new Point(0, 0), terrain)).toBeNull();
    });
});

// Outputs captured from main at 379b3673a, where lng/lat math does not go through the world coordinate helper; main's
// center search changed after the first capture (407a8ce9e). Each row is 11 inputs followed by 9 outputs, compared exactly.
const bitIdentityRows: number[][] = [
    [-47.66947732307017, -31.891872510313988, 12.335080658085644, 44.56127839162946, 336.473432360217, 2708.1988365389407, 633.7583729997277, 333.5038125514984, -48.08538376772776, -32.09453024622053, 6542.761099524796, 0.06608625848037492, -47.635173521078315, -0.07879338518436896, -48.10138014914466, -32.06339765398657, 2708.1988365389407, 13.435703555287997, -47.63887852731972, 0.006589276563389035],
    [29.529827423393726, -44.010491259396076, 4.350916175171733, 40.40351155679673, 295.4289326816797, 742.9703597445041, 119.00203712284565, 341.0222121980041, 30.327826311811805, -44.438611211720854, 11584.925448521972, 0.0002609985923233138, 47.682901433135356, -8.598491341610824, 30.222915348529853, -44.402985754807105, 742.9703597445041, 11.78582078869319, 27.14136282768186, -9.145886234506918],
    [-175.4359111469239, -60.00771701335907, 11.576151768676937, 56.23234930448234, 272.34073103405535, 2335.907760076225, 152.67861243337393, 567.1612800098956, -174.8650802965276, -59.31432515755296, 16009.216147474945, 0.039052676546020515, -175.26376370720283, -0.007036734336963946, -175.22507664128196, -59.30681470736939, 2335.907760076225, 10.511814234993166, -175.36093089187702, -0.042540721064781906],
    [-144.41486184485257, 7.587629780173302, 19.653839827515185, 37.38076251000166, 167.28623329661787, 1020.5210256390274, 658.2415254786611, 348.03880993276834, -144.6090480950661, 8.191629043780267, 13963.913300074637, 10.550601155815519, -144.4149643458768, 0.0004543238948713224, -144.5892778507015, 8.104884388547887, 1020.5210256390274, 12.062291260316952, -144.41507905283308, 0.0000017517999424399022],
    [-89.8710085451603, 64.21592768281698, 1.8496395740658045, 48.10014402028173, 158.33662692457438, 657.6341448817402, 48.85685257613659, 331.04150402359664, -90.20058967545629, 64.65715769259259, 19041.576908901334, 0.00004609766002574671, -138.11462647842262, 76.308455124853, -90.04266543186203, 64.48644307922882, 657.6341448817402, 10.104601204958538, -31.785299776317714, 30.85807805159577]
];

describe('mercator transform bit identity with the pre-refactor transform', () => {
    test.each(bitIdentityRows)('center %f,%f zoom %f pitch %f bearing %f', (lng, lat, zoom, pitch, bearing, elevation, px, py, camLng, camLat, alt, ...expected) => {
        const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
        transform.resize(800, 600);
        transform.setCenter(new LngLat(lng, lat));
        transform.setZoom(zoom);
        transform.setPitch(pitch);
        transform.setBearing(bearing);
        transform.setElevation(elevation);

        const camera = transform.getCameraLngLat();
        const fromCamera = transform.calculateCenterFromCameraLngLatAlt(new LngLat(camLng, camLat), alt, bearing, pitch);
        const screen = transform.screenPointToLocation(new Point(px, py));

        expect([
            transform.pixelsPerMeter, camera.lng, camera.lat,
            fromCamera.center.lng, fromCamera.center.lat, fromCamera.elevation, fromCamera.zoom,
            screen.lng, screen.lat,
        ]).toEqual(expected);
    });
});

describe('MercatorTransform over the identity CRS', () => {
    test('uses a non-wrapping helper', () => {
        const transform = createIdentityCrsTransform(200, 200);
        expect(transform.worldCoordinateHelper.wraps).toBe(false);
    });

    test('keeps the helper across clone', () => {
        const transform = createIdentityCrsTransform(200, 200);
        expect(transform.clone().worldCoordinateHelper).toBe(transform.worldCoordinateHelper);
    });

    test('sets no max bounds', () => {
        const transform = createIdentityCrsTransform(200, 200);
        expect(transform.getMaxBounds()).toBeNull();
    });

    describe('constrain', () => {
        test('keeps a center inside the square where the viewport reaches past the square', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(1);

            transform.setCenter(new LngLat(89, 89));

            expect(transform.center.lat).toBeCloseTo(89, 6);
        });

        test('leaves the zoom to minZoom when the square is smaller than the viewport', () => {
            const transform = createIdentityCrsTransform(200, 200);

            transform.setZoom(-2);

            expect(transform.zoom).toBe(-2);
        });

        test('keeps the viewport inside explicit max bounds set inside the square', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(4);
            transform.setMaxBounds(new LngLatBounds([-10, -10], [10, 10]));
            const worldSizeAtZoom4 = 8192;
            const degreesPerPixel = 180 / worldSizeAtZoom4;
            const halfViewport = 100;

            transform.setCenter(new LngLat(80, 80));

            expect(transform.center.lng).toBeCloseTo(10 - halfViewport * degreesPerPixel, 6);
            expect(transform.center.lat).toBeCloseTo(10 - halfViewport * degreesPerPixel, 6);
        });

        test('keeps the viewport inside max bounds that replace earlier ones', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(4);
            transform.setMaxBounds(new LngLatBounds([-10, -10], [10, 10]));
            transform.setCenter(new LngLat(0, 0));
            transform.setMaxBounds(new LngLatBounds([30, 30], [50, 50]));
            const worldSizeAtZoom4 = 8192;
            const degreesPerPixel = 180 / worldSizeAtZoom4;
            const halfViewport = 100;

            transform.setCenter(new LngLat(80, 80));

            expect(transform.center.lng).toBeCloseTo(50 - halfViewport * degreesPerPixel, 6);
            expect(transform.center.lat).toBeCloseTo(50 - halfViewport * degreesPerPixel, 6);
        });

        test('stops a center past the east edge of the square at the edge instead of wrapping it', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(4);

            transform.setCenter(new LngLat(200, 0));

            expect(transform.center.lng).toBeCloseTo(90, 6);
        });

        test('setLocationAtPoint keeps the longitude it was given instead of wrapping it', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(4);

            transform.setLocationAtPoint(new LngLat(85, 0), transform.centerPoint);

            expect(transform.center.lng).toBeCloseTo(85, 6);
        });
    });

    test('returns only the main world from getVisibleUnwrappedCoordinates while renderWorldCopies is on', () => {
        const transform = createIdentityCrsTransform(512, 512);
        transform.setZoom(0);
        transform.setCenter(new LngLat(0, 0));
        expect(transform.renderWorldCopies).toBe(true);
        expect(transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0))).toHaveLength(1);
    });

    test('apply carries a renderWorldCopies of false through the non-wrapping transform and back', () => {
        const identity = createIdentityCrsTransform(200, 200);
        identity.apply(createMercatorTransform({renderWorldCopies: false}), false);
        expect(identity.renderWorldCopies).toBe(false);

        const mercator = createMercatorTransform({renderWorldCopies: true});
        mercator.apply(identity, false);
        expect(mercator.renderWorldCopies).toBe(false);
    });

    describe('lng/lat and screen points', () => {
        test('round trips a location through screen space with the identity mapping', () => {
            const transform = createIdentityCrsTransform(200, 200);
            transform.setZoom(2);
            transform.setCenter(new LngLat(30, -20));
            const point = transform.locationToScreenPoint(new LngLat(35, -25));
            const back = transform.screenPointToLocation(point);
            expect(back.lng).toBeCloseTo(35, 6);
            expect(back.lat).toBeCloseTo(-25, 6);
        });

        test('places lng/lat 45,45 in the top-right quarter of the square', () => {
            const transform = createIdentityCrsTransform(512, 512);
            transform.setZoom(0);
            transform.setCenter(new LngLat(0, 0));
            const worldSizeAtZoom0 = 512;
            const point = transform.locationToScreenPoint(new LngLat(45, 45));
            expect(point.x).toBeCloseTo(0.75 * worldSizeAtZoom0, 6);
            expect(point.y).toBeCloseTo(0.25 * worldSizeAtZoom0, 6);
        });

        test('bounds of a pitched view that reaches past the world square stop at the poles instead of throwing', () => {
            const transform = createIdentityCrsTransform(512, 512);
            transform.setZoom(0);
            transform.setCenter(new LngLat(0, 0));
            transform.setPitch(60);

            const bounds = transform.getBounds();

            expect(bounds.getNorth()).toBe(90);
            expect(bounds.getSouth()).toBe(-90);
        });

        test('puts the camera above the center when unpitched', () => {
            const transform = createIdentityCrsTransform(512, 512);
            transform.setZoom(3);
            transform.setCenter(new LngLat(10, 20));
            const camera = transform.getCameraLngLat();
            expect(camera.lng).toBeCloseTo(10, 6);
            expect(camera.lat).toBeCloseTo(20, 6);
        });
    });

    describe('calculateCameraOptionsFromTo', () => {
        test('bearing and zoom follow the identity CRS, where lng and lat are the world axes and equal steps make a 45 degree bearing', () => {
            const transform = createIdentityCrsTransform(512, 512);
            transform.setZoom(1);

            const options = transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 0, {lng: 10, lat: 10}, 0);

            expect(options.bearing).toBeCloseTo(45, 10);
            expect(options.pitch).toBeCloseTo(90, 10);
            expect(options.center).toEqual(new LngLat(10, 10));
            expect(options.zoom).toBeCloseTo(4.254888, 6);
        });

        test('mercator tilts the bearing of the same step by its latitude stretch', () => {
            const transform = createMercatorTransform();
            transform.resize(512, 512);
            transform.setZoom(1);

            expect(transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 0, {lng: 10, lat: 10}, 0).bearing).not.toBeCloseTo(45, 1);
        });

        test('altitude is scaled by the CRS meters per world unit, so 90 meters down in a 180 meter world is half a world unit', () => {
            const transform = createIdentityCrsTransform(512, 512);
            transform.setZoom(1);

            const options = transform.calculateCameraOptionsFromTo({lng: 0, lat: 0}, 90, {lng: 0, lat: 0}, 0);

            expect(options.pitch).toBeCloseTo(0, 10);
            expect(options.zoom).toBeCloseTo(1.584963, 6);
        });
    });
});

describe('MercatorTransform.isLocationOccluded', () => {
    test('a location behind a ridge is hidden and one in front of it is in view', () => {
        const tileSpanAtZoom12 = 360 / (1 << 12);
        const ridgeAcrossTheTwoMiddleRows = createDEM((_x, y) => (y === 3 || y === 4) ? 3000 : 0);
        const terrain = createDEMTerrain([new OverscaledTileID(12, 0, 12, 2048, 2047)], ridgeAcrossTheTwoMiddleRows);
        const cameraSouthOfRidge = new LngLat(tileSpanAtZoom12 / 2, tileSpanAtZoom12 * 0.25);
        const transform = createTransformAt(cameraSouthOfRidge, 12, 75);
        const behindRidge = new LngLat(tileSpanAtZoom12 / 2, tileSpanAtZoom12 * 0.8);
        const inFrontOfRidge = new LngLat(tileSpanAtZoom12 / 2, tileSpanAtZoom12 * 0.3);

        expect(transform.isLocationOccluded(behindRidge, terrain)).toBe(true);
        expect(transform.isLocationOccluded(inFrontOfRidge, terrain)).toBe(false);
    });

    test('a location behind a ridge is in view once raised above the ridge', () => {
        const tileSpanAtZoom12 = 360 / (1 << 12);
        const ridgeAcrossTheTwoMiddleRows = createDEM((_x, y) => (y === 3 || y === 4) ? 3000 : 0);
        const terrain = createDEMTerrain([new OverscaledTileID(12, 0, 12, 2048, 2047)], ridgeAcrossTheTwoMiddleRows);
        const cameraSouthOfRidge = new LngLat(tileSpanAtZoom12 / 2, tileSpanAtZoom12 * 0.25);
        const transform = createTransformAt(cameraSouthOfRidge, 12, 75);
        const behindRidge = new LngLat(tileSpanAtZoom12 / 2, tileSpanAtZoom12 * 0.8);
        const elevationAboveRidge = 3500;

        expect(transform.isLocationOccluded(behindRidge, terrain, elevationAboveRidge)).toBe(false);
    });

    test('a location on the terrain nearer than twice the near clipping plane\'s distance is in view', () => {
        const height = 449;
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => height));
        const transform = createTransformAt(new LngLat(0, 0), 16, 60);
        const drawnAtTheScreenCenter = transform.screenPointToMercatorCoordinateAtZ(new Point(256, 256), height).toLngLat();

        expect(transform.isLocationOccluded(drawnAtTheScreenCenter, terrain)).toBe(false);
    });

    test('a location behind the camera or beyond the far plane is hidden', () => {
        const terrain = createDEMTerrain([new OverscaledTileID(0, 0, 0, 0, 0)], createDEM(() => 0));
        const transform = createTransformAt(new LngLat(0, 0), 6, 80);
        const behindTheCamera = new LngLat(0, -70);
        const beyondTheFarPlane = new LngLat(0, 70);

        expect(transform.isLocationOccluded(behindTheCamera, terrain)).toBe(true);
        expect(transform.isLocationOccluded(beyondTheFarPlane, terrain)).toBe(true);
    });

    test('nothing is hidden when the terrain has no renderable tiles', () => {
        const terrain = createDEMTerrain([], null);
        const transform = createTransformAt(new LngLat(0, 0), 12, 75);

        expect(transform.isLocationOccluded(new LngLat(0, 0.01), terrain)).toBe(false);
    });

    test('a location behind a ridge on the identity CRS is hidden, where mercator math would place it in front of the ridge', () => {
        const tileSpanAtZoom2 = 45;
        const ridgeAcrossTheTwoMiddleRows = createDEM((_x, y) => (y === 3 || y === 4) ? 14 : 0);
        const transform = createIdentityCrsTransform(512, 512);
        const terrain = new Terrain(createPainter(), createRasterDEMTileManager(), {source: 'dem'}, 'auto', () => transform.worldCoordinateHelper);
        terrain.tileManager.getRenderableTiles = () => [{tileID: new OverscaledTileID(2, 0, 2, 2, 1)} as Tile];
        terrain.tileManager.getSourceTile = (tileID) => ({tileID, dem: ridgeAcrossTheTwoMiddleRows}) as Tile;
        transform.setCenter(new LngLat(tileSpanAtZoom2 / 2, tileSpanAtZoom2 * 0.25));
        transform.setZoom(2);
        transform.setPitch(75);
        const behindRidge = new LngLat(tileSpanAtZoom2 / 2, 30);
        const inFrontOfRidge = new LngLat(tileSpanAtZoom2 / 2, tileSpanAtZoom2 * 0.3);

        expect(transform.isLocationOccluded(behindRidge, terrain)).toBe(true);
        expect(transform.isLocationOccluded(inFrontOfRidge, terrain)).toBe(false);
    });
});