diff --git a/CHANGES.md b/CHANGES.md index a5dbc6cede..4a80148c6c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # Change Log +### ? - ? + +##### Additions :tada: + +- Added `Tileset::sampleHeightCurrentDetail` to synchonously sample heights from the currently loaded tiles. + ### v0.59.0 - 2026-03-31 ##### Breaking Changes :mega: diff --git a/Cesium3DTilesSelection/include/Cesium3DTilesSelection/Tileset.h b/Cesium3DTilesSelection/include/Cesium3DTilesSelection/Tileset.h index 36c34466e1..557d8879ae 100644 --- a/Cesium3DTilesSelection/include/Cesium3DTilesSelection/Tileset.h +++ b/Cesium3DTilesSelection/include/Cesium3DTilesSelection/Tileset.h @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -365,6 +366,39 @@ class CESIUM3DTILESSELECTION_API Tileset final { CesiumAsync::Future sampleHeightMostDetailed( const std::vector& positions); + /** + * @brief Samples the height of this tileset at a list of cartographic + * positions using only currently-loaded tile content. + * + * Unlike {@link sampleHeightMostDetailed}, this method does not trigger any + * tile loads and returns immediately. It uses whatever is the most detailed + * LOD that is currently loaded in memory, which may not be the most detailed + * LOD available in the tileset. + * + * The height of the input positions is ignored. The output height is + * expressed in meters above the ellipsoid (usually WGS84), which should not + * be confused with a height above mean sea level. + * + * @param positions The positions for which to sample heights. + * @return The result of the height query. + */ + SampleHeightResult sampleHeightCurrentDetail( + const std::vector& positions) const; + + /** + * @brief Samples the height of this tileset at a single cartographic + * position using only currently-loaded tile content. + * + * This is a convenience overload of + * {@link sampleHeightCurrentDetail(const std::vector&) const}. + * + * @param position The position for which to sample height. + * @return The sampled height in meters above the ellipsoid, or std::nullopt + * if no loaded tile covers the position. + */ + std::optional sampleHeightCurrentDetail( + const CesiumGeospatial::Cartographic& position) const; + /** * @brief Gets the default view group that is used when calling * {@link updateView}. diff --git a/Cesium3DTilesSelection/src/Tileset.cpp b/Cesium3DTilesSelection/src/Tileset.cpp index 4afc47a0f4..7ff371c189 100644 --- a/Cesium3DTilesSelection/src/Tileset.cpp +++ b/Cesium3DTilesSelection/src/Tileset.cpp @@ -585,6 +585,57 @@ Tileset::sampleHeightMostDetailed(const std::vector& positions) { return promise.getFuture(); } +SampleHeightResult Tileset::sampleHeightCurrentDetail( + const std::vector& positions) const { + SampleHeightResult results; + if (positions.empty()) { + return results; + } + + results.positions.resize(positions.size(), Cartographic(0.0, 0.0, 0.0)); + results.sampleSuccess.resize(positions.size(), false); + + const Tile* pRootTile = this->_pTilesetContentManager->getRootTile(); + if (!pRootTile) { + results.warnings.emplace_back( + "Height sampling could not complete because the tileset root tile is " + "not available."); + for (size_t i = 0; i < positions.size(); ++i) { + results.positions[i] = positions[i]; + } + return results; + } + + const Ellipsoid& ellipsoid = this->_options.ellipsoid; + + for (size_t i = 0; i < positions.size(); ++i) { + TilesetHeightQuery query(positions[i], ellipsoid); + query.findLoadedCandidateTiles( + const_cast(pRootTile), + results.warnings); + query.intersectCandidateTiles(results.warnings); + + results.positions[i] = positions[i]; + std::optional height = query.getHeightFromIntersection(); + results.sampleSuccess[i] = height.has_value(); + if (height.has_value()) { + results.positions[i].height = *height; + } + } + + return results; +} + +std::optional +Tileset::sampleHeightCurrentDetail(const Cartographic& position) const { + SampleHeightResult result = + this->sampleHeightCurrentDetail(std::vector{position}); + if (!result.sampleSuccess.empty() && result.sampleSuccess[0]) { + return result.positions[0].height; + } + return std::nullopt; +} + TilesetViewGroup& Tileset::getDefaultViewGroup() { return this->_defaultViewGroup; } diff --git a/Cesium3DTilesSelection/src/TilesetHeightQuery.cpp b/Cesium3DTilesSelection/src/TilesetHeightQuery.cpp index b30601d056..8a8ee85534 100644 --- a/Cesium3DTilesSelection/src/TilesetHeightQuery.cpp +++ b/Cesium3DTilesSelection/src/TilesetHeightQuery.cpp @@ -214,6 +214,107 @@ void TilesetHeightQuery::findCandidateTiles( } } +namespace { +bool tileHasRenderContent(const Tile& tile) { + return tile.getState() >= TileLoadState::ContentLoaded && + tile.getContent().getRenderContent() != nullptr; +} +} // namespace + +void TilesetHeightQuery::findLoadedCandidateTiles( + Tile* pTile, + std::vector& warnings) { + if (pTile->getState() == TileLoadState::Failed) { + warnings.emplace_back("Tile load failed during query. Ignoring."); + return; + } + + const std::optional& contentBoundingVolume = + pTile->getContentBoundingVolume(); + + // Recurse into children whose bounding volumes intersect the ray, + // tracking whether any descendant became a candidate. We recurse even + // into children that are not yet loaded, because deeper descendants + // may be loaded. + bool anyDescendantCandidate = false; + if (!pTile->getChildren().empty()) { + for (Tile& child : pTile->getChildren()) { + if (!boundingVolumeContainsCoordinate( + child.getBoundingVolume(), + this->ray, + this->inputPosition, + this->ellipsoid)) + continue; + + size_t prevCount = + this->candidateTiles.size() + this->additiveCandidateTiles.size(); + findLoadedCandidateTiles(&child, warnings); + if (this->candidateTiles.size() + this->additiveCandidateTiles.size() > + prevCount) { + anyDescendantCandidate = true; + } + } + } + + bool isLeaf = pTile->getChildren().empty(); + + // For additive refinement, this tile is always a candidate alongside + // children. + if (!isLeaf && pTile->getRefine() == TileRefine::Add && + tileHasRenderContent(*pTile)) { + if (contentBoundingVolume) { + if (boundingVolumeContainsCoordinate( + *contentBoundingVolume, + this->ray, + this->inputPosition, + this->ellipsoid)) { + this->additiveCandidateTiles.emplace_back(pTile); + } + } else { + this->additiveCandidateTiles.emplace_back(pTile); + } + } + + // Use this tile as a leaf candidate if: + // - It is actually a leaf, OR + // - No descendant was found as a candidate AND this is not an + // additively-refined tile (those are already in additiveCandidateTiles) + // In either case, the tile must have renderable content. + if ((isLeaf || + (!anyDescendantCandidate && pTile->getRefine() != TileRefine::Add)) && + tileHasRenderContent(*pTile)) { + if (contentBoundingVolume) { + if (boundingVolumeContainsCoordinate( + *contentBoundingVolume, + this->ray, + this->inputPosition, + this->ellipsoid)) { + this->candidateTiles.emplace_back(pTile); + } + } else { + this->candidateTiles.emplace_back(pTile); + } + } +} + +void TilesetHeightQuery::intersectCandidateTiles( + std::vector& outWarnings) { + for (const Tile::Pointer& pTile : this->additiveCandidateTiles) { + this->intersectVisibleTile(pTile.get(), outWarnings); + } + for (const Tile::Pointer& pTile : this->candidateTiles) { + this->intersectVisibleTile(pTile.get(), outWarnings); + } +} + +std::optional TilesetHeightQuery::getHeightFromIntersection() const { + if (!this->intersection.has_value()) { + return std::nullopt; + } + return this->ellipsoid.getMaximumRadius() * rayOriginHeightFraction - + glm::sqrt(this->intersection->rayToWorldPointDistanceSq); +} + TilesetHeightRequest::TilesetHeightRequest( std::vector&& queries_, const CesiumAsync::Promise& promise_) noexcept @@ -381,12 +482,7 @@ bool TilesetHeightRequest::tryCompleteHeightRequest( // Do the intersect tests for (TilesetHeightQuery& query : this->queries) { - for (const Tile::Pointer& pTile : query.additiveCandidateTiles) { - query.intersectVisibleTile(pTile.get(), warnings); - } - for (const Tile::Pointer& pTile : query.candidateTiles) { - query.intersectVisibleTile(pTile.get(), warnings); - } + query.intersectCandidateTiles(warnings); } // All rays are done, create results @@ -402,14 +498,11 @@ bool TilesetHeightRequest::tryCompleteHeightRequest( for (size_t i = 0; i < this->queries.size(); ++i) { const TilesetHeightQuery& query = this->queries[i]; - bool sampleSuccess = query.intersection.has_value(); - results.sampleSuccess[i] = sampleSuccess; results.positions[i] = query.inputPosition; - - if (sampleSuccess) { - results.positions[i].height = - options.ellipsoid.getMaximumRadius() * rayOriginHeightFraction - - glm::sqrt(query.intersection->rayToWorldPointDistanceSq); + std::optional height = query.getHeightFromIntersection(); + results.sampleSuccess[i] = height.has_value(); + if (height.has_value()) { + results.positions[i].height = *height; } } diff --git a/Cesium3DTilesSelection/src/TilesetHeightQuery.h b/Cesium3DTilesSelection/src/TilesetHeightQuery.h index 39c846f348..23a021b7a0 100644 --- a/Cesium3DTilesSelection/src/TilesetHeightQuery.h +++ b/Cesium3DTilesSelection/src/TilesetHeightQuery.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,38 @@ class TilesetHeightQuery { * candidate search. */ void findCandidateTiles(Tile* pTile, std::vector& outWarnings); + + /** + * @brief Find candidate tiles using only currently-loaded tiles. + * + * Like {@link findCandidateTiles}, but only considers tiles that already have + * renderable content loaded. If a tile's children are not loaded, the tile + * itself is used as a candidate (if it has renderable content), rather than + * waiting for children to load. + * + * @param pTile The tile at which to start traversal. + * @param outWarnings On return, reports any warnings that occurred during + * candidate search. + */ + void + findLoadedCandidateTiles(Tile* pTile, std::vector& outWarnings); + + /** + * @brief Intersect the ray with all current candidate tiles (both additive + * and regular). + * + * @param outWarnings On return, reports any warnings that occurred during + * intersection testing. + */ + void intersectCandidateTiles(std::vector& outWarnings); + + /** + * @brief Compute the sampled height from the current intersection, if any. + * + * @return The height above the ellipsoid, or std::nullopt if no intersection + * exists. + */ + std::optional getHeightFromIntersection() const; }; /** diff --git a/Cesium3DTilesSelection/test/TestTilesetHeightQueries.cpp b/Cesium3DTilesSelection/test/TestTilesetHeightQueries.cpp index 0e935f2eed..afa0ae79c2 100644 --- a/Cesium3DTilesSelection/test/TestTilesetHeightQueries.cpp +++ b/Cesium3DTilesSelection/test/TestTilesetHeightQueries.cpp @@ -32,14 +32,14 @@ std::filesystem::path testDataPath = Cesium3DTilesSelection_TEST_DATA_DIR; } -TEST_CASE("Tileset height queries") { - // The coordinates and expected heights in this file were determined in Cesium - // for Unreal Engine by adding the tileset, putting a cube above the location - // of interest, adding a CesiumGlobeAnchor to it, and pressing the "End" key - // to drop it onto terrain. The coordinates were then copied out of the globe - // anchor, subtracting 0.5 from the height to account for "End" placing the - // bottom of the cube on the surface instead of its center. - +// The coordinates and expected heights in this file were determined in Cesium +// for Unreal Engine by adding the tileset, putting a cube above the location +// of interest, adding a CesiumGlobeAnchor to it, and pressing the "End" key +// to drop it onto terrain. The coordinates were then copied out of the globe +// anchor, subtracting 0.5 from the height to account for "End" placing the +// bottom of the cube on the surface instead of its center. + +TEST_CASE("Tileset most detailed height queries") { registerAllTileContentTypes(); std::shared_ptr pAccessor = @@ -357,3 +357,229 @@ TEST_CASE("Tileset height queries") { Math::Epsilon1)); } } + +namespace { + +/** + * @brief Creates a ViewState looking down at a given cartographic position. + */ +ViewState createViewState( + const Cartographic& target, + const CesiumGeospatial::Ellipsoid& ellipsoid = + CesiumGeospatial::Ellipsoid::WGS84) { + // Position the camera 200m above the target, looking straight down. + Cartographic cameraPosition(target.longitude, target.latitude, 200.0); + glm::dvec3 position = ellipsoid.cartographicToCartesian(cameraPosition); + glm::dvec3 target3D = ellipsoid.cartographicToCartesian( + Cartographic(target.longitude, target.latitude, 0.0)); + glm::dvec3 direction = glm::normalize(target3D - position); + glm::dvec3 up{0.0, 0.0, 1.0}; + glm::dvec2 viewportSize{500.0, 500.0}; + double aspectRatio = viewportSize.x / viewportSize.y; + double horizontalFieldOfView = Math::degreesToRadians(60.0); + double verticalFieldOfView = + std::atan(std::tan(horizontalFieldOfView * 0.5) / aspectRatio) * 2.0; + return ViewState( + position, + direction, + up, + viewportSize, + horizontalFieldOfView, + verticalFieldOfView, + ellipsoid); +} + +/** + * @brief Loads a tileset to completion for a given view using + * updateViewGroupOffline. + */ +void loadTilesetForView(Tileset& tileset, const ViewState& viewState) { + tileset.updateViewGroupOffline(tileset.getDefaultViewGroup(), {viewState}); +} + +} // namespace + +TEST_CASE("Tileset current detail height queries") { + registerAllTileContentTypes(); + + std::shared_ptr pAccessor = + std::make_shared(); + AsyncSystem asyncSystem(std::make_shared()); + + TilesetExternals externals{pAccessor, nullptr, asyncSystem, nullptr}; + + SUBCASE("Additive-refined tileset") { + std::string url = + "file://" + + Uri::nativePathToUriPath(StringHelpers::toStringUtf8( + (testDataPath / "Tileset" / "tileset.json").u8string())); + + Tileset tileset(externals, url); + + std::vector positions = { + // A point on geometry in "parent.b3dm", which should only be included + // because this tileset is additive-refined. + Cartographic::fromDegrees(-75.612088, 40.042526, 0.0), + + // A point on geometry in a leaf tile. + Cartographic::fromDegrees(-75.612025, 40.041684, 0.0)}; + + // Fully load the tileset at this location + ViewState viewState = createViewState(positions[0]); + loadTilesetForView(tileset, viewState); + + SampleHeightResult results = tileset.sampleHeightCurrentDetail(positions); + CHECK(results.warnings.empty()); + REQUIRE(results.positions.size() == 2); + + CHECK(results.sampleSuccess[0]); + CHECK(Math::equalsEpsilon( + results.positions[0].height, + 78.155809, + 0.0, + Math::Epsilon4)); + + CHECK(results.sampleSuccess[1]); + CHECK(Math::equalsEpsilon( + results.positions[1].height, + 7.837332, + 0.0, + Math::Epsilon4)); + } + + SUBCASE("Replace-refined tileset") { + std::string url = + "file://" + + Uri::nativePathToUriPath(StringHelpers::toStringUtf8( + (testDataPath / "ReplaceTileset" / "tileset.json").u8string())); + + Tileset tileset(externals, url); + + std::vector positions = { + // A point on geometry in "parent.b3dm", which should not be + // included because this tileset is replace-refined. + Cartographic::fromDegrees(-75.612088, 40.042526, 0.0), + + // A point on geometry in a leaf tile. + Cartographic::fromDegrees(-75.612025, 40.041684, 0.0)}; + + // Fully load the tileset at this location + ViewState viewState = createViewState(positions[0]); + loadTilesetForView(tileset, viewState); + + SampleHeightResult results = tileset.sampleHeightCurrentDetail(positions); + CHECK(results.warnings.empty()); + REQUIRE(results.positions.size() == 2); + + CHECK(!results.sampleSuccess[0]); + + CHECK(results.sampleSuccess[1]); + CHECK(Math::equalsEpsilon( + results.positions[1].height, + 7.837332, + 0.0, + Math::Epsilon4)); + } + + SUBCASE("Partially-loaded additive-refined tileset") { + // Use a very high maximumScreenSpaceError so that only the root tile is + // loaded (children are never requested because the root already meets SSE). + std::string url = + "file://" + + Uri::nativePathToUriPath(StringHelpers::toStringUtf8( + (testDataPath / "Tileset" / "tileset.json").u8string())); + + TilesetOptions options; + options.maximumScreenSpaceError = 999999.0; + + Tileset tileset(externals, url, options); + + std::vector positions = { + // A point on geometry in "parent.b3dm" (the root tile content). + Cartographic::fromDegrees(-75.612088, 40.042526, 0.0), + + // A point on geometry in a leaf tile, which should NOT be loaded. + Cartographic::fromDegrees(-75.612025, 40.041684, 0.0)}; + + ViewState viewState = createViewState(positions[0]); + loadTilesetForView(tileset, viewState); + + SampleHeightResult results = tileset.sampleHeightCurrentDetail(positions); + REQUIRE(results.positions.size() == 2); + + // The root tile's geometry should be found. + CHECK(results.sampleSuccess[0]); + CHECK(Math::equalsEpsilon( + results.positions[0].height, + 78.155809, + 0.0, + Math::Epsilon4)); + + // The leaf tile geometry should NOT be found because only the root is + // loaded. + CHECK(!results.sampleSuccess[1]); + } + + SUBCASE("Partially-loaded replace-refined tileset") { + // Use a very high maximumScreenSpaceError so that only the root tile is + // loaded (children are never requested because the root already meets SSE). + std::string url = + "file://" + + Uri::nativePathToUriPath(StringHelpers::toStringUtf8( + (testDataPath / "ReplaceTileset" / "tileset.json").u8string())); + + TilesetOptions options; + options.maximumScreenSpaceError = 999999.0; + + Tileset tileset(externals, url, options); + + std::vector positions = { + // A point on geometry in "parent.b3dm" (the root tile content). + // With replace refinement and only the root loaded, this geometry + // should be found (since no children have replaced it). + Cartographic::fromDegrees(-75.612088, 40.042526, 0.0), + + // A point on geometry in a leaf tile, which should NOT be loaded. + Cartographic::fromDegrees(-75.612025, 40.041684, 0.0)}; + + ViewState viewState = createViewState(positions[0]); + loadTilesetForView(tileset, viewState); + + SampleHeightResult results = tileset.sampleHeightCurrentDetail(positions); + REQUIRE(results.positions.size() == 2); + + // The root tile's geometry should be found because children haven't + // replaced it yet. + CHECK(results.sampleSuccess[0]); + CHECK(Math::equalsEpsilon( + results.positions[0].height, + 78.155809, + 0.0, + Math::Epsilon4)); + + // The leaf tile geometry should NOT be found because only the root is + // loaded. + CHECK(!results.sampleSuccess[1]); + } + + SUBCASE("Convenience overload") { + std::string url = + "file://" + + Uri::nativePathToUriPath(StringHelpers::toStringUtf8( + (testDataPath / "ReplaceTileset" / "tileset.json").u8string())); + + Tileset tileset(externals, url); + + Cartographic position = + Cartographic::fromDegrees(-75.612025, 40.041684, 0.0); + + // Fully load the tileset at this location + ViewState viewState = createViewState({position}); + loadTilesetForView(tileset, viewState); + + std::optional height = tileset.sampleHeightCurrentDetail(position); + + CHECK(height.has_value()); + CHECK(Math::equalsEpsilon(*height, 7.837332, 0.0, Math::Epsilon4)); + } +} \ No newline at end of file