// @flow

import Point from '@mapbox/point-geometry';

import {mat2, mat4, vec3, vec4} from 'gl-matrix';
import * as symbolSize from './symbol_size.js';
import {addDynamicAttributes, updateGlobeVertexNormal} from '../data/bucket/symbol_bucket.js';
import type Projection from '../geo/projection/projection.js';
import type Painter from '../render/painter.js';
import type Transform from '../geo/transform.js';
import type SymbolBucket from '../data/bucket/symbol_bucket.js';
import type {
    GlyphOffsetArray,
    SymbolLineVertexArray,
    SymbolDynamicLayoutArray,
    SymbolGlobeExtArray,
    PlacedSymbol
} from '../data/array_types.js';
import type {Mat4, Vec3, Vec4} from 'gl-matrix';

import {WritingMode} from '../symbol/shaping.js';
import {CanonicalTileID, OverscaledTileID} from '../source/tile_id.js';
import {calculateGlobeLabelMatrix} from '../geo/projection/globe_util.js';
export {updateLineLabels, hideGlyphs, getLabelPlaneMatrixForRendering, getLabelPlaneMatrixForPlacement, getGlCoordMatrix, project, projectClamped, getPerspectiveRatio, placeFirstAndLastGlyph, placeGlyphAlongLine, xyTransformMat4};

type PlacedGlyph = {|
    angle: number,
    path: Array<Vec3>,
    point: Vec3,
    tilePath: Array<Point>,
    up: Vec3
|};
type ProjectionCache = {[_: number]: Vec3};

type PlacementStatus = {
    needsFlipping?: boolean,
    notEnoughRoom?: boolean,
    useVertical?: boolean
};

const FlipState = {
    unknown: 0,
    flipRequired: 1,
    flipNotRequired: 2
};

const maxTangent = Math.tan(85 * Math.PI / 180);

/*
 * # Overview of coordinate spaces
 *
 * ## Tile coordinate spaces
 * Each label has an anchor. Some labels have corresponding line geometries.
 * The points for both anchors and lines are stored in tile units. Each tile has it's own
 * coordinate space going from (0, 0) at the top left to (EXTENT, EXTENT) at the bottom right.
 *
 * ## GL coordinate space
 * At the end of everything, the vertex shader needs to produce a position in GL coordinate space,
 * which is (-1, 1) at the top left and (1, -1) in the bottom right.
 *
 * ## Map pixel coordinate spaces
 * Each tile has a pixel coordinate space. It's just the tile units scaled so that one unit is
 * whatever counts as 1 pixel at the current zoom.
 * This space is used for pitch-alignment=map, rotation-alignment=map
 *
 * ## Rotated map pixel coordinate spaces
 * Like the above, but rotated so axis of the space are aligned with the viewport instead of the tile.
 * This space is used for pitch-alignment=map, rotation-alignment=viewport
 *
 * ## Viewport pixel coordinate space
 * (0, 0) is at the top left of the canvas and (pixelWidth, pixelHeight) is at the bottom right corner
 * of the canvas. This space is used for pitch-alignment=viewport
 *
 *
 * # Vertex projection
 * It goes roughly like this:
 * 1. project the anchor and line from tile units into the correct label coordinate space
 *      - map pixel space           pitch-alignment=map         rotation-alignment=map
 *      - rotated map pixel space   pitch-alignment=map         rotation-alignment=viewport
 *      - viewport pixel space      pitch-alignment=viewport    rotation-alignment=*
 * 2. if the label follows a line, find the point along the line that is the correct distance from the anchor.
 * 3. add the glyph's corner offset to the point from step 3
 * 4. convert from the label coordinate space to gl coordinates
 *
 * For horizontal labels we want to do step 1 in the shader for performance reasons (no cpu work).
 *      This is what `u_label_plane_matrix` is used for.
 * For labels aligned with lines we have to steps 1 and 2 on the cpu since we need access to the line geometry.
 *      This is what `updateLineLabels(...)` does.
 *      Since the conversion is handled on the cpu we just set `u_label_plane_matrix` to an identity matrix.
 *
 * Steps 3 and 4 are done in the shaders for all labels.
 */

