diff --git a/debug/extrusion-query.html b/debug/extrusion-query.html
new file mode 100644
index 00000000000..13c682a4a58
--- /dev/null
+++ b/debug/extrusion-query.html
@@ -0,0 +1,85 @@
+
+
+
+ Mapbox GL JS debug page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 5623efb5e72..36f4d69cebf 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"geojson-rewind": "^0.3.0",
"geojson-vt": "^3.2.1",
"gl-matrix": "^2.6.1",
- "grid-index": "^1.0.0",
+ "grid-index": "^1.1.0",
"minimist": "0.0.8",
"murmurhash-js": "^1.0.0",
"pbf": "^3.0.5",
diff --git a/src/data/bucket/fill_extrusion_bucket.js b/src/data/bucket/fill_extrusion_bucket.js
index d4867838876..37c71761571 100644
--- a/src/data/bucket/fill_extrusion_bucket.js
+++ b/src/data/bucket/fill_extrusion_bucket.js
@@ -112,7 +112,7 @@ class FillExtrusionBucket implements Bucket {
this.addFeature(patternFeature, geometry, index, {});
}
- options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
+ options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index, true);
}
}
diff --git a/src/data/feature_index.js b/src/data/feature_index.js
index 0cf3f33895d..f6fc671fe0a 100644
--- a/src/data/feature_index.js
+++ b/src/data/feature_index.js
@@ -15,6 +15,7 @@ import { OverscaledTileID } from '../source/tile_id';
import { register } from '../util/web_worker_transfer';
import EvaluationParameters from '../style/evaluation_parameters';
import SourceFeatureState from '../source/source_state';
+import {polygonIntersectsBox} from '../util/intersection_tests';
import type StyleLayer from '../style/style_layer';
import type {FeatureFilter} from '../style-spec/feature_filter';
@@ -25,10 +26,11 @@ import { FeatureIndexArray } from './array_types';
type QueryParameters = {
scale: number,
- posMatrix: Float32Array,
+ pixelPosMatrix: Float32Array,
transform: Transform,
tileSize: number,
- queryGeometry: Array>,
+ queryGeometry: Array,
+ cameraQueryGeometry: Array,
queryPadding: number,
params: {
filter: FilterSpecification,
@@ -42,6 +44,7 @@ class FeatureIndex {
y: number;
z: number;
grid: Grid;
+ grid3D: Grid;
featureIndexArray: FeatureIndexArray;
rawTileData: ArrayBuffer;
@@ -58,13 +61,16 @@ class FeatureIndex {
this.y = tileID.canonical.y;
this.z = tileID.canonical.z;
this.grid = grid || new Grid(EXTENT, 16, 0);
+ this.grid3D = new Grid(EXTENT, 16, 0);
this.featureIndexArray = featureIndexArray || new FeatureIndexArray();
}
- insert(feature: VectorTileFeature, geometry: Array>, featureIndex: number, sourceLayerIndex: number, bucketIndex: number) {
+ insert(feature: VectorTileFeature, geometry: Array>, featureIndex: number, sourceLayerIndex: number, bucketIndex: number, is3D?: boolean) {
const key = this.featureIndexArray.length;
this.featureIndexArray.emplaceBack(featureIndex, sourceLayerIndex, bucketIndex);
+ const grid = is3D ? this.grid3D : this.grid;
+
for (let r = 0; r < geometry.length; r++) {
const ring = geometry[r];
@@ -81,7 +87,7 @@ class FeatureIndex {
bbox[1] < EXTENT &&
bbox[2] >= 0 &&
bbox[3] >= 0) {
- this.grid.insert(key, bbox[0], bbox[1], bbox[2], bbox[3]);
+ grid.insert(key, bbox[0], bbox[1], bbox[2], bbox[3]);
}
}
}
@@ -105,23 +111,22 @@ class FeatureIndex {
const queryGeometry = args.queryGeometry;
const queryPadding = args.queryPadding * pixelsToTileUnits;
- let minX = Infinity;
- let minY = Infinity;
- let maxX = -Infinity;
- let maxY = -Infinity;
- for (let i = 0; i < queryGeometry.length; i++) {
- const ring = queryGeometry[i];
- for (let k = 0; k < ring.length; k++) {
- const p = ring[k];
- minX = Math.min(minX, p.x);
- minY = Math.min(minY, p.y);
- maxX = Math.max(maxX, p.x);
- maxY = Math.max(maxY, p.y);
- }
+ const bounds = getBounds(queryGeometry);
+ const matching = this.grid.query(bounds.minX - queryPadding, bounds.minY - queryPadding, bounds.maxX + queryPadding, bounds.maxY + queryPadding);
+
+ const cameraBounds = getBounds(args.cameraQueryGeometry);
+ const matching3D = this.grid3D.query(
+ cameraBounds.minX - queryPadding, cameraBounds.minY - queryPadding, cameraBounds.maxX + queryPadding, cameraBounds.maxY + queryPadding,
+ (bx1, by1, bx2, by2) => {
+ return polygonIntersectsBox(args.cameraQueryGeometry, bx1 - queryPadding, by1 - queryPadding, bx2 + queryPadding, by2 + queryPadding);
+ });
+
+ for (const key of matching3D) {
+ matching.push(key);
}
- const matching = this.grid.query(minX - queryPadding, minY - queryPadding, maxX + queryPadding, maxY + queryPadding);
matching.sort(topDownFeatureComparator);
+
const result = {};
let previousIndex;
for (let k = 0; k < matching.length; k++) {
@@ -150,7 +155,7 @@ class FeatureIndex {
// `feature-state` expression evaluation requires feature state to be available
featureState = sourceFeatureState.getState(styleLayer.sourceLayer || '_geojsonTileLayer', feature.id);
}
- return styleLayer.queryIntersectsFeature(queryGeometry, feature, featureState, featureGeometry, this.z, args.transform, pixelsToTileUnits, args.posMatrix);
+ return styleLayer.queryIntersectsFeature(queryGeometry, feature, featureState, featureGeometry, this.z, args.transform, pixelsToTileUnits, args.pixelPosMatrix);
}
);
}
@@ -166,7 +171,7 @@ class FeatureIndex {
filter: FeatureFilter,
filterLayerIDs: Array,
styleLayers: {[string]: StyleLayer},
- intersectionTest?: (feature: VectorTileFeature, styleLayer: StyleLayer) => boolean) {
+ intersectionTest?: (feature: VectorTileFeature, styleLayer: StyleLayer) => boolean | number) {
const layerIDs = this.bucketLayerIDs[bucketIndex];
if (filterLayerIDs && !arraysIntersect(filterLayerIDs, layerIDs))
@@ -189,7 +194,8 @@ class FeatureIndex {
const styleLayer = styleLayers[layerID];
if (!styleLayer) continue;
- if (intersectionTest && !intersectionTest(feature, styleLayer)) {
+ const intersectionZ = !intersectionTest || intersectionTest(feature, styleLayer);
+ if (!intersectionZ) {
// Only applied for non-symbol features
continue;
}
@@ -200,7 +206,7 @@ class FeatureIndex {
if (layerResult === undefined) {
layerResult = result[layerID] = [];
}
- layerResult.push({ featureIndex, feature: geojsonFeature });
+ layerResult.push({ featureIndex, feature: geojsonFeature, intersectionZ });
}
}
@@ -251,6 +257,20 @@ register(
export default FeatureIndex;
+function getBounds(geometry: Array) {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const p of geometry) {
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ }
+ return { minX, minY, maxX, maxY };
+}
+
function topDownFeatureComparator(a, b) {
return b - a;
}
diff --git a/src/geo/transform.js b/src/geo/transform.js
index 3bc60a863ff..bccffa27774 100644
--- a/src/geo/transform.js
+++ b/src/geo/transform.js
@@ -592,6 +592,59 @@ class Transform {
const topPoint = vec4.transformMat4(p, p, this.pixelMatrix);
return topPoint[3] / this.cameraToCenterDistance;
}
+
+ /*
+ * The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation`
+ * as the name for the location under the camera and on the surface of the earth (lng, lat, 0).
+ * `cameraPoint` is the projected position of the `cameraLocation`.
+ *
+ * This point is useful to us because only fill-extrusions that are between `cameraPoint` and
+ * the query point on the surface of the earth can extend and intersect the query.
+ *
+ * When the map is not pitched the `cameraPoint` is equivalent to the center of the map because
+ * the camera is right above the center of the map.
+ */
+ getCameraPoint() {
+ const pitch = this._pitch;
+ const yOffset = Math.tan(pitch) * (this.cameraToCenterDistance || 1);
+ return this.centerPoint.add(new Point(0, yOffset));
+ }
+
+ /*
+ * 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 includes all of the original query as well as all possible ares of the
+ * screen where the *base* of a visible extrusion could be.
+ * - For point queries, the line from the query point to the "camera point"
+ * - For other geometries, the envelope of the query geometry and the "camera point"
+ */
+ getCameraQueryGeometry(queryGeometry: Array): Array {
+ const c = this.getCameraPoint();
+
+ if (queryGeometry.length === 1) {
+ return [queryGeometry[0], c];
+ } else {
+ let minX = c.x;
+ let minY = c.y;
+ let maxX = c.x;
+ let maxY = c.y;
+ for (const p of queryGeometry) {
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ }
+ return [
+ new Point(minX, minY),
+ new Point(maxX, minY),
+ new Point(maxX, maxY),
+ new Point(minX, maxY),
+ new Point(minX, minY)
+ ];
+ }
+ }
}
export default Transform;
diff --git a/src/source/query_features.js b/src/source/query_features.js
index ffa9eb18475..fa0cb9891d8 100644
--- a/src/source/query_features.js
+++ b/src/source/query_features.js
@@ -2,20 +2,52 @@
import type SourceCache from './source_cache';
import type StyleLayer from '../style/style_layer';
-import type MercatorCoordinate from '../geo/mercator_coordinate';
import type CollisionIndex from '../symbol/collision_index';
import type Transform from '../geo/transform';
import type { RetainedQueryData } from '../symbol/placement';
import type {FilterSpecification} from '../style-spec/types';
import assert from 'assert';
+import { mat4 } from 'gl-matrix';
+
+/*
+ * Returns a matrix that can be used to convert from tile coordinates to viewport pixel coordinates.
+ */
+function getPixelPosMatrix(transform, tileID) {
+ const t = mat4.identity([]);
+ mat4.translate(t, t, [1, 1, 0]);
+ mat4.scale(t, t, [transform.width * 0.5, transform.height * 0.5, 1]);
+ return mat4.multiply(t, t, transform.calculatePosMatrix(tileID.toUnwrapped()));
+}
+
+function queryIncludes3DLayer(layers?: Array, styleLayers: {[string]: StyleLayer}, sourceID: string) {
+ if (layers) {
+ for (const layerID of layers) {
+ const layer = styleLayers[layerID];
+ if (layer && layer.source === sourceID && layer.type === 'fill-extrusion') {
+ return true;
+ }
+ }
+ } else {
+ for (const key in styleLayers) {
+ const layer = styleLayers[key];
+ if (layer.source === sourceID && layer.type === 'fill-extrusion') {
+ return true;
+ }
+ }
+ }
+ return false;
+}
export function queryRenderedFeatures(sourceCache: SourceCache,
styleLayers: {[string]: StyleLayer},
- queryGeometry: Array,
+ queryGeometry: Array,
params: { filter: FilterSpecification, layers: Array },
transform: Transform) {
+
+ const has3DLayer = queryIncludes3DLayer(params && params.layers, styleLayers, sourceCache.id);
+
const maxPitchScaleFactor = transform.maxPitchScaleFactor();
- const tilesIn = sourceCache.tilesIn(queryGeometry, maxPitchScaleFactor);
+ const tilesIn = sourceCache.tilesIn(queryGeometry, maxPitchScaleFactor, has3DLayer);
tilesIn.sort(sortTilesIn);
@@ -27,11 +59,12 @@ export function queryRenderedFeatures(sourceCache: SourceCache,
styleLayers,
sourceCache._state,
tileIn.queryGeometry,
+ tileIn.cameraQueryGeometry,
tileIn.scale,
params,
transform,
maxPitchScaleFactor,
- sourceCache.transform.calculatePosMatrix(tileIn.tileID.toUnwrapped()))
+ getPixelPosMatrix(sourceCache.transform, tileIn.tileID))
});
}
@@ -39,7 +72,8 @@ export function queryRenderedFeatures(sourceCache: SourceCache,
// Merge state from SourceCache into the results
for (const layerID in result) {
- result[layerID].forEach((feature) => {
+ result[layerID].forEach((featureWrapper) => {
+ const feature = featureWrapper.feature;
const state = sourceCache.getFeatureState(feature.layer['source-layer'], feature.id);
feature.source = feature.layer.source;
if (feature.layer['source-layer']) {
@@ -98,14 +132,15 @@ export function queryRenderedSymbols(styleLayers: {[string]: StyleLayer},
}
});
for (const symbolFeature of layerSymbols) {
- resultFeatures.push(symbolFeature.feature);
+ resultFeatures.push(symbolFeature);
}
}
}
// Merge state from SourceCache into the results
for (const layerName in result) {
- result[layerName].forEach((feature) => {
+ result[layerName].forEach((featureWrapper) => {
+ const feature = featureWrapper.feature;
const layer = styleLayers[layerName];
const sourceCache = sourceCaches[layer.source];
const state = sourceCache.getFeatureState(feature.layer['source-layer'], feature.id);
@@ -161,7 +196,7 @@ function mergeRenderedFeatureLayers(tiles) {
for (const tileFeature of tileFeatures) {
if (!wrappedIDFeatures[tileFeature.featureIndex]) {
wrappedIDFeatures[tileFeature.featureIndex] = true;
- resultFeatures.push(tileFeature.feature);
+ resultFeatures.push(tileFeature);
}
}
}
diff --git a/src/source/source_cache.js b/src/source/source_cache.js
index 63c0ac9c2b7..0255f204781 100644
--- a/src/source/source_cache.js
+++ b/src/source/source_cache.js
@@ -731,11 +731,23 @@ class SourceCache extends Evented {
/**
* Search through our current tiles and attempt to find the tiles that
* cover the given bounds.
- * @param queryGeometry coordinates of the corners of bounding rectangle
+ * @param pointQueryGeometry coordinates of the corners of bounding rectangle
* @returns {Array