UNPKG

@deck.gl/core

Version:

deck.gl core library

618 lines 25.4 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import PickLayersPass from "../passes/pick-layers-pass.js"; import { getClosestObject, getUniqueObjects } from "./picking/query-object.js"; import { processPickInfo, getLayerPickingInfo, getEmptyPickingInfo } from "./picking/pick-info.js"; /** Manages picking in a Deck context */ export default class DeckPicker { constructor(device) { this._pickable = true; this.device = device; this.pickLayersPass = new PickLayersPass(device); this.lastPickedInfo = { index: -1, layerId: null, info: null }; } setProps(props) { if ('layerFilter' in props) { this.layerFilter = props.layerFilter; } if ('_pickable' in props) { this._pickable = props._pickable; } } finalize() { if (this.pickingFBO) { this.pickingFBO.destroy(); } if (this.depthFBO) { this.depthFBO.destroy(); } } /** * Pick the closest info at given coordinate * @returns Promise that resolves with picking info */ pickObjectAsync(opts) { return this._pickClosestObjectAsync(opts); } /** * Picks a list of unique infos within a bounding box * @returns Promise that resolves to all unique infos within a bounding box */ pickObjectsAsync(opts) { return this._pickVisibleObjectsAsync(opts); } /** * Pick the closest info at given coordinate * @returns picking info * @deprecated WebGL only - use pickObjectAsync instead */ pickObject(opts) { return this._pickClosestObject(opts); } /** * Get all unique infos within a bounding box * @returns all unique infos within a bounding box * @deprecated WebGL only - use pickObjectAsync instead */ pickObjects(opts) { return this._pickVisibleObjects(opts); } // Returns a new picking info object by assuming the last picked object is still picked getLastPickedObject({ x, y, layers, viewports }, lastPickedInfo = this.lastPickedInfo.info) { const lastPickedLayerId = lastPickedInfo && lastPickedInfo.layer && lastPickedInfo.layer.id; const lastPickedViewportId = lastPickedInfo && lastPickedInfo.viewport && lastPickedInfo.viewport.id; const layer = lastPickedLayerId ? layers.find(l => l.id === lastPickedLayerId) : null; const viewport = (lastPickedViewportId && viewports.find(v => v.id === lastPickedViewportId)) || viewports[0]; const coordinate = viewport && viewport.unproject([x - viewport.x, y - viewport.y]); const info = { x, y, viewport, coordinate, layer }; return { ...lastPickedInfo, ...info }; } // Private /** Ensures that picking framebuffer exists and matches the canvas size */ _resizeBuffer() { // Create a frame buffer if not already available if (!this.pickingFBO) { this.pickingFBO = this.device.createFramebuffer({ colorAttachments: ['rgba8unorm'], depthStencilAttachment: 'depth16unorm' }); if (this.device.isTextureFormatRenderable('rgba32float')) { const depthFBO = this.device.createFramebuffer({ colorAttachments: ['rgba32float'], depthStencilAttachment: 'depth16unorm' }); this.depthFBO = depthFBO; } } // Resize it to current canvas size (this is a noop if size hasn't changed) const { canvas } = this.device.getDefaultCanvasContext(); this.pickingFBO?.resize({ width: canvas.width, height: canvas.height }); this.depthFBO?.resize({ width: canvas.width, height: canvas.height }); } /** Preliminary filtering of the layers list. Skid picking pass if no layer is pickable. */ _getPickable(layers) { if (this._pickable === false) { return null; } const pickableLayers = layers.filter(layer => this.pickLayersPass.shouldDrawLayer(layer) && !layer.isComposite); return pickableLayers.length ? pickableLayers : null; } /** * Pick the closest object at the given coordinate */ // eslint-disable-next-line max-statements,complexity async _pickClosestObjectAsync({ layers, views, viewports, x, y, radius = 0, depth = 1, mode = 'query', unproject3D, onViewportActive, effects }) { // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return { result: [], emptyInfo: getEmptyPickingInfo({ viewports, x, y, pixelRatio }) }; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) ]; const deviceRadius = Math.round(radius * pixelRatio); const { width, height } = this.pickingFBO; const deviceRect = this._getPickingRect({ deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceWidth: width, deviceHeight: height }); const cullRect = { x: x - radius, y: y - radius, width: radius * 2 + 1, height: radius * 2 + 1 }; let infos; const result = []; const affectedLayers = new Set(); for (let i = 0; i < depth; i++) { let pickInfo; if (deviceRect) { const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass: `picking:${mode}` }); pickInfo = getClosestObject({ ...pickedResult, deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceRect }); } else { pickInfo = { pickedColor: null, pickedObjectIndex: -1 }; } let z; if (pickInfo.pickedLayer && unproject3D && this.depthFBO) { const { pickedColors: pickedColors2 } = this._drawAndSample({ layers: [pickInfo.pickedLayer], views, viewports, onViewportActive, deviceRect: { x: pickInfo.pickedX, y: pickInfo.pickedY, width: 1, height: 1 }, cullRect, effects, pass: `picking:${mode}:z` }, true); // picked value is in common space (pixels) from the camera target (viewport.position) // convert it to meters from the ground if (pickedColors2[3]) { z = pickedColors2[0]; } } // Only exclude if we need to run picking again. // We need to run picking again if an object is detected AND // we have not exhausted the requested depth. if (pickInfo.pickedLayer && i + 1 < depth) { affectedLayers.add(pickInfo.pickedLayer); pickInfo.pickedLayer.disablePickingIndex(pickInfo.pickedObjectIndex); } // This logic needs to run even if no object is picked. infos = processPickInfo({ pickInfo, lastPickedInfo: this.lastPickedInfo, mode, layers: pickableLayers, viewports, x, y, z, pixelRatio }); for (const info of infos.values()) { if (info.layer) { result.push(info); } } // If no object is picked stop. if (!pickInfo.pickedColor) { break; } } // reset only affected buffers for (const layer of affectedLayers) { layer.restorePickingColors(); } return { result, emptyInfo: infos.get(null) }; } /** * Pick the closest object at the given coordinate * @deprecated WebGL only */ // eslint-disable-next-line max-statements,complexity _pickClosestObject({ layers, views, viewports, x, y, radius = 0, depth = 1, mode = 'query', unproject3D, onViewportActive, effects }) { // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return { result: [], emptyInfo: getEmptyPickingInfo({ viewports, x, y, pixelRatio }) }; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) ]; const deviceRadius = Math.round(radius * pixelRatio); const { width, height } = this.pickingFBO; const deviceRect = this._getPickingRect({ deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceWidth: width, deviceHeight: height }); const cullRect = { x: x - radius, y: y - radius, width: radius * 2 + 1, height: radius * 2 + 1 }; let infos; const result = []; const affectedLayers = new Set(); for (let i = 0; i < depth; i++) { let pickInfo; if (deviceRect) { const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass: `picking:${mode}` }); pickInfo = getClosestObject({ ...pickedResult, deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceRect }); } else { pickInfo = { pickedColor: null, pickedObjectIndex: -1 }; } let z; if (pickInfo.pickedLayer && unproject3D && this.depthFBO) { const { pickedColors: pickedColors2 } = this._drawAndSample({ layers: [pickInfo.pickedLayer], views, viewports, onViewportActive, deviceRect: { x: pickInfo.pickedX, y: pickInfo.pickedY, width: 1, height: 1 }, cullRect, effects, pass: `picking:${mode}:z` }, true); // picked value is in common space (pixels) from the camera target (viewport.position) // convert it to meters from the ground if (pickedColors2[3]) { z = pickedColors2[0]; } } // Only exclude if we need to run picking again. // We need to run picking again if an object is detected AND // we have not exhausted the requested depth. if (pickInfo.pickedLayer && i + 1 < depth) { affectedLayers.add(pickInfo.pickedLayer); pickInfo.pickedLayer.disablePickingIndex(pickInfo.pickedObjectIndex); } // This logic needs to run even if no object is picked. infos = processPickInfo({ pickInfo, lastPickedInfo: this.lastPickedInfo, mode, layers: pickableLayers, viewports, x, y, z, pixelRatio }); for (const info of infos.values()) { if (info.layer) { result.push(info); } } // If no object is picked stop. if (!pickInfo.pickedColor) { break; } } // reset only affected buffers for (const layer of affectedLayers) { layer.restorePickingColors(); } return { result, emptyInfo: infos.get(null) }; } /** * Pick all objects within the given bounding box */ // eslint-disable-next-line max-statements async _pickVisibleObjectsAsync({ layers, views, viewports, x, y, width = 1, height = 1, mode = 'query', maxObjects = null, onViewportActive, effects }) { const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return []; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); // @ts-expect-error TODO - assuming WebGL context const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location // @ts-expect-error TODO - assuming WebGL context const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; const deviceRect = { x: deviceLeft, y: deviceBottom, // deviceTop and deviceRight represent the first pixel outside the desired rect width: deviceRight - deviceLeft, height: deviceTop - deviceBottom }; const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect: { x, y, width, height }, effects, pass: `picking:${mode}` }); const pickInfos = getUniqueObjects(pickedResult); // `getUniqueObjects` dedup by picked color // However different picked color may be linked to the same picked object, e.g. stroke and fill of the same polygon // picked from different sub layers of a GeoJsonLayer // Here after resolving the picked index with `layer.getPickingInfo`, we need to dedup again by unique picked objects const uniquePickedObjects = new Map(); const uniqueInfos = []; const limitMaxObjects = Number.isFinite(maxObjects); for (let i = 0; i < pickInfos.length; i++) { if (limitMaxObjects && uniqueInfos.length >= maxObjects) { break; } const pickInfo = pickInfos[i]; let info = { color: pickInfo.pickedColor, layer: null, index: pickInfo.pickedObjectIndex, picked: true, x, y, pixelRatio }; info = getLayerPickingInfo({ layer: pickInfo.pickedLayer, info, mode }); // info.layer is always populated because it's a picked pixel const pickedLayerId = info.layer.id; if (!uniquePickedObjects.has(pickedLayerId)) { uniquePickedObjects.set(pickedLayerId, new Set()); } const uniqueObjectsInLayer = uniquePickedObjects.get(pickedLayerId); // info.object may be null if the layer is using non-iterable data. // Fall back to using index as identifier. const pickedObjectKey = info.object ?? info.index; if (!uniqueObjectsInLayer.has(pickedObjectKey)) { uniqueObjectsInLayer.add(pickedObjectKey); uniqueInfos.push(info); } } return uniqueInfos; } /** * Pick all objects within the given bounding box * @deprecated WebGL only */ // eslint-disable-next-line max-statements _pickVisibleObjects({ layers, views, viewports, x, y, width = 1, height = 1, mode = 'query', maxObjects = null, onViewportActive, effects }) { const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return []; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); // @ts-expect-error TODO - assuming WebGL context const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location // @ts-expect-error TODO - assuming WebGL context const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; const deviceRect = { x: deviceLeft, y: deviceBottom, // deviceTop and deviceRight represent the first pixel outside the desired rect width: deviceRight - deviceLeft, height: deviceTop - deviceBottom }; const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect: { x, y, width, height }, effects, pass: `picking:${mode}` }); const pickInfos = getUniqueObjects(pickedResult); // `getUniqueObjects` dedup by picked color // However different picked color may be linked to the same picked object, e.g. stroke and fill of the same polygon // picked from different sub layers of a GeoJsonLayer // Here after resolving the picked index with `layer.getPickingInfo`, we need to dedup again by unique picked objects const uniquePickedObjects = new Map(); const uniqueInfos = []; const limitMaxObjects = Number.isFinite(maxObjects); for (let i = 0; i < pickInfos.length; i++) { if (limitMaxObjects && uniqueInfos.length >= maxObjects) { break; } const pickInfo = pickInfos[i]; let info = { color: pickInfo.pickedColor, layer: null, index: pickInfo.pickedObjectIndex, picked: true, x, y, pixelRatio }; info = getLayerPickingInfo({ layer: pickInfo.pickedLayer, info, mode }); // info.layer is always populated because it's a picked pixel const pickedLayerId = info.layer.id; if (!uniquePickedObjects.has(pickedLayerId)) { uniquePickedObjects.set(pickedLayerId, new Set()); } const uniqueObjectsInLayer = uniquePickedObjects.get(pickedLayerId); // info.object may be null if the layer is using non-iterable data. // Fall back to using index as identifier. const pickedObjectKey = info.object ?? info.index; if (!uniqueObjectsInLayer.has(pickedObjectKey)) { uniqueObjectsInLayer.add(pickedObjectKey); uniqueInfos.push(info); } } return uniqueInfos; } // Note: Implementation of the overloaded signatures above, TSDoc is on the signatures async _drawAndSampleAsync({ layers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass }, pickZ = false) { const pickingFBO = pickZ ? this.depthFBO : this.pickingFBO; const opts = { layers, layerFilter: this.layerFilter, views, viewports, onViewportActive, pickingFBO, deviceRect, cullRect, effects, pass, pickZ, preRenderStats: {}, isPicking: true }; for (const effect of effects) { if (effect.useInPicking) { opts.preRenderStats[effect.id] = effect.preRender(opts); } } const { decodePickingColor } = this.pickLayersPass.render(opts); // Read from an already rendered picking buffer // Returns an Uint8ClampedArray of picked pixels const { x, y, width, height } = deviceRect; const pickedColors = new (pickZ ? Float32Array : Uint8Array)(width * height * 4); this.device.readPixelsToArrayWebGL(pickingFBO, { sourceX: x, sourceY: y, sourceWidth: width, sourceHeight: height, target: pickedColors }); return { pickedColors, decodePickingColor }; } // Note: Implementation of the overloaded signatures above, TSDoc is on the signatures _drawAndSample({ layers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass }, pickZ = false) { const pickingFBO = pickZ ? this.depthFBO : this.pickingFBO; const opts = { layers, layerFilter: this.layerFilter, views, viewports, onViewportActive, pickingFBO, deviceRect, cullRect, effects, pass, pickZ, preRenderStats: {}, isPicking: true }; for (const effect of effects) { if (effect.useInPicking) { opts.preRenderStats[effect.id] = effect.preRender(opts); } } const { decodePickingColor } = this.pickLayersPass.render(opts); // Read from an already rendered picking buffer // Returns an Uint8ClampedArray of picked pixels const { x, y, width, height } = deviceRect; const pickedColors = new (pickZ ? Float32Array : Uint8Array)(width * height * 4); this.device.readPixelsToArrayWebGL(pickingFBO, { sourceX: x, sourceY: y, sourceWidth: width, sourceHeight: height, target: pickedColors }); return { pickedColors, decodePickingColor }; } /** * Calculate a picking rect centered on deviceX and deviceY and clipped to device * @returns null if pixel is outside of device */ _getPickingRect({ deviceX, deviceY, deviceRadius, deviceWidth, deviceHeight }) { // Create a box of size `radius * 2 + 1` centered at [deviceX, deviceY] const x = Math.max(0, deviceX - deviceRadius); const y = Math.max(0, deviceY - deviceRadius); const width = Math.min(deviceWidth, deviceX + deviceRadius + 1) - x; const height = Math.min(deviceHeight, deviceY + deviceRadius + 1) - y; // x, y out of bounds. if (width <= 0 || height <= 0) { return null; } return { x, y, width, height }; } } //# sourceMappingURL=deck-picker.js.map