// SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors // This file is derived from the Cesium code base under Apache 2 license // See LICENSE.md and https://github.com/AnalyticalGraphicsInc/cesium/blob/master/LICENSE.md import { Matrix4, Vector3 } from '@math.gl/core'; import { Ellipsoid } from '@math.gl/geospatial'; import { Stats } from '@probe.gl/stats'; import { RequestScheduler, path } from '@loaders.gl/loader-utils'; import { TilesetCache } from "./tileset-cache.js"; import { calculateTransformProps } from "./helpers/transform-utils.js"; import { getFrameState, limitSelectedTiles } from "./helpers/frame-state.js"; import { getZoomFromBoundingVolume, getZoomFromExtent, getZoomFromFullExtent } from "./helpers/zoom.js"; import { Tile3D } from "./tile-3d.js"; import { TILESET_TYPE } from "../constants.js"; import { TilesetTraverser } from "./tileset-traverser.js"; // TODO - these should be moved into their respective modules import { Tileset3DTraverser } from "./format-3d-tiles/tileset-3d-traverser.js"; import { I3STilesetTraverser } from "./format-i3s/i3s-tileset-traverser.js"; const DEFAULT_PROPS = { description: '', ellipsoid: Ellipsoid.WGS84, modelMatrix: new Matrix4(), throttleRequests: true, maxRequests: 64, /** Default memory values optimized for viewing mesh-based 3D Tiles on both mobile and desktop devices */ maximumMemoryUsage: 32, memoryCacheOverflow: 1, maximumTilesSelected: 0, debounceTime: 0, onTileLoad: () => { }, onTileUnload: () => { }, onTileError: () => { }, onTraversalComplete: (selectedTiles) => selectedTiles, contentLoader: undefined, viewDistanceScale: 1.0, maximumScreenSpaceError: 8, memoryAdjustedScreenSpaceError: false, loadTiles: true, updateTransforms: true, viewportTraversersMap: null, loadOptions: { fetch: {} }, attributions: [], basePath: '', i3s: {} }; // Tracked Stats const TILES_TOTAL = 'Tiles In Tileset(s)'; const TILES_IN_MEMORY = 'Tiles In Memory'; const TILES_IN_VIEW = 'Tiles In View'; const TILES_RENDERABLE = 'Tiles To Render'; const TILES_LOADED = 'Tiles Loaded'; const TILES_LOADING = 'Tiles Loading'; const TILES_UNLOADED = 'Tiles Unloaded'; const TILES_LOAD_FAILED = 'Failed Tile Loads'; const POINTS_COUNT = 'Points/Vertices'; const TILES_GPU_MEMORY = 'Tile Memory Use'; const MAXIMUM_SSE = 'Maximum Screen Space Error'; /** * The Tileset loading and rendering flow is as below, * A rendered (i.e. deck.gl `Tile3DLayer`) triggers `tileset.update()` after a `tileset` is loaded * `tileset` starts traversing the tile tree and update `requestTiles` (tiles of which content need * to be fetched) and `selectedTiles` (tiles ready for rendering under the current viewport). * `Tile3DLayer` will update rendering based on `selectedTiles`. * `Tile3DLayer` also listens to `onTileLoad` callback and trigger another round of `update and then traversal` * when new tiles are loaded. * As I3S tileset have stored `tileHeader` file (metadata) and tile content files (geometry, texture, ...) separately. * During each traversal, it issues `tilHeader` requests if that `tileHeader` is not yet fetched, * after the tile header is fulfilled, it will resume the traversal starting from the tile just fetched (not root). * Tile3DLayer * | * await load(tileset) * | * tileset.update() * | async load tileHeader * tileset.traverse() -------------------------- Queued * | resume traversal after fetched | * |----------------------------------------| * | * | async load tile content * tilset.requestedTiles ----------------------------- RequestScheduler * | * tilset.selectedTiles (ready for rendering) | * | Listen to | * Tile3DLayer ----------- onTileLoad ----------------------| * | | notify new tile is available * updateLayers | * tileset.update // trigger another round of update */ export class Tileset3D { // props: Tileset3DProps; options; loadOptions; type; tileset; loader; url; basePath; modelMatrix; ellipsoid; lodMetricType; lodMetricValue; refine; root = null; roots = {}; /** @todo any->unknown */ asset = {}; // Metadata for the entire tileset description = ''; properties; extras = null; attributions = {}; credits = {}; stats; /** flags that contain information about data types in nested tiles */ contentFormats = { draco: false, meshopt: false, dds: false, ktx2: false }; // view props cartographicCenter = null; cartesianCenter = null; zoom = 1; boundingVolume = null; /** Updated based on the camera position and direction */ dynamicScreenSpaceErrorComputedDensity = 0.0; // METRICS /** * The maximum amount of GPU memory (in MB) that may be used to cache tiles * Tiles not in view are unloaded to enforce private */ maximumMemoryUsage = 32; /** The total amount of GPU memory in bytes used by the tileset. */ gpuMemoryUsageInBytes = 0; /** * If loading the level of detail required by maximumScreenSpaceError * results in the memory usage exceeding maximumMemoryUsage (GPU), level of detail refinement * will instead use this (larger) adjusted screen space error to achieve the * best possible visual quality within the available memory. */ memoryAdjustedScreenSpaceError = 0.0; _cacheBytes = 0; _cacheOverflowBytes = 0; /** Update tracker. increase in each update cycle. */ _frameNumber = 0; _queryParams = {}; _extensionsUsed = []; _tiles = {}; /** counter for tracking tiles requests */ _pendingCount = 0; /** Hold traversal results */ selectedTiles = []; // TRAVERSAL traverseCounter = 0; geometricError = 0; lastUpdatedVieports = null; _requestedTiles = []; _emptyTiles = []; frameStateData = {}; _traverser; _cache = new TilesetCache(); _requestScheduler; // Promise tracking updatePromise = null; tilesetInitializationPromise; /** * Create a new Tileset3D * @param json * @param props */ // eslint-disable-next-line max-statements constructor(tileset, options) { // PUBLIC MEMBERS this.options = { ...DEFAULT_PROPS, ...options }; // raw data this.tileset = tileset; this.loader = tileset.loader; // could be 3d tiles, i3s this.type = tileset.type; // The url to a tileset JSON file. this.url = tileset.url; this.basePath = tileset.basePath || path.dirname(this.url); this.modelMatrix = this.options.modelMatrix; this.ellipsoid = this.options.ellipsoid; // Geometric error when the tree is not rendered at all this.lodMetricType = tileset.lodMetricType; this.lodMetricValue = tileset.lodMetricValue; this.refine = tileset.root.refine; this.loadOptions = this.options.loadOptions || {}; // TRAVERSAL this._traverser = this._initializeTraverser(); this._requestScheduler = new RequestScheduler({ throttleRequests: this.options.throttleRequests, maxRequests: this.options.maxRequests }); this.memoryAdjustedScreenSpaceError = this.options.maximumScreenSpaceError; this._cacheBytes = this.options.maximumMemoryUsage * 1024 * 1024; this._cacheOverflowBytes = this.options.memoryCacheOverflow * 1024 * 1024; // METRICS // The total amount of GPU memory in bytes used by the tileset. this.stats = new Stats({ id: this.url }); this._initializeStats(); this.tilesetInitializationPromise = this._initializeTileSet(tileset); } /** Release resources */ destroy() { this._destroy(); } /** Is the tileset loaded (update needs to have been called at least once) */ isLoaded() { // Check that `_frameNumber !== 0` which means that update was called at least once return this._pendingCount === 0 && this._frameNumber !== 0 && this._requestedTiles.length === 0; } get tiles() { return Object.values(this._tiles); } get frameNumber() { return this._frameNumber; } get queryParams() { return new URLSearchParams(this._queryParams).toString(); } setProps(props) { this.options = { ...this.options, ...props }; } /** @deprecated */ // setOptions(options: Tileset3DProps): void { // this.options = {...this.options, ...options}; // } /** * Return a loadable tile url for a specific tile subpath * @param tilePath a tile subpath */ getTileUrl(tilePath) { const isDataUrl = tilePath.startsWith('data:'); if (isDataUrl) { return tilePath; } let tileUrl = tilePath; if (this.queryParams.length) { tileUrl = `${tilePath}${tilePath.includes('?') ? '&' : '?'}${this.queryParams}`; } return tileUrl; } // TODO CESIUM specific hasExtension(extensionName) { return Boolean(this._extensionsUsed.indexOf(extensionName) > -1); } /** * Update visible tiles relying on a list of viewports * @param viewports - list of viewports * @deprecated */ update(viewports = null) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.tilesetInitializationPromise.then(() => { if (!viewports && this.lastUpdatedVieports) { viewports = this.lastUpdatedVieports; } else { this.lastUpdatedVieports = viewports; } if (viewports) { this.doUpdate(viewports); } }); } /** * Update visible tiles relying on a list of viewports. * Do it with debounce delay to prevent update spam * @param viewports viewports * @returns Promise of new frameNumber */ async selectTiles(viewports = null) { await this.tilesetInitializationPromise; if (viewports) { this.lastUpdatedVieports = viewports; } if (!this.updatePromise) { this.updatePromise = new Promise((resolve) => { setTimeout(() => { if (this.lastUpdatedVieports) { this.doUpdate(this.lastUpdatedVieports); } resolve(this._frameNumber); this.updatePromise = null; }, this.options.debounceTime); }); } return this.updatePromise; } adjustScreenSpaceError() { if (this.gpuMemoryUsageInBytes < this._cacheBytes) { this.memoryAdjustedScreenSpaceError = Math.max(this.memoryAdjustedScreenSpaceError / 1.02, this.options.maximumScreenSpaceError); } else if (this.gpuMemoryUsageInBytes > this._cacheBytes + this._cacheOverflowBytes) { this.memoryAdjustedScreenSpaceError *= 1.02; } } /** * Update visible tiles relying on a list of viewports * @param viewports viewports */ // eslint-disable-next-line max-statements, complexity doUpdate(viewports) { if ('loadTiles' in this.options && !this.options.loadTiles) { return; } if (this.traverseCounter > 0) { return; } const preparedViewports = viewports instanceof Array ? viewports : [viewports]; this._cache.reset(); this._frameNumber++; this.traverseCounter = preparedViewports.length; const viewportsToTraverse = []; // First loop to decrement traverseCounter for (const viewport of preparedViewports) { const id = viewport.id; if (this._needTraverse(id)) { viewportsToTraverse.push(id); } else { this.traverseCounter--; } } // Second loop to traverse for (const viewport of preparedViewports) { const id = viewport.id; if (!this.roots[id]) { this.roots[id] = this._initializeTileHeaders(this.tileset, null); } if (!viewportsToTraverse.includes(id)) { continue; // eslint-disable-line no-continue } const frameState = getFrameState(viewport, this._frameNumber); this._traverser.traverse(this.roots[id], frameState, this.options); } } /** * Check if traversal is needed for particular viewport * @param {string} viewportId - id of a viewport * @return {boolean} */ _needTraverse(viewportId) { let traverserId = viewportId; if (this.options.viewportTraversersMap) { traverserId = this.options.viewportTraversersMap[viewportId]; } if (traverserId !== viewportId) { return false; } return true; } /** * The callback to post-process tiles after traversal procedure * @param frameState - frame state for tile culling */ _onTraversalEnd(frameState) { const id = frameState.viewport.id; if (!this.frameStateData[id]) { this.frameStateData[id] = { selectedTiles: [], _requestedTiles: [], _emptyTiles: [] }; } const currentFrameStateData = this.frameStateData[id]; const selectedTiles = Object.values(this._traverser.selectedTiles); const [filteredSelectedTiles, unselectedTiles] = limitSelectedTiles(selectedTiles, frameState, this.options.maximumTilesSelected); currentFrameStateData.selectedTiles = filteredSelectedTiles; for (const tile of unselectedTiles) { tile.unselect(); } currentFrameStateData._requestedTiles = Object.values(this._traverser.requestedTiles); currentFrameStateData._emptyTiles = Object.values(this._traverser.emptyTiles); this.traverseCounter--; if (this.traverseCounter > 0) { return; } this._updateTiles(); } /** * Update tiles relying on data from all traversers */ _updateTiles() { this.selectedTiles = []; this._requestedTiles = []; this._emptyTiles = []; for (const frameStateKey in this.frameStateData) { const frameStateDataValue = this.frameStateData[frameStateKey]; this.selectedTiles = this.selectedTiles.concat(frameStateDataValue.selectedTiles); this._requestedTiles = this._requestedTiles.concat(frameStateDataValue._requestedTiles); this._emptyTiles = this._emptyTiles.concat(frameStateDataValue._emptyTiles); } this.selectedTiles = this.options.onTraversalComplete(this.selectedTiles); for (const tile of this.selectedTiles) { this._tiles[tile.id] = tile; } this._loadTiles(); this._unloadTiles(); this._updateStats(); } _tilesChanged(oldSelectedTiles, selectedTiles) { if (oldSelectedTiles.length !== selectedTiles.length) { return true; } const set1 = new Set(oldSelectedTiles.map((t) => t.id)); const set2 = new Set(selectedTiles.map((t) => t.id)); let changed = oldSelectedTiles.filter((x) => !set2.has(x.id)).length > 0; changed = changed || selectedTiles.filter((x) => !set1.has(x.id)).length > 0; return changed; } _loadTiles() { // Sort requests by priority before making any requests. // This makes it less likely this requests will be cancelled after being issued. // requestedTiles.sort((a, b) => a._priority - b._priority); for (const tile of this._requestedTiles) { if (tile.contentUnloaded) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._loadTile(tile); } } } _unloadTiles() { // unload tiles from cache when hit maximumMemoryUsage this._cache.unloadTiles(this, (tileset, tile) => tileset._unloadTile(tile)); } _updateStats() { let tilesRenderable = 0; let pointsRenderable = 0; for (const tile of this.selectedTiles) { if (tile.contentAvailable && tile.content) { tilesRenderable++; if (tile.content.pointCount) { pointsRenderable += tile.content.pointCount; } else { // Calculate vertices for non point cloud tiles. pointsRenderable += tile.content.vertexCount; } } } this.stats.get(TILES_IN_VIEW).count = this.selectedTiles.length; this.stats.get(TILES_RENDERABLE).count = tilesRenderable; this.stats.get(POINTS_COUNT).count = pointsRenderable; this.stats.get(MAXIMUM_SSE).count = this.memoryAdjustedScreenSpaceError; } async _initializeTileSet(tilesetJson) { if (this.type === TILESET_TYPE.I3S) { this.calculateViewPropsI3S(); tilesetJson.root = await tilesetJson.root; } this.root = this._initializeTileHeaders(tilesetJson, null); if (this.type === TILESET_TYPE.TILES3D) { this._initializeTiles3DTileset(tilesetJson); this.calculateViewPropsTiles3D(); } if (this.type === TILESET_TYPE.I3S) { this._initializeI3STileset(); } } /** * Called during initialize Tileset to initialize the tileset's cartographic center (longitude, latitude) and zoom. * These metrics help apps center view on tileset * For I3S there is extent (<1.8 version) or fullExtent (>=1.8 version) to calculate view props * @returns */ calculateViewPropsI3S() { // for I3S 1.8 try to calculate with fullExtent const fullExtent = this.tileset.fullExtent; if (fullExtent) { const { xmin, xmax, ymin, ymax, zmin, zmax } = fullExtent; this.cartographicCenter = new Vector3(xmin + (xmax - xmin) / 2, ymin + (ymax - ymin) / 2, zmin + (zmax - zmin) / 2); this.cartesianCenter = new Vector3(); Ellipsoid.WGS84.cartographicToCartesian(this.cartographicCenter, this.cartesianCenter); this.zoom = getZoomFromFullExtent(fullExtent, this.cartographicCenter, this.cartesianCenter); return; } // for I3S 1.6-1.7 try to calculate with extent const extent = this.tileset.store?.extent; if (extent) { const [xmin, ymin, xmax, ymax] = extent; this.cartographicCenter = new Vector3(xmin + (xmax - xmin) / 2, ymin + (ymax - ymin) / 2, 0); this.cartesianCenter = new Vector3(); Ellipsoid.WGS84.cartographicToCartesian(this.cartographicCenter, this.cartesianCenter); this.zoom = getZoomFromExtent(extent, this.cartographicCenter, this.cartesianCenter); return; } // eslint-disable-next-line no-console console.warn('Extent is not defined in the tileset header'); this.cartographicCenter = new Vector3(); this.zoom = 1; return; } /** * Called during initialize Tileset to initialize the tileset's cartographic center (longitude, latitude) and zoom. * These metrics help apps center view on tileset. * For 3DTiles the root tile data is used to calculate view props. * @returns */ calculateViewPropsTiles3D() { const root = this.root; const { center } = root.boundingVolume; // TODO - handle all cases if (!center) { // eslint-disable-next-line no-console console.warn('center was not pre-calculated for the root tile'); this.cartographicCenter = new Vector3(); this.zoom = 1; return; } // cartographic coordinates are undefined at the center of the ellipsoid if (center[0] !== 0 || center[1] !== 0 || center[2] !== 0) { this.cartographicCenter = new Vector3(); Ellipsoid.WGS84.cartesianToCartographic(center, this.cartographicCenter); } else { this.cartographicCenter = new Vector3(0, 0, -Ellipsoid.WGS84.radii[0]); } this.cartesianCenter = center; this.zoom = getZoomFromBoundingVolume(root.boundingVolume, this.cartographicCenter); } _initializeStats() { this.stats.get(TILES_TOTAL); this.stats.get(TILES_LOADING); this.stats.get(TILES_IN_MEMORY); this.stats.get(TILES_IN_VIEW); this.stats.get(TILES_RENDERABLE); this.stats.get(TILES_LOADED); this.stats.get(TILES_UNLOADED); this.stats.get(TILES_LOAD_FAILED); this.stats.get(POINTS_COUNT); this.stats.get(TILES_GPU_MEMORY, 'memory'); this.stats.get(MAXIMUM_SSE); } // Installs the main tileset JSON file or a tileset JSON file referenced from a tile. // eslint-disable-next-line max-statements _initializeTileHeaders(tilesetJson, parentTileHeader) { // A tileset JSON file referenced from a tile may exist in a different directory than the root tileset. // Get the basePath relative to the external tileset. const rootTile = new Tile3D(this, tilesetJson.root, parentTileHeader); // resource // If there is a parentTileHeader, add the root of the currently loading tileset // to parentTileHeader's children, and update its depth. if (parentTileHeader) { parentTileHeader.children.push(rootTile); rootTile.depth = parentTileHeader.depth + 1; } // 3DTiles knows the hierarchy beforehand if (this.type === TILESET_TYPE.TILES3D) { const stack = []; stack.push(rootTile); while (stack.length > 0) { const tile = stack.pop(); this.stats.get(TILES_TOTAL).incrementCount(); const children = tile.header.children || []; for (const childHeader of children) { const childTile = new Tile3D(this, childHeader, tile); // Special handling for Google // A session key must be used for all tile requests if (childTile.contentUrl?.includes('?session=')) { const url = new URL(childTile.contentUrl); const session = url.searchParams.get('session'); // eslint-disable-next-line max-depth if (session) { this._queryParams.session = session; } } tile.children.push(childTile); childTile.depth = tile.depth + 1; stack.push(childTile); } } } return rootTile; } _initializeTraverser() { let TraverserClass; const type = this.type; switch (type) { case TILESET_TYPE.TILES3D: TraverserClass = Tileset3DTraverser; break; case TILESET_TYPE.I3S: TraverserClass = I3STilesetTraverser; break; default: TraverserClass = TilesetTraverser; } return new TraverserClass({ basePath: this.basePath, onTraversalEnd: this._onTraversalEnd.bind(this) }); } _destroyTileHeaders(parentTile) { this._destroySubtree(parentTile); } async _loadTile(tile) { let loaded; try { this._onStartTileLoading(); loaded = await tile.loadContent(); } catch (error) { this._onTileLoadError(tile, error instanceof Error ? error : new Error('load failed')); } finally { this._onEndTileLoading(); this._onTileLoad(tile, loaded); } } _onTileLoadError(tile, error) { this.stats.get(TILES_LOAD_FAILED).incrementCount(); const message = error.message || error.toString(); const url = tile.url; // TODO - Allow for probe log to be injected instead of console? console.error(`A 3D tile failed to load: ${tile.url} ${message}`); // eslint-disable-line this.options.onTileError(tile, message, url); } _onTileLoad(tile, loaded) { if (!loaded) { return; } if (this.type === TILESET_TYPE.I3S) { // We can't calculate tiles total in I3S in advance so we calculate it dynamically. const nodesInNodePages = this.tileset?.nodePagesTile?.nodesInNodePages || 0; this.stats.get(TILES_TOTAL).reset(); this.stats.get(TILES_TOTAL).addCount(nodesInNodePages); } // add coordinateOrigin and modelMatrix to tile if (tile && tile.content) { calculateTransformProps(tile, tile.content); } this.updateContentTypes(tile); this._addTileToCache(tile); this.options.onTileLoad(tile); } /** * Update information about data types in nested tiles * @param tile instance of a nested Tile3D */ updateContentTypes(tile) { if (this.type === TILESET_TYPE.I3S) { if (tile.header.isDracoGeometry) { this.contentFormats.draco = true; } switch (tile.header.textureFormat) { case 'dds': this.contentFormats.dds = true; break; case 'ktx2': this.contentFormats.ktx2 = true; break; default: } } else if (this.type === TILESET_TYPE.TILES3D) { const { extensionsRemoved = [] } = tile.content?.gltf || {}; if (extensionsRemoved.includes('KHR_draco_mesh_compression')) { this.contentFormats.draco = true; } if (extensionsRemoved.includes('EXT_meshopt_compression')) { this.contentFormats.meshopt = true; } if (extensionsRemoved.includes('KHR_texture_basisu')) { this.contentFormats.ktx2 = true; } } } _onStartTileLoading() { this._pendingCount++; this.stats.get(TILES_LOADING).incrementCount(); } _onEndTileLoading() { this._pendingCount--; this.stats.get(TILES_LOADING).decrementCount(); } _addTileToCache(tile) { this._cache.add(this, tile, (tileset) => tileset._updateCacheStats(tile)); } _updateCacheStats(tile) { this.stats.get(TILES_LOADED).incrementCount(); this.stats.get(TILES_IN_MEMORY).incrementCount(); // TODO: Calculate GPU memory usage statistics for a tile. this.gpuMemoryUsageInBytes += tile.gpuMemoryUsageInBytes || 0; this.stats.get(TILES_GPU_MEMORY).count = this.gpuMemoryUsageInBytes; // Adjust SSE based on cache limits if (this.options.memoryAdjustedScreenSpaceError) { this.adjustScreenSpaceError(); } } _unloadTile(tile) { this.gpuMemoryUsageInBytes -= tile.gpuMemoryUsageInBytes || 0; this.stats.get(TILES_IN_MEMORY).decrementCount(); this.stats.get(TILES_UNLOADED).incrementCount(); this.stats.get(TILES_GPU_MEMORY).count = this.gpuMemoryUsageInBytes; this.options.onTileUnload(tile); tile.unloadContent(); } // Traverse the tree and destroy all tiles _destroy() { const stack = []; if (this.root) { stack.push(this.root); } while (stack.length > 0) { const tile = stack.pop(); for (const child of tile.children) { stack.push(child); } this._destroyTile(tile); } this.root = null; } // Traverse the tree and destroy all sub tiles _destroySubtree(tile) { const root = tile; const stack = []; stack.push(root); while (stack.length > 0) { tile = stack.pop(); for (const child of tile.children) { stack.push(child); } if (tile !== root) { this._destroyTile(tile); } } root.children = []; } _destroyTile(tile) { this._cache.unloadTile(this, tile); this._unloadTile(tile); tile.destroy(); } _initializeTiles3DTileset(tilesetJson) { if (tilesetJson.queryString) { const searchParams = new URLSearchParams(tilesetJson.queryString); const queryParams = Object.fromEntries(searchParams.entries()); this._queryParams = { ...this._queryParams, ...queryParams }; } this.asset = tilesetJson.asset; if (!this.asset) { throw new Error('Tileset must have an asset property.'); } if (this.asset.version !== '0.0' && this.asset.version !== '1.0' && this.asset.version !== '1.1') { throw new Error('The tileset must be 3D Tiles version either 0.0 or 1.0 or 1.1.'); } // Note: `asset.tilesetVersion` is version of the tileset itself (not the version of the 3D TILES standard) // We add this version as a `v=1.0` query param to fetch the right version and not get an older cached version if ('tilesetVersion' in this.asset) { this._queryParams.v = this.asset.tilesetVersion; } // TODO - ion resources have a credits property we can use for additional attribution. this.credits = { attributions: this.options.attributions || [] }; this.description = this.options.description || ''; // Gets the tileset's properties dictionary object, which contains metadata about per-feature properties. this.properties = tilesetJson.properties; this.geometricError = tilesetJson.geometricError; this._extensionsUsed = tilesetJson.extensionsUsed || []; // Returns the extras property at the top of the tileset JSON (application specific metadata). this.extras = tilesetJson.extras; } _initializeI3STileset() { // @ts-expect-error if (this.loadOptions.i3s && 'token' in this.loadOptions.i3s) { // @ts-ignore this._queryParams.token = this.loadOptions.i3s.token; } } }