diff --git a/.gitignore b/.gitignore index 240b38f501bf..004aac89e86e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ yarn.lock # WebStorm user-specific .idea/workspace.xml .idea/tasks.xml +.idea/shelf diff --git a/Apps/Sandcastle/gallery/Terrain.html b/Apps/Sandcastle/gallery/Terrain.html index e927a609aedd..a69bf6ba09aa 100644 --- a/Apps/Sandcastle/gallery/Terrain.html +++ b/Apps/Sandcastle/gallery/Terrain.html @@ -59,6 +59,11 @@ credit: "Terrain data courtesy VT MÄK", }); + var arcGisProvider = new Cesium.ArcGISTiledElevationTerrainProvider({ + url: + "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer", + }); + Sandcastle.addToolbarMenu( [ { @@ -103,6 +108,12 @@ viewer.terrainProvider = vrTheWorldProvider; }, }, + { + text: "ArcGISTerrainProvider", + onselect: function () { + viewer.terrainProvider = arcGisProvider; + }, + }, ], "terrainMenu" ); diff --git a/CHANGES.md b/CHANGES.md index 82a3345ca538..f3b59d9f386e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ - Fixed an issue that prevented use of the full CesiumJS zip release package in a Node.js application. - Fixed an issue where certain inputs to EllipsoidGeodesic would result in a surfaceDistance of NaN. [#9316](https://github.com/CesiumGS/cesium/pull/9316) +- Fixed `sampleTerrain` and `sampleTerrainMostDetailed` not working for `ArcGISTiledElevationTerrainProvider`. [#9286](https://github.com/CesiumGS/cesium/pull/9286) ### 1.78 - 2021-02-01 diff --git a/Source/Core/ArcGISTiledElevationTerrainProvider.js b/Source/Core/ArcGISTiledElevationTerrainProvider.js index 1a95dbeb81ed..84123c2729d0 100644 --- a/Source/Core/ArcGISTiledElevationTerrainProvider.js +++ b/Source/Core/ArcGISTiledElevationTerrainProvider.js @@ -304,7 +304,14 @@ Object.defineProperties(ArcGISTiledElevationTerrainProvider.prototype, { */ availability: { get: function () { - return undefined; + //>>includeStart('debug', pragmas.debug) + if (!this._ready) { + throw new DeveloperError( + "availability must not be called before the terrain provider is ready." + ); + } + //>>includeEnd('debug'); + return this._tilesAvailable; }, }, }); @@ -650,6 +657,7 @@ function requestAvailability(that, level, x, y) { // Mark whole area as having availability loaded that._tilesAvailablityLoaded.addAvailableTileRange( + level, xOffset, yOffset, xOffset + dim, diff --git a/Source/Core/HeightmapTerrainData.js b/Source/Core/HeightmapTerrainData.js index fd235a8a38d6..ec4f4e6df771 100644 --- a/Source/Core/HeightmapTerrainData.js +++ b/Source/Core/HeightmapTerrainData.js @@ -434,8 +434,18 @@ HeightmapTerrainData.prototype.interpolateHeight = function ( var heightOffset = structure.heightOffset; var heightScale = structure.heightScale; + var isMeshCreated = defined(this._mesh); + var isLERCEncoding = this._encoding === HeightmapEncoding.LERC; + var isInterpolationImpossible = !isMeshCreated && isLERCEncoding; + if (isInterpolationImpossible) { + // We can't interpolate using the buffer because it's LERC encoded + // so please call createMesh() first and interpolate using the mesh; + // as mesh creation will decode the LERC buffer + return undefined; + } + var heightSample; - if (defined(this._mesh)) { + if (isMeshCreated) { var buffer = this._mesh.vertices; var encoding = this._mesh.encoding; var exaggeration = this._mesh.exaggeration; diff --git a/Source/Core/sampleTerrain.js b/Source/Core/sampleTerrain.js index 93f2f6f3407c..0d1220ee8bd5 100644 --- a/Source/Core/sampleTerrain.js +++ b/Source/Core/sampleTerrain.js @@ -99,6 +99,33 @@ function doSampling(terrainProvider, level, positions) { }); } +/** + * Calls {@link TerrainData#interpolateHeight} on a given {@link TerrainData} for a given {@link Cartographic} and + * will assign the height property if the return value is not undefined. + * + * If the return value is false; it's suggesting that you should call {@link TerrainData#createMesh} first. + * @param {Cartographic} position The position to interpolate for and assign the height value to + * @param {TerrainData} terrainData + * @param {Rectangle} rectangle + * @returns {Boolean} If the height was actually interpolated and assigned + * @private + */ +function interpolateAndAssignHeight(position, terrainData, rectangle) { + var height = terrainData.interpolateHeight( + rectangle, + position.longitude, + position.latitude + ); + if (height === undefined) { + // if height comes back as undefined, it may implicitly mean the terrain data + // requires us to call TerrainData.createMesh() first (ArcGIS requires this in particular) + // so we'll return false and do that next! + return false; + } + position.height = height; + return true; +} + function createInterpolateFunction(tileRequest) { var tilePositions = tileRequest.positions; var rectangle = tileRequest.tilingScheme.tileXYToRectangle( @@ -107,14 +134,50 @@ function createInterpolateFunction(tileRequest) { tileRequest.level ); return function (terrainData) { + var isMeshRequired = false; for (var i = 0; i < tilePositions.length; ++i) { var position = tilePositions[i]; - position.height = terrainData.interpolateHeight( - rectangle, - position.longitude, - position.latitude + var isHeightAssigned = interpolateAndAssignHeight( + position, + terrainData, + rectangle ); + // we've found a position which returned undefined - hinting to us + // that we probably need to create a mesh for this terrain data. + // so break out of this loop and create the mesh - then we'll interpolate all the heights again + if (!isHeightAssigned) { + isMeshRequired = true; + break; + } } + + if (!isMeshRequired) { + // all position heights were interpolated - we don't need the mesh + return when.resolve(); + } + + // create the mesh - and interpolate all the positions again + return terrainData + .createMesh({ + tilingScheme: tileRequest.tilingScheme, + x: tileRequest.x, + y: tileRequest.y, + level: tileRequest.level, + // interpolateHeight will divide away the exaggeration - so passing in 1 is fine; it doesn't really matter + exaggeration: 1, + // don't throttle this mesh creation because we've asked to sample these points; + // so sample them! We don't care how many tiles that is! + throttle: false, + }) + .then(function () { + // mesh has been created - so go through every position (maybe again) + // and re-interpolate the heights - presumably using the mesh this time + for (var i = 0; i < tilePositions.length; ++i) { + var position = tilePositions[i]; + // if it doesn't work this time - that's fine, we tried. + interpolateAndAssignHeight(position, terrainData, rectangle); + } + }); }; } diff --git a/Specs/Core/sampleTerrainSpec.js b/Specs/Core/sampleTerrainSpec.js index 7d0c47681381..b57e2c5568f7 100644 --- a/Specs/Core/sampleTerrainSpec.js +++ b/Specs/Core/sampleTerrainSpec.js @@ -1,6 +1,10 @@ +import { ArcGISTiledElevationTerrainProvider } from "../../Source/Cesium.js"; import { Cartographic } from "../../Source/Cesium.js"; import { CesiumTerrainProvider } from "../../Source/Cesium.js"; import { createWorldTerrain } from "../../Source/Cesium.js"; +import { defined } from "../../Source/Cesium.js"; +import { RequestScheduler } from "../../Source/Cesium.js"; +import { Resource } from "../../Source/Cesium.js"; import { sampleTerrain } from "../../Source/Cesium.js"; describe("Core/sampleTerrain", function () { @@ -99,4 +103,192 @@ describe("Core/sampleTerrain", function () { expect(positions[0].height).toBeDefined(); }); }); + + describe("with terrain providers", function () { + beforeEach(function () { + RequestScheduler.clearForSpecs(); + }); + + afterEach(function () { + Resource._Implementations.loadWithXhr = + Resource._DefaultImplementations.loadWithXhr; + }); + + function spyOnTerrainDataCreateMesh(terrainProvider) { + // do some sneaky spying so we can check how many times createMesh is called + var originalRequestTileGeometry = terrainProvider.requestTileGeometry; + spyOn(terrainProvider, "requestTileGeometry").and.callFake(function ( + x, + y, + level, + request + ) { + // Call the original function! + return originalRequestTileGeometry + .call(terrainProvider, x, y, level, request) + .then(function (tile) { + spyOn(tile, "createMesh").and.callThrough(); + // return the original tile - after we've spied on the createMesh method + return tile; + }); + }); + } + + function expectTileAndMeshCounts( + terrainProvider, + numberOfTilesRequested, + wasFirstTileMeshCreated + ) { + // assert how many tiles were requested + expect(terrainProvider.requestTileGeometry.calls.count()).toEqual( + numberOfTilesRequested + ); + + // get the first tile that was requested + return ( + terrainProvider.requestTileGeometry.calls + .first() + // the return value was the promise of the tile, so wait for that + .returnValue.then(function (terrainData) { + // assert if the mesh was created or not for this tile + expect(terrainData.createMesh.calls.count()).toEqual( + wasFirstTileMeshCreated ? 1 : 0 + ); + }) + ); + } + + function endsWith(value, suffix) { + return value.indexOf(suffix, value.length - suffix.length) >= 0; + } + + function patchXHRLoad(proxySpec) { + Resource._Implementations.loadWithXhr = function ( + url, + responseType, + method, + data, + headers, + deferred, + overrideMimeType + ) { + // find a key (source path) path in the spec which matches (ends with) the requested url + var availablePaths = Object.keys(proxySpec); + var proxiedUrl; + + for (var i = 0; i < availablePaths.length; i++) { + var srcPath = availablePaths[i]; + if (endsWith(url, srcPath)) { + proxiedUrl = proxySpec[availablePaths[i]]; + break; + } + } + + // it's a whitelist - meaning you have to proxy every request explicitly + if (!defined(proxiedUrl)) { + throw new Error( + "Unexpected XHR load to url: " + + url + + "; spec includes: " + + availablePaths.join(", ") + ); + } + + // make a real request to the proxied path for the matching source path + return Resource._DefaultImplementations.loadWithXhr( + proxiedUrl, + responseType, + method, + data, + headers, + deferred, + overrideMimeType + ); + }; + } + + it("should work for Cesium World Terrain", function () { + patchXHRLoad({ + "/layer.json": "Data/CesiumTerrainTileJson/9_759_335/layer.json", + "/9/759/335.terrain?v=1.2.0": + "Data/CesiumTerrainTileJson/9_759_335/9_759_335.terrain", + }); + var terrainProvider = new CesiumTerrainProvider({ + url: "made/up/url", + }); + + spyOnTerrainDataCreateMesh(terrainProvider); + + var positionA = Cartographic.fromDegrees( + 86.93666235421982, + 27.97989963555095 + ); + var positionB = Cartographic.fromDegrees( + 86.9366623542198, + 27.9798996355509 + ); + var positionC = Cartographic.fromDegrees( + 86.936662354213, + 27.979899635557 + ); + + var level = 9; + + return sampleTerrain(terrainProvider, level, [ + positionA, + positionB, + positionC, + ]).then(function () { + expect(positionA.height).toBeCloseTo(7780, 0); + expect(positionB.height).toBeCloseTo(7780, 0); + expect(positionC.height).toBeCloseTo(7780, 0); + // 1 tile was requested (all positions were close enough on the same tile) + // and the mesh was not created because we're using CWT - which doesn't need the mesh for interpolation + return expectTileAndMeshCounts(terrainProvider, 1, false); + }); + }); + + it("should work for ArcGIS terrain", function () { + patchXHRLoad({ + "/?f=pjson": "Data/ArcGIS/9_214_379/root.json", + "/tilemap/10/384/640/128/128": + "Data/ArcGIS/9_214_379/tilemap_10_384_640_128_128.json", + "/tile/9/214/379": "Data/ArcGIS/9_214_379/tile_9_214_379.tile", + }); + + var terrainProvider = new ArcGISTiledElevationTerrainProvider({ + url: "made/up/url", + }); + + spyOnTerrainDataCreateMesh(terrainProvider); + + var positionA = Cartographic.fromDegrees( + 86.93666235421982, + 27.97989963555095 + ); + var positionB = Cartographic.fromDegrees( + 86.9366623542198, + 27.9798996355509 + ); + var positionC = Cartographic.fromDegrees( + 86.936662354213, + 27.979899635557 + ); + + var level = 9; + return sampleTerrain(terrainProvider, level, [ + positionA, + positionB, + positionC, + ]).then(function () { + // 3 very similar positions + expect(positionA.height).toBeCloseTo(7681, 0); + expect(positionB.height).toBeCloseTo(7681, 0); + expect(positionC.height).toBeCloseTo(7681, 0); + // 1 tile was requested (all positions were close enough on the same tile) + // and the mesh was created once because we're using an ArcGIS tile + return expectTileAndMeshCounts(terrainProvider, 1, true); + }); + }); + }); }); diff --git a/Specs/Data/ArcGIS/9_214_379/root.json b/Specs/Data/ArcGIS/9_214_379/root.json new file mode 100644 index 000000000000..6bdad201d370 --- /dev/null +++ b/Specs/Data/ArcGIS/9_214_379/root.json @@ -0,0 +1,281 @@ +{ + "currentVersion": 10.6, + "serviceDescription": "
Terrain 3D provides global elevation for your work in 3D. You can use this layer to visualize your maps and layers in 3D. Ground heights are based on multiple sources. Heights are orthometric (sea level = 0), and water bodies that are above sea level have approximated nominal water heights. This layer includes data from multiple sources ranging from 1000m - 3m, including USGS NED (1/9,1/3,1,2 arc second) covering North America, SRTM (3 arc second) covering 60 degrees north and 56 degrees south and USGS GMTED (7.5, 15 and 30 arc second) covering global land and other GIS community contributors. For more information on this service, including the terms of use, visit us online<\/a>.<\/p>", + "name": "WorldElevation3D/Terrain3D", + "description": "