itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,100 lines (1,055 loc) • 44.3 kB
JavaScript
import * as THREE from 'three';
import { CRS, Coordinates } from '@itowns/geographic';
import Camera from "../Renderer/Camera.js";
import MainLoop, { MAIN_LOOP_EVENTS, RENDERING_PAUSED } from "./MainLoop.js";
import Capabilities from "./System/Capabilities.js";
import { COLOR_LAYERS_ORDER_CHANGED } from "../Renderer/ColorLayersOrdering.js";
import c3DEngine from "../Renderer/c3DEngine.js";
import RenderMode from "../Renderer/RenderMode.js";
import FeaturesUtils from "../Utils/FeaturesUtils.js";
import Scheduler from "./Scheduler/Scheduler.js";
import Picking from "./Picking.js";
import LabelLayer from "../Layer/LabelLayer.js";
import ObjectRemovalHelper from "../Process/ObjectRemovalHelper.js";
export const VIEW_EVENTS = {
/**
* Fires when all the layers of the view are considered initialized.
* Initialized in this context means: all layers are ready to be
* displayed (no pending network access, no visual improvement to be
* expected, ...).
* If you add new layers, the event will be fired again when all
* layers are ready.
* @event View#layers-initialized
* @property type {string} layers-initialized
*/
LAYERS_INITIALIZED: 'layers-initialized',
LAYER_REMOVED: 'layer-removed',
LAYER_ADDED: 'layer-added',
INITIALIZED: 'initialized',
COLOR_LAYERS_ORDER_CHANGED,
CAMERA_MOVED: 'camera-moved',
DISPOSED: 'disposed'
};
/**
* Fired on current view's domElement when double right-clicking it. Copies all properties of the second right-click
* MouseEvent (such as cursor position).
* @event View#dblclick-right
* @property {string} type dblclick-right
*/
function _preprocessLayer(view, layer, parentLayer) {
const source = layer.source;
if (parentLayer && !layer.extent) {
layer.extent = parentLayer.extent;
if (source && !source.extent) {
source.extent = parentLayer.extent;
}
}
if (layer.isGeometryLayer && !layer.isLabelLayer) {
// Find crs projection layer, this is projection destination
layer.crs = view.referenceCrs;
} else if (!layer.crs) {
if (parentLayer && parentLayer.tileMatrixSets && parentLayer.tileMatrixSets.includes(source.crs)) {
layer.crs = source.crs;
} else {
layer.crs = parentLayer && parentLayer.extent.crs;
}
}
if (layer.isLabelLayer) {
view.mainLoop.gfxEngine.label2dRenderer.registerLayer(layer);
} else if (layer.labelEnabled || layer.addLabelLayer) {
if (layer.labelEnabled) {
// eslint-disable-next-line no-console
console.info('layer.labelEnabled is deprecated use addLabelLayer, instead of');
}
// Because the features are shared between layer and labelLayer.
layer.buildExtent = true;
// label layer needs 3d data structure.
layer.structure = '3d';
const labelLayer = new LabelLayer(`${layer.id}-label`, {
source,
style: layer.style,
zoom: layer.zoom,
performance: layer.addLabelLayer.performance,
crs: source.crs,
visible: layer.visible,
margin: 15,
forceClampToTerrain: layer.addLabelLayer.forceClampToTerrain
});
layer.addEventListener('visible-property-changed', () => {
labelLayer.visible = layer.visible;
});
const removeLabelLayer = e => {
if (e.layerId === layer.id) {
view.removeLayer(labelLayer.id);
}
view.removeEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer);
};
view.addEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer);
layer.whenReady = layer.whenReady.then(() => {
view.addLayer(labelLayer);
return layer;
});
}
if (layer.isOGC3DTilesLayer) {
layer._setup(view);
}
return layer;
}
const _eventCoords = new THREE.Vector2();
const matrix = new THREE.Matrix4();
const screen = new THREE.Vector2();
const ray = new THREE.Ray();
const direction = new THREE.Vector3();
const positionVector = new THREE.Vector3();
const coordinates = new Coordinates('EPSG:4326');
const viewers = [];
// Size of the camera frustrum, in meters
let screenMeters;
let id = 0;
/**
* @property {number} id - The id of the view. It's incremented at each new view instance, starting at 0.
* @property {HTMLElement} domElement - The domElement holding the canvas where the view is displayed
* @property {String} referenceCrs - The coordinate reference system of the view
* @property {MainLoop} mainLoop - itowns mainloop scheduling the operations
* @property {THREE.Scene} scene - threejs scene of the view
* @property {Camera} camera - itowns camera (that holds a threejs camera that is directly accessible with View.camera3D)
* @property {THREE.Camera} camera3D - threejs camera that is stored in itowns camera
* @property {THREE.WebGLRenderer} renderer - threejs webglrenderer rendering this view
*/
class View extends THREE.EventDispatcher {
#layers = [];
#pixelDepthBuffer = (() => new Uint8Array(4))();
#fullSizeDepthBuffer;
/**
* Constructs an Itowns View instance
*
* @example <caption><b>Create a view with a custom Three.js camera.</b></caption>
* var viewerDiv = document.getElementById('viewerDiv');
* var customCamera = itowns.THREE.PerspectiveCamera();
* var view = itowns.View('EPSG:4326', viewerDiv, { camera: { cameraThree: customCamera } });
*
* @example <caption><b>Create a view with an orthographic camera, and grant it with Three.js custom controls.</b></caption>
* var viewerDiv = document.getElementById('viewerDiv');
* var view = itowns.View('EPSG:4326', viewerDiv, { camera: { type: itowns.CAMERA_TYPE.ORTHOGRAPHIC } });
* var customControls = itowns.THREE.OrbitControls(view.camera3D, viewerDiv);
*
* @param {String} crs - The default CRS of Three.js coordinates. Should be a cartesian CRS.
* @param {HTMLElement} viewerDiv - Where to instanciate the Three.js scene in the DOM
* @param {Object} [options] - Optional properties.
* @param {Object} [options.camera] - Options for the camera associated to the view. See {@link Camera} options.
* @param {MainLoop} [options.mainLoop] - {@link MainLoop} instance to use, otherwise a default one will be constructed
* @param {WebGLRenderer|Object} [options.renderer] - {@link WebGLRenderer} instance to use, otherwise
* a default one will be constructed. In this case, if options.renderer is an object, it will be used to
* configure the renderer (see {@link c3DEngine}. If not present, a new <canvas> will be created and
* added to viewerDiv (mutually exclusive with mainLoop)
* @param {Scene} [options.scene3D] - [THREE.Scene](https://threejs.org/docs/#api/en/scenes/Scene) instance to use, otherwise a default one will be constructed
* @param {Color} [options.diffuse] - [THREE.Color](https://threejs.org/docs/?q=color#api/en/math/Color) Diffuse color terrain material.
* This color is applied to terrain if there isn't color layer on terrain extent (by example on pole).
* @param {boolean} [options.enableFocusOnStart=true] - enable focus on dom element on start.
*/
constructor(crs, viewerDiv) {
let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
if (!viewerDiv) {
throw new Error('Invalid viewerDiv parameter (must non be null/undefined)');
}
super();
this.domElement = viewerDiv;
this.id = id++;
this.referenceCrs = crs;
let engine;
// options.renderer can be 2 separate things:
// - an actual renderer (in this case we don't use viewerDiv)
// - options for the renderer to be created
if (options.renderer && options.renderer.domElement) {
engine = new c3DEngine(options.renderer);
} else {
engine = new c3DEngine(viewerDiv, options.renderer);
}
this.mainLoop = options.mainLoop || new MainLoop(new Scheduler(), engine);
this.scene = options.scene3D || new THREE.Scene();
if (!options.scene3D) {
this.scene.matrixWorldAutoUpdate = false;
}
this.camera = new Camera(this.referenceCrs, this.mainLoop.gfxEngine.getWindowSize().x, this.mainLoop.gfxEngine.getWindowSize().y, options.camera);
this._frameRequesters = {};
this._resizeListener = () => this.resize();
window.addEventListener('resize', this._resizeListener, false);
this._changeSources = new Set();
this._delayedFrameRequesterRemoval = [];
this._allLayersAreReadyCallback = () => {
// all layers must be ready
const allReady = this.getLayers().every(layer => layer.ready);
if (allReady && this.mainLoop.scheduler.commandsWaitingExecutionCount() == 0 && this.mainLoop.renderingState == RENDERING_PAUSED) {
this.dispatchEvent({
type: VIEW_EVENTS.LAYERS_INITIALIZED
});
this.removeFrameRequester(MAIN_LOOP_EVENTS.UPDATE_END, this._allLayersAreReadyCallback);
}
};
this.camera.resize(this.domElement.clientWidth, this.domElement.clientHeight);
const fn = () => {
this.removeEventListener(VIEW_EVENTS.LAYERS_INITIALIZED, fn);
this.dispatchEvent({
type: VIEW_EVENTS.INITIALIZED
});
};
this.addEventListener(VIEW_EVENTS.LAYERS_INITIALIZED, fn);
this.#fullSizeDepthBuffer = new Uint8Array(4 * this.camera.width * this.camera.height);
// Indicates that view's domElement can be focused (the negative value indicates that domElement can't be
// focused sequentially using tab key). Focus is needed to capture some key events.
this.domElement.tabIndex = -1;
// Set focus on view's domElement.
if (!options.disableFocusOnStart) {
this.domElement.focus();
}
// Create a custom `dblclick-right` event that is triggered when double right-clicking
let rightClickTimeStamp;
this.domElement.addEventListener('mouseup', event => {
if (event.button === 2) {
// If pressed mouse button is right button
// If time between two right-clicks is bellow 500 ms, triggers a `dblclick-right` event
if (rightClickTimeStamp && event.timeStamp - rightClickTimeStamp < 500) {
this.domElement.dispatchEvent(new MouseEvent('dblclick-right', event));
}
rightClickTimeStamp = event.timeStamp;
}
});
// push all viewer to keep source.cache
viewers.push(this);
}
/**
* Get the Threejs renderer used to render this view.
* @returns {THREE.WebGLRenderer} the WebGLRenderer used to render this view.
*/
get renderer() {
return this.mainLoop?.gfxEngine?.getRenderer();
}
/**
* Get the threejs Camera of this view
* @returns {THREE.Camera} the threejs camera of this view
*/
get camera3D() {
return this.camera?.camera3D;
}
/**
* Dispose viewer before delete it.
*
* Method dispose all viewer objects
* - remove control
* - remove all layers
* - remove all frame requester
* - remove all events
* @param {boolean} [clearCache=false] Whether to clear all the caches or not (layers cache, style cache, tilesCache)
*/
dispose() {
let clearCache = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
const id = viewers.indexOf(this);
if (id == -1) {
console.warn('View already disposed');
return;
}
window.removeEventListener('resize', this._resizeListener);
// controls dispose
if (this.controls) {
if (typeof this.controls.dispose === 'function') {
this.controls.dispose();
}
delete this.controls;
}
// remove alls frameRequester
this.removeAllFrameRequesters();
// remove all layers
const layers = this.getLayers(l => !l.isTiledGeometryLayer && !l.isAtmosphere);
for (const layer of layers) {
this.removeLayer(layer.id, clearCache);
}
const atmospheres = this.getLayers(l => l.isAtmosphere);
for (const atmosphere of atmospheres) {
this.removeLayer(atmosphere.id, clearCache);
}
const tileLayers = this.getLayers(l => l.isTiledGeometryLayer);
for (const tileLayer of tileLayers) {
this.removeLayer(tileLayer.id, clearCache);
}
viewers.splice(id, 1);
// Remove remaining objects in the scene (e.g. helpers, debug, etc.)
this.scene.traverse(ObjectRemovalHelper.cleanup);
this.dispatchEvent({
type: VIEW_EVENTS.DISPOSED
});
// remove alls events
this.removeAllEvents();
}
/**
* Add layer in viewer.
* The layer id must be unique.
*
* The `layer.whenReady` is a promise that resolves when
* the layer is done. This promise is also returned by
* `addLayer` allowing to chain call.
*
* @param {LayerOptions|Layer|GeometryLayer} layer The layer to add in view.
* @param {Layer=} parentLayer it's the layer to which the layer will be attached.
* @return {Promise} a promise resolved with the new layer object when it is fully initialized or rejected if any error occurred.
*/
addLayer(layer, parentLayer) {
if (!layer || !layer.isLayer) {
return Promise.reject(new Error('Add Layer type object'));
}
const duplicate = this.getLayerById(layer.id);
if (duplicate) {
return layer._reject(new Error(`Invalid id '${layer.id}': id already used`));
}
layer = _preprocessLayer(this, layer, parentLayer);
if (parentLayer) {
if (layer.isColorLayer) {
const layerColors = this.getLayers(l => l.isColorLayer);
layer.sequence = layerColors.length;
}
parentLayer.attach(layer);
} else {
if (typeof layer.update !== 'function') {
return layer._reject(new Error('Cant add GeometryLayer: missing a update function'));
}
if (typeof layer.preUpdate !== 'function') {
return layer._reject(new Error('Cant add GeometryLayer: missing a preUpdate function'));
}
this.#layers.push(layer);
}
if (layer.object3d && !layer.object3d.parent && layer.object3d !== this.scene) {
this.scene.add(layer.object3d);
}
layer.startup().then(() => {
this.notifyChange(parentLayer || layer, false);
if (!this._frameRequesters[MAIN_LOOP_EVENTS.UPDATE_END] || !this._frameRequesters[MAIN_LOOP_EVENTS.UPDATE_END].includes(this._allLayersAreReadyCallback)) {
this.addFrameRequester(MAIN_LOOP_EVENTS.UPDATE_END, this._allLayersAreReadyCallback);
}
this.dispatchEvent({
type: VIEW_EVENTS.LAYER_ADDED,
layerId: layer.id
});
});
return layer.whenReady;
}
/**
* Removes a specific imagery layer from the current layer list. This removes layers inserted with attach().
* @example
* view.removeLayer('layerId');
* @param {string} layerId The identifier
* @param {boolean} [clearCache=false] Whether to clear all the layer cache or not
* @return {boolean}
*/
removeLayer(layerId, clearCache) {
const layer = this.getLayerById(layerId);
if (layer) {
const parentLayer = layer.parent;
// Remove and dispose all nodes
layer.delete(clearCache);
// Detach layer if it's attached
if (parentLayer && !parentLayer.detach(layer)) {
throw new Error(`Error to detach ${layerId} from ${parentLayer.id}`);
} else if (parentLayer == undefined) {
// Remove layer from viewer
this.#layers.splice(this.#layers.findIndex(l => l.id == layerId), 1);
}
if (layer.isColorLayer) {
// Update color layers sequence
const imageryLayers = this.getLayers(l => l.isColorLayer);
for (const color of imageryLayers) {
if (color.sequence > layer.sequence) {
color.sequence--;
}
}
}
// Remove unused cache in all viewers
// count of times the source is used in all viewer
let sharedSourceCount = 0;
for (const view of viewers) {
// add count of times the source is used in other layers
sharedSourceCount += view.getLayers(l => l.source.uid == layer.source.uid && l.crs == layer.crs).length;
}
// if sharedSourceCount equals to 0 so remove unused cache for this CRS
layer.source.onLayerRemoved({
unusedCrs: sharedSourceCount == 0 ? layer.crs : undefined
});
this.notifyChange(this.camera);
this.dispatchEvent({
type: VIEW_EVENTS.LAYER_REMOVED,
layerId
});
return true;
} else {
throw new Error(`${layerId} doesn't exist`);
}
}
/**
* Notifies the scene it needs to be updated due to changes exterior to the
* scene itself (e.g. camera movement).
* non-interactive events (e.g: texture loaded)
* @param {*} changeSource
* @param {boolean} needsRedraw - indicates if notified change requires a full scene redraw.
*/
notifyChange() {
let changeSource = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined;
let needsRedraw = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
if (changeSource) {
this._changeSources.add(changeSource);
if (!this.mainLoop.gfxEngine.renderer.xr.isPresenting && (changeSource.isTileMesh || changeSource.isCamera)) {
this.#fullSizeDepthBuffer.needsUpdate = true;
}
}
this.mainLoop.scheduleViewUpdate(this, needsRedraw);
}
/**
* Get all layers, with an optionnal filter applied.
* The filter method will be called with 2 args:
* - 1st: current layer
* - 2nd: (optional) the geometry layer to which the current layer is attached
* @example
* // get all layers
* view.getLayers();
* // get all color layers
* view.getLayers(layer => layer.isColorLayer);
* // get all elevation layers
* view.getLayers(layer => layer.isElevationLayer);
* // get all geometry layers
* view.getLayers(layer => layer.isGeometryLayer);
* // get one layer with id
* view.getLayers(layer => layer.id === 'itt');
* @param {function(Layer):boolean} filter
* @returns {Array<Layer>}
*/
getLayers(filter) {
const result = [];
for (const layer of this.#layers) {
if (!filter || filter(layer)) {
result.push(layer);
}
if (layer.attachedLayers) {
for (const attached of layer.attachedLayers) {
if (!filter || filter(attached, layer)) {
result.push(attached);
}
}
}
}
return result;
}
/**
* Gets the layer by identifier.
*
* @param {String} layerId The layer identifier
* @return {Layer} The layer by identifier.
*/
getLayerById(layerId) {
return this.getLayers(l => l.id === layerId)[0];
}
/**
* @name FrameRequester
* @function
*
* @description
* Method that will be called each time the `MainLoop` updates. This function
* will be given as parameter the delta (in ms) between this update and the
* previous one, and whether or not we just started to render again. This update
* is considered as the "next" update if `view.notifyChange` was called during a
* precedent update. If `view.notifyChange` has been called by something else
* (other micro/macrotask, UI events etc...), then this update is considered as
* being the "first". It can also receive optional arguments, depending on the
* attach point of this function. Currently only `BEFORE_LAYER_UPDATE /
* AFTER_LAYER_UPDATE` attach points provide an additional argument: the layer
* being updated.
* <br><br>
*
* This means that if a `frameRequester` function wants to animate something, it
* should keep on calling `view.notifyChange` until its task is done.
* <br><br>
*
* Implementors of `frameRequester` should keep in mind that this function will
* be potentially called at each frame, thus care should be given about
* performance.
* <br><br>
*
* Typical frameRequesters are controls, module wanting to animate moves or UI
* elements etc... Basically anything that would want to call
* requestAnimationFrame.
*
* @param {number} dt
* @param {boolean} updateLoopRestarted
* @param {...*} args
*/
/**
* Add a frame requester to this view.
*
* FrameRequesters can activate the MainLoop update by calling view.notifyChange.
*
* @param {String} when - decide when the frameRequester should be called during
* the update cycle. Can be any of {@link MAIN_LOOP_EVENTS}.
* @param {FrameRequester} frameRequester - this function will be called at each
* MainLoop update with the time delta between last update, or 0 if the MainLoop
* has just been relaunched.
*/
addFrameRequester(when, frameRequester) {
if (typeof frameRequester !== 'function') {
throw new Error('frameRequester must be a function');
}
if (!this._frameRequesters[when]) {
this._frameRequesters[when] = [frameRequester];
} else {
this._frameRequesters[when].push(frameRequester);
}
}
/**
* Remove a frameRequester.
* The effective removal will happen either later; at worst it'll be at
* the beginning of the next frame.
*
* @param {String} when - attach point of this requester. Can be any of
* {@link MAIN_LOOP_EVENTS}.
* @param {FrameRequester} frameRequester
*/
removeFrameRequester(when, frameRequester) {
if (this._frameRequesters[when].includes(frameRequester)) {
this._delayedFrameRequesterRemoval.push({
when,
frameRequester
});
} else {
console.error('Invalid call to removeFrameRequester: frameRequester isn\'t registered');
}
}
/**
* Removes all frame requesters.
*/
removeAllFrameRequesters() {
for (const when in this._frameRequesters) {
if (Object.prototype.hasOwnProperty.call(this._frameRequesters, when)) {
const frameRequesters = this._frameRequesters[when];
for (const frameRequester of frameRequesters) {
this.removeFrameRequester(when, frameRequester);
}
}
}
this._executeFrameRequestersRemovals();
}
/**
* Removes all viewer events.
*/
removeAllEvents() {
if (this._listeners === undefined) {
return;
}
for (const type in this._listeners) {
if (Object.prototype.hasOwnProperty.call(this._listeners, type)) {
delete this._listeners[type];
}
}
this._listeners = undefined;
}
_executeFrameRequestersRemovals() {
for (const toDelete of this._delayedFrameRequesterRemoval) {
const index = this._frameRequesters[toDelete.when].indexOf(toDelete.frameRequester);
if (index >= 0) {
this._frameRequesters[toDelete.when].splice(index, 1);
} else {
console.warn('FrameReq has already been removed');
}
}
this._delayedFrameRequesterRemoval.length = 0;
}
/**
* Execute a frameRequester.
*
* @param {String} when - attach point of this (these) requester(s). Can be any
* of {@link MAIN_LOOP_EVENTS}.
* @param {Number} dt - delta between this update and the previous one
* @param {boolean} updateLoopRestarted
* @param {...*} args - optional arguments
*/
execFrameRequesters(when, dt, updateLoopRestarted) {
if (!this._frameRequesters[when]) {
return;
}
if (this._delayedFrameRequesterRemoval.length > 0) {
this._executeFrameRequestersRemovals();
}
for (var _len = arguments.length, args = new Array(_len > 3 ? _len - 3 : 0), _key = 3; _key < _len; _key++) {
args[_key - 3] = arguments[_key];
}
for (const frameRequester of this._frameRequesters[when]) {
if (frameRequester.update) {
frameRequester.update(dt, updateLoopRestarted, args);
} else {
frameRequester(dt, updateLoopRestarted, args);
}
}
}
/**
* Extract view coordinates from a mouse-event / touch-event
* @param {event} event - event can be a MouseEvent or a TouchEvent
* @param {THREE.Vector2} target - the target to set the view coords in
* @param {number} [touchIdx=0] - finger index when using a TouchEvent
* @return {THREE.Vector2|undefined} - view coordinates (in pixels, 0-0 = top-left of the View).
* If the event is neither a `MouseEvent` nor a `TouchEvent`, the return is `undefined`.
*/
eventToViewCoords(event) {
let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : _eventCoords;
let touchIdx = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0;
const br = this.domElement.getBoundingClientRect();
if (event.touches && event.touches.length) {
return target.set(event.touches[touchIdx].clientX - br.x, event.touches[touchIdx].clientY - br.y);
} else if (event.offsetX !== undefined && event.offsetY !== undefined) {
const targetBoundingRect = event.target.getBoundingClientRect();
return target.set(targetBoundingRect.x + event.offsetX - br.x, targetBoundingRect.y + event.offsetY - br.y);
}
}
/**
* Extract normalized coordinates (NDC) from a mouse-event / touch-event
* @param {event} event - event can be a MouseEvent or a TouchEvent
* @param {number} touchIdx - finger index when using a TouchEvent (default: 0)
* @return {THREE.Vector2} - NDC coordinates (x and y are [-1, 1])
*/
eventToNormalizedCoords(event) {
let touchIdx = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
return this.viewToNormalizedCoords(this.eventToViewCoords(event, _eventCoords, touchIdx));
}
/**
* Convert view coordinates to normalized coordinates (NDC)
* @param {THREE.Vector2} viewCoords (in pixels, 0-0 = top-left of the View)
* @param {THREE.Vector2} target
* @return {THREE.Vector2} - NDC coordinates (x and y are [-1, 1])
*/
viewToNormalizedCoords(viewCoords) {
let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : _eventCoords;
target.x = 2 * (viewCoords.x / this.camera.width) - 1;
target.y = -2 * (viewCoords.y / this.camera.height) + 1;
return target;
}
/**
* Convert NDC coordinates to view coordinates
* @param {THREE.Vector2} ndcCoords
* @return {THREE.Vector2} - view coordinates (in pixels, 0-0 = top-left of the View)
*/
normalizedToViewCoords(ndcCoords) {
_eventCoords.x = (ndcCoords.x + 1) * 0.5 * this.camera.width;
_eventCoords.y = (ndcCoords.y - 1) * -0.5 * this.camera.height;
return _eventCoords;
}
/**
* Searches for objects in {@link GeometryLayer} and specified
* `THREE.Object3D`, under the mouse or at a specified coordinates, in this
* view.
*
* @param {Object} mouseOrEvt - Mouse position in window coordinates (from
* the top left corner of the window) or `MouseEvent` or `TouchEvent`.
* @param {number} [radius=0] - The picking will happen in a circle centered
* on mouseOrEvt. This is the radius of this circle, in pixels.
* @param {GeometryLayer|string|Object3D|Array<GeometryLayer|string|Object3D>} [where] - Where to look for
* objects. It can be a single {@link GeometryLayer}, `THREE.Object3D`, ID of a layer or an array of one of these or
* of a mix of these. If no location is specified, it will query on all {@link GeometryLayer} present in this `View`.
*
* @return {Object[]} - An array of objects. Each element contains at least
* an object property which is the `THREE.Object3D` under the cursor. Then
* depending on the queried layer/source, there may be additionnal
* properties (coming from `THREE.Raycaster` for instance).
*
* @example
* view.pickObjectsAt({ x, y })
* view.pickObjectsAt({ x, y }, 1, 'wfsBuilding')
* view.pickObjectsAt({ x, y }, 3, 'wfsBuilding', myLayer)
*/
pickObjectsAt(mouseOrEvt) {
let radius = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
let where = arguments.length > 2 ? arguments[2] : undefined;
const sources = [];
if (!where || where.length === 0) {
where = this.getLayers(l => l.isGeometryLayer);
}
if (!Array.isArray(where)) {
where = [where];
}
where.forEach(l => {
if (typeof l === 'string') {
l = this.getLayerById(l);
}
if (l && (l.isGeometryLayer || l.isObject3D)) {
sources.push(l);
}
});
if (sources.length == 0) {
return [];
}
const results = [];
const mouse = mouseOrEvt instanceof Event ? this.eventToViewCoords(mouseOrEvt) : mouseOrEvt;
for (const source of sources) {
if (source.isAtmosphere) {
continue;
}
if (source.isGeometryLayer) {
if (!source.ready) {
console.warn('view.pickObjectAt : layer is not ready : ', source);
continue;
}
source.pickObjectsAt(this, mouse, radius, results);
} else {
Picking.pickObjectsAt(this, mouse, radius, source, results);
}
}
return results;
}
/**
* Return the current zoom scale at the central point of the view. This
* function compute the scale of a map.
*
* @param {number} pitch - Screen pitch, in millimeters ; 0.28 by default
*
* @return {number} The zoom scale.
*/
getScale() {
let pitch = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0.28;
if (this.camera3D.isOrthographicCamera) {
return pitch * 1E-3 / this.getPixelsToMeters();
}
return this.getScaleFromDistance(pitch, this.getDistanceFromCamera());
}
getScaleFromDistance() {
let pitch = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0.28;
let distance = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
pitch /= 1000;
const fov = THREE.MathUtils.degToRad(this.camera3D.fov);
const unit = this.camera.height / (2 * distance * Math.tan(fov * 0.5));
return pitch * unit;
}
/**
* Given a screen coordinates, get the distance between the projected
* coordinates and the camera associated to this view.
*
* @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
* distance at. By default this is the middle of the screen.
*
* @return {number} The distance in meters.
*/
getDistanceFromCamera(screenCoord) {
this.getPickingPositionFromDepth(screenCoord, positionVector);
return this.camera3D.position.distanceTo(positionVector);
}
/**
* Get, for a specific screen coordinate, the projected distance on the
* surface of the main layer of the view.
*
* @param {number} [pixels=1] - The size, in pixels, to get in meters.
* @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
* projected distance at. By default, this is the middle of the screen.
*
* @return {number} The projected distance in meters.
*/
getPixelsToMeters() {
let pixels = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
let screenCoord = arguments.length > 1 ? arguments[1] : undefined;
if (this.camera3D.isOrthographicCamera) {
screenMeters = (this.camera3D.right - this.camera3D.left) / this.camera3D.zoom;
return pixels * screenMeters / this.camera.width;
}
return this.getPixelsToMetersFromDistance(pixels, this.getDistanceFromCamera(screenCoord));
}
getPixelsToMetersFromDistance() {
let pixels = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
let distance = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
return pixels * distance / this.camera._preSSE;
}
/**
* Get, for a specific screen coordinate, the size in pixels of a projected
* distance on the surface of the main layer of the view.
*
* @param {number} [meters=1] - The size, in meters, to get in pixels.
* @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
* projected distance at. By default, this is the middle of the screen.
*
* @return {number} The projected distance in pixels.
*/
getMetersToPixels() {
let meters = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
let screenCoord = arguments.length > 1 ? arguments[1] : undefined;
if (this.camera3D.isOrthographicCamera) {
screenMeters = (this.camera3D.right - this.camera3D.left) / this.camera3D.zoom;
return meters * this.camera.width / screenMeters;
}
return this.getMetersToPixelsFromDistance(meters, this.getDistanceFromCamera(screenCoord));
}
getMetersToPixelsFromDistance() {
let meters = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
let distance = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
return this.camera._preSSE * meters / distance;
}
/**
* Searches for {@link FeatureGeometry} in {@link ColorLayer}, under the mouse or at
* the specified coordinates, in this view. Combining them per layer and in a Feature
* like format.
*
* @param {Object} mouseOrEvt - Mouse position in window coordinates (from
* the top left corner of the window) or `MouseEvent` or `TouchEvent`.
* @param {number} [radius=3] - The picking will happen in a circle centered
* on mouseOrEvt. This is the radius of this circle, in pixels.
* @param {...ColorLayer|GeometryLayer|string} [where] - The layers to look
* into. If not specified, all {@link ColorLayer} and {@link GeometryLayer}
* layers of this view will be looked in.
*
* @return {Object} - An object, having one property per layer.
* For example, looking for features on layers `wfsBuilding` and `wfsRoads`
* will give an object like `{ wfsBuilding: [...], wfsRoads: [] }`.
* Each property is made of an array, that can be empty or filled with
* Feature like objects composed of:
* - the FeatureGeometry
* - the feature type
* - the style
* - the coordinate if the FeatureGeometry is a point
*
* @example
* view.pickFeaturesAt({ x, y });
* view.pickFeaturesAt({ x, y }, 1, 'wfsBuilding');
* view.pickFeaturesAt({ x, y }, 3, 'wfsBuilding', myLayer);
*/
pickFeaturesAt(mouseOrEvt) {
let radius = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 3;
for (var _len2 = arguments.length, where = new Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) {
where[_key2 - 2] = arguments[_key2];
}
if (Array.isArray(where[0])) {
console.warn('Deprecated: the ...where argument of View#pickFeaturesAt should not be an array anymore, but a list: use the spread operator if needed.');
where = where[0];
}
const layers = [];
const result = {};
where = where.length == 0 ? this.getLayers(l => l.isColorLayer || l.isGeometryLayer) : where;
where.forEach(l => {
if (typeof l === 'string') {
l = this.getLayerById(l);
}
if (l && l.isLayer) {
result[l.id] = [];
if (l.isColorLayer) {
layers.push(l.id);
}
}
});
// Get the mouse coordinates to the correct system
const mouse = mouseOrEvt instanceof Event ? this.eventToViewCoords(mouseOrEvt, _eventCoords) : mouseOrEvt;
const objects = this.pickObjectsAt(mouse, radius, ...where);
if (objects.length > 0) {
objects.forEach(o => result[o.layer.id].push(o));
}
if (layers.length == 0) {
return result;
}
this.getPickingPositionFromDepth(mouse, positionVector);
coordinates.crs = this.referenceCrs;
coordinates.setFromVector3(positionVector);
// Get the correct precision; the position variable will be set in this
// function.
let precision;
const precisions = {
M: this.getPixelsToMeters(radius, mouse),
D: 0.001 * radius
};
if (this.isPlanarView) {
precisions.D = precisions.M;
} else if (this.getPixelsToDegrees) {
precisions.D = this.getMetersToDegrees(precisions.M);
}
// Get the tile corresponding to where the cursor is
const tiles = Picking.pickTilesAt(this, mouse, radius, this.tileLayer);
for (const tile of tiles) {
if (!tile.object.material) {
continue;
}
for (const materialLayer of tile.object.material.getTiles(layers)) {
for (const texture of materialLayer.textures) {
if (!texture.features) {
continue;
}
precision = CRS.isMetricUnit(texture.features.crs) ? precisions.M : precisions.D;
const featuresUnderCoor = FeaturesUtils.filterFeaturesUnderCoordinate(coordinates, texture.features, precision);
featuresUnderCoor.forEach(feature => {
if (!result[materialLayer.id].find(f => f.geometry === feature.geometry)) {
result[materialLayer.id].push(feature);
}
});
}
}
}
return result;
}
readDepthBuffer(x, y, width, height, buffer) {
const g = this.mainLoop.gfxEngine;
const currentWireframe = this.tileLayer.wireframe;
const currentOpacity = this.tileLayer.opacity;
const currentVisibility = this.tileLayer.visible;
if (currentWireframe) {
this.tileLayer.wireframe = false;
}
if (currentOpacity < 1.0) {
this.tileLayer.opacity = 1.0;
}
if (!currentVisibility) {
this.tileLayer.visible = true;
}
const restore = this.tileLayer.level0Nodes.map(n => RenderMode.push(n, RenderMode.MODES.DEPTH));
buffer = g.renderViewToBuffer({
camera: this.camera,
scene: this.tileLayer.object3d
}, {
x,
y,
width,
height,
buffer
});
restore.forEach(r => r());
if (this.tileLayer.wireframe !== currentWireframe) {
this.tileLayer.wireframe = currentWireframe;
}
if (this.tileLayer.opacity !== currentOpacity) {
this.tileLayer.opacity = currentOpacity;
}
if (this.tileLayer.visible !== currentVisibility) {
this.tileLayer.visible = currentVisibility;
}
return buffer;
}
/**
* Returns the world position on the terrain (view's crs: referenceCrs) under view coordinates.
* This position is computed with depth buffer.
*
* @param {THREE.Vector2} mouse position in view coordinates (in pixel), if it's null so it's view's center.
* @param {THREE.Vector3} [target=THREE.Vector3()] target. the result will be copied into this Vector3. If not present a new one will be created.
* @return {THREE.Vector3} the world position on the terrain in view's crs: referenceCrs.
*/
getPickingPositionFromDepth(mouse) {
let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : new THREE.Vector3();
if (!this.tileLayer || this.tileLayer.level0Nodes.length == 0 || !this.tileLayer.level0Nodes[0]) {
target = undefined;
return;
}
const l = this.mainLoop;
const viewPaused = l.scheduler.commandsWaitingExecutionCount() == 0 && l.renderingState == RENDERING_PAUSED;
const g = l.gfxEngine;
const dim = g.getWindowSize();
mouse = mouse || dim.clone().multiplyScalar(0.5);
mouse.x = Math.floor(mouse.x);
mouse.y = Math.floor(mouse.y);
// Render/Read to buffer
let buffer;
if (viewPaused) {
if (this.#fullSizeDepthBuffer.needsUpdate) {
this.readDepthBuffer(0, 0, dim.x, dim.y, this.#fullSizeDepthBuffer);
this.#fullSizeDepthBuffer.needsUpdate = false;
}
const id = ((dim.y - mouse.y - 1) * dim.x + mouse.x) * 4;
buffer = this.#fullSizeDepthBuffer.slice(id, id + 4);
} else {
buffer = this.readDepthBuffer(mouse.x, mouse.y, 1, 1, this.#pixelDepthBuffer);
}
screen.x = mouse.x / dim.x * 2 - 1;
screen.y = -(mouse.y / dim.y) * 2 + 1;
if (Capabilities.isLogDepthBufferSupported() && this.camera3D.type == 'PerspectiveCamera') {
// TODO: solve this part with gl_FragCoord_Z and unproject
// Origin
ray.origin.copy(this.camera3D.position);
// Direction
ray.direction.set(screen.x, screen.y, 0.5);
// Unproject
matrix.multiplyMatrices(this.camera3D.matrixWorld, matrix.copy(this.camera3D.projectionMatrix).invert());
ray.direction.applyMatrix4(matrix);
ray.direction.sub(ray.origin);
direction.set(0, 0, 1.0);
direction.applyMatrix4(matrix);
direction.sub(ray.origin);
const angle = direction.angleTo(ray.direction);
const orthoZ = g.depthBufferRGBAValueToOrthoZ(buffer, this.camera3D);
const length = orthoZ / Math.cos(angle);
target.addVectors(this.camera3D.position, ray.direction.setLength(length));
} else {
const gl_FragCoord_Z = g.depthBufferRGBAValueToOrthoZ(buffer, this.camera3D);
target.set(screen.x, screen.y, gl_FragCoord_Z);
target.unproject(this.camera3D);
}
if (target.length() > 10000000) {
return undefined;
}
return target;
}
/**
* Returns the world {@link Coordinates} of the terrain at given view coordinates.
*
* @param {THREE.Vector2|event} [mouse] The view coordinates at which the world coordinates must be returned. This
* parameter can also be set to a mouse event from which the view coordinates will be deducted. If not specified,
* it will be defaulted to the view's center coordinates.
* @param {Coordinates} [target] The result will be copied into this {@link Coordinates} in the coordinate reference
* system of the given coordinate. If not specified, a new {@link Coordinates} instance will be created (in the
* view referenceCrs).
*
* @returns {Coordinates} The world {@link Coordinates} of the terrain at the given view coordinates in the
* coordinate reference system of the target or in the view referenceCrs if no target is specified.
*/
pickTerrainCoordinates(mouse) {
let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : new Coordinates(this.referenceCrs);
if (mouse instanceof Event) {
this.eventToViewCoords(mouse);
} else if (mouse && mouse.x !== undefined && mouse.y !== undefined) {
_eventCoords.copy(mouse);
} else {
_eventCoords.set(this.mainLoop.gfxEngine.width / 2, this.mainLoop.gfxEngine.height / 2);
}
this.getPickingPositionFromDepth(_eventCoords, positionVector);
coordinates.crs = this.referenceCrs;
coordinates.setFromVector3(positionVector);
coordinates.as(target.crs, target);
return target;
}
/**
* Returns the world {@link Coordinates} of the terrain at given view coordinates.
*
* @param {THREE.Vector2|event} [mouse] The view coordinates at which the world coordinates must be
* returned. This parameter can also be set to a mouse event from
* which the view coordinates will be deducted. If not specified, it
* will be defaulted to the view's center coordinates.
* @param {Coordinates} [target] The result will be copied into this {@link Coordinates}. If not
* specified, a new {@link Coordinates} instance will be created.
*
* @returns {Coordinates} The world {@link Coordinates} of the terrain at the given view coordinates.
*
* @deprecated Use View#pickTerrainCoordinates instead.
*/
pickCoordinates(mouse) {
let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : new Coordinates(this.referenceCrs);
console.warn('Deprecated, use View#pickTerrainCoordinates instead.');
return this.pickTerrainCoordinates(mouse, target);
}
/**
* Resize the viewer.
*
* @param {number} [width=viewerDiv.clientWidth] - The width to resize the
* viewer with. By default it is the `clientWidth` of the `viewerDiv`.
* @param {number} [height=viewerDiv.clientHeight] - The height to resize
* the viewer with. By default it is the `clientHeight` of the `viewerDiv`.
*/
resize(width, height) {
if (width < 0 || height < 0) {
console.warn(`Trying to resize the View with negative height (${height}) or width (${width}). Skipping resize.`);
return;
}
if (width == undefined) {
width = this.domElement.clientWidth;
}
if (height == undefined) {
height = this.domElement.clientHeight;
}
this.#fullSizeDepthBuffer = new Uint8Array(4 * width * height);
this.mainLoop.gfxEngine.onWindowResize(width, height);
if (width !== 0 && height !== 0) {
this.camera.resize(width, height);
this.notifyChange(this.camera3D);
}
}
}
export default View;