/**
* @file XYZTileNode.js
* @author Cody Smith and Emma Gould
* @date 2022
*
* @description A tree structure to manage the terrain tiles used by the Terrain Viewer.
*/
import {
getTileBounds,
splitTileCoordinates,
padDataBottomAndRight,
} from './utils/utils.js'
import * as martini from '@mapbox/martini'
import { getAndDecodeTerrariumElevationTile } from './utils/mapzenTiles.js'
import {
updateGeometry,
makeSkirtGeometry,
updateSkirtGeometry,
geometryFromMartiniMesh,
} from './utils/geometryUtils.js'
import * as THREE from 'three'
import { lla_ecef } from './utils/ECEF.js'
var Martini = martini.default
var nodeIDLookup = {}
var nodeCount = 1
export class XYZTileNode {
/**
* A tree structure to hold slippy tiles, and their 3d meshes.
*
* A node in a tree of slippy tiles. Each node has up to 4 children. The children have a z index of one more than the parent.
* The constructor takes the arguments x,y, z, and and optional parent.
*
*
* @class XYZTileNode
*
*
*
* @param {Number} x
* @param {Number} y
* @param {Number} z
* @param {XYZTileNode} parent
*/
constructor(x, y, z, parent) {
this.MAX_ZOOM = 15 // The maximum zoom level for the tiles
this.x = x
this.y = y
this.z = z
this.parent = parent
this.children = []
this.elevation = null
this.threeGroup = null
this._lastMArtiniError = null
this._isBusy = false
// Reuse node ids. Look for gap in the nodeIDLookup. This is needed becasue we cant go over 255*256, because of the shader.
// Node removal will leave a gap in the node id lookup.
for (let i = 1; i < 255 * 256; i++) {
if (nodeIDLookup[i] === undefined) {
this.id = i
nodeIDLookup[i] = this
break
}
}
nodeCount = nodeCount + 1
}
getRoot() {
if (this.parent) {
return this.parent.getRoot()
} else {
return this
}
}
isLeaf() {
return this.children.length === 0
}
/**
*
* @param {String} id
* @returns {XYZTileNode} return the node with the given id, or undefined.
*/
getNodeByID(id) {
return nodeIDLookup[id]
}
canSplit() {
return this.z < this.MAX_ZOOM
}
_createChildren() {
// do not create children if we are at the max zoom
if (this.z >= this.MAX_ZOOM) {
return this.children
}
const childCoords = splitTileCoordinates(this.x, this.y, this.z)
// create the children
const children = childCoords.map((c) => {
const childExists = this.children.find((child) => {
return child.x === c.x && child.y === c.y && child.z === c.z
})
if (childExists) {
return childExists
} else {
return new XYZTileNode(c.x, c.y, c.z, this)
}
})
this.children = children
return children
}
/**
* Get the elevation Dataset for this node.
* @returns {DataSet}
*/
async getElevation() {
if (!this.elevation) {
this._isBusy = true
const elevation = await getAndDecodeTerrariumElevationTile(
this.x,
this.y,
this.z
)
const elev257 = padDataBottomAndRight(elevation)
this.elevation = elev257
this._isBusy = false
}
return this.elevation
}
/**
* Get bounds for this node.
*
* @returns {LatLngBounds}
*/
getBounds() {
const bounds = getTileBounds(this.x, this.y, this.z) //.getSouthAndEastPadded(1 / 256)
return bounds
}
/**
* Get TIN from martini mesh
* @param {Number} martiniError
* @returns {Object} martini mesh
*/
async getMartiniMesh(martiniError = 0.1) {
if (this.needsUpdate()) {
this._isBusy = true
const rtin = new Martini(257)
const elev = await this.getElevation()
this.tile = rtin.createTile(elev.data)
this.martiniMesh = this.tile.getMesh(martiniError)
this._isBusy = false
}
return this.martiniMesh
}
async getThreeGroup(martiniError, homeMatrix, material) {
this._isBusy = true
if (this.needsUpdate(martiniError)) {
await this.getTerrainMesh(martiniError, homeMatrix, material)
this.getSkirtMesh(homeMatrix, material)
if (!this.threeGroup) {
this.threeGroup = new THREE.Group()
this.threeGroup.add(this.terrainMesh)
this.threeGroup.add(this.skirtMesh)
}
}
this._isBusy = false
return this.threeGroup
}
/**
* Create and update the 3d mesh for this node.
*
* @param {Number} martiniError
* @param {THREE.Matrix4} homeMatrix
* @param {THREE.Material} material
* @returns {THREE.Mesh}
*/
async getTerrainMesh(martiniError, homeMatrix, material) {
this._isBusy = true
const myElevation = await this.getElevation()
const myMesh = await this.getMartiniMesh(martiniError)
if (this.terrainMesh) {
updateGeometry(
this.terrainMesh.geometry,
myMesh,
myElevation,
this.getBounds(),
homeMatrix
)
} else {
const bounds = this.getBounds()
this.terrainMesh = new THREE.Mesh(
geometryFromMartiniMesh(
myMesh,
myElevation,
bounds,
this.id,
homeMatrix
),
material
)
// Bounding box for the mesh
/*const llECEF = lla_ecef(bounds.south, bounds.west, 0)
const urECEF = lla_ecef(bounds.north, bounds.east, 0)
const llView = new THREE.Vector3(...llECEF).applyMatrix4(homeMatrix)
const urView = new THREE.Vector3(...urECEF).applyMatrix4(homeMatrix)
this.bbox = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(llView.x, llView.y, 0),
new THREE.Vector3(urView.x, llView.y, 0),
new THREE.Vector3(urView.x, urView.y, 0),
new THREE.Vector3(llView.x, urView.y, 0),
new THREE.Vector3(llView.x, llView.y, 0),
]),
new THREE.LineBasicMaterial({ color: 0xffffff })
)
this.bbox.renderOrder = 900
this.bbox.material.depthTest = false
this.bbox.material.depthWrite = false*/
}
this._isBusy = false
return this.terrainMesh
}
getSkirtMesh(homeMatrix, material) {
if (this.skirtMesh) {
updateSkirtGeometry(
this.skirtMesh.geometry,
this.elevation,
this.getBounds(),
homeMatrix
)
} else {
this.skirtMesh = new THREE.Mesh(
makeSkirtGeometry(
this.elevation,
this.getBounds(),
this.id,
homeMatrix
),
material
)
}
return this.skirtMesh
}
/**
*
* @returns {Array} [XYZTileNode, XYZTileNode, ...]
*/
getLeafNodes() {
if (this.isLeaf()) {
return [this]
} else {
const leafNodes = []
this.children.forEach((child) => {
leafNodes.push(...child.getLeafNodes())
})
return leafNodes
}
}
/**
*
* @returns {Array} [XYZTileNode, XYZTileNode, ...]
*/
getNonLeafNodes() {
if (this.isLeaf()) {
return []
} else {
const nonLeafNodes = [this]
this.children.forEach((child) => {
nonLeafNodes.push(...child.getNonLeafNodes())
})
return nonLeafNodes
}
}
getAllNodesBelow() {
const nodes = [this]
this.children.forEach((child) => {
nodes.push(...child.getAllNodesBelow())
})
return nodes
}
/**
* Get all the children of this node, and create them if they don't exist.
* @returns {Array} [XYZTileNode, XYZTileNode, XYZTileNode, XYZTileNode]
*/
split() {
if (this.children.length < 4) {
this._createChildren()
}
return this.getChildren()
}
/**
* Remove a node from the tree.
*
* @param {XYZTileNode} node
*/
removeNode(node = this) {
if (node) {
if (this.isBusy()) {
console.error(
'Removing a node that is busy doing asynchronous stuff. This is probably a bug.'
)
}
if (node.parent) {
node.parent.children = node.parent.children.filter((child) => {
return child !== node
})
}
delete nodeIDLookup[node.id]
if (node.terrainMesh) {
if (node.terrainMesh.parent) node.terrainMesh.remove()
node.terrainMesh.geometry.dispose()
}
node.terrainMesh = undefined // for garbage collection
if (node.skirtMesh) {
if (node.skirtMesh.parent) node.skirtMesh.remove()
node.skirtMesh.geometry.dispose()
}
node.skirtMesh = undefined
if (node.threeGroup) {
if (node.threeGroup.parent) node.threeGroup.remove()
}
/*if (node.bbox) {
if (node.bbox.parent) node.bbox.remove()
if (node.bbox.geometry) node.bbox.geometry.dispose()
}
this.bbox = undefined*/
node.martiniMesh = undefined
node.elevation = undefined
node.parent = undefined
if (node.children) {
node.children.forEach((child) => {
this.removeNode(child)
})
}
node.children = []
} else {
throw ('node not found', node)
}
}
createParent() {
if (!this.getParent()) {
this.parent = new XYZTileNode(
Math.floor(this.x / 2),
Math.floor(this.y / 2),
this.z - 1
)
this.parent.children = [this]
this.parent._createChildren()
}
}
getParent() {
return this.parent
}
getSiblings() {
if (this.parent) {
return this.parent.children
} else {
return []
}
}
/**
* Get all the children of this node
* @returns {Array} [XYZTileNode, XYZTileNode, ...]
*/
getChildren() {
return this.children
}
needsUpdate(martiniError) {
if (this._lastMArtiniError !== martiniError) {
this._lastMArtiniError = martiniError
return true
}
if (!this.threeGroup) {
return true
}
return false
}
isBusy() {
return this._isBusy
}
toFullString() {
const allNodes = this.getAllNodesBelow()
const nodeStrings = allNodes.map((node) => {
return ` [x:${node.x},y:${node.y},z:${node.z}, id:${node.id}]`
})
return `total node count: ${allNodes.length} ${nodeStrings.join(', ')}`
}
toString() {
return `[x:${this.x},y:${this.y},z:${this.z}, id:${
this.id
}, decendents:${
this.getAllNodesBelow().length - 1
}, isLeaf:${this.isLeaf()}]`
}
}
/**
* Lookup nodes in the tree by the xyz coordinates of the tile.
* Returns found=true if the node was found along with the node. Otherwise it returns found=false and the closest parent node of the node being searched for.
*
* @param {Number} x
* @param {Number} y
* @param {Number} z
* @param {XYZTileNode} node Node to start the search from, preferably the root node.
* @returns {Object} {found: Boolean, node: XYZTileNode}
*/
export function lookupNodeByXYZ(x, y, z, node) {
if (node.x === x && node.y === y && node.z === z) {
return { found: true, node: node }
}
const xChoice = Math.floor(x / 2 ** (z - node.z - 1))
const yChoice = Math.floor(y / 2 ** (z - node.z - 1))
const child = node.children.find(
(child) => child.x === xChoice && child.y === yChoice
)
if (child) {
return this.lookupNodeByXYZ(x, y, z, child)
} else {
return { found: false, node: node }
}
}
/**
*
* @param {String} id
* @returns {XYZTileNode} return the node with the given id, or undefined.
*/
export function getNodeByID(id) {
return XYZTileNode.prototype.getNodeByID(id)
}
Source