UNPKG

mapbox-gl

Version:
556 lines (480 loc) 22.2 kB
// @flow import Point from '@mapbox/point-geometry'; import {getBounds, clamp, polygonizeBounds, bufferConvexPolygon} from '../util/util.js'; import {polygonIntersectsBox, polygonContainsPoint} from '../util/intersection_tests.js'; import EXTENT from '../data/extent.js'; import type {PointLike} from '@mapbox/point-geometry'; import type Transform from '../geo/transform.js'; import type Tile from '../source/tile.js'; import pixelsToTileUnits from '../source/pixels_to_tile_units.js'; import {vec3, vec4, mat4} from 'gl-matrix'; import {Ray} from '../util/primitives.js'; import MercatorCoordinate, {mercatorXfromLng} from '../geo/mercator_coordinate.js'; import type {OverscaledTileID} from '../source/tile_id.js'; import {getTilePoint, getTileVec3} from '../geo/projection/tile_transform.js'; import resample from '../geo/projection/resample.js'; import {GLOBE_RADIUS} from '../geo/projection/globe_util.js'; import {number as interpolate} from '../style-spec/util/interpolate.js'; type CachedPolygon = { // Query rectangle projected on the map plane polygon: MercatorCoordinate[]; // A flag tellingwhether the query polygon might span across mercator boundaries [0, 1] unwrapped: boolean; }; /** * A data-class that represents a screenspace query from `Map#queryRenderedFeatures`. * All the internal geometries and data are intented to be immutable and read-only. * Its lifetime is only for the duration of the query and fixed state of the map while the query is being processed. * * @class QueryGeometry */ export class QueryGeometry { screenBounds: Point[]; cameraPoint: Point; screenGeometry: Point[]; screenGeometryMercator: CachedPolygon; _screenRaycastCache: { [_: number]: CachedPolygon}; _cameraRaycastCache: { [_: number]: CachedPolygon}; isAboveHorizon: boolean; constructor(screenBounds: Point[], cameraPoint: Point, aboveHorizon: boolean, transform: Transform) { this.screenBounds = screenBounds; this.cameraPoint = cameraPoint; this._screenRaycastCache = {}; this._cameraRaycastCache = {}; this.isAboveHorizon = aboveHorizon; this.screenGeometry = this.bufferedScreenGeometry(0); this.screenGeometryMercator = this._bufferedScreenMercator(0, transform); } /** * Factory method to help contruct an instance while accounting for current map state. * * @static * @param {(PointLike | [PointLike, PointLike])} geometry The query geometry. * @param {Transform} transform The current map transform. * @returns {QueryGeometry} An instance of the QueryGeometry class. */ static createFromScreenPoints(geometry: PointLike | [PointLike, PointLike], transform: Transform): QueryGeometry { let screenGeometry; let aboveHorizon; // $FlowFixMe: Flow can't refine that this will be PointLike but we can if (geometry instanceof Point || typeof geometry[0] === 'number') { // $FlowFixMe const pt = Point.convert(geometry); screenGeometry = [pt]; aboveHorizon = transform.isPointAboveHorizon(pt); } else { // $FlowFixMe const tl = Point.convert(geometry[0]); // $FlowFixMe const br = Point.convert(geometry[1]); screenGeometry = [tl, br]; aboveHorizon = polygonizeBounds(tl, br).every((p) => transform.isPointAboveHorizon(p)); } return new QueryGeometry(screenGeometry, transform.getCameraPoint(), aboveHorizon, transform); } /** * Returns true if the initial query by the user was a single point. * * @returns {boolean} Returns `true` if the initial query geometry was a single point. */ isPointQuery(): boolean { return this.screenBounds.length === 1; } /** * Due to data-driven styling features do not uniform size(eg `circle-radius`) and can be offset differntly * from their original location(for example with `*-translate`). This means we have to expand our query region for * each tile to account for variation in these properties. * Each tile calculates a tile level max padding value (in screenspace pixels) when its parsed, this function * lets us calculate a buffered version of the screenspace query geometry for each tile. * * @param {number} buffer The tile padding in screenspace pixels. * @returns {Point[]} The buffered query geometry. */ bufferedScreenGeometry(buffer: number): Point[] { return polygonizeBounds( this.screenBounds[0], this.screenBounds.length === 1 ? this.screenBounds[0] : this.screenBounds[1], buffer ); } /** * When the map is pitched, some of the 3D features that intersect a query will not intersect * the query at the surface of the earth. Instead the feature may be closer and only intersect * the query because it extrudes into the air. * * This returns a geometry that is a convex polygon that encompasses the query frustum and the point underneath the camera. * Similar to `bufferedScreenGeometry`, buffering is added to account for variation in paint properties. * * Case 1: point underneath camera is exactly behind query volume * +----------+ * | | * | | * | | * + + * X X * X X * X X * X X * XX. * * Case 2: point is behind and to the right * +----------+ * | X * | X * | XX * + X * XXX XX * XXXX X * XXX XX * XX X * XXX. * * Case 3: point is behind and to the left * +----------+ * X | * X | * XX | * X + * X XXXX * XX XXX * X XXXX * X XXXX * XXX. * * @param {number} buffer The tile padding in screenspace pixels. * @returns {Point[]} The buffered query geometry. */ bufferedCameraGeometry(buffer: number): Point[] { const min = this.screenBounds[0]; const max = this.screenBounds.length === 1 ? this.screenBounds[0].add(new Point(1, 1)) : this.screenBounds[1]; const cameraPolygon = polygonizeBounds(min, max, 0, false); // Only need to account for point underneath camera if its behind query volume if (this.cameraPoint.y > max.y) { //case 1: insert point in the middle if (this.cameraPoint.x > min.x && this.cameraPoint.x < max.x) { cameraPolygon.splice(3, 0, this.cameraPoint); //case 2: replace btm right point } else if (this.cameraPoint.x >= max.x) { cameraPolygon[2] = this.cameraPoint; //case 3: replace btm left point } else if (this.cameraPoint.x <= min.x) { cameraPolygon[3] = this.cameraPoint; } } return bufferConvexPolygon(cameraPolygon, buffer); } // Creates a convex polygon in screen coordinates that encompasses the query frustum and // the camera location at globe's surface. Camera point can be at any side of the query polygon as // opposed to `bufferedCameraGeometry` which restricts the location to underneath the polygon. bufferedCameraGeometryGlobe(buffer: number): Point[] { const min = this.screenBounds[0]; const max = this.screenBounds.length === 1 ? this.screenBounds[0].add(new Point(1, 1)) : this.screenBounds[1]; // Padding is added to the query polygon before inclusion of the camera location. // Otherwise the buffered (narrow) polygon could penetrate the globe creating a lot of false positives const cameraPolygon = polygonizeBounds(min, max, buffer); const camPos = this.cameraPoint.clone(); const column = (camPos.x > min.x) + (camPos.x > max.x); const row = (camPos.y > min.y) + (camPos.y > max.y); const sector = row * 3 + column; switch (sector) { case 0: // replace top-left point (closed polygon) cameraPolygon[0] = camPos; cameraPolygon[4] = camPos.clone(); break; case 1: // insert point in the middle of top-left and top-right cameraPolygon.splice(1, 0, camPos); break; case 2: // replace top-right point cameraPolygon[1] = camPos; break; case 3: // insert point in the middle of top-left and bottom-left cameraPolygon.splice(4, 0, camPos); break; case 5: // insert point in the middle of top-right and bottom-right cameraPolygon.splice(2, 0, camPos); break; case 6: // replace bottom-left point cameraPolygon[3] = camPos; break; case 7: // insert point in the middle of bottom-left and bottom-right cameraPolygon.splice(3, 0, camPos); break; case 8: // replace bottom-right point cameraPolygon[2] = camPos; break; } return cameraPolygon; } /** * Checks if a tile is contained within this query geometry. * * @param {Tile} tile The tile to check. * @param {Transform} transform The current map transform. * @param {boolean} use3D A boolean indicating whether to query 3D features. * @param {number} cameraWrap A wrap value for offsetting the camera position. * @returns {?TilespaceQueryGeometry} Returns `undefined` if the tile does not intersect. */ containsTile(tile: Tile, transform: Transform, use3D: boolean, cameraWrap: number = 0): ?TilespaceQueryGeometry { // The buffer around the query geometry is applied in screen-space. // transform._pixelsPerMercatorPixel is used to compensate any extra scaling applied from the currently active projection. // Floating point errors when projecting into tilespace could leave a feature // outside the query volume even if it looks like it overlaps visually, a 1px bias value overcomes that. const bias = 1; const padding = tile.queryPadding / transform._pixelsPerMercatorPixel + bias; const cachedQuery = use3D ? this._bufferedCameraMercator(padding, transform) : this._bufferedScreenMercator(padding, transform); let wrap = tile.tileID.wrap + (cachedQuery.unwrapped ? cameraWrap : 0); const geometryForTileCheck = cachedQuery.polygon.map((p) => getTilePoint(tile.tileTransform, p, wrap)); if (!polygonIntersectsBox(geometryForTileCheck, 0, 0, EXTENT, EXTENT)) { return undefined; } wrap = tile.tileID.wrap + (this.screenGeometryMercator.unwrapped ? cameraWrap : 0); const tilespaceVec3s = this.screenGeometryMercator.polygon.map((p) => getTileVec3(tile.tileTransform, p, wrap)); const tilespaceGeometry = tilespaceVec3s.map((v) => new Point(v[0], v[1])); const cameraMercator = transform.getFreeCameraOptions().position || new MercatorCoordinate(0, 0, 0); const tilespaceCameraPosition = getTileVec3(tile.tileTransform, cameraMercator, wrap); const tilespaceRays = tilespaceVec3s.map((tileVec) => { const dir = vec3.sub(tileVec, tileVec, tilespaceCameraPosition); vec3.normalize(dir, dir); return new Ray(tilespaceCameraPosition, dir); }); const pixelToTileUnitsFactor = pixelsToTileUnits(tile, 1, transform.zoom) * transform._pixelsPerMercatorPixel; return { queryGeometry: this, tilespaceGeometry, tilespaceRays, bufferedTilespaceGeometry: geometryForTileCheck, bufferedTilespaceBounds: clampBoundsToTileExtents(getBounds(geometryForTileCheck)), tile, tileID: tile.tileID, pixelToTileUnitsFactor }; } /** * These methods add caching on top of the terrain raycasting provided by `Transform#pointCoordinate3d`. * Tiles come with different values of padding, however its very likely that multiple tiles share the same value of padding * based on the style. In that case we want to reuse the result from a previously computed terrain raycast. */ _bufferedScreenMercator(padding: number, transform: Transform): CachedPolygon { const key = cacheKey(padding); if (this._screenRaycastCache[key]) { return this._screenRaycastCache[key]; } else { let poly: CachedPolygon; if (transform.projection.name === 'globe') { poly = this._projectAndResample(this.bufferedScreenGeometry(padding), transform); } else { poly = { polygon: this.bufferedScreenGeometry(padding).map((p) => transform.pointCoordinate3D(p)), unwrapped: true }; } this._screenRaycastCache[key] = poly; return poly; } } _bufferedCameraMercator(padding: number, transform: Transform): CachedPolygon { const key = cacheKey(padding); if (this._cameraRaycastCache[key]) { return this._cameraRaycastCache[key]; } else { let poly: CachedPolygon; if (transform.projection.name === 'globe') { poly = this._projectAndResample(this.bufferedCameraGeometryGlobe(padding), transform); } else { poly = { polygon: this.bufferedCameraGeometry(padding).map((p) => transform.pointCoordinate3D(p)), unwrapped: true }; } this._cameraRaycastCache[key] = poly; return poly; } } _projectAndResample(polygon: Point[], transform: Transform): CachedPolygon { // Handle a special case where either north or south pole is inside the query polygon const polePolygon: ?CachedPolygon = projectPolygonCoveringPoles(polygon, transform); if (polePolygon) { return polePolygon; } // Resample the polygon by adding intermediate points so that straight lines of the shape // are correctly projected on the surface of the globe. const resampled = unwrapQueryPolygon(resamplePolygon(polygon, transform).map(p => new Point(wrap(p.x), p.y)), transform); return { polygon: resampled.polygon.map(p => new MercatorCoordinate(p.x, p.y)), unwrapped: resampled.unwrapped }; } } // Checks whether the provided polygon is crossing the antimeridian line and unwraps it if necessary. // The resulting polygon is continuous export function unwrapQueryPolygon(polygon: Point[], tr: Transform): {polygon: Point[], unwrapped: boolean} { let unwrapped = false; // Traverse edges of the polygon and unwrap vertices that are crossing the antimeridian. let maxX = -Infinity; let startEdge = 0; for (let e = 0; e < polygon.length - 1; e++) { if (polygon[e].x > maxX) { maxX = polygon[e].x; startEdge = e; } } for (let i = 0; i < polygon.length - 1; i++) { const edge = (startEdge + i) % (polygon.length - 1); const a = polygon[edge]; const b = polygon[edge + 1]; if (Math.abs(a.x - b.x) > 0.5) { // A straight line drawn on the globe can't have longer length than 0.5 on the x-axis // without crossing the antimeridian if (a.x < b.x) { a.x += 1; if (edge === 0) { // First and last points are duplicate for closed polygons polygon[polygon.length - 1].x += 1; } } else { b.x += 1; if (edge + 1 === polygon.length - 1) { polygon[0].x += 1; } } unwrapped = true; } } const cameraX = mercatorXfromLng(tr.center.lng); if (unwrapped && cameraX < Math.abs(cameraX - 1)) { polygon.forEach(p => { p.x -= 1; }); } return { polygon, unwrapped }; } // Special function for handling scenarios where one of the poles is inside the query polygon. // Finding projection of these kind of polygons is more involving as projecting just the corners will // produce a degenerate (self-intersecting, non-continuous, etc.) polygon in mercator coordinates export function projectPolygonCoveringPoles(polygon: Point[], tr: Transform): ?CachedPolygon { const matrix = mat4.multiply([], tr.pixelMatrix, tr.globeMatrix); // Transform north and south pole coordinates to the screen to see if they're // inside the query polygon const northPole = [0, -GLOBE_RADIUS, 0, 1]; const southPole = [0, GLOBE_RADIUS, 0, 1]; const center = [0, 0, 0, 1]; vec4.transformMat4(northPole, northPole, matrix); vec4.transformMat4(southPole, southPole, matrix); vec4.transformMat4(center, center, matrix); const screenNp = new Point(northPole[0] / northPole[3], northPole[1] / northPole[3]); const screenSp = new Point(southPole[0] / southPole[3], southPole[1] / southPole[3]); const containsNp = polygonContainsPoint(polygon, screenNp) && northPole[3] < center[3]; const containsSp = polygonContainsPoint(polygon, screenSp) && southPole[3] < center[3]; if (!containsNp && !containsSp) { return null; } // Project corner points of the polygon and traverse the ring to find the edge that's // crossing the zero longitude border. const result = findEdgeCrossingAntimeridian(polygon, tr, containsNp ? -1 : 1); if (!result) { return null; } // Start constructing the new polygon by resampling edges until the crossing edge const {idx, t} = result; let partA = idx > 1 ? resamplePolygon(polygon.slice(0, idx), tr) : []; let partB = idx < polygon.length ? resamplePolygon(polygon.slice(idx), tr) : []; partA = partA.map(p => new Point(wrap(p.x), p.y)); partB = partB.map(p => new Point(wrap(p.x), p.y)); // Resample first section of the ring (up to the edge that crosses the 0-line) const resampled = [...partA]; if (resampled.length === 0) { resampled.push(partB[partB.length - 1]); } // Find location of the crossing by interpolating mercator coordinates. // This will produce slightly off result as the crossing edge is not actually // linear on the globe. const a = resampled[resampled.length - 1]; const b = partB.length === 0 ? partA[0] : partB[0]; const intersectionY = interpolate(a.y, b.y, t); let mid; if (containsNp) { mid = [ new Point(0, intersectionY), new Point(0, 0), new Point(1, 0), new Point(1, intersectionY) ]; } else { mid = [ new Point(1, intersectionY), new Point(1, 1), new Point(0, 1), new Point(0, intersectionY) ]; } resampled.push(...mid); // Resample to the second section of the ring if (partB.length === 0) { resampled.push(partA[0]); } else { resampled.push(...partB); } return { polygon: resampled.map(p => new MercatorCoordinate(p.x, p.y)), unwrapped: false }; } function resamplePolygon(polygon: Point[], transform: Transform): Point[] { // Choose a tolerance value for the resampling logic that produces sufficiently // accurate polygons without creating too many points. The value 1 / 256 was chosen // based on empirical testing const tolerance = 1.0 / 256.0; return resample( polygon, p => { const mc = transform.pointCoordinate3D(p); p.x = mc.x; p.y = mc.y; }, tolerance); } function wrap(mercatorX: number): number { return mercatorX < 0 ? 1 + (mercatorX % 1) : mercatorX % 1; } function findEdgeCrossingAntimeridian(polygon: Point[], tr: Transform, direction: number): ?{idx: number, t: number} { for (let i = 1; i < polygon.length; i++) { const a = wrap(tr.pointCoordinate3D(polygon[i - 1]).x); const b = wrap(tr.pointCoordinate3D(polygon[i]).x); // direction < 0: mercator coordinate 0 will be crossed from left // direction > 0: mercator coordinate 1 will be crossed from right if (direction < 0) { if (a < b) { return {idx: i, t: -a / (b - 1 - a)}; } } else { if (b < a) { return {idx: i, t: (1 - a) / (b + 1 - a)}; } } } return null; } //Padding is in screen pixels and is only used as a coarse check, so 2 decimal places of precision should be good enough for a cache. function cacheKey(padding: number): number { return (padding * 100) | 0; } export type TilespaceQueryGeometry = { queryGeometry: QueryGeometry, tilespaceGeometry: Point[], tilespaceRays: Ray[], bufferedTilespaceGeometry: Point[], bufferedTilespaceBounds: { min: Point, max: Point}, tile: Tile, tileID: OverscaledTileID, pixelToTileUnitsFactor: number }; function clampBoundsToTileExtents(bounds: {min: Point, max: Point}): {min: Point, max: Point} { bounds.min.x = clamp(bounds.min.x, 0, EXTENT); bounds.min.y = clamp(bounds.min.y, 0, EXTENT); bounds.max.x = clamp(bounds.max.x, 0, EXTENT); bounds.max.y = clamp(bounds.max.y, 0, EXTENT); return bounds; }