/*
 * Returns a matrix for converting from tile units to the correct label coordinate space.
 * This variation of the function returns a label space matrix specialized for rendering.
 * It transforms coordinates as-is to whatever the target space is (either 2D or 3D).
 * See also `getLabelPlaneMatrixForPlacement`
 */
function getLabelPlaneMatrixForRendering(posMatrix: Float32Array,
                             tileID: CanonicalTileID,
                             pitchWithMap: boolean,
                             rotateWithMap: boolean,
                             transform: Transform,
                             projection: Projection,
                             pixelsToTileUnits: Float32Array): Float32Array {
    const m = mat4.create();

    if (pitchWithMap) {
        if (projection.name === 'globe') {
            const lm = calculateGlobeLabelMatrix(transform, tileID);
            mat4.multiply(m, m, lm);
        } else {
            const s = mat2.invert([], pixelsToTileUnits);
            m[0] = s[0];
            m[1] = s[1];
            m[4] = s[2];
            m[5] = s[3];
            if (!rotateWithMap) {
                mat4.rotateZ(m, m, transform.angle);
            }
        }
    } else {
        mat4.multiply(m, transform.labelPlaneMatrix, posMatrix);
    }

    return m;
}

/*
 * Returns a matrix for converting from tile units to the correct label coordinate space.
 * This variation of the function returns a matrix specialized for placement logic.
 * Coordinates will be clamped to x&y 2D plane which is used with viewport and map aligned placement
 * logic in most cases. Certain projections such as globe view will use 3D space for map aligned
 * label placement.
 */
function getLabelPlaneMatrixForPlacement(posMatrix: Float32Array,
                             tileID: CanonicalTileID,
                             pitchWithMap: boolean,
                             rotateWithMap: boolean,
                             transform: Transform,
                             projection: Projection,
                             pixelsToTileUnits: Float32Array): Float32Array {
    const m = getLabelPlaneMatrixForRendering(posMatrix, tileID, pitchWithMap, rotateWithMap, transform, projection, pixelsToTileUnits);

    // Symbol placement logic is performed in 2D in most scenarios.
    // For this reason project all coordinates to the xy-plane by discarding the z-component
    if (projection.name !== 'globe' || !pitchWithMap) {
        // Pre-multiply by scaling z to 0
        m[2] = m[6] = m[10] = m[14] = 0;
    }

    return m;
}

/*
 * Returns a matrix for converting from the correct label coordinate space to gl coords.
 */
function getGlCoordMatrix(posMatrix: Float32Array,
                          tileID: CanonicalTileID,
                          pitchWithMap: boolean,
                          rotateWithMap: boolean,
                          transform: Transform,
                          projection: Projection,
                          pixelsToTileUnits: Float32Array): Float32Array {
    if (pitchWithMap) {
        if (projection.name === 'globe') {
            const m = getLabelPlaneMatrixForRendering(posMatrix, tileID, pitchWithMap, rotateWithMap, transform, projection, pixelsToTileUnits);
            mat4.invert(m, m);
            mat4.multiply(m, posMatrix, m);
            return m;
        } else {
            const m = mat4.clone(posMatrix);
            const s = mat4.identity([]);
            s[0] = pixelsToTileUnits[0];
            s[1] = pixelsToTileUnits[1];
            s[4] = pixelsToTileUnits[2];
            s[5] = pixelsToTileUnits[3];
            mat4.multiply(m, m, s);
            if (!rotateWithMap) {
                mat4.rotateZ(m, m, -transform.angle);
            }
            return m;
        }
    } else {
        return transform.glCoordMatrix;
    }
}

