itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
457 lines (437 loc) • 17.1 kB
JavaScript
import * as THREE from 'three';
import GeometryLayer from "./GeometryLayer.js";
import { InfoTiledGeometryLayer } from "./InfoLayer.js";
import Picking from "../Core/Picking.js";
import convertToTile from "../Converter/convertToTile.js";
import ObjectRemovalHelper from "../Process/ObjectRemovalHelper.js";
import { getColorLayersIdOrderedBySequence } from "./ImageryLayers.js";
import { CACHE_POLICIES } from "../Core/Scheduler/Cache.js";
const subdivisionVector = new THREE.Vector3();
const boundingSphereCenter = new THREE.Vector3();
/**
* @property {InfoTiledGeometryLayer} info - Status information of layer
* @property {boolean} isTiledGeometryLayer - Used to checkout whether this
* layer is a TiledGeometryLayer. Default is true. You should not change this,
* as it is used internally for optimisation.
* @property {boolean} hideSkirt (default false) - Used to hide the skirt (tile borders).
* Useful when the layer opacity < 1
*
* @extends GeometryLayer
*/
class TiledGeometryLayer extends GeometryLayer {
/**
* A layer extending the {@link GeometryLayer}, but with a tiling notion.
*
* `TiledGeometryLayer` is the ground where `ColorLayer` and `ElevationLayer` are attached.
* `TiledGeometryLayer` is a quadtree data structure. At zoom 0,
* there is a single tile for the whole earth. At zoom level 1,
* the single tile splits into 4 tiles (2x2 tile square).
* Each zoom level quadtree divides the geometry tiles of the one before it.
* The camera distance determines how the tiles are subdivided for optimal data display.
*
* Some `GeometryLayer` can also be attached to the `TiledGeometryLayer` if they want to take advantage of the quadtree.
*
* 
* _In `GlobeView`, **red lines** represents the **WGS84 grid** and **orange lines** the **Pseudo-mercator grid**._
* _In this picture, there are tiles with 3 different zoom/levels._
*
* The zoom/level is based on [tiled web map](https://en.wikipedia.org/wiki/Tiled_web_map).
* It corresponds at meters by pixel. If the projection tile exceeds a certain pixel size (on screen)
* then it is subdivided into 4 tiles with a zoom greater than 1.
*
* @param {string} id - The id of the layer, that should be unique. It is
* not mandatory, but an error will be emitted if this layer is added a
* {@link View} that already has a layer going by that id.
* @param {THREE.Object3D} object3d - The object3d used to contain the
* geometry of the TiledGeometryLayer. It is usually a `THREE.Group`, but it
* can be anything inheriting from a `THREE.Object3d`.
* @param {Array} schemeTile - extents Array of root tiles
* @param {Object} builder - builder geometry object
* @param {Object} [config] - Optional configuration, all elements in it
* will be merged as is in the layer. For example, if the configuration
* contains three elements `name, protocol, extent`, these elements will be
* available using `layer.name` or something else depending on the property
* name.
* @param {Source} [config.source] - Description and options of the source.
*
* @throws {Error} `object3d` must be a valid `THREE.Object3d`.
*/
constructor(id, object3d, schemeTile, builder, config) {
const {
sseSubdivisionThreshold = 1.0,
minSubdivisionLevel,
maxSubdivisionLevel,
maxDeltaElevationLevel,
tileMatrixSets,
diffuse,
showOutline = false,
segments,
disableSkirt = false,
materialOptions = {},
...configGeometryLayer
} = config;
super(id, object3d, {
...configGeometryLayer,
// cacheLifeTime = CACHE_POLICIES.INFINITE because the cache is handled by the builder
cacheLifeTime: CACHE_POLICIES.INFINITE,
source: false
});
/**
* @type {boolean}
* @readonly
*/
this.isTiledGeometryLayer = true;
this.protocol = 'tile';
// TODO : this should be add in a preprocess method specific to GeoidLayer.
this.object3d.geoidHeight = 0;
/**
* @type {boolean}
*/
this.disableSkirt = disableSkirt;
this._hideSkirt = !!config.hideSkirt;
/**
* @type {number}
*/
this.sseSubdivisionThreshold = sseSubdivisionThreshold;
/**
* @type {number}
*/
this.minSubdivisionLevel = minSubdivisionLevel;
/**
* @type {number}
*/
this.maxSubdivisionLevel = maxSubdivisionLevel;
/**
* @type {number}
* @deprecated
*/
this.maxDeltaElevationLevel = maxDeltaElevationLevel;
this.segments = segments;
this.schemeTile = schemeTile;
this.builder = builder;
this.info = new InfoTiledGeometryLayer(this);
if (!this.schemeTile) {
throw new Error(`Cannot init tiled layer without schemeTile for layer ${this.id}`);
}
if (!this.builder) {
throw new Error(`Cannot init tiled layer without builder for layer ${this.id}`);
}
this.maxScreenSizeNode = this.sseSubdivisionThreshold * (this.sizeDiagonalTexture * 2);
this.tileMatrixSets = tileMatrixSets;
this.materialOptions = materialOptions;
/*
* @type {boolean}
*/
this.showOutline = showOutline;
/**
* @type {THREE.Vector3 | undefined}
*/
this.diffuse = diffuse;
this.level0Nodes = [];
const promises = [];
for (const root of this.schemeTile) {
promises.push(this.convert(undefined, root));
}
this._promises.push(Promise.all(promises).then(level0s => {
this.level0Nodes = level0s;
this.object3d.add(...level0s);
this.object3d.updateMatrixWorld();
}));
}
get hideSkirt() {
return this._hideSkirt;
}
set hideSkirt(value) {
if (!this.level0Nodes) {
return;
}
this._hideSkirt = value;
for (const node of this.level0Nodes) {
node.traverse(obj => {
if (obj.isTileMesh) {
obj.geometry.hideSkirt = value;
}
});
}
}
/**
* Picking method for this layer. It uses the {@link Picking#pickTilesAt}
* method.
*
* @param {View} view - The view instance.
* @param {Object} coordinates - The coordinates to pick in the view. It
* should have at least `x` and `y` properties.
* @param {number} radius - Radius of the picking circle.
* @param {Array} target - Array to push picking result.
*
* @return {Array} An array containing all targets picked under the
* specified coordinates.
*/
pickObjectsAt(view, coordinates) {
let radius = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.options.defaultPickingRadius;
let target = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : [];
return Picking.pickTilesAt(view, coordinates, radius, this, target);
}
/**
* Does pre-update work on the context:
* <ul>
* <li>update the `colorLayers` and `elevationLayers`</li>
* <li>update the `maxElevationLevel`</li>
* </ul>
*
* Once this work is done, it returns a list of nodes to update. Depending
* on the origin of `sources`, it can return a few things:
* <ul>
* <li>if `sources` is empty, it returns the first node of the layer
* (stored as `level0Nodes`), which will trigger the update of the whole
* tree</li>
* <li>if the update is triggered by a camera move, the whole tree is
* returned too</li>
* <li>if `source.layer` is this layer, it means that `source` is a node; a
* common ancestor will be found if there are multiple sources, with the
* default common ancestor being the first source itself</li>
* <li>else it returns the whole tree</li>
* </ul>
*
* @param {Object} context - The context of the update; see the {@link
* MainLoop} for more informations.
* @param {Set<GeometryLayer|TileMesh>} sources - A list of sources to
* generate a list of nodes to update.
*
* @return {TileMesh[]} The array of nodes to update.
*/
preUpdate(context, sources) {
if (sources.has(undefined) || sources.size == 0) {
return this.level0Nodes;
}
context.colorLayers = context.view.getLayers((l, a) => a && a.id == this.id && l.isColorLayer);
context.elevationLayers = context.view.getLayers((l, a) => a && a.id == this.id && l.isElevationLayer);
context.maxElevationLevel = -1;
for (const e of context.elevationLayers) {
context.maxElevationLevel = Math.max(e.source.zoom.max, context.maxElevationLevel);
}
if (context.maxElevationLevel == -1) {
context.maxElevationLevel = Infinity;
}
// Prepare ColorLayer sequence order
// In this moment, there is only one color layers sequence, because they are attached to tileLayer.
// In future, the sequence must be returned by parent geometry layer.
this.colorLayersOrder = getColorLayersIdOrderedBySequence(context.colorLayers);
let commonAncestor;
for (const source of sources.values()) {
if (source.isCamera) {
// if the change is caused by a camera move, no need to bother
// to find common ancestor: we need to update the whole tree:
// some invisible tiles may now be visible
return this.level0Nodes;
}
if (source.layer === this) {
if (!commonAncestor) {
commonAncestor = source;
} else {
commonAncestor = source.findCommonAncestor(commonAncestor);
if (!commonAncestor) {
return this.level0Nodes;
}
}
if (commonAncestor.material == null) {
commonAncestor = undefined;
}
}
}
if (commonAncestor) {
return [commonAncestor];
} else {
return this.level0Nodes;
}
}
/**
* Update a node of this layer. The node will not be updated if:
* <ul>
* <li>it does not have a parent, then it is removed</li>
* <li>its parent is being subdivided</li>
* <li>is not visible in the camera</li>
* </ul>
*
* @param {Object} context - The context of the update; see the {@link
* MainLoop} for more informations.
* @param {Layer} layer - Parameter to be removed once all update methods
* have been aligned.
* @param {TileMesh} node - The node to update.
*
* @returns {Object}
*/
update(context, layer, node) {
if (!node.parent) {
return ObjectRemovalHelper.removeChildrenAndCleanup(this, node);
}
// early exit if parent' subdivision is in progress
if (node.parent.pendingSubdivision) {
node.visible = false;
node.material.visible = false;
this.info.update(node);
return undefined;
}
// do proper culling
node.visible = !this.culling(node, context.camera);
if (node.visible) {
let requestChildrenUpdate = false;
node.material.visible = true;
node.material.layersNeedUpdate = true;
this.info.update(node);
if (node.pendingSubdivision || TiledGeometryLayer.hasEnoughTexturesToSubdivide(context, node) && this.subdivision(context, this, node)) {
this.subdivideNode(context, node);
// display iff children aren't ready
node.material.visible = node.pendingSubdivision;
this.info.update(node);
requestChildrenUpdate = true;
}
if (node.material.visible) {
if (!requestChildrenUpdate) {
return ObjectRemovalHelper.removeChildren(this, node);
}
}
return requestChildrenUpdate ? node.children.filter(n => n.layer == this) : undefined;
}
node.material.visible = false;
this.info.update(node);
return ObjectRemovalHelper.removeChildren(this, node);
}
convert(requester, extent) {
return convertToTile.convert(requester, extent, this);
}
// eslint-disable-next-line
culling(node, camera) {
return !camera.isBox3Visible(node.obb.box3D, node.matrixWorld);
}
/**
* Tell if a node has enough elevation or color textures to subdivide.
* Subdivision is prevented if:
* <ul>
* <li>the node is covered by at least one elevation layer and if the node
* doesn't have an elevation texture yet</li>
* <li>a color texture is missing</li>
* </ul>
*
* @param {Object} context - The context of the update; see the {@link
* MainLoop} for more informations.
* @param {TileMesh} node - The node to subdivide.
*
* @returns {boolean} False if the node can not be subdivided, true
* otherwise.
*/
static hasEnoughTexturesToSubdivide(context, node) {
const layerUpdateState = node.layerUpdateState || {};
let nodeLayer = node.material.getElevationTile();
for (const e of context.elevationLayers) {
const extents = node.getExtentsByProjection(e.crs);
const zoom = extents[0].zoom;
if (zoom > e.zoom.max || zoom < e.zoom.min) {
continue;
}
if (!e.frozen && e.ready && e.source.extentInsideLimit(node.extent, zoom) && (!nodeLayer || nodeLayer.level < 0)) {
// no stop subdivision in the case of a loading error
if (layerUpdateState[e.id] && layerUpdateState[e.id].inError()) {
continue;
}
return false;
}
}
for (const c of context.colorLayers) {
if (c.frozen || !c.visible || !c.ready) {
continue;
}
const extents = node.getExtentsByProjection(c.crs);
const zoom = extents[0].zoom;
if (zoom > c.zoom.max || zoom < c.zoom.min) {
continue;
}
// no stop subdivision in the case of a loading error
if (layerUpdateState[c.id] && layerUpdateState[c.id].inError()) {
continue;
}
nodeLayer = node.material.getColorTile(c.id);
if (c.source.extentInsideLimit(node.extent, zoom) && (!nodeLayer || nodeLayer.level < 0)) {
return false;
}
}
return true;
}
/**
* Subdivides a node of this layer. If the node is currently in the process
* of subdivision, it will not do anything here. The subdivision of a node
* will occur in four part, to create a quadtree. The extent of the node
* will be divided in four parts: north-west, north-east, south-west and
* south-east. Once all four nodes are created, they will be added to the
* current node and the view of the context will be notified of this change.
*
* @param {Object} context - The context of the update; see the {@link
* MainLoop} for more informations.
* @param {TileMesh} node - The node to subdivide.
* @return {Promise} { description_of_the_return_value }
*/
subdivideNode(context, node) {
if (!node.pendingSubdivision && !node.children.some(n => n.layer == this)) {
const extents = node.extent.subdivision();
// TODO: pendingSubdivision mechanism is fragile, get rid of it
node.pendingSubdivision = true;
const command = {
/* mandatory */
view: context.view,
requester: node,
layer: this,
priority: 10000,
/* specific params */
extentsSource: extents,
redraw: false
};
return context.scheduler.execute(command).then(children => {
for (const child of children) {
node.add(child);
child.updateMatrixWorld(true);
}
node.pendingSubdivision = false;
context.view.notifyChange(node, false);
}, err => {
node.pendingSubdivision = false;
if (!err.isCancelledCommandException) {
throw new Error(err);
}
});
}
}
/**
* Test the subdvision of a node, compared to this layer.
*
* @param {Object} context - The context of the update; see the {@link
* MainLoop} for more informations.
* @param {PlanarLayer} layer - This layer, parameter to be removed.
* @param {TileMesh} node - The node to test.
*
* @return {boolean} - True if the node is subdivisable, otherwise false.
*/
subdivision(context, layer, node) {
if (node.level < this.minSubdivisionLevel) {
return true;
}
if (this.maxSubdivisionLevel <= node.level) {
return false;
}
subdivisionVector.setFromMatrixScale(node.matrixWorld);
boundingSphereCenter.copy(node.boundingSphere.center).applyMatrix4(node.matrixWorld);
const distance = Math.max(0.0, context.camera.camera3D.position.distanceTo(boundingSphereCenter) - node.boundingSphere.radius * subdivisionVector.x);
// Size projection on pixel of bounding
if (context.camera.camera3D.isOrthographicCamera) {
const camera3D = context.camera.camera3D;
const preSSE = context.camera._preSSE * 2 * camera3D.zoom / (camera3D.top - camera3D.bottom);
node.screenSize = preSSE * node.boundingSphere.radius * subdivisionVector.x;
} else {
node.screenSize = context.camera._preSSE * (2 * node.boundingSphere.radius * subdivisionVector.x) / distance;
}
// The screen space error is calculated to have a correct texture display.
// For the projection of a texture's texel to be less than or equal to one pixel
const sse = node.screenSize / (this.sizeDiagonalTexture * 2);
return this.sseSubdivisionThreshold < sse;
}
}
export default TiledGeometryLayer;