maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
386 lines (338 loc) • 16.4 kB
text/typescript
import {describe, test, expect} from 'vitest';
import Point from '@mapbox/point-geometry';
import {type SymbolProjectionContext, type ProjectionSyntheticVertexArgs, findOffsetIntersectionPoint, projectWithMatrix, transformToOffsetNormal, projectLineVertexToLabelPlane, getPitchedLabelPlaneMatrix, getGlCoordMatrix, getTileSkewVectors, projectTileCoordinatesToClipSpace, projectTileCoordinatesToLabelPlane} from './projection.ts';
import {mat4} from 'gl-matrix';
import {SymbolLineVertexArray} from '../data/array_types.g.ts';
import {MercatorTransform} from '../geo/projection/mercator_transform.ts';
import {CanonicalTileID, UnwrappedTileID} from '../tile/tile_id.ts';
import {LngLat} from '../geo/lng_lat.ts';
import {expectToBeCloseToArray} from '../util/test/util.ts';
const TERRAIN_ELEVATION = 100;
const unwrappedTileID = new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0));
/**
* Builds a {@link SymbolProjectionContext} backed by a real, sized mercator transform, so that
* projections through it produce meaningful values. Pass `overrides` for whatever the test cares
* about; everything else falls back to a pitched viewport over flat terrain at `TERRAIN_ELEVATION`.
*/
function createDefaultTransform(): MercatorTransform {
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));
transform.setPitch(60);
return transform;
}
function createProjectionContext(overrides: Partial<SymbolProjectionContext> = {}): SymbolProjectionContext {
return {
projectionCache: {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false},
lineVertexArray: null,
pitchedLabelPlaneMatrix: mat4.create(),
getElevation: (_x, _y) => TERRAIN_ELEVATION,
tileAnchorPoint: new Point(0, 0),
pitchWithMap: false,
unwrappedTileID,
transform: overrides.transform ?? createDefaultTransform(),
width: 100,
height: 100,
translation: [0, 0],
...overrides
};
}
describe('Projection', () => {
test('matrix float precision', () => {
const point = new Point(10.000000005, 0);
const matrix = mat4.create();
expect(projectWithMatrix(point.x, point.y, matrix).point.x).toBeCloseTo(point.x, 10);
});
});
describe('Vertex to viewport projection', () => {
// A three point line along the x axis
const lineVertexArray = new SymbolLineVertexArray();
lineVertexArray.emplaceBack(-10, 0, -10);
lineVertexArray.emplaceBack(0, 0, 0);
lineVertexArray.emplaceBack(10, 0, 10);
const transform = new MercatorTransform();
test('projecting with null matrix', () => {
const projectionContext = createProjectionContext({
lineVertexArray,
getElevation: (_x, _y) => 0,
// Only relevant in "behind the camera" case, can't happen with null projection matrix
tileAnchorPoint: new Point(0, 0),
pitchWithMap: true,
unwrappedTileID: null,
transform,
width: 1,
height: 1
});
const syntheticVertexArgs: ProjectionSyntheticVertexArgs = {
distanceFromAnchor: 0,
previousVertex: new Point(0, 0),
direction: 1,
absOffsetX: 0
};
const first = projectLineVertexToLabelPlane(0, projectionContext, syntheticVertexArgs);
const second = projectLineVertexToLabelPlane(1, projectionContext, syntheticVertexArgs);
const third = projectLineVertexToLabelPlane(2, projectionContext, syntheticVertexArgs);
expect(first.x).toBeCloseTo(-10);
expect(second.x).toBeCloseTo(0);
expect(third.x).toBeCloseTo(10);
});
});
describe('Find offset line intersections', () => {
const lineVertexArray = new SymbolLineVertexArray();
// A three point line along x axis, to origin, and then up y axis
lineVertexArray.emplaceBack(-10, 0, -10);
lineVertexArray.emplaceBack(0, 0, 0);
lineVertexArray.emplaceBack(0, 10, 10);
// A three point line along the x axis
lineVertexArray.emplaceBack(-10, 0, -10);
lineVertexArray.emplaceBack(0, 0, 0);
lineVertexArray.emplaceBack(10, 0, 10);
const transform = new MercatorTransform();
const projectionContext = createProjectionContext({
lineVertexArray,
getElevation: (_x, _y) => 0,
transform,
pitchWithMap: true,
unwrappedTileID: null,
width: 1,
height: 1
});
// Only relevant in "behind the camera" case, can't happen with null projection matrix
const syntheticVertexArgs: ProjectionSyntheticVertexArgs = {
direction: 1,
distanceFromAnchor: 0,
previousVertex: new Point(0, 0),
absOffsetX: 0
};
test('concave', () => {
/*
| |
| |
________| |
__________| <- origin
*/
projectionContext.projectionCache = {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false};
const lineOffsetY = 1;
const prevToCurrent = new Point(10, 0);
const normal = transformToOffsetNormal(prevToCurrent, lineOffsetY, syntheticVertexArgs.direction);
expect(normal.y).toBeCloseTo(1);
expect(normal.x).toBeCloseTo(0);
const intersectionPoint = findOffsetIntersectionPoint(
1,
normal,
new Point(0, 0),
0,
3,
new Point(-10, 1),
lineOffsetY,
projectionContext,
syntheticVertexArgs
);
expect(intersectionPoint.y).toBeCloseTo(1);
expect(intersectionPoint.x).toBeCloseTo(-1);
});
test('convex', () => {
/*
| |
| |
origin \ | |
__________| |
____________|
*/
projectionContext.projectionCache = {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false};
const lineOffsetY = -1;
const prevToCurrent = new Point(10, 0);
const normal = transformToOffsetNormal(prevToCurrent, lineOffsetY, syntheticVertexArgs.direction);
expect(normal.y).toBeCloseTo(-1);
expect(normal.x).toBeCloseTo(0);
const intersectionPoint = findOffsetIntersectionPoint(
1,
normal,
new Point(0, 0),
0,
3,
new Point(-10, -1),
lineOffsetY,
projectionContext,
syntheticVertexArgs
);
expect(intersectionPoint.y).toBeCloseTo(-1);
expect(intersectionPoint.x).toBeCloseTo(1);
});
test('parallel', () => {
/*
______._____
______|_____
*/
projectionContext.projectionCache = {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false};
const lineOffsetY = 1;
const prevToCurrent = new Point(10, 0);
const intersectionPoint = findOffsetIntersectionPoint(
1,
transformToOffsetNormal(prevToCurrent, lineOffsetY, syntheticVertexArgs.direction),
new Point(0, 0),
3,
5,
new Point(-10, 1),
lineOffsetY,
projectionContext,
syntheticVertexArgs
);
expect(intersectionPoint.x).toBeCloseTo(0);
expect(intersectionPoint.y).toBeCloseTo(1);
});
test('getPitchedLabelPlaneMatrix: bearing and roll', () => {
const transform = new MercatorTransform();
transform.setBearing(0);
transform.setPitch(45);
transform.setRoll(45);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2)],
[0.4330127239227295, -0.4330127239227295, 0, 0, 0.3061862289905548, 0.3061862289905548, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2)],
[0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
});
test('getPitchedLabelPlaneMatrix: bearing and pitch', () => {
const transform = new MercatorTransform();
transform.setBearing(45);
transform.setPitch(45);
transform.setRoll(0);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2)],
[0.3535533845424652, -0.3535533845424652, 0, 0, 0.3535533845424652, 0.3535533845424652, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2)],
[0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
});
test('getPitchedLabelPlaneMatrix: bearing, pitch, and roll', () => {
const transform = new MercatorTransform();
transform.setBearing(45);
transform.setPitch(45);
transform.setRoll(45);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2)],
[0.08967986702919006, -0.5226925611495972, 0, 0, 0.5226925611495972, -0.08967986702919006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2)],
[0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
});
test('getGlCoordMatrix: bearing, pitch, and roll', () => {
const transform = new MercatorTransform();
transform.resize(128, 128);
transform.setBearing(45);
transform.setPitch(45);
transform.setRoll(45);
expectToBeCloseToArray([...getGlCoordMatrix(false, false, transform, 2)],
[...transform.pixelsToClipSpaceMatrix], 9);
expectToBeCloseToArray([...getGlCoordMatrix(false, true, transform, 2)],
[...transform.pixelsToClipSpaceMatrix], 9);
expectToBeCloseToArray([...getGlCoordMatrix(true, false, transform, 2)],
[-0.33820396661758423, 1.9711971282958984, 0, 0, -1.9711971282958984, 0.33820396661758423, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
expectToBeCloseToArray([...getGlCoordMatrix(true, true, transform, 2)],
[2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9);
});
test('getTileSkewVectors: bearing', () => {
const transform = new MercatorTransform();
transform.setBearing(45);
transform.setPitch(0);
transform.setRoll(0);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[0.7071067690849304, 0.7071067690849304]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-0.7071067690849304, 0.7071067690849304], 9);
});
test('getTileSkewVectors: roll', () => {
const transform = new MercatorTransform();
transform.setBearing(0);
transform.setPitch(0);
transform.setRoll(45);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[0.7071067690849304, 0.7071067690849304]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-0.7071067690849304, 0.7071067690849304], 9);
});
test('getTileSkewVectors: pitch', () => {
const transform = new MercatorTransform();
transform.setBearing(0);
transform.setPitch(45);
transform.setRoll(0);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[1.0, 0.0]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[0.0, 1.0], 9);
});
test('getTileSkewVectors: roll pitch bearing', () => {
const transform = new MercatorTransform();
transform.setBearing(45);
transform.setPitch(45);
transform.setRoll(45);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[-0.16910198330879211, 0.9855985641479492]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-0.9855985641479492, 0.16910198330879211], 9);
});
test('getTileSkewVectors: pitch 90 degrees', () => {
const transform = new MercatorTransform();
transform.setMaxPitch(180);
transform.setBearing(0);
transform.setPitch(89);
transform.setRoll(0);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[1, 0]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[0, 1], 9);
transform.setPitch(90);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[0, 0]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[0, 1], 9);
transform.setBearing(90);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[0, 0]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-1, 0], 9);
});
test('getTileSkewVectors: pitch 90 degrees with roll and bearing', () => {
const transform = new MercatorTransform();
transform.setMaxPitch(180);
transform.setBearing(45);
transform.setPitch(89);
transform.setRoll(45);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[-0.6946603059768677, 0.7193379402160645]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-0.7193379402160645, 0.6946603059768677], 9);
transform.setPitch(90);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast],
[-0.7071067690849304, 0.7071067690849304]);
expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth],
[-0.7071067690849304, 0.7071067690849304], 9);
});
});
describe('symbol height offset projection', () => {
const HEIGHT_OFFSET = 250;
test('projectTileCoordinatesToClipSpace measures the height offset from the terrain when the anchor is ground', () => {
const projectionContext = createProjectionContext({heightOffset: HEIGHT_OFFSET, heightAnchorGround: true});
const projected = projectTileCoordinatesToClipSpace(1024, 1024, projectionContext);
const expected = projectionContext.transform.projectTileCoordinates(1024, 1024, unwrappedTileID, TERRAIN_ELEVATION + HEIGHT_OFFSET);
expect(projected.point.x).toBeCloseTo(expected.point.x, 10);
expect(projected.point.y).toBeCloseTo(expected.point.y, 10);
});
test('projectTileCoordinatesToClipSpace ignores the terrain when the anchor is absolute', () => {
const projectionContext = createProjectionContext({heightOffset: HEIGHT_OFFSET, heightAnchorGround: false});
const projected = projectTileCoordinatesToClipSpace(1024, 1024, projectionContext);
const expected = projectionContext.transform.projectTileCoordinates(1024, 1024, unwrappedTileID, HEIGHT_OFFSET);
expect(projected.point.x).toBeCloseTo(expected.point.x, 10);
expect(projected.point.y).toBeCloseTo(expected.point.y, 10);
});
test('projectTileCoordinatesToClipSpace defaults to the plain terrain elevation without height properties', () => {
const projectionContext = createProjectionContext();
const projected = projectTileCoordinatesToClipSpace(1024, 1024, projectionContext);
const expected = projectionContext.transform.projectTileCoordinates(1024, 1024, unwrappedTileID, TERRAIN_ELEVATION);
expect(projected.point.x).toBeCloseTo(expected.point.x, 10);
expect(projected.point.y).toBeCloseTo(expected.point.y, 10);
});
test('projectTileCoordinatesToLabelPlane raises a viewport-aligned symbol by the height offset', () => {
const grounded = projectTileCoordinatesToLabelPlane(1024, 1024, createProjectionContext());
const raised = projectTileCoordinatesToLabelPlane(1024, 1024, createProjectionContext({heightOffset: HEIGHT_OFFSET, heightAnchorGround: true}));
// The label plane y axis points down the screen, so a raised symbol sits at a smaller y.
expect(raised.point.y).toBeLessThan(grounded.point.y);
// Raising the symbol only shifts it along y; x moves by no more than the perspective change.
expect(raised.point.x).toBeCloseTo(grounded.point.x, 4);
});
});