function project(x: number, y: number, z: number, matrix: Mat4): Vec4 {
    const pos = [x, y, z, 1];
    if (z) {
        vec4.transformMat4(pos, pos, matrix);
    } else {
        xyTransformMat4(pos, pos, matrix);
    }
    const w = pos[3];
    pos[0] /= w;
    pos[1] /= w;
    pos[2] /= w;
    return pos;
}

function projectClamped([x, y, z]: Vec3, matrix: Mat4): Vec4 {
    const pos = [x, y, z, 1];
    vec4.transformMat4(pos, pos, matrix);

    // Clamp distance to a positive value so we can avoid screen coordinate
    // being flipped possibly due to perspective projection
    const w = pos[3] = Math.max(pos[3], 0.000001);
    pos[0] /= w;
    pos[1] /= w;
    pos[2] /= w;
    return pos;
}

function getPerspectiveRatio(cameraToCenterDistance: number, signedDistanceFromCamera: number): number {
    return Math.min(0.5 + 0.5 * (cameraToCenterDistance / signedDistanceFromCamera), 1.5);
}

function isVisible(anchorPos: [number, number, number, number],
                   clippingBuffer: [number, number]) {
    const x = anchorPos[0] / anchorPos[3];
    const y = anchorPos[1] / anchorPos[3];
    const inPaddedViewport = (
        x >= -clippingBuffer[0] &&
        x <= clippingBuffer[0] &&
        y >= -clippingBuffer[1] &&
        y <= clippingBuffer[1]);
    return inPaddedViewport;
}

/*
 *  Update the `dynamicLayoutVertexBuffer` for the buffer with the correct glyph positions for the current map view.
 *  This is only run on labels that are aligned with lines. Horizontal labels are handled entirely in the shader.
 */
