From 9311afbe3d4d3409a15e3e4b5ce328eab2adf796 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Sat, 11 Oct 2025 15:47:10 +0200 Subject: [PATCH 1/9] Placeholder for dynamic content handling --- packages/engine/Source/Scene/Cesium3DTile.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/engine/Source/Scene/Cesium3DTile.js b/packages/engine/Source/Scene/Cesium3DTile.js index f06b3b97acd5..d3afe1429a07 100644 --- a/packages/engine/Source/Scene/Cesium3DTile.js +++ b/packages/engine/Source/Scene/Cesium3DTile.js @@ -1129,6 +1129,8 @@ Cesium3DTile.prototype.requestContent = function () { return; } + // XXX_DYNAMIC : Dynamic content handling will be added here + if (this.hasMultipleContents) { return requestMultipleContents(this); } From f79f19f6fca007fed4e6e39c6a08dcc4c0c78377 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Wed, 15 Oct 2025 19:27:22 +0200 Subject: [PATCH 2/9] Dynamic 3D Tiles drafts (squashed) --- packages/engine/Source/Core/Resource.js | 12 +- packages/engine/Source/Scene/Cesium3DTile.js | 394 +++-- .../engine/Source/Scene/Cesium3DTileset.js | 85 +- .../Source/Scene/Dynamic3DTileContent.js | 1462 +++++++++++++++++ .../Source/Scene/Multiple3DTileContent.js | 50 +- packages/engine/Source/Scene/finishContent.js | 68 + 6 files changed, 1871 insertions(+), 200 deletions(-) create mode 100644 packages/engine/Source/Scene/Dynamic3DTileContent.js create mode 100644 packages/engine/Source/Scene/finishContent.js diff --git a/packages/engine/Source/Core/Resource.js b/packages/engine/Source/Core/Resource.js index 7e4416e89acd..3d5447768173 100644 --- a/packages/engine/Source/Core/Resource.js +++ b/packages/engine/Source/Core/Resource.js @@ -790,7 +790,17 @@ Resource.prototype.appendForwardSlash = function () { * @returns {Promise|undefined} a promise that will resolve to the requested data when loaded. Returns undefined if request.throttle is true and the request does not have high enough priority. * * @example - * // load a single URL asynchronously + * // load a single URL asynchronously. + * // Note that fetchArrayBuffer may return 'undefined', and this will cause + * // an error here. There is no way to know when it will return 'undefined' + * // or an actual promise. If it returns 'undefined', it is necessary to + * // call it again, until it returns the actual promise. But it may not be + * // called again after it returned a promise, because then there will be + * // multiple promises. Also note that the returned promise may be + * // rejected and receive 'undefined' as the error, so it's impossible to + * // know WHY it was rejected. So you can either ignore that, or just try + * // it again, hoping that it will not be rejected next time. + * // If you are reading this: GOOD LUCK! * resource.fetchArrayBuffer().then(function(arrayBuffer) { * // use the data * }).catch(function(error) { diff --git a/packages/engine/Source/Scene/Cesium3DTile.js b/packages/engine/Source/Scene/Cesium3DTile.js index d3afe1429a07..9b3f1d3e2f4c 100644 --- a/packages/engine/Source/Scene/Cesium3DTile.js +++ b/packages/engine/Source/Scene/Cesium3DTile.js @@ -21,16 +21,12 @@ import RequestState from "../Core/RequestState.js"; import RequestType from "../Core/RequestType.js"; import Resource from "../Core/Resource.js"; import RuntimeError from "../Core/RuntimeError.js"; -import Cesium3DContentGroup from "./Cesium3DContentGroup.js"; -import Cesium3DTileContentFactory from "./Cesium3DTileContentFactory.js"; import Cesium3DTileContentState from "./Cesium3DTileContentState.js"; import Cesium3DTileContentType from "./Cesium3DTileContentType.js"; import Cesium3DTileOptimizationHint from "./Cesium3DTileOptimizationHint.js"; import Cesium3DTilePass from "./Cesium3DTilePass.js"; import Cesium3DTileRefine from "./Cesium3DTileRefine.js"; import Empty3DTileContent from "./Empty3DTileContent.js"; -import findContentMetadata from "./findContentMetadata.js"; -import findGroupMetadata from "./findGroupMetadata.js"; import findTileMetadata from "./findTileMetadata.js"; import hasExtension from "./hasExtension.js"; import Multiple3DTileContent from "./Multiple3DTileContent.js"; @@ -43,6 +39,8 @@ import TileBoundingSphere from "./TileBoundingSphere.js"; import TileOrientedBoundingBox from "./TileOrientedBoundingBox.js"; import Pass from "../Renderer/Pass.js"; import VerticalExaggeration from "../Core/VerticalExaggeration.js"; +import finishContent from "./finishContent.js"; +import Dynamic3DTileContent from "./Dynamic3DTileContent.js"; /** * A tile in a {@link Cesium3DTileset}. When a tile is first created, its content is not loaded; @@ -62,19 +60,6 @@ function Cesium3DTile(tileset, baseResource, header, parent) { this._tileset = tileset; this._header = header; - const hasContentsArray = defined(header.contents); - const hasMultipleContents = - (hasContentsArray && header.contents.length > 1) || - hasExtension(header, "3DTILES_multiple_contents"); - - // In the 1.0 schema, content is stored in tile.content instead of tile.contents - const contentHeader = - hasContentsArray && !hasMultipleContents - ? header.contents[0] - : header.content; - - this._contentHeader = contentHeader; - /** * The local transform of this tile. * @type {Matrix4} @@ -131,22 +116,6 @@ function Cesium3DTile(tileset, baseResource, header, parent) { ); this._boundingVolume2D = undefined; - let contentBoundingVolume; - - if (defined(contentHeader) && defined(contentHeader.boundingVolume)) { - // Non-leaf tiles may have a content bounding-volume, which is a tight-fit bounding volume - // around only the features in the tile. This box is useful for culling for rendering, - // but not for culling for traversing the tree since it does not guarantee spatial coherence, i.e., - // since it only bounds features in the tile, not the entire tile, children may be - // outside of this box. - contentBoundingVolume = this.createBoundingVolume( - contentHeader.boundingVolume, - computedTransform, - ); - } - this._contentBoundingVolume = contentBoundingVolume; - this._contentBoundingVolume2D = undefined; - let viewerRequestVolume; if (defined(header.viewerRequestVolume)) { viewerRequestVolume = this.createBoundingVolume( @@ -178,27 +147,6 @@ function Cesium3DTile(tileset, baseResource, header, parent) { this.updateGeometricErrorScale(); - let refine; - if (defined(header.refine)) { - if (header.refine === "replace" || header.refine === "add") { - Cesium3DTile._deprecationWarning( - "lowercase-refine", - `This tile uses a lowercase refine "${ - header.refine - }". Instead use "${header.refine.toUpperCase()}".`, - ); - } - refine = - header.refine.toUpperCase() === "REPLACE" - ? Cesium3DTileRefine.REPLACE - : Cesium3DTileRefine.ADD; - } else if (defined(parent)) { - // Inherit from parent tile if omitted. - refine = parent.refine; - } else { - refine = Cesium3DTileRefine.REPLACE; - } - /** * Specifies the type of refinement that is used when traversing this tile for rendering. * @@ -206,7 +154,7 @@ function Cesium3DTile(tileset, baseResource, header, parent) { * @readonly * @private */ - this.refine = refine; + this.refine = determineRefine(header.refine, parent); /** * Gets the tile's children. @@ -229,58 +177,6 @@ function Cesium3DTile(tileset, baseResource, header, parent) { */ this.parent = parent; - let content; - let hasEmptyContent = false; - let contentState; - let contentResource; - let serverKey; - - baseResource = Resource.createIfNeeded(baseResource); - - if (hasMultipleContents) { - contentState = Cesium3DTileContentState.UNLOADED; - // Each content may have its own URI, but they all need to be resolved - // relative to the tileset, so the base resource is used. - contentResource = baseResource.clone(); - } else if (defined(contentHeader)) { - let contentHeaderUri = contentHeader.uri; - if (defined(contentHeader.url)) { - Cesium3DTile._deprecationWarning( - "contentUrl", - 'This tileset JSON uses the "content.url" property which has been deprecated. Use "content.uri" instead.', - ); - contentHeaderUri = contentHeader.url; - } - if (contentHeaderUri === "") { - Cesium3DTile._deprecationWarning( - "contentUriEmpty", - "content.uri property is an empty string, which creates a circular dependency, making this tileset invalid. Omit the content property instead", - ); - content = new Empty3DTileContent(tileset, this); - hasEmptyContent = true; - contentState = Cesium3DTileContentState.READY; - } else { - contentState = Cesium3DTileContentState.UNLOADED; - contentResource = baseResource.getDerivedResource({ - url: contentHeaderUri, - }); - serverKey = RequestScheduler.getServerKey( - contentResource.getUrlComponent(), - ); - } - } else { - content = new Empty3DTileContent(tileset, this); - hasEmptyContent = true; - contentState = Cesium3DTileContentState.READY; - } - - this._content = content; - this._contentResource = contentResource; - this._contentState = contentState; - this._expiredContent = undefined; - - this._serverKey = serverKey; - /** * When true, the tile has no content. * @@ -289,7 +185,7 @@ function Cesium3DTile(tileset, baseResource, header, parent) { * * @private */ - this.hasEmptyContent = hasEmptyContent; + this.hasEmptyContent = false; /** * When true, the tile's content points to an external tileset. @@ -334,7 +230,7 @@ function Cesium3DTile(tileset, baseResource, header, parent) { * * @private */ - this.hasRenderableContent = !hasEmptyContent; + this.hasRenderableContent = false; /** * When true, the tile contains content metadata from implicit tiling. This flag is set @@ -362,7 +258,17 @@ function Cesium3DTile(tileset, baseResource, header, parent) { * * @private */ - this.hasMultipleContents = hasMultipleContents; + this.hasMultipleContents = false; + + // Initialize the content-related properties + this._contentBoundingVolume = undefined; + this._contentBoundingVolume2D = undefined; + this._content = undefined; + this._contentResource = undefined; + this._contentState = undefined; + this._expiredContent = undefined; + this._serverKey = undefined; + initializeContent(this, baseResource, header); /** * The node in the tileset's LRU cache, used to determine when to unload a tile's content. @@ -483,7 +389,7 @@ function Cesium3DTile(tileset, baseResource, header, parent) { */ this.implicitSubtree = undefined; - // Members that are updated every frame for tree traversal and rendering optimizations: + // Members that are updated every frame for tree traversal and rendering optimizations. this._distanceToCamera = 0.0; this._centerZDepth = 0.0; this._screenSpaceError = 0.0; @@ -535,6 +441,161 @@ function Cesium3DTile(tileset, baseResource, header, parent) { this._request = undefined; } +/** + * Initialize the content-related properties of the given tile. + * + * This assumes that the _tileset and the + * computedTransform of the given tile have + * already been set. + * + * It will initialize the following properties of the tile, based + * on the given resource and header information: + * + * - _contentHeader + * - _contentBoundingVolume + * - _contentBoundingVolume2D + * - _content + * - _contentResource + * - _contentState + * - _expiredContent + * - _serverKey + * - hasEmptyContent + * - hasRenderableContent + * - hasMultipleContents + * + * The exact meaning of these properties has to be derived from + * the code. This function was just introduced as first cleanup. + * + * @param {Cesium3DTile} tile The tile + * @param {Resource} baseResource The base resource for the tileset + * @param {object} header The JSON header for the tile + */ +function initializeContent(tile, baseResource, header) { + const hasContentsArray = defined(header.contents); + const hasMultipleContents = + (hasContentsArray && header.contents.length > 1) || + hasExtension(header, "3DTILES_multiple_contents"); + + // In the 1.0 schema, content is stored in tile.content instead of tile.contents + const contentHeader = + hasContentsArray && !hasMultipleContents + ? header.contents[0] + : header.content; + + let contentBoundingVolume; + + if (defined(contentHeader) && defined(contentHeader.boundingVolume)) { + // Non-leaf tiles may have a content bounding-volume, which is a tight-fit bounding volume + // around only the features in the tile. This box is useful for culling for rendering, + // but not for culling for traversing the tree since it does not guarantee spatial coherence, i.e., + // since it only bounds features in the tile, not the entire tile, children may be + // outside of this box. + contentBoundingVolume = tile.createBoundingVolume( + contentHeader.boundingVolume, + tile.computedTransform, + ); + } + + let content; + let contentState; + let contentResource; + let serverKey; + let hasEmptyContent = false; + + baseResource = Resource.createIfNeeded(baseResource); + + if (hasMultipleContents) { + contentState = Cesium3DTileContentState.UNLOADED; + // Each content may have its own URI, but they all need to be resolved + // relative to the tileset, so the base resource is used. + contentResource = baseResource.clone(); + } else if (defined(contentHeader)) { + let contentHeaderUri = contentHeader.uri; + if (defined(contentHeader.url)) { + Cesium3DTile._deprecationWarning( + "contentUrl", + 'This tileset JSON uses the "content.url" property which has been deprecated. Use "content.uri" instead.', + ); + contentHeaderUri = contentHeader.url; + } + if (contentHeaderUri === "") { + Cesium3DTile._deprecationWarning( + "contentUriEmpty", + "content.uri property is an empty string, which creates a circular dependency, making this tileset invalid. Omit the content property instead", + ); + content = new Empty3DTileContent(tile._tileset, tile); + hasEmptyContent = true; + contentState = Cesium3DTileContentState.READY; + } else { + contentState = Cesium3DTileContentState.UNLOADED; + contentResource = baseResource.getDerivedResource({ + url: contentHeaderUri, + }); + serverKey = RequestScheduler.getServerKey( + contentResource.getUrlComponent(), + ); + } + } else { + content = new Empty3DTileContent(tile._tileset, tile); + hasEmptyContent = true; + contentState = Cesium3DTileContentState.READY; + } + + tile._contentHeader = contentHeader; + tile._contentBoundingVolume = contentBoundingVolume; + tile._contentBoundingVolume2D = undefined; + tile._content = content; + tile._contentResource = contentResource; + tile._contentState = contentState; + tile._expiredContent = undefined; + tile._serverKey = serverKey; + tile.hasEmptyContent = hasEmptyContent; + tile.hasRenderableContent = !hasEmptyContent; + tile.hasMultipleContents = hasMultipleContents; +} + +/** + * Returns the value for the 'refine' property of a tile. + * + * If the given value from the header is one of the known, deprecated + * lowercase values ("add" or "remove"), then a deprecation warning + * will be printed, and the corresponding constant will be returned. + * + * If the value is undefined, and the parent is not + * undefined, then the value from the parent will + * be inherited and returned. + * + * Otherwise, REPLACE is returned as the default. + * + * @param {string|undefined} headerRefine The refine value from the JSON + * @param {Cesium3DTile|undefined} parent The parent tile + * @returns {number} The Cesium3DTileRefine value + */ +function determineRefine(headerRefine, parent) { + // Note: This will not create a warning for strings like "RePlAcE", + // but still handle them by uppercasing them. + if (defined(headerRefine)) { + if (headerRefine === "replace" || headerRefine === "add") { + Cesium3DTile._deprecationWarning( + "lowercase-refine", + `This tile uses a lowercase refine "${ + headerRefine + }". Instead use "${headerRefine.toUpperCase()}".`, + ); + } + const refine = + headerRefine.toUpperCase() === "REPLACE" + ? Cesium3DTileRefine.REPLACE + : Cesium3DTileRefine.ADD; + return refine; + } + if (defined(parent)) { + // Inherit from parent tile if omitted. + return parent.refine; + } + return Cesium3DTileRefine.REPLACE; +} + // This can be overridden for testing purposes Cesium3DTile._deprecationWarning = deprecationWarning; @@ -1135,9 +1196,71 @@ Cesium3DTile.prototype.requestContent = function () { return requestMultipleContents(this); } + // XXX_DYNAMIC + const contentHeader = this._contentHeader; + const hasDynamicContent = hasExtension(contentHeader, "3DTILES_dynamic"); + //console.log("hasDynamicContent", hasDynamicContent); + if (hasDynamicContent) { + return requestDynamicContent(this); + } + return requestSingleContent(this); }; +/* +async function processContentReadyPromise(tile, contentReadyPromise) { + tile._contentState = Cesium3DTileContentState.LOADING; + try { + const contentReady = await contentReadyPromise; + if (tile.isDestroyed()) { + // Tile is unloaded before the content can process + return; + } + // Tile was canceled, try again later + if (contentReady !== true) { + return; + } + tile._contentState = Cesium3DTileContentState.PROCESSING; + } catch (error) { + if (tile.isDestroyed()) { + // Tile is unloaded before the content can process + return; + } + tile._contentState = Cesium3DTileContentState.FAILED; + throw error; + } +} + */ + +/** + // XXX_DYNAMIC + + * @private + * @param {Cesium3DTile} tile + * @returns {Promise} A promise that resolves to the tile content + */ +async function requestDynamicContent(tile) { + console.log("XXX_DYNAMIC Requesting dynamic content"); + + let dynamicContent = tile._content; + const tileset = tile._tileset; + + if (!defined(dynamicContent)) { + // Create the content object immediately, it will handle scheduling + // requests for inner contents. + const extensionObject = tile._contentHeader.extensions["3DTILES_dynamic"]; + dynamicContent = new Dynamic3DTileContent( + tileset, + tile, + tile._contentResource.clone(), + extensionObject, + ); + tile._content = dynamicContent; + } + tile._contentState = Cesium3DTileContentState.READY; + return Promise.resolve(dynamicContent); +} + /** * Multiple {@link Cesium3DTileContent}s are allowed within a single tile either through * the tile JSON (3D Tiles 1.1) or the 3DTILES_multiple_contents extension. @@ -1352,52 +1475,12 @@ async function makeContent(tile, arrayBuffer) { tile.hasRenderableContent = false; } - let content; - const contentFactory = Cesium3DTileContentFactory[preprocessed.contentType]; if (tile.isDestroyed()) { return; } - - if (defined(preprocessed.binaryPayload)) { - content = await Promise.resolve( - contentFactory( - tileset, - tile, - tile._contentResource, - preprocessed.binaryPayload.buffer, - 0, - ), - ); - } else { - // JSON formats - content = await Promise.resolve( - contentFactory( - tileset, - tile, - tile._contentResource, - preprocessed.jsonPayload, - ), - ); - } - + const resource = tile._contentResource; const contentHeader = tile._contentHeader; - - if (tile.hasImplicitContentMetadata) { - const subtree = tile.implicitSubtree; - const coordinates = tile.implicitCoordinates; - content.metadata = subtree.getContentMetadataView(coordinates, 0); - } else if (!tile.hasImplicitContent) { - content.metadata = findContentMetadata(tileset, contentHeader); - } - - const groupMetadata = findGroupMetadata(tileset, contentHeader); - if (defined(groupMetadata)) { - content.group = new Cesium3DContentGroup({ - metadata: groupMetadata, - }); - } - - return content; + return finishContent(tile, resource, preprocessed, contentHeader, 0); } /** @@ -1407,8 +1490,13 @@ async function makeContent(tile, arrayBuffer) { * @private */ Cesium3DTile.prototype.cancelRequests = function () { + // XXX_DYNAMIC: This actually happens sometimes, but only when the tile is + // in the "LOADING" state. Now... what do do with dynamic tiles? + console.log("Cesium3DTile.cancelRequests is called"); if (this.hasMultipleContents) { this._content.cancelRequests(); + } else if (this._content instanceof Dynamic3DTileContent) { + this._content.cancelRequests(); } else { this._request.cancel(); } diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index cbc8a458ec8f..37b92d1c819b 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -216,10 +216,82 @@ function Cesium3DTileset(options) { this._modelUpAxis = undefined; this._modelForwardAxis = undefined; this._cache = new Cesium3DTilesetCache(); - this._processingQueue = []; - this._selectedTiles = []; + this._emptyTiles = []; + + /** + * The tiles that are 'selected' by the traversal. + * + * During the 'Cesium3DTileset.update' call, the tile traversal is + * executed. This includes the execution of the 'selectTiles' + * function of the traversal (which exists in different forms, + * depending on the traversal - but it's not really an interface, + * just different functions). + * + * The 'selectTiles' function will first clear this list of + * selected tiles, and then fill it with the tiles that are + * 'selected'. + * + * (This usually/roughly means that they are in the view frustum + * and have the right level of detail, but the details may vary) + * + * Some of these tiles may also be moved into the '_requestedTiles' + * as part of the traversal. + */ + this._selectedTiles = []; + + /** + * Tiles that are 'requested' according to the traversal. + * + * This is usually a subset of the '_selectedTiles': The list + * of requested tiles is cleared at the beginning of the traversal, + * and then some tiles that are 'selected' will also be added to + * these 'requested' tiles. + * + * There is no clear definition of what a 'requested' tile is. + * It roughly means that ~"their content has to be loaded". + * The tiles are added to this list, usually in a function + * called 'loadTile', which is literally saying that the tile + * is added to this list "if appropriate". + * + * The important point is that AFTER the traversal, the + * contents of these tiles will be loaded, meaning that + * 'Cesium3DTile.requestContent' will be called for them, + * and they will be added to the '_requestedTilesInFlight'. + * + * (Once the content is loaded, the tiles will be added to + * the '_processingQueue'); + */ this._requestedTiles = []; + + /** + * The tiles for which a content request is currently "in flight". + * + * This list is filled with tiles from the '_requestedTiles' + * in each frame. Tiles are removed from this list after each + * frame (when 'cancelOutOfViewRequests' is called), if their + * '_contentState' is no longer 'LOADING'. + * + * So a tile being in this list roughly means that its content + * is currently being loaded. + */ + this._requestedTilesInFlight = []; + + /** + * The tiles that are currently being processed. + * + * These are the tiles that have been 'selected' and 'requested' + * and whose content was eventually obtained. Before the next + * rendering pass, these tiles will be "processed", meaning that + * their 'Cesium3DTile.process' method will be called. + * + * This mainly means that the 'Cesium3DTileContent.update' function + * of their content is called, loading data and creating WebGL + * resources and doing other random stuff, which eventually leads + * to the tile moving from the 'PROCESSING' state into the 'READY' state. + */ + this._processingQueue = []; + this._selectedTilesToStyle = []; this._loadTimestamp = undefined; this._timeSinceLoad = 0.0; @@ -276,8 +348,6 @@ function Cesium3DTileset(options) { this._statisticsPerPass[i] = new Cesium3DTilesetStatistics(); } - this._requestedTilesInFlight = []; - this._maximumPriority = { foveatedFactor: -Number.MAX_VALUE, depth: -Number.MAX_VALUE, @@ -2362,6 +2432,13 @@ Cesium3DTileset.prototype.loadTileset = function ( return rootTile; }; +// XXX_DYNAMIC EXPERIMENT!!! +Cesium3DTileset.prototype.setDynamicContentPropertyProvider = function ( + dynamicContentPropertyProvider, +) { + this.dynamicContentPropertyProvider = dynamicContentPropertyProvider; +}; + /** * Make a {@link Cesium3DTile} for a specific tile. If the tile's header has implicit * tiling (3D Tiles 1.1) or uses the 3DTILES_implicit_tiling extension, diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js new file mode 100644 index 000000000000..f9c03fc1768c --- /dev/null +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -0,0 +1,1462 @@ +import defined from "../Core/defined.js"; +import destroyObject from "../Core/destroyObject.js"; +import DeveloperError from "../Core/DeveloperError.js"; +import Request from "../Core/Request.js"; +import RequestState from "../Core/RequestState.js"; +import RequestType from "../Core/RequestType.js"; +import preprocess3DTileContent from "./preprocess3DTileContent.js"; +import finishContent from "./finishContent.js"; +import Cesium3DTileStyle from "./Cesium3DTileStyle.js"; +import defer from "../Core/defer.js"; +import Cartesian3 from "../Core/Cartesian3.js"; + +/** + * A generic N-dimensional map, used internally for content lookups. + * + * + * // The "dimensions" (property names) are "x" and "y" + * const ndMap = new NDMap(["x", "y"]); + * + * // The "x" and "y" properties of the key are used when + * // storing the value under the given key. Any other + * // properties are ignored. + * const keyA = { x: 12, y: 34, otherProperty: "ignored" }; + * ndMap.set(keyA, "Example"); + * + * // The "x" and "y" properties of the key are used when + * // retrieving the value for the given key. Any other + * // properties are ignored. + * const keyB = { y: 34, x: 12, differentProperty: "alsoIgnored" }; + * const value = ndMap.get(keyB); // returns "Example" + * + * + * All functions that receive a "key" assume that the key contains properties + * that have the dimension names that have been given in the constructor. + * + * TODO This should to be tested EXTENSIVELY. + * Or let's just add the "@private" tag. + */ +class NDMap { + /** + * Create a new instance where the dimensions have the given names. + * + * These are the names of the properties that will be looked up + * in the 'key' for set/get operations, to determine the coordinates + * within the N-dimensional space. + * + * @param {string[]} dimensionNames + */ + constructor(dimensionNames) { + this._dimensionNames = dimensionNames; + + /** + * The backing map. + * + * @type {Map} + */ + this._lookup = new Map(); + } + + /** + * Returns the number of dimensions of this map + * + * @type {number} + */ + get _dimensions() { + return this._dimensionNames.length; + } + + /** + * Returns the current size of this map + * + * @returns {number} The size + */ + get size() { + return this._lookup.size(); + } + + /** + * Create the key (string) that will be used for the internal + * lookup, based on the given key object. + * + * @param {object} key The key object + * @returns {string} The lookup key + */ + _computeLookupKey(key) { + const k = {}; + const dimensionNames = this._dimensionNames; + for (let d = 0; d < dimensionNames.length; d++) { + const dimensionName = dimensionNames[d]; + k[dimensionName] = key[dimensionName]; + } + return JSON.stringify(k); + } + + /** + * Parse an object from the given lookup key. + * + * The object reflects the relevant dimensions from + * the 'dimensions' that this map refers to. + * + * @param {string} lookupKey The lookup string + * @returns {object} The key + */ + _parseLookupKey(lookupKey) { + return JSON.parse(lookupKey); + } + + /** + * Set the value for the given key. + * + * @param {object} key The key + * @param {any} value The value + */ + set(key, value) { + const lookupKey = this._computeLookupKey(key); + this._lookup.set(lookupKey, value); + } + + /** + * Get the value for the given key. + * + * Returns undefined if there is no entry for this key. + * + * @param {object} key The key + * @returns {any} The value + */ + get(key) { + const lookupKey = this._computeLookupKey(key); + return this._lookup.get(lookupKey); + } + + /** + * Returns whether an entry exists for the given key. + * + * @param {object} key The key + * @returns Whether the entry exists + */ + has(key) { + const lookupKey = this._computeLookupKey(key); + return this._lookup.has(lookupKey); + } + + /** + * Delete the entry from the given key, if it exists. + * + * @param {key} key The key + */ + delete(key) { + const lookupKey = this._computeLookupKey(key); + this._lookup.delete(lookupKey); + } + + /** + * Clear this map, removing all entries. + */ + clear() { + this._lookup.clear(); + } + + /** + * Returns all keys that are stored in this map. + * + * Note that these objects are not identical to the keys that + * have been used in the 'set' calls. They are just objects + * that have the same relevant properties as these keys. + * + * @returns {Iterable} The keys + */ + keys() { + return this._lookup.keys().map((k) => this._parseLookupKey(k)); + } + + /** + * Returns all values that are stored in this map. + * + * @returns {Iterable} The values + */ + values() { + return this._lookup.values(); + } +} + +/** + * Implementation of an LRU (least recently used) cache. + * + * Calling the 'get' or 'set' function constitutes "using" the + * respective key. When 'set' is called and this causes the + * size of the cache to grow beyond its maximum size, then + * the least recently used element will be evicted. + * + * It is possible to create a cache with an infinite maximum + * size. In this case, the 'trimToSize' method can be used + * to manually trim the cache to a certain size. + * + * The implementation resembles that of a Map, and offers + * most of the Map functions. + * + * TODO Maybe it should offer all of them... + */ +class LRUCache { + /** + * Creates a new instance with the given maximum size. + * + * @param {number} maxSize The maximum size + * @param {Function|undefined} evictionCallback The callback that will + * receive the key and value of all evicted entries. + */ + constructor(maxSize, evictionCallback) { + this._maxSize = maxSize; + this._evictionCallback = evictionCallback; + + /** + * The backing map + * + * @type {Map} + * @readonly + */ + this._map = new Map(); + } + + /** + * Set the maximum size that this cache may have. + * + * If the new maximum size is smaller than the current size + * of this cache, then the least recently used elements will + * be evicted until the size matches the maximum size. + * + * @param {number} maxSize The maximum size + */ + setMaximumSize(maxSize) { + this._maxSize = maxSize; + this._ensureMaxSize(); + } + + /** + * Returns the current size of this map + * + * @returns {number} The size + */ + get size() { + return this._map.size; + } + + /** + * Set the value for the given key. + * + * @param {object} key The key + * @param {any} value The value + */ + set(key, value) { + this._map.delete(key); + this._map.set(key, value); + this._ensureMaxSize(); + } + + /** + * Trim this cache to the given size. + * + * While the size is larger than the given size, the oldest + * (least recently used) elements will be evicted. + * + * @param {number} newSize The new size + */ + trimToSize(newSize) { + while (this.size > newSize) { + const oldestEntry = this._map.entries().next().value; + const oldestKey = oldestEntry[0]; + this._map.delete(oldestKey); + if (this._evictionCallback !== undefined) { + const oldestValue = oldestEntry[1]; + this._evictionCallback(oldestKey, oldestValue); + } + } + } + + /** + * Ensure that the number of elements in this cache is not + * larger than the maximum size. + * + * This will evict as many entries as necessary, in the + * order of their least recent usage. + */ + _ensureMaxSize() { + this.trimToSize(this._maxSize); + } + + /** + * Get the value for the given key. + * + * Returns undefined if there is no entry for this key. + * + * @param {object} key The key + * @returns {any} The value + */ + get(key) { + if (this._map.has(key)) { + const value = this._map.get(key); + + // Remove the entry and add it again, to put it + // at the end of the map (most recently used) + this._map.delete(key); + this._map.set(key, value); + return value; + } + return undefined; + } + + /** + * Returns whether an entry exists for the given key. + * + * @param {object} key The key + * @returns Whether the entry exists + */ + has(key) { + return this._map.has(key); + } + + /** + * Delete the entry from the given key, if it exists. + * + * @param {key} key The key + */ + delete(key) { + this._map.delete(key); + } + + /** + * Clear this map, removing all entries. + */ + clear() { + this._map.clear(); + } + + /** + * Returns the keys of this map + * + * @returns {Iterable} The keys + */ + keys() { + return this._map.keys(); + } + + /** + * Returns the values of this map + * + * @returns {Iterable} The values + */ + values() { + return this._map.values(); + } + + /** + * Returns the entries of this map + * + * @returns {Iterable} The entries + */ + entries() { + return this._map.entries(); + } +} + +/** + * A class serving as a convenience wrapper around a request for + * a resource. + */ +class RequestHandle { + /** + * Creates a new request handle for requesting the data for + * the given resource. + * + * @param {Resource} resource The resource + */ + constructor(resource) { + this._resource = resource; + + /** + * The actual CesiumJS Request object. + * + * This created when 'ensureRequested' is called and no + * request (promise) is currently pending. + * + * @type {Request|undefined} + */ + this._request = undefined; + + /** + * The possibly pending request promise. + * + * This created when 'ensureRequested' is called and no + * request (promise) is currently pending. + * + * @type {Promise|undefined} + */ + this._requestPromise = undefined; + + /** + * The deferred object that contains the promise for the + * actual result (i.e. the response from the request). + * + * This is created once and never changes. Its promise can + * be obtained with 'getResultPromise'. + * + * @type {object} + * @readonly + */ + this._deferred = defer(); + } + + /** + * Returns the promise for the result of the request. + * + * This will never be 'undefined'. It will never change. It will + * just be a promise that is either fulfilled with the response + * data from the equest, or rejected with an error indicating + * the reason for the rejection. + * + * The reason for the rejection can either be a real error, + * or 'RequestState.CANCELLED' when the request was cancelled + * (or never issued due to this throttling thingy). + * + * @returns {Promise} The promise + */ + getResultPromise() { + return this._deferred.promise; + } + + /** + * Ensure that there is a pending request, and that the promise + * that was returned bs 'getResultPromise' will eventually be + * fulfilled or rejected. + * + * This has to be called ~"in each frame". It will take care of + * making sure that the request is actually going out, eventually. + */ + ensureRequested() { + // Return immediately if there already is a pending promise. + if (defined(this._requestPromise)) { + return; + } + + // XXX_DYNAMIC The tileset.statistics.numberOfAttemptedRequests + // and tileset.statistics.numberOfPendingRequests values will + // have to be updated here. This class should not know these + // statistics, and even less know the tileset. + + // XXX_DYNAMIC: The Multiple3DTileContent class rambled about it being + // important to CLONE the resource, because of some resource leak, and + // to create a new request, to "avoid getting stuck in the cancelled state". + // Nobody knows what this was about. Let's wait for the issue to come in. + + // Create the request and assign it to the resource. + const request = this._createRequest(); + this._request = request; + const resource = this._resource; + resource.request = request; + + // Try to perform the actual request. Note that throttling may cause + // 'fetchArrayBuffer' to return 'undefined'. In this case, wait for + // the next call to 'ensureRequested'. + const requestPromise = resource.fetchArrayBuffer(); + if (!defined(requestPromise)) { + return; + } + + this._requestPromise = requestPromise; + + // When the promise is fulfilled, resolve it with the array buffer + // from the response. + // Regardless of whether the promise is fulfilled or rejected (with + // an 'undefined' error), it may always have been cancelled. No + // matter where the cancellation appears, reject the result promise + // with the CANCELLED state. + const onFulfilled = (arrayBuffer) => { + if (request.state === RequestState.CANCELLED) { + console.log( + `RequestHandle: Resource promise fulfilled but cancelled for ${request.url}`, + ); + this._requestPromise = undefined; + this._deferred.reject(RequestState.CANCELLED); + return; + } + console.log( + `RequestHandle: Resource promise fulfilled for ${request.url}`, + ); + this._deferred.resolve(arrayBuffer); + }; + + // Only when there is a real error, reject the result promise with + // this exact error. Otherwise, do that CANCELLED handling. + const onRejected = (error) => { + console.log( + `RequestHandle: Resource promise rejected for ${request.url} with error ${error}`, + ); + if (request.state === RequestState.CANCELLED) { + console.log( + `RequestHandle: Resource promise rejected but actually only cancelled for ${request.url} - better luck next time!`, + ); + this._requestPromise = undefined; + this._deferred.reject(RequestState.CANCELLED); + return; + } + this._deferred.reject(error); + }; + requestPromise.then(onFulfilled, onRejected); + } + + /** + * Create and return the request. + * + * This is similar to what was done in Multiple3DTileContent, except + * for the "priority function", which may not be applicable here... + * + * @returns {Request} The request + */ + _createRequest() { + const priorityFunction = () => { + return 0; + }; + const request = new Request({ + throttle: true, + throttleByServer: true, + type: RequestType.TILES3D, + priorityFunction: priorityFunction, + }); + return request; + } + + /** + * Cancel any pending request. + * + * This will cause a rejection + */ + cancel() { + if (defined(this._request)) { + // XXX_DYNAMIC For some reason, "cancel()" is + // marked as "private". So there is no valid + // way to cancel a request after all. + this._request.cancel(); + this._request = undefined; + } + this._deferred.reject(RequestState.CANCELLED); + } +} + +/** + * A class summarizing what is necessary to request tile content. + * + * Its main functionality is offered via the 'tryGetContent' function. + * It handles the "laziness" of the content request, and simply + * returns the content when it's done, and otherwise, it returns + * 'undefined', but ensures that there is a pending request and the + * content will eventually be available. When the content request + * or creation fails, then this will be indicated by the 'failed' + * flag becoming 'true'. + * + * The purpose of this class is to encapuslate the lifecycle + * and asynchronicity of content creation. Users should always + * and only use this content handle, and not rely on the presence + * of the content object, and not store the content object once + * it is created. + * + * @example + * // Pseudocode: + * const contentHandle = new ContentHandle(...); + * inEachFrame() { + * if (contentHandle.failed) { + * console.log("Error!"); + * return; + * } + * const content = contentHandle.tryGetContent(); + * if (!defined(content)) { + * console.log("Still waiting for content"); + * return; + * } + * console.log("Got content: ", content); + * } + */ +class ContentHandle { + /** + * Creates a new instance for the specified content of the given tile. + * + * @param {Cesium3DTile} tile The tile that the content belongs to + * @param {Resource} baseResource The base resource that the URLs + * will be resolved against. + * @param {object} contentHeader The content header, which is just the + * JSON representation of the 'content' from the tileset JSON. + */ + constructor(tile, baseResource, contentHeader) { + this._tile = tile; + this._baseResource = baseResource; + this._contentHeader = contentHeader; + + /** + * The request handle that will be used for issuing the actual + * request. + * + * This will be created when 'tryGetContent' is called. When + * its associated promise is fulfilled, then the actual + * content is created from the response. + * + * @type {RequestHandle|undefined} + */ + this._requestHandle = undefined; + + /** + * The actual content that was created. + * + * Calling 'tryGetContent' will initiate the creation of the + * content. When the underlying request succeeds and the + * content can be created, this will store the resulting + * content. + * + * @type {Cesium3DTileContent|undefined} + */ + this._content = undefined; + + /** + * Whether the content creation failed. + * + * See 'get failed()' for details. + * + * @type {boolean} + */ + this._failed = false; + } + + /** + * Returns whether the content creation ultimately failed. + * + * This will be 'true' if the underlying request was attempted + * and really failed (meaning that it was not just cancelled + * or deferred, but really failed, e.g. due to an invalid + * URL), OR when the creation of the content from the request + * response failed. + * + * The state of this flag will be reset to 'false' when calling + * the 'reset()' method. + * + * @returns {boolean} Whether the request or content creation failed + */ + get failed() { + return this._failed; + } + + /** + * Returns the content if it was already requested, received and created. + * + * This will not attempt to request or create the content. It will only + * return the content if it already exists. When this returns 'undefined', + * then the content was not requested yet, or the content creation + * actually failed. The latter can be checked with the 'failed()' getter. + * + * @returns {Cesium3DTileContent|undefined} The content + */ + getContentOptional() { + if (this.failed) { + //console.log(`ContentHandle: Failed for ${this._contentHeader.uri}`); + return undefined; + } + if (defined(this._content)) { + //console.log(`ContentHandle: Content exists for ${this._contentHeader.uri}`); + return this._content; + } + return undefined; + } + + /** + * Tries to obtain the content. + * + * If the content was already requested, received, and created, then + * this will return the content. + * + * Otherwise, this will return 'undefined'. + * + * If the request did not already fail, it will trigger the request + * and content creation if necessary, so that this method + * (or 'getContentOptional') will eventually return the content if + * its creation succeeds. + * + * @returns {Cesium3DTileContent|undefined} The content + */ + tryGetContent() { + const content = this.getContentOptional(); + if (defined(content)) { + return content; + } + // Don't retry a failed request + if (this.failed) { + return undefined; + } + this._ensureRequestPending(); + return undefined; + } + + /** + * Ensures that there is a pending request for the content. + * + * If there already is a request handle, then its 'ensureRequested' + * function will be called + * + * Otherwise, this will create a request handle for the content request. + * When the request succeeds, then the content will be created from + * the response. When the request or the content creation fails, then + * this content handle will turn into the 'failed()' state. + */ + _ensureRequestPending() { + if (defined(this._requestHandle)) { + this._requestHandle.ensureRequested(); + return; + } + + // Create the actual request handle + const uri = this._contentHeader.uri; + const baseResource = this._baseResource; + const resource = baseResource.getDerivedResource({ + url: uri, + }); + const requestHandle = new RequestHandle(resource); + this._requestHandle = requestHandle; + const requestHandleResultPromise = requestHandle.getResultPromise(); + + // When the request succeeds, try to create the content + // and store it as 'this._content'. If the content + // creation fails, store this as 'this._failed'. + const onRequestFulfilled = async (arrayBuffer) => { + console.log(`ContentHandle: Request was fulfilled for ${uri}`); + try { + const content = await this._createContent(resource, arrayBuffer); + console.log(`ContentHandle: Content was created for ${uri}`); + this._content = content; + // XXX_DYNAMIC Trigger some update...?! + } catch (error) { + console.log( + `ContentHandle: Content creation for ${uri} caused error ${error}`, + ); + this._failed = true; + } + }; + + // The request being rejected may have different reasons. + // It might really have failed. It may just have been + // cancelled. It may count as cancelled because it was + // not scheduled at all. Try to handle each case here: + const onRequestRejected = (error) => { + console.log( + `ContentHandle: Request was rejected for ${uri} with error ${error}`, + ); + // Apparently, cancelling causes a rejection. + // This should not count as "failed". Instead, + // the request handle is discarded, so that it + // will be re-created during the next call to + // _ensureRequestPending + if (error === RequestState.CANCELLED) { + console.log( + `ContentHandle: Request was rejected for ${uri}, but actually only cancelled. Better luck next time!`, + ); + this._requestHandle = undefined; + return; + } + + // Other errors should indeed cause this handle + // to be marked as "failed" + this._failed = true; + }; + requestHandleResultPromise.then(onRequestFulfilled, onRequestRejected); + requestHandle.ensureRequested(); + } + + /** + * Creates and returns the content for the given array buffer that was obtained + * as the response data for the given resource. + * + * @param {Resource} resource The content resource + * @param {ArrayBuffer} arrayBuffer The array buffer that was + * obtained as the response to the request. + * @returns {Cesium3DTileContent} The content + * @throws If the content creation fails for whatever reason + */ + _createContent(resource, arrayBuffer) { + const preprocessed = preprocess3DTileContent(arrayBuffer); + const contentHeader = this._contentHeader; + const tile = this._tile; + return finishContent(tile, resource, preprocessed, contentHeader, 0); + } + + /** + * Reset this handle to its initial state. + * + * This will cancel any pending requests, destroy any content that may + * already have been created, and prepare the handle to retry the + * requests and content creation when 'tryGetContent' is called. + */ + reset() { + if (defined(this._requestHandle)) { + console.log( + `ContentHandle: Cancelling request for ${this._contentHeader.uri}`, + ); + this._requestHandle.cancel(); + } + this._requestHandle = undefined; + if (defined(this._content)) { + this._content.destroy(); + } + this._content = undefined; + this._failed = false; + } +} + +// XXX_DYNAMIC See where to put these. Should be static +// properties, but eslint complains about that. +const DYNAMIC_CONTENT_HIDE_STYLE = new Cesium3DTileStyle({ + show: false, +}); +const DYNAMIC_CONTENT_SHOW_STYLE = new Cesium3DTileStyle({ + show: true, +}); + +/** + * XXX_DYNAMIC Comments! + * + * NOTE: Some of the more obscure request handling has been taken from + * Multiple3DTileContent. + * + * + * @extends Cesium3DTileContent + * @private + * @experimental This feature is using part of the 3D Tiles spec that is not final and is subject to change without Cesium's standard deprecation policy. + * + * Yup. It's a class. Sanity is spreading. Get used to it. + */ +class Dynamic3DTileContent { + /** + * Creates a new instance + * + * @constructor + * + * @param {Cesium3DTileset} tileset The tileset this content belongs to + * @param {Cesium3DTile} tile The content this content belongs to + * @param {Resource} tilesetResource The resource that points to the tileset. This will be used to derive each inner content's resource. + * @param {object} extensionObject The content-level extension object + */ + constructor(tileset, tile, tilesetResource, extensionObject) { + this._tileset = tileset; + this._tile = tile; + this._baseResource = tilesetResource; + + const dynamicContents = extensionObject.dynamicContents; + + /** + * XXX_DYNAMIC This assumes the presence and structure + * of the extension object. Add error handling here. + * + * @type {object} The dynamic contents + */ + this._dynamicContents = dynamicContents; + + /** + * A mapping from URL strings to ContentHandle objects. + * + * This is initialized with all the content definitions that + * are found in the 'dynamicContents' array. It will create + * one ContentHandle for each content. This map will never + * be modified after it was created. + * + * @type {Map} + * @readonly + */ + this._contentHandles = this._createContentHandles(); + + /** + * The maximum number of content objects that should be kept + * in memory at the same time. + * + * This is initialized with an arbitrary value. It will be + * increased as necessary to accommodate for the maximum + * number of contents that are found to be "active" at + * the same time. + * + * @type {number} + */ + this._loadedContentHandlesMaxSize = 10; + + /** + * The mapping from URLs to the ContentHandle objects whose + * content is currently defined (i.e. loaded). + * + * This will be filled in the 'update' function, evicting + * the least recently used content handles if necessary, + * and calling 'loadedContentHandleEvicted' for them. + * + * It is initialized with a maximum size of +Infinity. + * The maximum size will be ensured by calling its + * trimToSize function accordingly. + * + * @type {LRUCache} + */ + this._loadedContentHandles = new LRUCache( + Number.POSITIVE_INFINITY, + this.loadedContentHandleEvicted, + ); + + /** + * The mapping from "keys" to arrays(!) of URIs for the dynamic content. + * + * The keys are the 'keys' from the 'dynamicContents' array. They + * are just plain structures like + * '{ time: "2025-09-13", revision: "revision0" } + * that are used for looking up the associated URLs. + * + * This lookup will be used for determining the 'activeContentUris': + * The 'dynamicContentPropertyProvider' of the tileset will return + * an object that serves as a key for this lookup. The corresponding + * values (URIs) are the URIs of the contents that are currently active. + * + * @type {NDMap} + */ + this._dynamicContentUriLookup = this._createDynamicContentUriLookup(); + + /** + * The last style that was applied to this content. + * + * It will be applied to all "active" contents in the 'update' + * function. + * + * @type {Cesium3DTileStyle} + */ + this._lastStyle = DYNAMIC_CONTENT_SHOW_STYLE; + } + + /** + * The function that will be called when a content handle is + * evicted from the '_loadedContentHandles'. + * + * This will be called when the size of the '_loadedContentHandles' + * is trimmed to the '_loadedContentHandlesMaxSize', and receive + * the least recently used content handles. + * + * It will call 'reset()' on the content handle, cancelling all + * pending requests, and destroying the content. + * + * @param {string} uri The URI of the evicted content + * @param {ContentHandle} contentHandle The ContentHandle + */ + loadedContentHandleEvicted(uri, contentHandle) { + console.log("_loadedContentHandleEvicted with ", uri); + contentHandle.reset(); + } + + /** + * Create the mapping from URL strings to ContentHandle objects. + * + * This is called once from the constructor. The content handles + * will be used for tracking the process of requesting and + * creating the content objects. + * + * @returns {Map} + */ + _createContentHandles() { + const dynamicContents = this._dynamicContents; + + const contentHandles = new Map(); + for (let i = 0; i < dynamicContents.length; i++) { + const contentHeader = dynamicContents[i]; + const contentHandle = new ContentHandle( + this._tile, + this._baseResource, + contentHeader, + ); + const uri = contentHeader.uri; + contentHandles.set(uri, contentHandle); + } + return contentHandles; + } + + /** + * Creates the mapping from the "keys" that are found in the + * 'dynamicContents' array, to the arrays of URLs that are + * associated with these keys. + * + * @returns {NDMap} The mapping + */ + _createDynamicContentUriLookup() { + // XXX_DYNAMIC This assumes the presence and structure + // of the extension object. Add error handling here. + const tileset = this.tileset; + const topLevelExtensionObject = tileset.extensions["3DTILES_dynamic"]; + const dimensions = topLevelExtensionObject.dimensions; + const dimensionNames = dimensions.map((d) => d.name); + + const dynamicContents = this._dynamicContents; + const dynamicContentUriLookup = new NDMap(dimensionNames); + for (let i = 0; i < dynamicContents.length; i++) { + const dynamicContent = dynamicContents[i]; + let entries = dynamicContentUriLookup.get(dynamicContent.keys); + if (!defined(entries)) { + entries = Array(); + dynamicContentUriLookup.set(dynamicContent.keys, entries); + } + const uri = dynamicContent.uri; + entries.push(uri); + } + return dynamicContentUriLookup; + } + + /** + * Returns the array of URIs of contents that are currently 'active'. + * + * This will query the 'dynamicContentPropertyProvider' of the tileset. + * This provider returns what serves as a 'key' for the + * '_dynamicContentUriLookup'. This method returns the array of + * URIs that are found in that lookup, for the respective key. + * + * @type {string[]} The active content URIs + */ + get _activeContentUris() { + const tileset = this._tileset; + let dynamicContentPropertyProvider = tileset.dynamicContentPropertyProvider; + + // XXX_DYNAMIC For testing + if (!defined(dynamicContentPropertyProvider)) { + console.log("No dynamicContentPropertyProvider, using default"); + dynamicContentPropertyProvider = () => { + return { + exampleTimeStamp: "2025-09-26", + exampleRevision: "revision2", + }; + }; + tileset.dynamicContentPropertyProvider = dynamicContentPropertyProvider; + } + + const currentProperties = dynamicContentPropertyProvider(); + const lookup = this._dynamicContentUriLookup; + const currentEntries = lookup.get(currentProperties) ?? []; + return currentEntries; + } + + /** + * Returns the contents that are currently "active" AND loaded (!). + * + * This will obtain the '_activeContentUris'. For each URI, it will + * check whether the content was already requested and created. If + * it was already requested and created, it will be contained in + * the returned array. + * + * @type {Cesium3DTileContent[]} + */ + get _activeContents() { + const activeContents = []; + const activeContentUris = this._activeContentUris; + for (const activeContentUri of activeContentUris) { + const contentHandle = this._contentHandles.get(activeContentUri); + const activeContent = contentHandle.tryGetContent(); + if (defined(activeContent)) { + activeContents.push(activeContent); + } + } + return activeContents; + } + + /** + * Returns ALL content URIs that have been defined as contents + * in the dynamic content definition. + * + * @type {string[]} The content URIs + */ + get _allContentUris() { + // TODO Should be computed from the dynamicContents, + // once, in the constructor, as a SET (!) + const keys = this._contentHandles.keys(); + const allContentUris = [...keys]; + return allContentUris; + } + + /** + * Returns ALL contents that are currently loaded. + * + * @type {Cesium3DTileContent[]} The contents + */ + get _allContents() { + const allContents = []; + const contentHandleValues = this._contentHandles.values(); + for (const contentHandle of contentHandleValues) { + const content = contentHandle.getContentOptional(); + if (defined(content)) { + allContents.push(content); + } + } + return allContents; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. Checks if any of the inner contents have dirty featurePropertiesDirty. + * + * @type {boolean} + */ + get featurePropertiesDirty() { + const allContents = this._allContents; + for (const content of allContents) { + if (content.featurePropertiesDirty) { + return true; + } + } + + return false; + } + set featurePropertiesDirty(value) { + const allContents = this._allContents; + for (const content of allContents) { + content.featurePropertiesDirty = value; + } + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead call featuresLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get featuresLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead, call pointsLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get pointsLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead call trianglesLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get trianglesLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead call geometryByteLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get geometryByteLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead call texturesByteLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get texturesByteLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * Always returns 0. Instead call batchTableByteLength for a specific inner content. + * + * @type {number} + * @readonly + */ + get batchTableByteLength() { + return 0; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + get innerContents() { + return this._allContents; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * @type {boolean} + * @readonly + */ + get ready() { + // XXX_DYNAMIC Always true....!? + return true; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + get tileset() { + return this._tileset; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + get tile() { + return this._tile; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Unlike other content types, this content does not + * have a single URL, so this returns undefined. + * + * @type {string} + * @readonly + * @private + */ + get url() { + return undefined; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Always returns undefined. Instead call metadata for a specific inner content. + */ + get metadata() { + return undefined; + } + set metadata(value) { + //>>includeStart('debug', pragmas.debug); + throw new DeveloperError("This content cannot have metadata"); + //>>includeEnd('debug'); + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Always returns undefined. Instead call batchTable for a specific inner content. + */ + get batchTable() { + return undefined; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Always returns undefined. Instead call group for a specific inner content. + */ + get group() { + return undefined; + } + set group(value) { + //>>includeStart('debug', pragmas.debug); + throw new DeveloperError("This content cannot have group metadata"); + //>>includeEnd('debug'); + } + + /** + * Cancel all requests for inner contents. This is called by the tile + * when a tile goes out of view. + * + * XXX_DYNAMIC See checks for "tile.hasMultipleContents" + * This comment is WRONG. The conditions under which it is called are + * completely unclear. They are related to some frame counters and the + * tile state and some flags of the tile. + */ + cancelRequests() { + console.log("Cancelling requests for Dynamic3DTileContent"); + for (const contentHandle of this._contentHandles.values()) { + contentHandle.reset(); + } + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Always returns false. Instead call hasProperty for a specific inner content + */ + hasProperty(batchId, name) { + return false; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Always returns undefined. Instead call getFeature for a specific inner content + */ + getFeature(batchId) { + return undefined; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + applyDebugSettings(enabled, color) { + // XXX_DYNAMIC This has to store the last state, probably, + // and assign it in "update" to all contents that became active + const allContents = this._allContents; + for (const content of allContents) { + content.applyDebugSettings(enabled, color); + } + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + applyStyle(style) { + this._lastStyle = style; + const activeContents = this._activeContents; + for (const activeContent of activeContents) { + activeContent.applyStyle(style); + } + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + update(tileset, frameState) { + // Call the 'update' on all contents. + const allContents = this._allContents; + for (const content of allContents) { + content.update(tileset, frameState); + } + + // XXX_DYNAMIC There is no way to show or hide contents. + // Whether something is shown or not eventually depends + // on whether draw commands are scheduled. This happens + // as part of the "update" call. The "update" does not + // differentiate between "doing random stuff that has + // to be done somewhere", and scheduling the draw commands. + // It could be called "doRandomStuff" at this point. + + // Hide all contents. + for (const content of allContents) { + content.applyStyle(DYNAMIC_CONTENT_HIDE_STYLE); + } + + // Show only the active contents. + const activeContents = this._activeContents; + for (const activeContent of activeContents) { + activeContent.applyStyle(this._lastStyle); + } + + this._unloadOldContent(); + } + + /** + * Unload the least-recently used content. + */ + _unloadOldContent() { + // Collect all content handles that have a content that + // is currently loaded + const loadedContentHandles = this._loadedContentHandles; + const contentHandleEntries = this._contentHandles.entries(); + for (const [url, contentHandle] of contentHandleEntries) { + if (!loadedContentHandles.has(url)) { + const content = contentHandle.getContentOptional(); + if (defined(content)) { + loadedContentHandles.set(url, contentHandle); + } + } + } + + // Mark the active contents as "recently used" + const activeContentUris = this._activeContentUris; + for (const activeContentUri of activeContentUris) { + if (loadedContentHandles.has(activeContentUri)) { + loadedContentHandles.get(activeContentUri); + } + } + + // Ensure that at least the number of active contents + // is retained + const numActiveContents = activeContentUris.length; + this._loadedContentHandlesMaxSize = Math.max( + this._loadedContentHandlesMaxSize, + numActiveContents, + ); + + // Trim the LRU cache to the target size, calling the + // '_loadedContentHandleEvicted' for the least recently + // used content handles. + loadedContentHandles.trimToSize(this._loadedContentHandlesMaxSize); + } + + // XXX_DYNAMIC Unused right now... + /** + * Computes the difference of the given iterables. + * + * This will return a set containing all elements from the + * first iterable, omitting the ones from the second iterable. + * + * @param {Iterable} iterable0 The base set + * @param {Iterable} iterable1 The set to remove + * @returns {Iterable} The difference + */ + static _difference(iterable0, iterable1) { + const difference = new Set(iterable0); + iterable1.forEach((e) => difference.delete(e)); + return difference; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + * + * Find an intersection between a ray and the tile content surface that was rendered. The ray must be given in world coordinates. + * + * @param {Ray} ray The ray to test for intersection. + * @param {FrameState} frameState The frame state. + * @param {Cartesian3|undefined} [result] The intersection or undefined if none was found. + * @returns {Cartesian3|undefined} The intersection or undefined if none was found. + */ + pick(ray, frameState, result) { + let intersection; + let minDistance = Number.POSITIVE_INFINITY; + const contents = this._activeContents; + const length = contents.length; + + for (let i = 0; i < length; ++i) { + const candidate = contents[i].pick(ray, frameState, result); + + if (!defined(candidate)) { + continue; + } + + const distance = Cartesian3.distance(ray.origin, candidate); + if (distance < minDistance) { + intersection = candidate; + minDistance = distance; + } + } + + if (!defined(intersection)) { + return undefined; + } + return result; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + isDestroyed() { + return false; + } + + /** + * Part of the {@link Cesium3DTileContent} interface. + */ + destroy() { + const allContents = this._allContents; + for (const content of allContents) { + content.destroy(); + } + return destroyObject(this); + } +} + +export default Dynamic3DTileContent; diff --git a/packages/engine/Source/Scene/Multiple3DTileContent.js b/packages/engine/Source/Scene/Multiple3DTileContent.js index 16652eb08cde..488f8d270e8f 100644 --- a/packages/engine/Source/Scene/Multiple3DTileContent.js +++ b/packages/engine/Source/Scene/Multiple3DTileContent.js @@ -6,12 +6,9 @@ import Request from "../Core/Request.js"; import RequestScheduler from "../Core/RequestScheduler.js"; import RequestState from "../Core/RequestState.js"; import RequestType from "../Core/RequestType.js"; -import Cesium3DContentGroup from "./Cesium3DContentGroup.js"; import Cesium3DTileContentType from "./Cesium3DTileContentType.js"; -import Cesium3DTileContentFactory from "./Cesium3DTileContentFactory.js"; -import findContentMetadata from "./findContentMetadata.js"; -import findGroupMetadata from "./findGroupMetadata.js"; import preprocess3DTileContent from "./preprocess3DTileContent.js"; +import finishContent from "./finishContent.js"; /** * A collection of contents for tiles that have multiple contents, either via the tile JSON (3D Tiles 1.1) or the 3DTILES_multiple_contents extension. @@ -35,7 +32,11 @@ import preprocess3DTileContent from "./preprocess3DTileContent.js"; function Multiple3DTileContent(tileset, tile, tilesetResource, contentsJson) { this._tileset = tileset; this._tile = tile; - this._tilesetResource = tilesetResource; + + // XXX_DYNAMIC Was unused... ?! + // This could be avoided by writing a COMMENT!!! + //this._tilesetResource = tilesetResource; + this._contents = []; this._contentsCreated = false; @@ -532,8 +533,8 @@ async function createInnerContent(multipleContents, arrayBuffer, index) { try { const preprocessed = preprocess3DTileContent(arrayBuffer); - const tileset = multipleContents._tileset; const resource = multipleContents._innerContentResources[index]; + const contentHeader = multipleContents._innerContentHeaders[index]; const tile = multipleContents._tile; if (preprocessed.contentType === Cesium3DTileContentType.EXTERNAL_TILESET) { @@ -546,42 +547,7 @@ async function createInnerContent(multipleContents, arrayBuffer, index) { preprocessed.contentType === Cesium3DTileContentType.GEOMETRY || preprocessed.contentType === Cesium3DTileContentType.VECTOR; - let content; - const contentFactory = Cesium3DTileContentFactory[preprocessed.contentType]; - if (defined(preprocessed.binaryPayload)) { - content = await Promise.resolve( - contentFactory( - tileset, - tile, - resource, - preprocessed.binaryPayload.buffer, - 0, - ), - ); - } else { - // JSON formats - content = await Promise.resolve( - contentFactory(tileset, tile, resource, preprocessed.jsonPayload), - ); - } - - const contentHeader = multipleContents._innerContentHeaders[index]; - - if (tile.hasImplicitContentMetadata) { - const subtree = tile.implicitSubtree; - const coordinates = tile.implicitCoordinates; - content.metadata = subtree.getContentMetadataView(coordinates, index); - } else if (!tile.hasImplicitContent) { - content.metadata = findContentMetadata(tileset, contentHeader); - } - - const groupMetadata = findGroupMetadata(tileset, contentHeader); - if (defined(groupMetadata)) { - content.group = new Cesium3DContentGroup({ - metadata: groupMetadata, - }); - } - return content; + return finishContent(tile, resource, preprocessed, contentHeader, index); } catch (error) { handleInnerContentFailed(multipleContents, index, error); } diff --git a/packages/engine/Source/Scene/finishContent.js b/packages/engine/Source/Scene/finishContent.js new file mode 100644 index 000000000000..5ac77d15b4bc --- /dev/null +++ b/packages/engine/Source/Scene/finishContent.js @@ -0,0 +1,68 @@ +import defined from "../Core/defined.js"; +import Cesium3DTileContentFactory from "./Cesium3DTileContentFactory.js"; +import findContentMetadata from "./findContentMetadata.js"; +import findGroupMetadata from "./findGroupMetadata.js"; +import Cesium3DContentGroup from "./Cesium3DContentGroup.js"; + +/** + * Finalize the creation of a Cesium3DTileContent object. + * + * This takes the information from the tile and the preprocessed content + * data that was fetched from the resource, creates the proper + * Cesium3DTileContent instance, and assigns the + * content- and group metadata to it. + * + * @function + * + * @param {Cesium3DTile} tile The tile that contained the content + * @param {Resource} resource The resource + * @param {PreprocessedContent} preprocessed The return value of preprocess3DTileContent + * @param {object} contentHeader the JSON header for a {@link Cesium3DTileContent} + * @param {number} index The content index. This is 0 for a single content, or the index of the inner content for multiple contents. + * @return {Cesium3DTileContent} The finished Cesium3DTileContent + * @private + */ +async function finishContent( + tile, + resource, + preprocessed, + contentHeader, + index, +) { + const tileset = tile._tileset; + const contentFactory = Cesium3DTileContentFactory[preprocessed.contentType]; + let content; + if (defined(preprocessed.binaryPayload)) { + content = await Promise.resolve( + contentFactory( + tileset, + tile, + resource, + preprocessed.binaryPayload.buffer, + 0, + ), + ); + } else { + // JSON formats + content = await Promise.resolve( + contentFactory(tileset, tile, resource, preprocessed.jsonPayload), + ); + } + + if (tile.hasImplicitContentMetadata) { + const subtree = tile.implicitSubtree; + const coordinates = tile.implicitCoordinates; + content.metadata = subtree.getContentMetadataView(coordinates, index); + } else if (!tile.hasImplicitContent) { + content.metadata = findContentMetadata(tileset, contentHeader); + } + + const groupMetadata = findGroupMetadata(tileset, contentHeader); + if (defined(groupMetadata)) { + content.group = new Cesium3DContentGroup({ + metadata: groupMetadata, + }); + } + return content; +} +export default finishContent; From df75727e0280bde13e15eaeaaeaf3636f1eee7bf Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 16 Oct 2025 17:10:54 +0200 Subject: [PATCH 3/9] Switch from extension to content type --- packages/engine/Source/Scene/Cesium3DTile.js | 66 ----------- .../Scene/Cesium3DTileContentFactory.js | 4 + .../Source/Scene/Cesium3DTileContentType.js | 10 ++ .../engine/Source/Scene/Cesium3DTileset.js | 29 ++++- .../Source/Scene/Dynamic3DTileContent.js | 106 +++++++++++++++--- .../Source/Scene/preprocess3DTileContent.js | 9 ++ 6 files changed, 140 insertions(+), 84 deletions(-) diff --git a/packages/engine/Source/Scene/Cesium3DTile.js b/packages/engine/Source/Scene/Cesium3DTile.js index 9b3f1d3e2f4c..1f6f5724e0a0 100644 --- a/packages/engine/Source/Scene/Cesium3DTile.js +++ b/packages/engine/Source/Scene/Cesium3DTile.js @@ -1189,78 +1189,12 @@ Cesium3DTile.prototype.requestContent = function () { if (this.hasEmptyContent) { return; } - - // XXX_DYNAMIC : Dynamic content handling will be added here - if (this.hasMultipleContents) { return requestMultipleContents(this); } - - // XXX_DYNAMIC - const contentHeader = this._contentHeader; - const hasDynamicContent = hasExtension(contentHeader, "3DTILES_dynamic"); - //console.log("hasDynamicContent", hasDynamicContent); - if (hasDynamicContent) { - return requestDynamicContent(this); - } - return requestSingleContent(this); }; -/* -async function processContentReadyPromise(tile, contentReadyPromise) { - tile._contentState = Cesium3DTileContentState.LOADING; - try { - const contentReady = await contentReadyPromise; - if (tile.isDestroyed()) { - // Tile is unloaded before the content can process - return; - } - // Tile was canceled, try again later - if (contentReady !== true) { - return; - } - tile._contentState = Cesium3DTileContentState.PROCESSING; - } catch (error) { - if (tile.isDestroyed()) { - // Tile is unloaded before the content can process - return; - } - tile._contentState = Cesium3DTileContentState.FAILED; - throw error; - } -} - */ - -/** - // XXX_DYNAMIC - - * @private - * @param {Cesium3DTile} tile - * @returns {Promise} A promise that resolves to the tile content - */ -async function requestDynamicContent(tile) { - console.log("XXX_DYNAMIC Requesting dynamic content"); - - let dynamicContent = tile._content; - const tileset = tile._tileset; - - if (!defined(dynamicContent)) { - // Create the content object immediately, it will handle scheduling - // requests for inner contents. - const extensionObject = tile._contentHeader.extensions["3DTILES_dynamic"]; - dynamicContent = new Dynamic3DTileContent( - tileset, - tile, - tile._contentResource.clone(), - extensionObject, - ); - tile._content = dynamicContent; - } - tile._contentState = Cesium3DTileContentState.READY; - return Promise.resolve(dynamicContent); -} - /** * Multiple {@link Cesium3DTileContent}s are allowed within a single tile either through * the tile JSON (3D Tiles 1.1) or the 3DTILES_multiple_contents extension. diff --git a/packages/engine/Source/Scene/Cesium3DTileContentFactory.js b/packages/engine/Source/Scene/Cesium3DTileContentFactory.js index 57f341c592cd..dc92b05f7d7e 100644 --- a/packages/engine/Source/Scene/Cesium3DTileContentFactory.js +++ b/packages/engine/Source/Scene/Cesium3DTileContentFactory.js @@ -6,6 +6,7 @@ import Tileset3DTileContent from "./Tileset3DTileContent.js"; import Vector3DTileContent from "./Vector3DTileContent.js"; import GaussianSplat3DTileContent from "./GaussianSplat3DTileContent.js"; import RuntimeError from "../Core/RuntimeError.js"; +import Dynamic3DTileContent from "./Dynamic3DTileContent.js"; /** * Maps a tile's magic field in its header to a new content object for the tile's payload. @@ -54,6 +55,9 @@ const Cesium3DTileContentFactory = { externalTileset: function (tileset, tile, resource, json) { return Tileset3DTileContent.fromJson(tileset, tile, resource, json); }, + dynamicContents: function (tileset, tile, resource, json) { + return Dynamic3DTileContent.fromJson(tileset, tile, resource, json); + }, geom: function (tileset, tile, resource, arrayBuffer, byteOffset) { return new Geometry3DTileContent( tileset, diff --git a/packages/engine/Source/Scene/Cesium3DTileContentType.js b/packages/engine/Source/Scene/Cesium3DTileContentType.js index 8167d13e7583..598a207523d0 100644 --- a/packages/engine/Source/Scene/Cesium3DTileContentType.js +++ b/packages/engine/Source/Scene/Cesium3DTileContentType.js @@ -113,6 +113,16 @@ const Cesium3DTileContentType = { * @private */ EXTERNAL_TILESET: "externalTileset", + /** + * The content is a dynamic content, which contains an array of + * content objects with 'keys' that identify which content is + * active at a certain point in time. + * + * @type {string} + * @constant + * @private + */ + DYNAMIC_CONTENTS: "dynamicContents", /** * Multiple contents are handled separately from the other content types * due to differences in request scheduling. diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index 37b92d1c819b..c7038b994637 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -2331,6 +2331,16 @@ Cesium3DTileset.fromUrl = async function (url, options) { tileset._initialClippingPlanesOriginMatrix, ); + // Extract the information about the "dimensions" of the dynamic contents, + // if present + const hasDynamicContents = hasExtension(tilesetJson, "3DTILES_dynamic"); + if (hasDynamicContents) { + const dynamicContentsExtension = tilesetJson.extensions["3DTILES_dynamic"]; + tileset._dynamicContentsDimensions = dynamicContentsExtension.dimensions; + } else { + tileset._dynamicContentsDimensions = undefined; + } + return tileset; }; @@ -2432,10 +2442,27 @@ Cesium3DTileset.prototype.loadTileset = function ( return rootTile; }; -// XXX_DYNAMIC EXPERIMENT!!! +/** + * Set the function that determines which dynamic content is currently active. + * + * This is a function that returns a JSON plain object. This object corresponds + * to one 'key' of a dynamic content definition. It will caused the content + * with this key to be the currently active content. + * + * @param {Function|undefined} dynamicContentPropertyProvider The function + */ Cesium3DTileset.prototype.setDynamicContentPropertyProvider = function ( dynamicContentPropertyProvider, ) { + if ( + defined(dynamicContentPropertyProvider) && + !defined(this._dynamicContentsDimensions) + ) { + console.log( + "This tileset does not contain the 3DTILES_dynamic extension. The given function will not have an effect.", + ); + return; + } this.dynamicContentPropertyProvider = dynamicContentPropertyProvider; }; diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index f9c03fc1768c..82392c40ac8d 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -178,6 +178,34 @@ class NDMap { values() { return this._lookup.values(); } + + /** + * Returns the entries of this map + * + * @returns {Iterable} The entries + */ + entries() { + return this._lookup.entries().map(([k, v]) => [this._parseLookupKey(k), v]); + } + + /** + * Call the given function on each key/value pair + * + * @param {Function} callback The callback + * @param {any} thisArg A value to use as this when executing the callback + */ + forEach(callback, thisArg) { + this._entries().forEach(callback, thisArg); + } + + /** + * Returns an iterator over the entries of this map + * + * @returns {Iterator} The iterator + */ + [Symbol.iterator]() { + return this.entries(); + } } /** @@ -192,10 +220,7 @@ class NDMap { * size. In this case, the 'trimToSize' method can be used * to manually trim the cache to a certain size. * - * The implementation resembles that of a Map, and offers - * most of the Map functions. - * - * TODO Maybe it should offer all of them... + * The implementation resembles that of a Map */ class LRUCache { /** @@ -357,6 +382,25 @@ class LRUCache { entries() { return this._map.entries(); } + + /** + * Call the given function on each key/value pair + * + * @param {Function} callback The callback + * @param {any} thisArg A value to use as this when executing the callback + */ + forEach(callback, thisArg) { + this._map.forEach(callback, thisArg); + } + + /** + * Returns an iterator over the elements of this cache. + * + * @returns {Iterator} The iterator + */ + [Symbol.iterator]() { + return this._map[Symbol.iterator]; + } } /** @@ -830,29 +874,53 @@ const DYNAMIC_CONTENT_SHOW_STYLE = new Cesium3DTileStyle({ */ class Dynamic3DTileContent { /** - * Creates a new instance + * Creates an instance of Dynamic3DTileContent from a parsed JSON object + * @param {Cesium3DTileset} tileset The tileset that the content belongs to + * @param {Cesium3DTile} tile The tile that contained the content + * @param {Resource} tilesetResource The tileset Resource + * @param {object} contentJson The content JSON that contains the 'dynamicContents' array + * @returns {Dynamic3DTileContent} + */ + static fromJson(tileset, tile, resource, contentJson) { + const content = new Dynamic3DTileContent( + tileset, + tile, + resource, + contentJson, + ); + return content; + } + + /** + * Creates a new instance. + * + * This should only be called from 'fromJson'. * * @constructor * * @param {Cesium3DTileset} tileset The tileset this content belongs to * @param {Cesium3DTile} tile The content this content belongs to * @param {Resource} tilesetResource The resource that points to the tileset. This will be used to derive each inner content's resource. - * @param {object} extensionObject The content-level extension object + * @param {object} contentJson The content JSON that contains the 'dynamicContents' array + * + * @private */ - constructor(tileset, tile, tilesetResource, extensionObject) { + constructor(tileset, tile, tilesetResource, contentJson) { this._tileset = tileset; this._tile = tile; this._baseResource = tilesetResource; - const dynamicContents = extensionObject.dynamicContents; - /** - * XXX_DYNAMIC This assumes the presence and structure - * of the extension object. Add error handling here. + * The array of content objects. * - * @type {object} The dynamic contents + * Each of these objects is a 3D Tiles 'content', with an + * additional 'keys' property that contains the keys that + * are used for selecting the "active" content at any + * point in time. + * + * @type {object[]} The dynamic contents array */ - this._dynamicContents = dynamicContents; + this._dynamicContents = contentJson.dynamicContents; /** * A mapping from URL strings to ContentHandle objects. @@ -862,7 +930,7 @@ class Dynamic3DTileContent { * one ContentHandle for each content. This map will never * be modified after it was created. * - * @type {Map} + * @type {Map} * @readonly */ this._contentHandles = this._createContentHandles(); @@ -1231,9 +1299,13 @@ class Dynamic3DTileContent { return undefined; } set metadata(value) { - //>>includeStart('debug', pragmas.debug); - throw new DeveloperError("This content cannot have metadata"); - //>>includeEnd('debug'); + ////>>includeStart('debug', pragmas.debug); + //throw new DeveloperError("This content cannot have metadata"); + ////>>includeEnd('debug'); + console.log( + "Assigning metadata to dynamic content - what should that even mean?", + value, + ); } /** diff --git a/packages/engine/Source/Scene/preprocess3DTileContent.js b/packages/engine/Source/Scene/preprocess3DTileContent.js index 8bb8f76d5c20..ea7c5cf32278 100644 --- a/packages/engine/Source/Scene/preprocess3DTileContent.js +++ b/packages/engine/Source/Scene/preprocess3DTileContent.js @@ -84,6 +84,15 @@ function preprocess3DTileContent(arrayBuffer) { }; } + if (defined(json.dynamicContents)) { + // If this is not dynamic content, someone must have + // added that 'dynamicContents' property maliciously. + return { + contentType: Cesium3DTileContentType.DYNAMIC_CONTENTS, + jsonPayload: json, + }; + } + throw new RuntimeError("Invalid tile content."); } From 9bd92a616f608e2ab017c3a89a5d5e22baabb6ef Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Wed, 29 Oct 2025 20:08:30 +0100 Subject: [PATCH 4/9] Minor cleanups and comments. Statistics drafts. --- .../Source/Scene/Cesium3DTileContent.js | 22 +- .../Source/Scene/Dynamic3DTileContent.js | 249 ++++++++++++++---- 2 files changed, 204 insertions(+), 67 deletions(-) diff --git a/packages/engine/Source/Scene/Cesium3DTileContent.js b/packages/engine/Source/Scene/Cesium3DTileContent.js index 945242881b42..7e61ee0f9e0e 100644 --- a/packages/engine/Source/Scene/Cesium3DTileContent.js +++ b/packages/engine/Source/Scene/Cesium3DTileContent.js @@ -302,17 +302,17 @@ Cesium3DTileContent.prototype.getFeature = function (batchId) { }; /** - * Called when {@link Cesium3DTileset#debugColorizeTiles} changes. - *

