// @ts-check import Cartographic from "../Core/Cartographic.js"; import Cesium3DTileset from "./Cesium3DTileset.js"; import DeveloperError from "../Core/DeveloperError.js"; import Ellipsoid from "../Core/Ellipsoid.js"; import Rectangle from "../Core/Rectangle.js"; import Resource from "../Core/Resource.js"; import getAbsoluteUri from "../Core/getAbsoluteUri.js"; import WebMercatorTilingScheme from "../Core/WebMercatorTilingScheme.js"; import defined from "../Core/defined.js"; import destroyObject from "../Core/destroyObject.js"; import CesiumMath from "../Core/Math.js"; /** @import FrameState from "./FrameState.js"; */ /** @import PassState from "../Renderer/PassState.js"; */ /** @import TilingScheme from "../Core/TilingScheme.js"; */ /** * @typedef {object} TilesetJsonRoot * @property {object} [boundingVolume] * @property {number} [geometricError] * @property {"REPLACE"|"ADD"} [refine] * @property {unknown} [content] * @property {unknown[]} [children] * @ignore */ const DEFAULT_MIN_ZOOM = 0; const DEFAULT_MAX_ZOOM = 14; const DEFAULT_REGION_MINIMUM_HEIGHT = -1000.0; const DEFAULT_REGION_MAXIMUM_HEIGHT = 10000.0; const EARTH_CIRCUMFERENCE_METERS = 2.0 * Math.PI * Ellipsoid.WGS84.maximumRadius; const WEB_MERCATOR_TILE_SIZE = 256.0; const scratchTileRectangle = new Rectangle(); const scratchIntersectionRectangle = new Rectangle(); /** * Base provider for URL-template vector sources that are rendered through a * runtime-generated 3D Tiles tileset. * * @private */ class UrlTemplate3DTilesDataProvider { /** * @param {Resource|string} urlTemplate URL template containing {z}, {x}, and {y} placeholders. * @param {object} [options] Provider options. * @param {number} [options.minZoom=0] Minimum zoom level represented in the generated tileset. * @param {number} [options.maxZoom=14] Maximum zoom level represented in the generated tileset. * @param {Rectangle} [options.extent] Optional geographic extent in radians to constrain the generated tile tree. * @param {string} [options.featureIdProperty] Feature property name to use as feature ID when supported by content decoding. */ constructor(urlTemplate, options) { options = options ?? {}; this._resource = Resource.createIfNeeded(urlTemplate); // @ts-expect-error Missing types. this._urlTemplate = this._resource.url; this._extent = Rectangle.clone(options.extent); this._minZoom = options.minZoom ?? DEFAULT_MIN_ZOOM; this._maxZoom = options.maxZoom ?? DEFAULT_MAX_ZOOM; this._featureIdProperty = options.featureIdProperty; this._show = true; this._tileset = undefined; this._tilesetJsonUrl = undefined; } /** * Creates a provider from a URL template. * * @param {Resource|string} url URL template containing {z}, {x}, and {y} placeholders. * @param {object} [options] Provider options. * @returns {Promise} */ static async fromUrl(url, options) { const provider = new this(url, options); await provider._initializeTileset(); return provider; } /** * URL template containing {z}/{x}/{y}. * * @type {string} * @readonly */ get urlTemplate() { return this._urlTemplate; } /** * Resource derived from the URL template. * * @type {Resource} * @readonly */ get resource() { return this._resource; } /** * Optional geographic extent in radians used to generate tile headers. * * @type {Rectangle|undefined} * @readonly */ get extent() { return this._extent; } /** * Backing 3D Tileset. * * @type {Cesium3DTileset|undefined} * @readonly */ get tileset() { return this._tileset; } /** * Determines if the generated tileset is shown. * * @type {boolean} */ get show() { return this._show; } set show(value) { this._show = value; if (defined(this._tileset)) { this._tileset.show = value; } } /** @protected */ _createRuntimeTilesetOptions() { return { minZoom: this._minZoom, maxZoom: this._maxZoom, extent: this._extent, }; } /** * @protected * @returns {object} */ _createTilesetLoadOptions() { return {}; } /** * @protected * @param {Cesium3DTileset} _tileset */ _configureTileset(_tileset) {} /** * Subclasses must return a runtime content codec describing how to turn * a downloaded tile payload into a {@link Cesium3DTileContent}. See * {@link Cesium3DTileset#_runtimeContentCodec} for the expected shape. * * @protected * @returns {object} */ _createCodec() { //>>includeStart('debug', pragmas.debug); throw new DeveloperError( "UrlTemplate3DTilesDataProvider subclasses must implement _createCodec().", ); //>>includeEnd('debug'); } /** * @private */ async _initializeTileset() { const tilesetJson = buildRuntimeTilesetJson( this._resource, this._createRuntimeTilesetOptions(), ); const tilesetBlob = new Blob([JSON.stringify(tilesetJson)], { type: "application/json", }); const tilesetUrl = URL.createObjectURL(tilesetBlob); this._tilesetJsonUrl = tilesetUrl; try { this._tileset = await Cesium3DTileset.fromUrl( tilesetUrl, this._createTilesetLoadOptions(), ); } catch (error) { URL.revokeObjectURL(tilesetUrl); this._tilesetJsonUrl = undefined; throw error; } URL.revokeObjectURL(tilesetUrl); this._tilesetJsonUrl = undefined; this._configureTileset(this._tileset); this._tileset._runtimeContentCodec = this._createCodec(); this._tileset.show = this._show; } /** * @private * @param {FrameState} frameState */ update(frameState) { if (defined(this._tileset)) { this._tileset.update(frameState); } } /** * @private * @param {FrameState} frameState */ prePassesUpdate(frameState) { if (defined(this._tileset)) { this._tileset.prePassesUpdate(frameState); } } /** * @private * @param {FrameState} frameState */ postPassesUpdate(frameState) { if (defined(this._tileset)) { this._tileset.postPassesUpdate(frameState); } } /** * @private * @param {FrameState} frameState * @param {PassState} passState */ updateForPass(frameState, passState) { if (defined(this._tileset)) { this._tileset.updateForPass(frameState, passState); } } isDestroyed() { return false; } destroy() { if (defined(this._tileset)) { this._tileset.destroy(); this._tileset = undefined; } if (defined(this._tilesetJsonUrl)) { URL.revokeObjectURL(this._tilesetJsonUrl); this._tilesetJsonUrl = undefined; } this._resource = undefined; this._extent = undefined; this._featureIdProperty = undefined; return destroyObject(this); } } /** * @param {Resource} resource * @param {object} options * @param {number} options.minZoom * @param {number} options.maxZoom * @param {Rectangle} [options.extent] * @returns {object} * @ignore */ function buildRuntimeTilesetJson(resource, options) { const tilingScheme = new WebMercatorTilingScheme(); const extent = defined(options.extent) ? Rectangle.clone(options.extent) : Rectangle.clone(tilingScheme.rectangle); const minLevelRange = computeTileRangeForExtent( tilingScheme, extent, options.minZoom, ); /** @type {TilesetJsonRoot} */ const root = { boundingVolume: { region: rectangleToRegion(extent), }, // Root has no renderable content, so keep a coarse error to ensure // refinement reaches the first renderable zoom even when minZoom is high. geometricError: computeGeometricError(0), refine: "REPLACE", children: [], }; for (let y = minLevelRange.minY; y <= minLevelRange.maxY; y++) { for (let x = minLevelRange.minX; x <= minLevelRange.maxX; x++) { const child = buildTileNode( tilingScheme, resource, extent, options.minZoom, options.maxZoom, x, y, ); if (defined(child)) { root.children.push(child); } } } if (root.children.length === 0) { root.geometricError = 0.0; } return { asset: { version: "1.1", }, geometricError: root.geometricError, root: root, }; } /** * @param {TilingScheme} tilingScheme * @param {Resource} resource * @param {Rectangle} extent * @param {number} level * @param {number} maxZoom * @param {number} x * @param {number} y * @ignore */ function buildTileNode(tilingScheme, resource, extent, level, maxZoom, x, y) { if ( !tileIntersectsExtent( tilingScheme, level, x, y, extent, scratchTileRectangle, scratchIntersectionRectangle, ) ) { return undefined; } const tileRectangle = tilingScheme.tileXYToRectangle( x, y, level, new Rectangle(), ); /** @type {TilesetJsonRoot} */ const node = { boundingVolume: { region: rectangleToRegion(tileRectangle), }, geometricError: level < maxZoom ? computeGeometricError(level) : 0.0, refine: "REPLACE", content: { uri: resolveTileUrl(resource, level, x, y), }, }; if (level >= maxZoom) { return node; } const childLevel = level + 1; const children = []; for (let childY = y * 2; childY <= y * 2 + 1; childY++) { for (let childX = x * 2; childX <= x * 2 + 1; childX++) { const child = buildTileNode( tilingScheme, resource, extent, childLevel, maxZoom, childX, childY, ); if (defined(child)) { children.push(child); } } } if (children.length > 0) { node.children = children; } else { node.geometricError = 0.0; } return node; } /** * @param {Resource} resource * @param {number} level * @param {number} x * @param {number} y * @ignore */ function resolveTileUrl(resource, level, x, y) { // @ts-expect-error Missing types. const template = resource.url; const tileUrl = template .replace(/\{z\}/gi, `${level}`) .replace(/\{x\}/gi, `${x}`) .replace(/\{y\}/gi, `${y}`); return getAbsoluteUri(tileUrl); } /** * @param {number} level * @ignore */ function computeGeometricError(level) { return EARTH_CIRCUMFERENCE_METERS / ((1 << level) * WEB_MERCATOR_TILE_SIZE); } /** * @param {Rectangle} rectangle * @returns {number[]} * @ignore */ function rectangleToRegion(rectangle) { return [ rectangle.west, rectangle.south, rectangle.east, rectangle.north, DEFAULT_REGION_MINIMUM_HEIGHT, DEFAULT_REGION_MAXIMUM_HEIGHT, ]; } /** * @param {TilingScheme} tilingScheme * @param {Rectangle} extent * @param {number} level * @ignore */ function computeTileRangeForExtent(tilingScheme, extent, level) { const maxIndex = (1 << level) - 1; const nw = Cartographic.fromRadians(extent.west, extent.north); const se = Cartographic.fromRadians(extent.east, extent.south); const nwTile = tilingScheme.positionToTileXY(nw, level); const seTile = tilingScheme.positionToTileXY(se, level); if (!defined(nwTile) || !defined(seTile) || extent.west > extent.east) { return { minX: 0, maxX: maxIndex, minY: 0, maxY: maxIndex, }; } return { minX: CesiumMath.clamp(Math.min(nwTile.x, seTile.x), 0, maxIndex), maxX: CesiumMath.clamp(Math.max(nwTile.x, seTile.x), 0, maxIndex), minY: CesiumMath.clamp(Math.min(nwTile.y, seTile.y), 0, maxIndex), maxY: CesiumMath.clamp(Math.max(nwTile.y, seTile.y), 0, maxIndex), }; } /** * @param {TilingScheme} tilingScheme * @param {number} level * @param {number} x * @param {number} y * @param {Rectangle} extent * @param {Rectangle} tileRectangleScratch * @param {Rectangle} intersectionScratch * @ignore */ function tileIntersectsExtent( tilingScheme, level, x, y, extent, tileRectangleScratch, intersectionScratch, ) { const tileRectangle = tilingScheme.tileXYToRectangle( x, y, level, tileRectangleScratch, ); return defined( Rectangle.intersection(tileRectangle, extent, intersectionScratch), ); } export default UrlTemplate3DTilesDataProvider;