function updateLineLabels(bucket: SymbolBucket,
                          posMatrix: Float32Array,
                          painter: Painter,
                          isText: boolean,
                          labelPlaneMatrix: Float32Array,
                          glCoordMatrix: Float32Array,
                          pitchWithMap: boolean,
                          keepUpright: boolean,
                          getElevation: ?((p: Point) => Array<number>),
                          tileID: OverscaledTileID) {

    const tr = painter.transform;
    const sizeData = isText ? bucket.textSizeData : bucket.iconSizeData;
    const partiallyEvaluatedSize = symbolSize.evaluateSizeForZoom(sizeData, painter.transform.zoom);
    const isGlobe = tr.projection.name === 'globe';

    const clippingBuffer = [256 / painter.width * 2 + 1, 256 / painter.height * 2 + 1];

    const dynamicLayoutVertexArray = isText ?
        bucket.text.dynamicLayoutVertexArray :
        bucket.icon.dynamicLayoutVertexArray;
    dynamicLayoutVertexArray.clear();

    let globeExtVertexArray: ?SymbolGlobeExtArray = null;
    if (isGlobe) {
        globeExtVertexArray = isText ?
            bucket.text.globeExtVertexArray :
            bucket.icon.globeExtVertexArray;
    }

    const lineVertexArray = bucket.lineVertexArray;
    const placedSymbols = isText ? bucket.text.placedSymbolArray : bucket.icon.placedSymbolArray;

    const aspectRatio = painter.transform.width / painter.transform.height;

    let useVertical: ?boolean = false;
    let prevWritingMode;

    for (let s = 0; s < placedSymbols.length; s++) {
        const symbol = placedSymbols.get(s);
        const {numGlyphs, writingMode} = symbol;

        // Normally, the 'Horizontal|Vertical' writing mode is followed by a 'Vertical' counterpart, this
        // is not true for 'Vertical' only line labels. For this case, we'll have to overwrite the 'useVertical'
        // status before further checks.
        if (writingMode === WritingMode.vertical && !useVertical && prevWritingMode !== WritingMode.horizontal) {
            useVertical = true;
        }
        prevWritingMode = writingMode;

        // Don't do calculations for vertical glyphs unless the previous symbol was horizontal
        // and we determined that vertical glyphs were necessary.
        // Also don't do calculations for symbols that are collided and fully faded out
        if ((symbol.hidden || writingMode === WritingMode.vertical) && !useVertical) {
            hideGlyphs(numGlyphs, dynamicLayoutVertexArray);
            continue;
        }
        // Awkward... but we're counting on the paired "vertical" symbol coming immediately after its horizontal counterpart
        useVertical = false;

        // Project tile anchor to globe anchor
        const tileAnchorPoint = new Point(symbol.tileAnchorX, symbol.tileAnchorY);
        let {x, y, z} = tr.projection.projectTilePoint(tileAnchorPoint.x, tileAnchorPoint.y, tileID.canonical);
        if (getElevation) {
            const [dx, dy, dz] = getElevation(tileAnchorPoint);
            x += dx;
            y += dy;
            z += dz;
        }
        const anchorPos = [x, y, z, 1.0];
        vec4.transformMat4(anchorPos, anchorPos, posMatrix);

        // Don't bother calculating the correct point for invisible labels.
        if (!isVisible(anchorPos, clippingBuffer)) {
            hideGlyphs(numGlyphs, dynamicLayoutVertexArray);
            continue;
        }
        const cameraToAnchorDistance = anchorPos[3];
        const perspectiveRatio = getPerspectiveRatio(painter.transform.getCameraToCenterDistance(tr.projection), cameraToAnchorDistance);

        const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol);
        const pitchScaledFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio;

        const labelPlaneAnchorPoint = project(x, y, z, labelPlaneMatrix);

        // Skip labels behind the camera
        if (labelPlaneAnchorPoint[3] <= 0.0) {
            hideGlyphs(numGlyphs, dynamicLayoutVertexArray);
            continue;
        }

        let projectionCache: ProjectionCache = {};

        const getElevationForPlacement = pitchWithMap ? null : getElevation; // When pitchWithMap, we're projecting to scaled tile coordinate space: there is no need to get elevation as it doesn't affect projection.
        const placeUnflipped = placeGlyphsAlongLine(symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix,
            bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, globeExtVertexArray, labelPlaneAnchorPoint, tileAnchorPoint, projectionCache, aspectRatio, getElevationForPlacement, tr.projection, tileID, pitchWithMap);

        useVertical = placeUnflipped.useVertical;

        if (getElevationForPlacement && placeUnflipped.needsFlipping) projectionCache = {}; // Truncated points should be recalculated.
        if (placeUnflipped.notEnoughRoom || useVertical ||
            (placeUnflipped.needsFlipping &&
             placeGlyphsAlongLine(symbol, pitchScaledFontSize, true /*flipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix,
                 bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, globeExtVertexArray, labelPlaneAnchorPoint, tileAnchorPoint, projectionCache, aspectRatio, getElevationForPlacement, tr.projection, tileID, pitchWithMap).notEnoughRoom)) {
            hideGlyphs(numGlyphs, dynamicLayoutVertexArray);
        }
    }

    if (isText) {
        bucket.text.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray);
        if (globeExtVertexArray && bucket.text.globeExtVertexBuffer) {
            bucket.text.globeExtVertexBuffer.updateData(globeExtVertexArray);
        }
    } else {
        bucket.icon.dynamicLayoutVertexBuffer.updateData(dynamicLayoutVertexArray);
        if (globeExtVertexArray && bucket.icon.globeExtVertexBuffer) {
            bucket.icon.globeExtVertexBuffer.updateData(globeExtVertexArray);
        }
    }
}

function placeFirstAndLastGlyph(
    fontScale: number,
    glyphOffsetArray: GlyphOffsetArray,
    lineOffsetX: number,
    lineOffsetY: number,
    flip: boolean,
    anchorPoint: Vec3,
    tileAnchorPoint: Point,
    symbol: PlacedSymbol,
    lineVertexArray: SymbolLineVertexArray,
    labelPlaneMatrix: Float32Array,
    projectionCache: ProjectionCache,
    getElevation: ?((p: Point) => Array<number>),
    returnPathInTileCoords: ?boolean,
    projection: Projection,
    tileID: OverscaledTileID,
    pitchWithMap: boolean): null | {|first: PlacedGlyph, last: PlacedGlyph|} {

    const {lineStartIndex, glyphStartIndex, segment} = symbol;
    const glyphEndIndex = glyphStartIndex + symbol.numGlyphs;
    const lineEndIndex = lineStartIndex + symbol.lineLength;

    const firstGlyphOffset = glyphOffsetArray.getoffsetX(glyphStartIndex);
    const lastGlyphOffset = glyphOffsetArray.getoffsetX(glyphEndIndex - 1);

    const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, segment,
        lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, getElevation, returnPathInTileCoords, true, projection, tileID, pitchWithMap);
    if (!firstPlacedGlyph)
        return null;

    const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, segment,
        lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, getElevation, returnPathInTileCoords, true, projection, tileID, pitchWithMap);
    if (!lastPlacedGlyph)
        return null;

    return {first: firstPlacedGlyph, last: lastPlacedGlyph};
}

// Check in the glCoordinate space, the rough estimation of angle between the text line and the Y axis.
// If the angle if less or equal to 5 degree, then keep the text glyphs unflipped even if it is required.
function isInFlipRetainRange(dx: number, dy: number) {
    return dx === 0 || Math.abs(dy / dx) > maxTangent;
}

function requiresOrientationChange(writingMode: number, flipState: number, dx: number, dy: number) {
    if (writingMode === WritingMode.horizontal && Math.abs(dy) > Math.abs(dx)) {
        // On top of choosing whether to flip, choose whether to render this version of the glyphs or the alternate
        // vertical glyphs. We can't just filter out vertical glyphs in the horizontal range because the horizontal
        // and vertical versions can have slightly different projections which could lead to angles where both or
        // neither showed.
        return {useVertical: true};
    }
    // Check if flipping is required for "verticalOnly" case.
    if (writingMode === WritingMode.vertical) {
        return dy > 0 ? {needsFlipping: true} : null;
    }

    // symbol's flipState stores the flip decision from the previous frame, and that
    // decision is reused when the symbol is in the retain range.
    if (flipState !== FlipState.unknown && isInFlipRetainRange(dx, dy)) {
        return (flipState === FlipState.flipRequired) ? {needsFlipping: true} : null;
    }

    // Check if flipping is required for "horizontal" case.
    return dx < 0 ? {needsFlipping: true} : null;
}

function placeGlyphsAlongLine(symbol: PlacedSymbol, fontSize: number, flip: boolean, keepUpright: boolean, posMatrix: Float32Array, labelPlaneMatrix: Float32Array, glCoordMatrix: Float32Array, glyphOffsetArray: GlyphOffsetArray, lineVertexArray: SymbolLineVertexArray, dynamicLayoutVertexArray: SymbolDynamicLayoutArray, globeExtVertexArray: ?SymbolGlobeExtArray, anchorPoint: VecType, tileAnchorPoint: Point, projectionCache: ProjectionCache, aspectRatio: number, getElevation: ?((p: Point) => Array<number>), projection: Projection, tileID: OverscaledTileID, pitchWithMap: boolean): PlacementStatus {
    const fontScale = fontSize / 24;
    const lineOffsetX = symbol.lineOffsetX * fontScale;
    const lineOffsetY = symbol.lineOffsetY * fontScale;
    const {lineStartIndex, glyphStartIndex, numGlyphs, segment, writingMode, flipState} = symbol;
    const lineEndIndex = lineStartIndex + symbol.lineLength;

    const addGlyph = (glyph: PlacedGlyph) => {
        if (globeExtVertexArray) {
            const [ux, uy, uz] = glyph.up;
            const offset = dynamicLayoutVertexArray.length;
            updateGlobeVertexNormal(globeExtVertexArray, offset + 0, ux, uy, uz);
            updateGlobeVertexNormal(globeExtVertexArray, offset + 1, ux, uy, uz);
            updateGlobeVertexNormal(globeExtVertexArray, offset + 2, ux, uy, uz);
            updateGlobeVertexNormal(globeExtVertexArray, offset + 3, ux, uy, uz);
        }
        const [x, y, z] = glyph.point;
        addDynamicAttributes(dynamicLayoutVertexArray, x, y, z, glyph.angle);
    };

    if (numGlyphs > 1) {
        // Place the first and the last glyph in the label first, so we can figure out
        // the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode
        const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol, lineVertexArray, labelPlaneMatrix, projectionCache, getElevation, false, projection, tileID, pitchWithMap);
        if (!firstAndLastGlyph) {
            return {notEnoughRoom: true};
        }

        if (keepUpright && !flip) {
            let [x0, y0, z0] = firstAndLastGlyph.first.point;
            let [x1, y1, z1] = firstAndLastGlyph.last.point;
            [x0, y0] = project(x0, y0, z0, glCoordMatrix);
            [x1, y1] = project(x1, y1, z1, glCoordMatrix);
            const orientationChange = requiresOrientationChange(writingMode, flipState, (x1 - x0) * aspectRatio, y1 - y0);
            symbol.flipState = orientationChange && orientationChange.needsFlipping ? FlipState.flipRequired : FlipState.flipNotRequired;
            if (orientationChange) {
                return orientationChange;
            }
        }

        addGlyph(firstAndLastGlyph.first);
        for (let glyphIndex = glyphStartIndex + 1; glyphIndex < glyphStartIndex + numGlyphs - 1; glyphIndex++) {
            // Since first and last glyph fit on the line, the rest of the glyphs can be placed too, but check to make sure
            const glyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(glyphIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, segment,
                lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, getElevation, false, false, projection, tileID, pitchWithMap);
            if (!glyph) {
                // undo previous glyphs of the symbol if it doesn't fit; it will be filled with hideGlyphs instead
                dynamicLayoutVertexArray.length -= 4 * (glyphIndex - glyphStartIndex);
                return {notEnoughRoom: true};
            }
            addGlyph(glyph);
        }
        addGlyph(firstAndLastGlyph.last);
    } else {
        // Only a single glyph to place
        // So, determine whether to flip based on projected angle of the line segment it's on
        if (keepUpright && !flip) {
            const a = project(tileAnchorPoint.x, tileAnchorPoint.y, 0, posMatrix);
            const tileVertexIndex = lineStartIndex + segment + 1;
            const tileSegmentEnd = new Point(lineVertexArray.getx(tileVertexIndex), lineVertexArray.gety(tileVertexIndex));
            const projectedVertex = project(tileSegmentEnd.x, tileSegmentEnd.y, 0, posMatrix);
            // We know the anchor will be in the viewport, but the end of the line segment may be
            // behind the plane of the camera, in which case we can use a point at any arbitrary (closer)
            // point on the segment.
            const b = (projectedVertex[3] > 0) ?
                projectedVertex :
                projectTruncatedLineSegment(tileAnchorPoint, tileSegmentEnd, a, 1, posMatrix, undefined, projection, tileID.canonical);

            const orientationChange = requiresOrientationChange(writingMode, flipState, (b[0] - a[0]) * aspectRatio, b[1] - a[1]);
            symbol.flipState = orientationChange && orientationChange.needsFlipping ? FlipState.flipRequired : FlipState.flipNotRequired;
            if (orientationChange) {
                return orientationChange;
            }
        }
        const singleGlyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(glyphStartIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, segment,
            lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, getElevation, false, false, projection, tileID, pitchWithMap);
        if (!singleGlyph) {
            return {notEnoughRoom: true};
        }

        addGlyph(singleGlyph);
    }
    return {};
}

function elevatePointAndProject(p: Point, tileID: CanonicalTileID, posMatrix: Float32Array, projection: Projection, getElevation: ?((p: Point) => Array<number>)) {
    const {x, y, z} = projection.projectTilePoint(p.x, p.y, tileID);
    if (!getElevation) {
        return project(x, y, z, posMatrix);
    }
    const [dx, dy, dz] = getElevation(p);
    return project(x + dx, y + dy, z + dz, posMatrix);
}

function projectTruncatedLineSegment(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Vec3, minimumLength: number, projectionMatrix: Float32Array, getElevation: ?((p: Point) => Array<number>), projection: Projection, tileID: CanonicalTileID): Vec3 {
    // We are assuming "previousTilePoint" won't project to a point within one unit of the camera plane
    // If it did, that would mean our label extended all the way out from within the viewport to a (very distant)
    // point near the plane of the camera. We wouldn't be able to render the label anyway once it crossed the
    // plane of the camera.
    const unitVertex = previousTilePoint.sub(currentTilePoint)._unit()._add(previousTilePoint);
    const projectedUnit = elevatePointAndProject(unitVertex, tileID, projectionMatrix, projection, getElevation);
    vec3.sub(projectedUnit, previousProjectedPoint, projectedUnit);
    vec3.normalize(projectedUnit, projectedUnit);

    return vec3.scaleAndAdd(projectedUnit, previousProjectedPoint, projectedUnit, minimumLength);
}

function placeGlyphAlongLine(
    offsetX: number,
    lineOffsetX: number,
    lineOffsetY: number,
    flip: boolean,
    anchorPoint: Vec3,
    tileAnchorPoint: Point,
    anchorSegment: number,
    lineStartIndex: number,
    lineEndIndex: number,
    lineVertexArray: SymbolLineVertexArray,
    labelPlaneMatrix: Float32Array,
    projectionCache: ProjectionCache,
    getElevation: ?((p: Point) => Array<number>),
    returnPathInTileCoords: ?boolean,
    endGlyph: ?boolean,
    reprojection: Projection,
    tileID: OverscaledTileID,
    pitchWithMap: boolean): null | PlacedGlyph {

    const combinedOffsetX = flip ?
        offsetX - lineOffsetX :
        offsetX + lineOffsetX;

    let dir = combinedOffsetX > 0 ? 1 : -1;

    let angle = 0;
    if (flip) {
        // The label needs to be flipped to keep text upright.
        // Iterate in the reverse direction.
        dir *= -1;
        angle = Math.PI;
    }

    if (dir < 0) angle += Math.PI;

    let currentIndex = lineStartIndex + anchorSegment + (dir > 0 ? 0 : 1) | 0;
    let current = anchorPoint;
    let prev = anchorPoint;
    let distanceToPrev = 0;
    let currentSegmentDistance = 0;
    const absOffsetX = Math.abs(combinedOffsetX);
    const pathVertices = [];
    const tilePath = [];
    let currentVertex = tileAnchorPoint;
    let prevVertex = currentVertex;

    const getTruncatedLineSegment = () => {
        return projectTruncatedLineSegment(prevVertex, currentVertex, prev, absOffsetX - distanceToPrev + 1, labelPlaneMatrix, getElevation, reprojection, tileID.canonical);
    };

    while (distanceToPrev + currentSegmentDistance <= absOffsetX) {
        currentIndex += dir;

        // offset does not fit on the projected line
        if (currentIndex < lineStartIndex || currentIndex >= lineEndIndex)
            return null;

        prev = current;
        prevVertex = currentVertex;

        pathVertices.push(prev);
        if (returnPathInTileCoords) tilePath.push(prevVertex);

        currentVertex = new Point(lineVertexArray.getx(currentIndex), lineVertexArray.gety(currentIndex));
        current = projectionCache[currentIndex];
        if (!current) {
            const projection = elevatePointAndProject(currentVertex, tileID.canonical, labelPlaneMatrix, reprojection, getElevation);
            if (projection[3] > 0) {
                current = projectionCache[currentIndex] = projection;
            } else {
                // The vertex is behind the plane of the camera, so we can't project it
                // Instead, we'll create a vertex along the line that's far enough to include the glyph
                // Don't cache because the new vertex might not be far enough out for future glyphs on the same segment
                current = getTruncatedLineSegment();
            }
        }

        distanceToPrev += currentSegmentDistance;
        currentSegmentDistance = vec3.distance(prev, current);
    }

    if (endGlyph && getElevation) {
        // For terrain, always truncate end points in order to handle terrain curvature.
        // If previously truncated, on signedDistanceFromCamera < 0, don't do it.
        // Cache as end point. The cache is cleared if there is need for flipping in updateLineLabels.
        if (projectionCache[currentIndex]) {
            current = getTruncatedLineSegment();
            currentSegmentDistance = vec3.distance(prev, current);
        }
        projectionCache[currentIndex] = current;
    }

    // The point is on the current segment. Interpolate to find it. Compute points on both label plane and tile space
    const segmentInterpolationT = (absOffsetX - distanceToPrev) / currentSegmentDistance;
    const tilePoint = currentVertex.sub(prevVertex)._mult(segmentInterpolationT)._add(prevVertex);
    const prevToCurrent = vec3.sub([], current, prev);
    const labelPlanePoint = vec3.scaleAndAdd([], prev, prevToCurrent, segmentInterpolationT);

    let axisZ: Vec3 = [0, 0, 1];
    let diffX = prevToCurrent[0];
    let diffY = prevToCurrent[1];

    if (pitchWithMap) {
        axisZ = reprojection.upVector(tileID.canonical, tilePoint.x, tilePoint.y);

        if (axisZ[0] !== 0 || axisZ[1] !== 0 || axisZ[2] !== 1) {
            // Compute coordinate frame that is aligned to the tangent of the surface
            const axisX = [axisZ[2], 0, -axisZ[0]];
            const axisY = vec3.cross([], axisZ, axisX);
            vec3.normalize(axisX, axisX);
            vec3.normalize(axisY, axisY);
            diffX = vec3.dot(prevToCurrent, axisX);
            diffY = vec3.dot(prevToCurrent, axisY);
        }
    }

    // offset the point from the line to text-offset and icon-offset
    if (lineOffsetY) {
        // Find a coordinate frame for the vertical offset
        const offsetDir = vec3.cross([], axisZ, prevToCurrent);
        vec3.normalize(offsetDir, offsetDir);
        vec3.scaleAndAdd(labelPlanePoint, labelPlanePoint, offsetDir, lineOffsetY * dir);
    }

    const segmentAngle = angle + Math.atan2(diffY, diffX);

    pathVertices.push(labelPlanePoint);
    if (returnPathInTileCoords) {
        tilePath.push(tilePoint);
    }

    return {
        point: labelPlanePoint,
        angle: segmentAngle,
        path: pathVertices,
        tilePath,
        up: axisZ
    };
}

// Hide them by moving them offscreen. We still need to add them to the buffer
// because the dynamic buffer is paired with a static buffer that doesn't get updated.
function hideGlyphs(num: number, dynamicLayoutVertexArray: SymbolDynamicLayoutArray) {
    const offset = dynamicLayoutVertexArray.length;
    const end = offset + 4 * num;
    dynamicLayoutVertexArray.resize(end);
    // Since all hidden glyphs have the same attributes, we can build up the array faster with a single call to
    // Float32Array.fill for all vertices, instead of calling addDynamicAttributes for each vertex.
    dynamicLayoutVertexArray.float32.fill(-Infinity, offset * 4, end * 4);
}

// For line label layout, we're not using z output and our w input is always 1
// This custom matrix transformation ignores those components to make projection faster
function xyTransformMat4(out: Vec4, a: Vec4, m: Mat4): Vec4 {
    const x = a[0], y = a[1];
    out[0] = m[0] * x + m[4] * y + m[12];
    out[1] = m[1] * x + m[5] * y + m[13];
    out[3] = m[3] * x + m[7] * y + m[15];
    return out;
}
