From 9cf9065b0a5c9468c0b4b154b3382c65c91edff4 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:53:52 +0200 Subject: [PATCH 01/53] Consolidate raster handling and use std::expected --- src/CMakeLists.txt | 6 +- src/dag_builder/encoded.h | 6 +- src/dag_builder/thread_safe_storage.h | 6 +- src/mesh_convert/main.cpp | 6 +- src/sf_builder/CMakeLists.txt | 2 +- src/sf_builder/mesh_builder.cpp | 55 ++++--- src/sf_builder/mesh_builder.h | 4 +- src/sf_builder/raster.h | 145 ------------------ src/sf_builder/raw_dataset_reader.h | 14 +- src/sf_builder/terrainbuilder.cpp | 2 +- src/sf_merger/mask.h | 14 +- src/terrainlib/CMakeLists.txt | 1 - src/terrainlib/io/bytes.cpp | 14 +- src/terrainlib/io/bytes.h | 6 +- src/terrainlib/io/serialize.h | 10 +- src/terrainlib/io/serialize.inl | 20 +-- src/terrainlib/mesh/encode.h | 24 +-- src/terrainlib/mesh/io.cpp | 8 +- src/terrainlib/mesh/io.h | 6 +- src/terrainlib/mesh/io/gltf.cpp | 34 ++-- src/terrainlib/mesh/io/gltf.h | 12 +- src/terrainlib/mesh/io/terrain.cpp | 40 ++--- src/terrainlib/mesh/io/terrain.h | 10 +- .../octree/storage/IndexedStorage.h | 2 +- src/terrainlib/octree/storage/RawStorage.h | 22 +-- src/terrainlib/octree/storage/Storage.h | 16 +- src/terrainlib/octree/storage/codec/Codec.h | 6 +- .../octree/storage/codec/DefaultCodec.h | 6 +- .../octree/storage/codec/MeshCodec.h | 6 +- src/terrainlib/octree/storage/helpers.cpp | 6 +- src/terrainlib/octree/storage/helpers.h | 4 +- src/terrainlib/octree/storage/open.h | 4 +- src/terrainlib/octree/storage/open.inl | 6 +- src/terrainlib/pch.h | 2 +- src/terrainlib/srs.h | 10 +- src/terrainlib/uv/unwrap.cpp | 8 +- src/terrainlib/uv/unwrap.h | 10 +- src/tile_builder/CMakeLists.txt | 2 +- src/tile_builder/DatasetReader.cpp | 12 +- src/tile_builder/DatasetReader.h | 8 +- src/tile_builder/Image.cpp | 20 --- src/tile_builder/Image.h | 105 ------------- src/tile_builder/ParallelTileGenerator.cpp | 2 +- src/tile_builder/ParallelTileGenerator.h | 6 +- src/tile_builder/algorithms/primitives.h | 103 ------------- .../algorithms/raster_triangle_scanline.h | 101 ------------ src/tile_builder/alpine_raster.cpp | 8 +- src/tile_builder/alpine_raster.h | 4 +- src/tile_builder/image_writer.cpp | 46 ++++++ src/tile_builder/image_writer.h | 51 ++++++ unittests/CMakeLists.txt | 2 +- unittests/terrainlib/mesh_io.cpp | 12 +- .../tilebuilder/alpine_raster_format.cpp | 8 +- unittests/tilebuilder/dataset_reading.cpp | 17 +- unittests/tilebuilder/image.cpp | 66 -------- unittests/tilebuilder/image_writer.cpp | 86 +++++++++++ .../tilebuilder/parallel_tile_generator.cpp | 2 +- 57 files changed, 431 insertions(+), 783 deletions(-) delete mode 100644 src/sf_builder/raster.h delete mode 100644 src/tile_builder/Image.cpp delete mode 100644 src/tile_builder/Image.h delete mode 100644 src/tile_builder/algorithms/primitives.h delete mode 100644 src/tile_builder/algorithms/raster_triangle_scanline.h create mode 100644 src/tile_builder/image_writer.cpp create mode 100644 src/tile_builder/image_writer.h delete mode 100644 unittests/tilebuilder/image.cpp create mode 100644 unittests/tilebuilder/image_writer.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5ece70b8..95f856ed 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -35,10 +35,6 @@ alp_add_git_repository(stb URL https://github.com/nothings/stb COMMITISH f4a71b1 add_library(stb INTERFACE) target_include_directories(stb SYSTEM INTERFACE ${stb_SOURCE_DIR}) -alp_add_git_repository(tl_expected URL https://github.com/TartanLlama/expected.git COMMITISH v1.1.0 DO_NOT_ADD_SUBPROJECT) -add_library(tl_expected INTERFACE) -target_include_directories(tl_expected SYSTEM INTERFACE ${tl_expected_SOURCE_DIR}/include) - set(SPDLOG_FMT_EXTERNAL ON CACHE BOOL "SPDLOG_FMT_EXTERNAL" FORCE) alp_add_git_repository(spdlog URL https://github.com/gabime/spdlog.git COMMITISH v1.17.0) @@ -54,7 +50,7 @@ set(LIBIGL_COPYLEFT_CORE ON) set(LIBIGL_RESTRICTED_TRIANGLE ON) alp_add_git_repository(libigl URL https://github.com/libigl/libigl.git COMMITISH v2.6.0) -alp_add_git_repository(radix URL https://github.com/AlpineMapsOrg/radix.git COMMITISH 2ce3484513b826bb11e5433f868b048eaffe3e5d NOT_SYSTEM DEEP_CLONE) +alp_add_git_repository(radix URL https://github.com/AlpineMapsOrg/radix.git COMMITISH 9720bce6399f4581e35edc88f87651f020348b34 NOT_SYSTEM DEEP_CLONE) set(ALP_CLI_COMPONENT_OPTIONS ALP_BUILD_SF_MERGER diff --git a/src/dag_builder/encoded.h b/src/dag_builder/encoded.h index 769e567f..2ccee0e9 100644 --- a/src/dag_builder/encoded.h +++ b/src/dag_builder/encoded.h @@ -6,7 +6,7 @@ #include #include -#include +#include #include #include #include @@ -165,14 +165,14 @@ auto serialize(Archive &archive, Clustering &clustering) { } } -inline tl::expected +inline std::expected save_clustering(const Clustering &clustering, const std::filesystem::path &path, const bool make_dirs = true) { return ::io::write_to_path(clustering, path, make_dirs); } -inline tl::expected +inline std::expected load_clustering(const std::filesystem::path &path) { return ::io::read_from_path(path); } diff --git a/src/dag_builder/thread_safe_storage.h b/src/dag_builder/thread_safe_storage.h index 4190acac..b0d25998 100644 --- a/src/dag_builder/thread_safe_storage.h +++ b/src/dag_builder/thread_safe_storage.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "octree/Id.h" @@ -33,7 +33,7 @@ class ThreadSafeStorage { return std::move(this->_storage); } - tl::expected load(const octree::Id &id) const { + std::expected load(const octree::Id &id) const { std::shared_lock lock(this->_mutex); return this->_storage.load(id); } @@ -47,7 +47,7 @@ class ThreadSafeStorage { return this->_storage.base_path(); } - tl::expected save(const octree::Id &id, const value_type &value) const { + std::expected save(const octree::Id &id, const value_type &value) const { std::unique_lock lock(this->_mutex); return this->_storage.save(id, value); } diff --git a/src/mesh_convert/main.cpp b/src/mesh_convert/main.cpp index ef258319..a396ce50 100644 --- a/src/mesh_convert/main.cpp +++ b/src/mesh_convert/main.cpp @@ -1,7 +1,7 @@ #include #include -#include +#include #include "mesh/SimpleMesh.h" #include "mesh/io.h" @@ -11,7 +11,7 @@ void run(const cli::Args& args) { LOG_INFO("Loading input mesh..."); - const tl::expected load_result = mesh::io::load_from_path(args.input_path); + const std::expected load_result = mesh::io::load_from_path(args.input_path); if (!load_result.has_value()) { LOG_ERROR("Failed to load mesh: {}", load_result.error().description()); return; @@ -30,7 +30,7 @@ void run(const cli::Args& args) { } LOG_INFO("Writing output mesh..."); - const tl::expected save_result = mesh::io::save_to_path(mesh, args.output_path); + const std::expected save_result = mesh::io::save_to_path(mesh, args.output_path); if (!save_result.has_value()) { LOG_ERROR("Failed to save mesh: {}", save_result.error().description()); return; diff --git a/src/sf_builder/CMakeLists.txt b/src/sf_builder/CMakeLists.txt index c50cae1c..cfdabb70 100644 --- a/src/sf_builder/CMakeLists.txt +++ b/src/sf_builder/CMakeLists.txt @@ -3,7 +3,7 @@ add_library(sfbuilderlib mesh_builder.cpp ) target_include_directories(sfbuilderlib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(sfbuilderlib PUBLIC terrainlib spdlog tl_expected) +target_link_libraries(sfbuilderlib PUBLIC terrainlib spdlog) add_executable(sf-builder main.cpp) target_link_libraries(sf-builder PRIVATE sfbuilderlib CLI11::CLI11) diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 364d2368..cba54e72 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -15,7 +16,7 @@ #include "mesh/SimpleMesh.h" #include "mesh/cleanup.h" #include "mesh_builder.h" -#include "raster.h" +#include #include "raw_dataset_reader.h" #include "srs.h" #include "mesh/clip.h" @@ -50,7 +51,7 @@ glm::dvec2 apply_transform(std::array transform, const glm::tvec2 return result; } -glm::dvec3 convert_pixel_to_vertex(const float height, const raster::Coords pixel_coords, const RawDatasetReader& reader, const PixelBounds& pixel_bounds) { +glm::dvec3 convert_pixel_to_vertex(const float height, const glm::uvec2 pixel_coords, const RawDatasetReader& reader, const PixelBounds& pixel_bounds) { const glm::dvec2 point_offset_in_raster(0.5); // Convert pixel coordinates into a point in the dataset's srs. const glm::dvec2 coords_raster_relative = glm::dvec2(pixel_coords) + point_offset_in_raster; const glm::dvec2 coords_raster_absolute = coords_raster_relative + glm::dvec2(pixel_bounds.min); @@ -58,9 +59,9 @@ glm::dvec3 convert_pixel_to_vertex(const float height, const raster::Coords pixe return coords_source; } -SimpleMesh meshify(const raster::Raster& source_points, const raster::Mask& mask) { +SimpleMesh meshify(const radix::Raster& source_points, const radix::RasterMask& mask) { // Compact the vertex grid into a list of valid ones. - const size_t valid_vertex_count = std::reduce(mask.begin(), mask.end(), 0); + const size_t valid_vertex_count = std::reduce(mask.begin(), mask.end(), size_t(0)); // Check if we even have any valid vertices. Can happen if all of the region is padding. if (valid_vertex_count == 0) { return SimpleMesh(); @@ -69,11 +70,15 @@ SimpleMesh meshify(const raster::Raster& source_points, const raster std::vector positions; positions.reserve(valid_vertex_count); - const raster::Raster vertex_index_map = raster::transform(source_points, mask, [&](const glm::dvec3 &point) -> size_t { + auto vertex_index_map_result = radix::raster::transform(source_points, mask, [&](const glm::dvec3& point) -> size_t { const size_t index = positions.size(); positions.push_back(point); return index; }); + DEBUG_ASSERT(vertex_index_map_result.has_value()); + if (!vertex_index_map_result.has_value()) + return {}; + const auto vertex_index_map = std::move(*vertex_index_map_result); DEBUG_ASSERT(positions.size() == valid_vertex_count); // Allocate triangle vector @@ -81,13 +86,13 @@ SimpleMesh meshify(const raster::Raster& source_points, const raster std::vector triangles; triangles.reserve(max_triangle_count); - for (size_t y = 0; y < source_points.height() - 1; y++) { - for (size_t x = 0; x < source_points.width() - 1; x++) { - const std::array quad { - raster::Coords{x, y}, - raster::Coords{x + 1, y}, - raster::Coords{x + 1, y + 1}, - raster::Coords{x, y + 1}}; + for (unsigned y = 0; y < source_points.height() - 1; y++) { + for (unsigned x = 0; x < source_points.width() - 1; x++) { + const std::array quad { + glm::uvec2{x, y}, + glm::uvec2{x + 1, y}, + glm::uvec2{x + 1, y + 1}, + glm::uvec2{x, y + 1}}; for (uint32_t i = 0; i < 4; i++) { const auto& v0 = quad[i]; @@ -142,7 +147,7 @@ radix::geometry::Aabb3d extend_bounds_to_3d(radix::geometry::Aabb2d bounds2d) { } } -tl::expected build_reference_mesh_tile( +std::expected build_reference_mesh_tile( Dataset &dataset, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &tile_srs, const radix::tile::SrsBounds &tile_bounds, @@ -150,7 +155,7 @@ tl::expected build_reference_mesh_tile( return build_reference_mesh_patch(dataset, mesh_srs, tile_srs, extend_bounds_to_3d(tile_bounds), texture_srs, texture_bounds); } -tl::expected build_reference_mesh_patch( +std::expected build_reference_mesh_patch( Dataset &dataset, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &clip_srs, const radix::geometry::Aabb3d &clip_bounds, @@ -171,35 +176,39 @@ tl::expected build_reference_mesh_patch( radix::geometry::Aabb2i pixel_bounds = reader.transform_srs_bounds_to_pixel_bounds(target_bounds_in_source_srs); add_border_to_aabb(pixel_bounds, Border(1)); LOG_TRACE("Reading pixels [({}, {})-({}, {})] from dataset", pixel_bounds.min.x, pixel_bounds.min.y, pixel_bounds.max.x, pixel_bounds.max.y); - const std::optional read_result = reader.read_data_in_pixel_bounds_clamped(pixel_bounds); - if (!read_result.has_value() || read_result->size() == 0) { - return tl::unexpected(BuildMeshError::OutOfBounds); + auto read_result = reader.read_data_in_pixel_bounds_clamped(pixel_bounds); + if (!read_result.has_value() || read_result->buffer().empty()) { + return std::unexpected(BuildMeshError::OutOfBounds); } - const raster::HeightMap height_map = read_result.value(); + const radix::Raster height_map = std::move(*read_result); LOG_TRACE("Finding valid pixels"); const float no_data_value = reader.get_no_data_value(); - const raster::Mask valid_mask = raster::transform(height_map, [=](const float height) { + const radix::RasterMask valid_mask = radix::raster::transform(height_map, [=](const float height) { return height != no_data_value; }); LOG_TRACE("Transforming pixels to vertices"); - const raster::Raster source_points = raster::transform(height_map, valid_mask, [&](const float height, const raster::Coords& coords) { + auto source_points_result = radix::raster::transform(height_map, valid_mask, [&](const float height, const glm::uvec2& coords) { return convert_pixel_to_vertex(height, coords, reader, pixel_bounds); }); + DEBUG_ASSERT(source_points_result.has_value()); + if (!source_points_result.has_value()) + return std::unexpected(BuildMeshError::EmptyRegion); + const auto source_points = std::move(*source_points_result); LOG_TRACE("Generating triangles"); SimpleMesh mesh_in_source_srs = meshify(source_points, valid_mask); // Check if we even have any valid vertices. Can happen if all of the region is padding. if (mesh_in_source_srs.vertex_count() == 0 || mesh_in_source_srs.face_count() == 0) { - return tl::unexpected(BuildMeshError::EmptyRegion); + return std::unexpected(BuildMeshError::EmptyRegion); } // Fast check if all vertices will be clipped const radix::geometry::Aabb3d actual_source_bounds = calculate_bounds(mesh_in_source_srs); const radix::geometry::Aabb3d approx_clip_bounds = srs::encompassing_bounds_transfer(source_srs, clip_srs, actual_source_bounds); if (!radix::geometry::intersect(approx_clip_bounds, clip_bounds)) { - return tl::unexpected(BuildMeshError::EmptyRegion); + return std::unexpected(BuildMeshError::EmptyRegion); } LOG_TRACE("Clipping mesh based on target bounds"); @@ -207,7 +216,7 @@ tl::expected build_reference_mesh_patch( SimpleMesh clipped_mesh = mesh::clip_on_bounds(mesh_in_clip_srs, clip_bounds); // Check if there are any vertices left if (clipped_mesh.vertex_count() == 0 || clipped_mesh.face_count() == 0) { - return tl::unexpected(BuildMeshError::EmptyRegion); + return std::unexpected(BuildMeshError::EmptyRegion); } // TODO: move this to another function? diff --git a/src/sf_builder/mesh_builder.h b/src/sf_builder/mesh_builder.h index acf3e4b8..2979042d 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include "Dataset.h" #include "srs.h" @@ -17,7 +17,7 @@ enum class BuildMeshError { std::ostream &operator<<(std::ostream &os, BuildMeshError error); /// Builds a mesh from the given height dataset. -tl::expected build_reference_mesh_patch( +std::expected build_reference_mesh_patch( Dataset &dataset, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &clip_srs, const radix::geometry::Aabb3d &clip_bounds, diff --git a/src/sf_builder/raster.h b/src/sf_builder/raster.h deleted file mode 100644 index fe9ee5a6..00000000 --- a/src/sf_builder/raster.h +++ /dev/null @@ -1,145 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -namespace raster { - -using Index = size_t; -using Coords = glm::vec<2, size_t>; - -template -class Raster { -public: - Raster() = default; - Raster(Index width, Index height) - : _width(width), _height(height), _data(width * height) { - } - - [[nodiscard]] Index width() const { - return this->_width; - } - [[nodiscard]] Index height() const { - return this->_height; - } - [[nodiscard]] decltype(auto) pixel(const Coords &coords) { - DEBUG_ASSERT(coords.x < this->_width); - DEBUG_ASSERT(coords.y < this->_height); - const Index pixel_index = this->index(coords); - if constexpr (std::is_same_v) { - return static_cast(this->_data[pixel_index]); - } else { - return static_cast(this->_data[pixel_index]); - } - } - [[nodiscard]] decltype(auto) pixel(const Coords &coords) const { - DEBUG_ASSERT(coords.x < this->_width); - DEBUG_ASSERT(coords.y < this->_height); - const Index pixel_index = this->index(coords); - if constexpr (std::is_same_v) { - return static_cast(this->_data[pixel_index]); - } else { - return static_cast(this->_data[pixel_index]); - } - } - - [[nodiscard]] Index index(const Coords& coords) const { - return coords.y * this->_width + coords.x; - } - - [[nodiscard]] T *data() { - return this->_data.data(); - } - [[nodiscard]] const T *data() const { - return this->_data.data(); - } - - [[nodiscard]] Index size() const { - return this->_data.size(); - } - [[nodiscard]] auto begin() { - return this->_data.begin(); - } - [[nodiscard]] auto end() { - return this->_data.end(); - } - [[nodiscard]] auto begin() const { - return this->_data.begin(); - } - [[nodiscard]] auto end() const { - return this->_data.end(); - } - -private: - unsigned _width = 0; - unsigned _height = 0; - std::vector _data; -}; - -using Mask = Raster; -using HeightMap = Raster; - -template -concept TransformFn = requires(F f, In in) { - { f(in) }; -}; - -template -concept TransformFnWithCoords = requires(F f, In in, Coords coord) { - { f(in, coord) }; -}; - -template F> -[[nodiscard]] auto transform(const Raster &input, F &&f) { - using Out = decltype(f(input.pixel(Coords(0, 0)))); - Raster output(input.width(), input.height()); - std::transform(input.begin(), input.end(), output.begin(), std::forward(f)); - return output; -} - -template F> -[[nodiscard]] auto transform(const Raster &input, F &&f) { - using Out = decltype(f(input.pixel(Coords(0, 0)), Coords(0, 0))); - Raster output(input.width(), input.height()); - for (Index y = 0; y < input.height(); ++y) { - for (Index x = 0; x < input.width(); ++x) { - Coords coords(x, y); - output.pixel(coords) = f(input.pixel(coords), coords); - } - } - return output; -} - -template F> -[[nodiscard]] auto transform(const Raster &input, const Mask &mask, F &&f) { - using Out = decltype(f(input.pixel(Coords(0, 0)))); - Raster output(input.width(), input.height()); - for (Index y = 0; y < input.height(); ++y) { - for (Index x = 0; x < input.width(); ++x) { - Coords coords(x, y); - if (mask.pixel(coords)) { - output.pixel(coords) = f(input.pixel(coords)); - } - } - } - return output; -} - -template F> -[[nodiscard]] auto transform(const Raster &input, const Mask &mask, F &&f) { - using Out = decltype(f(input.pixel(Coords(0, 0)), Coords(0, 0))); - Raster output(input.width(), input.height()); - for (Index y = 0; y < input.height(); ++y) { - for (Index x = 0; x < input.width(); ++x) { - Coords coords(x, y); - if (mask.pixel(coords)) { - output.pixel(coords) = f(input.pixel(coords), coords); - } - } - } - return output; -} -} diff --git a/src/sf_builder/raw_dataset_reader.h b/src/sf_builder/raw_dataset_reader.h index 9eed966e..88e4d9cb 100644 --- a/src/sf_builder/raw_dataset_reader.h +++ b/src/sf_builder/raw_dataset_reader.h @@ -7,7 +7,7 @@ #include #include "log.h" -#include "raster.h" +#include namespace terrainbuilder { @@ -46,7 +46,7 @@ class RawDatasetReader { } // TODO: support reading other data types - std::optional read_data_in_pixel_bounds(const radix::geometry::Aabb2i& bounds) { + std::optional> read_data_in_pixel_bounds(const radix::geometry::Aabb2i& bounds) { DEBUG_ASSERT(glm::all(glm::greaterThanEqual(bounds.min, glm::ivec2(0)))); DEBUG_ASSERT(glm::all(glm::greaterThanEqual(bounds.max, glm::ivec2(0)))); DEBUG_ASSERT(glm::all(glm::lessThan(bounds.min, glm::ivec2(this->dataset_size())))); @@ -56,7 +56,7 @@ class RawDatasetReader { GDALRasterBand *height_band = this->dataset->GetRasterBand(1); // non-owning pointer // Initialize the HeightData for reading - raster::HeightMap height_data(bounds.width(), bounds.height()); + radix::Raster height_data(glm::uvec2(bounds.width(), bounds.height())); if (bounds.width() == 0 || bounds.height() == 0) { LOG_WARN("Target dataset bounds are empty"); return height_data; @@ -65,7 +65,7 @@ class RawDatasetReader { // Read data from the heights band into heights_data const int read_result = height_band->RasterIO( GF_Read, bounds.min.x, bounds.min.y, bounds.width(), bounds.height(), - static_cast(height_data.data()), bounds.width(), bounds.height(), GDT_Float32, 0, 0); + static_cast(height_data.buffer().data()), bounds.width(), bounds.height(), GDT_Float32, 0, 0); if (read_result != CE_None) { const char * message = CPLGetLastErrorMsg(); @@ -75,7 +75,7 @@ class RawDatasetReader { return height_data; } - std::optional read_data_in_pixel_bounds_clamped(radix::geometry::Aabb2i &bounds) { + std::optional> read_data_in_pixel_bounds_clamped(radix::geometry::Aabb2i &bounds) { const auto original_bounds = bounds; const glm::ivec2 max_in_bounds = glm::ivec2(this->dataset_size()) - glm::ivec2(1); @@ -90,13 +90,13 @@ class RawDatasetReader { if (bounds.width() == 0 || bounds.height() == 0) { LOG_WARN("Target dataset bounds are empty (clamped)"); - return raster::HeightMap(0, 0); + return radix::Raster(); } return this->read_data_in_pixel_bounds(bounds); } - std::optional read_data_in_srs_bounds(const radix::tile::SrsBounds &bounds) { + std::optional> read_data_in_srs_bounds(const radix::tile::SrsBounds &bounds) { // Transform the SrsBounds to pixel space radix::geometry::Aabb2i pixel_bounds = this->transform_srs_bounds_to_pixel_bounds(bounds); diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index ca5a74db..09d47079 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -56,7 +56,7 @@ std::optional build_patch( std::chrono::high_resolution_clock::time_point start; start = std::chrono::high_resolution_clock::now(); LOG_INFO("Building mesh..."); - tl::expected mesh_result = build_reference_mesh_patch( + std::expected mesh_result = build_reference_mesh_patch( dataset, mesh_srs, target_bounds_srs, target_bounds, diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 6ca6ccd3..47f2b875 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -253,7 +253,7 @@ auto length2(const Vec &v) -> decltype(glm::dot(v, v)) { } // namespace -inline tl::expected load_referenced_from_dataset(Dataset& mask_dataset) { +inline std::expected load_referenced_from_dataset(Dataset& mask_dataset) { GDALDataset *dataset = mask_dataset.gdalDataset(); MultipolygonWithHoles2 polygons; @@ -265,7 +265,7 @@ inline tl::expected load_referenced_from_datas if (polygons.is_empty()) { LOG_ERROR("No valid polygons found in mask dataset '{}'", mask_dataset.name()); - return tl::unexpected(LoadErrorKind::EmptySource); + return std::unexpected(LoadErrorKind::EmptySource); } OGRSpatialReference srs; @@ -438,25 +438,25 @@ inline MeshMask extrude( return extrude(mask, padded_radius_range); } -inline tl::expected load_referenced_from_path(const std::filesystem::path &path) { +inline std::expected load_referenced_from_path(const std::filesystem::path &path) { if (!std::filesystem::exists(path)) { LOG_ERROR("Mask file does not exist: {}", path); - return tl::unexpected(LoadErrorKind::FileNotFound); + return std::unexpected(LoadErrorKind::FileNotFound); } auto ds_opt = Dataset::open_vector(path); if (!ds_opt.has_value()) { LOG_ERROR("Failed to load mask datset: {}", path); - return tl::unexpected(LoadErrorKind::FileNotFound); + return std::unexpected(LoadErrorKind::FileNotFound); } Dataset dataset = std::move(ds_opt.value()); return load_referenced_from_dataset(dataset); } -inline tl::expected load_from_path(const std::filesystem::path &path, const glm::dvec2& radius_range) { +inline std::expected load_from_path(const std::filesystem::path &path, const glm::dvec2& radius_range) { auto ref_mask_res = load_referenced_from_path(path); if (!ref_mask_res.has_value()) { - return tl::unexpected(ref_mask_res.error()); + return std::unexpected(ref_mask_res.error()); } ReferencedPolygonMask ref_polygon_mask = std::move(ref_mask_res.value()); SpherePolygonMask sphere_polygon_mask = project_onto_sphere(std::move(ref_polygon_mask), radius_range.x); diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 79dd32d6..2e321547 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -71,7 +71,6 @@ target_link_libraries(terrainlib PUBLIC zpp_bits cgltf TBB::tbb - tl_expected opencv_core opencv_imgproc opencv_imgcodecs diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp index 499b0ee9..1412ce33 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/bytes.cpp @@ -6,7 +6,7 @@ namespace io { -tl::expected write_bytes_to_path(const std::span bytes, const std::filesystem::path &path, bool make_dirs) { +std::expected write_bytes_to_path(const std::span bytes, const std::filesystem::path &path, bool make_dirs) { LOG_TRACE("Writing bytes to path {}", path); if (make_dirs) { @@ -16,31 +16,31 @@ tl::expected write_bytes_to_path(const std::span byt std::ofstream file(path, std::ios::binary); if (!file.is_open()) { LOG_DEBUG("Failed to open file for writing {}", path); - return tl::unexpected(Error::OpenFile); + return std::unexpected(Error::OpenFile); } file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); if (!file.good()) { LOG_ERROR("Failed to write bytes to file {}", path); - return tl::unexpected(Error::WriteBytes); + return std::unexpected(Error::WriteBytes); } return {}; } -tl::expected, Error> read_bytes_from_path(const std::filesystem::path& path) { +std::expected, Error> read_bytes_from_path(const std::filesystem::path& path) { LOG_TRACE("Reading bytes from path {}", path); std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file.is_open()) { LOG_DEBUG("Failed to open file for reading {}", path); - return tl::unexpected(Error::OpenFile); + return std::unexpected(Error::OpenFile); } const std::streamsize size = file.tellg(); if (size < 0) { LOG_ERROR("Failed to determine size for file {}", path); - return tl::unexpected(Error::DetermineSize); + return std::unexpected(Error::DetermineSize); } std::vector buffer(static_cast(size)); @@ -49,7 +49,7 @@ tl::expected, Error> read_bytes_from_path(const std::filesy if (!file.good()) { LOG_ERROR("Failed to read bytes from file {}", path); - return tl::unexpected(Error::ReadBytes); + return std::unexpected(Error::ReadBytes); } return buffer; diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/bytes.h index 5c156cf4..be3852dc 100644 --- a/src/terrainlib/io/bytes.h +++ b/src/terrainlib/io/bytes.h @@ -4,13 +4,13 @@ #include #include -#include +#include #include "io/Error.h" namespace io { -tl::expected write_bytes_to_path(const std::span bytes, const std::filesystem::path &path, bool make_dirs = true); -tl::expected, Error> read_bytes_from_path(const std::filesystem::path &path); +std::expected write_bytes_to_path(const std::span bytes, const std::filesystem::path &path, bool make_dirs = true); +std::expected, Error> read_bytes_from_path(const std::filesystem::path &path); } diff --git a/src/terrainlib/io/serialize.h b/src/terrainlib/io/serialize.h index 55919e23..ffda2d1f 100644 --- a/src/terrainlib/io/serialize.h +++ b/src/terrainlib/io/serialize.h @@ -4,21 +4,21 @@ #include #include -#include +#include #include "io/Error.h" namespace io { template -tl::expected, Error> write_to_bytes(const T &value); +std::expected, Error> write_to_bytes(const T &value); template -tl::expected read_from_bytes(const std::span bytes); +std::expected read_from_bytes(const std::span bytes); template -tl::expected write_to_path(const T &value, const std::filesystem::path &path, bool make_dirs = true); +std::expected write_to_path(const T &value, const std::filesystem::path &path, bool make_dirs = true); template -tl::expected read_from_path(const std::filesystem::path &path); +std::expected read_from_path(const std::filesystem::path &path); } diff --git a/src/terrainlib/io/serialize.inl b/src/terrainlib/io/serialize.inl index 90d7080b..80324d03 100644 --- a/src/terrainlib/io/serialize.inl +++ b/src/terrainlib/io/serialize.inl @@ -9,7 +9,7 @@ namespace io { template -tl::expected, Error> write_to_bytes(const T &value) { +std::expected, Error> write_to_bytes(const T &value) { std::vector data; zpp::bits::out out(data); const auto result = out(value); @@ -21,11 +21,11 @@ tl::expected, Error> write_to_bytes(const T &value) { case std::errc::no_buffer_space: // growing buffer would grow beyond the allocation limits or overflow. case std::errc::message_size: // message size is beyond the user defined allocation limits. case std::errc::result_out_of_range: // attempting to write or read from a too short buffer. - return tl::unexpected(Error::OutOfMemory); + return std::unexpected(Error::OutOfMemory); case std::errc::value_too_large: // varint (variable length integer) encoding is beyond the representation limits. case std::errc::bad_message: // attempt to read a variant of unrecognized type. case std::errc::invalid_argument: // attempting to serialize null pointer or a value-less variant. - return tl::unexpected(Error::Serialize); + return std::unexpected(Error::Serialize); case std::errc::protocol_error: // attempt to deserialize an invalid protocol message. case std::errc::not_supported: // attempt to call an RPC that is not listed as supported. UNREACHABLE(); @@ -37,7 +37,7 @@ tl::expected, Error> write_to_bytes(const T &value) { } template -tl::expected read_from_bytes(const std::span bytes) { +std::expected read_from_bytes(const std::span bytes) { zpp::bits::in in(bytes); T value; const auto result = in(value); @@ -49,11 +49,11 @@ tl::expected read_from_bytes(const std::span bytes) { case std::errc::no_buffer_space: // growing buffer would grow beyond the allocation limits or overflow. case std::errc::message_size: // message size is beyond the user defined allocation limits. case std::errc::result_out_of_range: // attempting to write or read from a too short buffer. - return tl::unexpected(Error::OutOfMemory); + return std::unexpected(Error::OutOfMemory); case std::errc::value_too_large: // varint (variable length integer) encoding is beyond the representation limits. case std::errc::bad_message: // attempt to read a variant of unrecognized type. case std::errc::invalid_argument: // attempting to serialize null pointer or a value-less variant. - return tl::unexpected(Error::Deserialize); + return std::unexpected(Error::Deserialize); case std::errc::protocol_error: // attempt to deserialize an invalid protocol message. case std::errc::not_supported: // attempt to call an RPC that is not listed as supported. UNREACHABLE(); @@ -65,20 +65,20 @@ tl::expected read_from_bytes(const std::span bytes) { } template -tl::expected read_from_path(const std::filesystem::path &path) { +std::expected read_from_path(const std::filesystem::path &path) { const auto result = read_bytes_from_path(path); if (!result.has_value()) { - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } const std::vector bytes = result.value(); return read_from_bytes(bytes); } template -tl::expected write_to_path(const T &value, const std::filesystem::path &path, bool make_dirs) { +std::expected write_to_path(const T &value, const std::filesystem::path &path, bool make_dirs) { const auto result = write_to_bytes(value); if (!result.has_value()) { - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } const std::vector bytes = result.value(); diff --git a/src/terrainlib/mesh/encode.h b/src/terrainlib/mesh/encode.h index 302fd016..eca66508 100644 --- a/src/terrainlib/mesh/encode.h +++ b/src/terrainlib/mesh/encode.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "log.h" #include "mesh/EncodedMesh.h" @@ -81,7 +81,7 @@ inline std::ostream& operator<<(std::ostream& os, const EncodeError& err) { } template -tl::expected encode(const Simple_& mesh, const EncodeOptions options = {}) { +std::expected encode(const Simple_& mesh, const EncodeOptions options = {}) { using Mesh = Simple_; const size_t vertex_count = mesh.vertex_count(); @@ -96,7 +96,7 @@ tl::expected encode(const Simple_& mesh, const position_buf.resize(meshopt_encodeVertexBufferBound(vertex_count, position_size)); const size_t pos_written = meshopt_encodeVertexBuffer(position_buf.data(), position_buf.size(), mesh.positions.data(), vertex_count, position_size); if (pos_written == 0) { - return tl::unexpected(EncodeError::PositionEncode); + return std::unexpected(EncodeError::PositionEncode); } position_buf.resize(pos_written); } @@ -107,7 +107,7 @@ tl::expected encode(const Simple_& mesh, const uv_buf.resize(meshopt_encodeVertexBufferBound(vertex_count, uv_size)); const size_t uv_written = meshopt_encodeVertexBuffer(uv_buf.data(), uv_buf.size(), mesh.uvs.data(), vertex_count, uv_size); if (uv_written == 0) { - return tl::unexpected(EncodeError::UvEncode); + return std::unexpected(EncodeError::UvEncode); } uv_buf.resize(uv_written); } @@ -121,7 +121,7 @@ tl::expected encode(const Simple_& mesh, const reinterpret_cast(mesh.triangles.data()), index_count); if (index_written == 0) { - return tl::unexpected(EncodeError::TriangleEncode); + return std::unexpected(EncodeError::TriangleEncode); } index_buf.resize(index_written); } @@ -133,7 +133,7 @@ tl::expected encode(const Simple_& mesh, const texture_buf = mesh::io::write_texture_to_encoded_buffer(mesh.texture.value(), options.texture_format); } catch (const cv::Exception &e) { LOG_ERROR("Failed while encoding texture {}", e.what()); - return tl::unexpected(EncodeError::TextureEncode); + return std::unexpected(EncodeError::TextureEncode); } } @@ -189,13 +189,13 @@ inline std::ostream &operator<<(std::ostream &os, const DecodeError &err) { template -tl::expected, DecodeError> decode(const Encoded &encoded, const DecodeOptions = {}) { +std::expected, DecodeError> decode(const Encoded &encoded, const DecodeOptions = {}) { const uint32_t expected_component_type = component_type_id(); const Encoded::Header& header = encoded.header; if (header.version != 1 || header.n_dims != n_dims || header.component_type != expected_component_type) { - return tl::unexpected(DecodeError::IncompatibleData); + return std::unexpected(DecodeError::IncompatibleData); } using Mesh = Simple_; @@ -208,7 +208,7 @@ tl::expected, DecodeError> decode(const Encoded &encoded, con mesh.positions.resize(vertex_count); result = meshopt_decodeVertexBuffer(mesh.positions.data(), vertex_count, position_size, encoded.positions.data(), encoded.positions.size()); if (result != 0) { - return tl::unexpected(DecodeError::PositionDecode); + return std::unexpected(DecodeError::PositionDecode); } // Decode uvs @@ -217,7 +217,7 @@ tl::expected, DecodeError> decode(const Encoded &encoded, con mesh.uvs.resize(vertex_count); result = meshopt_decodeVertexBuffer(mesh.uvs.data(), vertex_count, uv_size, encoded.uvs.data(), encoded.uvs.size()); if (result != 0) { - return tl::unexpected(DecodeError::UvDecode); + return std::unexpected(DecodeError::UvDecode); } } @@ -228,7 +228,7 @@ tl::expected, DecodeError> decode(const Encoded &encoded, con mesh.triangles.resize(face_count); result = meshopt_decodeIndexBuffer(mesh.triangles.data(), index_count, index_size, encoded.triangles.data(), encoded.triangles.size()); if (result != 0) { - return tl::unexpected(DecodeError::TriangleDecode); + return std::unexpected(DecodeError::TriangleDecode); } // Decode texture @@ -237,7 +237,7 @@ tl::expected, DecodeError> decode(const Encoded &encoded, con mesh.texture = mesh::io::read_texture_from_encoded_bytes(encoded.texture); } catch (const cv::Exception &e) { LOG_ERROR("Failed while decoding texture {}", e.what()); - return tl::unexpected(DecodeError::TextureDecode); + return std::unexpected(DecodeError::TextureDecode); } } diff --git a/src/terrainlib/mesh/io.cpp b/src/terrainlib/mesh/io.cpp index 6d56c2c1..467e0537 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -6,7 +6,7 @@ namespace mesh::io { -tl::expected load_from_path( +std::expected load_from_path( const std::filesystem::path &path, const LoadOptions& options) { const std::filesystem::path extension = path.extension(); @@ -15,11 +15,11 @@ tl::expected load_from_path( } else if (extension == ".terrain") { return terrain::load_from_path(path, options); } else { - return tl::unexpected(LoadMeshErrorKind::UnsupportedFormat); + return std::unexpected(LoadMeshErrorKind::UnsupportedFormat); } } -tl::expected save_to_path( +std::expected save_to_path( const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions &options) { @@ -33,7 +33,7 @@ tl::expected save_to_path( } else if (extension == ".terrain") { return terrain::save_to_path(mesh, path, options); } else { - return tl::unexpected(SaveMeshErrorKind::UnsupportedFormat); + return std::unexpected(SaveMeshErrorKind::UnsupportedFormat); } } diff --git a/src/terrainlib/mesh/io.h b/src/terrainlib/mesh/io.h index 81fb076e..ea474c73 100644 --- a/src/terrainlib/mesh/io.h +++ b/src/terrainlib/mesh/io.h @@ -2,7 +2,7 @@ #include -#include +#include #include "mesh/SimpleMesh.h" #include "mesh/io/options.h" @@ -10,11 +10,11 @@ namespace mesh::io { -tl::expected load_from_path( +std::expected load_from_path( const std::filesystem::path &path, const LoadOptions& options = {}); -tl::expected save_to_path( +std::expected save_to_path( const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions& options = {}); diff --git a/src/terrainlib/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index bb01bb45..61c0fec1 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -212,7 +212,7 @@ std::optional load_texture_from_material(const cgltf_material &material #define GET_OR_INVALID_FORMAT(var, opt) \ do { \ if (!(opt).has_value()) { \ - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); \ + return std::unexpected(LoadMeshErrorKind::InvalidFormat); \ } else { \ var = opt.value(); \ } \ @@ -277,56 +277,56 @@ static std::string image_ext_to_mime(std::string_view extension) { } } -tl::expected load_raw_from_path(const std::filesystem::path &path) { +std::expected load_raw_from_path(const std::filesystem::path &path) { cgltf_options options = {}; cgltf_data *data = NULL; const std::string path_str = path.string(); const char *path_ptr = path_str.c_str(); cgltf_result result = cgltf_parse_file(&options, path_ptr, &data); if (result != cgltf_result::cgltf_result_success) { - return tl::unexpected(result); + return std::unexpected(result); } result = cgltf_load_buffers(&options, data, path_ptr); if (result != cgltf_result::cgltf_result_success) { cgltf_free(data); - return tl::unexpected(result); + return std::unexpected(result); } result = cgltf_validate(data); if (result != cgltf_result_success) { cgltf_free(data); - return tl::unexpected(result); + return std::unexpected(result); } return RawMesh(data, cgltf_free); } -tl::expected load_from_raw(const RawMesh &raw, const LoadOptions& /* options */) { +std::expected load_from_raw(const RawMesh &raw, const LoadOptions& /* options */) { LOG_TRACE("Loading mesh from gltf data"); const cgltf_data &data = *raw; const auto mesh_opt = get_single_element("mesh", data.meshes_count, data.meshes); if (!mesh_opt.has_value()) { - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } const cgltf_mesh &mesh = mesh_opt.value(); const auto mesh_primitive_opt = get_single_element("mesh primitive", mesh.primitives_count, mesh.primitives); if (!mesh_primitive_opt.has_value()) { - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } const cgltf_primitive &mesh_primitive = mesh_primitive_opt.value(); if (mesh_primitive.type != cgltf_primitive_type::cgltf_primitive_type_triangles) { LOG_ERROR("mesh has invalid primitive type"); - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } // indices if (mesh_primitive.indices == nullptr) { LOG_ERROR("mesh primitive has no indices"); - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } cgltf_accessor &index_accessor = *mesh_primitive.indices; std::vector indices; @@ -342,13 +342,13 @@ tl::expected load_from_raw(const RawMesh &raw, const cgltf_attribute *position_attr = find_attribute_with_type(mesh_primitive.attributes, mesh_primitive.attributes_count, cgltf_attribute_type_position); if (position_attr == nullptr) { LOG_ERROR("mesh has no position attribute"); - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } cgltf_accessor &position_accessor = *position_attr->data; if (position_accessor.type != cgltf_type_vec3) { LOG_WARN("mesh positions are not vec3"); - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } std::vector positions; positions.resize(position_accessor.count); @@ -363,7 +363,7 @@ tl::expected load_from_raw(const RawMesh &raw, const cgltf_accessor &uv_accessor = *uv_attr->data; if (uv_accessor.type != cgltf_type_vec2) { LOG_WARN("mesh uvss are not vec2"); - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } uvs.resize(uv_accessor.count); cgltf_accessor_unpack_floats(&uv_accessor, reinterpret_cast(uvs.data()), uvs.size() * 2); @@ -393,7 +393,7 @@ tl::expected load_from_raw(const RawMesh &raw, const } /// Saves the mesh as a .gltf or .glb file at the given path. -tl::expected save_to_path( +std::expected save_to_path( const SimpleMesh &terrain_mesh, const std::filesystem::path &path, const SaveOptions& options) { @@ -731,10 +731,10 @@ tl::expected save_to_path( return {}; } -tl::expected load_from_path(const std::filesystem::path &path, const LoadOptions &options) { - tl::expected raw_mesh = load_raw_from_path(path); +std::expected load_from_path(const std::filesystem::path &path, const LoadOptions &options) { + std::expected raw_mesh = load_raw_from_path(path); if (!raw_mesh) { - return tl::unexpected(map_cgltf_error(raw_mesh.error())); + return std::unexpected(map_cgltf_error(raw_mesh.error())); } return load_from_raw(*raw_mesh, options); } diff --git a/src/terrainlib/mesh/io/gltf.h b/src/terrainlib/mesh/io/gltf.h index 60757382..20333e93 100644 --- a/src/terrainlib/mesh/io/gltf.h +++ b/src/terrainlib/mesh/io/gltf.h @@ -5,7 +5,7 @@ #include #include -#include +#include #include "mesh/SimpleMesh.h" #include "mesh/io/error.h" @@ -15,19 +15,19 @@ namespace mesh::io::gltf { using RawMesh = std::unique_ptr; -tl::expected load_from_path( +std::expected load_from_path( const std::filesystem::path &path, const LoadOptions &options = {}); -tl::expected load_from_raw( +std::expected load_from_raw( const RawMesh &mesh, const LoadOptions &options = {}); -tl::expected save_to_path( +std::expected save_to_path( const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions &options = {}); -// tl::expected save_to_raw(const RawMesh &mesh, const SaveOptions &options = {}); +// std::expected save_to_raw(const RawMesh &mesh, const SaveOptions &options = {}); -tl::expected load_raw_from_path(const std::filesystem::path &path); +std::expected load_raw_from_path(const std::filesystem::path &path); } // namespace mesh::io::gltf diff --git a/src/terrainlib/mesh/io/terrain.cpp b/src/terrainlib/mesh/io/terrain.cpp index 1068a991..d443d6f8 100644 --- a/src/terrainlib/mesh/io/terrain.cpp +++ b/src/terrainlib/mesh/io/terrain.cpp @@ -34,24 +34,24 @@ SaveMeshError save_error_from_io_error(::io::Error error) { } } -tl::expected write_bytes_to_path( +std::expected write_bytes_to_path( const std::span bytes, const std::filesystem::path &path) { const auto result = ::io::write_bytes_to_path(bytes, path); if (!result.has_value()) { - return tl::unexpected(save_error_from_io_error(result.error())); + return std::unexpected(save_error_from_io_error(result.error())); } return {}; } -tl::expected, LoadMeshError> read_bytes_from_path(const std::filesystem::path &path) { +std::expected, LoadMeshError> read_bytes_from_path(const std::filesystem::path &path) { const auto result = ::io::read_bytes_from_path(path); if (!result.has_value()) { - return tl::unexpected(load_error_from_io_error(result.error())); + return std::unexpected(load_error_from_io_error(result.error())); } return result.value(); } -tl::expected, SaveMeshError> save_encoded_to_buffer(const mesh::Encoded &mesh) { +std::expected, SaveMeshError> save_encoded_to_buffer(const mesh::Encoded &mesh) { LOG_TRACE("Serializing mesh to buffer"); // TODO: this ignores the texture format in SaveOptions @@ -66,7 +66,7 @@ tl::expected, SaveMeshError> save_encoded_to_buffer(const m case std::errc::no_buffer_space: case std::errc::message_size: case std::errc::result_out_of_range: - return tl::unexpected(SaveMeshErrorKind::OutOfMemory); + return std::unexpected(SaveMeshErrorKind::OutOfMemory); break; default: UNREACHABLE(); @@ -77,7 +77,7 @@ tl::expected, SaveMeshError> save_encoded_to_buffer(const m return data; } -tl::expected load_encoded_from_buffer(const std::span bytes) { +std::expected load_encoded_from_buffer(const std::span bytes) { LOG_TRACE("Deserializing mesh from buffer"); zpp::bits::in in(bytes); @@ -90,12 +90,12 @@ tl::expected load_encoded_from_buffer(const std::s switch (result) { case std::errc::no_buffer_space: case std::errc::message_size: - return tl::unexpected(LoadMeshErrorKind::OutOfMemory); + return std::unexpected(LoadMeshErrorKind::OutOfMemory); case std::errc::value_too_large: case std::errc::bad_message: case std::errc::protocol_error: case std::errc::result_out_of_range: - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); case std::errc::not_supported: case std::errc::invalid_argument: UNREACHABLE(); @@ -109,61 +109,61 @@ tl::expected load_encoded_from_buffer(const std::s } } -tl::expected, SaveMeshError> save_to_buffer(const SimpleMesh &mesh, const SaveOptions& options) { +std::expected, SaveMeshError> save_to_buffer(const SimpleMesh &mesh, const SaveOptions& options) { const auto encode_result = mesh::encode(mesh, mesh::EncodeOptions{ .texture_format = options.texture_format}); if (!encode_result.has_value()) { - return tl::unexpected(SaveMeshErrorKind::UnsupportedFormat); + return std::unexpected(SaveMeshErrorKind::UnsupportedFormat); } const mesh::Encoded encoded = encode_result.value(); const auto deser_result = save_encoded_to_buffer(encoded); if (!deser_result.has_value()) { - return tl::unexpected(deser_result.error()); + return std::unexpected(deser_result.error()); } const std::vector buffer = deser_result.value(); return buffer; } -tl::expected load_from_buffer(const std::span bytes, const LoadOptions & /* options */) { +std::expected load_from_buffer(const std::span bytes, const LoadOptions & /* options */) { const auto deser_result = load_encoded_from_buffer(bytes); if (!deser_result.has_value()) { - return tl::unexpected(deser_result.error()); + return std::unexpected(deser_result.error()); } const mesh::Encoded encoded = deser_result.value(); const auto decode_result = mesh::decode(encoded); if (!decode_result.has_value()) { - return tl::unexpected(LoadMeshErrorKind::InvalidFormat); + return std::unexpected(LoadMeshErrorKind::InvalidFormat); } const mesh::Simple mesh = decode_result.value(); return mesh; } -tl::expected load_from_path(const std::filesystem::path &path, const LoadOptions & /* options */) { +std::expected load_from_path(const std::filesystem::path &path, const LoadOptions & /* options */) { const auto bytes_result = read_bytes_from_path(path); if (!bytes_result.has_value()) { - return tl::unexpected(bytes_result.error()); + return std::unexpected(bytes_result.error()); } const std::vector bytes = bytes_result.value(); return load_from_buffer(bytes); } -tl::expected save_to_path(const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions& options) { +std::expected save_to_path(const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions& options) { LOG_TRACE("Saving mesh as high precision terrain"); const auto result = save_to_buffer(mesh, options); if (!result.has_value()) { - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } const std::vector bytes = result.value(); const auto write_result = write_bytes_to_path(bytes, path); if (!write_result.has_value()) { - return tl::unexpected(write_result.error()); + return std::unexpected(write_result.error()); } return {}; diff --git a/src/terrainlib/mesh/io/terrain.h b/src/terrainlib/mesh/io/terrain.h index 9c505c14..fd097a24 100644 --- a/src/terrainlib/mesh/io/terrain.h +++ b/src/terrainlib/mesh/io/terrain.h @@ -3,7 +3,7 @@ #include #include -#include +#include #include "mesh/SimpleMesh.h" #include "mesh/io/error.h" @@ -11,10 +11,10 @@ namespace mesh::io::terrain { -tl::expected load_from_path(const std::filesystem::path &path, const LoadOptions &options = {}); -tl::expected load_from_buffer(const std::span buffer, const LoadOptions &options = {}); +std::expected load_from_path(const std::filesystem::path &path, const LoadOptions &options = {}); +std::expected load_from_buffer(const std::span buffer, const LoadOptions &options = {}); -tl::expected save_to_path(const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions &options = {}); -tl::expected, SaveMeshError> save_to_buffer(const SimpleMesh &mesh, const SaveOptions &options = {}); +std::expected save_to_path(const SimpleMesh &mesh, const std::filesystem::path &path, const SaveOptions &options = {}); +std::expected, SaveMeshError> save_to_buffer(const SimpleMesh &mesh, const SaveOptions &options = {}); } // namespace mesh::io::terrain diff --git a/src/terrainlib/octree/storage/IndexedStorage.h b/src/terrainlib/octree/storage/IndexedStorage.h index 70076cd3..3ba94e8f 100644 --- a/src/terrainlib/octree/storage/IndexedStorage.h +++ b/src/terrainlib/octree/storage/IndexedStorage.h @@ -45,7 +45,7 @@ class IndexedStorage_ : public Storage_ { void update_index() noexcept { Storage_::update_index(); } - tl::expected save_index() const noexcept { + std::expected save_index() const noexcept { if (!this->is_index_dirty()) { return {}; } diff --git a/src/terrainlib/octree/storage/RawStorage.h b/src/terrainlib/octree/storage/RawStorage.h index 679efb38..39482250 100644 --- a/src/terrainlib/octree/storage/RawStorage.h +++ b/src/terrainlib/octree/storage/RawStorage.h @@ -3,7 +3,7 @@ #include #include -#include +#include #include "octree/Id.h" #include "octree/disk/Layout.h" @@ -29,23 +29,23 @@ class RawStorage_ { RawStorage_(RawStorage_ &&) = default; RawStorage_ &operator=(RawStorage_ &&) = default; - tl::expected load(const Id &id) const noexcept { + std::expected load(const Id &id) const noexcept { const auto path = this->path_for(id); return Codec::load_from_path(path); } - tl::expected save(const Id &id, const T &node) const noexcept { + std::expected save(const Id &id, const T &node) const noexcept { const auto path = this->path_for(id); return Codec::save_to_path(node, path); } - tl::expected copy_to(const Id &id, RawStorage_ &target) const noexcept { + std::expected copy_to(const Id &id, RawStorage_ &target) const noexcept { return target.copy_from(id, *this); } - tl::expected copy_from(const Id &id, const RawStorage_ &source) noexcept { + std::expected copy_from(const Id &id, const RawStorage_ &source) noexcept { if (!source.has(id)) { - return tl::unexpected(CopyErrorKind::FileNotFound); + return std::unexpected(CopyErrorKind::FileNotFound); } const auto source_path = source.path_for(id); @@ -54,13 +54,13 @@ class RawStorage_ { // TODO: should this error instead? const auto load_result = source.load(id); if (!load_result.has_value()) { - return tl::unexpected(CopyErrorKind::Read); + return std::unexpected(CopyErrorKind::Read); } const value_type node = load_result.value(); const auto save_result = this->save(id, node); if (!save_result.has_value()) { - return tl::unexpected(CopyErrorKind::Write); + return std::unexpected(CopyErrorKind::Write); } return {}; } @@ -68,18 +68,18 @@ class RawStorage_ { std::error_code ec; if (std::filesystem::remove(target_path, ec)) { if (ec) { - return tl::unexpected(CopyErrorKind::RemoveOld); + return std::unexpected(CopyErrorKind::RemoveOld); } } std::filesystem::create_directories(target_path.parent_path(), ec); if (ec) { - return tl::unexpected(CopyErrorKind::CreateDirectories); + return std::unexpected(CopyErrorKind::CreateDirectories); } std::filesystem::create_hard_link(source_path, target_path, ec); if (ec) { - return tl::unexpected(CopyErrorKind::CreateLink); + return std::unexpected(CopyErrorKind::CreateLink); } return {}; diff --git a/src/terrainlib/octree/storage/Storage.h b/src/terrainlib/octree/storage/Storage.h index 7fbfc9eb..782a9c03 100644 --- a/src/terrainlib/octree/storage/Storage.h +++ b/src/terrainlib/octree/storage/Storage.h @@ -7,7 +7,7 @@ #include #include -#include +#include #include "mesh/io.h" #include "octree/Id.h" @@ -143,13 +143,13 @@ class Storage_ { } } - tl::expected load(const Id &id) const noexcept { + std::expected load(const Id &id) const noexcept { if (const auto value_opt = this->_cache.get(id)) { return value_opt.value(); } if (!this->_index.contains(id, true)) { - return tl::unexpected(Codec::file_not_found()); + return std::unexpected(Codec::file_not_found()); } const auto result = this->_inner.load(id); @@ -159,7 +159,7 @@ class Storage_ { return result; } - tl::expected save(const Id &id, const value_type &value) noexcept { + std::expected save(const Id &id, const value_type &value) noexcept { if (this->check_overwrite(id)) { LOG_ERROR_AND_EXIT("tried to overwrite value when not allowed"); } @@ -172,9 +172,9 @@ class Storage_ { return result; } - tl::expected copy_from(const Id &id, const Storage_ &source) noexcept { + std::expected copy_from(const Id &id, const Storage_ &source) noexcept { if (!source._index.contains(id, true)) { - return tl::unexpected(CopyErrorKind::FileNotFound); + return std::unexpected(CopyErrorKind::FileNotFound); } if (this->check_overwrite(id)) { @@ -189,7 +189,7 @@ class Storage_ { return result; } - tl::expected copy_to(const Id &id, Storage_ &target) const noexcept { + std::expected copy_to(const Id &id, Storage_ &target) const noexcept { return target.copy_from(id, *this); } @@ -252,7 +252,7 @@ class Storage_ { } } - tl::expected save_or_create_index() noexcept { + std::expected save_or_create_index() noexcept { if (this->is_indexed() && !this->_index.dirty) { return {}; } diff --git a/src/terrainlib/octree/storage/codec/Codec.h b/src/terrainlib/octree/storage/codec/Codec.h index b4c3bc47..f31b6f45 100644 --- a/src/terrainlib/octree/storage/codec/Codec.h +++ b/src/terrainlib/octree/storage/codec/Codec.h @@ -4,7 +4,7 @@ #include #include -#include +#include template concept CodecFor = @@ -16,10 +16,10 @@ concept CodecFor = requires std::same_as; { Codec::load_from_path(path) } noexcept - -> std::same_as>; + -> std::same_as>; { Codec::save_to_path(value, path) } noexcept - -> std::same_as>; + -> std::same_as>; { Codec::file_not_found() } noexcept -> std::same_as; diff --git a/src/terrainlib/octree/storage/codec/DefaultCodec.h b/src/terrainlib/octree/storage/codec/DefaultCodec.h index dd70a27b..dc4acd5a 100644 --- a/src/terrainlib/octree/storage/codec/DefaultCodec.h +++ b/src/terrainlib/octree/storage/codec/DefaultCodec.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "Codec.h" #include "io/serialize.h" @@ -18,11 +18,11 @@ struct ZppBitsCodec { using load_error = io::Error; using save_error = io::Error; - static tl::expected load_from_path(const std::filesystem::path& path) noexcept { + static std::expected load_from_path(const std::filesystem::path& path) noexcept { return io::read_from_path(path); } - static tl::expected save_to_path(const value_type& value, const std::filesystem::path& path) noexcept { + static std::expected save_to_path(const value_type& value, const std::filesystem::path& path) noexcept { return io::write_to_path(value, path); } diff --git a/src/terrainlib/octree/storage/codec/MeshCodec.h b/src/terrainlib/octree/storage/codec/MeshCodec.h index 2dc65fb6..7a15400a 100644 --- a/src/terrainlib/octree/storage/codec/MeshCodec.h +++ b/src/terrainlib/octree/storage/codec/MeshCodec.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "mesh/io.h" @@ -15,11 +15,11 @@ struct MeshCodec { using load_error = mesh::io::LoadMeshError; using save_error = mesh::io::SaveMeshError; - static tl::expected load_from_path(const std::filesystem::path& path) noexcept { + static std::expected load_from_path(const std::filesystem::path& path) noexcept { return mesh::io::load_from_path(path); } - static tl::expected save_to_path(const value_type& value, const std::filesystem::path& path) noexcept { + static std::expected save_to_path(const value_type& value, const std::filesystem::path& path) noexcept { return mesh::io::save_to_path(value, path); } diff --git a/src/terrainlib/octree/storage/helpers.cpp b/src/terrainlib/octree/storage/helpers.cpp index 7366be55..ed817515 100644 --- a/src/terrainlib/octree/storage/helpers.cpp +++ b/src/terrainlib/octree/storage/helpers.cpp @@ -5,7 +5,7 @@ #include #include -#include +#include #include "octree/storage/helpers.h" #include "io/serialize.h" @@ -90,7 +90,7 @@ std::optional guess_layout_strategy( return std::nullopt; } -tl::expected save_index_map(const IndexMap& index, const disk::Layout& layout) { +std::expected save_index_map(const IndexMap& index, const disk::Layout& layout) { const auto index_path = layout.base_path() / disk::v1::index_file_name(); LOG_TRACE("Saving octree storage index to {}", index_path); @@ -102,7 +102,7 @@ tl::expected save_index_map(const IndexMap& index, const disk:: const auto result = io::write_to_path(index_file, index_path); if (!result.has_value()) { LOG_ERROR("Failed to save octree storage index to {}", index_path); - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } return {}; } diff --git a/src/terrainlib/octree/storage/helpers.h b/src/terrainlib/octree/storage/helpers.h index a7dff810..288096cf 100644 --- a/src/terrainlib/octree/storage/helpers.h +++ b/src/terrainlib/octree/storage/helpers.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "io/Error.h" #include "octree/IndexMap.h" @@ -22,7 +22,7 @@ struct LayoutWithoutBase { std::optional guess_layout_strategy( const std::filesystem::path &base_path, size_t max_files_to_check = 100); -tl::expected save_index_map(const IndexMap &index, const disk::Layout &layout); +std::expected save_index_map(const IndexMap &index, const disk::Layout &layout); void update_index_map(IndexMap &index, const disk::Layout &layout); } diff --git a/src/terrainlib/octree/storage/open.h b/src/terrainlib/octree/storage/open.h index ec212e88..b21ede08 100644 --- a/src/terrainlib/octree/storage/open.h +++ b/src/terrainlib/octree/storage/open.h @@ -4,7 +4,7 @@ #include #include -#include +#include #include "io/Error.h" #include "octree/disk/layout/strategy/Default.h" @@ -21,7 +21,7 @@ struct OpenOptions { }; template Codec = DefaultCodecFor> -tl::expected, io::Error> open_index(const std::filesystem::path &index_path); +std::expected, io::Error> open_index(const std::filesystem::path &index_path); template Codec = DefaultCodecFor> Storage_ open_folder( const std::filesystem::path &base_path, diff --git a/src/terrainlib/octree/storage/open.inl b/src/terrainlib/octree/storage/open.inl index a7cfdb99..a948f4ac 100644 --- a/src/terrainlib/octree/storage/open.inl +++ b/src/terrainlib/octree/storage/open.inl @@ -4,7 +4,7 @@ #include #include -#include +#include #include "io/serialize.h" #include "log.h" @@ -19,13 +19,13 @@ namespace octree { template Codec> -tl::expected, io::Error> open_index(const std::filesystem::path &index_path) { +std::expected, io::Error> open_index(const std::filesystem::path &index_path) { LOG_TRACE("Opening storage index {}", index_path); const auto result = io::read_from_path(index_path); if (!result.has_value()) { LOG_TRACE("Failed to open storage index due to {}", result.error()); - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } auto index_file = result.value(); LOG_TRACE("Successfully read storage index with {} entries.", index_file.map.size()); diff --git a/src/terrainlib/pch.h b/src/terrainlib/pch.h index dce9cf57..dfbd471c 100644 --- a/src/terrainlib/pch.h +++ b/src/terrainlib/pch.h @@ -34,7 +34,7 @@ #include #include #include -#include +#include #include // Internal headers diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index af8308f5..a5e5ce5c 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -32,7 +32,7 @@ #include #include #include -#include +#include #include namespace srs { @@ -352,19 +352,19 @@ inline radix::geometry::Aabb3d encompassing_bounds_transfer( return encompassing_bounds_transfer(transform.get(), source_bounds, intermediate_points_edges, intermediate_points_faces); } -inline tl::expected from_epsg(const uint32_t epsg) { +inline std::expected from_epsg(const uint32_t epsg) { OGRSpatialReference srs; if (srs.importFromEPSG(epsg) != OGRERR_NONE) { - return tl::unexpected(fmt::format("Failed to import spatial reference from EPSG code: {}", epsg)); + return std::unexpected(fmt::format("Failed to import spatial reference from EPSG code: {}", epsg)); } srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); return srs; } -inline tl::expected from_user_input(const std::string &user_input) { +inline std::expected from_user_input(const std::string &user_input) { OGRSpatialReference srs; if (srs.SetFromUserInput(user_input.c_str()) != OGRERR_NONE) { - return tl::unexpected(fmt::format("Failed to set spatial reference from user input: {}", user_input)); + return std::unexpected(fmt::format("Failed to set spatial reference from user input: {}", user_input)); } srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); return srs; diff --git a/src/terrainlib/uv/unwrap.cpp b/src/terrainlib/uv/unwrap.cpp index 5e61092b..206eb285 100644 --- a/src/terrainlib/uv/unwrap.cpp +++ b/src/terrainlib/uv/unwrap.cpp @@ -124,7 +124,7 @@ void clamp_uv_map(UvMap &map, size_t vertex_count) { } } -tl::expected parameterize_mesh(cgal::Mesh &mesh, Algorithm algorithm, Border border) { +std::expected parameterize_mesh(cgal::Mesh &mesh, Algorithm algorithm, Border border) { const cgal::HalfedgeDescriptor bhd = CGAL::Polygon_mesh_processing::longest_border(mesh).first; DEBUG_ASSERT(bhd != boost::graph_traits::null_halfedge()); @@ -173,7 +173,7 @@ tl::expected parameterize_mesh(cgal::Mesh &mesh, Algorit } if (result != CGAL::Surface_mesh_parameterization::OK) { - return tl::unexpected(UnwrapError(result)); + return std::unexpected(UnwrapError(result)); } const auto vertex_count = CGAL::num_vertices(mesh); @@ -201,7 +201,7 @@ std::vector decode_uv_map(const UvMap &map, size_t vertex_count) { } } -tl::expected unwrap( +std::expected unwrap( const std::span triangles, const std::span positions, Algorithm algorithm, @@ -213,7 +213,7 @@ tl::expected unwrap( cgal::Mesh cgal_mesh = convert::to_cgal_mesh(mesh); auto result = parameterize_mesh(cgal_mesh, algorithm, border); if (!result) { - return tl::unexpected(result.error()); + return std::unexpected(result.error()); } const CgalUvMap cgal_uv_map = result.value(); const Map uv_map = decode_uv_map(cgal_uv_map, mesh.vertex_count()); diff --git a/src/terrainlib/uv/unwrap.h b/src/terrainlib/uv/unwrap.h index 53e0fc3a..8a9a564d 100644 --- a/src/terrainlib/uv/unwrap.h +++ b/src/terrainlib/uv/unwrap.h @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include @@ -51,13 +51,13 @@ using Texture = cv::Mat; inline constexpr Algorithm DEFAULT_ALGORITHM = Algorithm::TutteBarycentricMapping; inline constexpr Border DEFAULT_BORDER = Border::Circle; -tl::expected unwrap( +std::expected unwrap( const std::span triangles, const std::span positions, Algorithm algorithm = DEFAULT_ALGORITHM, Border border = DEFAULT_BORDER); -inline tl::expected unwrap( +inline std::expected unwrap( const std::vector& triangles, const std::vector& positions, Algorithm algorithm = DEFAULT_ALGORITHM, @@ -69,7 +69,7 @@ inline tl::expected unwrap( border); } -inline tl::expected unwrap( +inline std::expected unwrap( const mesh::View &mesh, Algorithm algorithm = DEFAULT_ALGORITHM, Border border = DEFAULT_BORDER) { @@ -80,7 +80,7 @@ inline tl::expected unwrap( border); } -inline tl::expected unwrap( +inline std::expected unwrap( const mesh::Simple &mesh, Algorithm algorithm = DEFAULT_ALGORITHM, Border border = DEFAULT_BORDER) { diff --git a/src/tile_builder/CMakeLists.txt b/src/tile_builder/CMakeLists.txt index d4464342..576df41c 100644 --- a/src/tile_builder/CMakeLists.txt +++ b/src/tile_builder/CMakeLists.txt @@ -1,7 +1,7 @@ add_library(tilebuilderlib alpine_raster.cpp DatasetReader.cpp - Image.cpp + image_writer.cpp ParallelTileGenerator.cpp ParallelTiler.cpp TileHeightsGenerator.cpp diff --git a/src/tile_builder/DatasetReader.cpp b/src/tile_builder/DatasetReader.cpp index bad96340..a472ae54 100644 --- a/src/tile_builder/DatasetReader.cpp +++ b/src/tile_builder/DatasetReader.cpp @@ -29,7 +29,7 @@ #include "Dataset.h" #include "Exception.h" -#include "Image.h" +#include #include "ctb/types.hpp" #include "log.h" @@ -185,12 +185,12 @@ DatasetReader::DatasetReader(const std::shared_ptr& dataset, const OGRS throw Exception(fmt::format("Dataset does not contain band number {} (there are {} bands).", band, dataset->n_bands())); } -HeightData DatasetReader::read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const +radix::Raster DatasetReader::read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const { return readFrom(m_dataset, bounds, width, height); } -HeightData DatasetReader::readWithOverviews(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const +radix::Raster DatasetReader::readWithOverviews(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const { #ifdef ALP_ENABLE_OVERVIEW_READING auto transformer_args = make_image_transform_args(*this, m_dataset.get(), bounds, width, height); @@ -202,7 +202,7 @@ HeightData DatasetReader::readWithOverviews(const radix::tile::SrsBounds& bounds #endif } -HeightData DatasetReader::readFrom(const std::shared_ptr& source_dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const +radix::Raster DatasetReader::readFrom(const std::shared_ptr& source_dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const { // if we have performance problems with the warping, it'd still be possible to approximate the warping operation with a linear transform (mostly when zoomed in / on higher zoom levels). // CTB does this in GDALTiler.cpp around line 375 ("// Decide if we are doing an approximate or exact transformation"). @@ -212,9 +212,9 @@ HeightData DatasetReader::readFrom(const std::shared_ptr& source_datase auto warped_dataset = Dataset(static_cast(GDALCreateWarpedVRT(source_dataset->gdalDataset(), int(width), int(height), adfGeoTransform.data(), warp_options.first.get()))); auto* heights_band = warped_dataset.gdalDataset()->GetRasterBand(1); // non-owning pointer - auto heights_data = HeightData(width, height); + auto heights_data = radix::Raster({ width, height }); if (heights_band->RasterIO(GF_Read, 0, 0, int(width), int(height), - static_cast(heights_data.data()), int(width), int(height), GDT_Float32, 0, 0) + static_cast(heights_data.buffer().data()), int(width), int(height), GDT_Float32, 0, 0) != CE_None) throw Exception("couldn't read data"); diff --git a/src/tile_builder/DatasetReader.h b/src/tile_builder/DatasetReader.h index e75b1ff8..a60400c6 100644 --- a/src/tile_builder/DatasetReader.h +++ b/src/tile_builder/DatasetReader.h @@ -23,7 +23,7 @@ #include #include -#include "Image.h" +#include #include class Dataset; @@ -33,8 +33,8 @@ class DatasetReader { public: DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band, bool warn_on_missing_overviews = true); - HeightData read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; - HeightData readWithOverviews(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; + radix::Raster read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; + radix::Raster readWithOverviews(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; unsigned dataset_band() const { return m_band; } bool isReprojecting() const { return m_requires_reprojection; } @@ -42,7 +42,7 @@ class DatasetReader { std::string target_srs_wkt() const { return m_target_srs_wkt; } protected: - HeightData readFrom(const std::shared_ptr& dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; + radix::Raster readFrom(const std::shared_ptr& dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; private: std::shared_ptr m_dataset; diff --git a/src/tile_builder/Image.cpp b/src/tile_builder/Image.cpp deleted file mode 100644 index 3b33465b..00000000 --- a/src/tile_builder/Image.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include -#include "Image.h" -#include - -void image::saveImageAsPng(const Image& inputImage, const std::string& path) -{ - const int width = static_cast(inputImage.width()); - const int height = static_cast(inputImage.height()); - - cv::Mat image(height, width, CV_8UC3); - - for (int row = 0; row < height; ++row) { - for (int col = 0; col < width; ++col) { - const glm::u8vec3& pixel = inputImage.pixel(height - row - 1, col); // Flip vertically - image.at(row, col) = cv::Vec3b(pixel.z, pixel.y, pixel.x); // RGB to BGR - } - } - - cv::imwrite(path, image); -} diff --git a/src/tile_builder/Image.h b/src/tile_builder/Image.h deleted file mode 100644 index 5785329d..00000000 --- a/src/tile_builder/Image.h +++ /dev/null @@ -1,105 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - *****************************************************************************/ - -#ifndef IMAGE_H -#define IMAGE_H - -#include -#include -#include -#include -#include -#include - -#include - -namespace tntn { -template -class Raster; -} - -template -class Image { -public: - Image() = default; - Image(unsigned width, unsigned height) - : m_width(width) - , m_height(height) - , m_data(size_t(m_width * m_height)) - { - } - - [[nodiscard]] unsigned width() const { return m_width; } - [[nodiscard]] unsigned height() const { return m_height; } - [[nodiscard]] T pixel(unsigned row, unsigned column) const - { - assert(column < m_width); - assert(row < m_height); - assert(m_data.size() == size_t(m_width * m_height)); - return m_data[row * m_width + column]; - } - - [[nodiscard]] float* data() { return m_data.data(); } - - [[nodiscard]] auto size() const { return m_data.size(); } - [[nodiscard]] auto begin() { return m_data.begin(); } - [[nodiscard]] auto end() { return m_data.end(); } - [[nodiscard]] auto begin() const { return m_data.begin(); } - [[nodiscard]] auto end() const { return m_data.end(); } - -private: - unsigned m_width = 0; - unsigned m_height = 0; - std::vector m_data; - - friend class tntn::Raster; -}; - -using HeightData = Image; -using RgbImage = Image; -using uchar = unsigned char; - -namespace image { -void saveImageAsPng(const Image& image, const std::string& path); - -template -[[nodiscard]] auto transformImage(const Image& i, Fun conversion_fun) -> Image -{ - using T2 = decltype(conversion_fun(*i.begin())); - Image i2(i.width(), i.height()); - std::transform(i.begin(), i.end(), i2.begin(), conversion_fun); - return i2; -} - -template -void debugOut(const Image& image, const std::string& path) -{ - auto [min, max] = std::ranges::minmax(image); - - // [min=min, ..] is required for cpp correctness. min/max from the capture are not variables, we need to copy them: - // https://stackoverflow.com/questions/50799719/reference-to-local-binding-declared-in-enclosing-function?noredirect=1&lq=1 - saveImageAsPng(transformImage(image, [min = min, max = max](auto v) { - const auto c = uchar(255.F * (float(v) - float(min)) / float(max - min)); - return glm::u8vec3(c, c, c); - }), - path); -} -} - -#endif // HEIGHTDATA_H diff --git a/src/tile_builder/ParallelTileGenerator.cpp b/src/tile_builder/ParallelTileGenerator.cpp index e0d7e731..70924640 100644 --- a/src/tile_builder/ParallelTileGenerator.cpp +++ b/src/tile_builder/ParallelTileGenerator.cpp @@ -64,7 +64,7 @@ const ctb::Grid& ParallelTileGenerator::grid() const return m_grid; } -void ParallelTileGenerator::write(const radix::tile::Descriptor& tile, const HeightData& heights) const +void ParallelTileGenerator::write(const radix::tile::Descriptor& tile, const radix::Raster& heights) const { const auto dir_path = fmt::format("{}/{}/{}", m_output_data_path, tile.id.zoom_level, tile.id.coords.x); const auto file_path = fmt::format("{}/{}.{}", dir_path, tile.id.coords.y, m_tile_writer->formatFileEnding()); diff --git a/src/tile_builder/ParallelTileGenerator.h b/src/tile_builder/ParallelTileGenerator.h index f95a412f..f9fa61e0 100644 --- a/src/tile_builder/ParallelTileGenerator.h +++ b/src/tile_builder/ParallelTileGenerator.h @@ -22,7 +22,7 @@ #include #include -#include "Image.h" +#include #include "ParallelTiler.h" #include "ctb/Grid.hpp" #include "ctb/types.hpp" @@ -49,7 +49,7 @@ class ParallelTileGenerator { void setWarnOnMissingOverviews(bool flag) { m_warn_on_missing_overviews = flag; } [[nodiscard]] const ParallelTiler& tiler() const; [[nodiscard]] const ctb::Grid& grid() const; - void write(const radix::tile::Descriptor& tile, const HeightData& heights) const; + void write(const radix::tile::Descriptor& tile, const radix::Raster& heights) const; void process(const std::pair& zoom_range, bool progress_bar_on_console = false, bool generate_world_wide_tiles = false) const; }; @@ -68,7 +68,7 @@ class ParallelTileWriterInterface { virtual ~ParallelTileWriterInterface() = default; ParallelTileWriterInterface& operator=(const ParallelTileWriterInterface&) = default; ParallelTileWriterInterface& operator=(ParallelTileWriterInterface&&) = default; - virtual void write(const std::string& file_path, const radix::tile::Descriptor& tile, const HeightData& heights) const = 0; + virtual void write(const std::string& file_path, const radix::tile::Descriptor& tile, const radix::Raster& heights) const = 0; [[nodiscard]] radix::tile::Border formatRequiresBorder() const; [[nodiscard]] const std::string& formatFileEnding() const; }; diff --git a/src/tile_builder/algorithms/primitives.h b/src/tile_builder/algorithms/primitives.h deleted file mode 100644 index a6101364..00000000 --- a/src/tile_builder/algorithms/primitives.h +++ /dev/null @@ -1,103 +0,0 @@ -#include -#include -#include - -#ifndef ALGORITHMS_PRIMITIVES_H -#define ALGORITHMS_PRIMITIVES_H - -namespace primitives { - -// two times the area of the ccw triangle with vertices a, b, and c. -// negative, if the triangle is cw -template -inline T triAreaX2(const glm::tvec2& a, const glm::tvec2& b, const glm::tvec2& c) -{ - // doesn't work for unsigned types. if you need that, come up with something :) - static_assert(std::is_signed_v); - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); -} - -enum class Winding : char { - CW = -1, - Undefined = 0, - CCW = 1 -}; - -template -inline Winding winding(const glm::tvec2& a, const glm::tvec2& b, const glm::tvec2& c) -{ - constexpr bool is_integral = std::is_integral_v; - if constexpr (is_integral) { - using Signed = typename std::conditional::type, T>::type; - auto v = (Signed(b.x) - Signed(a.x)) * (Signed(c.y) - Signed(a.y)) - (Signed(b.y) - Signed(a.y)) * (Signed(c.x) - Signed(a.x)); - if (v == 0) - return Winding::Undefined; - if (v < 0) - return Winding::CW; - return Winding::CCW; - } else { - auto v = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); - if (std::abs(v) < 0.0000000000000001) - return Winding::Undefined; - if (v < 0) - return Winding::CW; - return Winding::CCW; - } -} - -template -inline bool ccw(const glm::tvec2& a, const glm::tvec2& b, const glm::tvec2& c) -{ - constexpr bool is_integral = std::is_integral_v; - if constexpr (is_integral) { - using Signed = typename std::conditional::type, T>::type; - if constexpr (include_border) - return (Signed(b.x) - Signed(a.x)) * (Signed(c.y) - Signed(a.y)) >= (Signed(b.y) - Signed(a.y)) * (Signed(c.x) - Signed(a.x)); - return (Signed(b.x) - Signed(a.x)) * (Signed(c.y) - Signed(a.y)) > (Signed(b.y) - Signed(a.y)) * (Signed(c.x) - Signed(a.x)); - } - if constexpr (include_border) - return (b.x - a.x) * (c.y - a.y) >= (b.y - a.y) * (c.x - a.x); - return (b.x - a.x) * (c.y - a.y) > (b.y - a.y) * (c.x - a.x); -} - -template -inline bool rightOf(const glm::tvec2& x, const glm::tvec2& org, const glm::tvec2& dest) -{ - return ccw(x, dest, org); -} - -template -inline bool leftOf(const glm::tvec2& x, const glm::tvec2& org, const glm::tvec2& dest) -{ - return ccw(x, org, dest); -} - -// https://www.scratchapixel.com/lessons/3d-basic-rendering/rasterization-practical-implementation/rasterization-stage -// the bottom edge of the raster is not included! -template -inline bool inside(const glm::tvec2& x, const glm::tvec2& a, const glm::tvec2& b, const glm::tvec2& c) -{ - constexpr bool is_integral = std::is_integral_v; - using Signed = typename std::conditional::type, T>::type; - using sVec = glm::tvec2; - // return leftOf(x, a, b) && leftOf(x, b, c) && leftOf(x, c, a); - const auto ccw = [](Winding w) { return w == Winding::CCW; }; - const auto undef = [](Winding w) { return w == Winding::Undefined; }; - const auto topleftedge = [](const sVec& edge) { return (edge.y == 0 && edge.x < 0) || edge.y < 0; }; - - const auto w_ab = winding(x, a, b); - const auto w_bc = winding(x, b, c); - const auto w_ca = winding(x, c, a); - const auto e_ab = sVec(b) - sVec(a); - const auto e_bc = sVec(c) - sVec(b); - const auto e_ca = sVec(a) - sVec(c); - - bool overlap = true; - overlap &= ccw(w_ab) || (undef(w_ab) && topleftedge(e_ab)); - overlap &= ccw(w_bc) || (undef(w_bc) && topleftedge(e_bc)); - overlap &= ccw(w_ca) || (undef(w_ca) && topleftedge(e_ca)); - return overlap; -} -} - -#endif diff --git a/src/tile_builder/algorithms/raster_triangle_scanline.h b/src/tile_builder/algorithms/raster_triangle_scanline.h deleted file mode 100644 index 7e22fdca..00000000 --- a/src/tile_builder/algorithms/raster_triangle_scanline.h +++ /dev/null @@ -1,101 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - *****************************************************************************/ - -#ifndef ALGORITHMS_RASTER_TRIANGLE_SCANLINE_H -#define ALGORITHMS_RASTER_TRIANGLE_SCANLINE_H - -#include "primitives.h" -#include "tntn/Raster.h" -#include -#include -#include - -namespace raster { - -template -void triangle_scanline(const tntn::Raster& raster, const glm::uvec2& a, const glm::uvec2& b, const glm::uvec2& c, const Lambda& fun) -{ - assert(primitives::winding(a, b, c) == primitives::Winding::CCW); - - const auto min = glm::min(glm::min(a, b), c); - const auto max = glm::max(glm::max(a, b), c); - for (auto y = min.y; y <= max.y; ++y) { - for (auto x = min.x; x <= max.x; ++x) { - const auto coord = glm::uvec2(x, y); - if (primitives::inside(coord, a, b, c)) - fun(coord, raster.value(coord.y, coord.x)); // raster is row / column - } - } - if (min.y == 0) { - // bottom row of raster is not included in primitives::inside, so check for it and make an extrawurscht. - const auto walk_bottom = [&](const glm::uvec2& a, const glm::uvec2& b) { - if ((b - a).y == 0 && a.y == 0) { - const auto end_x = std::max(a.x, b.x); - for (auto x = std::min(a.x, b.x); x < end_x; ++x) { - const auto coord = glm::uvec2(x, 0); - fun(coord, raster.value(coord.y, coord.x)); // raster is row / column - } - } - }; - walk_bottom(a, b); - walk_bottom(b, c); - walk_bottom(c, a); - } - const auto last_x = raster.get_width() - 1; - if (max.x == last_x) { - // similar with the rightmost column - const auto walk_right = [&](const glm::uvec2& a, const glm::uvec2& b) { - if ((b - a).x == 0 && a.x == last_x) { - const auto end_y = std::max(a.y, b.y); - for (auto y = std::min(a.y, b.y); y < end_y; ++y) { - const auto coord = glm::uvec2(last_x, y); - fun(coord, raster.value(coord.y, coord.x)); // raster is row / column - } - } - }; - walk_right(a, b); - walk_right(b, c); - walk_right(c, a); - } - // if (max.x == last_x && min.y == 0) { - // const auto check_br = [&](const glm::uvec2& a, const glm::uvec2& b) { - // if ((b - a).y == 0 && a.y == 0 && b.x == last_x) { - // const auto coord = glm::uvec2(last_x, 0); - // fun(coord, raster.value(coord.y, coord.x)); // raster is row / column - // } - // }; - // check_br(a, b); - // check_br(b, c); - // check_br(c, a); - // } - const auto last_y = raster.get_height() - 1; - if (max.x == last_x && max.y == last_y) { - const auto check_tr = [&](const glm::uvec2& a, const glm::uvec2& b) { - if ((b - a).x == 0 && b.y == last_y && b.x == last_x) { - const auto coord = glm::uvec2(last_x, last_y); - fun(coord, raster.value(coord.y, coord.x)); // raster is row / column - } - }; - check_tr(a, b); - check_tr(b, c); - check_tr(c, a); - } -} -} -#endif diff --git a/src/tile_builder/alpine_raster.cpp b/src/tile_builder/alpine_raster.cpp index b9ce5353..be4e0e1d 100644 --- a/src/tile_builder/alpine_raster.cpp +++ b/src/tile_builder/alpine_raster.cpp @@ -25,9 +25,10 @@ #include #include -#include "Image.h" #include "ParallelTileGenerator.h" #include "ctb/Grid.hpp" +#include "image_writer.h" +#include #include ParallelTileGenerator alpine_raster::make_generator(const std::string& input_data_path, const std::string& output_data_path, ctb::Grid::Srs srs, radix::tile::Scheme tiling_scheme, radix::tile::Border border, unsigned grid_resolution) @@ -35,8 +36,7 @@ ParallelTileGenerator alpine_raster::make_generator(const std::string& input_dat return ParallelTileGenerator::make(input_data_path, srs, tiling_scheme, std::make_unique(border), output_data_path, grid_resolution); } -void alpine_raster::TileWriter::write(const std::string& file_path, const radix::tile::Descriptor&, const HeightData& heights) const +void alpine_raster::TileWriter::write(const std::string& file_path, const radix::tile::Descriptor&, const radix::Raster& heights) const { - image::saveImageAsPng(image::transformImage(heights, radix::height_encoding::to_rgb), - file_path); + image::saveImageAsPng(radix::raster::transform(heights, radix::height_encoding::to_rgb), file_path); } diff --git a/src/tile_builder/alpine_raster.h b/src/tile_builder/alpine_raster.h index 966d55fb..22eee044 100644 --- a/src/tile_builder/alpine_raster.h +++ b/src/tile_builder/alpine_raster.h @@ -25,7 +25,7 @@ #include #include -#include "Image.h" +#include #include "ParallelTileGenerator.h" #include #include "ctb/Grid.hpp" @@ -38,7 +38,7 @@ class TileWriter : public ParallelTileWriterInterface { : ParallelTileWriterInterface(border, "png") { } - void write(const std::string& base_path, const radix::tile::Descriptor& tile, const HeightData& heights) const override; + void write(const std::string& base_path, const radix::tile::Descriptor& tile, const radix::Raster& heights) const override; }; [[nodiscard]] ParallelTileGenerator make_generator( const std::string& input_data_path, diff --git a/src/tile_builder/image_writer.cpp b/src/tile_builder/image_writer.cpp new file mode 100644 index 00000000..725cdfae --- /dev/null +++ b/src/tile_builder/image_writer.cpp @@ -0,0 +1,46 @@ +/***************************************************************************** + * Alpine Terrain Builder + * Copyright (C) 2022 alpinemaps.org + * Copyright (C) 2022 Adam Celarek + * Copyright (C) 2025 Martin Braunsperger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#include "image_writer.h" + +#include +#include + +void image::saveImageAsPng(const radix::Raster& input_image, const std::string& path) +{ + const int width = static_cast(input_image.width()); + const int height = static_cast(input_image.height()); + + cv::Mat image(height, width, CV_8UC3); + + for (int row = 0; row < height; ++row) { + for (int column = 0; column < width; ++column) { + const auto& pixel = input_image.pixel({ static_cast(column), static_cast(height - row - 1) }); + image.at(row, column) = cv::Vec3b(pixel.z, pixel.y, pixel.x); + } + } + + try { + if (!cv::imwrite(path, image)) + throw std::runtime_error("Failed to write PNG image to " + path); + } catch (const cv::Exception& error) { + throw std::runtime_error("Failed to write PNG image to " + path + ": " + error.what()); + } +} diff --git a/src/tile_builder/image_writer.h b/src/tile_builder/image_writer.h new file mode 100644 index 00000000..db8ff8f9 --- /dev/null +++ b/src/tile_builder/image_writer.h @@ -0,0 +1,51 @@ +/***************************************************************************** + * Alpine Terrain Builder + * Copyright (C) 2022 alpinemaps.org + * Copyright (C) 2022 Adam Celarek + * Copyright (C) 2025 Martin Braunsperger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace image { + +void saveImageAsPng(const radix::Raster& image, const std::string& path); + +template +void debugOut(const radix::Raster& image, const std::string& path) +{ + if (image.buffer().empty()) + throw std::invalid_argument("Can't write an empty raster to " + path); + + const auto [min, max] = std::ranges::minmax(image); + const auto range = float(max) - float(min); + saveImageAsPng(radix::raster::transform(image, [min, range](const auto value) { + const auto intensity = range == 0.F ? std::uint8_t(0) : std::uint8_t(255.F * (float(value) - float(min)) / range); + return glm::u8vec3(intensity); + }), + path); +} + +} // namespace image diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index cbf59ea8..647863fe 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -66,7 +66,7 @@ if(TARGET tilebuilderlib) tilebuilder/alpine_raster_format.cpp tilebuilder/dataset_reading.cpp tilebuilder/depth_first_tile_traverser.cpp - tilebuilder/image.cpp + tilebuilder/image_writer.cpp tilebuilder/parallel_tile_generator.cpp tilebuilder/parallel_tiler.cpp tilebuilder/tile_heights_generator.cpp diff --git a/unittests/terrainlib/mesh_io.cpp b/unittests/terrainlib/mesh_io.cpp index bac6225b..cb117366 100644 --- a/unittests/terrainlib/mesh_io.cpp +++ b/unittests/terrainlib/mesh_io.cpp @@ -44,14 +44,14 @@ TEST_CASE("transcode roundtrip") { mesh.texture = cv::Mat3b(100, 100); cv::randu(*mesh.texture, cv::Scalar(0, 0, 0), cv::Scalar(256, 256, 256)); - const tl::expected encode_result = + const std::expected encode_result = mesh::encode(mesh, mesh::EncodeOptions{.texture_format = ".png"}); if (!encode_result.has_value()) { FAIL(encode_result.error()); } const mesh::Encoded encoded = encode_result.value(); - const tl::expected decode_result = + const std::expected decode_result = mesh::decode(encoded, mesh::DecodeOptions{}); if (!decode_result.has_value()) { FAIL(decode_result.error()); @@ -93,7 +93,7 @@ TEST_CASE("io roundtrip") { mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}); CHECK(std::filesystem::exists(mesh_path)); - const tl::expected result = mesh::io::load_from_path(mesh_path); + const std::expected result = mesh::io::load_from_path(mesh_path); if (!result.has_value()) { FAIL(result.error().description()); } @@ -136,7 +136,7 @@ TEST_CASE("io roundtrip high precision") { mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}); CHECK(std::filesystem::exists(mesh_path)); - const tl::expected result = mesh::io::load_from_path(mesh_path); + const std::expected result = mesh::io::load_from_path(mesh_path); if (!result.has_value()) { FAIL(result.error().description()); } @@ -179,7 +179,7 @@ TEST_CASE("io roundtrip no texture") { mesh::io::save_to_path(mesh, mesh_path); CHECK(std::filesystem::exists(mesh_path)); - const tl::expected result = mesh::io::load_from_path(mesh_path); + const std::expected result = mesh::io::load_from_path(mesh_path); if (!result.has_value()) { FAIL(result.error().description()); } @@ -216,7 +216,7 @@ TEST_CASE("io roundtrip no texture and uvs") { mesh::io::save_to_path(mesh, mesh_path); CHECK(std::filesystem::exists(mesh_path)); - const tl::expected result = mesh::io::load_from_path(mesh_path); + const std::expected result = mesh::io::load_from_path(mesh_path); if (!result.has_value()) { FAIL(result.error().description()); } diff --git a/unittests/tilebuilder/alpine_raster_format.cpp b/unittests/tilebuilder/alpine_raster_format.cpp index 00ec9bf4..5c5cafd4 100644 --- a/unittests/tilebuilder/alpine_raster_format.cpp +++ b/unittests/tilebuilder/alpine_raster_format.cpp @@ -24,7 +24,7 @@ #include #include -#include "Image.h" +#include #include "alpine_raster.h" #include "ctb/Grid.hpp" @@ -42,14 +42,14 @@ TEMPLATE_TEST_CASE("alpine raster format, border ", "", std::true_type, std::fal SECTION("raste write") { const auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, radix::tile::Border::Yes); - generator.write(radix::tile::Descriptor { {0, glm::uvec2(0, 0)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, HeightData(257, 257)); + generator.write(radix::tile::Descriptor { {0, glm::uvec2(0, 0)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, radix::Raster({ 257, 257 })); CHECK(std::filesystem::exists("./unittest_tiles/0/0/0.png")); - generator.write(radix::tile::Descriptor { {1, glm::uvec2(2, 3)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, HeightData(257, 257)); + generator.write(radix::tile::Descriptor { {1, glm::uvec2(2, 3)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, radix::Raster({ 257, 257 })); CHECK(std::filesystem::exists("./unittest_tiles/1/2/3.png")); // check that a second write doesn't crash - generator.write(radix::tile::Descriptor { {1, glm::uvec2(2, 3)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, HeightData(257, 257)); + generator.write(radix::tile::Descriptor { {1, glm::uvec2(2, 3)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, radix::Raster({ 257, 257 })); CHECK(std::filesystem::exists("./unittest_tiles/1/2/3.png")); // in the best case, we would read back the data and check it. but that's too much work for now. diff --git a/unittests/tilebuilder/dataset_reading.cpp b/unittests/tilebuilder/dataset_reading.cpp index 17c714e9..ba2804db 100644 --- a/unittests/tilebuilder/dataset_reading.cpp +++ b/unittests/tilebuilder/dataset_reading.cpp @@ -27,6 +27,7 @@ #include "Dataset.h" #include "DatasetReader.h" #include "ctb/types.hpp" +#include "image_writer.h" #include "srs.h" using namespace radix; @@ -184,7 +185,7 @@ TEST_CASE("reading") if (ALP_UNITTESTS_DEBUG_IMAGES) { image::debugOut(ref_heights, fmt::format("./heights_{}_{}.png", test_name, dataset_name.substr(s, l))); - auto height_diffs = HeightData(render_width, render_height); + auto height_diffs = radix::Raster({ render_width, render_height }); std::transform(ref_heights.begin(), ref_heights.end(), heights.begin(), height_diffs.begin(), [](auto a, auto b) { return std::abs(a - b); }); const auto path = fmt::format("./diffs_{}_{}.png", test_name, dataset_name.substr(s, l)); image::debugOut(height_diffs, path); @@ -194,7 +195,7 @@ TEST_CASE("reading") const auto t = std::abs(double(a) - double(b)); largest_abs_diff = std::max(t, largest_abs_diff); return t * t; - }) / double(ref_heights.size()); + }) / double(ref_heights.buffer_length()); // fmt::print("{} | {}; mse: {}, largest_abs_diff: {}\n", test_name, dataset_name.substr(s, l), mse, largest_abs_diff); CHECK(largest_abs_diff < double(max_abs_diff)); CHECK(mse < max_mse); @@ -248,7 +249,7 @@ TEST_CASE("reading") image::debugOut(low_res_heights, fmt::format("./low_res_heights.png")); image::debugOut(high_res_heights, fmt::format("./high_res_heights.png")); - auto height_diffs = HeightData(render_width, render_height); + auto height_diffs = radix::Raster({ render_width, render_height }); std::transform(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), height_diffs.begin(), [](auto a, auto b) { return std::abs(a - b); }); image::debugOut(height_diffs, "./diff_low_res_high_res.png"); } @@ -257,7 +258,7 @@ TEST_CASE("reading") const auto t = std::abs(double(a) - double(b)); largest_abs_diff = std::max(t, largest_abs_diff); return t * t; - }) / double(low_res_heights.size()); + }) / double(low_res_heights.buffer_length()); // fmt::print("mse: {}, largest_abs_diff: {}\n", mse, largest_abs_diff); CHECK(largest_abs_diff < double(max_abs_diff)); CHECK(mse < max_mse); @@ -290,7 +291,7 @@ TEST_CASE("reading") image::debugOut(low_res_heights, fmt::format("./ov_with_warping_low_res_heights.png")); image::debugOut(high_res_heights, fmt::format("./ov_with_warping_high_res_heights.png")); - auto height_diffs = HeightData(render_width, render_height); + auto height_diffs = radix::Raster({ render_width, render_height }); std::transform(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), height_diffs.begin(), [](auto a, auto b) { return std::abs(a - b); }); image::debugOut(height_diffs, "./ov_with_warping_diff_low_res_high_res.png"); } @@ -299,7 +300,7 @@ TEST_CASE("reading") const auto t = std::abs(double(a) - double(b)); largest_abs_diff = std::max(t, largest_abs_diff); return t * t; - }) / double(low_res_heights.size()); + }) / double(low_res_heights.buffer_length()); // fmt::print("mse: {}, largest_abs_diff: {}\n", mse, largest_abs_diff); CHECK(largest_abs_diff < double(max_abs_diff)); CHECK(mse < max_mse); @@ -332,7 +333,7 @@ TEST_CASE("reading") image::debugOut(low_res_heights, fmt::format("./lowres_ov_with_warping_low_res_heights.png")); image::debugOut(high_res_heights, fmt::format("./lowres_ov_with_warping_high_res_heights.png")); - auto height_diffs = HeightData(render_width, render_height); + auto height_diffs = radix::Raster({ render_width, render_height }); std::transform(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), height_diffs.begin(), [](auto a, auto b) { return std::abs(a - b); }); image::debugOut(height_diffs, "./lowres_ov_with_warping_diff_low_res_high_res.png"); } @@ -341,7 +342,7 @@ TEST_CASE("reading") const auto t = std::abs(double(a) - double(b)); largest_abs_diff = std::max(t, largest_abs_diff); return t * t; - }) / double(low_res_heights.size()); + }) / double(low_res_heights.buffer_length()); // fmt::print("render w/h: {}/{}, mse: {}, largest_abs_diff: {}\n", render_width, render_height, mse, largest_abs_diff); CHECK(largest_abs_diff < double(max_abs_diff)); CHECK(mse < max_mse); diff --git a/unittests/tilebuilder/image.cpp b/unittests/tilebuilder/image.cpp deleted file mode 100644 index 448aef27..00000000 --- a/unittests/tilebuilder/image.cpp +++ /dev/null @@ -1,66 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - *****************************************************************************/ - -#include -#include -#include -#include "Image.h" - -using Catch::Approx; - -TEST_CASE("image") -{ - SECTION("iteration") - { - HeightData d { 40, 60 }; - REQUIRE(d.size() == 40 * 60); - REQUIRE(d.height() == 60); - REQUIRE(d.width() == 40); - int v = 0; - std::ranges::for_each(d, [&](auto& d) { d = float(v++); }); - - v = 0; - for (unsigned r = 0; r < d.height(); ++r) { - for (unsigned c = 0; c < d.width(); ++c) { - REQUIRE(d.pixel(r, c) == Approx(float(v++))); - } - } - } - - SECTION("conversion") - { - HeightData d { 40, 60 }; - int v_init = 0; - std::ranges::for_each(d, [&](auto& d) { d = float(v_init++); }); - - Image image = image::transformImage(d, [&](auto v) { const auto b = uchar(255.F * v / float(v_init)); return glm::u8vec3(b, b, b); }); - const auto max = float(v_init); - v_init = 0; - for (unsigned r = 0; r < d.height(); ++r) { - for (unsigned c = 0; c < d.width(); ++c) { - const auto t = uchar(float(v_init) * 255.F / max); - REQUIRE(image.pixel(r, c).x == t); - REQUIRE(image.pixel(r, c).y == t); - REQUIRE(image.pixel(r, c).z == t); - v_init++; - } - } -// image::saveImageAsPng(image, "/home/madam/Documents/work/tuw/alpinemaps/tmp/test.png"); - } -} diff --git a/unittests/tilebuilder/image_writer.cpp b/unittests/tilebuilder/image_writer.cpp new file mode 100644 index 00000000..aeddf2f2 --- /dev/null +++ b/unittests/tilebuilder/image_writer.cpp @@ -0,0 +1,86 @@ +/***************************************************************************** + * Alpine Terrain Builder + * Copyright (C) 2022 alpinemaps.org + * Copyright (C) 2022 Adam Celarek + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + *****************************************************************************/ + +#include +#include +#include + +#include +#include +#include + +#include "image_writer.h" + +TEST_CASE("tile builder writes radix rasters as PNG images") +{ + const auto output_path = std::filesystem::temp_directory_path() / "alpine_terrain_builder_image_writer_test.png"; + std::filesystem::remove(output_path); + + radix::Raster raster({ 2, 2 }); + raster.pixel({ 0, 0 }) = { 255, 0, 0 }; + raster.pixel({ 1, 0 }) = { 0, 255, 0 }; + raster.pixel({ 0, 1 }) = { 0, 0, 255 }; + raster.pixel({ 1, 1 }) = { 255, 255, 255 }; + + image::saveImageAsPng(raster, output_path.string()); + + const auto decoded = cv::imread(output_path.string(), cv::IMREAD_COLOR); + REQUIRE(decoded.rows == 2); + REQUIRE(decoded.cols == 2); + CHECK(decoded.at(0, 0) == cv::Vec3b(255, 0, 0)); + CHECK(decoded.at(0, 1) == cv::Vec3b(255, 255, 255)); + CHECK(decoded.at(1, 0) == cv::Vec3b(0, 0, 255)); + CHECK(decoded.at(1, 1) == cv::Vec3b(0, 255, 0)); + + std::filesystem::remove(output_path); +} + +TEST_CASE("tile builder handles image writer edge cases") +{ + SECTION("empty debug raster is rejected") + { + CHECK_THROWS_AS(image::debugOut(radix::Raster {}, "empty.png"), std::invalid_argument); + } + + SECTION("constant debug raster is written as black") + { + const auto output_path = std::filesystem::temp_directory_path() / "alpine_terrain_builder_constant_raster_test.png"; + std::filesystem::remove(output_path); + + radix::Raster raster({ 2, 2 }); + std::ranges::fill(raster, 42.F); + image::debugOut(raster, output_path.string()); + + const auto decoded = cv::imread(output_path.string(), cv::IMREAD_COLOR); + REQUIRE(decoded.rows == 2); + REQUIRE(decoded.cols == 2); + CHECK(cv::countNonZero(decoded.reshape(1)) == 0); + + std::filesystem::remove(output_path); + } + + SECTION("PNG write failure is reported") + { + const auto missing_directory = std::filesystem::temp_directory_path() / "alpine_terrain_builder_missing_directory"; + std::filesystem::remove_all(missing_directory); + const auto output_path = missing_directory / "image.png"; + + CHECK_THROWS_AS(image::saveImageAsPng(radix::Raster({ 1, 1 }), output_path.string()), std::runtime_error); + } +} diff --git a/unittests/tilebuilder/parallel_tile_generator.cpp b/unittests/tilebuilder/parallel_tile_generator.cpp index ee57262d..3fb7a7c4 100644 --- a/unittests/tilebuilder/parallel_tile_generator.cpp +++ b/unittests/tilebuilder/parallel_tile_generator.cpp @@ -43,7 +43,7 @@ TEST_CASE("parallel tile generator") , m_validation_error_counter(validation_error_counter) { } - void write(const std::string& file_path, const radix::tile::Descriptor& tile, const HeightData& heights) const override + void write(const std::string& file_path, const radix::tile::Descriptor& tile, const radix::Raster& heights) const override { if (file_path.empty() || tile.gridSize != 256 || heights.width() != 256 || heights.height() != 256) (*m_validation_error_counter)++; From 64794bb38b8cc39ee5da4423d765f1e8f0fc2f99 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:32:22 +0200 Subject: [PATCH 02/53] Document raster store design --- docs/raster-store/README.md | 33 ++ docs/raster-store/architecture.md | 272 ++++++++++++++++ .../decisions-and-open-questions.md | 196 ++++++++++++ docs/raster-store/requirements.md | 191 +++++++++++ docs/raster-store/sampling-and-generation.md | 249 +++++++++++++++ docs/raster-store/status-quo.md | 274 ++++++++++++++++ docs/raster-store/storage-format.md | 296 ++++++++++++++++++ 7 files changed, 1511 insertions(+) create mode 100644 docs/raster-store/README.md create mode 100644 docs/raster-store/architecture.md create mode 100644 docs/raster-store/decisions-and-open-questions.md create mode 100644 docs/raster-store/requirements.md create mode 100644 docs/raster-store/sampling-and-generation.md create mode 100644 docs/raster-store/status-quo.md create mode 100644 docs/raster-store/storage-format.md diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md new file mode 100644 index 00000000..61662c83 --- /dev/null +++ b/docs/raster-store/README.md @@ -0,0 +1,33 @@ +# Raster store design + +This directory describes a proposed authoritative raster store and the +generation of delivery tile pyramids from it. The documents are a design +baseline, not a finalized binary-format specification. + +The documents distinguish three kinds of statement: + +- **Confirmed** records a requirement or decision established in the design + discussion. +- **Proposed** records the current recommendation and is subject to review. +- **Open** records a question that must be resolved before the affected part + is implemented. + +## Documents + +- [Requirements and terminology](requirements.md) +- [Status quo and reuse assessment](status-quo.md) +- [Architecture](architecture.md) +- [Storage format](storage-format.md) +- [Sampling and pyramid generation](sampling-and-generation.md) +- [Decisions and open questions](decisions-and-open-questions.md) + +## Scope + +The authoritative store holds Web Mercator raster data at the best available +quality, together with exact per-pixel source attribution. It is separate from +delivery tile pyramids, which are filtered, formatted, and regenerated for a +particular consumer. + +The current scope includes raster imagery of different kinds, including +height rasters used to generate geometry. It does not define vector storage, +rendering styles, mesh formats, or a final source-ranking policy. diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md new file mode 100644 index 00000000..d778b83a --- /dev/null +++ b/docs/raster-store/architecture.md @@ -0,0 +1,272 @@ +# Architecture + +## System boundary + +The design separates authoritative data management from delivery generation: + +```text +Input rasters + │ + │ inspect, transform to Web Mercator, select source per pixel + ▼ +Raster-store builder + │ + │ writes a new immutable snapshot + ▼ +Authoritative raster store + │ + ├── payload chunks + ├── per-pixel source maps + ├── source catalog + └── sparse quadtree index + │ + │ reconstruct, filter, sample, encode + ▼ +Pyramid generators + ├── area-pixel texture pyramids + └── vertex-pixel height/geometry pyramids +``` + +The store resolves which source owns each stored pixel. Generators may blend +those selected payload values but do not modify the authoritative snapshot. + +## Dataset organization + +### Proposed + +A store root contains immutable snapshots. Each snapshot contains a manifest, +a source catalog, and one or more named raster layers: + +```text +store/ +└── snapshots/ + └── / + ├── manifest + ├── sources + └── layers/ + ├── heights/ + │ ├── layer + │ ├── raster.index + │ └── chunks/... + └── orthophoto/ + ├── layer + ├── raster.index + └── chunks/... +``` + +The names and exact hierarchy are illustrative. The important boundaries are: + +- a source catalog has snapshot-wide identity and metadata; +- each layer has a homogeneous pixel schema; +- each layer owns a sparse quadtree index; and +- each physical quadtree node maps to one atomic chunk container. + +The store manifest records the format version, snapshot identity, parent +snapshot when applicable, publication state, Web Mercator definition, and +layer list. + +## Spatial model + +### Confirmed + +The hierarchy uses `radix::tile::Id` semantics: zoom, `x`, and `y`, with four +children per node. Inputs are transformed into Web Mercator during ingestion. + +Physical payloads may occur at several quadtree levels. A physical parent can +coexist with physical descendants so that the parent remains a coherent +fallback representation. + +### Proposed + +The persistent store uses one canonical `y` convention. Slippy/XYZ is the +current recommendation because it matches common web-map paths, but this is +not yet confirmed. API callers may convert from TMS before lookup. + +A layer chooses one fixed chunk pixel dimension. Every physical node in that +layer covers the Web Mercator bounds of its tile ID with that many stored +pixels per side. Deeper nodes therefore provide twice the linear spatial +resolution at each level. + +Store chunks are non-overlapping. Any halo required for filtering or any +shared border required by a delivery format is assembled by a generator. + +## Sparse quadtree index + +The index uses the same four logical states as the octree index: + +| State | Chunk exists | Indexed descendants | +|---|---:|---:| +| `Leaf` | yes | no | +| `Inner` | yes | yes | +| `Virtual` | no | yes | +| `Missing` | no | no | + +`Missing` is represented by absence from the index, not serialized as an +entry. + +The index answers structural questions only. It does not claim that every +child exists and does not mark a virtual subtree as spatially complete. + +### Required operations + +- Look up a node without probing the filesystem. +- Add and remove physical nodes while maintaining virtual ancestors. +- Traverse only indexed branches. +- Enumerate physical descendants of a subtree. +- Find the nearest physical ancestor for fallback. +- Determine whether descendants may improve a requested output. +- Serialize and validate a versioned 2D topology. + +The last two operations may require aggregate metadata beyond +`Leaf/Inner/Virtual`, such as best descendant resolution or coverage. Such +metadata is an optimization and should be derivable from authoritative +entries. + +## Chunk model + +Each physical node owns one logical chunk: + +```text +Chunk +├── identity and schema reference +├── local source table +├── payload raster +└── source-index raster +``` + +The payload and source raster have identical dimensions. Every source-index +element names exactly one local source entry. + +### Why one atomic container is proposed + +An atomic container keeps payload and provenance inseparable, makes one +hard-link represent one unchanged chunk, and prevents a snapshot from pairing +a new payload with an old source map after interruption. + +The container may internally use independently compressed blocks to support +bounded window reads. Atomicity does not require monolithic decompression. + +## Source catalog and local tables + +### Proposed + +The source catalog assigns a stable `SourceId` to every ingested source and +records metadata such as: + +- source URI or durable identity; +- content/version identity where available; +- original CRS and transform; +- original and effective resolution; +- acquisition or publication time; +- explicit priority or quality fields; +- bands, data type, colour interpretation, and NoData information; and +- ingestion software and parameters. + +Each chunk stores a compact local source table: + +```text +local index 0 → SourceId 918 +local index 1 → SourceId 42 +local index 2 → SourceId 7001 +``` + +The source map stores local indexes rather than full global IDs. Its integer +width may be selected per chunk if the container records that width. + +This arrangement provides exact per-pixel attribution while keeping common +single- or few-source chunks compact. + +`SourceId` values referenced by a hard-linked chunk must remain stable across +the complete snapshot lineage in which that chunk is reused. A child snapshot +must preserve every catalog record referenced by inherited chunks; it cannot +renumber the catalog independently. + +## Source selection and fallback + +Source selection is a builder policy, not a property of the raster container. +Its initial comparison is expected to prioritize effective pixel resolution. + +For a candidate input, the builder conceptually performs: + +```text +for each affected stored pixel: + compare candidate source with selected source + write one winning payload value + write the winning source ID +``` + +The physical hierarchy supports a separate generator decision: + +```text +physical node is sufficiently accurate and coherent + → use it directly + +physical node is insufficient and better descendants exist + → refine and combine descendants, falling back to ancestors for gaps +``` + +These policies must not be conflated. Pixel ownership determines the contents +of one physical chunk. Refinement determines which physical chunks contribute +to a requested derived tile. + +## Snapshot lifecycle + +### Proposed + +A build never mutates a published snapshot: + +1. Create a private staging snapshot. +2. Load the parent snapshot's manifest and indexes when updating. +3. Identify affected chunks from candidate source bounds and resolution. +4. Hard-link unchanged chunk containers into staging. +5. Rebuild changed chunk containers. +6. Write new source catalogs, layer metadata, and indexes. +7. Validate references, dimensions, checksums, and index/file agreement. +8. Atomically publish the completed snapshot. + +Because hard links share inodes, a linked container must never be opened for +in-place modification. Changed chunks are written to new temporary paths and +renamed into place. + +Cross-filesystem hard links cannot be created. The builder must expose this as +a clear configuration or error rather than discovering it after a long build. + +## Pyramid generator interface + +A generator requests a layer over a target tile and sampling specification. +The store reader supplies selected authoritative values and provenance over a +window large enough for the generator's filter support. + +The generator owns: + +- target output zoom and dimensions; +- vertex-pixel or area-pixel placement; +- low-pass/reconstruction filter; +- NoData normalization during filtering; +- colour-space and alpha treatment; +- border construction; +- output codec; and +- tile-level contributing-source metadata. + +The store owns: + +- chunk location and decoding; +- sparse hierarchy and physical fallback; +- exact stored source map; +- source catalog lookup; and +- consistent window access across chunk boundaries. + +## Scaling implications + +The 10-100 MB chunk target reduces filesystem entry and index counts but makes +whole-chunk rewrites expensive. The builder should therefore: + +- determine affected chunks before decoding parent data; +- bound the number of resident payload/source-map pairs; +- stream source windows rather than load complete input datasets; +- use internal block compression if partial reads are frequent; and +- avoid materializing world-scale tile vectors before parallel execution. + +The first implementation should prefer correctness and measurable behaviour. +Compression blocks, caches, aggregate index metadata, and scheduling should be +driven by profiles and representative datasets. diff --git a/docs/raster-store/decisions-and-open-questions.md b/docs/raster-store/decisions-and-open-questions.md new file mode 100644 index 00000000..02371b4f --- /dev/null +++ b/docs/raster-store/decisions-and-open-questions.md @@ -0,0 +1,196 @@ +# Decisions and open questions + +This register prevents confirmed requirements, recommendations, and unresolved +questions from being mixed together. A future architectural decision record +may replace an entry when implementation requires a durable choice. + +## Confirmed decisions + +### D1: Separate authoritative and derived layers + +The raster store is authoritative. Delivery tile pyramids are derived and may +be regenerated with different sampling, filtering, dimensions, or codecs. + +### D2: Canonical Web Mercator ingestion + +Inputs are transformed into Web Mercator while building the authoritative +store, analogous to the SF builder transforming geometry into its canonical +ECEF space. + +### D3: Use a quadtree and Radix tile identity + +The hierarchy is two-dimensional and uses `radix::tile::Id`, not +`octree::Id`. + +### D4: Preserve physical coarse fallbacks + +A physical chunk may coexist with descendants. Generators may use the parent +as a coherent coarse representation or refine into more accurate descendants. + +### D5: Store chunks are non-overlapping + +The authoritative store does not duplicate height borders. Overlapping +rendering borders are generated from a consistent filtered signal. + +### D6: One selected value and source per stored pixel + +Every stored pixel contains exactly one payload value and one source +attribution. The store does not keep alternate candidate values for that +pixel. + +### D7: Generated provenance is tile-level + +Derived filtering may blend payload values from several sources. Generated +tiles retain provenance per tile, not per output pixel. + +### D8: Generator terminology + +Use **vertex pixel** for generated values on grid vertices and **area pixel** +for generated values associated with raster cells. The names describe required +output placement, not source measurement history. + +### D9: Large authoritative chunks + +Store tiles should be approximately 10-100 MB. They are storage chunks, not +ordinary web delivery tiles. + +### D10: Radix owns the generic raster primitive + +`radix::Raster` is the common in-memory raster representation. Store +georeferencing, provenance, persistence, and snapshot lifecycle remain outside +Radix. + +## Current recommendations + +These are proposed defaults, not confirmed decisions. + +### R1: Immutable snapshots + +Publish immutable snapshots, hard-link unchanged chunk containers, and write +changed chunks to new inodes. This avoids accidental mutation through hard +links and supports discardable failed builds. + +### R2: One atomic chunk container + +Keep payload, local source table, and source map in one container so they +cannot become inconsistent and can be reused with one hard link. + +### R3: Fixed power-of-two chunk dimension per layer + +Choose a fixed side length from a logical byte budget. Do not split spatial +identities dynamically based on compressed size. + +### R4: Snapshot-wide catalog plus local source tables + +Store full metadata once in a snapshot catalog. Let each chunk map compact +local source indexes to stable catalog IDs. + +### R5: Canonical persistent tile scheme + +Persist one scheme and normalize at API boundaries. Slippy/XYZ is the current +candidate, but TMS has not been rejected. + +### R6: Explicit versioning and validation + +New format files should have magic values, versions, checksums, strict tile-ID +validation, and a rebuildable index. The current octree index binary should +not be reused as an unlabelled quadtree format. + +### R7: Global filtering across chunk boundaries + +Generators request filter halos and operate on logical global windows. They do +not clamp at internal chunk or delivery-tile boundaries. + +## Open questions + +### Spatial and layer model + +1. Are raster kinds always separate named layers with independent indexes? +2. Which exact Web Mercator definition and world extent are canonical? +3. Is the persistent scheme Slippy/XYZ or TMS? +4. What is the maximum supported zoom? +5. What exact spatial interpretation does the store assign to its own raster + elements? This is independent of vertex-pixel/area-pixel delivery policy + but must be defined for georeferencing and reconstruction. +6. Does source accuracy vary only by source, or can it vary spatially within a + source? + +### Chunk size and physical format + +7. Does 10-100 MB mean logical bytes, compressed disk bytes, or constraints on + both? +8. Are units decimal MB or binary MiB? +9. Is chunk dimension fixed globally or selected per layer? +10. Which representative payload schemas must be used to choose between + 2,048², 4,096², or another size? +11. Does the source map use `uint8_t`, `uint16_t`, or adaptive width? +12. Which container serialization, compression, block size, and checksum are + suitable for representative imagery and source maps? +13. Is `//` sufficient for filesystem scaling, or is prefix sharding + required? + +### Source metadata and selection + +14. What creates a stable `SourceId`: catalog sequence, content hash, external + ID, or a combination? +15. Which source metadata fields are mandatory? +16. How is a pixel with no valid source represented? +17. What is the first complete source-ranking policy after pixel resolution? +18. How much resolution loss is acceptable to prefer one coherent source over + a finer mosaic? +19. How are acquisition time, quality flags, explicit priority, and ties + handled? +20. Can an update remove or invalidate a previously selected source, and if + so, where does replacement data come from when alternates are not stored? + +### Filtering and generation + +21. Which reconstruction and low-pass filters are required for heights? +22. Is terrain reduction optimized only for anti-aliasing, or also for + geometric error and preservation of extrema? +23. Which colour space and alpha convention are authoritative for imagery? +24. Which semantic raster kinds require categorical or conservative + reduction rather than linear filtering? +25. What are the exact NoData normalization rules? +26. Is tile-level provenance only a source-ID set, or does it include + contribution fractions? +27. How are Web Mercator horizontal wrap and north/south boundaries filtered? +28. Must shared generated height edges be bit-identical across separate runs + and execution orders? + +### Index and implementation structure + +29. Should the octree index/storage be generalized over hierarchical ID type, + or should the raster store receive a separate 2D implementation? +30. Should `radix::tile::Id` itself gain checked root/zoom/coordinate and + serialization APIs, or should storage use a checked adapter? +31. Does the index need aggregate coverage or best-descendant-resolution data, + or can the first version derive it during traversal? +32. How is an existing index verified against chunk files without making every + open operation scan the filesystem? + +### Snapshots and operation + +33. What atomically publishes a completed snapshot? +34. Are cross-filesystem updates rejected or allowed with a copy fallback? +35. What is the retention and garbage-collection policy? +36. How does a long build resume, and how are staged chunks validated before + being trusted? +37. Which corruption and compatibility guarantees are required for long-term + archival use? + +## Suggested order for resolving questions + +The questions do not need to be answered all at once. A practical order is: + +1. Fix the store's spatial sampling interpretation and layer model. +2. Select representative payload formats and datasets. +3. Prototype chunk dimensions, source-index widths, and block compression. +4. Decide generic versus separate index/storage implementation. +5. Specify source identity, NoData, and the initial resolution ranking. +6. Implement a single-source area-pixel path with golden grid tests. +7. Add multi-source ownership and immutable snapshots. +8. Prototype vertex-pixel height filtering and seam tests. +9. Finalize operational publication, recovery, and compatibility rules. + +Each step should produce a small approved plan before code changes begin. diff --git a/docs/raster-store/requirements.md b/docs/raster-store/requirements.md new file mode 100644 index 00000000..e31bf841 --- /dev/null +++ b/docs/raster-store/requirements.md @@ -0,0 +1,191 @@ +# Requirements and terminology + +## Goals + +### Confirmed + +The system has two distinct layers: + +1. An authoritative raster store that retains the best representation + available for every covered pixel. +2. Derived tile pyramids generated from that store for rendering or other + delivery formats. + +Inputs may use arbitrary supported coordinate reference systems. Ingestion +transforms them into Web Mercator before placing their data in the store. + +The store must support multiple raster kinds. Height data is one raster kind; +orthophotos and other image-like data are others. Pixel format, NoData +handling, filtering, and output encoding may differ by kind. + +The store must support mixed accuracy over space. A physical payload at a +coarser quadtree node may remain available as a coherent fallback while more +accurate payloads exist in descendants. + +### Proposed + +Each named raster layer has a homogeneous value schema and its own sparse +quadtree index. Layers share the same Web Mercator tile addressing and source +catalog infrastructure but do not mix incompatible pixel types in one +payload. + +The authoritative store is output-format independent. It does not contain +rendering borders or require the sampling placement of a particular delivery +format. + +## Stored pixels and provenance + +### Confirmed + +Every stored pixel has exactly: + +- one payload value; and +- one source attribution. + +A tile carries a local source table and a source map. Each source-map element +indexes one entry in the local table. A local entry identifies source metadata +held by the store. + +The store does not blend source identifiers and does not retain several +candidate payload values for one stored pixel. Source selection is resolved +before the pixel is committed. + +Generated tiles do not retain per-pixel provenance. They record provenance at +tile granularity, for example as the set of sources that contributed to the +filtered output tile. Filtering may blend payload values across source +boundaries without changing the authoritative store's provenance model. + +### Open + +- Whether a source map refers to a dataset-wide source catalog by stable ID, + or contains complete source metadata in each tile. +- The reserved representation for a pixel with no valid source. +- Whether generated tile provenance needs only a set of source IDs or also + approximate contribution fractions. + +## Tile and chunk requirements + +### Confirmed + +Store tiles are large data chunks, not ordinary 256-pixel web delivery tiles. +Their target size is approximately 10-100 MB. + +Store tiles do not duplicate rendering overlap. In particular, overlapping +height borders are generated later and are not authoritative duplicated data. + +Tiles are addressed by a two-dimensional quadtree using +`radix::tile::Id`, not `octree::Id`. + +### Proposed + +The size target should initially be interpreted as an uncompressed logical +byte budget for payload plus source map. That gives predictable peak memory, +I/O, and rewrite cost. Compressed on-disk size varies too much with content to +be the only sizing invariant. + +The pixel side length should be a power of two. Illustrative uncompressed +sizes are: + +| Side length | Pixels | RGB8 + uint16 source | float32 + uint16 source | +|---:|---:|---:|---:| +| 2,048 | 4,194,304 | 20 MiB | 24 MiB | +| 4,096 | 16,777,216 | 80 MiB | 96 MiB | + +These calculations exclude headers, the local source table, alignment, and +compression. They demonstrate that a fixed 2,048- or 4,096-pixel chunk is in +the intended range for common formats; they do not decide the final chunk +dimension. + +### Open + +- Whether the 10-100 MB target refers to logical, compressed, or both sizes. +- Whether all layers use one fixed side length or choose it from their bytes + per pixel and source-index width. +- Whether the source map uses a fixed or per-tile adaptive integer width. +- Whether payload and provenance are one atomic container or coordinated + files. + +## Accuracy and fallback + +### Confirmed + +Pixel resolution is expected to be the most important source-accuracy signal, +but the final ranking rule is not yet known. + +A coarse, coherent source should be usable directly for a coarse output tile +even when more accurate fragments exist below it. Generators must not be +forced to patch every output from the deepest available descendants. + +### Proposed + +Source metadata records effective ground resolution after reprojection as +well as original resolution where meaningful. Source selection is a policy +interface rather than a fixed comparison embedded in the storage format. + +A pyramid generator refines into descendants only when the current physical +node does not satisfy the requested output resolution or another configured +quality criterion. + +### Open + +- The resolution tolerance that permits use of one coherent source instead + of a finer mosaic. +- Tie-breaking by acquisition time, explicit priority, quality, or source + identity. +- Whether source accuracy is scalar per source or may vary spatially. + +## Updates and reuse + +### Proposed + +Published store versions are immutable snapshots. Building a new snapshot +hard-links unchanged tile containers from an earlier snapshot and writes new +containers only for changed tiles. The snapshot owns a new index and manifest. + +This is safer than mutating hard-linked files in place and makes a failed build +discardable without damaging an earlier published store. + +Hard-link reuse requires source and destination snapshots to reside on the +same filesystem. The implementation must either require that condition or +define an explicit copy fallback. + +### Open + +- Snapshot publication and atomic rename rules. +- Retention and garbage-collection policy. +- Whether cross-filesystem builds fail or copy unchanged chunks. + +## Terminology + +**Raster store tile** or **chunk** +: A large authoritative file associated with a quadtree tile ID. It is not a + delivery tile and is expected to contain roughly 10-100 MB. + +**Payload** +: The selected raster value array stored by a chunk. + +**Source map** +: A raster aligned one-to-one with the payload. Each element selects exactly + one entry in the chunk's local source table. + +**Physical node** +: A quadtree node with an authoritative chunk on disk. + +**Virtual node** +: An index-only quadtree node with descendants but no chunk of its own. + +**Vertex pixel** +: A generated value located on a grid vertex. Height tiles for mesh generation + require vertex pixels, including shared boundary positions. + +**Area pixel** +: A generated value associated with a raster cell. Ordinary texture outputs + use area pixels whose cell boundaries align with tile boundaries. + +The terms vertex pixel and area pixel describe generator outputs. They do not +assert how an original sensor or source raster produced its values. + +**Derived tile** +: A filtered and encoded output tile generated from the authoritative store. +It may have different dimensions, sampling placement, encoding, and +provenance granularity from a store chunk. diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md new file mode 100644 index 00000000..83309521 --- /dev/null +++ b/docs/raster-store/sampling-and-generation.md @@ -0,0 +1,249 @@ +# Sampling and pyramid generation + +This document defines generator-facing sampling terminology and invariants. +It deliberately separates output sampling from how an original source raster +was measured or produced. + +## Terminology + +### Vertex pixel + +A vertex pixel is a generated value located on a grid vertex. For a tile with +`N` intervals per side, vertex positions are: + +```text +x(i) = left + i × tile_width / N, i = 0 … N +``` + +The output has `N+1` pixels per side: + +```text +tile boundary tile boundary +●---------●---------●---------●---------● +``` + +Height maps used to form mesh vertices require this placement. Adjacent +rendering tiles contain overlapping copies of their shared edge and corner +vertex pixels. + +### Area pixel + +An area pixel is a generated value associated with one raster cell. For a tile +with `N` cells per side, effective cell-centre positions are: + +```text +x(i) = left + (i + 1/2) × tile_width / N, i = 0 … N-1 +``` + +The output has `N` pixels per side: + +```text +tile boundary tile boundary +│ × × × × │ +``` + +The term describes placement and support in the generated grid. It does not +assert that the input value was a physical area integral. + +## Source semantics versus output placement + +An input height raster may have been produced from LiDAR points through +gridding, interpolation, fitting, or averaging. An orthophoto may already have +passed through sensor integration, reconstruction, reprojection, and +resampling. Those histories do not decide where a delivery format requires +its output values. + +Generation is modelled as: + +```text +stored discrete raster + ↓ reconstruct its implied field +continuous or evaluable field + ↓ low-pass for target resolution +filtered field + ↓ evaluate on requested output grid +vertex pixels or area pixels +``` + +The source interpretation and reconstruction rule are layer/generator policy. +Vertex-pixel and area-pixel placement are output requirements. + +## Reduction by two + +### Vertex pixels + +At fine spacing `Δ`, fine vertex positions are `nΔ`. Coarse positions are +`2mΔ`, coinciding with every second fine location: + +```text +fine: ●---●---●---●---● +coarse: ●-------●-------● +``` + +Copying every second value would be unfiltered decimation and is unacceptable +because frequencies above the new Nyquist limit would alias. The generator +must low-pass first, using a kernel centred on each retained vertex position: + +```text +coarse[m] = Σ h[k] × fine[2m - k] +``` + +The spatial centre stays in place; the value generally changes because it is +sampled from the filtered signal. + +### Area pixels + +Fine area-pixel centres are `(n+1/2)Δ`. A coarse cell spans two fine cells and +has its centre at `(2m+1)Δ`, halfway between two fine centres: + +```text +fine cells: |---- × ----|---- × ----| +coarse cell: |---------- × ----------| +``` + +The simplest reduction is the average of each 2x2 fine block. If fine values +are exact equal-area averages, that produces the exact average over the union +of the four cells. A box filter is not an ideal anti-aliasing filter, however, +and may be insufficient for visual imagery or other signals. + +A higher-quality area-pixel reduction applies a low-pass filter with the +correct half-sample phase, centred on the coarse cell centre. Its support may +extend beyond the four cells geometrically covered by the coarse cell. + +## No duplicated height borders in the store + +The authoritative store does not persist overlapping rendering borders. +Pyramid generation constructs a vertex-pixel output only after reconstruction +and filtering. + +The implementation may obtain a requested `(N+1) × (N+1)` output window by +reading non-overlapping store chunks plus the filter halo required on every +side. The generated shared vertices must be computed from the same global +coordinates and source data for both neighbouring output tiles. + +The generator must not independently clamp its filter at each tile edge. +Clamping would make an internal tile boundary behave like a data boundary and +could produce seams. + +Two implementation strategies can satisfy the invariant: + +1. Evaluate shared global vertex coordinates deterministically from a common + window reader; or +2. Generate a metatile, filter it once, and split it into overlapping output + tiles. + +The first gives execution-order independence. The second may reduce repeated +I/O. They can coexist if tests establish identical results. + +## Filter halos and chunk boundaries + +Any nontrivial low-pass filter needs samples outside the exact output bounds. +The required halo is determined by the reconstruction and reduction filters, +not by a fixed one-pixel border flag. + +The store reader should expose a logical raster window over the quadtree. It +resolves: + +- physical chunks selected for the requested accuracy; +- ancestor fallback where finer data is absent; +- chunk and source-map decoding; and +- neighbouring data needed by the window. + +The generator determines the requested halo and applies boundary conditions +only at true dataset/world boundaries or NoData boundaries. + +## Mixed sources + +The raster store contains exactly one payload and one source ID for each +stored pixel. A generator filter may span pixels attributed to several +sources: + +```text +store pixels: A A A B B +filter support: [-------] +output value: blend of A and B payloads +``` + +This is allowed. A generated output pixel does not retain a source ID. The +generated tile records tile-level provenance, at minimum the set of source IDs +whose payload values contributed nonzero filter weight to any output pixel. + +Source IDs are categorical and are never averaged. Payload filtering and +provenance collection are parallel operations: + +```text +numeric payload samples → weighted filtered value +source IDs → contributing-source set +``` + +The exact handling of invalid/NoData samples requires a policy. A common +continuous-raster rule is to normalize by the total weight of valid samples, +but that must not be applied automatically to categorical data. + +## Layer-specific filtering + +Sampling placement alone does not determine a correct filter: + +| Semantic kind | Relevant considerations | +|---|---| +| Height | low-pass before decimation; terrain error and peak loss | +| Orthophoto | linear-light filtering; alpha premultiplication | +| Categorical | mode, coverage, or another categorical policy | +| Probability/coverage | conservative area averaging may be appropriate | +| Vector/normal | component filtering followed by normalization where needed | +| Mask/NoData | validity-aware weights and explicit coverage rules | + +The current `radix::raster::generate_mipmap` performs a component-wise 2x2 +box average. It may be a reference for simple area-pixel aggregation, but it +does not implement these policies or vertex-pixel filtering. + +## Coherent coarse-source selection + +The generator need not always filter the deepest available descendants. If a +physical chunk at the requested scale is sufficiently accurate, using that +single coherent source may be preferable to composing several finer sources. + +The selection process is conceptually: + +```text +choose physical representation(s) for the requested output and quality policy + ↓ +read a continuous window with fallback and required halo + ↓ +filter for the target resolution + ↓ +evaluate vertex pixels or area pixels +``` + +Source-selection/refinement policy precedes filtering. Filtering does not +change the authoritative hierarchy. + +## World and dataset boundaries + +The generator needs explicit rules for: + +- horizontal wrapping at the Web Mercator antimeridian; +- north/south limits of the Web Mercator world; +- areas with no physical ancestor or descendant; +- NoData holes inside otherwise covered chunks; and +- filters whose support crosses a layer's coverage boundary. + +These rules are not yet decided. Tests must distinguish true boundaries from +ordinary internal chunk and delivery-tile boundaries. + +## Required golden tests + +Before production filtering is implemented, synthetic fixtures should prove: + +1. A constant raster remains constant across chunks and pyramid levels. +2. An impulse or frequency sweep demonstrates the chosen anti-alias response. +3. Two adjacent area-pixel tiles match a single equivalent metatile result. +4. Two adjacent vertex-pixel tiles produce bit-identical shared edges. +5. Filtering is unchanged when a store window is split into different chunks. +6. A source boundary blends payloads but reports both tile-level sources. +7. A NoData boundary follows the configured validity rule. +8. A coherent physical parent can be chosen instead of finer descendants. +9. TMS and Slippy input IDs normalize to the same canonical spatial tile. + +The filter coefficients and acceptable numeric tolerances remain open design +decisions. The tests should lock them only after representative evaluation. diff --git a/docs/raster-store/status-quo.md b/docs/raster-store/status-quo.md new file mode 100644 index 00000000..9d2b5701 --- /dev/null +++ b/docs/raster-store/status-quo.md @@ -0,0 +1,274 @@ +# Status quo and reuse assessment + +This document evaluates the current repository after commit `9cf9065` +(`Consolidate raster handling and use std::expected`). It distinguishes +reusable mechanisms from interfaces that encode assumptions unsuitable for +the raster store. + +## Summary + +The project already contains most low-level ingredients: + +- `radix::Raster` for contiguous typed raster memory; +- `radix::tile::Id` for quadtree addressing; +- Web Mercator grid calculations and GDAL reprojection in `tile_builder`; +- sparse topology, indexed traversal, codecs, layouts, and hard-link reuse in + the octree storage code; and +- the SF merger's snapshot-like reuse of unchanged subtrees. + +There is no existing component that should become the raster store unchanged. +The best path is to compose the Radix raster and tile primitives with a new +2D storage layer, while extracting or adapting selected octree-storage ideas. + +## Reuse matrix + +| Component | Assessment | Intended use | +|---|---|---| +| `radix::Raster` | Reuse directly | In-memory payloads and source maps | +| `radix::RasterMask` | Reuse directly | Temporary validity/selection masks | +| `radix::raster::transform` | Reuse directly | Typed pixel transformations | +| `radix::raster::generate_mipmap` | Do not use as general generator | Only a 2x2 component-wise box average | +| `radix::tile::Id` | Reuse after hardening or through an adapter | Persistent quadtree keys | +| `radix::quad_tree::Node` | Do not reuse for disk index | Dense in-memory ownership model | +| CTB `GlobalMercator` and grid bounds | Reuse initially | Tile bounds and Web Mercator resolution | +| `Dataset` and GDAL setup | Reuse/adapt | Input discovery and reprojection | +| `DatasetReader` | Adapt substantially | Windowed, typed, multi-band ingestion | +| `Tiler` / `ParallelTiler` | Reuse calculations, replace orchestration | Candidate chunk enumeration | +| `ParallelTileGenerator` | Do not use as store builder | Small-file writer with incompatible lifecycle | +| Octree `IndexMap` algorithm | Reuse design; generalize or port | Sparse physical/virtual topology | +| Octree `Storage_` / `RawStorage_` | Reuse design and selected code | Codec boundary, indexing, hard-link copy | +| Octree disk layouts | Do not use as-is | They encode `octree::Id` and 3D paths | +| SF merger visitors and geometry code | Do not reuse | Mesh- and ECEF-specific semantics | +| SF merger unchanged-subtree copy | Reuse design | Snapshot construction and hard links | +| `zpp_bits` serialization helpers | Reuse cautiously | Versioned metadata/index serialization | + +## Radix raster + +### What can be reused + +`extern/radix/src/radix/raster.h` now provides a shared, value-typed raster: + +- rectangular `glm::uvec2` dimensions; +- contiguous row-major `std::vector` storage; +- element and byte spans; +- typed pixel access; +- move construction from an existing vector; +- a contiguous byte-valued `RasterMask`; +- dimension-checked concatenation; and +- masked and unmasked transforms. + +These properties fit both store arrays: + +```cpp +radix::Raster payload; +radix::Raster source_map; +``` + +The class correctly remains independent of Web Mercator, tile IDs, source +metadata, compression, and file I/O. Those belong to higher layers. + +### What needs adaptation around it + +Large chunks and filtered generation will benefit from operations not +currently supplied by `Raster`: + +- non-owning raster views and subwindows; +- explicit row stride where external codecs require it; +- halo/window assembly across neighbouring chunks; +- checked construction that reports allocation/dimension errors without + relying on assertions; and +- streaming or block processing when a full set of input chunks would exceed + the memory budget. + +These should be introduced only when required. They are not reasons to embed +store concepts into `Raster`. + +### What cannot be reused for final filtering + +`radix::raster::generate_mipmap` requires a square, power-of-two raster and +reduces each 2x2 group by component-wise averaging. It does not provide: + +- a selectable reconstruction or low-pass filter; +- the phase difference between vertex pixels and area pixels; +- halo samples across tile boundaries; +- linear-light colour and premultiplied-alpha handling; +- NoData-aware normalization; +- categorical reduction; or +- source-contribution tracking. + +It is therefore a useful simple raster utility, not the raster-store pyramid +generator. + +## Radix tile addressing + +### What can be reused + +`radix::tile::Id` already contains the required 2D identity: + +- zoom level; +- `x/y` coordinates; +- parent and four-child relationships; +- TMS/Slippy conversion; and +- hashing and ordering support. + +### Required hardening + +Persistent storage needs stronger invariants than the current convenience +type supplies: + +- Calling `parent()` at zoom zero currently underflows. +- Construction does not reject coordinates outside `[0, 2^z)`. +- Shifting `1u << zoom_level` limits valid conversion at high zooms. +- The scheme participates in identity, so the same spatial tile in TMS and + Slippy form becomes two keys. +- There is no persistent serialization contract or format version. + +The store should choose one canonical scheme and normalize all IDs at its API +boundary. Whether the hardening belongs in Radix or in a checked store adapter +is open. + +`radix::quad_tree::Node` is not a replacement for the index. It owns a +fully allocated group of four children whenever refined, represents no +missing child within such a group, and has no persistence or physical/virtual +status. The store requires a sparse map keyed by tile ID. + +## Tile builder and GDAL path + +### Reusable foundations + +The current tile builder already demonstrates: + +- opening GDAL raster datasets; +- reading dataset bounds; +- selecting Web Mercator or geodetic CTB grids; +- transforming arbitrary source SRS data during reads; +- calculating tile bounds and resolutions; +- enumerating intersecting `radix::tile::Id` values; and +- parallel per-tile processing. + +`ctb::GlobalMercator`, `Tiler::tile_for`, and `ParallelTiler` are useful +references and may be reused initially for grid math. + +### Required changes + +`DatasetReader` currently reads one band into a float raster, uses cubic GDAL +warping, and constructs a warped VRT for a requested output rectangle. The +store needs typed and multi-band reads, explicit alpha/NoData handling, +controlled resampling, source metadata, and deterministic alignment with the +store chunk grid. + +`Tiler` models delivery-oriented dimensions through `Border::Yes/No` and a +south/east extra pixel. Store chunks have no rendering overlap, and generated +vertex pixels require an explicit global sampling/filtering model. The border +boolean should not define store geometry. + +`ParallelTileGenerator` writes individual image files for explicitly +enumerated tiles. It is not suitable as the snapshot builder because it lacks: + +- source-map generation; +- old-snapshot reuse; +- atomic chunk containers; +- index transactions and publication; +- resumability validation; and +- bounded enumeration/streaming for very large tile sets. + +Its parallel work pattern and progress reporting may still inform the new +builder. + +## Octree index and storage + +### Reusable design + +The octree index captures the required topology semantics: + +- `Leaf`: physical payload without indexed descendants; +- `Inner`: physical payload with indexed descendants; +- `Virtual`: no physical payload, but indexed descendants; and +- absence from the map: missing node and subtree. + +Adding a physical node creates virtual ancestors and promotes a physical +ancestor from leaf to inner. Removing nodes collapses unused virtual chains. +Traversal follows only present index entries. + +The storage stack also has useful separation between: + +- logical indexed storage; +- raw path-based storage; +- disk layout; +- payload codec; and +- optional cache. + +`RawStorage_::copy_from` demonstrates hard-link reuse when source and target +extensions match. The SF merger demonstrates copying unchanged physical +subtrees and writing a new output index. + +### Why it cannot be reused unchanged + +The entire stack uses concrete `octree::Id` types. `IndexMap`, cache APIs, +layouts, filesystem path parsing, traversal, storage, serialization, and +formatting all embed this type. The coordinate-directory layout is +`level/x/y/z`, and the default codec is a mesh codec. + +The existing index file also records no tree kind. Reinterpreting its +serialized `(level, index)` octree IDs as web tiles would be unsafe. + +A 2D implementation can either: + +1. generalize the hierarchy/storage stack over an ID and layout policy; or +2. create a raster-store-specific 2D implementation using the same algorithms. + +Generalization avoids duplicate infrastructure but has a larger blast radius +in mature octree code. A separate implementation is initially safer but risks +long-term duplication. This requires an explicit decision before coding. + +### Behaviours that should not be copied blindly + +- Hard-link failure currently has no copy fallback. +- Existing indexes are trusted rather than reconciled against filesystem + contents when opened. +- An unindexed output discovers files through a final recursive directory + scan. +- Some error paths use assertions or process termination. +- The binary index lacks an explicit magic/version/topology header suitable + for a new durable format. + +The raster store should retain the useful topology and codec boundaries while +specifying stronger snapshot, validation, and error-handling rules. + +## SF builder and merger + +The SF builder's ECEF octree placement, mesh construction, texture atlases, +mask clipping, and mesh visitors are not reusable for a Web Mercator raster +store. + +The reusable ideas are architectural: + +- transform source data into a canonical spatial system during build; +- preserve physical ancestors as fallback representations; +- use an index to avoid per-node filesystem probing; +- stop refinement when a coherent representation is sufficient; and +- hard-link unchanged chunks into a new output dataset. + +Those ideas should be reimplemented against Radix tile IDs and raster payloads +rather than adapted through mesh abstractions. + +## Recommended component boundary + +The current components suggest the following dependency direction: + +```text +Radix + Raster, RasterMask, tile::Id, geometry + ↓ +Terrain library + GDAL dataset access, Web Mercator grid math, filtering primitives + ↓ +Raster store + source catalog, chunk container, sparse index, snapshots + ↓ +Builders and generators + ingestion, source selection, texture pyramids, height/geometry pyramids +``` + +The raster store should consume `radix::Raster`; Radix should not depend on +the store's source catalog, file format, or snapshot lifecycle. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md new file mode 100644 index 00000000..56a639b9 --- /dev/null +++ b/docs/raster-store/storage-format.md @@ -0,0 +1,296 @@ +# Storage format + +This document specifies the logical format and required invariants. Exact +binary encodings, field widths, compression libraries, and filenames remain +open unless explicitly marked confirmed. + +## Format principles + +### Confirmed + +- The persistent hierarchy is a Web Mercator quadtree keyed by Radix tile IDs. +- Store chunks are non-overlapping and approximately 10-100 MB. +- Every stored pixel has one payload value and one source attribution. +- A coarse physical chunk may coexist with more accurate descendants. +- Rendering overlap and delivery encodings are not stored authoritatively. + +### Proposed + +- All files have explicit magic, format version, and byte-order declarations. +- Payload and source map are held in one atomic chunk container. +- Published files are immutable. +- Checksums cover metadata and independently compressed data sections. +- Unknown optional fields can be skipped by length; incompatible required + fields cause a clear load failure. + +## Store manifest + +The root manifest should contain: + +```text +format magic and version +snapshot ID +optional parent snapshot ID +creation metadata +canonical CRS identifier and definition +canonical tile scheme +source-catalog reference +layer descriptors +``` + +The CRS must be specified more strongly than a human-readable name. The exact +EPSG/Web Mercator definition and world extent used by tile calculations must +be unambiguous. + +## Layer descriptor + +A layer descriptor should contain: + +```text +layer ID and name +semantic kind +payload pixel format +band/component interpretation +chunk width and height +NoData representation +index reference +chunk layout/codec identifier +``` + +The descriptor defines stored data. Vertex-pixel versus area-pixel delivery is +generator policy and is not required as a layer field unless the eventual +store permits more than one stored spatial placement. + +The semantic kind is still useful because it constrains valid operations. For +example, height scalars, linear colour, gamma-encoded colour, categorical +values, masks, and vectors require different filtering. + +## Source catalog + +A source-catalog record should contain a stable ID and sufficient provenance +to understand or reproduce ingestion. Candidate fields are: + +```text +SourceId +source identity and version/content fingerprint +human-readable name +original URI or path, if durable and safe to persist +original CRS and geotransform +original dimensions, bands, and pixel type +original NoData/alpha information +original nominal resolution +effective Web Mercator resolution +acquisition/publication time +priority and quality metadata +ingestion timestamp +ingestion transform and resampling policy +``` + +Pathnames may contain deployment-specific or sensitive information. The +format should support opaque source identities without requiring an original +local path. + +Source IDs are persistent across related snapshots. A snapshot that hard-links +an older chunk must retain the same meaning for every global source ID in that +chunk's local table. Snapshot-local catalog compaction may not renumber IDs +referenced by inherited containers. + +## Quadtree index file + +The index should contain: + +```text +magic +format version +tree kind = quadtree +canonical scheme +chunk layout identifier +entry count +entries: TileId → NodeStatus +optional aggregate metadata +checksum +``` + +`TileId` must be validated when decoded: + +```text +zoom is supported +x < 2^zoom +y < 2^zoom +scheme is canonical or omitted because the file declares it globally +``` + +Node status has the following invariant: + +- `Leaf` and `Inner` have a chunk file. +- `Virtual` has no chunk file. +- `Inner` and `Virtual` have at least one indexed descendant. +- `Leaf` has no indexed descendant. +- Every non-root entry has all required ancestors represented. + +The index must be reconstructible by scanning valid chunk paths. A rebuild +tool should write a new index rather than silently changing a published one. + +## Chunk container + +### Logical structure + +```text +Chunk header +├── magic and version +├── tile ID +├── layer/schema identity +├── raster dimensions +├── payload encoding +├── source-index encoding +└── section directory + +Local source table +├── local source index +└── global SourceId + +Payload sections +└── typed raster data + +Source-map sections +└── local source indexes, one per payload pixel +``` + +Payload and source-map dimensions must match exactly. A decoder rejects the +container if a source-map value is outside the local source table. + +### Source-index width + +Possible representations include: + +- `uint8_t` for at most 256 local entries; +- `uint16_t` for at most 65,536 local entries; or +- a width selected per chunk and recorded in the header. + +Adaptive width is attractive because most chunks are expected to use few +sources, but it adds codec branches. This is an open implementation decision. + +A reserved local entry may represent no valid source. If this is adopted, its +payload validity and filtering semantics must be defined rather than inferred +from an arbitrary payload value. + +### Compression and random access + +The chunk-size target makes whole-file decompression undesirable for small +windows. A proposed container divides payload and source map into matching +independently compressed blocks and records their offsets in the section +directory. + +Block dimensions should support: + +- bounded memory; +- halo reads across chunks; +- checksumming damaged regions; +- skipping source-map blocks when provenance is not requested; and +- skipping payload blocks during provenance-only inspection. + +The payload and source-map blocks need not use the same compression algorithm. +Source maps are categorical and may benefit from run-length, palette, or +general-purpose compression. The initial implementation should benchmark +representative data before fixing a codec. + +## Chunk sizing + +### Proposed sizing rule + +Choose a power-of-two side length from a logical byte budget: + +```text +logical bytes ≈ width × height × + (payload bytes per pixel + source-index bytes per pixel) +``` + +The target interval is 10-100 MB, including both large raster arrays but +excluding small metadata. For example: + +```text +4096² × (RGB8 + uint16 source) ≈ 80 MiB +4096² × (float32 + uint16 source) ≈ 96 MiB +2048² × (RGBA8 + uint16 source) ≈ 24 MiB +``` + +The examples use binary MiB despite the conversational 10-100 MB requirement; +the final specification must choose units explicitly. + +If a layer's pixel type would place 4,096² outside the budget, the layer may +choose another fixed power-of-two dimension. Varying dimensions between nodes +within one layer is not recommended because it complicates resolution and +window calculations. + +### Why compressed size should not drive identity + +Compressed size depends on terrain, imagery, source-map fragmentation, and +codec settings. Splitting chunks dynamically to hit a compressed-byte target +would make spatial identities content-dependent and destabilize updates. +A fixed spatial grid with a logical size budget gives predictable identity; +compression is an optimization. + +## Filesystem layout + +A proposed chunk path is: + +```text +layers//chunks///.rst +``` + +This is familiar and reconstructible, but very large datasets may place too +many entries in intermediate directories. Alternative layouts may shard by +Morton/Hilbert prefix. The layout identifier in layer metadata allows this to +change without changing tile identity. + +Layout parsing must accept only canonical chunk files. Auxiliary previews, +temporary files, and generated delivery images must not accidentally become +index entries during a rebuild scan. + +## Immutability and hard links + +An unchanged chunk in a child snapshot may be a hard link to the parent's +container. The following rules are mandatory if hard links are used: + +- Published containers are never modified in place. +- A changed container is written to a distinct temporary inode. +- Completion uses an atomic rename within the target filesystem. +- Snapshot deletion removes names, not shared content still referenced by + another snapshot. +- Validation may inspect inode/link counts for diagnostics but format + correctness never depends on them. + +The index, manifest, and source catalog are snapshot-specific and normally new +files even when many chunks are linked. + +## Consistency and recovery + +A snapshot is publishable only if: + +- every physical index entry resolves to one valid chunk; +- no virtual entry has a chunk; +- all chunk tile IDs and layer IDs match their paths and index entries; +- source maps reference valid local entries; +- local entries reference valid catalog records; +- dimensions and formats match the layer descriptor; and +- checksums pass. + +Temporary and incomplete files use names excluded from layout scanning. +Publication should make the complete snapshot visible in one atomic metadata +operation. The exact mechanism is open. + +## Explicitly not finalized + +This document does not yet choose: + +- binary serialization library; +- integer field widths; +- compression codecs and block dimensions; +- exact source metadata fields; +- checksum algorithm; +- file extensions and directory names; +- snapshot publication mechanism; or +- generic versus raster-specific index implementation. + +Those choices require prototypes and representative datasets, and should not +be implied by a first implementation. From ec0384784232efcc8d9c4f1b13e01e98ce6d4416 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:51:21 +0200 Subject: [PATCH 03/53] Use Slippy tile coordinates throughout --- README.md | 15 +- docs/tile-downloader/download.sh | 6 +- docs/tile-downloader/root_tile_list | 728 +++++++++--------- src/CMakeLists.txt | 2 +- src/sf_builder/main.cpp | 23 +- src/sf_builder/texture_assembler.h | 3 +- src/sf_builder/tile_provider.h | 21 +- src/terrainlib/CMakeLists.txt | 1 + src/terrainlib/ctb/GlobalGeodetic.hpp | 5 +- src/terrainlib/ctb/GlobalMercator.hpp | 5 +- src/terrainlib/ctb/Grid.hpp | 27 +- src/terrainlib/tile_path.h | 14 + src/tile_builder/ParallelTileGenerator.cpp | 4 +- src/tile_builder/ParallelTileGenerator.h | 2 +- src/tile_builder/ParallelTiler.cpp | 17 +- src/tile_builder/ParallelTiler.h | 2 +- src/tile_builder/TileHeightsGenerator.cpp | 9 +- src/tile_builder/TileHeightsGenerator.h | 4 +- src/tile_builder/Tiler.cpp | 9 +- src/tile_builder/Tiler.h | 5 +- src/tile_builder/TopDownTiler.cpp | 7 +- src/tile_builder/TopDownTiler.h | 2 +- src/tile_builder/alpine_raster.cpp | 4 +- src/tile_builder/alpine_raster.h | 1 - src/tile_builder/main.cpp | 10 +- src/tile_downloader/TileDownloader.h | 24 +- src/tile_downloader/TileUrlBuilder.h | 57 +- src/tile_downloader/cli.cpp | 34 +- src/tile_downloader/cli.h | 7 +- src/tile_downloader/main.cpp | 6 +- unittests/CMakeLists.txt | 9 + unittests/sf_builder/texture.cpp | 104 +-- unittests/tile_downloader/url_builder.cpp | 45 ++ .../tilebuilder/alpine_raster_format.cpp | 6 +- .../depth_first_tile_traverser.cpp | 42 +- .../tilebuilder/parallel_tile_generator.cpp | 14 +- unittests/tilebuilder/parallel_tiler.cpp | 78 +- .../tilebuilder/tile_heights_generator.cpp | 8 +- unittests/tilebuilder/top_down_tiler.cpp | 30 +- 39 files changed, 732 insertions(+), 658 deletions(-) create mode 100644 src/terrainlib/tile_path.h create mode 100644 unittests/tile_downloader/url_builder.cpp diff --git a/README.md b/README.md index e4af0bdb..20d2d8d8 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,20 @@ The tools typically only handle one tile per command which makes it infeasible t In this example, we'll show how to build the hierarchy for Vienna's city center (Zoom: 13, X: 4468, Y:2840). ### 1. Downloading tiles -The following command will download the basemap tiles from our mirror with the following format: -https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{zoom}/{Y}/{X}.jpeg +Tile coordinates always use the Google/Mapbox/XYZ convention internally: the origin is north-west, X grows east, and Y grows south. Google Maps, Mapbox, OpenStreetMap, and most XYZ services use the common URL order `{zoom}/{x}/{y}`; this is the downloader default. + +Some providers use `{zoom}/{y}/{x}` instead. Both built-in Austrian providers currently require `--url-coordinate-order yx`; for example, the Gataki mirror uses: + +`https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{zoom}/{y}/{x}.jpeg` Example for the root tile: https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/13/2840/4468.jpeg -The tiles will be downloaded into the folder `./tiles/`. +Select URL coordinate order with `--url-coordinate-order xy|yx`. Select the URL Y direction with `--url-y-direction down|up`; `down` is the common Google/Mapbox convention and `up` is the legacy TMS convention. These options affect only remote URLs. Downloaded files always use the common Google/Mapbox layout `{zoom}/{x}/{y}.jpeg`. + +The following command downloads the mirror's `{zoom}/{y}/{x}` URLs and writes the root tile to `./tiles/13/4468/2840.jpeg`: ``` -./tile-downloader --provider gataki --zoom 13 --row 2840 --col 4468 --max-zoom-level 19 +./tile-downloader --provider gataki --zoom 13 --x 4468 --y 2840 --url-coordinate-order yx --max-zoom-level 19 ``` ### 2. Download heightmap dataset @@ -84,4 +89,4 @@ In order to build, you need to install: - tbb (intel threading building blocks) sudo apt-get install libcgal-dev libopencv-dev libfmt-dev libglm-dev libgdal-dev catch2 libfreeimage-dev libtbb-dev libcurl4-openssl-dev -(libgmp-dev libmpfr-dev libsqlite3-dev) \ No newline at end of file +(libgmp-dev libmpfr-dev libsqlite3-dev) diff --git a/docs/tile-downloader/download.sh b/docs/tile-downloader/download.sh index b375ad75..d5aef40a 100755 --- a/docs/tile-downloader/download.sh +++ b/docs/tile-downloader/download.sh @@ -3,7 +3,7 @@ build_path="/home/madam/Documents/work/tuw/alpinemaps/build-terrain-builder-Desktop_Qt_6_2_3_GCC_64bit-Release/src" while read p; do - read zoom row col <<<${p//[^0-9]/ } - echo -e "nice -10 \$build_path/tile-downloader --provider basemap --zoom ${zoom} --row ${row} --col ${col} --verbosity 0&" -# nice -10 $build_path/tile-downloader --provider basemap --zoom ${zoom} --row ${row} --col ${col} --verbosity 0& + read zoom x y <<<${p//[^0-9]/ } + echo -e "nice -10 \$build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --url-coordinate-order yx --verbosity 0&" +# nice -10 $build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --url-coordinate-order yx --verbosity 0& done &data radix::geometry::Aabb3d parse_bounds_from_tile( const std::vector &data, - radix::tile::Scheme scheme, const OGRSpatialReference &srs) { // Determine the correct Grid type based on SRS @@ -86,7 +85,7 @@ radix::geometry::Aabb3d parse_bounds_from_tile( const unsigned int zoom_level = data[0]; const glm::uvec2 tile_coords(data[1], data[2]); - const radix::tile::Id target_tile(zoom_level, tile_coords, scheme); + const radix::tile::Id target_tile(zoom_level, tile_coords); return extend_bounds_to_3d(grid->srsBounds(target_tile, false)); } @@ -117,7 +116,6 @@ radix::geometry::Aabb3d parse_target_bounds( const std::vector &bounds_data, const std::vector &node_data, const std::vector &tile_data, - radix::tile::Scheme tile_scheme, OGRSpatialReference &srs) { if (!bounds_data.empty()) { @@ -125,7 +123,7 @@ radix::geometry::Aabb3d parse_target_bounds( } if (!tile_data.empty()) { - return parse_bounds_from_tile(tile_data, tile_scheme, srs); + return parse_bounds_from_tile(tile_data, srs); } if (!node_data.empty()) { @@ -221,7 +219,6 @@ int run(std::span args) { std::vector target_tile_data; std::vector target_node_data; std::string target_srs_input; - radix::tile::Scheme target_tile_scheme; auto *target = single->add_option_group("target"); target->add_option("--bounds", target_bounds_data, "Target bounds for the reference mesh as \"{xmin} {width} {ymin} {height} [{zmin} {depth}]\"") @@ -235,15 +232,6 @@ int run(std::span args) { single->add_option("--output", output_path, "Output path were the mesh is written to (.terrain, .gltf or .glb)") ->required(); - std::map scheme_str_map{ - {"slippymap", radix::tile::Scheme::SlippyMap}, - {"google", radix::tile::Scheme::SlippyMap}, - {"tms", radix::tile::Scheme::Tms}}; - single->add_option("--scheme", target_tile_scheme, "Tile scheme") - ->default_val(radix::tile::Scheme::SlippyMap) - ->needs("--tile") - ->transform(CLI::CheckedTransformer(scheme_str_map, CLI::ignore_case)); - single->add_option("--srs", target_srs_input, "EPSG code of the srs of the target bounds or id"); single->callback([&]() { if (target_srs_input.empty()) { @@ -288,14 +276,14 @@ int run(std::span args) { std::unique_ptr tile_provider; if (texture_base_path.has_value()) { - BasemapSchemeTilePathProvider basemap_provider(texture_base_path.value()); + GoogleMapboxTilePathProvider basemap_provider(texture_base_path.value()); if (min_texture_level.has_value() || max_texture_level.has_value()) { - tile_provider = std::make_unique>( + tile_provider = std::make_unique>( std::move(basemap_provider), min_texture_level, max_texture_level); } else { - tile_provider = std::make_unique(std::move(basemap_provider)); + tile_provider = std::make_unique(std::move(basemap_provider)); } } @@ -305,7 +293,6 @@ int run(std::span args) { target_bounds_data, target_node_data, target_tile_data, - target_tile_scheme, target_srs); terrainbuilder::build_and_save_patch( diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 90ee1ac2..d51d02bd 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -148,7 +148,6 @@ namespace terrainbuilder { const radix::tile::Id root_tile, const glm::uvec2 tile_image_pixel_size, const unsigned int max_zoom_level) { - tile = tile.to(radix::tile::Scheme::SlippyMap); const size_t relative_zoom_level = tile.zoom_level - root_tile.zoom_level; const glm::uvec2 tile_size_factor = glm::uvec2(std::pow(2, max_zoom_level - tile.zoom_level)); const glm::uvec2 tile_size = tile_image_pixel_size * tile_size_factor; @@ -379,7 +378,7 @@ inline std::optional try_get_tile_path(const radix::tile: // Start by transforming the input bounds into the srs the tiles are in. const radix::tile::SrsBounds encompassing_bounds = srs::encompassing_bounds_transfer(target_srs, grid.getSRS(), target_bounds); // Then we find the smallest tile (id) that encompasses these bounds. - const radix::tile::Id smallest_encompassing_tile = grid.findSmallestEncompassingTile(encompassing_bounds).value().to(radix::tile::Scheme::SlippyMap); + const radix::tile::Id smallest_encompassing_tile = grid.findSmallestEncompassingTile(encompassing_bounds).value(); LOG_TRACE("Smallest encompassing tile for texture bounds is {}", radix::tile::to_string(smallest_encompassing_tile)); if (max_zoom.has_value() && smallest_encompassing_tile.zoom_level > max_zoom.value()) { diff --git a/src/sf_builder/tile_provider.h b/src/sf_builder/tile_provider.h index da90c0e2..c709536d 100644 --- a/src/sf_builder/tile_provider.h +++ b/src/sf_builder/tile_provider.h @@ -6,6 +6,8 @@ #include #include +#include "tile_path.h" + class TileProvider { public: virtual ~TileProvider() = default; @@ -52,16 +54,13 @@ class StaticTileProvider : public TileProvider { public: std::unordered_map tiles; - StaticTileProvider(const std::unordered_map& tiles) { - // TODO: remove this once the == operator of tile::Id is updated. - for (const auto& tile : tiles) { - const radix::tile::Id tile_id = tile.first.to(radix::tile::Scheme::SlippyMap); - this->tiles[tile_id] = tile.second; - } + StaticTileProvider(const std::unordered_map& tiles) + : tiles(tiles) + { } virtual std::optional get_tile(const radix::tile::Id tile_id) const override { - const auto tile = this->tiles.find(tile_id.to(radix::tile::Scheme::SlippyMap)); + const auto tile = this->tiles.find(tile_id); if (tile != this->tiles.end()) { return tile->second; } else { @@ -70,7 +69,7 @@ class StaticTileProvider : public TileProvider { } virtual bool has_tile(const radix::tile::Id tile_id) const override { - return this->tiles.find(tile_id.to(radix::tile::Scheme::SlippyMap)) != this->tiles.cend(); + return this->tiles.find(tile_id) != this->tiles.cend(); } }; @@ -139,13 +138,13 @@ class ZoomRangeTileProvider final : public TileProvider { uint32_t _max_zoom; }; -class BasemapSchemeTilePathProvider : public TilePathProvider { +class GoogleMapboxTilePathProvider : public TilePathProvider { public: - BasemapSchemeTilePathProvider(std::filesystem::path base_path) + GoogleMapboxTilePathProvider(std::filesystem::path base_path) : base_path(base_path) {} std::optional get_tile_path(const radix::tile::Id tile_id) const override { - return base_path / std::to_string(tile_id.zoom_level) / std::to_string(tile_id.coords.y) / (std::to_string(tile_id.coords.x) + ".jpeg"); + return google_tile_path(base_path, tile_id, ".jpeg"); } private: diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 2e321547..c799f067 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -48,6 +48,7 @@ add_library(terrainlib init.cpp log.cpp ProgressIndicator.cpp + tile_path.h ) target_compile_features(terrainlib PUBLIC cxx_std_23) diff --git a/src/terrainlib/ctb/GlobalGeodetic.hpp b/src/terrainlib/ctb/GlobalGeodetic.hpp index 5ddeecc5..917690b9 100644 --- a/src/terrainlib/ctb/GlobalGeodetic.hpp +++ b/src/terrainlib/ctb/GlobalGeodetic.hpp @@ -28,10 +28,11 @@ namespace ctb { /** - * @brief An implementation of the TMS Global Geodetic Profile + * @brief An implementation of the global Geodetic grid profile * * This class models the [Tile Mapping Service Global Geodetic * Profile](http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-geodetic). + * Its public tile identifiers use Google/Mapbox/XYZ coordinates. */ class GlobalGeodetic : public Grid { public: @@ -42,7 +43,7 @@ class GlobalGeodetic : public Grid { cSRS, 4326, // global geodetic has 2 root tiles: https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-geodetic - std::vector{radix::tile::Id{0, {0, 0}, radix::tile::Scheme::Tms}, radix::tile::Id{0, {1, 0}, radix::tile::Scheme::Tms}}, + std::vector{radix::tile::Id{0, {0, 0}}, radix::tile::Id{0, {1, 0}}}, 2) { } diff --git a/src/terrainlib/ctb/GlobalMercator.hpp b/src/terrainlib/ctb/GlobalMercator.hpp index 2da275a4..9de4af42 100644 --- a/src/terrainlib/ctb/GlobalMercator.hpp +++ b/src/terrainlib/ctb/GlobalMercator.hpp @@ -29,10 +29,11 @@ class GlobalMercator; } /** - * @brief An implementation of the TMS Global Mercator Profile + * @brief An implementation of the global Mercator grid profile * * This class models the [Tile Mapping Service Global Mercator * Profile](http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-mercator). + * Its public tile identifiers use Google/Mapbox/XYZ coordinates. */ class ctb::GlobalMercator : public Grid { public: @@ -41,7 +42,7 @@ class ctb::GlobalMercator : public Grid { radix::tile::SrsBounds{{-cOriginShift, -cOriginShift}, {cOriginShift, cOriginShift}}, cSRS, 3857, - std::vector{radix::tile::Id{0, {0, 0}, radix::tile::Scheme::Tms}}, + std::vector{radix::tile::Id{0, {0, 0}}}, 2) { } diff --git a/src/terrainlib/ctb/Grid.hpp b/src/terrainlib/ctb/Grid.hpp index a4338843..8d81488c 100644 --- a/src/terrainlib/ctb/Grid.hpp +++ b/src/terrainlib/ctb/Grid.hpp @@ -51,15 +51,8 @@ class Grid; * The code here generalises the logic in the `gdal2tiles.py` script available * with the GDAL library. * - * Warning: The y directino is dangerous. Sometimes the positve y axis points north, sometimes not. - * - GlobalMercator has y positive pointing north - * - GlobalGeodetic as well. - * - https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#1/175.75/56.27 - * - google webmercator has tile coordinates where y=0 is the northern most tile. - * - tms webmercator has tile coordinates with y=0 being southern most. - * - * Effectively, ctb::Grid is always positive pointing north. Support for google webmercator / - * slippyMap is done in Tile.h + * Tile identifiers use Google/Mapbox/XYZ coordinates: the origin is north-west and y grows south. + * CRS and pixel coordinates inside Grid retain their conventional positive-north orientation. */ class ctb::Grid { public: @@ -143,23 +136,25 @@ class ctb::Grid { /// Get the tile coordinate in which a location falls at a specific zoom level [[nodiscard]] inline radix::tile::Id crsToTile(const CRSPoint &coord, i_zoom zoom) const { const PixelPoint pixel = crsToPixels(coord, zoom); - TilePoint tile = pixelsToTile(pixel); + const TilePoint tile = pixelsToTile(pixel); + const auto tile_count = i_tile(1u << zoom); - return {zoom, tile, radix::tile::Scheme::Tms}; + return {zoom, {tile.x, tile_count - tile.y - 1}}; } /// Get the CRS bounds of a particular tile /// border_se should be true if a border should be included on the south eastern corner /// e.g., for the cesium raster terrain format (https://github.com/CesiumGS/cesium/wiki/heightmap-1%2E0) [[nodiscard]] inline radix::tile::SrsBounds srsBounds(const radix::tile::Id &tile_id, bool border_se) const { - const auto tms_tile_id = tile_id.to(radix::tile::Scheme::Tms); + const auto tile_count = i_tile(1u << tile_id.zoom_level); + const auto grid_y = tile_count - tile_id.coords.y - 1; // get the pixels coordinates representing the tile bounds - const PixelPoint pxMinLeft(tms_tile_id.coords.x * mGridSize, tms_tile_id.coords.y * mGridSize); - const PixelPoint pxMaxRight((tms_tile_id.coords.x + 1) * mGridSize + border_se, (tms_tile_id.coords.y + 1) * mGridSize + border_se); + const PixelPoint pxMinLeft(tile_id.coords.x * mGridSize, grid_y * mGridSize); + const PixelPoint pxMaxRight((tile_id.coords.x + 1) * mGridSize + border_se, (grid_y + 1) * mGridSize + border_se); // convert pixels to native coordinates - const CRSPoint minLeft = pixelsToCrs(pxMinLeft, tms_tile_id.zoom_level); - const CRSPoint maxRight = pixelsToCrs(pxMaxRight, tms_tile_id.zoom_level); + const CRSPoint minLeft = pixelsToCrs(pxMinLeft, tile_id.zoom_level); + const CRSPoint maxRight = pixelsToCrs(pxMaxRight, tile_id.zoom_level); return { minLeft, maxRight }; } diff --git a/src/terrainlib/tile_path.h b/src/terrainlib/tile_path.h new file mode 100644 index 00000000..8f866d80 --- /dev/null +++ b/src/terrainlib/tile_path.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +#include + +[[nodiscard]] inline std::filesystem::path google_tile_path( + const std::filesystem::path& base_path, + const radix::tile::Id& tile_id, + const std::string& extension) +{ + return base_path / std::to_string(tile_id.zoom_level) / std::to_string(tile_id.coords.x) / (std::to_string(tile_id.coords.y) + extension); +} diff --git a/src/tile_builder/ParallelTileGenerator.cpp b/src/tile_builder/ParallelTileGenerator.cpp index 70924640..6a52a497 100644 --- a/src/tile_builder/ParallelTileGenerator.cpp +++ b/src/tile_builder/ParallelTileGenerator.cpp @@ -41,7 +41,7 @@ ParallelTileGenerator::ParallelTileGenerator(const std::string& input_data_path, } ParallelTileGenerator ParallelTileGenerator::make(const std::string& input_data_path, - ctb::Grid::Srs srs, radix::tile::Scheme tiling_scheme, + ctb::Grid::Srs srs, std::unique_ptr tile_writer, const std::string& output_data_path, unsigned grid_resolution) @@ -51,7 +51,7 @@ ParallelTileGenerator ParallelTileGenerator::make(const std::string& input_data_ if (srs == ctb::Grid::Srs::SphericalMercator) grid = ctb::GlobalMercator(grid_resolution); const auto border = tile_writer->formatRequiresBorder(); - return { input_data_path, grid, ParallelTiler(grid, dataset->bounds(grid.getSRS()), border, tiling_scheme), std::move(tile_writer), output_data_path }; + return { input_data_path, grid, ParallelTiler(grid, dataset->bounds(grid.getSRS()), border), std::move(tile_writer), output_data_path }; } const ParallelTiler& ParallelTileGenerator::tiler() const diff --git a/src/tile_builder/ParallelTileGenerator.h b/src/tile_builder/ParallelTileGenerator.h index f9fa61e0..cd6f6240 100644 --- a/src/tile_builder/ParallelTileGenerator.h +++ b/src/tile_builder/ParallelTileGenerator.h @@ -41,7 +41,7 @@ class ParallelTileGenerator { public: ParallelTileGenerator(const std::string& input_data_path, const ctb::Grid& grid, const ParallelTiler& tiler, std::unique_ptr tile_writer, const std::string& output_data_path); [[nodiscard]] static ParallelTileGenerator make(const std::string& input_data_path, - ctb::Grid::Srs srs, radix::tile::Scheme tiling_scheme, + ctb::Grid::Srs srs, std::unique_ptr tile_writer, const std::string& output_data_path, unsigned grid_resolution = 256); diff --git a/src/tile_builder/ParallelTiler.cpp b/src/tile_builder/ParallelTiler.cpp index 3f1c2958..fe6b2330 100644 --- a/src/tile_builder/ParallelTiler.cpp +++ b/src/tile_builder/ParallelTiler.cpp @@ -22,32 +22,31 @@ #include "Exception.h" #include -ParallelTiler::ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme) : Tiler(grid, bounds, border, scheme) +ParallelTiler::ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border) : Tiler(grid, bounds, border) { } radix::tile::Id ParallelTiler::southWestTile(unsigned zoom_level) const { - return grid().crsToTile(bounds().min, zoom_level).to(scheme()); + return grid().crsToTile(bounds().min, zoom_level); } radix::tile::Id ParallelTiler::northEastTile(unsigned zoom_level) const { const auto epsilon = grid().resolution(zoom_level) / 100; - return grid().crsToTile(bounds().max - epsilon, zoom_level).to(scheme()); + return grid().crsToTile(bounds().max - epsilon, zoom_level); } std::vector ParallelTiler::generateTiles(unsigned zoom_level) const { - // in the tms scheme south west corresponds to the smaller numbers. hence we can iterate from sw to ne - const auto sw = southWestTile(zoom_level).to(radix::tile::Scheme::Tms).coords; - const auto ne = northEastTile(zoom_level).to(radix::tile::Scheme::Tms).coords; + const auto sw = southWestTile(zoom_level).coords; + const auto ne = northEastTile(zoom_level).coords; std::vector tiles; - tiles.reserve((ne.y - sw.y + 1) * (ne.x - sw.x + 1)); - for (auto ty = sw.y; ty <= ne.y; ++ty) { + tiles.reserve((sw.y - ne.y + 1) * (ne.x - sw.x + 1)); + for (auto ty = ne.y; ty <= sw.y; ++ty) { for (auto tx = sw.x; tx <= ne.x; ++tx) { - const auto tile_id = radix::tile::Id { zoom_level, { tx, ty }, radix::tile::Scheme::Tms }.to(scheme()); + const auto tile_id = radix::tile::Id { zoom_level, { tx, ty } }; tiles.emplace_back(tile_for(tile_id)); if (tiles.size() >= 1'000'000'000) // think about creating an on the fly tile generator. storing so many tiles takes a lot of memory. diff --git a/src/tile_builder/ParallelTiler.h b/src/tile_builder/ParallelTiler.h index 7d01f0de..62cd07ac 100644 --- a/src/tile_builder/ParallelTiler.h +++ b/src/tile_builder/ParallelTiler.h @@ -23,7 +23,7 @@ class ParallelTiler : public Tiler { public: - ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme); + ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); [[nodiscard]] std::vector generateTiles(unsigned zoom_level) const; [[nodiscard]] std::vector generateTiles(const std::pair& zoom_range) const; diff --git a/src/tile_builder/TileHeightsGenerator.cpp b/src/tile_builder/TileHeightsGenerator.cpp index 6c816dde..0e535fab 100644 --- a/src/tile_builder/TileHeightsGenerator.cpp +++ b/src/tile_builder/TileHeightsGenerator.cpp @@ -28,10 +28,9 @@ #include "depth_first_tile_traverser.h" #include -TileHeightsGenerator::TileHeightsGenerator(std::string input_data_path, ctb::Grid::Srs srs, radix::tile::Scheme scheme, radix::tile::Border border, std::filesystem::path output_path) +TileHeightsGenerator::TileHeightsGenerator(std::string input_data_path, ctb::Grid::Srs srs, radix::tile::Border border, std::filesystem::path output_path) : m_input_data_path(std::move(input_data_path)) , m_srs(srs) - , m_scheme(scheme) , m_border(border) , m_output_path(std::move(output_path)) { @@ -51,7 +50,7 @@ void TileHeightsGenerator::run(unsigned max_zoom_level) const grid = ctb::GlobalMercator(64); const auto bounds = dataset->bounds(grid.getSRS()); const auto tile_reader = DatasetReader(dataset, grid.getSRS(), 1, false); - const auto tiler = TopDownTiler(grid, bounds, m_border, m_scheme); + const auto tiler = TopDownTiler(grid, bounds, m_border); auto tile_heights = radix::TileHeights(); const auto read_function = [&](const radix::tile::Descriptor& tile) -> MinMaxData { @@ -74,10 +73,10 @@ void TileHeightsGenerator::run(unsigned max_zoom_level) const }; - traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 0, 0 }, m_scheme }, max_zoom_level); + traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 0, 0 } }, max_zoom_level); if (m_srs == ctb::Grid::Srs::WGS84) { // two root tiles - traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 1, 0 }, m_scheme }, max_zoom_level); + traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 1, 0 } }, max_zoom_level); } tile_heights.write_to(m_output_path); diff --git a/src/tile_builder/TileHeightsGenerator.h b/src/tile_builder/TileHeightsGenerator.h index 0d64af56..703a1227 100644 --- a/src/tile_builder/TileHeightsGenerator.h +++ b/src/tile_builder/TileHeightsGenerator.h @@ -29,11 +29,9 @@ class TileHeightsGenerator { std::string m_input_data_path; ctb::Grid::Srs m_srs; - radix::tile::Scheme m_scheme; radix::tile::Border m_border; std::filesystem::path m_output_path; public: - TileHeightsGenerator(std::string input_data_path, ctb::Grid::Srs srs, radix::tile::Scheme scheme, radix::tile::Border border, std::filesystem::path output_path); + TileHeightsGenerator(std::string input_data_path, ctb::Grid::Srs srs, radix::tile::Border border, std::filesystem::path output_path); void run(unsigned max_zoom_level) const; }; - diff --git a/src/tile_builder/Tiler.cpp b/src/tile_builder/Tiler.cpp index 00153c6c..fafa3ba7 100644 --- a/src/tile_builder/Tiler.cpp +++ b/src/tile_builder/Tiler.cpp @@ -20,11 +20,10 @@ #include -Tiler::Tiler(ctb::Grid grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme) +Tiler::Tiler(ctb::Grid grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border) : m_grid(std::move(grid)) , m_bounds(bounds) , m_border_south_east(border) - , m_scheme(scheme) { } @@ -55,11 +54,6 @@ radix::tile::Descriptor Tiler::tile_for(const radix::tile::Id& tile_id) const return {tile_id, srs_bounds, grid().getEpsgCode(), grid_size(), tile_size()}; } -radix::tile::Scheme Tiler::scheme() const -{ - return m_scheme; -} - const radix::tile::SrsBounds& Tiler::bounds() const { return m_bounds; @@ -69,4 +63,3 @@ void Tiler::setBounds(const radix::tile::SrsBounds& newBounds) { m_bounds = newBounds; } - diff --git a/src/tile_builder/Tiler.h b/src/tile_builder/Tiler.h index d477ce3b..70b5049e 100644 --- a/src/tile_builder/Tiler.h +++ b/src/tile_builder/Tiler.h @@ -25,9 +25,8 @@ class Tiler { public: - Tiler(ctb::Grid grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme); + Tiler(ctb::Grid grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); - [[nodiscard]] radix::tile::Scheme scheme() const; [[nodiscard]] const radix::tile::SrsBounds& bounds() const; void setBounds(const radix::tile::SrsBounds& newBounds); [[nodiscard]] radix::tile::Descriptor tile_for(const radix::tile::Id& tile_id) const; @@ -43,6 +42,4 @@ class Tiler const ctb::Grid m_grid; radix::tile::SrsBounds m_bounds; const radix::tile::Border m_border_south_east; - const radix::tile::Scheme m_scheme; }; - diff --git a/src/tile_builder/TopDownTiler.cpp b/src/tile_builder/TopDownTiler.cpp index 9962962e..ca5ead02 100644 --- a/src/tile_builder/TopDownTiler.cpp +++ b/src/tile_builder/TopDownTiler.cpp @@ -18,15 +18,14 @@ #include "TopDownTiler.h" -TopDownTiler::TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme) - : Tiler(grid, bounds, border, scheme) +TopDownTiler::TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border) + : Tiler(grid, bounds, border) { } std::vector TopDownTiler::generateTiles(const radix::tile::Id& parent_id) const { - assert(parent_id.scheme == scheme()); - const auto tile_ids = parent_id.to(scheme()).children(); + const auto tile_ids = parent_id.children(); std::vector tiles; for (const auto& tile_id : tile_ids) { radix::tile::Descriptor t = tile_for(tile_id); diff --git a/src/tile_builder/TopDownTiler.h b/src/tile_builder/TopDownTiler.h index a2e065d1..f078ca89 100644 --- a/src/tile_builder/TopDownTiler.h +++ b/src/tile_builder/TopDownTiler.h @@ -24,7 +24,7 @@ class TopDownTiler : public Tiler { public: - TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border, radix::tile::Scheme scheme); + TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); [[nodiscard]] std::vector generateTiles(const radix::tile::Id& parent_id) const; }; diff --git a/src/tile_builder/alpine_raster.cpp b/src/tile_builder/alpine_raster.cpp index be4e0e1d..c5d8a6b0 100644 --- a/src/tile_builder/alpine_raster.cpp +++ b/src/tile_builder/alpine_raster.cpp @@ -31,9 +31,9 @@ #include #include -ParallelTileGenerator alpine_raster::make_generator(const std::string& input_data_path, const std::string& output_data_path, ctb::Grid::Srs srs, radix::tile::Scheme tiling_scheme, radix::tile::Border border, unsigned grid_resolution) +ParallelTileGenerator alpine_raster::make_generator(const std::string& input_data_path, const std::string& output_data_path, ctb::Grid::Srs srs, radix::tile::Border border, unsigned grid_resolution) { - return ParallelTileGenerator::make(input_data_path, srs, tiling_scheme, std::make_unique(border), output_data_path, grid_resolution); + return ParallelTileGenerator::make(input_data_path, srs, std::make_unique(border), output_data_path, grid_resolution); } void alpine_raster::TileWriter::write(const std::string& file_path, const radix::tile::Descriptor&, const radix::Raster& heights) const diff --git a/src/tile_builder/alpine_raster.h b/src/tile_builder/alpine_raster.h index 22eee044..d82f05a6 100644 --- a/src/tile_builder/alpine_raster.h +++ b/src/tile_builder/alpine_raster.h @@ -44,7 +44,6 @@ class TileWriter : public ParallelTileWriterInterface { const std::string& input_data_path, const std::string& output_data_path, ctb::Grid::Srs srs, - radix::tile::Scheme tiling_scheme, radix::tile::Border border, unsigned grid_resolution = 256); }; diff --git a/src/tile_builder/main.cpp b/src/tile_builder/main.cpp index c2f0fe05..06ec1c93 100644 --- a/src/tile_builder/main.cpp +++ b/src/tile_builder/main.cpp @@ -12,28 +12,26 @@ int main() { // const std::string input_raster = "/home/madam/rajaton/raw/Oe_2020/OeRect_01m_gs_31287.img"; // const std::string output_path = "/home/madam/rajaton/tiles/atb_terrain/"; - //// const auto generator = alpine_raster::make_generator("./test_tiles/", "/home/madam/valtava/raw/Oe_2020/OeRect_01m_gs_31287.img", ctb::Grid::Srs::SphericalMercator, Tiler::Scheme::SlippyMap, Tiler::Border::No); + //// const auto generator = alpine_raster::make_generator("./test_tiles/", "/home/madam/valtava/raw/Oe_2020/OeRect_01m_gs_31287.img", ctb::Grid::Srs::SphericalMercator, radix::tile::Border::No); //// generator.process({16, 16}); - // const auto generator = cesium_tin_terra::make_generator(input_raster, output_path, ctb::Grid::Srs::WGS84, Tiler::Scheme::Tms, Tiler::Border::Yes); // generator.process({0, 5}, true, true); // generator.process({6, 16}, true, false); const std::string input_raster = "/home/madam/valtava/raw/Oe_2020/OeRect_01m_gs_31287.img"; // const std::string input_raster = "/home/madam/valtava/raw/vienna/innenstadt_gs_1m_mgi.tif"; const std::string output_path = "/home/madam/valtava/tiles/alpine_png2"; - // const auto generator = alpine_raster::make_generator("./test_tiles/", "/home/madam/valtava/raw/Oe_2020/OeRect_01m_gs_31287.img", ctb::Grid::Srs::SphericalMercator, Tiler::Scheme::SlippyMap, Tiler::Border::No); + // const auto generator = alpine_raster::make_generator("./test_tiles/", "/home/madam/valtava/raw/Oe_2020/OeRect_01m_gs_31287.img", ctb::Grid::Srs::SphericalMercator, radix::tile::Border::No); // generator.process({16, 16}); - const auto generator = alpine_raster::make_generator(input_raster, output_path, ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, radix::tile::Border::Yes, 64); + const auto generator = alpine_raster::make_generator(input_raster, output_path, ctb::Grid::Srs::SphericalMercator, radix::tile::Border::Yes, 64); // generator.process({0, 5}, true, true); generator.process({ 15, 16 }, true, false); - // const auto metadata = MetaDataGenerator::make(input_raster, ctb::Grid::Srs::WGS84, Tiler::Scheme::Tms); // const auto json = layer_json_writer::process(metadata); //// generate height data (min and max) for tiles up to level 13 // const auto base_path = std::filesystem::path(output_path); // constexpr auto file_name = "height_data.atb"; -// const auto generator = TileHeightsGenerator(input_raster, ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, radix::tile::Border::Yes, base_path / file_name); +// const auto generator = TileHeightsGenerator(input_raster, ctb::Grid::Srs::SphericalMercator, radix::tile::Border::Yes, base_path / file_name); // generator.run(13); return 0; diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index ceb5103b..94e24af9 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -12,13 +12,14 @@ #include "HttpClient.h" #include "TileLogger.h" #include "TileUrlBuilder.h" +#include "tile_path.h" class TileDownloader { public: - TileDownloader(const TileUrlBuilder &url_builder, std::string output_pattern, + TileDownloader(const TileUrlBuilder &url_builder, std::filesystem::path output_directory, bool early_skip, std::optional max_zoom_level) : _url_builder(url_builder), - _output_pattern(std::move(output_pattern)), + _output_directory(std::move(output_directory)), _early_skip(early_skip), _max_zoom_level(max_zoom_level) {} @@ -51,7 +52,7 @@ class TileDownloader { private: const TileUrlBuilder &_url_builder; - std::string _output_pattern; + std::filesystem::path _output_directory; HttpClient _http; TileLogger _logger; bool _early_skip; @@ -69,21 +70,8 @@ class TileDownloader { return std::filesystem::exists(this->tile_path(tile)); } - std::string tile_path(const radix::tile::Id &tile) const { - std::string path = this->_output_pattern; - replace_all(path, "{zoom}", std::to_string(tile.zoom_level)); - replace_all(path, "{x}", std::to_string(tile.coords.x)); - replace_all(path, "{y}", std::to_string(tile.coords.y)); - replace_all(path, "{ext}", "jpeg"); - return path; - } - - static void replace_all(std::string &s, std::string_view find, std::string_view replace) { - size_t pos = 0; - while ((pos = s.find(find, pos)) != std::string::npos) { - s.replace(pos, find.length(), replace); - pos += replace.length(); - } + std::filesystem::path tile_path(const radix::tile::Id &tile) const { + return google_tile_path(_output_directory, tile, ".jpeg"); } static void ensure_parent_dirs(const std::filesystem::path &path) { diff --git a/src/tile_downloader/TileUrlBuilder.h b/src/tile_downloader/TileUrlBuilder.h index 328cf110..2e0b22d3 100644 --- a/src/tile_downloader/TileUrlBuilder.h +++ b/src/tile_downloader/TileUrlBuilder.h @@ -1,10 +1,42 @@ #pragma once +#include +#include #include +#include #include #include +enum class TileCoordinateOrder { + Xy, + Yx +}; + +enum class TileYDirection { + Down, + Up +}; + +struct TileUrlFormat { + TileCoordinateOrder coordinate_order = TileCoordinateOrder::Xy; + TileYDirection y_direction = TileYDirection::Down; + + [[nodiscard]] std::pair coordinates(const radix::tile::Id& tile_id) const + { + auto y = tile_id.coords.y; + if (y_direction == TileYDirection::Up) { + if (tile_id.zoom_level >= std::numeric_limits::digits) + throw std::invalid_argument("tile zoom level is too large for legacy TMS coordinates"); + y = (1u << tile_id.zoom_level) - y - 1; + } + + if (coordinate_order == TileCoordinateOrder::Xy) + return { tile_id.coords.x, y }; + return { y, tile_id.coords.x }; + } +}; + class TileUrlBuilder { public: virtual ~TileUrlBuilder() = default; @@ -13,27 +45,40 @@ class TileUrlBuilder { class BasemapTileUrlBuilder : public TileUrlBuilder { public: - BasemapTileUrlBuilder(std::string layer, std::string style) - : _layer(std::move(layer)), _style(std::move(style)) {} + BasemapTileUrlBuilder(std::string layer, std::string style, TileUrlFormat format = {}) + : _layer(std::move(layer)) + , _style(std::move(style)) + , _format(format) + { + } std::string build_url(const radix::tile::Id &tile_id) const override { - const auto t = tile_id.to(radix::tile::Scheme::SlippyMap); + const auto [first_coordinate, second_coordinate] = _format.coordinates(tile_id); return fmt::format( "https://mapsneu.wien.gv.at/basemap/{}/{}/google3857/{}/{}/{}.jpeg", - this->_layer, this->_style, t.zoom_level, t.coords.y, t.coords.x); + _layer, _style, tile_id.zoom_level, first_coordinate, second_coordinate); } private: std::string _layer; std::string _style; + TileUrlFormat _format; }; class GatakiTileUrlBuilder : public TileUrlBuilder { public: + explicit GatakiTileUrlBuilder(TileUrlFormat format = {}) + : _format(format) + { + } + std::string build_url(const radix::tile::Id &tile_id) const override { - const auto t = tile_id.to(radix::tile::Scheme::SlippyMap); + const auto [first_coordinate, second_coordinate] = _format.coordinates(tile_id); return fmt::format( "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{}/{}/{}.jpeg", - t.zoom_level, t.coords.y, t.coords.x); + tile_id.zoom_level, first_coordinate, second_coordinate); } + +private: + TileUrlFormat _format; }; diff --git a/src/tile_downloader/cli.cpp b/src/tile_downloader/cli.cpp index b83d4c36..4c5acb95 100644 --- a/src/tile_downloader/cli.cpp +++ b/src/tile_downloader/cli.cpp @@ -18,24 +18,30 @@ Args parse(int argc, const char *const *argv) { ->check(CLI::IsMember({"basemap", "gataki"}, CLI::ignore_case)); app.add_option("--zoom", args.zoom, "Root tile zoom level")->required(); - app.add_option("--x,--row", args.x, "Root tile x coordinate")->required(); - app.add_option("--y,--col", args.y, "Root tile y coordinate")->required(); - - const std::map scheme_map{ - {"slippymap", radix::tile::Scheme::SlippyMap}, - {"google", radix::tile::Scheme::SlippyMap}, - {"xyz", radix::tile::Scheme::SlippyMap}, - {"tms", radix::tile::Scheme::Tms}}; - args.scheme = radix::tile::Scheme::SlippyMap; - app.add_option("--scheme", args.scheme, "Tile scheme") - ->default_val(radix::tile::Scheme::SlippyMap) - ->transform(CLI::CheckedTransformer(scheme_map, CLI::ignore_case)); + app.add_option("--x,--col", args.x, "Root tile x/column in Google/Mapbox coordinates")->required(); + app.add_option("--y,--row", args.y, "Root tile y/row in Google/Mapbox coordinates")->required(); + + const std::map coordinate_order_map{ + {"xy", TileCoordinateOrder::Xy}, + {"yx", TileCoordinateOrder::Yx}}; + args.url_coordinate_order = TileCoordinateOrder::Xy; + app.add_option("--url-coordinate-order", args.url_coordinate_order, "URL coordinate order: xy (common Google/Mapbox format) or yx") + ->default_str("xy") + ->transform(CLI::CheckedTransformer(coordinate_order_map, CLI::ignore_case)); + + const std::map y_direction_map{ + {"down", TileYDirection::Down}, + {"up", TileYDirection::Up}}; + args.url_y_direction = TileYDirection::Down; + app.add_option("--url-y-direction", args.url_y_direction, "URL y direction: down (common Google/Mapbox format) or up (legacy TMS)") + ->default_str("down") + ->transform(CLI::CheckedTransformer(y_direction_map, CLI::ignore_case)); args.srs = 3857; app.add_option("--srs", args.srs, "Spatial reference system EPSG code")->default_val(3857); - args.output = "tiles/{zoom}/{y}/{x}.{ext}"; - app.add_option("--output", args.output, "Output path template")->default_val(args.output); + args.output = "tiles"; + app.add_option("--output", args.output, "Output directory; files use Google/Mapbox zoom/x/y.jpeg layout")->default_val(args.output.string()); const std::map log_level_names{ {"off", spdlog::level::off}, diff --git a/src/tile_downloader/cli.h b/src/tile_downloader/cli.h index 03edd30f..bc667f98 100644 --- a/src/tile_downloader/cli.h +++ b/src/tile_downloader/cli.h @@ -7,6 +7,8 @@ #include #include +#include "TileUrlBuilder.h" + namespace cli { struct Args { @@ -14,9 +16,10 @@ struct Args { unsigned int zoom; unsigned int x; unsigned int y; - radix::tile::Scheme scheme; + TileCoordinateOrder url_coordinate_order; + TileYDirection url_y_direction; unsigned int srs; - std::string output; + std::filesystem::path output; spdlog::level::level_enum log_level; bool early_skip; std::optional max_zoom_level; diff --git a/src/tile_downloader/main.cpp b/src/tile_downloader/main.cpp index 79102590..b2973a29 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -20,12 +20,12 @@ int main(int argc, char *argv[]) { std::transform(provider.begin(), provider.end(), provider.begin(), [](unsigned char c) { return std::tolower(c); }); if (provider == "basemap") { - url_builder = std::make_unique(args.layer, args.style); + url_builder = std::make_unique(args.layer, args.style, TileUrlFormat { args.url_coordinate_order, args.url_y_direction }); } else { - url_builder = std::make_unique(); + url_builder = std::make_unique(TileUrlFormat { args.url_coordinate_order, args.url_y_direction }); } - const radix::tile::Id root_id = {args.zoom, {args.x, args.y}, args.scheme}; + const radix::tile::Id root_id = {args.zoom, {args.x, args.y}}; TileDownloader downloader(*url_builder, args.output, args.early_skip, args.max_zoom_level); downloader.download_recursive(root_id); diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 647863fe..909360fb 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -86,6 +86,15 @@ if(TARGET sfbuilderlib) atb_configure_test(unittests_sfbuilder) endif() +if(TARGET tile-downloader) + add_executable(unittests_tile_downloader + tile_downloader/url_builder.cpp + ) + target_include_directories(unittests_tile_downloader PRIVATE ${CMAKE_SOURCE_DIR}/src/tile_downloader) + target_link_libraries(unittests_tile_downloader PRIVATE terrainlib Catch2::Catch2WithMain) + atb_configure_test(unittests_tile_downloader) +endif() + if(TARGET dagbuilderlib) add_executable(unittests_dagbuilder catch2_helpers.h diff --git a/unittests/sf_builder/texture.cpp b/unittests/sf_builder/texture.cpp index 4f33e016..b32188d4 100644 --- a/unittests/sf_builder/texture.cpp +++ b/unittests/sf_builder/texture.cpp @@ -34,7 +34,7 @@ TEST_CASE("estimate_zoom_level", "[terrainbuilder]") { const ctb::Grid grid = ctb::GlobalMercator(); - const radix::tile::Id tile(20, glm::uvec2(0, 1), radix::tile::Scheme::SlippyMap); + const radix::tile::Id tile(20, glm::uvec2(0, 1)); const radix::tile::SrsBounds tile_bounds = grid.srsBounds(tile, false); const radix::tile::SrsBounds shifted_bounds(tile_bounds.min + glm::dvec2(-100, 420), tile_bounds.max + glm::dvec2(-100, 420)); @@ -67,11 +67,11 @@ class AlwaysEmptyTileProvider : public TileProvider { }; TEST_CASE("texture assembler takes root tile if only available ", "[terrainbuilder]") { - const radix::tile::Id root_tile(3, glm::uvec2(5, 4), radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(3, glm::uvec2(5, 4)); const std::set available_tiles = { - {3, {5, 4}, radix::tile::Scheme::SlippyMap}}; + {3, {5, 4}}}; const std::set expected_tiles = { - {3, {5, 4}, radix::tile::Scheme::SlippyMap}}; + {3, {5, 4}}}; const AvailabilityListEmptyTileProvider tile_provider(available_tiles); @@ -88,18 +88,18 @@ TEST_CASE("texture assembler takes root tile if only available ", "[terrainbuild } TEST_CASE("texture assembler ignores parent if all children are present", "[terrainbuilder]") { - const radix::tile::Id root_tile(3, glm::uvec2(5, 4), radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(3, glm::uvec2(5, 4)); const std::set available_tiles = { - {3, {5, 4}, radix::tile::Scheme::SlippyMap}, - {4, {10, 8}, radix::tile::Scheme::SlippyMap}, - {4, {11, 8}, radix::tile::Scheme::SlippyMap}, - {4, {10, 9}, radix::tile::Scheme::SlippyMap}, - {4, {11, 9}, radix::tile::Scheme::SlippyMap}}; + {3, {5, 4}}, + {4, {10, 8}}, + {4, {11, 8}}, + {4, {10, 9}}, + {4, {11, 9}}}; const std::set expected_tiles = { - {4, {10, 8}, radix::tile::Scheme::SlippyMap}, - {4, {11, 8}, radix::tile::Scheme::SlippyMap}, - {4, {10, 9}, radix::tile::Scheme::SlippyMap}, - {4, {11, 9}, radix::tile::Scheme::SlippyMap}}; + {4, {10, 8}}, + {4, {11, 8}}, + {4, {10, 9}}, + {4, {11, 9}}}; const AvailabilityListEmptyTileProvider tile_provider(available_tiles); @@ -116,15 +116,15 @@ TEST_CASE("texture assembler ignores parent if all children are present", "[terr } TEST_CASE("texture assembler considers max zoom level", "[terrainbuilder]") { - const radix::tile::Id root_tile(3, glm::uvec2(5, 4), radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(3, glm::uvec2(5, 4)); const std::set available_tiles = { - {3, {5, 4}, radix::tile::Scheme::SlippyMap}, - {4, {10, 8}, radix::tile::Scheme::SlippyMap}, - {4, {11, 8}, radix::tile::Scheme::SlippyMap}, - {4, {10, 9}, radix::tile::Scheme::SlippyMap}, - {4, {11, 9}, radix::tile::Scheme::SlippyMap}}; + {3, {5, 4}}, + {4, {10, 8}}, + {4, {11, 8}}, + {4, {10, 9}}, + {4, {11, 9}}}; const std::set expected_tiles = { - {3, {5, 4}, radix::tile::Scheme::SlippyMap}}; + {3, {5, 4}}}; const AvailabilityListEmptyTileProvider tile_provider(available_tiles); @@ -143,29 +143,29 @@ TEST_CASE("texture assembler considers max zoom level", "[terrainbuilder]") { TEST_CASE("texture assembler works for arbitrary bounds", "[terrainbuilder]") { const std::set available_tiles = { - // {21, {1048576, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048577, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048578, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048579, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048580, 1048576}, radix::tile::Scheme::Tms}, - // {21, {1048581, 1048576}, radix::tile::Scheme::Tms}, - {20, {524288, 524288}, radix::tile::Scheme::Tms}, - {20, {524289, 524288}, radix::tile::Scheme::Tms}, - {20, {524290, 524288}, radix::tile::Scheme::Tms}, - // {19, {262144, 262144}, radix::tile::Scheme::Tms}, - {19, {262145, 262144}, radix::tile::Scheme::Tms}}; + // {21, {1048576, 1048575}}, + {21, {1048577, 1048575}}, + {21, {1048578, 1048575}}, + {21, {1048579, 1048575}}, + {21, {1048580, 1048575}}, + // {21, {1048581, 1048575}}, + {20, {524288, 524287}}, + {20, {524289, 524287}}, + {20, {524290, 524287}}, + // {19, {262144, 262143}}, + {19, {262145, 262143}}}; const std::set expected_tiles = { - // {21, {1048576, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048577, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048578, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048579, 1048576}, radix::tile::Scheme::Tms}, - {21, {1048580, 1048576}, radix::tile::Scheme::Tms}, - // {21, {1048581, 1048576}, radix::tile::Scheme::Tms}, - {20, {524288, 524288}, radix::tile::Scheme::Tms}, - // {20, {524289, 524288}, radix::tile::Scheme::Tms}, - {20, {524290, 524288}, radix::tile::Scheme::Tms}, - // {19, {262144, 262144}, radix::tile::Scheme::Tms}, - // {19, {262145, 262144}, radix::tile::Scheme::Tms} + // {21, {1048576, 1048575}}, + {21, {1048577, 1048575}}, + {21, {1048578, 1048575}}, + {21, {1048579, 1048575}}, + {21, {1048580, 1048575}}, + // {21, {1048581, 1048575}}, + {20, {524288, 524287}}, + // {20, {524289, 524287}}, + {20, {524290, 524287}}, + // {19, {262144, 262143}}, + // {19, {262145, 262143}} }; const AvailabilityListEmptyTileProvider tile_provider(available_tiles); @@ -208,7 +208,7 @@ TEST_CASE("texture assembler does not fail if there are not tiles", "[terrainbui TEST_CASE("texture assembler assembles single tile", "[terrainbuilder]") { const std::unordered_map tiles_to_texture = { - {radix::tile::Id(0, {0, 0}, radix::tile::Scheme::SlippyMap), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 0, 255))}, + {radix::tile::Id(0, {0, 0}), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 0, 255))}, }; std::vector tiles_to_splatter; std::transform(tiles_to_texture.begin(), tiles_to_texture.end(), std::back_inserter(tiles_to_splatter), @@ -217,7 +217,7 @@ TEST_CASE("texture assembler assembles single tile", "[terrainbuilder]") { const StaticTileProvider tile_provider(tiles_to_texture); const ctb::Grid grid = ctb::GlobalMercator(); - const radix::tile::Id root_tile(0, {0, 0}, radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, grid, @@ -234,8 +234,8 @@ TEST_CASE("texture assembler assembles single tile", "[terrainbuilder]") { TEST_CASE("texture assembler assembles two tiles", "[terrainbuilder]") { const std::unordered_map tiles_to_texture = { - {radix::tile::Id(1, {0, 0}, radix::tile::Scheme::SlippyMap), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 0, 255))}, - {radix::tile::Id(1, {0, 1}, radix::tile::Scheme::SlippyMap), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 255, 0))}, + {radix::tile::Id(1, {0, 0}), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 0, 255))}, + {radix::tile::Id(1, {0, 1}), cv::Mat(1, 1, CV_8UC3, cv::Vec3b(0, 255, 0))}, }; std::vector tiles_to_splatter; std::transform(tiles_to_texture.begin(), tiles_to_texture.end(), std::back_inserter(tiles_to_splatter), @@ -244,7 +244,7 @@ TEST_CASE("texture assembler assembles two tiles", "[terrainbuilder]") { const StaticTileProvider tile_provider(tiles_to_texture); const ctb::Grid grid = ctb::GlobalMercator(); - const radix::tile::Id root_tile(0, {0, 0}, radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, grid, @@ -262,9 +262,9 @@ TEST_CASE("texture assembler assembles two tiles", "[terrainbuilder]") { TEST_CASE("texture assembler correct order of texture writes", "[terrainbuilder]") { const std::unordered_map tiles_to_texture = { - {radix::tile::Id(0, {0, 0}, radix::tile::Scheme::SlippyMap), cv::Mat(1, 1, CV_8UC1, uint8_t(1))}, - {radix::tile::Id(1, {0, 1}, radix::tile::Scheme::Tms), cv::Mat(1, 1, CV_8UC1, uint8_t(2))}, - {radix::tile::Id(1, {0, 1}, radix::tile::Scheme::SlippyMap), cv::Mat(1, 1, CV_8UC1, uint8_t(3))}, + {radix::tile::Id(0, {0, 0}), cv::Mat(1, 1, CV_8UC1, uint8_t(1))}, + {radix::tile::Id(1, {0, 0}), cv::Mat(1, 1, CV_8UC1, uint8_t(2))}, + {radix::tile::Id(1, {0, 1}), cv::Mat(1, 1, CV_8UC1, uint8_t(3))}, }; std::vector tiles_to_splatter; std::transform(tiles_to_texture.begin(), tiles_to_texture.end(), std::back_inserter(tiles_to_splatter), @@ -275,7 +275,7 @@ TEST_CASE("texture assembler correct order of texture writes", "[terrainbuilder] const StaticTileProvider tile_provider(tiles_to_texture); const ctb::Grid grid = ctb::GlobalMercator(); - const radix::tile::Id root_tile(0, {0, 0}, radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, grid, diff --git a/unittests/tile_downloader/url_builder.cpp b/unittests/tile_downloader/url_builder.cpp new file mode 100644 index 00000000..dd4ade75 --- /dev/null +++ b/unittests/tile_downloader/url_builder.cpp @@ -0,0 +1,45 @@ +#include + +#include "TileUrlBuilder.h" +#include "tile_path.h" + +namespace { +constexpr radix::tile::Id tile { 3, { 1, 2 } }; + +void check_urls(const TileUrlFormat format, const std::string& coordinate_path) +{ + const BasemapTileUrlBuilder basemap("layer", "style", format); + CHECK(basemap.build_url(tile) == "https://mapsneu.wien.gv.at/basemap/layer/style/google3857/" + coordinate_path + ".jpeg"); + + const GatakiTileUrlBuilder gataki(format); + CHECK(gataki.build_url(tile) == "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/" + coordinate_path + ".jpeg"); +} +} + +TEST_CASE("tile URL coordinate formats") +{ + SECTION("xy with downward y") + { + check_urls({ TileCoordinateOrder::Xy, TileYDirection::Down }, "3/1/2"); + } + + SECTION("yx with downward y") + { + check_urls({ TileCoordinateOrder::Yx, TileYDirection::Down }, "3/2/1"); + } + + SECTION("xy with upward legacy TMS y") + { + check_urls({ TileCoordinateOrder::Xy, TileYDirection::Up }, "3/1/5"); + } + + SECTION("yx with upward legacy TMS y") + { + check_urls({ TileCoordinateOrder::Yx, TileYDirection::Up }, "3/5/1"); + } +} + +TEST_CASE("downloaded tile path uses Google and Mapbox layout") +{ + CHECK(google_tile_path("tiles", tile, ".jpeg") == std::filesystem::path("tiles/3/1/2.jpeg")); +} diff --git a/unittests/tilebuilder/alpine_raster_format.cpp b/unittests/tilebuilder/alpine_raster_format.cpp index 5c5cafd4..a0d98223 100644 --- a/unittests/tilebuilder/alpine_raster_format.cpp +++ b/unittests/tilebuilder/alpine_raster_format.cpp @@ -41,7 +41,7 @@ TEMPLATE_TEST_CASE("alpine raster format, border ", "", std::true_type, std::fal SECTION("raste write") { - const auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, radix::tile::Border::Yes); + const auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, radix::tile::Border::Yes); generator.write(radix::tile::Descriptor { {0, glm::uvec2(0, 0)}, {}, int(ctb::Grid::Srs::SphericalMercator), 256, 257 }, radix::Raster({ 257, 257 })); CHECK(std::filesystem::exists("./unittest_tiles/0/0/0.png")); @@ -58,7 +58,7 @@ TEMPLATE_TEST_CASE("alpine raster format, border ", "", std::true_type, std::fal SECTION("process all tiles") { - auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, testTypeValue2Border(TestType::value)); + auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, testTypeValue2Border(TestType::value)); generator.setWarnOnMissingOverviews(false); generator.process({ 0, 7 }); const auto tiles = generator.tiler().generateTiles({ 0, 7 }); @@ -71,7 +71,7 @@ TEMPLATE_TEST_CASE("alpine raster format, border ", "", std::true_type, std::fal #if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED SECTION("process all tiles with max zoom") { - auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, testTypeValue2Border(TestType::value)); + auto generator = alpine_raster::make_generator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", "./unittest_tiles/", ctb::Grid::Srs::SphericalMercator, testTypeValue2Border(TestType::value)); generator.setWarnOnMissingOverviews(false); generator.process({ 4, 8 }); const auto tiles = generator.tiler().generateTiles({ 4, 8 }); diff --git a/unittests/tilebuilder/depth_first_tile_traverser.cpp b/unittests/tilebuilder/depth_first_tile_traverser.cpp index ef753a3a..5cf7fb5e 100644 --- a/unittests/tilebuilder/depth_first_tile_traverser.cpp +++ b/unittests/tilebuilder/depth_first_tile_traverser.cpp @@ -37,8 +37,8 @@ TEST_CASE("depth_first_tile_traverser interface") const auto aggregate_function = [](std::vector) { return ReadType {}; }; const auto grid = ctb::GlobalMercator(); - const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No, radix::tile::Scheme::Tms); - const radix::tile::Id root_id = { 0, { 0, 0 }, tiler.scheme() }; + const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No); + const radix::tile::Id root_id = { 0, { 0, 0 } }; const unsigned max_zoom_level = 3; traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, root_id, max_zoom_level); @@ -68,15 +68,15 @@ TEST_CASE("depth_first_tile_traverser basics") }; const auto grid = ctb::GlobalMercator(); - const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No, radix::tile::Scheme::Tms); - const radix::tile::Id root_id = { 0, { 0, 0 }, tiler.scheme() }; + const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No); + const radix::tile::Id root_id = { 0, { 0, 0 } }; SECTION("reads root tile #1") { const auto result = traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, root_id, 0); REQUIRE(read_tiles.size() == 1); CHECK(aggregate_calls.size() == 0); - CHECK(read_tiles.contains(radix::tile::Id{0, {0, 0}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{0, {0, 0}})); CHECK(result.d == glm::uvec2 { 0, 0 }); } @@ -84,7 +84,7 @@ TEST_CASE("depth_first_tile_traverser basics") { const auto result = traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 2, { 1, 3 } }, 2); REQUIRE(read_tiles.size() == 1); - CHECK(read_tiles.contains(radix::tile::Id{2, {1, 3}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{2, {1, 3}})); CHECK(result.d == glm::uvec2 { 1, 3 }); } @@ -92,10 +92,10 @@ TEST_CASE("depth_first_tile_traverser basics") { traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 0, 0 } }, 1); REQUIRE(read_tiles.size() == 4); - CHECK(read_tiles.contains(radix::tile::Id{1, {0, 0}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{1, {0, 1}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{1, {1, 0}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{1, {1, 1}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{1, {0, 0}})); + CHECK(read_tiles.contains(radix::tile::Id{1, {0, 1}})); + CHECK(read_tiles.contains(radix::tile::Id{1, {1, 0}})); + CHECK(read_tiles.contains(radix::tile::Id{1, {1, 1}})); } SECTION("aggregate is called correctly") @@ -119,7 +119,7 @@ TEST_CASE("depth_first_tile_traverser austrian heights") const auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_100m_mgi.tif").value(); // const auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_mgi.tif"); const auto bounds = dataset->bounds(grid.getSRS()); - const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No, radix::tile::Scheme::Tms); + const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No); const auto tile_reader = DatasetReader(dataset, grid.getSRS(), 1, false); // const auto dataset_reader = DatasetReader() std::set read_tiles; @@ -142,14 +142,14 @@ TEST_CASE("depth_first_tile_traverser austrian heights") return aggr; }; - const radix::tile::Id root_id = { 0, { 0, 0 }, tiler.scheme() }; + const radix::tile::Id root_id = { 0, { 0, 0 } }; SECTION("reads root tile") { const auto result = traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, root_id, 0); REQUIRE(read_tiles.size() == 1); CHECK(aggregate_calls.size() == 0); - CHECK(read_tiles.contains(radix::tile::Id{0, {0, 0}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{0, {0, 0}})); CHECK(result.first >= 0); CHECK(result.first <= 4000); CHECK(result.second >= 0); @@ -161,14 +161,14 @@ TEST_CASE("depth_first_tile_traverser austrian heights") const auto result = traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, root_id, 6); CHECK(read_tiles.size() == 6); CHECK(aggregate_calls.size() == 9); - CHECK(read_tiles.contains(radix::tile::Id{6, {33, 41}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{6, {33, 42}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{6, {33, 22}})); + CHECK(read_tiles.contains(radix::tile::Id{6, {33, 21}})); - CHECK(read_tiles.contains(radix::tile::Id{6, {34, 41}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{6, {34, 42}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{6, {34, 22}})); + CHECK(read_tiles.contains(radix::tile::Id{6, {34, 21}})); - CHECK(read_tiles.contains(radix::tile::Id{6, {35, 41}, tiler.scheme()})); - CHECK(read_tiles.contains(radix::tile::Id{6, {35, 42}, tiler.scheme()})); + CHECK(read_tiles.contains(radix::tile::Id{6, {35, 22}})); + CHECK(read_tiles.contains(radix::tile::Id{6, {35, 21}})); CHECK(result.first >= 0); CHECK(result.first <= 500); CHECK(result.second >= 2000); @@ -187,8 +187,8 @@ TEST_CASE("depth_first_tile_traverser aggregate is not called with an empty vect // parent tile is produced, because its border overlaps the extents // child tiles have smaller pixels -> their border does not overlap the extents any more. bounds.min.x = (bounds.width() / 256) / 4; - const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::Yes, radix::tile::Scheme::Tms); - const radix::tile::Id root_id = { 0, { 0, 0 }, tiler.scheme() }; + const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::Yes); + const radix::tile::Id root_id = { 0, { 0, 0 } }; const auto read_function = [&](const radix::tile::Descriptor&) -> int { return 0; diff --git a/unittests/tilebuilder/parallel_tile_generator.cpp b/unittests/tilebuilder/parallel_tile_generator.cpp index 3fb7a7c4..f9326334 100644 --- a/unittests/tilebuilder/parallel_tile_generator.cpp +++ b/unittests/tilebuilder/parallel_tile_generator.cpp @@ -55,7 +55,7 @@ TEST_CASE("parallel tile generator") }; std::filesystem::path base_path = "./unittest_tiles/"; - auto generator = ParallelTileGenerator::make(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, std::make_unique(&tile_counter, &validation_error_counter), base_path); + auto generator = ParallelTileGenerator::make(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::SphericalMercator, std::make_unique(&tile_counter, &validation_error_counter), base_path); generator.setWarnOnMissingOverviews(false); SECTION("dataset tiles only") { @@ -63,12 +63,12 @@ TEST_CASE("parallel tile generator") CHECK(validation_error_counter == 0); CHECK(tile_counter == 27); CHECK(std::filesystem::exists(base_path / "0" / "0" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "1" / "1" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "2" / "2.empty")); - CHECK(std::filesystem::exists(base_path / "3" / "4" / "5.empty")); - CHECK(std::filesystem::exists(base_path / "4" / "8" / "10.empty")); - CHECK(std::filesystem::exists(base_path / "5" / "16" / "20.empty")); - CHECK(std::filesystem::exists(base_path / "7" / "70" / "84.empty")); + CHECK(std::filesystem::exists(base_path / "1" / "1" / "0.empty")); + CHECK(std::filesystem::exists(base_path / "2" / "2" / "1.empty")); + CHECK(std::filesystem::exists(base_path / "3" / "4" / "2.empty")); + CHECK(std::filesystem::exists(base_path / "4" / "8" / "5.empty")); + CHECK(std::filesystem::exists(base_path / "5" / "16" / "11.empty")); + CHECK(std::filesystem::exists(base_path / "7" / "70" / "43.empty")); } SECTION("world wide tiles") { diff --git a/unittests/tilebuilder/parallel_tiler.cpp b/unittests/tilebuilder/parallel_tiler.cpp index 815b48ba..b18124cf 100644 --- a/unittests/tilebuilder/parallel_tiler.cpp +++ b/unittests/tilebuilder/parallel_tiler.cpp @@ -23,23 +23,21 @@ #include "ctb/GlobalGeodetic.hpp" #include "ctb/GlobalMercator.hpp" #include -#include #include #include #include #include -#include using Catch::Approx; using namespace radix; -TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::false_type) +TEST_CASE("ParallelTiler") { // const auto bounds = radix::tile::SrsBounds(1'000'000, 6'000'000, 2'000'000, 6'700'000); // in m SECTION("mercator / level 0") { const auto grid = ctb::GlobalMercator(); - const auto tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::No, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::No); CHECK(tiler.northEastTile(0).coords == glm::uvec2(0, 0)); CHECK(tiler.southWestTile(0).coords == glm::uvec2(0, 0)); @@ -59,26 +57,25 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f CHECK(t.srsBounds.max.x == Approx(grid.getExtent().max.x)); } - SECTION("mercator tms / level 1 and 2") + SECTION("mercator / level 1 and 2") { const auto grid = ctb::GlobalMercator(); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_mgi.tif").value(); - const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::No, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::No); - CHECK(tiler.northEastTile(1).coords == glm::uvec2(1, TestType::value ? 1 : 0)); - CHECK(tiler.southWestTile(1).coords == glm::uvec2(1, TestType::value ? 1 : 0)); + CHECK(tiler.northEastTile(1).coords == glm::uvec2(1, 0)); + CHECK(tiler.southWestTile(1).coords == glm::uvec2(1, 0)); - CHECK(tiler.northEastTile(2).coords == glm::uvec2(2, TestType::value ? 2 : 1)); - CHECK(tiler.southWestTile(2).coords == glm::uvec2(2, TestType::value ? 2 : 1)); + CHECK(tiler.northEastTile(2).coords == glm::uvec2(2, 1)); + CHECK(tiler.southWestTile(2).coords == glm::uvec2(2, 1)); { // https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#1/-5.80/62.29 - // this code is for TMS mapping, i.e., tile y = 0 is south const auto l1_tiles = tiler.generateTiles(1); REQUIRE(l1_tiles.size() == 1); const auto t = l1_tiles.front(); CHECK(t.id.zoom_level == 1); - CHECK(t.id.coords == glm::uvec2(1, TestType::value ? 1 : 0)); + CHECK(t.id.coords == glm::uvec2(1, 0)); CHECK(t.gridSize == 256); CHECK(t.tileSize == 256); @@ -92,7 +89,7 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f REQUIRE(l2_tiles.size() == 1); const auto t = l2_tiles.front(); CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(2, TestType::value ? 2 : 1)); + CHECK(t.id.coords == glm::uvec2(2, 1)); CHECK(t.gridSize == 256); CHECK(t.tileSize == 256); @@ -103,10 +100,10 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f } } - SECTION("geodetic tms / level 0") + SECTION("geodetic / level 0") { const auto grid = ctb::GlobalGeodetic(64); - const auto tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::Yes, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::Yes); CHECK(tiler.northEastTile(0).coords == glm::uvec2(1, 0)); CHECK(tiler.southWestTile(0).coords == glm::uvec2(0, 0)); @@ -140,24 +137,24 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f CHECK(t1.srsBounds.max.x == Approx(grid.getExtent().max.x + grid.resolution(0))); } - SECTION("geodetic tms / level 1 and 2") + SECTION("geodetic / level 1 and 2") { const auto grid = ctb::GlobalGeodetic(64); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_mgi.tif").value(); - const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::Yes, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::Yes); - CHECK(tiler.northEastTile(1).coords == glm::uvec2(2, TestType::value ? 1 : 0)); - CHECK(tiler.southWestTile(1).coords == glm::uvec2(2, TestType::value ? 1 : 0)); + CHECK(tiler.northEastTile(1).coords == glm::uvec2(2, 0)); + CHECK(tiler.southWestTile(1).coords == glm::uvec2(2, 0)); - CHECK(tiler.northEastTile(2).coords == glm::uvec2(4, TestType::value ? 3 : 0)); - CHECK(tiler.southWestTile(2).coords == glm::uvec2(4, TestType::value ? 3 : 0)); + CHECK(tiler.northEastTile(2).coords == glm::uvec2(4, 0)); + CHECK(tiler.southWestTile(2).coords == glm::uvec2(4, 0)); { const auto l1_tiles = tiler.generateTiles(1); REQUIRE(l1_tiles.size() == 1); const auto t = l1_tiles.front(); CHECK(t.id.zoom_level == 1); - CHECK(t.id.coords == glm::uvec2(2, TestType::value ? 1 : 0)); + CHECK(t.id.coords == glm::uvec2(2, 0)); CHECK(t.gridSize == 64); CHECK(t.tileSize == 65); @@ -173,7 +170,7 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f REQUIRE(l2_tiles.size() == 1); const auto t = l2_tiles.front(); CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(4, TestType::value ? 3 : 0)); + CHECK(t.id.coords == glm::uvec2(4, 0)); CHECK(t.gridSize == 64); CHECK(t.tileSize == 65); @@ -186,26 +183,25 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f } } - SECTION("mercator tms / level 1 and 2 (test with cape horn, on southern and western hemisphere)") + SECTION("mercator / level 1 and 2 (test with cape horn, on southern and western hemisphere)") { const auto grid = ctb::GlobalMercator(); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/capehorn/small.tif").value(); - const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::No, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::No); - CHECK(tiler.northEastTile(1).coords == glm::uvec2(0, TestType::value ? 0 : 1)); - CHECK(tiler.southWestTile(1).coords == glm::uvec2(0, TestType::value ? 0 : 1)); + CHECK(tiler.northEastTile(1).coords == glm::uvec2(0, 1)); + CHECK(tiler.southWestTile(1).coords == glm::uvec2(0, 1)); - CHECK(tiler.northEastTile(2).coords == glm::uvec2(1, TestType::value ? 1 : 2)); - CHECK(tiler.southWestTile(2).coords == glm::uvec2(1, TestType::value ? 1 : 2)); + CHECK(tiler.northEastTile(2).coords == glm::uvec2(1, 2)); + CHECK(tiler.southWestTile(2).coords == glm::uvec2(1, 2)); { // https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#1/-5.80/62.29 - // this code is for TMS mapping, i.e., tile y = 0 is south const auto l1_tiles = tiler.generateTiles(1); REQUIRE(l1_tiles.size() == 1); const auto t = l1_tiles.front(); CHECK(t.id.zoom_level == 1); - CHECK(t.id.coords == glm::uvec2(0, TestType::value ? 0 : 1)); + CHECK(t.id.coords == glm::uvec2(0, 1)); CHECK(t.gridSize == 256); CHECK(t.tileSize == 256); @@ -219,7 +215,7 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f REQUIRE(l2_tiles.size() == 1); const auto t = l2_tiles.front(); CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(1, TestType::value ? 1 : 2)); + CHECK(t.id.coords == glm::uvec2(1, 2)); CHECK(t.gridSize == 256); CHECK(t.tileSize == 256); @@ -230,24 +226,24 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f } } - SECTION("geodetic tms / level 1 and 2 (test with cape horn, on southern and western hemisphere)") + SECTION("geodetic / level 1 and 2 (test with cape horn, on southern and western hemisphere)") { const auto grid = ctb::GlobalGeodetic(64); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/capehorn/small.tif").value(); - const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::Yes, TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap); + const auto tiler = ParallelTiler(grid, dataset->bounds(grid.getSRS()), radix::tile::Border::Yes); - CHECK(tiler.northEastTile(1).coords == glm::uvec2(1, TestType::value ? 0 : 1)); - CHECK(tiler.southWestTile(1).coords == glm::uvec2(1, TestType::value ? 0 : 1)); + CHECK(tiler.northEastTile(1).coords == glm::uvec2(1, 1)); + CHECK(tiler.southWestTile(1).coords == glm::uvec2(1, 1)); - CHECK(tiler.northEastTile(2).coords == glm::uvec2(2, TestType::value ? 0 : 3)); - CHECK(tiler.southWestTile(2).coords == glm::uvec2(2, TestType::value ? 0 : 3)); + CHECK(tiler.northEastTile(2).coords == glm::uvec2(2, 3)); + CHECK(tiler.southWestTile(2).coords == glm::uvec2(2, 3)); { const auto l1_tiles = tiler.generateTiles(1); REQUIRE(l1_tiles.size() == 1); const auto t = l1_tiles.front(); CHECK(t.id.zoom_level == 1); - CHECK(t.id.coords == glm::uvec2(1, TestType::value ? 0 : 1)); + CHECK(t.id.coords == glm::uvec2(1, 1)); CHECK(t.gridSize == 64); CHECK(t.tileSize == 65); @@ -263,7 +259,7 @@ TEMPLATE_TEST_CASE("ParallelTiler, using tms scheme", "", std::true_type, std::f REQUIRE(l2_tiles.size() == 1); const auto t = l2_tiles.front(); CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(2, TestType::value ? 0 : 3)); + CHECK(t.id.coords == glm::uvec2(2, 3)); CHECK(t.gridSize == 64); CHECK(t.tileSize == 65); @@ -281,7 +277,7 @@ TEST_CASE("ParallelTiler returns tiles for several zoom levels") { const auto dataset = Dataset(ALP_TEST_DATA_DIR "/austria/at_mgi.tif"); const auto grid = ctb::GlobalMercator(256); - const auto tiler = ParallelTiler(grid, dataset.bounds(grid.getSRS()), radix::tile::Border::Yes, radix::tile::Scheme::Tms); + const auto tiler = ParallelTiler(grid, dataset.bounds(grid.getSRS()), radix::tile::Border::Yes); SECTION("generate from 0 to 7") { diff --git a/unittests/tilebuilder/tile_heights_generator.cpp b/unittests/tilebuilder/tile_heights_generator.cpp index 43f74237..89c532ae 100644 --- a/unittests/tilebuilder/tile_heights_generator.cpp +++ b/unittests/tilebuilder/tile_heights_generator.cpp @@ -30,7 +30,7 @@ TEST_CASE("TileHeightsGenerator") constexpr auto file_name = "height_data.atb"; SECTION("mercator") { - const auto generator = TileHeightsGenerator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::SphericalMercator, radix::tile::Scheme::Tms, radix::tile::Border::Yes, base_path / file_name); + const auto generator = TileHeightsGenerator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::SphericalMercator, radix::tile::Border::Yes, base_path / file_name); generator.run(8); const auto heights = TileHeights::read_from(base_path / file_name); @@ -41,7 +41,7 @@ TEST_CASE("TileHeightsGenerator") } { - auto [min, max] = heights.query({ 8, { 138, 166 } }); // part of styria, lower and upper austria (https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#8/15.69/47.75) + auto [min, max] = heights.query({ 8, { 138, 89 } }); // part of styria, lower and upper austria (https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#8/15.69/47.75) CHECK(min > 300); CHECK(min < 400); CHECK(max > 1500); @@ -50,7 +50,7 @@ TEST_CASE("TileHeightsGenerator") } SECTION("geodetic") { - const auto generator = TileHeightsGenerator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::WGS84, radix::tile::Scheme::Tms, radix::tile::Border::Yes, base_path / file_name); + const auto generator = TileHeightsGenerator(ALP_TEST_DATA_DIR "/austria/at_mgi.tif", ctb::Grid::Srs::WGS84, radix::tile::Border::Yes, base_path / file_name); generator.run(8); const auto heights = TileHeights::read_from(base_path / file_name); @@ -61,7 +61,7 @@ TEST_CASE("TileHeightsGenerator") } { - auto [min, max] = heights.query({ 8, { 270, 194 } }); // can't check the address easily, because there is no web service showing geodetic tile names. + auto [min, max] = heights.query({ 8, { 270, 61 } }); // can't check the address easily, because there is no web service showing geodetic tile names. CHECK(min > 500); CHECK(min < 700); CHECK(max > 3600); diff --git a/unittests/tilebuilder/top_down_tiler.cpp b/unittests/tilebuilder/top_down_tiler.cpp index cd1e33b5..61645361 100644 --- a/unittests/tilebuilder/top_down_tiler.cpp +++ b/unittests/tilebuilder/top_down_tiler.cpp @@ -21,7 +21,7 @@ #include "TopDownTiler.h" #include "ctb/GlobalGeodetic.hpp" #include "ctb/GlobalMercator.hpp" -#include +#include #include using namespace radix; @@ -49,18 +49,16 @@ void compare_tile_lists(const std::vector& a_tiles, std } -TEMPLATE_TEST_CASE("BottomUpTiler, using tms scheme", "", std::true_type, std::false_type) +TEST_CASE("TopDownTiler") { - const auto scheme = TestType::value ? radix::tile::Scheme::Tms : radix::tile::Scheme::SlippyMap; - SECTION("mercator / level 0 all") { const auto grid = ctb::GlobalMercator(); - const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No, scheme); + const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No); - const auto tiles = tiler.generateTiles({0, { 0, 0 }, scheme}); + const auto tiles = tiler.generateTiles({0, { 0, 0 }}); REQUIRE(tiles.size() == 4); - const auto parallel_tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::No, scheme); + const auto parallel_tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::No); compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); } @@ -69,22 +67,22 @@ TEMPLATE_TEST_CASE("BottomUpTiler, using tms scheme", "", std::true_type, std::f const auto grid = ctb::GlobalMercator(); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_mgi.tif").value(); const auto bounds = dataset->bounds(grid.getSRS()); - const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No, scheme); + const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No); - const auto tiles = tiler.generateTiles({0, { 0, 0 }, scheme}); + const auto tiles = tiler.generateTiles({0, { 0, 0 }}); REQUIRE(tiles.size() == 1); - const auto parallel_tiler = ParallelTiler(grid, bounds, radix::tile::Border::No, scheme); + const auto parallel_tiler = ParallelTiler(grid, bounds, radix::tile::Border::No); compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); } SECTION("geodetic / level 0 east half") { const auto grid = ctb::GlobalGeodetic(); - const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No, scheme); + const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No); - const auto tiles = tiler.generateTiles({0, { 1, 0 }, scheme}); + const auto tiles = tiler.generateTiles({0, { 1, 0 }}); REQUIRE(tiles.size() == 4); - const auto parallel_tiler = ParallelTiler(grid, {{0, -90}, {180, 90}}, radix::tile::Border::No, scheme); + const auto parallel_tiler = ParallelTiler(grid, {{0, -90}, {180, 90}}, radix::tile::Border::No); compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); } @@ -93,11 +91,11 @@ TEMPLATE_TEST_CASE("BottomUpTiler, using tms scheme", "", std::true_type, std::f const auto grid = ctb::GlobalGeodetic(); auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_mgi.tif").value(); const auto bounds = dataset->bounds(grid.getSRS()); - const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No, scheme); + const auto tiler = TopDownTiler(grid, bounds, radix::tile::Border::No); - const auto tiles = tiler.generateTiles({0, { 1, 0 }, scheme}); + const auto tiles = tiler.generateTiles({0, { 1, 0 }}); REQUIRE(tiles.size() == 1); - const auto parallel_tiler = ParallelTiler(grid, bounds, radix::tile::Border::No, scheme); + const auto parallel_tiler = ParallelTiler(grid, bounds, radix::tile::Border::No); compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); } } From 7a384ff72924113ceb2392838e79e8b645c376ea Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:46:18 +0200 Subject: [PATCH 04/53] ci: allow warnings on raster-store branch --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1c36786..e96f76f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1G + ALP_CI_WARNINGS_AS_ERRORS: ${{ (github.ref_name == 'raster-store' || github.head_ref == 'raster-store') && 'OFF' || 'ON' }} jobs: build-and-test: @@ -122,7 +123,7 @@ jobs: -DCMAKE_CXX_COMPILER=${{ matrix.cxx }} -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - -DALP_WARNINGS_AS_ERRORS=ON + -DALP_WARNINGS_AS_ERRORS=${{ env.ALP_CI_WARNINGS_AS_ERRORS }} ${{ matrix.cmake_flags }} - name: Save installed CMake dependencies From 39d36be9306f0e543e6831294eacfce11a1f03db Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:18:16 +0200 Subject: [PATCH 05/53] Make tile downloader URL formats provider-driven Bundle URL patterns and Y direction with the built-in Basemap and Gataki providers. Replace layer, style, and coordinate-order flags with custom placeholder URLs and an optional legacy TMS Y direction. Keep downloaded files in Google/Mapbox Slippy layout and update URL-generation tests and documentation. --- README.md | 10 ++- docs/tile-downloader/download.sh | 4 +- src/tile_downloader/TileUrlBuilder.h | 105 +++++++++++----------- src/tile_downloader/cli.cpp | 28 +++--- src/tile_downloader/cli.h | 7 +- src/tile_downloader/main.cpp | 19 ++-- unittests/tile_downloader/url_builder.cpp | 43 +++++---- 7 files changed, 104 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 20d2d8d8..011b8cd6 100644 --- a/README.md +++ b/README.md @@ -24,22 +24,24 @@ The tools typically only handle one tile per command which makes it infeasible t In this example, we'll show how to build the hierarchy for Vienna's city center (Zoom: 13, X: 4468, Y:2840). ### 1. Downloading tiles -Tile coordinates always use the Google/Mapbox/XYZ convention internally: the origin is north-west, X grows east, and Y grows south. Google Maps, Mapbox, OpenStreetMap, and most XYZ services use the common URL order `{zoom}/{x}/{y}`; this is the downloader default. +Tile coordinates always use the Google/Mapbox/XYZ convention internally: the origin is north-west, X grows east, and Y grows south. Google Maps, Mapbox, OpenStreetMap, and most XYZ services use the common URL order `{zoom}/{x}/{y}`. -Some providers use `{zoom}/{y}/{x}` instead. Both built-in Austrian providers currently require `--url-coordinate-order yx`; for example, the Gataki mirror uses: +The `basemap` and `gataki` providers select their complete URL pattern and Y direction automatically. Both currently use downward Y with the URL order `{zoom}/{y}/{x}`. The `basemap` provider downloads the basemap.at orthophoto, and the Gataki mirror uses: `https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{zoom}/{y}/{x}.jpeg` Example for the root tile: https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/13/2840/4468.jpeg -Select URL coordinate order with `--url-coordinate-order xy|yx`. Select the URL Y direction with `--url-y-direction down|up`; `down` is the common Google/Mapbox convention and `up` is the legacy TMS convention. These options affect only remote URLs. Downloaded files always use the common Google/Mapbox layout `{zoom}/{x}/{y}.jpeg`. +For another service, pass a quoted `--url` pattern containing `{zoom}`, `{x}`, and `{y}`. Placeholder placement selects URL coordinate order. Custom URLs default to downward Y; use `--url-y-direction up` for legacy TMS. Downloaded files always use the common Google/Mapbox layout `{zoom}/{x}/{y}.jpeg`, independently of the remote URL. The following command downloads the mirror's `{zoom}/{y}/{x}` URLs and writes the root tile to `./tiles/13/4468/2840.jpeg`: ``` -./tile-downloader --provider gataki --zoom 13 --x 4468 --y 2840 --url-coordinate-order yx --max-zoom-level 19 +./tile-downloader --provider gataki --zoom 13 --x 4468 --y 2840 --max-zoom-level 19 ``` +For example, a custom Google/Mapbox URL can be selected with `--url 'https://example.test/{zoom}/{x}/{y}.jpeg'`. + ### 2. Download heightmap dataset The meshes are built from a heightmap dataset, therefore we need to download one. For this example we'll only use a small part of the complete dataset for the whole of austria (available at https://gataki.cg.tuwien.ac.at/raw/Oe_2020/, 268 GB to 1.1 TB). The part we're gonna use contains Vienna's city center and it is available at https://gataki.cg.tuwien.ac.at/raw/vienna/innenstadt_gs_1m_mgi.tif (228 MB). diff --git a/docs/tile-downloader/download.sh b/docs/tile-downloader/download.sh index d5aef40a..dcd168f8 100755 --- a/docs/tile-downloader/download.sh +++ b/docs/tile-downloader/download.sh @@ -4,6 +4,6 @@ build_path="/home/madam/Documents/work/tuw/alpinemaps/build-terrain-builder-Desk while read p; do read zoom x y <<<${p//[^0-9]/ } - echo -e "nice -10 \$build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --url-coordinate-order yx --verbosity 0&" -# nice -10 $build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --url-coordinate-order yx --verbosity 0& + echo -e "nice -10 \$build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --verbosity 0&" +# nice -10 $build_path/tile-downloader --provider basemap --zoom ${zoom} --x ${x} --y ${y} --verbosity 0& done #include #include +#include #include -#include #include -enum class TileCoordinateOrder { - Xy, - Yx +enum class TileDownloadProvider { + Basemap, + Gataki }; enum class TileYDirection { @@ -18,67 +18,68 @@ enum class TileYDirection { Up }; -struct TileUrlFormat { - TileCoordinateOrder coordinate_order = TileCoordinateOrder::Xy; +struct TileProviderConfig { + std::string url_pattern; TileYDirection y_direction = TileYDirection::Down; +}; - [[nodiscard]] std::pair coordinates(const radix::tile::Id& tile_id) const - { - auto y = tile_id.coords.y; - if (y_direction == TileYDirection::Up) { - if (tile_id.zoom_level >= std::numeric_limits::digits) - throw std::invalid_argument("tile zoom level is too large for legacy TMS coordinates"); - y = (1u << tile_id.zoom_level) - y - 1; - } - - if (coordinate_order == TileCoordinateOrder::Xy) - return { tile_id.coords.x, y }; - return { y, tile_id.coords.x }; +[[nodiscard]] inline TileProviderConfig tile_provider_config(TileDownloadProvider provider) +{ + switch (provider) { + case TileDownloadProvider::Basemap: + return { + "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{zoom}/{y}/{x}.jpeg", + TileYDirection::Down + }; + case TileDownloadProvider::Gataki: + return { + "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{zoom}/{y}/{x}.jpeg", + TileYDirection::Down + }; } -}; -class TileUrlBuilder { -public: - virtual ~TileUrlBuilder() = default; - virtual std::string build_url(const radix::tile::Id &tile_id) const = 0; -}; + throw std::invalid_argument("unknown tile download provider"); +} -class BasemapTileUrlBuilder : public TileUrlBuilder { +class TileUrlBuilder { public: - BasemapTileUrlBuilder(std::string layer, std::string style, TileUrlFormat format = {}) - : _layer(std::move(layer)) - , _style(std::move(style)) - , _format(format) + explicit TileUrlBuilder(TileProviderConfig config) + : _url_pattern(std::move(config.url_pattern)) + , _y_direction(config.y_direction) { + if (_url_pattern.find("{zoom}") == std::string::npos + || _url_pattern.find("{x}") == std::string::npos + || _url_pattern.find("{y}") == std::string::npos) { + throw std::invalid_argument("tile URL pattern must contain {zoom}, {x}, and {y}"); + } } - std::string build_url(const radix::tile::Id &tile_id) const override { - const auto [first_coordinate, second_coordinate] = _format.coordinates(tile_id); - return fmt::format( - "https://mapsneu.wien.gv.at/basemap/{}/{}/google3857/{}/{}/{}.jpeg", - _layer, _style, tile_id.zoom_level, first_coordinate, second_coordinate); + [[nodiscard]] std::string build_url(const radix::tile::Id& tile_id) const + { + auto y = tile_id.coords.y; + if (_y_direction == TileYDirection::Up) { + if (tile_id.zoom_level >= std::numeric_limits::digits) + throw std::invalid_argument("tile zoom level is too large for legacy TMS coordinates"); + y = (1u << tile_id.zoom_level) - y - 1; + } + + auto url = _url_pattern; + replace_all(url, "{zoom}", std::to_string(tile_id.zoom_level)); + replace_all(url, "{x}", std::to_string(tile_id.coords.x)); + replace_all(url, "{y}", std::to_string(y)); + return url; } private: - std::string _layer; - std::string _style; - TileUrlFormat _format; -}; - -class GatakiTileUrlBuilder : public TileUrlBuilder { -public: - explicit GatakiTileUrlBuilder(TileUrlFormat format = {}) - : _format(format) + static void replace_all(std::string& value, std::string_view placeholder, const std::string& replacement) { + size_t position = 0; + while ((position = value.find(placeholder, position)) != std::string::npos) { + value.replace(position, placeholder.size(), replacement); + position += replacement.size(); + } } - std::string build_url(const radix::tile::Id &tile_id) const override { - const auto [first_coordinate, second_coordinate] = _format.coordinates(tile_id); - return fmt::format( - "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{}/{}/{}.jpeg", - tile_id.zoom_level, first_coordinate, second_coordinate); - } - -private: - TileUrlFormat _format; + std::string _url_pattern; + TileYDirection _y_direction; }; diff --git a/src/tile_downloader/cli.cpp b/src/tile_downloader/cli.cpp index 4c5acb95..567d197b 100644 --- a/src/tile_downloader/cli.cpp +++ b/src/tile_downloader/cli.cpp @@ -13,28 +13,26 @@ Args parse(int argc, const char *const *argv) { Args args; - app.add_option("--provider", args.provider, "Tile provider (basemap or gataki)") - ->required() - ->check(CLI::IsMember({"basemap", "gataki"}, CLI::ignore_case)); + const std::map provider_map{ + {"basemap", TileDownloadProvider::Basemap}, + {"gataki", TileDownloadProvider::Gataki}}; + auto* source_group = app.add_option_group("Tile source"); + source_group->add_option("--provider", args.provider, "Configured tile provider (basemap or gataki)") + ->transform(CLI::CheckedTransformer(provider_map, CLI::ignore_case)); + auto* url_option = source_group->add_option("--url", args.url_pattern, "Custom tile URL pattern containing {zoom}, {x}, and {y}"); + source_group->require_option(1); app.add_option("--zoom", args.zoom, "Root tile zoom level")->required(); app.add_option("--x,--col", args.x, "Root tile x/column in Google/Mapbox coordinates")->required(); app.add_option("--y,--row", args.y, "Root tile y/row in Google/Mapbox coordinates")->required(); - const std::map coordinate_order_map{ - {"xy", TileCoordinateOrder::Xy}, - {"yx", TileCoordinateOrder::Yx}}; - args.url_coordinate_order = TileCoordinateOrder::Xy; - app.add_option("--url-coordinate-order", args.url_coordinate_order, "URL coordinate order: xy (common Google/Mapbox format) or yx") - ->default_str("xy") - ->transform(CLI::CheckedTransformer(coordinate_order_map, CLI::ignore_case)); - const std::map y_direction_map{ {"down", TileYDirection::Down}, {"up", TileYDirection::Up}}; args.url_y_direction = TileYDirection::Down; - app.add_option("--url-y-direction", args.url_y_direction, "URL y direction: down (common Google/Mapbox format) or up (legacy TMS)") + app.add_option("--url-y-direction", args.url_y_direction, "Custom URL y direction: down (Google/Mapbox) or up (legacy TMS)") ->default_str("down") + ->needs(url_option) ->transform(CLI::CheckedTransformer(y_direction_map, CLI::ignore_case)); args.srs = 3857; @@ -62,12 +60,6 @@ Args parse(int argc, const char *const *argv) { app.add_option("--max-zoom-level", args.max_zoom_level, "Maximum zoom level to descend to"); - args.layer = "bmaporthofoto30cm"; - app.add_option("--layer", args.layer, "Basemap layer name")->default_val(args.layer); - - args.style = "normal"; - app.add_option("--style", args.style, "Basemap style")->default_val(args.style); - try { app.parse(argc, argv); } catch (const CLI::ParseError &e) { diff --git a/src/tile_downloader/cli.h b/src/tile_downloader/cli.h index bc667f98..80fa1d7c 100644 --- a/src/tile_downloader/cli.h +++ b/src/tile_downloader/cli.h @@ -4,7 +4,6 @@ #include #include -#include #include #include "TileUrlBuilder.h" @@ -12,19 +11,17 @@ namespace cli { struct Args { - std::string provider; + std::optional provider; + std::optional url_pattern; unsigned int zoom; unsigned int x; unsigned int y; - TileCoordinateOrder url_coordinate_order; TileYDirection url_y_direction; unsigned int srs; std::filesystem::path output; spdlog::level::level_enum log_level; bool early_skip; std::optional max_zoom_level; - std::string layer; - std::string style; }; Args parse(int argc, const char *const *argv); diff --git a/src/tile_downloader/main.cpp b/src/tile_downloader/main.cpp index b2973a29..18fbf28d 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -1,7 +1,3 @@ -#include -#include -#include - #include "TileDownloader.h" #include "TileUrlBuilder.h" #include "cli.h" @@ -15,19 +11,14 @@ int main(int argc, char *argv[]) { LOG_ERROR_AND_EXIT("unsupported srs EPSG \"{}\"", args.srs); } - std::unique_ptr url_builder; - std::string provider = args.provider; - std::transform(provider.begin(), provider.end(), provider.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (provider == "basemap") { - url_builder = std::make_unique(args.layer, args.style, TileUrlFormat { args.url_coordinate_order, args.url_y_direction }); - } else { - url_builder = std::make_unique(TileUrlFormat { args.url_coordinate_order, args.url_y_direction }); - } + const auto provider_config = args.provider.has_value() + ? tile_provider_config(*args.provider) + : TileProviderConfig { *args.url_pattern, args.url_y_direction }; + const TileUrlBuilder url_builder(provider_config); const radix::tile::Id root_id = {args.zoom, {args.x, args.y}}; - TileDownloader downloader(*url_builder, args.output, args.early_skip, args.max_zoom_level); + TileDownloader downloader(url_builder, args.output, args.early_skip, args.max_zoom_level); downloader.download_recursive(root_id); return 0; diff --git a/unittests/tile_downloader/url_builder.cpp b/unittests/tile_downloader/url_builder.cpp index dd4ade75..e6bdaccf 100644 --- a/unittests/tile_downloader/url_builder.cpp +++ b/unittests/tile_downloader/url_builder.cpp @@ -5,38 +5,47 @@ namespace { constexpr radix::tile::Id tile { 3, { 1, 2 } }; +} -void check_urls(const TileUrlFormat format, const std::string& coordinate_path) +TEST_CASE("configured tile provider URLs") { - const BasemapTileUrlBuilder basemap("layer", "style", format); - CHECK(basemap.build_url(tile) == "https://mapsneu.wien.gv.at/basemap/layer/style/google3857/" + coordinate_path + ".jpeg"); + { + const TileUrlBuilder builder(tile_provider_config(TileDownloadProvider::Basemap)); + CHECK(builder.build_url(tile) == "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/3/2/1.jpeg"); + } - const GatakiTileUrlBuilder gataki(format); - CHECK(gataki.build_url(tile) == "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/" + coordinate_path + ".jpeg"); -} + { + const TileUrlBuilder builder(tile_provider_config(TileDownloadProvider::Gataki)); + CHECK(builder.build_url(tile) == "https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/3/2/1.jpeg"); + } } -TEST_CASE("tile URL coordinate formats") +TEST_CASE("custom tile URL patterns") { - SECTION("xy with downward y") + SECTION("zoom/x/y with downward y") { - check_urls({ TileCoordinateOrder::Xy, TileYDirection::Down }, "3/1/2"); + const TileUrlBuilder builder({ "https://example.test/{zoom}/{x}/{y}.png", TileYDirection::Down }); + CHECK(builder.build_url(tile) == "https://example.test/3/1/2.png"); } - SECTION("yx with downward y") + SECTION("zoom/y/x with downward y") { - check_urls({ TileCoordinateOrder::Yx, TileYDirection::Down }, "3/2/1"); + const TileUrlBuilder builder({ "https://example.test/{zoom}/{y}/{x}.png", TileYDirection::Down }); + CHECK(builder.build_url(tile) == "https://example.test/3/2/1.png"); } - SECTION("xy with upward legacy TMS y") + SECTION("upward legacy TMS y") { - check_urls({ TileCoordinateOrder::Xy, TileYDirection::Up }, "3/1/5"); + const TileUrlBuilder builder({ "https://example.test/{zoom}/{x}/{y}.png", TileYDirection::Up }); + CHECK(builder.build_url(tile) == "https://example.test/3/1/5.png"); } +} - SECTION("yx with upward legacy TMS y") - { - check_urls({ TileCoordinateOrder::Yx, TileYDirection::Up }, "3/5/1"); - } +TEST_CASE("tile URL patterns require all coordinate placeholders") +{ + CHECK_THROWS_AS(TileUrlBuilder({ "https://example.test/{x}/{y}.png", TileYDirection::Down }), std::invalid_argument); + CHECK_THROWS_AS(TileUrlBuilder({ "https://example.test/{zoom}/{y}.png", TileYDirection::Down }), std::invalid_argument); + CHECK_THROWS_AS(TileUrlBuilder({ "https://example.test/{zoom}/{x}.png", TileYDirection::Down }), std::invalid_argument); } TEST_CASE("downloaded tile path uses Google and Mapbox layout") From 9b3ad878bc07afeb212837cbbaa0a5f79572800a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:47:30 +0200 Subject: [PATCH 06/53] Revert "ci: allow warnings on raster-store branch" This reverts commit 7a384ff72924113ceb2392838e79e8b645c376ea. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e96f76f3..c1c36786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1G - ALP_CI_WARNINGS_AS_ERRORS: ${{ (github.ref_name == 'raster-store' || github.head_ref == 'raster-store') && 'OFF' || 'ON' }} jobs: build-and-test: @@ -123,7 +122,7 @@ jobs: -DCMAKE_CXX_COMPILER=${{ matrix.cxx }} -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - -DALP_WARNINGS_AS_ERRORS=${{ env.ALP_CI_WARNINGS_AS_ERRORS }} + -DALP_WARNINGS_AS_ERRORS=ON ${{ matrix.cmake_flags }} - name: Save installed CMake dependencies From 79898583649974c1bb7391550745e2a20b1a04ab Mon Sep 17 00:00:00 2001 From: Adam <5292991+adam-ce@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:02:55 +0200 Subject: [PATCH 07/53] Update storage-format.md --- docs/raster-store/storage-format.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 56a639b9..2c13ce99 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -9,8 +9,8 @@ open unless explicitly marked confirmed. ### Confirmed - The persistent hierarchy is a Web Mercator quadtree keyed by Radix tile IDs. -- Store chunks are non-overlapping and approximately 10-100 MB. -- Every stored pixel has one payload value and one source attribution. +- Store chunks are non-overlapping and approximately 10-100 MB after compression. +- Every stored pixel has one payload value (can be vector type) and one source attribution. - A coarse physical chunk may coexist with more accurate descendants. - Rendering overlap and delivery encodings are not stored authoritatively. From 617dd1d600d41f6277eb181cf62bb2ac0384bb0a Mon Sep 17 00:00:00 2001 From: Adam Celarek <5292991+adam-ce@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:22:37 +0200 Subject: [PATCH 08/53] define part of the storage format --- docs/raster-store/storage-format.md | 381 +++++++--------------------- 1 file changed, 87 insertions(+), 294 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 2c13ce99..8be6b5eb 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -1,296 +1,89 @@ # Storage format -This document specifies the logical format and required invariants. Exact -binary encodings, field widths, compression libraries, and filenames remain -open unless explicitly marked confirmed. - -## Format principles - -### Confirmed - -- The persistent hierarchy is a Web Mercator quadtree keyed by Radix tile IDs. -- Store chunks are non-overlapping and approximately 10-100 MB after compression. -- Every stored pixel has one payload value (can be vector type) and one source attribution. -- A coarse physical chunk may coexist with more accurate descendants. -- Rendering overlap and delivery encodings are not stored authoritatively. - -### Proposed - -- All files have explicit magic, format version, and byte-order declarations. -- Payload and source map are held in one atomic chunk container. -- Published files are immutable. -- Checksums cover metadata and independently compressed data sections. -- Unknown optional fields can be skipped by length; incompatible required - fields cause a clear load failure. - -## Store manifest - -The root manifest should contain: - -```text -format magic and version -snapshot ID -optional parent snapshot ID -creation metadata -canonical CRS identifier and definition -canonical tile scheme -source-catalog reference -layer descriptors -``` - -The CRS must be specified more strongly than a human-readable name. The exact -EPSG/Web Mercator definition and world extent used by tile calculations must -be unambiguous. - -## Layer descriptor - -A layer descriptor should contain: - -```text -layer ID and name -semantic kind -payload pixel format -band/component interpretation -chunk width and height -NoData representation -index reference -chunk layout/codec identifier -``` - -The descriptor defines stored data. Vertex-pixel versus area-pixel delivery is -generator policy and is not required as a layer field unless the eventual -store permits more than one stored spatial placement. - -The semantic kind is still useful because it constrains valid operations. For -example, height scalars, linear colour, gamma-encoded colour, categorical -values, masks, and vectors require different filtering. - -## Source catalog - -A source-catalog record should contain a stable ID and sufficient provenance -to understand or reproduce ingestion. Candidate fields are: - -```text -SourceId -source identity and version/content fingerprint -human-readable name -original URI or path, if durable and safe to persist -original CRS and geotransform -original dimensions, bands, and pixel type -original NoData/alpha information -original nominal resolution -effective Web Mercator resolution -acquisition/publication time -priority and quality metadata -ingestion timestamp -ingestion transform and resampling policy -``` - -Pathnames may contain deployment-specific or sensitive information. The -format should support opaque source identities without requiring an original -local path. - -Source IDs are persistent across related snapshots. A snapshot that hard-links -an older chunk must retain the same meaning for every global source ID in that -chunk's local table. Snapshot-local catalog compaction may not renumber IDs -referenced by inherited containers. - -## Quadtree index file - -The index should contain: - -```text -magic -format version -tree kind = quadtree -canonical scheme -chunk layout identifier -entry count -entries: TileId → NodeStatus -optional aggregate metadata -checksum -``` - -`TileId` must be validated when decoded: - -```text -zoom is supported -x < 2^zoom -y < 2^zoom -scheme is canonical or omitted because the file declares it globally -``` - -Node status has the following invariant: - -- `Leaf` and `Inner` have a chunk file. -- `Virtual` has no chunk file. -- `Inner` and `Virtual` have at least one indexed descendant. -- `Leaf` has no indexed descendant. -- Every non-root entry has all required ancestors represented. - -The index must be reconstructible by scanning valid chunk paths. A rebuild -tool should write a new index rather than silently changing a published one. - -## Chunk container - -### Logical structure - -```text -Chunk header -├── magic and version -├── tile ID -├── layer/schema identity -├── raster dimensions -├── payload encoding -├── source-index encoding -└── section directory - -Local source table -├── local source index -└── global SourceId - -Payload sections -└── typed raster data - -Source-map sections -└── local source indexes, one per payload pixel -``` - -Payload and source-map dimensions must match exactly. A decoder rejects the -container if a source-map value is outside the local source table. - -### Source-index width - -Possible representations include: - -- `uint8_t` for at most 256 local entries; -- `uint16_t` for at most 65,536 local entries; or -- a width selected per chunk and recorded in the header. - -Adaptive width is attractive because most chunks are expected to use few -sources, but it adds codec branches. This is an open implementation decision. - -A reserved local entry may represent no valid source. If this is adopted, its -payload validity and filtering semantics must be defined rather than inferred -from an arbitrary payload value. - -### Compression and random access - -The chunk-size target makes whole-file decompression undesirable for small -windows. A proposed container divides payload and source map into matching -independently compressed blocks and records their offsets in the section -directory. - -Block dimensions should support: - -- bounded memory; -- halo reads across chunks; -- checksumming damaged regions; -- skipping source-map blocks when provenance is not requested; and -- skipping payload blocks during provenance-only inspection. - -The payload and source-map blocks need not use the same compression algorithm. -Source maps are categorical and may benefit from run-length, palette, or -general-purpose compression. The initial implementation should benchmark -representative data before fixing a codec. - -## Chunk sizing - -### Proposed sizing rule - -Choose a power-of-two side length from a logical byte budget: - -```text -logical bytes ≈ width × height × - (payload bytes per pixel + source-index bytes per pixel) -``` - -The target interval is 10-100 MB, including both large raster arrays but -excluding small metadata. For example: - -```text -4096² × (RGB8 + uint16 source) ≈ 80 MiB -4096² × (float32 + uint16 source) ≈ 96 MiB -2048² × (RGBA8 + uint16 source) ≈ 24 MiB -``` - -The examples use binary MiB despite the conversational 10-100 MB requirement; -the final specification must choose units explicitly. - -If a layer's pixel type would place 4,096² outside the budget, the layer may -choose another fixed power-of-two dimension. Varying dimensions between nodes -within one layer is not recommended because it complicates resolution and -window calculations. - -### Why compressed size should not drive identity - -Compressed size depends on terrain, imagery, source-map fragmentation, and -codec settings. Splitting chunks dynamically to hit a compressed-byte target -would make spatial identities content-dependent and destabilize updates. -A fixed spatial grid with a logical size budget gives predictable identity; -compression is an optimization. - -## Filesystem layout - -A proposed chunk path is: - -```text -layers//chunks///.rst -``` - -This is familiar and reconstructible, but very large datasets may place too -many entries in intermediate directories. Alternative layouts may shard by -Morton/Hilbert prefix. The layout identifier in layer metadata allows this to -change without changing tile identity. - -Layout parsing must accept only canonical chunk files. Auxiliary previews, -temporary files, and generated delivery images must not accidentally become -index entries during a rebuild scan. - -## Immutability and hard links - -An unchanged chunk in a child snapshot may be a hard link to the parent's -container. The following rules are mandatory if hard links are used: - -- Published containers are never modified in place. -- A changed container is written to a distinct temporary inode. -- Completion uses an atomic rename within the target filesystem. -- Snapshot deletion removes names, not shared content still referenced by - another snapshot. -- Validation may inspect inode/link counts for diagnostics but format - correctness never depends on them. - -The index, manifest, and source catalog are snapshot-specific and normally new -files even when many chunks are linked. - -## Consistency and recovery - -A snapshot is publishable only if: - -- every physical index entry resolves to one valid chunk; -- no virtual entry has a chunk; -- all chunk tile IDs and layer IDs match their paths and index entries; -- source maps reference valid local entries; -- local entries reference valid catalog records; -- dimensions and formats match the layer descriptor; and -- checksums pass. - -Temporary and incomplete files use names excluded from layout scanning. -Publication should make the complete snapshot visible in one atomic metadata -operation. The exact mechanism is open. - -## Explicitly not finalized - -This document does not yet choose: - -- binary serialization library; -- integer field widths; -- compression codecs and block dimensions; -- exact source metadata fields; -- checksum algorithm; -- file extensions and directory names; -- snapshot publication mechanism; or -- generic versus raster-specific index implementation. - -Those choices require prototypes and representative datasets, and should not -be implied by a first implementation. +This document specifies the logical format and required invariants for the following data stores: +- raster-fundamentalis (our authoritative raster store) +- tile-base (the tile-pyramid for our tile-server) +- the source-attribution table (for correct copyright attribution and data selection) + +## Source-attribution table +The source attribution table is used in raster-fundamentalis, tile-base, and in abbreviated format in the delivered tiles. +- stores a vector of structs (called Table), each struct describing one source +- the struct (called Entity) contains: + - the spatial resolution in pixel-width at the equator (EPSG:3857) + - the date of data acquisition + - the date of ingestion + - a copyright string + - a copyright link string + - a license string +- the index 0 is reserved for "no-data" +- the index must be checked to be smaller than 2^16-1, and we throw unsupported if it becomes larger (this is because of the storage format of tiles). +- the source attribution table is implemented in src/terrainlib/source_attribution.h, in the namespace source_attribution::* +- We need a `struct` declaration and a `using Table = .. ` (both versioned) +- the source-attributino table is stored in a file named source_attribution_table.ard (.alpine raster data) +- there is one per directory tree (it's valid for all tiles stored within the same directory tree, all siblings and children). +- given a tile (either rf or tb), the lookup of the source attribution table is first in the same directory and then in all parrent dirs, until a source_attribution_table.ard is found (or a failure is thrown). + +## raster-fundamentalis (rf) format +raster-fundamentalis is our authoritative raster-store, containing only the data and no overviews / downsampled version. +- The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by tile IDs (radix::tile::ID, https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). +- unlike a tile pyramid, not every level is occupied (there is no downsampled versions of the data). +- stored tiles have a resolution of 4096x4096 pixels +- Every stored pixel has one payload value (can be vector type) and one source attribution index. +- Coarse physical tiles (e.g. zoom level 10) may coexist with more accurate descendants (e.g. zoom level 15). +- the source attribution index is stored as uint16, and indexes into a global source attribution table (see above). +- data is stored in radix::Raster objects (one for source attribution index, one for the actual data) +- raster-fundamentalis is implemented in src/terrainlib/raster/fundamentalis.h, in the namespace raster::fundamentalis::* +- tiles are stored in .arft files (alpine raster fundamentalis tile) + +### unclear +- exact quadtree format / how to reuse structura fundamentalis code +- file names / paths / index file name + +## Tile-base Format (tb) +tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. it is used directly by the tile-server to generate tiles at the requested resolution and format. +- The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by tile IDs (radix::tile::ID, https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). +- every level is occupied, and every level selects an adequate data source +- stored tiles have a resolution of 4096x4096 pixels (?) +- Every stored pixel has one payload value (can be vector type) and one source attribution index. +- the source attribution index is stored as uint16, and indexes into a global source attribution table (see above) +- data is stored in radix::Raster objects (one for source attribution index, one for the actual data) +- tile-base is implemented in src/terrainlib/raster/tile_base.h, in the namespace raster::tile_base::* +- tiles are stored in .artb files (alpine raster tile base) + +### tile-server +- should generate tiles of requested resolution and pixel type (vertex|area) on the fly +- requests by url, e.g.: layer/vertex|area/resolution/z/x/y.ending + +### unclear +- how to build tile-base from raster-fundamentalis +- details of the tile server +- file names / index file name + +## serialization / deserialization and versioning +- all files are serialised with zpp::bits in two levels + - first level contains: + - an uint64 long file type specific random magic number, generated once at coding time, as an definitive file type identifier + - a version number (uint32) + - a checksum for the payload, computed from the compressed data + - an enum for the compression algorithm + - a payload (byte vector), the second level + - the second level is a compressed byte array. the compressed payload is deserialised directly into the respective versioned data classes (tile or source attribution table) +- data structs are stored in versioned namespaces, e.g.: raster::fundamentalis::v1::Tile +- outside the versioned namespace, there is a using declaration for the newest version +- outside the versioned namespace, there is serialization function, taking only the newest version +- outside the versioned namespace, there is a deserialization function, taking a byte stream, and returning the newest version (convert to the newest version, if the payload encodes an older version) +- conversion to newer versions is done by the constructor, e.g. the v2::Tile constructor shall take a v1::Tile, and convert it to v2. once we have a v3, it would take a v2. this way we would have a conversion trail from v1 to v3. +- we have clear fails if + - the magic or version is unsupported + - if the checksum check fails + - if the compression algorithm is missing or unsupported. + - deserialization fails +- we fail by throwing + + +## to be defined +- semantic layer kind (height scalars, linear colour, gamma-encoded colour, categorical values), should be used for filtering +- human readable description? +- enumeration of layers etc? + +For now, these things will be defined in code, we will have one terrain-elevation, one surface-elevation and one ortho-photo store. later probably also a percentage store (for the snow layer) From 9350bb326ea88ed22b59ec692485d9e0b55dfae2 Mon Sep 17 00:00:00 2001 From: Adam Celarek <5292991+adam-ce@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:18:45 +0200 Subject: [PATCH 09/53] work on docs --- docs/raster-store/README.md | 20 +- docs/raster-store/architecture.md | 220 +++--------------- .../decisions-and-open-questions.md | 196 ---------------- docs/raster-store/requirements.md | 191 --------------- docs/raster-store/terminology.md | 35 +++ 5 files changed, 73 insertions(+), 589 deletions(-) delete mode 100644 docs/raster-store/decisions-and-open-questions.md delete mode 100644 docs/raster-store/requirements.md create mode 100644 docs/raster-store/terminology.md diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 61662c83..e757e417 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -4,30 +4,14 @@ This directory describes a proposed authoritative raster store and the generation of delivery tile pyramids from it. The documents are a design baseline, not a finalized binary-format specification. -The documents distinguish three kinds of statement: - -- **Confirmed** records a requirement or decision established in the design - discussion. -- **Proposed** records the current recommendation and is subject to review. -- **Open** records a question that must be resolved before the affected part - is implemented. - ## Documents -- [Requirements and terminology](requirements.md) +- [Terminology](terminology.md) - [Status quo and reuse assessment](status-quo.md) - [Architecture](architecture.md) - [Storage format](storage-format.md) - [Sampling and pyramid generation](sampling-and-generation.md) -- [Decisions and open questions](decisions-and-open-questions.md) ## Scope -The authoritative store holds Web Mercator raster data at the best available -quality, together with exact per-pixel source attribution. It is separate from -delivery tile pyramids, which are filtered, formatted, and regenerated for a -particular consumer. - -The current scope includes raster imagery of different kinds, including -height rasters used to generate geometry. It does not define vector storage, -rendering styles, mesh formats, or a final source-ranking policy. +The documents mostly hold format information, and no tool descriptions. We will be writing tools that query and transform these formats. Tool descriptions will be given later. diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index d778b83a..356cdc25 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -5,101 +5,49 @@ The design separates authoritative data management from delivery generation: ```text -Input rasters +Input rasters (GDAL) │ - │ inspect, transform to Web Mercator, select source per pixel + │ inspect, transform to Web Mercator, define source, one source per pixel -> rf_builder ▼ -Raster-store builder +raster-fundamentalis (one rf per source at the beginning) │ - │ writes a new immutable snapshot + │ rf_merger: merge two rf stores based on (vecrtor) mask, take one tile if no overlap / far from vector border, merge strategy otherwise + │ at the beginning, have xor strategy, and exact border, later we may implement linear blending ▼ -Authoritative raster store +Authoritative rf raster store │ - ├── payload chunks - ├── per-pixel source maps - ├── source catalog - └── sparse quadtree index - │ - │ reconstruct, filter, sample, encode + │ generate overviews using defined filtering strategy, all area pixels. for now simple averaging, later maybe larger filter sizes / more complex filters ▼ -Pyramid generators - ├── area-pixel texture pyramids - └── vertex-pixel height/geometry pyramids +tile-base store (one per layer, one per data version. user visible server should only need one version per layer) + ├── read by tile-server + ├── area/vertex pixel tile generation + └── select resolution, type etc by url ``` -The store resolves which source owns each stored pixel. Generators may blend -those selected payload values but do not modify the authoritative snapshot. - ## Dataset organization ### Proposed -A store root contains immutable snapshots. Each snapshot contains a manifest, -a source catalog, and one or more named raster layers: +A store root contains immutable snapshots. Each snapshot contains an index, a source attribution table and the data: ```text store/ -└── snapshots/ - └── / - ├── manifest - ├── sources - └── layers/ - ├── heights/ - │ ├── layer - │ ├── raster.index - │ └── chunks/... - └── orthophoto/ - ├── layer - ├── raster.index - └── chunks/... +└── snapshot-id/ + ├── source_attribution_table.ard + ├── index + └── chunks/...[.arft|.artb] (or, z/x/y.[arft|artb]) ``` -The names and exact hierarchy are illustrative. The important boundaries are: - -- a source catalog has snapshot-wide identity and metadata; -- each layer has a homogeneous pixel schema; -- each layer owns a sparse quadtree index; and -- each physical quadtree node maps to one atomic chunk container. - -The store manifest records the format version, snapshot identity, parent -snapshot when applicable, publication state, Web Mercator definition, and -layer list. - -## Spatial model - -### Confirmed - -The hierarchy uses `radix::tile::Id` semantics: zoom, `x`, and `y`, with four -children per node. Inputs are transformed into Web Mercator during ingestion. - -Physical payloads may occur at several quadtree levels. A physical parent can -coexist with physical descendants so that the parent remains a coherent -fallback representation. - -### Proposed - -The persistent store uses one canonical `y` convention. Slippy/XYZ is the -current recommendation because it matches common web-map paths, but this is -not yet confirmed. API callers may convert from TMS before lookup. - -A layer chooses one fixed chunk pixel dimension. Every physical node in that -layer covers the Web Mercator bounds of its tile ID with that many stored -pixels per side. Deeper nodes therefore provide twice the linear spatial -resolution at each level. - -Store chunks are non-overlapping. Any halo required for filtering or any -shared border required by a delivery format is assembled by a generator. - ## Sparse quadtree index The index uses the same four logical states as the octree index: -| State | Chunk exists | Indexed descendants | -|---|---:|---:| -| `Leaf` | yes | no | -| `Inner` | yes | yes | -| `Virtual` | no | yes | -| `Missing` | no | no | +| State | Chunk exists | Indexed descendants | +|-----------|-------------:|--------------------:| +| `Leaf` | yes | no | +| `Inner` | yes | yes | +| `Virtual` | no | yes | +| `Missing` | no | no | `Missing` is represented by absence from the index, not serialized as an entry. @@ -124,114 +72,33 @@ entries. ## Chunk model -Each physical node owns one logical chunk: - -```text -Chunk -├── identity and schema reference -├── local source table -├── payload raster -└── source-index raster -``` - -The payload and source raster have identical dimensions. Every source-index -element names exactly one local source entry. - -### Why one atomic container is proposed - -An atomic container keeps payload and provenance inseparable, makes one -hard-link represent one unchanged chunk, and prevents a snapshot from pairing -a new payload with an old source map after interruption. - -The container may internally use independently compressed blocks to support -bounded window reads. Atomicity does not require monolithic decompression. - -## Source catalog and local tables - -### Proposed - -The source catalog assigns a stable `SourceId` to every ingested source and -records metadata such as: - -- source URI or durable identity; -- content/version identity where available; -- original CRS and transform; -- original and effective resolution; -- acquisition or publication time; -- explicit priority or quality fields; -- bands, data type, colour interpretation, and NoData information; and -- ingestion software and parameters. - -Each chunk stores a compact local source table: - -```text -local index 0 → SourceId 918 -local index 1 → SourceId 42 -local index 2 → SourceId 7001 -``` - -The source map stores local indexes rather than full global IDs. Its integer -width may be selected per chunk if the container records that width. - -This arrangement provides exact per-pixel attribution while keeping common -single- or few-source chunks compact. - -`SourceId` values referenced by a hard-linked chunk must remain stable across -the complete snapshot lineage in which that chunk is reused. A child snapshot -must preserve every catalog record referenced by inherited chunks; it cannot -renumber the catalog independently. +Each physical node owns one logical chunk, see storage-format.md ## Source selection and fallback -Source selection is a builder policy, not a property of the raster container. -Its initial comparison is expected to prioritize effective pixel resolution. - -For a candidate input, the builder conceptually performs: - -```text -for each affected stored pixel: - compare candidate source with selected source - write one winning payload value - write the winning source ID -``` - -The physical hierarchy supports a separate generator decision: - -```text -physical node is sufficiently accurate and coherent - → use it directly - -physical node is insufficient and better descendants exist - → refine and combine descendants, falling back to ancestors for gaps -``` +Source selection is a policy of the tools, not a property of the raster container. +Its initial comparison is expected to prioritize effective pixel resolution, and can be represented as a per zoom level or global ordered vector of attribution indices. -These policies must not be conflated. Pixel ownership determines the contents -of one physical chunk. Refinement determines which physical chunks contribute -to a requested derived tile. +At the beginning the selection will be binary, later we may introduce blending (over pixels of one zoom level, or several zoom levels). ## Snapshot lifecycle -### Proposed - -A build never mutates a published snapshot: +A snapshot is never mutated, instead, operations build new snapshots, while reducing disk usage by using hard links: -1. Create a private staging snapshot. -2. Load the parent snapshot's manifest and indexes when updating. -3. Identify affected chunks from candidate source bounds and resolution. -4. Hard-link unchanged chunk containers into staging. -5. Rebuild changed chunk containers. -6. Write new source catalogs, layer metadata, and indexes. -7. Validate references, dimensions, checksums, and index/file agreement. -8. Atomically publish the completed snapshot. +when adding data, we would: +1. create a new sf from the new data +2. define a validity mask for the new data +3. define a source merging priority (an ordering of sources) +4. using these two, a new snapshot is generated from the new data and the existing / authoritative snapshot, hardlinking tiles without change. Because hard links share inodes, a linked container must never be opened for -in-place modification. Changed chunks are written to new temporary paths and -renamed into place. +in-place modification. Existing snapshots are considered immutable. Obsolete snapshots can be deleted, the data will be preserved if necessary due to reference counting in the inodes. +When merging, we need to create new hardlinks for unchanged rf tiles (taken completely from either snapshot), and we need to create new rf tiles if the new tile shares information from both. Cross-filesystem hard links cannot be created. The builder must expose this as -a clear configuration or error rather than discovering it after a long build. +a clear configuration error rather than discovering it after a long build. -## Pyramid generator interface +## Pyramid generator interface (to be confirmed, LLM, do not use the following without consultation) A generator requests a layer over a target tile and sampling specification. The store reader supplies selected authoritative values and provenance over a @@ -255,18 +122,3 @@ The store owns: - exact stored source map; - source catalog lookup; and - consistent window access across chunk boundaries. - -## Scaling implications - -The 10-100 MB chunk target reduces filesystem entry and index counts but makes -whole-chunk rewrites expensive. The builder should therefore: - -- determine affected chunks before decoding parent data; -- bound the number of resident payload/source-map pairs; -- stream source windows rather than load complete input datasets; -- use internal block compression if partial reads are frequent; and -- avoid materializing world-scale tile vectors before parallel execution. - -The first implementation should prefer correctness and measurable behaviour. -Compression blocks, caches, aggregate index metadata, and scheduling should be -driven by profiles and representative datasets. diff --git a/docs/raster-store/decisions-and-open-questions.md b/docs/raster-store/decisions-and-open-questions.md deleted file mode 100644 index 02371b4f..00000000 --- a/docs/raster-store/decisions-and-open-questions.md +++ /dev/null @@ -1,196 +0,0 @@ -# Decisions and open questions - -This register prevents confirmed requirements, recommendations, and unresolved -questions from being mixed together. A future architectural decision record -may replace an entry when implementation requires a durable choice. - -## Confirmed decisions - -### D1: Separate authoritative and derived layers - -The raster store is authoritative. Delivery tile pyramids are derived and may -be regenerated with different sampling, filtering, dimensions, or codecs. - -### D2: Canonical Web Mercator ingestion - -Inputs are transformed into Web Mercator while building the authoritative -store, analogous to the SF builder transforming geometry into its canonical -ECEF space. - -### D3: Use a quadtree and Radix tile identity - -The hierarchy is two-dimensional and uses `radix::tile::Id`, not -`octree::Id`. - -### D4: Preserve physical coarse fallbacks - -A physical chunk may coexist with descendants. Generators may use the parent -as a coherent coarse representation or refine into more accurate descendants. - -### D5: Store chunks are non-overlapping - -The authoritative store does not duplicate height borders. Overlapping -rendering borders are generated from a consistent filtered signal. - -### D6: One selected value and source per stored pixel - -Every stored pixel contains exactly one payload value and one source -attribution. The store does not keep alternate candidate values for that -pixel. - -### D7: Generated provenance is tile-level - -Derived filtering may blend payload values from several sources. Generated -tiles retain provenance per tile, not per output pixel. - -### D8: Generator terminology - -Use **vertex pixel** for generated values on grid vertices and **area pixel** -for generated values associated with raster cells. The names describe required -output placement, not source measurement history. - -### D9: Large authoritative chunks - -Store tiles should be approximately 10-100 MB. They are storage chunks, not -ordinary web delivery tiles. - -### D10: Radix owns the generic raster primitive - -`radix::Raster` is the common in-memory raster representation. Store -georeferencing, provenance, persistence, and snapshot lifecycle remain outside -Radix. - -## Current recommendations - -These are proposed defaults, not confirmed decisions. - -### R1: Immutable snapshots - -Publish immutable snapshots, hard-link unchanged chunk containers, and write -changed chunks to new inodes. This avoids accidental mutation through hard -links and supports discardable failed builds. - -### R2: One atomic chunk container - -Keep payload, local source table, and source map in one container so they -cannot become inconsistent and can be reused with one hard link. - -### R3: Fixed power-of-two chunk dimension per layer - -Choose a fixed side length from a logical byte budget. Do not split spatial -identities dynamically based on compressed size. - -### R4: Snapshot-wide catalog plus local source tables - -Store full metadata once in a snapshot catalog. Let each chunk map compact -local source indexes to stable catalog IDs. - -### R5: Canonical persistent tile scheme - -Persist one scheme and normalize at API boundaries. Slippy/XYZ is the current -candidate, but TMS has not been rejected. - -### R6: Explicit versioning and validation - -New format files should have magic values, versions, checksums, strict tile-ID -validation, and a rebuildable index. The current octree index binary should -not be reused as an unlabelled quadtree format. - -### R7: Global filtering across chunk boundaries - -Generators request filter halos and operate on logical global windows. They do -not clamp at internal chunk or delivery-tile boundaries. - -## Open questions - -### Spatial and layer model - -1. Are raster kinds always separate named layers with independent indexes? -2. Which exact Web Mercator definition and world extent are canonical? -3. Is the persistent scheme Slippy/XYZ or TMS? -4. What is the maximum supported zoom? -5. What exact spatial interpretation does the store assign to its own raster - elements? This is independent of vertex-pixel/area-pixel delivery policy - but must be defined for georeferencing and reconstruction. -6. Does source accuracy vary only by source, or can it vary spatially within a - source? - -### Chunk size and physical format - -7. Does 10-100 MB mean logical bytes, compressed disk bytes, or constraints on - both? -8. Are units decimal MB or binary MiB? -9. Is chunk dimension fixed globally or selected per layer? -10. Which representative payload schemas must be used to choose between - 2,048², 4,096², or another size? -11. Does the source map use `uint8_t`, `uint16_t`, or adaptive width? -12. Which container serialization, compression, block size, and checksum are - suitable for representative imagery and source maps? -13. Is `//` sufficient for filesystem scaling, or is prefix sharding - required? - -### Source metadata and selection - -14. What creates a stable `SourceId`: catalog sequence, content hash, external - ID, or a combination? -15. Which source metadata fields are mandatory? -16. How is a pixel with no valid source represented? -17. What is the first complete source-ranking policy after pixel resolution? -18. How much resolution loss is acceptable to prefer one coherent source over - a finer mosaic? -19. How are acquisition time, quality flags, explicit priority, and ties - handled? -20. Can an update remove or invalidate a previously selected source, and if - so, where does replacement data come from when alternates are not stored? - -### Filtering and generation - -21. Which reconstruction and low-pass filters are required for heights? -22. Is terrain reduction optimized only for anti-aliasing, or also for - geometric error and preservation of extrema? -23. Which colour space and alpha convention are authoritative for imagery? -24. Which semantic raster kinds require categorical or conservative - reduction rather than linear filtering? -25. What are the exact NoData normalization rules? -26. Is tile-level provenance only a source-ID set, or does it include - contribution fractions? -27. How are Web Mercator horizontal wrap and north/south boundaries filtered? -28. Must shared generated height edges be bit-identical across separate runs - and execution orders? - -### Index and implementation structure - -29. Should the octree index/storage be generalized over hierarchical ID type, - or should the raster store receive a separate 2D implementation? -30. Should `radix::tile::Id` itself gain checked root/zoom/coordinate and - serialization APIs, or should storage use a checked adapter? -31. Does the index need aggregate coverage or best-descendant-resolution data, - or can the first version derive it during traversal? -32. How is an existing index verified against chunk files without making every - open operation scan the filesystem? - -### Snapshots and operation - -33. What atomically publishes a completed snapshot? -34. Are cross-filesystem updates rejected or allowed with a copy fallback? -35. What is the retention and garbage-collection policy? -36. How does a long build resume, and how are staged chunks validated before - being trusted? -37. Which corruption and compatibility guarantees are required for long-term - archival use? - -## Suggested order for resolving questions - -The questions do not need to be answered all at once. A practical order is: - -1. Fix the store's spatial sampling interpretation and layer model. -2. Select representative payload formats and datasets. -3. Prototype chunk dimensions, source-index widths, and block compression. -4. Decide generic versus separate index/storage implementation. -5. Specify source identity, NoData, and the initial resolution ranking. -6. Implement a single-source area-pixel path with golden grid tests. -7. Add multi-source ownership and immutable snapshots. -8. Prototype vertex-pixel height filtering and seam tests. -9. Finalize operational publication, recovery, and compatibility rules. - -Each step should produce a small approved plan before code changes begin. diff --git a/docs/raster-store/requirements.md b/docs/raster-store/requirements.md deleted file mode 100644 index e31bf841..00000000 --- a/docs/raster-store/requirements.md +++ /dev/null @@ -1,191 +0,0 @@ -# Requirements and terminology - -## Goals - -### Confirmed - -The system has two distinct layers: - -1. An authoritative raster store that retains the best representation - available for every covered pixel. -2. Derived tile pyramids generated from that store for rendering or other - delivery formats. - -Inputs may use arbitrary supported coordinate reference systems. Ingestion -transforms them into Web Mercator before placing their data in the store. - -The store must support multiple raster kinds. Height data is one raster kind; -orthophotos and other image-like data are others. Pixel format, NoData -handling, filtering, and output encoding may differ by kind. - -The store must support mixed accuracy over space. A physical payload at a -coarser quadtree node may remain available as a coherent fallback while more -accurate payloads exist in descendants. - -### Proposed - -Each named raster layer has a homogeneous value schema and its own sparse -quadtree index. Layers share the same Web Mercator tile addressing and source -catalog infrastructure but do not mix incompatible pixel types in one -payload. - -The authoritative store is output-format independent. It does not contain -rendering borders or require the sampling placement of a particular delivery -format. - -## Stored pixels and provenance - -### Confirmed - -Every stored pixel has exactly: - -- one payload value; and -- one source attribution. - -A tile carries a local source table and a source map. Each source-map element -indexes one entry in the local table. A local entry identifies source metadata -held by the store. - -The store does not blend source identifiers and does not retain several -candidate payload values for one stored pixel. Source selection is resolved -before the pixel is committed. - -Generated tiles do not retain per-pixel provenance. They record provenance at -tile granularity, for example as the set of sources that contributed to the -filtered output tile. Filtering may blend payload values across source -boundaries without changing the authoritative store's provenance model. - -### Open - -- Whether a source map refers to a dataset-wide source catalog by stable ID, - or contains complete source metadata in each tile. -- The reserved representation for a pixel with no valid source. -- Whether generated tile provenance needs only a set of source IDs or also - approximate contribution fractions. - -## Tile and chunk requirements - -### Confirmed - -Store tiles are large data chunks, not ordinary 256-pixel web delivery tiles. -Their target size is approximately 10-100 MB. - -Store tiles do not duplicate rendering overlap. In particular, overlapping -height borders are generated later and are not authoritative duplicated data. - -Tiles are addressed by a two-dimensional quadtree using -`radix::tile::Id`, not `octree::Id`. - -### Proposed - -The size target should initially be interpreted as an uncompressed logical -byte budget for payload plus source map. That gives predictable peak memory, -I/O, and rewrite cost. Compressed on-disk size varies too much with content to -be the only sizing invariant. - -The pixel side length should be a power of two. Illustrative uncompressed -sizes are: - -| Side length | Pixels | RGB8 + uint16 source | float32 + uint16 source | -|---:|---:|---:|---:| -| 2,048 | 4,194,304 | 20 MiB | 24 MiB | -| 4,096 | 16,777,216 | 80 MiB | 96 MiB | - -These calculations exclude headers, the local source table, alignment, and -compression. They demonstrate that a fixed 2,048- or 4,096-pixel chunk is in -the intended range for common formats; they do not decide the final chunk -dimension. - -### Open - -- Whether the 10-100 MB target refers to logical, compressed, or both sizes. -- Whether all layers use one fixed side length or choose it from their bytes - per pixel and source-index width. -- Whether the source map uses a fixed or per-tile adaptive integer width. -- Whether payload and provenance are one atomic container or coordinated - files. - -## Accuracy and fallback - -### Confirmed - -Pixel resolution is expected to be the most important source-accuracy signal, -but the final ranking rule is not yet known. - -A coarse, coherent source should be usable directly for a coarse output tile -even when more accurate fragments exist below it. Generators must not be -forced to patch every output from the deepest available descendants. - -### Proposed - -Source metadata records effective ground resolution after reprojection as -well as original resolution where meaningful. Source selection is a policy -interface rather than a fixed comparison embedded in the storage format. - -A pyramid generator refines into descendants only when the current physical -node does not satisfy the requested output resolution or another configured -quality criterion. - -### Open - -- The resolution tolerance that permits use of one coherent source instead - of a finer mosaic. -- Tie-breaking by acquisition time, explicit priority, quality, or source - identity. -- Whether source accuracy is scalar per source or may vary spatially. - -## Updates and reuse - -### Proposed - -Published store versions are immutable snapshots. Building a new snapshot -hard-links unchanged tile containers from an earlier snapshot and writes new -containers only for changed tiles. The snapshot owns a new index and manifest. - -This is safer than mutating hard-linked files in place and makes a failed build -discardable without damaging an earlier published store. - -Hard-link reuse requires source and destination snapshots to reside on the -same filesystem. The implementation must either require that condition or -define an explicit copy fallback. - -### Open - -- Snapshot publication and atomic rename rules. -- Retention and garbage-collection policy. -- Whether cross-filesystem builds fail or copy unchanged chunks. - -## Terminology - -**Raster store tile** or **chunk** -: A large authoritative file associated with a quadtree tile ID. It is not a - delivery tile and is expected to contain roughly 10-100 MB. - -**Payload** -: The selected raster value array stored by a chunk. - -**Source map** -: A raster aligned one-to-one with the payload. Each element selects exactly - one entry in the chunk's local source table. - -**Physical node** -: A quadtree node with an authoritative chunk on disk. - -**Virtual node** -: An index-only quadtree node with descendants but no chunk of its own. - -**Vertex pixel** -: A generated value located on a grid vertex. Height tiles for mesh generation - require vertex pixels, including shared boundary positions. - -**Area pixel** -: A generated value associated with a raster cell. Ordinary texture outputs - use area pixels whose cell boundaries align with tile boundaries. - -The terms vertex pixel and area pixel describe generator outputs. They do not -assert how an original sensor or source raster produced its values. - -**Derived tile** -: A filtered and encoded output tile generated from the authoritative store. -It may have different dimensions, sampling placement, encoding, and -provenance granularity from a store chunk. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md new file mode 100644 index 00000000..cff4ab31 --- /dev/null +++ b/docs/raster-store/terminology.md @@ -0,0 +1,35 @@ +# Terminology +** payload raster ** +: A square data matrix (image), containing the actual data (e.g. elevation, colour..) + +** attribution raster ** +: A square data matrix (image), containing indices into the source-attribution table + +** source-attribution table ** +: A table of data sources, including meta data like resolution, dates etc. + +**raster-fundamentalis (rf)** +: The authoritative raster store + +**tile-base (tb)** +: basically rf with overviews, used to generate derived tiles + +**Vertex pixel** +: A generated value located on a grid vertex. Height tiles for mesh generation + require vertex pixels, including shared boundary positions. + +**Area pixel** +: A generated value associated with a raster cell. Ordinary texture outputs + use area pixels whose cell boundaries align with tile boundaries. + +The terms vertex pixel and area pixel describe generator outputs. They do not +assert how an original sensor or source raster produced its values. + + +**tile** +: A chunk of raster data with an tile ID. Rf is a store for tiles, tb is a store for tiles, and we generate derived tiles / output tiles for the client. + +**Derived / output tile** +: A filtered and encoded output tile generated from the tile base store. +It may have different dimensions, sampling placement, encoding, and +provenance granularity from a store chunk. From f6dfb159b956d4c060bda0d5e09f92f5590d3b26 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:12:01 +0200 Subject: [PATCH 10/53] Document pre-refactor SF architecture --- docs/raster-store/before-refactor/README.md | 433 ++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 docs/raster-store/before-refactor/README.md diff --git a/docs/raster-store/before-refactor/README.md b/docs/raster-store/before-refactor/README.md new file mode 100644 index 00000000..f9d3d9f0 --- /dev/null +++ b/docs/raster-store/before-refactor/README.md @@ -0,0 +1,433 @@ +# SF storage and merge architecture before the 2D/3D refactor + +This report describes the existing Structura Fundamentalis (SF) builder, +sparse octree index, storage stack, and mask-based dataset merger. Its purpose +is to identify the mechanisms that can be generalised from 3D octrees to a +shared 2D/3D hierarchy without carrying mesh- and ECEF-specific behaviour into +the common storage layer. + +The central conclusion is: + +> Generalise the sparse hierarchy, storage, traversal, merge decisions, and +> unchanged-subtree reuse. Keep GDAL ingestion, raster processing, mesh +> construction, spatial transforms, and mask geometry in dimension-specific +> adapters. + +The current storage templates are already payload-generic, but they are not +hierarchy-generic: `octree::Id` is embedded throughout indexing, traversal, +caches, disk paths, and serialization. + +## Current architecture + +```mermaid +flowchart TB + subgraph Applications + SFB[sf-builder] + SFM[sf-merger] + end + + subgraph Input + GDALR[GDAL raster datasource] + GDALV[GDAL vector mask] + OLD[Base SF dataset] + NEW[New SF dataset] + end + + subgraph Domain3D["3D and mesh policy"] + SPACE[octree::Space and ECEF bounds] + MESHBUILD[Height raster to SimpleMesh] + MESHMASK[Polygon to spherical extruded MeshMask] + MESHMERGE[Clip, combine, and texture atlas] + FALLBACK[Reconstruct child by clipping ancestor mesh] + end + + subgraph GenericCandidate["Mostly generalisable mechanisms"] + INDEX[IndexMap and NodeStatus] + WALK[Depth-first and breadth-first traversal] + DRIVER[Merger and merge result actions] + STORAGE[Storage and IndexedStorage] + RAW[RawStorage] + CODEC[Codec policy] + LAYOUT[Layout strategy] + LINK[Hard-link unchanged payload] + end + + GDALR --> SFB + SFB --> SPACE + SFB --> MESHBUILD + MESHBUILD --> STORAGE + + OLD --> SFM + NEW --> SFM + GDALV --> MESHMASK + MESHMASK --> SFM + SFM --> DRIVER + DRIVER --> FALLBACK + DRIVER --> MESHMERGE + DRIVER --> STORAGE + + STORAGE --> INDEX + STORAGE --> RAW + RAW --> CODEC + RAW --> LAYOUT + RAW --> LINK + WALK --> INDEX + DRIVER --> WALK +``` + +The significant code boundaries are: + +- hierarchy identity: [`octree::Id`](../../../src/terrainlib/octree/Id.h); +- sparse topology: [`IndexMap`](../../../src/terrainlib/octree/IndexMap.h); +- traversal: [`octree::traverse`](../../../src/terrainlib/octree/traverse.h); +- logical storage: + [`Storage_`](../../../src/terrainlib/octree/storage/Storage.h); +- raw file and hard-link storage: + [`RawStorage_`](../../../src/terrainlib/octree/storage/RawStorage.h); +- disk paths: [`disk::Layout`](../../../src/terrainlib/octree/disk/Layout.h); +- merge driver: [`Merger`](../../../src/sf_merger/merge.h). + +## Sparse index model + +The index has four logical states: + +| State | Physical payload | Indexed descendants | +|---|---:|---:| +| `Missing` | no | no | +| `Virtual` | no | yes | +| `Leaf` | yes | no | +| `Inner` | yes | yes | + +`Missing` is represented by absence from `IndexMap`. The other states are +defined by +[`NodeStatus`](../../../src/terrainlib/octree/NodeStatus.h). + +```mermaid +stateDiagram-v2 + [*] --> Missing + + Missing --> Leaf: add physical root or node + Missing --> Virtual: add deeper descendant + + Virtual --> Inner: add physical payload here + Leaf --> Inner: add physical descendant + + Inner --> Leaf: remove final descendant + Leaf --> Missing: remove payload and no descendants + Virtual --> Missing: remove final descendant + Inner --> Virtual: remove payload but keep descendants +``` + +This state machine is dimension-independent. What is currently 3D-specific is +how a node finds its parent and children: `octree::Id` uses three +Morton-interleaved coordinates and eight children. + +Traversal is also reusable in concept. It: + +1. looks up the root in the sparse index; +2. visits only entries that exist; +3. enumerates children through the concrete ID type; and +4. supports depth-first or breadth-first order plus a refinement predicate. + +The only reason +[`traverse()`](../../../src/terrainlib/octree/traverse.h) +is 3D is its dependency on `octree::Id` and `octree::Id::children()`. + +## Creating an SF dataset from a GDAL datasource + +The current SF builder creates physical mesh nodes at one requested octree +level. + +```mermaid +sequenceDiagram + participant CLI as sf-builder CLI + participant DS as Dataset and GDAL + participant Space as octree::Space + participant Build as terrainbuilder + participant Reader as RawDatasetReader + participant Raster as Raster and RasterMask + participant Mesh as mesh operations + participant Store as Storage + participant Index as IndexMap and terrain.index + + CLI->>DS: Open raster datasource + DS-->>CLI: SRS, 2D bounds, and height range + + CLI->>Space: Transform bounds to ECEF + Space-->>CLI: Smallest enclosing octree node + + loop Intersecting children until target level + CLI->>Space: Node bounds and intersection tests + end + + loop Each target-level node + CLI->>Build: build_patch using node bounds + Build->>Reader: Map SRS bounds to source pixels + Reader->>DS: RasterIO band 1 as float + Reader-->>Raster: Height raster + Raster->>Raster: Build NoData validity mask + Raster->>Mesh: Generate positions and triangle grid + Mesh->>Mesh: Clip to node volume + Mesh->>Mesh: Generate UVs and transform output SRS + Mesh-->>Store: SimpleMesh with optional texture + Store->>Store: MeshCodec writes node file + end + + Store->>Index: Scan output paths + Index->>Index: Add leaves and virtual ancestors + Index->>Store: Write terrain.index +``` + +### Current components + +| Responsibility | Current component | +|---|---| +| Own and open a GDAL datasource | [`Dataset`](../../../src/terrainlib/Dataset.h) | +| One-time GDAL registration | [`initialize_gdal_once()`](../../../src/terrainlib/init.cpp) | +| ECEF octree geometry | [`octree::Space`](../../../src/terrainlib/octree/Space.h) | +| Batch enumeration and orchestration | [`build_all_patches()`](../../../src/sf_builder/terrainbuilder.cpp) | +| Direct source-window reading | [`RawDatasetReader`](../../../src/sf_builder/raw_dataset_reader.h) | +| Height-to-mesh conversion | [`build_reference_mesh_patch()`](../../../src/sf_builder/mesh_builder.cpp) | +| Optional imagery | [`TileProvider`](../../../src/sf_builder/tile_provider.h) and [`texture_assembler.h`](../../../src/sf_builder/texture_assembler.h) | +| Mesh persistence | `Storage_` | +| Sparse topology | `IndexMap` | +| Index creation and serialization | `save_or_create_index()` and `terrain.index` | + +For a new output, `build_all_patches()` opens unindexed storage. Individual +saves therefore do not build the index incrementally. The final +`save_or_create_index()` recursively scans the output directory, parses node +paths, and calls `IndexMap::add()`. + +### More suitable 2D GDAL path + +The separate +[`DatasetReader`](../../../src/tile_builder/DatasetReader.h) +is closer to what a 2D SF or raster-store builder needs: + +- it accepts requested target-SRS bounds and output dimensions; +- it creates a GDAL warped VRT; +- it reprojects into the target grid; and +- it returns `radix::Raster`. + +[`Tiler`](../../../src/tile_builder/Tiler.h) and +[`ParallelTiler`](../../../src/tile_builder/ParallelTiler.h) +already enumerate `radix::tile::Id` values over dataset bounds. + +Their grid calculations are useful, but `ParallelTileGenerator` is a +delivery-file writer rather than an SF snapshot builder. + +## Mask-based SF dataset merging + +```mermaid +flowchart TD + START[Open base and new datasets as IndexedStorage] + MASK[Read vector mask through GDAL and OGR] + PREP[Polygon repair, sphere projection, triangulation, radial extrusion] + ROOT[Start at octree root] + STATUS[Read left and right NodeStatus] + POLICY[Masked visitor evaluates node] + RECURSE[Clip mask to node bounds and recurse into 8 children] + LEFT[Keep base subtree] + RIGHT[Keep new subtree] + MERGE[Clip base outside mask and new inside mask] + ATLAS[Combine meshes and rebuild texture atlas] + WRITE[Write new physical node] + COPY[Traverse unchanged source subtree] + LINK[Hard-link each physical payload] + INDEX[Scan and save new output index] + + START --> ROOT + MASK --> PREP --> POLICY + ROOT --> STATUS --> POLICY + + POLICY -->|virtual or refinement required| RECURSE + RECURSE --> STATUS + + POLICY -->|unchanged left| LEFT --> COPY + POLICY -->|unchanged right| RIGHT --> COPY + COPY --> LINK + + POLICY -->|boundary node| MERGE --> ATLAS --> WRITE + POLICY -->|no retained data| INDEX + + LINK --> INDEX + WRITE --> INDEX +``` + +### Merge components + +- [`Merger`](../../../src/sf_merger/merge.h) is the recursive dispatcher. +- [`NodeLoader`](../../../src/sf_merger/NodeLoader.h) reports status and loads + payloads. +- When an exact payload is missing, `NodeLoader` searches physical ancestors + and reconstructs the requested child by clipping the ancestor mesh. That + reconstruction is 3D-specific. +- [`NodeData`](../../../src/sf_merger/merge/NodeData.h) lazily exposes a node + payload to a visitor. +- [`Result`](../../../src/sf_merger/merge/Result.h) gives the driver four + actions: recurse, ignore, preserve one source unchanged, or write a merged + payload. +- [`Masked`](../../../src/sf_merger/merge/visitor/Masked.h) implements + mesh-and-mask policy. +- [`NodeWriter`](../../../src/sf_merger/NodeWriter.h) writes changed nodes or + copies unchanged subtrees. + +The vector-mask pipeline in +[`mask.h`](../../../src/sf_merger/mask.h) +is almost entirely 3D and Earth-specific after OGR polygon loading: + +```text +OGR polygons + -> referenced 2D polygon mask + -> ECEF and spherical projection + -> triangulated surface + -> extrusion over an Earth-radius interval + -> closed 3D MeshMask +``` + +For 2D raster merging, only the OGR polygon loading and CRS transformation +ideas remain relevant. Triangulation, spherical projection, extrusion, 3D +clipping, and texture atlases should not enter the generic core. + +## Hard-link behaviour + +Hard links are implemented at the lowest storage layer in +[`RawStorage_::copy_from()`](../../../src/terrainlib/octree/storage/RawStorage.h): + +```mermaid +flowchart TD + COPY[copy_from node] + EXISTS{Source payload exists?} + EXT{Source and target extensions match?} + DECODE[Decode source payload] + ENCODE[Encode into target format] + REMOVE[Remove existing target] + DIRS[Create parent directories] + HARDLINK[Create hard link] + DONE[Add physical node to target index] + + COPY --> EXISTS + EXISTS -->|no| ERROR1[FileNotFound] + EXISTS -->|yes| EXT + EXT -->|no| DECODE --> ENCODE --> DONE + EXT -->|yes| REMOVE --> DIRS --> HARDLINK --> DONE +``` + +Properties: + +- Hard linking is payload-agnostic and fully generalisable. +- It happens only when source and target filename extensions match. +- Different formats cause decode and re-encode through the codec. +- There is no copy fallback if hard-link creation fails, including across + filesystems. +- Snapshot immutability is not enforced by `Storage_`; it is a caller-level + convention. +- The existing target file is removed before the link is created. +- The new output receives a fresh index; `terrain.index` itself is not linked. + +## Generalisation assessment + +| Mechanism | Assessment | Required change | +|---|---|---| +| `NodeStatus` state model | Reuse essentially unchanged | Move outside `octree` naming | +| `IndexMap` state transitions | Generalise | Template over node key and tree traits | +| Sparse traversal | Generalise | Obtain children through tree traits | +| `Storage_` | Good starting point | Also template over node key and layout | +| `RawStorage_` hard-link behaviour | Generalise | Key-neutral paths and explicit fallback policy | +| Codec concept | Reuse | No dimensional dependency | +| Cache interface | Generalise | Key type is currently `octree::Id` | +| Layout strategy | Generalise | Key-neutral path API | +| Index file | Replace or version | Record topology kind, format version, and validated key | +| Merge result algebra | Generalise | `Merged` must hold generic payload, not `SimpleMesh` | +| Recursive merge driver | Generalise | Tree traits, payload, loader/writer, and policy | +| Unchanged-subtree reuse | Reuse | Enumerate every physical state, including `Inner` | +| GDAL datasource ownership | Reuse or adapt | Typed, multi-band reads and explicit NoData | +| Mask loading through OGR | Reuse or adapt | Produce dimension-specific mask representation | +| `octree::Space` | Keep 3D | Add a sibling 2D grid or space policy | +| Mesh clipping, atlas, and UV code | Keep 3D | Do not place in generic storage | +| ECEF and spherical mask conversion | Keep 3D | 2D uses polygon classification or rasterisation | +| `MeshCodec` | Keep 3D | Add a raster chunk codec or container | + +## Existing `Inner` limitation + +The index supports `Inner`, but the SF merger effectively does not: + +- `Merger::call_merge()` dispatches only `Missing`, `Leaf`, and `Virtual` + combinations. +- `Masked::visit()` declares `Inner` unreachable. +- `NodeWriter::copy_subtree_to_output()` skips `Virtual` and asserts every + other visited node is `Leaf`. + +This matters because a physical coarse node coexisting with physical +descendants is exactly the `Inner` case. A general 2D/3D implementation should +make "has a physical payload" and "has indexed descendants" independent +properties and handle all four states throughout traversal and merging. + +## Recommended target architecture + +```mermaid +flowchart TB + subgraph Core["Dimension-neutral hierarchical store"] + TRAITS["TreeTraits<Key>
root, parent, children, validation"] + SI["SparseIndex<Key>"] + TR["Traversal<Key>"] + ST["Storage<Key, Payload, Codec>"] + DL["DiskLayout<Key>"] + ME["MergeEngine<Key, Payload, Policy>"] + SNAP[Snapshot and subtree reuse] + end + + subgraph D2["2D adapter"] + TILE[radix::tile::Id] + GRID[Web Mercator grid] + RASTER[RasterChunk] + GDAL[GDAL warped-window reader] + MASK2[2D polygon or raster mask policy] + RC[Raster codec and container] + end + + subgraph D3["3D adapter"] + OCT[octree::Id] + ECEF[ECEF octree space] + MESH[SimpleMesh] + MASK3[Extruded mesh-mask policy] + MC[MeshCodec] + end + + TILE --> TRAITS + OCT --> TRAITS + + RASTER --> ST + MESH --> ST + RC --> ST + MC --> ST + + MASK2 --> ME + MASK3 --> ME + + SI --> TR + SI --> ST + DL --> ST + TR --> ME + ST --> SNAP + ME --> SNAP +``` + +The central abstractions should therefore be: + +1. `TreeTraits`: root, parent, children, maximum depth, and key + validation. +2. `SparseIndex`: the current `IndexMap` algorithm. +3. `Traversal`: DFS, BFS, and refinement independent of child + count. +4. `DiskLayout`: key-to-path and path-to-key conversion. +5. `Storage`: logical storage and index maintenance. +6. `MergeEngine`: paired sparse-tree walking. +7. Generic merge actions such as `Recurse`, `Ignore`, `KeepLeft`, `KeepRight`, + and `Write`. +8. A snapshot copier that hard-links unchanged physical nodes without knowing + their payload type. +9. Separate 2D and 3D spatial, mask, and fallback policies. + +The GDAL builder and mask merger should be clients of this core, not part of +it. From 037501c9d4f307a15d195410bd7c7a9bc15ac479 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:09:33 +0200 Subject: [PATCH 11/53] docs: add 2D and 3D store refactor plan --- docs/raster-store/refactor-plan.md | 577 +++++++++++++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 docs/raster-store/refactor-plan.md diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md new file mode 100644 index 00000000..026ef6e1 --- /dev/null +++ b/docs/raster-store/refactor-plan.md @@ -0,0 +1,577 @@ +# 2D/3D hierarchical store refactor plan + +Status: proposal for review. This document is an implementation plan, not a +record of completed work. + +## Decisions already made + +- The shared implementation will live in `src/terrainlib/store` and use the + `store` namespace. +- The 2D implementation will live in `src/terrainlib/raster_store` and use the + `raster_store` namespace. +- Existing 3D Structura Fundamentalis datasets must remain readable and + writable without changing their on-disk contract. +- 3D compatibility includes both existing path layouts: + `flat` and `level_and_coordinate_directories`. +- The 2D raster-fundamentalis format is a separate format. Requirements in + this directory apply to that 2D format and must not be retrofitted onto + existing 3D datasets. +- A path layout strategy should contain a stable identifier and two + operations: key to relative path, and relative path to key. It should not + require an inheritance hierarchy, RTTI, global self-registration, or heap + allocation. +- 3D geometry, ECEF bounds, mesh codecs, mesh reconstruction, mask geometry, + and raster-specific processing remain outside the shared store. + +## Goals + +1. Use one sparse hierarchy implementation for `octree::Id` and + `radix::tile::Id`. +2. Use one traversal, storage, cache, codec boundary, and unchanged-subtree + copier for 2D and 3D. +3. Make paired-tree walking reusable without putting mesh or raster merge + policy into the shared layer. +4. Replace the current layout-strategy class hierarchy with small path-mapping + values backed by function pairs. +5. Preserve all valid existing 3D index files and payload paths. +6. Introduce the 2D storage adapter without inventing unspecified raster file + details. +7. Land the refactor in small, testable steps. Every phase should build and + pass tests before the next phase begins. + +## Non-goals + +- Changing `octree::Id`, `octree::Space`, `IdRect`, `OddLevelShifted`, or + other 3D spatial calculations. +- Defining or implementing GDAL ingestion, raster resampling, filtering, + source selection, or mask rasterisation. +- Defining the `.arft` payload or source-attribution-table serialization + beyond the requirements already in [storage-format.md](storage-format.md). +- Implementing an `rf_builder`, `rf_merger`, tile-base generator, or tile + server in this refactor. +- Changing the existing 3D hard-link policy by adding a silent file-copy + fallback. +- Refactoring unrelated octree, DAG, mesh, or tile-builder code. + +## Compatibility contract + +Before moving code, tests must lock down the following 3D behaviour: + +| Item | Required compatibility | +|---|---| +| Index filename | `terrain.index` | +| Index field order | layout ID, preferred extension, index map | +| Node-key encoding | existing `octree::Id` level/index serialization | +| Node-status encoding | `Leaf = 0`, `Inner = 1`, `Virtual = 2` | +| Flat layout ID | `flat` | +| Flat path | `-` | +| Coordinate layout ID | `level_and_coordinate_directories` | +| Coordinate path | `///` | +| Default layout | existing level/coordinate layout | +| Layout detection | both existing layouts remain detectable | +| Copy with equal extensions | hard link, or an explicit error | +| Copy with different extensions | decode and encode through the codec | + +Compatibility means that the refactored code can open datasets written before +the refactor and produces datasets that the pre-refactor code can open. Exact +byte-for-byte rewriting of an unordered index map is not required, but the +serialized schema and values must remain compatible. + +The 3D index disk type should remain a versioned 3D adapter. The shared store +must not add a topology field, new header, checksum, or compression layer to +`terrain.index`. + +## Proposed source boundary + +```text +src/terrainlib/ +├── store/ +│ ├── NodeStatus.h +│ ├── NodeStatusOrMissing.h +│ ├── Traits.h +│ ├── Index.h +│ ├── traverse.h +│ ├── PathMapping.h +│ ├── Layout.h +│ ├── Codec.h +│ ├── CopyError.h +│ ├── RawStorage.h +│ ├── Storage.h +│ ├── IndexedStorage.h +│ ├── copy_subtree.h +│ ├── cache/ +│ │ ├── Interface.h +│ │ ├── Dummy.h +│ │ └── Lru.h +│ └── merge/ +│ ├── Action.h +│ └── walk.h +├── octree/ +│ ├── Id.h +│ ├── StoreTraits.h +│ ├── store_layout/ +│ │ ├── Flat.h +│ │ ├── LevelAndCoordinateDirectories.h +│ │ └── Mappings.h +│ └── storage/ +│ ├── IndexFile.h +│ └── open.h +└── raster_store/ + ├── StoreTraits.h + ├── IndexFile.h + ├── Storage.h + └── store_layout/ + └── Zxy.h +``` + +The exact file grouping may be collapsed if a file would only contain a few +lines. The important boundaries are: + +- `store` contains dimension- and payload-neutral mechanisms; +- `octree` contains the legacy 3D format and key adapters; +- `raster_store` contains the new 2D format and key adapters; and +- subdirectory names match their namespaces where a subnamespace is used. + +Temporary forwarding headers and aliases under `octree` are allowed during +migration. They must not contain a second implementation. + +## Shared interfaces + +The names below are the intended shape, not signatures that must be copied +verbatim without testing. + +### Hierarchy traits + +The store should be parameterized by one traits type rather than assuming that +all key classes expose identical member functions: + +```cpp +template +concept HierarchyTraits = requires(typename Traits::Key key) { + typename Traits::Key; + typename Traits::Hasher; + { Traits::root() } -> std::same_as; + { Traits::parent(key) }; + { Traits::children(key) }; + { Traits::is_valid(key) } -> std::same_as; +}; +``` + +The concrete names should be: + +```cpp +store::Index +store::Index +``` + +`octree::StoreTraits` adapts the existing optional parent/children API without +changing `octree::Id`. + +`raster_store::StoreTraits` adapts `radix::tile::Id` and must: + +- treat zoom zero as the only root; +- never call `radix::tile::Id::parent()` at zoom zero, where it underflows; +- reject coordinates outside `[0, 2^zoom)`; +- define a supported maximum zoom without an overflowing shift; +- use `radix::tile::Id::Hasher`; and +- accept only one canonical XYZ/Slippy interpretation at the persistent + boundary. + +The shared code must obtain roots, parents, children, validation, and hashing +through the traits. It must not use dimension checks or specialize behaviour +on key types internally. + +### Sparse index and traversal + +Move the existing four-state model to: + +```cpp +store::NodeStatus +store::NodeStatusOrMissing +store::Index +store::traverse(index, visitor, refine, root, order) +``` + +The index algorithm remains the current one: + +- adding a physical descendant creates virtual ancestors; +- adding a payload to a virtual node makes it `Inner`; +- removing a payload from an `Inner` node makes it `Virtual`; +- removing the final descendant collapses virtual ancestors; and +- a physical parent becomes `Leaf` after its last descendant is removed. + +Traversal must follow only indexed nodes and use `Traits::children`. Child +order is the order supplied by the traits and is therefore deterministic per +hierarchy, not universally fixed by `store`. + +### Path mappings + +Replace `octree::disk::layout::Strategy` and +`octree::disk::layout::StrategyRegister` with a value similar to: + +```cpp +template +struct store::PathMapping { + std::string_view id; + std::filesystem::path (*key_to_path)( + const Key&, std::string_view extension_with_dot); + std::optional (*path_to_key)( + const std::filesystem::path& relative_path); +}; +``` + +`store::Layout` owns the base directory, preferred extension, and one +`PathMapping`. It only adds/removes the base directory around the two +mapping functions. + +The stable ID is format metadata, not a third strategy operation. The +dimension adapters provide ordinary lookup functions: + +```cpp +octree::store_layout::flat() +octree::store_layout::level_and_coordinate_directories() +octree::store_layout::from_id(id) +octree::store_layout::all() + +raster_store::store_layout::zxy() +raster_store::store_layout::from_id(id) +``` + +This retains runtime selection from an index file while removing virtual +dispatch, RTTI type-to-ID lookup, static registration, and ownership through +`unique_ptr`. + +Path parsers must validate the complete relative path and the expected file +extension at the `Layout` boundary. They must return an error or `nullopt`; +they must not assert on input read from disk. + +### Storage and format adapters + +Generalize these mechanisms over traits, payload, and codec: + +```cpp +store::RawStorage +store::Storage +store::IndexedStorage +store::cache::Interface +``` + +The payload codec remains path-based and key-neutral. Mesh and raster codecs +remain with their payload domains. + +Index serialization is not a responsibility of `store::Index`. Opening and +saving a dataset must receive a dimension-specific format adapter which +provides: + +- the index filename; +- index read/write conversion; +- mapping lookup by stable ID; +- the default mapping; and +- the mappings considered during legacy directory scanning. + +This adapter may be a compile-time policy or a small value of function +pointers. Choose the smaller implementation after the Phase 0 tests exist. +It must not reintroduce a class hierarchy or global registration. + +For 3D, the adapter reads and writes the current `octree` index DTO unchanged. +For 2D, it reads and writes a separately versioned +`raster_store::v1` DTO using the serialization envelope required by +[storage-format.md](storage-format.md). + +Automatic dirty-index saving currently happens in the 3D storage destructor. +Preserve that behaviour for existing 3D entry points during the migration. +The new 2D snapshot API should require an explicit finalization/publication +step; a destructor must not make an incomplete snapshot authoritative. + +### Unchanged-subtree reuse + +Move payload-neutral subtree copying out of `sf_merger::NodeWriter`. The +shared operation should: + +1. traverse an indexed source subtree; +2. skip `Virtual` nodes; +3. copy physical payloads for both `Leaf` and `Inner`; +4. use `Storage::copy_from` so equal-format payloads are hard-linked; +5. update the target index incrementally; and +6. return an error instead of asserting or terminating. + +This fixes the current incorrect assumption that every non-virtual visited +node is a `Leaf`. + +Hard-link rules: + +- never modify an existing linked payload in place; +- matching extensions require hard-link creation; +- different extensions retain the current decode/re-encode path; +- hard-link failure is explicit; +- 2D snapshot tools must preflight that source and destination support hard + links before a long operation starts; and +- no silent copy fallback is introduced by this refactor. + +### Paired hierarchy walking + +Extract only the dimension-neutral control flow from `sf_merger::Merger`. +The shared walker obtains the left and right status for a key and asks a +policy for one of: + +```cpp +store::merge::Recurse +store::merge::Ignore +store::merge::KeepLeft +store::merge::KeepRight +store::merge::Write +``` + +The walker owns recursion and unchanged-subtree reuse. The policy owns +selection and payload combination. + +All 16 combinations of `Missing`, `Leaf`, `Inner`, and `Virtual` must be +handled. Unsupported combinations may return a typed error, but they must not +fall into `UNREACHABLE()`. + +The existing 3D adapter retains: + +- `NodeLoader` ancestor mesh reconstruction; +- ECEF node bounds; +- mesh masks and clipping; +- mesh combination and texture atlas generation; and +- mesh validation and auxiliary texture writes. + +A future 2D merger can supply a raster policy without changing the shared +walker. Implementing that policy is outside this refactor. + +## Implementation phases + +Each phase should be one reviewable commit unless the tests and implementation +are clearer as two commits. Do not begin a later phase while the current phase +has failing tests. + +### Phase 0 — Capture current compatibility + +No production behaviour changes. + +1. Add golden 3D fixtures created by the current code: + - one `terrain.index` using `flat`; + - one using `level_and_coordinate_directories`; + - physical payload paths for a root, child, and deeper descendant; and + - an index containing `Leaf`, `Virtual`, and `Inner`. +2. Test that both fixtures open, resolve the expected IDs and extensions, and + traverse the expected sparse nodes. +3. Add path round-trip tests for boundary IDs and both layouts. +4. Add storage tests for: + - matching-extension hard links; + - different-extension decode/re-encode; + - overwrite rejection; + - indexed and unindexed opens; and + - final index creation by directory scan. +5. Record the pre-refactor public aliases used by `sf_builder`, `sf_merger`, + `sf_index_browser`, `dag_builder`, and `dag_convert_debug`. + +Exit criterion: the compatibility tests pass against the untouched +implementation and fail when any stable filename, layout ID, path encoding, +status value, or index field order is deliberately changed. + +### Phase 1 — Extract topology into `store` + +1. Move `NodeStatus` and `NodeStatusOrMissing` to `store`, preserving their + underlying values and serialization. +2. Introduce the hierarchy-traits concept and `octree::StoreTraits`. +3. Convert `IndexMap` into `store::Index`. +4. Convert traversal into `store::traverse`. +5. Add `raster_store::StoreTraits` for `radix::tile::Id`. +6. Run the same index-transition and DFS/BFS tests with both trait types. +7. Provide temporary `octree` aliases so downstream migration is separate + from the algorithm extraction. + +Exit criterion: 2D and 3D keys pass the same topology suite; existing 3D +callers still build through aliases; no filesystem code has changed. + +### Phase 2 — Replace disk layout strategies + +1. Add `store::PathMapping` and `store::Layout`. +2. Port the two existing 3D layouts to ordinary function pairs without + changing paths or stable IDs. +3. Replace the singleton strategy registry with explicit `from_id()` and + `all()` functions in the 3D adapter. +4. Port layout guessing to consume a span of mappings supplied by the format + adapter. +5. Add the proposed 2D `z/x/y.arft` mapping only after the review decision + listed below is resolved. +6. Switch path and layout-detection tests to the new implementation. +7. Delete the old strategy base class, registration machinery, and concrete + strategy classes once no call site uses them. + +Exit criterion: both legacy fixtures resolve to identical payload paths; +there is no layout inheritance, RTTI lookup, static registrar, or owning +strategy pointer. + +### Phase 3 — Generalize storage and index lifecycle + +1. Move the codec concept, copy error, raw storage, caches, logical storage, + and indexed storage into `store`. +2. Replace every embedded `octree::Id` with `Traits::Key`. +3. Keep payload codecs outside the shared module: + `octree::MeshCodec` remains 3D, and the future `.arft` codec remains under + `raster_store`. +4. Split generic directory scanning from 3D index serialization. +5. Implement the small format-adapter boundary described above. +6. Keep the current 3D `terrain.index` DTO and open functions as compatibility + adapters over the shared storage. +7. Migrate the existing octree storage aliases and all application callers. +8. Preserve the current 3D destructor-save behaviour until all callers have + explicit index finalization. + +Exit criterion: all existing applications build and all Phase 0 fixtures pass +through the shared storage implementation. No second storage implementation +remains under `octree`. + +### Phase 4 — Generalize subtree reuse and paired walking + +1. Add the shared unchanged-subtree copier. +2. Test copies containing `Leaf`, `Virtual`, and `Inner` nodes. +3. Add the paired hierarchy walker and typed actions. +4. Cover all 16 status pairs with table-driven tests. +5. Adapt the 3D merger to the shared walker while keeping mesh policy in + `sf_merger`. +6. Remove generic recursion and copy logic from `sf_merger::Merger` and + `NodeWriter`. +7. Add a 3D integration test proving an unchanged subtree is hard-linked and + a changed boundary node is newly written. + +Exit criterion: the existing 3D merger behaviour is preserved, `Inner` no +longer reaches `UNREACHABLE()`, and the shared walker contains no mesh, ECEF, +GDAL, OpenCV, or raster dependencies. + +### Phase 5 — Add the 2D raster-fundamentalis adapter + +This phase starts only after the unresolved 2D format decisions below are +edited into decisions in this document. + +1. Add the checked 2D persistent-key conversion around `radix::tile::Id`. +2. Add the chosen 2D path mapping and stable ID. +3. Define the versioned 2D index DTO and index filename. +4. Use the required magic/version/checksum/compression serialization envelope. +5. Add the 2D format adapter and storage aliases under + `raster_store`. +6. Exercise storage with a small test codec if the final `.arft` codec is not + implemented yet; do not make `.arft` claims from a placeholder codec. +7. Test: + - invalid and boundary tile IDs; + - index serialization and validation; + - `Leaf`/`Inner` coexistence; + - sparse traversal and ancestor lookup; + - path round trips; + - snapshot hard-link reuse; and + - explicit cross-filesystem/preflight failure. + +Exit criterion: the same shared store can create, open, traverse, and reuse a +minimal 2D raster-fundamentalis fixture without changing the 3D fixtures. + +### Phase 6 — Cleanup and documentation + +1. Remove temporary forwarding headers that no repository caller needs. +2. Remove obsolete files under `octree/disk` and the old generic + implementation under `octree/storage`. +3. Keep only 3D key, format, codec, and compatibility adapters under + `octree`. +4. Update includes, CMake source lists, and precompiled-header includes. +5. Update [architecture.md](architecture.md), + [status-quo.md](status-quo.md), and the before-refactor report with links to + the implemented boundary. Preserve the before-refactor report as history; + do not rewrite it as if it described the new code. +6. Document the final public names and a minimal 2D/3D opening example. + +Exit criterion: repository search finds no generic implementation tied to +`octree::Id`; all tests pass; the old layout strategy hierarchy is gone. + +## Test and verification plan + +Tests should live in the existing `unittests_terrainlib` target. Suggested +files: + +```text +unittests/terrainlib/store_index.cpp +unittests/terrainlib/store_traverse.cpp +unittests/terrainlib/store_layout.cpp +unittests/terrainlib/store_storage.cpp +unittests/terrainlib/store_compatibility.cpp +unittests/terrainlib/store_merge_walk.cpp +``` + +During implementation: + +1. Build in `$source_dir/build/$config_name`. +2. Run unit tests from that build directory. +3. Run the focused store tests after each edit. +4. Run the full `unittests_terrainlib` target at every phase boundary. +5. Build `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and + `dag_convert_debug` after their storage aliases move. +6. Run any existing merger integration fixture after Phase 4. +7. Inspect `git diff --check` and the final worktree before each commit. + +No formatting-only pass or unrelated refactor belongs in these commits. + +## Expected migration map + +| Current code | Target | +|---|---| +| `octree/NodeStatus.h` | `store/NodeStatus.h` plus temporary alias | +| `octree/NodeStatusOrMissing.h` | `store/NodeStatusOrMissing.h` plus temporary alias | +| `octree/IndexMap.*` | `store/Index.h` | +| `octree/traverse.h` | `store/traverse.h` | +| `octree/disk/Layout.h` | `store/Layout.h` | +| `octree/disk/layout/Strategy.h` | `store/PathMapping.h` | +| `StrategyRegister.h` | explicit dimension-adapter lookup functions | +| `strategy/Flat.h` | `octree/store_layout/Flat.h` | +| `strategy/LevelAndCoordinateDirectories.h` | `octree/store_layout/LevelAndCoordinateDirectories.h` | +| `octree/storage/cache/*` | `store/cache/*` | +| `octree/storage/codec/Codec.h` | `store/Codec.h` | +| `octree/storage/RawStorage.h` | `store/RawStorage.h` | +| `octree/storage/Storage.h` | `store/Storage.h` | +| `octree/storage/IndexedStorage.h` | `store/IndexedStorage.h` | +| `octree/storage/helpers.*` | generic scan helpers plus 3D format adapter | +| `octree/disk/IndexFile.h` | versioned 3D format adapter under `octree` | +| `sf_merger::NodeWriter` subtree loop | `store/copy_subtree.h` | +| `sf_merger::Merger` recursion | `store/merge/walk.h` | + +## Risks and controls + +| Risk | Control | +|---|---| +| Existing indexes stop loading | Golden pre-refactor fixtures and unchanged 3D DTO | +| Valid legacy paths are parsed differently | Characterization and round-trip tests before replacement | +| Template migration creates a large unreviewable diff | Compatibility aliases and phase-by-phase caller migration | +| `radix::tile::Id` root underflows | Traits intercept root parent lookup | +| Invalid 2D coordinates become persistent | Validate on every disk/API boundary | +| `Inner` payloads are lost during subtree reuse | Copy every physical status and test mixed-depth fixtures | +| Linked snapshots are modified in place | Immutable snapshot API and overwrite-disabled output | +| Hard-link failure appears late | 2D operation preflight and explicit errors | +| Shared code accumulates mesh/raster policy | Dependency tests/review against the source boundary | +| Generic index accidentally dictates both disk formats | Separate 3D and 2D format adapters | + +## Decisions required before Phase 5 + +These are intentionally unresolved because the current raster-store documents +mark them as unclear: + +1. **2D index filename:** choose the filename used inside a + raster-fundamentalis snapshot. +2. **2D payload path:** confirm `z/x/y.arft`, including whether chunks live + directly under the snapshot or below a `chunks/` directory. +3. **2D layout ID:** choose the stable string serialized in the index. +4. **Maximum zoom:** choose the supported persistent range and integer widths + for zoom, x, and y. +5. **Index contents:** decide whether v1 stores only sparse status entries or + also derived aggregate metadata. The first implementation should omit + derivable metadata unless a concrete query requires it. +6. **Index envelope constants:** assign the 2D index magic number, compression + choice, and version according to the common serialization rules. +7. **Publication:** define whether snapshot completion uses an atomic rename, + a manifest marker, or an external store-level operation. The generic + storage layer should expose finalization but not invent store-level + lifecycle policy. + +Until these decisions are made, Phases 0–4 can complete and the shared +implementation can be proven with `radix::tile::Id` in memory and with +temporary-directory tests. No provisional 2D disk format should escape into +production data. From 3c6c9f8fe7c7ff9737f6ab7ecec81eb72d1445d2 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:26:21 +0200 Subject: [PATCH 12/53] Document NodeLoader cache hazard --- src/terrainlib/octree/storage/cache/LruCache.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terrainlib/octree/storage/cache/LruCache.h b/src/terrainlib/octree/storage/cache/LruCache.h index fcf4f20f..1c705e05 100644 --- a/src/terrainlib/octree/storage/cache/LruCache.h +++ b/src/terrainlib/octree/storage/cache/LruCache.h @@ -11,6 +11,9 @@ namespace octree::cache { // TODO: UNTESTED +// Do not use this cache for NodeLoader's ancestor lookup until cached ancestors +// are clipped to the requested node bounds. NodeLoader currently returns a +// cached ancestor unchanged, producing incorrect geometry for child requests. template class Lru_ : public ICache { public: From fa3282c03451479c3c60629447ba124ef312ca2b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:54:11 +0200 Subject: [PATCH 13/53] Make progress monitoring cancellable --- src/terrainlib/ProgressIndicator.cpp | 38 ++++++++++++------- src/terrainlib/ProgressIndicator.h | 4 ++ .../terrainlib/progress_indicator_test.cpp | 27 +++++++++++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/terrainlib/ProgressIndicator.cpp b/src/terrainlib/ProgressIndicator.cpp index 565f7ee8..a9f49272 100644 --- a/src/terrainlib/ProgressIndicator.cpp +++ b/src/terrainlib/ProgressIndicator.cpp @@ -39,6 +39,7 @@ void ProgressIndicator::task_finished() { if (m_step > m_n_steps) { throw std::runtime_error("Too many steps reported."); } + m_monitor_condition.notify_all(); } std::jthread ProgressIndicator::start_monitoring() const { @@ -50,19 +51,30 @@ std::jthread ProgressIndicator::start_monitoring() const { }; const auto t0 = std::chrono::steady_clock::now(); - std::jthread thread([=, this]() { - const auto delta_t = 500ms; - auto v_t_minus_1 = this->m_step.load(); - do { - auto v_t = this->m_step.load(); - const auto delta_v = v_t - v_t_minus_1; - v_t_minus_1 = v_t; - print(delta_v, delta_t); - std::this_thread::sleep_for(delta_t); - } while (this->m_step < this->m_n_steps); - const auto t1 = std::chrono::steady_clock::now(); - print(this->m_n_steps, std::chrono::duration_cast(t1 - t0)); - std::cout << std::endl; + std::jthread thread([=, this](std::stop_token stop_token) noexcept { + try { + const auto delta_t = 500ms; + auto v_t_minus_1 = this->m_step.load(); + while (!stop_token.stop_requested() && this->m_step < this->m_n_steps) { + const auto v_t = this->m_step.load(); + const auto delta_v = v_t - v_t_minus_1; + v_t_minus_1 = v_t; + print(delta_v, delta_t); + + std::unique_lock lock(this->m_monitor_mutex); + this->m_monitor_condition.wait_for( + lock, stop_token, delta_t, [this]() { return this->m_step >= this->m_n_steps; }); + } + + if (!stop_token.stop_requested()) { + const auto t1 = std::chrono::steady_clock::now(); + print(this->m_n_steps, std::chrono::duration_cast(t1 - t0)); + std::cout << std::endl; + } + } catch (...) { + // Console output and formatting must not terminate the process from + // inside the monitoring thread. + } }); return thread; } diff --git a/src/terrainlib/ProgressIndicator.h b/src/terrainlib/ProgressIndicator.h index fcf34eb0..ca16e36e 100644 --- a/src/terrainlib/ProgressIndicator.h +++ b/src/terrainlib/ProgressIndicator.h @@ -21,12 +21,16 @@ #include #include +#include +#include #include #include class ProgressIndicator { const size_t m_n_steps; std::atomic m_step = 0; + mutable std::condition_variable_any m_monitor_condition; + mutable std::mutex m_monitor_mutex; public: ProgressIndicator(size_t n_steps); diff --git a/unittests/terrainlib/progress_indicator_test.cpp b/unittests/terrainlib/progress_indicator_test.cpp index b994ddb7..826c6e8d 100644 --- a/unittests/terrainlib/progress_indicator_test.cpp +++ b/unittests/terrainlib/progress_indicator_test.cpp @@ -19,7 +19,10 @@ #include "ProgressIndicator.h" #include +#include +#include #include +#include #include #include @@ -100,4 +103,28 @@ TEST_CASE("progress indicator") monitoring_thread.join(); CHECK_THROWS(pi.task_finished()); } + + SECTION("monitoring can be stopped before all tasks finish") + { + ProgressIndicator pi(1); + auto monitoring_thread = pi.start_monitoring(); + + std::condition_variable_any fallback_condition; + std::mutex fallback_mutex; + std::jthread fallback([&](std::stop_token stop_token) { + std::unique_lock lock(fallback_mutex); + fallback_condition.wait_for(lock, stop_token, 2s, []() { return false; }); + if (!stop_token.stop_requested()) { + pi.task_finished(); + } + }); + + const auto before_stop = std::chrono::steady_clock::now(); + monitoring_thread.request_stop(); + monitoring_thread.join(); + const auto stop_duration = std::chrono::steady_clock::now() - before_stop; + fallback.request_stop(); + + CHECK(stop_duration < 250ms); + } } From bec6c7b5e0379e275784672825c21cb5aea8d8ac Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:55:51 +0200 Subject: [PATCH 14/53] Guard tile progress with an RAII session --- src/tile_downloader/TileDownloader.h | 4 +- src/tile_downloader/TileLogger.h | 77 +++++++++++++++++++++++----- unittests/CMakeLists.txt | 1 + unittests/tile_downloader/logger.cpp | 33 ++++++++++++ 4 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 unittests/tile_downloader/logger.cpp diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index 1af9a9ba..3b863d40 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -25,9 +25,9 @@ class TileDownloader { _max_zoom_level(max_zoom_level) {} void download_recursive(const radix::tile::Id &root_id) { - this->_logger.start(); + auto progress_session = this->_logger.start(); this->download_recursive_core(root_id); - this->_logger.finish(); + progress_session.finish(); } private: diff --git a/src/tile_downloader/TileLogger.h b/src/tile_downloader/TileLogger.h index 3919e74c..af30a4e0 100644 --- a/src/tile_downloader/TileLogger.h +++ b/src/tile_downloader/TileLogger.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -31,13 +32,12 @@ struct TileResult { class TileLogger { public: + class Session; + explicit TileLogger(unsigned root_zoom_level) : _root_zoom_level(root_zoom_level), _progress(PROGRESS_RESOLUTION) {} - // Starts the progress display; pair with a call to finish(). - void start() { - this->_progress_thread = this->_progress.start_monitoring(); - } + [[nodiscard]] Session start(); // The tile turned out not to exist (or errored out), so its branch stops here. void missing(const radix::tile::Id &tile) { @@ -64,9 +64,20 @@ class TileLogger { }, status); } - // Fills in any steps still missing due to floating point rounding, so the - // progress indicator reaches its total, then joins the thread started by start(). - void finish() { +private: + static constexpr size_t PROGRESS_RESOLUTION = 10'000; + + unsigned _root_zoom_level; + ProgressIndicator _progress; + std::jthread _progress_thread; + size_t _steps_done = 0; + std::map _level_counts; + + void start_monitoring() { + this->_progress_thread = this->_progress.start_monitoring(); + } + + void finish_monitoring() { while (this->_steps_done < PROGRESS_RESOLUTION) { this->_progress.task_finished(); this->_steps_done++; @@ -76,14 +87,18 @@ class TileLogger { } } -private: - static constexpr size_t PROGRESS_RESOLUTION = 10'000; + void cancel_monitoring() noexcept { + if (!this->_progress_thread.joinable()) { + return; + } - unsigned _root_zoom_level; - ProgressIndicator _progress; - std::jthread _progress_thread; - size_t _steps_done = 0; - std::map _level_counts; + this->_progress_thread.request_stop(); + try { + this->_progress_thread.join(); + } catch (...) { + // Session cleanup must not replace the active exception. + } + } static std::string format(const radix::tile::Id &tile) { return fmt::format("Tile[Zoom={}, X={}, Y={}]", tile.zoom_level, tile.coords.x, tile.coords.y); @@ -114,3 +129,37 @@ class TileLogger { } } }; + +class TileLogger::Session { +public: + explicit Session(TileLogger &logger) + : _logger(&logger) { + this->_logger->start_monitoring(); + } + + Session(const Session &) = delete; + Session &operator=(const Session &) = delete; + + Session(Session &&other) noexcept + : _logger(std::exchange(other._logger, nullptr)) {} + + Session &operator=(Session &&) = delete; + + ~Session() { + if (this->_logger) { + this->_logger->cancel_monitoring(); + } + } + + void finish() { + this->_logger->finish_monitoring(); + this->_logger = nullptr; + } + +private: + TileLogger *_logger; +}; + +inline TileLogger::Session TileLogger::start() { + return Session(*this); +} diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 41117671..4fe1abd4 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -89,6 +89,7 @@ endif() if(TARGET tile-downloader) add_executable(unittests_tile_downloader + tile_downloader/logger.cpp tile_downloader/url_builder.cpp ) target_include_directories(unittests_tile_downloader PRIVATE ${CMAKE_SOURCE_DIR}/src/tile_downloader) diff --git a/unittests/tile_downloader/logger.cpp b/unittests/tile_downloader/logger.cpp new file mode 100644 index 00000000..c41ecb4c --- /dev/null +++ b/unittests/tile_downloader/logger.cpp @@ -0,0 +1,33 @@ +#include "TileLogger.h" + +#include +#include + +#include + +using namespace std::literals; + +TEST_CASE("tile logger session stops monitoring during exceptional unwinding") +{ + TileLogger logger(0); + + const auto before_throw = std::chrono::steady_clock::now(); + CHECK_THROWS_AS( + [&]() { + auto session = logger.start(); + throw std::runtime_error("download failed"); + }(), + std::runtime_error); + const auto unwind_duration = std::chrono::steady_clock::now() - before_throw; + + CHECK(unwind_duration < 250ms); +} + +TEST_CASE("tile logger session can finish normally") +{ + TileLogger logger(0); + auto session = logger.start(); + + logger.skipped(radix::tile::Id{0, {0, 0}}); + session.finish(); +} From 5171513d5f3fb794607e75a221fcef631169f0b9 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:58:36 +0200 Subject: [PATCH 15/53] Propagate exceptions from curl callbacks --- src/tile_downloader/HttpClient.h | 72 ++++++++++++++++++----- unittests/CMakeLists.txt | 4 +- unittests/tile_downloader/http_client.cpp | 62 +++++++++++++++++++ 3 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 unittests/tile_downloader/http_client.cpp diff --git a/src/tile_downloader/HttpClient.h b/src/tile_downloader/HttpClient.h index 4db7e230..d5ef1ca4 100644 --- a/src/tile_downloader/HttpClient.h +++ b/src/tile_downloader/HttpClient.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include #include +#include #include #include @@ -18,7 +20,10 @@ using ProgressFn = std::function; class HttpClient { public: - HttpClient() : _curl(curl_easy_init()) { + using WriteFn = std::function &, const char *, size_t)>; + + explicit HttpClient(WriteFn write = {}) + : _curl(curl_easy_init()), _write(std::move(write)) { if (!_curl) { throw std::runtime_error("failed to init cURL"); } @@ -35,10 +40,12 @@ class HttpClient { HttpResponse get(const std::string &url, const ProgressFn &on_progress = {}) const { HttpResponse response; + WriteContext write_context{&response.body, &this->_write, {}}; + ProgressContext progress_context{&on_progress, {}}; curl_easy_setopt(this->_curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(this->_curl, CURLOPT_WRITEFUNCTION, write_cb); - curl_easy_setopt(this->_curl, CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(this->_curl, CURLOPT_WRITEDATA, &write_context); curl_easy_setopt(this->_curl, CURLOPT_TIMEOUT, 5L); curl_easy_setopt(this->_curl, CURLOPT_CONNECTTIMEOUT, 5L); curl_easy_setopt(this->_curl, CURLOPT_FAILONERROR, 1L); @@ -46,12 +53,18 @@ class HttpClient { if (on_progress) { curl_easy_setopt(this->_curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(this->_curl, CURLOPT_XFERINFOFUNCTION, progress_cb); - curl_easy_setopt(this->_curl, CURLOPT_XFERINFODATA, &on_progress); + curl_easy_setopt(this->_curl, CURLOPT_XFERINFODATA, &progress_context); } else { curl_easy_setopt(this->_curl, CURLOPT_NOPROGRESS, 1L); } response.curl_code = curl_easy_perform(this->_curl); + if (write_context.exception) { + std::rethrow_exception(write_context.exception); + } + if (progress_context.exception) { + std::rethrow_exception(progress_context.exception); + } char *ct = nullptr; if (curl_easy_getinfo(this->_curl, CURLINFO_CONTENT_TYPE, &ct) == CURLE_OK && ct) { @@ -68,23 +81,50 @@ class HttpClient { } private: - CURL *_curl; + struct WriteContext { + std::vector *buffer; + const WriteFn *write; + std::exception_ptr exception; + }; - static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { - auto &buf = *static_cast *>(userdata); - size_t total = size * nmemb; - buf.insert(buf.end(), static_cast(ptr), static_cast(ptr) + total); - return total; + struct ProgressContext { + const ProgressFn *progress; + std::exception_ptr exception; + }; + + CURL *_curl; + WriteFn _write; + + static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) noexcept { + auto &context = *static_cast(userdata); + const size_t total = size * nmemb; + try { + const auto *data = static_cast(ptr); + if (*context.write) { + (*context.write)(*context.buffer, data, total); + } else { + context.buffer->insert(context.buffer->end(), data, data + total); + } + return total; + } catch (...) { + context.exception = std::current_exception(); + return 0; + } } static int progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow, - curl_off_t /*ultotal*/, curl_off_t /*ulnow*/) { - const auto &fn = *static_cast(clientp); - if (dltotal > 0) { - fn(double(dlnow) / double(dltotal)); - } else { - fn(-1.0); + curl_off_t /*ultotal*/, curl_off_t /*ulnow*/) noexcept { + auto &context = *static_cast(clientp); + try { + if (dltotal > 0) { + (*context.progress)(double(dlnow) / double(dltotal)); + } else { + (*context.progress)(-1.0); + } + return 0; + } catch (...) { + context.exception = std::current_exception(); + return 1; } - return 0; } }; diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 4fe1abd4..c24810b2 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -88,12 +88,14 @@ if(TARGET sfbuilderlib) endif() if(TARGET tile-downloader) + find_package(CURL REQUIRED) add_executable(unittests_tile_downloader + tile_downloader/http_client.cpp tile_downloader/logger.cpp tile_downloader/url_builder.cpp ) target_include_directories(unittests_tile_downloader PRIVATE ${CMAKE_SOURCE_DIR}/src/tile_downloader) - target_link_libraries(unittests_tile_downloader PRIVATE terrainlib Catch2::Catch2WithMain) + target_link_libraries(unittests_tile_downloader PRIVATE terrainlib Catch2::Catch2WithMain CURL::libcurl) atb_configure_test(unittests_tile_downloader) endif() diff --git a/unittests/tile_downloader/http_client.cpp b/unittests/tile_downloader/http_client.cpp new file mode 100644 index 00000000..61311a8c --- /dev/null +++ b/unittests/tile_downloader/http_client.cpp @@ -0,0 +1,62 @@ +#include "HttpClient.h" + +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryFile { +public: + TemporaryFile() + : _path(std::filesystem::temp_directory_path() / "atb-http-client-test.txt") { + std::ofstream output(_path, std::ios::binary); + output << "response body"; + REQUIRE(output); + } + + ~TemporaryFile() { + std::error_code error; + std::filesystem::remove(_path, error); + } + + [[nodiscard]] std::string url() const { + return "file://" + _path.string(); + } + +private: + std::filesystem::path _path; +}; + +class CallbackError : public std::runtime_error { +public: + CallbackError() + : std::runtime_error("callback failed") {} +}; + +} + +TEST_CASE("http client propagates response writer exceptions") +{ + const TemporaryFile source; + HttpClient client([](std::vector &, const char *, size_t) { + throw CallbackError(); + }); + + CHECK_THROWS_AS(client.get(source.url()), CallbackError); +} + +TEST_CASE("http client propagates progress callback exceptions") +{ + const TemporaryFile source; + HttpClient client; + + CHECK_THROWS_AS( + client.get(source.url(), [](double) { + throw CallbackError(); + }), + CallbackError); +} From 817c7d74bca002e16d32493d55a2f416b09ce310 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:00:09 +0200 Subject: [PATCH 16/53] Detect tile output write failures --- src/tile_downloader/TileDownloader.h | 12 +----- src/tile_downloader/write_file.h | 26 ++++++++++++ unittests/CMakeLists.txt | 1 + unittests/tile_downloader/write_file.cpp | 53 ++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 src/tile_downloader/write_file.h create mode 100644 unittests/tile_downloader/write_file.cpp diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index 3b863d40..32372ec7 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -13,6 +12,7 @@ #include "TileLogger.h" #include "TileUrlBuilder.h" #include "tile_path.h" +#include "write_file.h" class TileDownloader { public: @@ -89,14 +89,6 @@ class TileDownloader { } } - static void write_file(const std::filesystem::path &path, const std::vector &data) { - std::ofstream out(path, std::ios::binary); - if (!out) { - throw std::runtime_error(fmt::format("failed to open \"{}\" for writing", path.string())); - } - out.write(data.data(), data.size()); - } - TileResult::Status download_tile(const radix::tile::Id &tile) { const auto path = std::filesystem::absolute(this->tile_path(tile)); @@ -112,7 +104,7 @@ class TileDownloader { HttpResponse response = this->_http.get(url); if (response.curl_code == CURLE_OK && this->_http.is_image(response)) { - write_file(path, response.body); + write_file_checked(path, response.body); return TileResult::Downloaded{}; } diff --git a/src/tile_downloader/write_file.h b/src/tile_downloader/write_file.h new file mode 100644 index 00000000..61883228 --- /dev/null +++ b/src/tile_downloader/write_file.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +#include + +inline void write_file_checked(const std::filesystem::path &path, const std::vector &data) +{ + std::ofstream output(path, std::ios::binary); + if (!output) { + throw std::runtime_error(fmt::format("failed to open \"{}\" for writing", path.string())); + } + + output.write(data.data(), static_cast(data.size())); + if (!output) { + throw std::runtime_error(fmt::format("failed to write \"{}\"", path.string())); + } + + output.close(); + if (!output) { + throw std::runtime_error(fmt::format("failed to finish writing \"{}\"", path.string())); + } +} diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index c24810b2..569d95be 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -93,6 +93,7 @@ if(TARGET tile-downloader) tile_downloader/http_client.cpp tile_downloader/logger.cpp tile_downloader/url_builder.cpp + tile_downloader/write_file.cpp ) target_include_directories(unittests_tile_downloader PRIVATE ${CMAKE_SOURCE_DIR}/src/tile_downloader) target_link_libraries(unittests_tile_downloader PRIVATE terrainlib Catch2::Catch2WithMain CURL::libcurl) diff --git a/unittests/tile_downloader/write_file.cpp b/unittests/tile_downloader/write_file.cpp new file mode 100644 index 00000000..57efbde3 --- /dev/null +++ b/unittests/tile_downloader/write_file.cpp @@ -0,0 +1,53 @@ +#include "write_file.h" + +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryOutput { +public: + TemporaryOutput() + : _path(std::filesystem::temp_directory_path() / "atb-write-file-test.bin") {} + + ~TemporaryOutput() { + std::error_code error; + std::filesystem::remove(_path, error); + } + + [[nodiscard]] const std::filesystem::path &path() const { + return _path; + } + +private: + std::filesystem::path _path; +}; + +} + +TEST_CASE("checked file writer persists the complete response") +{ + const TemporaryOutput output; + const std::vector expected{'t', 'i', 'l', 'e'}; + + write_file_checked(output.path(), expected); + + std::ifstream input(output.path(), std::ios::binary); + const auto begin = std::istreambuf_iterator(input); + const std::vector actual(begin, std::istreambuf_iterator{}); + CHECK(actual == expected); +} + +#if defined(__linux__) +TEST_CASE("checked file writer reports persistence failures") +{ + const std::vector data(64 * 1024, 'x'); + + CHECK_THROWS_AS(write_file_checked("/dev/full", data), std::runtime_error); +} +#endif From fcade5498904ea1d069020c0772a6977d31d2b78 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:36:45 +0200 Subject: [PATCH 17/53] Publish tile files atomically --- src/tile_downloader/write_file.h | 21 +++++++++++++- unittests/tile_downloader/write_file.cpp | 36 +++++++++++++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/tile_downloader/write_file.h b/src/tile_downloader/write_file.h index 61883228..7d7d2d20 100644 --- a/src/tile_downloader/write_file.h +++ b/src/tile_downloader/write_file.h @@ -7,7 +7,9 @@ #include -inline void write_file_checked(const std::filesystem::path &path, const std::vector &data) +namespace tile_downloader_detail { + +inline void write_file_checked_direct(const std::filesystem::path &path, const std::vector &data) { std::ofstream output(path, std::ios::binary); if (!output) { @@ -24,3 +26,20 @@ inline void write_file_checked(const std::filesystem::path &path, const std::vec throw std::runtime_error(fmt::format("failed to finish writing \"{}\"", path.string())); } } + +} + +inline void write_file_checked(const std::filesystem::path &path, const std::vector &data) +{ + auto staging_path = path; + staging_path += ".part"; + + try { + tile_downloader_detail::write_file_checked_direct(staging_path, data); + std::filesystem::rename(staging_path, path); + } catch (...) { + std::error_code cleanup_error; + std::filesystem::remove(staging_path, cleanup_error); + throw; + } +} diff --git a/unittests/tile_downloader/write_file.cpp b/unittests/tile_downloader/write_file.cpp index 57efbde3..0463a389 100644 --- a/unittests/tile_downloader/write_file.cpp +++ b/unittests/tile_downloader/write_file.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -12,18 +13,29 @@ namespace { class TemporaryOutput { public: - TemporaryOutput() - : _path(std::filesystem::temp_directory_path() / "atb-write-file-test.bin") {} + explicit TemporaryOutput(std::string_view name) + : _path(std::filesystem::temp_directory_path() / name) { + std::error_code error; + std::filesystem::remove_all(_path, error); + std::filesystem::remove(staging_path(), error); + } ~TemporaryOutput() { std::error_code error; - std::filesystem::remove(_path, error); + std::filesystem::remove_all(_path, error); + std::filesystem::remove(staging_path(), error); } [[nodiscard]] const std::filesystem::path &path() const { return _path; } + [[nodiscard]] std::filesystem::path staging_path() const { + auto staging = _path; + staging += ".part"; + return staging; + } + private: std::filesystem::path _path; }; @@ -32,7 +44,7 @@ class TemporaryOutput { TEST_CASE("checked file writer persists the complete response") { - const TemporaryOutput output; + const TemporaryOutput output("atb-write-file-success.bin"); const std::vector expected{'t', 'i', 'l', 'e'}; write_file_checked(output.path(), expected); @@ -41,6 +53,20 @@ TEST_CASE("checked file writer persists the complete response") const auto begin = std::istreambuf_iterator(input); const std::vector actual(begin, std::istreambuf_iterator{}); CHECK(actual == expected); + CHECK_FALSE(std::filesystem::exists(output.staging_path())); +} + +TEST_CASE("checked file writer preserves the final path when promotion fails") +{ + const TemporaryOutput output("atb-write-file-promotion-failure"); + REQUIRE(std::filesystem::create_directory(output.path())); + + CHECK_THROWS_AS( + write_file_checked(output.path(), std::vector{'t', 'i', 'l', 'e'}), + std::filesystem::filesystem_error); + + CHECK(std::filesystem::is_directory(output.path())); + CHECK_FALSE(std::filesystem::exists(output.staging_path())); } #if defined(__linux__) @@ -49,5 +75,7 @@ TEST_CASE("checked file writer reports persistence failures") const std::vector data(64 * 1024, 'x'); CHECK_THROWS_AS(write_file_checked("/dev/full", data), std::runtime_error); + CHECK(std::filesystem::is_character_file("/dev/full")); + CHECK_FALSE(std::filesystem::exists("/dev/full.part")); } #endif From b7e7c0e6258a561a9850e1f4968ab9af705d35d2 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:25:37 +0200 Subject: [PATCH 18/53] Finalize tile parents after descendants --- src/tile_downloader/TileDownloader.h | 77 ++++++++++++----- src/tile_downloader/TileLogger.h | 3 +- src/tile_downloader/cli.cpp | 4 - src/tile_downloader/cli.h | 1 - src/tile_downloader/main.cpp | 6 +- src/tile_downloader/write_file.h | 35 ++++++-- unittests/CMakeLists.txt | 1 + unittests/tile_downloader/downloader.cpp | 105 +++++++++++++++++++++++ unittests/tile_downloader/write_file.cpp | 39 ++++++--- 9 files changed, 221 insertions(+), 50 deletions(-) create mode 100644 unittests/tile_downloader/downloader.cpp diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index 32372ec7..7cc0252a 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -17,17 +18,17 @@ class TileDownloader { public: TileDownloader(const TileUrlBuilder &url_builder, std::filesystem::path output_directory, - bool early_skip, std::optional max_zoom_level, unsigned root_zoom_level) + std::optional max_zoom_level, unsigned root_zoom_level) : _url_builder(url_builder), _output_directory(std::move(output_directory)), _logger(root_zoom_level), - _early_skip(early_skip), _max_zoom_level(max_zoom_level) {} - void download_recursive(const radix::tile::Id &root_id) { + [[nodiscard]] bool download_recursive(const radix::tile::Id &root_id) { auto progress_session = this->_logger.start(); - this->download_recursive_core(root_id); + const bool complete = this->download_recursive_core(root_id); progress_session.finish(); + return complete; } private: @@ -35,52 +36,81 @@ class TileDownloader { std::filesystem::path _output_directory; HttpClient _http; TileLogger _logger; - bool _early_skip; std::optional _max_zoom_level; - void download_recursive_core(const radix::tile::Id &root_id) { + [[nodiscard]] bool download_recursive_core(const radix::tile::Id &root_id) { auto result = this->download_tile(root_id); this->_logger.report_error(root_id, result); + if (std::holds_alternative(result)) { + this->_logger.skipped(root_id); + return true; + } + + if (std::holds_alternative(result)) { + this->_logger.missing(root_id); + return true; + } + if (is_failure(result)) { this->_logger.missing(root_id); - return; + return false; } const auto children = root_id.children(); + bool children_complete = true; for (size_t i = 0; i < children.size(); i++) { - if (this->_early_skip && i + 1 < children.size()) { - if (this->tile_exists(children[i + 1])) { - this->_logger.skipped(children[i]); - continue; - } - } - if (this->_max_zoom_level.has_value() && children[i].zoom_level > *this->_max_zoom_level) { this->_logger.skipped(children[i]); continue; } - this->download_recursive_core(children[i]); + if (!this->download_recursive_core(children[i])) { + children_complete = false; + } + } + + if (!children_complete) { + return false; } + + mark_tile_children_complete(this->tile_path(root_id), this->completion_time(children)); + return true; } static bool is_failure(const TileResult::Status &result) { - return std::holds_alternative(result) - || std::holds_alternative(result) + return std::holds_alternative(result) || std::holds_alternative(result) || std::holds_alternative(result) || std::holds_alternative(result); } - bool tile_exists(const radix::tile::Id &tile) const { - return std::filesystem::exists(this->tile_path(tile)); - } - std::filesystem::path tile_path(const radix::tile::Id &tile) const { return google_tile_path(_output_directory, tile, ".jpeg"); } + std::filesystem::file_time_type completion_time( + const std::array &children) const + { + auto completion_time = std::filesystem::file_time_type::clock::now(); + for (const auto &child : children) { + if (this->_max_zoom_level.has_value() && child.zoom_level > *this->_max_zoom_level) { + continue; + } + + const auto child_path = this->tile_path(child); + if (!std::filesystem::exists(child_path)) { + continue; + } + + const auto child_time = std::filesystem::last_write_time(child_path); + if (child_time >= completion_time) { + completion_time = child_time + std::filesystem::file_time_type::duration{1}; + } + } + return completion_time; + } + static void ensure_parent_dirs(const std::filesystem::path &path) { std::error_code ec; std::filesystem::create_directories(path.parent_path(), ec); @@ -95,6 +125,9 @@ class TileDownloader { if (std::filesystem::exists(path)) { return TileResult::Skipped{}; } + if (std::filesystem::exists(children_pending_tile_path(path))) { + return TileResult::ChildrenPending{}; + } ensure_parent_dirs(path); @@ -104,7 +137,7 @@ class TileDownloader { HttpResponse response = this->_http.get(url); if (response.curl_code == CURLE_OK && this->_http.is_image(response)) { - write_file_checked(path, response.body); + write_file_children_pending(path, response.body); return TileResult::Downloaded{}; } diff --git a/src/tile_downloader/TileLogger.h b/src/tile_downloader/TileLogger.h index af30a4e0..c9d6c81b 100644 --- a/src/tile_downloader/TileLogger.h +++ b/src/tile_downloader/TileLogger.h @@ -20,6 +20,7 @@ struct TileResult { struct Downloaded {}; + struct ChildrenPending {}; struct Skipped {}; struct Absent {}; struct HttpError { long status_code; }; @@ -27,7 +28,7 @@ struct TileResult { struct CurlError { CURLcode code; }; struct TimedOut {}; - using Status = std::variant; + using Status = std::variant; }; class TileLogger { diff --git a/src/tile_downloader/cli.cpp b/src/tile_downloader/cli.cpp index 567d197b..bd1ca8af 100644 --- a/src/tile_downloader/cli.cpp +++ b/src/tile_downloader/cli.cpp @@ -54,10 +54,6 @@ Args parse(int argc, const char *const *argv) { ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) ->default_val(spdlog::level::info); - args.early_skip = true; - app.add_option("--early-skip", args.early_skip, "Resume optimization: skip completed subtrees") - ->default_val(true); - app.add_option("--max-zoom-level", args.max_zoom_level, "Maximum zoom level to descend to"); try { diff --git a/src/tile_downloader/cli.h b/src/tile_downloader/cli.h index 80fa1d7c..8e2380f8 100644 --- a/src/tile_downloader/cli.h +++ b/src/tile_downloader/cli.h @@ -20,7 +20,6 @@ struct Args { unsigned int srs; std::filesystem::path output; spdlog::level::level_enum log_level; - bool early_skip; std::optional max_zoom_level; }; diff --git a/src/tile_downloader/main.cpp b/src/tile_downloader/main.cpp index eb0fa8d1..17c25314 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -18,8 +18,6 @@ int main(int argc, char *argv[]) { const radix::tile::Id root_id = {args.zoom, {args.x, args.y}}; - TileDownloader downloader(url_builder, args.output, args.early_skip, args.max_zoom_level, root_id.zoom_level); - downloader.download_recursive(root_id); - - return 0; + TileDownloader downloader(url_builder, args.output, args.max_zoom_level, root_id.zoom_level); + return downloader.download_recursive(root_id) ? 0 : 1; } diff --git a/src/tile_downloader/write_file.h b/src/tile_downloader/write_file.h index 7d7d2d20..0a0cffa5 100644 --- a/src/tile_downloader/write_file.h +++ b/src/tile_downloader/write_file.h @@ -29,17 +29,40 @@ inline void write_file_checked_direct(const std::filesystem::path &path, const s } -inline void write_file_checked(const std::filesystem::path &path, const std::vector &data) +[[nodiscard]] inline std::filesystem::path partial_tile_path(const std::filesystem::path &path) { - auto staging_path = path; - staging_path += ".part"; + auto partial_path = path; + partial_path += ".part"; + return partial_path; +} + +[[nodiscard]] inline std::filesystem::path children_pending_tile_path(const std::filesystem::path &path) +{ + auto pending_path = path; + pending_path += ".children-pending"; + return pending_path; +} + +inline void write_file_children_pending(const std::filesystem::path &path, const std::vector &data) +{ + const auto partial_path = partial_tile_path(path); + const auto pending_path = children_pending_tile_path(path); try { - tile_downloader_detail::write_file_checked_direct(staging_path, data); - std::filesystem::rename(staging_path, path); + tile_downloader_detail::write_file_checked_direct(partial_path, data); + std::filesystem::rename(partial_path, pending_path); } catch (...) { std::error_code cleanup_error; - std::filesystem::remove(staging_path, cleanup_error); + std::filesystem::remove(partial_path, cleanup_error); throw; } } + +inline void mark_tile_children_complete( + const std::filesystem::path &path, + std::filesystem::file_time_type completion_time) +{ + const auto pending_path = children_pending_tile_path(path); + std::filesystem::last_write_time(pending_path, completion_time); + std::filesystem::rename(pending_path, path); +} diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 569d95be..27d24f95 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -90,6 +90,7 @@ endif() if(TARGET tile-downloader) find_package(CURL REQUIRED) add_executable(unittests_tile_downloader + tile_downloader/downloader.cpp tile_downloader/http_client.cpp tile_downloader/logger.cpp tile_downloader/url_builder.cpp diff --git a/unittests/tile_downloader/downloader.cpp b/unittests/tile_downloader/downloader.cpp new file mode 100644 index 00000000..2d599d47 --- /dev/null +++ b/unittests/tile_downloader/downloader.cpp @@ -0,0 +1,105 @@ +#include "TileDownloader.h" + +#include +#include +#include + +#include + +namespace { + +class TemporaryPyramid { +public: + explicit TemporaryPyramid(std::string_view name) + : _path(std::filesystem::temp_directory_path() / name) { + std::error_code error; + std::filesystem::remove_all(_path, error); + std::filesystem::create_directories(_path); + } + + ~TemporaryPyramid() { + std::error_code error; + std::filesystem::remove_all(_path, error); + } + + [[nodiscard]] const std::filesystem::path &path() const { + return _path; + } + + [[nodiscard]] std::filesystem::path tile_path(const radix::tile::Id &tile) const { + return google_tile_path(_path, tile, ".jpeg"); + } + + void create_pending(const radix::tile::Id &tile) const { + const auto path = tile_path(tile); + std::filesystem::create_directories(path.parent_path()); + write_file_children_pending(path, std::vector{'t', 'i', 'l', 'e'}); + } + + void create_complete(const radix::tile::Id &tile) const { + create_pending(tile); + mark_tile_children_complete( + tile_path(tile), std::filesystem::file_time_type::clock::now()); + } + +private: + std::filesystem::path _path; +}; + +const TileUrlBuilder missing_file_url({ + "file:///definitely-missing-atb-tile/{zoom}/{x}/{y}.jpeg", + TileYDirection::Down +}); + +} + +TEST_CASE("tile downloader promotes parents after completed children") +{ + const TemporaryPyramid pyramid("atb-downloader-complete-pyramid"); + const radix::tile::Id root{0, {0, 0}}; + const auto children = root.children(); + + pyramid.create_pending(root); + for (const auto &child : children) { + pyramid.create_pending(child); + } + + TileDownloader downloader(missing_file_url, pyramid.path(), 1u, root.zoom_level); + REQUIRE(downloader.download_recursive(root)); + + REQUIRE(std::filesystem::exists(pyramid.tile_path(root))); + CHECK_FALSE(std::filesystem::exists(children_pending_tile_path(pyramid.tile_path(root)))); + const auto root_time = std::filesystem::last_write_time(pyramid.tile_path(root)); + + for (const auto &child : children) { + CHECK(std::filesystem::exists(pyramid.tile_path(child))); + CHECK_FALSE(std::filesystem::exists(children_pending_tile_path(pyramid.tile_path(child)))); + CHECK(std::filesystem::last_write_time(pyramid.tile_path(child)) < root_time); + } + + REQUIRE(std::filesystem::remove(pyramid.tile_path(children.front()))); + TileDownloader resumed_downloader(missing_file_url, pyramid.path(), 1u, root.zoom_level); + CHECK(resumed_downloader.download_recursive(root)); + CHECK_FALSE(std::filesystem::exists(pyramid.tile_path(children.front()))); +} + +TEST_CASE("tile downloader leaves ancestors pending after a child failure") +{ + const TemporaryPyramid pyramid("atb-downloader-failed-pyramid"); + const radix::tile::Id root{0, {0, 0}}; + const auto children = root.children(); + + pyramid.create_pending(root); + for (size_t i = 1; i < children.size(); ++i) { + pyramid.create_complete(children[i]); + } + + TileDownloader downloader(missing_file_url, pyramid.path(), 1u, root.zoom_level); + CHECK_FALSE(downloader.download_recursive(root)); + + CHECK_FALSE(std::filesystem::exists(pyramid.tile_path(root))); + CHECK(std::filesystem::exists(children_pending_tile_path(pyramid.tile_path(root)))); + for (size_t i = 1; i < children.size(); ++i) { + CHECK(std::filesystem::exists(pyramid.tile_path(children[i]))); + } +} diff --git a/unittests/tile_downloader/write_file.cpp b/unittests/tile_downloader/write_file.cpp index 0463a389..7918b22d 100644 --- a/unittests/tile_downloader/write_file.cpp +++ b/unittests/tile_downloader/write_file.cpp @@ -17,23 +17,27 @@ class TemporaryOutput { : _path(std::filesystem::temp_directory_path() / name) { std::error_code error; std::filesystem::remove_all(_path, error); - std::filesystem::remove(staging_path(), error); + std::filesystem::remove(partial_path(), error); + std::filesystem::remove(pending_path(), error); } ~TemporaryOutput() { std::error_code error; std::filesystem::remove_all(_path, error); - std::filesystem::remove(staging_path(), error); + std::filesystem::remove(partial_path(), error); + std::filesystem::remove(pending_path(), error); } [[nodiscard]] const std::filesystem::path &path() const { return _path; } - [[nodiscard]] std::filesystem::path staging_path() const { - auto staging = _path; - staging += ".part"; - return staging; + [[nodiscard]] std::filesystem::path partial_path() const { + return partial_tile_path(_path); + } + + [[nodiscard]] std::filesystem::path pending_path() const { + return children_pending_tile_path(_path); } private: @@ -47,26 +51,36 @@ TEST_CASE("checked file writer persists the complete response") const TemporaryOutput output("atb-write-file-success.bin"); const std::vector expected{'t', 'i', 'l', 'e'}; - write_file_checked(output.path(), expected); + write_file_children_pending(output.path(), expected); - std::ifstream input(output.path(), std::ios::binary); + CHECK_FALSE(std::filesystem::exists(output.path())); + CHECK_FALSE(std::filesystem::exists(output.partial_path())); + REQUIRE(std::filesystem::exists(output.pending_path())); + + std::ifstream input(output.pending_path(), std::ios::binary); const auto begin = std::istreambuf_iterator(input); const std::vector actual(begin, std::istreambuf_iterator{}); CHECK(actual == expected); - CHECK_FALSE(std::filesystem::exists(output.staging_path())); + + mark_tile_children_complete( + output.path(), std::filesystem::file_time_type::clock::now()); + CHECK(std::filesystem::exists(output.path())); + CHECK_FALSE(std::filesystem::exists(output.pending_path())); } TEST_CASE("checked file writer preserves the final path when promotion fails") { const TemporaryOutput output("atb-write-file-promotion-failure"); REQUIRE(std::filesystem::create_directory(output.path())); + write_file_children_pending(output.path(), std::vector{'t', 'i', 'l', 'e'}); CHECK_THROWS_AS( - write_file_checked(output.path(), std::vector{'t', 'i', 'l', 'e'}), + mark_tile_children_complete( + output.path(), std::filesystem::file_time_type::clock::now()), std::filesystem::filesystem_error); CHECK(std::filesystem::is_directory(output.path())); - CHECK_FALSE(std::filesystem::exists(output.staging_path())); + CHECK(std::filesystem::exists(output.pending_path())); } #if defined(__linux__) @@ -74,8 +88,9 @@ TEST_CASE("checked file writer reports persistence failures") { const std::vector data(64 * 1024, 'x'); - CHECK_THROWS_AS(write_file_checked("/dev/full", data), std::runtime_error); + CHECK_THROWS_AS(write_file_children_pending("/dev/full", data), std::runtime_error); CHECK(std::filesystem::is_character_file("/dev/full")); CHECK_FALSE(std::filesystem::exists("/dev/full.part")); + CHECK_FALSE(std::filesystem::exists("/dev/full.children-pending")); } #endif From f0c296161e95a332b2d5c86890fc9b0f2af8feed Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:41:00 +0200 Subject: [PATCH 19/53] Remove tile completion timestamp rewriting --- src/tile_downloader/TileDownloader.h | 25 +----------------------- src/tile_downloader/write_file.h | 5 +---- unittests/tile_downloader/downloader.cpp | 5 +---- unittests/tile_downloader/write_file.cpp | 6 ++---- 4 files changed, 5 insertions(+), 36 deletions(-) diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index 7cc0252a..e1f49bf4 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -74,7 +73,7 @@ class TileDownloader { return false; } - mark_tile_children_complete(this->tile_path(root_id), this->completion_time(children)); + mark_tile_children_complete(this->tile_path(root_id)); return true; } @@ -89,28 +88,6 @@ class TileDownloader { return google_tile_path(_output_directory, tile, ".jpeg"); } - std::filesystem::file_time_type completion_time( - const std::array &children) const - { - auto completion_time = std::filesystem::file_time_type::clock::now(); - for (const auto &child : children) { - if (this->_max_zoom_level.has_value() && child.zoom_level > *this->_max_zoom_level) { - continue; - } - - const auto child_path = this->tile_path(child); - if (!std::filesystem::exists(child_path)) { - continue; - } - - const auto child_time = std::filesystem::last_write_time(child_path); - if (child_time >= completion_time) { - completion_time = child_time + std::filesystem::file_time_type::duration{1}; - } - } - return completion_time; - } - static void ensure_parent_dirs(const std::filesystem::path &path) { std::error_code ec; std::filesystem::create_directories(path.parent_path(), ec); diff --git a/src/tile_downloader/write_file.h b/src/tile_downloader/write_file.h index 0a0cffa5..ab2d07f2 100644 --- a/src/tile_downloader/write_file.h +++ b/src/tile_downloader/write_file.h @@ -58,11 +58,8 @@ inline void write_file_children_pending(const std::filesystem::path &path, const } } -inline void mark_tile_children_complete( - const std::filesystem::path &path, - std::filesystem::file_time_type completion_time) +inline void mark_tile_children_complete(const std::filesystem::path &path) { const auto pending_path = children_pending_tile_path(path); - std::filesystem::last_write_time(pending_path, completion_time); std::filesystem::rename(pending_path, path); } diff --git a/unittests/tile_downloader/downloader.cpp b/unittests/tile_downloader/downloader.cpp index 2d599d47..083ecd49 100644 --- a/unittests/tile_downloader/downloader.cpp +++ b/unittests/tile_downloader/downloader.cpp @@ -38,8 +38,7 @@ class TemporaryPyramid { void create_complete(const radix::tile::Id &tile) const { create_pending(tile); - mark_tile_children_complete( - tile_path(tile), std::filesystem::file_time_type::clock::now()); + mark_tile_children_complete(tile_path(tile)); } private: @@ -69,12 +68,10 @@ TEST_CASE("tile downloader promotes parents after completed children") REQUIRE(std::filesystem::exists(pyramid.tile_path(root))); CHECK_FALSE(std::filesystem::exists(children_pending_tile_path(pyramid.tile_path(root)))); - const auto root_time = std::filesystem::last_write_time(pyramid.tile_path(root)); for (const auto &child : children) { CHECK(std::filesystem::exists(pyramid.tile_path(child))); CHECK_FALSE(std::filesystem::exists(children_pending_tile_path(pyramid.tile_path(child)))); - CHECK(std::filesystem::last_write_time(pyramid.tile_path(child)) < root_time); } REQUIRE(std::filesystem::remove(pyramid.tile_path(children.front()))); diff --git a/unittests/tile_downloader/write_file.cpp b/unittests/tile_downloader/write_file.cpp index 7918b22d..2773c4ed 100644 --- a/unittests/tile_downloader/write_file.cpp +++ b/unittests/tile_downloader/write_file.cpp @@ -62,8 +62,7 @@ TEST_CASE("checked file writer persists the complete response") const std::vector actual(begin, std::istreambuf_iterator{}); CHECK(actual == expected); - mark_tile_children_complete( - output.path(), std::filesystem::file_time_type::clock::now()); + mark_tile_children_complete(output.path()); CHECK(std::filesystem::exists(output.path())); CHECK_FALSE(std::filesystem::exists(output.pending_path())); } @@ -75,8 +74,7 @@ TEST_CASE("checked file writer preserves the final path when promotion fails") write_file_children_pending(output.path(), std::vector{'t', 'i', 'l', 'e'}); CHECK_THROWS_AS( - mark_tile_children_complete( - output.path(), std::filesystem::file_time_type::clock::now()), + mark_tile_children_complete(output.path()), std::filesystem::filesystem_error); CHECK(std::filesystem::is_directory(output.path())); From 6c5da1341841556449c0a5695f2bbde1881cad2e Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:53:50 +0200 Subject: [PATCH 20/53] work on refactor plan --- docs/raster-store/refactor-plan.md | 391 +++++++++++++++++++++------- docs/raster-store/storage-format.md | 30 +-- docs/raster-store/terminology.md | 3 - 3 files changed, 311 insertions(+), 113 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 026ef6e1..f6f9bda9 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -3,6 +3,10 @@ Status: proposal for review. This document is an implementation plan, not a record of completed work. +## Purpose + +This plan describes how to extract the existing octree-specific index, traversal, storage, codec, and subtree-reuse code into a shared 2D/3D store. The refactor must preserve existing 3D datasets while enabling raster-fundamentalis storage without duplicating infrastructure. + ## Decisions already made - The shared implementation will live in `src/terrainlib/store` and use the @@ -13,13 +17,21 @@ record of completed work. writable without changing their on-disk contract. - 3D compatibility includes both existing path layouts: `flat` and `level_and_coordinate_directories`. -- The 2D raster-fundamentalis format is a separate format. Requirements in +- The 2D raster tile format is a separate format. Requirements in this directory apply to that 2D format and must not be retrofitted onto - existing 3D datasets. + existing 3D datasets. The storage format for 2d tiles is defined in storage-format.md - A path layout strategy should contain a stable identifier and two - operations: key to relative path, and relative path to key. It should not - require an inheritance hierarchy, RTTI, global self-registration, or heap - allocation. + operations: key to extensionless `NodePath`, and `NodePath` to key. It + should not require an inheritance hierarchy, RTTI, global + self-registration, or heap allocation. +- A configured codec owns all filename endings and maps one `NodePath` to one + or more physical files. `Codec::paths()` must not need a payload. +- Codecs are stateful runtime objects behind a small interface. They may + support reading, writing, or both; an unsupported operation throws. reading and writing must be reentrant (callable concurrently from different threads). +- `copy_from()` hard-links every file when the input and output codecs return + the same path list for a common dummy `NodePath`. Otherwise it decodes with + the input codec and encodes with the output codec. Callers can force + re-encoding. - 3D geometry, ECEF bounds, mesh codecs, mesh reconstruction, mask geometry, and raster-specific processing remain outside the shared store. @@ -27,16 +39,18 @@ record of completed work. 1. Use one sparse hierarchy implementation for `octree::Id` and `radix::tile::Id`. -2. Use one traversal, storage, cache, codec boundary, and unchanged-subtree - copier for 2D and 3D. +2. Use one traversal, storage, cache, runtime codec boundary, and + unchanged-subtree copier for 2D and 3D. 3. Make paired-tree walking reusable without putting mesh or raster merge policy into the shared layer. 4. Replace the current layout-strategy class hierarchy with small path-mapping values backed by function pairs. -5. Preserve all valid existing 3D index files and payload paths. -6. Introduce the 2D storage adapter without inventing unspecified raster file +5. Allow one logical node payload to consist of multiple files without making + layouts aware of those files. +6. Preserve all valid existing 3D index files and payload paths. +7. Introduce the 2D storage adapter without inventing unspecified raster file details. -7. Land the refactor in small, testable steps. Every phase should build and +8. Land the refactor in small, testable steps. Every phase should build and pass tests before the next phase begins. ## Non-goals @@ -45,7 +59,7 @@ record of completed work. other 3D spatial calculations. - Defining or implementing GDAL ingestion, raster resampling, filtering, source selection, or mask rasterisation. -- Defining the `.arft` payload or source-attribution-table serialization +- Defining the `.amort` payload or source-attribution-table serialization beyond the requirements already in [storage-format.md](storage-format.md). - Implementing an `rf_builder`, `rf_merger`, tile-base generator, or tile server in this refactor. @@ -69,8 +83,9 @@ Before moving code, tests must lock down the following 3D behaviour: | Coordinate path | `///` | | Default layout | existing level/coordinate layout | | Layout detection | both existing layouts remain detectable | -| Copy with equal extensions | hard link, or an explicit error | -| Copy with different extensions | decode and encode through the codec | +| Codec selection | legacy preferred extension selects terrain or configured glTF codec | +| Equal codec path lists | hard-link every file, or report an explicit error | +| Different codec path lists | decode with input codec and encode with output codec | Compatibility means that the refactored code can open datasets written before the refactor and produces datasets that the pre-refactor code can open. Exact @@ -91,6 +106,7 @@ src/terrainlib/ │ ├── Traits.h │ ├── Index.h │ ├── traverse.h +│ ├── NodePath.h │ ├── PathMapping.h │ ├── Layout.h │ ├── Codec.h @@ -106,6 +122,10 @@ src/terrainlib/ │ └── merge/ │ ├── Action.h │ └── walk.h +├── mesh/ +│ └── codec/ +│ ├── Terrain.h +│ └── Gltf.h ├── octree/ │ ├── Id.h │ ├── StoreTraits.h @@ -120,6 +140,9 @@ src/terrainlib/ ├── StoreTraits.h ├── IndexFile.h ├── Storage.h + ├── codec/ + │ ├── Arft.h + │ └── Debug.h └── store_layout/ └── Zxy.h ``` @@ -128,8 +151,10 @@ The exact file grouping may be collapsed if a file would only contain a few lines. The important boundaries are: - `store` contains dimension- and payload-neutral mechanisms; -- `octree` contains the legacy 3D format and key adapters; -- `raster_store` contains the new 2D format and key adapters; and +- `mesh::codec` contains the separately configured terrain and glTF codecs; +- `octree` contains the 3D format and key adapters; +- `raster_store` contains the new 2D format, key adapters, and raster codecs; + and - subdirectory names match their namespaces where a subnamespace is used. Temporary forwarding headers and aliases under `octree` are allowed during @@ -204,25 +229,33 @@ Traversal must follow only indexed nodes and use `Traits::children`. Child order is the order supplied by the traits and is therefore deterministic per hierarchy, not universally fixed by `store`. -### Path mappings +### Node paths and path mappings + +`store::NodePath` is an extensionless logical location for one hierarchy +node. For example: + +```text +octree flat 12-123456 +octree coordinates 12/34/56/78 +raster-store ZXY 12/2200/1400 +``` -Replace `octree::disk::layout::Strategy` and +It does not necessarily name a physical file. Replace +`octree::disk::layout::Strategy` and `octree::disk::layout::StrategyRegister` with a value similar to: ```cpp template struct store::PathMapping { std::string_view id; - std::filesystem::path (*key_to_path)( - const Key&, std::string_view extension_with_dot); - std::optional (*path_to_key)( - const std::filesystem::path& relative_path); + NodePath (*key_to_node_path)(const Key&); + std::optional (*node_path_to_key)(const NodePath&); }; ``` -`store::Layout` owns the base directory, preferred extension, and one -`PathMapping`. It only adds/removes the base directory around the two -mapping functions. +`store::Layout` owns the base directory and one `PathMapping`. It +does not own a preferred extension. A configured codec expands the +extensionless `NodePath` into the physical file or files. The stable ID is format metadata, not a third strategy operation. The dimension adapters provide ordinary lookup functions: @@ -241,40 +274,132 @@ This retains runtime selection from an index file while removing virtual dispatch, RTTI type-to-ID lookup, static registration, and ownership through `unique_ptr`. -Path parsers must validate the complete relative path and the expected file -extension at the `Layout` boundary. They must return an error or `nullopt`; -they must not assert on input read from disk. +Path parsers validate the complete logical `NodePath`, not a file ending. +Codec or format-adapter code removes and validates physical file endings +before asking the layout to recover a key. Invalid disk input returns an error +or `nullopt`; it must not trigger an assertion. + +### Codec interface + +A codec is a configured runtime object for one logical payload type. It owns +all physical filename endings and may map one `NodePath` to several files: + +```cpp +template +class store::Codec { +public: + virtual ~Codec() = default; + + virtual std::vector + paths(const NodePath& node_path) const = 0; + + virtual NodeData read(const NodePath&) const { + throw UnsupportedCodecOperation{"read"}; + } + + virtual void write( + const NodePath&, + const NodeData&) const { + throw UnsupportedCodecOperation{"write"}; + } +}; +``` + +Concrete codecs contain their configuration and are constructed before +storage use. Unsupported read or write operations may use the base +implementation and throw at runtime. + +`Codec::paths()` has the following contract: + +- it needs no NodeData payload and performs no filesystem access; +- it returns every physical file belonging to the logical node; +- results depend only on codec configuration and the supplied `NodePath`; +- result order is stable and pairs corresponding input/output files; +- two codecs returning the same path list for the same `NodePath` must produce + mutually compatible files; and +- different artifact counts or filename endings produce different lists. + +Examples: + +```text +Terrain codec + 12/34/56/78 + -> 12/34/56/78.terrain + +glTF codec configured for binary output + 12/34/56/78 + -> 12/34/56/78.glb + +glTF codec configured for JSON output + 12/34/56/78 + -> 12/34/56/78.gltf + +Debug raster codec configured for JPEG data and PNG attribution + 12/2200/1400 + -> 12/2200/1400.data.jpg + -> 12/2200/1400.attribution.png +``` + +The mesh side has separate terrain and glTF codecs because they use different +format implementations. Binary `.glb` and JSON `.gltf` remain configurations +of one glTF codec because both use the same `cgltf` implementation. + +The raster side has similarly shaped codecs specialized on PixelType: + +```cpp +template struct raster_store::codec::Amort : store::Codec> { .. }; +template struct raster_store::codec::Debug : store::Codec> { .. }; +``` + +`Amort` supports reading and writing. `Debug` supports writing only and may +contain runtime options for data format, attribution format, JPEG quality, or +similar debugging choices. It is not template-composed from separate image +codec types. + ### Storage and format adapters -Generalize these mechanisms over traits, payload, and codec: +Generalize storage over traits and NodeData. It owns a configured codec through +the runtime interface: ```cpp -store::RawStorage -store::Storage -store::IndexedStorage -store::cache::Interface +store::RawStorage +store::Storage +store::IndexedStorage +store::cache::Interface ``` -The payload codec remains path-based and key-neutral. Mesh and raster codecs -remain with their payload domains. +The NodeData codecs remain with their domains under `mesh::codec` and +`raster_store::codec`. Index serialization is not a responsibility of `store::Index`. Opening and -saving a dataset must receive a dimension-specific format adapter which -provides: +saving a dataset receives a dimension-specific format adapter which provides: - the index filename; - index read/write conversion; - mapping lookup by stable ID; - the default mapping; and -- the mappings considered during legacy directory scanning. +- legacy directory discovery where it is required. This adapter may be a compile-time policy or a small value of function pointers. Choose the smaller implementation after the Phase 0 tests exist. -It must not reintroduce a class hierarchy or global registration. +It must not reintroduce a layout class hierarchy or global registration. For 3D, the adapter reads and writes the current `octree` index DTO unchanged. -For 2D, it reads and writes a separately versioned +Its legacy `preferred_extension` field selects the configured codec: + +```text +.terrain -> terrain codec +.glb -> glTF codec with binary container +.gltf -> glTF codec with JSON container +``` + +Legacy unindexed-directory discovery remains in the 3D adapter: it recognizes +the known codec endings, removes them to obtain a `NodePath`, and then invokes +the selected layout parser. The generic layout does not recover keys directly +from codec-owned file paths. + +For 2D, the adapter reads and writes a separately versioned `raster_store::v1` DTO using the serialization envelope required by [storage-format.md](storage-format.md). @@ -283,17 +408,48 @@ Preserve that behaviour for existing 3D entry points during the migration. The new 2D snapshot API should require an explicit finalization/publication step; a destructor must not make an incomplete snapshot authoritative. -### Unchanged-subtree reuse +### Copying and unchanged-subtree reuse + +Keep `Storage::copy_from()` close to its current role. Add: + +```cpp +struct CopyOptions { + bool force_reencode = false; +}; +``` + +For one key, `copy_from()`: + +1. calls the input and output `Codec::paths()` with the same fixed dummy + `NodePath`; +2. when the lists are equal and `force_reencode` is false, calls both codecs + again with their actual source and target `NodePath` values and hard-links + every source path to the corresponding target path; +3. when the dummy lists differ or re-encoding is forced, reads the payload + with the input codec and writes it with the output codec; and +4. updates the target index only after all links or the write complete. + +The dummy path must be fixed and collision-free, for example +`__codec_probe__/node`. Path lists are compared exactly, including count, +order, and filename endings. + +If linking several files fails partway through, remove the target links +created by that call before returning the error. There is no silent copy +fallback. An unsupported read or write needed for re-encoding throws through +the codec interface. + +Codec settings that do not change `paths()`, such as compression level or +JPEG quality, do not force re-encoding by default. A caller that needs those +settings applied to every node passes `force_reencode = true`. Move payload-neutral subtree copying out of `sf_merger::NodeWriter`. The -shared operation should: +shared operation: -1. traverse an indexed source subtree; -2. skip `Virtual` nodes; -3. copy physical payloads for both `Leaf` and `Inner`; -4. use `Storage::copy_from` so equal-format payloads are hard-linked; -5. update the target index incrementally; and -6. return an error instead of asserting or terminating. +1. traverses an indexed source subtree; +2. skips `Virtual` nodes; +3. calls `copy_from()` for physical payloads in both `Leaf` and `Inner` + states; and +4. propagates errors instead of asserting or terminating. This fixes the current incorrect assumption that every non-virtual visited node is a `Leaf`. @@ -301,12 +457,14 @@ node is a `Leaf`. Hard-link rules: - never modify an existing linked payload in place; -- matching extensions require hard-link creation; -- different extensions retain the current decode/re-encode path; +- a matching codec path list hard-links every file; +- a different path list decodes with the input codec and encodes with the + output codec; +- `force_reencode` always selects decode/encode; - hard-link failure is explicit; -- 2D snapshot tools must preflight that source and destination support hard - links before a long operation starts; and -- no silent copy fallback is introduced by this refactor. +- 2D snapshot tools preflight that source and destination support hard links + before a long operation starts; and +- no silent file-copy fallback is introduced. ### Paired hierarchy walking @@ -361,6 +519,7 @@ No production behaviour changes. 4. Add storage tests for: - matching-extension hard links; - different-extension decode/re-encode; + - `.terrain`, `.glb`, and `.gltf` dispatch; - overwrite rejection; - indexed and unindexed opens; and - final index creation by directory scan. @@ -388,59 +547,95 @@ callers still build through aliases; no filesystem code has changed. ### Phase 2 — Replace disk layout strategies -1. Add `store::PathMapping` and `store::Layout`. +1. Add `store::NodePath`, `store::PathMapping`, and + `store::Layout`. 2. Port the two existing 3D layouts to ordinary function pairs without - changing paths or stable IDs. + changing stable IDs. The mappings return `level-index` and + `level/x/y/z` without file endings. 3. Replace the singleton strategy registry with explicit `from_id()` and `all()` functions in the 3D adapter. -4. Port layout guessing to consume a span of mappings supplied by the format - adapter. -5. Add the proposed 2D `z/x/y.arft` mapping only after the review decision - listed below is resolved. -6. Switch path and layout-detection tests to the new implementation. -7. Delete the old strategy base class, registration machinery, and concrete +4. Move the legacy preferred extension out of generic `Layout` and retain it + in the 3D format adapter. +5. Port legacy layout discovery so it strips recognized 3D file endings before + calling `node_path_to_key()`. +6. Add the proposed 2D `z/x/y` mapping only after the review decision listed + below is resolved. The raster codec, not the mapping, adds `.arft` or debug + endings. +7. Switch node-path and layout-detection tests to the new implementation. +8. Delete the old strategy base class, registration machinery, and concrete strategy classes once no call site uses them. -Exit criterion: both legacy fixtures resolve to identical payload paths; +Exit criterion: the legacy 3D adapter plus codec resolves both fixtures to +identical physical payload paths; generic `Layout` contains no extension; there is no layout inheritance, RTTI lookup, static registrar, or owning strategy pointer. ### Phase 3 — Generalize storage and index lifecycle -1. Move the codec concept, copy error, raw storage, caches, logical storage, - and indexed storage into `store`. -2. Replace every embedded `octree::Id` with `Traits::Key`. -3. Keep payload codecs outside the shared module: - `octree::MeshCodec` remains 3D, and the future `.arft` codec remains under - `raster_store`. -4. Split generic directory scanning from 3D index serialization. -5. Implement the small format-adapter boundary described above. -6. Keep the current 3D `terrain.index` DTO and open functions as compatibility - adapters over the shared storage. -7. Migrate the existing octree storage aliases and all application callers. -8. Preserve the current 3D destructor-save behaviour until all callers have - explicit index finalization. +1. Add the stateful `store::Codec` interface with `paths()`, `read()`, + and `write()`. +2. Split the current extension-dispatching `octree::MeshCodec` into: + - a terrain codec; and + - one glTF codec configured for binary `.glb` or JSON `.gltf`. +3. Move copy error, raw storage, caches, logical storage, and indexed storage + into `store`. +4. Make storage own a configured `std::unique_ptr>`; remove the + codec template parameter from storage. +5. Replace every embedded `octree::Id` with `Traits::Key`. +6. Make every raw file operation obtain its complete file list through + `Codec::paths()`. `has()` requires every listed file, and `remove()` removes + every listed file. +7. Keep payload codecs outside the shared module under `mesh::codec` and + `raster_store::codec`. +8. Split generic index maintenance from 3D index serialization and legacy + folder discovery. +9. Keep the current 3D `terrain.index` DTO and open functions as compatibility + adapters over the shared storage. Map its preferred extension to a + configured terrain or glTF codec. +10. Migrate the existing octree storage aliases and all application callers. +11. Preserve the current 3D destructor-save behaviour until all callers have + explicit index finalization. + +Add focused codec tests using single-file, multi-file, read/write, and +write-only test codecs before depending on the raster payload implementation. Exit criterion: all existing applications build and all Phase 0 fixtures pass -through the shared storage implementation. No second storage implementation -remains under `octree`. +through the shared runtime codec and storage implementation. No +extension-dispatching mesh codec or second storage implementation remains +under `octree`. ### Phase 4 — Generalize subtree reuse and paired walking -1. Add the shared unchanged-subtree copier. -2. Test copies containing `Leaf`, `Virtual`, and `Inner` nodes. -3. Add the paired hierarchy walker and typed actions. -4. Cover all 16 status pairs with table-driven tests. -5. Adapt the 3D merger to the shared walker while keeping mesh policy in +1. Change `Storage::copy_from()` to compare input and output codec path lists + for the fixed dummy `NodePath`. +2. Hard-link all actual files when the lists match, with cleanup of links + created by a partially failed call. +3. Decode with the input codec and encode with the output codec when lists + differ. +4. Add `CopyOptions::force_reencode`, defaulting to false. +5. Test: + - one-file hard linking; + - multi-file hard linking; + - different path counts and endings; + - forced re-encoding with otherwise equal paths; + - conversion between terrain and glTF; + - conversion into a write-only codec; and + - runtime failure when a required codec operation is unsupported. +6. Add the shared unchanged-subtree copier. +7. Test copies containing `Leaf`, `Virtual`, and `Inner` nodes. +8. Add the paired hierarchy walker and typed actions. +9. Cover all 16 status pairs with table-driven tests. +10. Adapt the 3D merger to the shared walker while keeping mesh policy in `sf_merger`. -6. Remove generic recursion and copy logic from `sf_merger::Merger` and +11. Remove generic recursion and copy logic from `sf_merger::Merger` and `NodeWriter`. -7. Add a 3D integration test proving an unchanged subtree is hard-linked and +12. Add a 3D integration test proving an unchanged subtree is hard-linked and a changed boundary node is newly written. Exit criterion: the existing 3D merger behaviour is preserved, `Inner` no -longer reaches `UNREACHABLE()`, and the shared walker contains no mesh, ECEF, -GDAL, OpenCV, or raster dependencies. +longer reaches `UNREACHABLE()`, multi-file reuse works through +`Codec::paths()`, and the shared walker contains no mesh, ECEF, GDAL, OpenCV, +or raster dependencies. ### Phase 5 — Add the 2D raster-fundamentalis adapter @@ -453,15 +648,20 @@ edited into decisions in this document. 4. Use the required magic/version/checksum/compression serialization envelope. 5. Add the 2D format adapter and storage aliases under `raster_store`. -6. Exercise storage with a small test codec if the final `.arft` codec is not - implemented yet; do not make `.arft` claims from a placeholder codec. -7. Test: +6. Add `raster_store::codec::Arft` with runtime format options when + the final `.arft` serialization is available. +7. Add the output-only `raster_store::codec::Debug` with runtime + options for its data and attribution files. +8. If the final `.arft` serialization is not available, exercise storage with + the Phase 3 test codec and do not make `.arft` claims from it. +9. Test: - invalid and boundary tile IDs; - index serialization and validation; - `Leaf`/`Inner` coexistence; - sparse traversal and ancestor lookup; - path round trips; - - snapshot hard-link reuse; and + - snapshot hard-link reuse; + - ARFT-to-debug output conversion; and - explicit cross-filesystem/preflight failure. Exit criterion: the same shared store can create, open, traverse, and reuse a @@ -493,6 +693,7 @@ files: unittests/terrainlib/store_index.cpp unittests/terrainlib/store_traverse.cpp unittests/terrainlib/store_layout.cpp +unittests/terrainlib/store_codec.cpp unittests/terrainlib/store_storage.cpp unittests/terrainlib/store_compatibility.cpp unittests/terrainlib/store_merge_walk.cpp @@ -519,13 +720,15 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `octree/NodeStatusOrMissing.h` | `store/NodeStatusOrMissing.h` plus temporary alias | | `octree/IndexMap.*` | `store/Index.h` | | `octree/traverse.h` | `store/traverse.h` | +| complete node paths embedded in layouts | extensionless `store/NodePath.h` plus codec endings | | `octree/disk/Layout.h` | `store/Layout.h` | | `octree/disk/layout/Strategy.h` | `store/PathMapping.h` | | `StrategyRegister.h` | explicit dimension-adapter lookup functions | | `strategy/Flat.h` | `octree/store_layout/Flat.h` | | `strategy/LevelAndCoordinateDirectories.h` | `octree/store_layout/LevelAndCoordinateDirectories.h` | | `octree/storage/cache/*` | `store/cache/*` | -| `octree/storage/codec/Codec.h` | `store/Codec.h` | +| `octree/storage/codec/Codec.h` | runtime `store/Codec.h` | +| `octree/storage/codec/MeshCodec.h` | `mesh/codec/Terrain.h` and configured `mesh/codec/Gltf.h` | | `octree/storage/RawStorage.h` | `store/RawStorage.h` | | `octree/storage/Storage.h` | `store/Storage.h` | | `octree/storage/IndexedStorage.h` | `store/IndexedStorage.h` | @@ -545,6 +748,9 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Invalid 2D coordinates become persistent | Validate on every disk/API boundary | | `Inner` payloads are lost during subtree reuse | Copy every physical status and test mixed-depth fixtures | | Linked snapshots are modified in place | Immutable snapshot API and overwrite-disabled output | +| Multi-file hard linking fails partway through | Remove links created by the failed `copy_from()` before returning | +| Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | +| Output-only codec is selected for required input | Throw a clear unsupported-operation error at runtime | | Hard-link failure appears late | 2D operation preflight and explicit errors | | Shared code accumulates mesh/raster policy | Dependency tests/review against the source boundary | | Generic index accidentally dictates both disk formats | Separate 3D and 2D format adapters | @@ -556,8 +762,9 @@ mark them as unclear: 1. **2D index filename:** choose the filename used inside a raster-fundamentalis snapshot. -2. **2D payload path:** confirm `z/x/y.arft`, including whether chunks live - directly under the snapshot or below a `chunks/` directory. +2. **2D node and payload paths:** confirm the extensionless `z/x/y` + `NodePath`, the ARFT codec's resulting `z/x/y.arft` file, and whether + chunks live directly under the snapshot or below a `chunks/` directory. 3. **2D layout ID:** choose the stable string serialized in the index. 4. **Maximum zoom:** choose the supported persistent range and integer widths for zoom, x, and y. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 8be6b5eb..673bbfd5 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -23,36 +23,30 @@ The source attribution table is used in raster-fundamentalis, tile-base, and in - there is one per directory tree (it's valid for all tiles stored within the same directory tree, all siblings and children). - given a tile (either rf or tb), the lookup of the source attribution table is first in the same directory and then in all parrent dirs, until a source_attribution_table.ard is found (or a failure is thrown). +## alpine maps raster store format +this is the binary format used to store rf and tb tiles. both share the same basic tile format, but use it in different ways. +- The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by tile IDs (radix::tile::ID, https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). +- stored tiles have a resolution of 4096x4096 pixels +- Every stored pixel has one data value (can be vector type) and one source attribution index. +- the source attribution index is stored as uint16, and indexes into a global source attribution table (see above) +- data is stored in radix::Raster objects (one for source attribution index, one for the actual data), i.e. template struct raster_store::Tile { radix::Raster data; radix::Raster source_attribution; }; +- the file ending is .amort (AlpineMapsOrg raster tile), it is serialised used the principles outlined below. + ## raster-fundamentalis (rf) format raster-fundamentalis is our authoritative raster-store, containing only the data and no overviews / downsampled version. -- The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by tile IDs (radix::tile::ID, https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). - unlike a tile pyramid, not every level is occupied (there is no downsampled versions of the data). -- stored tiles have a resolution of 4096x4096 pixels -- Every stored pixel has one payload value (can be vector type) and one source attribution index. - Coarse physical tiles (e.g. zoom level 10) may coexist with more accurate descendants (e.g. zoom level 15). -- the source attribution index is stored as uint16, and indexes into a global source attribution table (see above). -- data is stored in radix::Raster objects (one for source attribution index, one for the actual data) -- raster-fundamentalis is implemented in src/terrainlib/raster/fundamentalis.h, in the namespace raster::fundamentalis::* -- tiles are stored in .arft files (alpine raster fundamentalis tile) - -### unclear -- exact quadtree format / how to reuse structura fundamentalis code -- file names / paths / index file name +- the raster-fundamentalis builder is implemented in src/rf-builder/*, it consumes raw gdal data. ## Tile-base Format (tb) tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. it is used directly by the tile-server to generate tiles at the requested resolution and format. -- The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by tile IDs (radix::tile::ID, https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). - every level is occupied, and every level selects an adequate data source -- stored tiles have a resolution of 4096x4096 pixels (?) -- Every stored pixel has one payload value (can be vector type) and one source attribution index. -- the source attribution index is stored as uint16, and indexes into a global source attribution table (see above) -- data is stored in radix::Raster objects (one for source attribution index, one for the actual data) -- tile-base is implemented in src/terrainlib/raster/tile_base.h, in the namespace raster::tile_base::* -- tiles are stored in .artb files (alpine raster tile base) +- the tile-base builder is implemented in src/tb-builder, it consumes rf ### tile-server - should generate tiles of requested resolution and pixel type (vertex|area) on the fly - requests by url, e.g.: layer/vertex|area/resolution/z/x/y.ending +- live in src/tile-server/* ### unclear - how to build tile-base from raster-fundamentalis diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index cff4ab31..eb98976b 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -1,7 +1,4 @@ # Terminology -** payload raster ** -: A square data matrix (image), containing the actual data (e.g. elevation, colour..) - ** attribution raster ** : A square data matrix (image), containing indices into the source-attribution table From b52879ec66fb4e3b0e87680fc6067f68b968572b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:10:05 +0200 Subject: [PATCH 21/53] plan++ --- docs/raster-store/refactor-plan.md | 148 +++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index f6f9bda9..60b31bcf 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -410,7 +410,40 @@ step; a destructor must not make an incomplete snapshot authoritative. ### Copying and unchanged-subtree reuse -Keep `Storage::copy_from()` close to its current role. Add: +There are two separate responsibilities in the current implementation: + +1. `sf_merger::NodeWriter` traverses a source subtree and decides which + indexed nodes to reuse. +2. `octree::Storage::copy_from()` delegates to + `octree::RawStorage::copy_from()`, where + `std::filesystem::create_hard_link()` performs the actual hard link and the + target index is updated on success. + +The filesystem hard-link implementation is therefore already in terrainlib. +This refactor shall move the payload-neutral subtree traversal and copy +orchestration out of `sf_merger`. + +The current call chain is: + +```text +sf_merger decides to keep a source subtree unchanged + -> NodeWriter traverses the source index + -> octree::Storage::copy_from() + -> octree::RawStorage::copy_from() + -> create_hard_link(), or decode/encode when formats differ +``` + +The target call chain becomes: + +```text +merge policy decides to keep a source subtree unchanged + -> store::copy_subtree() + -> store::Storage::copy_from() + -> hard-link every codec path, or decode/encode +``` + +`Storage::copy_from()` remains the operation for copying one logical node. +Add: ```cpp struct CopyOptions { @@ -442,17 +475,118 @@ Codec settings that do not change `paths()`, such as compression level or JPEG quality, do not force re-encoding by default. A caller that needs those settings applied to every node passes `force_reencode = true`. -Move payload-neutral subtree copying out of `sf_merger::NodeWriter`. The -shared operation: +#### Shared subtree copier + +Move the payload-neutral traversal in +`sf_merger::NodeWriter::copy_subtree_to_output()` to a shared operation such +as: + +```cpp +std::expected +store::copy_subtree( + const IndexedStorage& source, + Storage& target, + const Key& root, + CopyOptions options = {}); +``` + +The shared operation: 1. traverses an indexed source subtree; 2. skips `Virtual` nodes; 3. calls `copy_from()` for physical payloads in both `Leaf` and `Inner` - states; and -4. propagates errors instead of asserting or terminating. + states; +4. continues traversal below `Inner` nodes; and +5. returns copy failures to its caller instead of converting them into an + assertion or immediate process termination. + +The operation is payload-neutral because it only interprets hierarchy status +and delegates each physical node to `Storage::copy_from()`. It does not know +about meshes, rasters, masks, attribution, or their encodings. + +#### `Leaf`, `Inner`, and `Virtual` + +The sparse index distinguishes payload presence from descendant presence: + +| Status | Physical payload | Indexed descendants | +|---|---:|---:| +| `Leaf` | yes | no | +| `Inner` | yes | yes | +| `Virtual` | no | yes | + +`Missing` is represented by absence from the sparse index and is not visited +by subtree traversal. + +The current `NodeWriter` callback effectively does: + +```cpp +if (status == NodeStatus::Virtual) { + return; +} + +DEBUG_ASSERT(status == NodeStatus::Leaf); +DEBUG_ASSERT_VAL(target.copy_from(id, source)); +``` + +The `DEBUG_ASSERT(status == Leaf)` is an incorrect assumption. `Inner` is +also a physical state and its payload must be copied. The existing +`IndexMap::add()` creates an `Inner` node whenever a physical `Leaf` gains a +physical descendant. Such a hierarchy is valid in 3D and is explicitly +required for raster-fundamentalis, where a coarse physical tile may coexist +with more accurate descendants. + +For example: + +```text +zoom 10 physical tile -> Inner +└── zoom 11 physical tile -> Leaf +``` + +Reusing this subtree must preserve both payloads. Skipping the `Inner` +payload would lose the coarse fallback; asserting on `Inner` rejects a valid +hierarchy. + +The shared copier shall instead handle status as: + +```cpp +switch (status) { +case NodeStatus::Virtual: + break; +case NodeStatus::Leaf: +case NodeStatus::Inner: + target.copy_from(id, source, options); + break; +} +``` -This fixes the current incorrect assumption that every non-virtual visited -node is a `Leaf`. +Traversal shall continue below the `Inner` node. Copying the parent first adds it +to the target as a `Leaf`; copying its descendant then promotes the parent to +`Inner`, reconstructing the source topology through the normal index +transitions. + +The existing paired merger has a related limitation: its dispatcher only +handles `Missing`, `Leaf`, and `Virtual` pairs and sends any pair containing +`Inner` to `UNREACHABLE()`. The paired hierarchy-walking work below must +handle or explicitly reject all 16 status combinations without treating +valid input data as an impossible program state. + +#### Error propagation + +The lower storage layer already represents ordinary copy failures, including +missing source files, directory creation failure, hard-link failure, decode +failure, and encode failure. The current `NodeWriter` consumes +`Storage::copy_from()` with `DEBUG_ASSERT_VAL`, while some overwrite paths +terminate through `LOG_ERROR_AND_EXIT()`. Because +`copy_subtree_to_output()` returns `void`, the merge caller cannot report or +handle these failures. + +The shared copier must return the failure through `copy_subtree()` and the +merge call chain until the application boundary can report it with the +affected key and path. An unsupported codec read or write may propagate as +the codec's runtime exception. Assertions remain appropriate for internal +invariants, but filesystem conditions, unsupported conversions, malformed +datasets, and overwrite conflicts are operational errors rather than +assertion failures. Hard-link rules: From a2a9aa4ce3f0bd05be5363e132fb4fa30e996a2c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:19:11 +0200 Subject: [PATCH 22/53] plan++ --- docs/raster-store/refactor-plan.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 60b31bcf..27787697 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -475,6 +475,18 @@ Codec settings that do not change `paths()`, such as compression level or JPEG quality, do not force re-encoding by default. A caller that needs those settings applied to every node passes `force_reencode = true`. +Hard-link rules: + +- never modify an existing linked payload in place; +- a matching codec path list hard-links every file; +- a different path list decodes with the input codec and encodes with the + output codec; +- `force_reencode` always selects decode/encode; +- hard-link failure is explicit; +- 2D snapshot tools preflight that source and destination support hard links + before a long operation starts; and +- no silent file-copy fallback is introduced. + #### Shared subtree copier Move the payload-neutral traversal in @@ -588,18 +600,6 @@ invariants, but filesystem conditions, unsupported conversions, malformed datasets, and overwrite conflicts are operational errors rather than assertion failures. -Hard-link rules: - -- never modify an existing linked payload in place; -- a matching codec path list hard-links every file; -- a different path list decodes with the input codec and encodes with the - output codec; -- `force_reencode` always selects decode/encode; -- hard-link failure is explicit; -- 2D snapshot tools preflight that source and destination support hard links - before a long operation starts; and -- no silent file-copy fallback is introduced. - ### Paired hierarchy walking Extract only the dimension-neutral control flow from `sf_merger::Merger`. From 37ffeaccb7130aae424a9c5c6f54fa29ffffef9c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:13:11 +0200 Subject: [PATCH 23/53] plan++ --- docs/raster-store/refactor-plan.md | 7 +++++++ docs/raster-store/storage-format.md | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 27787697..5af2530a 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -896,21 +896,28 @@ mark them as unclear: 1. **2D index filename:** choose the filename used inside a raster-fundamentalis snapshot. + => call it raster_store.index. 2. **2D node and payload paths:** confirm the extensionless `z/x/y` `NodePath`, the ARFT codec's resulting `z/x/y.arft` file, and whether chunks live directly under the snapshot or below a `chunks/` directory. + => I already renamed .arft into .amort (alpine maps org raster tile) in several places. look for places i missed and rename it. the chunks / tiles shall live under a path defined by the store_layout, the default 2d store layout shall put them into z/x/y, not in chunks/z/x/y. 3. **2D layout ID:** choose the stable string serialized in the index. + => "zoom/x/y_google" 4. **Maximum zoom:** choose the supported persistent range and integer widths for zoom, x, and y. + => use the tile id from radix. x and y are unsigned 32, that would give maximum zoom 31 or 32? that should be well enough. 5. **Index contents:** decide whether v1 stores only sparse status entries or also derived aggregate metadata. The first implementation should omit derivable metadata unless a concrete query requires it. + => is this a question? 6. **Index envelope constants:** assign the 2D index magic number, compression choice, and version according to the common serialization rules. + => erm, question? 7. **Publication:** define whether snapshot completion uses an atomic rename, a manifest marker, or an external store-level operation. The generic storage layer should expose finalization but not invent store-level lifecycle policy. + => use an atomic rename, but don't hide the file. use a .part extension to the new directory name, and then remove it. make sure to flush and close all files before renaming. Until these decisions are made, Phases 0–4 can complete and the shared implementation can be proven with `radix::tile::Id` in memory and with diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 673bbfd5..c476517d 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -72,7 +72,8 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - if the checksum check fails - if the compression algorithm is missing or unsupported. - deserialization fails -- we fail by throwing +- we fail by throwing in these cases +- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd ## to be defined From db795cbd0c0268e7229d65abe1cd384883adf7e2 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:54:36 +0200 Subject: [PATCH 24/53] plan++ --- docs/raster-store/architecture.md | 21 +++- docs/raster-store/refactor-plan.md | 153 +++++++++++++++++----------- docs/raster-store/storage-format.md | 15 ++- 3 files changed, 124 insertions(+), 65 deletions(-) diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 356cdc25..10a62418 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -34,10 +34,14 @@ A store root contains immutable snapshots. Each snapshot contains an index, a so store/ └── snapshot-id/ ├── source_attribution_table.ard - ├── index - └── chunks/...[.arft|.artb] (or, z/x/y.[arft|artb]) + ├── raster_store.index + └── //.amort ``` +The shown payload path is the default `zoom/x/y_google` layout. Other layouts +may map the same tile IDs differently; there is no mandatory `chunks/` +directory. + ## Sparse quadtree index The index uses the same four logical states as the octree index: @@ -98,6 +102,19 @@ When merging, we need to create new hardlinks for unchanged rf tiles (taken comp Cross-filesystem hard links cannot be created. The builder must expose this as a clear configuration error rather than discovering it after a long build. +A new snapshot is assembled in a sibling directory named +`.part`. The builder writes the index last, validates the +snapshot, flushes and closes all files, and atomically renames the directory +to `` on the same filesystem. The final destination must not +already exist. A `.part` directory is incomplete and must not be exposed as a +published snapshot during normal operation. + +Publication does not guarantee durability or safe recovery across a power +failure, operating-system crash, or storage failure. The implementation does +not perform platform-specific filesystem syncing. After such a failure, +either a `.part` directory or a final snapshot may be unusable and must be +validated and rebuilt. + ## Pyramid generator interface (to be confirmed, LLM, do not use the following without consultation) A generator requests a layer over a target tile and sampling specification. diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 5af2530a..31da1ef5 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -141,10 +141,10 @@ src/terrainlib/ ├── IndexFile.h ├── Storage.h ├── codec/ - │ ├── Arft.h + │ ├── Amort.h │ └── Debug.h └── store_layout/ - └── Zxy.h + └── ZoomXYGoogle.h ``` The exact file grouping may be collapsed if a file would only contain a few @@ -197,10 +197,15 @@ changing `octree::Id`. - treat zoom zero as the only root; - never call `radix::tile::Id::parent()` at zoom zero, where it underflows; - reject coordinates outside `[0, 2^zoom)`; -- define a supported maximum zoom without an overflowing shift; +- accept zoom levels 0 through + `std::numeric_limits::digits`, inclusive; +- treat that maximum zoom as terminal because a child cannot be represented + by the `uint32_t` x/y coordinates; +- validate the maximum zoom without evaluating an overflowing + `uint32_t{1} << 32`; - use `radix::tile::Id::Hasher`; and -- accept only one canonical XYZ/Slippy interpretation at the persistent - boundary. +- use the Google/XYZ convention, with the origin at the north-west, at the + persistent boundary. The shared code must obtain roots, parents, children, validation, and hashing through the traits. It must not use dimension checks or specialize behaviour @@ -266,7 +271,7 @@ octree::store_layout::level_and_coordinate_directories() octree::store_layout::from_id(id) octree::store_layout::all() -raster_store::store_layout::zxy() +raster_store::store_layout::zoom_x_y_google() raster_store::store_layout::from_id(id) ``` @@ -399,9 +404,11 @@ the known codec endings, removes them to obtain a `NodePath`, and then invokes the selected layout parser. The generic layout does not recover keys directly from codec-owned file paths. -For 2D, the adapter reads and writes a separately versioned -`raster_store::v1` DTO using the serialization envelope required by -[storage-format.md](storage-format.md). +For 2D, the adapter reads and writes `raster_store.index` as a separately +versioned `raster_store::v1` DTO using the serialization envelope required by +[storage-format.md](storage-format.md). Version 1 stores the layout ID and +sparse key/status entries. `Missing` is represented by absence; derivable +aggregate metadata is not stored. Automatic dirty-index saving currently happens in the 3D storage destructor. Preserve that behaviour for existing 3D entry points during the migration. @@ -692,9 +699,8 @@ callers still build through aliases; no filesystem code has changed. in the 3D format adapter. 5. Port legacy layout discovery so it strips recognized 3D file endings before calling `node_path_to_key()`. -6. Add the proposed 2D `z/x/y` mapping only after the review decision listed - below is resolved. The raster codec, not the mapping, adds `.arft` or debug - endings. +6. Add the 2D `z/x/y` mapping with stable ID `zoom/x/y_google`. The raster + codec, not the mapping, adds `.amort` or debug endings. 7. Switch node-path and layout-detection tests to the new implementation. 8. Delete the old strategy base class, registration machinery, and concrete strategy classes once no call site uses them. @@ -773,30 +779,42 @@ or raster dependencies. ### Phase 5 — Add the 2D raster-fundamentalis adapter -This phase starts only after the unresolved 2D format decisions below are -edited into decisions in this document. - -1. Add the checked 2D persistent-key conversion around `radix::tile::Id`. -2. Add the chosen 2D path mapping and stable ID. -3. Define the versioned 2D index DTO and index filename. -4. Use the required magic/version/checksum/compression serialization envelope. +All required 2D format decisions are resolved below. + +1. Add the checked 2D persistent-key conversion around `radix::tile::Id`, + supporting zoom 0 through `std::numeric_limits::digits` and + treating that maximum zoom as terminal. +2. Add the default `//` path mapping with stable ID + `zoom/x/y_google`. +3. Define `raster_store.index` with a versioned 2D DTO containing the layout + ID and sparse key/status entries, without derived aggregate metadata. Use + fixed-width `uint32_t` zoom/x/y fields and serialize entries in + lexicographic `(zoom, x, y)` order. +4. Implement the version-1 magic/version/checksum/compression envelope. Store + zlib CRC-32 as `uint32_t`, computed over the compressed payload, and use + zstd at its best-compression setting. Import zstd through the project's + CMake dependency facility; use the existing `ZLIB::ZLIB` dependency for + CRC-32. 5. Add the 2D format adapter and storage aliases under `raster_store`. -6. Add `raster_store::codec::Arft` with runtime format options when - the final `.arft` serialization is available. +6. Add `raster_store::codec::Amort` with runtime format options when + the final `.amort` serialization is available. 7. Add the output-only `raster_store::codec::Debug` with runtime options for its data and attribution files. -8. If the final `.arft` serialization is not available, exercise storage with - the Phase 3 test codec and do not make `.arft` claims from it. +8. If the final `.amort` serialization is not available, exercise storage + with the Phase 3 test codec and do not make `.amort` claims from it. 9. Test: - - invalid and boundary tile IDs; + - invalid and boundary tile IDs, including the maximum zoom and rejected + children beyond it; - index serialization and validation; - `Leaf`/`Inner` coexistence; - sparse traversal and ancestor lookup; - path round trips; - snapshot hard-link reuse; - - ARFT-to-debug output conversion; and - - explicit cross-filesystem/preflight failure. + - AMORT-to-debug output conversion; + - explicit cross-filesystem/preflight failure; + - publication from `.part` to ``; and + - publication rejection when the destination already exists. Exit criterion: the same shared store can create, open, traverse, and reuse a minimal 2D raster-fundamentalis fixture without changing the 3D fixtures. @@ -891,35 +909,54 @@ No formatting-only pass or unrelated refactor belongs in these commits. ## Decisions required before Phase 5 -These are intentionally unresolved because the current raster-store documents -mark them as unclear: - -1. **2D index filename:** choose the filename used inside a - raster-fundamentalis snapshot. - => call it raster_store.index. -2. **2D node and payload paths:** confirm the extensionless `z/x/y` - `NodePath`, the ARFT codec's resulting `z/x/y.arft` file, and whether - chunks live directly under the snapshot or below a `chunks/` directory. - => I already renamed .arft into .amort (alpine maps org raster tile) in several places. look for places i missed and rename it. the chunks / tiles shall live under a path defined by the store_layout, the default 2d store layout shall put them into z/x/y, not in chunks/z/x/y. -3. **2D layout ID:** choose the stable string serialized in the index. - => "zoom/x/y_google" -4. **Maximum zoom:** choose the supported persistent range and integer widths - for zoom, x, and y. - => use the tile id from radix. x and y are unsigned 32, that would give maximum zoom 31 or 32? that should be well enough. -5. **Index contents:** decide whether v1 stores only sparse status entries or - also derived aggregate metadata. The first implementation should omit - derivable metadata unless a concrete query requires it. - => is this a question? -6. **Index envelope constants:** assign the 2D index magic number, compression - choice, and version according to the common serialization rules. - => erm, question? -7. **Publication:** define whether snapshot completion uses an atomic rename, - a manifest marker, or an external store-level operation. The generic - storage layer should expose finalization but not invent store-level - lifecycle policy. - => use an atomic rename, but don't hide the file. use a .part extension to the new directory name, and then remove it. make sure to flush and close all files before renaming. - -Until these decisions are made, Phases 0–4 can complete and the shared -implementation can be proven with `radix::tile::Id` in memory and with -temporary-directory tests. No provisional 2D disk format should escape into -production data. +The review resolved the layout, key, index-content, checksum, and publication +mechanism questions. One detail remains before Phase 5. + +### Resolved decisions + +1. **Index filename and location:** each snapshot stores + `raster_store.index` directly at its root. +2. **Node and payload paths:** `store_layout` determines the extensionless + node path. The default 2D mapping produces `//` directly below + the snapshot; there is no fixed `chunks/` directory. The AMORT codec + expands it to `//.amort`. +3. **Layout identity and coordinates:** the stable layout ID serialized in the + index is `zoom/x/y_google`. It uses Google/XYZ coordinates with the origin + at the north-west. +4. **Persistent key and zoom range:** use `radix::tile::Id`, whose x and y + coordinates must fit in `uint32_t`. The maximum zoom is therefore + `std::numeric_limits::digits` (32): at that zoom every + `uint32_t` x/y value is representable and valid. A maximum-zoom tile is + terminal because its children would require another coordinate bit. + Validation must special-case this boundary rather than evaluate an + overflowing `uint32_t{1} << 32`. The versioned disk DTO uses fixed-width + `uint32_t` fields for zoom, x, and y rather than serializing the platform + `unsigned` type directly. +5. **Version-1 index contents:** store the layout ID and sparse key/status + entries. `Leaf`, `Inner`, and `Virtual` are serialized; `Missing` is + represented by absence. Do not store derived aggregate metadata until a + concrete query demonstrates that it is required. Serialize entries in + lexicographic `(zoom, x, y)` order and reject duplicate keys while reading. +6. **Serialization envelope:** version 1 uses a fixed, file-type-specific + 64-bit magic value generated once during implementation, a 32-bit version, + zlib CRC-32 stored as `uint32_t` and computed over the compressed payload, + a compression enum, and the compressed payload. Use zstd with its + best-compression setting. The exact magic value is an implementation + constant, not a further design decision. +7. **Publication:** build a snapshot in a sibling directory named + `.part`. Write the index last, validate the completed snapshot, + flush and close every file, and atomically rename the directory to + `` on the same filesystem. The destination must not already + exist. A `.part` directory is never considered published; the rename + removes the suffix without a separate marker or manifest. This guarantees + atomic visibility to concurrent readers during normal operation. +8. **Crash behavior:** the store does not guarantee durability or safe + recovery across a power failure, operating-system crash, or storage + failure. Do not add `fsync()`, `fdatasync()`, `FlushFileBuffers()`, or + equivalent platform-specific synchronization to this refactor. After such + a failure, either a `.part` directory or a final snapshot may be unusable + and must be validated and rebuilt. Flushing and closing files remains + required before the rename for normal-operation correctness, but must not + be documented as a crash-durability guarantee. + +There are no remaining decisions blocking Phase 5. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index c476517d..54832f91 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -31,6 +31,9 @@ this is the binary format used to store rf and tb tiles. both share the same bas - the source attribution index is stored as uint16, and indexes into a global source attribution table (see above) - data is stored in radix::Raster objects (one for source attribution index, one for the actual data), i.e. template struct raster_store::Tile { radix::Raster data; radix::Raster source_attribution; }; - the file ending is .amort (AlpineMapsOrg raster tile), it is serialised used the principles outlined below. +- each snapshot stores its index as `raster_store.index`. +- the default `zoom/x/y_google` layout stores a tile as + `//.amort`, directly below the snapshot root. ## raster-fundamentalis (rf) format raster-fundamentalis is our authoritative raster-store, containing only the data and no overviews / downsampled version. @@ -51,18 +54,19 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an ### unclear - how to build tile-base from raster-fundamentalis - details of the tile server -- file names / index file name ## serialization / deserialization and versioning - all files are serialised with zpp::bits in two levels - first level contains: - an uint64 long file type specific random magic number, generated once at coding time, as an definitive file type identifier - a version number (uint32) - - a checksum for the payload, computed from the compressed data - - an enum for the compression algorithm + - an enum for the checksum, default to HandledByCompressionLib, other option None + - a string checksum for the payload, computed from the uncompressed data (default empty) + - an enum for the compression algorithm, default to to ZStd_BestCompression, other option None. - a payload (byte vector), the second level - the second level is a compressed byte array. the compressed payload is deserialised directly into the respective versioned data classes (tile or source attribution table) -- data structs are stored in versioned namespaces, e.g.: raster::fundamentalis::v1::Tile +- data structs are stored in versioned namespaces, e.g.: + `raster_store::v1::Tile` - outside the versioned namespace, there is a using declaration for the newest version - outside the versioned namespace, there is serialization function, taking only the newest version - outside the versioned namespace, there is a deserialization function, taking a byte stream, and returning the newest version (convert to the newest version, if the payload encodes an older version) @@ -73,7 +77,8 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - if the compression algorithm is missing or unsupported. - deserialization fails - we fail by throwing in these cases -- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd +- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. the context must be configured to compute and check a checksum. do compression and decompression in functions with an std::byte interface (and error handling). dispatch is done via a simple switch case. +- checksum: assert the enum is either None or HandledByCompressionLib. ## to be defined From cfc4e13e853b82b9ff79dcad32faff9d9cfc55db Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:54:39 +0200 Subject: [PATCH 25/53] Clarify refactor plan error handling --- docs/raster-store/refactor-plan.md | 41 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 31da1ef5..51792bbd 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -27,7 +27,10 @@ This plan describes how to extract the existing octree-specific index, traversal - A configured codec owns all filename endings and maps one `NodePath` to one or more physical files. `Codec::paths()` must not need a payload. - Codecs are stateful runtime objects behind a small interface. They may - support reading, writing, or both; an unsupported operation throws. reading and writing must be reentrant (callable concurrently from different threads). + support reading, writing, or both. Reading and writing return + `std::expected`; unsupported operations and other operational failures are + reported as error values. Reading and writing must be reentrant (callable + concurrently from different threads). - `copy_from()` hard-links every file when the input and output codecs return the same path list for a common dummy `NodePath`. Otherwise it decodes with the input codec and encodes with the output codec. Callers can force @@ -298,21 +301,26 @@ public: virtual std::vector paths(const NodePath& node_path) const = 0; - virtual NodeData read(const NodePath&) const { - throw UnsupportedCodecOperation{"read"}; + virtual std::expected + read(const NodePath&) const { + return std::unexpected( + CodecError::unsupported_operation("read")); } - virtual void write( + virtual std::expected write( const NodePath&, const NodeData&) const { - throw UnsupportedCodecOperation{"write"}; + return std::unexpected( + CodecError::unsupported_operation("write")); } }; ``` Concrete codecs contain their configuration and are constructed before -storage use. Unsupported read or write operations may use the base -implementation and throw at runtime. +storage use. `CodecError` is a payload-neutral operational error that records +the failed operation, an error category, and a diagnostic message. Concrete +codecs convert their domain errors to it. Unsupported read or write operations +may use the base implementation and return its `UnsupportedOperation` error. `Codec::paths()` has the following contract: @@ -475,8 +483,8 @@ order, and filename endings. If linking several files fails partway through, remove the target links created by that call before returning the error. There is no silent copy -fallback. An unsupported read or write needed for re-encoding throws through -the codec interface. +fallback. An unsupported read or write needed for re-encoding is returned +through `CopyError`, retaining the underlying `CodecError`. Codec settings that do not change `paths()`, such as compression level or JPEG quality, do not force re-encoding by default. A caller that needs those @@ -601,11 +609,11 @@ handle these failures. The shared copier must return the failure through `copy_subtree()` and the merge call chain until the application boundary can report it with the -affected key and path. An unsupported codec read or write may propagate as -the codec's runtime exception. Assertions remain appropriate for internal -invariants, but filesystem conditions, unsupported conversions, malformed -datasets, and overwrite conflicts are operational errors rather than -assertion failures. +affected key and path. Codec, filesystem, unsupported-conversion, malformed +dataset, and overwrite failures are propagated with `std::expected`; +`CopyError` retains any underlying `CodecError`. Assertions remain appropriate +for internal invariants, but operational failures are not assertion failures +or intentionally thrown exceptions. ### Paired hierarchy walking @@ -902,16 +910,13 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Linked snapshots are modified in place | Immutable snapshot API and overwrite-disabled output | | Multi-file hard linking fails partway through | Remove links created by the failed `copy_from()` before returning | | Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | -| Output-only codec is selected for required input | Throw a clear unsupported-operation error at runtime | +| Output-only codec is selected for required input | Return a clear `UnsupportedOperation` error | | Hard-link failure appears late | 2D operation preflight and explicit errors | | Shared code accumulates mesh/raster policy | Dependency tests/review against the source boundary | | Generic index accidentally dictates both disk formats | Separate 3D and 2D format adapters | ## Decisions required before Phase 5 -The review resolved the layout, key, index-content, checksum, and publication -mechanism questions. One detail remains before Phase 5. - ### Resolved decisions 1. **Index filename and location:** each snapshot stores From 060ca21c455c99cca3de7b469d19540ba81c37cb Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:05:19 +0200 Subject: [PATCH 26/53] Document DAG storage compatibility --- docs/raster-store/refactor-plan.md | 150 ++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 36 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 51792bbd..fa3c2944 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -15,6 +15,9 @@ This plan describes how to extract the existing octree-specific index, traversal `raster_store` namespace. - Existing 3D Structura Fundamentalis datasets must remain readable and writable without changing their on-disk contract. +- Existing DAG datasets stored in the 3D hierarchy with `.bin` payloads must + also remain readable and writable without changing their index or payload + serialization. - 3D compatibility includes both existing path layouts: `flat` and `level_and_coordinate_directories`. - The 2D raster tile format is a separate format. Requirements in @@ -31,6 +34,9 @@ This plan describes how to extract the existing octree-specific index, traversal `std::expected`; unsupported operations and other operational failures are reported as error values. Reading and writing must be reentrant (callable concurrently from different threads). +- Legacy index metadata selects a codec through an explicit, caller-supplied + resolver. The octree format adapter does not contain a global codec registry + or depend on mesh or DAG payload types. - `copy_from()` hard-links every file when the input and output codecs return the same path list for a common dummy `NodePath`. Otherwise it decodes with the input codec and encodes with the output codec. Callers can force @@ -69,6 +75,7 @@ This plan describes how to extract the existing octree-specific index, traversal - Changing the existing 3D hard-link policy by adding a silent file-copy fallback. - Refactoring unrelated octree, DAG, mesh, or tile-builder code. +- Changing the serialized schema of existing DAG `.bin` payloads. ## Compatibility contract @@ -86,7 +93,9 @@ Before moving code, tests must lock down the following 3D behaviour: | Coordinate path | `///` | | Default layout | existing level/coordinate layout | | Layout detection | both existing layouts remain detectable | -| Codec selection | legacy preferred extension selects terrain or configured glTF codec | +| Mesh codec selection | legacy preferred extension selects terrain or configured glTF codec | +| DAG codec selection | legacy `.bin` preferred extension selects the ZPP Bits codec | +| DAG payload encoding | existing `dag::ClusterBatch` ZPP Bits serialization | | Equal codec path lists | hard-link every file, or report an explicit error | | Different codec path lists | decode with input codec and encode with output codec | @@ -122,6 +131,8 @@ src/terrainlib/ │ │ ├── Interface.h │ │ ├── Dummy.h │ │ └── Lru.h +│ ├── codec/ +│ │ └── ZppBits.h │ └── merge/ │ ├── Action.h │ └── walk.h @@ -154,6 +165,8 @@ The exact file grouping may be collapsed if a file would only contain a few lines. The important boundaries are: - `store` contains dimension- and payload-neutral mechanisms; +- `store::codec::ZppBits` is the reusable concrete codec for payload types + that provide ZPP Bits serialization; - `mesh::codec` contains the separately configured terrain and glTF codecs; - `octree` contains the 3D format and key adapters; - `raster_store` contains the new 2D format, key adapters, and raster codecs; @@ -163,6 +176,12 @@ lines. The important boundaries are: Temporary forwarding headers and aliases under `octree` are allowed during migration. They must not contain a second implementation. +DAG serialization remains owned by `dag_builder`. Consolidate the existing +`dag::Id`, `dag::ClusterBatch`, `Clustering`, `Cluster`, and `TextureSet` +serialization functions in `src/dag_builder/serialization.h`. The DAG storage +adapter includes that header explicitly so template instantiation does not +depend on callers including `encoded.h` in the correct order. + ## Shared interfaces The names below are the intended shape, not signatures that must be copied @@ -357,6 +376,19 @@ The mesh side has separate terrain and glTF codecs because they use different format implementations. Binary `.glb` and JSON `.gltf` remain configurations of one glTF codec because both use the same `cgltf` implementation. +The shared module also provides: + +```cpp +template +struct store::codec::ZppBits : store::Codec { .. }; +``` + +It uses the existing `io::read_from_path()` and `io::write_to_path()` +functions, maps one node to `.bin`, and converts `io::Error` to +`CodecError`. It contains no DAG-specific serialization logic. DAG payload +serialization remains in `dag_builder/serialization.h`, and its field order +and meshoptimizer/JPEG encoding remain unchanged. + The raster side has similarly shaped codecs specialized on PixelType: ```cpp @@ -382,8 +414,9 @@ store::IndexedStorage store::cache::Interface ``` -The NodeData codecs remain with their domains under `mesh::codec` and -`raster_store::codec`. +Domain-specific NodeData codecs remain under `mesh::codec` and +`raster_store::codec`. The reusable ZPP Bits codec remains under +`store::codec`. Index serialization is not a responsibility of `store::Index`. Opening and saving a dataset receives a dimension-specific format adapter which provides: @@ -399,18 +432,33 @@ pointers. Choose the smaller implementation after the Phase 0 tests exist. It must not reintroduce a layout class hierarchy or global registration. For 3D, the adapter reads and writes the current `octree` index DTO unchanged. -Its legacy `preferred_extension` field selects the configured codec: +When opening indexed storage or discovering a legacy unindexed directory, it +passes the legacy `preferred_extension` to a caller-supplied codec resolver. +The resolver is an ordinary callable and returns +`std::expected>, CodecError>`. It is not +a global registry. + +The payload domains provide ordinary resolver functions: ```text -.terrain -> terrain codec -.glb -> glTF codec with binary container -.gltf -> glTF codec with JSON container +mesh::codec::from_extension + .terrain -> terrain codec + .glb -> glTF codec with binary container + .gltf -> glTF codec with JSON container + +dag::codec::from_extension + .bin -> store::codec::ZppBits ``` +An unknown extension returns an explicit `UnsupportedCodec` error. Opening a +new empty store may receive an already constructed codec instead of a resolver. +Convenience functions in `src/dag_builder/storage.h` supply the DAG resolver so +application call sites do not handle it directly. + Legacy unindexed-directory discovery remains in the 3D adapter: it recognizes -the known codec endings, removes them to obtain a `NodePath`, and then invokes -the selected layout parser. The generic layout does not recover keys directly -from codec-owned file paths. +candidate endings by asking the supplied resolver, removes an accepted ending +to obtain a `NodePath`, and then invokes the selected layout parser. The +generic layout does not recover keys directly from codec-owned file paths. For 2D, the adapter reads and writes `raster_store.index` as a separately versioned `raster_store::v1` DTO using the serialization envelope required by @@ -660,15 +708,19 @@ No production behaviour changes. 1. Add golden 3D fixtures created by the current code: - one `terrain.index` using `flat`; - one using `level_and_coordinate_directories`; + - one DAG dataset whose index selects `.bin` and whose payload contains a + valid serialized `dag::ClusterBatch`; - physical payload paths for a root, child, and deeper descendant; and - an index containing `Leaf`, `Virtual`, and `Inner`. -2. Test that both fixtures open, resolve the expected IDs and extensions, and +2. Test that all fixtures open, resolve the expected IDs and extensions, and traverse the expected sparse nodes. 3. Add path round-trip tests for boundary IDs and both layouts. 4. Add storage tests for: - matching-extension hard links; - different-extension decode/re-encode; - - `.terrain`, `.glb`, and `.gltf` dispatch; + - `.terrain`, `.glb`, `.gltf`, and `.bin` dispatch through the appropriate + domain resolver; + - explicit failure for an unknown preferred extension; - overwrite rejection; - indexed and unindexed opens; and - final index creation by directory scan. @@ -677,7 +729,8 @@ No production behaviour changes. Exit criterion: the compatibility tests pass against the untouched implementation and fail when any stable filename, layout ID, path encoding, -status value, or index field order is deliberately changed. +status value, index field order, or DAG payload serialization is deliberately +changed. ### Phase 1 — Extract topology into `store` @@ -713,8 +766,8 @@ callers still build through aliases; no filesystem code has changed. 8. Delete the old strategy base class, registration machinery, and concrete strategy classes once no call site uses them. -Exit criterion: the legacy 3D adapter plus codec resolves both fixtures to -identical physical payload paths; generic `Layout` contains no extension; +Exit criterion: the legacy 3D adapter plus codecs resolve all Phase 0 fixtures +to identical physical payload paths; generic `Layout` contains no extension; there is no layout inheritance, RTTI lookup, static registrar, or owning strategy pointer. @@ -722,35 +775,48 @@ strategy pointer. 1. Add the stateful `store::Codec` interface with `paths()`, `read()`, and `write()`. -2. Split the current extension-dispatching `octree::MeshCodec` into: +2. Move the current generic `octree::ZppBitsCodec` to the runtime + `store::codec::ZppBits`, preserving its `.bin` paths and serialized + payload bytes. +3. Consolidate the DAG serialization functions in + `src/dag_builder/serialization.h` without changing their serialized field + order, meshoptimizer encoding, or JPEG texture encoding. +4. Split the current extension-dispatching `octree::MeshCodec` into: - a terrain codec; and - one glTF codec configured for binary `.glb` or JSON `.gltf`. -3. Move copy error, raw storage, caches, logical storage, and indexed storage +5. Move copy error, raw storage, caches, logical storage, and indexed storage into `store`. -4. Make storage own a configured `std::unique_ptr>`; remove the +6. Make storage own a configured `std::unique_ptr>`; remove the codec template parameter from storage. -5. Replace every embedded `octree::Id` with `Traits::Key`. -6. Make every raw file operation obtain its complete file list through +7. Replace every embedded `octree::Id` with `Traits::Key`. +8. Make every raw file operation obtain its complete file list through `Codec::paths()`. `has()` requires every listed file, and `remove()` removes every listed file. -7. Keep payload codecs outside the shared module under `mesh::codec` and - `raster_store::codec`. -8. Split generic index maintenance from 3D index serialization and legacy +9. Keep domain-specific payload codecs outside the shared module under + `mesh::codec` and `raster_store::codec`. +10. Split generic index maintenance from 3D index serialization and legacy folder discovery. -9. Keep the current 3D `terrain.index` DTO and open functions as compatibility - adapters over the shared storage. Map its preferred extension to a - configured terrain or glTF codec. -10. Migrate the existing octree storage aliases and all application callers. -11. Preserve the current 3D destructor-save behaviour until all callers have +11. Keep the current 3D `terrain.index` DTO and open functions as compatibility + adapters over the shared storage. Resolve its preferred extension through + the caller-supplied mesh or DAG resolver. +12. Add DAG storage convenience functions that supply + `dag::codec::from_extension`, and migrate `dag_builder` and + `dag_convert_debug` to them. +13. Migrate the existing octree storage aliases and all other application + callers. +14. Preserve the current 3D destructor-save behaviour until all callers have explicit index finalization. Add focused codec tests using single-file, multi-file, read/write, and write-only test codecs before depending on the raster payload implementation. +Test that a pre-refactor DAG fixture opens through the new resolver and that a +new deterministic `.bin` payload matches the Phase 0 golden bytes and remains +readable through the unchanged ZPP serialization functions. Exit criterion: all existing applications build and all Phase 0 fixtures pass -through the shared runtime codec and storage implementation. No -extension-dispatching mesh codec or second storage implementation remains -under `octree`. +through the shared runtime codec and storage implementation. Existing DAG +payload bytes and `.bin` paths remain compatible. No extension-dispatching +mesh codec or second storage implementation remains under `octree`. ### Phase 4 — Generalize subtree reuse and paired walking @@ -846,8 +912,8 @@ Exit criterion: repository search finds no generic implementation tied to ## Test and verification plan -Tests should live in the existing `unittests_terrainlib` target. Suggested -files: +Generic store tests should live in the existing `unittests_terrainlib` target. +Suggested files: ```text unittests/terrainlib/store_index.cpp @@ -859,16 +925,22 @@ unittests/terrainlib/store_compatibility.cpp unittests/terrainlib/store_merge_walk.cpp ``` +The DAG payload-compatibility fixture and resolver integration test belong in +`unittests_dagbuilder`, because `terrainlib` must not depend on +`dag::ClusterBatch` or its serializers. + During implementation: 1. Build in `$source_dir/build/$config_name`. 2. Run unit tests from that build directory. 3. Run the focused store tests after each edit. 4. Run the full `unittests_terrainlib` target at every phase boundary. -5. Build `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and +5. Run `unittests_dagbuilder` after the ZPP codec, DAG serialization header, or + DAG resolver changes. +6. Build `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and `dag_convert_debug` after their storage aliases move. -6. Run any existing merger integration fixture after Phase 4. -7. Inspect `git diff --check` and the final worktree before each commit. +7. Run any existing merger integration fixture after Phase 4. +8. Inspect `git diff --check` and the final worktree before each commit. No formatting-only pass or unrelated refactor belongs in these commits. @@ -888,12 +960,15 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `strategy/LevelAndCoordinateDirectories.h` | `octree/store_layout/LevelAndCoordinateDirectories.h` | | `octree/storage/cache/*` | `store/cache/*` | | `octree/storage/codec/Codec.h` | runtime `store/Codec.h` | +| `octree/storage/codec/DefaultCodec.h` | runtime `store/codec/ZppBits.h` | | `octree/storage/codec/MeshCodec.h` | `mesh/codec/Terrain.h` and configured `mesh/codec/Gltf.h` | | `octree/storage/RawStorage.h` | `store/RawStorage.h` | | `octree/storage/Storage.h` | `store/Storage.h` | | `octree/storage/IndexedStorage.h` | `store/IndexedStorage.h` | | `octree/storage/helpers.*` | generic scan helpers plus 3D format adapter | | `octree/disk/IndexFile.h` | versioned 3D format adapter under `octree` | +| DAG serializers in `dag_node.h` and `encoded.h` | `dag_builder/serialization.h` | +| `dag_builder/storage.h` aliases | DAG storage aliases plus codec resolver convenience functions | | `sf_merger::NodeWriter` subtree loop | `store/copy_subtree.h` | | `sf_merger::Merger` recursion | `store/merge/walk.h` | @@ -902,6 +977,9 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Risk | Control | |---|---| | Existing indexes stop loading | Golden pre-refactor fixtures and unchanged 3D DTO | +| Existing DAG `.bin` datasets stop loading | Golden DAG fixture, unchanged serializers, and explicit `.bin` resolver | +| Octree format adapter gains DAG dependencies | Caller-supplied resolver owned by `dag_builder` | +| Unknown legacy extension silently selects the wrong codec | Return an explicit `UnsupportedCodec` error | | Valid legacy paths are parsed differently | Characterization and round-trip tests before replacement | | Template migration creates a large unreviewable diff | Compatibility aliases and phase-by-phase caller migration | | `radix::tile::Id` root underflows | Traits intercept root parent lookup | From fb918f0ac25ca2722774447a58586fde197f8fdb Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:19:39 +0200 Subject: [PATCH 27/53] Refine raster store refactor scope --- docs/raster-store/README.md | 6 +- docs/raster-store/architecture.md | 35 +- docs/raster-store/refactor-plan.md | 635 +++++++++++------------------ docs/raster-store/rf_builder.md | 202 +++++++++ docs/raster-store/rf_merger.md | 231 +++++++++++ 5 files changed, 707 insertions(+), 402 deletions(-) create mode 100644 docs/raster-store/rf_builder.md create mode 100644 docs/raster-store/rf_merger.md diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index e757e417..54006330 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -11,7 +11,11 @@ baseline, not a finalized binary-format specification. - [Architecture](architecture.md) - [Storage format](storage-format.md) - [Sampling and pyramid generation](sampling-and-generation.md) +- [DRAFT rf_builder idea notes](rf_builder.md) +- [DRAFT rf_merger idea notes](rf_merger.md) ## Scope -The documents mostly hold format information, and no tool descriptions. We will be writing tools that query and transform these formats. Tool descriptions will be given later. +The documents mostly hold format information. The `rf_builder` and +`rf_merger` documents are explicitly non-authoritative idea parking lots, not +tool specifications or implementation plans. diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 10a62418..204c7ac7 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -99,21 +99,34 @@ Because hard links share inodes, a linked container must never be opened for in-place modification. Existing snapshots are considered immutable. Obsolete snapshots can be deleted, the data will be preserved if necessary due to reference counting in the inodes. When merging, we need to create new hardlinks for unchanged rf tiles (taken completely from either snapshot), and we need to create new rf tiles if the new tile shares information from both. -Cross-filesystem hard links cannot be created. The builder must expose this as -a clear configuration error rather than discovering it after a long build. +### Publication A new snapshot is assembled in a sibling directory named -`.part`. The builder writes the index last, validates the -snapshot, flushes and closes all files, and atomically renames the directory -to `` on the same filesystem. The final destination must not -already exist. A `.part` directory is incomplete and must not be exposed as a -published snapshot during normal operation. +`.part`. Publication follows this protocol: + +1. Write all payload and metadata files into the `.part` directory. +2. Write the index last and validate the completed snapshot. +3. Flush and close every file. +4. Atomically rename the directory to `` on the same filesystem. + +The final destination must not already exist. A `.part` directory is +incomplete and is never considered published. The rename removes the suffix; +there is no separate marker or manifest. During normal operation this gives +readers atomic visibility: they see either no final snapshot or the completed +one. + +Cross-filesystem publication is unsupported because the final rename and any +hard links must remain on one filesystem. A builder or merger must reject that +configuration before starting a long operation. Publication does not guarantee durability or safe recovery across a power -failure, operating-system crash, or storage failure. The implementation does -not perform platform-specific filesystem syncing. After such a failure, -either a `.part` directory or a final snapshot may be unusable and must be -validated and rebuilt. +failure, operating-system crash, or storage failure. Flushing and closing +files before the rename is required for normal-operation correctness, but is +not a crash-durability guarantee. The implementation does not require +`fsync()`, `fdatasync()`, `FlushFileBuffers()`, or equivalent +platform-specific synchronization. After such a failure, either a `.part` +directory or a final snapshot may be unusable and must be validated and +rebuilt. ## Pyramid generator interface (to be confirmed, LLM, do not use the following without consultation) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index fa3c2944..a6689376 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -5,24 +5,39 @@ record of completed work. ## Purpose -This plan describes how to extract the existing octree-specific index, traversal, storage, codec, and subtree-reuse code into a shared 2D/3D store. The refactor must preserve existing 3D datasets while enabling raster-fundamentalis storage without duplicating infrastructure. +This plan describes how to extract the existing octree-specific index, +traversal, storage, and codec code into a shared 2D/3D store. The refactor +must preserve existing 3D datasets and prove, using test-only mappings and +codecs where necessary, that the shared mechanisms work with a 2D key. +Persistent raster-fundamentalis formats, adapters, and tools are later work. ## Decisions already made - The shared implementation will live in `src/terrainlib/store` and use the `store` namespace. -- The 2D implementation will live in `src/terrainlib/raster_store` and use the - `raster_store` namespace. +- The minimal 2D traits adapter used to exercise the shared hierarchy will + live in `src/terrainlib/raster_store` and use the `raster_store` namespace. +- Common Structura Fundamentalis validation and errors will live in + `src/terrainlib/sf` and use the `sf` namespace. - Existing 3D Structura Fundamentalis datasets must remain readable and writable without changing their on-disk contract. - Existing DAG datasets stored in the 3D hierarchy with `.bin` payloads must also remain readable and writable without changing their index or payload serialization. +- `Inner` is a valid shared topology state and is supported by DAG, + raster-fundamentalis, and tile-base datasets. It is not valid in Structura + Fundamentalis datasets. +- SF producer and processing boundaries validate indexed SF data and return a + typed `sf::InvalidTopology` error containing an offending key when `Inner` + is present. The diagnostic `sf_index_browser` is exempt. This is an SF data + invariant, not a generic store or octree-format rule. - 3D compatibility includes both existing path layouts: `flat` and `level_and_coordinate_directories`. -- The 2D raster tile format is a separate format. Requirements in - this directory apply to that 2D format and must not be retrofitted onto - existing 3D datasets. The storage format for 2d tiles is defined in storage-format.md +- A persistent 2D raster tile format is not defined by this refactor. + [architecture.md](architecture.md) and + [storage-format.md](storage-format.md) describe intended direction and + provisional requirements that will be finalized in later RF work. They must + not be retrofitted onto existing 3D datasets. - A path layout strategy should contain a stable identifier and two operations: key to extensionless `NodePath`, and `NodePath` to key. It should not require an inheritance hierarchy, RTTI, global @@ -39,8 +54,7 @@ This plan describes how to extract the existing octree-specific index, traversal or depend on mesh or DAG payload types. - `copy_from()` hard-links every file when the input and output codecs return the same path list for a common dummy `NodePath`. Otherwise it decodes with - the input codec and encodes with the output codec. Callers can force - re-encoding. + the input codec and encodes with the output codec. - 3D geometry, ECEF bounds, mesh codecs, mesh reconstruction, mask geometry, and raster-specific processing remain outside the shared store. @@ -48,18 +62,17 @@ This plan describes how to extract the existing octree-specific index, traversal 1. Use one sparse hierarchy implementation for `octree::Id` and `radix::tile::Id`. -2. Use one traversal, storage, cache, runtime codec boundary, and - unchanged-subtree copier for 2D and 3D. -3. Make paired-tree walking reusable without putting mesh or raster merge - policy into the shared layer. -4. Replace the current layout-strategy class hierarchy with small path-mapping +2. Make traversal, storage, cache, and the runtime codec boundary + dimension-neutral while keeping production format adapters 3D-only in this + refactor. +3. Replace the current layout-strategy class hierarchy with small path-mapping values backed by function pairs. -5. Allow one logical node payload to consist of multiple files without making +4. Allow one logical node payload to consist of multiple files without making layouts aware of those files. -6. Preserve all valid existing 3D index files and payload paths. -7. Introduce the 2D storage adapter without inventing unspecified raster file - details. -8. Land the refactor in small, testable steps. Every phase should build and +5. Preserve all valid existing 3D index files and payload paths. +6. Prove the shared topology and traversal with `radix::tile::Id` without + defining a persistent 2D adapter or format. +7. Land the refactor in small, testable steps. Every phase should build and pass tests before the next phase begins. ## Non-goals @@ -68,10 +81,18 @@ This plan describes how to extract the existing octree-specific index, traversal other 3D spatial calculations. - Defining or implementing GDAL ingestion, raster resampling, filtering, source selection, or mask rasterisation. -- Defining the `.amort` payload or source-attribution-table serialization - beyond the requirements already in [storage-format.md](storage-format.md). -- Implementing an `rf_builder`, `rf_merger`, tile-base generator, or tile - server in this refactor. +- Defining or implementing a raster-fundamentalis index, payload format, + layout ID, codec, publication lifecycle, or persistent storage adapter. +- Implementing an `rf_builder`, `rf_merger`, tile-base generator, tile server, + snapshot-reuse operation, or other RF tool in this refactor. +- Defining the raster-fundamentalis merge policy or the final paired-hierarchy + walker/action algebra. That work is deferred until `rf_merger` requirements + are defined. +- Defining a shared subtree-copy abstraction, `Inner` subtree-copy behaviour, + or forced re-encoding policy for RF. Those decisions are deferred until + `rf_merger`. +- Adding merge semantics for `Inner` nodes in SF. Such nodes are invalid SF + input and must be rejected before merge dispatch. - Changing the existing 3D hard-link policy by adding a silent file-copy fallback. - Refactoring unrelated octree, DAG, mesh, or tile-builder code. @@ -87,6 +108,8 @@ Before moving code, tests must lock down the following 3D behaviour: | Index field order | layout ID, preferred extension, index map | | Node-key encoding | existing `octree::Id` level/index serialization | | Node-status encoding | `Leaf = 0`, `Inner = 1`, `Virtual = 2` | +| Valid SF statuses | `Leaf` and `Virtual`; reject `Inner` with `sf::InvalidTopology` | +| Valid DAG statuses | `Leaf`, `Inner`, and `Virtual` | | Flat layout ID | `flat` | | Flat path | `-` | | Coordinate layout ID | `level_and_coordinate_directories` | @@ -126,20 +149,19 @@ src/terrainlib/ │ ├── RawStorage.h │ ├── Storage.h │ ├── IndexedStorage.h -│ ├── copy_subtree.h │ ├── cache/ │ │ ├── Interface.h │ │ ├── Dummy.h │ │ └── Lru.h -│ ├── codec/ -│ │ └── ZppBits.h -│ └── merge/ -│ ├── Action.h -│ └── walk.h +│ └── codec/ +│ └── ZppBits.h ├── mesh/ │ └── codec/ │ ├── Terrain.h │ └── Gltf.h +├── sf/ +│ ├── InvalidTopology.h +│ └── validate_index.h ├── octree/ │ ├── Id.h │ ├── StoreTraits.h @@ -151,14 +173,7 @@ src/terrainlib/ │ ├── IndexFile.h │ └── open.h └── raster_store/ - ├── StoreTraits.h - ├── IndexFile.h - ├── Storage.h - ├── codec/ - │ ├── Amort.h - │ └── Debug.h - └── store_layout/ - └── ZoomXYGoogle.h + └── StoreTraits.h ``` The exact file grouping may be collapsed if a file would only contain a few @@ -168,11 +183,25 @@ lines. The important boundaries are: - `store::codec::ZppBits` is the reusable concrete codec for payload types that provide ZPP Bits serialization; - `mesh::codec` contains the separately configured terrain and glTF codecs; +- `sf` contains SF-specific topology validation and errors shared by + `sf_builder`, `sf_merger`, and `dag_builder`; - `octree` contains the 3D format and key adapters; -- `raster_store` contains the new 2D format, key adapters, and raster codecs; - and +- `raster_store` contains only the minimal 2D hierarchy traits adapter in this + refactor; and - subdirectory names match their namespaces where a subnamespace is used. +Shared topology and octree-format adapters accept `Inner`. The SF restriction +is enforced by `sf::validate_index()` at SF producer/consumer boundaries and +reported as `sf::InvalidTopology`. It must not be embedded in `store::Index`, +traversal, or the generic 3D disk adapter. `sf_index_browser` is a diagnostic +tool and intentionally does not apply SF validation, so it can display invalid +trees including `Inner`. + +`sf::validate_index()` returns +`std::expected`. SF application-level error types +must retain this error and `CopyError` when propagating failures; neither is +reduced to a log message, assertion, or generic boolean. + Temporary forwarding headers and aliases under `octree` are allowed during migration. They must not contain a second implementation. @@ -225,9 +254,10 @@ changing `octree::Id`. by the `uint32_t` x/y coordinates; - validate the maximum zoom without evaluating an overflowing `uint32_t{1} << 32`; -- use `radix::tile::Id::Hasher`; and -- use the Google/XYZ convention, with the origin at the north-west, at the - persistent boundary. +- use `radix::tile::Id::Hasher`. + +This traits adapter defines only hierarchy operations. It does not define +persistent coordinates, a path layout, or an RF disk format. The shared code must obtain roots, parents, children, validation, and hashing through the traits. It must not use dimension checks or specialize behaviour @@ -264,7 +294,6 @@ node. For example: ```text octree flat 12-123456 octree coordinates 12/34/56/78 -raster-store ZXY 12/2200/1400 ``` It does not necessarily name a physical file. Replace @@ -292,9 +321,6 @@ octree::store_layout::flat() octree::store_layout::level_and_coordinate_directories() octree::store_layout::from_id(id) octree::store_layout::all() - -raster_store::store_layout::zoom_x_y_google() -raster_store::store_layout::from_id(id) ``` This retains runtime selection from an index file while removing virtual @@ -366,10 +392,10 @@ glTF codec configured for JSON output 12/34/56/78 -> 12/34/56/78.gltf -Debug raster codec configured for JPEG data and PNG attribution - 12/2200/1400 - -> 12/2200/1400.data.jpg - -> 12/2200/1400.attribution.png +Multi-file test codec + 12/34/56/78 + -> 12/34/56/78.data + -> 12/34/56/78.metadata ``` The mesh side has separate terrain and glTF codecs because they use different @@ -389,19 +415,6 @@ functions, maps one node to `.bin`, and converts `io::Error` to serialization remains in `dag_builder/serialization.h`, and its field order and meshoptimizer/JPEG encoding remain unchanged. -The raster side has similarly shaped codecs specialized on PixelType: - -```cpp -template struct raster_store::codec::Amort : store::Codec> { .. }; -template struct raster_store::codec::Debug : store::Codec> { .. }; -``` - -`Amort` supports reading and writing. `Debug` supports writing only and may -contain runtime options for data format, attribution format, JPEG quality, or -similar debugging choices. It is not template-composed from separate image -codec types. - - ### Storage and format adapters Generalize storage over traits and NodeData. It owns a configured codec through @@ -414,9 +427,8 @@ store::IndexedStorage store::cache::Interface ``` -Domain-specific NodeData codecs remain under `mesh::codec` and -`raster_store::codec`. The reusable ZPP Bits codec remains under -`store::codec`. +Domain-specific mesh codecs remain under `mesh::codec`. The reusable ZPP Bits +codec remains under `store::codec`. RF codecs are deferred. Index serialization is not a responsibility of `store::Index`. Opening and saving a dataset receives a dimension-specific format adapter which provides: @@ -460,69 +472,49 @@ candidate endings by asking the supplied resolver, removes an accepted ending to obtain a `NodePath`, and then invokes the selected layout parser. The generic layout does not recover keys directly from codec-owned file paths. -For 2D, the adapter reads and writes `raster_store.index` as a separately -versioned `raster_store::v1` DTO using the serialization envelope required by -[storage-format.md](storage-format.md). Version 1 stores the layout ID and -sparse key/status entries. `Missing` is represented by absence; derivable -aggregate metadata is not stored. - Automatic dirty-index saving currently happens in the 3D storage destructor. Preserve that behaviour for existing 3D entry points during the migration. -The new 2D snapshot API should require an explicit finalization/publication -step; a destructor must not make an incomplete snapshot authoritative. -### Copying and unchanged-subtree reuse +### Copying one node and SF subtree reuse There are two separate responsibilities in the current implementation: -1. `sf_merger::NodeWriter` traverses a source subtree and decides which - indexed nodes to reuse. +1. `sf_merger::NodeWriter` traverses a source subtree and + `sf_merger::cut_leaf_node()` identifies an unchanged leaf. 2. `octree::Storage::copy_from()` delegates to `octree::RawStorage::copy_from()`, where `std::filesystem::create_hard_link()` performs the actual hard link and the target index is updated on success. The filesystem hard-link implementation is therefore already in terrainlib. -This refactor shall move the payload-neutral subtree traversal and copy -orchestration out of `sf_merger`. +Move the one-node storage operation into the shared store, but keep subtree +selection and traversal in `sf_merger`. There is no current DAG caller, and RF +subtree-copy requirements will be defined with `rf_merger`. -The current call chain is: +The migrated call chains are: ```text sf_merger decides to keep a source subtree unchanged -> NodeWriter traverses the source index - -> octree::Storage::copy_from() - -> octree::RawStorage::copy_from() - -> create_hard_link(), or decode/encode when formats differ -``` - -The target call chain becomes: + -> store::Storage::copy_from() + -> hard-link every codec path, or decode/encode -```text -merge policy decides to keep a source subtree unchanged - -> store::copy_subtree() +sf_merger determines that a cut leaf is unchanged + -> cut_leaf_node() -> store::Storage::copy_from() -> hard-link every codec path, or decode/encode ``` `Storage::copy_from()` remains the operation for copying one logical node. -Add: - -```cpp -struct CopyOptions { - bool force_reencode = false; -}; -``` - For one key, `copy_from()`: 1. calls the input and output `Codec::paths()` with the same fixed dummy `NodePath`; -2. when the lists are equal and `force_reencode` is false, calls both codecs - again with their actual source and target `NodePath` values and hard-links - every source path to the corresponding target path; -3. when the dummy lists differ or re-encoding is forced, reads the payload - with the input codec and writes it with the output codec; and +2. when the lists are equal, calls both codecs again with their actual source + and target `NodePath` values and hard-links every source path to the + corresponding target path; +3. when the dummy lists differ, reads the payload with the input codec and + writes it with the output codec; and 4. updates the target index only after all links or the write complete. The dummy path must be fixed and collision-free, for example @@ -534,166 +526,73 @@ created by that call before returning the error. There is no silent copy fallback. An unsupported read or write needed for re-encoding is returned through `CopyError`, retaining the underlying `CodecError`. -Codec settings that do not change `paths()`, such as compression level or -JPEG quality, do not force re-encoding by default. A caller that needs those -settings applied to every node passes `force_reencode = true`. - Hard-link rules: - never modify an existing linked payload in place; - a matching codec path list hard-links every file; - a different path list decodes with the input codec and encodes with the output codec; -- `force_reencode` always selects decode/encode; - hard-link failure is explicit; -- 2D snapshot tools preflight that source and destination support hard links - before a long operation starts; and - no silent file-copy fallback is introduced. -#### Shared subtree copier - -Move the payload-neutral traversal in -`sf_merger::NodeWriter::copy_subtree_to_output()` to a shared operation such -as: - -```cpp -std::expected -store::copy_subtree( - const IndexedStorage& source, - Storage& target, - const Key& root, - CopyOptions options = {}); -``` - -The shared operation: - -1. traverses an indexed source subtree; -2. skips `Virtual` nodes; -3. calls `copy_from()` for physical payloads in both `Leaf` and `Inner` - states; -4. continues traversal below `Inner` nodes; and -5. returns copy failures to its caller instead of converting them into an - assertion or immediate process termination. - -The operation is payload-neutral because it only interprets hierarchy status -and delegates each physical node to `Storage::copy_from()`. It does not know -about meshes, rasters, masks, attribution, or their encodings. +#### SF-local subtree traversal -#### `Leaf`, `Inner`, and `Virtual` +`sf_merger::NodeWriter::copy_subtree_to_output()` remains in `sf_merger`. It +uses shared traversal and `Storage::copy_from()`, but it is not promoted to a +generic store API during this refactor. -The sparse index distinguishes payload presence from descendant presence: +SF validation guarantees that its indexed inputs contain only `Leaf` and +`Virtual` nodes. The SF-local traversal skips `Virtual`, copies `Leaf`, and +does not define behaviour for `Inner`. An `Inner` node is rejected before +merge or cut processing with `sf::InvalidTopology` containing the offending +key. -| Status | Physical payload | Indexed descendants | -|---|---:|---:| -| `Leaf` | yes | no | -| `Inner` | yes | yes | -| `Virtual` | no | yes | - -`Missing` is represented by absence from the sparse index and is not visited -by subtree traversal. - -The current `NodeWriter` callback effectively does: - -```cpp -if (status == NodeStatus::Virtual) { - return; -} - -DEBUG_ASSERT(status == NodeStatus::Leaf); -DEBUG_ASSERT_VAL(target.copy_from(id, source)); -``` - -The `DEBUG_ASSERT(status == Leaf)` is an incorrect assumption. `Inner` is -also a physical state and its payload must be copied. The existing -`IndexMap::add()` creates an `Inner` node whenever a physical `Leaf` gains a -physical descendant. Such a hierarchy is valid in 3D and is explicitly -required for raster-fundamentalis, where a coarse physical tile may coexist -with more accurate descendants. - -For example: - -```text -zoom 10 physical tile -> Inner -└── zoom 11 physical tile -> Leaf -``` - -Reusing this subtree must preserve both payloads. Skipping the `Inner` -payload would lose the coarse fallback; asserting on `Inner` rejects a valid -hierarchy. - -The shared copier shall instead handle status as: - -```cpp -switch (status) { -case NodeStatus::Virtual: - break; -case NodeStatus::Leaf: -case NodeStatus::Inner: - target.copy_from(id, source, options); - break; -} -``` - -Traversal shall continue below the `Inner` node. Copying the parent first adds it -to the target as a `Leaf`; copying its descendant then promotes the parent to -`Inner`, reconstructing the source topology through the normal index -transitions. - -The existing paired merger has a related limitation: its dispatcher only -handles `Missing`, `Leaf`, and `Virtual` pairs and sends any pair containing -`Inner` to `UNREACHABLE()`. The paired hierarchy-walking work below must -handle or explicitly reject all 16 status combinations without treating -valid input data as an impossible program state. +When `rf_merger` is designed, it can initially compose `store::traverse` and +`Storage::copy_from()`. At that point, the SF and RF implementations provide +enough evidence to decide whether a shared subtree copier is useful and how it +must handle RF `Inner` nodes. #### Error propagation The lower storage layer already represents ordinary copy failures, including missing source files, directory creation failure, hard-link failure, decode failure, and encode failure. The current `NodeWriter` consumes -`Storage::copy_from()` with `DEBUG_ASSERT_VAL`, while some overwrite paths -terminate through `LOG_ERROR_AND_EXIT()`. Because -`copy_subtree_to_output()` returns `void`, the merge caller cannot report or -handle these failures. - -The shared copier must return the failure through `copy_subtree()` and the -merge call chain until the application boundary can report it with the -affected key and path. Codec, filesystem, unsupported-conversion, malformed -dataset, and overwrite failures are propagated with `std::expected`; -`CopyError` retains any underlying `CodecError`. Assertions remain appropriate -for internal invariants, but operational failures are not assertion failures -or intentionally thrown exceptions. - -### Paired hierarchy walking - -Extract only the dimension-neutral control flow from `sf_merger::Merger`. -The shared walker obtains the left and right status for a key and asks a -policy for one of: - -```cpp -store::merge::Recurse -store::merge::Ignore -store::merge::KeepLeft -store::merge::KeepRight -store::merge::Write -``` - -The walker owns recursion and unchanged-subtree reuse. The policy owns -selection and payload combination. - -All 16 combinations of `Missing`, `Leaf`, `Inner`, and `Virtual` must be -handled. Unsupported combinations may return a typed error, but they must not -fall into `UNREACHABLE()`. - -The existing 3D adapter retains: - -- `NodeLoader` ancestor mesh reconstruction; -- ECEF node bounds; -- mesh masks and clipping; -- mesh combination and texture atlas generation; and -- mesh validation and auxiliary texture writes. - -A future 2D merger can supply a raster policy without changing the shared -walker. Implementing that policy is outside this refactor. +`Storage::copy_from()` with `DEBUG_ASSERT_VAL`, as does the unchanged-leaf path +in `cut_leaf_node()`, while some overwrite paths terminate through +`LOG_ERROR_AND_EXIT()`. Their `void` call chains prevent the application from +reporting or handling these failures. + +Return failures from the SF-local subtree and cut functions through their +callers until the application boundary can report the affected key and path. +Codec, filesystem, unsupported-conversion, malformed-dataset, and overwrite +failures are propagated with `std::expected`; `CopyError` retains any +underlying `CodecError`. Assertions remain appropriate for internal +invariants, but operational failures are not assertion failures or +intentionally thrown exceptions. + +### Paired hierarchy walking is deferred + +This refactor does not extract `sf_merger::Merger` into a shared paired-tree +walker. SF only permits `Missing`, `Leaf`, and `Virtual`, and its current +recursion does not provide enough evidence to define the `Inner` behaviour +needed by raster-fundamentalis and tile-base merging. + +The previously proposed mutually exclusive actions `Recurse`, `Ignore`, +`KeepLeft`, `KeepRight`, and `Write` cannot express both an action for the +current physical payload and recursion into descendants. `Inner` merging may +require both. Whether the future interface uses a combined `WriteAndRecurse` +action or independent current-node and descendant decisions belongs to the +`rf_merger` design. + +For this refactor: + +- keep recursion, subtree traversal, and mesh policy in `sf_merger`; +- validate SF inputs and reject `Inner` through `std::expected`; +- move only the one-node `Storage::copy_from()` mechanism into `store`; and +- do not add a shared `store::merge` namespace. + +A future `rf_merger` task will define the paired-tree action algebra from the +2D requirements and may migrate `sf_merger` once both use cases are known. ## Implementation phases @@ -705,26 +604,30 @@ has failing tests. No production behaviour changes. -1. Add golden 3D fixtures created by the current code: +1. Add golden SF fixtures created by the current code: - one `terrain.index` using `flat`; - one using `level_and_coordinate_directories`; - - one DAG dataset whose index selects `.bin` and whose payload contains a - valid serialized `dag::ClusterBatch`; - - physical payload paths for a root, child, and deeper descendant; and - - an index containing `Leaf`, `Virtual`, and `Inner`. -2. Test that all fixtures open, resolve the expected IDs and extensions, and + - across the fixtures, physical payload paths for a root, child, and deeper + descendant, without placing physical payloads at ancestor and descendant + keys in the same index; and + - index entries containing `Leaf` and `Virtual`, but no `Inner`. +2. Add one golden DAG dataset whose index selects `.bin`, whose payload + contains a valid serialized `dag::ClusterBatch`, and whose index contains + `Leaf`, `Virtual`, and `Inner`. +3. Test that all fixtures open, resolve the expected IDs and extensions, and traverse the expected sparse nodes. -3. Add path round-trip tests for boundary IDs and both layouts. -4. Add storage tests for: +4. Add path round-trip tests for boundary IDs and both layouts. +5. Add storage tests for: - matching-extension hard links; - different-extension decode/re-encode; - - `.terrain`, `.glb`, `.gltf`, and `.bin` dispatch through the appropriate - domain resolver; + - `.terrain`, `.glb`, `.gltf`, and `.bin` open/read dispatch through the + appropriate domain resolver; the `.bin` case does not exercise + `copy_from()`; - explicit failure for an unknown preferred extension; - overwrite rejection; - indexed and unindexed opens; and - final index creation by directory scan. -5. Record the pre-refactor public aliases used by `sf_builder`, `sf_merger`, +6. Record the pre-refactor public aliases used by `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and `dag_convert_debug`. Exit criterion: the compatibility tests pass against the untouched @@ -760,10 +663,8 @@ callers still build through aliases; no filesystem code has changed. in the 3D format adapter. 5. Port legacy layout discovery so it strips recognized 3D file endings before calling `node_path_to_key()`. -6. Add the 2D `z/x/y` mapping with stable ID `zoom/x/y_google`. The raster - codec, not the mapping, adds `.amort` or debug endings. -7. Switch node-path and layout-detection tests to the new implementation. -8. Delete the old strategy base class, registration machinery, and concrete +6. Switch node-path and layout-detection tests to the new implementation. +7. Delete the old strategy base class, registration machinery, and concrete strategy classes once no call site uses them. Exit criterion: the legacy 3D adapter plus codecs resolve all Phase 0 fixtures @@ -792,8 +693,8 @@ strategy pointer. 8. Make every raw file operation obtain its complete file list through `Codec::paths()`. `has()` requires every listed file, and `remove()` removes every listed file. -9. Keep domain-specific payload codecs outside the shared module under - `mesh::codec` and `raster_store::codec`. +9. Keep domain-specific mesh codecs outside the shared module under + `mesh::codec`. 10. Split generic index maintenance from 3D index serialization and legacy folder discovery. 11. Keep the current 3D `terrain.index` DTO and open functions as compatibility @@ -806,6 +707,10 @@ strategy pointer. callers. 14. Preserve the current 3D destructor-save behaviour until all callers have explicit index finalization. +15. Instantiate the shared storage tests with `raster_store::StoreTraits` + using a test-only path mapping and codec. This proves the storage templates + contain no hidden `octree::Id` dependency without defining a stable RF + layout, codec, or disk format. Add focused codec tests using single-file, multi-file, read/write, and write-only test codecs before depending on the raster payload implementation. @@ -818,7 +723,7 @@ through the shared runtime codec and storage implementation. Existing DAG payload bytes and `.bin` paths remain compatible. No extension-dispatching mesh codec or second storage implementation remains under `octree`. -### Phase 4 — Generalize subtree reuse and paired walking +### Phase 4 — Harden node reuse and enforce SF topology 1. Change `Storage::copy_from()` to compare input and output codec path lists for the fixed dummy `NodePath`. @@ -826,74 +731,37 @@ mesh codec or second storage implementation remains under `octree`. created by a partially failed call. 3. Decode with the input codec and encode with the output codec when lists differ. -4. Add `CopyOptions::force_reencode`, defaulting to false. -5. Test: +4. Test: - one-file hard linking; - multi-file hard linking; - different path counts and endings; - - forced re-encoding with otherwise equal paths; + - cleanup after a partially failed multi-file hard link; - conversion between terrain and glTF; - - conversion into a write-only codec; and - - runtime failure when a required codec operation is unsupported. -6. Add the shared unchanged-subtree copier. -7. Test copies containing `Leaf`, `Virtual`, and `Inner` nodes. -8. Add the paired hierarchy walker and typed actions. -9. Cover all 16 status pairs with table-driven tests. -10. Adapt the 3D merger to the shared walker while keeping mesh policy in - `sf_merger`. -11. Remove generic recursion and copy logic from `sf_merger::Merger` and - `NodeWriter`. -12. Add a 3D integration test proving an unchanged subtree is hard-linked and - a changed boundary node is newly written. - -Exit criterion: the existing 3D merger behaviour is preserved, `Inner` no -longer reaches `UNREACHABLE()`, multi-file reuse works through -`Codec::paths()`, and the shared walker contains no mesh, ECEF, GDAL, OpenCV, -or raster dependencies. - -### Phase 5 — Add the 2D raster-fundamentalis adapter - -All required 2D format decisions are resolved below. - -1. Add the checked 2D persistent-key conversion around `radix::tile::Id`, - supporting zoom 0 through `std::numeric_limits::digits` and - treating that maximum zoom as terminal. -2. Add the default `//` path mapping with stable ID - `zoom/x/y_google`. -3. Define `raster_store.index` with a versioned 2D DTO containing the layout - ID and sparse key/status entries, without derived aggregate metadata. Use - fixed-width `uint32_t` zoom/x/y fields and serialize entries in - lexicographic `(zoom, x, y)` order. -4. Implement the version-1 magic/version/checksum/compression envelope. Store - zlib CRC-32 as `uint32_t`, computed over the compressed payload, and use - zstd at its best-compression setting. Import zstd through the project's - CMake dependency facility; use the existing `ZLIB::ZLIB` dependency for - CRC-32. -5. Add the 2D format adapter and storage aliases under - `raster_store`. -6. Add `raster_store::codec::Amort` with runtime format options when - the final `.amort` serialization is available. -7. Add the output-only `raster_store::codec::Debug` with runtime - options for its data and attribution files. -8. If the final `.amort` serialization is not available, exercise storage - with the Phase 3 test codec and do not make `.amort` claims from it. -9. Test: - - invalid and boundary tile IDs, including the maximum zoom and rejected - children beyond it; - - index serialization and validation; - - `Leaf`/`Inner` coexistence; - - sparse traversal and ancestor lookup; - - path round trips; - - snapshot hard-link reuse; - - AMORT-to-debug output conversion; - - explicit cross-filesystem/preflight failure; - - publication from `.part` to ``; and - - publication rejection when the destination already exists. - -Exit criterion: the same shared store can create, open, traverse, and reuse a -minimal 2D raster-fundamentalis fixture without changing the 3D fixtures. - -### Phase 6 — Cleanup and documentation + - overwrite rejection; and + - missing-file, hard-link, decode, and encode error propagation. +5. Add `sf::validate_index()`, returning `sf::InvalidTopology` with the + offending key when it encounters `Inner`. +6. Apply the validator to SF-builder output finalization, SF-merger merge and + cut inputs, SF-merger output, and the DAG builder's SF input. Do not apply + it when opening DAG datasets, through generic octree/store adapters, or in + the diagnostic `sf_index_browser`. +7. Keep SF recursion, subtree traversal, and mesh policy in `sf_merger`. + Change its subtree and cut call chains to propagate validation and + `copy_from()` failures through `std::expected` to the application boundary. +8. Add integration tests proving: + - valid `Leaf`/`Virtual` SF merge behaviour is unchanged; + - an SF input containing `Inner` fails validation before merge dispatch; + - an unchanged SF subtree is hard-linked and a changed boundary node is + newly written; and + - an unchanged leaf in the SF cut path is hard-linked while a clipped leaf + is newly written. + +Exit criterion: one-node copying works through `Codec::paths()`, SF consumers +reject `Inner` with a typed error before processing, existing valid SF merge +and cut behaviour is preserved, and neither a shared subtree copier nor a +paired-tree walker has been introduced. + +### Phase 5 — Cleanup and documentation 1. Remove temporary forwarding headers that no repository caller needs. 2. Remove obsolete files under `octree/disk` and the old generic @@ -902,10 +770,15 @@ minimal 2D raster-fundamentalis fixture without changing the 3D fixtures. `octree`. 4. Update includes, CMake source lists, and precompiled-header includes. 5. Update [architecture.md](architecture.md), - [status-quo.md](status-quo.md), and the before-refactor report with links to - the implemented boundary. Preserve the before-refactor report as history; - do not rewrite it as if it described the new code. -6. Document the final public names and a minimal 2D/3D opening example. + [storage-format.md](storage-format.md), [status-quo.md](status-quo.md), and + the before-refactor report with links to the implemented boundary. State + clearly that RF formats and tools remain future work and that architecture + requirements are not acceptance criteria for this refactor. Preserve the + before-refactor report as history; do not rewrite it as if it described the + new code. +6. Document the final public names, a legacy 3D opening example, and an + in-memory `radix::tile::Id` topology example. Do not document a persistent + RF format or opening API. Exit criterion: repository search finds no generic implementation tied to `octree::Id`; all tests pass; the old layout strategy hierarchy is gone. @@ -922,13 +795,19 @@ unittests/terrainlib/store_layout.cpp unittests/terrainlib/store_codec.cpp unittests/terrainlib/store_storage.cpp unittests/terrainlib/store_compatibility.cpp -unittests/terrainlib/store_merge_walk.cpp +unittests/terrainlib/sf_validate_index.cpp ``` The DAG payload-compatibility fixture and resolver integration test belong in `unittests_dagbuilder`, because `terrainlib` must not depend on `dag::ClusterBatch` or its serializers. +The validator's unit tests belong in `unittests_terrainlib`. Boundary tests +belong with their consumers: SF-builder output validation in +`unittests_sfbuilder`, merge and cut validation/error propagation in +`unittests_sfmerger`, and DAG-builder SF-input validation in +`unittests_dagbuilder`. `sf_index_browser` remains unvalidated by design. + During implementation: 1. Build in `$source_dir/build/$config_name`. @@ -939,7 +818,9 @@ During implementation: DAG resolver changes. 6. Build `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and `dag_convert_debug` after their storage aliases move. -7. Run any existing merger integration fixture after Phase 4. +7. Run `unittests_sfbuilder`, `unittests_sfmerger`, and + `unittests_dagbuilder`, plus any existing merger integration fixture, after + the Phase 4 validation changes. 8. Inspect `git diff --check` and the final worktree before each commit. No formatting-only pass or unrelated refactor belongs in these commits. @@ -969,8 +850,9 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `octree/disk/IndexFile.h` | versioned 3D format adapter under `octree` | | DAG serializers in `dag_node.h` and `encoded.h` | `dag_builder/serialization.h` | | `dag_builder/storage.h` aliases | DAG storage aliases plus codec resolver convenience functions | -| `sf_merger::NodeWriter` subtree loop | `store/copy_subtree.h` | -| `sf_merger::Merger` recursion | `store/merge/walk.h` | +| `sf_merger::NodeWriter` subtree loop | remains in `sf_merger`; return copy failures through `std::expected` | +| `sf_merger::cut_leaf_node()` copy path | remains in `sf_merger`; return copy failures through `std::expected` | +| SF merger `Inner` `UNREACHABLE()` path | `sf::validate_index()` returning `sf::InvalidTopology` | ## Risks and controls @@ -983,63 +865,36 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Valid legacy paths are parsed differently | Characterization and round-trip tests before replacement | | Template migration creates a large unreviewable diff | Compatibility aliases and phase-by-phase caller migration | | `radix::tile::Id` root underflows | Traits intercept root parent lookup | -| Invalid 2D coordinates become persistent | Validate on every disk/API boundary | -| `Inner` payloads are lost during subtree reuse | Copy every physical status and test mixed-depth fixtures | -| Linked snapshots are modified in place | Immutable snapshot API and overwrite-disabled output | +| Invalid `radix::tile::Id` values enter the shared index | Validate through `raster_store::StoreTraits` and test boundary zooms | +| Invalid `Inner` nodes reach SF merge dispatch | Validate every SF input first and return the offending key in a typed error | +| SF subtree copying is generalized before RF requirements exist | Keep it in `sf_merger`; reconsider extraction with `rf_merger` | +| A paired-walker API is fixed before RF semantics are known | Defer its action algebra until `rf_merger` requirements are defined | | Multi-file hard linking fails partway through | Remove links created by the failed `copy_from()` before returning | | Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | | Output-only codec is selected for required input | Return a clear `UnsupportedOperation` error | -| Hard-link failure appears late | 2D operation preflight and explicit errors | -| Shared code accumulates mesh/raster policy | Dependency tests/review against the source boundary | -| Generic index accidentally dictates both disk formats | Separate 3D and 2D format adapters | - -## Decisions required before Phase 5 - -### Resolved decisions - -1. **Index filename and location:** each snapshot stores - `raster_store.index` directly at its root. -2. **Node and payload paths:** `store_layout` determines the extensionless - node path. The default 2D mapping produces `//` directly below - the snapshot; there is no fixed `chunks/` directory. The AMORT codec - expands it to `//.amort`. -3. **Layout identity and coordinates:** the stable layout ID serialized in the - index is `zoom/x/y_google`. It uses Google/XYZ coordinates with the origin - at the north-west. -4. **Persistent key and zoom range:** use `radix::tile::Id`, whose x and y - coordinates must fit in `uint32_t`. The maximum zoom is therefore - `std::numeric_limits::digits` (32): at that zoom every - `uint32_t` x/y value is representable and valid. A maximum-zoom tile is - terminal because its children would require another coordinate bit. - Validation must special-case this boundary rather than evaluate an - overflowing `uint32_t{1} << 32`. The versioned disk DTO uses fixed-width - `uint32_t` fields for zoom, x, and y rather than serializing the platform - `unsigned` type directly. -5. **Version-1 index contents:** store the layout ID and sparse key/status - entries. `Leaf`, `Inner`, and `Virtual` are serialized; `Missing` is - represented by absence. Do not store derived aggregate metadata until a - concrete query demonstrates that it is required. Serialize entries in - lexicographic `(zoom, x, y)` order and reject duplicate keys while reading. -6. **Serialization envelope:** version 1 uses a fixed, file-type-specific - 64-bit magic value generated once during implementation, a 32-bit version, - zlib CRC-32 stored as `uint32_t` and computed over the compressed payload, - a compression enum, and the compressed payload. Use zstd with its - best-compression setting. The exact magic value is an implementation - constant, not a further design decision. -7. **Publication:** build a snapshot in a sibling directory named - `.part`. Write the index last, validate the completed snapshot, - flush and close every file, and atomically rename the directory to - `` on the same filesystem. The destination must not already - exist. A `.part` directory is never considered published; the rename - removes the suffix without a separate marker or manifest. This guarantees - atomic visibility to concurrent readers during normal operation. -8. **Crash behavior:** the store does not guarantee durability or safe - recovery across a power failure, operating-system crash, or storage - failure. Do not add `fsync()`, `fdatasync()`, `FlushFileBuffers()`, or - equivalent platform-specific synchronization to this refactor. After such - a failure, either a `.part` directory or a final snapshot may be unusable - and must be validated and rebuilt. Flushing and closing files remains - required before the rename for normal-operation correctness, but must not - be documented as a crash-durability guarantee. - -There are no remaining decisions blocking Phase 5. +| Shared code accumulates mesh or provisional RF policy | Dependency tests/review against the source boundary | +| The refactor accidentally fixes the future RF disk format | Do not add a persistent 2D format adapter, layout, or codec | + +## Deferred raster-fundamentalis work + +This refactor is a prerequisite for, not an implementation of, the system +described in [architecture.md](architecture.md). That document and +[storage-format.md](storage-format.md) remain useful design input, but their RF +details are not acceptance criteria for this refactor and are not declared +final here. + +A later RF design phase must resolve and test at least: + +- the persistent index filename, schema, versioning, and serialization + envelope; +- persistent tile keys, coordinate convention, layout IDs, and node paths; +- tile and source-attribution payload formats and codecs; +- snapshot construction, validation, publication, and crash expectations; +- hard-link preflight and unchanged-tile reuse; +- `rf_merger` policy, paired-tree walking, and `Inner` copy behaviour; and +- RF builders, converters, debugging outputs, and other tools. + +None of those decisions blocks completion of this refactor. +The removed ideas are retained, without implementation-plan status, in the +explicitly draft [rf_builder notes](rf_builder.md) and +[rf_merger notes](rf_merger.md). diff --git a/docs/raster-store/rf_builder.md b/docs/raster-store/rf_builder.md new file mode 100644 index 00000000..7006b981 --- /dev/null +++ b/docs/raster-store/rf_builder.md @@ -0,0 +1,202 @@ +# DRAFT — `rf_builder` + +Status: **draft archive of removed ideas**. + +This is not a current implementation plan, accepted format specification, or +statement that the choices below are correct. It is a lightly reformatted +archive of RF-builder and persistent-2D material removed from +[refactor-plan.md](refactor-plan.md). The details are retained so they are not +lost; they require a separate design pass after the shared-store refactor. + +## Proposed names and source boundary + +The removed proposal placed the 2D implementation in +`src/terrainlib/raster_store` and used the `raster_store` namespace: + +```text +src/terrainlib/raster_store/ +├── StoreTraits.h +├── IndexFile.h +├── Storage.h +├── codec/ +│ ├── Amort.h +│ └── Debug.h +└── store_layout/ + └── ZoomXYGoogle.h +``` + +The proposed boundary was: + +- `store` contains dimension- and payload-neutral mechanisms; +- `raster_store` contains the 2D format, key adapters, and raster codecs; and +- subdirectory names match their namespaces where a subnamespace is used. + +The proposed concrete index type was: + +```cpp +store::Index +``` + +## `raster_store::StoreTraits` + +The removed proposal adapted `radix::tile::Id` through +`raster_store::StoreTraits` and specified that it: + +- treats zoom zero as the only root; +- never calls `radix::tile::Id::parent()` at zoom zero, where it underflows; +- rejects coordinates outside `[0, 2^zoom)`; +- accepts zoom levels 0 through + `std::numeric_limits::digits`, inclusive; +- treats that maximum zoom as terminal because a child cannot be represented + by the `uint32_t` x/y coordinates; +- validates the maximum zoom without evaluating an overflowing + `uint32_t{1} << 32`; +- uses `radix::tile::Id::Hasher`; and +- uses the Google/XYZ convention, with the origin at the north-west, at the + persistent boundary. + +The shared code was expected to obtain roots, parents, children, validation, +and hashing through the traits without specializing on the key type. + +## Node paths and layout + +The removed example mapped a raster node to an extensionless `store::NodePath`: + +```text +raster-store ZXY 12/2200/1400 +``` + +The proposed lookup functions were: + +```cpp +raster_store::store_layout::zoom_x_y_google() +raster_store::store_layout::from_id(id) +``` + +The stable layout ID was `zoom/x/y_google`. The default mapping produced +`//` directly below the snapshot root; there was no fixed +`chunks/` directory. A codec, rather than the layout, added `.amort` or debug +file endings. + +## Payload and codec sketches + +The removed proposal used the shared runtime codec interface and placed +raster codecs in `raster_store::codec`: + +```cpp +template +struct raster_store::codec::Amort + : store::Codec> { .. }; + +template +struct raster_store::codec::Debug + : store::Codec> { .. }; +``` + +`Amort` was proposed as readable and writable. `Debug` was proposed as +write-only, with runtime options for data format, attribution format, JPEG +quality, or similar debugging choices. It was not to be template-composed +from separate image codec types. + +The multi-file debug example was: + +```text +Debug raster codec configured for JPEG data and PNG attribution + 12/2200/1400 + -> 12/2200/1400.data.jpg + -> 12/2200/1400.attribution.png +``` + +The proposed final tile path was `//.amort`. The `.amort` payload +and source-attribution-table serialization were explicitly not defined beyond +the separate storage-format notes. + +## Index and format-adapter sketch + +The removed proposal used a separately versioned `raster_store::v1` DTO stored +as `raster_store.index`. + +The proposed version-1 contents were: + +- a layout ID; +- sparse key/status entries; +- serialized `Leaf`, `Inner`, and `Virtual` values; +- `Missing` represented by absence; +- no derived aggregate metadata until a concrete query requires it; +- fixed-width `uint32_t` zoom/x/y fields instead of platform `unsigned`; +- entries ordered lexicographically by `(zoom, x, y)`; and +- duplicate keys rejected while reading. + +The proposed serialization envelope contained: + +- a fixed, file-type-specific 64-bit magic value generated during + implementation; +- a 32-bit version; +- zlib CRC-32 stored as `uint32_t` and computed over the compressed payload; +- a compression enum; and +- a zstd-compressed payload using zstd's best-compression setting. + +The proposal imported zstd through the project's CMake dependency facility and +used the existing `ZLIB::ZLIB` dependency for CRC-32. + +Index serialization was not a responsibility of `store::Index`. The proposed +2D format adapter supplied the index filename, index conversion, mapping +lookup, and default mapping. + +## Snapshot publication sketch + +The removed proposal required explicit finalization/publication for the 2D +snapshot API; a destructor was not to make an incomplete snapshot +authoritative. + +The publication details are now retained in +[architecture.md](architecture.md#publication). The removed proposal used a +sibling `.part` directory, wrote the index last, validated and +closed all files, and atomically renamed it to `` on the same +filesystem. The destination could not already exist. + +The removed text explicitly did not promise durability or safe recovery after +a power failure, operating-system crash, or storage failure, and did not add +`fsync()`, `fdatasync()`, `FlushFileBuffers()`, or equivalent synchronization. + +## Removed implementation and verification ideas + +The removed 2D-adapter phase contained these items: + +- add the checked persistent-key conversion around `radix::tile::Id`; +- add the `//` mapping with stable ID `zoom/x/y_google`; +- define the versioned `raster_store.index` DTO; +- implement the magic/version/checksum/compression envelope; +- add the 2D format adapter and storage aliases under `raster_store`; +- add `raster_store::codec::Amort` when final `.amort` + serialization is available; +- add the output-only `raster_store::codec::Debug`; +- use a test codec instead of making `.amort` claims if final serialization is + unavailable; and +- keep raster-specific processing outside the shared store. + +The removed verification list contained: + +- invalid and boundary tile IDs, including maximum zoom and rejected children; +- index serialization and validation; +- `Leaf`/`Inner` coexistence; +- sparse traversal and ancestor lookup; +- path round trips; +- snapshot hard-link reuse; +- AMORT-to-debug output conversion; +- explicit cross-filesystem/preflight failure; +- publication from `.part` to ``; and +- publication rejection when the destination already exists. + +## Removed risks and controls + +| Removed risk | Removed control | +|---|---| +| `radix::tile::Id` root underflows | Traits intercept root parent lookup | +| Invalid 2D coordinates become persistent | Validate on every disk/API boundary | +| Linked snapshots are modified in place | Immutable snapshot API and overwrite-disabled output | +| Hard-link failure appears late | 2D operation preflight and explicit errors | +| Generic index dictates both disk formats | Separate 3D and 2D format adapters | + +All material in this document remains provisional despite the concrete names +preserved above. diff --git a/docs/raster-store/rf_merger.md b/docs/raster-store/rf_merger.md new file mode 100644 index 00000000..3fc97d0a --- /dev/null +++ b/docs/raster-store/rf_merger.md @@ -0,0 +1,231 @@ +# DRAFT — `rf_merger` + +Status: **draft archive of removed ideas**. + +This is not a current implementation plan or accepted RF merge specification. +It is a lightly reformatted archive of subtree-copy and paired-walker material +removed from [refactor-plan.md](refactor-plan.md). The details are retained so +they are not lost; later discussion already established that RF semantics must +be designed before choosing these abstractions. + +## Proposed names and source boundary + +The removed proposal added these dimension-neutral files: + +```text +src/terrainlib/store/ +├── copy_subtree.h +└── merge/ + ├── Action.h + └── walk.h +``` + +The proposed migration map was: + +| Existing code | Removed target | +|---|---| +| `sf_merger::NodeWriter` subtree loop | `store/copy_subtree.h` | +| `sf_merger::Merger` recursion | `store/merge/walk.h` | + +The proposal intended shared mechanisms to contain no mesh or raster merge +policy. + +## One-node copy proposal + +The proposed one-node API added: + +```cpp +struct CopyOptions { + bool force_reencode = false; +}; +``` + +For one key, the proposed `Storage::copy_from()` behaviour was: + +1. Call input and output `Codec::paths()` with the same fixed dummy + `NodePath`, proposed as `__codec_probe__/node`. +2. Compare path lists exactly, including count, order, and filename endings. +3. When the lists match and `force_reencode` is false, call both codecs with + the actual source and target `NodePath` and hard-link every corresponding + file. +4. When lists differ or re-encoding is forced, read with the input codec and + write with the output codec. +5. Update the target index only after all links or the write complete. + +If a multi-file link failed partway through, the proposal removed target links +created by that call before returning `CopyError`. `CopyError` retained any +underlying `CodecError`. There was no silent file-copy fallback. + +Codec settings that did not change `paths()`, such as compression level or +JPEG quality, did not force re-encoding by default. Callers could pass +`force_reencode = true`. + +The removed hard-link rules were: + +- never modify an existing linked payload in place; +- matching codec path lists hard-link every file; +- different path lists decode with the input codec and encode with the output + codec; +- `force_reencode` selects decode/encode; +- hard-link failure is explicit; +- 2D snapshot tools preflight hard-link support before a long operation; and +- no silent file-copy fallback. + +## Shared subtree-copy proposal + +The removed API sketch was: + +```cpp +std::expected +store::copy_subtree( + const IndexedStorage& source, + Storage& target, + const Key& root, + CopyOptions options = {}); +``` + +The proposed operation: + +1. traversed an indexed source subtree; +2. skipped `Virtual` nodes; +3. called `copy_from()` for physical payloads in `Leaf` and `Inner` states; +4. continued traversal below `Inner`; and +5. returned copy failures rather than asserting or terminating. + +The operation was intended to be payload-neutral and know nothing about +meshes, rasters, masks, attribution, or their encodings. + +The removed target call chain was: + +```text +merge policy decides to keep a source subtree unchanged + -> store::copy_subtree() + -> store::Storage::copy_from() + -> hard-link every codec path, or decode/encode +``` + +## Removed `Inner` copying rationale + +The removed proposal used this topology model: + +| Status | Physical payload | Indexed descendants | +|---|---:|---:| +| `Leaf` | yes | no | +| `Inner` | yes | yes | +| `Virtual` | no | yes | + +It used this RF example: + +```text +zoom 10 physical tile -> Inner +└── zoom 11 physical tile -> Leaf +``` + +The proposal said subtree reuse must preserve both payloads: copy the parent +payload, continue below it, and allow insertion of the descendant to promote +the copied parent from `Leaf` to `Inner` through normal index transitions. + +The proposed status handling was: + +```cpp +switch (status) { +case NodeStatus::Virtual: + break; +case NodeStatus::Leaf: +case NodeStatus::Inner: + target.copy_from(id, source, options); + break; +} +``` + +This described unchanged-subtree copying only. It did not define how two RF +trees should be merged. + +## Paired-tree walker proposal + +The removed proposal extracted dimension-neutral recursion from +`sf_merger::Merger` and introduced these policy results: + +```cpp +store::merge::Recurse +store::merge::Ignore +store::merge::KeepLeft +store::merge::KeepRight +store::merge::Write +``` + +The walker owned recursion and unchanged-subtree reuse. The policy owned +selection and payload combination. + +The removed proposal required all 16 combinations of `Missing`, `Leaf`, +`Inner`, and `Virtual` to be handled or rejected with a typed error rather than +falling into `UNREACHABLE()`. + +It kept these concerns outside the shared walker: + +- `NodeLoader` ancestor mesh reconstruction; +- ECEF node bounds; +- mesh masks and clipping; +- mesh combination and texture atlas generation; and +- mesh validation and auxiliary texture writes. + +The proposal suggested that a future 2D merger could supply a raster policy. +Later discussion identified that the mutually exclusive action list cannot +express both an action for an `Inner` payload and recursion into descendants. +Ideas mentioned after that were a combined `WriteAndRecurse` result or +independent current-node and descendant decisions. None is selected. + +## Removed implementation and verification ideas + +The removed subtree/walker phase contained: + +- compare input and output codec path lists for a fixed dummy `NodePath`; +- hard-link all files when lists match, including partial-failure cleanup; +- decode with the input codec and encode with the output codec when lists + differ; +- add `CopyOptions::force_reencode`; +- add the shared unchanged-subtree copier; +- add the paired hierarchy walker and typed actions; +- cover all 16 status pairs with table-driven tests; +- adapt the 3D merger while keeping mesh policy in `sf_merger`; +- remove generic recursion and copy logic from `sf_merger::Merger` and + `NodeWriter`; and +- add a 3D integration test for an unchanged hard-linked subtree and a newly + written changed boundary node. + +The removed focused tests included: + +- one-file hard linking; +- multi-file hard linking; +- different path counts and endings; +- forced re-encoding with otherwise equal paths; +- conversion between terrain and glTF; +- conversion into a write-only codec; +- runtime failure for an unsupported codec operation; +- copies containing `Leaf`, `Virtual`, and `Inner`; and +- all 16 paired status combinations. + +The proposed generic test file was: + +```text +unittests/terrainlib/store_merge_walk.cpp +``` + +## Removed error and risk notes + +The removed error-propagation proposal passed copy failures through +`copy_subtree()` and the merge call chain to the application boundary with the +affected key and path. Codec, filesystem, unsupported-conversion, +malformed-dataset, and overwrite failures used `std::expected`; operational +failures were not assertions or intentional exceptions. + +| Removed risk | Removed control | +|---|---| +| `Inner` payloads are lost during subtree reuse | Copy every physical status and test mixed-depth fixtures | +| Multi-file hard linking fails partway through | Remove links created by the failed `copy_from()` before returning | +| Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | +| Output-only codec is selected for required input | Return a clear `UnsupportedOperation` error | +| Shared code accumulates mesh/raster policy | Dependency tests/review against the source boundary | + +All material in this document remains provisional despite the concrete names +preserved above. From b4e80a85a9e74ce355215e4c8a824979247668f3 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:21:11 +0200 Subject: [PATCH 28/53] Clarify raster store refactor guarantees --- docs/raster-store/README.md | 8 ++++++-- docs/raster-store/refactor-plan.md | 25 ++++++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 54006330..5ebae8f5 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -11,8 +11,12 @@ baseline, not a finalized binary-format specification. - [Architecture](architecture.md) - [Storage format](storage-format.md) - [Sampling and pyramid generation](sampling-and-generation.md) -- [DRAFT rf_builder idea notes](rf_builder.md) -- [DRAFT rf_merger idea notes](rf_merger.md) + +## Plans + +- [Store refactoring plan](refactor-plan.md) +- [DRAFT RF builder plan archive](rf_builder.md) +- [DRAFT RF merger plan archive](rf_merger.md) ## Scope diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index a6689376..45ae114a 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -49,12 +49,20 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. `std::expected`; unsupported operations and other operational failures are reported as error values. Reading and writing must be reentrant (callable concurrently from different threads). +- This refactor does not add synchronization to storage, indexes, or caches + and does not change their concurrency guarantees. Existing caller-side + synchronization and concurrency behaviour are preserved; any concurrency + bug fix is separate work. - Legacy index metadata selects a codec through an explicit, caller-supplied resolver. The octree format adapter does not contain a global codec registry or depend on mesh or DAG payload types. - `copy_from()` hard-links every file when the input and output codecs return the same path list for a common dummy `NodePath`. Otherwise it decodes with the input codec and encodes with the output codec. +- The `.png` written beside changed meshes by `sf_merger::NodeWriter` is an + unmanaged debug artifact. It is not part of a logical node, is not returned + by `Codec::paths()`, and is not indexed or copied by storage. Its existing + application-local behaviour is preserved. - 3D geometry, ECEF bounds, mesh codecs, mesh reconstruction, mask geometry, and raster-specific processing remain outside the shared store. @@ -521,10 +529,11 @@ The dummy path must be fixed and collision-free, for example `__codec_probe__/node`. Path lists are compared exactly, including count, order, and filename endings. -If linking several files fails partway through, remove the target links -created by that call before returning the error. There is no silent copy -fallback. An unsupported read or write needed for re-encoding is returned -through `CopyError`, retaining the underlying `CodecError`. +If linking several files fails partway through, return the error without a +transactional rollback guarantee; target links already created by the call +may remain. There is no silent copy fallback. An unsupported read or write +needed for re-encoding is returned through `CopyError`, retaining the +underlying `CodecError`. Hard-link rules: @@ -727,15 +736,16 @@ mesh codec or second storage implementation remains under `octree`. 1. Change `Storage::copy_from()` to compare input and output codec path lists for the fixed dummy `NodePath`. -2. Hard-link all actual files when the lists match, with cleanup of links - created by a partially failed call. +2. Hard-link all actual files when the lists match. A partially failed + multi-file operation returns an error without rolling back links already + created. 3. Decode with the input codec and encode with the output codec when lists differ. 4. Test: - one-file hard linking; - multi-file hard linking; - different path counts and endings; - - cleanup after a partially failed multi-file hard link; + - error propagation after a partially failed multi-file hard link; - conversion between terrain and glTF; - overwrite rejection; and - missing-file, hard-link, decode, and encode error propagation. @@ -851,6 +861,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | DAG serializers in `dag_node.h` and `encoded.h` | `dag_builder/serialization.h` | | `dag_builder/storage.h` aliases | DAG storage aliases plus codec resolver convenience functions | | `sf_merger::NodeWriter` subtree loop | remains in `sf_merger`; return copy failures through `std::expected` | +| `sf_merger::NodeWriter` auxiliary `.png` write | remains an unmanaged, application-local debug artifact | | `sf_merger::cut_leaf_node()` copy path | remains in `sf_merger`; return copy failures through `std::expected` | | SF merger `Inner` `UNREACHABLE()` path | `sf::validate_index()` returning `sf::InvalidTopology` | From f463e159b237a0cd0c61a156c4c91ca27fea5df6 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:46:11 +0200 Subject: [PATCH 29/53] Finalize raster store refactor plan --- docs/raster-store/refactor-plan.md | 179 +++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 32 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 45ae114a..66574ef4 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -49,16 +49,33 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. `std::expected`; unsupported operations and other operational failures are reported as error values. Reading and writing must be reentrant (callable concurrently from different threads). +- `store::RawStorage` exclusively owns the configured + `std::unique_ptr>`. `store::Storage` owns the raw + storage and therefore owns the codec transitively. Storage consumers do not + receive a codec template parameter and application call sites do not manage + codec objects. - This refactor does not add synchronization to storage, indexes, or caches and does not change their concurrency guarantees. Existing caller-side synchronization and concurrency behaviour are preserved; any concurrency bug fix is separate work. - Legacy index metadata selects a codec through an explicit, caller-supplied - resolver. The octree format adapter does not contain a global codec registry - or depend on mesh or DAG payload types. + resolver supplied by the payload-domain opening function. The octree format + adapter does not contain a global codec registry or depend on mesh or DAG + payload types. Application-level storage consumers do not call the resolver + or handle the resulting codec object. +- Dimension-specific index persistence uses a small runtime + `store::IndexFormat` value containing ordinary function pointers. It + is not an inheritance hierarchy and does not use global registration. - `copy_from()` hard-links every file when the input and output codecs return the same path list for a common dummy `NodePath`. Otherwise it decodes with the input codec and encodes with the output codec. +- Public store operations reject invalid hierarchy keys through + `std::expected`; invalid keys are not represented by assertions or generic + booleans. +- Preserve `StorageSettings::allow_overwrite`. It defaults to `false`; + rejected overwrites return `AlreadyExists` through `std::expected`, and + enabling it preserves the existing DAG-builder overwrite and debug-export + behaviour. - The `.png` written beside changed meshes by `sf_merger::NodeWriter` is an unmanaged debug artifact. It is not part of a logical node, is not returned by `Codec::paths()`, and is not indexed or copied by storage. Its existing @@ -129,6 +146,7 @@ Before moving code, tests must lock down the following 3D behaviour: | DAG payload encoding | existing `dag::ClusterBatch` ZPP Bits serialization | | Equal codec path lists | hard-link every file, or report an explicit error | | Different codec path lists | decode with input codec and encode with output codec | +| Overwrite setting | `StorageSettings::allow_overwrite`, default `false`; enabled writes replace existing payloads | Compatibility means that the refactored code can open datasets written before the refactor and produces datasets that the pre-refactor code can open. Exact @@ -147,13 +165,18 @@ src/terrainlib/ │ ├── NodeStatus.h │ ├── NodeStatusOrMissing.h │ ├── Traits.h +│ ├── InvalidKey.h │ ├── Index.h │ ├── traverse.h │ ├── NodePath.h │ ├── PathMapping.h │ ├── Layout.h +│ ├── IndexFormat.h │ ├── Codec.h +│ ├── CodecError.h +│ ├── OpenError.h │ ├── CopyError.h +│ ├── StorageSettings.h │ ├── RawStorage.h │ ├── Storage.h │ ├── IndexedStorage.h @@ -262,7 +285,9 @@ changing `octree::Id`. by the `uint32_t` x/y coordinates; - validate the maximum zoom without evaluating an overflowing `uint32_t{1} << 32`; -- use `radix::tile::Id::Hasher`. +- use `radix::tile::Id::Hasher`; and +- return children in the deterministic order produced by + `radix::tile::Id::children()`. This traits adapter defines only hierarchy operations. It does not define persistent coordinates, a path layout, or an RF disk format. @@ -271,6 +296,14 @@ The shared code must obtain roots, parents, children, validation, and hashing through the traits. It must not use dimension checks or specialize behaviour on key types internally. +Operations accepting a key validate it through `Traits::is_valid()`. Index +lookup and mutation, traversal with an explicit root, and storage operations +return an `std::expected` retaining an `InvalidKey` when validation fails. +Keys produced internally by `Traits::root()`, `Traits::parent()`, and +`Traits::children()` are trusted only after trait-specific tests establish that +they preserve validity. The child order affects traversal order and is locked +down by the 2D and 3D trait tests; it is not serialized as separate metadata. + ### Sparse index and traversal Move the existing four-state model to: @@ -374,6 +407,10 @@ storage use. `CodecError` is a payload-neutral operational error that records the failed operation, an error category, and a diagnostic message. Concrete codecs convert their domain errors to it. Unsupported read or write operations may use the base implementation and return its `UnsupportedOperation` error. +Codec writes preserve the current directory-creation behaviour: they create +the parent directories required by their output paths before writing. The +storage hard-link path continues to create its target parent directories before +linking. `Codec::paths()` has the following contract: @@ -435,23 +472,65 @@ store::IndexedStorage store::cache::Interface ``` +`RawStorage` owns the +`std::unique_ptr>`. `Storage` owns `RawStorage`, and +`IndexedStorage` owns or derives from `Storage`; no other layer shares codec +ownership. Moving storage transfers ownership. Caches, layouts, index formats, +resolvers, and application consumers never own the codec. + Domain-specific mesh codecs remain under `mesh::codec`. The reusable ZPP Bits codec remains under `store::codec`. RF codecs are deferred. Index serialization is not a responsibility of `store::Index`. Opening and -saving a dataset receives a dimension-specific format adapter which provides: +saving a dataset receives the following small runtime values: + +```cpp +template +struct store::IndexMetadata { + store::Index index; + std::string layout_id; + std::string codec_selector; +}; + +template +struct store::IndexFormat { + std::string_view index_filename; + + std::expected, IndexFormatError> + (*read)(const std::filesystem::path& index_path); + + std::expected + (*write)( + const std::filesystem::path& index_path, + const IndexMetadata& metadata); + + std::optional> + (*mapping_from_id)(std::string_view id); + + PathMapping + (*default_mapping)(); +}; +``` + +The value provides: - the index filename; - index read/write conversion; - mapping lookup by stable ID; -- the default mapping; and -- legacy directory discovery where it is required. +- the default mapping. -This adapter may be a compile-time policy or a small value of function -pointers. Choose the smaller implementation after the Phase 0 tests exist. -It must not reintroduce a layout class hierarchy or global registration. +Legacy directory discovery is an octree opening helper which composes the +octree index format, the supplied payload-domain codec resolver, and the known +octree mappings. It is not a generic `IndexFormat` operation. Neither the +format value nor discovery may reintroduce a layout class hierarchy or global +registration. For 3D, the adapter reads and writes the current `octree` index DTO unchanged. +Its `codec_selector` is exactly the legacy `preferred_extension`, including +the leading dot. Storage retains the selected `IndexFormat`, index path, +layout ID, and codec selector as its index-persistence state, so explicit and +destructor-triggered index saves can reproduce the same metadata. + When opening indexed storage or discovering a legacy unindexed directory, it passes the legacy `preferred_extension` to a caller-supplied codec resolver. The resolver is an ordinary callable and returns @@ -471,9 +550,28 @@ dag::codec::from_extension ``` An unknown extension returns an explicit `UnsupportedCodec` error. Opening a -new empty store may receive an already constructed codec instead of a resolver. -Convenience functions in `src/dag_builder/storage.h` supply the DAG resolver so -application call sites do not handle it directly. +new empty store receives an explicit legacy codec selector plus an already +constructed codec at the payload-domain opening boundary; the generic storage +does not infer persistent metadata from `Codec::paths()`. Mesh and DAG +convenience functions select or resolve the codec and pass ownership into raw +storage, so application storage consumers do not handle codec objects. +Convenience functions in `src/dag_builder/storage.h` supply the DAG resolver. + +Opening functions return their requested storage type through +`std::expected<..., OpenError>`. `OpenError` is a typed sum which retains the +failing path and the underlying error where applicable: + +- index I/O or malformed index metadata (`IndexFormatError`); +- filesystem failure; +- unknown layout ID; +- unsupported codec selector or codec construction failure (`CodecError`); and +- invalid hierarchy key (`InvalidKey`). + +Loading and saving likewise return storage-level expected errors which retain +an invalid key, an underlying `CodecError`, and `AlreadyExists` for a rejected +save. `CopyError` retains invalid-key, missing-source, overwrite, filesystem, +and codec failures. Domain/application boundaries may add context, but must not +discard these errors. Legacy unindexed-directory discovery remains in the 3D adapter: it recognizes candidate endings by asking the supplied resolver, removes an accepted ending @@ -531,9 +629,12 @@ order, and filename endings. If linking several files fails partway through, return the error without a transactional rollback guarantee; target links already created by the call -may remain. There is no silent copy fallback. An unsupported read or write -needed for re-encoding is returned through `CopyError`, retaining the -underlying `CodecError`. +may remain. The copy operation stops immediately, leaves the target index +unchanged for that logical node, and propagates the failure until the +application aborts the overall operation. There is no journal, rollback, +cleanup guarantee, or silent copy fallback. An unsupported read or write needed +for re-encoding is returned through `CopyError`, retaining the underlying +`CodecError`. Hard-link rules: @@ -629,11 +730,8 @@ No production behaviour changes. 5. Add storage tests for: - matching-extension hard links; - different-extension decode/re-encode; - - `.terrain`, `.glb`, `.gltf`, and `.bin` open/read dispatch through the - appropriate domain resolver; the `.bin` case does not exercise - `copy_from()`; - - explicit failure for an unknown preferred extension; - overwrite rejection; + - overwrite-enabled replacement; - indexed and unindexed opens; and - final index creation by directory scan. 6. Record the pre-refactor public aliases used by `sf_builder`, `sf_merger`, @@ -652,7 +750,9 @@ changed. 3. Convert `IndexMap` into `store::Index`. 4. Convert traversal into `store::traverse`. 5. Add `raster_store::StoreTraits` for `radix::tile::Id`. -6. Run the same index-transition and DFS/BFS tests with both trait types. +6. Run the same index-transition and DFS/BFS tests with both trait types, + including deterministic child order, invalid-key errors through + `std::expected`, maximum-depth children, and explicit traversal roots. 7. Provide temporary `octree` aliases so downstream migration is separate from the algorithm extraction. @@ -696,27 +796,38 @@ strategy pointer. - one glTF codec configured for binary `.glb` or JSON `.gltf`. 5. Move copy error, raw storage, caches, logical storage, and indexed storage into `store`. -6. Make storage own a configured `std::unique_ptr>`; remove the - codec template parameter from storage. +6. Make `RawStorage` exclusively own a configured + `std::unique_ptr>`. `Storage` owns it transitively through + raw storage; remove the codec template parameter from every storage type. 7. Replace every embedded `octree::Id` with `Traits::Key`. 8. Make every raw file operation obtain its complete file list through `Codec::paths()`. `has()` requires every listed file, and `remove()` removes every listed file. 9. Keep domain-specific mesh codecs outside the shared module under `mesh::codec`. -10. Split generic index maintenance from 3D index serialization and legacy - folder discovery. +10. Add the function-pointer-based `IndexFormat`, `IndexMetadata`, and + typed format/open errors. Split generic index maintenance from 3D index + serialization and legacy folder discovery. 11. Keep the current 3D `terrain.index` DTO and open functions as compatibility - adapters over the shared storage. Resolve its preferred extension through - the caller-supplied mesh or DAG resolver. + adapters over the shared storage. Retain its exact preferred extension as + `codec_selector`, resolve it through the payload-domain mesh or DAG + resolver, and retain the format metadata required by automatic saving. 12. Add DAG storage convenience functions that supply - `dag::codec::from_extension`, and migrate `dag_builder` and - `dag_convert_debug` to them. + `dag::codec::from_extension`, and mesh storage convenience functions that + select the configured terrain or glTF codec. Migrate `dag_builder`, + `dag_convert_debug`, and mesh storage consumers without exposing codec + objects at application call sites. 13. Migrate the existing octree storage aliases and all other application callers. 14. Preserve the current 3D destructor-save behaviour until all callers have explicit index finalization. -15. Instantiate the shared storage tests with `raster_store::StoreTraits` +15. Preserve `StorageSettings::allow_overwrite`, including the DAG builder's + overwrite mode and repeat debug export. Replace process termination on a + rejected overwrite with `AlreadyExists` in the storage-level expected + error. +16. Add resolver tests for `.terrain`, `.glb`, `.gltf`, and `.bin` open/read + dispatch, plus explicit failure for an unknown preferred extension. +17. Instantiate the shared storage tests with `raster_store::StoreTraits` using a test-only path mapping and codec. This proves the storage templates contain no hidden `octree::Id` dependency without defining a stable RF layout, codec, or disk format. @@ -725,7 +836,9 @@ Add focused codec tests using single-file, multi-file, read/write, and write-only test codecs before depending on the raster payload implementation. Test that a pre-refactor DAG fixture opens through the new resolver and that a new deterministic `.bin` payload matches the Phase 0 golden bytes and remains -readable through the unchanged ZPP serialization functions. +readable through the unchanged ZPP serialization functions. Test unknown +layout IDs, malformed index metadata, invalid hierarchy keys, and retained +underlying open/codec errors through `std::expected`. Exit criterion: all existing applications build and all Phase 0 fixtures pass through the shared runtime codec and storage implementation. Existing DAG @@ -747,7 +860,8 @@ mesh codec or second storage implementation remains under `octree`. - different path counts and endings; - error propagation after a partially failed multi-file hard link; - conversion between terrain and glTF; - - overwrite rejection; and + - overwrite rejection; + - overwrite-enabled replacement; and - missing-file, hard-link, decode, and encode error propagation. 5. Add `sf::validate_index()`, returning `sf::InvalidTopology` with the offending key when it encounters `Inner`. @@ -856,6 +970,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `octree/storage/RawStorage.h` | `store/RawStorage.h` | | `octree/storage/Storage.h` | `store/Storage.h` | | `octree/storage/IndexedStorage.h` | `store/IndexedStorage.h` | +| `octree::StorageSettings::allow_overwrite` | `store::StorageSettings::allow_overwrite`, preserving default and enabled behaviour | | `octree/storage/helpers.*` | generic scan helpers plus 3D format adapter | | `octree/disk/IndexFile.h` | versioned 3D format adapter under `octree` | | DAG serializers in `dag_node.h` and `encoded.h` | `dag_builder/serialization.h` | @@ -880,7 +995,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Invalid `Inner` nodes reach SF merge dispatch | Validate every SF input first and return the offending key in a typed error | | SF subtree copying is generalized before RF requirements exist | Keep it in `sf_merger`; reconsider extraction with `rf_merger` | | A paired-walker API is fixed before RF semantics are known | Defer its action algebra until `rf_merger` requirements are defined | -| Multi-file hard linking fails partway through | Remove links created by the failed `copy_from()` before returning | +| Multi-file hard linking fails partway through | Stop immediately, leave the node unindexed, propagate the error, and abort the overall operation; partial files may remain | | Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | | Output-only codec is selected for required input | Return a clear `UnsupportedOperation` error | | Shared code accumulates mesh or provisional RF policy | Dependency tests/review against the source boundary | From c6ef9e5a1f75b13a0678d86ecfad9ee1d3aae51a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:00:37 +0200 Subject: [PATCH 30/53] plan++ --- docs/raster-store/README.md | 1 + docs/raster-store/refactor-plan.md | 45 +++++++++++++++++++++--------- docs/raster-store/todo.md | 5 ++++ 3 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 docs/raster-store/todo.md diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 5ebae8f5..04429875 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -15,6 +15,7 @@ baseline, not a finalized binary-format specification. ## Plans - [Store refactoring plan](refactor-plan.md) +- [Raster store TODO](todo.md) - [DRAFT RF builder plan archive](rf_builder.md) - [DRAFT RF merger plan archive](rf_merger.md) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 66574ef4..f881a114 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -76,6 +76,10 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. rejected overwrites return `AlreadyExists` through `std::expected`, and enabling it preserves the existing DAG-builder overwrite and debug-export behaviour. +- The existing cache implementations are currently non-functional. Port their + public API where it remains useful, keep application call sites building, and + provide compile coverage only. Cache behaviour is not a compatibility + requirement of this refactor. - The `.png` written beside changed meshes by `sf_merger::NodeWriter` is an unmanaged debug artifact. It is not part of a logical node, is not returned by `Codec::paths()`, and is not indexed or copied by storage. Its existing @@ -149,7 +153,7 @@ Before moving code, tests must lock down the following 3D behaviour: | Overwrite setting | `StorageSettings::allow_overwrite`, default `false`; enabled writes replace existing payloads | Compatibility means that the refactored code can open datasets written before -the refactor and produces datasets that the pre-refactor code can open. Exact +the refactor. Pre-refactor readers are not tested against post-refactor outpus. Exact byte-for-byte rewriting of an unordered index map is not required, but the serialized schema and values must remain compatible. @@ -233,6 +237,10 @@ trees including `Inner`. must retain this error and `CopyError` when propagating failures; neither is reduced to a log message, assertion, or generic boolean. +For this refactor, `sf::validate_index()` checks only for `Inner`. It does not +validate other structural or disk/filesystem invariants. Possible future +extensions are recorded in [todo.md](todo.md). + Temporary forwarding headers and aliases under `octree` are allowed during migration. They must not contain a second implementation. @@ -753,8 +761,7 @@ changed. 6. Run the same index-transition and DFS/BFS tests with both trait types, including deterministic child order, invalid-key errors through `std::expected`, maximum-depth children, and explicit traversal roots. -7. Provide temporary `octree` aliases so downstream migration is separate - from the algorithm extraction. +7. Provide temporary `octree` aliases so downstream migration is separate where this makes sense, otherwise migrate downstream immediately. Exit criterion: 2D and 3D keys pass the same topology suite; existing 3D callers still build through aliases; no filesystem code has changed. @@ -794,8 +801,9 @@ strategy pointer. 4. Split the current extension-dispatching `octree::MeshCodec` into: - a terrain codec; and - one glTF codec configured for binary `.glb` or JSON `.gltf`. -5. Move copy error, raw storage, caches, logical storage, and indexed storage - into `store`. +5. Move copy error, raw storage, logical storage, and indexed storage into + `store`. Port the existing cache API where useful for source compatibility, + but do not require cache behaviour tests. 6. Make `RawStorage` exclusively own a configured `std::unique_ptr>`. `Storage` owns it transitively through raw storage; remove the codec template parameter from every storage type. @@ -864,17 +872,23 @@ mesh codec or second storage implementation remains under `octree`. - overwrite-enabled replacement; and - missing-file, hard-link, decode, and encode error propagation. 5. Add `sf::validate_index()`, returning `sf::InvalidTopology` with the - offending key when it encounters `Inner`. -6. Apply the validator to SF-builder output finalization, SF-merger merge and - cut inputs, SF-merger output, and the DAG builder's SF input. Do not apply - it when opening DAG datasets, through generic octree/store adapters, or in - the diagnostic `sf_index_browser`. + offending key when it encounters `Inner`. Do not add other validation rules + in this refactor. +6. Apply the validator to SF-merger merge and cut inputs and the DAG builder's + SF input before processing. Apply it to SF-builder and SF-merger output + after the completed `terrain.index` has been written. If output validation + fails, retain the written index and payloads for diagnosis, propagate + `sf::InvalidTopology` to the command line, and report that the output is + invalid. Do not apply the validator when opening DAG datasets, through + generic octree/store adapters, or in the diagnostic `sf_index_browser`. 7. Keep SF recursion, subtree traversal, and mesh policy in `sf_merger`. Change its subtree and cut call chains to propagate validation and `copy_from()` failures through `std::expected` to the application boundary. 8. Add integration tests proving: - valid `Leaf`/`Virtual` SF merge behaviour is unchanged; - an SF input containing `Inner` fails validation before merge dispatch; + - invalid SF output writes `terrain.index` for diagnosis and returns + `sf::InvalidTopology` to the application boundary; - an unchanged SF subtree is hard-linked and a changed boundary node is newly written; and - an unchanged leaf in the SF cut path is hard-linked while a clipped leaf @@ -882,8 +896,9 @@ mesh codec or second storage implementation remains under `octree`. Exit criterion: one-node copying works through `Codec::paths()`, SF consumers reject `Inner` with a typed error before processing, existing valid SF merge -and cut behaviour is preserved, and neither a shared subtree copier nor a -paired-tree walker has been introduced. +and cut behaviour is preserved, invalid SF output remains inspectable with a +written `terrain.index`, and neither a shared subtree copier nor a paired-tree +walker has been introduced. ### Phase 5 — Cleanup and documentation @@ -932,6 +947,10 @@ belong with their consumers: SF-builder output validation in `unittests_sfmerger`, and DAG-builder SF-input validation in `unittests_dagbuilder`. `sf_index_browser` remains unvalidated by design. +Cache migration has compile coverage only. Existing cache application call +sites must continue to build where the API remains meaningful, but no cache +behaviour or compatibility test is required. + During implementation: 1. Build in `$source_dir/build/$config_name`. @@ -963,7 +982,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `StrategyRegister.h` | explicit dimension-adapter lookup functions | | `strategy/Flat.h` | `octree/store_layout/Flat.h` | | `strategy/LevelAndCoordinateDirectories.h` | `octree/store_layout/LevelAndCoordinateDirectories.h` | -| `octree/storage/cache/*` | `store/cache/*` | +| `octree/storage/cache/*` | `store/cache/*` where useful; compile compatibility only | | `octree/storage/codec/Codec.h` | runtime `store/Codec.h` | | `octree/storage/codec/DefaultCodec.h` | runtime `store/codec/ZppBits.h` | | `octree/storage/codec/MeshCodec.h` | `mesh/codec/Terrain.h` and configured `mesh/codec/Gltf.h` | diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md new file mode 100644 index 00000000..fe9bf14a --- /dev/null +++ b/docs/raster-store/todo.md @@ -0,0 +1,5 @@ +# Raster store TODO + +- Consider extending `sf::validate_index()` beyond rejecting `Inner` after + additional SF invariants and their required error reporting are defined. + This is not part of the shared-store refactor. From 7bc6a3503afaf93c466f3b9c2f2acfe6057b5df4 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:12:20 +0200 Subject: [PATCH 31/53] Combine path mapping and codec phases --- docs/raster-store/refactor-plan.md | 110 ++++++++++++++++------------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index f881a114..a0a9d995 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -766,82 +766,95 @@ changed. Exit criterion: 2D and 3D keys pass the same topology suite; existing 3D callers still build through aliases; no filesystem code has changed. -### Phase 2 — Replace disk layout strategies +### Phase 2 — Introduce path mappings and runtime codecs + +This phase introduces the extensionless layout and runtime codec pieces +together. It does not cut production storage over to them yet. The existing +storage, layout strategies, and static codecs remain temporarily as the +working compatibility path until Phase 3 can replace the complete +layout-plus-codec path construction in one step. 1. Add `store::NodePath`, `store::PathMapping`, and `store::Layout`. -2. Port the two existing 3D layouts to ordinary function pairs without +2. Add the stateful `store::Codec` interface with `paths()`, `read()`, + and `write()`, plus `CodecError`. +3. Add the runtime `store::codec::ZppBits`, preserving the existing `.bin` + path and serialized payload bytes. +4. Consolidate the DAG serialization functions in + `src/dag_builder/serialization.h` without changing their serialized field + order, meshoptimizer encoding, or JPEG texture encoding. +5. Add the terrain codec and one glTF codec configured for binary `.glb` or + JSON `.gltf`. Keep the current extension-dispatching `octree::MeshCodec` + only as temporary production compatibility glue until the Phase 3 cutover. +6. Port the two existing 3D layouts to ordinary function pairs without changing stable IDs. The mappings return `level-index` and `level/x/y/z` without file endings. -3. Replace the singleton strategy registry with explicit `from_id()` and - `all()` functions in the 3D adapter. -4. Move the legacy preferred extension out of generic `Layout` and retain it - in the 3D format adapter. -5. Port legacy layout discovery so it strips recognized 3D file endings before - calling `node_path_to_key()`. -6. Switch node-path and layout-detection tests to the new implementation. -7. Delete the old strategy base class, registration machinery, and concrete - strategy classes once no call site uses them. - -Exit criterion: the legacy 3D adapter plus codecs resolve all Phase 0 fixtures -to identical physical payload paths; generic `Layout` contains no extension; -there is no layout inheritance, RTTI lookup, static registrar, or owning -strategy pointer. +7. Add explicit `from_id()` and `all()` lookup functions in the 3D adapter. + Keep the singleton strategy registry only for the old production storage + path until Phase 3. +8. Compose each new 3D mapping with each applicable runtime codec in tests and + prove that they resolve all Phase 0 fixtures to identical physical payload + paths. +9. Add focused codec tests using single-file, multi-file, read/write, and + write-only test codecs. Test stable path ordering, unsupported operations, + directory creation, and conversion of domain errors to `CodecError`. + +Exit criterion: the extensionless mappings and runtime codecs together resolve +all Phase 0 fixtures to their existing physical payload paths; generic +`Layout` contains no extension; the new codec tests pass; existing production +storage and all callers still build unchanged through the temporary legacy +path. ### Phase 3 — Generalize storage and index lifecycle -1. Add the stateful `store::Codec` interface with `paths()`, `read()`, - and `write()`. -2. Move the current generic `octree::ZppBitsCodec` to the runtime - `store::codec::ZppBits`, preserving its `.bin` paths and serialized - payload bytes. -3. Consolidate the DAG serialization functions in - `src/dag_builder/serialization.h` without changing their serialized field - order, meshoptimizer encoding, or JPEG texture encoding. -4. Split the current extension-dispatching `octree::MeshCodec` into: - - a terrain codec; and - - one glTF codec configured for binary `.glb` or JSON `.gltf`. -5. Move copy error, raw storage, logical storage, and indexed storage into +1. Cut the production path construction over to the Phase 2 + `store::Layout` and runtime codecs. Move the legacy preferred extension + out of layout state and retain it as the 3D format adapter's codec selector. +2. Port legacy layout discovery so it recognizes codec endings through the + supplied resolver, strips the accepted ending, and then calls + `node_path_to_key()`. +3. Move copy error, raw storage, logical storage, and indexed storage into `store`. Port the existing cache API where useful for source compatibility, but do not require cache behaviour tests. -6. Make `RawStorage` exclusively own a configured +4. Make `RawStorage` exclusively own a configured `std::unique_ptr>`. `Storage` owns it transitively through raw storage; remove the codec template parameter from every storage type. -7. Replace every embedded `octree::Id` with `Traits::Key`. -8. Make every raw file operation obtain its complete file list through +5. Replace every embedded `octree::Id` with `Traits::Key`. +6. Make every raw file operation obtain its complete file list through `Codec::paths()`. `has()` requires every listed file, and `remove()` removes every listed file. -9. Keep domain-specific mesh codecs outside the shared module under +7. Keep domain-specific mesh codecs outside the shared module under `mesh::codec`. -10. Add the function-pointer-based `IndexFormat`, `IndexMetadata`, and - typed format/open errors. Split generic index maintenance from 3D index - serialization and legacy folder discovery. -11. Keep the current 3D `terrain.index` DTO and open functions as compatibility - adapters over the shared storage. Retain its exact preferred extension as - `codec_selector`, resolve it through the payload-domain mesh or DAG - resolver, and retain the format metadata required by automatic saving. -12. Add DAG storage convenience functions that supply +8. Add the function-pointer-based `IndexFormat`, `IndexMetadata`, and + typed format/open errors. Split generic index maintenance from 3D index + serialization and legacy folder discovery. +9. Keep the current 3D `terrain.index` DTO and open functions as compatibility + adapters over the shared storage. Retain its exact preferred extension as + `codec_selector`, resolve it through the payload-domain mesh or DAG + resolver, and retain the format metadata required by automatic saving. +10. Add DAG storage convenience functions that supply `dag::codec::from_extension`, and mesh storage convenience functions that select the configured terrain or glTF codec. Migrate `dag_builder`, `dag_convert_debug`, and mesh storage consumers without exposing codec objects at application call sites. -13. Migrate the existing octree storage aliases and all other application +11. Migrate the existing octree storage aliases and all other application callers. -14. Preserve the current 3D destructor-save behaviour until all callers have +12. Preserve the current 3D destructor-save behaviour until all callers have explicit index finalization. -15. Preserve `StorageSettings::allow_overwrite`, including the DAG builder's +13. Preserve `StorageSettings::allow_overwrite`, including the DAG builder's overwrite mode and repeat debug export. Replace process termination on a rejected overwrite with `AlreadyExists` in the storage-level expected error. -16. Add resolver tests for `.terrain`, `.glb`, `.gltf`, and `.bin` open/read +14. Add resolver tests for `.terrain`, `.glb`, `.gltf`, and `.bin` open/read dispatch, plus explicit failure for an unknown preferred extension. -17. Instantiate the shared storage tests with `raster_store::StoreTraits` +15. Instantiate the shared storage tests with `raster_store::StoreTraits` using a test-only path mapping and codec. This proves the storage templates contain no hidden `octree::Id` dependency without defining a stable RF layout, codec, or disk format. +16. Delete the old strategy base class, strategy registry, concrete strategy + classes, static codec concept and codecs, and their temporary compatibility + glue once no call site uses them. -Add focused codec tests using single-file, multi-file, read/write, and -write-only test codecs before depending on the raster payload implementation. Test that a pre-refactor DAG fixture opens through the new resolver and that a new deterministic `.bin` payload matches the Phase 0 golden bytes and remains readable through the unchanged ZPP serialization functions. Test unknown @@ -851,7 +864,8 @@ underlying open/codec errors through `std::expected`. Exit criterion: all existing applications build and all Phase 0 fixtures pass through the shared runtime codec and storage implementation. Existing DAG payload bytes and `.bin` paths remain compatible. No extension-dispatching -mesh codec or second storage implementation remains under `octree`. +mesh codec, layout inheritance, RTTI lookup, static registrar, owning strategy +pointer, or second storage implementation remains under `octree`. ### Phase 4 — Harden node reuse and enforce SF topology From 60de87a6d133f9403cc5c905b5654ea4956933cc Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:25:44 +0200 Subject: [PATCH 32/53] Clarify codec and overwrite verification --- docs/raster-store/refactor-plan.md | 8 ++++---- docs/raster-store/todo.md | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index a0a9d995..379fb7a6 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -738,7 +738,6 @@ No production behaviour changes. 5. Add storage tests for: - matching-extension hard links; - different-extension decode/re-encode; - - overwrite rejection; - overwrite-enabled replacement; - indexed and unindexed opens; and - final index creation by directory scan. @@ -844,7 +843,8 @@ path. 13. Preserve `StorageSettings::allow_overwrite`, including the DAG builder's overwrite mode and repeat debug export. Replace process termination on a rejected overwrite with `AlreadyExists` in the storage-level expected - error. + error. Test that a rejected save returns `AlreadyExists` and that enabling + overwrite replaces the existing payload. 14. Add resolver tests for `.terrain`, `.glb`, `.gltf`, and `.bin` open/read dispatch, plus explicit failure for an unknown preferred extension. 15. Instantiate the shared storage tests with `raster_store::StoreTraits` @@ -882,8 +882,8 @@ pointer, or second storage implementation remains under `octree`. - different path counts and endings; - error propagation after a partially failed multi-file hard link; - conversion between terrain and glTF; - - overwrite rejection; - - overwrite-enabled replacement; and + - `copy_from()` overwrite rejection; + - `copy_from()` overwrite-enabled replacement; and - missing-file, hard-link, decode, and encode error propagation. 5. Add `sf::validate_index()`, returning `sf::InvalidTopology` with the offending key when it encounters `Inner`. Do not add other validation rules diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index fe9bf14a..fecdfba6 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -3,3 +3,9 @@ - Consider extending `sf::validate_index()` beyond rejecting `Inner` after additional SF invariants and their required error reporting are defined. This is not part of the shared-store refactor. +- Resolve the existing glTF write exception path before implementing the + runtime codec error contract. `mesh::io::gltf::save_to_path()` currently + throws when `cgltf_write_file()` fails, while codec operational failures are + intended to be returned through `std::expected`. Decide whether mesh I/O + should return a typed error or the glTF codec should catch and translate the + exception, and explicitly confirm whether changing mesh I/O is in scope. From 68983a9849435c2ac83e9e27d1213012be790a2f Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:43:05 +0200 Subject: [PATCH 33/53] Enable HFA and read vector layer CRS --- cmake/SetupGDAL.cmake | 1 + src/terrainlib/Dataset.cpp | 12 +++++++++--- unittests/terrainlib/dataset.cpp | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index c1e2185c..a5c51da6 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -38,6 +38,7 @@ function(alp_setup_gdal) COMMITISH ${ARG_GDAL_VERSION} CMAKE_ARGUMENTS -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF + -DGDAL_ENABLE_DRIVER_HFA=ON -DOGR_BUILD_OPTIONAL_DRIVERS=OFF -DBUILD_APPS=OFF -DBUILD_TESTING=OFF diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 43098966..38cca735 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -27,6 +27,7 @@ #include #include +#include #include "ctb/Grid.hpp" #include "init.h" @@ -213,11 +214,16 @@ radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) con } OGRSpatialReference Dataset::srs() const { - const char *srcWKT = m_gdal_dataset->GetProjectionRef(); - if (!strlen(srcWKT)) { + const OGRSpatialReference *source_srs = m_gdal_dataset->GetSpatialRef(); + for (int layer_index = 0; source_srs == nullptr && layer_index < m_gdal_dataset->GetLayerCount(); ++layer_index) { + if (OGRLayer *layer = m_gdal_dataset->GetLayer(layer_index)) { + source_srs = layer->GetSpatialRef(); + } + } + if (source_srs == nullptr) { throw std::runtime_error("The source dataset does not have a spatial reference system assigned"); } - auto srs = OGRSpatialReference(srcWKT); + auto srs = *source_srs; srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); return srs; } diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index 0e67eb54..c7f359d6 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -20,11 +20,13 @@ #include "../catch2_helpers.h" #include +#include #include "Dataset.h" #include "ctb/GlobalGeodetic.hpp" #include "ctb/GlobalMercator.hpp" #include "ctb/types.hpp" +#include "init.h" #include "srs.h" using namespace radix; @@ -106,6 +108,24 @@ TEST_CASE("datasets are as expected") { } } +TEST_CASE("vector datasets use the layer spatial reference") { + initialize_gdal_once(); + GDALDriver *driver = GetGDALDriverManager()->GetDriverByName("Memory"); + REQUIRE(driver != nullptr); + + GDALDataset *raw_dataset = driver->Create("", 0, 0, 0, GDT_Unknown, nullptr); + REQUIRE(raw_dataset != nullptr); + + OGRSpatialReference expected_srs; + REQUIRE(expected_srs.importFromEPSG(31287) == OGRERR_NONE); + expected_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + REQUIRE(raw_dataset->CreateLayer("mask", &expected_srs, wkbPolygon, nullptr) != nullptr); + + Dataset dataset(raw_dataset); + const OGRSpatialReference actual_srs = dataset.srs(); + CHECK(actual_srs.IsSame(&expected_srs)); +} + TEST_CASE("bbox width pixels") { auto d_mgi = Dataset(ALP_TEST_DATA_DIR "/austria/at_mgi.tif"); auto d_wgs84 = Dataset(ALP_TEST_DATA_DIR "/austria/at_wgs84.tif"); From 3edc572941650813634e7fba20f59e3535ae2c12 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:08:28 +0200 Subject: [PATCH 34/53] Enable GeoPackage vector support --- cmake/SetupGDAL.cmake | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index a5c51da6..d3d111ab 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -40,6 +40,8 @@ function(alp_setup_gdal) -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF -DGDAL_ENABLE_DRIVER_HFA=ON -DOGR_BUILD_OPTIONAL_DRIVERS=OFF + -DOGR_ENABLE_DRIVER_GPKG=ON + -DOGR_ENABLE_DRIVER_SQLITE=ON -DBUILD_APPS=OFF -DBUILD_TESTING=OFF -DBUILD_PYTHON_BINDINGS=OFF @@ -47,6 +49,7 @@ function(alp_setup_gdal) -DBUILD_CSHARP_BINDINGS=OFF -DGDAL_USE_ICONV=OFF -DGDAL_USE_EXTERNAL_LIBS=OFF + -DGDAL_USE_SQLITE3=ON "-DCMAKE_INSTALL_RPATH=\$ORIGIN/../../proj/lib" ) From ddde549cb99b373f51a214f6466e15b51f2b5d2c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:46:31 +0200 Subject: [PATCH 35/53] Simplify vector masks before triangulation --- cmake/SetupGDAL.cmake | 16 +- docs/raster-store/golden-e2e.sh | 288 ++++++++++++++++++++++++++++++++ src/CMakeLists.txt | 2 +- src/sf_merger/mask.h | 100 +++++++++-- unittests/CMakeLists.txt | 1 + unittests/sf_merger/mask.cpp | 57 +++++++ 6 files changed, 447 insertions(+), 17 deletions(-) create mode 100755 docs/raster-store/golden-e2e.sh create mode 100644 unittests/sf_merger/mask.cpp diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index d3d111ab..6be25b74 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -21,17 +21,24 @@ if(NOT COMMAND alp_setup_cmake_project) endif() function(alp_setup_gdal) - set(oneValueArgs GDAL_VERSION PROJ_VERSION) + set(oneValueArgs GDAL_VERSION GEOS_VERSION PROJ_VERSION) cmake_parse_arguments(ARG "" "${oneValueArgs}" "" ${ARGN}) - if(NOT ARG_GDAL_VERSION OR NOT ARG_PROJ_VERSION) - message(FATAL_ERROR "alp_setup_gdal() needs: GDAL_VERSION PROJ_VERSION ") + if(NOT ARG_GDAL_VERSION OR NOT ARG_GEOS_VERSION OR NOT ARG_PROJ_VERSION) + message(FATAL_ERROR "alp_setup_gdal() needs: GDAL_VERSION GEOS_VERSION PROJ_VERSION ") endif() alp_setup_cmake_project(proj URL https://github.com/OSGeo/PROJ.git COMMITISH ${ARG_PROJ_VERSION} CMAKE_ARGUMENTS -DBUILD_TESTING=OFF -DBUILD_APPS=OFF) find_package(PROJ CONFIG REQUIRED) - set(_proj_install "${ALP_PROJ_INSTALL_DIR}") + alp_setup_cmake_project(geos + URL https://github.com/libgeos/geos.git + COMMITISH ${ARG_GEOS_VERSION} + CMAKE_ARGUMENTS + -DBUILD_SHARED_LIBS=OFF + -DBUILD_TESTING=OFF + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + ) alp_setup_cmake_project(gdal URL https://github.com/OSGeo/gdal.git @@ -49,6 +56,7 @@ function(alp_setup_gdal) -DBUILD_CSHARP_BINDINGS=OFF -DGDAL_USE_ICONV=OFF -DGDAL_USE_EXTERNAL_LIBS=OFF + -DGDAL_USE_GEOS=ON -DGDAL_USE_SQLITE3=ON "-DCMAKE_INSTALL_RPATH=\$ORIGIN/../../proj/lib" ) diff --git a/docs/raster-store/golden-e2e.sh b/docs/raster-store/golden-e2e.sh new file mode 100755 index 00000000..208ecb92 --- /dev/null +++ b/docs/raster-store/golden-e2e.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash + +# Repeatable, non-unit end-to-end verification for the current 3D SF/DAG path. +# Inputs are prepared once under INPUT_ROOT. Completed steps are skipped through +# explicit state markers, so an interrupted SF or DAG run can be resumed. + +set -Eeuo pipefail + +readonly SOURCE_DIR="/home/codex/Documents/alpine-terrain-builder/terrain-builder-raster-store" +readonly BUILD_DIR="${SOURCE_DIR}/build/hfa-vector-srs" +readonly RUN_ROOT="/data/scratch/codex/alpine-terrain-builder-golden-e2e" +readonly INPUT_ROOT="${RUN_ROOT}/inputs" +readonly REFERENCE_ROOT="${RUN_ROOT}/reference" +readonly LOG_ROOT="${RUN_ROOT}/logs" +readonly STATE_ROOT="${RUN_ROOT}/state" + +readonly SF_BUILDER="${BUILD_DIR}/src/sf_builder/sf-builder" +readonly SF_MERGER="${BUILD_DIR}/src/sf_merger/sf-merger" +readonly DAG_BUILDER="${BUILD_DIR}/src/dag_builder/dag-builder" + +# This VRT is a 4x4 km, native-resolution window into the original HFA dataset. +readonly GS_VRT="${INPUT_ROOT}/grossglockner-gs-4km/grossglockner-gs-4km.vrt" +readonly BASEMAP_TILES="${INPUT_ROOT}/basemap" +readonly GATAKI_TILES="${INPUT_ROOT}/gataki" +# The exact Tirol border exercises the sf-merger's built-in 0.1 m +# topology-preserving mask simplification. +readonly TIROL_MASK="${INPUT_ROOT}/tirol-boundary/benchmark-shape/exact/tirol.shp" + +readonly SF_ROOT="${REFERENCE_ROOT}/sf" +readonly DAG_ROOT="${REFERENCE_ROOT}/dag" +readonly SF_BASEMAP="${SF_ROOT}/grossglockner-gs-basemap-terrain" +readonly SF_GATAKI="${SF_ROOT}/grossglockner-gs-gataki-terrain" +readonly SF_MERGED="${SF_ROOT}/grossglockner-gs-merged-terrain" +readonly DAG_MERGED="${DAG_ROOT}/grossglockner-gs-merged-terrain" + +readonly SF_TARGET_LEVEL="${SF_TARGET_LEVEL:-15}" +readonly SF_THREADS="${SF_THREADS:-12}" +readonly MIN_TEXTURE_LEVEL=12 +readonly MAX_TEXTURE_LEVEL=19 + +mkdir -p "${SF_ROOT}" "${DAG_ROOT}" "${LOG_ROOT}" "${STATE_ROOT}" +exec > >(tee -a "${LOG_ROOT}/golden-e2e.log") 2>&1 + +timestamp() +{ + date --iso-8601=seconds +} + +run_timed() +{ + local name="$1" + shift + + local start_epoch end_epoch elapsed status + start_epoch="$(date +%s)" + printf '[%s] START %s\n' "$(timestamp)" "${name}" + + set +e + "$@" + status=$? + set -e + + end_epoch="$(date +%s)" + elapsed=$((end_epoch - start_epoch)) + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${name}" "${start_epoch}" "${end_epoch}" "${elapsed}" "${status}" \ + >> "${LOG_ROOT}/timings.tsv" + printf '[%s] END %s status=%s elapsed_seconds=%s\n' \ + "$(timestamp)" "${name}" "${status}" "${elapsed}" + + if ((status != 0)); then + return "${status}" + fi +} + +require_file() +{ + if [[ ! -f "$1" ]]; then + printf 'Required file is missing: %s\n' "$1" >&2 + exit 2 + fi +} + +require_directory() +{ + if [[ ! -d "$1" ]]; then + printf 'Required directory is missing: %s\n' "$1" >&2 + exit 2 + fi +} + +require_executable() +{ + if [[ ! -x "$1" ]]; then + printf 'Required executable is missing: %s\n' "$1" >&2 + exit 2 + fi +} + +verify_snapshot() +{ + local snapshot="$1" + local extension="$2" + local payload_count + + require_file "${snapshot}/terrain.index" + if [[ ! -s "${snapshot}/terrain.index" ]]; then + printf 'Index is empty: %s\n' "${snapshot}/terrain.index" >&2 + return 1 + fi + + payload_count="$(find "${snapshot}" -type f -name "*${extension}" | wc -l)" + if ((payload_count == 0)); then + printf 'No %s payloads found in %s\n' "${extension}" "${snapshot}" >&2 + return 1 + fi + printf 'Verified %s: %s payloads\n' "${snapshot}" "${payload_count}" +} + +build_sf() +{ + local name="$1" + local textures="$2" + local output="$3" + local marker="${STATE_ROOT}/${name}.complete" + + if [[ -f "${marker}" ]]; then + printf '[%s] SKIP %s: completion marker exists\n' "$(timestamp)" "${name}" + verify_snapshot "${output}" ".terrain" + return + fi + + mkdir -p "${output}" + run_timed "${name}" \ + "${SF_BUILDER}" \ + --dataset "${GS_VRT}" \ + --textures "${textures}" \ + --min-texture-level "${MIN_TEXTURE_LEVEL}" \ + --max-texture-level "${MAX_TEXTURE_LEVEL}" \ + --mesh-srs EPSG:4978 \ + --verbosity info \ + batch \ + --target-level "${SF_TARGET_LEVEL}" \ + --output "${output}" \ + --format .terrain \ + --threads "${SF_THREADS}" + + verify_snapshot "${output}" ".terrain" + touch "${marker}" +} + +merge_sf() +{ + local marker="${STATE_ROOT}/merge_sf.complete" + + if [[ -f "${marker}" ]]; then + printf '[%s] SKIP merge_sf: completion marker exists\n' "$(timestamp)" + verify_snapshot "${SF_MERGED}" ".terrain" + return + fi + + if [[ -d "${SF_MERGED}" ]] && [[ -n "$(find "${SF_MERGED}" -mindepth 1 -print -quit)" ]]; then + printf 'Partial merge output exists at %s.\n' "${SF_MERGED}" >&2 + printf 'The current sf-merger cannot safely resume this output; refusing to overwrite it.\n' >&2 + return 1 + fi + + mkdir -p "${SF_MERGED}" + run_timed merge_sf \ + "${SF_MERGER}" merge \ + --base "${SF_GATAKI}" \ + --new "${SF_BASEMAP}" \ + --mask "${TIROL_MASK}" \ + --output "${SF_MERGED}" \ + --verbosity info + + verify_snapshot "${SF_MERGED}" ".terrain" + touch "${marker}" +} + +build_dag() +{ + local marker="${STATE_ROOT}/build_dag.complete" + local continuation=(--overwrite) + + if [[ -f "${marker}" ]]; then + printf '[%s] SKIP build_dag: completion marker exists\n' "$(timestamp)" + verify_snapshot "${DAG_MERGED}" ".bin" + return + fi + + mkdir -p "${DAG_MERGED}" + if [[ -s "${DAG_MERGED}/terrain.index" ]]; then + continuation=(--resume) + fi + + run_timed build_dag \ + "${DAG_BUILDER}" \ + --input "${SF_MERGED}" \ + --output "${DAG_MERGED}" \ + "${continuation[@]}" \ + --verbosity info + + verify_snapshot "${DAG_MERGED}" ".bin" + touch "${marker}" +} + +write_manifest() +{ + local name="$1" + local snapshot="$2" + local output="${LOG_ROOT}/${name}.sha256" + + ( + cd "${snapshot}" + find . -type f ! -name terrain.index -print0 \ + | sort -z \ + | xargs -0 -r sha256sum + ) > "${output}" +} + +record_hard_links() +{ + local merged_count=0 + local linked_to_gataki=0 + local linked_to_basemap=0 + local newly_written=0 + local merged_file relative merged_inode candidate_inode + + while IFS= read -r -d '' merged_file; do + relative="${merged_file#"${SF_MERGED}/"}" + merged_inode="$(stat -c '%d:%i' "${merged_file}")" + ((merged_count += 1)) + + if [[ -f "${SF_GATAKI}/${relative}" ]]; then + candidate_inode="$(stat -c '%d:%i' "${SF_GATAKI}/${relative}")" + if [[ "${merged_inode}" == "${candidate_inode}" ]]; then + ((linked_to_gataki += 1)) + continue + fi + fi + + if [[ -f "${SF_BASEMAP}/${relative}" ]]; then + candidate_inode="$(stat -c '%d:%i' "${SF_BASEMAP}/${relative}")" + if [[ "${merged_inode}" == "${candidate_inode}" ]]; then + ((linked_to_basemap += 1)) + continue + fi + fi + + ((newly_written += 1)) + done < <(find "${SF_MERGED}" -type f -name '*.terrain' -print0) + + printf 'merged_payloads=%s\nlinked_to_gataki=%s\nlinked_to_basemap=%s\nnewly_written=%s\n' \ + "${merged_count}" "${linked_to_gataki}" "${linked_to_basemap}" "${newly_written}" \ + | tee "${LOG_ROOT}/merge-hard-links.txt" +} + +require_file "${GS_VRT}" +require_file "${TIROL_MASK}" +require_directory "${BASEMAP_TILES}" +require_directory "${GATAKI_TILES}" +require_executable "${SF_BUILDER}" +require_executable "${SF_MERGER}" +require_executable "${DAG_BUILDER}" + +{ + printf 'run_started=%s\n' "$(timestamp)" + printf 'git_commit=%s\n' "$(git -C "${SOURCE_DIR}" rev-parse HEAD)" + printf 'git_status=%q\n' "$(git -C "${SOURCE_DIR}" status --porcelain=v1 --branch)" + printf 'sf_target_level=%s\n' "${SF_TARGET_LEVEL}" + printf 'sf_threads=%s\n' "${SF_THREADS}" + printf 'min_texture_level=%s\n' "${MIN_TEXTURE_LEVEL}" + printf 'max_texture_level=%s\n' "${MAX_TEXTURE_LEVEL}" + printf 'cpu_count=%s\n' "$(nproc)" +} >> "${LOG_ROOT}/run-metadata.txt" + +build_sf build_sf_basemap_terrain "${BASEMAP_TILES}" "${SF_BASEMAP}" +build_sf build_sf_gataki_terrain "${GATAKI_TILES}" "${SF_GATAKI}" +merge_sf +record_hard_links +build_dag +run_timed manifest_sf_basemap write_manifest sf-basemap "${SF_BASEMAP}" +run_timed manifest_sf_gataki write_manifest sf-gataki "${SF_GATAKI}" +run_timed manifest_sf_merged write_manifest sf-merged "${SF_MERGED}" +run_timed manifest_dag_merged write_manifest dag-merged "${DAG_MERGED}" + +printf '[%s] Golden end-to-end run complete\n' "$(timestamp)" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a33beb5c..b419708d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -14,7 +14,7 @@ alp_setup_cmake_project(tbb URL https://github.com/uxlfoundation/oneTBB.git COMM find_package(TBB REQUIRED) include(../cmake/SetupGDAL.cmake) -alp_setup_gdal(GDAL_VERSION v3.10.3 PROJ_VERSION 9.6.0) +alp_setup_gdal(GDAL_VERSION v3.10.3 GEOS_VERSION 3.13.1 PROJ_VERSION 9.6.0) alp_setup_cmake_project(cgal URL https://github.com/CGAL/cgal.git COMMITISH "v6.0.1" CMAKE_ARGUMENTS -DBUILD_TESTING=OFF -DWITH_examples=OFF) find_package(CGAL REQUIRED) diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 47f2b875..aac4a7ba 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -68,7 +69,9 @@ namespace { enum class LoadErrorKind { UnsupportedFormat, FileNotFound, - EmptySource + EmptySource, + InvalidGeometry, + UnsupportedSpatialReference }; class LoadError { @@ -95,6 +98,10 @@ class LoadError { return "file not found"; case LoadErrorKind::EmptySource: return "empty input source"; + case LoadErrorKind::InvalidGeometry: + return "invalid geometry"; + case LoadErrorKind::UnsupportedSpatialReference: + return "unsupported spatial reference"; default: return "unknown error"; } @@ -109,6 +116,58 @@ class LoadError { }; namespace { +constexpr double simplification_tolerance_metres = 0.1; + +std::optional simplification_tolerance(const OGRSpatialReference &srs) { + if (srs.IsProjected()) { + const double metres_per_unit = srs.GetLinearUnits(); + if (metres_per_unit > 0) { + return simplification_tolerance_metres / metres_per_unit; + } + } + + if (srs.IsGeographic()) { + OGRErr error = OGRERR_NONE; + const double semi_major_axis_metres = srs.GetSemiMajor(&error); + const double radians_per_unit = srs.GetAngularUnits(); + if (error == OGRERR_NONE && semi_major_axis_metres > 0 && radians_per_unit > 0) { + return simplification_tolerance_metres / semi_major_axis_metres / radians_per_unit; + } + } + + return std::nullopt; +} + +uint64_t point_count(const OGRGeometry &geometry) { + const OGRwkbGeometryType geometry_type = wkbFlatten(geometry.getGeometryType()); + if (geometry_type == wkbPolygon) { + const OGRPolygon *polygon = geometry.toPolygon(); + uint64_t count = polygon->getExteriorRing()->getNumPoints(); + for (int i = 0; i < polygon->getNumInteriorRings(); ++i) { + count += polygon->getInteriorRing(i)->getNumPoints(); + } + return count; + } + + if (geometry_type == wkbMultiPolygon || geometry_type == wkbGeometryCollection) { + const OGRGeometryCollection *collection = geometry.toGeometryCollection(); + uint64_t count = 0; + for (int i = 0; i < collection->getNumGeometries(); ++i) { + count += point_count(*collection->getGeometryRef(i)); + } + return count; + } + + return 0; +} + +std::unique_ptr simplify_geometry( + const OGRGeometry &geometry, + const double tolerance +) { + return std::unique_ptr(geometry.SimplifyPreserveTopology(tolerance)); +} + std::optional convert_ring(const OGRLinearRing &ring, bool is_outer) { uint32_t num_points = ring.getNumPoints(); if (ring.get_IsClosed()) { @@ -256,11 +315,38 @@ auto length2(const Vec &v) -> decltype(glm::dot(v, v)) { inline std::expected load_referenced_from_dataset(Dataset& mask_dataset) { GDALDataset *dataset = mask_dataset.gdalDataset(); + OGRSpatialReference srs; + // TODO: remove this try catch + try { + srs = mask_dataset.srs(); + } catch (std::runtime_error &e) { + LOG_WARN("Mask does not reference an srs, assuming WGS84"); + srs = srs::wgs84(); + // srs.SetAxisMappingStrategy(OAMS_AUTHORITY_COMPLIANT); + } + + const std::optional tolerance = simplification_tolerance(srs); + if (!tolerance) { + LOG_ERROR("Cannot express the {} m mask simplification tolerance in the source SRS", + simplification_tolerance_metres); + return std::unexpected(LoadErrorKind::UnsupportedSpatialReference); + } + MultipolygonWithHoles2 polygons; for (auto &&feature_layer_pair : dataset->GetFeatures()) { OGRGeometry *geometry = feature_layer_pair.feature->GetGeometryRef(); DEBUG_ASSERT(geometry); - process_geometry(*geometry, polygons); + + const uint64_t original_point_count = point_count(*geometry); + std::unique_ptr simplified = simplify_geometry(*geometry, *tolerance); + if (!simplified || simplified->IsEmpty() || !simplified->IsValid()) { + LOG_ERROR("Failed to simplify mask geometry while preserving its topology"); + return std::unexpected(LoadErrorKind::InvalidGeometry); + } + + LOG_DEBUG("Simplified mask with {} m tolerance from {} to {} points", + simplification_tolerance_metres, original_point_count, point_count(*simplified)); + process_geometry(*simplified, polygons); } if (polygons.is_empty()) { @@ -268,16 +354,6 @@ inline std::expected load_referenced_from_data return std::unexpected(LoadErrorKind::EmptySource); } - OGRSpatialReference srs; - // TODO: remove this try catch - try { - srs = mask_dataset.srs(); - } catch (std::runtime_error &e) { - LOG_WARN("Mask does not reference an srs, assuming WGS84"); - srs = srs::wgs84(); - // srs.SetAxisMappingStrategy(OAMS_AUTHORITY_COMPLIANT); - } - return ReferencedPolygonMask{.polygons = std::move(polygons), .srs = std::move(srs)}; } diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 27d24f95..0ea40bcc 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -116,6 +116,7 @@ endif() if(TARGET sfmergerlib) add_executable(unittests_sfmerger catch2_helpers.h + sf_merger/mask.cpp sf_merger/sphere_projector.cpp ) target_link_libraries(unittests_sfmerger PRIVATE sfmergerlib Catch2::Catch2WithMain) diff --git a/unittests/sf_merger/mask.cpp b/unittests/sf_merger/mask.cpp new file mode 100644 index 00000000..48f0ed63 --- /dev/null +++ b/unittests/sf_merger/mask.cpp @@ -0,0 +1,57 @@ +#include "../catch2_helpers.h" + +#include + +#include "mask.h" + +TEST_CASE("Mask simplification uses a fixed tenth of a metre") { + OGRSpatialReference projected; + REQUIRE(projected.importFromEPSG(31287) == OGRERR_NONE); + + const std::optional projected_tolerance = mask::simplification_tolerance(projected); + REQUIRE(projected_tolerance); + CHECK(*projected_tolerance == Catch::Approx(0.1)); + + OGRSpatialReference geographic; + REQUIRE(geographic.SetWellKnownGeogCS("WGS84") == OGRERR_NONE); + + const std::optional geographic_tolerance = mask::simplification_tolerance(geographic); + REQUIRE(geographic_tolerance); + CHECK(*geographic_tolerance == Catch::Approx( + 0.1 / geographic.GetSemiMajor() / geographic.GetAngularUnits())); +} + +TEST_CASE("Mask simplification preserves polygon topology") { + OGRLinearRing outer; + outer.addPoint(0.0, 0.0); + outer.addPoint(1.0, 0.01); + outer.addPoint(2.0, 0.0); + outer.addPoint(2.0, 2.0); + outer.addPoint(0.0, 2.0); + outer.addPoint(0.0, 0.0); + + OGRLinearRing hole; + hole.addPoint(0.5, 0.5); + hole.addPoint(1.0, 0.51); + hole.addPoint(1.5, 0.5); + hole.addPoint(1.5, 1.5); + hole.addPoint(0.5, 1.5); + hole.addPoint(0.5, 0.5); + + OGRPolygon polygon; + REQUIRE(polygon.addRing(&outer) == OGRERR_NONE); + REQUIRE(polygon.addRing(&hole) == OGRERR_NONE); + REQUIRE(polygon.IsValid()); + + const uint64_t original_point_count = mask::point_count(polygon); + std::unique_ptr simplified = mask::simplify_geometry(polygon, 0.1); + + REQUIRE(simplified); + REQUIRE_FALSE(simplified->IsEmpty()); + REQUIRE(simplified->IsValid()); + CHECK(mask::point_count(*simplified) < original_point_count); + + const OGRPolygon *simplified_polygon = simplified->toPolygon(); + REQUIRE(simplified_polygon); + CHECK(simplified_polygon->getNumInteriorRings() == 1); +} From ea6cb53d55cc18ae96c18272708b51b5d619f2ac Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:48:33 +0200 Subject: [PATCH 36/53] Preserve SF format when merging --- src/sf_merger/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sf_merger/main.cpp b/src/sf_merger/main.cpp index bd20a16b..6ff4896a 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.cpp @@ -41,7 +41,7 @@ void run(const cli::MergeArgs& args) { LOG_TRACE("Creating output dataset at {}", args.output_path); std::filesystem::create_directories(args.output_path); - octree::Storage output_dataset = octree::open_folder(args.output_path, false, octree::OpenOptions{.preferred_extension_with_dot = ".glb"}); + octree::Storage output_dataset = octree::open_folder(args.output_path, false, octree::OpenOptions{.preferred_extension_with_dot = std::string(base_dataset.layout().extension_with_dot())}); std::optional mask = flatten(map(args.mask_path, load_mask_from_path)); From 19589e3b9528717196dae2804c4637d7fa19985f Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:48:55 +0200 Subject: [PATCH 37/53] Update raster storage format design --- docs/raster-store/storage-format.md | 31 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 54832f91..c70d706b 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -50,35 +50,36 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - should generate tiles of requested resolution and pixel type (vertex|area) on the fly - requests by url, e.g.: layer/vertex|area/resolution/z/x/y.ending - live in src/tile-server/* +- the delivery tile format is not yet defined -### unclear -- how to build tile-base from raster-fundamentalis -- details of the tile server - -## serialization / deserialization and versioning -- all files are serialised with zpp::bits in two levels - - first level contains: - - an uint64 long file type specific random magic number, generated once at coding time, as an definitive file type identifier +## serialization / deserialization envelope and versioning +-zpp::bits is a library that takes a cpp object and (de)serialises it (from)into a byte stream, +- we serialise objects with zpp::bits in two levels + - first level (the envelope) contains: + - a c++ class identifier (string like type) + - an uint64 long file type specific magic number, F5FBD3EF919428CA, as an definitive file format identifier - a version number (uint32) - an enum for the checksum, default to HandledByCompressionLib, other option None - - a string checksum for the payload, computed from the uncompressed data (default empty) + - a string checksum for the payload, computed from the uncompressed data (default empty, this is unused for now) - an enum for the compression algorithm, default to to ZStd_BestCompression, other option None. - a payload (byte vector), the second level - - the second level is a compressed byte array. the compressed payload is deserialised directly into the respective versioned data classes (tile or source attribution table) + - the second level is a compressed byte vector. the compressed payload is deserialised directly into the respective versioned data classes - data structs are stored in versioned namespaces, e.g.: `raster_store::v1::Tile` - outside the versioned namespace, there is a using declaration for the newest version -- outside the versioned namespace, there is serialization function, taking only the newest version +- outside the versioned namespace, there is a using declaration for the serialization function, taking only the newest version - outside the versioned namespace, there is a deserialization function, taking a byte stream, and returning the newest version (convert to the newest version, if the payload encodes an older version) - conversion to newer versions is done by the constructor, e.g. the v2::Tile constructor shall take a v1::Tile, and convert it to v2. once we have a v3, it would take a v2. this way we would have a conversion trail from v1 to v3. +- serialization/deserialization should be done in a templated function, receiving the class type, version, and name (string like type) as template parameters, and the object as function parameter. the functions above should be implemented in terms of the templated function, e.g. by try reading with V3, then V2, then v1. a more elegant design with a few more functions may be proposed, if it generalises nicely. I can imagine, that something with a variadic template (versioned classes) could work. - we have clear fails if - - the magic or version is unsupported + - the classname or magic is wrong, or the version is unsupported - if the checksum check fails - if the compression algorithm is missing or unsupported. - deserialization fails -- we fail by throwing in these cases -- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. the context must be configured to compute and check a checksum. do compression and decompression in functions with an std::byte interface (and error handling). dispatch is done via a simple switch case. -- checksum: assert the enum is either None or HandledByCompressionLib. +- we fail by returning an unexpected in these cases +- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. the context must be configured to compute and check a checksum. +- an API for compression and decompression should be created. data should be passed as std:: vector, error handling via std expected, dispatch between compression algorithms is done via a simple switch case. +- checksum: verify the enum is either None or HandledByCompressionLib. ## to be defined From 304333a7f1bc041780dd1a0f2879965de218795a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:26 +0200 Subject: [PATCH 38/53] Add versioned serialization envelope --- docs/raster-store/storage-format.md | 40 ++-- src/CMakeLists.txt | 12 ++ src/terrainlib/CMakeLists.txt | 2 + src/terrainlib/io/compression.cpp | 156 ++++++++++++++ src/terrainlib/io/compression.h | 67 ++++++ src/terrainlib/io/envelope.h | 140 +++++++++++++ src/terrainlib/io/envelope.inl | 132 ++++++++++++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/envelope.cpp | 313 ++++++++++++++++++++++++++++ 9 files changed, 845 insertions(+), 18 deletions(-) create mode 100644 src/terrainlib/io/compression.cpp create mode 100644 src/terrainlib/io/compression.h create mode 100644 src/terrainlib/io/envelope.h create mode 100644 src/terrainlib/io/envelope.inl create mode 100644 unittests/terrainlib/envelope.cpp diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index c70d706b..df26fe7e 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -52,34 +52,38 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - live in src/tile-server/* - the delivery tile format is not yet defined -## serialization / deserialization envelope and versioning --zpp::bits is a library that takes a cpp object and (de)serialises it (from)into a byte stream, -- we serialise objects with zpp::bits in two levels - - first level (the envelope) contains: - - a c++ class identifier (string like type) - - an uint64 long file type specific magic number, F5FBD3EF919428CA, as an definitive file format identifier - - a version number (uint32) - - an enum for the checksum, default to HandledByCompressionLib, other option None - - a string checksum for the payload, computed from the uncompressed data (default empty, this is unused for now) - - an enum for the compression algorithm, default to to ZStd_BestCompression, other option None. - - a payload (byte vector), the second level - - the second level is a compressed byte vector. the compressed payload is deserialised directly into the respective versioned data classes +## serialization / deserialization envelope and versioning +- `zpp::bits` serialises C++ objects to byte streams and deserialises them again. +- The envelope is generic and is not specific to the raster store. +- We serialise objects with `zpp::bits` in two levels. + - The first level is an aggregate with the following fields, in this order: + - `uint64 magic`, always `F5FBD3EF919428CA`, identifying this envelope format; + - `string class_name`, identifying the payload type; + - `uint32 class_version`, identifying the versioned payload type; + - `ChecksumAlgorithm checksum_algorithm`, default `HandledByCompressionLib`, alternatively `None`; + - `string checksum`, empty when the checksum is handled by the compression library; + - `CompressionAlgorithm compression_algorithm`, default `ZstdBestCompressionWithChecksum`, alternatively `None`; + - `Bytes compressed_data`, containing the second level. + - The second level is a compressed byte vector. It is deserialised directly into the selected versioned payload class. +- The magic is shared by all payload types. `class_name` distinguishes payload types. An incompatible future envelope layout requires a new magic. - data structs are stored in versioned namespaces, e.g.: `raster_store::v1::Tile` - outside the versioned namespace, there is a using declaration for the newest version -- outside the versioned namespace, there is a using declaration for the serialization function, taking only the newest version +- outside the versioned namespace, there is a serialization wrapper function taking only the newest version - outside the versioned namespace, there is a deserialization function, taking a byte stream, and returning the newest version (convert to the newest version, if the payload encodes an older version) -- conversion to newer versions is done by the constructor, e.g. the v2::Tile constructor shall take a v1::Tile, and convert it to v2. once we have a v3, it would take a v2. this way we would have a conversion trail from v1 to v3. -- serialization/deserialization should be done in a templated function, receiving the class type, version, and name (string like type) as template parameters, and the object as function parameter. the functions above should be implemented in terms of the templated function, e.g. by try reading with V3, then V2, then v1. a more elegant design with a few more functions may be proposed, if it generalises nicely. I can imagine, that something with a variadic template (versioned classes) could work. +- Newer versions provide a static `from_previous` function, e.g. `v2::Tile::from_previous(v1::Tile)`. Static conversion functions keep the payload types aggregates, allowing `zpp::bits` to serialise them without per-type serialisation declarations. A conversion trail upgrades v1 to v2 and then v3. +- `Version` pairs version numbers with payload types. `PayloadSchema` defines the class name, supported versions, latest type, and conversion trail. +- The generic serialisation function receives the schema and version as template parameters. Deserialisation reads `class_version`, deserialises exactly that payload type, and follows the conversion trail to the latest type. It does not speculatively try other payload versions. - we have clear fails if - the classname or magic is wrong, or the version is unsupported - if the checksum check fails - if the compression algorithm is missing or unsupported. - deserialization fails - we fail by returning an unexpected in these cases -- compression: use libzstd with best compression. libzstd must be imported via the projects cmake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. the context must be configured to compute and check a checksum. -- an API for compression and decompression should be created. data should be passed as std:: vector, error handling via std expected, dispatch between compression algorithms is done via a simple switch case. -- checksum: verify the enum is either None or HandledByCompressionLib. +- Compression uses libzstd at its best compression level. Libzstd is imported through the project's CMake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. `ZstdBestCompressionWithChecksum` writes an embedded zstd frame checksum, which libzstd verifies while decompressing. +- `compress_with_checksum` accepts a `vector` and returns the compressed bytes plus the external checksum string. `checked_decompress` accepts both and returns the decompressed bytes. Both use `std::expected` and dispatch with a switch. +- `None` compression must be paired with `None` checksum. `ZstdBestCompressionWithChecksum` must be paired with `HandledByCompressionLib`; its external checksum string must be empty because the checksum is embedded in the zstd frame. +- Decompression rejects output larger than 1 GiB. ## to be defined diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b419708d..13dd4474 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -42,6 +42,18 @@ alp_add_git_repository(zpp_bits URL https://github.com/eyalz800/zpp_bits.git COM add_library(zpp_bits INTERFACE) target_include_directories(zpp_bits SYSTEM INTERFACE ${zpp_bits_SOURCE_DIR}) +alp_setup_cmake_project(zstd + URL https://github.com/AlpineMapsOrgDependencies/zstd.git + COMMITISH 5c7b7bad26808e6b40ac3b3d0075466e27738a9d + CMAKE_ARGUMENTS + -DZSTD_BUILD_PROGRAMS=OFF + -DZSTD_BUILD_TESTS=OFF + -DZSTD_BUILD_CONTRIB=OFF + -DZSTD_BUILD_SHARED=OFF + -DZSTD_BUILD_STATIC=ON +) +find_package(zstd CONFIG REQUIRED) + alp_add_git_repository(libassert URL https://github.com/jeremy-rifkin/libassert.git COMMITISH v2.1.0) alp_add_git_repository(magic_enum URL https://github.com/Neargye/magic_enum.git COMMITISH v0.9.7) alp_add_git_repository(meshoptimizer URL https://github.com/zeux/meshoptimizer.git COMMITISH v1.0.1) diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index c799f067..a258d045 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(terrainlib ctb/types.hpp io/bytes.cpp + io/compression.cpp io/utils.cpp mesh/igl/igl.cpp @@ -70,6 +71,7 @@ target_link_libraries(terrainlib PUBLIC spdlog fmt zpp_bits + zstd::libzstd_static cgltf TBB::tbb opencv_core diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp new file mode 100644 index 00000000..9a2b3bad --- /dev/null +++ b/src/terrainlib/io/compression.cpp @@ -0,0 +1,156 @@ +#define ZSTD_STATIC_LINKING_ONLY +#include +#include + +#include "io/compression.h" + +#include +#include +#include +#include + +namespace io::envelope { +namespace { + +using CompressionContext = std::unique_ptr; + +std::expected validate_algorithms( + const CompressionAlgorithm compression_algorithm, + const ChecksumAlgorithm checksum_algorithm) +{ + switch (checksum_algorithm) { + case ChecksumAlgorithm::None: + case ChecksumAlgorithm::HandledByCompressionLib: + break; + default: + return std::unexpected(Error{ErrorCode::UnsupportedChecksumAlgorithm}); + } + + switch (compression_algorithm) { + case CompressionAlgorithm::None: + case CompressionAlgorithm::ZstdBestCompressionWithChecksum: + break; + default: + return std::unexpected(Error{ErrorCode::UnsupportedCompressionAlgorithm}); + } + + const bool no_compression = compression_algorithm == CompressionAlgorithm::None + && checksum_algorithm == ChecksumAlgorithm::None; + const bool zstd_with_checksum = + compression_algorithm == CompressionAlgorithm::ZstdBestCompressionWithChecksum + && checksum_algorithm == ChecksumAlgorithm::HandledByCompressionLib; + if (!no_compression && !zstd_with_checksum) { + return std::unexpected(Error{ErrorCode::InvalidAlgorithmCombination}); + } + + return {}; +} + +} // namespace + +std::expected compress_with_checksum( + const Bytes &uncompressed_data, + const CompressionAlgorithm compression_algorithm, + const ChecksumAlgorithm checksum_algorithm) +{ + if (const auto validation = validate_algorithms(compression_algorithm, checksum_algorithm); !validation) { + return std::unexpected(validation.error()); + } + if (uncompressed_data.size() > default_max_decompressed_size) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } + + if (compression_algorithm == CompressionAlgorithm::None) { + return CompressedData{uncompressed_data, {}}; + } + + CompressionContext context{ZSTD_createCCtx(), &ZSTD_freeCCtx}; + if (!context) { + return std::unexpected(Error{ErrorCode::CompressionFailed}); + } + + if (ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_compressionLevel, ZSTD_maxCLevel())) + || ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_checksumFlag, 1))) { + return std::unexpected(Error{ErrorCode::CompressionFailed}); + } + + const std::size_t capacity = ZSTD_compressBound(uncompressed_data.size()); + Bytes compressed_data(capacity); + const std::size_t compressed_size = ZSTD_compress2( + context.get(), + compressed_data.data(), + compressed_data.size(), + uncompressed_data.data(), + uncompressed_data.size()); + if (ZSTD_isError(compressed_size)) { + return std::unexpected(Error{ErrorCode::CompressionFailed}); + } + + compressed_data.resize(compressed_size); + return CompressedData{std::move(compressed_data), {}}; +} + +std::expected checked_decompress( + const Bytes &compressed_data, + const CompressionAlgorithm compression_algorithm, + const ChecksumAlgorithm checksum_algorithm, + const std::string_view checksum, + const std::size_t max_decompressed_size) +{ + if (const auto validation = validate_algorithms(compression_algorithm, checksum_algorithm); !validation) { + return std::unexpected(validation.error()); + } + if (!checksum.empty()) { + return std::unexpected(Error{ErrorCode::InvalidAlgorithmCombination}); + } + + const std::size_t effective_max_decompressed_size = + std::min(max_decompressed_size, default_max_decompressed_size); + + if (compression_algorithm == CompressionAlgorithm::None) { + if (compressed_data.size() > effective_max_decompressed_size) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } + return compressed_data; + } + + ZSTD_frameHeader frame_header{}; + const std::size_t frame_header_result = ZSTD_getFrameHeader( + &frame_header, compressed_data.data(), compressed_data.size()); + if (ZSTD_isError(frame_header_result) || frame_header_result != 0) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } + if (frame_header.checksumFlag == 0) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } + + const unsigned long long content_size = + ZSTD_getFrameContentSize(compressed_data.data(), compressed_data.size()); + if (content_size == ZSTD_CONTENTSIZE_ERROR || content_size == ZSTD_CONTENTSIZE_UNKNOWN + || content_size > std::numeric_limits::max()) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } + if (content_size > effective_max_decompressed_size) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } + + Bytes uncompressed_data(static_cast(content_size)); + const std::size_t decompressed_size = ZSTD_decompress( + uncompressed_data.data(), + uncompressed_data.size(), + compressed_data.data(), + compressed_data.size()); + if (ZSTD_isError(decompressed_size)) { + if (ZSTD_getErrorCode(decompressed_size) == ZSTD_error_checksum_wrong) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } + if (decompressed_size != uncompressed_data.size()) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } + + return uncompressed_data; +} + +} // namespace io::envelope diff --git a/src/terrainlib/io/compression.h b/src/terrainlib/io/compression.h new file mode 100644 index 00000000..acaca8d1 --- /dev/null +++ b/src/terrainlib/io/compression.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace io::envelope { + +using Bytes = std::vector; + +inline constexpr std::size_t default_max_decompressed_size = std::size_t{1} << 30; + +enum class ChecksumAlgorithm : std::uint8_t { + None, + HandledByCompressionLib, +}; + +enum class CompressionAlgorithm : std::uint8_t { + None, + ZstdBestCompressionWithChecksum, +}; + +enum class ErrorCode : std::uint8_t { + SerializationFailed, + DeserializationFailed, + InvalidMagic, + WrongClassName, + UnsupportedClassVersion, + UnsupportedChecksumAlgorithm, + UnsupportedCompressionAlgorithm, + InvalidAlgorithmCombination, + ChecksumMismatch, + CompressionFailed, + DecompressionFailed, + SizeLimitExceeded, +}; + +struct Error { + ErrorCode code; + std::errc serialization_error{}; + + constexpr bool operator==(const Error &) const = default; +}; + +struct CompressedData { + Bytes compressed_data; + std::string checksum; +}; + +std::expected compress_with_checksum( + const Bytes &uncompressed_data, + CompressionAlgorithm compression_algorithm, + ChecksumAlgorithm checksum_algorithm); + +std::expected checked_decompress( + const Bytes &compressed_data, + CompressionAlgorithm compression_algorithm, + ChecksumAlgorithm checksum_algorithm, + std::string_view checksum, + std::size_t max_decompressed_size = default_max_decompressed_size); + +} // namespace io::envelope diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h new file mode 100644 index 00000000..2c17e004 --- /dev/null +++ b/src/terrainlib/io/envelope.h @@ -0,0 +1,140 @@ +#pragma once + +#include "io/compression.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace io::envelope { + +inline constexpr std::uint64_t magic = 0xF5FBD3EF919428CAULL; + +struct Envelope { + std::uint64_t magic; + std::string class_name; + std::uint32_t class_version; + ChecksumAlgorithm checksum_algorithm; + std::string checksum; + CompressionAlgorithm compression_algorithm; + Bytes compressed_data; +}; + +template +struct FixedString { + char value[Size]; + + constexpr FixedString(const char (&text)[Size]) + { + std::copy_n(text, Size, value); + } + + constexpr auto operator<=>(const FixedString &) const = default; +}; + +template +struct Version { + static constexpr std::uint32_t number = Number; + using payload_type = VersionedPayloadType; +}; + +namespace detail { + +template +struct FindVersion; + +template +struct FindVersion { + using type = std::conditional_t::type>; +}; + +template +struct FindVersion { + using type = void; +}; + +template +consteval bool versions_are_strictly_increasing() +{ + constexpr std::uint32_t numbers[] = {Versions::number...}; + for (std::size_t index = 1; index < sizeof...(Versions); ++index) { + if (numbers[index - 1] >= numbers[index]) { + return false; + } + } + return true; +} + +template +concept ConvertsFromPrevious = requires(From previous) { + { To::from_previous(std::move(previous)) } -> std::same_as; +}; + +template +consteval bool conversions_are_valid(std::index_sequence) +{ + return (ConvertsFromPrevious< + typename std::tuple_element_t::payload_type, + typename std::tuple_element_t::payload_type> + && ...); +} + +} // namespace detail + +template +struct PayloadSchema { + static_assert(sizeof...(Versions) > 0, "a payload schema requires at least one version"); + static_assert(detail::versions_are_strictly_increasing(), + "payload versions must be strictly increasing"); + + using version_tuple = std::tuple; + static constexpr std::size_t version_count = sizeof...(Versions); + + template + using version_at = std::tuple_element_t; + + using latest_version_descriptor = version_at; + using latest_type = typename latest_version_descriptor::payload_type; + + static constexpr std::string_view class_name{ClassName.value, sizeof(ClassName.value) - 1}; + static constexpr std::uint32_t latest_version = latest_version_descriptor::number; + + template + static constexpr bool supports_version = + !std::is_void_v::type>; + + static constexpr bool supports_version_number(const std::uint32_t number) + { + return ((number == Versions::number) || ...); + } + + template + using payload_type = typename detail::FindVersion::type::payload_type; + + static_assert(version_count == 1 + || detail::conversions_are_valid( + std::make_index_sequence{}), + "each payload version must provide from_previous for the preceding version"); +}; + +template +std::expected serialize( + const typename Schema::template payload_type &payload, + CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdBestCompressionWithChecksum, + ChecksumAlgorithm checksum_algorithm = ChecksumAlgorithm::HandledByCompressionLib); + +template +std::expected deserialize( + std::span bytes, + std::size_t max_decompressed_size = default_max_decompressed_size); + +} // namespace io::envelope + +#include "io/envelope.inl" diff --git a/src/terrainlib/io/envelope.inl b/src/terrainlib/io/envelope.inl new file mode 100644 index 00000000..ebb1f3bf --- /dev/null +++ b/src/terrainlib/io/envelope.inl @@ -0,0 +1,132 @@ +#pragma once + +#include + +namespace io::envelope { +namespace detail { + +template +std::expected serialize_to_bytes(const Value &value) +{ + Bytes bytes; + zpp::bits::out output(bytes, zpp::bits::alloc_limit()); + const zpp::bits::errc result = output(value); + if (zpp::bits::failure(result)) { + return std::unexpected(Error{ErrorCode::SerializationFailed, result.code}); + } + return bytes; +} + +template +std::expected deserialize_from_bytes(const std::span bytes) +{ + Value value{}; + zpp::bits::in input(bytes, zpp::bits::alloc_limit()); + const zpp::bits::errc result = input(value); + if (zpp::bits::failure(result) || input.position() != bytes.size()) { + return std::unexpected(Error{ + ErrorCode::DeserializationFailed, + zpp::bits::failure(result) ? result.code : std::errc::bad_message, + }); + } + return value; +} + +template +typename Schema::latest_type convert_to_latest(Current current) +{ + if constexpr (Index + 1 == Schema::version_count) { + return current; + } else { + using Next = typename Schema::template version_at::payload_type; + return convert_to_latest(Next::from_previous(std::move(current))); + } +} + +template +std::expected deserialize_version( + const std::uint32_t class_version, + const std::span payload_bytes) +{ + using CurrentVersion = typename Schema::template version_at; + if (class_version == CurrentVersion::number) { + auto payload = deserialize_from_bytes(payload_bytes); + if (!payload) { + return std::unexpected(payload.error()); + } + return convert_to_latest(std::move(*payload)); + } + + if constexpr (Index + 1 < Schema::version_count) { + return deserialize_version(class_version, payload_bytes); + } else { + return std::unexpected(Error{ErrorCode::UnsupportedClassVersion}); + } +} + +} // namespace detail + +template +std::expected serialize( + const typename Schema::template payload_type &payload, + const CompressionAlgorithm compression_algorithm, + const ChecksumAlgorithm checksum_algorithm) +{ + static_assert(Schema::template supports_version, + "the requested payload version is not part of the schema"); + + auto payload_bytes = detail::serialize_to_bytes(payload); + if (!payload_bytes) { + return std::unexpected(payload_bytes.error()); + } + + auto compressed = compress_with_checksum(*payload_bytes, compression_algorithm, checksum_algorithm); + if (!compressed) { + return std::unexpected(compressed.error()); + } + + const Envelope envelope{ + .magic = magic, + .class_name = std::string{Schema::class_name}, + .class_version = VersionNumber, + .checksum_algorithm = checksum_algorithm, + .checksum = std::move(compressed->checksum), + .compression_algorithm = compression_algorithm, + .compressed_data = std::move(compressed->compressed_data), + }; + return detail::serialize_to_bytes(envelope); +} + +template +std::expected deserialize( + const std::span bytes, + const std::size_t max_decompressed_size) +{ + auto envelope = detail::deserialize_from_bytes(bytes); + if (!envelope) { + return std::unexpected(envelope.error()); + } + if (envelope->magic != magic) { + return std::unexpected(Error{ErrorCode::InvalidMagic}); + } + if (envelope->class_name != Schema::class_name) { + return std::unexpected(Error{ErrorCode::WrongClassName}); + } + if (!Schema::supports_version_number(envelope->class_version)) { + return std::unexpected(Error{ErrorCode::UnsupportedClassVersion}); + } + + auto payload_bytes = checked_decompress( + envelope->compressed_data, + envelope->compression_algorithm, + envelope->checksum_algorithm, + envelope->checksum, + max_decompressed_size); + if (!payload_bytes) { + return std::unexpected(payload_bytes.error()); + } + + return detail::deserialize_version(envelope->class_version, *payload_bytes); +} + +} // namespace io::envelope diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 0ea40bcc..d43ee019 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(unittests_terrainlib terrainlib/cow.cpp terrainlib/dataset.cpp terrainlib/enumerate.cpp + terrainlib/envelope.cpp terrainlib/fixed_vector.cpp terrainlib/grid.cpp terrainlib/hash_utils.cpp diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp new file mode 100644 index 00000000..999e5b24 --- /dev/null +++ b/unittests/terrainlib/envelope.cpp @@ -0,0 +1,313 @@ +#include "../catch2_helpers.h" + +#include "io/envelope.h" + +#include + +namespace { + +namespace v1 { + +struct Payload { + std::uint32_t id; + std::string name; + + bool operator==(const Payload &) const = default; +}; + +} // namespace v1 + +namespace v2 { + +struct Payload { + std::uint64_t id; + std::string name; + bool enabled; + + static Payload from_previous(v1::Payload previous) + { + return { + .id = previous.id, + .name = std::move(previous.name), + .enabled = true, + }; + } + + bool operator==(const Payload &) const = default; +}; + +} // namespace v2 + +namespace v3 { + +struct Payload { + std::uint64_t id; + std::string label; + bool enabled; + std::vector samples; + + static Payload from_previous(v2::Payload previous) + { + return { + .id = previous.id, + .label = std::move(previous.name), + .enabled = previous.enabled, + .samples = {}, + }; + } + + bool operator==(const Payload &) const = default; +}; + +} // namespace v3 + +using Schema = io::envelope::PayloadSchema< + "test.Payload", + io::envelope::Version<1, v1::Payload>, + io::envelope::Version<2, v2::Payload>, + io::envelope::Version<3, v3::Payload>>; + +static_assert(std::is_aggregate_v); +static_assert(std::is_aggregate_v); +static_assert(std::is_aggregate_v); +static_assert(Schema::class_name == "test.Payload"); +static_assert(Schema::latest_version == 3); +static_assert(std::same_as); +static_assert(std::same_as, v1::Payload>); + +io::envelope::Bytes encode_envelope(const io::envelope::Envelope &envelope) +{ + io::envelope::Bytes bytes; + zpp::bits::out output(bytes); + output(envelope).or_throw(); + return bytes; +} + +io::envelope::Envelope decode_envelope(const io::envelope::Bytes &bytes) +{ + io::envelope::Envelope envelope{}; + zpp::bits::in input(bytes); + input(envelope).or_throw(); + return envelope; +} + +template +void check_error(const Result &result, const io::envelope::ErrorCode expected) +{ + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().code == expected); +} + +} // namespace + +TEST_CASE("Envelope round trips the latest payload version") +{ + const v3::Payload expected{ + .id = 42, + .label = "latest", + .enabled = false, + .samples = {1, 2, 3}, + }; + + const auto bytes = io::envelope::serialize(expected); + REQUIRE(bytes.has_value()); + + const auto envelope = decode_envelope(*bytes); + CHECK(envelope.magic == io::envelope::magic); + CHECK(envelope.class_name == Schema::class_name); + CHECK(envelope.class_version == 3); + CHECK(envelope.checksum_algorithm == io::envelope::ChecksumAlgorithm::HandledByCompressionLib); + CHECK(envelope.checksum.empty()); + CHECK(envelope.compression_algorithm + == io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == expected); +} + +TEST_CASE("Envelope upgrades older payload versions") +{ + SECTION("version 1 is upgraded through every subsequent version") + { + const v1::Payload original{.id = 7, .name = "version one"}; + const auto bytes = io::envelope::serialize(original); + REQUIRE(bytes.has_value()); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == v3::Payload{ + .id = 7, + .label = "version one", + .enabled = true, + .samples = {}, + }); + } + + SECTION("version 2 is upgraded to the latest version") + { + const v2::Payload original{.id = 9, .name = "version two", .enabled = false}; + const auto bytes = io::envelope::serialize(original); + REQUIRE(bytes.has_value()); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == v3::Payload{ + .id = 9, + .label = "version two", + .enabled = false, + .samples = {}, + }); + } +} + +TEST_CASE("Envelope supports uncompressed data without a checksum") +{ + const v3::Payload expected{ + .id = 11, + .label = "plain", + .enabled = true, + .samples = {5, 8}, + }; + const auto bytes = io::envelope::serialize( + expected, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::None); + REQUIRE(bytes.has_value()); + + const auto envelope = decode_envelope(*bytes); + CHECK(envelope.compression_algorithm == io::envelope::CompressionAlgorithm::None); + CHECK(envelope.checksum_algorithm == io::envelope::ChecksumAlgorithm::None); + CHECK(envelope.checksum.empty()); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == expected); +} + +TEST_CASE("Checked compression round trips and validates its checksum") +{ + const io::envelope::Bytes original(4096, std::byte{0x2a}); + const auto compressed = io::envelope::compress_with_checksum( + original, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib); + REQUIRE(compressed.has_value()); + CHECK(compressed->compressed_data.size() < original.size()); + CHECK(compressed->checksum.empty()); + + const auto result = io::envelope::checked_decompress( + compressed->compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + compressed->checksum); + REQUIRE(result.has_value()); + CHECK(*result == original); + + auto corrupted = compressed->compressed_data; + corrupted.back() ^= std::byte{0x01}; + check_error( + io::envelope::checked_decompress( + corrupted, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + {}), + io::envelope::ErrorCode::ChecksumMismatch); + + check_error( + io::envelope::checked_decompress( + compressed->compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + {}, + original.size() - 1), + io::envelope::ErrorCode::SizeLimitExceeded); +} + +TEST_CASE("Envelope rejects incompatible metadata") +{ + const v3::Payload payload{.id = 1, .label = "metadata", .enabled = true, .samples = {}}; + const auto serialized = io::envelope::serialize(payload); + REQUIRE(serialized.has_value()); + + SECTION("magic") + { + auto envelope = decode_envelope(*serialized); + ++envelope.magic; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::InvalidMagic); + } + + SECTION("class name") + { + auto envelope = decode_envelope(*serialized); + envelope.class_name = "other.Payload"; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::WrongClassName); + } + + SECTION("class version") + { + auto envelope = decode_envelope(*serialized); + envelope.class_version = 99; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::UnsupportedClassVersion); + } + + SECTION("checksum algorithm") + { + auto envelope = decode_envelope(*serialized); + envelope.checksum_algorithm = static_cast(99); + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::UnsupportedChecksumAlgorithm); + } + + SECTION("compression algorithm") + { + auto envelope = decode_envelope(*serialized); + envelope.compression_algorithm = static_cast(99); + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::UnsupportedCompressionAlgorithm); + } + + SECTION("algorithm combination") + { + auto envelope = decode_envelope(*serialized); + envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::None; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::InvalidAlgorithmCombination); + } + + SECTION("external checksum") + { + auto envelope = decode_envelope(*serialized); + envelope.checksum = "not used by zstd"; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::InvalidAlgorithmCombination); + } +} + +TEST_CASE("Envelope reports malformed serialized data") +{ + SECTION("envelope") + { + const io::envelope::Bytes malformed{std::byte{0x01}, std::byte{0x02}}; + check_error(io::envelope::deserialize(malformed), + io::envelope::ErrorCode::DeserializationFailed); + } + + SECTION("payload") + { + const io::envelope::Envelope envelope{ + .magic = io::envelope::magic, + .class_name = std::string{Schema::class_name}, + .class_version = 3, + .checksum_algorithm = io::envelope::ChecksumAlgorithm::None, + .checksum = {}, + .compression_algorithm = io::envelope::CompressionAlgorithm::None, + .compressed_data = {std::byte{0x01}}, + }; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DeserializationFailed); + } +} From 0634ae1d897872c1420bfd6313f3802e002afc16 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:27:02 +0200 Subject: [PATCH 39/53] Store uncompressed envelope size --- docs/raster-store/storage-format.md | 5 +- src/terrainlib/io/compression.cpp | 23 +++-- src/terrainlib/io/envelope.h | 1 + src/terrainlib/io/envelope.inl | 18 +++- unittests/terrainlib/envelope.cpp | 146 +++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 10 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index df26fe7e..29103995 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -63,6 +63,7 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - `ChecksumAlgorithm checksum_algorithm`, default `HandledByCompressionLib`, alternatively `None`; - `string checksum`, empty when the checksum is handled by the compression library; - `CompressionAlgorithm compression_algorithm`, default `ZstdBestCompressionWithChecksum`, alternatively `None`; + - `uint64 uncompressed_size`, the exact size of the uncompressed second level; - `Bytes compressed_data`, containing the second level. - The second level is a compressed byte vector. It is deserialised directly into the selected versioned payload class. - The magic is shared by all payload types. `class_name` distinguishes payload types. An incompatible future envelope layout requires a new magic. @@ -82,8 +83,10 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - we fail by returning an unexpected in these cases - Compression uses libzstd at its best compression level. Libzstd is imported through the project's CMake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. `ZstdBestCompressionWithChecksum` writes an embedded zstd frame checksum, which libzstd verifies while decompressing. - `compress_with_checksum` accepts a `vector` and returns the compressed bytes plus the external checksum string. `checked_decompress` accepts both and returns the decompressed bytes. Both use `std::expected` and dispatch with a switch. +- `checked_decompress` accepts a maximum decompressed size, defaulting to 1 GiB. It uses a size reported by the compression format when available, otherwise it uses the maximum as its allocation bound and shrinks the result to the produced size. +- Envelope deserialisation passes `uncompressed_size` as that maximum and requires the produced size to match it exactly. A mismatch is a decompression failure. A declared size above the caller's limit or the hard 1 GiB limit is a size-limit failure. - `None` compression must be paired with `None` checksum. `ZstdBestCompressionWithChecksum` must be paired with `HandledByCompressionLib`; its external checksum string must be empty because the checksum is embedded in the zstd frame. -- Decompression rejects output larger than 1 GiB. +- Decompression never allocates or produces output larger than 1 GiB. ## to be defined diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp index 9a2b3bad..c137988c 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.cpp @@ -124,17 +124,23 @@ std::expected checked_decompress( return std::unexpected(Error{ErrorCode::ChecksumMismatch}); } - const unsigned long long content_size = + const unsigned long long frame_content_size = ZSTD_getFrameContentSize(compressed_data.data(), compressed_data.size()); - if (content_size == ZSTD_CONTENTSIZE_ERROR || content_size == ZSTD_CONTENTSIZE_UNKNOWN - || content_size > std::numeric_limits::max()) { + if (frame_content_size == ZSTD_CONTENTSIZE_ERROR + || (frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN + && frame_content_size > std::numeric_limits::max())) { return std::unexpected(Error{ErrorCode::DecompressionFailed}); } - if (content_size > effective_max_decompressed_size) { + + const bool content_size_is_known = frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN; + if (content_size_is_known && frame_content_size > effective_max_decompressed_size) { return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); } - Bytes uncompressed_data(static_cast(content_size)); + const std::size_t allocation_size = content_size_is_known + ? static_cast(frame_content_size) + : effective_max_decompressed_size; + Bytes uncompressed_data(allocation_size); const std::size_t decompressed_size = ZSTD_decompress( uncompressed_data.data(), uncompressed_data.size(), @@ -144,12 +150,17 @@ std::expected checked_decompress( if (ZSTD_getErrorCode(decompressed_size) == ZSTD_error_checksum_wrong) { return std::unexpected(Error{ErrorCode::ChecksumMismatch}); } + if (!content_size_is_known + && ZSTD_getErrorCode(decompressed_size) == ZSTD_error_dstSize_tooSmall) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } return std::unexpected(Error{ErrorCode::DecompressionFailed}); } - if (decompressed_size != uncompressed_data.size()) { + if (content_size_is_known && decompressed_size != uncompressed_data.size()) { return std::unexpected(Error{ErrorCode::DecompressionFailed}); } + uncompressed_data.resize(decompressed_size); return uncompressed_data; } diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index 2c17e004..4b36065b 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -24,6 +24,7 @@ struct Envelope { ChecksumAlgorithm checksum_algorithm; std::string checksum; CompressionAlgorithm compression_algorithm; + std::uint64_t uncompressed_size; Bytes compressed_data; }; diff --git a/src/terrainlib/io/envelope.inl b/src/terrainlib/io/envelope.inl index ebb1f3bf..c9055894 100644 --- a/src/terrainlib/io/envelope.inl +++ b/src/terrainlib/io/envelope.inl @@ -2,6 +2,8 @@ #include +#include + namespace io::envelope { namespace detail { @@ -92,6 +94,7 @@ std::expected serialize( .checksum_algorithm = checksum_algorithm, .checksum = std::move(compressed->checksum), .compression_algorithm = compression_algorithm, + .uncompressed_size = payload_bytes->size(), .compressed_data = std::move(compressed->compressed_data), }; return detail::serialize_to_bytes(envelope); @@ -116,15 +119,28 @@ std::expected deserialize( return std::unexpected(Error{ErrorCode::UnsupportedClassVersion}); } + const std::size_t effective_max_decompressed_size = + std::min(max_decompressed_size, default_max_decompressed_size); + if (envelope->uncompressed_size > std::numeric_limits::max() + || envelope->uncompressed_size > effective_max_decompressed_size) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } + auto payload_bytes = checked_decompress( envelope->compressed_data, envelope->compression_algorithm, envelope->checksum_algorithm, envelope->checksum, - max_decompressed_size); + static_cast(envelope->uncompressed_size)); if (!payload_bytes) { + if (payload_bytes.error().code == ErrorCode::SizeLimitExceeded) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } return std::unexpected(payload_bytes.error()); } + if (payload_bytes->size() != envelope->uncompressed_size) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } return detail::deserialize_version(envelope->class_version, *payload_bytes); } diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 999e5b24..a8b82273 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -2,8 +2,12 @@ #include "io/envelope.h" +#include #include +#include +#include + namespace { namespace v1 { @@ -75,14 +79,20 @@ static_assert(Schema::latest_version == 3); static_assert(std::same_as); static_assert(std::same_as, v1::Payload>); -io::envelope::Bytes encode_envelope(const io::envelope::Envelope &envelope) +template +io::envelope::Bytes encode_value(const Value &value) { io::envelope::Bytes bytes; zpp::bits::out output(bytes); - output(envelope).or_throw(); + output(value).or_throw(); return bytes; } +io::envelope::Bytes encode_envelope(const io::envelope::Envelope &envelope) +{ + return encode_value(envelope); +} + io::envelope::Envelope decode_envelope(const io::envelope::Bytes &bytes) { io::envelope::Envelope envelope{}; @@ -91,6 +101,30 @@ io::envelope::Envelope decode_envelope(const io::envelope::Bytes &bytes) return envelope; } +io::envelope::Bytes compress_without_content_size(const io::envelope::Bytes &uncompressed_data) +{ + const std::unique_ptr context{ + ZSTD_createCCtx(), &ZSTD_freeCCtx}; + if (!context + || ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_contentSizeFlag, 0)) + || ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_checksumFlag, 1))) { + throw std::runtime_error{"could not configure zstd test context"}; + } + + io::envelope::Bytes compressed_data(ZSTD_compressBound(uncompressed_data.size())); + const std::size_t compressed_size = ZSTD_compress2( + context.get(), + compressed_data.data(), + compressed_data.size(), + uncompressed_data.data(), + uncompressed_data.size()); + if (ZSTD_isError(compressed_size)) { + throw std::runtime_error{"could not create zstd test data"}; + } + compressed_data.resize(compressed_size); + return compressed_data; +} + template void check_error(const Result &result, const io::envelope::ErrorCode expected) { @@ -120,6 +154,7 @@ TEST_CASE("Envelope round trips the latest payload version") CHECK(envelope.checksum.empty()); CHECK(envelope.compression_algorithm == io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum); + CHECK(envelope.uncompressed_size == encode_value(expected).size()); const auto result = io::envelope::deserialize(*bytes); REQUIRE(result.has_value()); @@ -179,6 +214,7 @@ TEST_CASE("Envelope supports uncompressed data without a checksum") CHECK(envelope.compression_algorithm == io::envelope::CompressionAlgorithm::None); CHECK(envelope.checksum_algorithm == io::envelope::ChecksumAlgorithm::None); CHECK(envelope.checksum.empty()); + CHECK(envelope.uncompressed_size == envelope.compressed_data.size()); const auto result = io::envelope::deserialize(*bytes); REQUIRE(result.has_value()); @@ -204,6 +240,19 @@ TEST_CASE("Checked compression round trips and validates its checksum") REQUIRE(result.has_value()); CHECK(*result == original); + const auto empty_compressed = io::envelope::compress_with_checksum( + {}, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib); + REQUIRE(empty_compressed.has_value()); + const auto empty_result = io::envelope::checked_decompress( + empty_compressed->compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + empty_compressed->checksum); + REQUIRE(empty_result.has_value()); + CHECK(empty_result->empty()); + auto corrupted = compressed->compressed_data; corrupted.back() ^= std::byte{0x01}; check_error( @@ -224,6 +273,30 @@ TEST_CASE("Checked compression round trips and validates its checksum") io::envelope::ErrorCode::SizeLimitExceeded); } +TEST_CASE("Checked decompression uses its maximum when the format omits the content size") +{ + const io::envelope::Bytes original(4096, std::byte{0x37}); + const auto compressed_data = compress_without_content_size(original); + + const auto result = io::envelope::checked_decompress( + compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + {}, + original.size()); + REQUIRE(result.has_value()); + CHECK(*result == original); + + check_error( + io::envelope::checked_decompress( + compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::HandledByCompressionLib, + {}, + original.size() - 1), + io::envelope::ErrorCode::SizeLimitExceeded); +} + TEST_CASE("Envelope rejects incompatible metadata") { const v3::Payload payload{.id = 1, .label = "metadata", .enabled = true, .samples = {}}; @@ -285,6 +358,74 @@ TEST_CASE("Envelope rejects incompatible metadata") check_error(io::envelope::deserialize(encode_envelope(envelope)), io::envelope::ErrorCode::InvalidAlgorithmCombination); } + + SECTION("uncompressed size is smaller than the payload") + { + auto envelope = decode_envelope(*serialized); + --envelope.uncompressed_size; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DecompressionFailed); + } + + SECTION("uncompressed size is larger than the payload") + { + auto envelope = decode_envelope(*serialized); + ++envelope.uncompressed_size; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DecompressionFailed); + } + + SECTION("zero uncompressed size does not mean unspecified") + { + auto envelope = decode_envelope(*serialized); + envelope.uncompressed_size = 0; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DecompressionFailed); + } + + SECTION("uncompressed size exceeds the hard limit") + { + auto envelope = decode_envelope(*serialized); + envelope.uncompressed_size = io::envelope::default_max_decompressed_size + 1; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::SizeLimitExceeded); + } + + SECTION("uncompressed size exceeds a caller limit") + { + auto envelope = decode_envelope(*serialized); + check_error( + io::envelope::deserialize( + encode_envelope(envelope), + static_cast(envelope.uncompressed_size - 1)), + io::envelope::ErrorCode::SizeLimitExceeded); + } +} + +TEST_CASE("Envelope validates the size of uncompressed data") +{ + const v3::Payload payload{.id = 2, .label = "plain", .enabled = true, .samples = {1}}; + const auto serialized = io::envelope::serialize( + payload, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::None); + REQUIRE(serialized.has_value()); + + SECTION("declared size is smaller") + { + auto envelope = decode_envelope(*serialized); + --envelope.uncompressed_size; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DecompressionFailed); + } + + SECTION("declared size is larger") + { + auto envelope = decode_envelope(*serialized); + ++envelope.uncompressed_size; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::DecompressionFailed); + } } TEST_CASE("Envelope reports malformed serialized data") @@ -305,6 +446,7 @@ TEST_CASE("Envelope reports malformed serialized data") .checksum_algorithm = io::envelope::ChecksumAlgorithm::None, .checksum = {}, .compression_algorithm = io::envelope::CompressionAlgorithm::None, + .uncompressed_size = 1, .compressed_data = {std::byte{0x01}}, }; check_error(io::envelope::deserialize(encode_envelope(envelope)), From cf6d4a6bce4c68843a4dec27dfa48e153d3d07e2 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:28:04 +0200 Subject: [PATCH 40/53] Add CRC-32C envelope checksums --- docs/raster-store/storage-format.md | 8 +- src/terrainlib/io/compression.cpp | 150 +++++++++++++++-------- src/terrainlib/io/compression.h | 1 + unittests/terrainlib/envelope.cpp | 181 ++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 51 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 29103995..5767e0f8 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -60,8 +60,8 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - `uint64 magic`, always `F5FBD3EF919428CA`, identifying this envelope format; - `string class_name`, identifying the payload type; - `uint32 class_version`, identifying the versioned payload type; - - `ChecksumAlgorithm checksum_algorithm`, default `HandledByCompressionLib`, alternatively `None`; - - `string checksum`, empty when the checksum is handled by the compression library; + - `ChecksumAlgorithm checksum_algorithm`, default `HandledByCompressionLib`, alternatively `Crc32c` or `None`; + - `string checksum`, empty when no external checksum is used, otherwise the CRC-32C value; - `CompressionAlgorithm compression_algorithm`, default `ZstdBestCompressionWithChecksum`, alternatively `None`; - `uint64 uncompressed_size`, the exact size of the uncompressed second level; - `Bytes compressed_data`, containing the second level. @@ -83,9 +83,11 @@ tile-base is a hierarchy build from raster-fundamentalis, containing all data an - we fail by returning an unexpected in these cases - Compression uses libzstd at its best compression level. Libzstd is imported through the project's CMake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. `ZstdBestCompressionWithChecksum` writes an embedded zstd frame checksum, which libzstd verifies while decompressing. - `compress_with_checksum` accepts a `vector` and returns the compressed bytes plus the external checksum string. `checked_decompress` accepts both and returns the decompressed bytes. Both use `std::expected` and dispatch with a switch. +- `Crc32c` is an external CRC-32C (Castagnoli) checksum of the complete uncompressed serialised payload. It uses the reflected polynomial `82F63B78`, an initial value of `FFFFFFFF`, and a final XOR of `FFFFFFFF`. The checksum string contains exactly eight lowercase hexadecimal characters. The empty payload has checksum `00000000`; the ASCII test vector `123456789` has checksum `e3069283`. CRC-32C detects accidental corruption but is not cryptographic. - `checked_decompress` accepts a maximum decompressed size, defaulting to 1 GiB. It uses a size reported by the compression format when available, otherwise it uses the maximum as its allocation bound and shrinks the result to the produced size. - Envelope deserialisation passes `uncompressed_size` as that maximum and requires the produced size to match it exactly. A mismatch is a decompression failure. A declared size above the caller's limit or the hard 1 GiB limit is a size-limit failure. -- `None` compression must be paired with `None` checksum. `ZstdBestCompressionWithChecksum` must be paired with `HandledByCompressionLib`; its external checksum string must be empty because the checksum is embedded in the zstd frame. +- `None` compression may be paired with `None` or `Crc32c`. `ZstdBestCompressionWithChecksum` may be paired with `HandledByCompressionLib` or `Crc32c`; with `Crc32c`, both the embedded zstd checksum and the external CRC-32C are checked. `HandledByCompressionLib` is invalid with `None` compression, and `None` checksum is invalid with `ZstdBestCompressionWithChecksum`. +- `None` and `HandledByCompressionLib` require an empty checksum string. `Crc32c` requires its canonical eight-character checksum. Deserialisation verifies it after bounded decompression and before parsing the payload with `zpp::bits`; envelope deserialisation also requires the resulting size to exactly match `uncompressed_size`. - Decompression never allocates or produces output larger than 1 GiB. diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp index c137988c..ba18e638 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.cpp @@ -5,6 +5,7 @@ #include "io/compression.h" #include +#include #include #include #include @@ -14,6 +15,38 @@ namespace { using CompressionContext = std::unique_ptr; +std::string crc32c_checksum(const Bytes &data) +{ + static constexpr auto table = [] { + constexpr std::uint32_t polynomial = 0x82f63b78u; + + std::array values{}; + for (std::size_t index = 0; index < values.size(); ++index) { + auto crc = static_cast(index); + for (int bit = 0; bit < 8; ++bit) { + crc = (crc >> 1u) ^ ((crc & 1u) != 0u ? polynomial : 0u); + } + values[index] = crc; + } + return values; + }(); + + std::uint32_t crc = 0xffffffffu; + for (const auto value : data) { + const auto index = (crc ^ std::to_integer(value)) & 0xffu; + crc = table[index] ^ (crc >> 8u); + } + crc = ~crc; + + constexpr char hex_digits[] = "0123456789abcdef"; + std::string checksum(8, '0'); + for (std::size_t index = 0; index < checksum.size(); ++index) { + const auto shift = static_cast((checksum.size() - index - 1) * 4); + checksum[index] = hex_digits[(crc >> shift) & 0x0fu]; + } + return checksum; +} + std::expected validate_algorithms( const CompressionAlgorithm compression_algorithm, const ChecksumAlgorithm checksum_algorithm) @@ -21,6 +54,7 @@ std::expected validate_algorithms( switch (checksum_algorithm) { case ChecksumAlgorithm::None: case ChecksumAlgorithm::HandledByCompressionLib: + case ChecksumAlgorithm::Crc32c: break; default: return std::unexpected(Error{ErrorCode::UnsupportedChecksumAlgorithm}); @@ -35,10 +69,12 @@ std::expected validate_algorithms( } const bool no_compression = compression_algorithm == CompressionAlgorithm::None - && checksum_algorithm == ChecksumAlgorithm::None; + && (checksum_algorithm == ChecksumAlgorithm::None + || checksum_algorithm == ChecksumAlgorithm::Crc32c); const bool zstd_with_checksum = compression_algorithm == CompressionAlgorithm::ZstdBestCompressionWithChecksum - && checksum_algorithm == ChecksumAlgorithm::HandledByCompressionLib; + && (checksum_algorithm == ChecksumAlgorithm::HandledByCompressionLib + || checksum_algorithm == ChecksumAlgorithm::Crc32c); if (!no_compression && !zstd_with_checksum) { return std::unexpected(Error{ErrorCode::InvalidAlgorithmCombination}); } @@ -60,8 +96,12 @@ std::expected compress_with_checksum( return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); } + const std::string checksum = checksum_algorithm == ChecksumAlgorithm::Crc32c + ? crc32c_checksum(uncompressed_data) + : std::string{}; + if (compression_algorithm == CompressionAlgorithm::None) { - return CompressedData{uncompressed_data, {}}; + return CompressedData{uncompressed_data, checksum}; } CompressionContext context{ZSTD_createCCtx(), &ZSTD_freeCCtx}; @@ -87,7 +127,7 @@ std::expected compress_with_checksum( } compressed_data.resize(compressed_size); - return CompressedData{std::move(compressed_data), {}}; + return CompressedData{std::move(compressed_data), checksum}; } std::expected checked_decompress( @@ -100,67 +140,81 @@ std::expected checked_decompress( if (const auto validation = validate_algorithms(compression_algorithm, checksum_algorithm); !validation) { return std::unexpected(validation.error()); } - if (!checksum.empty()) { + if (checksum_algorithm != ChecksumAlgorithm::Crc32c && !checksum.empty()) { return std::unexpected(Error{ErrorCode::InvalidAlgorithmCombination}); } + if (checksum_algorithm == ChecksumAlgorithm::Crc32c + && (checksum.size() != 8 + || !std::all_of(checksum.begin(), checksum.end(), [](const char character) { + return (character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f'); + }))) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } const std::size_t effective_max_decompressed_size = std::min(max_decompressed_size, default_max_decompressed_size); + Bytes uncompressed_data; if (compression_algorithm == CompressionAlgorithm::None) { if (compressed_data.size() > effective_max_decompressed_size) { return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); } - return compressed_data; - } - - ZSTD_frameHeader frame_header{}; - const std::size_t frame_header_result = ZSTD_getFrameHeader( - &frame_header, compressed_data.data(), compressed_data.size()); - if (ZSTD_isError(frame_header_result) || frame_header_result != 0) { - return std::unexpected(Error{ErrorCode::DecompressionFailed}); - } - if (frame_header.checksumFlag == 0) { - return std::unexpected(Error{ErrorCode::ChecksumMismatch}); - } + uncompressed_data = compressed_data; + } else { + ZSTD_frameHeader frame_header{}; + const std::size_t frame_header_result = ZSTD_getFrameHeader( + &frame_header, compressed_data.data(), compressed_data.size()); + if (ZSTD_isError(frame_header_result) || frame_header_result != 0) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } + if (frame_header.checksumFlag == 0) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } - const unsigned long long frame_content_size = - ZSTD_getFrameContentSize(compressed_data.data(), compressed_data.size()); - if (frame_content_size == ZSTD_CONTENTSIZE_ERROR - || (frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN - && frame_content_size > std::numeric_limits::max())) { - return std::unexpected(Error{ErrorCode::DecompressionFailed}); - } + const unsigned long long frame_content_size = + ZSTD_getFrameContentSize(compressed_data.data(), compressed_data.size()); + if (frame_content_size == ZSTD_CONTENTSIZE_ERROR + || (frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN + && frame_content_size > std::numeric_limits::max())) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); + } - const bool content_size_is_known = frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN; - if (content_size_is_known && frame_content_size > effective_max_decompressed_size) { - return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); - } + const bool content_size_is_known = frame_content_size != ZSTD_CONTENTSIZE_UNKNOWN; + if (content_size_is_known && frame_content_size > effective_max_decompressed_size) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } - const std::size_t allocation_size = content_size_is_known - ? static_cast(frame_content_size) - : effective_max_decompressed_size; - Bytes uncompressed_data(allocation_size); - const std::size_t decompressed_size = ZSTD_decompress( - uncompressed_data.data(), - uncompressed_data.size(), - compressed_data.data(), - compressed_data.size()); - if (ZSTD_isError(decompressed_size)) { - if (ZSTD_getErrorCode(decompressed_size) == ZSTD_error_checksum_wrong) { - return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + const std::size_t allocation_size = content_size_is_known + ? static_cast(frame_content_size) + : effective_max_decompressed_size; + uncompressed_data.resize(allocation_size); + const std::size_t decompressed_size = ZSTD_decompress( + uncompressed_data.data(), + uncompressed_data.size(), + compressed_data.data(), + compressed_data.size()); + if (ZSTD_isError(decompressed_size)) { + if (ZSTD_getErrorCode(decompressed_size) == ZSTD_error_checksum_wrong) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } + if (!content_size_is_known + && ZSTD_getErrorCode(decompressed_size) == ZSTD_error_dstSize_tooSmall) { + return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + } + return std::unexpected(Error{ErrorCode::DecompressionFailed}); } - if (!content_size_is_known - && ZSTD_getErrorCode(decompressed_size) == ZSTD_error_dstSize_tooSmall) { - return std::unexpected(Error{ErrorCode::SizeLimitExceeded}); + if (content_size_is_known && decompressed_size != uncompressed_data.size()) { + return std::unexpected(Error{ErrorCode::DecompressionFailed}); } - return std::unexpected(Error{ErrorCode::DecompressionFailed}); - } - if (content_size_is_known && decompressed_size != uncompressed_data.size()) { - return std::unexpected(Error{ErrorCode::DecompressionFailed}); + + uncompressed_data.resize(decompressed_size); } - uncompressed_data.resize(decompressed_size); + if (checksum_algorithm == ChecksumAlgorithm::Crc32c + && crc32c_checksum(uncompressed_data) != checksum) { + return std::unexpected(Error{ErrorCode::ChecksumMismatch}); + } return uncompressed_data; } diff --git a/src/terrainlib/io/compression.h b/src/terrainlib/io/compression.h index acaca8d1..104f9edd 100644 --- a/src/terrainlib/io/compression.h +++ b/src/terrainlib/io/compression.h @@ -18,6 +18,7 @@ inline constexpr std::size_t default_max_decompressed_size = std::size_t{1} << 3 enum class ChecksumAlgorithm : std::uint8_t { None, HandledByCompressionLib, + Crc32c, }; enum class CompressionAlgorithm : std::uint8_t { diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index a8b82273..109f2b1b 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace { @@ -132,6 +133,16 @@ void check_error(const Result &result, const io::envelope::ErrorCode expected) CHECK(result.error().code == expected); } +io::envelope::Bytes bytes_from_string(const std::string_view text) +{ + io::envelope::Bytes bytes; + bytes.reserve(text.size()); + for (const char character : text) { + bytes.push_back(static_cast(static_cast(character))); + } + return bytes; +} + } // namespace TEST_CASE("Envelope round trips the latest payload version") @@ -221,6 +232,158 @@ TEST_CASE("Envelope supports uncompressed data without a checksum") CHECK(*result == expected); } +TEST_CASE("CRC-32C uses its canonical hexadecimal representation") +{ + SECTION("standard test vector") + { + const auto compressed = io::envelope::compress_with_checksum( + bytes_from_string("123456789"), + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(compressed.has_value()); + CHECK(compressed->checksum == "e3069283"); + } + + SECTION("empty input") + { + const auto compressed = io::envelope::compress_with_checksum( + {}, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(compressed.has_value()); + CHECK(compressed->checksum == "00000000"); + } +} + +TEST_CASE("CRC-32C protects independently compressed data") +{ + const auto original = bytes_from_string("payload protected by CRC-32C"); + + SECTION("without compression") + { + const auto compressed = io::envelope::compress_with_checksum( + original, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(compressed.has_value()); + + const auto result = io::envelope::checked_decompress( + compressed->compressed_data, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c, + compressed->checksum); + REQUIRE(result.has_value()); + CHECK(*result == original); + + auto corrupted = compressed->compressed_data; + corrupted.front() ^= std::byte{0x01}; + check_error( + io::envelope::checked_decompress( + corrupted, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c, + compressed->checksum), + io::envelope::ErrorCode::ChecksumMismatch); + } + + SECTION("with zstd compression") + { + const auto compressed = io::envelope::compress_with_checksum( + original, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(compressed.has_value()); + REQUIRE(compressed->checksum.size() == 8); + + const auto result = io::envelope::checked_decompress( + compressed->compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::Crc32c, + compressed->checksum); + REQUIRE(result.has_value()); + CHECK(*result == original); + + auto wrong_checksum = compressed->checksum; + wrong_checksum.front() = wrong_checksum.front() == '0' ? '1' : '0'; + check_error( + io::envelope::checked_decompress( + compressed->compressed_data, + io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, + io::envelope::ChecksumAlgorithm::Crc32c, + wrong_checksum), + io::envelope::ErrorCode::ChecksumMismatch); + } + + SECTION("malformed checksum") + { + check_error( + io::envelope::checked_decompress( + original, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c, + "E3069283"), + io::envelope::ErrorCode::ChecksumMismatch); + check_error( + io::envelope::checked_decompress( + original, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c, + {}), + io::envelope::ErrorCode::ChecksumMismatch); + } +} + +TEST_CASE("Envelope round trips and upgrades payloads protected by CRC-32C") +{ + SECTION("latest version") + { + const v3::Payload expected{ + .id = 12, + .label = "crc", + .enabled = true, + .samples = {3, 5, 8}, + }; + const auto bytes = io::envelope::serialize( + expected, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(bytes.has_value()); + + const auto envelope = decode_envelope(*bytes); + CHECK(envelope.checksum_algorithm == io::envelope::ChecksumAlgorithm::Crc32c); + CHECK(envelope.checksum.size() == 8); + CHECK(envelope.compression_algorithm == io::envelope::CompressionAlgorithm::None); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == expected); + + auto corrupted = envelope; + corrupted.compressed_data.front() ^= std::byte{0x01}; + check_error(io::envelope::deserialize(encode_envelope(corrupted)), + io::envelope::ErrorCode::ChecksumMismatch); + } + + SECTION("older version") + { + const v1::Payload original{.id = 13, .name = "crc version one"}; + const auto bytes = io::envelope::serialize( + original, + io::envelope::CompressionAlgorithm::None, + io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(bytes.has_value()); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == v3::Payload{ + .id = 13, + .label = "crc version one", + .enabled = true, + .samples = {}, + }); + } +} + TEST_CASE("Checked compression round trips and validates its checksum") { const io::envelope::Bytes original(4096, std::byte{0x2a}); @@ -359,6 +522,24 @@ TEST_CASE("Envelope rejects incompatible metadata") io::envelope::ErrorCode::InvalidAlgorithmCombination); } + SECTION("malformed CRC-32C checksum") + { + auto envelope = decode_envelope(*serialized); + envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::Crc32c; + envelope.checksum = "1234"; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::ChecksumMismatch); + } + + SECTION("incorrect CRC-32C checksum") + { + auto envelope = decode_envelope(*serialized); + envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::Crc32c; + envelope.checksum = "00000000"; + check_error(io::envelope::deserialize(encode_envelope(envelope)), + io::envelope::ErrorCode::ChecksumMismatch); + } + SECTION("uncompressed size is smaller than the payload") { auto envelope = decode_envelope(*serialized); From b424e204a4fddd1db02ebebc6f29aa11404cc182 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:01:39 +0200 Subject: [PATCH 41/53] update plan with new DAG code --- docs/raster-store/refactor-plan.md | 196 ++++++++++++++++++++--------- docs/raster-store/todo.md | 6 - 2 files changed, 139 insertions(+), 63 deletions(-) diff --git a/docs/raster-store/refactor-plan.md b/docs/raster-store/refactor-plan.md index 379fb7a6..f783e5d0 100644 --- a/docs/raster-store/refactor-plan.md +++ b/docs/raster-store/refactor-plan.md @@ -21,9 +21,9 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. `src/terrainlib/sf` and use the `sf` namespace. - Existing 3D Structura Fundamentalis datasets must remain readable and writable without changing their on-disk contract. -- Existing DAG datasets stored in the 3D hierarchy with `.bin` payloads must - also remain readable and writable without changing their index or payload - serialization. +- DAG datasets written by the current code when Phase 0 begins must remain + readable and writable throughout the refactor without changing their index + or payload serialization. Older DAG payload schemas are not supported. - `Inner` is a valid shared topology state and is supported by DAG, raster-fundamentalis, and tile-base datasets. It is not valid in Structura Fundamentalis datasets. @@ -49,6 +49,8 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. `std::expected`; unsupported operations and other operational failures are reported as error values. Reading and writing must be reentrant (callable concurrently from different threads). +- The runtime glTF codec catches exceptions from the existing glTF mesh writer + and converts them to `CodecError`; changing the mesh I/O API is out of scope. - `store::RawStorage` exclusively owns the configured `std::unique_ptr>`. `store::Storage` owns the raw storage and therefore owns the codec transitively. Storage consumers do not @@ -58,6 +60,9 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. and does not change their concurrency guarantees. Existing caller-side synchronization and concurrency behaviour are preserved; any concurrency bug fix is separate work. +- Preserve `dag::ThreadSafeStorage` as the caller-side synchronization around + DAG output storage. DAG storage remains cacheless while shared-lock reads are + used. - Legacy index metadata selects a codec through an explicit, caller-supplied resolver supplied by the payload-domain opening function. The octree format adapter does not contain a global codec registry or depend on mesh or DAG @@ -125,7 +130,7 @@ Persistent raster-fundamentalis formats, adapters, and tools are later work. - Changing the existing 3D hard-link policy by adding a silent file-copy fallback. - Refactoring unrelated octree, DAG, mesh, or tile-builder code. -- Changing the serialized schema of existing DAG `.bin` payloads. +- Changing the serialized schema of the current DAG `.bin` payloads. ## Compatibility contract @@ -147,15 +152,16 @@ Before moving code, tests must lock down the following 3D behaviour: | Layout detection | both existing layouts remain detectable | | Mesh codec selection | legacy preferred extension selects terrain or configured glTF codec | | DAG codec selection | legacy `.bin` preferred extension selects the ZPP Bits codec | -| DAG payload encoding | existing `dag::ClusterBatch` ZPP Bits serialization | +| DAG payload encoding | current `dag::ClusterBatch` ZPP Bits serialization: metadata, then clustering | | Equal codec path lists | hard-link every file, or report an explicit error | | Different codec path lists | decode with input codec and encode with output codec | | Overwrite setting | `StorageSettings::allow_overwrite`, default `false`; enabled writes replace existing payloads | -Compatibility means that the refactored code can open datasets written before -the refactor. Pre-refactor readers are not tested against post-refactor outpus. Exact +Compatibility means that each refactor phase can open the Phase 0 fixtures +written by the current code. DAG formats older than the Phase 0 baseline and +pre-refactor readers opening post-refactor output are not tested. Exact byte-for-byte rewriting of an unordered index map is not required, but the -serialized schema and values must remain compatible. +serialized schema and values must remain compatible during the migration. The 3D index disk type should remain a versioned 3D adapter. The shared store must not add a topology field, new header, checksum, or compression layer to @@ -244,11 +250,19 @@ extensions are recorded in [todo.md](todo.md). Temporary forwarding headers and aliases under `octree` are allowed during migration. They must not contain a second implementation. -DAG serialization remains owned by `dag_builder`. Consolidate the existing -`dag::Id`, `dag::ClusterBatch`, `Clustering`, `Cluster`, and `TextureSet` -serialization functions in `src/dag_builder/serialization.h`. The DAG storage -adapter includes that header explicitly so template instantiation does not -depend on callers including `encoded.h` in the correct order. +DAG serialization remains owned by `dag_builder`. Consolidate the serializers +for `dag::Id`, `dag::Group`, `dag::NodeMetadata`, `dag::ClusterBatch`, +`radix::geometry::Aabb3d`, GLM vectors, `Clustering`, `Cluster`, and +`TextureSet` in `src/dag_builder/serialization.h`. The DAG storage adapter +includes that header explicitly so template instantiation does not depend on +caller include order. Preserve the current tuples exactly: + +- `ClusterBatch`: metadata, clustering; +- `NodeMetadata`: group assignment, groups; and +- `Group`: children, error, bounds. + +`Group::child_errors` is currently neither populated nor serialized. It remains +non-persistent in this refactor and the Phase 0 fixture locks down its omission. ## Shared interfaces @@ -312,6 +326,20 @@ Keys produced internally by `Traits::root()`, `Traits::parent()`, and they preserve validity. The child order affects traversal order and is locked down by the 2D and 3D trait tests; it is not serialized as separate metadata. +The API result shapes are: + +- index lookup returns `expected, InvalidKey>`; +- index mutation and predicates return `expected>`; +- traversal returns `expected>`; and +- storage `load`, `save`, `has`, `remove`, path lookup, and `copy_from` return + operation-specific `expected` types which retain `InvalidKey` and any + applicable codec, filesystem, missing-source, or overwrite error. + +During Phase 1, a forwarding `octree::IndexMap` compatibility wrapper preserves +the old optional/bool API for existing 3D callers with already-valid +`octree::Id` values. It delegates to `store::Index`, is not +a second implementation, and is removed after callers migrate. + ### Sparse index and traversal Move the existing four-state model to: @@ -486,6 +514,11 @@ store::cache::Interface ownership. Moving storage transfers ownership. Caches, layouts, index formats, resolvers, and application consumers never own the codec. +A move disarms the source so its destructor cannot save moved-out index state. +Move assignment must finalize a displaced dirty destination through the +existing destructor-save policy rather than silently discard it. Tests cover +the DAG builder's move into and release from `dag::ThreadSafeStorage`. + Domain-specific mesh codecs remain under `mesh::codec`. The reusable ZPP Bits codec remains under `store::codec`. RF codecs are deferred. @@ -555,6 +588,9 @@ mesh::codec::from_extension dag::codec::from_extension .bin -> store::codec::ZppBits + +dag::codec::metadata_from_extension + .bin -> read-only dag::codec::MetadataView ``` An unknown extension returns an explicit `UnsupportedCodec` error. Opening a @@ -563,7 +599,12 @@ constructed codec at the payload-domain opening boundary; the generic storage does not infer persistent metadata from `Codec::paths()`. Mesh and DAG convenience functions select or resolve the codec and pass ownership into raw storage, so application storage consumers do not handle codec objects. -Convenience functions in `src/dag_builder/storage.h` supply the DAG resolver. +Convenience functions in `src/dag_builder/storage.h` supply the DAG resolvers. +Preserve `DagMetaStorage` and `IndexedDagMetaStorage` as independently readable +views of the metadata prefix in each `ClusterBatch` `.bin` file. Their codec +returns `UnsupportedOperation` from writes instead of terminating or replacing +a full batch with metadata alone. `dag::codec::MetadataView` implements this +domain-specific read-only prefix view over the generic ZPP Bits codec. Opening functions return their requested storage type through `std::expected<..., OpenError>`. `OpenError` is a typed sum which retains the @@ -578,13 +619,18 @@ failing path and the underlying error where applicable: Loading and saving likewise return storage-level expected errors which retain an invalid key, an underlying `CodecError`, and `AlreadyExists` for a rejected save. `CopyError` retains invalid-key, missing-source, overwrite, filesystem, -and codec failures. Domain/application boundaries may add context, but must not -discard these errors. +and codec failures. The SF call chains changed in Phase 4 retain these errors +to their application boundary. DAG callers preserve their existing per-node +log-and-continue and command-line policies; making the DAG builder fail-fast is +not part of this refactor. Legacy unindexed-directory discovery remains in the 3D adapter: it recognizes candidate endings by asking the supplied resolver, removes an accepted ending to obtain a `NodePath`, and then invokes the selected layout parser. The generic layout does not recover keys directly from codec-owned file paths. +Only a missing index triggers this discovery path. An unreadable or malformed +index, unknown layout, or unsupported codec selector returns `OpenError` and +must not silently fall back to directory discovery. Automatic dirty-index saving currently happens in the 3D storage destructor. Preserve that behaviour for existing 3D entry points during the migration. @@ -635,14 +681,15 @@ The dummy path must be fixed and collision-free, for example `__codec_probe__/node`. Path lists are compared exactly, including count, order, and filename endings. -If linking several files fails partway through, return the error without a -transactional rollback guarantee; target links already created by the call -may remain. The copy operation stops immediately, leaves the target index -unchanged for that logical node, and propagates the failure until the -application aborts the overall operation. There is no journal, rollback, -cleanup guarantee, or silent copy fallback. An unsupported read or write needed -for re-encoding is returned through `CopyError`, retaining the underlying -`CodecError`. +If overwrite is enabled for an indexed target, remove the target index entry +immediately before the first target file is modified. If linking several files +then fails partway through, return the error without a transactional rollback +guarantee; target links or old files may remain, but the logical node stays +unindexed. The copy operation stops immediately and propagates the failure +until the application aborts the overall operation. There is no journal, +rollback, cleanup guarantee, or silent copy fallback. An unsupported read or +write needed for re-encoding is returned through `CopyError`, retaining the +underlying `CodecError`. Hard-link rules: @@ -731,7 +778,10 @@ No production behaviour changes. - index entries containing `Leaf` and `Virtual`, but no `Inner`. 2. Add one golden DAG dataset whose index selects `.bin`, whose payload contains a valid serialized `dag::ClusterBatch`, and whose index contains - `Leaf`, `Virtual`, and `Inner`. + `Leaf`, `Virtual`, and `Inner`. Use the current metadata-then-clustering + format, include non-trivial group metadata, and prove that the same file can + be opened through the read-only `NodeMetadata` view. Lock down that + `Group::child_errors` is not serialized. 3. Test that all fixtures open, resolve the expected IDs and extensions, and traverse the expected sparse nodes. 4. Add path round-trip tests for boundary IDs and both layouts. @@ -742,7 +792,8 @@ No production behaviour changes. - indexed and unindexed opens; and - final index creation by directory scan. 6. Record the pre-refactor public aliases used by `sf_builder`, `sf_merger`, - `sf_index_browser`, `dag_builder`, and `dag_convert_debug`. + `sf_index_browser`, `dag_builder`, and `dag_convert_debug`, including + `DagMetaStorage`, `IndexedDagMetaStorage`, and `dag::ThreadSafeStorage`. Exit criterion: the compatibility tests pass against the untouched implementation and fail when any stable filename, layout ID, path encoding, @@ -760,7 +811,8 @@ changed. 6. Run the same index-transition and DFS/BFS tests with both trait types, including deterministic child order, invalid-key errors through `std::expected`, maximum-depth children, and explicit traversal roots. -7. Provide temporary `octree` aliases so downstream migration is separate where this makes sense, otherwise migrate downstream immediately. +7. Provide the temporary forwarding `octree::IndexMap` wrapper and aliases so + downstream migration is separate; otherwise migrate downstream immediately. Exit criterion: 2D and 3D keys pass the same topology suite; existing 3D callers still build through aliases; no filesystem code has changed. @@ -785,6 +837,7 @@ layout-plus-codec path construction in one step. 5. Add the terrain codec and one glTF codec configured for binary `.glb` or JSON `.gltf`. Keep the current extension-dispatching `octree::MeshCodec` only as temporary production compatibility glue until the Phase 3 cutover. + Test that glTF writer exceptions become `CodecError` values. 6. Port the two existing 3D layouts to ordinary function pairs without changing stable IDs. The mappings return `level-index` and `level/x/y/z` without file endings. @@ -796,7 +849,9 @@ layout-plus-codec path construction in one step. paths. 9. Add focused codec tests using single-file, multi-file, read/write, and write-only test codecs. Test stable path ordering, unsupported operations, - directory creation, and conversion of domain errors to `CodecError`. + directory creation, conversion of domain errors to `CodecError`, and + concurrent reads and writes. Exercise reentrancy of every production codec + used by the parallel DAG builder. Exit criterion: the extensionless mappings and runtime codecs together resolve all Phase 0 fixtures to their existing physical payload paths; generic @@ -826,27 +881,31 @@ path. `mesh::codec`. 8. Add the function-pointer-based `IndexFormat`, `IndexMetadata`, and typed format/open errors. Split generic index maintenance from 3D index - serialization and legacy folder discovery. + serialization and legacy folder discovery. Test that only a missing index + starts discovery; all other index errors are returned. 9. Keep the current 3D `terrain.index` DTO and open functions as compatibility adapters over the shared storage. Retain its exact preferred extension as `codec_selector`, resolve it through the payload-domain mesh or DAG resolver, and retain the format metadata required by automatic saving. -10. Add DAG storage convenience functions that supply - `dag::codec::from_extension`, and mesh storage convenience functions that - select the configured terrain or glTF codec. Migrate `dag_builder`, - `dag_convert_debug`, and mesh storage consumers without exposing codec - objects at application call sites. +10. Add DAG storage convenience functions that supply the writable batch and + read-only metadata resolvers, and mesh storage convenience functions that + select the configured terrain or glTF codec. Preserve the DAG metadata + storage aliases. Migrate `dag_builder`, `dag_convert_debug`, and mesh + storage consumers without exposing codec objects at application call sites. 11. Migrate the existing octree storage aliases and all other application - callers. + callers, including adapting `dag::ThreadSafeStorage` to the new key and + expected-returning APIs while preserving its shared/exclusive locking. 12. Preserve the current 3D destructor-save behaviour until all callers have - explicit index finalization. + explicit index finalization. Test move construction, move assignment over + dirty state, moved-from destruction, and the DAG move/release path. 13. Preserve `StorageSettings::allow_overwrite`, including the DAG builder's overwrite mode and repeat debug export. Replace process termination on a rejected overwrite with `AlreadyExists` in the storage-level expected error. Test that a rejected save returns `AlreadyExists` and that enabling overwrite replaces the existing payload. -14. Add resolver tests for `.terrain`, `.glb`, `.gltf`, and `.bin` open/read - dispatch, plus explicit failure for an unknown preferred extension. +14. Add resolver tests for `.terrain`, `.glb`, `.gltf`, writable + `ClusterBatch` `.bin`, and read-only `NodeMetadata` `.bin` dispatch, plus + explicit failure for an unknown preferred extension and metadata writes. 15. Instantiate the shared storage tests with `raster_store::StoreTraits` using a test-only path mapping and codec. This proves the storage templates contain no hidden `octree::Id` dependency without defining a stable RF @@ -855,15 +914,15 @@ path. classes, static codec concept and codecs, and their temporary compatibility glue once no call site uses them. -Test that a pre-refactor DAG fixture opens through the new resolver and that a -new deterministic `.bin` payload matches the Phase 0 golden bytes and remains -readable through the unchanged ZPP serialization functions. Test unknown -layout IDs, malformed index metadata, invalid hierarchy keys, and retained -underlying open/codec errors through `std::expected`. +Test that the Phase 0 DAG fixture opens through both new resolvers and that a +new deterministic `.bin` payload matches its golden bytes and remains readable +through the unchanged ZPP serialization functions. Test unknown layout IDs, +malformed index metadata, invalid hierarchy keys, and retained underlying +open/codec errors through `std::expected`. Exit criterion: all existing applications build and all Phase 0 fixtures pass -through the shared runtime codec and storage implementation. Existing DAG -payload bytes and `.bin` paths remain compatible. No extension-dispatching +through the shared runtime codec and storage implementation. Phase 0 DAG +payload bytes and `.bin` paths remain unchanged. No extension-dispatching mesh codec, layout inheritance, RTTI lookup, static registrar, owning strategy pointer, or second storage implementation remains under `octree`. @@ -881,6 +940,8 @@ pointer, or second storage implementation remains under `octree`. - multi-file hard linking; - different path counts and endings; - error propagation after a partially failed multi-file hard link; + - overwrite failure leaving an existing logical node unindexed even when + old or partial files remain; - conversion between terrain and glTF; - `copy_from()` overwrite rejection; - `copy_from()` overwrite-enabled replacement; and @@ -895,6 +956,9 @@ pointer, or second storage implementation remains under `octree`. `sf::InvalidTopology` to the command line, and report that the output is invalid. Do not apply the validator when opening DAG datasets, through generic octree/store adapters, or in the diagnostic `sf_index_browser`. + Extract an SF-builder finalization boundary into `sfbuilderlib` which writes + the index, validates it, and returns the typed result so output validation + and command-line propagation are directly testable. 7. Keep SF recursion, subtree traversal, and mesh policy in `sf_merger`. Change its subtree and cut call chains to propagate validation and `copy_from()` failures through `std::expected` to the application boundary. @@ -936,6 +1000,18 @@ walker has been introduced. Exit criterion: repository search finds no generic implementation tied to `octree::Id`; all tests pass; the old layout strategy hierarchy is gone. +### Phase 6 — Remove migration-only compatibility fixtures + +1. Delete the Phase 0 golden SF and DAG datasets and the tests whose only + purpose is opening or byte-comparing data written before the completed + refactor. +2. Keep the format round-trip, path, resolver, topology, storage, error, and + application integration tests which exercise the final implementation + without pre-refactor fixture files. + +Exit criterion: no pre-refactor dataset fixture remains, and the retained test +suite passes against data produced by the final implementation. + ## Test and verification plan Generic store tests should live in the existing `unittests_terrainlib` target. @@ -947,13 +1023,14 @@ unittests/terrainlib/store_traverse.cpp unittests/terrainlib/store_layout.cpp unittests/terrainlib/store_codec.cpp unittests/terrainlib/store_storage.cpp -unittests/terrainlib/store_compatibility.cpp +unittests/terrainlib/store_compatibility.cpp # temporary through Phase 5 unittests/terrainlib/sf_validate_index.cpp ``` -The DAG payload-compatibility fixture and resolver integration test belong in -`unittests_dagbuilder`, because `terrainlib` must not depend on -`dag::ClusterBatch` or its serializers. +The temporary DAG payload-compatibility fixture and resolver integration test +belong in `unittests_dagbuilder`, because `terrainlib` must not depend on +`dag::ClusterBatch` or its serializers. Phase 6 removes the fixture and its +byte-comparison test while retaining resolver tests built from current data. The validator's unit tests belong in `unittests_terrainlib`. Boundary tests belong with their consumers: SF-builder output validation in @@ -973,8 +1050,11 @@ During implementation: 4. Run the full `unittests_terrainlib` target at every phase boundary. 5. Run `unittests_dagbuilder` after the ZPP codec, DAG serialization header, or DAG resolver changes. -6. Build `sf_builder`, `sf_merger`, `sf_index_browser`, `dag_builder`, and - `dag_convert_debug` after their storage aliases move. +6. Configure with `ALP_BUILD_SF_BUILDER`, `ALP_BUILD_SF_MERGER`, + `ALP_BUILD_SF_INDEX_BROWSER`, `ALP_BUILD_DAG_BUILDER`, and + `ALP_BUILD_DAG_CONVERT_DEBUG` enabled, then build `sf-builder`, `sf-merger`, + `sf-index-browser`, `dag-builder`, and `dag-convert-debug` after their + storage aliases move. 7. Run `unittests_sfbuilder`, `unittests_sfmerger`, and `unittests_dagbuilder`, plus any existing merger integration fixture, after the Phase 4 validation changes. @@ -1000,14 +1080,16 @@ No formatting-only pass or unrelated refactor belongs in these commits. | `octree/storage/codec/Codec.h` | runtime `store/Codec.h` | | `octree/storage/codec/DefaultCodec.h` | runtime `store/codec/ZppBits.h` | | `octree/storage/codec/MeshCodec.h` | `mesh/codec/Terrain.h` and configured `mesh/codec/Gltf.h` | +| `octree/storage/codec/ReadOnlyCodec.h` metadata specialization | `dag::codec::MetadataView` | | `octree/storage/RawStorage.h` | `store/RawStorage.h` | | `octree/storage/Storage.h` | `store/Storage.h` | | `octree/storage/IndexedStorage.h` | `store/IndexedStorage.h` | | `octree::StorageSettings::allow_overwrite` | `store::StorageSettings::allow_overwrite`, preserving default and enabled behaviour | | `octree/storage/helpers.*` | generic scan helpers plus 3D format adapter | | `octree/disk/IndexFile.h` | versioned 3D format adapter under `octree` | -| DAG serializers in `dag_node.h` and `encoded.h` | `dag_builder/serialization.h` | -| `dag_builder/storage.h` aliases | DAG storage aliases plus codec resolver convenience functions | +| DAG serializers in `dag_node.h`, `dag_id.h`, `metadata.h`, `encoded.h`, and `zpp_bits_glm.h` | `dag_builder/serialization.h` | +| `dag_builder/storage.h` aliases | Batch and read-only metadata storage aliases plus codec resolver convenience functions | +| `dag_builder/thread_safe_storage.h` | Adapted caller-side wrapper over shared storage | | `sf_merger::NodeWriter` subtree loop | remains in `sf_merger`; return copy failures through `std::expected` | | `sf_merger::NodeWriter` auxiliary `.png` write | remains an unmanaged, application-local debug artifact | | `sf_merger::cut_leaf_node()` copy path | remains in `sf_merger`; return copy failures through `std::expected` | @@ -1018,7 +1100,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Risk | Control | |---|---| | Existing indexes stop loading | Golden pre-refactor fixtures and unchanged 3D DTO | -| Existing DAG `.bin` datasets stop loading | Golden DAG fixture, unchanged serializers, and explicit `.bin` resolver | +| The refactor changes current DAG `.bin` bytes | Temporary golden DAG fixture, unchanged serializers, and explicit `.bin` resolvers | | Octree format adapter gains DAG dependencies | Caller-supplied resolver owned by `dag_builder` | | Unknown legacy extension silently selects the wrong codec | Return an explicit `UnsupportedCodec` error | | Valid legacy paths are parsed differently | Characterization and round-trip tests before replacement | @@ -1028,7 +1110,7 @@ No formatting-only pass or unrelated refactor belongs in these commits. | Invalid `Inner` nodes reach SF merge dispatch | Validate every SF input first and return the offending key in a typed error | | SF subtree copying is generalized before RF requirements exist | Keep it in `sf_merger`; reconsider extraction with `rf_merger` | | A paired-walker API is fixed before RF semantics are known | Defer its action algebra until `rf_merger` requirements are defined | -| Multi-file hard linking fails partway through | Stop immediately, leave the node unindexed, propagate the error, and abort the overall operation; partial files may remain | +| Multi-file hard linking fails partway through | Remove any existing index entry before modifying target files, stop immediately, leave the node unindexed, propagate the error, and abort the overall operation; old or partial files may remain | | Incompatible codecs return the same path list | Treat path-list equality as a codec contract and test every concrete codec pairing | | Output-only codec is selected for required input | Return a clear `UnsupportedOperation` error | | Shared code accumulates mesh or provisional RF policy | Dependency tests/review against the source boundary | @@ -1044,8 +1126,8 @@ final here. A later RF design phase must resolve and test at least: -- the persistent index filename, schema, versioning, and serialization - envelope; +- the persistent index filename and schema, and how they use the existing + generic serialization envelope and versioning support; - persistent tile keys, coordinate convention, layout IDs, and node paths; - tile and source-attribution payload formats and codecs; - snapshot construction, validation, publication, and crash expectations; diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index fecdfba6..fe9bf14a 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -3,9 +3,3 @@ - Consider extending `sf::validate_index()` beyond rejecting `Inner` after additional SF invariants and their required error reporting are defined. This is not part of the shared-store refactor. -- Resolve the existing glTF write exception path before implementing the - runtime codec error contract. `mesh::io::gltf::save_to_path()` currently - throws when `cgltf_write_file()` fails, while codec operational failures are - intended to be returned through `std::expected`. Decide whether mesh I/O - should return a typed error or the glTF codec should catch and translate the - exception, and explicitly confirm whether changing mesh I/O is in scope. From cfe96d9aef138d14fce67c04608ed8509226126a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:12:01 +0200 Subject: [PATCH 42/53] Update Radix dependency --- src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d8488333..0cdac5df 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -69,7 +69,7 @@ set(LIBIGL_COPYLEFT_CORE ON) set(LIBIGL_RESTRICTED_TRIANGLE ON) alp_add_git_repository(libigl URL https://github.com/libigl/libigl.git COMMITISH v2.6.0) -alp_add_git_repository(radix URL https://github.com/AlpineMapsOrg/radix.git COMMITISH 2ce3484513b826bb11e5433f868b048eaffe3e5d NOT_SYSTEM) +alp_add_git_repository(radix URL https://github.com/AlpineMapsOrg/radix.git COMMITISH 6babc632401cb95a701ce431cb68adf91e2c1b71 NOT_SYSTEM) set(ALP_CLI_COMPONENT_OPTIONS ALP_BUILD_SF_MERGER From 2c133b82929b502d710d9ed516738b06f9b984b0 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:44:41 +0200 Subject: [PATCH 43/53] Disable GEOS developer warnings --- cmake/SetupGDAL.cmake | 1 + 1 file changed, 1 insertion(+) diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index 6be25b74..fdc85178 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -37,6 +37,7 @@ function(alp_setup_gdal) CMAKE_ARGUMENTS -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF + -DGEOS_BUILD_DEVELOPER=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON ) From 6660990788e950aff36b9a3e94a404d2a520da52 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:29:49 +0200 Subject: [PATCH 44/53] Handle storage write failures --- src/dag_builder/build.cpp | 5 ++++- src/sf_merger/cut.h | 5 ++++- src/sf_merger/merge.h | 5 ++++- src/terrainlib/octree/storage/codec/ReadOnlyCodec.h | 5 ++--- src/terrainlib/octree/storage/open.inl | 5 ++++- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index c7e453be..a176aa28 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -529,7 +529,10 @@ std::unordered_set build_level( const auto save_result = ctx.output_storage.save(target, *result); if (save_result) { if (debug_storage) { - debug_storage->save(target, clustering_to_mesh(result->clustering)); + const auto debug_save_result = debug_storage->save(target, clustering_to_mesh(result->clustering)); + if (!debug_save_result.has_value()) { + LOG_ERROR_AND_EXIT("Failed to save debug mesh for node {}: {}", target, debug_save_result.error()); + } } saved_ids.push_back(target); } else { diff --git a/src/sf_merger/cut.h b/src/sf_merger/cut.h index b33d0a37..411d47ce 100644 --- a/src/sf_merger/cut.h +++ b/src/sf_merger/cut.h @@ -114,7 +114,10 @@ inline void cut_dataset( const bool keep_inside) { Context ctx(input, output, octree::Space::earth(), keep_inside); cut_node(ctx, octree::Id::root(), mask); - output.save_or_create_index(); + const auto index_result = output.save_or_create_index(); + if (!index_result.has_value()) { + LOG_ERROR_AND_EXIT("Failed to save output index in {}: {}", output.base_path(), index_result.error()); + } } inline void cut_dataset( diff --git a/src/sf_merger/merge.h b/src/sf_merger/merge.h index ea3a1b18..180dbb59 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/merge.h @@ -177,5 +177,8 @@ inline void merge_datasets( merger.merge_root(); } - output_dataset.save_or_create_index(); + const auto index_result = output_dataset.save_or_create_index(); + if (!index_result.has_value()) { + LOG_ERROR_AND_EXIT("Failed to save output index in {}: {}", output_dataset.base_path(), index_result.error()); + } } diff --git a/src/terrainlib/octree/storage/codec/ReadOnlyCodec.h b/src/terrainlib/octree/storage/codec/ReadOnlyCodec.h index 2d397cb2..3ea68ba9 100644 --- a/src/terrainlib/octree/storage/codec/ReadOnlyCodec.h +++ b/src/terrainlib/octree/storage/codec/ReadOnlyCodec.h @@ -1,9 +1,8 @@ #pragma once +#include #include -#include - #include "log.h" namespace octree { @@ -12,7 +11,7 @@ namespace octree { // read-only (e.g. a view that aliases another storage's files). template struct ReadOnlyCodec : Codec { - static tl::expected save_to_path( + static std::expected save_to_path( const typename Codec::value_type &, const std::filesystem::path &) noexcept { LOG_ERROR_AND_EXIT("Attempted to write through a read-only codec"); } diff --git a/src/terrainlib/octree/storage/open.inl b/src/terrainlib/octree/storage/open.inl index 473308c2..c60c3699 100644 --- a/src/terrainlib/octree/storage/open.inl +++ b/src/terrainlib/octree/storage/open.inl @@ -93,7 +93,10 @@ Storage_ open_folder( IndexMap map; helpers::update_index_map(map, layout); if (!map.empty()) { - helpers::save_index_map(map, layout); + const auto save_result = helpers::save_index_map(map, layout); + if (!save_result.has_value()) { + LOG_ERROR_AND_EXIT("Failed to create storage index in {}: {}", base_path, save_result.error()); + } } return Storage_(RawStorage_(std::move(layout)), std::move(map)); } From 3d47287ff610e5b6b20090cfef114dbebcdf20a5 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:31:19 +0200 Subject: [PATCH 45/53] Cancel parallel terrain builds after write failures --- src/sf_builder/terrainbuilder.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 3e7bde4b..2e30b2b4 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -275,6 +275,7 @@ void build_all_patches( logger->set_level(new_level); } + tbb::task_group_context context; tbb::parallel_for(size_t(0), target_nodes.size(), [&](size_t i) { const auto &node = target_nodes[i]; if (!overwrite_existing && storage.has(node)) { @@ -299,16 +300,33 @@ void build_all_patches( if (mesh_result.has_value()) { const auto mesh = std::move(mesh_result.value()); mesh::validate(mesh); - storage.save(node, mesh); + const auto save_result = storage.save(node, mesh); + if (!save_result.has_value()) { + LOG_ERROR("Failed to save mesh for node {}: {}", node, save_result.error()); + progress.task_finished(); + context.cancel_group_execution(); + return; + } } progress.task_finished(); - }); + }, context); // Restore original level logger->set_level(original_level); + if (context.is_group_execution_cancelled()) { + progress_thread.request_stop(); + } progress_thread.join(); - storage.save_or_create_index(); + + if (context.is_group_execution_cancelled()) { + LOG_ERROR_AND_EXIT("Failed to build all terrain patches"); + } + + const auto index_result = storage.save_or_create_index(); + if (!index_result.has_value()) { + LOG_ERROR_AND_EXIT("Failed to save output index in {}: {}", storage.base_path(), index_result.error()); + } } } From f999a0b6e6162aa8e97010bec961f29b8803d061 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:40:53 +0200 Subject: [PATCH 46/53] Handle DAG index write failure --- src/dag_builder/main.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/dag_builder/main.cpp b/src/dag_builder/main.cpp index e247e119..16a57874 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -39,14 +39,18 @@ int main(int argc, char **argv) { // If the user specified neither target, fall back to a default error. if (!args.target_ratio && !args.target_error) { options.relative_target_error = 0.001f; - } - - dag::build_levels(input_storage, output_storage, options, args.level_range); - output_storage.save_index(); - - return EXIT_SUCCESS; + } + + dag::build_levels(input_storage, output_storage, options, args.level_range); + const auto index_result = output_storage.save_index(); + if (!index_result.has_value()) { + LOG_ERROR("Failed to save output index in {}: {}", args.output_path, index_result.error()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; } catch (const std::exception &e) { LOG_ERROR("{}", e.what()); return EXIT_FAILURE; } -} \ No newline at end of file +} From 47c42a185db9bc03f113be5020bdec43ed6ffc57 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:41:40 +0200 Subject: [PATCH 47/53] Preserve DAG main line endings --- src/dag_builder/main.cpp | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/dag_builder/main.cpp b/src/dag_builder/main.cpp index 16a57874..1827cca5 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -39,18 +39,18 @@ int main(int argc, char **argv) { // If the user specified neither target, fall back to a default error. if (!args.target_ratio && !args.target_error) { options.relative_target_error = 0.001f; - } - - dag::build_levels(input_storage, output_storage, options, args.level_range); - const auto index_result = output_storage.save_index(); - if (!index_result.has_value()) { - LOG_ERROR("Failed to save output index in {}: {}", args.output_path, index_result.error()); - return EXIT_FAILURE; - } - - return EXIT_SUCCESS; + } + + dag::build_levels(input_storage, output_storage, options, args.level_range); + const auto index_result = output_storage.save_index(); + if (!index_result.has_value()) { + LOG_ERROR("Failed to save output index in {}: {}", args.output_path, index_result.error()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; } catch (const std::exception &e) { LOG_ERROR("{}", e.what()); return EXIT_FAILURE; } -} +} From c7b6be231a90d6c2afb6d2a3796c16d42a7704b9 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:51:54 +0200 Subject: [PATCH 48/53] Check mesh fixture writes --- unittests/terrainlib/mesh_io.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/unittests/terrainlib/mesh_io.cpp b/unittests/terrainlib/mesh_io.cpp index cb117366..fd886bac 100644 --- a/unittests/terrainlib/mesh_io.cpp +++ b/unittests/terrainlib/mesh_io.cpp @@ -90,7 +90,7 @@ TEST_CASE("io roundtrip") { std::filesystem::remove(mesh_path); CHECK(!std::filesystem::exists(mesh_path)); - mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}); + REQUIRE(mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}).has_value()); CHECK(std::filesystem::exists(mesh_path)); const std::expected result = mesh::io::load_from_path(mesh_path); @@ -133,7 +133,7 @@ TEST_CASE("io roundtrip high precision") { std::filesystem::remove(mesh_path); CHECK(!std::filesystem::exists(mesh_path)); - mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}); + REQUIRE(mesh::io::save_to_path(mesh, mesh_path, mesh::io::SaveOptions{.texture_format = ".png"}).has_value()); CHECK(std::filesystem::exists(mesh_path)); const std::expected result = mesh::io::load_from_path(mesh_path); @@ -176,7 +176,7 @@ TEST_CASE("io roundtrip no texture") { std::filesystem::remove(mesh_path); CHECK(!std::filesystem::exists(mesh_path)); - mesh::io::save_to_path(mesh, mesh_path); + REQUIRE(mesh::io::save_to_path(mesh, mesh_path).has_value()); CHECK(std::filesystem::exists(mesh_path)); const std::expected result = mesh::io::load_from_path(mesh_path); @@ -213,7 +213,7 @@ TEST_CASE("io roundtrip no texture and uvs") { std::filesystem::remove(mesh_path); CHECK(!std::filesystem::exists(mesh_path)); - mesh::io::save_to_path(mesh, mesh_path); + REQUIRE(mesh::io::save_to_path(mesh, mesh_path).has_value()); CHECK(std::filesystem::exists(mesh_path)); const std::expected result = mesh::io::load_from_path(mesh_path); From 6cac18f5ea2ff863fefd9ca164e9b67218dea7b1 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:58:25 +0200 Subject: [PATCH 49/53] Add repeatable raster-store golden test --- docs/raster-store/golden-e2e.sh | 68 ++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/docs/raster-store/golden-e2e.sh b/docs/raster-store/golden-e2e.sh index 208ecb92..b919938c 100755 --- a/docs/raster-store/golden-e2e.sh +++ b/docs/raster-store/golden-e2e.sh @@ -7,39 +7,47 @@ set -Eeuo pipefail readonly SOURCE_DIR="/home/codex/Documents/alpine-terrain-builder/terrain-builder-raster-store" -readonly BUILD_DIR="${SOURCE_DIR}/build/hfa-vector-srs" +readonly BUILD_DIR="${SOURCE_DIR}/build/golden-e2e" readonly RUN_ROOT="/data/scratch/codex/alpine-terrain-builder-golden-e2e" readonly INPUT_ROOT="${RUN_ROOT}/inputs" readonly REFERENCE_ROOT="${RUN_ROOT}/reference" readonly LOG_ROOT="${RUN_ROOT}/logs" readonly STATE_ROOT="${RUN_ROOT}/state" +readonly RUN_ID="${RUN_ID:-$(date +%Y%m%dT%H%M%S)}" +readonly RUN_LOG_ROOT="${LOG_ROOT}/runs/${RUN_ID}" +readonly TIMINGS_FILE="${RUN_LOG_ROOT}/timings.tsv" readonly SF_BUILDER="${BUILD_DIR}/src/sf_builder/sf-builder" readonly SF_MERGER="${BUILD_DIR}/src/sf_merger/sf-merger" readonly DAG_BUILDER="${BUILD_DIR}/src/dag_builder/dag-builder" -# This VRT is a 4x4 km, native-resolution window into the original HFA dataset. -readonly GS_VRT="${INPUT_ROOT}/grossglockner-gs-4km/grossglockner-gs-4km.vrt" +# These VRTs are identical 4x4 km, native-resolution windows into the GS and +# GT rasters. They are recreated from the raw datasets at the start of a run. +readonly RAW_GS="/data/raw/raster_data/Oe_2020/OeRect_01m_gs_31287.img" +readonly RAW_GT="/data/raw/raster_data/Oe_2020/OeRect_01m_gt_31287.img" +readonly ELEVATION_ROOT="${INPUT_ROOT}/grossglockner-elevation-4km" +readonly GS_VRT="${ELEVATION_ROOT}/grossglockner-gs-4km.vrt" +readonly GT_VRT="${ELEVATION_ROOT}/grossglockner-gt-4km.vrt" readonly BASEMAP_TILES="${INPUT_ROOT}/basemap" readonly GATAKI_TILES="${INPUT_ROOT}/gataki" -# The exact Tirol border exercises the sf-merger's built-in 0.1 m -# topology-preserving mask simplification. -readonly TIROL_MASK="${INPUT_ROOT}/tirol-boundary/benchmark-shape/exact/tirol.shp" +# A 0.01 m simplification preserves the Tirol border to sub-centimetre area +# accuracy while avoiding the exact geometry's pathological merge time. +readonly TIROL_MASK="${INPUT_ROOT}/tirol-boundary/benchmark-shape/0_01m/tirol.shp" readonly SF_ROOT="${REFERENCE_ROOT}/sf" readonly DAG_ROOT="${REFERENCE_ROOT}/dag" -readonly SF_BASEMAP="${SF_ROOT}/grossglockner-gs-basemap-terrain" -readonly SF_GATAKI="${SF_ROOT}/grossglockner-gs-gataki-terrain" -readonly SF_MERGED="${SF_ROOT}/grossglockner-gs-merged-terrain" -readonly DAG_MERGED="${DAG_ROOT}/grossglockner-gs-merged-terrain" +readonly SF_BASEMAP="${SF_ROOT}/grossglockner-basemap-gs-terrain" +readonly SF_GATAKI="${SF_ROOT}/grossglockner-gataki-gt-terrain" +readonly SF_MERGED="${SF_ROOT}/grossglockner-merged-terrain" +readonly DAG_MERGED="${DAG_ROOT}/grossglockner-merged-terrain" readonly SF_TARGET_LEVEL="${SF_TARGET_LEVEL:-15}" readonly SF_THREADS="${SF_THREADS:-12}" readonly MIN_TEXTURE_LEVEL=12 readonly MAX_TEXTURE_LEVEL=19 -mkdir -p "${SF_ROOT}" "${DAG_ROOT}" "${LOG_ROOT}" "${STATE_ROOT}" -exec > >(tee -a "${LOG_ROOT}/golden-e2e.log") 2>&1 +mkdir -p "${SF_ROOT}" "${DAG_ROOT}" "${RUN_LOG_ROOT}" "${STATE_ROOT}" +exec > >(tee -a "${RUN_LOG_ROOT}/golden-e2e.log") 2>&1 timestamp() { @@ -64,7 +72,7 @@ run_timed() elapsed=$((end_epoch - start_epoch)) printf '%s\t%s\t%s\t%s\t%s\n' \ "${name}" "${start_epoch}" "${end_epoch}" "${elapsed}" "${status}" \ - >> "${LOG_ROOT}/timings.tsv" + >> "${TIMINGS_FILE}" printf '[%s] END %s status=%s elapsed_seconds=%s\n' \ "$(timestamp)" "${name}" "${status}" "${elapsed}" @@ -120,8 +128,9 @@ verify_snapshot() build_sf() { local name="$1" - local textures="$2" - local output="$3" + local dataset="$2" + local textures="$3" + local output="$4" local marker="${STATE_ROOT}/${name}.complete" if [[ -f "${marker}" ]]; then @@ -133,7 +142,7 @@ build_sf() mkdir -p "${output}" run_timed "${name}" \ "${SF_BUILDER}" \ - --dataset "${GS_VRT}" \ + --dataset "${dataset}" \ --textures "${textures}" \ --min-texture-level "${MIN_TEXTURE_LEVEL}" \ --max-texture-level "${MAX_TEXTURE_LEVEL}" \ @@ -168,8 +177,8 @@ merge_sf() mkdir -p "${SF_MERGED}" run_timed merge_sf \ "${SF_MERGER}" merge \ - --base "${SF_GATAKI}" \ - --new "${SF_BASEMAP}" \ + --base "${SF_BASEMAP}" \ + --new "${SF_GATAKI}" \ --mask "${TIROL_MASK}" \ --output "${SF_MERGED}" \ --verbosity info @@ -209,7 +218,7 @@ write_manifest() { local name="$1" local snapshot="$2" - local output="${LOG_ROOT}/${name}.sha256" + local output="${RUN_LOG_ROOT}/${name}.sha256" ( cd "${snapshot}" @@ -253,10 +262,23 @@ record_hard_links() printf 'merged_payloads=%s\nlinked_to_gataki=%s\nlinked_to_basemap=%s\nnewly_written=%s\n' \ "${merged_count}" "${linked_to_gataki}" "${linked_to_basemap}" "${newly_written}" \ - | tee "${LOG_ROOT}/merge-hard-links.txt" + | tee "${RUN_LOG_ROOT}/merge-hard-links.txt" } +prepare_elevation_vrts() +{ + mkdir -p "${ELEVATION_ROOT}" + gdal_translate -q -of VRT -srcwin 259507 245043 4000 4000 "${RAW_GS}" "${GS_VRT}" + gdal_translate -q -of VRT -srcwin 259507 245043 4000 4000 "${RAW_GT}" "${GT_VRT}" + gdal_edit.py -units m "${GS_VRT}" + gdal_edit.py -units m "${GT_VRT}" +} + +require_file "${RAW_GS}" +require_file "${RAW_GT}" +run_timed prepare_elevation_vrts prepare_elevation_vrts require_file "${GS_VRT}" +require_file "${GT_VRT}" require_file "${TIROL_MASK}" require_directory "${BASEMAP_TILES}" require_directory "${GATAKI_TILES}" @@ -273,10 +295,10 @@ require_executable "${DAG_BUILDER}" printf 'min_texture_level=%s\n' "${MIN_TEXTURE_LEVEL}" printf 'max_texture_level=%s\n' "${MAX_TEXTURE_LEVEL}" printf 'cpu_count=%s\n' "$(nproc)" -} >> "${LOG_ROOT}/run-metadata.txt" +} >> "${RUN_LOG_ROOT}/run-metadata.txt" -build_sf build_sf_basemap_terrain "${BASEMAP_TILES}" "${SF_BASEMAP}" -build_sf build_sf_gataki_terrain "${GATAKI_TILES}" "${SF_GATAKI}" +build_sf build_sf_basemap_gs_terrain "${GS_VRT}" "${BASEMAP_TILES}" "${SF_BASEMAP}" +build_sf build_sf_gataki_gt_terrain "${GT_VRT}" "${GATAKI_TILES}" "${SF_GATAKI}" merge_sf record_hard_links build_dag From ab65652c90e2cdb509138979f7edd470251567fa Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:50:19 +0200 Subject: [PATCH 50/53] Add SF border merge integration reproducer --- unittests/CMakeLists.txt | 8 ++ .../data/sf_builder_merge_border/README.md | 27 ++++ .../sf_builder_merge_border/elevation/gs.tif | Bin 0 -> 46542 bytes .../elevation/gs.tif.aux.xml | 12 ++ .../sf_builder_merge_border/elevation/gt.tif | Bin 0 -> 45788 bytes .../elevation/gt.tif.aux.xml | 12 ++ .../sf_builder_merge_border/mask/tirol.dbf | Bin 0 -> 607 bytes .../sf_builder_merge_border/mask/tirol.prj | 1 + .../sf_builder_merge_border/mask/tirol.shp | Bin 0 -> 181588 bytes .../sf_builder_merge_border/mask/tirol.shx | Bin 0 -> 108 bytes .../orthophoto/basemap/12/2192/1439.jpeg | Bin 0 -> 14634 bytes .../orthophoto/basemap/13/4384/2878.jpeg | Bin 0 -> 13650 bytes .../orthophoto/basemap/14/8768/5756.jpeg | Bin 0 -> 10822 bytes .../orthophoto/basemap/15/17537/11513.jpeg | Bin 0 -> 6964 bytes .../orthophoto/basemap/16/35074/23026.jpeg | Bin 0 -> 8516 bytes .../orthophoto/basemap/17/70148/46052.jpeg | Bin 0 -> 11307 bytes .../orthophoto/basemap/17/70148/46053.jpeg | Bin 0 -> 6662 bytes .../orthophoto/basemap/17/70149/46052.jpeg | Bin 0 -> 9113 bytes .../orthophoto/basemap/17/70149/46053.jpeg | Bin 0 -> 2824 bytes .../orthophoto/gataki/12/2192/1439.jpeg | Bin 0 -> 14489 bytes .../orthophoto/gataki/13/4384/2878.jpeg | Bin 0 -> 13684 bytes .../orthophoto/gataki/14/8768/5756.jpeg | Bin 0 -> 11032 bytes .../orthophoto/gataki/15/17537/11513.jpeg | Bin 0 -> 7147 bytes .../orthophoto/gataki/16/35074/23026.jpeg | Bin 0 -> 10403 bytes .../orthophoto/gataki/17/70148/46052.jpeg | Bin 0 -> 15534 bytes .../orthophoto/gataki/17/70148/46053.jpeg | Bin 0 -> 6938 bytes .../orthophoto/gataki/17/70149/46052.jpeg | Bin 0 -> 11622 bytes .../orthophoto/gataki/17/70149/46053.jpeg | Bin 0 -> 2630 bytes unittests/sf_merger/integration.cpp | 117 ++++++++++++++++++ 29 files changed, 177 insertions(+) create mode 100644 unittests/data/sf_builder_merge_border/README.md create mode 100644 unittests/data/sf_builder_merge_border/elevation/gs.tif create mode 100644 unittests/data/sf_builder_merge_border/elevation/gs.tif.aux.xml create mode 100644 unittests/data/sf_builder_merge_border/elevation/gt.tif create mode 100644 unittests/data/sf_builder_merge_border/elevation/gt.tif.aux.xml create mode 100644 unittests/data/sf_builder_merge_border/mask/tirol.dbf create mode 100644 unittests/data/sf_builder_merge_border/mask/tirol.prj create mode 100644 unittests/data/sf_builder_merge_border/mask/tirol.shp create mode 100644 unittests/data/sf_builder_merge_border/mask/tirol.shx create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/12/2192/1439.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/13/4384/2878.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/14/8768/5756.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/15/17537/11513.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/16/35074/23026.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70148/46052.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70148/46053.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46052.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46053.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/12/2192/1439.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/13/4384/2878.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/14/8768/5756.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/15/17537/11513.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/16/35074/23026.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46052.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46053.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46052.jpeg create mode 100644 unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46053.jpeg create mode 100644 unittests/sf_merger/integration.cpp diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 6e694422..ce1f32b5 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -132,5 +132,13 @@ if(TARGET sfmergerlib) sf_merger/sphere_projector.cpp ) target_link_libraries(unittests_sfmerger PRIVATE sfmergerlib Catch2::Catch2WithMain) + if(TARGET sf-builder AND TARGET sf-merger) + target_sources(unittests_sfmerger PRIVATE sf_merger/integration.cpp) + target_compile_definitions(unittests_sfmerger PRIVATE + ALP_SF_BUILDER_PATH="$" + ALP_SF_MERGER_PATH="$" + ) + add_dependencies(unittests_sfmerger sf-builder sf-merger) + endif() atb_configure_test(unittests_sfmerger) endif() diff --git a/unittests/data/sf_builder_merge_border/README.md b/unittests/data/sf_builder_merge_border/README.md new file mode 100644 index 00000000..98aeb37e --- /dev/null +++ b/unittests/data/sf_builder_merge_border/README.md @@ -0,0 +1,27 @@ +# SF builder/merger border fixture + +This approximately 454 KB input fixture drives both SF builders and the SF +merger. It retains two adjacent level-15 mask-border nodes: one valid merge and +one merge that currently produces a malformed terrain file. The integration +test intentionally requires both results to be readable, so the known defect +makes the test fail. + +The fixture contains: + +- 128 x 176 pixel, 1 m GS and GT elevation crops derived from the Geoland.at + `Oe_2020` datasets; +- the nine Basemap and nine Gataki orthophoto tiles intersecting that crop from + zoom levels 12 through 17; and +- the full Statistik Austria NUTS-2 Tirol (`AT33`) polygon simplified with a + 5 m tolerance. The full extent is significant because the mask projection + uses the polygon bounds; clipping the mask no longer reproduces the defect. + +Sources and attribution: + +- Elevation: Geoland.at (Austria and its federal states), CC BY 4.0. +- Basemap orthophotos: + `https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{zoom}/{y}/{x}.jpeg`. +- Gataki orthophotos: + `https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/{zoom}/{y}/{x}.jpeg`. +- Tirol boundary: Statistik Austria NUTS-2 2026-01-01, feature `AT33`, obtained + from the Statistik Austria geodata WFS. diff --git a/unittests/data/sf_builder_merge_border/elevation/gs.tif b/unittests/data/sf_builder_merge_border/elevation/gs.tif new file mode 100644 index 0000000000000000000000000000000000000000..e52e55ae5001408e9bee5b3fa6ea4619eaa04d7c GIT binary patch literal 46542 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

@)zvi^HzSaptS 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 sf1dKc?51YUCQg_5{Q!{BHyOFDZs!r2qf` literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/elevation/gs.tif.aux.xml b/unittests/data/sf_builder_merge_border/elevation/gs.tif.aux.xml new file mode 100644 index 00000000..ba9a1518 --- /dev/null +++ b/unittests/data/sf_builder_merge_border/elevation/gs.tif.aux.xml @@ -0,0 +1,12 @@ + + + Layer_1 + + 3352.736328125 + 3507.5627441406 + 3446.9904349175 + 33.144155747231 + 100 + + + diff --git a/unittests/data/sf_builder_merge_border/elevation/gt.tif b/unittests/data/sf_builder_merge_border/elevation/gt.tif new file mode 100644 index 0000000000000000000000000000000000000000..4c0f00fb97494c42ae526220522a69dc66c66d39 GIT binary patch literal 45788 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 + + Layer_1 + + 3352.7292480469 + 3507.3479003906 + 3446.7743897004 + 33.203697482712 + 100 + + + diff --git a/unittests/data/sf_builder_merge_border/mask/tirol.dbf b/unittests/data/sf_builder_merge_border/mask/tirol.dbf new file mode 100644 index 0000000000000000000000000000000000000000..de57a09cf4afbe7fd8d32b6ac242ef3bf650fa50 GIT binary patch literal 607 zcmZRsW0z-SU|>jO_zR*?K{x|LdVFRIRL~hj|3g&)l+R1dO$7^LmFHD(3^6uV7_lHE MvnW4j#QI(e0FACnhX4Qo literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/mask/tirol.prj b/unittests/data/sf_builder_merge_border/mask/tirol.prj new file mode 100644 index 00000000..7a4c2ca9 --- /dev/null +++ b/unittests/data/sf_builder_merge_border/mask/tirol.prj @@ -0,0 +1 @@ +PROJCS["MGI_Austria_Lambert",GEOGCS["GCS_MGI",DATUM["D_MGI",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Lambert_Conformal_Conic"],PARAMETER["False_Easting",400000.0],PARAMETER["False_Northing",400000.0],PARAMETER["Central_Meridian",13.3333333333333],PARAMETER["Standard_Parallel_1",49.0],PARAMETER["Standard_Parallel_2",46.0],PARAMETER["Latitude_Of_Origin",47.5],UNIT["m",1.0]] \ No newline at end of file diff --git a/unittests/data/sf_builder_merge_border/mask/tirol.shp b/unittests/data/sf_builder_merge_border/mask/tirol.shp new file mode 100644 index 0000000000000000000000000000000000000000..a395287e104bc6b2761be511a1e0d758dfc9e4ad GIT binary patch literal 181588 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 Zd37u2_wgT2%=$sF{csp;-VOP^`~P;P5WxTd literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/mask/tirol.shx b/unittests/data/sf_builder_merge_border/mask/tirol.shx new file mode 100644 index 0000000000000000000000000000000000000000..d622c5263f3704ca2a5f74e9af4f6ecc64aa32cf GIT binary patch literal 108 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/basemap/13/4384/2878.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/basemap/13/4384/2878.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7d1f0469fba76de53dd08beffb74c783dba92567 GIT binary patch literal 13650 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 X9@=;qbgumERu37Sj?pOlzhD0a#FZ7W literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/basemap/14/8768/5756.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/basemap/14/8768/5756.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f130de4ad5aeab5bc0b75708ce50f174a85bda1e GIT binary patch literal 10822 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/basemap/15/17537/11513.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/basemap/15/17537/11513.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7b913fabbfb6ca594b89ed476dc86618c3963447 GIT binary patch literal 6964 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46052.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46052.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..2c524849e74aca825268d0146056525687e1ccf1 GIT binary patch literal 9113 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 Uby`55jV$|*XMR;hsh^Yo1(Em|iU0rr literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46053.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/basemap/17/70149/46053.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..891149c61dcc49c231f47b3f60a18d33f6c91b06 GIT binary patch literal 2824 zcmbVMX;2ea9Di?*+*AS~MG8ieP=cadu`1&MsHjXo1P=<-mSRz{oq+)nC^OIm0c$*g zh~lRpwV)mCAfVV5N5P8N85%295bDJt;!MR0YDMYWO|s;q$7V8b-|;_w$J>dniMzlx zCVF)=fDnRD&<{-XfMtNgVzF5yhs|blxg4HA74riEgF}=`rNn>X;sv3L=7uRlsU(ogd2$kN^Nl)GgJp>46wdkSsQb%j2U1jjjNLIF1oGNfHD)osaebAtc>=Ln2x3 zvB_*{hA321TE&q?)m`*hb8SQ(wlTAi%M-utIo-?eU4KQuEaklS<}X+nzHIr5=#{Hh z$E;nqKJJ6~gv3u%Hf`RLy7jYdS=+O7a=*yiQB+*A^Q&FjvU1(QibIDhj~uQ3`q(!$ zwZ~7Ku5V~ObGGT+_bsiLe!ARtrTyx!*E@f^akH!Y*1h|E{R4v!h8_+-dHU@6i_x(^ zU;ahmqHvlYBQP6Why;ic1WvFiToB7ae_TkAz9B5P$XIrAhPyOW!x2T5R@GhP%EH!+ zcx=qP#uLkx_xzqv&}d};4p`y;LdF1_#B~b@aEQdigMlvGx0S(X|3bibA>rV6G~ z*nKlQvQ%kyuPW`$$Xx?24@#>ll^@=6kVq-Wg8WC~kl=&|@$sXF2Hs@BjD&<`0mO_V znhiRenHJkJIwQ0>Ru~0kih(t!qfgb;BQLC~+UL>jRh$%Qpp-HZ(EwD~Bq!DkIh~p`9>rwVIpSAD;rWT_O=V^#Zf1`L~ zpjd76@>Efz-X^k>;+Jul6C(YPw=M9|T+BghG(VVO3SDfYL`X%$lo9BJlZGfmIcbm% zqW2v25^m(!8o9kbmQomZ6B%jjPn%@34?=3PX@*4TBja$JafJOYpFp*=Qo4jfw(vfAITyNSr pj@<905WP&%&@hD>*6c8((Xo675S=^5BGySk-IRTxj}4sY{u>j|ok9Qr literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/12/2192/1439.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/12/2192/1439.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..09439caca04546bda52b9f4d1fb88714a013a5d9 GIT binary patch literal 14489 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 AU;qFB literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/13/4384/2878.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/13/4384/2878.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..9803fc2dffadf9cb41ae1fc9242c3cedfb3f75d0 GIT binary patch literal 13684 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/14/8768/5756.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/14/8768/5756.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..dace6836ceb7f6c88024f854559c35f2caa8cb83 GIT binary patch literal 11032 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/15/17537/11513.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/15/17537/11513.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..41645e1385502b2b7e6a6b8aa97f6cb08ef6d3f7 GIT binary patch literal 7147 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46052.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46052.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b2a4b76b8eaaf5a7b8149055253fa72230aaa771 GIT binary patch literal 15534 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 Vv3BnS_7eoK%IswJkNjKx-vEm}&P@OS literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46053.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70148/46053.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..dc3ac54e54416b627c0892ad082af95278b7d4a7 GIT binary patch literal 6938 zcmbVx2T)V%xBW>1p#=m4Bs3|ZNlgePG$S<>DWOU6UU~-&D2R#%1O%i=Km?3R@2GST z^&--1q)8PiqJSVOMNuy=3HrPD|DSm?@4b^blat}(`}SUYt+n?F`xE;q00QCy zz+4~*7ihm7I0^tDMryRQfPWnzFob~-%EZjV%0|7Ro)Z9rAP_JEgprYffqHi&_3r=! z7bCYA@&xp-wHuQ-fd>_pmd7liSN)t9|8f(d;vN#s!pe7qA1)v%B`qU+46UlBu7T0i zH!w6Z#+jJf*xK1UoN{#X@bvQb@%8f$J$F7V{6fUVYca8L*W(isN$DB4GH+*P=j0a@ z78RG2mX+7k*3~yOHZ?zPd(qy}+11_CJ3KNvHvVd2a%z5Iap~>yyZ0-rpFV%t+TPjy z`fZPv7cI}<$3bHMUtUxYKwt(22m=!>FAzAKdP2Au7{!oK?i1EbZiK_)s3>M0y|ldQ z=PVK`_)T8-ke94{2=u(HMIMgN~N`%hy3f~%Ra!hVs9dUdnoJ;#!W~+)=G`2DPYf*nL8E~gVYmu1@#<@ z@JN(Df>H`j`l5nb*NrZdW4UQ8;%$2ttD$pTAkf$@!mDJZVPILvoC0|y(iQcU5=9=X zCx4z1OMqPyC3BD|JMbAcOHFg5O57V6cu%E5$Y5XFp890L5tU%aI~wLYyHye*g$?Ik zaYv|ql-{+?Y0T%wR0#~NH>S$BG~~@7XO?S{f;U|5rcwOQyj>Y_g=C1=Wwt8};z9r& zodAf9nNcmttgs0YXfsF7X;KK5Rmpuu(x{v*(r~d)e(#+k+=uJ8+-*s6z*M_WjW6uR z->gZmYq)4*9q3R!?&xIf6D*#pF=!DPaIP|Gy4ll?*@=M%umWU2zCbQxE^rfi5o$i*s|9~qVK@3=}f59)FKh*hphL3@iH*7WM&o+W`QIRwB5gYwJnNl z@$iC!MPvN2Zx+sHgvjAVoIbJ=*@$-?d;k_QVs2cKCYILUjXV7+%-Bol&?~e; zRHn8nB2Do6ouxzFi_iQ$dyR9yhT;=~%yn_70zRLv&ApdLD1lQi>$%Ei%%n3NTw5_~ z7K#hrcV{RsAn3*i5;rv!0~T=m6bIfqyFW?q!zj?4HcaRQ==n zcrm_dxZ2U-9A;`d0T3dXC;$_QxVi|(2apI)h@ecjcBqq|aL>%Z*mnArbr^g6SxlEy ze(1CK(#}p_BN+)RGvxZ~+=7~sAj7H*!XkT8<9K6YjY{x`$BfqEBb;@B6$r~JL&Kaw z8cYYb&Xb1L()l0BV4yt63(-Dx(fO54?q;Bu|j!p7b1at z)ZZL8_i8$qdoQ@AQBP+d7`k{Gs?TC`E^pLRaY4ngCZ*&>*z(Fp3Bi~UJqO+5 zavAg0Ei<+1kufi;ntSyGdp&Q9=8L;W7g%iM%{powee z-luJ4jg7?Fkayt^;y-xrVg`a81wORdR$9y0x-5AJ2f&{Ox|sjeY+JO-Zr6Xyl&Qlg z(^1{%@i=Z)!a&f-7DTpRfzOa_IP2P}dmwyq*JvMvW#rl7Xb{s0%!XydGM@yHWFQphZC?@c-opS9edWDyQ-c)jOE% ze3B!F=~Asy!zH{EtXZ+|kat=nB+@vFk@T5KjCBuMjCN0F=*LPDGoiz{|~ zQb`!px>7&jF_+qz6+4obxoee(?2CpjT*L>z&~iS+VFs<*x%|y=nlWmKSLqVF@XyTj zGaiRU6(pXiw~*6QZke1i%zk4QZ_a_LM4H2Tyi*Muzblkq3zeRe$knaCb6YXK@PS8+ zI=7dJA4c)iLha1c1Vp@{N`5z@>k~(Y4&do3tz+^L#{_S8dsIG&=a?s3pOyDxA%x25 zUFRahRaUwsw#%c>oWKnapDSq?mG-fm%@bFns;RpoDX{(Zbu6PVcGZ=KnQp~&3J((n zeBo(j{wD);$3AZ!Az6yS!0}t&j(4t>e@hWJ^lcAlgmuz3)T=eKW z>Fjsp0rBL|BDt<-8ABVxL+x@;_{o2kCsc6%*dyqjjO`i<+)008k!R@w$q}e!*g#$c z;k>4W1;0Icr;A*pcZXh>aJ^igPbA~hzwXF&Rvwk_BJzAbZYm$= z9hYcQw4c%C{`qWb7KmJZVYez(>%P)IU`Ktb%>!=(w()^ zt#-cgZidv%*T)fvt#H_n{KWRzicL|i|Ax7+$ zW)?~0#_~zxZpRw7mLu+E#pHsi_);6zA$BftZ9{HtM#Mexo6x;+skq{Y+T0iLP}%17 z9vJd@kFaGGdL!oSC+C(KV6{v7X2Jc-c3X%^(&W2_6gCZ$sktW-!j@mXZ@lLl-MY$J z(d8#x(bWz29W2_w9e}J=l0pZo?)1c#T zHF(3MA$(+mLelQfg})~xj>{PtG^!NWJRT7xxf>7akwcJHBZ=R|VC-WH{C1RDSykStFb_=?zfe)`>JJU(F=L;E68rcLD z_s{R3@-TTHpjYGGi1$2{!h78$^KF$SuBuN=r!I?b@0N3^B3Y>2=72Ta>8RNF;+2b! zAegPcuNJxc{xQaHI6o4|4=>D8V{$EcxJru0dJau)eHi4yUfLco zleV5?D$8CoC>!tON=+P06AoQEAYV{PA{0&N$>s3r5Qxe8jxbbv^sTyQ@Zu%=kVxna zfs^fzldRYKWjUE`Caxx{mQKDrwr+jU5vZD>f&hBckY3}j!u&04Nj*QYql!&`JM6-S z{ANtgZd>yj*&~q?6>!Rvf&YG*mERK#E z>GTG!xF(|e#G~$;)|mLkQ<+X9yeS|9d4c|ZimeYF!UDUt#NG)F;M)^GsDGIAAN#R< zGcf+4@X*nsBb3ty0X=rhEkUnO-S%U>I#jO(GFrg+T&>d#gerIE5_XAy*PFRJzSkA3 z$?;n+0Up^I2a>}|6I&EMgxghvDGUMJa<#{Z&Sl$0t_Yr1YB{K5c^$3G(ngtsbdafT z*ym793X?*GKS?(aVfE}i#Y4FEb?q7JLdTbNlWfLXM=bP@W^Hu=KSOf}*vVlcuuysP zw{RJ#7rK#&8-6~2*0keJBsX<;c<0-A1W7kde4Wk~di#{gR6yV0m3q9QYaDwKLv!Gk z$4ArWgjd62z9%b_m-)W2ERuKfBChFZJwMwglDQmapS3=lGGlYy%Ewer&yc$M5xRiH zs8wL3emd}DC%}lRr$ivHaxOBlyJmBsRreMujKOn7Q+xQ)PWEja%x1cIa{+k47wq!K+^x61^i#)`bZYnx`BasUqeD5OQFk>eXao2G zveEQF=}ni3N@3UhnST4JUeET~C5xrbz)ddbr z)=SJcrhpIhNo#F1nSP1hIDW9HBBb_!nF5+kARxr0N@i}ll^`)WeBR)oDdWoslNw+?l-W2FTN(5V|;N0ZC zDc8Pfcu8gjI%+QkSuDArQ7Um$yeW2(t9DsC>S?@CpaO9okfeS4U|y$RG7an;U1?mE zTsoY|M8QLB+8HIBoqY$nZ0liM33A=5u_<;?P%cNw^F5O%uUwf36WVRHM9c_|8_QZKs|XBHZW#h6O%aQopSldlg-Ghcg+Npob)ILybZx! zVo>1t(3j$vK6E=hlmpml;ZZ>o;dLt%Jgs5Tmwn2U98zxlCBF@&c z;b7A_55DKOxp63D_YYslh_3MTBR?-MA+SnHWVwBfT;6*s?_4S1C~7r-@%tZ@1pC5; zrq6=hF+;*DptN)8W*6>xt_AQBI9`51YM);pFnih=(c+6u`XZM(_i9_`$+56pCS=p5RNTEhwmD3E4OTl8idG8|4} z(kYkf#ch9tIT=E81dcB(mYCb5v<~#!krZY4t;DGKG5kML)*mKHT#nWap3ps_>-^s5-(rp8}8SKLMg z*`L~#aZCGp=*ufKv00(*=e?7<6x|PRYGA>ETYF)7Ens7>c^xRB5b4_{Jf~(XRa?KU zWfb4-wnJD7}q9Xdv6O~dNz{egU&jPP2mJ9Gp&8Z z^2z>obs8h6!un54tHbM*?%}&j2G*FBB=?(we+blg+GsbuxZ7&4axuqHq$pp5)Y?T< zBVRdxHe4_B?e~E54e|Npq~$6(?%AGN^AkC=MnPlSZ_}em3^u}?agPJj=?5$DPrbqy z_BxlF$<O_6q8?7#A zZj2Q*#C3HSz$@Y9nh!a24HAz(WLPz>{tqx+40fu-$*7JvywXW=zuHd{n z0P4X(v^nq-+Li}SK;M;q8R0+K6~JKKeqZf*X7IE6YiNVajoiDFd#uqZec0B}5d|D25Afa**REdU!5T3sq#88Hr9+|LWO#ID+UxfJQ z4Knng$oYxQvzyS=H9%@`Xa9cza1l|Gh@9- z3b_)UVU7sGLu^(|>xA^&aTF-<3sq%ph{|gu8rzd#&1i(erO+I-YuDj^>X8f7& z@Cl9l26;A^`RX5~mlF7tY#tl+7xchs#UU2I+4xF;?)LW(sHy)N!)c{scl$;`0z0`()Q26~4DrwI|NaE&k@9=nH1*zE3$o&} z9Gk7UL~_2c$)hiFIEg!SJZ_8ObOfR)eXt1==%LXewSgpV4$Ns35hh+zl=7?pn$xP0 zFX-zEermb+V-1J$Oj#67k@HYS10${UJ=v-KkOu@`HplaCBF*)vQPJ56u^z{oD!X!U z==cG@9yWimmL5OoExzZOmGMwCR$;;cSytjx=(3877Or@kQs`=jn7H$3XGB%BlH~Nv z_KlDy{;k`&fQhf3CY+?tMz4)o$w9jH{yD4CQUTMxnf6P_crA{P1NdlzhJj<%v`uIx zlQlq%i>U}eTOr%%n(8S|VPKaCVu%%4BC|U19|4&Rfr%{!3~Z2bznhOsAd$DfQ9;kA5Q8I4)=C^W8~R#!9VKBL7bm57Rdm%t;(tYTcNPJ z)7uEGBmH|7$bh)LW9YrHLH}UioY-~;&gXya!X}=t=ae=+%F*1L=%){M5Hu}57Wxz= zg`o!`5V7hDFoULXp*pr#dd7MTNJ>tJDwU@(EcD-C(NT&T`Mqk$x~7ImQg G$^QZhw>(q; literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46052.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46052.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..bcaf922b48ba997d7b064259fcdcad5f8ec15eb8 GIT binary patch literal 11622 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 literal 0 HcmV?d00001 diff --git a/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46053.jpeg b/unittests/data/sf_builder_merge_border/orthophoto/gataki/17/70149/46053.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..076925008cf341f3cf3b07a853b64467b0582583 GIT binary patch literal 2630 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<ECr+Na zbot8FYu9hwy!G(W<0ns_J%91?)yGetzkL1n{m0K=Ab)}U2?G$1q4^6K2#icDEX*wI zAb&A3l>;%eAPcLaA)An6AbVn=u#!j7{Xt-7S7<)zUHnZ zE(Q&T1xyXf6@fRl8T>f*%2}z;ywT~&{+){xRMQTev{=A%`^CF=1rugmQmRW!a{Qxd zy>U^6$&K7|FRm90Uj2FeM_@MV=~ZTZewiI*4lgu2j4~L(28uO6O@>lJKz`QkV@qTg zY>;62w#3MXD~UBUu$3Wz(Sg%}l|h6dfClC_`h@2K<17ZB3t+QArX!mT;&TH<^fEwxMsgWgFAG8p zRf?$r**?Z%uoHxTk8G=akdf+>Ir7>giVtuTzkFdWG;B+GI85n(tqtihoP z4{bhVx8t)6>`$cFltqazxOw1k#PlGNJbpjI{RC&g0~n_tr!Z`Rq2&nNPMm&3 zwFAj>P&={u5zTW`;L!-T4@W$rnh(pes9}!jM^y7sBM%zbpsb7JN06^T=^kM+s?7`w zQ24c=?1APfaAHKY3q4R-8SENR!w*}IM|K4~lVA&0WN%_x1#}@ObkUOzs{JUwLU + +#include +#include +#include +#include +#include + +#include "mesh/io.h" + +namespace { + +class TemporaryDirectory { +public: + TemporaryDirectory() { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + _path = std::filesystem::temp_directory_path() / + ("alpine-terrain-builder-sf-merge-" + std::to_string(suffix)); + std::filesystem::create_directories(_path); + } + + ~TemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(_path, error); + } + + TemporaryDirectory(const TemporaryDirectory &) = delete; + TemporaryDirectory &operator=(const TemporaryDirectory &) = delete; + + [[nodiscard]] const std::filesystem::path &path() const { + return _path; + } + +private: + std::filesystem::path _path; +}; + +std::string shell_quote(const std::filesystem::path &path) { + std::string quoted = "'"; + for (const char character : path.string()) { + if (character == '\'') { + quoted += "'\\''"; + } else { + quoted += character; + } + } + return quoted + "'"; +} + +int build_sf( + const std::filesystem::path &dataset, + const std::filesystem::path &textures, + const std::filesystem::path &output) { + const std::string command = + shell_quote(ALP_SF_BUILDER_PATH) + + " --dataset " + shell_quote(dataset) + + " --textures " + shell_quote(textures) + + " --min-texture-level 12 --max-texture-level 17" + " --mesh-srs EPSG:4978 --verbosity warn" + " batch --target-level 15 --output " + shell_quote(output) + + " --format .terrain --threads 1"; + return std::system(command.c_str()); +} + +} // namespace + +TEST_CASE("SF builders reproduce working and malformed mask-border merges", "[integration][sf-builder][sf-merger]") { + const std::filesystem::path fixture = + std::filesystem::path(ALP_TEST_DATA_DIR) / "sf_builder_merge_border"; + const TemporaryDirectory temporary_directory; + const std::filesystem::path base_output = temporary_directory.path() / "basemap-gs"; + const std::filesystem::path new_output = temporary_directory.path() / "gataki-gt"; + const std::filesystem::path merged_output = temporary_directory.path() / "merged"; + + REQUIRE(build_sf( + fixture / "elevation/gs.tif", + fixture / "orthophoto/basemap", + base_output) == 0); + REQUIRE(build_sf( + fixture / "elevation/gt.tif", + fixture / "orthophoto/gataki", + new_output) == 0); + + const std::string merge_command = + shell_quote(ALP_SF_MERGER_PATH) + + " merge --base " + shell_quote(base_output) + + " --new " + shell_quote(new_output) + + " --mask " + shell_quote(fixture / "mask/tirol.shp") + + " --output " + shell_quote(merged_output) + + " --verbosity warn"; + REQUIRE(std::system(merge_command.c_str()) == 0); + + const std::filesystem::path working_relative = "15/26291/18610/27235.terrain"; + const std::filesystem::path malformed_relative = "15/26290/18610/27235.terrain"; + const std::filesystem::path working_path = merged_output / working_relative; + const std::filesystem::path malformed_path = merged_output / malformed_relative; + + REQUIRE(std::filesystem::exists(working_path)); + CHECK_FALSE(std::filesystem::equivalent(working_path, base_output / working_relative)); + CHECK_FALSE(std::filesystem::equivalent(working_path, new_output / working_relative)); + + const auto working_mesh = mesh::io::load_from_path(working_path); + REQUIRE(working_mesh.has_value()); + CHECK_FALSE(working_mesh->is_empty()); + + REQUIRE(std::filesystem::exists(malformed_path)); + CHECK_FALSE(std::filesystem::equivalent(malformed_path, base_output / malformed_relative)); + CHECK_FALSE(std::filesystem::equivalent(malformed_path, new_output / malformed_relative)); + + const auto malformed_mesh = mesh::io::load_from_path(malformed_path); + const std::string malformed_error = malformed_mesh.has_value() + ? std::string() + : malformed_mesh.error().description(); + INFO("Malformed mask-border merge: " << malformed_error); + REQUIRE(malformed_mesh.has_value()); + CHECK_FALSE(malformed_mesh->is_empty()); +} From 9671b3102623c015436e94dbb6922da07edcc76c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:31:55 +0200 Subject: [PATCH 51/53] Fix sanitizer symbolization in CI --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4516ef8..4e0fd500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,7 @@ jobs: wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null echo "deb https://apt.llvm.org/noble/ llvm-toolchain-noble-23 main" | sudo tee /etc/apt/sources.list.d/llvm-23.list sudo apt-get update - sudo apt-get install -y --no-install-recommends clang-23 lld-23 libclang-rt-23-dev + sudo apt-get install -y --no-install-recommends clang-23 lld-23 llvm-23 libclang-rt-23-dev - name: Install GCC ${{ matrix.cxx }} if: matrix.cxx == 'g++-16' @@ -186,6 +186,9 @@ jobs: export PROJ_DATA="${{ github.workspace }}/build/alp_external/proj/share/proj" export PROJ_LIB="${PROJ_DATA}" export GDAL_DATA="${{ github.workspace }}/build/alp_external/gdal/share/gdal" + if [[ "${{ matrix.sanitizer }}" == "asan" ]]; then + export ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer-23 + fi if [[ "${{ matrix.sanitizer }}" == "tsan" ]]; then export TSAN_OPTIONS="suppressions=${{ github.workspace }}/extern/tbb/cmake/suppressions/tsan.suppressions" fi From e1a252a9f0919b6b04b93605c104f841b8f8e77a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:31:55 +0200 Subject: [PATCH 52/53] Update cgltf for writer leak fix --- src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4a7150f8..aeea6394 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,7 +32,7 @@ find_package(CGAL REQUIRED) include(../cmake/SetupOpenCV.cmake) alp_setup_opencv(4.11.0) -alp_add_git_repository(cgltf URL https://github.com/AlpineMapsOrgDependencies/cgltf.git COMMITISH e035b820ccde6ab20451b612d8033c9b76ea0ce4 DO_NOT_ADD_SUBPROJECT) +alp_add_git_repository(cgltf URL https://github.com/AlpineMapsOrgDependencies/cgltf.git COMMITISH 1549b07fe059e1917bbdda1f61ec36b5f4d6718a DO_NOT_ADD_SUBPROJECT) add_library(cgltf INTERFACE) target_include_directories(cgltf SYSTEM INTERFACE ${cgltf_SOURCE_DIR}) From 4c5c60d8d58cfd1ccfdcb2d514682aa52393fbec Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:30:57 +0200 Subject: [PATCH 53/53] Fix stale SF tile ID test --- unittests/sf_builder/texture.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unittests/sf_builder/texture.cpp b/unittests/sf_builder/texture.cpp index 06118437..3da052b4 100644 --- a/unittests/sf_builder/texture.cpp +++ b/unittests/sf_builder/texture.cpp @@ -351,7 +351,7 @@ TEST_CASE("texture assembler reports the content region", "[terrainbuilder]") { tile_image.row(row).setTo(uint8_t(row)); } - const radix::tile::Id root_tile(0, {0, 0}, radix::tile::Scheme::SlippyMap); + const radix::tile::Id root_tile(0, {0, 0}); const StaticTileProvider tile_provider({{root_tile, tile_image}}); const std::vector tiles_to_splatter = {root_tile};