- * This is used to implement the Cesium3DTileContent interface, but is - * not part of the public Cesium API. - *

- * - * @param {boolean} enabled Whether to enable or disable debug settings. - * @returns {Cesium3DTileFeature} The corresponding {@link Cesium3DTileFeature} object. - - * @private - */ + * Called when {@link Cesium3DTileset#debugColorizeTiles} changes. + *

+ * This is used to implement the Cesium3DTileContent interface, but is + * not part of the public Cesium API. + *

+ * + * @param {boolean} enabled Whether to enable or disable debug settings. + * @param {Color|undefined} color The color to apply + * + * @private + */ Cesium3DTileContent.prototype.applyDebugSettings = function (enabled, color) { DeveloperError.throwInstantiationError(); }; diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index 82392c40ac8d..bf2ddf65b879 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -1,6 +1,5 @@ import defined from "../Core/defined.js"; import destroyObject from "../Core/destroyObject.js"; -import DeveloperError from "../Core/DeveloperError.js"; import Request from "../Core/Request.js"; import RequestState from "../Core/RequestState.js"; import RequestType from "../Core/RequestType.js"; @@ -9,6 +8,7 @@ import finishContent from "./finishContent.js"; import Cesium3DTileStyle from "./Cesium3DTileStyle.js"; import defer from "../Core/defer.js"; import Cartesian3 from "../Core/Cartesian3.js"; +import DeveloperError from "../Core/DeveloperError.js"; /** * A generic N-dimensional map, used internally for content lookups. @@ -403,6 +403,33 @@ class LRUCache { } } +// XXX_DYNAMIC Experiments for that tileset statistics handling... +// eslint-disable-next-line no-unused-vars +class RequestListener { + requestAttempted(request) {} + requestStarted(request) {} + requestCancelled(request) {} + requestCompleted(request) {} + requestFailed(request) {} +} +class LoggingRequestListener { + requestAttempted(request) { + console.log(`requestAttempted for ${request.url}`); + } + requestStarted(request) { + console.log(`requestStarted for ${request.url}`); + } + requestCancelled(request) { + console.log(`requestCancelled for ${request.url}`); + } + requestCompleted(request) { + console.log(`requestCompleted for ${request.url}`); + } + requestFailed(request) { + console.log(`requestFailed for ${request.url}`); + } +} + /** * A class serving as a convenience wrapper around a request for * a resource. @@ -448,6 +475,19 @@ class RequestHandle { * @readonly */ this._deferred = defer(); + + /** + * The listeners that will be informed about the request state + * + * @type {RequestListener[]} + * @readonly + */ + this._requestListeners = []; + } + + // XXX_DYNAMIC Experiments for that tileset statistics handling... + addRequestListener(requestListener) { + this._requestListeners.push(requestListener); } /** @@ -503,9 +543,10 @@ class RequestHandle { // the next call to 'ensureRequested'. const requestPromise = resource.fetchArrayBuffer(); if (!defined(requestPromise)) { + this._fireRequestAttempted(); return; } - + this._fireRequestStarted(); this._requestPromise = requestPromise; // When the promise is fulfilled, resolve it with the array buffer @@ -521,12 +562,15 @@ class RequestHandle { ); this._requestPromise = undefined; this._deferred.reject(RequestState.CANCELLED); + this._fireRequestCancelled(); + this._fireRequestAttempted(); return; } console.log( `RequestHandle: Resource promise fulfilled for ${request.url}`, ); this._deferred.resolve(arrayBuffer); + this._fireRequestCompleted(); }; // Only when there is a real error, reject the result promise with @@ -541,9 +585,13 @@ class RequestHandle { ); this._requestPromise = undefined; this._deferred.reject(RequestState.CANCELLED); + this._fireRequestCancelled(); + this._fireRequestAttempted(); return; } this._deferred.reject(error); + this._fireRequestFailed(); + this._fireRequestAttempted(); }; requestPromise.then(onFulfilled, onRejected); } @@ -584,6 +632,33 @@ class RequestHandle { } this._deferred.reject(RequestState.CANCELLED); } + + // XXX_DYNAMIC Experiments for that tileset statistics handling... + _fireRequestAttempted() { + for (const requestListener of this._requestListeners) { + requestListener.requestAttempted(this._request); + } + } + _fireRequestStarted() { + for (const requestListener of this._requestListeners) { + requestListener.requestStarted(this._request); + } + } + _fireRequestCancelled() { + for (const requestListener of this._requestListeners) { + requestListener.requestCancelled(this._request); + } + } + _fireRequestCompleted() { + for (const requestListener of this._requestListeners) { + requestListener.requestCompleted(this._request); + } + } + _fireRequestFailed() { + for (const requestListener of this._requestListeners) { + requestListener.requestFailed(this._request); + } + } } /** @@ -630,8 +705,28 @@ class ContentHandle { * JSON representation of the 'content' from the tileset JSON. */ constructor(tile, baseResource, contentHeader) { + /** + * The tile that the content belongs to. + * + * This is only required for passing it through to 'finishContent'. + * + * @type {Cesium3DTile} + */ this._tile = tile; + + /** + * The base resource. The content resource will be created by + * calling getDerivedResource with the content URI in this. + * + * @type {Resource} + */ this._baseResource = baseResource; + + /** + * The JSON representation of the 'content' from the tileset JSON. + * + * @type {object} + */ this._contentHeader = contentHeader; /** @@ -760,6 +855,28 @@ class ContentHandle { url: uri, }); const requestHandle = new RequestHandle(resource); + + // Attach a listener that will update the tileset statistics + const tileset = this._tile.tileset; + requestHandle.addRequestListener(new LoggingRequestListener()); + requestHandle.addRequestListener({ + requestAttempted(request) { + tileset.statistics.numberOfAttemptedRequests++; + }, + requestStarted(request) { + tileset.statistics.numberOfPendingRequests++; + }, + requestCancelled(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestCompleted(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestFailed(request) { + tileset.statistics.numberOfPendingRequests--; + }, + }); + this._requestHandle = requestHandle; const requestHandleResultPromise = requestHandle.getResultPromise(); @@ -880,6 +997,8 @@ class Dynamic3DTileContent { * @param {Resource} tilesetResource The tileset Resource * @param {object} contentJson The content JSON that contains the 'dynamicContents' array * @returns {Dynamic3DTileContent} + * @throws {DeveloperError} If the tileset does not contain the + * top-level dynamic content extension object. */ static fromJson(tileset, tile, resource, contentJson) { const content = new Dynamic3DTileContent( @@ -898,17 +1017,38 @@ class Dynamic3DTileContent { * * @constructor * - * @param {Cesium3DTileset} tileset The tileset this content belongs to - * @param {Cesium3DTile} tile The content this content belongs to + * @param {Cesium3DTileset} tileset The tileset that this content belongs to + * @param {Cesium3DTile} tile The tile that this content belongs to * @param {Resource} tilesetResource The resource that points to the tileset. This will be used to derive each inner content's resource. * @param {object} contentJson The content JSON that contains the 'dynamicContents' array + * @throws {DeveloperError} If the tileset does not contain the + * top-level dynamic content extension object. * * @private */ constructor(tileset, tile, tilesetResource, contentJson) { + /** + * The tileset that this content belongs to. + * + * The 'dynamicContentPropertyProvider' of this tileset will be + * used to determine which contents are currently "active" in + * the "_activeContentUris" getter. + * + * @type {Cesium3DTileset} + * @readonly + */ this._tileset = tileset; + + /** + * The tile that this content belongs to. + * + * This is only required for the Cesium3DTileContent implementation, + * and for handing it on to "finishContent". + * + * @type {Cesium3DTile} + * @readonly + */ this._tile = tile; - this._baseResource = tilesetResource; /** * The array of content objects. @@ -918,7 +1058,8 @@ class Dynamic3DTileContent { * are used for selecting the "active" content at any * point in time. * - * @type {object[]} The dynamic contents array + * @type {object[]} + * @readonly */ this._dynamicContents = contentJson.dynamicContents; @@ -933,7 +1074,7 @@ class Dynamic3DTileContent { * @type {Map} * @readonly */ - this._contentHandles = this._createContentHandles(); + this._contentHandles = this._createContentHandles(tilesetResource); /** * The maximum number of content objects that should be kept @@ -961,6 +1102,7 @@ class Dynamic3DTileContent { * trimToSize function accordingly. * * @type {LRUCache} + * @readonly */ this._loadedContentHandles = new LRUCache( Number.POSITIVE_INFINITY, @@ -981,6 +1123,7 @@ class Dynamic3DTileContent { * values (URIs) are the URIs of the contents that are currently active. * * @type {NDMap} + * @readonly */ this._dynamicContentUriLookup = this._createDynamicContentUriLookup(); @@ -990,7 +1133,7 @@ class Dynamic3DTileContent { * It will be applied to all "active" contents in the 'update' * function. * - * @type {Cesium3DTileStyle} + * @type {Cesium3DTileStyle|undefined} */ this._lastStyle = DYNAMIC_CONTENT_SHOW_STYLE; } @@ -1021,17 +1164,18 @@ class Dynamic3DTileContent { * will be used for tracking the process of requesting and * creating the content objects. * + * @param {Resource} baseResource The base resource (from the tileset) * @returns {Map} */ - _createContentHandles() { + _createContentHandles(baseResource) { const dynamicContents = this._dynamicContents; const contentHandles = new Map(); for (let i = 0; i < dynamicContents.length; i++) { const contentHeader = dynamicContents[i]; const contentHandle = new ContentHandle( - this._tile, - this._baseResource, + this.tile, + baseResource, contentHeader, ); const uri = contentHeader.uri; @@ -1046,12 +1190,17 @@ class Dynamic3DTileContent { * associated with these keys. * * @returns {NDMap} The mapping + * @throws {DeveloperError} If the tileset does not contain the + * top-level dynamic content extension object. */ _createDynamicContentUriLookup() { - // XXX_DYNAMIC This assumes the presence and structure - // of the extension object. Add error handling here. const tileset = this.tileset; const topLevelExtensionObject = tileset.extensions["3DTILES_dynamic"]; + if (!defined(topLevelExtensionObject)) { + throw new DeveloperError( + "Cannot create a Dynamic3DTileContent for a tileset that does not contain a top-level dynamic content extension object.", + ); + } const dimensions = topLevelExtensionObject.dimensions; const dimensionNames = dimensions.map((d) => d.name); @@ -1081,7 +1230,7 @@ class Dynamic3DTileContent { * @type {string[]} The active content URIs */ get _activeContentUris() { - const tileset = this._tileset; + const tileset = this.tileset; let dynamicContentPropertyProvider = tileset.dynamicContentPropertyProvider; // XXX_DYNAMIC For testing @@ -1159,6 +1308,17 @@ class Dynamic3DTileContent { /** * Part of the {@link Cesium3DTileContent} interface. Checks if any of the inner contents have dirty featurePropertiesDirty. * + * XXX_DYNAMIC: This is offered by each Cesium3DTileContent, with varying + * degrees of enthusiasm about how meaningful it is. It is only used in + * Cesium3DTilesetTraversal.selectTile, where it dertermines whether + * tiles go into the tileset._selectedTilesToStyle, which seems to be + * some sort of optimization attempt to only style "changed" tiles + * (and not all selected tiles). It's quickly getting convoluted from + * there. Some "styleDirty" flag seems to be important... + * TL;DR: Let's skip theoretical optimizations (otherwise: SHOW ME + * THE BENCHMARK!) - likely, this should just always return true + * or false, leaving optimizations to the applyStyle function. + * * @type {boolean} */ get featurePropertiesDirty() { @@ -1284,7 +1444,6 @@ class Dynamic3DTileContent { * * @type {string} * @readonly - * @private */ get url() { return undefined; @@ -1292,20 +1451,12 @@ class Dynamic3DTileContent { /** * Part of the {@link Cesium3DTileContent} interface. - * - * Always returns undefined. Instead call metadata for a specific inner content. */ get metadata() { return undefined; } set metadata(value) { - ////>>includeStart('debug', pragmas.debug); - //throw new DeveloperError("This content cannot have metadata"); - ////>>includeEnd('debug'); - console.log( - "Assigning metadata to dynamic content - what should that even mean?", - value, - ); + // Ignored } /** @@ -1319,16 +1470,12 @@ class Dynamic3DTileContent { /** * Part of the {@link Cesium3DTileContent} interface. - * - * Always returns undefined. Instead call group for a specific inner content. */ get group() { return undefined; } set group(value) { - //>>includeStart('debug', pragmas.debug); - throw new DeveloperError("This content cannot have group metadata"); - //>>includeEnd('debug'); + // Ignored } /** @@ -1353,6 +1500,8 @@ class Dynamic3DTileContent { * Always returns false. Instead call hasProperty for a specific inner content */ hasProperty(batchId, name) { + // XXX_DYNAMIC Does it make sense to just iterate over + // the activeContents and check them...? return false; } @@ -1362,6 +1511,8 @@ class Dynamic3DTileContent { * Always returns undefined. Instead call getFeature for a specific inner content */ getFeature(batchId) { + // XXX_DYNAMIC Does it make sense to just iterate over + // the activeContents and check them...? return undefined; } @@ -1369,12 +1520,8 @@ class Dynamic3DTileContent { * Part of the {@link Cesium3DTileContent} interface. */ applyDebugSettings(enabled, color) { - // XXX_DYNAMIC This has to store the last state, probably, - // and assign it in "update" to all contents that became active - const allContents = this._allContents; - for (const content of allContents) { - content.applyDebugSettings(enabled, color); - } + this._lastDebugSettingsEnabled = enabled; + this._lastDebugSettingsColor = color; } /** @@ -1382,10 +1529,6 @@ class Dynamic3DTileContent { */ applyStyle(style) { this._lastStyle = style; - const activeContents = this._activeContents; - for (const activeContent of activeContents) { - activeContent.applyStyle(style); - } } /** @@ -1417,6 +1560,17 @@ class Dynamic3DTileContent { activeContent.applyStyle(this._lastStyle); } + // Assign debug settings to all active contents + for (const activeContent of activeContents) { + // The applyDebugSettings call will override any/ style color + // that was previously set. I'm not gonna sort this out. + if (this._lastDebugSettingsEnabled) { + activeContent.applyDebugSettings( + this._lastDebugSettingsEnabled, + this._lastDebugSettingsColor, + ); + } + } this._unloadOldContent(); } @@ -1459,23 +1613,6 @@ class Dynamic3DTileContent { loadedContentHandles.trimToSize(this._loadedContentHandlesMaxSize); } - // XXX_DYNAMIC Unused right now... - /** - * Computes the difference of the given iterables. - * - * This will return a set containing all elements from the - * first iterable, omitting the ones from the second iterable. - * - * @param {Iterable} iterable0 The base set - * @param {Iterable} iterable1 The set to remove - * @returns {Iterable} The difference - */ - static _difference(iterable0, iterable1) { - const difference = new Set(iterable0); - iterable1.forEach((e) => difference.delete(e)); - return difference; - } - /** * Part of the {@link Cesium3DTileContent} interface. * From 0daebfe83261dfa955cd87cc3517ee40f9e5f1bd Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Mon, 3 Nov 2025 18:45:38 +0100 Subject: [PATCH 5/9] Clanups and drafts for specs and statistics --- .../engine/Source/Scene/Cesium3DTileset.js | 83 +- .../Source/Scene/Dynamic3DTileContent.js | 569 +++++++-- .../Specs/Scene/Dynamic3DTileContentSpec.js | 1068 +++++++++++++++++ 3 files changed, 1623 insertions(+), 97 deletions(-) create mode 100644 packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index c7038b994637..71332c239fd0 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -1147,6 +1147,17 @@ function Cesium3DTileset(options) { instanceFeatureIdLabel = `instanceFeatureId_${instanceFeatureIdLabel}`; } this._instanceFeatureIdLabel = instanceFeatureIdLabel; + + /** + * The function that determines which inner contents of a dynamic + * contents object are currently active. + * + * See setDynamicContentPropertyProvider for details. + * + * @type {Function|undefined} + * @private + */ + this._dynamicContentPropertyProvider = undefined; } Object.defineProperties(Cesium3DTileset.prototype, { @@ -2173,6 +2184,21 @@ Object.defineProperties(Cesium3DTileset.prototype, { this._instanceFeatureIdLabel = value; }, }, + + /** + * Returns the function that provides the properties based on + * which inner contents of a dynamic content should be active. + * + * @memberof Cesium3DTileset.prototype + * @readonly + * @type {Function|undefined} + * @private + */ + dynamicContentPropertyProvider: { + get: function () { + return this._dynamicContentPropertyProvider; + }, + }, }); /** @@ -2332,7 +2358,9 @@ Cesium3DTileset.fromUrl = async function (url, options) { ); // Extract the information about the "dimensions" of the dynamic contents, - // if present + // if present. + // XXX_DYNAMIC This should probably not be done here, but ... + // maybe in the constructor or so...? The lifecycle, though... const hasDynamicContents = hasExtension(tilesetJson, "3DTILES_dynamic"); if (hasDynamicContents) { const dynamicContentsExtension = tilesetJson.extensions["3DTILES_dynamic"]; @@ -2446,7 +2474,7 @@ Cesium3DTileset.prototype.loadTileset = function ( * Set the function that determines which dynamic content is currently active. * * This is a function that returns a JSON plain object. This object corresponds - * to one 'key' of a dynamic content definition. It will caused the content + * to one 'key' of a dynamic content definition. It will cause the content * with this key to be the currently active content. * * @param {Function|undefined} dynamicContentPropertyProvider The function @@ -2461,11 +2489,58 @@ Cesium3DTileset.prototype.setDynamicContentPropertyProvider = function ( console.log( "This tileset does not contain the 3DTILES_dynamic extension. The given function will not have an effect.", ); - return; } - this.dynamicContentPropertyProvider = dynamicContentPropertyProvider; + this._dynamicContentPropertyProvider = dynamicContentPropertyProvider; }; +/** + * XXX_DYNAMIC A draft for a convenience function for the dynamic content + * properties provider. Whether or not this should be offered depends on + * how much we want to specialize all this for single ISO8601 date strings. + * We could even omit the "timeDimensionName" if this was a fixed, specified + * string like "isoTimeStamp" or so. + * + * --- + * + * Set the function that determines which dynamic content is currently active, + * based on the ISO8601 string representation of the current time of the given + * clock. + * + * @param {string} timeDimensionName The name of the property that will + * contain the ISO8601 date string of the current time of the clock + * @param {Clock} clock The clock that provides the current time + */ +Cesium3DTileset.prototype.setDefaultTimeDynamicContentPropertyProvider = + function (timeDimensionName, clock) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.string("timeDimensionName", timeDimensionName); + Check.typeOf.object("clock", clock); + //>>includeEnd('debug'); + + const dimensions = this._dynamicContentsDimensions; + if (defined(dimensions)) { + const dimensionNames = dimensions.map((d) => d.name); + if (!dimensionNames.includes(timeDimensionName)) { + console.log( + `The time dimension name ${timeDimensionName} is not a valid dimension name. Valid dimension names are`, + dimensionNames, + ); + } + } + + const dynamicContentPropertyProvider = () => { + const currentTime = clock.currentTime; + if (!defined(currentTime)) { + return undefined; + } + const currentTimeString = JulianDate.toIso8601(currentTime); + return { + [timeDimensionName]: currentTimeString, + }; + }; + this.setDynamicContentPropertyProvider(dynamicContentPropertyProvider); + }; + /** * Make a {@link Cesium3DTile} for a specific tile. If the tile's header has implicit * tiling (3D Tiles 1.1) or uses the 3DTILES_implicit_tiling extension, diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index bf2ddf65b879..6db5a71b27e9 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -44,6 +44,9 @@ class NDMap { * in the 'key' for set/get operations, to determine the coordinates * within the N-dimensional space. * + * The given array may not be modified after it was passed to + * this constructor. + * * @param {string[]} dimensionNames */ constructor(dimensionNames) { @@ -143,7 +146,7 @@ class NDMap { /** * Delete the entry from the given key, if it exists. * - * @param {key} key The key + * @param {object} key The key */ delete(key) { const lookupKey = this._computeLookupKey(key); @@ -206,6 +209,24 @@ class NDMap { [Symbol.iterator]() { return this.entries(); } + + /** + * Returns the value corresponding to the specified key, creating and + * inserting it if it was not yet present, using the given function + * for its creation. + * + * @param {object} key The key + * @param {Function} defaultCreator The default creator + */ + getOrInsertComputed(key, defaultCreator) { + const lookupKey = this._computeLookupKey(key); + if (this._lookup.has(lookupKey)) { + return this._lookup.get(lookupKey); + } + const value = defaultCreator(); + this._lookup.set(lookupKey, value); + return value; + } } /** @@ -403,16 +424,55 @@ class LRUCache { } } -// XXX_DYNAMIC Experiments for that tileset statistics handling... -// eslint-disable-next-line no-unused-vars +/** + * Interface for all classes that want to be informed about the + * state of a request + */ class RequestListener { + /** + * Will be called when the given request was attempted. + * + * This means that the request was started, and then + * was cancelled or failed (but not completed). + * + * @param {Request} request The request + */ requestAttempted(request) {} + + /** + * Will be called when the given request was started. + * + * @param {Request} request The request + */ requestStarted(request) {} + + /** + * Will be called when the given request was cancelled. + * + * @param {Request} request The request + */ requestCancelled(request) {} + + /** + * Will be called when the given request was completed. + * + * @param {Request} request The request + */ requestCompleted(request) {} + + /** + * Will be called when the given request failed + * + * @param {Request} request The request + */ requestFailed(request) {} } -class LoggingRequestListener { + +/** + * Implementation of a RequestListener that just logs the + * request states to the console. + */ +class LoggingRequestListener extends RequestListener { requestAttempted(request) { console.log(`requestAttempted for ${request.url}`); } @@ -479,15 +539,29 @@ class RequestHandle { /** * The listeners that will be informed about the request state * - * @type {RequestListener[]} + * @type {Set} * @readonly */ - this._requestListeners = []; + this._requestListeners = new Set(); } - // XXX_DYNAMIC Experiments for that tileset statistics handling... + /** + * Add the given listener to be informed about the state of the + * underlying request. + * + * @param {RequestListener} requestListener The listener + */ addRequestListener(requestListener) { - this._requestListeners.push(requestListener); + this._requestListeners.add(requestListener); + } + + /** + * Remove the given listener + * + * @param {RequestListener} requestListener The listener + */ + removeRequestListener(requestListener) { + this._requestListeners.delete(requestListener); } /** @@ -495,7 +569,7 @@ class RequestHandle { * * This will never be 'undefined'. It will never change. It will * just be a promise that is either fulfilled with the response - * data from the equest, or rejected with an error indicating + * data from the request, or rejected with an error indicating * the reason for the rejection. * * The reason for the rejection can either be a real error, @@ -633,27 +707,45 @@ class RequestHandle { this._deferred.reject(RequestState.CANCELLED); } - // XXX_DYNAMIC Experiments for that tileset statistics handling... + /** + * Inform all registered listeners that the request was attempted + */ _fireRequestAttempted() { for (const requestListener of this._requestListeners) { requestListener.requestAttempted(this._request); } } + + /** + * Inform all registered listeners that the request was started + */ _fireRequestStarted() { for (const requestListener of this._requestListeners) { requestListener.requestStarted(this._request); } } + + /** + * Inform all registered listeners that the request was cancelled + */ _fireRequestCancelled() { for (const requestListener of this._requestListeners) { requestListener.requestCancelled(this._request); } } + + /** + * Inform all registered listeners that the request was completed + */ _fireRequestCompleted() { for (const requestListener of this._requestListeners) { requestListener.requestCompleted(this._request); } } + + /** + * Inform all registered listeners that the request failed + */ _fireRequestFailed() { for (const requestListener of this._requestListeners) { requestListener.requestFailed(this._request); @@ -661,6 +753,41 @@ class RequestHandle { } } +/** + * Interface for all classes that want to be informed about the + * state of a content + */ +class ContentListener { + /** + * Will be called when the given content was loaded + * and became 'ready' + * + * @param {Cesium3DTileContent} content The content + */ + contentLoadedAndReady(content) {} + + /** + * Will be called when the given content is unloaded, + * immediately before calling its 'destroy' method. + * + * @param {Cesium3DTileContent} content The content + */ + contentUnloaded(content) {} +} + +/** + * Implementation of a ContentListener that just logs the + * states to the console. + */ +class LoggingContentListener extends ContentListener { + contentLoadedAndReady(content) { + console.log(`contentLoadedAndReady for `, content); + } + contentUnloaded(content) { + console.log(`contentUnloaded for `, content); + } +} + /** * A class summarizing what is necessary to request tile content. * @@ -761,6 +888,87 @@ class ContentHandle { * @type {boolean} */ this._failed = false; + + /** + * Only used for testing. See awaitPromise. + * @type {object} + * @readonly + */ + this._deferred = defer(); + + /** + * The listeners that will be informed about the state of the + * request that is created and handled by this instance. + * + * @type {Set} + * @readonly + */ + this._requestListeners = new Set(); + + /** + * The listeners that will be informed about the state of the + * content that is handled by this instance. + * + * @type {Set} + * @readonly + */ + this._contentListeners = new Set(); + } + + /** + * XXX_DYNAMIC: Only intended for testing: If there is a pending + * request for the content, then wait until the content is + * created, or the content creation failed. + * + * This is here because all the request handling is in the content + * classes, without abstractions and clear lifecycle definitions. + */ + async awaitPromise() { + if (defined(this._requestHandle)) { + try { + await this._deferred.promise; + } catch (error) { + // Ignored + } + } + } + + /** + * Add the given listener to be informed about the state of the + * underlying request. + * + * @param {RequestListener} requestListener The listener + */ + addRequestListener(requestListener) { + this._requestListeners.add(requestListener); + } + + /** + * Remove the given listener + * + * @param {RequestListener} requestListener The listener + */ + removeRequestListener(requestListener) { + this._requestListeners.delete(requestListener); + } + + /** + * Add the given listener to be informed about the state of the + * content. + * + * @param {ContentListener} contentListener The listener + */ + addContentListener(contentListener) { + this._contentListeners.add(contentListener); + } + + /** + * Remove the given listener + * + * @param {ContentListener} contentListener The listener + */ + removeContentListener(contentListener) { + this._contentListeners.delete(contentListener); } /** @@ -856,26 +1064,9 @@ class ContentHandle { }); const requestHandle = new RequestHandle(resource); - // Attach a listener that will update the tileset statistics - const tileset = this._tile.tileset; - requestHandle.addRequestListener(new LoggingRequestListener()); - requestHandle.addRequestListener({ - requestAttempted(request) { - tileset.statistics.numberOfAttemptedRequests++; - }, - requestStarted(request) { - tileset.statistics.numberOfPendingRequests++; - }, - requestCancelled(request) { - tileset.statistics.numberOfPendingRequests--; - }, - requestCompleted(request) { - tileset.statistics.numberOfPendingRequests--; - }, - requestFailed(request) { - tileset.statistics.numberOfPendingRequests--; - }, - }); + for (const requestListener of this._requestListeners) { + requestHandle.addRequestListener(requestListener); + } this._requestHandle = requestHandle; const requestHandleResultPromise = requestHandle.getResultPromise(); @@ -889,12 +1080,13 @@ class ContentHandle { const content = await this._createContent(resource, arrayBuffer); console.log(`ContentHandle: Content was created for ${uri}`); this._content = content; - // XXX_DYNAMIC Trigger some update...?! + this._deferred.resolve(); } catch (error) { console.log( `ContentHandle: Content creation for ${uri} caused error ${error}`, ); this._failed = true; + this._deferred.resolve(); } }; @@ -916,12 +1108,14 @@ class ContentHandle { `ContentHandle: Request was rejected for ${uri}, but actually only cancelled. Better luck next time!`, ); this._requestHandle = undefined; + this._deferred.resolve(); return; } // Other errors should indeed cause this handle // to be marked as "failed" this._failed = true; + this._deferred.resolve(); }; requestHandleResultPromise.then(onRequestFulfilled, onRequestRejected); requestHandle.ensureRequested(); @@ -960,10 +1154,56 @@ class ContentHandle { } this._requestHandle = undefined; if (defined(this._content)) { + this._fireContentUnloaded(this._content); this._content.destroy(); } this._content = undefined; this._failed = false; + this._deferred = defer(); + } + + /** + * Wrapper around content.update, for implementing the + * Cesium3DTileContent interface... + * + * @param {Cesium3DTileset} tileset The tileset + * @param {FrameState} frameState The frame state + */ + updateContent(tileset, frameState) { + const content = this._content; + if (!defined(content)) { + return; + } + const oldReady = content.ready; + content.update(tileset, frameState); + const newReady = content.ready; + if (!oldReady && newReady) { + this._fireContentLoadedAndReady(content); + } + } + + /** + * Inform all registered listeners that the content was loaded + * and became 'ready' (meaning that it was really loaded...) + * + * @param {Cesium3DTileContent} content The content + */ + _fireContentLoadedAndReady(content) { + for (const contentListener of this._contentListeners) { + contentListener.contentLoadedAndReady(content); + } + } + + /** + * Inform all registered listeners that the content was unloaded, + * just before it is destroyed + * + * @param {Cesium3DTileContent} content The content + */ + _fireContentUnloaded(content) { + for (const contentListener of this._contentListeners) { + contentListener.contentUnloaded(content); + } } } @@ -1153,7 +1393,7 @@ class Dynamic3DTileContent { * @param {ContentHandle} contentHandle The ContentHandle */ loadedContentHandleEvicted(uri, contentHandle) { - console.log("_loadedContentHandleEvicted with ", uri); + console.log(`_loadedContentHandleEvicted with ${uri}`); contentHandle.reset(); } @@ -1165,7 +1405,7 @@ class Dynamic3DTileContent { * creating the content objects. * * @param {Resource} baseResource The base resource (from the tileset) - * @returns {Map} + * @returns {Map} The content handles */ _createContentHandles(baseResource) { const dynamicContents = this._dynamicContents; @@ -1178,12 +1418,62 @@ class Dynamic3DTileContent { baseResource, contentHeader, ); + this._attachTilesetStatisticsTracker(contentHandle); + const uri = contentHeader.uri; contentHandles.set(uri, contentHandle); } return contentHandles; } + /** + * Attach a listener to the given content handle that will update + * the tileset statistics based on the request state. + * + * @param {ContentHandle} contentHandle The content handle + */ + _attachTilesetStatisticsTracker(contentHandle) { + // XXX_DYNAMIC Debug logs... + contentHandle.addRequestListener(new LoggingRequestListener()); + contentHandle.addContentListener(new LoggingContentListener()); + + const tileset = this._tile.tileset; + contentHandle.addRequestListener({ + requestAttempted(request) { + tileset.statistics.numberOfAttemptedRequests++; + }, + requestStarted(request) { + tileset.statistics.numberOfPendingRequests++; + }, + requestCancelled(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestCompleted(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestFailed(request) { + tileset.statistics.numberOfPendingRequests--; + }, + }); + + contentHandle.addContentListener({ + contentLoadedAndReady(content) { + console.log( + "-------------------------- update statistics for loaded ", + content, + ); + tileset.statistics.incrementLoadCounts(content); + }, + contentUnloaded(content) { + console.log( + "-------------------------- update statistics for unloaded ", + content, + ); + tileset.statistics.decrementLoadCounts(content); + }, + }); + } + /** * Creates the mapping from the "keys" that are found in the * 'dynamicContents' array, to the arrays of URLs that are @@ -1195,7 +1485,8 @@ class Dynamic3DTileContent { */ _createDynamicContentUriLookup() { const tileset = this.tileset; - const topLevelExtensionObject = tileset.extensions["3DTILES_dynamic"]; + const extensions = tileset.extensions ?? {}; + const topLevelExtensionObject = extensions["3DTILES_dynamic"]; if (!defined(topLevelExtensionObject)) { throw new DeveloperError( "Cannot create a Dynamic3DTileContent for a tileset that does not contain a top-level dynamic content extension object.", @@ -1208,12 +1499,11 @@ class Dynamic3DTileContent { const dynamicContentUriLookup = new NDMap(dimensionNames); for (let i = 0; i < dynamicContents.length; i++) { const dynamicContent = dynamicContents[i]; - let entries = dynamicContentUriLookup.get(dynamicContent.keys); - if (!defined(entries)) { - entries = Array(); - dynamicContentUriLookup.set(dynamicContent.keys, entries); - } const uri = dynamicContent.uri; + const key = dynamicContent.keys; + const entries = dynamicContentUriLookup.getOrInsertComputed(key, () => + Array(), + ); entries.push(uri); } return dynamicContentUriLookup; @@ -1227,25 +1517,30 @@ class Dynamic3DTileContent { * '_dynamicContentUriLookup'. This method returns the array of * URIs that are found in that lookup, for the respective key. * + * If there is no dynamicContentPropertyProvider, then an empty + * array will be returned. + * + * If the dynamicContentPropertyProvider returns undefined, then + * an empty array will be returned. + * + * If there are no active contents, then an empty array will be + * returned. + * + * Callers may NOT modify the returned array. + * * @type {string[]} The active content URIs */ get _activeContentUris() { const tileset = this.tileset; - let dynamicContentPropertyProvider = tileset.dynamicContentPropertyProvider; - - // XXX_DYNAMIC For testing + const dynamicContentPropertyProvider = + tileset.dynamicContentPropertyProvider; if (!defined(dynamicContentPropertyProvider)) { - console.log("No dynamicContentPropertyProvider, using default"); - dynamicContentPropertyProvider = () => { - return { - exampleTimeStamp: "2025-09-26", - exampleRevision: "revision2", - }; - }; - tileset.dynamicContentPropertyProvider = dynamicContentPropertyProvider; + return []; } - const currentProperties = dynamicContentPropertyProvider(); + if (!defined(currentProperties)) { + return []; + } const lookup = this._dynamicContentUriLookup; const currentEntries = lookup.get(currentProperties) ?? []; return currentEntries; @@ -1259,6 +1554,11 @@ class Dynamic3DTileContent { * it was already requested and created, it will be contained in * the returned array. * + * If there are no active contents, then an empty array will be + * returned. + * + * Callers may NOT modify the returned array. + * * @type {Cesium3DTileContent[]} */ get _activeContents() { @@ -1274,35 +1574,21 @@ class Dynamic3DTileContent { return activeContents; } - /** - * Returns ALL content URIs that have been defined as contents - * in the dynamic content definition. - * - * @type {string[]} The content URIs - */ - get _allContentUris() { - // TODO Should be computed from the dynamicContents, - // once, in the constructor, as a SET (!) - const keys = this._contentHandles.keys(); - const allContentUris = [...keys]; - return allContentUris; - } - /** * Returns ALL contents that are currently loaded. * * @type {Cesium3DTileContent[]} The contents */ - get _allContents() { - const allContents = []; + get _allLoadedContents() { + const allLoadedContents = []; const contentHandleValues = this._contentHandles.values(); for (const contentHandle of contentHandleValues) { const content = contentHandle.getContentOptional(); if (defined(content)) { - allContents.push(content); + allLoadedContents.push(content); } } - return allContents; + return allLoadedContents; } /** @@ -1322,8 +1608,8 @@ class Dynamic3DTileContent { * @type {boolean} */ get featurePropertiesDirty() { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { if (content.featurePropertiesDirty) { return true; } @@ -1332,83 +1618,178 @@ class Dynamic3DTileContent { return false; } set featurePropertiesDirty(value) { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.featurePropertiesDirty = value; } } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call featuresLength for a specific inner content. * * @type {number} * @readonly */ get featuresLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("featuresLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead, call pointsLength for a specific inner content. * * @type {number} * @readonly */ get pointsLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("pointsLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call trianglesLength for a specific inner content. * * @type {number} * @readonly */ get trianglesLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("trianglesLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call geometryByteLength for a specific inner content. * * @type {number} * @readonly */ get geometryByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("geometryByteLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call texturesByteLength for a specific inner content. * * @type {number} * @readonly */ get texturesByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("texturesByteLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call batchTableByteLength for a specific inner content. * * @type {number} * @readonly */ get batchTableByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("batchTableByteLength"); return 0; } + /** + * Calls getAggregated with each loaded content and the given + * property, and returns the sum. + * + * See getAggregated for details. + * + * @param {string} property The property + * @returns The result + */ + getAggregatedLoaded(property) { + const allLoadedContents = this._allLoadedContents; + let result = 0; + for (const content of allLoadedContents) { + result += Dynamic3DTileContent.getAggregated(content, property); + } + return result; + } + + /** + * The Cesium3DTileContent interface does not really make sense. + * + * It is underspecified, the functions/properties that it contains have no + * coherence, and most of them do not make sense for most implementations. + * The way how that interface and its functions are used shows that + * ambiguity and vagueness, even without the corner case of dynamic + * content. For example, the "tile debug labels" show a geometry- and + * memory size of 0 for composite content, because the function that + * creates these labels is not aware that Composite3DTileContent and + * Multiple3DTileContent require it to iterate over the "innerContents". + * Some of the functions are called at places where the state of + * the content is not clear, including Cesium3DTile.process, in the + * block with that "if (...!this.contentReady && this._content.ready)" + * statement that does not make sense for dynamic content. (This could + * be avoided by proper state management, but let's not get into that). + * + * So this function tries to squeeze some sense out of what is there: + * + * It fetches the value of the specified property of the given content, + * or the sum of the values from recursing into "innerContents" if + * the latter are defined. + * + * Note that a content could have the specified property AND innerContents. + * This function could take the value from the content itself, and ADD the + * values from the inner contents. But if, at any point in time, the + * implementation of the composite- and multiple content are fixed by + * computing this sum on their own, such an implementation would break. + * + * At some point, we have to shrug this off. + * + * @param {Cesium3DTileContent} content The content + * @param {string} property The property + * @returns The result + */ + static getAggregated(content, property) { + const innerContents = content.innerContents; + if (defined(innerContents)) { + let sum = 0; + for (const innerContent of content.innerContents) { + sum += Dynamic3DTileContent.getAggregated(innerContent[property]); + } + return sum; + } + return content[property]; + } + /** * Part of the {@link Cesium3DTileContent} interface. */ get innerContents() { - return this._allContents; + // XXX_DYNAMIC It's not clear whether this should return + // the loaded contents. Most of the tracking that could + // require clients to call this function should happen + // INSIDE this class, because the "inner contents" can + // be loaded and unloaded at any point in time. + //return this._allLoadedContents; + return []; } /** @@ -1535,10 +1916,9 @@ class Dynamic3DTileContent { * Part of the {@link Cesium3DTileContent} interface. */ update(tileset, frameState) { - // Call the 'update' on all contents. - const allContents = this._allContents; - for (const content of allContents) { - content.update(tileset, frameState); + // Call update for all contents + for (const contentHandle of this._contentHandles.values()) { + contentHandle.updateContent(tileset, frameState); } // XXX_DYNAMIC There is no way to show or hide contents. @@ -1550,7 +1930,8 @@ class Dynamic3DTileContent { // It could be called "doRandomStuff" at this point. // Hide all contents. - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.applyStyle(DYNAMIC_CONTENT_HIDE_STYLE); } @@ -1562,7 +1943,7 @@ class Dynamic3DTileContent { // Assign debug settings to all active contents for (const activeContent of activeContents) { - // The applyDebugSettings call will override any/ style color + // The applyDebugSettings call will override any style color // that was previously set. I'm not gonna sort this out. if (this._lastDebugSettingsEnabled) { activeContent.applyDebugSettings( @@ -1578,10 +1959,10 @@ class Dynamic3DTileContent { * Unload the least-recently used content. */ _unloadOldContent() { - // Collect all content handles that have a content that - // is currently loaded - const loadedContentHandles = this._loadedContentHandles; + // Iterate over all content handles. If the content of a certain handle + // is currently loaded, then store it in the loadedContentHandles. const contentHandleEntries = this._contentHandles.entries(); + const loadedContentHandles = this._loadedContentHandles; for (const [url, contentHandle] of contentHandleEntries) { if (!loadedContentHandles.has(url)) { const content = contentHandle.getContentOptional(); @@ -1591,7 +1972,8 @@ class Dynamic3DTileContent { } } - // Mark the active contents as "recently used" + // Mark the "active" contents as "recently used", to prevent + // them from being evicted from the loadedContentHandles cache const activeContentUris = this._activeContentUris; for (const activeContentUri of activeContentUris) { if (loadedContentHandles.has(activeContentUri)) { @@ -1616,7 +1998,8 @@ class Dynamic3DTileContent { /** * Part of the {@link Cesium3DTileContent} interface. * - * Find an intersection between a ray and the tile content surface that was rendered. The ray must be given in world coordinates. + * Find an intersection between a ray and the tile content surface that was + * rendered. The ray must be given in world coordinates. * * @param {Ray} ray The ray to test for intersection. * @param {FrameState} frameState The frame state. @@ -1660,8 +2043,8 @@ class Dynamic3DTileContent { * Part of the {@link Cesium3DTileContent} interface. */ destroy() { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.destroy(); } return destroyObject(this); diff --git a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js new file mode 100644 index 000000000000..d32d7011e8cf --- /dev/null +++ b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js @@ -0,0 +1,1068 @@ +import { Cesium3DTileset, Resource } from "../../index.js"; +import createScene from "../../../../Specs/createScene.js"; +import Dynamic3DTileContent from "../../Source/Scene/Dynamic3DTileContent.js"; +import Clock from "../../Source/Core/Clock.js"; +import JulianDate from "../../Source/Core/JulianDate.js"; +import ClockRange from "../../Source/Core/ClockRange.js"; +import ClockStep from "../../Source/Core/ClockStep.js"; +import generateJsonBuffer from "../../../../Specs/generateJsonBuffer.js"; +import ContextLimits from "../../Source/Renderer/ContextLimits.js"; + +const basicDynamicExampleExtensionObject = { + dimensions: [ + { + name: "exampleTimeStamp", + keySet: ["2025-09-25", "2025-09-26"], + }, + { + name: "exampleRevision", + keySet: ["revision0", "revision1"], + }, + ], +}; + +const basicDynamicExampleContent = { + dynamicContents: [ + { + uri: "exampleContent-2025-09-25-revision0.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }, + }, + { + uri: "exampleContent-2025-09-25-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision1", + }, + }, + { + uri: "exampleContent-2025-09-26-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-26", + exampleRevision: "revision0", + }, + }, + { + uri: "exampleContent-2025-09-26-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-26", + exampleRevision: "revision1", + }, + }, + ], +}; + +const basicDynamicExampleTilesetJson = { + asset: { + version: "1.1", + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + + geometricError: 4096, + root: { + boundingVolume: { + box: [32.0, -1.5, 0, 32.0, 0, 0, 0, 1.5, 0, 0, 0, 0], + }, + geometricError: 512, + content: { + uri: "content.json", + }, + refine: "REPLACE", + }, +}; + +const isoDynamicExampleExtensionObject = { + dimensions: [ + { + name: "exampleIsoTimeStamp", + keySet: ["2013-12-25T00:00:00Z", "2013-12-26T00:00:00Z"], + }, + ], +}; + +const isoDynamicExampleContent = { + dynamicContents: [ + { + uri: "exampleContent-iso-A.glb", + keys: { + exampleIsoTimeStamp: "2013-12-25T00:00:00Z", + }, + }, + { + uri: "exampleContent-iso-B.glb", + keys: { + exampleIsoTimeStamp: "2013-12-26T00:00:00Z", + exampleRevision: "revision1", + }, + }, + ], +}; + +function createDummyGltfBuffer() { + const gltf = { + asset: { + version: "2.0", + }, + }; + return generateJsonBuffer(gltf).buffer; +} + +describe( + "Scene/Dynamic3DTileContent", + function () { + let scene; + + const tilesetResource = new Resource({ url: "http://example.com" }); + + beforeAll(function () { + scene = createScene(); + }); + + afterAll(function () { + scene.destroyForSpecs(); + }); + + afterEach(function () { + scene.primitives.removeAll(); + }); + + it("___XXX_DYNAMIC_WORKS___", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + // eslint-disable-next-line no-unused-vars + let mockResolve; + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now reject the pending request, and wait for things to settle... + mockReject("SPEC_REJECTION"); + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(0); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); + }); + + it("BASIC___XXX_DYNAMIC_WORKS___", function () { + // For spec: Create a dummy tileset and fill it + // with the necessary (private!) properties + const tileset = new Cesium3DTileset(); + tileset._extensions = { + "3DTILES_dynamic": isoDynamicExampleExtensionObject, + }; + tileset._dynamicContentsDimensions = + isoDynamicExampleExtensionObject.dimensions; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + isoDynamicExampleContent, + ); + + // Create a dummy clock for the dynamic content property provider + const clock = new Clock({ + startTime: JulianDate.fromIso8601("2013-12-25"), + currentTime: JulianDate.fromIso8601("2013-12-25"), + stopTime: JulianDate.fromIso8601("2013-12-26"), + clockRange: ClockRange.LOOP_STOP, + clockStep: ClockStep.SYSTEM_CLOCK_MULTIPLIER, + }); + tileset.setDefaultTimeDynamicContentPropertyProvider( + "exampleIsoTimeStamp", + clock, + ); + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual(["exampleContent-iso-A.glb"]); + + // Change the current clock time, and expect this + // to be reflected in the active content URIs + clock.currentTime = JulianDate.fromIso8601("2013-12-26"); + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual(["exampleContent-iso-B.glb"]); + }); + + it("___QUARRY___XXX_DYNAMIC_WORKS___", function () { + const tileset = basicDynamicExampleTilesetJson; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual([ + "exampleContent-2025-09-25-revision0.glb", + ]); + + // Change the dynamic content properties, and expect + // this to be reflected in the active content URIs + dynamicContentProperties.exampleRevision = "revision1"; + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual([ + "exampleContent-2025-09-25-revision1.glb", + ]); + }); + + //======================================================================== + // Experimental + + it("returns the active content URIs matching the object that is returned by the default time-dynamic content property provider", function () { + // For spec: Create a dummy tileset and fill it + // with the necessary (private!) properties + const tileset = new Cesium3DTileset(); + tileset._extensions = { + "3DTILES_dynamic": isoDynamicExampleExtensionObject, + }; + tileset._dynamicContentsDimensions = + isoDynamicExampleExtensionObject.dimensions; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + isoDynamicExampleContent, + ); + + // Create a dummy clock for the dynamic content property provider + const clock = new Clock({ + startTime: JulianDate.fromIso8601("2013-12-25"), + currentTime: JulianDate.fromIso8601("2013-12-25"), + stopTime: JulianDate.fromIso8601("2013-12-26"), + clockRange: ClockRange.LOOP_STOP, + clockStep: ClockStep.SYSTEM_CLOCK_MULTIPLIER, + }); + tileset.setDefaultTimeDynamicContentPropertyProvider( + "exampleIsoTimeStamp", + clock, + ); + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual(["exampleContent-iso-A.glb"]); + + // Change the current clock time, and expect this + // to be reflected in the active content URIs + clock.currentTime = JulianDate.fromIso8601("2013-12-26"); + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual(["exampleContent-iso-B.glb"]); + }); + + //======================================================================== + // Veeery experimental... + + it("tracks the number of pending requests in the tileset statistics", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + let mockResolve; + // eslint-disable-next-line no-unused-vars + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now resolve the pending request... + mockResolve(createDummyGltfBuffer()); + + // Wait for things to settle... + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(1); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + }); + + it("tracks the number of attempted requests in the tileset statistics", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + // eslint-disable-next-line no-unused-vars + let mockResolve; + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now reject the pending request + mockReject("SPEC_REJECTION"); + + // Wait for things to settle... + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(0); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); + }); + + //======================================================================== + // DONE: + + it("returns an empty array as the active content URIs when there is no dynamicContentPropertyProvider", function () { + const tileset = basicDynamicExampleTilesetJson; + + // For spec: There is no dynamicContentPropertyProvider + tileset.dynamicContentPropertyProvider = undefined; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns undefined", function () { + const tileset = basicDynamicExampleTilesetJson; + + tileset.dynamicContentPropertyProvider = () => { + // For spec: Return undefined as the current properties + return undefined; + }; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns an object that does not have the required properties", function () { + const tileset = basicDynamicExampleTilesetJson; + + tileset.dynamicContentPropertyProvider = () => { + // For spec: Return an object that does not have + // the exampleTimeStamp (but an unused property) + return { + ignoredExamplePropertyForSpec: "Ignored", + exampleRevision: "revision0", + }; + }; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns the active content URIs matching the object that is returned by the dynamicContentPropertyProvider", function () { + const tileset = basicDynamicExampleTilesetJson; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual([ + "exampleContent-2025-09-25-revision0.glb", + ]); + + // Change the dynamic content properties, and expect + // this to be reflected in the active content URIs + dynamicContentProperties.exampleRevision = "revision1"; + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual([ + "exampleContent-2025-09-25-revision1.glb", + ]); + }); + + /* + it("requestInnerContents returns promise that resolves to content if successful", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.resolve(makeGltfBuffer()); + }); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + await expectAsync(promise).toBeResolvedTo(jasmine.any(Array)); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + }); + + it("requestInnerContents returns undefined and updates statistics if all requests cannot be scheduled", function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + RequestScheduler.maximumRequestsPerServer = 2; + expect(content.requestInnerContents()).toBeUndefined(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); + }); + + it("requestInnerContents handles inner content failures", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + tileFailed: new Event(), + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.reject(new Error("my error")); + }); + + const failureSpy = jasmine.createSpy(); + mockTileset.tileFailed.addEventListener(failureSpy); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + await expectAsync(promise).toBeResolved(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + expect(failureSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + message: "my error", + }), + ); + }); + + it("requestInnerContents handles cancelled requests", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.resolve(makeGltfBuffer()); + }); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + content.cancelRequests(); + + await expectAsync(promise).toBeResolved(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); + }); + + it("becomes ready", async function () { + const tileset = await Cesium3DTilesTester.loadTileset( + scene, + multipleContentsUrl, + ); + expect(tileset.root.contentReady).toBeTrue(); + expect(tileset.root.content).toBeDefined(); + }); + + it("renders multiple contents", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + expectRenderMultipleContents, + ); + }); + + it("renders multiple contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyUrl, + ).then(expectRenderMultipleContents); + }); + + it("renders multiple contents (legacy with 'content')", function () { + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyWithContentUrl, + ).then(expectRenderMultipleContents); + }); + + it("renders valid tiles after tile failure", function () { + const originalLoadJson = Cesium3DTileset.loadJson; + spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { + return originalLoadJson(tilesetUrl).then(function (tilesetJson) { + const contents = tilesetJson.root.contents; + const badTile = { + uri: "nonexistent.b3dm", + }; + contents.splice(1, 0, badTile); + + return tilesetJson; + }); + }); + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + expectRenderMultipleContents, + ); + }); + + it("renders valid tiles after tile failure (legacy)", function () { + const originalLoadJson = Cesium3DTileset.loadJson; + spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { + return originalLoadJson(tilesetUrl).then(function (tilesetJson) { + const content = + tilesetJson.root.extensions["3DTILES_multiple_contents"].contents; + const badTile = { + uri: "nonexistent.b3dm", + }; + content.splice(1, 0, badTile); + + return tilesetJson; + }); + }); + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyUrl, + ).then(expectRenderMultipleContents); + }); + + it("cancelRequests cancels in-flight requests", function () { + viewNothing(); + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + viewAllTiles(); + scene.renderForSpecs(); + + const multipleContents = tileset.root.content; + multipleContents.cancelRequests(); + + return Cesium3DTilesTester.waitForTilesLoaded(scene, tileset).then( + function () { + // the content should be canceled once in total + expect(multipleContents._cancelCount).toBe(1); + }, + ); + }, + ); + }); + + it("destroys", function () { + return Cesium3DTilesTester.tileDestroys(scene, multipleContentsUrl); + }); + + describe("metadata", function () { + const withGroupMetadataUrl = + "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.1.json"; + const withGroupMetadataLegacyUrl = + "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.0.json"; + const withExplicitContentMetadataUrl = + "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.1.json"; + const withExplicitContentMetadataLegacyUrl = + "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.0.json"; + const withImplicitContentMetadataUrl = + "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.1.json"; + const withImplicitContentMetadataLegacyUrl = + "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.0.json"; + + let metadataClass; + let groupMetadata; + + beforeAll(function () { + metadataClass = MetadataClass.fromJson({ + id: "test", + class: { + properties: { + name: { + type: "STRING", + }, + height: { + type: "SCALAR", + componentType: "FLOAT32", + }, + }, + }, + }); + + groupMetadata = new GroupMetadata({ + id: "testGroup", + group: { + properties: { + name: "Test Group", + height: 35.6, + }, + }, + class: metadataClass, + }); + }); + + it("group metadata returns undefined", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + const content = tileset.root.content; + expect(content.group).not.toBeDefined(); + }, + ); + }); + + it("assigning group metadata throws", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + expect(function () { + const content = tileset.root.content; + content.group = new Cesium3DContentGroup({ + metadata: groupMetadata, + }); + }).toThrowDeveloperError(); + }, + ); + }); + + it("initializes group metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withGroupMetadataUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const buildingsContent = innerContents[0]; + let groupMetadata = buildingsContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(255, 127, 0), + ); + expect(groupMetadata.getProperty("priority")).toBe(10); + expect(groupMetadata.getProperty("isInstanced")).toBe(false); + + const cubesContent = innerContents[1]; + groupMetadata = cubesContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 255, 127), + ); + expect(groupMetadata.getProperty("priority")).toBe(5); + expect(groupMetadata.getProperty("isInstanced")).toBe(true); + }); + }); + + it("initializes group metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withGroupMetadataLegacyUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const buildingsContent = innerContents[0]; + let groupMetadata = buildingsContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(255, 127, 0), + ); + expect(groupMetadata.getProperty("priority")).toBe(10); + expect(groupMetadata.getProperty("isInstanced")).toBe(false); + + const cubesContent = innerContents[1]; + groupMetadata = cubesContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 255, 127), + ); + expect(groupMetadata.getProperty("priority")).toBe(5); + expect(groupMetadata.getProperty("isInstanced")).toBe(true); + }); + }); + + it("content metadata returns undefined", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + const content = tileset.root.content; + expect(content.metadata).not.toBeDefined(); + }, + ); + }); + + it("assigning content metadata throws", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + expect(function () { + const content = tileset.root.content; + content.metadata = {}; + }).toThrowDeveloperError(); + }, + ); + }); + + it("initializes explicit content metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withExplicitContentMetadataUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const batchedContent = innerContents[0]; + const batchedMetadata = batchedContent.metadata; + expect(batchedMetadata).toBeDefined(); + expect(batchedMetadata.getProperty("highlightColor")).toEqual( + new Cartesian3(0, 0, 255), + ); + expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); + + const instancedContent = innerContents[1]; + const instancedMetadata = instancedContent.metadata; + expect(instancedMetadata).toBeDefined(); + expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( + 50, + ); + expect(instancedMetadata.getProperty("author")).toEqual( + "Sample Author", + ); + }); + }); + + it("initializes explicit content metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withExplicitContentMetadataLegacyUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const batchedContent = innerContents[0]; + const batchedMetadata = batchedContent.metadata; + expect(batchedMetadata).toBeDefined(); + expect(batchedMetadata.getProperty("highlightColor")).toEqual( + new Cartesian3(0, 0, 255), + ); + expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); + + const instancedContent = innerContents[1]; + const instancedMetadata = instancedContent.metadata; + expect(instancedMetadata).toBeDefined(); + expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( + 50, + ); + expect(instancedMetadata.getProperty("author")).toEqual( + "Sample Author", + ); + }); + }); + + it("initializes implicit content metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withImplicitContentMetadataUrl, + ).then(function (tileset) { + const placeholderTile = tileset.root; + const subtreeRootTile = placeholderTile.children[0]; + + // This retrieves the tile at (1, 1, 1) + const subtreeChildTile = subtreeRootTile.children[0]; + + const multipleContents = subtreeChildTile.content; + const innerContents = multipleContents.innerContents; + + const buildingContent = innerContents[0]; + const buildingMetadata = buildingContent.metadata; + expect(buildingMetadata).toBeDefined(); + expect(buildingMetadata.getProperty("height")).toEqual(50); + expect(buildingMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 0, 255), + ); + + const treeContent = innerContents[1]; + const treeMetadata = treeContent.metadata; + expect(treeMetadata).toBeDefined(); + expect(treeMetadata.getProperty("age")).toEqual(16); + }); + }); + + it("initializes implicit content metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withImplicitContentMetadataLegacyUrl, + ).then(function (tileset) { + const placeholderTile = tileset.root; + const subtreeRootTile = placeholderTile.children[0]; + + // This retrieves the tile at (1, 1, 1) + const subtreeChildTile = subtreeRootTile.children[0]; + + const multipleContents = subtreeChildTile.content; + const innerContents = multipleContents.innerContents; + + const buildingContent = innerContents[0]; + const buildingMetadata = buildingContent.metadata; + expect(buildingMetadata).toBeDefined(); + expect(buildingMetadata.getProperty("height")).toEqual(50); + expect(buildingMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 0, 255), + ); + + const treeContent = innerContents[1]; + const treeMetadata = treeContent.metadata; + expect(treeMetadata).toBeDefined(); + expect(treeMetadata.getProperty("age")).toEqual(16); + }); + }); + }); + */ + }, + "WebGL", +); From 165d426689ee1796b1c222e2bfca8a844408c9f4 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Mon, 3 Nov 2025 18:45:38 +0100 Subject: [PATCH 6/9] Cleanups and drafts for specs and statistics --- .../engine/Source/Scene/Cesium3DTileset.js | 83 +- .../Source/Scene/Dynamic3DTileContent.js | 569 +++++++-- .../Specs/Scene/Dynamic3DTileContentSpec.js | 1068 +++++++++++++++++ 3 files changed, 1623 insertions(+), 97 deletions(-) create mode 100644 packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index c7038b994637..71332c239fd0 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -1147,6 +1147,17 @@ function Cesium3DTileset(options) { instanceFeatureIdLabel = `instanceFeatureId_${instanceFeatureIdLabel}`; } this._instanceFeatureIdLabel = instanceFeatureIdLabel; + + /** + * The function that determines which inner contents of a dynamic + * contents object are currently active. + * + * See setDynamicContentPropertyProvider for details. + * + * @type {Function|undefined} + * @private + */ + this._dynamicContentPropertyProvider = undefined; } Object.defineProperties(Cesium3DTileset.prototype, { @@ -2173,6 +2184,21 @@ Object.defineProperties(Cesium3DTileset.prototype, { this._instanceFeatureIdLabel = value; }, }, + + /** + * Returns the function that provides the properties based on + * which inner contents of a dynamic content should be active. + * + * @memberof Cesium3DTileset.prototype + * @readonly + * @type {Function|undefined} + * @private + */ + dynamicContentPropertyProvider: { + get: function () { + return this._dynamicContentPropertyProvider; + }, + }, }); /** @@ -2332,7 +2358,9 @@ Cesium3DTileset.fromUrl = async function (url, options) { ); // Extract the information about the "dimensions" of the dynamic contents, - // if present + // if present. + // XXX_DYNAMIC This should probably not be done here, but ... + // maybe in the constructor or so...? The lifecycle, though... const hasDynamicContents = hasExtension(tilesetJson, "3DTILES_dynamic"); if (hasDynamicContents) { const dynamicContentsExtension = tilesetJson.extensions["3DTILES_dynamic"]; @@ -2446,7 +2474,7 @@ Cesium3DTileset.prototype.loadTileset = function ( * Set the function that determines which dynamic content is currently active. * * This is a function that returns a JSON plain object. This object corresponds - * to one 'key' of a dynamic content definition. It will caused the content + * to one 'key' of a dynamic content definition. It will cause the content * with this key to be the currently active content. * * @param {Function|undefined} dynamicContentPropertyProvider The function @@ -2461,11 +2489,58 @@ Cesium3DTileset.prototype.setDynamicContentPropertyProvider = function ( console.log( "This tileset does not contain the 3DTILES_dynamic extension. The given function will not have an effect.", ); - return; } - this.dynamicContentPropertyProvider = dynamicContentPropertyProvider; + this._dynamicContentPropertyProvider = dynamicContentPropertyProvider; }; +/** + * XXX_DYNAMIC A draft for a convenience function for the dynamic content + * properties provider. Whether or not this should be offered depends on + * how much we want to specialize all this for single ISO8601 date strings. + * We could even omit the "timeDimensionName" if this was a fixed, specified + * string like "isoTimeStamp" or so. + * + * --- + * + * Set the function that determines which dynamic content is currently active, + * based on the ISO8601 string representation of the current time of the given + * clock. + * + * @param {string} timeDimensionName The name of the property that will + * contain the ISO8601 date string of the current time of the clock + * @param {Clock} clock The clock that provides the current time + */ +Cesium3DTileset.prototype.setDefaultTimeDynamicContentPropertyProvider = + function (timeDimensionName, clock) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.string("timeDimensionName", timeDimensionName); + Check.typeOf.object("clock", clock); + //>>includeEnd('debug'); + + const dimensions = this._dynamicContentsDimensions; + if (defined(dimensions)) { + const dimensionNames = dimensions.map((d) => d.name); + if (!dimensionNames.includes(timeDimensionName)) { + console.log( + `The time dimension name ${timeDimensionName} is not a valid dimension name. Valid dimension names are`, + dimensionNames, + ); + } + } + + const dynamicContentPropertyProvider = () => { + const currentTime = clock.currentTime; + if (!defined(currentTime)) { + return undefined; + } + const currentTimeString = JulianDate.toIso8601(currentTime); + return { + [timeDimensionName]: currentTimeString, + }; + }; + this.setDynamicContentPropertyProvider(dynamicContentPropertyProvider); + }; + /** * Make a {@link Cesium3DTile} for a specific tile. If the tile's header has implicit * tiling (3D Tiles 1.1) or uses the 3DTILES_implicit_tiling extension, diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index bf2ddf65b879..6db5a71b27e9 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -44,6 +44,9 @@ class NDMap { * in the 'key' for set/get operations, to determine the coordinates * within the N-dimensional space. * + * The given array may not be modified after it was passed to + * this constructor. + * * @param {string[]} dimensionNames */ constructor(dimensionNames) { @@ -143,7 +146,7 @@ class NDMap { /** * Delete the entry from the given key, if it exists. * - * @param {key} key The key + * @param {object} key The key */ delete(key) { const lookupKey = this._computeLookupKey(key); @@ -206,6 +209,24 @@ class NDMap { [Symbol.iterator]() { return this.entries(); } + + /** + * Returns the value corresponding to the specified key, creating and + * inserting it if it was not yet present, using the given function + * for its creation. + * + * @param {object} key The key + * @param {Function} defaultCreator The default creator + */ + getOrInsertComputed(key, defaultCreator) { + const lookupKey = this._computeLookupKey(key); + if (this._lookup.has(lookupKey)) { + return this._lookup.get(lookupKey); + } + const value = defaultCreator(); + this._lookup.set(lookupKey, value); + return value; + } } /** @@ -403,16 +424,55 @@ class LRUCache { } } -// XXX_DYNAMIC Experiments for that tileset statistics handling... -// eslint-disable-next-line no-unused-vars +/** + * Interface for all classes that want to be informed about the + * state of a request + */ class RequestListener { + /** + * Will be called when the given request was attempted. + * + * This means that the request was started, and then + * was cancelled or failed (but not completed). + * + * @param {Request} request The request + */ requestAttempted(request) {} + + /** + * Will be called when the given request was started. + * + * @param {Request} request The request + */ requestStarted(request) {} + + /** + * Will be called when the given request was cancelled. + * + * @param {Request} request The request + */ requestCancelled(request) {} + + /** + * Will be called when the given request was completed. + * + * @param {Request} request The request + */ requestCompleted(request) {} + + /** + * Will be called when the given request failed + * + * @param {Request} request The request + */ requestFailed(request) {} } -class LoggingRequestListener { + +/** + * Implementation of a RequestListener that just logs the + * request states to the console. + */ +class LoggingRequestListener extends RequestListener { requestAttempted(request) { console.log(`requestAttempted for ${request.url}`); } @@ -479,15 +539,29 @@ class RequestHandle { /** * The listeners that will be informed about the request state * - * @type {RequestListener[]} + * @type {Set} * @readonly */ - this._requestListeners = []; + this._requestListeners = new Set(); } - // XXX_DYNAMIC Experiments for that tileset statistics handling... + /** + * Add the given listener to be informed about the state of the + * underlying request. + * + * @param {RequestListener} requestListener The listener + */ addRequestListener(requestListener) { - this._requestListeners.push(requestListener); + this._requestListeners.add(requestListener); + } + + /** + * Remove the given listener + * + * @param {RequestListener} requestListener The listener + */ + removeRequestListener(requestListener) { + this._requestListeners.delete(requestListener); } /** @@ -495,7 +569,7 @@ class RequestHandle { * * This will never be 'undefined'. It will never change. It will * just be a promise that is either fulfilled with the response - * data from the equest, or rejected with an error indicating + * data from the request, or rejected with an error indicating * the reason for the rejection. * * The reason for the rejection can either be a real error, @@ -633,27 +707,45 @@ class RequestHandle { this._deferred.reject(RequestState.CANCELLED); } - // XXX_DYNAMIC Experiments for that tileset statistics handling... + /** + * Inform all registered listeners that the request was attempted + */ _fireRequestAttempted() { for (const requestListener of this._requestListeners) { requestListener.requestAttempted(this._request); } } + + /** + * Inform all registered listeners that the request was started + */ _fireRequestStarted() { for (const requestListener of this._requestListeners) { requestListener.requestStarted(this._request); } } + + /** + * Inform all registered listeners that the request was cancelled + */ _fireRequestCancelled() { for (const requestListener of this._requestListeners) { requestListener.requestCancelled(this._request); } } + + /** + * Inform all registered listeners that the request was completed + */ _fireRequestCompleted() { for (const requestListener of this._requestListeners) { requestListener.requestCompleted(this._request); } } + + /** + * Inform all registered listeners that the request failed + */ _fireRequestFailed() { for (const requestListener of this._requestListeners) { requestListener.requestFailed(this._request); @@ -661,6 +753,41 @@ class RequestHandle { } } +/** + * Interface for all classes that want to be informed about the + * state of a content + */ +class ContentListener { + /** + * Will be called when the given content was loaded + * and became 'ready' + * + * @param {Cesium3DTileContent} content The content + */ + contentLoadedAndReady(content) {} + + /** + * Will be called when the given content is unloaded, + * immediately before calling its 'destroy' method. + * + * @param {Cesium3DTileContent} content The content + */ + contentUnloaded(content) {} +} + +/** + * Implementation of a ContentListener that just logs the + * states to the console. + */ +class LoggingContentListener extends ContentListener { + contentLoadedAndReady(content) { + console.log(`contentLoadedAndReady for `, content); + } + contentUnloaded(content) { + console.log(`contentUnloaded for `, content); + } +} + /** * A class summarizing what is necessary to request tile content. * @@ -761,6 +888,87 @@ class ContentHandle { * @type {boolean} */ this._failed = false; + + /** + * Only used for testing. See awaitPromise. + * @type {object} + * @readonly + */ + this._deferred = defer(); + + /** + * The listeners that will be informed about the state of the + * request that is created and handled by this instance. + * + * @type {Set} + * @readonly + */ + this._requestListeners = new Set(); + + /** + * The listeners that will be informed about the state of the + * content that is handled by this instance. + * + * @type {Set} + * @readonly + */ + this._contentListeners = new Set(); + } + + /** + * XXX_DYNAMIC: Only intended for testing: If there is a pending + * request for the content, then wait until the content is + * created, or the content creation failed. + * + * This is here because all the request handling is in the content + * classes, without abstractions and clear lifecycle definitions. + */ + async awaitPromise() { + if (defined(this._requestHandle)) { + try { + await this._deferred.promise; + } catch (error) { + // Ignored + } + } + } + + /** + * Add the given listener to be informed about the state of the + * underlying request. + * + * @param {RequestListener} requestListener The listener + */ + addRequestListener(requestListener) { + this._requestListeners.add(requestListener); + } + + /** + * Remove the given listener + * + * @param {RequestListener} requestListener The listener + */ + removeRequestListener(requestListener) { + this._requestListeners.delete(requestListener); + } + + /** + * Add the given listener to be informed about the state of the + * content. + * + * @param {ContentListener} contentListener The listener + */ + addContentListener(contentListener) { + this._contentListeners.add(contentListener); + } + + /** + * Remove the given listener + * + * @param {ContentListener} contentListener The listener + */ + removeContentListener(contentListener) { + this._contentListeners.delete(contentListener); } /** @@ -856,26 +1064,9 @@ class ContentHandle { }); const requestHandle = new RequestHandle(resource); - // Attach a listener that will update the tileset statistics - const tileset = this._tile.tileset; - requestHandle.addRequestListener(new LoggingRequestListener()); - requestHandle.addRequestListener({ - requestAttempted(request) { - tileset.statistics.numberOfAttemptedRequests++; - }, - requestStarted(request) { - tileset.statistics.numberOfPendingRequests++; - }, - requestCancelled(request) { - tileset.statistics.numberOfPendingRequests--; - }, - requestCompleted(request) { - tileset.statistics.numberOfPendingRequests--; - }, - requestFailed(request) { - tileset.statistics.numberOfPendingRequests--; - }, - }); + for (const requestListener of this._requestListeners) { + requestHandle.addRequestListener(requestListener); + } this._requestHandle = requestHandle; const requestHandleResultPromise = requestHandle.getResultPromise(); @@ -889,12 +1080,13 @@ class ContentHandle { const content = await this._createContent(resource, arrayBuffer); console.log(`ContentHandle: Content was created for ${uri}`); this._content = content; - // XXX_DYNAMIC Trigger some update...?! + this._deferred.resolve(); } catch (error) { console.log( `ContentHandle: Content creation for ${uri} caused error ${error}`, ); this._failed = true; + this._deferred.resolve(); } }; @@ -916,12 +1108,14 @@ class ContentHandle { `ContentHandle: Request was rejected for ${uri}, but actually only cancelled. Better luck next time!`, ); this._requestHandle = undefined; + this._deferred.resolve(); return; } // Other errors should indeed cause this handle // to be marked as "failed" this._failed = true; + this._deferred.resolve(); }; requestHandleResultPromise.then(onRequestFulfilled, onRequestRejected); requestHandle.ensureRequested(); @@ -960,10 +1154,56 @@ class ContentHandle { } this._requestHandle = undefined; if (defined(this._content)) { + this._fireContentUnloaded(this._content); this._content.destroy(); } this._content = undefined; this._failed = false; + this._deferred = defer(); + } + + /** + * Wrapper around content.update, for implementing the + * Cesium3DTileContent interface... + * + * @param {Cesium3DTileset} tileset The tileset + * @param {FrameState} frameState The frame state + */ + updateContent(tileset, frameState) { + const content = this._content; + if (!defined(content)) { + return; + } + const oldReady = content.ready; + content.update(tileset, frameState); + const newReady = content.ready; + if (!oldReady && newReady) { + this._fireContentLoadedAndReady(content); + } + } + + /** + * Inform all registered listeners that the content was loaded + * and became 'ready' (meaning that it was really loaded...) + * + * @param {Cesium3DTileContent} content The content + */ + _fireContentLoadedAndReady(content) { + for (const contentListener of this._contentListeners) { + contentListener.contentLoadedAndReady(content); + } + } + + /** + * Inform all registered listeners that the content was unloaded, + * just before it is destroyed + * + * @param {Cesium3DTileContent} content The content + */ + _fireContentUnloaded(content) { + for (const contentListener of this._contentListeners) { + contentListener.contentUnloaded(content); + } } } @@ -1153,7 +1393,7 @@ class Dynamic3DTileContent { * @param {ContentHandle} contentHandle The ContentHandle */ loadedContentHandleEvicted(uri, contentHandle) { - console.log("_loadedContentHandleEvicted with ", uri); + console.log(`_loadedContentHandleEvicted with ${uri}`); contentHandle.reset(); } @@ -1165,7 +1405,7 @@ class Dynamic3DTileContent { * creating the content objects. * * @param {Resource} baseResource The base resource (from the tileset) - * @returns {Map} + * @returns {Map} The content handles */ _createContentHandles(baseResource) { const dynamicContents = this._dynamicContents; @@ -1178,12 +1418,62 @@ class Dynamic3DTileContent { baseResource, contentHeader, ); + this._attachTilesetStatisticsTracker(contentHandle); + const uri = contentHeader.uri; contentHandles.set(uri, contentHandle); } return contentHandles; } + /** + * Attach a listener to the given content handle that will update + * the tileset statistics based on the request state. + * + * @param {ContentHandle} contentHandle The content handle + */ + _attachTilesetStatisticsTracker(contentHandle) { + // XXX_DYNAMIC Debug logs... + contentHandle.addRequestListener(new LoggingRequestListener()); + contentHandle.addContentListener(new LoggingContentListener()); + + const tileset = this._tile.tileset; + contentHandle.addRequestListener({ + requestAttempted(request) { + tileset.statistics.numberOfAttemptedRequests++; + }, + requestStarted(request) { + tileset.statistics.numberOfPendingRequests++; + }, + requestCancelled(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestCompleted(request) { + tileset.statistics.numberOfPendingRequests--; + }, + requestFailed(request) { + tileset.statistics.numberOfPendingRequests--; + }, + }); + + contentHandle.addContentListener({ + contentLoadedAndReady(content) { + console.log( + "-------------------------- update statistics for loaded ", + content, + ); + tileset.statistics.incrementLoadCounts(content); + }, + contentUnloaded(content) { + console.log( + "-------------------------- update statistics for unloaded ", + content, + ); + tileset.statistics.decrementLoadCounts(content); + }, + }); + } + /** * Creates the mapping from the "keys" that are found in the * 'dynamicContents' array, to the arrays of URLs that are @@ -1195,7 +1485,8 @@ class Dynamic3DTileContent { */ _createDynamicContentUriLookup() { const tileset = this.tileset; - const topLevelExtensionObject = tileset.extensions["3DTILES_dynamic"]; + const extensions = tileset.extensions ?? {}; + const topLevelExtensionObject = extensions["3DTILES_dynamic"]; if (!defined(topLevelExtensionObject)) { throw new DeveloperError( "Cannot create a Dynamic3DTileContent for a tileset that does not contain a top-level dynamic content extension object.", @@ -1208,12 +1499,11 @@ class Dynamic3DTileContent { const dynamicContentUriLookup = new NDMap(dimensionNames); for (let i = 0; i < dynamicContents.length; i++) { const dynamicContent = dynamicContents[i]; - let entries = dynamicContentUriLookup.get(dynamicContent.keys); - if (!defined(entries)) { - entries = Array(); - dynamicContentUriLookup.set(dynamicContent.keys, entries); - } const uri = dynamicContent.uri; + const key = dynamicContent.keys; + const entries = dynamicContentUriLookup.getOrInsertComputed(key, () => + Array(), + ); entries.push(uri); } return dynamicContentUriLookup; @@ -1227,25 +1517,30 @@ class Dynamic3DTileContent { * '_dynamicContentUriLookup'. This method returns the array of * URIs that are found in that lookup, for the respective key. * + * If there is no dynamicContentPropertyProvider, then an empty + * array will be returned. + * + * If the dynamicContentPropertyProvider returns undefined, then + * an empty array will be returned. + * + * If there are no active contents, then an empty array will be + * returned. + * + * Callers may NOT modify the returned array. + * * @type {string[]} The active content URIs */ get _activeContentUris() { const tileset = this.tileset; - let dynamicContentPropertyProvider = tileset.dynamicContentPropertyProvider; - - // XXX_DYNAMIC For testing + const dynamicContentPropertyProvider = + tileset.dynamicContentPropertyProvider; if (!defined(dynamicContentPropertyProvider)) { - console.log("No dynamicContentPropertyProvider, using default"); - dynamicContentPropertyProvider = () => { - return { - exampleTimeStamp: "2025-09-26", - exampleRevision: "revision2", - }; - }; - tileset.dynamicContentPropertyProvider = dynamicContentPropertyProvider; + return []; } - const currentProperties = dynamicContentPropertyProvider(); + if (!defined(currentProperties)) { + return []; + } const lookup = this._dynamicContentUriLookup; const currentEntries = lookup.get(currentProperties) ?? []; return currentEntries; @@ -1259,6 +1554,11 @@ class Dynamic3DTileContent { * it was already requested and created, it will be contained in * the returned array. * + * If there are no active contents, then an empty array will be + * returned. + * + * Callers may NOT modify the returned array. + * * @type {Cesium3DTileContent[]} */ get _activeContents() { @@ -1274,35 +1574,21 @@ class Dynamic3DTileContent { return activeContents; } - /** - * Returns ALL content URIs that have been defined as contents - * in the dynamic content definition. - * - * @type {string[]} The content URIs - */ - get _allContentUris() { - // TODO Should be computed from the dynamicContents, - // once, in the constructor, as a SET (!) - const keys = this._contentHandles.keys(); - const allContentUris = [...keys]; - return allContentUris; - } - /** * Returns ALL contents that are currently loaded. * * @type {Cesium3DTileContent[]} The contents */ - get _allContents() { - const allContents = []; + get _allLoadedContents() { + const allLoadedContents = []; const contentHandleValues = this._contentHandles.values(); for (const contentHandle of contentHandleValues) { const content = contentHandle.getContentOptional(); if (defined(content)) { - allContents.push(content); + allLoadedContents.push(content); } } - return allContents; + return allLoadedContents; } /** @@ -1322,8 +1608,8 @@ class Dynamic3DTileContent { * @type {boolean} */ get featurePropertiesDirty() { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { if (content.featurePropertiesDirty) { return true; } @@ -1332,83 +1618,178 @@ class Dynamic3DTileContent { return false; } set featurePropertiesDirty(value) { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.featurePropertiesDirty = value; } } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call featuresLength for a specific inner content. * * @type {number} * @readonly */ get featuresLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("featuresLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead, call pointsLength for a specific inner content. * * @type {number} * @readonly */ get pointsLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("pointsLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call trianglesLength for a specific inner content. * * @type {number} * @readonly */ get trianglesLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("trianglesLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call geometryByteLength for a specific inner content. * * @type {number} * @readonly */ get geometryByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("geometryByteLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call texturesByteLength for a specific inner content. * * @type {number} * @readonly */ get texturesByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("texturesByteLength"); return 0; } /** * Part of the {@link Cesium3DTileContent} interface. - * Always returns 0. Instead call batchTableByteLength for a specific inner content. * * @type {number} * @readonly */ get batchTableByteLength() { + // XXX_DYNAMIC It's not clear whether this should return + // the aggregated value, or whether it is only used for + // the statistics, which are now tracked manually in + // _attachTilesetStatisticsTracker + //return this.getAggregatedLoaded("batchTableByteLength"); return 0; } + /** + * Calls getAggregated with each loaded content and the given + * property, and returns the sum. + * + * See getAggregated for details. + * + * @param {string} property The property + * @returns The result + */ + getAggregatedLoaded(property) { + const allLoadedContents = this._allLoadedContents; + let result = 0; + for (const content of allLoadedContents) { + result += Dynamic3DTileContent.getAggregated(content, property); + } + return result; + } + + /** + * The Cesium3DTileContent interface does not really make sense. + * + * It is underspecified, the functions/properties that it contains have no + * coherence, and most of them do not make sense for most implementations. + * The way how that interface and its functions are used shows that + * ambiguity and vagueness, even without the corner case of dynamic + * content. For example, the "tile debug labels" show a geometry- and + * memory size of 0 for composite content, because the function that + * creates these labels is not aware that Composite3DTileContent and + * Multiple3DTileContent require it to iterate over the "innerContents". + * Some of the functions are called at places where the state of + * the content is not clear, including Cesium3DTile.process, in the + * block with that "if (...!this.contentReady && this._content.ready)" + * statement that does not make sense for dynamic content. (This could + * be avoided by proper state management, but let's not get into that). + * + * So this function tries to squeeze some sense out of what is there: + * + * It fetches the value of the specified property of the given content, + * or the sum of the values from recursing into "innerContents" if + * the latter are defined. + * + * Note that a content could have the specified property AND innerContents. + * This function could take the value from the content itself, and ADD the + * values from the inner contents. But if, at any point in time, the + * implementation of the composite- and multiple content are fixed by + * computing this sum on their own, such an implementation would break. + * + * At some point, we have to shrug this off. + * + * @param {Cesium3DTileContent} content The content + * @param {string} property The property + * @returns The result + */ + static getAggregated(content, property) { + const innerContents = content.innerContents; + if (defined(innerContents)) { + let sum = 0; + for (const innerContent of content.innerContents) { + sum += Dynamic3DTileContent.getAggregated(innerContent[property]); + } + return sum; + } + return content[property]; + } + /** * Part of the {@link Cesium3DTileContent} interface. */ get innerContents() { - return this._allContents; + // XXX_DYNAMIC It's not clear whether this should return + // the loaded contents. Most of the tracking that could + // require clients to call this function should happen + // INSIDE this class, because the "inner contents" can + // be loaded and unloaded at any point in time. + //return this._allLoadedContents; + return []; } /** @@ -1535,10 +1916,9 @@ class Dynamic3DTileContent { * Part of the {@link Cesium3DTileContent} interface. */ update(tileset, frameState) { - // Call the 'update' on all contents. - const allContents = this._allContents; - for (const content of allContents) { - content.update(tileset, frameState); + // Call update for all contents + for (const contentHandle of this._contentHandles.values()) { + contentHandle.updateContent(tileset, frameState); } // XXX_DYNAMIC There is no way to show or hide contents. @@ -1550,7 +1930,8 @@ class Dynamic3DTileContent { // It could be called "doRandomStuff" at this point. // Hide all contents. - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.applyStyle(DYNAMIC_CONTENT_HIDE_STYLE); } @@ -1562,7 +1943,7 @@ class Dynamic3DTileContent { // Assign debug settings to all active contents for (const activeContent of activeContents) { - // The applyDebugSettings call will override any/ style color + // The applyDebugSettings call will override any style color // that was previously set. I'm not gonna sort this out. if (this._lastDebugSettingsEnabled) { activeContent.applyDebugSettings( @@ -1578,10 +1959,10 @@ class Dynamic3DTileContent { * Unload the least-recently used content. */ _unloadOldContent() { - // Collect all content handles that have a content that - // is currently loaded - const loadedContentHandles = this._loadedContentHandles; + // Iterate over all content handles. If the content of a certain handle + // is currently loaded, then store it in the loadedContentHandles. const contentHandleEntries = this._contentHandles.entries(); + const loadedContentHandles = this._loadedContentHandles; for (const [url, contentHandle] of contentHandleEntries) { if (!loadedContentHandles.has(url)) { const content = contentHandle.getContentOptional(); @@ -1591,7 +1972,8 @@ class Dynamic3DTileContent { } } - // Mark the active contents as "recently used" + // Mark the "active" contents as "recently used", to prevent + // them from being evicted from the loadedContentHandles cache const activeContentUris = this._activeContentUris; for (const activeContentUri of activeContentUris) { if (loadedContentHandles.has(activeContentUri)) { @@ -1616,7 +1998,8 @@ class Dynamic3DTileContent { /** * Part of the {@link Cesium3DTileContent} interface. * - * Find an intersection between a ray and the tile content surface that was rendered. The ray must be given in world coordinates. + * Find an intersection between a ray and the tile content surface that was + * rendered. The ray must be given in world coordinates. * * @param {Ray} ray The ray to test for intersection. * @param {FrameState} frameState The frame state. @@ -1660,8 +2043,8 @@ class Dynamic3DTileContent { * Part of the {@link Cesium3DTileContent} interface. */ destroy() { - const allContents = this._allContents; - for (const content of allContents) { + const allLoadedContents = this._allLoadedContents; + for (const content of allLoadedContents) { content.destroy(); } return destroyObject(this); diff --git a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js new file mode 100644 index 000000000000..d32d7011e8cf --- /dev/null +++ b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js @@ -0,0 +1,1068 @@ +import { Cesium3DTileset, Resource } from "../../index.js"; +import createScene from "../../../../Specs/createScene.js"; +import Dynamic3DTileContent from "../../Source/Scene/Dynamic3DTileContent.js"; +import Clock from "../../Source/Core/Clock.js"; +import JulianDate from "../../Source/Core/JulianDate.js"; +import ClockRange from "../../Source/Core/ClockRange.js"; +import ClockStep from "../../Source/Core/ClockStep.js"; +import generateJsonBuffer from "../../../../Specs/generateJsonBuffer.js"; +import ContextLimits from "../../Source/Renderer/ContextLimits.js"; + +const basicDynamicExampleExtensionObject = { + dimensions: [ + { + name: "exampleTimeStamp", + keySet: ["2025-09-25", "2025-09-26"], + }, + { + name: "exampleRevision", + keySet: ["revision0", "revision1"], + }, + ], +}; + +const basicDynamicExampleContent = { + dynamicContents: [ + { + uri: "exampleContent-2025-09-25-revision0.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }, + }, + { + uri: "exampleContent-2025-09-25-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision1", + }, + }, + { + uri: "exampleContent-2025-09-26-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-26", + exampleRevision: "revision0", + }, + }, + { + uri: "exampleContent-2025-09-26-revision1.glb", + keys: { + exampleTimeStamp: "2025-09-26", + exampleRevision: "revision1", + }, + }, + ], +}; + +const basicDynamicExampleTilesetJson = { + asset: { + version: "1.1", + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + + geometricError: 4096, + root: { + boundingVolume: { + box: [32.0, -1.5, 0, 32.0, 0, 0, 0, 1.5, 0, 0, 0, 0], + }, + geometricError: 512, + content: { + uri: "content.json", + }, + refine: "REPLACE", + }, +}; + +const isoDynamicExampleExtensionObject = { + dimensions: [ + { + name: "exampleIsoTimeStamp", + keySet: ["2013-12-25T00:00:00Z", "2013-12-26T00:00:00Z"], + }, + ], +}; + +const isoDynamicExampleContent = { + dynamicContents: [ + { + uri: "exampleContent-iso-A.glb", + keys: { + exampleIsoTimeStamp: "2013-12-25T00:00:00Z", + }, + }, + { + uri: "exampleContent-iso-B.glb", + keys: { + exampleIsoTimeStamp: "2013-12-26T00:00:00Z", + exampleRevision: "revision1", + }, + }, + ], +}; + +function createDummyGltfBuffer() { + const gltf = { + asset: { + version: "2.0", + }, + }; + return generateJsonBuffer(gltf).buffer; +} + +describe( + "Scene/Dynamic3DTileContent", + function () { + let scene; + + const tilesetResource = new Resource({ url: "http://example.com" }); + + beforeAll(function () { + scene = createScene(); + }); + + afterAll(function () { + scene.destroyForSpecs(); + }); + + afterEach(function () { + scene.primitives.removeAll(); + }); + + it("___XXX_DYNAMIC_WORKS___", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + // eslint-disable-next-line no-unused-vars + let mockResolve; + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now reject the pending request, and wait for things to settle... + mockReject("SPEC_REJECTION"); + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(0); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); + }); + + it("BASIC___XXX_DYNAMIC_WORKS___", function () { + // For spec: Create a dummy tileset and fill it + // with the necessary (private!) properties + const tileset = new Cesium3DTileset(); + tileset._extensions = { + "3DTILES_dynamic": isoDynamicExampleExtensionObject, + }; + tileset._dynamicContentsDimensions = + isoDynamicExampleExtensionObject.dimensions; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + isoDynamicExampleContent, + ); + + // Create a dummy clock for the dynamic content property provider + const clock = new Clock({ + startTime: JulianDate.fromIso8601("2013-12-25"), + currentTime: JulianDate.fromIso8601("2013-12-25"), + stopTime: JulianDate.fromIso8601("2013-12-26"), + clockRange: ClockRange.LOOP_STOP, + clockStep: ClockStep.SYSTEM_CLOCK_MULTIPLIER, + }); + tileset.setDefaultTimeDynamicContentPropertyProvider( + "exampleIsoTimeStamp", + clock, + ); + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual(["exampleContent-iso-A.glb"]); + + // Change the current clock time, and expect this + // to be reflected in the active content URIs + clock.currentTime = JulianDate.fromIso8601("2013-12-26"); + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual(["exampleContent-iso-B.glb"]); + }); + + it("___QUARRY___XXX_DYNAMIC_WORKS___", function () { + const tileset = basicDynamicExampleTilesetJson; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual([ + "exampleContent-2025-09-25-revision0.glb", + ]); + + // Change the dynamic content properties, and expect + // this to be reflected in the active content URIs + dynamicContentProperties.exampleRevision = "revision1"; + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual([ + "exampleContent-2025-09-25-revision1.glb", + ]); + }); + + //======================================================================== + // Experimental + + it("returns the active content URIs matching the object that is returned by the default time-dynamic content property provider", function () { + // For spec: Create a dummy tileset and fill it + // with the necessary (private!) properties + const tileset = new Cesium3DTileset(); + tileset._extensions = { + "3DTILES_dynamic": isoDynamicExampleExtensionObject, + }; + tileset._dynamicContentsDimensions = + isoDynamicExampleExtensionObject.dimensions; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + isoDynamicExampleContent, + ); + + // Create a dummy clock for the dynamic content property provider + const clock = new Clock({ + startTime: JulianDate.fromIso8601("2013-12-25"), + currentTime: JulianDate.fromIso8601("2013-12-25"), + stopTime: JulianDate.fromIso8601("2013-12-26"), + clockRange: ClockRange.LOOP_STOP, + clockStep: ClockStep.SYSTEM_CLOCK_MULTIPLIER, + }); + tileset.setDefaultTimeDynamicContentPropertyProvider( + "exampleIsoTimeStamp", + clock, + ); + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual(["exampleContent-iso-A.glb"]); + + // Change the current clock time, and expect this + // to be reflected in the active content URIs + clock.currentTime = JulianDate.fromIso8601("2013-12-26"); + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual(["exampleContent-iso-B.glb"]); + }); + + //======================================================================== + // Veeery experimental... + + it("tracks the number of pending requests in the tileset statistics", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + let mockResolve; + // eslint-disable-next-line no-unused-vars + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now resolve the pending request... + mockResolve(createDummyGltfBuffer()); + + // Wait for things to settle... + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(1); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + }); + + it("tracks the number of attempted requests in the tileset statistics", async function () { + // Create a dummy tileset for testing the statistic tracking + const tileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + + extensions: { + "3DTILES_dynamic": basicDynamicExampleExtensionObject, + }, + }; + + // Create a dummy tile for testing the statistic tracking + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const tile = { + tileset: tileset, + _tileset: tileset, + }; + + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Create a mock promise to manually resolve the + // resource request + // eslint-disable-next-line no-unused-vars + let mockResolve; + let mockReject; + const mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + console.log("returning mockPromise"); + return mockPromise; + }); + + // Initially, expect there to be no active contents, but + // one pending request + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now reject the pending request + mockReject("SPEC_REJECTION"); + + // Wait for things to settle... + for (const contentHandle of content._contentHandles.values()) { + await contentHandle.awaitPromise(); + } + + // Now expect there to be one content, but no pending requests + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(0); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); + }); + + //======================================================================== + // DONE: + + it("returns an empty array as the active content URIs when there is no dynamicContentPropertyProvider", function () { + const tileset = basicDynamicExampleTilesetJson; + + // For spec: There is no dynamicContentPropertyProvider + tileset.dynamicContentPropertyProvider = undefined; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns undefined", function () { + const tileset = basicDynamicExampleTilesetJson; + + tileset.dynamicContentPropertyProvider = () => { + // For spec: Return undefined as the current properties + return undefined; + }; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns an object that does not have the required properties", function () { + const tileset = basicDynamicExampleTilesetJson; + + tileset.dynamicContentPropertyProvider = () => { + // For spec: Return an object that does not have + // the exampleTimeStamp (but an unused property) + return { + ignoredExamplePropertyForSpec: "Ignored", + exampleRevision: "revision0", + }; + }; + + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const activeContentUris = content._activeContentUris; + expect(activeContentUris).toEqual([]); + }); + + it("returns the active content URIs matching the object that is returned by the dynamicContentPropertyProvider", function () { + const tileset = basicDynamicExampleTilesetJson; + const tile = {}; + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Expect the active content URIs to match the content + // URIs for the current dynamic content properties + const activeContentUrisA = content._activeContentUris; + expect(activeContentUrisA).toEqual([ + "exampleContent-2025-09-25-revision0.glb", + ]); + + // Change the dynamic content properties, and expect + // this to be reflected in the active content URIs + dynamicContentProperties.exampleRevision = "revision1"; + + const activeContentUrisB = content._activeContentUris; + expect(activeContentUrisB).toEqual([ + "exampleContent-2025-09-25-revision1.glb", + ]); + }); + + /* + it("requestInnerContents returns promise that resolves to content if successful", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.resolve(makeGltfBuffer()); + }); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + await expectAsync(promise).toBeResolvedTo(jasmine.any(Array)); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + }); + + it("requestInnerContents returns undefined and updates statistics if all requests cannot be scheduled", function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + RequestScheduler.maximumRequestsPerServer = 2; + expect(content.requestInnerContents()).toBeUndefined(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); + }); + + it("requestInnerContents handles inner content failures", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + tileFailed: new Event(), + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.reject(new Error("my error")); + }); + + const failureSpy = jasmine.createSpy(); + mockTileset.tileFailed.addEventListener(failureSpy); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + await expectAsync(promise).toBeResolved(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + expect(failureSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + message: "my error", + }), + ); + }); + + it("requestInnerContents handles cancelled requests", async function () { + const mockTileset = { + statistics: { + numberOfPendingRequests: 0, + numberOfAttemptedRequests: 0, + }, + }; + const tile = {}; + const content = new Multiple3DTileContent( + mockTileset, + tile, + tilesetResource, + contentsJson, + ); + + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + return Promise.resolve(makeGltfBuffer()); + }); + + const promise = content.requestInnerContents(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + + content.cancelRequests(); + + await expectAsync(promise).toBeResolved(); + expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); + expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); + }); + + it("becomes ready", async function () { + const tileset = await Cesium3DTilesTester.loadTileset( + scene, + multipleContentsUrl, + ); + expect(tileset.root.contentReady).toBeTrue(); + expect(tileset.root.content).toBeDefined(); + }); + + it("renders multiple contents", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + expectRenderMultipleContents, + ); + }); + + it("renders multiple contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyUrl, + ).then(expectRenderMultipleContents); + }); + + it("renders multiple contents (legacy with 'content')", function () { + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyWithContentUrl, + ).then(expectRenderMultipleContents); + }); + + it("renders valid tiles after tile failure", function () { + const originalLoadJson = Cesium3DTileset.loadJson; + spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { + return originalLoadJson(tilesetUrl).then(function (tilesetJson) { + const contents = tilesetJson.root.contents; + const badTile = { + uri: "nonexistent.b3dm", + }; + contents.splice(1, 0, badTile); + + return tilesetJson; + }); + }); + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + expectRenderMultipleContents, + ); + }); + + it("renders valid tiles after tile failure (legacy)", function () { + const originalLoadJson = Cesium3DTileset.loadJson; + spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { + return originalLoadJson(tilesetUrl).then(function (tilesetJson) { + const content = + tilesetJson.root.extensions["3DTILES_multiple_contents"].contents; + const badTile = { + uri: "nonexistent.b3dm", + }; + content.splice(1, 0, badTile); + + return tilesetJson; + }); + }); + return Cesium3DTilesTester.loadTileset( + scene, + multipleContentsLegacyUrl, + ).then(expectRenderMultipleContents); + }); + + it("cancelRequests cancels in-flight requests", function () { + viewNothing(); + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + viewAllTiles(); + scene.renderForSpecs(); + + const multipleContents = tileset.root.content; + multipleContents.cancelRequests(); + + return Cesium3DTilesTester.waitForTilesLoaded(scene, tileset).then( + function () { + // the content should be canceled once in total + expect(multipleContents._cancelCount).toBe(1); + }, + ); + }, + ); + }); + + it("destroys", function () { + return Cesium3DTilesTester.tileDestroys(scene, multipleContentsUrl); + }); + + describe("metadata", function () { + const withGroupMetadataUrl = + "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.1.json"; + const withGroupMetadataLegacyUrl = + "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.0.json"; + const withExplicitContentMetadataUrl = + "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.1.json"; + const withExplicitContentMetadataLegacyUrl = + "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.0.json"; + const withImplicitContentMetadataUrl = + "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.1.json"; + const withImplicitContentMetadataLegacyUrl = + "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.0.json"; + + let metadataClass; + let groupMetadata; + + beforeAll(function () { + metadataClass = MetadataClass.fromJson({ + id: "test", + class: { + properties: { + name: { + type: "STRING", + }, + height: { + type: "SCALAR", + componentType: "FLOAT32", + }, + }, + }, + }); + + groupMetadata = new GroupMetadata({ + id: "testGroup", + group: { + properties: { + name: "Test Group", + height: 35.6, + }, + }, + class: metadataClass, + }); + }); + + it("group metadata returns undefined", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + const content = tileset.root.content; + expect(content.group).not.toBeDefined(); + }, + ); + }); + + it("assigning group metadata throws", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + expect(function () { + const content = tileset.root.content; + content.group = new Cesium3DContentGroup({ + metadata: groupMetadata, + }); + }).toThrowDeveloperError(); + }, + ); + }); + + it("initializes group metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withGroupMetadataUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const buildingsContent = innerContents[0]; + let groupMetadata = buildingsContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(255, 127, 0), + ); + expect(groupMetadata.getProperty("priority")).toBe(10); + expect(groupMetadata.getProperty("isInstanced")).toBe(false); + + const cubesContent = innerContents[1]; + groupMetadata = cubesContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 255, 127), + ); + expect(groupMetadata.getProperty("priority")).toBe(5); + expect(groupMetadata.getProperty("isInstanced")).toBe(true); + }); + }); + + it("initializes group metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withGroupMetadataLegacyUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const buildingsContent = innerContents[0]; + let groupMetadata = buildingsContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(255, 127, 0), + ); + expect(groupMetadata.getProperty("priority")).toBe(10); + expect(groupMetadata.getProperty("isInstanced")).toBe(false); + + const cubesContent = innerContents[1]; + groupMetadata = cubesContent.group.metadata; + expect(groupMetadata).toBeDefined(); + expect(groupMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 255, 127), + ); + expect(groupMetadata.getProperty("priority")).toBe(5); + expect(groupMetadata.getProperty("isInstanced")).toBe(true); + }); + }); + + it("content metadata returns undefined", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + const content = tileset.root.content; + expect(content.metadata).not.toBeDefined(); + }, + ); + }); + + it("assigning content metadata throws", function () { + return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( + function (tileset) { + expect(function () { + const content = tileset.root.content; + content.metadata = {}; + }).toThrowDeveloperError(); + }, + ); + }); + + it("initializes explicit content metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withExplicitContentMetadataUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const batchedContent = innerContents[0]; + const batchedMetadata = batchedContent.metadata; + expect(batchedMetadata).toBeDefined(); + expect(batchedMetadata.getProperty("highlightColor")).toEqual( + new Cartesian3(0, 0, 255), + ); + expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); + + const instancedContent = innerContents[1]; + const instancedMetadata = instancedContent.metadata; + expect(instancedMetadata).toBeDefined(); + expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( + 50, + ); + expect(instancedMetadata.getProperty("author")).toEqual( + "Sample Author", + ); + }); + }); + + it("initializes explicit content metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withExplicitContentMetadataLegacyUrl, + ).then(function (tileset) { + const multipleContents = tileset.root.content; + const innerContents = multipleContents.innerContents; + + const batchedContent = innerContents[0]; + const batchedMetadata = batchedContent.metadata; + expect(batchedMetadata).toBeDefined(); + expect(batchedMetadata.getProperty("highlightColor")).toEqual( + new Cartesian3(0, 0, 255), + ); + expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); + + const instancedContent = innerContents[1]; + const instancedMetadata = instancedContent.metadata; + expect(instancedMetadata).toBeDefined(); + expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( + 50, + ); + expect(instancedMetadata.getProperty("author")).toEqual( + "Sample Author", + ); + }); + }); + + it("initializes implicit content metadata for inner contents", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withImplicitContentMetadataUrl, + ).then(function (tileset) { + const placeholderTile = tileset.root; + const subtreeRootTile = placeholderTile.children[0]; + + // This retrieves the tile at (1, 1, 1) + const subtreeChildTile = subtreeRootTile.children[0]; + + const multipleContents = subtreeChildTile.content; + const innerContents = multipleContents.innerContents; + + const buildingContent = innerContents[0]; + const buildingMetadata = buildingContent.metadata; + expect(buildingMetadata).toBeDefined(); + expect(buildingMetadata.getProperty("height")).toEqual(50); + expect(buildingMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 0, 255), + ); + + const treeContent = innerContents[1]; + const treeMetadata = treeContent.metadata; + expect(treeMetadata).toBeDefined(); + expect(treeMetadata.getProperty("age")).toEqual(16); + }); + }); + + it("initializes implicit content metadata for inner contents (legacy)", function () { + return Cesium3DTilesTester.loadTileset( + scene, + withImplicitContentMetadataLegacyUrl, + ).then(function (tileset) { + const placeholderTile = tileset.root; + const subtreeRootTile = placeholderTile.children[0]; + + // This retrieves the tile at (1, 1, 1) + const subtreeChildTile = subtreeRootTile.children[0]; + + const multipleContents = subtreeChildTile.content; + const innerContents = multipleContents.innerContents; + + const buildingContent = innerContents[0]; + const buildingMetadata = buildingContent.metadata; + expect(buildingMetadata).toBeDefined(); + expect(buildingMetadata.getProperty("height")).toEqual(50); + expect(buildingMetadata.getProperty("color")).toEqual( + new Cartesian3(0, 0, 255), + ); + + const treeContent = innerContents[1]; + const treeMetadata = treeContent.metadata; + expect(treeMetadata).toBeDefined(); + expect(treeMetadata.getProperty("age")).toEqual(16); + }); + }); + }); + */ + }, + "WebGL", +); From 6a07d547817c14513155cbe12755f1b02d6903fa Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 6 Nov 2025 13:54:58 +0100 Subject: [PATCH 7/9] Squashed commits for spec experiments --- .../engine/Source/Scene/Cesium3DTileset.js | 44 +- .../Source/Scene/Dynamic3DTileContent.js | 68 +- .../Specs/Scene/Dynamic3DTileContentSpec.js | 993 ++++++------------ 3 files changed, 372 insertions(+), 733 deletions(-) diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index 71332c239fd0..aca21f0e970c 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -1152,7 +1152,7 @@ function Cesium3DTileset(options) { * The function that determines which inner contents of a dynamic * contents object are currently active. * - * See setDynamicContentPropertyProvider for details. + * See the setter of this property for details. * * @type {Function|undefined} * @private @@ -2186,8 +2186,13 @@ Object.defineProperties(Cesium3DTileset.prototype, { }, /** - * Returns the function that provides the properties based on - * which inner contents of a dynamic content should be active. + * The function that provides the properties based on which inner + * contents of a dynamic content should be active. + * + * This is a function that returns a JSON plain object. This object corresponds + * to one 'key' of a dynamic content definition. It will cause the content + * with this key to be the currently active content, namely, when the + * "update" function of that content is called. * * @memberof Cesium3DTileset.prototype * @readonly @@ -2198,6 +2203,14 @@ Object.defineProperties(Cesium3DTileset.prototype, { get: function () { return this._dynamicContentPropertyProvider; }, + set: function (value) { + if (defined(value) && !defined(this._dynamicContentsDimensions)) { + console.log( + "This tileset does not contain the 3DTILES_dynamic extension. The given function will not have an effect.", + ); + } + this._dynamicContentPropertyProvider = value; + }, }, }); @@ -2470,29 +2483,6 @@ Cesium3DTileset.prototype.loadTileset = function ( return rootTile; }; -/** - * Set the function that determines which dynamic content is currently active. - * - * This is a function that returns a JSON plain object. This object corresponds - * to one 'key' of a dynamic content definition. It will cause the content - * with this key to be the currently active content. - * - * @param {Function|undefined} dynamicContentPropertyProvider The function - */ -Cesium3DTileset.prototype.setDynamicContentPropertyProvider = function ( - dynamicContentPropertyProvider, -) { - if ( - defined(dynamicContentPropertyProvider) && - !defined(this._dynamicContentsDimensions) - ) { - console.log( - "This tileset does not contain the 3DTILES_dynamic extension. The given function will not have an effect.", - ); - } - this._dynamicContentPropertyProvider = dynamicContentPropertyProvider; -}; - /** * XXX_DYNAMIC A draft for a convenience function for the dynamic content * properties provider. Whether or not this should be offered depends on @@ -2538,7 +2528,7 @@ Cesium3DTileset.prototype.setDefaultTimeDynamicContentPropertyProvider = [timeDimensionName]: currentTimeString, }; }; - this.setDynamicContentPropertyProvider(dynamicContentPropertyProvider); + this.dynamicContentPropertyProvider = dynamicContentPropertyProvider; }; /** diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index 6db5a71b27e9..f5372a0eec8a 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -916,19 +916,21 @@ class ContentHandle { } /** - * XXX_DYNAMIC: Only intended for testing: If there is a pending - * request for the content, then wait until the content is - * created, or the content creation failed. + * Only intended for testing: + * + * If there is a pending request for the content, then wait until + * the content is created, or the content creation failed. * * This is here because all the request handling is in the content * classes, without abstractions and clear lifecycle definitions. */ - async awaitPromise() { + async waitForSpecs() { if (defined(this._requestHandle)) { try { await this._deferred.promise; } catch (error) { // Ignored + console.log(error); } } } @@ -1080,13 +1082,16 @@ class ContentHandle { const content = await this._createContent(resource, arrayBuffer); console.log(`ContentHandle: Content was created for ${uri}`); this._content = content; - this._deferred.resolve(); + this._deferred.resolve(content); } catch (error) { console.log( `ContentHandle: Content creation for ${uri} caused error ${error}`, ); this._failed = true; - this._deferred.resolve(); + + // The promise is only intended for testign, and may not be awaited, + // so it cannot be rejected without causing an uncaught error. + this._deferred.resolve(error); } }; @@ -1108,14 +1113,20 @@ class ContentHandle { `ContentHandle: Request was rejected for ${uri}, but actually only cancelled. Better luck next time!`, ); this._requestHandle = undefined; - this._deferred.resolve(); + + // The promise is only intended for testign, and may not be awaited, + // so it cannot be rejected without causing an uncaught error. + this._deferred.resolve(error); return; } // Other errors should indeed cause this handle // to be marked as "failed" this._failed = true; - this._deferred.resolve(); + + // The promise is only intended for testign, and may not be awaited, + // so it cannot be rejected without causing an uncaught error. + this._deferred.resolve(error); }; requestHandleResultPromise.then(onRequestFulfilled, onRequestRejected); requestHandle.ensureRequested(); @@ -1316,19 +1327,6 @@ class Dynamic3DTileContent { */ this._contentHandles = this._createContentHandles(tilesetResource); - /** - * The maximum number of content objects that should be kept - * in memory at the same time. - * - * This is initialized with an arbitrary value. It will be - * increased as necessary to accommodate for the maximum - * number of contents that are found to be "active" at - * the same time. - * - * @type {number} - */ - this._loadedContentHandlesMaxSize = 10; - /** * The mapping from URLs to the ContentHandle objects whose * content is currently defined (i.e. loaded). @@ -1349,6 +1347,19 @@ class Dynamic3DTileContent { this.loadedContentHandleEvicted, ); + /** + * The maximum number of content objects that should be kept + * in the "_loadedContentHandles" LRU cache at the same time. + * + * This is initialized with an arbitrary value. It will be + * increased as necessary to accommodate for the maximum + * number of contents that are found to be "active" at + * any point in time. + * + * @type {number} + */ + this._loadedContentHandlesMaxSize = 10; + /** * The mapping from "keys" to arrays(!) of URIs for the dynamic content. * @@ -2049,6 +2060,21 @@ class Dynamic3DTileContent { } return destroyObject(this); } + + /** + * Only intended for testing: + * + * Wait until all pending promises from content requests are + * either resolved or rejected. + */ + async waitForSpecs() { + for (const contentHandle of this._contentHandles.values()) { + await contentHandle.waitForSpecs(); + } + } } export default Dynamic3DTileContent; + +// Exposed for testing. They should be individual files, though... +export { NDMap, LRUCache, RequestHandle, ContentHandle }; diff --git a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js index d32d7011e8cf..e3d89ceb5bda 100644 --- a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js +++ b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js @@ -1,12 +1,18 @@ -import { Cesium3DTileset, Resource } from "../../index.js"; -import createScene from "../../../../Specs/createScene.js"; -import Dynamic3DTileContent from "../../Source/Scene/Dynamic3DTileContent.js"; +import Dynamic3DTileContent, { + ContentHandle, +} from "../../Source/Scene/Dynamic3DTileContent.js"; import Clock from "../../Source/Core/Clock.js"; import JulianDate from "../../Source/Core/JulianDate.js"; import ClockRange from "../../Source/Core/ClockRange.js"; import ClockStep from "../../Source/Core/ClockStep.js"; import generateJsonBuffer from "../../../../Specs/generateJsonBuffer.js"; import ContextLimits from "../../Source/Renderer/ContextLimits.js"; +import ImageBasedLighting from "../../Source/Scene/ImageBasedLighting.js"; +import pollToPromise from "../../../../Specs/pollToPromise.js"; +import defined from "../../Source/Core/defined.js"; +import Matrix4 from "../../Source/Core/Matrix4.js"; +import Cesium3DTileset from "../../Source/Scene/Cesium3DTileset.js"; +import Resource from "../../Source/Core/Resource.js"; const basicDynamicExampleExtensionObject = { dimensions: [ @@ -54,28 +60,6 @@ const basicDynamicExampleContent = { ], }; -const basicDynamicExampleTilesetJson = { - asset: { - version: "1.1", - }, - - extensions: { - "3DTILES_dynamic": basicDynamicExampleExtensionObject, - }, - - geometricError: 4096, - root: { - boundingVolume: { - box: [32.0, -1.5, 0, 32.0, 0, 0, 0, 1.5, 0, 0, 0, 0], - }, - geometricError: 512, - content: { - uri: "content.json", - }, - refine: "REPLACE", - }, -}; - const isoDynamicExampleExtensionObject = { dimensions: [ { @@ -112,49 +96,117 @@ function createDummyGltfBuffer() { return generateJsonBuffer(gltf).buffer; } -describe( - "Scene/Dynamic3DTileContent", - function () { - let scene; +class MockResourceFetchArrayBufferPromise { + constructor() { + this.mockResolve = undefined; + this.mockReject = undefined; + this.mockPromise = new Promise((resolve, reject) => { + this.mockResolve = resolve; + this.mockReject = reject; + }); + const that = this; + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + return that.mockPromise; + }); + } + + resolve(result) { + this.mockResolve(result); + } + reject(error) { + this.mockReject(error); + } +} - const tilesetResource = new Resource({ url: "http://example.com" }); +function createMockTileset(dynamicExtensionObject) { + const tileset = new Cesium3DTileset(); + tileset._extensions = { + "3DTILES_dynamic": dynamicExtensionObject, + }; + tileset._dynamicContentsDimensions = dynamicExtensionObject.dimensions; - beforeAll(function () { - scene = createScene(); - }); + // XXX Has to be inserted, otherwise it crashes... + tileset.imageBasedLighting = new ImageBasedLighting(); - afterAll(function () { - scene.destroyForSpecs(); - }); + // XXX Have to mock all sorts of stuff, because everybody + // thinks that "private" does not mean anything. + const root = { + tileset: tileset, + _tileset: tileset, + computedTransform: new Matrix4(), + }; + tileset._root = root; - afterEach(function () { - scene.primitives.removeAll(); - }); + return tileset; +} - it("___XXX_DYNAMIC_WORKS___", async function () { - // Create a dummy tileset for testing the statistic tracking - const tileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, - }, +function initializeMockContextLimits() { + // XXX Have to do this... + ContextLimits._maximumCubeMapSize = 2; + // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 - extensions: { - "3DTILES_dynamic": basicDynamicExampleExtensionObject, - }, - }; + // XXX Have to do this as well. Sure, the maximum + // aliased line width has to be set properly for + // testing dynamic contents. + ContextLimits._minimumAliasedLineWidth = -10000; + ContextLimits._maximumAliasedLineWidth = 10000; +} - // Create a dummy tile for testing the statistic tracking - // XXX Have to mock all sorts of stuff, because everybody - // thinks that "private" does not mean anything. - const tile = { - tileset: tileset, - _tileset: tileset, - }; +function createMockFrameState() { + // XXX More mocking, otherwise it crashes somewhere... + const frameState = { + context: { + id: "01234", + uniformState: { + view3D: new Matrix4(), + }, + }, + passes: {}, + afterRender: [], + brdfLutGenerator: { + update() { + // console.log("Oh, whatever..."); + }, + }, + fog: {}, + }; + return frameState; +} - // XXX Have to do this... - ContextLimits._maximumCubeMapSize = 2; - // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 +async function waitForContentHandleReady(contentHandle, tileset, frameState) { + return pollToPromise(() => { + const currentContent = contentHandle._content; + if (!defined(currentContent)) { + console.log("No content yet"); + return false; + } + contentHandle.updateContent(tileset, frameState); + for (const afterRenderCallback of frameState.afterRender) { + afterRenderCallback(); + } + if (!currentContent.ready) { + console.log("currentContent not ready", currentContent); + return false; + } + return true; + }); +} + +describe( + "Scene/Dynamic3DTileContent", + function () { + beforeAll(function () { + initializeMockContextLimits(); + }); + + it("___XXX_DYNAMIC_WORKS___", async function () { + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; const content = new Dynamic3DTileContent( tileset, @@ -173,21 +225,7 @@ describe( // Create a mock promise to manually resolve the // resource request - // eslint-disable-next-line no-unused-vars - let mockResolve; - let mockReject; - const mockPromise = new Promise((resolve, reject) => { - mockResolve = resolve; - mockReject = reject; - }); - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - // XXX For some reason, fetchArrayBuffer twiddles with the - // state of the request, and assigns the url from the - // resource to it. Seriously, what is all this? - this.request.url = this.url; - console.log("returning mockPromise"); - return mockPromise; - }); + const mockPromise = new MockResourceFetchArrayBufferPromise(); // Initially, expect there to be no active contents, but // one pending request @@ -197,10 +235,8 @@ describe( expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); // Now reject the pending request, and wait for things to settle... - mockReject("SPEC_REJECTION"); - for (const contentHandle of content._contentHandles.values()) { - await contentHandle.awaitPromise(); - } + mockPromise.reject("SPEC_REJECTION"); + await content.waitForSpecs(); // Now expect there to be one content, but no pending requests const activeContentsB = content._activeContents; @@ -209,99 +245,14 @@ describe( expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); }); - it("BASIC___XXX_DYNAMIC_WORKS___", function () { - // For spec: Create a dummy tileset and fill it - // with the necessary (private!) properties - const tileset = new Cesium3DTileset(); - tileset._extensions = { - "3DTILES_dynamic": isoDynamicExampleExtensionObject, - }; - tileset._dynamicContentsDimensions = - isoDynamicExampleExtensionObject.dimensions; - - const tile = {}; - const content = new Dynamic3DTileContent( - tileset, - tile, - tilesetResource, - isoDynamicExampleContent, - ); - - // Create a dummy clock for the dynamic content property provider - const clock = new Clock({ - startTime: JulianDate.fromIso8601("2013-12-25"), - currentTime: JulianDate.fromIso8601("2013-12-25"), - stopTime: JulianDate.fromIso8601("2013-12-26"), - clockRange: ClockRange.LOOP_STOP, - clockStep: ClockStep.SYSTEM_CLOCK_MULTIPLIER, - }); - tileset.setDefaultTimeDynamicContentPropertyProvider( - "exampleIsoTimeStamp", - clock, - ); - - // Expect the active content URIs to match the content - // URIs for the current dynamic content properties - const activeContentUrisA = content._activeContentUris; - expect(activeContentUrisA).toEqual(["exampleContent-iso-A.glb"]); - - // Change the current clock time, and expect this - // to be reflected in the active content URIs - clock.currentTime = JulianDate.fromIso8601("2013-12-26"); - - const activeContentUrisB = content._activeContentUris; - expect(activeContentUrisB).toEqual(["exampleContent-iso-B.glb"]); - }); - - it("___QUARRY___XXX_DYNAMIC_WORKS___", function () { - const tileset = basicDynamicExampleTilesetJson; - const tile = {}; - const content = new Dynamic3DTileContent( - tileset, - tile, - tilesetResource, - basicDynamicExampleContent, - ); - - const dynamicContentProperties = { - exampleTimeStamp: "2025-09-25", - exampleRevision: "revision0", - }; - tileset.dynamicContentPropertyProvider = () => { - return dynamicContentProperties; - }; - - // Expect the active content URIs to match the content - // URIs for the current dynamic content properties - const activeContentUrisA = content._activeContentUris; - expect(activeContentUrisA).toEqual([ - "exampleContent-2025-09-25-revision0.glb", - ]); - - // Change the dynamic content properties, and expect - // this to be reflected in the active content URIs - dynamicContentProperties.exampleRevision = "revision1"; - - const activeContentUrisB = content._activeContentUris; - expect(activeContentUrisB).toEqual([ - "exampleContent-2025-09-25-revision1.glb", - ]); - }); - //======================================================================== // Experimental it("returns the active content URIs matching the object that is returned by the default time-dynamic content property provider", function () { - // For spec: Create a dummy tileset and fill it - // with the necessary (private!) properties - const tileset = new Cesium3DTileset(); - tileset._extensions = { - "3DTILES_dynamic": isoDynamicExampleExtensionObject, - }; - tileset._dynamicContentsDimensions = - isoDynamicExampleExtensionObject.dimensions; + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(isoDynamicExampleExtensionObject); + const tile = tileset._root; - const tile = {}; const content = new Dynamic3DTileContent( tileset, tile, @@ -339,29 +290,9 @@ describe( // Veeery experimental... it("tracks the number of pending requests in the tileset statistics", async function () { - // Create a dummy tileset for testing the statistic tracking - const tileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, - }, - - extensions: { - "3DTILES_dynamic": basicDynamicExampleExtensionObject, - }, - }; - - // Create a dummy tile for testing the statistic tracking - // XXX Have to mock all sorts of stuff, because everybody - // thinks that "private" does not mean anything. - const tile = { - tileset: tileset, - _tileset: tileset, - }; - - // XXX Have to do this... - ContextLimits._maximumCubeMapSize = 2; - // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; const content = new Dynamic3DTileContent( tileset, @@ -378,23 +309,7 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the - // resource request - let mockResolve; - // eslint-disable-next-line no-unused-vars - let mockReject; - const mockPromise = new Promise((resolve, reject) => { - mockResolve = resolve; - mockReject = reject; - }); - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - // XXX For some reason, fetchArrayBuffer twiddles with the - // state of the request, and assigns the url from the - // resource to it. Seriously, what is all this? - this.request.url = this.url; - console.log("returning mockPromise"); - return mockPromise; - }); + const mockPromise = new MockResourceFetchArrayBufferPromise(); // Initially, expect there to be no active contents, but // one pending request @@ -404,12 +319,10 @@ describe( expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); // Now resolve the pending request... - mockResolve(createDummyGltfBuffer()); + mockPromise.resolve(createDummyGltfBuffer()); // Wait for things to settle... - for (const contentHandle of content._contentHandles.values()) { - await contentHandle.awaitPromise(); - } + await content.waitForSpecs(); // Now expect there to be one content, but no pending requests const activeContentsB = content._activeContents; @@ -419,29 +332,9 @@ describe( }); it("tracks the number of attempted requests in the tileset statistics", async function () { - // Create a dummy tileset for testing the statistic tracking - const tileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, - }, - - extensions: { - "3DTILES_dynamic": basicDynamicExampleExtensionObject, - }, - }; - - // Create a dummy tile for testing the statistic tracking - // XXX Have to mock all sorts of stuff, because everybody - // thinks that "private" does not mean anything. - const tile = { - tileset: tileset, - _tileset: tileset, - }; - - // XXX Have to do this... - ContextLimits._maximumCubeMapSize = 2; - // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; const content = new Dynamic3DTileContent( tileset, @@ -460,21 +353,7 @@ describe( // Create a mock promise to manually resolve the // resource request - // eslint-disable-next-line no-unused-vars - let mockResolve; - let mockReject; - const mockPromise = new Promise((resolve, reject) => { - mockResolve = resolve; - mockReject = reject; - }); - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - // XXX For some reason, fetchArrayBuffer twiddles with the - // state of the request, and assigns the url from the - // resource to it. Seriously, what is all this? - this.request.url = this.url; - console.log("returning mockPromise"); - return mockPromise; - }); + const mockPromise = new MockResourceFetchArrayBufferPromise(); // Initially, expect there to be no active contents, but // one pending request @@ -483,13 +362,9 @@ describe( expect(tileset.statistics.numberOfPendingRequests).toBe(1); expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); - // Now reject the pending request - mockReject("SPEC_REJECTION"); - - // Wait for things to settle... - for (const contentHandle of content._contentHandles.values()) { - await contentHandle.awaitPromise(); - } + // Now reject the pending request and wait for things to settle + mockPromise.reject("SPEC_REJECTION"); + await content.waitForSpecs(); // Now expect there to be one content, but no pending requests const activeContentsB = content._activeContents; @@ -502,11 +377,13 @@ describe( // DONE: it("returns an empty array as the active content URIs when there is no dynamicContentPropertyProvider", function () { - const tileset = basicDynamicExampleTilesetJson; + const tilesetResource = new Resource({ url: "http://example.com" }); + + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; // For spec: There is no dynamicContentPropertyProvider tileset.dynamicContentPropertyProvider = undefined; - const tile = {}; const content = new Dynamic3DTileContent( tileset, tile, @@ -519,14 +396,15 @@ describe( }); it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns undefined", function () { - const tileset = basicDynamicExampleTilesetJson; + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; tileset.dynamicContentPropertyProvider = () => { // For spec: Return undefined as the current properties return undefined; }; - const tile = {}; const content = new Dynamic3DTileContent( tileset, tile, @@ -539,7 +417,9 @@ describe( }); it("returns an empty array as the active content URIs when the dynamicContentPropertyProvider returns an object that does not have the required properties", function () { - const tileset = basicDynamicExampleTilesetJson; + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; tileset.dynamicContentPropertyProvider = () => { // For spec: Return an object that does not have @@ -550,7 +430,6 @@ describe( }; }; - const tile = {}; const content = new Dynamic3DTileContent( tileset, tile, @@ -563,8 +442,10 @@ describe( }); it("returns the active content URIs matching the object that is returned by the dynamicContentPropertyProvider", function () { - const tileset = basicDynamicExampleTilesetJson; - const tile = {}; + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; + const content = new Dynamic3DTileContent( tileset, tile, @@ -596,473 +477,215 @@ describe( "exampleContent-2025-09-25-revision1.glb", ]); }); + }, + "WebGL", +); - /* - it("requestInnerContents returns promise that resolves to content if successful", async function () { - const mockTileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, - }, - }; - const tile = {}; - const content = new Multiple3DTileContent( - mockTileset, - tile, - tilesetResource, - contentsJson, - ); - - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - return Promise.resolve(makeGltfBuffer()); - }); - - const promise = content.requestInnerContents(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); - - await expectAsync(promise).toBeResolvedTo(jasmine.any(Array)); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); +describe( + "Scene/Dynamic3DTileContent/ContentHandle", + function () { + beforeAll(function () { + initializeMockContextLimits(); }); - it("requestInnerContents returns undefined and updates statistics if all requests cannot be scheduled", function () { - const mockTileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, + it("___XXX_DYNAMIC_CONTENT_HANDLE_WORKS___", async function () { + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; + const frameState = createMockFrameState(); + + const contentHeader = { + uri: "exampleContent-2025-09-25-revision0.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", }, }; - const tile = {}; - const content = new Multiple3DTileContent( - mockTileset, + const contentHandle = new ContentHandle( tile, tilesetResource, - contentsJson, + contentHeader, ); - RequestScheduler.maximumRequestsPerServer = 2; - expect(content.requestInnerContents()).toBeUndefined(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); - }); + contentHandle.addRequestListener({ + requestAttempted(request) { + console.log("requestAttempted", request); + //tileset.statistics.numberOfAttemptedRequests++; + }, + requestStarted(request) { + console.log("requestStarted", request); + //tileset.statistics.numberOfPendingRequests++; + }, + requestCancelled(request) { + console.log("requestCancelled", request); + //tileset.statistics.numberOfPendingRequests--; + }, + requestCompleted(request) { + console.log("requestCompleted", request); + //tileset.statistics.numberOfPendingRequests--; + }, + requestFailed(request) { + console.log("requestFailed", request); + //tileset.statistics.numberOfPendingRequests--; + }, + }); - it("requestInnerContents handles inner content failures", async function () { - const mockTileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, + contentHandle.addContentListener({ + contentLoadedAndReady(content) { + console.log("contentLoadedAndReady", content); + //tileset.statistics.incrementLoadCounts(content); + }, + contentUnloaded(content) { + console.log("contentUnloaded", content); + //tileset.statistics.decrementLoadCounts(content); }, - tileFailed: new Event(), + }); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; }; - const tile = {}; - const content = new Multiple3DTileContent( - mockTileset, - tile, - tilesetResource, - contentsJson, - ); - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - return Promise.reject(new Error("my error")); - }); + // Create a mock promise to manually resolve the + // resource request + const mockPromise = new MockResourceFetchArrayBufferPromise(); - const failureSpy = jasmine.createSpy(); - mockTileset.tileFailed.addEventListener(failureSpy); + const triedContent = contentHandle.tryGetContent(); + console.log("tryGetContent", triedContent); - const promise = content.requestInnerContents(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); + // Now resolve the pending request... + mockPromise.resolve(createDummyGltfBuffer()); - await expectAsync(promise).toBeResolved(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); - expect(failureSpy).toHaveBeenCalledWith( - jasmine.objectContaining({ - message: "my error", - }), - ); + // Wait for the content to become "ready" + await contentHandle.waitForSpecs(); + await waitForContentHandleReady(contentHandle, tileset, frameState); }); - it("requestInnerContents handles cancelled requests", async function () { - const mockTileset = { - statistics: { - numberOfPendingRequests: 0, - numberOfAttemptedRequests: 0, + //======================================================================== + // DONE: + + it("informs listeners about contentLoadedAndReady", async function () { + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; + const frameState = createMockFrameState(); + + const contentHeader = { + uri: "exampleContent-2025-09-25-revision0.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", }, }; - const tile = {}; - const content = new Multiple3DTileContent( - mockTileset, + const contentHandle = new ContentHandle( tile, tilesetResource, - contentsJson, + contentHeader, ); - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - return Promise.resolve(makeGltfBuffer()); + // Attach the listener that is expected to be called + let contentLoadedAndReadyCallCount = 0; + contentHandle.addContentListener({ + contentLoadedAndReady(content) { + //console.log("contentLoadedAndReady", content); + contentLoadedAndReadyCallCount++; + }, + contentUnloaded(content) { + //console.log("contentUnloaded", content); + }, }); - const promise = content.requestInnerContents(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(3); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(0); - - content.cancelRequests(); - - await expectAsync(promise).toBeResolved(); - expect(mockTileset.statistics.numberOfPendingRequests).toBe(0); - expect(mockTileset.statistics.numberOfAttemptedRequests).toBe(3); - }); + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; - it("becomes ready", async function () { - const tileset = await Cesium3DTilesTester.loadTileset( - scene, - multipleContentsUrl, - ); - expect(tileset.root.contentReady).toBeTrue(); - expect(tileset.root.content).toBeDefined(); - }); + // Create a mock promise to manually resolve the + // resource request + const mockPromise = new MockResourceFetchArrayBufferPromise(); - it("renders multiple contents", function () { - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - expectRenderMultipleContents, - ); - }); + // Try to get the content (it's not there yet...) + contentHandle.tryGetContent(); - it("renders multiple contents (legacy)", function () { - return Cesium3DTilesTester.loadTileset( - scene, - multipleContentsLegacyUrl, - ).then(expectRenderMultipleContents); - }); + // Now resolve the pending request... + mockPromise.resolve(createDummyGltfBuffer()); - it("renders multiple contents (legacy with 'content')", function () { - return Cesium3DTilesTester.loadTileset( - scene, - multipleContentsLegacyWithContentUrl, - ).then(expectRenderMultipleContents); - }); + // Wait for the content to become "ready" + await contentHandle.waitForSpecs(); + await waitForContentHandleReady(contentHandle, tileset, frameState); - it("renders valid tiles after tile failure", function () { - const originalLoadJson = Cesium3DTileset.loadJson; - spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { - return originalLoadJson(tilesetUrl).then(function (tilesetJson) { - const contents = tilesetJson.root.contents; - const badTile = { - uri: "nonexistent.b3dm", - }; - contents.splice(1, 0, badTile); - - return tilesetJson; - }); - }); - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - expectRenderMultipleContents, - ); + // Expect the listener to have been informed + expect(contentLoadedAndReadyCallCount).toBe(1); }); - it("renders valid tiles after tile failure (legacy)", function () { - const originalLoadJson = Cesium3DTileset.loadJson; - spyOn(Cesium3DTileset, "loadJson").and.callFake(function (tilesetUrl) { - return originalLoadJson(tilesetUrl).then(function (tilesetJson) { - const content = - tilesetJson.root.extensions["3DTILES_multiple_contents"].contents; - const badTile = { - uri: "nonexistent.b3dm", - }; - content.splice(1, 0, badTile); - - return tilesetJson; - }); - }); - return Cesium3DTilesTester.loadTileset( - scene, - multipleContentsLegacyUrl, - ).then(expectRenderMultipleContents); - }); + it("informs listeners about contentUnloaded", async function () { + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; + const frameState = createMockFrameState(); - it("cancelRequests cancels in-flight requests", function () { - viewNothing(); - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - function (tileset) { - viewAllTiles(); - scene.renderForSpecs(); - - const multipleContents = tileset.root.content; - multipleContents.cancelRequests(); - - return Cesium3DTilesTester.waitForTilesLoaded(scene, tileset).then( - function () { - // the content should be canceled once in total - expect(multipleContents._cancelCount).toBe(1); - }, - ); + const contentHeader = { + uri: "exampleContent-2025-09-25-revision0.glb", + keys: { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", }, + }; + const contentHandle = new ContentHandle( + tile, + tilesetResource, + contentHeader, ); - }); - - it("destroys", function () { - return Cesium3DTilesTester.tileDestroys(scene, multipleContentsUrl); - }); - - describe("metadata", function () { - const withGroupMetadataUrl = - "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.1.json"; - const withGroupMetadataLegacyUrl = - "./Data/Cesium3DTiles/MultipleContents/GroupMetadata/tileset_1.0.json"; - const withExplicitContentMetadataUrl = - "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.1.json"; - const withExplicitContentMetadataLegacyUrl = - "./Data/Cesium3DTiles/Metadata/MultipleContentsWithMetadata/tileset_1.0.json"; - const withImplicitContentMetadataUrl = - "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.1.json"; - const withImplicitContentMetadataLegacyUrl = - "./Data/Cesium3DTiles/Metadata/ImplicitMultipleContentsWithMetadata/tileset_1.0.json"; - - let metadataClass; - let groupMetadata; - - beforeAll(function () { - metadataClass = MetadataClass.fromJson({ - id: "test", - class: { - properties: { - name: { - type: "STRING", - }, - height: { - type: "SCALAR", - componentType: "FLOAT32", - }, - }, - }, - }); - - groupMetadata = new GroupMetadata({ - id: "testGroup", - group: { - properties: { - name: "Test Group", - height: 35.6, - }, - }, - class: metadataClass, - }); - }); - it("group metadata returns undefined", function () { - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - function (tileset) { - const content = tileset.root.content; - expect(content.group).not.toBeDefined(); - }, - ); - }); - - it("assigning group metadata throws", function () { - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - function (tileset) { - expect(function () { - const content = tileset.root.content; - content.group = new Cesium3DContentGroup({ - metadata: groupMetadata, - }); - }).toThrowDeveloperError(); - }, - ); - }); - - it("initializes group metadata for inner contents", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withGroupMetadataUrl, - ).then(function (tileset) { - const multipleContents = tileset.root.content; - const innerContents = multipleContents.innerContents; - - const buildingsContent = innerContents[0]; - let groupMetadata = buildingsContent.group.metadata; - expect(groupMetadata).toBeDefined(); - expect(groupMetadata.getProperty("color")).toEqual( - new Cartesian3(255, 127, 0), - ); - expect(groupMetadata.getProperty("priority")).toBe(10); - expect(groupMetadata.getProperty("isInstanced")).toBe(false); - - const cubesContent = innerContents[1]; - groupMetadata = cubesContent.group.metadata; - expect(groupMetadata).toBeDefined(); - expect(groupMetadata.getProperty("color")).toEqual( - new Cartesian3(0, 255, 127), - ); - expect(groupMetadata.getProperty("priority")).toBe(5); - expect(groupMetadata.getProperty("isInstanced")).toBe(true); - }); + // Attach the listener that is expected to be called + let contentLoadedAndReadyCallCount = 0; + let contentUnloadedCallCount = 0; + contentHandle.addContentListener({ + contentLoadedAndReady(content) { + //console.log("contentLoadedAndReady", content); + contentLoadedAndReadyCallCount++; + }, + contentUnloaded(content) { + //console.log("contentUnloaded", content); + contentUnloadedCallCount++; + }, }); - it("initializes group metadata for inner contents (legacy)", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withGroupMetadataLegacyUrl, - ).then(function (tileset) { - const multipleContents = tileset.root.content; - const innerContents = multipleContents.innerContents; - - const buildingsContent = innerContents[0]; - let groupMetadata = buildingsContent.group.metadata; - expect(groupMetadata).toBeDefined(); - expect(groupMetadata.getProperty("color")).toEqual( - new Cartesian3(255, 127, 0), - ); - expect(groupMetadata.getProperty("priority")).toBe(10); - expect(groupMetadata.getProperty("isInstanced")).toBe(false); - - const cubesContent = innerContents[1]; - groupMetadata = cubesContent.group.metadata; - expect(groupMetadata).toBeDefined(); - expect(groupMetadata.getProperty("color")).toEqual( - new Cartesian3(0, 255, 127), - ); - expect(groupMetadata.getProperty("priority")).toBe(5); - expect(groupMetadata.getProperty("isInstanced")).toBe(true); - }); - }); + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; - it("content metadata returns undefined", function () { - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - function (tileset) { - const content = tileset.root.content; - expect(content.metadata).not.toBeDefined(); - }, - ); - }); + // Create a mock promise to manually resolve the + // resource request + const mockPromise = new MockResourceFetchArrayBufferPromise(); - it("assigning content metadata throws", function () { - return Cesium3DTilesTester.loadTileset(scene, multipleContentsUrl).then( - function (tileset) { - expect(function () { - const content = tileset.root.content; - content.metadata = {}; - }).toThrowDeveloperError(); - }, - ); - }); + // Try to get the content (it's not there yet...) + contentHandle.tryGetContent(); - it("initializes explicit content metadata for inner contents", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withExplicitContentMetadataUrl, - ).then(function (tileset) { - const multipleContents = tileset.root.content; - const innerContents = multipleContents.innerContents; - - const batchedContent = innerContents[0]; - const batchedMetadata = batchedContent.metadata; - expect(batchedMetadata).toBeDefined(); - expect(batchedMetadata.getProperty("highlightColor")).toEqual( - new Cartesian3(0, 0, 255), - ); - expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); - - const instancedContent = innerContents[1]; - const instancedMetadata = instancedContent.metadata; - expect(instancedMetadata).toBeDefined(); - expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( - 50, - ); - expect(instancedMetadata.getProperty("author")).toEqual( - "Sample Author", - ); - }); - }); + // Now resolve the pending request... + mockPromise.resolve(createDummyGltfBuffer()); - it("initializes explicit content metadata for inner contents (legacy)", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withExplicitContentMetadataLegacyUrl, - ).then(function (tileset) { - const multipleContents = tileset.root.content; - const innerContents = multipleContents.innerContents; - - const batchedContent = innerContents[0]; - const batchedMetadata = batchedContent.metadata; - expect(batchedMetadata).toBeDefined(); - expect(batchedMetadata.getProperty("highlightColor")).toEqual( - new Cartesian3(0, 0, 255), - ); - expect(batchedMetadata.getProperty("author")).toEqual("Cesium"); - - const instancedContent = innerContents[1]; - const instancedMetadata = instancedContent.metadata; - expect(instancedMetadata).toBeDefined(); - expect(instancedMetadata.getProperty("numberOfInstances")).toEqual( - 50, - ); - expect(instancedMetadata.getProperty("author")).toEqual( - "Sample Author", - ); - }); - }); + // Wait for the content to become "ready" + await contentHandle.waitForSpecs(); + await waitForContentHandleReady(contentHandle, tileset, frameState); - it("initializes implicit content metadata for inner contents", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withImplicitContentMetadataUrl, - ).then(function (tileset) { - const placeholderTile = tileset.root; - const subtreeRootTile = placeholderTile.children[0]; - - // This retrieves the tile at (1, 1, 1) - const subtreeChildTile = subtreeRootTile.children[0]; - - const multipleContents = subtreeChildTile.content; - const innerContents = multipleContents.innerContents; - - const buildingContent = innerContents[0]; - const buildingMetadata = buildingContent.metadata; - expect(buildingMetadata).toBeDefined(); - expect(buildingMetadata.getProperty("height")).toEqual(50); - expect(buildingMetadata.getProperty("color")).toEqual( - new Cartesian3(0, 0, 255), - ); - - const treeContent = innerContents[1]; - const treeMetadata = treeContent.metadata; - expect(treeMetadata).toBeDefined(); - expect(treeMetadata.getProperty("age")).toEqual(16); - }); - }); + // Reset the handle to unload the content + contentHandle.reset(); - it("initializes implicit content metadata for inner contents (legacy)", function () { - return Cesium3DTilesTester.loadTileset( - scene, - withImplicitContentMetadataLegacyUrl, - ).then(function (tileset) { - const placeholderTile = tileset.root; - const subtreeRootTile = placeholderTile.children[0]; - - // This retrieves the tile at (1, 1, 1) - const subtreeChildTile = subtreeRootTile.children[0]; - - const multipleContents = subtreeChildTile.content; - const innerContents = multipleContents.innerContents; - - const buildingContent = innerContents[0]; - const buildingMetadata = buildingContent.metadata; - expect(buildingMetadata).toBeDefined(); - expect(buildingMetadata.getProperty("height")).toEqual(50); - expect(buildingMetadata.getProperty("color")).toEqual( - new Cartesian3(0, 0, 255), - ); - - const treeContent = innerContents[1]; - const treeMetadata = treeContent.metadata; - expect(treeMetadata).toBeDefined(); - expect(treeMetadata.getProperty("age")).toEqual(16); - }); - }); + // Expect the listener to have been informed + expect(contentLoadedAndReadyCallCount).toBe(1); + expect(contentUnloadedCallCount).toBe(1); }); - */ }, "WebGL", ); From 59b9f79c4a8ea2e023a40ade1cfaf874a1f6c299 Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Thu, 6 Nov 2025 14:28:55 +0100 Subject: [PATCH 8/9] Draft for RequestHandle spec --- .../Specs/Scene/Dynamic3DTileContentSpec.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js index e3d89ceb5bda..a9094f51dab7 100644 --- a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js +++ b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js @@ -1,5 +1,6 @@ import Dynamic3DTileContent, { ContentHandle, + RequestHandle, } from "../../Source/Scene/Dynamic3DTileContent.js"; import Clock from "../../Source/Core/Clock.js"; import JulianDate from "../../Source/Core/JulianDate.js"; @@ -481,6 +482,45 @@ describe( "WebGL", ); +describe("Scene/Dynamic3DTileContent/RequestHandle", function () { + beforeAll(function () { + initializeMockContextLimits(); + }); + + it("___XXX_DYNAMIC_REQUEST_HANDLE_WORKS___", async function () { + const resource = new Resource({ url: "http://example.com/SPEC_DATA.glb" }); + const requestHandle = new RequestHandle(resource); + + // Create a mock promise to manually resolve the + // resource request + const mockPromise = new MockResourceFetchArrayBufferPromise(); + + // Fetch the promise from the request handle + const resultPromise = requestHandle.getResultPromise(); + resultPromise + .then(function (arrayBuffer) { + // use the data + console.log("resolved with ", arrayBuffer); + }) + .catch(function (error) { + // an error occurred + console.log("rejected with ", error); + }); + + // Ensure that there is a pending request + requestHandle.ensureRequested(); + + // This can be called any number of times... + requestHandle.ensureRequested(); + requestHandle.ensureRequested(); + + // Now resolve the pending request, one way or another... + mockPromise.resolve(createDummyGltfBuffer()); + //mockPromise.reject("SPEC_REJECTION"); + //requestHandle.cancel(); + }); +}); + describe( "Scene/Dynamic3DTileContent/ContentHandle", function () { From 48bbf6fd4ffe4fe3109f46148d2dfccf461aca8a Mon Sep 17 00:00:00 2001 From: Marco Hutter Date: Sun, 16 Nov 2025 16:58:01 +0100 Subject: [PATCH 9/9] Squashed commits for spec experiments --- .../Source/Scene/Dynamic3DTileContent.js | 35 +- .../Specs/Scene/Dynamic3DTileContentSpec.js | 677 ++++++++++++++++-- 2 files changed, 636 insertions(+), 76 deletions(-) diff --git a/packages/engine/Source/Scene/Dynamic3DTileContent.js b/packages/engine/Source/Scene/Dynamic3DTileContent.js index f5372a0eec8a..798e52707dc3 100644 --- a/packages/engine/Source/Scene/Dynamic3DTileContent.js +++ b/packages/engine/Source/Scene/Dynamic3DTileContent.js @@ -572,8 +572,7 @@ class RequestHandle { * data from the request, or rejected with an error indicating * the reason for the rejection. * - * The reason for the rejection can either be a real error, - * or 'RequestState.CANCELLED' when the request was cancelled + * The error may simply indicate that the request was cancelled * (or never issued due to this throttling thingy). * * @returns {Promise} The promise @@ -596,11 +595,6 @@ class RequestHandle { return; } - // XXX_DYNAMIC The tileset.statistics.numberOfAttemptedRequests - // and tileset.statistics.numberOfPendingRequests values will - // have to be updated here. This class should not know these - // statistics, and even less know the tileset. - // XXX_DYNAMIC: The Multiple3DTileContent class rambled about it being // important to CLONE the resource, because of some resource leak, and // to create a new request, to "avoid getting stuck in the cancelled state". @@ -617,6 +611,9 @@ class RequestHandle { // the next call to 'ensureRequested'. const requestPromise = resource.fetchArrayBuffer(); if (!defined(requestPromise)) { + console.log( + `RequestHandle: Could not schedule request for ${request.url}, probably throttling`, + ); this._fireRequestAttempted(); return; } @@ -635,7 +632,9 @@ class RequestHandle { `RequestHandle: Resource promise fulfilled but cancelled for ${request.url}`, ); this._requestPromise = undefined; - this._deferred.reject(RequestState.CANCELLED); + const rejectionError = new Error("Request was cancelled"); + rejectionError.code = RequestState.CANCELLED; + this._deferred.reject(rejectionError); this._fireRequestCancelled(); this._fireRequestAttempted(); return; @@ -651,14 +650,16 @@ class RequestHandle { // this exact error. Otherwise, do that CANCELLED handling. const onRejected = (error) => { console.log( - `RequestHandle: Resource promise rejected for ${request.url} with error ${error}`, + `RequestHandle: Request promise rejected for ${request.url} with error ${error}, checking for cancellation....`, ); if (request.state === RequestState.CANCELLED) { console.log( - `RequestHandle: Resource promise rejected but actually only cancelled for ${request.url} - better luck next time!`, + `RequestHandle: Request promise rejected but actually only cancelled for ${request.url} - better luck next time!`, ); this._requestPromise = undefined; - this._deferred.reject(RequestState.CANCELLED); + const rejectionError = new Error("Request was cancelled"); + rejectionError.code = RequestState.CANCELLED; + this._deferred.reject(rejectionError); this._fireRequestCancelled(); this._fireRequestAttempted(); return; @@ -704,7 +705,9 @@ class RequestHandle { this._request.cancel(); this._request = undefined; } - this._deferred.reject(RequestState.CANCELLED); + const rejectionError = new Error("Request was cancelled"); + rejectionError.code = RequestState.CANCELLED; + this._deferred.reject(rejectionError); } /** @@ -1108,13 +1111,13 @@ class ContentHandle { // the request handle is discarded, so that it // will be re-created during the next call to // _ensureRequestPending - if (error === RequestState.CANCELLED) { + if (defined(error) && error.code === RequestState.CANCELLED) { console.log( `ContentHandle: Request was rejected for ${uri}, but actually only cancelled. Better luck next time!`, ); this._requestHandle = undefined; - // The promise is only intended for testign, and may not be awaited, + // The promise is only intended for testing, and may not be awaited, // so it cannot be rejected without causing an uncaught error. this._deferred.resolve(error); return; @@ -1470,14 +1473,14 @@ class Dynamic3DTileContent { contentHandle.addContentListener({ contentLoadedAndReady(content) { console.log( - "-------------------------- update statistics for loaded ", + "Dynamic3DTileContent content handle listener contentLoadedAndReady - update statistics for loaded content: ", content, ); tileset.statistics.incrementLoadCounts(content); }, contentUnloaded(content) { console.log( - "-------------------------- update statistics for unloaded ", + "Dynamic3DTileContent content handle listener contentUnloaded - update statistics for unloaded content: ", content, ); tileset.statistics.decrementLoadCounts(content); diff --git a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js index a9094f51dab7..e416edb32c06 100644 --- a/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js +++ b/packages/engine/Specs/Scene/Dynamic3DTileContentSpec.js @@ -15,6 +15,9 @@ import Matrix4 from "../../Source/Core/Matrix4.js"; import Cesium3DTileset from "../../Source/Scene/Cesium3DTileset.js"; import Resource from "../../Source/Core/Resource.js"; +// A basic top-level extension object that will be added to the +// tileset extensions: It defines the "dimensions" of the +// dynamic content, matching the basicDynamicExampleContent const basicDynamicExampleExtensionObject = { dimensions: [ { @@ -28,6 +31,9 @@ const basicDynamicExampleExtensionObject = { ], }; +// A basic dynamic content that represents what is read from +// the content JSON. The structure is described in the +// basicDynamicExampleExtensionObject const basicDynamicExampleContent = { dynamicContents: [ { @@ -61,6 +67,10 @@ const basicDynamicExampleContent = { ], }; +// A top-level extension object that will be added to the +// tileset extensions: It defines the "dimensions" of the +// dynamic content, matching the isoDynamicExampleContent, +// where the time stamp is an actual ISO8601 string. const isoDynamicExampleExtensionObject = { dimensions: [ { @@ -70,6 +80,9 @@ const isoDynamicExampleExtensionObject = { ], }; +// A dynamic content that represents what is read from +// the content JSON. The structure is described in the +// isoDynamicExampleExtensionObject const isoDynamicExampleContent = { dynamicContents: [ { @@ -88,6 +101,15 @@ const isoDynamicExampleContent = { ], }; +/** + * Creates a buffer containing a minimal valid glTF 2.0 asset + * in JSON representation. + * + * This asset does not contain any binary data. It is only + * used for the specs. + * + * @returns {ArrayBuffer} The buffer + */ function createDummyGltfBuffer() { const gltf = { asset: { @@ -97,7 +119,78 @@ function createDummyGltfBuffer() { return generateJsonBuffer(gltf).buffer; } -class MockResourceFetchArrayBufferPromise { +/** + * A class that tries to provide mocking infrastructure for the + * obscure Resource.fetchArrayBuffer behavior. + * + * It establishes a spy on Resource.fetchArrayBuffer to return + * a "mocking" promise. Calling "resolve" or "reject" will + * resolve or reject this promise accordingly + */ +class ResourceFetchArrayBufferPromiseMock { + /** + * Returns a single mocking object. + * + * After calling this function, the next call to Resource.fetchArrayBuffer + * will return a promise that can be resolved or rejected by calling + * "resolve" or "reject" on this ResourceFetchArrayBufferPromiseMock + * + * @returns {ResourceFetchArrayBufferPromiseMock} The mocking object + */ + static create() { + const result = new ResourceFetchArrayBufferPromiseMock(); + ResourceFetchArrayBufferPromiseMock.setup([result]); + return result; + } + + /** + * Creates a single object that can be used for mocking + * Resource.fetchArrayBuffer calls. + * + * Instances that are created with this method can be passed + * to the "setup" method. + * + * @returns {ResourceFetchArrayBufferPromiseMock} The mocking object + */ + static createSingle() { + const result = new ResourceFetchArrayBufferPromiseMock(); + return result; + } + + /** + * Set up the spy for Resource.fetchArrayBuffer to return the + * mocking promises from the given mocking objects. + * + * Subsequent calls to Resource.fetchArrayBuffer will return + * the "mocking promises" of the given objects. If any + * of the given objects is undefined, then undefined will + * be returned (emulating the "throttling" stuff..) + * + * @param {ResourceFetchArrayBufferPromiseMock|undefined} resourceFetchArrayBufferPromiseMocks The mocking objects + */ + static setup(resourceFetchArrayBufferPromiseMocks) { + let counter = 0; + spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { + // XXX_DYNAMIC For some reason, fetchArrayBuffer twiddles with the + // state of the request, and assigns the url from the + // resource to it. Seriously, what is all this? + this.request.url = this.url; + const resourceFetchArrayBufferPromiseMock = + resourceFetchArrayBufferPromiseMocks[counter]; + const promise = resourceFetchArrayBufferPromiseMock?.mockPromise; + counter++; + console.log( + `Calling mocked Resource.fetchArrayBuffer for ${this.request.url}, returning ${promise}`, + ); + return promise; + }); + } + + /** + * Default constructor. + * + * Only called from factory methods. + */ constructor() { this.mockResolve = undefined; this.mockReject = undefined; @@ -105,24 +198,45 @@ class MockResourceFetchArrayBufferPromise { this.mockResolve = resolve; this.mockReject = reject; }); - const that = this; - spyOn(Resource.prototype, "fetchArrayBuffer").and.callFake(function () { - // XXX For some reason, fetchArrayBuffer twiddles with the - // state of the request, and assigns the url from the - // resource to it. Seriously, what is all this? - this.request.url = this.url; - return that.mockPromise; - }); } + /** + * Resolve the promise that was previously returned by + * Resource.fetchArrayBuffer with the given object. + * + * @param {any} result The result + */ resolve(result) { this.mockResolve(result); } + + /** + * Reject the promise that was previously returned by + * Resource.fetchArrayBuffer with the given error. + * + * @param {any} error The error + */ reject(error) { this.mockReject(error); } } +/** + * Creates a new, actual Cesium3DTileset object that is used in these + * specs. + * + * The returned tileset will have the given object as its + * "3DTILES_dynamic" extension. + * + * The returned tileset may not be ~"fully valid". It may contain + * some stuff that only has to be inserted so that it does not + * crash at random places. (For example, its "root" may not be + * a real Cesium3DTile objects, but only some dummy object). + * + * @param {any} dynamicExtensionObject The extension object that + * defines the structure of the dynamic content + * @returns The tileset + */ function createMockTileset(dynamicExtensionObject) { const tileset = new Cesium3DTileset(); tileset._extensions = { @@ -130,10 +244,10 @@ function createMockTileset(dynamicExtensionObject) { }; tileset._dynamicContentsDimensions = dynamicExtensionObject.dimensions; - // XXX Has to be inserted, otherwise it crashes... + // XXX_DYNAMIC Has to be inserted, otherwise it crashes... tileset.imageBasedLighting = new ImageBasedLighting(); - // XXX Have to mock all sorts of stuff, because everybody + // XXX_DYNAMIC Have to mock all sorts of stuff, because everybody // thinks that "private" does not mean anything. const root = { tileset: tileset, @@ -145,23 +259,43 @@ function createMockTileset(dynamicExtensionObject) { return tileset; } +/** + * A function that has to be called before all specs, and that + * initializes some ContextLimits values with dummy values to + * prevent crashes. + */ function initializeMockContextLimits() { - // XXX Have to do this... + // XXX_DYNAMIC Have to do this... ContextLimits._maximumCubeMapSize = 2; - // otherwise, it crashes due to invalid array size after at https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 + // otherwise, it crashes due to invalid array size near https://github.com/CesiumGS/cesium/blob/453b40d6f10d6da35366ab7c7b7dc5667b1cde06/packages/engine/Source/Scene/DynamicEnvironmentMapManager.js#L84 - // XXX Have to do this as well. Sure, the maximum + // XXX_DYNAMIC Have to do this as well. Sure, the maximum // aliased line width has to be set properly for // testing dynamic contents. ContextLimits._minimumAliasedLineWidth = -10000; ContextLimits._maximumAliasedLineWidth = 10000; } +/** + * Creates an object that can be used in place of a "FrameState" + * for these specs. + * + * This is not a real FrameState object. It does not contain or + * require a GL context. It only contains some properties that + * are acccessed somewhere, and that are filled with dummy + * values to prevent crashes, but still allow to load the + * DUMMY(!) Model3DTileContent that is created from the + * createDummyGltfBuffer glTF objects. + * + * @returns {any} Something that can be used like a FrameState + * in the narrow context of these specs. + */ function createMockFrameState() { - // XXX More mocking, otherwise it crashes somewhere... + // A dummy object that contains the properties that are + // somewhere assumed to be present... const frameState = { context: { - id: "01234", + id: "MOCK_CONTEXT_ID", uniformState: { view3D: new Matrix4(), }, @@ -170,7 +304,7 @@ function createMockFrameState() { afterRender: [], brdfLutGenerator: { update() { - // console.log("Oh, whatever..."); + // Not updating anything here. }, }, fog: {}, @@ -178,19 +312,38 @@ function createMockFrameState() { return frameState; } +/** + * Wait util the content from the given content handle is "ready". + * + * This will poll the content handle util its content is available + * (by the underlying request being resolved), and then poll + * that content until its "ready" flag turns "true", calling + * contentHandle.updateContent repeatedly. + * + * @param {ContentHandle} contentHandle The content handle + * @param {Cesium3DTileset} tileset The tileset + * @param {frameState} frameState The frame state + * @returns {Promise} The promise + */ async function waitForContentHandleReady(contentHandle, tileset, frameState) { + await contentHandle.waitForSpecs(); return pollToPromise(() => { + // The _content is created once the response was received const currentContent = contentHandle._content; if (!defined(currentContent)) { - console.log("No content yet"); + //console.log("No content yet"); return false; } + + // All the magic is happening here... contentHandle.updateContent(tileset, frameState); + + // The afterRender callbacks are what's setting ready=true... for (const afterRenderCallback of frameState.afterRender) { afterRenderCallback(); } if (!currentContent.ready) { - console.log("currentContent not ready", currentContent); + //console.log("currentContent not ready", currentContent); return false; } return true; @@ -204,6 +357,7 @@ describe( initializeMockContextLimits(); }); + /*/ Quarry/experiments it("___XXX_DYNAMIC_WORKS___", async function () { const tilesetResource = new Resource({ url: "http://example.com" }); const tileset = createMockTileset(basicDynamicExampleExtensionObject); @@ -224,9 +378,9 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = ResourceFetchArrayBufferPromiseMock.create(); // Initially, expect there to be no active contents, but // one pending request @@ -236,7 +390,8 @@ describe( expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); // Now reject the pending request, and wait for things to settle... - mockPromise.reject("SPEC_REJECTION"); + resourceFetchArrayBufferPromiseMock.reject("SPEC_REJECTION"); + await content.waitForSpecs(); // Now expect there to be one content, but no pending requests @@ -245,9 +400,15 @@ describe( expect(tileset.statistics.numberOfPendingRequests).toBe(0); expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); }); + //*/ //======================================================================== - // Experimental + // Experimental: + // Test for the "setDefaultTimeDynamicContentPropertyProvider" + // convenience function. It allows setting a dynamic content + // property provider based on a CesiumJS "Clock", and uses + // this to determine the current dynamic content properties + // from the ISO8601 string of the currentTime of the clock. it("returns the active content URIs matching the object that is returned by the default time-dynamic content property provider", function () { const tilesetResource = new Resource({ url: "http://example.com" }); @@ -288,7 +449,7 @@ describe( }); //======================================================================== - // Veeery experimental... + // Tileset statistics tracking: it("tracks the number of pending requests in the tileset statistics", async function () { const tilesetResource = new Resource({ url: "http://example.com" }); @@ -310,29 +471,33 @@ describe( return dynamicContentProperties; }; - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); - // Initially, expect there to be no active contents, but - // one pending request + // Expect there to be NO active contents + // Expect there to be ONE pending request + // Expect there to be NO attempted requests const activeContentsA = content._activeContents; expect(activeContentsA).toEqual([]); expect(tileset.statistics.numberOfPendingRequests).toBe(1); expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); // Now resolve the pending request... - mockPromise.resolve(createDummyGltfBuffer()); + resourceFetchArrayBufferPromiseMock.resolve(createDummyGltfBuffer()); // Wait for things to settle... await content.waitForSpecs(); - // Now expect there to be one content, but no pending requests + // Expect there to be ONE active contents + // Expect there to be NO pending requests + // Expect there to be NO attempted requests (the request was resolved!) const activeContentsB = content._activeContents; expect(activeContentsB.length).toEqual(1); expect(tileset.statistics.numberOfPendingRequests).toBe(0); expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); }); - it("tracks the number of attempted requests in the tileset statistics", async function () { + it("tracks the number of attempted requests in the tileset statistics when a request fails", async function () { const tilesetResource = new Resource({ url: "http://example.com" }); const tileset = createMockTileset(basicDynamicExampleExtensionObject); const tile = tileset._root; @@ -352,22 +517,73 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); - // Initially, expect there to be no active contents, but - // one pending request + // Expect there to be NO active contents + // Expect there to be ONE pending request + // Expect there to be NO attempted requests const activeContentsA = content._activeContents; expect(activeContentsA).toEqual([]); expect(tileset.statistics.numberOfPendingRequests).toBe(1); expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); // Now reject the pending request and wait for things to settle - mockPromise.reject("SPEC_REJECTION"); + resourceFetchArrayBufferPromiseMock.reject("SPEC_REJECTION"); await content.waitForSpecs(); - // Now expect there to be one content, but no pending requests + // Expect there to STILL be to active content (the request failed!) + // Expect there to be NO more pending requests + // Expect there to be ONE attempted request + const activeContentsB = content._activeContents; + expect(activeContentsB.length).toEqual(0); + expect(tileset.statistics.numberOfPendingRequests).toBe(0); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(1); + }); + + it("tracks the number of attempted requests in the tileset statistics when a request was not issued due to throttling", async function () { + const tilesetResource = new Resource({ url: "http://example.com" }); + const tileset = createMockTileset(basicDynamicExampleExtensionObject); + const tile = tileset._root; + + const content = new Dynamic3DTileContent( + tileset, + tile, + tilesetResource, + basicDynamicExampleContent, + ); + + const dynamicContentProperties = { + exampleTimeStamp: "2025-09-25", + exampleRevision: "revision0", + }; + tileset.dynamicContentPropertyProvider = () => { + return dynamicContentProperties; + }; + + // Set up the Resource.fetchArrayBuffer mock to return + // "undefined", emulating that the request was not + // issued due to throttling + ResourceFetchArrayBufferPromiseMock.setup([undefined]); + + // Expect there to be NO active contents + // Expect there to be ONE pending request + // Expect there to be NO attempted requests + const activeContentsA = content._activeContents; + expect(activeContentsA).toEqual([]); + expect(tileset.statistics.numberOfPendingRequests).toBe(1); + expect(tileset.statistics.numberOfAttemptedRequests).toBe(0); + + // Now wait for things to settle. This will involve the + // fetchArrayBuffer call returning "undefined", meaning + // that the request was not really issued + await content.waitForSpecs(); + + // Expect there to STILL be NO active contents + // Expect there to be NO pending requests + // Expect there to be ONE attempted requests const activeContentsB = content._activeContents; expect(activeContentsB.length).toEqual(0); expect(tileset.statistics.numberOfPendingRequests).toBe(0); @@ -375,7 +591,7 @@ describe( }); //======================================================================== - // DONE: + // Active content URI handling it("returns an empty array as the active content URIs when there is no dynamicContentPropertyProvider", function () { const tilesetResource = new Resource({ url: "http://example.com" }); @@ -482,29 +698,236 @@ describe( "WebGL", ); +//============================================================================ + +//============================================================================ +// RequestHandle: +// TODO: There should be dedicated tests for the listener handling +// The existing ones (that update the statistics) are actually +// already "integration tests". + describe("Scene/Dynamic3DTileContent/RequestHandle", function () { beforeAll(function () { initializeMockContextLimits(); }); + /*/ Quarry/experiments it("___XXX_DYNAMIC_REQUEST_HANDLE_WORKS___", async function () { + // XXX_DYNAMIC So here's the deal: + // + // Resource, Request, and RequestScheduler are exposing a + // pretty confusing and underspecified behavior. + // + // For example, Resource.prototype._makeRequest (undocumented!!!) is + // doing a lot of stuff: + // - Assigning some URLs. + // - Creating a request function (Yeah. Why not...) + // - Passing that whole thing to the RequestScheduler. + // - Ignoring the quirks of that class, and setting up some + // chain of promises for some sorts of "retries".... + // + // The actual behavior of Resource.fetchArraybuffer has nothing + // to do with what the inlined code snippet suggests. + // + // The whole behavior that is related to "throttling" is + // undocumented and confusing (e.g. a request can suddenly + // become "cancelled" when it is not issued at all...) + // + // The fact that it was necessary to introduce the RequestHandle + // and ContentHandle classes in an attempt to hide all this already + // is a time sink that is hard to account for. + // But in order to test whether these classes DO hide the quirks + // of the existing classes, it would be necessary to create mocks + // that perfectly(!) mimic this exact behavior. Nothing of that is + // specified, so it's nearly impossible to mock it in a way that + // reflects the actual behavior. + // + const urlA = "http://example.com/SPEC_DATA_A.glb"; + const urlB = "http://example.com/SPEC_DATA_B.glb"; + const urlC = "http://example.com/SPEC_DATA_C.glb"; + const resourceA = new Resource({ + url: urlA, + }); + const requestHandleA = new RequestHandle(resourceA); + + const resourceB = new Resource({ + url: urlB + }); + const requestHandleB = new RequestHandle(resourceB); + + const resourceC = new Resource({ + url: urlC + }); + const requestHandleC = new RequestHandle(resourceC); + + // Create mocks to manually resolve the requests + const resourceFetchArrayBufferPromiseMocks = []; + resourceFetchArrayBufferPromiseMocks.push(ResourceFetchArrayBufferPromiseMock.createSingle()); + resourceFetchArrayBufferPromiseMocks.push(undefined); // Pretend throttling kicks in here... + resourceFetchArrayBufferPromiseMocks.push(ResourceFetchArrayBufferPromiseMock.createSingle()); + resourceFetchArrayBufferPromiseMocks.push(ResourceFetchArrayBufferPromiseMock.createSingle()); + ResourceFetchArrayBufferPromiseMock.setup(resourceFetchArrayBufferPromiseMocks); + + // Track the URLs that are resolved/rejected for the specs + const resolvedUrls = []; + const rejectedUrls = []; + + // Fetch the promises from the request handles, + // and track the resolved/rejected URLs + const resultPromiseA = requestHandleA.getResultPromise(); + resultPromiseA + .then(function (arrayBuffer) { + //console.log("resolved A with ", arrayBuffer); + resolvedUrls.push(urlA); + }) + .catch(function (error) { + //console.log("rejected A with ", error); + rejectedUrls.push(urlA); + }); + + const resultPromiseB = requestHandleB.getResultPromise(); + resultPromiseB + .then(function (arrayBuffer) { + //console.log("resolved B with ", arrayBuffer); + resolvedUrls.push(urlB); + }) + .catch(function (error) { + //console.log("rejected B with ", error); + rejectedUrls.push(urlB); + }); + + const resultPromiseC = requestHandleC.getResultPromise(); + resultPromiseC + .then(function (arrayBuffer) { + //console.log("resolved C with ", arrayBuffer); + resolvedUrls.push(urlC); + }) + .catch(function (error) { + //console.log("rejected C with ", error); + rejectedUrls.push(urlC); + }); + + // Ensure that there are pending requests + requestHandleA.ensureRequested(); + requestHandleB.ensureRequested(); + requestHandleC.ensureRequested(); + + // Resolve the requests that have not been throttled + resourceFetchArrayBufferPromiseMocks[0].resolve(new ArrayBuffer(12)); + // The second mock is "undefined", emulating throttling + resourceFetchArrayBufferPromiseMocks[2].resolve(new ArrayBuffer(23)); + + // Ensure that there are pending requests (this will retry + // the one that has been throttled) + requestHandleA.ensureRequested(); + requestHandleB.ensureRequested(); + requestHandleC.ensureRequested(); + + // Finally, resolve the request that was retried after being + // throttled in the first call + resourceFetchArrayBufferPromiseMocks[3].resolve(new ArrayBuffer(34)); + + // Wait and see... + await resultPromiseA; + await resultPromiseC; + await resultPromiseB; + + //console.log("resolvedUrls: ", resolvedUrls); + //console.log("rejectedUrls: ", rejectedUrls); + + // Expect the resolved URLs in the order in which they have been resolved + // Expect no URLs to have been rejected + expect(resolvedUrls).toEqual([ urlA, urlC, urlB ]); + expect(rejectedUrls).toEqual([]); + }); + //*/ + + it("properly resolves the result promise when the resource promise is resolved", async function () { + const resource = new Resource({ url: "http://example.com/SPEC_DATA.glb" }); + const requestHandle = new RequestHandle(resource); + + // Create a promise mock to manually resolve the + // resource request + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); + + // Fetch the promise from the request handle + let resolveCount = 0; + const resultPromise = requestHandle.getResultPromise(); + resultPromise + .then(function (arrayBuffer) { + if (defined(arrayBuffer)) { + resolveCount++; + } + }) + .catch(function (error) { + console.log("Should not happen in this spec: ", error); + }); + + // Ensure that there is a pending request + requestHandle.ensureRequested(); + + // This can be called any number of times... + requestHandle.ensureRequested(); + requestHandle.ensureRequested(); + + // Now resolve the pending request + resourceFetchArrayBufferPromiseMock.resolve(createDummyGltfBuffer()); + + await expectAsync(resultPromise).toBeResolved(); + expect(resolveCount).toBe(1); + }); + + it("properly rejects the result promise when the resource promise is rejected", async function () { const resource = new Resource({ url: "http://example.com/SPEC_DATA.glb" }); const requestHandle = new RequestHandle(resource); - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); + + // Fetch the promise from the request handle + let rejectCount = 0; + const resultPromise = requestHandle.getResultPromise(); + resultPromise + .then(function (arrayBuffer) { + console.log("Should not happen in this spec: ", arrayBuffer); + }) + .catch(function (error) { + rejectCount++; + }); + + // Ensure that there is a pending request + requestHandle.ensureRequested(); + + // This can be called any number of times... + requestHandle.ensureRequested(); + requestHandle.ensureRequested(); + + // Now resolve the pending request + resourceFetchArrayBufferPromiseMock.reject("SPEC_REJECTION"); + + await expectAsync(resultPromise).toBeRejectedWith("SPEC_REJECTION"); + expect(rejectCount).toBe(1); + }); + + it("properly rejects the result promise when the request is cancelled", async function () { + const resource = new Resource({ url: "http://example.com/SPEC_DATA.glb" }); + const requestHandle = new RequestHandle(resource); + + // Create a promise mock to not actually send out a request + ResourceFetchArrayBufferPromiseMock.create(); // Fetch the promise from the request handle + let rejectCount = 0; const resultPromise = requestHandle.getResultPromise(); resultPromise .then(function (arrayBuffer) { - // use the data - console.log("resolved with ", arrayBuffer); + console.log("Should not happen in this spec: ", arrayBuffer); }) .catch(function (error) { - // an error occurred - console.log("rejected with ", error); + rejectCount++; }); // Ensure that there is a pending request @@ -514,12 +937,125 @@ describe("Scene/Dynamic3DTileContent/RequestHandle", function () { requestHandle.ensureRequested(); requestHandle.ensureRequested(); - // Now resolve the pending request, one way or another... - mockPromise.resolve(createDummyGltfBuffer()); - //mockPromise.reject("SPEC_REJECTION"); - //requestHandle.cancel(); + // Now cancel the pending request + requestHandle.cancel(); + + await expectAsync(resultPromise).toBeRejectedWithError(); + expect(rejectCount).toBe(1); + }); + + it("properly retries and eventually resolves throttled requests", async function () { + const urlA = "http://example.com/SPEC_DATA_A.glb"; + const urlB = "http://example.com/SPEC_DATA_B.glb"; + const urlC = "http://example.com/SPEC_DATA_C.glb"; + const resourceA = new Resource({ + url: urlA, + }); + const requestHandleA = new RequestHandle(resourceA); + + const resourceB = new Resource({ + url: urlB, + }); + const requestHandleB = new RequestHandle(resourceB); + + const resourceC = new Resource({ + url: urlC, + }); + const requestHandleC = new RequestHandle(resourceC); + + // Create mocks to manually resolve the requests + const resourceFetchArrayBufferPromiseMocks = []; + resourceFetchArrayBufferPromiseMocks.push( + ResourceFetchArrayBufferPromiseMock.createSingle(), + ); + resourceFetchArrayBufferPromiseMocks.push(undefined); // Pretend throttling kicks in here... + resourceFetchArrayBufferPromiseMocks.push( + ResourceFetchArrayBufferPromiseMock.createSingle(), + ); + resourceFetchArrayBufferPromiseMocks.push( + ResourceFetchArrayBufferPromiseMock.createSingle(), + ); + ResourceFetchArrayBufferPromiseMock.setup( + resourceFetchArrayBufferPromiseMocks, + ); + + // Track the URLs that are resolved/rejected for the specs + const resolvedUrls = []; + const rejectedUrls = []; + + // Fetch the promises from the request handles, + // and track the resolved/rejected URLs + const resultPromiseA = requestHandleA.getResultPromise(); + resultPromiseA + .then(function (arrayBuffer) { + //console.log("resolved A with ", arrayBuffer); + resolvedUrls.push(urlA); + }) + .catch(function (error) { + //console.log("rejected A with ", error); + rejectedUrls.push(urlA); + }); + + const resultPromiseB = requestHandleB.getResultPromise(); + resultPromiseB + .then(function (arrayBuffer) { + //console.log("resolved B with ", arrayBuffer); + resolvedUrls.push(urlB); + }) + .catch(function (error) { + //console.log("rejected B with ", error); + rejectedUrls.push(urlB); + }); + + const resultPromiseC = requestHandleC.getResultPromise(); + resultPromiseC + .then(function (arrayBuffer) { + //console.log("resolved C with ", arrayBuffer); + resolvedUrls.push(urlC); + }) + .catch(function (error) { + //console.log("rejected C with ", error); + rejectedUrls.push(urlC); + }); + + // Ensure that there are pending requests + requestHandleA.ensureRequested(); + requestHandleB.ensureRequested(); + requestHandleC.ensureRequested(); + + // Resolve the requests that have not been throttled + resourceFetchArrayBufferPromiseMocks[0].resolve(new ArrayBuffer(12)); + // The second mock is "undefined", emulating throttling + resourceFetchArrayBufferPromiseMocks[2].resolve(new ArrayBuffer(23)); + + // Ensure that there are pending requests (this will retry + // the one that has been throttled) + requestHandleA.ensureRequested(); + requestHandleB.ensureRequested(); + requestHandleC.ensureRequested(); + + // Finally, resolve the request that was retried after being + // throttled in the first call + resourceFetchArrayBufferPromiseMocks[3].resolve(new ArrayBuffer(34)); + + // Wait and see... + await resultPromiseA; + await resultPromiseC; + await resultPromiseB; + + //console.log("resolvedUrls: ", resolvedUrls); + //console.log("rejectedUrls: ", rejectedUrls); + + // Expect the resolved URLs in the order in which they have been resolved + // Expect no URLs to have been rejected + expect(resolvedUrls).toEqual([urlA, urlC, urlB]); + expect(rejectedUrls).toEqual([]); }); }); +//============================================================================ + +//============================================================================ +// ContentHandle: describe( "Scene/Dynamic3DTileContent/ContentHandle", @@ -528,6 +1064,7 @@ describe( initializeMockContextLimits(); }); + /*/ Quarry/experiments it("___XXX_DYNAMIC_CONTENT_HANDLE_WORKS___", async function () { const tilesetResource = new Resource({ url: "http://example.com" }); const tileset = createMockTileset(basicDynamicExampleExtensionObject); @@ -589,23 +1126,43 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = ResourceFetchArrayBufferPromiseMock.create(); const triedContent = contentHandle.tryGetContent(); console.log("tryGetContent", triedContent); // Now resolve the pending request... - mockPromise.resolve(createDummyGltfBuffer()); + resourceFetchArrayBufferPromiseMock.resolve(createDummyGltfBuffer()); // Wait for the content to become "ready" - await contentHandle.waitForSpecs(); await waitForContentHandleReady(contentHandle, tileset, frameState); }); + //*/ //======================================================================== - // DONE: + // Content listener handling. + // + // These listeners will, in reality, be attached to the content handles + // that are created in the Dynamic3DTileContent, via the + // _attachTilesetStatisticsTracker function. There, they will + // be used to update the tileset statistics according to the content + // that is loaded or unloaded. + // + // It does not matter what these listeners are doing! + // + // There should be specs for the tileset statistics, to check whether + // they are properly handling loaded/unloaded content. + // + // Here is a spec that only checks whether the listeners are informed + // properly. + // + // (Later, there may be some "integration level" test (with an actual + // tileset JSON being loaded from the Specs/Data), where the combination + // of both is tested. But apart from that, "loading contents" and + // "updating some statistics" are COMPLETELY unrelated things, and + // should be tested independently) it("informs listeners about contentLoadedAndReady", async function () { const tilesetResource = new Resource({ url: "http://example.com" }); @@ -646,18 +1203,18 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); // Try to get the content (it's not there yet...) contentHandle.tryGetContent(); // Now resolve the pending request... - mockPromise.resolve(createDummyGltfBuffer()); + resourceFetchArrayBufferPromiseMock.resolve(createDummyGltfBuffer()); // Wait for the content to become "ready" - await contentHandle.waitForSpecs(); await waitForContentHandleReady(contentHandle, tileset, frameState); // Expect the listener to have been informed @@ -705,18 +1262,18 @@ describe( return dynamicContentProperties; }; - // Create a mock promise to manually resolve the + // Create a promise mock to manually resolve the // resource request - const mockPromise = new MockResourceFetchArrayBufferPromise(); + const resourceFetchArrayBufferPromiseMock = + ResourceFetchArrayBufferPromiseMock.create(); // Try to get the content (it's not there yet...) contentHandle.tryGetContent(); // Now resolve the pending request... - mockPromise.resolve(createDummyGltfBuffer()); + resourceFetchArrayBufferPromiseMock.resolve(createDummyGltfBuffer()); // Wait for the content to become "ready" - await contentHandle.waitForSpecs(); await waitForContentHandleReady(contentHandle, tileset, frameState); // Reset the handle to unload the content