diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0fd500..396ab23e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: build_type: Release sanitizer: normal warnings_as_errors: ON - cmake_flags: "" + cmake_flags: -DCMAKE_CXX_FLAGS=-Wno-maybe-uninitialized - name: clang-23-asan cc: clang-23 cxx: clang++-23 @@ -188,13 +188,14 @@ jobs: 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 + export ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" + export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1:suppressions=${{ github.workspace }}/misc/suppression/ubsan.txt" fi if [[ "${{ matrix.sanitizer }}" == "tsan" ]]; then - export TSAN_OPTIONS="suppressions=${{ github.workspace }}/extern/tbb/cmake/suppressions/tsan.suppressions" + export TSAN_OPTIONS="suppressions=${{ github.workspace }}/misc/suppression/tsan.txt" fi status=0 ./unittests/unittests_terrainlib "~mesh::clip_on_bounds benchmark" || status=$? - ./unittests/unittests_tilebuilder || status=$? ./unittests/unittests_sfbuilder || status=$? ./unittests/unittests_dagbuilder || status=$? ./unittests/unittests_sfmerger || status=$? diff --git a/CMakeLists.txt b/CMakeLists.txt index 0eb184a8..6aedbaa5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,89 +1,19 @@ -cmake_minimum_required(VERSION 3.21) +cmake_minimum_required(VERSION 3.25) project(alpine-terrain-builder LANGUAGES C CXX) # Executable targets -option(ALP_BUILD_TILE_BUILDER "Build tile-builder" ON) -option(ALP_BUILD_SF_MERGER "Build sf-merger" ON) -option(ALP_BUILD_SF_BUILDER "Build sf-builder" ON) -option(ALP_BUILD_TILE_DOWNLOADER "Build tile-downloader" ON) -option(ALP_BUILD_MESH_CONVERT "Build mesh-convert" ON) -option(ALP_BUILD_SF_INDEX_BROWSER "Build sf-index-browser" ON) -option(ALP_BUILD_DAG_BUILDER "Build dag-builder" ON) -option(ALP_BUILD_DAG_CONVERT_DEBUG "Build dag-convert-debug" ON) -option(ALP_BUILD_SF_BROWSER "Build sf-browser" ON) -option(ALP_BUILD_UNITTESTS "Build unit tests for enabled components" ON) - -# Project configuration -option(ALP_ENABLE_THREAD_SANITIZER "Compile with ThreadSanitizer enabled" OFF) -option(ALP_ENABLE_ADDRESS_SANITIZER "Compile with AddressSanitizer enabled" OFF) -option(ALP_ENABLE_OVERVIEW_READING "Enable GDAL overview reading (broken with newer GDAL versions)" OFF) -option(ALP_ENABLE_UNITY_BUILD "Enable unity (jumbo) builds to speed compilation" OFF) -option(ALP_WARNINGS_AS_ERRORS "Treat first-party compiler warnings as errors" OFF) - -set(ALP_ENABLE_ASSERTS_DEFAULT OFF) -if(CMAKE_BUILD_TYPE MATCHES "^(Debug|RelWithDebInfo)$") - set(ALP_ENABLE_ASSERTS_DEFAULT ON) -endif() -option(ALP_ENABLE_ASSERTS "Do not define NDEBUG, i.e., enable asserts" ${ALP_ENABLE_ASSERTS_DEFAULT}) - -set(ALP_UNITY_BUILD_BATCH_SIZE 4 CACHE STRING "Number of source files per unity translation unit") -set(ALP_RELEASE_OPT_LEVEL "3" CACHE STRING "Optimization level (0=O0,1=O1,2=O2,3=O3)") -set(ALP_DEPENDENCIES_N_BUILD_JOBS "" CACHE STRING - "Maximum number of parallel jobs for independently built CMake dependencies") - -# Unit-test configuration -option(ALP_UNITTESTS_EXTENDED "Perform extended unit tests (they take long)" OFF) -option(ALP_UNITTESTS_DEBUG_IMAGES "Output debug height images for visual comparison" OFF) -set(ALP_UNITTESTS_AUSTRIA_HIGHRES "" CACHE FILEPATH "Path to the high resolution Austrian terrain dataset") - -if(ALP_ENABLE_ADDRESS_SANITIZER AND ALP_ENABLE_THREAD_SANITIZER) - message(FATAL_ERROR "Cannot enable both AddressSanitizer and ThreadSanitizer") -endif() - -set(ALP_SANITIZER_FLAGS) -if(ALP_ENABLE_ADDRESS_SANITIZER) - message(STATUS "AddressSanitizer enabled") - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - set(ALP_SANITIZER_FLAGS -fsanitize=address -fsanitize=leak -fsanitize=undefined -fno-omit-frame-pointer -g) - add_compile_options(${ALP_SANITIZER_FLAGS}) - add_link_options(${ALP_SANITIZER_FLAGS}) - else() - message(WARNING "ALP_ENABLE_ADDRESS_SANITIZER set but compiler is not GCC/Clang; ignoring") - endif() -endif() - -if(ALP_ENABLE_THREAD_SANITIZER) - if(CMAKE_BUILD_TYPE STREQUAL "Release") - message(WARNING "ThreadSanitizer is not recommended with Release builds. Use Debug or RelWithDebInfo for accurate race detection.") - endif() - message(STATUS "ThreadSanitizer enabled") - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - set(ALP_SANITIZER_FLAGS -fsanitize=thread -fno-omit-frame-pointer -fno-optimize-sibling-calls -g) - add_compile_options(${ALP_SANITIZER_FLAGS}) - add_link_options(${ALP_SANITIZER_FLAGS}) - else() - message(WARNING "ALP_ENABLE_THREAD_SANITIZER set but compiler is not GCC/Clang; ignoring") - endif() -endif() - -if(CMAKE_BUILD_TYPE STREQUAL "Release") - message(STATUS "Using optimization level ${ALP_RELEASE_OPT_LEVEL}") - add_compile_options($<$:-O${ALP_RELEASE_OPT_LEVEL}>) -endif() - -# Compiler launchers must be configured before targets are created. -find_program(SCCACHE_PROGRAM sccache) -find_program(CCACHE_PROGRAM ccache) -if(SCCACHE_PROGRAM) - message(STATUS "Using sccache as compiler launcher: ${SCCACHE_PROGRAM}") - set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") - set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") -elseif(CCACHE_PROGRAM) - message(STATUS "Using ccache as compiler launcher: ${CCACHE_PROGRAM}") - set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") - set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") -endif() +option(ALP_BUILD_SF_MERGER "Build sf-merger" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_SF_BUILDER "Build sf-builder" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_TILE_DOWNLOADER "Build tile-downloader" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_MESH_CONVERT "Build mesh-convert" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_SF_INDEX_BROWSER "Build sf-index-browser" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_DAG_BUILDER "Build dag-builder" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_DAG_CONVERT_DEBUG "Build dag-convert-debug" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_SF_BROWSER "Build sf-browser" ${PROJECT_IS_TOP_LEVEL}) +option(ALP_BUILD_UNITTESTS "Build unit tests for enabled components" ${PROJECT_IS_TOP_LEVEL}) + +include(cmake/ConfigureBuild.cmake) include(cmake/AddRepo.cmake) include(cmake/SetupCMakeProject.cmake) diff --git a/NOTICE b/NOTICE deleted file mode 100644 index f72afba3..00000000 --- a/NOTICE +++ /dev/null @@ -1,45 +0,0 @@ -This software includes code from (see comments in the respective files): -- Cesium Terrain Builder (Apache License, Version 2.0, https://github.com/ahuarte47/cesium-terrain-builder) -- tin-terrain (MIT License, https://github.com/heremaps/tin-terrain) - -------------------------------------------------------------------------------- - -Copyright 2014 GeoData - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -------------------------------------------------------------------------------- - -The MIT License (MIT) - -Copyright (C) 2018 HERE Europe B.V. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -------------------------------------------------------------------------------- diff --git a/README.md b/README.md index 3cbf664e..4a24c305 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # alpine-terrain-builder See the project [code style](docs/code-style.md) before contributing. + +The RF import, merge, and image-export tools live in +[tile-foundry](https://github.com/AlpineMapsOrg/tile-foundry). +This repository provides their `terrainlib` dependency, including raster-store. + +When included as a CMake subproject, tools and tests default to disabled. +Enable individual `ALP_BUILD_*` options explicitly when needed. + +Sanitizer exclusions are maintained in `misc/suppression/`. CI's ASan job also +enables UBSan and loads [ubsan.txt](misc/suppression/ubsan.txt) for the known CGAL +arrangement downcasts and the ignored old Boolean value in +`set_with_guarantees()`. These exclusions leave those upstream issues unfixed; +address checks, leak detection and unrelated undefined-behavior checks remain +enabled. + +To use the same UBSan exclusions locally, set these variables from the repository +root before running an ASan test executable: + +```sh +export ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" +export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1:suppressions=$PWD/misc/suppression/ubsan.txt" +``` + +Ensure `llvm-symbolizer` is available on `PATH`, or set `ASAN_SYMBOLIZER_PATH` to +its absolute path, so the function-specific exclusion can match. + +CI's TSan job loads [tsan.txt](misc/suppression/tsan.txt), containing the two +oneTBB initialization exclusions and GDAL's unlocked cache-head comparison in +`GDALRasterBlock::Touch()`. The GDAL race is accepted without patching the +dependency; the suppression matches any race with that function in its stack. +For local TSan tests, set this variable from the repository root: + +```sh +export TSAN_OPTIONS="suppressions=$PWD/misc/suppression/tsan.txt" +``` diff --git a/cmake/ConfigureBuild.cmake b/cmake/ConfigureBuild.cmake new file mode 100644 index 00000000..b5fbcc75 --- /dev/null +++ b/cmake/ConfigureBuild.cmake @@ -0,0 +1,70 @@ +include_guard(GLOBAL) + +# Project configuration +option(ALP_ENABLE_THREAD_SANITIZER "Compile with ThreadSanitizer enabled" OFF) +option(ALP_ENABLE_ADDRESS_SANITIZER "Compile with AddressSanitizer enabled" OFF) +option(ALP_ENABLE_UNITY_BUILD "Enable unity (jumbo) builds to speed compilation" OFF) +option(ALP_WARNINGS_AS_ERRORS "Treat first-party compiler warnings as errors" OFF) + +set(ALP_ENABLE_ASSERTS_DEFAULT OFF) +if(CMAKE_BUILD_TYPE MATCHES "^(Debug|RelWithDebInfo)$") + set(ALP_ENABLE_ASSERTS_DEFAULT ON) +endif() +option(ALP_ENABLE_ASSERTS "Do not define NDEBUG, i.e., enable asserts" ${ALP_ENABLE_ASSERTS_DEFAULT}) + +set(ALP_UNITY_BUILD_BATCH_SIZE 4 CACHE STRING "Number of source files per unity translation unit") +set(ALP_RELEASE_OPT_LEVEL "3" CACHE STRING "Optimization level (0=O0,1=O1,2=O2,3=O3)") +set(ALP_DEPENDENCIES_N_BUILD_JOBS "" CACHE STRING + "Maximum number of parallel jobs for independently built CMake dependencies") + +# Unit-test configuration +option(ALP_UNITTESTS_EXTENDED "Perform extended unit tests (they take long)" OFF) +option(ALP_UNITTESTS_DEBUG_IMAGES "Output debug height images for visual comparison" OFF) + +if(ALP_ENABLE_ADDRESS_SANITIZER AND ALP_ENABLE_THREAD_SANITIZER) + message(FATAL_ERROR "Cannot enable both AddressSanitizer and ThreadSanitizer") +endif() + +set(ALP_SANITIZER_FLAGS) +if(ALP_ENABLE_ADDRESS_SANITIZER) + message(STATUS "AddressSanitizer enabled") + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + set(ALP_SANITIZER_FLAGS -fsanitize=address -fsanitize=leak -fsanitize=undefined -fno-omit-frame-pointer -g) + add_compile_options(${ALP_SANITIZER_FLAGS}) + add_link_options(${ALP_SANITIZER_FLAGS}) + else() + message(WARNING "ALP_ENABLE_ADDRESS_SANITIZER set but compiler is not GCC/Clang; ignoring") + endif() +endif() + +if(ALP_ENABLE_THREAD_SANITIZER) + if(CMAKE_BUILD_TYPE STREQUAL "Release") + message(WARNING "ThreadSanitizer is not recommended with Release builds. Use Debug or RelWithDebInfo for accurate race detection.") + endif() + message(STATUS "ThreadSanitizer enabled") + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + set(ALP_SANITIZER_FLAGS -fsanitize=thread -fno-omit-frame-pointer -fno-optimize-sibling-calls -g) + add_compile_options(${ALP_SANITIZER_FLAGS}) + add_link_options(${ALP_SANITIZER_FLAGS}) + else() + message(WARNING "ALP_ENABLE_THREAD_SANITIZER set but compiler is not GCC/Clang; ignoring") + endif() +endif() + +if(CMAKE_BUILD_TYPE STREQUAL "Release") + message(STATUS "Using optimization level ${ALP_RELEASE_OPT_LEVEL}") + add_compile_options($<$:-O${ALP_RELEASE_OPT_LEVEL}>) +endif() + +# Compiler launchers must be configured before targets are created. +find_program(SCCACHE_PROGRAM sccache) +find_program(CCACHE_PROGRAM ccache) +if(SCCACHE_PROGRAM) + message(STATUS "Using sccache as compiler launcher: ${SCCACHE_PROGRAM}") + set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") +elseif(CCACHE_PROGRAM) + message(STATUS "Using ccache as compiler launcher: ${CCACHE_PROGRAM}") + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") +endif() diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index e08315b9..27e2b6b6 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -57,6 +57,7 @@ function(alp_setup_gdal) -DBUILD_CSHARP_BINDINGS=OFF -DGDAL_USE_ICONV=OFF -DGDAL_USE_EXTERNAL_LIBS=OFF + -DGDAL_USE_CURL=ON -DGDAL_USE_GEOS=ON -DGDAL_USE_SQLITE3=ON "-DCMAKE_INSTALL_RPATH=\$ORIGIN/../../proj/lib" diff --git a/cmake/SetupJpegXL.cmake b/cmake/SetupJpegXL.cmake new file mode 100644 index 00000000..e03a6af8 --- /dev/null +++ b/cmake/SetupJpegXL.cmake @@ -0,0 +1,59 @@ +############################################################################# +# AlpineMaps.org +# Copyright (C) 2026 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 . +############################################################################# + +if(NOT COMMAND alp_setup_cmake_project) + include(${CMAKE_CURRENT_LIST_DIR}/SetupCMakeProject.cmake) +endif() + +# libjxl installs only pkg-config files. JpegXL::jxl is therefore defined from +# the static libraries of the install, which also contains highway and brotli. +function(alp_setup_jpegxl version) + alp_setup_cmake_project(libjxl + URL https://github.com/libjxl/libjxl.git COMMITISH ${version} + CMAKE_ARGUMENTS + -DBUILD_SHARED_LIBS=OFF + -DBUILD_TESTING=OFF + -DJPEGXL_ENABLE_TOOLS=OFF + -DJPEGXL_ENABLE_FUZZERS=OFF + -DJPEGXL_ENABLE_DOXYGEN=OFF + -DJPEGXL_ENABLE_MANPAGES=OFF + -DJPEGXL_ENABLE_BENCHMARK=OFF + -DJPEGXL_ENABLE_EXAMPLES=OFF + -DJPEGXL_ENABLE_JNI=OFF + -DJPEGXL_ENABLE_SJPEG=OFF + -DJPEGXL_ENABLE_OPENEXR=OFF + -DJPEGXL_ENABLE_TCMALLOC=OFF + -DJPEGXL_BUNDLE_LIBPNG=OFF + ) + + find_path(_alp_jpegxl_include_dir jxl/decode.h + PATHS "${ALP_libjxl_INSTALL_DIR}/include" NO_DEFAULT_PATH NO_CACHE REQUIRED) + # Static link order: dependents before their dependencies. + set(_alp_jpegxl_libraries) + foreach(_alp_name IN ITEMS jxl jxl_cms hwy brotlidec brotlienc brotlicommon) + find_library(_alp_jpegxl_${_alp_name} NAMES ${_alp_name} + PATHS "${ALP_libjxl_INSTALL_DIR}" PATH_SUFFIXES lib lib64 NO_DEFAULT_PATH NO_CACHE REQUIRED) + list(APPEND _alp_jpegxl_libraries "${_alp_jpegxl_${_alp_name}}") + endforeach() + + # Global, because static libraries pass their private link dependencies on to every linking directory. + add_library(JpegXL::jxl INTERFACE IMPORTED GLOBAL) + target_include_directories(JpegXL::jxl INTERFACE "${_alp_jpegxl_include_dir}") + target_compile_definitions(JpegXL::jxl INTERFACE JXL_STATIC_DEFINE JXL_CMS_STATIC_DEFINE) + target_link_libraries(JpegXL::jxl INTERFACE ${_alp_jpegxl_libraries}) +endfunction() diff --git a/cmake/SetupOpenCV.cmake b/cmake/SetupOpenCV.cmake index e73cbbe9..f4be5639 100644 --- a/cmake/SetupOpenCV.cmake +++ b/cmake/SetupOpenCV.cmake @@ -41,7 +41,7 @@ function(alp_setup_opencv version) -DWITH_OPENGL=OFF -DWITH_GSTREAMER=OFF -DWITH_FFMPEG=OFF - -DWITH_JPEG=ON -DWITH_PNG=ON -DWITH_TIFF=ON + -DWITH_JPEG=ON -DBUILD_JPEG=ON -DWITH_PNG=ON -DWITH_TIFF=ON -DWITH_GTK=OFF -DWITH_QT=OFF -DBUILD_LIST=core,imgproc,imgcodecs ) diff --git a/docs/adr/0001-shared-raster-attribution-table.md b/docs/adr/0001-shared-raster-attribution-table.md new file mode 100644 index 00000000..b0255384 --- /dev/null +++ b/docs/adr/0001-shared-raster-attribution-table.md @@ -0,0 +1,16 @@ +# Share an attribution table across raster snapshots + +RF snapshots normally share a source-attribution table. A TB receives an +independent copy beside its index with the same attribution numbering. This +allows compatible tiles to be hard-linked across RF and TB roots while the TB +can be transferred as one directory and remains valid after RF removal or +table edits. Lookup checks beside the index, then the index directory's parent +and grandparent, giving the nearest table precedence. + +Independent numbering could assign the same index to different sources, +requiring attribution rasters to be rewritten when combining snapshots. +Preserving numbering avoids that rewrite. RF snapshots using a shared table +depend on it; a TB with its local copy is self-contained. Entries are maintained +manually and may be corrected or reused once the user knows that no retained +snapshot using that table refers to them. Removing an entry and shifting later +indices is forbidden. Tools do not edit entries; TB construction copies them. diff --git a/docs/adr/0002-native-raster-pixel-buffers.md b/docs/adr/0002-native-raster-pixel-buffers.md new file mode 100644 index 00000000..2de27ac9 --- /dev/null +++ b/docs/adr/0002-native-raster-pixel-buffers.md @@ -0,0 +1,11 @@ +# Store native raster pixel buffers inside the envelope + +The first `.amort` format stores dimensions and the native element bytes of +the data and attribution rasters inside `io::envelope`. Supporting only x86 +and the project's current unpadded GLM configuration lets one templated codec +preserve payload bits without per-component GLM serializers or an explicit +matrix of type instantiations. This binds the byte encoding to the supported +scalar representations and pixel layouts; it does not promise portability +to other layouts or architectures. Compile-time layout checks and read-time +byte-count checks enforce that representation, while the metadata retains the +payload-type identifier. diff --git a/docs/adr/0005-xxh3-64-envelope-hashes.md b/docs/adr/0005-xxh3-64-envelope-hashes.md new file mode 100644 index 00000000..7452c175 --- /dev/null +++ b/docs/adr/0005-xxh3-64-envelope-hashes.md @@ -0,0 +1,44 @@ +# Hash envelope payloads with XXH3-64 + +Envelopes store XXH3-64 (seed 0) of the uncompressed payload as 8 big-endian +bytes in their header, and readers verify it. The hash is both the integrity +check and a content identity that `io::envelope::read_header` exposes without +reading the payload, for TB dependency records and RF merger fingerprints. +Hashing uncompressed bytes keeps the identity independent of the Zstandard +settings. The header field is binary, not text, and `io::hash::Algorithm` +leaves room for longer or stronger hashes. + +Single-threaded throughput on an AMD Ryzen 9 3900X over 1 GiB in memory: + +| Algorithm | Output | Throughput | +|---|---:|---:| +| XXH3-64, `io::hash::data`, Release build | 64 bit | 18.3 GB/s | +| CRC-32C, SSE4.2 | 32 bit | 10.7 GB/s | +| SHA-1, SHA-NI (`sha1sum`) | 160 bit | 1.8 GB/s | +| SHA-256, SHA-NI (`sha256sum`) | 256 bit | 1.6 GB/s | +| FNV-1a 64 | 64 bit | 1.1 GB/s | +| BLAKE2b (`b2sum`) | 512 bit | 0.9 GB/s | +| MD5 (`md5sum`) | 128 bit | 0.8 GB/s | +| CRC-32C, former envelope table | 32 bit | 0.5 GB/s | + +XXH3-64 was measured with the Catch2 benchmark in +`unittests/terrainlib/io_hash.cpp` at 58.7 ms mean over 100 samples, compiled +with the project's Release flags (`-O3`, no `-march`). A 4096-pixel float tile +with attribution is 96 MiB uncompressed, so the hash costs about 5 ms per write +or verified read, against a few hundred for Zstandard. With 64 bits and no +adversary, a changed payload keeps its hash with probability about 2⁻⁶⁴. + +Rejected alternatives: + +- FNV-1a, formerly used for RF merger fingerprints: about 90 ms per tile and + weaker mixing. +- CRC-32C: 32 bits are too few for an identity, and the table implementation + was the slowest option. It was removed rather than kept beside XXH3-64. +- The Zstandard frame checksum: only the low 32 bits of XXH64, stored at the + end of the frame and unavailable for uncompressed payloads. The + `…WithChecksum` compression variants were removed; frames are written + without it. +- A snapshot UUID: identifies a write, not content, so identical tiles from + different runs would not match. +- The SHA family: slower by an order of magnitude, with collision resistance + that is not needed without an adversary. diff --git a/docs/code-style.md b/docs/code-style.md index bed47211..d50ad2d7 100644 --- a/docs/code-style.md +++ b/docs/code-style.md @@ -34,7 +34,15 @@ Use consistent names for related classes and operations. A function name should ## Error handling -Functions that can fail return `Expected`, which is an alias for `std::expected`. +Failures fall into three tiers: + +- **Bugs:** violated invariants and preconditions, including results that can fail only through a programming error. Use libassert `ASSERT`, `DEBUG_ASSERT` for checks too expensive for release builds, `PANIC` or `UNREACHABLE`, and `Error::asserting_unwrap` for such an `Expected`. They abort with a stack trace. +- **Recoverable failures:** reusable functions, including all of terrainlib, return `Expected`, which is an alias for `std::expected`, whenever a caller might react to the failure. Library code does not throw. +- **Unrecoverable failures in tools:** application code of command-line tools may throw `Error::Exception` for invalid input and runtime failures it cannot recover from, using `Error::raise` for new errors and `Error::throwing_unwrap` for failed results. The tool's `main` catches it, logs the error and its stack trace, and exits with a nonzero status. + +Every `Error` records the stack trace where it was made; `Error::stacktrace()` resolves it for logging. + +The rest of this section describes `Expected` results. ### Checking results diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index ae885f41..a3fb601c 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -1,25 +1,16 @@ # 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. +This directory describes authoritative raster tile storage and the planned +generation of delivery tile pyramids from it. RF import and merging are +implemented in [tile-foundry](https://github.com/AlpineMapsOrg/tile-foundry); +TB generation and the tile server remain future work. ## Documents - [Terminology](terminology.md) -- [Status quo and reuse assessment](status-quo.md) -- [Architecture](architecture.md) +- [Architecture](architecture.md): components, lifecycle, and the planned + TB builder and tile server - [Storage format](storage-format.md) -- [Sampling and pyramid generation](sampling-and-generation.md) -- [Implemented refactor status](refactor-status.md) +- [TODO](todo.md) -## Plans - -- [Store refactoring plan](refactor-plan.md) -- [Raster store TODO](todo.md) - -## Scope - -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. +Decisions and their rationale are recorded in the [ADRs](../adr/). diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 392c02e9..38ea1bc4 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -1,83 +1,33 @@ # Architecture -## Implementation status - -This document describes the intended raster-fundamentalis and tile-base -architecture. Its RF format, snapshot-publication, builder, merger, pyramid, -and server requirements are future work; they were not acceptance criteria -for the completed 2D/3D hierarchy-store refactor. The implemented boundary is -recorded in [refactor-status.md](refactor-status.md), and the original plan is -kept in [refactor-plan.md](refactor-plan.md). - -The refactor delivered the dimension-neutral mechanisms needed by that future -work: - -- `store::Index`, `store::traverse`, layouts, runtime codecs, storage, - typed errors, and cache interfaces; -- `octree::StoreTraits` plus legacy 3D layout/index/open adapters; -- `raster_store::StoreTraits` for in-memory topology keyed by - `radix::tile::Id`; and -- SF-only topology validation under `sf`. - -It deliberately did not define a persistent RF index, RF payload codec, RF -opening API, `rf_builder`, `rf_merger`, tile-base generator, or tile server. - -### Final public names - -The shared API uses `store::NodeStatus`, `store::NodeStatusOrMissing`, -`store::Index`, `store::traverse`, `store::RawStorage`, -`store::Storage`, and `store::IndexedStorage`. Mesh storage aliases live under -`mesh::storage`; DAG batch and metadata aliases live under `dag::storage`. -The `octree` namespace retains the 3D key, traits, path mappings, legacy index -DTO, and mesh-opening compatibility functions. - -A legacy 3D mesh dataset is opened through the 3D adapter: - -```cpp -#include "mesh/storage.h" - -auto opened = mesh::storage::open_folder_indexed(dataset_path); -if (!opened.has_value()) { - return std::unexpected(opened.error()); -} -mesh::storage::IndexedStorage storage = std::move(opened.value()); -``` - -The 2D traits adapter can exercise the shared topology without implying a -persistent RF format: +This document describes the responsibilities and lifecycle of raster-store +(RS), raster-fundamentalis (RF), tile-base (TB), and the tile-server. +Raster-store provides geospatial raster tile storage for the alpine maps +project on top of the shared store mechanisms also used for 3D meshes. It is +part of the `terrainlib` target. -```cpp -#include "raster_store/StoreTraits.h" -#include "store/Index.h" -#include "store/traverse.h" - -store::Index index; -const radix::tile::Id tile{2, {1, 3}}; -auto added = index.add(tile); -auto walked = store::traverse(index, [](const auto &id, store::NodeStatus status) { - // In-memory hierarchy processing only. -}); -``` +[Storage format](storage-format.md) defines the directory layout, serialized +data, and format invariants. [Terminology](terminology.md) defines the domain +language. ## System boundary The design separates authoritative data management from delivery generation: ```text -Input rasters (GDAL) +Input rasters (e.g. GDAL) │ │ inspect, transform to Web Mercator, define source, one source per pixel -> rf_builder ▼ raster-fundamentalis (one rf per source at the beginning) │ - │ 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 + │ rf_merger: merge two rf stores based on attribution priority list ▼ Authoritative rf raster store │ - │ generate overviews using defined filtering strategy, all area pixels. for now simple averaging, later maybe larger filter sizes / more complex filters + │ generate overviews using defined filtering strategy. ▼ -tile-base store (one per layer, one per data version. user visible server should only need one version per layer) +tile-base store (one per channel; every build is a new snapshot, the server needs one version per channel) ├── read by tile-server ├── area/vertex pixel tile generation └── select resolution, type etc by url @@ -85,129 +35,115 @@ tile-base store (one per layer, one per data version. user visible server should ## Dataset organization -### Proposed - -A store root contains immutable snapshots. Each snapshot contains an index, a source attribution table and the data: - -```text -store/ -└── snapshot-id/ - ├── source_attribution_table.ard - ├── raster_store.index - └── //.amort -``` +RF and tile-base use the same storage format but live under separate roots. +Their different data-selection and hierarchy policies belong to their tools. +Additional metadata can be introduced later using the same envelope mechanism. +See the [directory layout](storage-format.md#directory-layout) for filenames +and the [attribution lookup rules](storage-format.md#source-attribution-table). + +RF snapshots normally share an attribution table. TB construction copies that +table unchanged into the output snapshot, preserving indices. This copy is +independent of the mutable RF table, so later edits or RF removal do not +affect it. The complete TB can then be transferred by rsync as one directory. +An RF snapshot using an ancestor table depends on that external table. + +The source table is maintained manually. Builders and overview generators +consume entries without editing or renumbering them. A user may correct an +entry or clear its field values while retaining the complete object. Reusing +an index requires the user to establish that no retained snapshot using that +table refers to it; the library does not track this lifecycle. The required +entry structure is specified in the storage format. See also the +[shared-table decision](../adr/0001-shared-raster-attribution-table.md). -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. +## Snapshot lifecycle -## Sparse quadtree index +Datasets may be several TiB in size, so builds and merges can take days or +weeks. The RF builder, RF merger, and TB builder follow these policies: -The index uses the same four logical states as the octree index: +- Every build or merge generates a new snapshot; published snapshots are + never mutated. +- Errors abort the build. +- Builders checkpoint the index at least every few minutes so an aborted + build remains readable as a cache. +- Callers may supply a cache store from an earlier or aborted run to skip + processing nodes that can be reused. +- Reusable tiles are hard-linked to limit storage cost. -| State | Chunk exists | Indexed descendants | -|-----------|-------------:|--------------------:| -| `Leaf` | yes | no | -| `Inner` | yes | yes | -| `Virtual` | no | yes | -| `Missing` | no | no | +Because hard links share inodes, a linked container must never be opened for +in-place modification. Obsolete snapshots can be deleted; other hard links +keep the data alive. Published snapshots are opened read-only. -`Missing` is represented by absence from the index, not serialized as an -entry. +Hard-link reuse may cross RF and TB roots. The calling tool decides whether +a tile can be reused, including payload type, dimensions, encoding, and +attribution-index compatibility. Storage performs the requested operation +and reports operational failures; it does not add semantic compatibility +checks. A TB with different tile dimensions needs newly generated tiles +wherever existing tiles cannot be reused unchanged. Linked TB payloads and +the independent attribution table remain valid after removing the RF. -The index answers structural questions only. It does not claim that every -child exists and does not mark a virtual subtree as spatially complete. +### Opening and checkpoints -### Required operations +Normal opening rejects `.part` snapshot directories. An explicit option opens +one read-only for cache reuse, using the last saved index and ignoring +unindexed files. Checkpoint timing belongs to the builder, which explicitly +saves the index periodically. -- 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. +Checkpoints replace the index through a temporary file so a failed write does +not truncate the previous checkpoint. Destruction may save a dirty index but +never publishes a snapshot. Checkpointing and publication require the caller +to finish outstanding writes first. -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. +Opening and publication do not perform an additional whole-snapshot +validation pass or scan payload files for existence. Errors are reported when +the index or a tile is read. -## Chunk model +### Publication -Each physical node owns one logical chunk, see storage-format.md +A new snapshot is assembled in a sibling directory named +`.part`. Publication follows this protocol: -## Source selection and fallback +1. Write all payload and metadata files into the `.part` directory. +2. Write the final index last and flush and close the output files. +3. Rename the directory to `` on the same filesystem. -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. +Publication returns success after the rename without reopening the snapshot. +The rename uses Linux `renameat2` with `RENAME_NOREPLACE` to reject +destination collisions; other platforms currently report `Unsupported`. -At the beginning the selection will be binary, later we may introduce blending (over pixels of one zoom level, or several zoom levels). +A `.part` directory is incomplete and is never considered published. 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. -## Snapshot lifecycle +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. -A snapshot is never mutated, instead, operations build new snapshots, while reducing disk usage by using hard links: +Publication does not guarantee durability or safe recovery across a power +failure, operating-system crash, or storage failure; there is no `fsync()`. +After such a failure, either a `.part` directory or a final snapshot may be +unusable and must be validated or rebuilt. -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. +## Tools -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. +### tb_builder -### Publication +Tile-base adds overviews to one RF and retains the data. It holds one +channel; derived channels such as shadings are produced by separate tools. Every level is occupied +and selects an adequate source. It must support at least minimum, maximum, and average +aggregation. A TB may choose different tile dimensions from its RF input. -A new snapshot is assembled in a sibling directory named -`.part`. Publication follows this protocol: +Planned location: `src/tb-builder`. -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. +### tile_server -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. +The server consumes tile-base and generates renderer tiles of the requested +dimensions, sampling placement, and encoding on the fly. Requests identify +these choices in the URL, for example `layer/vertex|area/resolution/z/x/y.ending`. +Output dimensions may be 65px or 256px, with area or vertex pixels. -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. +Delivery encoding is not yet finalized. Required options include JPEG, PNG, +compressed ETC2/DXT1, and compressed raw rasters. Some layers combine several +tile-bases, such as DTM and DSM, or percentage larger than slope angle (PLaTSA), +Phong shading, and AO shading. An ECEF bounding-sphere tree is planned later. -Publication does not guarantee durability or safe recovery across a power -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) - -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. +Planned location: `src/tile-server/*`. diff --git a/docs/raster-store/golden-e2e.sh b/docs/raster-store/golden-e2e.sh index df44f53e..8bda2058 100755 --- a/docs/raster-store/golden-e2e.sh +++ b/docs/raster-store/golden-e2e.sh @@ -2,7 +2,8 @@ # 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. +# explicit state markers. The tools write .part and publish when +# complete; an interrupted run's .part is reused as the next run's --cache. set -Eeuo pipefail @@ -136,6 +137,31 @@ verify_snapshot() printf 'Verified %s: %s payloads\n' "${snapshot}" "${payload_count}" } +# Moves an interrupted run's .part to -cache.part and sets +# CACHE_ARGUMENTS to reuse it. The tools refuse an existing . +CACHE_ARGUMENTS=() +prepare_cache() +{ + local output="$1" + local cache="${output}-cache.part" + + CACHE_ARGUMENTS=() + if [[ -d "${output}.part" ]]; then + # A resumed run's .part already links every node of the older cache. + rm -rf "${cache}" + mv "${output}.part" "${cache}" + fi + if [[ -d "${cache}" ]]; then + printf '[%s] Reusing %s as cache\n' "$(timestamp)" "${cache}" + CACHE_ARGUMENTS=(--cache "${cache}") + fi +} + +discard_cache() +{ + rm -rf "${1}-cache.part" +} + build_sf() { local name="$1" @@ -150,7 +176,7 @@ build_sf() return fi - mkdir -p "${output}" + prepare_cache "${output}" run_timed "${name}" \ "${SF_BUILDER}" \ --dataset "${dataset}" \ @@ -163,9 +189,11 @@ build_sf() --target-level "${SF_TARGET_LEVEL}" \ --output "${output}" \ --format .sfmesh \ - --threads "${SF_THREADS}" + --threads "${SF_THREADS}" \ + "${CACHE_ARGUMENTS[@]}" verify_snapshot "${output}" ".sfmesh" + discard_cache "${output}" touch "${marker}" } @@ -179,29 +207,24 @@ merge_sf() 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}" + prepare_cache "${SF_MERGED}" run_timed merge_sf \ "${SF_MERGER}" merge \ --base "${SF_BASEMAP}" \ --new "${SF_GATAKI}" \ --mask "${TIROL_MASK}" \ --output "${SF_MERGED}" \ + "${CACHE_ARGUMENTS[@]}" \ --verbosity info verify_snapshot "${SF_MERGED}" ".sfmesh" + discard_cache "${SF_MERGED}" 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)" @@ -209,19 +232,16 @@ build_dag() return fi - mkdir -p "${DAG_MERGED}" - if [[ -s "${DAG_MERGED}/octree.storeindex" ]]; then - continuation=(--resume) - fi - + prepare_cache "${DAG_MERGED}" run_timed build_dag \ "${DAG_BUILDER}" \ --input "${SF_MERGED}" \ --output "${DAG_MERGED}" \ - "${continuation[@]}" \ + "${CACHE_ARGUMENTS[@]}" \ --verbosity info verify_snapshot "${DAG_MERGED}" ".dag" ".dagmeta" + discard_cache "${DAG_MERGED}" touch "${marker}" } diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md deleted file mode 100644 index 83309521..00000000 --- a/docs/raster-store/sampling-and-generation.md +++ /dev/null @@ -1,249 +0,0 @@ -# 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/storage-format.md b/docs/raster-store/storage-format.md index 5bfaf28e..bb9d2bd9 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -1,111 +1,233 @@ # Storage format -> **Status:** future raster-store design. The completed 2D/3D store refactor -> did not implement or finalize the RF/TB formats described below. These -> requirements were not acceptance criteria for that refactor. See -> [refactor-status.md](refactor-status.md) for the implemented shared boundary. - -The 3D octree stores now use the versioned envelope formats described in -[envelope-migration-plan.md](envelope-migration-plan.md). The shared store -still supplies only an in-memory `raster_store::StoreTraits` adapter for -`radix::tile::Id`; there is currently no persistent RF index, `.amort` codec, -snapshot publisher, or RF opening API. Names and formats below remain -proposals until their own implementation plans approve them. - -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) +This document defines the shared on-disk format for raster-fundamentalis (RF) +and tile-base (TB), including source attribution. [Architecture](architecture.md) +owns system responsibilities, attribution management, and the snapshot +lifecycle. [Terminology](terminology.md) defines the domain +language. + +## Directory layout + +An RF collection typically has this layout: + +```text +snapshot-root/ + ├── source_attribution_table.json + ├── store-snapshot-id.log + └── store-snapshot-id/ + ├── raster_store.metadata + ├── raster_store.index + └── //.amort +``` + +The RF builder appends runtime messages to `.log` beside the +snapshot so startup failures and aborted runs are also recorded. + +A TB snapshot has a local attribution table: + +```text +tile-base-root/ + └── store-snapshot-id/ + ├── source_attribution_table.json + ├── raster_store.metadata + ├── raster_store.index + ├── build_log.txt + └── //.amort +``` + +The default layout ID is `zoom/x/y_google`, mapping tiles to `//` +with the codec adding `.amort` or `.amoit`. Other layouts may map the same +tile IDs differently. [Dataset organization](architecture.md#dataset-organization) +explains shared RF tables and independent TB copies. ## 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). - -## 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. -- 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. -- unlike a tile pyramid, not every level is occupied (there is no downsampled versions of the data). -- Coarse physical tiles (e.g. zoom level 10) may coexist with more accurate descendants (e.g. zoom level 15). -- 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. -- every level is occupied, and every level selects an adequate data source -- 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/* -- the delivery tile format is not yet defined - -## 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 `Crc32c` or `None`; - - `string checksum`, empty when no external checksum is used, otherwise the CRC-32C value; - - `CompressionAlgorithm compression_algorithm`, default `ZstdDefaultCompressionWithChecksum`, alternatively `ZstdBestCompressionWithChecksum` or `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. -- 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 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) -- 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 uses libzstd at its default compression level. Libzstd is imported through the project's CMake install facility from https://github.com/AlpineMapsOrgDependencies/zstd. Both zstd modes write an embedded frame checksum, which libzstd verifies while decompressing; `ZstdBestCompressionWithChecksum` remains available for explicit callers. -- `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 may be paired with `None` or `Crc32c`. Either zstd mode 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 a zstd mode. -- `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. - - -## 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) + +`source_attribution_table.json` stores a vector of attribution objects +(`Table`, containing `Entity` objects), as a top-level JSON array. Each entry +has these required fields: + +- `spatial_resolution`: numeric pixel width in metres at the equator (EPSG:3857). +- `acquisition_date`: string. +- `ingestion_date`: string. +- `copyright`: string. +- `copyright_link`: string. +- `license`: string. + +Date strings are preserved verbatim without date parsing or interpretation by +the storage library. Every slot, including slot 0, contains a complete object +with all fields; slots are never null or represented by deletion markers. +Entries the user considers removed remain ordinary objects to the library. +The library implements no clearing operation or special cleared-entry values. +Removing an entry and shifting later indices is forbidden. See +[attribution management](architecture.md#dataset-organization) for ownership +and reuse policy. + +Attribution indices must be strictly less than 65535; indices at or above +65535 produce an `Unsupported` error at checked table/API boundaries. The RF +builder validates attribution references before producing tiles, including references outside the selected table. Storage does +not scan attribution rasters to check indices on each tile read or write. +The reserved meaning of index 0 is defined under +[Attribution and payload preservation](#attribution-and-payload-preservation). + +Table lookup checks beside the index first, then the index directory's parent, +then its grandparent. It stops at the first table found and does not search +farther. Tile attribution indices refer to this selected table. Delivered +tiles use an abbreviated form of attribution information. + +## Index and metadata + +`raster_store.index` and `raster_store.metadata` use separate versioned +`io::envelope` payloads. The index contains the sparse hierarchy and the +content hash of every physical tile, so that dependency checks need no +tile-file access. Metadata +contains the layout ID, codec selector, exact payload-type identifier, and +nominal/stored tile sizes, halo width, and resolved value mapping. All tiles +in a snapshot share these values. `nominal_tile_size` defaults to 4096 and +must be a positive power of two; `halo_width` defaults to zero and cannot +exceed the nominal size. `stored_tile_size = nominal_tile_size + 2 * halo_width`. +Both square tile rasters must match the stored size, which need not be a +power of two. TB snapshots may store a halo; RF snapshots must have +`halo_width` zero. + +The value mapping is `Linear` or `SRGBA`. Creation defaults RGB8/RGBA8 to +SRGBA and other types to Linear; an explicit override takes precedence. +Metadata stores the resolved mapping. SRGBA treats RGB channels as sRGB and +alpha as linear. It is a value mapping rather than a colour-space label: +linear data also includes elevation. + +The codec selector is a codec name string, independent of file extensions. +Opening resolves a reader from this metadata string. A future resolver may +choose among compatible reader implementations; no such registry is required +for the first codec. Existing mesh stores retain extension-based selectors. + +### Version 1 representation + +The metadata codec selector is `amort` or `amoit`; payload filenames end in +`.amort` or `.amoit`. +The versioned payloads use the existing `io::envelope` serialization. Version 1 +was revised directly for halo metadata and index tile hashes; no compatibility +with earlier V1 payloads is provided. Size fields are 32-bit unsigned on +supported platforms; mapping enum values are 0 for Linear and 1 for SRGBA: + +| Envelope class | Version | Payload fields in serialization order | +|---|---|---| +| `raster_store.Metadata` | 1 | layout ID string, payload-type string, codec-selector string, `unsigned` stored_tile_size, nominal_tile_size, halo_width, `pixel::Mapping` value_mapping | +| `raster_store.Index` | 1 | vector of entries: `uint32_t` zoom, x, y, then `uint8_t` status; vector of hash entries: `uint32_t` zoom, x, y, then hash byte vector | +| `raster_store.Tile` | 1 | `uint32_t` width, height, data byte vector, attribution byte vector | +| `raster_store.ImageTile` | 1 | as `raster_store.Tile`, but the data byte vector holds a JPEG XL codestream | + +Both index vectors are written in `(zoom, x, y)` order. Readers reject +duplicate keys, invalid keys/statuses, and inconsistent parent/child topology. +Status values are 0 for `Leaf`, 1 for `Inner`, and 2 for `Virtual`. + +A tile hash is the envelope hash of the tile payload, as written by the codec; +the index stores it as opaque bytes. Opening a snapshot also rejects missing +hashes of physical tiles, hashes shorter than four bytes, and hashes for +virtual or absent tiles. Indexed reads compare the recorded hash with the one +verified during the envelope read, which detects a tile file replaced by +another valid one. `octree.StoreIndex` version 1, used for meshes and DAGs, has +the same structure with `uint8_t` level and `uint64_t` index keys. Its hashes +are required in the same way, except for codecs that do not hash, such as +glTF, whose indexes must not contain any. + +Scalar payload identifiers encode representation, such as `float32`, +`float64`, `int16`, and `uint32`. GLM vectors use `vecN`, for example +`vec3` or `vec3`. These identifiers are independent of compiler +type names. Supported scalar aliases with the same native representation +share an identifier. Both raster buffers use the stored dimensions and +row-major order, with x varying fastest. No additional raster-specific limit +is imposed on allocation; envelope serialization retains its own limits. + +### Envelope + +Every file is one `io::envelope`: a header followed by the compressed payload +as a byte vector. The header fields are, in serialization order: `uint64_t` +magic `0xA6EFA707D12E4404`, class-name string, `uint32_t` class version, +`uint8_t` hash algorithm, hash byte vector, `uint8_t` compression algorithm +and `uint64_t` uncompressed size. Strings and vectors carry a 32-bit size +prefix. Class names are at most 128 bytes and hashes at most 64 bytes, so the +header fits in `io::envelope::max_header_size`, 222 bytes. + +Hash algorithms are 0 for `None`, which stores an empty hash, and 1 for +`Xxh3_64`: XXH3-64 with seed 0 over the uncompressed payload, stored as 8 +big-endian bytes. Compression algorithms are 0 for `None`, 1 for +`ZstdBestCompression` and 2 for `ZstdDefaultCompression`; Zstandard frames are +written without their frame checksum. Writers default to `Xxh3_64` with +`ZstdDefaultCompression`. Readers reject other magic values, unknown +enumerators and oversized names or hashes, and verify the recorded hash +against the decompressed payload. + +`io::envelope::read_header` decodes only the header from a file prefix. Its +hash identifies the payload content, but that read does not verify it. Indexed +tile hashes make header reads unnecessary for dependency checks. See the +[hash decision](../adr/0005-xxh3-64-envelope-hashes.md). + +### Sparse quadtree index + +The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by +`radix::tile::Id`, using the [Google/XYZ convention](https://docs.maptiler.com/google-maps-coordinates-tile-bounds-projection/). +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. [Opening and checkpoints](architecture.md#opening-and-checkpoints) +defines when these files are read and how incomplete snapshots are opened. + +## Tile payload + +All stored tiles in one snapshot use one payload type and area pixels. Each +pixel has one data value, which may be a vector, and one `uint16_t` index into +the source-attribution table. A tile is represented by two rasters, data and +source attribution. + +Tile files use the `.amort` extension (AlpineMapsOrg raster tile) and +`io::envelope`. The writer exposes the envelope compression option, defaulting +to standard Zstandard compression, and always uses an XXH3-64 hash. Readers +accept the algorithms supported by `io::envelope`; each envelope records its +selected algorithms. Tiles hard-linked from another snapshot keep the hash of +their source, which is copied from the source index without reading the +tile. AMORT writing is lossless to preserve data through repeated merging and +overview generation. AMOIT (`.amoit`, AlpineMapsOrg image tile) supports only +RGB8 data, which it stores as a lossy JPEG XL codestream (distance 0.6, effort +3, sRGB); attribution stays lossless. RF builder uses AMOIT by default for +sRGB RGB8 snapshots; RF merger keeps the shared codec of its inputs. +Different codecs may be used for writing and reading when their +representations are compatible. The first version stores no derived tile +metadata such as extrema or contributing-attribution lists. + +### Pixel serialization scope + +Raster serialization remains templated and is instantiated only for pixel +types actually used, including basic scalar types (`float`, `double`, integer +types) and GLM vectors. The first implementation does not explicitly +instantiate a matrix of these types. Only x86 with the project's current +unpadded GLM configuration needs to be supported. + +The `.amort` envelope payload stores raster dimensions and the native element +bytes of both rasters, obtained through `radix::Raster::bytes()`. +It does not serialize the object representation of `Raster` or `std::vector`. +One templated codec handles the buffers without per-component GLM serializers. +Compile-time checks require trivially copyable pixel types and unpadded GLM +layouts. Reading allocates typed rasters and verifies buffer byte counts +before copying. The metadata's payload-type identifier still distinguishes +equally sized types such as `float` and `uint32_t`. + +See [the native-buffer decision](../adr/0002-native-raster-pixel-buffers.md). + +### Attribution and payload preservation + +Attribution rasters default to index 0, meaning unattributed. Neither that +index nor the fields of attribution entry 0 determine payload usability. +Attribution is not a validity mask: an ordinary numeric value with index 0 +participates in generic scaling just like any other supplied data value. + +AMORT preserves all payloads losslessly, including NaNs and values with +attribution zero. Callers of numerical algorithms must supply usable values; +storage does not repair or validate payloads for them. Source NoData remains +an importer concern, separate from stored attribution. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index eb98976b..76cd36fb 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -1,15 +1,135 @@ # Terminology + +**Tile interior**: +The square raster region corresponding to a tile ID's geographic extent, +excluding its halo. + +**Stored tile size**: +The side length in pixels of a persisted tile raster, including its halo. + +**Nominal tile size**: +The side length in pixels of a tile's interior, excluding its halo. + +**Value mapping**: +The interpretation of stored numeric values as linear values or sRGB-encoded +colour channels with linear alpha where present. Linear values include +elevation data as well as linear colour data. + +**Tile halo**: +Additional raster pixels surrounding a tile interior on all four sides, +including the corners, at the interior's pixel spacing. +_Avoid_: Tile border + +**Halo width**: +The number of halo pixels on each side of a tile interior. +_Avoid_: Border width + +**Tile with halo**: +A tile whose data and attribution rasters cover both its interior and halo. +A halo width of zero is permitted. +_Avoid_: Tile with borders, tile with border + +**Stored halo width**: +The halo width of the tile rasters persisted in a raster-store snapshot. +TB snapshots may store a halo; RF snapshots must not. + +**Requested halo width**: +The halo width of the tile requested by a consumer of the raster store. + ** 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. +: The catalog giving meaning to a snapshot's attribution indices, including resolution, dates, copyright, and license information. It may be shared among snapshots or copied into an individual snapshot. + +**Attribution entry** +: A source attribution identified by its position in the applicable source-attribution table, kept stable while referenced. Several imports and validity masks may use the same entry. + +**Unattributed pixel** +: A stored pixel with attribution index 0. Missing attribution makes no claim + about the usability of its payload and does not exclude it from arithmetic. +_Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) + +**Source NoData** +: Missing or invalid samples declared by an input dataset, such as through a + GDAL NoData value or source validity mask. This is an import concern, distinct + from missing attribution in stored rasters. + +**Validity mask** +: A temporary RF-builder input defining the spatial region selecting the centres + of accepted output pixels. It does not exclude otherwise valid source + contributions to filtering outside that region. It is not retained in the RF. **raster-fundamentalis (rf)** -: The authoritative raster store +: The authoritative raster store from which delivery data is derived. + +**RF import** +: Raster data accepted from one prepared GDAL dataset or one online tile + pyramid under one validity mask and attributed to one source-attribution entry. + +**Source tile** +: An input raster covering one tile-grid region at a particular source zoom. + It is distinct from an RF tile, which can cover the region of many source tiles. + +**Source tile pyramid** +: A hierarchy of source tiles representing a region at different zoom levels, + potentially with different deepest available levels in different places. + +**Source zoom** +: The spatial subdivision level of the source tile grid. It does not express + pixel spacing without the source tile dimensions and need not equal RF zoom. + +**Minimum source zoom** +: The first source level considered by an import; lower levels are outside + its source search and fallback range. + +**Maximum source zoom** +: The ceiling of an import's source search. It does not require all regions + to have data at that level. + +**RF leaf tile** +: A physical RF tile with no physical descendants. A disjoint RF import has + neither a physical ancestor nor a physical descendant of any retained tile. + +**Disjoint RF snapshot** +: An RF snapshot in which no physical tile has a physical ancestor or + descendant. Different regions may have different RF zoom levels. + +**Attribution priority** +: The explicit preference assigned to an attribution entry when competing + valid raster values cover the same location. + +**Priority table** +: The common ordered list of attribution entries used to select between the + two inputs of an RF merge. It need not list every attribution present. + +**Representative attribution** +: The single attribution index retained for a resampled value under the + shared scaling rules. It is not + a complete record of the sources contributing to that value. + +**Ancestor fallback** +: Coarser source imagery supplying a region where finer source imagery is + absent. The supplying source ancestor need not be retained as an RF tile. + +**Local sampling ratio** +: The output pixel spacing divided by the smallest source pixel size at a + location, both measured in output coordinates. Values above one reduce source + detail. + +**Channel** +: One kind of raster data, such as orthophoto RGB, a digital surface model + (DSM), a digital terrain model (DTM) or a shading. **tile-base (tb)** -: basically rf with overviews, used to generate derived tiles +: A hierarchical raster store holding one channel. It retains the data of one + RF and adds overview levels, built bottom-up by a reduction chosen for that + channel, such as filtered resampling, minimum or maximum. Several TBs exist + side by side, one per channel. + +**Layer** +: A client-visible tile product served by the tile server. A layer is derived + from one or more TBs, for example DSM and DTM combined, or several shadings. **Vertex pixel** : A generated value located on a grid vertex. Height tiles for mesh generation diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index fe9bf14a..f36808ed 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -3,3 +3,7 @@ - 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. +- Design derived-channel generation (shading, slope, PLaTSA, AO). It is not + part of the TB builder, which converts one RF into one TB without computing + channels. A separate tool is expected to produce an RF-like input for the TB + builder; \ No newline at end of file diff --git a/docs/sf-merger-multipolygon-mask-topology.md b/docs/sf-merger-multipolygon-mask-topology.md index a9e04855..31bddf04 100644 --- a/docs/sf-merger-multipolygon-mask-topology.md +++ b/docs/sf-merger-multipolygon-mask-topology.md @@ -192,15 +192,3 @@ Changes to SF mask processing must preserve these invariants: coordinates touch. 8. Meshes passed to CGAL volume operations satisfy the required topology, and Boolean outputs are validated in debug builds. - -## Regression coverage - -The relevant tests are: - -- `unittests/sf_merger/mask.cpp`: disconnected and point-touching synthetic - masks, union and empty-mask semantics, and the real multipolygon fixture after - node-bounds clipping. -- `unittests/sf_merger/integration.cpp`: an end-to-end SF build and merge across - regular and point-touching mask borders. -- `unittests/dag_builder/multi_component.cpp`: the downstream simplification - handoff for disconnected components that meet at one point. diff --git a/misc/suppression/tsan.txt b/misc/suppression/tsan.txt new file mode 100644 index 00000000..c18f0c76 --- /dev/null +++ b/misc/suppression/tsan.txt @@ -0,0 +1,15 @@ +# Preserve the oneTBB suppressions previously used by CI. +# Possible data race during ittnotify initialization. Low impact. +race:__itt_nullify_all_pointers +race:__itt_init_ittlib + +# GDAL's unlocked poNewest comparison in Touch() races with cache-head updates. +# The cache lock protects list changes; accept this data race instead of carrying +# a dependency patch. TSan matches stack frames, so other races through Touch() +# are also excluded by this rule. +race:GDALRasterBlock::Touch() + +# Q12 accepts GDAL 3.10's same-value MEM creation callback race. Independent +# RF fill datasets still share the registered driver. This stack suppression +# also excludes other races reached through Create(); no fill-wide lock is used. +race:GDALDriver::Create( diff --git a/misc/suppression/ubsan.txt b/misc/suppression/ubsan.txt new file mode 100644 index 00000000..3899c176 --- /dev/null +++ b/misc/suppression/ubsan.txt @@ -0,0 +1,24 @@ +# CGAL 6.0.1 arrangement facade downcasts. +# https://github.com/CGAL/cgal/issues/9140 +# vptr_check matches the dynamic type's Itanium-mangled name. +vptr_check:N4CGAL8Arr_faceI +vptr_check:N4CGAL12Arr_halfedgeI +vptr_check:N4CGAL10Arr_vertexI +vptr_check:N4CGAL24Arrangement_on_surface_2I + +# The trapezoidal-decomposition constructors ignore the old Boolean returned +# by this setter. Exclude that read instead of patching the CGAL header. +# Only Boolean diagnostics in this specific setter are excluded. +bool:CGAL::Trapezoidal_decomposition_2<*::set_with_guarantees(bool) + +# OpenCV's random-number dispatch casts typed functions to a common signature. +# Suppress only function-type diagnostics in that implementation. +function:*opencv/modules/core/src/rand.cpp + +# OpenCV's IPP color conversion dispatch casts typed functions to a common signature. +# Suppress only function-type diagnostics in that implementation. +function:*opencv/modules/imgproc/src/color.hpp + +# OpenCV's IPP resize fallback uses -1 as an unsupported-interpolation sentinel. +# Suppress only enum diagnostics in that implementation. +enum:*opencv/modules/imgproc/src/resize.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aeea6394..50c4d072 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,23 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2023 Adam Celarek-Litofcenko +# Copyright (C) 2023 Martin Braunsperger +# Copyright (C) 2023 Adrian Gawor +# +# 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 . +# ***************************************************************************** + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) message(STATUS "C++ Standard: ${CMAKE_CXX_STANDARD}") @@ -32,6 +52,9 @@ find_package(CGAL REQUIRED) include(../cmake/SetupOpenCV.cmake) alp_setup_opencv(4.11.0) +include(../cmake/SetupJpegXL.cmake) +alp_setup_jpegxl(v0.12.0) + 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}) @@ -65,6 +88,10 @@ alp_setup_cmake_project(zstd ) find_package(zstd CONFIG REQUIRED) +alp_add_git_repository(xxhash URL https://github.com/Cyan4973/xxHash.git COMMITISH v0.8.3 DO_NOT_ADD_SUBPROJECT) +add_library(xxhash INTERFACE) +target_include_directories(xxhash SYSTEM INTERFACE ${xxhash_SOURCE_DIR}) + 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) @@ -75,7 +102,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 6babc632401cb95a701ce431cb68adf91e2c1b71 NOT_SYSTEM) +alp_add_git_repository(radix URL https://github.com/AlpineMapsOrg/radix.git COMMITISH 39bf8b0c89459b3266048086a8d0121992c8f430 NOT_SYSTEM) set(ALP_CLI_COMPONENT_OPTIONS ALP_BUILD_SF_MERGER @@ -156,9 +183,6 @@ endif() add_subdirectory(terrainlib) -if(ALP_BUILD_TILE_BUILDER) - add_subdirectory(tile_builder) -endif() if(ALP_BUILD_SF_MERGER) add_subdirectory(sf_merger) endif() diff --git a/src/dag_builder/CMakeLists.txt b/src/dag_builder/CMakeLists.txt index 5acf10af..f98259bc 100644 --- a/src/dag_builder/CMakeLists.txt +++ b/src/dag_builder/CMakeLists.txt @@ -1,5 +1,23 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# Copyright (C) 2026 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 . +# ***************************************************************************** + add_library(dagbuilderlib - ContinuationMode.h Partitioning.h TextureSet.h VertexInCluster.h @@ -15,6 +33,7 @@ add_library(dagbuilderlib dag_node.h fixed.h fixed_point.h + inputs.h meshopt.h metadata.h optimize.h diff --git a/src/dag_builder/ContinuationMode.h b/src/dag_builder/ContinuationMode.h deleted file mode 100644 index 46a707ab..00000000 --- a/src/dag_builder/ContinuationMode.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -enum class ContinuationMode { - Error, - Resume, - Overwrite, -}; diff --git a/src/dag_builder/Partitioning.h b/src/dag_builder/Partitioning.h index cbc6fba5..25d60777 100644 --- a/src/dag_builder/Partitioning.h +++ b/src/dag_builder/Partitioning.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/TextureSet.h b/src/dag_builder/TextureSet.h index 5f4361d4..03228818 100644 --- a/src/dag_builder/TextureSet.h +++ b/src/dag_builder/TextureSet.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/VertexInCluster.h b/src/dag_builder/VertexInCluster.h index cafdccfa..3dfa0911 100644 --- a/src/dag_builder/VertexInCluster.h +++ b/src/dag_builder/VertexInCluster.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/VertexInClustering.h b/src/dag_builder/VertexInClustering.h index 4600141c..eee21b86 100644 --- a/src/dag_builder/VertexInClustering.h +++ b/src/dag_builder/VertexInClustering.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/BakeSource.h b/src/dag_builder/atlas/BakeSource.h index d10690da..aa7a937c 100644 --- a/src/dag_builder/atlas/BakeSource.h +++ b/src/dag_builder/atlas/BakeSource.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/bake_cluster_texture.h b/src/dag_builder/atlas/bake_cluster_texture.h index ee3c201c..5789054c 100644 --- a/src/dag_builder/atlas/bake_cluster_texture.h +++ b/src/dag_builder/atlas/bake_cluster_texture.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/pull_reproject_texture.h b/src/dag_builder/atlas/pull_reproject_texture.h index 736a3089..fc468cfe 100644 --- a/src/dag_builder/atlas/pull_reproject_texture.h +++ b/src/dag_builder/atlas/pull_reproject_texture.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/HorizontalStripPlanner.cpp b/src/dag_builder/atlas/rect/HorizontalStripPlanner.cpp index 25dd0389..455e30d7 100644 --- a/src/dag_builder/atlas/rect/HorizontalStripPlanner.cpp +++ b/src/dag_builder/atlas/rect/HorizontalStripPlanner.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/dag_builder/atlas/rect/HorizontalStripPlanner.h b/src/dag_builder/atlas/rect/HorizontalStripPlanner.h index 9f1e7935..7af840e4 100644 --- a/src/dag_builder/atlas/rect/HorizontalStripPlanner.h +++ b/src/dag_builder/atlas/rect/HorizontalStripPlanner.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/PackingPlanner.cpp b/src/dag_builder/atlas/rect/PackingPlanner.cpp index 5356abf6..785af01a 100644 --- a/src/dag_builder/atlas/rect/PackingPlanner.cpp +++ b/src/dag_builder/atlas/rect/PackingPlanner.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/dag_builder/atlas/rect/PackingPlanner.h b/src/dag_builder/atlas/rect/PackingPlanner.h index e06eacb8..12826247 100644 --- a/src/dag_builder/atlas/rect/PackingPlanner.h +++ b/src/dag_builder/atlas/rect/PackingPlanner.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/Plan.h b/src/dag_builder/atlas/rect/Plan.h index 8097c9dc..3e9aa810 100644 --- a/src/dag_builder/atlas/rect/Plan.h +++ b/src/dag_builder/atlas/rect/Plan.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/Planner.h b/src/dag_builder/atlas/rect/Planner.h index 53a772d7..8a359715 100644 --- a/src/dag_builder/atlas/rect/Planner.h +++ b/src/dag_builder/atlas/rect/Planner.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/atlas.cpp b/src/dag_builder/atlas/rect/atlas.cpp index 9c0f1f31..28f8df9e 100644 --- a/src/dag_builder/atlas/rect/atlas.cpp +++ b/src/dag_builder/atlas/rect/atlas.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/atlas/rect/atlas.h b/src/dag_builder/atlas/rect/atlas.h index f67b92c2..3a8b9cbf 100644 --- a/src/dag_builder/atlas/rect/atlas.h +++ b/src/dag_builder/atlas/rect/atlas.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/rect/create.h b/src/dag_builder/atlas/rect/create.h index 362f3147..1a8bb64b 100644 --- a/src/dag_builder/atlas/rect/create.h +++ b/src/dag_builder/atlas/rect/create.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/atlas/reprojection.h b/src/dag_builder/atlas/reprojection.h index b09f83ed..bed733eb 100644 --- a/src/dag_builder/atlas/reprojection.h +++ b/src/dag_builder/atlas/reprojection.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index e398e51f..103057c9 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -1,5 +1,27 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include @@ -17,7 +39,7 @@ #include "compact.h" #include "geometry/geometry.h" #include "numeric/int_math.h" -#include "log.h" +#include "cli_support/log.h" #include "merge/clusterings.h" #include "mesh/SimpleMesh.h" #include "mesh/io.h" @@ -27,7 +49,7 @@ #include "octree/Space.h" #include "mesh/storage.h" #include "store/traverse.h" -#include "ProgressIndicator.h" +#include "cli_support/ProgressIndicator.h" #include "partition.h" #include "simplify.h" #include "slice.h" @@ -37,7 +59,7 @@ #include "utils.h" #include "vertex_lock.h" #include "parallel.h" -#include "ContinuationMode.h" +#include "store/snapshot.h" #include "sf/validate_index.h" namespace dag { @@ -155,6 +177,10 @@ struct BuildContext { const octree::Space &space; const octree::OddLevelShifted &shifted_space; const radix::geometry::Aabb3d &root_bounds; + const dag::storage::IndexedStorage* cache; + const std::function& stop_requested; + mutable std::atomic_bool stopped = false; + mutable std::atomic last_checkpoint = std::chrono::steady_clock::now().time_since_epoch().count(); }; // Assemble a node's metadata from a build_lod result. @@ -487,8 +513,18 @@ std::optional find_max_input_level(const std::vector build_level( +std::filesystem::path debug_mesh_path(const std::filesystem::path& output_path) +{ + auto path = store::snapshot::normalized(output_path); + if (store::snapshot::is_partial(path)) { + path.replace_extension(); + } + path += "-debug"; + return path; +} + +// Build a single level of the DAG, returning the set of nodes that were built or the first failure. +Expected> build_level( const uint32_t level, const std::vector> &input_by_level, const std::unordered_set &prev_level_built, @@ -502,42 +538,57 @@ std::unordered_set build_level( LOG_INFO("Building level {} ({} targets)", level, targets.size()); - std::unordered_set already_built; - if (ctx.options.continuation_mode != ContinuationMode::Overwrite) { - for (const octree::Id &target : targets) { - if (DEBUG_ASSERT_VAL(ctx.output_storage.has(target)).value()) { - already_built.insert(target); - } - } - } - - if (ctx.options.continuation_mode == ContinuationMode::Error && !already_built.empty()) { - LOG_ERROR_AND_EXIT("Found some of target nodes already in built in the output directory, use --resume or --overwrite"); - } - // Initialize debug storage if requested (contains .glb meshes) std::optional> debug_storage = std::nullopt; if (ctx.options.write_debug_meshes) { mesh::storage::OpenOptions options; options.preferred_extension = ".glb"; - auto debug_result = mesh::storage::open_folder( - ctx.output_storage.base_path().string() + "-debug", - std::move(options)); + auto debug_result = mesh::storage::open_folder(debug_mesh_path(ctx.output_storage.base_path()), std::move(options)); if (!debug_result) { - LOG_ERROR_AND_EXIT( - "Failed to open debug mesh storage: {}", - debug_result.error().to_string()); + return Error::propagate(std::move(debug_result), "open debug mesh storage"); } debug_storage.emplace(std::move(debug_result.value())); } tbb::concurrent_vector saved_ids; - ProgressIndicator progress(targets.size()); + cli_support::ProgressIndicator progress(targets.size()); auto progress_thread = progress.start_monitoring(); + tbb::concurrent_vector linked_ids; + // One task at a time saves the index once the checkpoint interval has passed. + const auto checkpoint = [&] { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + auto last = ctx.last_checkpoint.load(); + if (now - std::chrono::steady_clock::duration(last) < store::snapshot::checkpoint_interval + || !ctx.last_checkpoint.compare_exchange_strong(last, now.count())) { + return; + } + if (const auto saved = ctx.output_storage.save_or_create_index(); !saved) { + LOG_WARN("Could not checkpoint the index: {}", saved.error().to_string()); + } + }; + // A failure stops the level like a stop request: active tasks finish their nodes, queued ones are skipped. + tbb::concurrent_vector failures; + const auto fail = [&](std::unexpected error) { + failures.push_back(std::move(error).error()); + ctx.stopped = true; + progress.task_finished(); + }; + parallel_foreach(targets, [&](const octree::Id &target) { - if (already_built.contains(target)) { + if (ctx.stopped || (ctx.stop_requested && ctx.stop_requested())) { + ctx.stopped = true; + progress.task_finished(); + return; + } + if (ctx.cache != nullptr && DEBUG_ASSERT_VAL(ctx.cache->has(target)).value()) { + auto linked = ctx.output_storage.link_from(target, *ctx.cache); + if (!linked) { + fail(Error::propagate(std::move(linked), fmt::format("link cached node {}", target))); + return; + } + linked_ids.push_back(target); progress.task_finished(); return; } @@ -547,32 +598,32 @@ std::unordered_set build_level( auto result = build_node(target, target_input_ids, dag_ids, ctx); if (result) { - const auto save_result = ctx.output_storage.save(target, *result); - if (save_result) { - if (debug_storage) { - const auto debug_mesh = clustering_to_mesh(result->clustering); - const auto debug_save_result = debug_storage->save(target, debug_mesh); - if (!debug_save_result) { - LOG_ERROR_AND_EXIT( - "Failed to save debug mesh for node {}: {}", - target, - debug_save_result.error().to_string()); - } + auto save_result = ctx.output_storage.save(target, *result); + if (!save_result) { + fail(Error::propagate(std::move(save_result), fmt::format("save node {}", target))); + return; + } + if (debug_storage) { + const auto debug_mesh = clustering_to_mesh(result->clustering); + auto debug_save_result = debug_storage->save(target, debug_mesh); + if (!debug_save_result) { + fail(Error::propagate(std::move(debug_save_result), fmt::format("save debug mesh for node {}", target))); + return; } - saved_ids.push_back(target); - } else { - LOG_ERROR( - "Failed to save node {}: {}", - target, - save_result.error().to_string()); } + saved_ids.push_back(target); } + checkpoint(); progress.task_finished(); }, ctx.options.parallelize); progress_thread.join(); - std::unordered_set built = std::move(already_built); + if (!failures.empty()) { + return std::unexpected(std::move(failures.front())); + } + + std::unordered_set built(linked_ids.begin(), linked_ids.end()); for (const octree::Id &id : saved_ids) { built.insert(id); } @@ -587,7 +638,9 @@ Expected build_levels( const mesh::storage::IndexedStorage &input_storage, dag::storage::IndexedStorage &output_storage, const BuildOptions &options, - const AnyRange &level_range) { + const AnyRange &level_range, + const dag::storage::IndexedStorage *cache, + const std::function &stop_requested) { auto validation = sf::validate_index(input_storage.index()); if (!validation) { return validation; @@ -617,31 +670,29 @@ Expected build_levels( return {}; } - // Seed prev_level_built with any already-built nodes one level finer than the first level. - // TODO: use hierachical lookup here based on root_bounds and IdRect std::unordered_set prev_level_built; - for (const auto &[id, status] : output_storage.index()) { - if (id.level() == range.end && status != store::NodeStatus::Virtual) { - prev_level_built.insert(id); - } - } - - BuildContext ctx{input_storage, store::ThreadSafeStorage(std::move(output_storage)), options, space, shifted_space, root_bounds}; + BuildContext ctx{input_storage, store::ThreadSafeStorage(std::move(output_storage)), options, space, shifted_space, root_bounds, cache, stop_requested}; for (uint32_t level = range.end; level-- > range.start;) { - prev_level_built = build_level( - level, - input_by_level, - prev_level_built, - ctx); + auto built = build_level(level, input_by_level, prev_level_built, ctx); - // Persist per level so a finished level can be read back before the whole run completes + // Persist per level so a finished level can be read back before the whole run completes, + // and after a failure so the nodes committed before it are kept if (const auto result = ctx.output_storage.save_or_create_index(); !result) { LOG_WARN( "Could not save index after level {}: {}", level, result.error().to_string()); } + if (!built) { + output_storage = std::move(ctx.output_storage).release(); + return Error::propagate(std::move(built), fmt::format("build DAG level {}", level)); + } + if (ctx.stopped) { + output_storage = std::move(ctx.output_storage).release(); + return Error::fail(Error::Code::Cancelled, "DAG build cancelled; incomplete snapshot retained"); + } + prev_level_built = std::move(built.value()); } output_storage = std::move(ctx.output_storage).release(); diff --git a/src/dag_builder/build.h b/src/dag_builder/build.h index 041fad1a..b0ea0511 100644 --- a/src/dag_builder/build.h +++ b/src/dag_builder/build.h @@ -1,10 +1,30 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "ContinuationMode.h" #include "Range.h" #include "build_config.h" #include "texturing.h" @@ -29,18 +49,24 @@ struct BuildOptions { IncludeMode include_mode = IncludeMode::CurrentOnly; bool write_debug_meshes = IS_DEBUG_BUILD; bool parallelize = false; - ContinuationMode continuation_mode = ContinuationMode::Error; }; +// Debug meshes go beside the output as -debug, also while it is built in .part. +std::filesystem::path debug_mesh_path(const std::filesystem::path& output_path); + Expected build_full( const mesh::storage::IndexedStorage &input_storage, dag::storage::IndexedStorage &output_storage, const BuildOptions &options); -Expected build_levels( - const mesh::storage::IndexedStorage &input_storage, - dag::storage::IndexedStorage &output_storage, - const BuildOptions &options, - const AnyRange &level_range); +// Builds the levels into output_storage, linking nodes that the cache has instead of +// building them. When stop_requested returns true, the active nodes are finished and +// the result is an error with code Cancelled. +Expected build_levels(const mesh::storage::IndexedStorage& input_storage, + dag::storage::IndexedStorage& output_storage, + const BuildOptions& options, + const AnyRange& level_range, + const dag::storage::IndexedStorage* cache = nullptr, + const std::function& stop_requested = {}); } // namespace dag diff --git a/src/dag_builder/centroids.h b/src/dag_builder/centroids.h index ce1e7330..f1156446 100644 --- a/src/dag_builder/centroids.h +++ b/src/dag_builder/centroids.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/cli.cpp b/src/dag_builder/cli.cpp index 1cca3916..21f2ff0c 100644 --- a/src/dag_builder/cli.cpp +++ b/src/dag_builder/cli.cpp @@ -1,9 +1,30 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "cli.h" #include #include #include +#include "cli_support/log.h" + #include #include #include @@ -17,16 +38,6 @@ using namespace cli; namespace { -const std::unordered_map log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}, -}; - const std::unordered_map include_mode_names{ {"current", dag::IncludeMode::CurrentOnly}, {"current-and-coarser", dag::IncludeMode::CurrentAndCoarser}, @@ -87,36 +98,6 @@ octree::Id make_octree_id(const std::vector &values) { throw CLI::ValidationError("--root-node expects either: or "); } -ContinuationMode make_continuation_mode(const bool resume, const bool overwrite) { - if (resume && overwrite) { - UNREACHABLE(); - } - if (resume) { - return ContinuationMode::Resume; - } else if (overwrite) { - return ContinuationMode::Overwrite; - } else { - return ContinuationMode::Error; - } - - // TODO(ORIGINAL AUTHOR): !!! THIS FALLBACK NEEDS AN AUTHORITATIVE BEHAVIOUR DECISION !!! - // - // Neither --resume nor --overwrite is a normal command-line combination, but the intended - // continuation semantics are unclear. Do not replace this exception without first confirming - // the desired behaviour with the original author. - // - // Codex believes the correct implementation is: - // - // return ContinuationMode::Error; - // - // ContinuationMode::Error is already the default in Args and BuildOptions, and build_level() - // contains a dedicated branch for it. Returning Error would therefore preserve the apparent - // design: refuse to overwrite existing output unless the user explicitly selects --resume or - // --overwrite. The exception below intentionally keeps the unsupported path loud until the - // original author confirms or corrects that interpretation. - throw std::runtime_error("unsupported operation"); -} - } // namespace Args cli::parse(int argc, const char *const *argv) { @@ -141,14 +122,14 @@ Args cli::parse(int argc, const char *const *argv) { .write_debug_meshes = false, .parallelize = false, .include_mode = dag::IncludeMode::CurrentOnly, - .continuation_mode = ContinuationMode::Error + .cache_path = std::nullopt }; app.add_option("--input", args.input_path, "Path to input mesh dataset") ->required() ->check(CLI::ExistingDirectory); - app.add_option("--output", args.output_path, "Path to write generated dag levels") + app.add_option("--output", args.output_path, "New path for the generated dag levels; they are written to .part, which is renamed to when complete") ->required(); std::vector root_node_values; @@ -195,11 +176,8 @@ Args cli::parse(int argc, const char *const *argv) { ->default_val(args.texture_gutter) ->check(CLI::NonNegativeNumber); - bool resume = false; - bool overwrite = false; - app.add_flag("--resume", resume, "Resume building the dag from then data in output"); - app.add_flag("--overwrite", overwrite, "Overwrite data already present in output") - ->excludes("--resume"); + app.add_option("--cache", args.cache_path, "Incomplete .part output of an earlier build with the same inputs and options to reuse") + ->check(CLI::ExistingDirectory); app.add_flag("--write-debug-meshes", args.write_debug_meshes, "Write debug .glb meshes alongside the output"); @@ -210,7 +188,7 @@ Args cli::parse(int argc, const char *const *argv) { ->default_val(args.include_mode); app.add_option("--verbosity", args.log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)) ->default_val(args.log_level); try { @@ -220,7 +198,6 @@ Args cli::parse(int argc, const char *const *argv) { if (!root_node_values.empty()) { args.root_node = make_octree_id(root_node_values); } - args.continuation_mode = make_continuation_mode(resume, overwrite); args.sizing_options.mode = make_texture_sizing_mode(texels_per_triangle); } catch (const CLI::ParseError &e) { diff --git a/src/dag_builder/cli.h b/src/dag_builder/cli.h index 3c903c6b..561afcc4 100644 --- a/src/dag_builder/cli.h +++ b/src/dag_builder/cli.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 @@ -33,7 +51,7 @@ struct Args { bool write_debug_meshes; bool parallelize; dag::IncludeMode include_mode; - ContinuationMode continuation_mode; + std::optional cache_path; }; Args parse(int argc, const char *const *argv); diff --git a/src/dag_builder/cluster.h b/src/dag_builder/cluster.h index c72121e7..b603b937 100644 --- a/src/dag_builder/cluster.h +++ b/src/dag_builder/cluster.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/clusterize.h b/src/dag_builder/clusterize.h index aa28b339..fba1f69f 100644 --- a/src/dag_builder/clusterize.h +++ b/src/dag_builder/clusterize.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index f5a5badf..ae1ddfbd 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -1,11 +1,32 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include #include +#include #include #include @@ -15,7 +36,6 @@ #include "cluster.h" #include "dag_node.h" #include "io/envelope.h" -#include "io/envelope_file.h" #include "mesh/io/texture.h" #include "meshopt.h" @@ -361,9 +381,30 @@ namespace { return {}; } - Expected read_metadata(const std::filesystem::path& path) + constexpr std::size_t file_hash_size = 8; + + struct FileHashes { + std::optional clustering; + std::optional metadata; + }; + + Expected split_node_hash(const std::optional node_hash) + { + if (!node_hash.has_value()) { + return FileHashes {}; + } + if (node_hash->size() != 2 * file_hash_size) { + return Error::fail(Error::Code::CorruptData, "expected DAG node hash has " + std::to_string(node_hash->size()) + " bytes instead of 16"); + } + return FileHashes { + .clustering = node_hash->first(file_hash_size), + .metadata = node_hash->subspan(file_hash_size), + }; + } + + Expected read_metadata(const std::filesystem::path& path, const std::optional expected_hash) { - auto payload = io::envelope::read_from_path(path); + auto payload = io::envelope::read(path, expected_hash); if (!payload) { return Error::propagate(std::move(payload), "read DAG metadata"); } @@ -384,14 +425,18 @@ std::vector ClusterBatch::paths(const std::filesystem::pa }; } -Expected ClusterBatch::read(const std::filesystem::path& node_path) const +Expected ClusterBatch::read(const std::filesystem::path& node_path, const std::optional expected_hash) const { const auto node_paths = paths(node_path); - auto clustering_payload = io::envelope::read_from_path(node_paths[0]); + auto expected_file_hashes = split_node_hash(expected_hash); + if (!expected_file_hashes) { + return Error::propagate(std::move(expected_file_hashes), "read DAG files \"" + node_paths[0].string() + "\" and \"" + node_paths[1].string() + "\""); + } + auto clustering_payload = io::envelope::read(node_paths[0], expected_file_hashes->clustering); if (!clustering_payload) { return Error::propagate(std::move(clustering_payload), "read DAG clustering"); } - auto metadata = read_metadata(node_paths[1]); + auto metadata = read_metadata(node_paths[1], expected_file_hashes->metadata); if (!metadata) { return Error::propagate(std::move(metadata)); } @@ -409,7 +454,7 @@ Expected ClusterBatch::read(const std::filesystem::path& node return batch; } -Expected ClusterBatch::write(const std::filesystem::path& node_path, const dag::ClusterBatch& batch) const +Expected ClusterBatch::write(const std::filesystem::path& node_path, const dag::ClusterBatch& batch) const { const auto node_paths = paths(node_path); if (auto valid = validate_batch(batch, Error::Code::InvalidInput); !valid) { @@ -426,20 +471,31 @@ Expected ClusterBatch::write(const std::filesystem::path& node_path, const if (auto valid = format::validate(metadata); !valid) { return Error::propagate(std::move(valid), "validate DAG metadata for \"" + node_paths[1].string() + "\""); } - auto data_written = io::envelope::write_to_path(*clustering, node_paths[0]); - if (!data_written) { - return Error::propagate(std::move(data_written), "write DAG clustering"); + auto clustering_hash = io::envelope::write(*clustering, node_paths[0], io::byte::WriteMode::CreateNew); + if (!clustering_hash) { + return Error::propagate(std::move(clustering_hash), "write DAG clustering"); } - auto metadata_written = io::envelope::write_to_path(metadata, node_paths[1]); - if (!metadata_written) { - return Error::propagate(std::move(metadata_written), "write DAG metadata"); + auto metadata_hash = io::envelope::write(metadata, node_paths[1], io::byte::WriteMode::CreateNew); + if (!metadata_hash) { + return Error::propagate(std::move(metadata_hash), "write DAG metadata"); } - return {}; + ASSERT(clustering_hash->size() == file_hash_size && metadata_hash->size() == file_hash_size); + store::NodeDataHash node_hash = std::move(*clustering_hash); + node_hash.insert(node_hash.end(), metadata_hash->begin(), metadata_hash->end()); + return node_hash; } std::vector Metadata::paths(const std::filesystem::path& node_path) const { return { add_extension(node_path, ".dagmeta") }; } -Expected Metadata::read(const std::filesystem::path& node_path) const { return read_metadata(paths(node_path).front()); } +Expected Metadata::read(const std::filesystem::path& node_path, const std::optional expected_hash) const +{ + const std::filesystem::path path = paths(node_path).front(); + auto expected_file_hashes = split_node_hash(expected_hash); + if (!expected_file_hashes) { + return Error::propagate(std::move(expected_file_hashes), "read DAG metadata \"" + path.string() + "\""); + } + return read_metadata(path, expected_file_hashes->metadata); +} Expected>> cluster_batch_from_extension(const std::string_view extension) { diff --git a/src/dag_builder/codec.h b/src/dag_builder/codec.h index 8510cac7..ec753028 100644 --- a/src/dag_builder/codec.h +++ b/src/dag_builder/codec.h @@ -1,7 +1,26 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -10,17 +29,24 @@ namespace dag::codec { +// A node's identity is the sixteen-byte concatenation of the .dag and .dagmeta envelope hashes, +// so that each file can be verified on its own. class ClusterBatch final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override; - Expected read(const std::filesystem::path& node_path) const override; - Expected write(const std::filesystem::path& node_path, const dag::ClusterBatch& batch) const override; + Expected read( + const std::filesystem::path& node_path, std::optional expected_hash = std::nullopt) const override; + Expected write(const std::filesystem::path& node_path, const dag::ClusterBatch& batch) const override; + bool supports_hashing() const override { return true; } }; +// Reads only the .dagmeta file and verifies it against the second half of an expected node identity. class Metadata final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override; - Expected read(const std::filesystem::path& node_path) const override; + Expected read( + const std::filesystem::path& node_path, std::optional expected_hash = std::nullopt) const override; + bool supports_hashing() const override { return true; } }; Expected>> cluster_batch_from_extension(std::string_view extension); diff --git a/src/dag_builder/compact.h b/src/dag_builder/compact.h index 896d936d..566483bc 100644 --- a/src/dag_builder/compact.h +++ b/src/dag_builder/compact.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/correspondence.h b/src/dag_builder/correspondence.h index c6cd77f4..eef25475 100644 --- a/src/dag_builder/correspondence.h +++ b/src/dag_builder/correspondence.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/dag_id.h b/src/dag_builder/dag_id.h index 3370d1bf..91f9e8bd 100644 --- a/src/dag_builder/dag_id.h +++ b/src/dag_builder/dag_id.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/dag_node.h b/src/dag_builder/dag_node.h index 2b46e6ec..f264bce1 100644 --- a/src/dag_builder/dag_node.h +++ b/src/dag_builder/dag_node.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "cluster.h" diff --git a/src/dag_builder/fixed.h b/src/dag_builder/fixed.h index 0e3735d0..f3e2e888 100644 --- a/src/dag_builder/fixed.h +++ b/src/dag_builder/fixed.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/fixed_point.h b/src/dag_builder/fixed_point.h index 6765c2ab..4575c8ed 100644 --- a/src/dag_builder/fixed_point.h +++ b/src/dag_builder/fixed_point.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "fixed.h" diff --git a/src/dag_builder/inputs.h b/src/dag_builder/inputs.h new file mode 100644 index 00000000..d6d7bda3 --- /dev/null +++ b/src/dag_builder/inputs.h @@ -0,0 +1,94 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include "Range.h" +#include "build.h" +#include "io/envelope.h" +#include "octree/storage/IndexFile.h" +#include "store/inputs.h" + +namespace dag::inputs { + +// Everything that determines the built DAG; a cache must match it. +struct Record { + // Optional members defeat zpp_bits' member counting. + using serialize = zpp::bits::members<15>; + + store::inputs::Fingerprint input; + std::uint32_t root_level = 0; + std::uint64_t root_index = 0; + std::optional first_level; + std::optional last_level; + std::uint32_t clusters_per_partition = 0; + std::optional target_ratio; + std::optional relative_target_error; + bool allow_texture_reuse = true; + std::uint32_t charting = 0; + // Absent for texel budgets relative to the source textures. + std::optional texels_per_triangle; + std::uint32_t max_node_texels = 0; + std::uint32_t texture_gutter = 0; + std::uint32_t include_mode = 0; + // Bump on changes that alter the built DAG. + std::uint32_t processing_version = 1; + + bool operator==(const Record&) const = default; +}; + +using Schema = io::envelope::PayloadSchema<"dag_builder.Inputs", io::envelope::Version<1, Record>>; + +inline Expected make_record(const std::filesystem::path& input, const BuildOptions& options, const AnyRange& levels) +{ + auto fingerprint = store::inputs::fingerprint(input, octree::storage::metadata_file_name, octree::storage::index_file_name); + if (!fingerprint) { + return Error::propagate(std::move(fingerprint), "fingerprint DAG input \"" + input.string() + "\""); + } + Record record; + record.input = std::move(*fingerprint); + record.root_level = options.root_node.level(); + record.root_index = options.root_node.index_on_level(); + const auto first = levels.start_bound(); + if (first.kind != BoundKind::Unbounded) { + record.first_level = first.kind == BoundKind::Included ? first.value.value() : first.value.value() + 1; + } + const auto last = levels.end_bound(); + if (last.kind != BoundKind::Unbounded) { + record.last_level = last.kind == BoundKind::Included ? last.value.value() : last.value.value() - 1; + } + record.clusters_per_partition = options.clusters_per_partition; + record.target_ratio = options.target_ratio; + record.relative_target_error = options.relative_target_error; + record.allow_texture_reuse = options.texture_options.allow_texture_reuse; + record.charting = static_cast(options.texture_options.charting); + if (const auto* constant = std::get_if(&options.texture_options.sizing.mode)) { + record.texels_per_triangle = constant->target_texels_per_triangle; + } + record.max_node_texels = options.texture_options.sizing.max_node_texels; + record.texture_gutter = options.texture_options.atlas.padding; + record.include_mode = static_cast(options.include_mode); + return record; +} + +} // namespace dag::inputs diff --git a/src/dag_builder/main.cpp b/src/dag_builder/main.cpp index 177924b7..7292402e 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -1,18 +1,41 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "cli.h" #include "build.h" -#include "log.h" +#include "cli.h" +#include "cli_support/log.h" +#include "cli_support/stop_signal.h" +#include "inputs.h" #include "mesh/storage.h" #include "storage.h" -#include "ContinuationMode.h" +#include "store/inputs.h" +#include "store/snapshot.h" int main(int argc, char **argv) { const cli::Args args = cli::parse(argc, argv); - Log::init(args.log_level); + cli_support::Log::init(args.log_level); try { auto input_result = mesh::storage::open_folder_indexed(args.input_path); @@ -23,17 +46,7 @@ int main(int argc, char **argv) { input_result.error().to_string()); return EXIT_FAILURE; } - auto output_result = dag::storage::open_folder_indexed(args.output_path); - if (!output_result) { - LOG_ERROR( - "Failed to open output dataset {}: {}", - args.output_path, - output_result.error().to_string()); - return EXIT_FAILURE; - } const mesh::storage::IndexedStorage input_storage = std::move(input_result.value()); - dag::storage::IndexedStorage output_storage = std::move(output_result.value()); - output_storage.settings().allow_overwrite = args.continuation_mode == ContinuationMode::Overwrite; dag::BuildOptions options{ .clusters_per_partition = args.clusters_per_partition, @@ -50,28 +63,67 @@ int main(int argc, char **argv) { .include_mode = args.include_mode, .write_debug_meshes = args.write_debug_meshes, .parallelize = args.parallelize, - .continuation_mode = args.continuation_mode }; // 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; } + const auto record = dag::inputs::make_record(args.input_path, options, args.level_range); + if (!record) { + LOG_ERROR("Failed to record the build inputs: {}", record.error().to_string()); + return EXIT_FAILURE; + } + std::optional cache; + if (args.cache_path.has_value()) { + const auto& cache_path = args.cache_path.value(); + if (const auto valid = store::inputs::validate_cache(cache_path, *record); !valid) { + LOG_ERROR("Invalid cache {}: {}", cache_path, valid.error().to_string()); + return EXIT_FAILURE; + } + if (const auto linkable = store::snapshot::check_link_filesystem(cache_path, args.output_path); !linkable) { + LOG_ERROR("Unusable cache {}: {}", cache_path, linkable.error().to_string()); + return EXIT_FAILURE; + } + auto cache_result = dag::storage::open_index(cache_path / octree::storage::index_file_name); + if (!cache_result) { + LOG_ERROR("Failed to open cache {}: {}", cache_path, cache_result.error().to_string()); + return EXIT_FAILURE; + } + cache.emplace(std::move(*cache_result)); + } + if (args.write_debug_meshes) { + if (const auto absent = store::snapshot::require_absent(dag::debug_mesh_path(args.output_path)); !absent) { + LOG_ERROR("Debug mesh output must be new: {}", absent.error().to_string()); + return EXIT_FAILURE; + } + } + + auto output_result = dag::storage::create(args.output_path); + if (!output_result) { + LOG_ERROR("Failed to create output dataset {}: {}", args.output_path, output_result.error().to_string()); + return EXIT_FAILURE; + } + dag::storage::IndexedStorage output_storage = std::move(output_result.value()); + if (const auto written = store::inputs::write(output_storage.base_path(), *record); !written) { + LOG_ERROR("Failed to write the input record: {}", written.error().to_string()); + return EXIT_FAILURE; + } + + cli_support::stop_signal::install(); const auto build_result = dag::build_levels( - input_storage, - output_storage, - options, - args.level_range); + input_storage, output_storage, options, args.level_range, cache ? &cache.value() : nullptr, cli_support::stop_signal::requested); if (!build_result) { - LOG_ERROR("Invalid Structura Fundamentalis input: {}", build_result.error().to_string()); + if (build_result.error().code() == Error::Code::Cancelled) { + LOG_INFO("{}", build_result.error().to_string()); + return cli_support::stop_signal::exit_code(); + } + LOG_ERROR("DAG build failed: {}", build_result.error().to_string()); return EXIT_FAILURE; } - const auto index_result = output_storage.save_index(); - if (!index_result) { - LOG_ERROR( - "Failed to save output index in {}: {}", - args.output_path, - index_result.error().to_string()); + const auto published = dag::storage::publish(std::move(output_storage)); + if (!published) { + LOG_ERROR("Failed to publish output {}: {}", args.output_path, published.error().to_string()); return EXIT_FAILURE; } diff --git a/src/dag_builder/merge/clusterings.cpp b/src/dag_builder/merge/clusterings.cpp index f3fd1ee1..f80ee473 100644 --- a/src/dag_builder/merge/clusterings.cpp +++ b/src/dag_builder/merge/clusterings.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/merge/clusterings.h b/src/dag_builder/merge/clusterings.h index 89c9b7e1..f531e35e 100644 --- a/src/dag_builder/merge/clusterings.h +++ b/src/dag_builder/merge/clusterings.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/merge/clusters.h b/src/dag_builder/merge/clusters.h index 9baa4dbe..f5e08051 100644 --- a/src/dag_builder/merge/clusters.h +++ b/src/dag_builder/merge/clusters.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -10,7 +29,7 @@ #include "build_config.h" #include "cluster.h" #include "enumerate.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/connectivity/adjacency.h" #include "range_utils.h" #include "validate.h" diff --git a/src/dag_builder/merge/weld.h b/src/dag_builder/merge/weld.h index adaa002f..fd5a09db 100644 --- a/src/dag_builder/merge/weld.h +++ b/src/dag_builder/merge/weld.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 @@ -14,7 +32,7 @@ #include "cluster.h" #include "compact.h" #include "enumerate.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/connectivity/adjacency.h" #include "spatial_lookup/Hashmap.h" #include "validate.h" diff --git a/src/dag_builder/meshopt.h b/src/dag_builder/meshopt.h index 54be4556..b184d759 100644 --- a/src/dag_builder/meshopt.h +++ b/src/dag_builder/meshopt.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/metadata.h b/src/dag_builder/metadata.h index e5c5d35e..6c338696 100644 --- a/src/dag_builder/metadata.h +++ b/src/dag_builder/metadata.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/optimize.h b/src/dag_builder/optimize.h index af76c8f6..c87d85aa 100644 --- a/src/dag_builder/optimize.h +++ b/src/dag_builder/optimize.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "cluster.h" diff --git a/src/dag_builder/parallel.h b/src/dag_builder/parallel.h index 9416ffaf..668ae20c 100644 --- a/src/dag_builder/parallel.h +++ b/src/dag_builder/parallel.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/partition.h b/src/dag_builder/partition.h index 03819458..3724ec55 100644 --- a/src/dag_builder/partition.h +++ b/src/dag_builder/partition.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/simplify.h b/src/dag_builder/simplify.h index e641994b..60b508ce 100644 --- a/src/dag_builder/simplify.h +++ b/src/dag_builder/simplify.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/slice.h b/src/dag_builder/slice.h index bdf60e20..1e4d3a35 100644 --- a/src/dag_builder/slice.h +++ b/src/dag_builder/slice.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_builder/split.h b/src/dag_builder/split.h index 63032a54..723964d8 100644 --- a/src/dag_builder/split.h +++ b/src/dag_builder/split.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 @@ -10,7 +28,7 @@ #include "containers/Size.h" #include "containers/Buffer.h" #include "clusterize.h" -#include "log.h" +#include "cli_support/log.h" #include "meshopt.h" #include "numeric/number_types.h" #include "utils.h" diff --git a/src/dag_builder/storage.h b/src/dag_builder/storage.h index 76b89b5f..16a27e2f 100644 --- a/src/dag_builder/storage.h +++ b/src/dag_builder/storage.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -6,6 +25,7 @@ #include "codec.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" +#include "octree/storage/snapshot.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" @@ -50,6 +70,15 @@ open_folder_indexed(const std::filesystem::path &path, OpenOptions options = {}) std::move(options)); } +// Creates an empty storage in .part; see store::snapshot. +inline Expected create(const std::filesystem::path& path) +{ + return octree::storage::create( + path, std::string(payload_class), ".dag", dag::codec::cluster_batch_from_extension, octree::storage::default_mapping()); +} + +inline Expected publish(IndexedStorage storage) { return octree::storage::publish(std::move(storage)); } + inline Expected open_metadata_indexed(const std::filesystem::path &path) { const std::filesystem::path index_path = diff --git a/src/dag_builder/texture_reuse.h b/src/dag_builder/texture_reuse.h index 08222fa0..3d82df9a 100644 --- a/src/dag_builder/texture_reuse.h +++ b/src/dag_builder/texture_reuse.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 @@ -11,7 +29,7 @@ #include "Partitioning.h" #include "cluster.h" #include "enumerate.h" -#include "log.h" +#include "cli_support/log.h" #include "range_utils.h" namespace detail { diff --git a/src/dag_builder/texture_sizing.h b/src/dag_builder/texture_sizing.h index dbbdf904..453f6a6c 100644 --- a/src/dag_builder/texture_sizing.h +++ b/src/dag_builder/texture_sizing.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/dag_builder/texturing.h b/src/dag_builder/texturing.h index 6886056f..355ed5a9 100644 --- a/src/dag_builder/texturing.h +++ b/src/dag_builder/texturing.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 @@ -15,7 +33,7 @@ #include "cluster.h" #include "compact.h" #include "enumerate.h" -#include "log.h" +#include "cli_support/log.h" #include "Partitioning.h" #include "range_utils.h" #include "texture_reuse.h" diff --git a/src/dag_builder/utils.h b/src/dag_builder/utils.h index 81ca5ec7..c8a77af6 100644 --- a/src/dag_builder/utils.h +++ b/src/dag_builder/utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/validate.h b/src/dag_builder/validate.h index f0ba53d8..74fdc034 100644 --- a/src/dag_builder/validate.h +++ b/src/dag_builder/validate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_builder/vertex_lock.h b/src/dag_builder/vertex_lock.h index 440bfa53..e0c5dbcb 100644 --- a/src/dag_builder/vertex_lock.h +++ b/src/dag_builder/vertex_lock.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/dag_convert_debug/CMakeLists.txt b/src/dag_convert_debug/CMakeLists.txt index d94dc228..a75a9d34 100644 --- a/src/dag_convert_debug/CMakeLists.txt +++ b/src/dag_convert_debug/CMakeLists.txt @@ -1,3 +1,21 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_executable(dag-convert-debug cli.h cli.cpp main.cpp diff --git a/src/dag_convert_debug/cli.cpp b/src/dag_convert_debug/cli.cpp index 338d668f..b925a0c5 100644 --- a/src/dag_convert_debug/cli.cpp +++ b/src/dag_convert_debug/cli.cpp @@ -1,27 +1,33 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "cli.h" #include #include +#include "cli_support/log.h" + #include -#include #include using namespace cli; -namespace { - -const std::map log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}}; - -} // namespace - Args cli::parse(int argc, const char *const *argv) { DEBUG_ASSERT(argc >= 0); CLI::App app{"dag_convert_debug"}; @@ -39,7 +45,7 @@ Args cli::parse(int argc, const char *const *argv) { app.add_option("output,--output", args.output_path, "Path to write debug meshes to: a folder if --input is a folder, or a mesh file if --input is a file"); app.add_option("--verbosity", args.log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)) ->default_val(args.log_level); try { diff --git a/src/dag_convert_debug/cli.h b/src/dag_convert_debug/cli.h index 667edda3..8112df02 100644 --- a/src/dag_convert_debug/cli.h +++ b/src/dag_convert_debug/cli.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/dag_convert_debug/main.cpp b/src/dag_convert_debug/main.cpp index b0f8c8c5..73bb6a4d 100644 --- a/src/dag_convert_debug/main.cpp +++ b/src/dag_convert_debug/main.cpp @@ -1,15 +1,35 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "cli.h" #include "codec.h" #include "dag_node.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/io.h" #include "mesh/storage.h" #include "mesh/storage.h" -#include "ProgressIndicator.h" +#include "cli_support/ProgressIndicator.h" #include "storage.h" +#include "store/snapshot.h" #include "utils.h" namespace { @@ -30,6 +50,10 @@ void export_node(const cli::Args &args) { const mesh::Simple mesh = clustering_to_mesh(load_result.value().clustering); + if (const auto absent = store::snapshot::require_absent(args.output_path); !absent) { + LOG_ERROR("Output must be new: {}", absent.error().to_string()); + return; + } const auto save_result = mesh::io::save_to_path(mesh, args.output_path); if (!save_result) { LOG_ERROR("Failed to save mesh to {}: {}", args.output_path, save_result.error().to_string()); @@ -47,6 +71,10 @@ void export_storage(const cli::Args &args) { } const dag::storage::IndexedStorage input_storage = std::move(input_result.value()); + if (const auto absent = store::snapshot::require_absent(args.output_path); !absent) { + LOG_ERROR("Output must be new: {}", absent.error().to_string()); + return; + } mesh::storage::OpenOptions options; options.preferred_extension = ".glb"; auto output_result = mesh::storage::open_folder( @@ -60,11 +88,10 @@ void export_storage(const cli::Args &args) { return; } mesh::storage::Storage output_storage = std::move(output_result.value()); - output_storage.settings().allow_overwrite = true; size_t exported_count = 0; - ProgressIndicator progress(input_storage.index().size()); + cli_support::ProgressIndicator progress(input_storage.index().size()); auto progress_thread = progress.start_monitoring(); for (const auto &[id, status] : input_storage.index()) { @@ -116,7 +143,7 @@ void export_storage(const cli::Args &args) { int main(int argc, char **argv) { const cli::Args args = cli::parse(argc, argv); - Log::init(args.log_level); + cli_support::Log::init(args.log_level); if (std::filesystem::is_directory(args.input_path)) { export_storage(args); diff --git a/src/mesh_convert/CMakeLists.txt b/src/mesh_convert/CMakeLists.txt index 01a98169..78895b30 100644 --- a/src/mesh_convert/CMakeLists.txt +++ b/src/mesh_convert/CMakeLists.txt @@ -1,3 +1,21 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_executable(mesh-convert cli.h cli.cpp main.cpp diff --git a/src/mesh_convert/cli.cpp b/src/mesh_convert/cli.cpp index 4b690703..fa462842 100644 --- a/src/mesh_convert/cli.cpp +++ b/src/mesh_convert/cli.cpp @@ -1,6 +1,25 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 #include "cli.h" +#include "cli_support/log.h" using namespace cli; @@ -23,16 +42,8 @@ Args cli::parse(int argc, const char * const* argv) { ->expected(2); spdlog::level::level_enum log_level = spdlog::level::level_enum::info; - const std::map log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}}; app.add_option("--verbosity", log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)); + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)); try { app.parse(argc, argv); diff --git a/src/mesh_convert/cli.h b/src/mesh_convert/cli.h index dde4122d..c67969ad 100644 --- a/src/mesh_convert/cli.h +++ b/src/mesh_convert/cli.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 diff --git a/src/mesh_convert/main.cpp b/src/mesh_convert/main.cpp index a2f742ab..e6a45923 100644 --- a/src/mesh_convert/main.cpp +++ b/src/mesh_convert/main.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 @@ -5,7 +24,7 @@ #include "mesh/SimpleMesh.h" #include "mesh/io.h" #include "opencv_utils.h" -#include "log.h" +#include "cli_support/log.h" #include "cli.h" void run(const cli::Args& args) { @@ -38,7 +57,7 @@ void run(const cli::Args& args) { int main(int argc, char **argv) { const cli::Args args = cli::parse(argc, argv); - Log::init(args.log_level); + cli_support::Log::init(args.log_level); run(args); } diff --git a/src/sf_browser/CMakeLists.txt b/src/sf_browser/CMakeLists.txt index d3f379ce..21886f48 100644 --- a/src/sf_browser/CMakeLists.txt +++ b/src/sf_browser/CMakeLists.txt @@ -1,3 +1,23 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2023 Adam Celarek-Litofcenko +# Copyright (C) 2023 Martin Braunsperger +# Copyright (C) 2023 Adrian Gawor +# +# 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 . +# ***************************************************************************** + add_subdirectory(res) add_executable(sf-browser diff --git a/src/sf_browser/core/Application.cpp b/src/sf_browser/core/Application.cpp index 9da1afce..efa9a4de 100644 --- a/src/sf_browser/core/Application.cpp +++ b/src/sf_browser/core/Application.cpp @@ -1,7 +1,27 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Application.h" #include -#include +#include #include diff --git a/src/sf_browser/core/Application.h b/src/sf_browser/core/Application.h index ca10a6c0..3a0e136a 100644 --- a/src/sf_browser/core/Application.h +++ b/src/sf_browser/core/Application.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/Buffer.cpp b/src/sf_browser/core/Buffer.cpp index 1de7cffc..81bcf9d3 100644 --- a/src/sf_browser/core/Buffer.cpp +++ b/src/sf_browser/core/Buffer.cpp @@ -1,5 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Buffer.h" -#include +#include Buffer::Buffer(GLenum target, GLenum usage) : m_target(target), m_usage(usage) { glCreateBuffers(1, &m_handle); diff --git a/src/sf_browser/core/Buffer.h b/src/sf_browser/core/Buffer.h index b82d0f9a..f6d1efcd 100644 --- a/src/sf_browser/core/Buffer.h +++ b/src/sf_browser/core/Buffer.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/Camera.cpp b/src/sf_browser/core/Camera.cpp index d78b0367..5cd23cfe 100644 --- a/src/sf_browser/core/Camera.cpp +++ b/src/sf_browser/core/Camera.cpp @@ -1,5 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Camera.h" -#include +#include Camera::Camera(CameraConfig config) : m_fov_deg(config.fov_deg), diff --git a/src/sf_browser/core/Camera.h b/src/sf_browser/core/Camera.h index 37dd491a..4db98c44 100644 --- a/src/sf_browser/core/Camera.h +++ b/src/sf_browser/core/Camera.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/geometry/UnitCube.h b/src/sf_browser/core/geometry/UnitCube.h index 891c8f52..69366944 100644 --- a/src/sf_browser/core/geometry/UnitCube.h +++ b/src/sf_browser/core/geometry/UnitCube.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/rendering/OctreeRenderManager.cpp b/src/sf_browser/core/rendering/OctreeRenderManager.cpp index 5d7aea83..00590408 100644 --- a/src/sf_browser/core/rendering/OctreeRenderManager.cpp +++ b/src/sf_browser/core/rendering/OctreeRenderManager.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "OctreeRenderManager.h" #include diff --git a/src/sf_browser/core/rendering/OctreeRenderManager.h b/src/sf_browser/core/rendering/OctreeRenderManager.h index 67400d28..01e95f9e 100644 --- a/src/sf_browser/core/rendering/OctreeRenderManager.h +++ b/src/sf_browser/core/rendering/OctreeRenderManager.h @@ -1,3 +1,23 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "octree/Id.h" #include "octree/Space.h" diff --git a/src/sf_browser/core/shader/GLUniformAbstractions.h b/src/sf_browser/core/shader/GLUniformAbstractions.h index 10863652..03520781 100644 --- a/src/sf_browser/core/shader/GLUniformAbstractions.h +++ b/src/sf_browser/core/shader/GLUniformAbstractions.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/shader/Shader.cpp b/src/sf_browser/core/shader/Shader.cpp index a5694265..a4aa2954 100644 --- a/src/sf_browser/core/shader/Shader.cpp +++ b/src/sf_browser/core/shader/Shader.cpp @@ -1,5 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Shader.h" -#include +#include Shader::Shader(GLenum type) : m_type(type) { m_handle = glCreateShader(m_type); diff --git a/src/sf_browser/core/shader/Shader.h b/src/sf_browser/core/shader/Shader.h index 158cbdee..84fa39d9 100644 --- a/src/sf_browser/core/shader/Shader.h +++ b/src/sf_browser/core/shader/Shader.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/core/shader/ShaderProgram.cpp b/src/sf_browser/core/shader/ShaderProgram.cpp index 10148212..c23dafc5 100644 --- a/src/sf_browser/core/shader/ShaderProgram.cpp +++ b/src/sf_browser/core/shader/ShaderProgram.cpp @@ -1,6 +1,25 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "ShaderProgram.h" #include -#include +#include #include ShaderProgram::ShaderProgram() { diff --git a/src/sf_browser/core/shader/ShaderProgram.h b/src/sf_browser/core/shader/ShaderProgram.h index ea4d4942..493ebb78 100644 --- a/src/sf_browser/core/shader/ShaderProgram.h +++ b/src/sf_browser/core/shader/ShaderProgram.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Shader.h" diff --git a/src/sf_browser/core/shader/Uniform.h b/src/sf_browser/core/shader/Uniform.h index ae1ba5b7..e50acb64 100644 --- a/src/sf_browser/core/shader/Uniform.h +++ b/src/sf_browser/core/shader/Uniform.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "GLUniformAbstractions.h" diff --git a/src/sf_browser/core/window/Window.cpp b/src/sf_browser/core/window/Window.cpp index 66b011c0..c9e0e97b 100644 --- a/src/sf_browser/core/window/Window.cpp +++ b/src/sf_browser/core/window/Window.cpp @@ -1,5 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Window.h" -#include +#include #include std::atomic Window::glfw_initialized(false); diff --git a/src/sf_browser/core/window/Window.h b/src/sf_browser/core/window/Window.h index fe090a12..47d4e56e 100644 --- a/src/sf_browser/core/window/Window.h +++ b/src/sf_browser/core/window/Window.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_browser/main.cpp b/src/sf_browser/main.cpp index 19552eeb..650a4fb3 100644 --- a/src/sf_browser/main.cpp +++ b/src/sf_browser/main.cpp @@ -1,12 +1,31 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "core/Application.h" -#include +#include int main() { #ifdef DEBUG - Log::init(spdlog::level::trace); + cli_support::Log::init(spdlog::level::trace); #else - Log::init(spdlog::level::info); + cli_support::Log::init(spdlog::level::info); #endif Application app("Alpenite Browser", 1280, 720); diff --git a/src/sf_browser/res/CMakeLists.txt b/src/sf_browser/res/CMakeLists.txt index 49087e2d..755aae80 100644 --- a/src/sf_browser/res/CMakeLists.txt +++ b/src/sf_browser/res/CMakeLists.txt @@ -1,3 +1,22 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2025 Adrian Gawor +# Copyright (C) 2025 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + # Default Resource Library # Resources are accessed in the code by # 1. Including the CMRC header file: #include diff --git a/src/sf_browser/res/shaders/octree_lines.frag b/src/sf_browser/res/shaders/octree_lines.frag index b15c84d2..17fafdf1 100644 --- a/src/sf_browser/res/shaders/octree_lines.frag +++ b/src/sf_browser/res/shaders/octree_lines.frag @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 . + *****************************************************************************/ + #version 460 core in VS_OUT { diff --git a/src/sf_browser/res/shaders/octree_lines.vert b/src/sf_browser/res/shaders/octree_lines.vert index d2ebb5e0..d910a013 100644 --- a/src/sf_browser/res/shaders/octree_lines.vert +++ b/src/sf_browser/res/shaders/octree_lines.vert @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Adrian Gawor + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 . + *****************************************************************************/ + #version 460 core layout (location = 0) in vec3 pos; diff --git a/src/sf_builder/CMakeLists.txt b/src/sf_builder/CMakeLists.txt index 4a32f9cc..a9eedef5 100644 --- a/src/sf_builder/CMakeLists.txt +++ b/src/sf_builder/CMakeLists.txt @@ -1,5 +1,24 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_library(sfbuilderlib border.h + inputs.h mesh_builder.h mesh_builder.cpp raw_dataset_reader.h terrainbuilder.h terrainbuilder.cpp diff --git a/src/sf_builder/border.h b/src/sf_builder/border.h index 4ad54a59..41fa6215 100644 --- a/src/sf_builder/border.h +++ b/src/sf_builder/border.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2021 Adam Celarek-Litofcenko + * Copyright (C) 2021 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 diff --git a/src/sf_builder/inputs.h b/src/sf_builder/inputs.h new file mode 100644 index 00000000..db31428d --- /dev/null +++ b/src/sf_builder/inputs.h @@ -0,0 +1,49 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/envelope.h" + +namespace terrainbuilder::inputs { + +// Everything that determines the meshes of a batch build; a cache must match it. +struct Record { + // Optional members defeat zpp_bits' member counting. + using serialize = zpp::bits::members<8>; + + std::string dataset; + std::optional textures; + std::optional min_texture_level; + std::optional max_texture_level; + std::string mesh_srs; + std::uint32_t target_level = 0; + std::string format; + // Bump on changes that alter the produced meshes. + std::uint32_t processing_version = 1; + + bool operator==(const Record&) const = default; +}; + +using Schema = io::envelope::PayloadSchema<"sf_builder.Inputs", io::envelope::Version<1, Record>>; + +} // namespace terrainbuilder::inputs diff --git a/src/sf_builder/main.cpp b/src/sf_builder/main.cpp index 888c2af4..11d2ac18 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2021 Adam Celarek-Litofcenko + * Copyright (C) 2021 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 #include #include @@ -12,19 +31,17 @@ #include #include -#include "Dataset.h" -#include "log.h" -#include "srs.h" -#include "terrainbuilder.h" +#include "cli_support/log.h" +#include "cli_support/stop_signal.h" +#include "gis/Dataset.h" +#include "gis/srs.h" +#include "gis/webmercator.h" #include "octree/Space.h" +#include "terrainbuilder.h" #include "tile_provider.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" - OGRSpatialReference parse_srs(const std::string &user_input) { - const auto result = srs::from_user_input(user_input); + const auto result = gis::srs::from_user_input(user_input); if (!result) { LOG_ERROR(result.error()); exit(1); @@ -72,28 +89,21 @@ radix::geometry::Aabb3d parse_bounds_from_tile( const std::vector &data, const OGRSpatialReference &srs) { - // Determine the correct Grid type based on SRS - std::optional grid; - const OGRSpatialReference &webmercator_srs = srs::webmercator(); - const OGRSpatialReference &wgs84_srs = srs::wgs84(); - if (srs.IsSame(&webmercator_srs)) { - grid = ctb::GlobalMercator(); - } else if (srs.IsSame(&wgs84_srs)) { - grid = ctb::GlobalGeodetic(); - } else { - LOG_ERROR_AND_EXIT("Only WebMercator (EPSG:3857) or WGS84 (EPSG:4326) supported for --tile."); + const OGRSpatialReference &webmercator_srs = gis::srs::webmercator(); + if (!srs.IsSame(&webmercator_srs)) { + LOG_ERROR_AND_EXIT("Only WebMercator (EPSG:3857) supported for --tile."); } const uint32_t zoom_level = data[0]; const glm::uvec2 tile_coords(data[1], data[2]); const radix::tile::Id target_tile(zoom_level, tile_coords); - return extend_bounds_to_3d(grid->srsBounds(target_tile, false)); + return extend_bounds_to_3d(gis::webmercator::tile_bounds(target_tile)); } radix::geometry::Aabb3d parse_bounds_from_node(const std::vector &data, const OGRSpatialReference &srs) { // Validate node SRS (only ECEF) - const OGRSpatialReference &ecef_srs = srs::ecef(); + const OGRSpatialReference &ecef_srs = gis::srs::ecef(); if (!srs.IsSame(&ecef_srs)) { LOG_ERROR_AND_EXIT("Only ECEF (EPSG:4978) supported for --node."); } @@ -109,6 +119,9 @@ radix::geometry::Aabb3d parse_bounds_from_node(const std::vector &data } else { LOG_ERROR_AND_EXIT("Invalid number of args for --node. Expected 2 or 4."); } + if (target_node.level() < terrainbuilder::min_target_level) { + LOG_ERROR_AND_EXIT("The level of --node must be at least {}.", unsigned(terrainbuilder::min_target_level)); + } return octree::Space::earth().get_node_bounds(target_node); } @@ -134,18 +147,32 @@ radix::geometry::Aabb3d parse_target_bounds( UNREACHABLE(); } -void log_dataset_overview(const Dataset &dataset) { +void log_dataset_overview(const gis::Dataset &dataset) { const std::string name = dataset.name(); const uint32_t widthPx = dataset.widthInPixels(); const uint32_t heightPx = dataset.heightInPixels(); const uint32_t bands = dataset.n_bands(); - const auto datasetSrs = dataset.srs(); - const auto bounds = dataset.bounds3d(true); + const auto datasetSrsResult = dataset.srs(); + if (!datasetSrsResult) { + LOG_ERROR("Failed to read dataset SRS: {}", datasetSrsResult.error().to_string()); + return; + } + const auto datasetSrs = *datasetSrsResult; + const auto boundsResult = dataset.bounds3d(true); + if (!boundsResult) { + LOG_ERROR("Failed to read dataset bounds: {}", boundsResult.error().to_string()); + return; + } + const auto bounds = *boundsResult; - const auto boundsEcef = - srs::encompassing_bounds_transfer(datasetSrs, srs::ecef(), bounds); + const auto boundsEcefResult = gis::srs::ecef_coverage(datasetSrs, bounds); + if (!boundsEcefResult) { + LOG_ERROR("Failed to transform dataset bounds to ECEF: {}", boundsEcefResult.error().to_string()); + return; + } + const auto boundsEcef = *boundsEcefResult; const octree::Space earth = octree::Space::earth(); const auto enclosingNode = @@ -157,7 +184,7 @@ void log_dataset_overview(const Dataset &dataset) { LOG_INFO("Dataset"); LOG_INFO(" name : {}", name); LOG_INFO(" size : {} x {} px | bands={}", widthPx, heightPx, bands); - LOG_INFO(" srs : {}", srs::friendly_name(datasetSrs)); + LOG_INFO(" srs : {}", gis::srs::friendly_name(datasetSrs)); LOG_INFO(" bounds : [{:.3f}, {:.3f}, {:.3f}] - [{:.3f}, {:.3f}, {:.3f}]", bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z); @@ -190,15 +217,6 @@ int run(std::span args) { std::filesystem::path output_path; spdlog::level::level_enum log_level = spdlog::level::level_enum::trace; - const std::map log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}}; - app.add_option("--dataset", dataset_path, "Path to a heightmap dataset file") ->required() ->check(CLI::ExistingFile); @@ -211,7 +229,7 @@ int run(std::span args) { app.add_option("--max-texture-level", max_texture_level, "Maximum texture zoom level to use for assembling textures.") ->needs("--textures"); app.add_option("--verbosity", log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)); + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)); // === SINGLE COMMAND === auto *single = app.add_subcommand("single", "Build a single reference mesh") @@ -253,9 +271,10 @@ int run(std::span args) { octree::Id::Level target_level; batch->add_option("--target-level", target_level, "Level of detail for batch generation") - ->required(); + ->required() + ->check(CLI::Range(unsigned(terrainbuilder::min_target_level), unsigned(octree::Id::max_level()))); std::filesystem::path output_base_path; - batch->add_option("--output", output_base_path, "Output path were the meshes are written to.") + batch->add_option("--output", output_base_path, "New output path; the meshes are written to .part, which is renamed to when complete") ->required(); std::string output_format; batch->add_option("--format", output_format, "Output mesh format") @@ -264,17 +283,17 @@ int run(std::span args) { uint32_t num_threads = 0; batch->add_option("--threads", num_threads, "Number of threads to use") ->check(CLI::PositiveNumber); - bool overwrite_existing = false; - batch->add_flag("--overwrite", overwrite_existing, "Overwrite existing mesh files"); + std::optional cache_path; + batch->add_option("--cache", cache_path, "Incomplete .part output of an earlier batch build with the same inputs to reuse")->check(CLI::ExistingDirectory); CLI11_PARSE(app, argc, argv); - Log::init(log_level); + cli_support::Log::init(log_level); - Dataset dataset(dataset_path.string()); + gis::Dataset dataset(dataset_path.string()); log_dataset_overview(dataset); OGRSpatialReference mesh_srs = parse_srs(mesh_srs_input); - OGRSpatialReference texture_srs = srs::webmercator(); + OGRSpatialReference texture_srs = gis::srs::webmercator(); std::unique_ptr tile_provider; if (texture_base_path.has_value()) { @@ -317,19 +336,27 @@ int run(std::span args) { const auto maybe_s = actual_num_threads == 1 ? "" : "s"; LOG_INFO("Using {} thread{} for batch processing.", actual_num_threads, maybe_s); + const auto identifier = [](const std::filesystem::path& path) { return std::filesystem::absolute(path).lexically_normal().string(); }; + terrainbuilder::inputs::Record record; + record.dataset = identifier(dataset_path); + if (texture_base_path.has_value()) { + record.textures = identifier(*texture_base_path); + } + record.min_texture_level = min_texture_level; + record.max_texture_level = max_texture_level; + record.mesh_srs = mesh_srs_input; + record.target_level = target_level; + record.format = output_format; + + cli_support::stop_signal::install(); const auto build_result = terrainbuilder::build_all_patches( - dataset, - target_level, - texture_srs, - tile_provider.get(), - mesh_srs, - output_base_path, - output_format, - overwrite_existing); + dataset, texture_srs, tile_provider.get(), mesh_srs, output_base_path, record, cache_path, cli_support::stop_signal::requested); if (!build_result) { - LOG_ERROR( - "Failed to finalize Structura Fundamentalis output: {}", - build_result.error().to_string()); + if (build_result.error().code() == Error::Code::Cancelled) { + LOG_INFO("{}", build_result.error().to_string()); + return cli_support::stop_signal::exit_code(); + } + LOG_ERROR("Failed to build Structura Fundamentalis output: {}", build_result.error().to_string()); return 1; } } diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index be440a94..91de8226 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -1,4 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2021 Adam Celarek-Litofcenko + * Copyright (C) 2021 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 +#include #include #include #include @@ -11,14 +31,14 @@ #include #include -#include "Dataset.h" -#include "log.h" +#include "gis/Dataset.h" +#include "cli_support/log.h" #include "mesh/SimpleMesh.h" #include "mesh/cleanup.h" #include "mesh_builder.h" #include #include "raw_dataset_reader.h" -#include "srs.h" +#include "gis/srs.h" #include "mesh/clip.h" #include "mesh/validate.h" #include "mesh/bounds.h" @@ -34,6 +54,9 @@ std::ostream &operator<<(std::ostream &os, BuildMeshError error) { case BuildMeshError::EmptyRegion: os << "empty region"; break; + case BuildMeshError::TransformationFailed: + os << "transformation failed"; + break; default: os << "unknown build error"; break; @@ -113,15 +136,21 @@ SimpleMesh meshify(const radix::Raster& source_points, const radix:: return SimpleMesh(triangles, positions); } -SimpleMesh transform_mesh(SimpleMesh&& source_mesh, const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs) { - const auto transform = srs::transformation(source_srs, target_srs); - srs::transform_points_inplace(transform.get(), source_mesh.positions); - return source_mesh; +Expected transform_mesh(SimpleMesh&& source_mesh, const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) +{ + auto transform = gis::srs::transformation(source_srs, target_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + if (auto result = gis::srs::transform_points_inplace(transform->get(), source_mesh.positions); !result) { + return Error::propagate(std::move(result)); + } + return std::move(source_mesh); } /* SimpleMesh transform_mesh(const SimpleMesh &source_mesh, const OGRSpatialReference &source_srs, const OGRSpatialReference& target_srs) { SimpleMesh target_mesh; - target_mesh.positions = srs::transform_points(source_srs, target_srs, source_mesh.positions); + target_mesh.positions = gis::srs::transform_points(source_srs, target_srs, source_mesh.positions); target_mesh.triangles = source_mesh.triangles; target_mesh.uvs = source_mesh.uvs; target_mesh.texture = source_mesh.texture; @@ -129,8 +158,20 @@ SimpleMesh transform_mesh(const SimpleMesh &source_mesh, const OGRSpatialReferen } */ -std::vector generate_uv_space(const std::vector& positions, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &texture_srs, radix::tile::SrsBounds& texture_bounds) { - std::vector uvs = srs::transform_points_to_2d(srs::transformation(mesh_srs, texture_srs).get(), positions); +Expected> generate_uv_space(const std::vector& positions, + const OGRSpatialReference& mesh_srs, + const OGRSpatialReference& texture_srs, + radix::tile::SrsBounds& texture_bounds) +{ + auto transform = gis::srs::transformation(mesh_srs, texture_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + auto transformed = gis::srs::transform_points_to_2d(transform->get(), positions); + if (!transformed) { + return Error::propagate(std::move(transformed)); + } + std::vector uvs = std::move(*transformed); texture_bounds = radix::tile::SrsBounds(radix::geometry::find_bounds(std::span(uvs))); for (glm::dvec2 &uv : uvs) { @@ -140,81 +181,172 @@ std::vector generate_uv_space(const std::vector& positio return uvs; } -radix::geometry::Aabb3d extend_bounds_to_3d(radix::geometry::Aabb2d bounds2d) { - const double infinity = std::numeric_limits::infinity(); - const glm::dvec3 min(bounds2d.min, -infinity); - const glm::dvec3 max(bounds2d.max, infinity); - return radix::geometry::Aabb3d(min, max); +BuildMeshError log_transformation_error(const Error& error) +{ + LOG_ERROR("Mesh transformation failed: {}", error.to_string()); + return BuildMeshError::TransformationFailed; } + +// Shifts longitude bounds in degrees by whole turns into the turn starting at west_edge, +// splitting them at its end. +std::vector wrap_longitudes(const radix::tile::SrsBounds& bounds, const double west_edge) +{ + constexpr double turn = 360; + const double west = bounds.min.x - turn * std::floor((bounds.min.x - west_edge) / turn); + const double east = west + bounds.width(); + if (east <= west_edge + turn) { + return { { { west, bounds.min.y }, { east, bounds.max.y } } }; + } + return { { { west, bounds.min.y }, { west_edge + turn, bounds.max.y } }, { { west_edge, bounds.min.y }, { east - turn, bounds.max.y } } }; } -std::expected build_reference_mesh_tile( - Dataset &dataset, - const OGRSpatialReference &mesh_srs, - const OGRSpatialReference &tile_srs, const radix::tile::SrsBounds &tile_bounds, - const OGRSpatialReference &texture_srs, radix::tile::SrsBounds &texture_bounds) { - return build_reference_mesh_patch(dataset, mesh_srs, tile_srs, extend_bounds_to_3d(tile_bounds), texture_srs, texture_bounds); +// Appends the meshes without merging vertices. +SimpleMesh concatenate(std::vector&& meshes) +{ + if (meshes.size() == 1) { + return std::move(meshes.front()); + } + SimpleMesh result; + for (const SimpleMesh& mesh : meshes) { + const glm::uvec3 offset(static_cast(result.positions.size())); + for (const glm::uvec3& triangle : mesh.triangles) { + result.triangles.push_back(triangle + offset); + } + result.positions.insert(result.positions.end(), mesh.positions.begin(), mesh.positions.end()); + } + return result; +} +} // namespace + +Expected> native_read_windows( + const OGRSpatialReference& dataset_srs, const radix::tile::SrsBounds& native_bounds, const radix::geometry::Aabb3d& ecef_bounds) +{ + auto coverage = gis::srs::ecef2srs_coverage(ecef_bounds, dataset_srs); + if (!coverage) { + return Error::propagate(std::move(coverage)); + } + // Geographic coverage is in degrees, within [-180, 180]. + const double west_edge = native_bounds.centre().x - 180; + std::vector windows; + for (const radix::geometry::Aabb3d& bounds : *coverage) { + const radix::tile::SrsBounds bounds_2d(bounds); + const auto parts = dataset_srs.IsGeographic() ? wrap_longitudes(bounds_2d, west_edge) : std::vector { bounds_2d }; + for (const auto& part : parts) { + const radix::tile::SrsBounds window(glm::max(part.min, native_bounds.min), glm::min(part.max, native_bounds.max)); + if (window.min.x < window.max.x && window.min.y < window.max.y) { + windows.push_back(window); + } + } + } + return windows; } std::expected build_reference_mesh_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &clip_srs, const radix::geometry::Aabb3d &clip_bounds, const OGRSpatialReference &texture_srs, radix::tile::SrsBounds &texture_bounds) { - const OGRSpatialReference &source_srs = dataset.srs(); + const auto source_srs_result = dataset.srs(); + if (!source_srs_result) { + return std::unexpected(log_transformation_error(source_srs_result.error())); + } + const OGRSpatialReference& source_srs = *source_srs_result; - // Translate tile bounds from tile srs into the source srs, so we know what data to read. - radix::tile::SrsBounds target_bounds_in_source_srs; - if (std::isinf(clip_bounds.min.z) && std::isinf(clip_bounds.max.z)) { - // Make target bounds 2d if the unbounded by height. - target_bounds_in_source_srs = srs::encompassing_bounds_transfer(clip_srs, source_srs, radix::tile::SrsBounds(clip_bounds)); - } else { - target_bounds_in_source_srs = srs::encompassing_bounds_transfer(clip_srs, source_srs, clip_bounds); + // Coverage in the source srs is computed from ECEF bounds. + const bool unbounded_height = !std::isfinite(clip_bounds.min.z) || !std::isfinite(clip_bounds.max.z); + radix::geometry::Aabb3d ecef_clip_bounds = clip_bounds; + if (const auto ecef_srs = gis::srs::ecef(); !clip_srs.IsSame(&ecef_srs)) { + // The source srs is 2D, so the windows for bounds unbounded in height don't depend on the + // height they are computed at. + radix::geometry::Aabb3d finite_clip_bounds = clip_bounds; + if (!std::isfinite(finite_clip_bounds.min.z)) { + finite_clip_bounds.min.z = std::isfinite(clip_bounds.max.z) ? clip_bounds.max.z : 0; + } + if (!std::isfinite(finite_clip_bounds.max.z)) { + finite_clip_bounds.max.z = finite_clip_bounds.min.z; + } + const auto ecef_bounds = gis::srs::ecef_coverage(clip_srs, finite_clip_bounds); + if (!ecef_bounds) { + return std::unexpected(log_transformation_error(ecef_bounds.error())); + } + ecef_clip_bounds = *ecef_bounds; } - // Read height data according to bounds directly from dataset (no interpolation). - RawDatasetReader reader(dataset); - 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); - 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); + // Find what data to read in the source srs. + const auto native_bounds = dataset.bounds(); + if (!native_bounds) { + return std::unexpected(log_transformation_error(native_bounds.error())); + } + const auto windows = native_read_windows(source_srs, *native_bounds, ecef_clip_bounds); + if (!windows) { + return std::unexpected(log_transformation_error(windows.error())); } - const radix::Raster height_map = std::move(*read_result); - LOG_TRACE("Finding valid pixels"); + // Read height data in each window directly from dataset (no interpolation). + RawDatasetReader reader(dataset); const float no_data_value = reader.get_no_data_value(); - const radix::RasterMask valid_mask = radix::raster::transform(height_map, [=](const float height) { - return height != no_data_value; - }); + bool any_read = false; + std::vector meshes_in_source_srs; + for (const radix::tile::SrsBounds& window : *windows) { + radix::geometry::Aabb2i pixel_bounds = reader.transform_srs_bounds_to_pixel_bounds(window); + 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); + auto read_result = reader.read_data_in_pixel_bounds_clamped(pixel_bounds); + if (!read_result.has_value()) { + return std::unexpected(BuildMeshError::OutOfBounds); + } + if (read_result->buffer().empty()) { + continue; + } + any_read = true; + const radix::Raster height_map = std::move(*read_result); - LOG_TRACE("Transforming pixels to vertices"); - 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("Finding valid pixels"); + const radix::RasterMask valid_mask = radix::raster::transform(height_map, [=](const float height) { return height != no_data_value; }); - 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) { + LOG_TRACE("Transforming pixels to vertices"); + 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 = meshify(source_points, valid_mask); + // Check if we even have any valid vertices. Can happen if all of the region is padding. + if (mesh.vertex_count() != 0 && mesh.face_count() != 0) { + meshes_in_source_srs.push_back(std::move(mesh)); + } + } + if (!any_read) { + return std::unexpected(BuildMeshError::OutOfBounds); + } + if (meshes_in_source_srs.empty()) { return std::unexpected(BuildMeshError::EmptyRegion); } + SimpleMesh mesh_in_source_srs = concatenate(std::move(meshes_in_source_srs)); - // 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 std::unexpected(BuildMeshError::EmptyRegion); + // Fast check if all vertices will be clipped. Horizontally, the windows already are close to + // the clip bounds, so this only applies to their heights. + if (!unbounded_height) { + const radix::geometry::Aabb3d actual_source_bounds = calculate_bounds(mesh_in_source_srs); + const auto actual_ecef_bounds = gis::srs::ecef_coverage(source_srs, actual_source_bounds); + if (!actual_ecef_bounds) { + return std::unexpected(log_transformation_error(actual_ecef_bounds.error())); + } + if (!radix::geometry::intersect(*actual_ecef_bounds, ecef_clip_bounds)) { + return std::unexpected(BuildMeshError::EmptyRegion); + } } LOG_TRACE("Clipping mesh based on target bounds"); - const SimpleMesh mesh_in_clip_srs = transform_mesh(std::move(mesh_in_source_srs), source_srs, clip_srs); - SimpleMesh clipped_mesh = mesh::clip_on_bounds(mesh_in_clip_srs, clip_bounds); + const auto mesh_in_clip_srs = transform_mesh(std::move(mesh_in_source_srs), source_srs, clip_srs); + if (!mesh_in_clip_srs) { + return std::unexpected(log_transformation_error(mesh_in_clip_srs.error())); + } + 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 std::unexpected(BuildMeshError::EmptyRegion); @@ -222,11 +354,19 @@ std::expected build_reference_mesh_patch( // TODO: move this to another function? LOG_TRACE("Generating uv space and calculating required texture bounds"); - clipped_mesh.uvs = generate_uv_space(clipped_mesh.positions, clip_srs, texture_srs, texture_bounds); + auto uvs = generate_uv_space(clipped_mesh.positions, clip_srs, texture_srs, texture_bounds); + if (!uvs) { + return std::unexpected(log_transformation_error(uvs.error())); + } + clipped_mesh.uvs = std::move(*uvs); LOG_TRACE("Transforming mesh into output srs"); - SimpleMesh target_mesh = transform_mesh(std::move(clipped_mesh), clip_srs, mesh_srs); - + auto target_mesh_result = transform_mesh(std::move(clipped_mesh), clip_srs, mesh_srs); + if (!target_mesh_result) { + return std::unexpected(log_transformation_error(target_mesh_result.error())); + } + SimpleMesh target_mesh = std::move(*target_mesh_result); + mesh::remove_isolated_vertices(target_mesh); // TODO: is this still required? mesh::validate(target_mesh); return target_mesh; diff --git a/src/sf_builder/mesh_builder.h b/src/sf_builder/mesh_builder.h index 2979042d..71568c4a 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.h @@ -1,9 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2021 Adam Celarek-Litofcenko + * Copyright (C) 2021 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 "Dataset.h" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/srs.h" #include "mesh/SimpleMesh.h" #include "border.h" @@ -12,13 +31,21 @@ namespace terrainbuilder { enum class BuildMeshError { OutOfBounds, - EmptyRegion + EmptyRegion, + TransformationFailed }; std::ostream &operator<<(std::ostream &os, BuildMeshError error); +/// Parts of a dataset's native bounds that may contain points within the given ECEF bounds. +/// Geographic longitudes are shifted by whole turns into the turn centred on the native bounds, +/// so that a dataset stored, e.g., at longitudes [180, 190] is read for [-180, -170]. +/// Each location is read at most once, so there is a seam at the edges of that turn. +Expected> native_read_windows( + const OGRSpatialReference& dataset_srs, const radix::tile::SrsBounds& native_bounds, const radix::geometry::Aabb3d& ecef_bounds); + /// Builds a mesh from the given height dataset. std::expected build_reference_mesh_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &mesh_srs, const OGRSpatialReference &clip_srs, const radix::geometry::Aabb3d &clip_bounds, const OGRSpatialReference &texture_srs, radix::tile::SrsBounds &texture_bounds); diff --git a/src/sf_builder/raw_dataset_reader.h b/src/sf_builder/raw_dataset_reader.h index 096526be..ec308415 100644 --- a/src/sf_builder/raw_dataset_reader.h +++ b/src/sf_builder/raw_dataset_reader.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adam Celarek-Litofcenko + * + * 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 @@ -13,14 +32,14 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include namespace terrainbuilder { class RawDatasetReader { public: - RawDatasetReader(Dataset &target_dataset) + RawDatasetReader(gis::Dataset &target_dataset) : RawDatasetReader(target_dataset.gdalDataset()) {} RawDatasetReader(GDALDataset *target_dataset) : dataset(target_dataset) { diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 6195b213..626507df 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -1,3 +1,23 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 @@ -15,6 +35,7 @@ #include #include +#include #include #include #include @@ -24,13 +45,11 @@ #include #include -#include "Dataset.h" -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/srs.h" -#include "ProgressIndicator.h" -#include "log.h" +#include "cli_support/ProgressIndicator.h" +#include "cli_support/log.h" #include "mesh/SimpleMesh.h" #include "mesh/io.h" #include "mesh_builder.h" @@ -42,8 +61,10 @@ #include "octree/Id.h" #include "octree/Space.h" #include "octree/utils.h" -#include "store/ThreadSafeStorage.h" #include "sf/finalize_storage.h" +#include "store/ThreadSafeStorage.h" +#include "store/inputs.h" +#include "store/snapshot.h" namespace terrainbuilder { @@ -58,13 +79,12 @@ std::string format_secs_since(const std::chrono::high_resolution_clock::time_poi } std::optional build_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &target_bounds_srs, const radix::geometry::Aabb3d &target_bounds, const OGRSpatialReference &texture_srs, const TileProvider* tile_provider, const OGRSpatialReference &mesh_srs) { - const ctb::Grid grid = ctb::GlobalMercator(); radix::tile::SrsBounds texture_bounds; std::chrono::high_resolution_clock::time_point start; @@ -78,7 +98,12 @@ std::optional build_patch( if (!mesh_result) { const BuildMeshError error = mesh_result.error(); if (error == BuildMeshError::OutOfBounds) { - const radix::tile::SrsBounds dataset_bounds = dataset.bounds(); + const auto dataset_bounds_result = dataset.bounds(); + if (!dataset_bounds_result) { + LOG_ERROR("Target bounds are fully outside of dataset region: {}", dataset_bounds_result.error().to_string()); + return std::nullopt; + } + const radix::tile::SrsBounds dataset_bounds = *dataset_bounds_result; LOG_ERROR("Target bounds are fully outside of dataset region\n" "Dataset {{\n" "\t x={}, y={}, w={}, h={}.\n" @@ -93,6 +118,8 @@ std::optional build_patch( LOG_WARN("Target bounds are inside dataset, but the region is empty"); return std::nullopt; } + // Transformation failures are logged where they occur. + return std::nullopt; } SimpleMesh mesh = mesh_result.value(); LOG_DEBUG("Mesh building took {}s", format_secs_since(start)); @@ -101,7 +128,7 @@ std::optional build_patch( if (tile_provider != nullptr) { start = std::chrono::high_resolution_clock::now(); LOG_INFO("Assembling mesh texture"); - std::optional texture = assemble_texture_from_tiles(grid, texture_srs, texture_bounds, *tile_provider); + std::optional texture = assemble_texture_from_tiles(texture_srs, texture_bounds, *tile_provider); if (!texture.has_value()) { LOG_ERROR("Failed to assemble texture"); // TODO: should we return nullopt here? @@ -120,7 +147,7 @@ std::optional build_patch( } void build_and_save_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &target_bounds_srs, const radix::geometry::Aabb3d &target_bounds, const OGRSpatialReference &texture_srs, @@ -173,58 +200,107 @@ T expect(const std::optional &opt, const std::string &msg) { } } -Expected build_all_patches( - Dataset &dataset, - const octree::Id::Level target_level, - const OGRSpatialReference &texture_srs, - const TileProvider *tile_provider, - const OGRSpatialReference &mesh_srs, - const std::filesystem::path &output_base_path, - const std::string &output_format, - const bool overwrite_existing -) { - if (!std::filesystem::exists(output_base_path)) { - LOG_TRACE("Output base path {} does not exist, creating it", output_base_path); - std::filesystem::create_directories(output_base_path); - } else if (!std::filesystem::is_directory(output_base_path)) { - LOG_ERROR_AND_EXIT("Output base path {} exists but is not a directory", output_base_path); +Expected build_all_patches(gis::Dataset& dataset, + const OGRSpatialReference& texture_srs, + const TileProvider* tile_provider, + const OGRSpatialReference& mesh_srs, + const std::filesystem::path& output_path, + const inputs::Record& record, + const std::optional& cache_path, + const std::function& stop_requested) +{ + if (record.target_level < min_target_level || record.target_level > octree::Id::max_level()) { + return Error::fail(Error::Code::InvalidInput, + fmt::format("target level {} is outside of {}..{}", record.target_level, unsigned(min_target_level), unsigned(octree::Id::max_level()))); } + const auto target_level = octree::Id::Level(record.target_level); - mesh::storage::OpenOptions open_options; - open_options.preferred_extension = output_format; - auto storage_result = mesh::storage::open_folder( - output_base_path, - std::move(open_options)); + std::optional cache; + if (cache_path.has_value()) { + if (auto valid = store::inputs::validate_cache(*cache_path, record); !valid) { + return Error::propagate(std::move(valid), "validate SF builder cache"); + } + if (auto linkable = store::snapshot::check_link_filesystem(*cache_path, output_path); !linkable) { + return linkable; + } + auto opened = mesh::storage::open_index(*cache_path / octree::storage::index_file_name); + if (!opened) { + return Error::propagate(std::move(opened), "open SF builder cache"); + } + cache.emplace(std::move(*opened)); + } + + mesh::storage::OpenOptions create_options; + create_options.preferred_extension = record.format; + auto storage_result = mesh::storage::create(output_path, std::move(create_options)); if (!storage_result) { - return Error::propagate( - std::move(storage_result), "open output terrain dataset \"" + output_base_path.string() + "\""); + return Error::propagate(std::move(storage_result), "create output terrain dataset \"" + output_path.string() + "\""); + } + if (auto written = store::inputs::write(storage_result->base_path(), record); !written) { + return written; } - mesh::storage::Storage raw_storage = std::move(storage_result.value()); - raw_storage.settings().allow_overwrite = overwrite_existing; - store::ThreadSafeStorage storage(std::move(raw_storage)); + store::ThreadSafeStorage storage(std::move(storage_result.value())); - const auto dataset_srs = dataset.srs(); - const auto dataset_bounds = dataset.bounds3d(true); + auto dataset_srs_result = dataset.srs(); + if (!dataset_srs_result) { + return Error::propagate(std::move(dataset_srs_result), "read dataset SRS"); + } + const auto dataset_srs = *dataset_srs_result; + auto dataset_bounds_result = dataset.bounds3d(false); + if (!dataset_bounds_result) { + return Error::propagate(std::move(dataset_bounds_result), "read dataset bounds"); + } + const auto dataset_bounds = *dataset_bounds_result; + auto dataset_coverage_result = dataset.geographic_coverage(); + if (!dataset_coverage_result) { + return Error::propagate(std::move(dataset_coverage_result), "read dataset coverage"); + } + const auto dataset_coverage = *dataset_coverage_result; - const auto ecef_srs = srs::ecef(); - const auto ecef_bounds = srs::encompassing_bounds_transfer( - dataset_srs, ecef_srs, dataset_bounds); + const auto ecef_srs = gis::srs::ecef(); + auto ecef_bounds_result = gis::srs::ecef_coverage(dataset_srs, dataset_bounds); + if (!ecef_bounds_result) { + return Error::propagate(std::move(ecef_bounds_result), "transform dataset bounds to ECEF"); + } + const auto ecef_bounds = *ecef_bounds_result; const auto space = octree::Space::earth(); const auto root_node = expect( space.find_smallest_node_encompassing_bounds(ecef_bounds), "Dataset is outside the octree root node."); + // Nodes within this radius contain no terrain: it is 100 km below the earth's mean radius. + constexpr double terrain_free_radius = 6371008 - 100000; + const auto within_terrain_free_radius = [](const radix::geometry::Aabb3d& bounds) { + const glm::dvec3 farthest_corner = glm::max(glm::abs(bounds.min), glm::abs(bounds.max)); + return glm::length(farthest_corner) < terrain_free_radius; + }; + // Below min_target_level, nodes touch the centre and are traversed without coverage checks. + // From there on, those nodes are within the terrain free radius, which keeps the remaining + // ones far enough from the centre for ecef2srs_coverage. + ASSERT(glm::length(space.get_node_size_at_level(min_target_level)) < terrain_free_radius); + const auto intersects_dataset = [&](const std::vector& node_coverage) { + for (const radix::geometry::Aabb3d& node_part : node_coverage) { + if (node_part.max.z < dataset_bounds.min.z || dataset_bounds.max.z < node_part.min.z) { + continue; + } + for (const radix::tile::SrsBounds& dataset_part : dataset_coverage) { + if (radix::geometry::intersect(radix::tile::SrsBounds(node_part), dataset_part)) { + return true; + } + } + } + return false; + }; + tbb::concurrent_vector concurrent_targets; tbb::task_group tg; std::function process_node; - tbb::enumerable_thread_specific> transform_ecef_dataset([&]() { - auto local_ecef_srs = ecef_srs; - auto local_dataset_srs = dataset_srs; - auto transform = srs::transformation(local_ecef_srs, local_dataset_srs); - return transform; - }); + // SRS objects are not shared between threads. + tbb::enumerable_thread_specific local_wgs84_srs([]() { return gis::srs::wgs84(); }); + tbb::enumerable_thread_specific local_dataset_srs([&]() { return dataset_srs; }); + const auto native_bounds = radix::tile::SrsBounds(dataset_bounds); process_node = [&](octree::Id node) { // Check if node intersects with ecef bounds of dataset @@ -232,14 +308,21 @@ Expected build_all_patches( if (!radix::geometry::intersect(node_bounds, ecef_bounds)) { return; } - - // Check if node bounds in dataset srs intersect with dataset - const auto node_bounds_dataset_srs = srs::encompassing_bounds_transfer( - &*transform_ecef_dataset.local(), node_bounds, 7, 3); - if (!radix::geometry::intersect(node_bounds_dataset_srs, dataset_bounds)) { + if (within_terrain_free_radius(node_bounds)) { return; } + // Check if node coverage intersects with dataset in geographic coordinates + if (node.level() >= min_target_level) { + const auto node_coverage = gis::srs::ecef2srs_coverage(node_bounds, local_wgs84_srs.local()); + if (!node_coverage) { + // Keep the node, dropping it could lose terrain. + LOG_WARN("Failed to compute coverage of node {}: {}", node, node_coverage.error().to_string()); + } else if (!intersects_dataset(*node_coverage)) { + return; + } + } + if (node.level() < target_level) { if (auto children = node.children(); children.has_value()) { for (const auto &child : *children) { @@ -247,6 +330,11 @@ Expected build_all_patches( } } } else if (node.level() == target_level) { + // Geographic coverage includes nodes outside of the native bounds of projected datasets. + const auto windows = native_read_windows(local_dataset_srs.local(), native_bounds, node_bounds); + if (windows && windows->empty()) { + return; + } concurrent_targets.push_back(node); } }; @@ -257,25 +345,25 @@ Expected build_all_patches( std::vector target_nodes; target_nodes.assign(concurrent_targets.begin(), concurrent_targets.end()); - ProgressIndicator progress(target_nodes.size()); + cli_support::ProgressIndicator progress(target_nodes.size()); std::jthread progress_thread = progress.start_monitoring(); // Clone dataset and SRSs for each thread - tbb::enumerable_thread_specific local_dataset([&]() { + tbb::enumerable_thread_specific local_dataset([&]() { return dataset.clone(); }); tbb::enumerable_thread_specific> local_ecef_srs([&]() { - return srs::clone(ecef_srs); + return gis::srs::clone(ecef_srs); }); tbb::enumerable_thread_specific> local_texture_srs([&]() { - return srs::clone(texture_srs); + return gis::srs::clone(texture_srs); }); tbb::enumerable_thread_specific> local_mesh_srs([&]() { - return srs::clone(mesh_srs); + return gis::srs::clone(mesh_srs); }); // Decrease log level to error to avoid excessive logging during mesh building - auto logger = Log::get_logger(); + auto logger = cli_support::Log::get_logger(); const auto original_level = logger->level(); const auto new_level = spdlog::level::err; // Only set to if it's more restrictive than current level @@ -283,22 +371,46 @@ Expected build_all_patches( logger->set_level(new_level); } + std::atomic_bool stopped = false; + std::atomic last_checkpoint = std::chrono::steady_clock::now().time_since_epoch().count(); + // One task at a time saves the index once the checkpoint interval has passed. + const auto checkpoint = [&]() -> Expected { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + auto last = last_checkpoint.load(); + if (now - std::chrono::steady_clock::duration(last) < store::snapshot::checkpoint_interval + || !last_checkpoint.compare_exchange_strong(last, now.count())) { + return {}; + } + return storage.save_or_create_index(); + }; tbb::task_group_context context; tbb::parallel_for(size_t(0), target_nodes.size(), [&](size_t i) { const auto &node = target_nodes[i]; - const auto already_exists = storage.has(node); - if (!already_exists) { - LOG_ERROR( - "Failed to inspect node {}: {}", - node, - already_exists.error().to_string()); + if (stop_requested && stop_requested()) { + stopped = true; progress.task_finished(); context.cancel_group_execution(); return; } - if (!overwrite_existing && already_exists.value()) { - progress.task_finished(); // TODO: correctly handle virtual nodes - return; + if (cache.has_value()) { + const auto cached = cache->has(node); + if (!cached) { + LOG_ERROR("Failed to inspect cached node {}: {}", node, cached.error().to_string()); + progress.task_finished(); + context.cancel_group_execution(); + return; + } + if (cached.value()) { + const auto linked = storage.link_from(node, *cache); + if (!linked) { + LOG_ERROR("Failed to link cached node {}: {}", node, linked.error().to_string()); + progress.task_finished(); + context.cancel_group_execution(); + return; + } + progress.task_finished(); + return; + } } auto &dataset = local_dataset.local(); @@ -330,6 +442,12 @@ Expected build_all_patches( } } + if (const auto saved = checkpoint(); !saved) { + LOG_ERROR("Failed to checkpoint the index: {}", saved.error().to_string()); + progress.task_finished(); + context.cancel_group_execution(); + return; + } progress.task_finished(); }, context); @@ -341,15 +459,25 @@ Expected build_all_patches( } progress_thread.join(); + auto finalized_storage = std::move(storage).release(); if (context.is_group_execution_cancelled()) { - LOG_ERROR_AND_EXIT("Failed to build all terrain patches"); + if (auto saved = finalized_storage.save_index(); !saved) { + return Error::propagate(std::move(saved), "checkpoint incomplete terrain storage"); + } + if (stopped) { + return Error::fail(Error::Code::Cancelled, "SF build cancelled; incomplete snapshot retained at " + finalized_storage.base_path().string()); + } + return Error::fail( + Error::Code::Internal, "failed to build all terrain patches; incomplete snapshot retained at " + finalized_storage.base_path().string()); } - auto finalized_storage = std::move(storage).release(); auto finalized = sf::finalize_storage(finalized_storage); if (!finalized) { - return Error::propagate( - std::move(finalized), "finalize generated terrain storage \"" + output_base_path.string() + "\""); + return Error::propagate(std::move(finalized), "finalize generated terrain storage \"" + finalized_storage.base_path().string() + "\""); + } + auto published = mesh::storage::publish(std::move(finalized_storage)); + if (!published) { + return Error::propagate(std::move(published), "publish generated terrain storage \"" + output_path.string() + "\""); } return {}; } diff --git a/src/sf_builder/terrainbuilder.h b/src/sf_builder/terrainbuilder.h index 64ad66d0..3842c604 100644 --- a/src/sf_builder/terrainbuilder.h +++ b/src/sf_builder/terrainbuilder.h @@ -1,21 +1,46 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 "Dataset.h" -#include "octree/Id.h" +#include "Error.h" +#include "gis/Dataset.h" +#include "inputs.h" #include "mesh/SimpleMesh.h" -#include "tile_provider.h" #include "mesh/storage.h" -#include "Error.h" +#include "octree/Id.h" +#include "tile_provider.h" namespace terrainbuilder { +/// Coarser nodes touch the earth's centre, where coverage in the dataset's SRS is unavailable. +constexpr octree::Id::Level min_target_level = 2; + void build_and_save_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &target_bounds_srs, const radix::geometry::Aabb3d &target_bounds, const OGRSpatialReference &texture_srs, @@ -24,20 +49,23 @@ void build_and_save_patch( const std::filesystem::path &output_path); std::optional build_patch( - Dataset &dataset, + gis::Dataset &dataset, const OGRSpatialReference &target_bounds_srs, const radix::geometry::Aabb3d &target_bounds, const OGRSpatialReference &texture_srs, const TileProvider *tile_provider, const OGRSpatialReference &mesh_srs); -Expected build_all_patches( - Dataset &dataset, - const octree::Id::Level target_level, - const OGRSpatialReference &texture_srs, - const TileProvider *tile_provider, - const OGRSpatialReference &mesh_srs, - const std::filesystem::path &output_base_path, - const std::string &output_format, - const bool overwrite_existing); +// Builds all nodes at record.target_level into .part and publishes it to +// output_path. Nodes in the cache, an incomplete earlier build with the same record, are +// linked instead of built. When stop_requested returns true, the active nodes are finished +// and the incomplete snapshot is retained, and the result is an error with code Cancelled. +Expected build_all_patches(gis::Dataset& dataset, + const OGRSpatialReference& texture_srs, + const TileProvider* tile_provider, + const OGRSpatialReference& mesh_srs, + const std::filesystem::path& output_path, + const inputs::Record& record, + const std::optional& cache_path = std::nullopt, + const std::function& stop_requested = {}); } diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 8de959f1..f7e5a84a 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -1,9 +1,32 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adam Celarek-Litofcenko + * + * 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 +#include #include #include #include @@ -14,15 +37,14 @@ #include #include -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" #include "geometry/geometry.h" #include "numeric/glm_math.h" #include "numeric/int_math.h" -#include "srs.h" +#include "gis/srs.h" +#include "gis/webmercator.h" #include "tile_provider.h" -#include "log.h" +#include "cli_support/log.h" namespace terrainbuilder { @@ -38,14 +60,43 @@ namespace terrainbuilder { return reference_zoom_level + zoom_level_change; } +/// Searches for the smallest Web Mercator tile that encompasses the given bounding box. +[[nodiscard]] inline std::optional find_smallest_encompassing_tile(const radix::tile::SrsBounds& bounds) +{ + // We dont want to recurse indefinetely if the bounds are empty. + if (bounds.width() == 0 || bounds.height() == 0) { + throw std::invalid_argument("bounds cannot be empty"); + } + + const std::array points = { bounds.min, bounds.max, glm::dvec2(bounds.min.x, bounds.max.y), glm::dvec2(bounds.max.x, bounds.min.y) }; + const auto all_points_inside = [&](const radix::tile::Id& tile) { + const radix::tile::SrsBounds tile_bounds = gis::webmercator::tile_bounds(tile); + return std::ranges::all_of(points, [&](const glm::dvec2& point) { return tile_bounds.contains_inclusive(point); }); + }; + + // We start at the root tile and repeatedly check every subtile until we find one that contains all + // of the bounding box. + radix::tile::Id current_smallest_encompassing_tile(0, { 0, 0 }); + if (!all_points_inside(current_smallest_encompassing_tile)) { + return std::nullopt; + } + while (true) { + const std::array children = current_smallest_encompassing_tile.children(); + const auto child = std::ranges::find_if(children, all_points_inside); + // Stop if none of the children contains all of the points. + if (child == children.end()) { + return current_smallest_encompassing_tile; + } + current_smallest_encompassing_tile = *child; + } +} + /// Find all the tiles needed to construct the texture for the given target bounds under the root tile. /// The result tiles are ordered in such a way that they can be sequentially written to a texture. /// Larger tiles are only included if they are not covered by smaller tiles. [[nodiscard]] inline std::vector find_relevant_tiles_to_splatter_in_bounds( /// The root tile specifying the tiles to consider. const radix::tile::Id root_tile, - /// Specifes the grid used to organize the image tiles. - const ctb::Grid &grid, /// The bounds for which texture data should be created. const radix::tile::SrsBounds &target_bounds, /// Provider class that maps tile ids to their textures. @@ -58,7 +109,7 @@ namespace terrainbuilder { // at a high zoom level. So we try to estimate the actual zoom level of the target bounds and recurse at least // to that level to find relevant textures. if (!min_zoom_level_to_examine.has_value()) { - min_zoom_level_to_examine = estimate_zoom_level(root_tile.zoom_level, grid.srsBounds(root_tile, false), target_bounds) + 1; + min_zoom_level_to_examine = estimate_zoom_level(root_tile.zoom_level, gis::webmercator::tile_bounds(root_tile), target_bounds) + 1; } // A list of tiles determined to be relevant for the target bounds. @@ -71,7 +122,7 @@ namespace terrainbuilder { } // Check if we even require this tile to fill the target region. - const radix::tile::SrsBounds tile_bounds = grid.srsBounds(tile, false); + const radix::tile::SrsBounds tile_bounds = gis::webmercator::tile_bounds(tile); if (!radix::geometry::intersect(target_bounds, tile_bounds)) { return true; } @@ -351,8 +402,6 @@ inline uint32_t find_max_zoom_level(const std::span tiles [[nodiscard]] inline AssembledTexture splatter_tiles_to_texture( const radix::tile::Id root_tile, - /// Specifes the grid used to organize the image tiles. - const ctb::Grid &grid, /// The bounds for which texture data should be created. const radix::tile::SrsBounds &target_bounds, /// A mapping from tile id to a filesystem path. @@ -367,7 +416,7 @@ inline AssembledTexture splatter_tiles_to_texture( return {}; } - const radix::tile::SrsBounds root_tile_bounds = grid.srsBounds(root_tile, false); + const radix::tile::SrsBounds root_tile_bounds = gis::webmercator::tile_bounds(root_tile); const uint32_t max_zoom_level = find_max_zoom_level(tiles_to_splatter); DEBUG_ASSERT(max_zoom_level >= root_tile.zoom_level); @@ -444,8 +493,6 @@ inline AssembledTexture splatter_tiles_to_texture( /// Creates a texture for the given region. [[nodiscard]] inline std::optional assemble_texture_from_tiles( - /// Specifes the grid used to organize the image tiles. - const ctb::Grid &grid, /// Specifies the srs the target bounds are in. const OGRSpatialReference &target_srs, /// The bounds for which texture data should be created. @@ -465,10 +512,11 @@ inline std::optional assemble_texture_from_tiles( return std::nullopt; } - // 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. - radix::tile::Id smallest_encompassing_tile = grid.findSmallestEncompassingTile(encompassing_bounds).value(); + // The texture is linear in the srs of the tiles, the uvs are linear in the target srs. + ASSERT(gis::srs::webmercator().IsSame(&target_srs)); + const radix::tile::SrsBounds& encompassing_bounds = target_bounds; + // Find the smallest tile (id) that encompasses these bounds. + radix::tile::Id smallest_encompassing_tile = find_smallest_encompassing_tile(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()) { @@ -480,9 +528,9 @@ inline std::optional assemble_texture_from_tiles( smallest_encompassing_tile = smallest_encompassing_tile.parent(); } - // Tile pixel coordinates are stored as 32-bit unsigned ints (see PixelPoint/i_pixel), so - // coords.x * grid.tileSize() overflows past this zoom level; never recurse deeper than that. - const uint32_t max_safe_zoom_level = static_cast(std::log2(std::numeric_limits::max() / grid.tileSize())); + // Very small target bounds can push the examined zoom level arbitrarily deep, but + // gis::webmercator::tile_bounds is only valid up to this zoom level; never recurse deeper than that. + const uint32_t max_safe_zoom_level = 32; const uint32_t clamped_max_zoom = std::min(max_zoom.value_or(max_safe_zoom_level), max_safe_zoom_level); // The padding reaches past the requested bounds, so select tiles for slightly grown bounds. @@ -492,7 +540,7 @@ inline std::optional assemble_texture_from_tiles( // Find relevant tiles in bounds const std::vector tiles_to_splatter = find_relevant_tiles_to_splatter_in_bounds( - smallest_encompassing_tile, grid, selection_bounds, tile_provider, clamped_max_zoom); + smallest_encompassing_tile, selection_bounds, tile_provider, clamped_max_zoom); LOG_TRACE("Found {} relevant texture tiles", tiles_to_splatter.size()); // If we found to relevant tiles, we are done. @@ -502,6 +550,6 @@ inline std::optional assemble_texture_from_tiles( } // Splatter tiles into texture buffer - return splatter_tiles_to_texture(smallest_encompassing_tile, grid, encompassing_bounds, tile_provider, tiles_to_splatter, rescale_filter, min_padding, alignment); + return splatter_tiles_to_texture(smallest_encompassing_tile, encompassing_bounds, tile_provider, tiles_to_splatter, rescale_filter, min_padding, alignment); } } diff --git a/src/sf_builder/tile_provider.h b/src/sf_builder/tile_provider.h index c709536d..08ae75eb 100644 --- a/src/sf_builder/tile_provider.h +++ b/src/sf_builder/tile_provider.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_index_browser/CMakeLists.txt b/src/sf_index_browser/CMakeLists.txt index c580b6a5..d28e2c26 100644 --- a/src/sf_index_browser/CMakeLists.txt +++ b/src/sf_index_browser/CMakeLists.txt @@ -1,3 +1,21 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_executable(sf-index-browser cli.h cli.cpp main.cpp diff --git a/src/sf_index_browser/cli.cpp b/src/sf_index_browser/cli.cpp index 60b680e6..077d628e 100644 --- a/src/sf_index_browser/cli.cpp +++ b/src/sf_index_browser/cli.cpp @@ -1,9 +1,30 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "cli.h" #include #include #include +#include "cli_support/log.h" + using namespace cli; Args cli::parse(int argc, const char * const * argv) { @@ -21,17 +42,8 @@ Args cli::parse(int argc, const char * const * argv) { args.full_view = false; app.add_flag("--full", args.full_view, "Start at octree root instead of dataset root."); - const std::map - log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}}; app.add_option("--verbosity", args.log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)) ->default_val(spdlog::level::level_enum::info); try { diff --git a/src/sf_index_browser/cli.h b/src/sf_index_browser/cli.h index dcbb8bdd..8234bcfe 100644 --- a/src/sf_index_browser/cli.h +++ b/src/sf_index_browser/cli.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_index_browser/main.cpp b/src/sf_index_browser/main.cpp index 11f7db1a..8e1db413 100644 --- a/src/sf_index_browser/main.cpp +++ b/src/sf_index_browser/main.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -11,7 +30,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include "octree/Id.h" #include "mesh/storage.h" #include "mesh/storage.h" @@ -303,7 +322,7 @@ int run(const cli::Args &args) { int main(int argc, char **argv) { const cli::Args args = cli::parse(argc, argv); - Log::init(args.log_level); + cli_support::Log::init(args.log_level); const std::string arg_str = std::accumulate(argv, argv + argc, std::string(), [](const std::string &acc, const char *arg) { diff --git a/src/sf_merger/CMakeLists.txt b/src/sf_merger/CMakeLists.txt index 2f647ab4..9b82d696 100644 --- a/src/sf_merger/CMakeLists.txt +++ b/src/sf_merger/CMakeLists.txt @@ -1,8 +1,27 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_library(sfmergerlib INTERFACE NodeLoader.h NodeWriter.h cut.h earth.h + inputs.h mask.h merge.h utils.h diff --git a/src/sf_merger/NodeLoader.h b/src/sf_merger/NodeLoader.h index 55e5a76a..f89974e4 100644 --- a/src/sf_merger/NodeLoader.h +++ b/src/sf_merger/NodeLoader.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/sf_merger/NodeWriter.h b/src/sf_merger/NodeWriter.h index 3bca914f..701dd1eb 100644 --- a/src/sf_merger/NodeWriter.h +++ b/src/sf_merger/NodeWriter.h @@ -1,10 +1,29 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Error.h" #include "NodeLoader.h" @@ -18,43 +37,22 @@ class NodeWriter { public: NodeWriter(mesh::storage::Storage &storage) : _storage(storage) {} - Expected has_node( - const octree::Id &id) { - return this->_storage.has(id); - } - Expected write_node( const octree::Id &id, const SimpleMesh &mesh) { mesh::validate(mesh); - auto save_result = this->_storage.save(id, mesh); - if (!save_result) { - return save_result; - } - auto path_result = this->_storage.path_for(id); - if (!path_result) { - return Error::propagate( - std::move(path_result), "resolve texture output path for node " + id.to_string()); - } - auto p = path_result.value(); - // change extension to .png - p.replace_extension(".png"); - if (mesh.texture.has_value()) { - try { - if (!cv::imwrite(p, mesh.texture.value())) { - return Error::fail(Error::Code::Io, "write node texture to", p); - } - } catch (const cv::Exception& error) { - return Error::fail( - Error::Code::Io, "write node texture to \"" + p.string() + "\": " + error.what()); - } - } - return {}; + return this->_storage.save(id, mesh); } - Expected copy_subtree_to_output( + Expected link_node(const octree::Id& id, const mesh::storage::IndexedStorage& source) { return this->_storage.link_from(id, source); } + + Expected save_index() { return this->_storage.save_index(); } + + // Calls poll before linking each leaf and stops at its first error, e.g. a cancellation. + Expected link_subtree_to_output( const octree::Id &id, - const NodeLoader &loader) { + const NodeLoader &loader, + const std::function()> &poll) { std::optional error = std::nullopt; auto traversal = store::traverse( loader.storage().index(), @@ -67,7 +65,11 @@ class NodeWriter { } DEBUG_ASSERT(status == store::NodeStatus::Leaf); - auto result = this->_storage.copy_from(child_id, loader.storage()); + if (auto polled = poll(); !polled) { + error = std::move(polled).error(); + return; + } + auto result = this->_storage.link_from(child_id, loader.storage()); if (!result) { error = std::move(result).error(); } @@ -76,11 +78,11 @@ class NodeWriter { id); if (!traversal) { return Error::propagate( - std::move(traversal), "traverse source subtree rooted at " + id.to_string() + " while copying"); + std::move(traversal), "traverse source subtree rooted at " + id.to_string() + " while linking"); } if (error.has_value()) { return Error::propagate( - std::move(error.value()), "copy subtree rooted at " + id.to_string() + " to output"); + std::move(error.value()), "link subtree rooted at " + id.to_string() + " to output"); } return {}; } diff --git a/src/sf_merger/atlas/HorizontalStripLayoutPlanner.h b/src/sf_merger/atlas/HorizontalStripLayoutPlanner.h index 1ede7554..aee5d1a2 100644 --- a/src/sf_merger/atlas/HorizontalStripLayoutPlanner.h +++ b/src/sf_merger/atlas/HorizontalStripLayoutPlanner.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/atlas/Layout.h b/src/sf_merger/atlas/Layout.h index 18790a1e..54fa22cf 100644 --- a/src/sf_merger/atlas/Layout.h +++ b/src/sf_merger/atlas/Layout.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/atlas/LayoutPlanner.h b/src/sf_merger/atlas/LayoutPlanner.h index 04b50c9b..4c669ed6 100644 --- a/src/sf_merger/atlas/LayoutPlanner.h +++ b/src/sf_merger/atlas/LayoutPlanner.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/atlas/Sheet.h b/src/sf_merger/atlas/Sheet.h index acf3cb74..ecf57040 100644 --- a/src/sf_merger/atlas/Sheet.h +++ b/src/sf_merger/atlas/Sheet.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/atlas/build.h b/src/sf_merger/atlas/build.h index f85b63a9..c5fcdbad 100644 --- a/src/sf_merger/atlas/build.h +++ b/src/sf_merger/atlas/build.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/cli.cpp b/src/sf_merger/cli.cpp index 0ed8f19f..70ed0d7e 100644 --- a/src/sf_merger/cli.cpp +++ b/src/sf_merger/cli.cpp @@ -1,9 +1,29 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 "cli.h" #include #include #include +#include "cli_support/log.h" + using namespace cli; Args cli::parse(int argc, const char *const *argv) { @@ -26,10 +46,13 @@ Args cli::parse(int argc, const char *const *argv) { merge.add_option("--mask", merge_args.mask_path, "Path to a mask denoting the valid area of the new dataset") ->check(CLI::ExistingFile); - merge.add_option("--output", merge_args.output_path, "Path to output write the merged dataset to") + merge + .add_option( + "--output", merge_args.output_path, "New path for the merged dataset; it is written to .part, which is renamed to when complete") ->required(); - merge.add_option("--overwrite", merge_args.overwrite_output, "Overwrite any data already in output"); + merge.add_option("--cache", merge_args.cache_path, "Incomplete .part output of an earlier merge with the same inputs to reuse") + ->check(CLI::ExistingDirectory); /* const std::map method_names{ @@ -47,25 +70,19 @@ Args cli::parse(int argc, const char *const *argv) { ->check(CLI::ExistingDirectory); cut.add_option("--mask", cut_args.mask_path, "Path to a mask denoting the regions to retain") + ->required() ->check(CLI::ExistingFile); - cut.add_option("--output", cut_args.output_path, "Path to output the cut dataset to"); + cut.add_option("--output", cut_args.output_path, "New path for the cut dataset; it is written to .part, which is renamed to when complete") + ->required(); cut.add_flag("--keep-inside,!--keep-outside", cut_args.keep_inside, "Keep the part of the dataset thats inside/outside the mask."); cut.fallthrough(); spdlog::level::level_enum log_level; - const std::map log_level_names{ - {"off", spdlog::level::level_enum::off}, - {"critical", spdlog::level::level_enum::critical}, - {"error", spdlog::level::level_enum::err}, - {"warn", spdlog::level::level_enum::warn}, - {"info", spdlog::level::level_enum::info}, - {"debug", spdlog::level::level_enum::debug}, - {"trace", spdlog::level::level_enum::trace}}; app.add_option("--verbosity", log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)) ->default_val(spdlog::level::level_enum::info); app.require_subcommand(1); diff --git a/src/sf_merger/cli.h b/src/sf_merger/cli.h index d8fb6f8e..3cb438e5 100644 --- a/src/sf_merger/cli.h +++ b/src/sf_merger/cli.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 @@ -25,7 +43,7 @@ struct MergeArgs : public BaseArgs { std::filesystem::path output_path; std::optional mask_path; // MergeAlgorithm algorihm; - bool overwrite_output; + std::optional cache_path; }; struct CutArgs : public BaseArgs { diff --git a/src/sf_merger/cut.h b/src/sf_merger/cut.h index b06437f0..e6924a37 100644 --- a/src/sf_merger/cut.h +++ b/src/sf_merger/cut.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -5,7 +24,7 @@ #include #include "mesh/SimpleMesh.h" -#include "log.h" +#include "cli_support/log.h" #include "mask.h" #include "octree/Id.h" #include "mesh/storage.h" @@ -46,9 +65,9 @@ inline Expected cut_leaf_node( const Cow clipped = clip_on_mask(mesh, mask, ctx.keep_inside); if (clipped.is_ref()) { LOG_TRACE("Mesh was fully inside the mask"); - auto copy_result = ctx.output.copy_from(id, ctx.input); - if (!copy_result) { - return copy_result; + auto link_result = ctx.output.link_from(id, ctx.input); + if (!link_result) { + return link_result; } } else { const SimpleMesh &clipped_mesh = clipped; @@ -155,20 +174,22 @@ inline Expected cut_dataset( const std::filesystem::path &output_path, const bool keep_inside) { LOG_TRACE("Creating output dataset at {}", output_path); - std::filesystem::create_directories(output_path); - mesh::storage::OpenOptions options; - options.default_mapping = octree::store_layout::level_and_coordinate_directories(); - options.preferred_extension = ".glb"; - auto output_result = mesh::storage::open_folder_indexed(output_path, std::move(options)); + options.default_mapping = input_dataset.layout().mapping(); + options.preferred_extension = std::string(input_dataset.codec_selector().value_or(".sfmesh")); + auto output_result = mesh::storage::create(output_path, std::move(options)); if (!output_result) { - return Error::propagate( - std::move(output_result), "open cut output dataset \"" + output_path.string() + "\""); + return Error::propagate(std::move(output_result), "create cut output dataset \"" + output_path.string() + "\""); } mesh::storage::IndexedStorage output_dataset = std::move(output_result.value()); - if (!output_dataset.index().empty()) { - return Error::fail(Error::Code::AlreadyExists, "output dataset already contains nodes: " + output_path.string()); - } - return cut_dataset(input_dataset, mask, output_dataset, keep_inside); + auto cut = cut_dataset(input_dataset, mask, output_dataset, keep_inside); + if (!cut) { + return cut; + } + auto published = mesh::storage::publish(std::move(output_dataset)); + if (!published) { + return Error::propagate(std::move(published), "publish cut output dataset \"" + output_path.string() + "\""); + } + return {}; } diff --git a/src/sf_merger/earth.h b/src/sf_merger/earth.h index 0c973aac..d98003a6 100644 --- a/src/sf_merger/earth.h +++ b/src/sf_merger/earth.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/inputs.h b/src/sf_merger/inputs.h new file mode 100644 index 00000000..127aebaa --- /dev/null +++ b/src/sf_merger/inputs.h @@ -0,0 +1,66 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include "io/envelope.h" +#include "octree/storage/IndexFile.h" +#include "store/inputs.h" + +namespace sf_merger::inputs { + +// Everything that determines the merged meshes; a cache must match it. +struct Record { + // Optional members defeat zpp_bits' member counting. + using serialize = zpp::bits::members<4>; + + store::inputs::Fingerprint base; + store::inputs::Fingerprint added; + std::optional mask; + // Bump on changes that alter the merged meshes. + std::uint32_t processing_version = 1; + + bool operator==(const Record&) const = default; +}; + +using Schema = io::envelope::PayloadSchema<"sf_merger.Inputs", io::envelope::Version<1, Record>>; + +inline Expected make_record(const std::filesystem::path& base, const std::filesystem::path& added, const std::optional& mask) +{ + Record record; + for (auto [path, fingerprint] : { std::pair { &base, &record.base }, std::pair { &added, &record.added } }) { + auto result = store::inputs::fingerprint(*path, octree::storage::metadata_file_name, octree::storage::index_file_name); + if (!result) { + return Error::propagate(std::move(result), "fingerprint SF merge input \"" + path->string() + "\""); + } + *fingerprint = std::move(*result); + } + if (mask.has_value()) { + record.mask = std::filesystem::absolute(*mask).lexically_normal().string(); + } + return record; +} + +} // namespace sf_merger::inputs diff --git a/src/sf_merger/main.cpp b/src/sf_merger/main.cpp index 3cc7b7bc..6ac0dfcf 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.cpp @@ -1,38 +1,53 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adrian Gawor + * Copyright (C) 2023 Adam Celarek-Litofcenko + * + * 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 "cli.h" +#include "cli_support/log.h" +#include "cli_support/stop_signal.h" #include "cut.h" -#include "log.h" +#include "earth.h" +#include "inputs.h" #include "mask.h" -#include "mesh/storage.h" #include "merge.h" +#include "mesh/storage.h" #include "optional_utils.h" -#include "earth.h" -std::optional load_mask_from_path(const std::filesystem::path& path) { - if (std::filesystem::exists(path)) { - LOG_INFO("Loading mask file from {}", path); - const glm::dvec2 radius_range = mask::pad_radius_range(earth::radius_range(), 2); - auto result = mask::load_from_path(path, radius_range); - if (result) { - const auto mask = result.value(); - LOG_DEBUG("Loaded mask successfully ({} vertices, {} triangles)", - mask.vertex_count(), mask.face_count()); - return mask; - } else { - LOG_ERROR("Failed to load mask: {}", result.error().description()); - } - } else { - LOG_DEBUG("No mask file found at {}, proceeding without mask", path); +MeshMask load_mask_from_path(const std::filesystem::path& path) +{ + LOG_INFO("Loading mask file from {}", path); + const glm::dvec2 radius_range = mask::pad_radius_range(earth::radius_range(), 2); + auto result = mask::load_from_path(path, radius_range); + if (!result) { + LOG_ERROR_AND_EXIT("Failed to load mask {}: {}", path, result.error().to_string()); } - - return std::nullopt; + LOG_DEBUG("Loaded mask successfully ({} vertices, {} triangles)", result->vertex_count(), result->face_count()); + return std::move(*result); } -void run(const cli::MergeArgs& args) { +int run(const cli::MergeArgs& args) +{ LOG_TRACE("Loading base dataset from {}", args.base_path); auto base_result = mesh::storage::open_folder_indexed(args.base_path); if (!base_result) { @@ -53,33 +68,28 @@ void run(const cli::MergeArgs& args) { } mesh::storage::IndexedStorage new_dataset = std::move(new_result.value()); - LOG_TRACE("Creating output dataset at {}", args.output_path); - std::filesystem::create_directories(args.output_path); - mesh::storage::OpenOptions options; - options.default_mapping = base_dataset.layout().mapping(); - options.preferred_extension = std::string(base_dataset.codec_selector().value_or(".sfmesh")); - auto output_result = mesh::storage::open_folder(args.output_path, std::move(options)); - if (!output_result) { - LOG_ERROR_AND_EXIT( - "Failed to open output dataset {}: {}", - args.output_path, - output_result.error().to_string()); + const auto record = sf_merger::inputs::make_record(args.base_path, args.new_path, args.mask_path); + if (!record) { + LOG_ERROR_AND_EXIT("Failed to record the merge inputs: {}", record.error().to_string()); } - mesh::storage::Storage output_dataset = std::move(output_result.value()); - std::optional mask = flatten(map(args.mask_path, load_mask_from_path)); + std::optional mask = map(args.mask_path, load_mask_from_path); - const auto merge_result = merge_datasets( - base_dataset, - new_dataset, - output_dataset, - mask); + cli_support::stop_signal::install(); + const auto merge_result = merge_datasets(base_dataset, new_dataset, args.output_path, mask, *record, args.cache_path, cli_support::stop_signal::requested); if (!merge_result) { - LOG_ERROR_AND_EXIT("Failed to merge datasets: {}", merge_result.error().to_string()); + if (merge_result.error().code() == Error::Code::Cancelled) { + LOG_INFO("{}", merge_result.error().to_string()); + return cli_support::stop_signal::exit_code(); + } + LOG_ERROR("Failed to merge datasets: {}", merge_result.error().to_string()); + return 1; } + return 0; } -void run(const cli::CutArgs& args) { +int run(const cli::CutArgs& args) +{ LOG_TRACE("Loading input dataset from {}", args.input_path); auto input_result = mesh::storage::open_folder_indexed(args.input_path); if (!input_result) { @@ -89,25 +99,28 @@ void run(const cli::CutArgs& args) { input_result.error().to_string()); } const mesh::storage::IndexedStorage input_dataset = std::move(input_result.value()); - const MeshMask mask = DEBUG_ASSERT_VAL(load_mask_from_path(args.mask_path)).value(); + const MeshMask mask = load_mask_from_path(args.mask_path); const auto cut_result = cut_dataset( input_dataset, mask, args.output_path, args.keep_inside); if (!cut_result) { - LOG_ERROR_AND_EXIT("Failed to cut dataset: {}", cut_result.error().to_string()); + LOG_ERROR("Failed to cut dataset: {}", cut_result.error().to_string()); + return 1; } + return 0; } -void run(const cli::Args &args) { - std::visit([](const auto& args) { run(args); }, args); +int run(const cli::Args& args) +{ + return std::visit([](const auto& args) { return run(args); }, args); } int main(int argc, char **argv) { const cli::Args args = cli::parse(argc, argv); const auto log_level = std::visit([](const auto& args) { return args.log_level; }, args); - Log::init(log_level); + cli_support::Log::init(log_level); const std::string arg_str = std::accumulate(argv, argv + argc, std::string(), [](const std::string &acc, const char *arg) { @@ -115,7 +128,7 @@ int main(int argc, char **argv) { }); LOG_DEBUG("Running with: {}", arg_str); - run(args); + return run(args); // TODO: remove // std::filesystem::path source = "../../../meshes/out-merge"; // std::filesystem::path destination = "/mnt/c/Users/Admin/Downloads/out-merge"; diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 304cbce3..0afc8d2c 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -1,22 +1,43 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 #include -#include "Dataset.h" +#include "gis/Dataset.h" +#include "gis/vector_mask.h" #include "mesh/SimpleMesh.h" #include "mesh/cgal.h" #include "mesh/convert.h" #include "mesh/validate.h" #include "mesh/validate_cgal.h" #include "mesh/bounds.h" -#include "srs.h" +#include "gis/srs.h" #include "SphereProjector.h" #include @@ -31,17 +52,13 @@ #include #include -using Kernel = cgal::kernel::epeck::Kernel; -using Point2 = Kernel::Point_2; -using Polygon2 = CGAL::Polygon_2; -using PolygonWithHoles2 = CGAL::Polygon_with_holes_2; -using PolygonSet2 = CGAL::Polygon_set_2; -using MultipolygonWithHoles2 = CGAL::Multipolygon_with_holes_2; - -struct ReferencedPolygonMask { - MultipolygonWithHoles2 polygons; - OGRSpatialReference srs; -}; +using gis::vector_mask::Kernel; +using gis::vector_mask::Point2; +using gis::vector_mask::Polygon2; +using gis::vector_mask::PolygonWithHoles2; +using gis::vector_mask::PolygonSet2; +using gis::vector_mask::MultipolygonWithHoles2; +using gis::vector_mask::ReferencedPolygonMask; struct SpherePolygonMask { MultipolygonWithHoles2 polygons; @@ -89,211 +106,23 @@ namespace { } } -enum class LoadErrorKind { - UnsupportedFormat, - FileNotFound, - EmptySource, - InvalidGeometry, - UnsupportedSpatialReference -}; - -class LoadError { -public: - LoadError() = default; - constexpr LoadError(LoadErrorKind kind) - : kind(kind) {} - - operator LoadErrorKind() const { - return this->kind; - } - constexpr bool operator==(LoadError other) const { - return this->kind == other.kind; - } - constexpr bool operator!=(LoadError other) const { - return this->kind != other.kind; - } - - std::string description() const { - switch (kind) { - case LoadErrorKind::UnsupportedFormat: - return "format not supported"; - case LoadErrorKind::FileNotFound: - 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"; - } - } - - friend std::ostream &operator<<(std::ostream &os, const LoadError &err) { - return os << err.description(); - } - -private: - LoadErrorKind kind; -}; +inline constexpr double simplification_tolerance_m = 0.1; 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(); - const OGRLinearRing *exterior_ring = polygon->getExteriorRing(); - uint64_t count = exterior_ring ? exterior_ring->getNumPoints() : 0; - 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()) { - num_points--; - } - if (num_points < 3) { - return std::nullopt; - } - - Polygon2 polygon; - for (uint32_t i = 0; i < num_points; i++) { - polygon.push_back(Point2(ring.getX(i), ring.getY(i))); - } - - if (!polygon.is_simple()) { - // Contains self intersections or duplicate points - LOG_WARN("Skipping non-simple polygon"); - return std::nullopt; - } - - if (is_outer != polygon.is_counterclockwise_oriented()) { - polygon.reverse_orientation(); - } - - return polygon; -} - -std::optional convert_polygon(const OGRPolygon &ogr_polygon) { - const OGRLinearRing *outer_ring = ogr_polygon.getExteriorRing(); - DEBUG_ASSERT(outer_ring); - - auto outer_opt = convert_ring(*outer_ring, true); - if (!outer_opt) { - return std::nullopt; - } - const Polygon2 outer = std::move(outer_opt.value()); - - PolygonWithHoles2 polygon(outer); - const uint32_t num_holes = static_cast(ogr_polygon.getNumInteriorRings()); - for (uint32_t i = 0; i < num_holes; i++) { - const OGRLinearRing *inner = ogr_polygon.getInteriorRing(i); - DEBUG_ASSERT(inner); - auto hole_opt = convert_ring(*inner, false); - if (!hole_opt) { - continue; - } - Polygon2 hole = std::move(hole_opt.value()); - polygon.add_hole(std::move(hole)); - } - - return polygon; -} - -void process_geometry(const OGRGeometry &geometry, MultipolygonWithHoles2 &out) { - const OGRwkbGeometryType geometry_type = wkbFlatten(geometry.getGeometryType()); // map 2.5d to 2d - - switch (geometry_type) { - case wkbPolygon: { - const OGRPolygon *polygon = geometry.toPolygon(); - DEBUG_ASSERT(polygon); - if (auto result = convert_polygon(*polygon)) { - out.add_polygon_with_holes(std::move(*result)); - } - break; - } - - case wkbMultiPolygon: - case wkbGeometryCollection: { - const OGRGeometryCollection *collection = geometry.toGeometryCollection(); - DEBUG_ASSERT(collection); - const uint32_t num_children = collection->getNumGeometries(); - for (uint32_t i = 0; i < num_children; i++) { - process_geometry(*collection->getGeometryRef(i), out); - } - break; - } - - default: - LOG_WARN("Skipping unsupported geometry type: {}", OGRGeometryTypeToName(geometry_type)); - break; - } -} - -void convert_to_ecef_and_project_onto_sphere(MultipolygonWithHoles2 &polygons, const SphereProjector &projector, const OGRSpatialReference &srs) { - const auto srs_transform = srs::transformation(srs, srs::ecef()); - for (auto &polygon : polygons.polygons_with_holes()) { - for (auto &cgal_point : polygon.outer_boundary()) { - const glm::dvec2 source_point = convert::to_glm_point(cgal_point); - const glm::dvec3 ecef_point = srs::transform_point(srs_transform.get(), glm::dvec3(source_point, 0)); - const glm::dvec2 projected_point = projector.project_point(ecef_point); - cgal_point = convert::to_cgal_point(projected_point); - } - for (auto &hole : polygon.holes()) { - for (auto &cgal_point : hole) { - const glm::dvec2 source_point = convert::to_glm_point(cgal_point); - const glm::dvec3 ecef_point = srs::transform_point(srs_transform.get(), glm::dvec3(source_point, 0)); - const glm::dvec2 projected_point = projector.project_point(ecef_point); - cgal_point = convert::to_cgal_point(projected_point); + template + void for_each_point(MultipolygonWithHoles2& polygons, Function function) + { + for (auto& polygon : polygons.polygons_with_holes()) { + for (auto& cgal_point : polygon.outer_boundary()) { + function(cgal_point); + } + for (auto& hole : polygon.holes()) { + for (auto& cgal_point : hole) { + function(cgal_point); + } } } } -} /* glm::dvec2 calculate_radius_squared_range(const SimpleMesh3d &mesh) { @@ -332,68 +161,22 @@ glm::dvec2 calculate_radius_range(const std::span meshes) { } // namespace -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(); - if (!geometry) { - LOG_ERROR("Mask feature has no geometry"); - return std::unexpected(LoadErrorKind::InvalidGeometry); - } - - 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()) { - LOG_ERROR("No valid polygons found in mask dataset '{}'", mask_dataset.name()); - return std::unexpected(LoadErrorKind::EmptySource); - } - - return ReferencedPolygonMask{.polygons = std::move(polygons), .srs = std::move(srs)}; -} - inline SpherePolygonMask project_onto_sphere(ReferencedPolygonMask ref_mask, const double radius) { auto mask_2d = std::move(ref_mask.polygons); auto srs = std::move(ref_mask.srs); // Translate to ecef and project onto sphere - const auto source_bounds_cgal = mask_2d.bbox(); - const radix::geometry::Aabb3d source_bounds( - glm::dvec3(source_bounds_cgal.xmin(), source_bounds_cgal.ymin(), 0), - glm::dvec3(source_bounds_cgal.xmax(), source_bounds_cgal.ymax(), 0)); - const radix::geometry::Aabb3d ecef_bounds = srs::non_exact_bounds_transform(source_bounds, srs, srs::ecef()); - const glm::dvec3 bounds_center = ecef_bounds.centre(); - const glm::dvec3 tangent_point = scale_to_length(bounds_center, radius); + std::vector ecef_points; + for_each_point(mask_2d, [&](const Point2& cgal_point) { ecef_points.emplace_back(convert::to_glm_point(cgal_point), 0); }); + const auto srs_transform = Error::asserting_unwrap(gis::srs::transformation(srs, gis::srs::ecef())); + Error::asserting_unwrap(gis::srs::transform_points_inplace(srs_transform.get(), ecef_points)); + // Unlike the bounds in the source srs, the bounds of the points stay on the mask's side of the + // earth for masks split at the antimeridian. + const radix::geometry::Aabb3d ecef_bounds = radix::geometry::find_bounds(std::span(ecef_points)); + const glm::dvec3 tangent_point = scale_to_length(ecef_bounds.centre(), radius); const SphereProjector projector(tangent_point); - convert_to_ecef_and_project_onto_sphere(mask_2d, projector, srs); + auto ecef_point = ecef_points.begin(); + for_each_point(mask_2d, [&](Point2& cgal_point) { cgal_point = convert::to_cgal_point(projector.project_point(*ecef_point++)); }); return SpherePolygonMask(mask_2d, projector); } @@ -552,25 +335,18 @@ inline MeshMask extrude( return extrude(mask, padded_radius_range); } -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 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 std::unexpected(LoadErrorKind::FileNotFound); +inline Expected load_referenced_from_path(const std::filesystem::path &path) { + auto opened = gis::Dataset::open_vector(path); + if (!opened) { + return Error::propagate(std::move(opened)); } - Dataset dataset = std::move(ds_opt.value()); - return load_referenced_from_dataset(dataset); + return gis::vector_mask::simplify(gis::vector_mask::load(*opened), simplification_tolerance_m); } -inline std::expected load_from_path(const std::filesystem::path &path, const glm::dvec2& radius_range) { +inline 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) { - return std::unexpected(ref_mask_res.error()); + return Error::propagate(std::move(ref_mask_res)); } 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/sf_merger/merge.h b/src/sf_merger/merge.h index 8f902a02..cffe98d6 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/merge.h @@ -1,24 +1,49 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 +#include "Error.h" #include "NodeLoader.h" #include "NodeWriter.h" +#include "inputs.h" #include "mask.h" #include "merge/NodeData.h" #include "merge/Result.h" #include "merge/visitor/Masked.h" #include "merge/visitor/Simple.h" #include "merge/visitor/Visitor.h" -#include "octree/Id.h" #include "mesh/storage.h" -#include "store/NodeStatusOrMissing.h" -#include "store/cache/Dummy.h" -#include "Error.h" +#include "octree/Id.h" #include "sf/finalize_storage.h" #include "sf/validate_index.h" +#include "store/NodeStatusOrMissing.h" +#include "store/cache/Dummy.h" +#include "store/inputs.h" +#include "store/snapshot.h" inline std::string get_dataset_name(const mesh::storage::Storage &storage) { return storage.layout().base_path().filename().string(); @@ -38,11 +63,19 @@ class Merger { using Context = Visitor::Context; using Result = merge::Result; - Merger( - Visitor& visitor, + Merger(Visitor& visitor, NodeLoader left, NodeLoader right, - NodeWriter output) : _visitor(visitor), _left(left), _right(right), _output(output) { + NodeWriter output, + const mesh::storage::IndexedStorage* cache = nullptr, + std::function stop_requested = {}) + : _visitor(visitor) + , _left(left) + , _right(right) + , _output(output) + , _cache(cache) + , _stop_requested(std::move(stop_requested)) + { } Expected merge_root() { @@ -64,14 +97,18 @@ class Merger { const Context& ctx ) { LOG_DEBUG("[{}] Start merging (left = {}, right = {})", id, left_status, right_status); - auto has_result = this->_output.has_node(id); - if (!has_result) { - return Error::propagate( - std::move(has_result), "check merged output for node " + id.to_string()); + if (auto polled = this->poll(); !polled) { + return polled; } - if (has_result.value()) { - LOG_DEBUG("[{}] Already merged, skipping...", id); - return {}; // Already merged previously + if (this->_cache != nullptr) { + auto cached = this->_cache->has(id); + if (!cached) { + return Error::propagate(std::move(cached), "check merge cache for node " + id.to_string()); + } + if (cached.value()) { + LOG_DEBUG("[{}] Restored from cache", id); + return this->_output.link_node(id, *this->_cache); + } } const auto merge_result = this->call_merge(id, left_status, right_status, ctx); @@ -90,7 +127,7 @@ class Merger { return {}; } else if constexpr (std::is_same_v) { LOG_DEBUG("[{}] remains unchanged (same as {})", id, (result.source == merge::Source::Left ? "left" : "right")); - // If the node should remain unchanged, but its not present on disk we cannot copy it. + // If the node should remain unchanged, but its not present on disk we cannot link it. if (result.source == merge::Source::Left && left_status == Status::Missing) { auto mesh_opt = this->_left.load_node(id); if (mesh_opt.has_value()) { @@ -108,11 +145,12 @@ class Merger { } } } else { - auto copy_result = this->_output.copy_subtree_to_output( + auto link_result = this->_output.link_subtree_to_output( id, - result.source == merge::Source::Left ? this->_left : this->_right); - if (!copy_result) { - return copy_result; + result.source == merge::Source::Left ? this->_left : this->_right, + [this] { return this->poll(); }); + if (!link_result) { + return link_result; } } return {}; @@ -131,6 +169,19 @@ class Merger { } private: + // Stops before the next node when requested, and checkpoints the output index. + Expected poll() + { + if (this->_stop_requested && this->_stop_requested()) { + return Error::fail(Error::Code::Cancelled, "SF merge cancelled; incomplete snapshot retained"); + } + if (std::chrono::steady_clock::now() - this->_last_checkpoint < store::snapshot::checkpoint_interval) { + return {}; + } + this->_last_checkpoint = std::chrono::steady_clock::now(); + return this->_output.save_index(); + } + static constexpr auto STATUSES = magic_enum::enum_values(); static constexpr auto _STATUS_COUNT = STATUSES.size(); static constexpr auto _MAX_INDEX = _STATUS_COUNT * _STATUS_COUNT; @@ -179,13 +230,21 @@ class Merger { NodeLoader _left; NodeLoader _right; NodeWriter _output; + const mesh::storage::IndexedStorage* _cache; + std::function _stop_requested; + std::chrono::steady_clock::time_point _last_checkpoint = std::chrono::steady_clock::now(); }; -inline Expected merge_datasets( - const mesh::storage::IndexedStorage &left_dataset, - const mesh::storage::IndexedStorage &right_dataset, - mesh::storage::Storage &output_dataset, - const std::optional mask = std::nullopt) { +// Merges into output_dataset, linking nodes that the cache has instead of merging them. +// When stop_requested returns true, the merge stops before the next node with an error +// with code Cancelled. On failure, the output index is saved for a later cache. +inline Expected merge_datasets(const mesh::storage::IndexedStorage& left_dataset, + const mesh::storage::IndexedStorage& right_dataset, + mesh::storage::Storage& output_dataset, + const std::optional mask = std::nullopt, + const mesh::storage::IndexedStorage* cache = nullptr, + const std::function& stop_requested = {}) +{ for (const mesh::storage::IndexedStorage *input : {&left_dataset, &right_dataset}) { auto validation = sf::validate_index(input->index()); if (!validation) { @@ -205,20 +264,24 @@ inline Expected merge_datasets( store::cache::Dummy right_cache; NodeLoader right(right_dataset, right_cache, space); NodeWriter output(output_dataset); + Expected result; if (mask.has_value()) { merge::visitor::Masked visitor {mask.value(), space}; - Merger merger(visitor, left, right, output); - auto result = merger.merge_root(); - if (!result) { - return Error::propagate(std::move(result), "merge masked datasets"); - } + Merger merger(visitor, left, right, output, cache, stop_requested); + result = merger.merge_root(); } else { merge::visitor::Simple visitor; - Merger merger(visitor, left, right, output); - auto result = merger.merge_root(); - if (!result) { - return Error::propagate(std::move(result), "merge datasets"); + Merger merger(visitor, left, right, output, cache, stop_requested); + result = merger.merge_root(); + } + if (!result) { + if (auto saved = output_dataset.save_or_create_index(); !saved) { + LOG_ERROR("Failed to save the index of the incomplete merge output: {}", saved.error().to_string()); + } + if (result.error().code() == Error::Code::Cancelled) { + return result; } + return Error::propagate(std::move(result), mask.has_value() ? "merge masked datasets" : "merge datasets"); } auto finalized = sf::finalize_storage(output_dataset); @@ -228,3 +291,56 @@ inline Expected merge_datasets( } return {}; } + +// Merges into .part and publishes it to output_path; see store::snapshot. +// The cache must be an incomplete merge output with the same record. +inline Expected merge_datasets(const mesh::storage::IndexedStorage& left_dataset, + const mesh::storage::IndexedStorage& right_dataset, + const std::filesystem::path& output_path, + const std::optional& mask, + const sf_merger::inputs::Record& record, + const std::optional& cache_path = std::nullopt, + const std::function& stop_requested = {}) +{ + for (const mesh::storage::IndexedStorage* input : { &left_dataset, &right_dataset }) { + if (auto linkable = store::snapshot::check_link_filesystem(input->base_path(), output_path); !linkable) { + return linkable; + } + } + std::optional cache; + if (cache_path.has_value()) { + if (auto valid = store::inputs::validate_cache(*cache_path, record); !valid) { + return Error::propagate(std::move(valid), "validate SF merge cache"); + } + if (auto linkable = store::snapshot::check_link_filesystem(*cache_path, output_path); !linkable) { + return linkable; + } + auto opened = mesh::storage::open_index(*cache_path / octree::storage::index_file_name); + if (!opened) { + return Error::propagate(std::move(opened), "open SF merge cache"); + } + cache.emplace(std::move(*opened)); + } + + mesh::storage::OpenOptions options; + options.default_mapping = left_dataset.layout().mapping(); + options.preferred_extension = std::string(left_dataset.codec_selector().value_or(".sfmesh")); + auto output_result = mesh::storage::create(output_path, std::move(options)); + if (!output_result) { + return Error::propagate(std::move(output_result), "create merge output dataset \"" + output_path.string() + "\""); + } + mesh::storage::IndexedStorage output = std::move(output_result.value()); + if (auto written = store::inputs::write(output.base_path(), record); !written) { + return written; + } + + auto merged = merge_datasets(left_dataset, right_dataset, output, mask, cache ? &cache.value() : nullptr, stop_requested); + if (!merged) { + return merged; + } + auto published = mesh::storage::publish(std::move(output)); + if (!published) { + return Error::propagate(std::move(published), "publish merge output dataset \"" + output_path.string() + "\""); + } + return {}; +} diff --git a/src/sf_merger/merge/NodeData.h b/src/sf_merger/merge/NodeData.h index 2bdd72f1..7108944f 100644 --- a/src/sf_merger/merge/NodeData.h +++ b/src/sf_merger/merge/NodeData.h @@ -1,9 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "NodeLoader.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/SimpleMesh.h" #include "octree/Id.h" #include "optional_utils.h" diff --git a/src/sf_merger/merge/Result.h b/src/sf_merger/merge/Result.h index 86c95892..17fc30d8 100644 --- a/src/sf_merger/merge/Result.h +++ b/src/sf_merger/merge/Result.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/sf_merger/merge/visitor/Masked.h b/src/sf_merger/merge/visitor/Masked.h index 0a78b37b..9c3b8746 100644 --- a/src/sf_merger/merge/visitor/Masked.h +++ b/src/sf_merger/merge/visitor/Masked.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "mask.h" diff --git a/src/sf_merger/merge/visitor/Simple.h b/src/sf_merger/merge/visitor/Simple.h index 9305ce7b..2c31d90d 100644 --- a/src/sf_merger/merge/visitor/Simple.h +++ b/src/sf_merger/merge/visitor/Simple.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "merge/Result.h" diff --git a/src/sf_merger/merge/visitor/Visitor.h b/src/sf_merger/merge/visitor/Visitor.h index 6607e222..10e9dd02 100644 --- a/src/sf_merger/merge/visitor/Visitor.h +++ b/src/sf_merger/merge/visitor/Visitor.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "merge/NodeData.h" diff --git a/src/sf_merger/utils.h b/src/sf_merger/utils.h index af80aa78..3f7acff3 100644 --- a/src/sf_merger/utils.h +++ b/src/sf_merger/utils.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 11c40f01..aecea71a 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -1,8 +1,25 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# Copyright (C) 2026 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 . +# ***************************************************************************** + add_library(terrainlib Error.h Error.cpp - Dataset.h Dataset.cpp ImageKey.h - ProgressIndicator.h ProgressIndicator.cpp Range.h SphereProjector.h VecHash.h @@ -13,20 +30,21 @@ add_library(terrainlib fmt_impls.h glm_utils.h hash_utils.h - init.h init.cpp - log.h log.cpp macros.h opencv_utils.h optional_utils.h pch.h range_utils.h - srs.h string_utils.h tile_path.h type_utils.h variant_utils.h vector_utils.h + cli_support/log.h cli_support/log.cpp + cli_support/ProgressIndicator.h cli_support/ProgressIndicator.cpp + cli_support/stop_signal.h cli_support/stop_signal.cpp + containers/Buffer.h containers/Cow.h containers/FixedVector.h @@ -42,23 +60,26 @@ add_library(terrainlib containers/UnionFind.h containers/Vector2D.h - ctb/CTBException.hpp - ctb/GlobalGeodetic.hpp ctb/GlobalGeodetic.cpp - ctb/GlobalMercator.hpp ctb/GlobalMercator.cpp - ctb/Grid.hpp - ctb/TileCoordinate.hpp - ctb/types.hpp - geometry/CoordFrame.h geometry/PlaneFrame.h geometry/Rect.h geometry/geometry.h - io/bytes.h io/bytes.cpp + gis/Dataset.h gis/Dataset.cpp + gis/DatasetReader.h gis/DatasetReader.cpp + gis/gdal.h gis/gdal.cpp + gis/srs.h gis/srs.cpp + gis/srs/detail.h gis/srs/detail.cpp + gis/vector_mask.h gis/vector_mask.cpp + gis/webmercator.h gis/webmercator.cpp + + io/byte.h io/byte.cpp io/compression.h io/compression.cpp io/conversion.h - io/envelope.h - io/envelope_file.h + io/image.h io/image.cpp + io/envelope.h io/envelope.cpp + io/glm_serialization.h + io/hash.h io/hash.cpp io/utils.h io/utils.cpp mesh/EncodedMesh.h @@ -136,6 +157,7 @@ add_library(terrainlib octree/StoreTraits.h octree/storage/IndexFile.h octree/storage/open_runtime.h + octree/storage/snapshot.h octree/store_layout/Flat.h octree/store_layout/LevelAndCoordinateDirectories.h octree/store_layout/Mappings.h @@ -156,7 +178,31 @@ add_library(terrainlib range/RangeTo.h range/RangeToInclusive.h + raster/algorithm.h + raster/View.h + raster/ClampedView.h + raster/algorithm/detail.h + raster/algorithm/conversion.h + raster/algorithm/reduce.h + raster/algorithm/reducers.h + raster/algorithm/scale.h + raster/algorithm/scaling_detail.h + raster/algorithm/copy.h + raster/algorithm/fold.h + raster/algorithm/transform.h + raster/algorithm/window_transform.h + raster/algorithm/zip_transform.h + raster_store/StoreTraits.h + raster_store/TilePool.h + raster_store/Tile.h + raster_store/attribution.h raster_store/attribution.cpp + raster_store/io/TileCodec.h + raster_store/io/manifest.h raster_store/io/manifest.cpp + raster_store/path_layout.h + raster_store/pixel.h + raster_store/scaler.h + raster_store/storage.h sf/finalize_storage.h sf/validate_index.h @@ -175,6 +221,7 @@ add_library(terrainlib store/Index.h store/IndexFormat.h store/IndexedStorage.h + store/inputs.h store/inputs.cpp store/NodeStatus.h store/NodeStatusOrMissing.h store/path_layout.h @@ -186,6 +233,7 @@ add_library(terrainlib store/cache/Interface.h store/cache/Lru.h store/open.h + store/snapshot.h store/snapshot.cpp store/traverse.h texture/Mask.h @@ -235,6 +283,9 @@ target_link_libraries(terrainlib PUBLIC xatlas ) -if(ALP_ENABLE_OVERVIEW_READING) - target_compile_definitions(terrainlib PUBLIC ALP_ENABLE_OVERVIEW_READING) -endif() +# Error captures stack traces; libassert already links cpptrace publicly. +target_link_libraries(terrainlib PRIVATE cpptrace::cpptrace) +# Only io/hash.cpp includes xxHash. +target_link_libraries(terrainlib PRIVATE xxhash) +# Only io/image.cpp includes libjxl. +target_link_libraries(terrainlib PRIVATE JpegXL::jxl) diff --git a/src/terrainlib/Error.cpp b/src/terrainlib/Error.cpp index e5acb8dd..57d61ca7 100644 --- a/src/terrainlib/Error.cpp +++ b/src/terrainlib/Error.cpp @@ -1,8 +1,29 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include #include +#include #include +#include namespace { @@ -23,6 +44,8 @@ std::string_view code_name(const Error::Code code) return "Io"; case Error::Code::ResourceExhausted: return "ResourceExhausted"; + case Error::Code::Cancelled: + return "Cancelled"; case Error::Code::Internal: return "Internal"; } @@ -35,6 +58,8 @@ std::string describe_system_error(const std::error_code& cause) { return fmt::fo Error::Error(const Code code, Frame frame) : m_code(code) + // Only the raw addresses are captured here; symbols are resolved when printed. + , m_stacktrace(std::make_shared(cpptrace::generate_raw_trace(1))) { m_frames.push_back(std::move(frame)); } @@ -103,6 +128,46 @@ std::unexpected Error::propagate(Error&& error, std::string message, cons return std::unexpected { std::move(error).with_context(std::move(message), location) }; } +void Error::raise(const Code code, std::string message, const std::source_location location) { throw Exception(make(code, std::move(message), location)); } + +void Error::raise(const Code code, const std::string_view operation, const std::filesystem::path& path, const std::source_location location) +{ + throw Exception(make(code, operation, path, location)); +} + +void Error::raise(const Code code, const std::string_view operation, const std::error_code& cause, const std::source_location location) +{ + throw Exception(make(code, operation, cause, location)); +} + +void Error::raise( + const Code code, const std::string_view operation, const std::filesystem::path& path, const std::error_code& cause, const std::source_location location) +{ + throw Exception(make(code, operation, path, cause, location)); +} + +void Error::raise(const Code code, + const std::string_view operation, + const std::filesystem::path& source, + const std::filesystem::path& destination, + const std::error_code& cause, + const std::source_location location) +{ + throw Exception(make(code, operation, source, destination, cause, location)); +} + +void Error::panic(const Error& error, const std::source_location location) +{ + PANIC(fmt::format("unexpected failure at {}:{}\n{}\nError origin:\n{}", location.file_name(), location.line(), error.to_string(), error.stacktrace())); + std::abort(); +} + +Error::Exception::Exception(Error error) + : m_error(std::move(error)) + , m_what(m_error.to_string()) +{ +} + Error Error::with_context(std::string message, const std::source_location location) && { Error result = std::move(*this); @@ -137,3 +202,5 @@ std::string Error::to_string() const } return result; } + +std::string Error::stacktrace() const { return m_stacktrace ? m_stacktrace->resolve().to_string() : std::string {}; } diff --git a/src/terrainlib/Error.h b/src/terrainlib/Error.h index a304f8da..811c0c8e 100644 --- a/src/terrainlib/Error.h +++ b/src/terrainlib/Error.h @@ -1,14 +1,39 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include +#include #include #include +namespace cpptrace { +struct raw_trace; +} + class Error { public: enum class Code { @@ -26,6 +51,8 @@ class Error { Io, /// An explicit size, storage, or other recoverable resource limit was exceeded. ResourceExhausted, + /// An operation was cancelled before publication or completion. + Cancelled, /// An internal invariant or an otherwise valid operation failed unexpectedly. Internal, }; @@ -35,6 +62,9 @@ class Error { std::source_location location; }; + // Thrown by tools for errors they cannot recover from; reusable code returns Expected. + class Exception; + static Error make(Code code, std::string message, std::source_location location = std::source_location::current()); static Error make( Code code, std::string_view operation, const std::filesystem::path& path, std::source_location location = std::source_location::current()); @@ -76,16 +106,59 @@ class Error { std::expected&& result, Code code, std::string message, std::source_location location = std::source_location::current()); static std::unexpected propagate(Error&& error, std::string message = "propagated", std::source_location location = std::source_location::current()); + // Throw an Exception for a new error, like fail. + [[noreturn]] static void raise(Code code, std::string message, std::source_location location = std::source_location::current()); + [[noreturn]] static void raise( + Code code, std::string_view operation, const std::filesystem::path& path, std::source_location location = std::source_location::current()); + [[noreturn]] static void raise( + Code code, std::string_view operation, const std::error_code& cause, std::source_location location = std::source_location::current()); + [[noreturn]] static void raise(Code code, + std::string_view operation, + const std::filesystem::path& path, + const std::error_code& cause, + std::source_location location = std::source_location::current()); + [[noreturn]] static void raise(Code code, + std::string_view operation, + const std::filesystem::path& source, + const std::filesystem::path& destination, + const std::error_code& cause, + std::source_location location = std::source_location::current()); + + // Returns the value, or throws an Exception for an error the tool cannot + // recover from. A nonempty message adds a context frame. + template + static T throwing_unwrap(std::expected&& result, std::string message = {}, std::source_location location = std::source_location::current()); + + // Returns the value; a failure is a bug and panics through libassert. + template + static T asserting_unwrap(std::expected&& result, std::source_location location = std::source_location::current()); + Code code() const { return m_code; } std::string to_string() const; + // The stack trace where the error was made, resolved on demand. + std::string stacktrace() const; private: Error(Code code, Frame frame); [[nodiscard]] Error with_context(std::string message, std::source_location location) &&; [[nodiscard]] Error reclassified(Code code, std::string message, std::source_location location) &&; + [[noreturn]] static void panic(const Error& error, std::source_location location); Code m_code; std::vector m_frames; + std::shared_ptr m_stacktrace; +}; + +class Error::Exception : public std::exception { +public: + explicit Exception(Error error); + + const char* what() const noexcept override { return m_what.c_str(); } + const Error& error() const noexcept { return m_error; } + +private: + Error m_error; + std::string m_what; }; template @@ -106,3 +179,28 @@ std::unexpected Error::propagate(std::expected&& result, const std::move(result).error().reclassified(code, std::move(message), location), }; } + +template +T Error::throwing_unwrap(std::expected&& result, std::string message, const std::source_location location) +{ + if (!result) { + if (message.empty()) { + throw Exception(std::move(result).error()); + } + throw Exception(std::move(result).error().with_context(std::move(message), location)); + } + if constexpr (!std::is_void_v) { + return std::move(*result); + } +} + +template +T Error::asserting_unwrap(std::expected&& result, const std::source_location location) +{ + if (!result) { + panic(result.error(), location); + } + if constexpr (!std::is_void_v) { + return std::move(*result); + } +} diff --git a/src/terrainlib/ImageKey.h b/src/terrainlib/ImageKey.h index d438e373..d4aca5a0 100644 --- a/src/terrainlib/ImageKey.h +++ b/src/terrainlib/ImageKey.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/Range.h b/src/terrainlib/Range.h index 128947f7..96e07456 100644 --- a/src/terrainlib/Range.h +++ b/src/terrainlib/Range.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "range/AnyRange.h" diff --git a/src/terrainlib/SphereProjector.h b/src/terrainlib/SphereProjector.h index 9d95762e..45b13bf7 100644 --- a/src/terrainlib/SphereProjector.h +++ b/src/terrainlib/SphereProjector.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/VecHash.h b/src/terrainlib/VecHash.h index ae62b3a8..ff60eca2 100644 --- a/src/terrainlib/VecHash.h +++ b/src/terrainlib/VecHash.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/VecRange.h b/src/terrainlib/VecRange.h index 4f2c9763..3989270f 100644 --- a/src/terrainlib/VecRange.h +++ b/src/terrainlib/VecRange.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/array_utils.h b/src/terrainlib/array_utils.h index 00682e71..1c718214 100644 --- a/src/terrainlib/array_utils.h +++ b/src/terrainlib/array_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/build_config.h b/src/terrainlib/build_config.h index 3c953475..79470fd1 100644 --- a/src/terrainlib/build_config.h +++ b/src/terrainlib/build_config.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #define ALP_UNUSED(...) static_cast([](auto &&...) {}(__VA_ARGS__)) diff --git a/src/terrainlib/ProgressIndicator.cpp b/src/terrainlib/cli_support/ProgressIndicator.cpp similarity index 94% rename from src/terrainlib/ProgressIndicator.cpp rename to src/terrainlib/cli_support/ProgressIndicator.cpp index cd9a4dcf..cee9d26e 100644 --- a/src/terrainlib/ProgressIndicator.cpp +++ b/src/terrainlib/cli_support/ProgressIndicator.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -17,7 +17,7 @@ * along with this program. If not, see . *****************************************************************************/ -#include "ProgressIndicator.h" +#include "cli_support/ProgressIndicator.h" #include #include @@ -30,6 +30,8 @@ using namespace std::literals; +namespace cli_support { + ProgressIndicator::ProgressIndicator(size_t n_steps) : m_n_steps(n_steps) { } @@ -106,3 +108,5 @@ std::string ProgressIndicator::progress_bar(const uint32_t bar_width) const { std::string ProgressIndicator::x_of_y_done_message() const { return fmt::format("{}/{}", m_step.load(), m_n_steps); } + +} // namespace cli_support diff --git a/src/terrainlib/ProgressIndicator.h b/src/terrainlib/cli_support/ProgressIndicator.h similarity index 90% rename from src/terrainlib/ProgressIndicator.h rename to src/terrainlib/cli_support/ProgressIndicator.h index ca16e36e..fcb52ee5 100644 --- a/src/terrainlib/ProgressIndicator.h +++ b/src/terrainlib/cli_support/ProgressIndicator.h @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -26,6 +26,8 @@ #include #include +namespace cli_support { + class ProgressIndicator { const size_t m_n_steps; std::atomic m_step = 0; @@ -40,3 +42,5 @@ class ProgressIndicator { [[nodiscard]] std::string progress_bar(const uint32_t bar_width=50) const; [[nodiscard]] std::string x_of_y_done_message() const; }; + +} // namespace cli_support diff --git a/src/terrainlib/cli_support/log.cpp b/src/terrainlib/cli_support/log.cpp new file mode 100644 index 00000000..976a0000 --- /dev/null +++ b/src/terrainlib/cli_support/log.cpp @@ -0,0 +1,75 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 +#include +#include + +#include + +#include + +#include "cli_support/log.h" + +namespace cli_support { + +const std::shared_ptr& Log::get_logger() +{ + static const std::shared_ptr logger = [] { + spdlog::set_pattern("[%Y-%m-%d %T.%e] [%=3n] [%^%l%$] %v"); + auto created = spdlog::stderr_color_mt("LOG"); + created->set_level(spdlog::level::info); + return created; + }(); + return logger; +} + +void Log::init(spdlog::level::level_enum log_level) { get_logger()->set_level(log_level); } + +void Log::add_file(const std::filesystem::path& path) +{ + auto file_sink = std::make_shared(path.string(), false); + file_sink->set_pattern("[%Y-%m-%d %T.%e] [%n] [%l] %v"); + get_logger()->sinks().push_back(std::move(file_sink)); + get_logger()->flush_on(spdlog::level::info); +} + +const std::map& log_level_names() +{ + static const std::map names { + { "off", spdlog::level::off }, + { "critical", spdlog::level::critical }, + { "error", spdlog::level::err }, + { "warn", spdlog::level::warn }, + { "info", spdlog::level::info }, + { "debug", spdlog::level::debug }, + { "trace", spdlog::level::trace }, + }; + return names; +} + +void log_assertion_failures() +{ + libassert::set_failure_handler([](const libassert::assertion_info& info) { + LOG_ERROR("{}", info.to_string(0, libassert::color_scheme::blank)); + Log::get_logger()->flush(); + std::abort(); + }); +} + +} // namespace cli_support diff --git a/src/terrainlib/cli_support/log.h b/src/terrainlib/cli_support/log.h new file mode 100644 index 00000000..b176eba8 --- /dev/null +++ b/src/terrainlib/cli_support/log.h @@ -0,0 +1,106 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 + +#ifndef SPDLOG_FMT_EXTERNAL +#define SPDLOG_FMT_EXTERNAL +#define FMT_HEADER_ONLY +#endif +#include "fmt_impls.h" +#include "numeric/int_math.h" +#include +#include +#include +#include +#include + +#include +#include + +namespace cli_support { + +class Log { +public: + static void init(spdlog::level::level_enum level); + // Also writes the log to path, appending, and flushes it from level info on. + static void add_file(const std::filesystem::path& path); + // Creates the logger on first use, at level info; safe to call from any thread. + static const std::shared_ptr& get_logger(); +}; + +// Names accepted by the --verbosity option of the tools. +const std::map& log_level_names(); + +// Assertion failures are bugs; logs them with their stack trace, so that they also +// reach a log file, before aborting. +void log_assertion_failures(); + +} // namespace cli_support + +#define LOG_TRACE(...) SPDLOG_LOGGER_TRACE(::cli_support::Log::get_logger(), __VA_ARGS__) +#define LOG_DEBUG(...) SPDLOG_LOGGER_DEBUG(::cli_support::Log::get_logger(), __VA_ARGS__) +#define LOG_INFO(...) SPDLOG_LOGGER_INFO (::cli_support::Log::get_logger(), __VA_ARGS__) +#define LOG_WARN(...) SPDLOG_LOGGER_WARN (::cli_support::Log::get_logger(), __VA_ARGS__) +#define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(::cli_support::Log::get_logger(), __VA_ARGS__) +#define LOG_ERROR_AND_EXIT(...) \ + do { \ + LOG_ERROR(__VA_ARGS__); \ + exit(1); \ + } while (false) + +// Logs at most once per call site for the lifetime of the process, even across threads. +#define LOG_WARN_ONCE(...) \ + do { \ + static std::atomic _log_warn_once_fired { false }; \ + if (!_log_warn_once_fired.exchange(true)) { \ + LOG_WARN(__VA_ARGS__); \ + } \ + } while (false) + +namespace cli_support { + +// Counts the occurrences at a call site; the power-of-two occurrences (1, 2, 4, +// 8, 16, ...) are the ones to log. +class LogBackoff { +public: + // Counts an occurrence and returns its number if it is to be logged; safe to + // call from any thread. + std::optional count_occurrence() + { + const uint64_t occurrence = m_count.fetch_add(1, std::memory_order_relaxed) + 1; + if (!is_power_of_two(occurrence)) { + return std::nullopt; + } + return occurrence; + } + +private: + std::atomic m_count { 0 }; +}; + +} // namespace cli_support + +// Logs on occurrence 1, 2, 4, 8, 16, ... per call site, with the occurrence count appended. +#define LOG_WARN_BACKOFF(fmt_str, ...) \ + do { \ + static ::cli_support::LogBackoff _log_warn_backoff; \ + if (const auto _log_warn_occurrence = _log_warn_backoff.count_occurrence()) { \ + LOG_WARN(fmt_str " (occurrence {})", ##__VA_ARGS__, *_log_warn_occurrence); \ + } \ + } while (false) diff --git a/src/tile_builder/ParallelTiler.h b/src/terrainlib/cli_support/stop_signal.cpp similarity index 53% rename from src/tile_builder/ParallelTiler.h rename to src/terrainlib/cli_support/stop_signal.cpp index 62cd07ac..3272186b 100644 --- a/src/tile_builder/ParallelTiler.h +++ b/src/terrainlib/cli_support/stop_signal.cpp @@ -1,7 +1,6 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko * * 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 @@ -17,17 +16,31 @@ * along with this program. If not, see . *****************************************************************************/ -#pragma once +#include "cli_support/stop_signal.h" -#include "Tiler.h" +#include +#include -class ParallelTiler : public Tiler { -public: - ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); +namespace cli_support::stop_signal { - [[nodiscard]] std::vector generateTiles(unsigned zoom_level) const; - [[nodiscard]] std::vector generateTiles(const std::pair& zoom_range) const; +namespace { + static_assert(std::atomic::is_always_lock_free); + std::atomic received = 0; + void record(int signal) { received.store(signal, std::memory_order_relaxed); } +} // namespace - [[nodiscard]] radix::tile::Id southWestTile(unsigned zoom_level) const; - [[nodiscard]] radix::tile::Id northEastTile(unsigned zoom_level) const; -}; +void install() +{ + std::signal(SIGINT, record); + std::signal(SIGTERM, record); +} + +bool requested() { return received.load(std::memory_order_relaxed) != 0; } + +int exit_code() +{ + const auto signal = received.load(std::memory_order_relaxed); + return signal ? 128 + signal : 1; +} + +} // namespace cli_support::stop_signal diff --git a/src/tile_builder/Exception.h b/src/terrainlib/cli_support/stop_signal.h similarity index 65% rename from src/tile_builder/Exception.h rename to src/terrainlib/cli_support/stop_signal.h index 6984a400..92450bc5 100644 --- a/src/tile_builder/Exception.h +++ b/src/terrainlib/cli_support/stop_signal.h @@ -1,7 +1,6 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko * * 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 @@ -17,19 +16,15 @@ * along with this program. If not, see . *****************************************************************************/ -#ifndef EXCEPTION_H -#define EXCEPTION_H +#pragma once -#include +namespace cli_support::stop_signal { -/// This represents a CTB runtime error -class Exception : public std::runtime_error { -public: - Exception(const std::string& message) - : std::runtime_error(message) - { - while (false) { } - } -}; +// Installs SIGINT and SIGTERM handlers that only record the signal, so a tool can +// finish its active work, checkpoint, and exit through its normal error path. +void install(); +bool requested(); +// 128 + the received signal, or 1 if no signal was received. +int exit_code(); -#endif // EXCEPTION_H +} // namespace cli_support::stop_signal diff --git a/src/terrainlib/containers/Buffer.h b/src/terrainlib/containers/Buffer.h index a731d2bc..d0647f26 100644 --- a/src/terrainlib/containers/Buffer.h +++ b/src/terrainlib/containers/Buffer.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/Cow.h b/src/terrainlib/containers/Cow.h index 85001f7e..ed8f5714 100644 --- a/src/terrainlib/containers/Cow.h +++ b/src/terrainlib/containers/Cow.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/FixedVector.h b/src/terrainlib/containers/FixedVector.h index 51c03ac4..6bc49086 100644 --- a/src/terrainlib/containers/FixedVector.h +++ b/src/terrainlib/containers/FixedVector.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/HybridIndexPairMap.h b/src/terrainlib/containers/HybridIndexPairMap.h index 0c2db6f9..0e0958bf 100644 --- a/src/terrainlib/containers/HybridIndexPairMap.h +++ b/src/terrainlib/containers/HybridIndexPairMap.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/containers/HybridVector.h b/src/terrainlib/containers/HybridVector.h index 11f540b8..db190d8e 100644 --- a/src/terrainlib/containers/HybridVector.h +++ b/src/terrainlib/containers/HybridVector.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/OffsetTable.h b/src/terrainlib/containers/OffsetTable.h index e476e2f5..4ec23eba 100644 --- a/src/terrainlib/containers/OffsetTable.h +++ b/src/terrainlib/containers/OffsetTable.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/containers/OffsetVector.h b/src/terrainlib/containers/OffsetVector.h index 76800bb5..0876a707 100644 --- a/src/terrainlib/containers/OffsetVector.h +++ b/src/terrainlib/containers/OffsetVector.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/containers/SegmentedBuffer.h b/src/terrainlib/containers/SegmentedBuffer.h index d4a49f1f..7d79c0d5 100644 --- a/src/terrainlib/containers/SegmentedBuffer.h +++ b/src/terrainlib/containers/SegmentedBuffer.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/containers/Size.h b/src/terrainlib/containers/Size.h index efc068b5..673db241 100644 --- a/src/terrainlib/containers/Size.h +++ b/src/terrainlib/containers/Size.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/StampSet.h b/src/terrainlib/containers/StampSet.h index 50e757d6..b6924dae 100644 --- a/src/terrainlib/containers/StampSet.h +++ b/src/terrainlib/containers/StampSet.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/containers/ThreadLocalCache.h b/src/terrainlib/containers/ThreadLocalCache.h index 815c76fb..32fde2f3 100644 --- a/src/terrainlib/containers/ThreadLocalCache.h +++ b/src/terrainlib/containers/ThreadLocalCache.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include diff --git a/src/terrainlib/containers/TinyVector.h b/src/terrainlib/containers/TinyVector.h index 77c8fd41..15ed8e04 100644 --- a/src/terrainlib/containers/TinyVector.h +++ b/src/terrainlib/containers/TinyVector.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/containers/UnionFind.h b/src/terrainlib/containers/UnionFind.h index f63a6b4d..f47ccf94 100644 --- a/src/terrainlib/containers/UnionFind.h +++ b/src/terrainlib/containers/UnionFind.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/containers/Vector2D.h b/src/terrainlib/containers/Vector2D.h index a6d66416..12477b78 100644 --- a/src/terrainlib/containers/Vector2D.h +++ b/src/terrainlib/containers/Vector2D.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/ctb/CTBException.hpp b/src/terrainlib/ctb/CTBException.hpp deleted file mode 100644 index 7e306d19..00000000 --- a/src/terrainlib/ctb/CTBException.hpp +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef CTBEXCEPTION_HPP -#define CTBEXCEPTION_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file CTBException.hpp - * @brief This declares and defines the `CTBException` class - */ - -#include - -namespace ctb { -class CTBException; -} - -/// This represents a CTB runtime error -class ctb::CTBException : public std::runtime_error { -public: - CTBException(const char* message) - : std::runtime_error(message) - { - while (false) { } - } -}; - -#endif /* CTBEXCEPTION_HPP */ diff --git a/src/terrainlib/ctb/GlobalGeodetic.cpp b/src/terrainlib/ctb/GlobalGeodetic.cpp deleted file mode 100644 index 37c08df2..00000000 --- a/src/terrainlib/ctb/GlobalGeodetic.cpp +++ /dev/null @@ -1,34 +0,0 @@ -/******************************************************************************* - * Copyright 2014 GeoData - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file GlobalGeodetic.cpp - * @brief This defines the `GlobalGeodetic` class - */ - -#include "GlobalGeodetic.hpp" - -using namespace ctb; - -// Set the spatial reference -static OGRSpatialReference setSRS_Geodetic() { - OGRSpatialReference srs; - srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - srs.importFromEPSG(4326); - return srs; -} - -const OGRSpatialReference GlobalGeodetic::cSRS = setSRS_Geodetic(); diff --git a/src/terrainlib/ctb/GlobalGeodetic.hpp b/src/terrainlib/ctb/GlobalGeodetic.hpp deleted file mode 100644 index 917690b9..00000000 --- a/src/terrainlib/ctb/GlobalGeodetic.hpp +++ /dev/null @@ -1,57 +0,0 @@ -#ifndef GLOBALGEODETIC_HPP -#define GLOBALGEODETIC_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file GlobalGeodetic.hpp - * @brief This defines and declares the `GlobalGeodetic` class - */ - -#include "Grid.hpp" -#include "types.hpp" - -namespace ctb { - -/** - * @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: - /// Initialise the profile with a specific tile size - GlobalGeodetic(i_tile tileSize = 256) - : Grid(tileSize, - radix::tile::SrsBounds{{-180, -90}, {180, 90}}, - 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::Id{0, {1, 0}}}, - 2) { - } - -protected: - /// The EPSG:4326 spatial reference system - static const OGRSpatialReference cSRS; -}; - -} - -#endif /* GLOBALGEODETIC_HPP */ diff --git a/src/terrainlib/ctb/GlobalMercator.cpp b/src/terrainlib/ctb/GlobalMercator.cpp deleted file mode 100644 index ebe8f7a8..00000000 --- a/src/terrainlib/ctb/GlobalMercator.cpp +++ /dev/null @@ -1,41 +0,0 @@ -/******************************************************************************* - * Copyright 2014 GeoData - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file GlobalMercator.cpp - * @brief This defines the `GlobalMercator` class - */ - -#define _USE_MATH_DEFINES // for M_PI - -#include "GlobalMercator.hpp" - -using namespace ctb; - -// Set the class level properties -const unsigned int GlobalMercator::cSemiMajorAxis = 6378137; -const double GlobalMercator::cEarthCircumference = 2 * M_PI * GlobalMercator::cSemiMajorAxis; -const double GlobalMercator::cOriginShift = GlobalMercator::cEarthCircumference / 2.0; - -// Set the spatial reference -static OGRSpatialReference setSRS_Mercator() { - OGRSpatialReference srs; - srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - srs.importFromEPSG(3857); - return srs; -} - -const OGRSpatialReference GlobalMercator::cSRS = setSRS_Mercator(); \ No newline at end of file diff --git a/src/terrainlib/ctb/GlobalMercator.hpp b/src/terrainlib/ctb/GlobalMercator.hpp deleted file mode 100644 index 9de4af42..00000000 --- a/src/terrainlib/ctb/GlobalMercator.hpp +++ /dev/null @@ -1,64 +0,0 @@ -#ifndef GLOBALMERCATOR_HPP -#define GLOBALMERCATOR_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file GlobalMercator.hpp - * @brief This defines the `GlobalMercator` class - */ - -#include "Grid.hpp" - -namespace ctb { -class GlobalMercator; -} - -/** - * @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: - GlobalMercator(i_tile tileSize = 256) - : Grid(tileSize, - radix::tile::SrsBounds{{-cOriginShift, -cOriginShift}, {cOriginShift, cOriginShift}}, - cSRS, - 3857, - std::vector{radix::tile::Id{0, {0, 0}}}, - 2) { - } - -protected: - /// The semi major axis of the WGS84 ellipsoid (the radius of the earth in - /// meters) - static const unsigned int cSemiMajorAxis; - - /// The circumference of the earth in meters - static const double cEarthCircumference; - - /// The coordinate origin (the middle of the grid extent) - static const double cOriginShift; - - /// The EPSG:3785 spatial reference system - static const OGRSpatialReference cSRS; -}; - -#endif /* GLOBALMERCATOR_HPP */ diff --git a/src/terrainlib/ctb/Grid.hpp b/src/terrainlib/ctb/Grid.hpp deleted file mode 100644 index 8d81488c..00000000 --- a/src/terrainlib/ctb/Grid.hpp +++ /dev/null @@ -1,341 +0,0 @@ -#ifndef CTBGRID_HPP -#define CTBGRID_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file Grid.hpp - * @brief This defines and declares the `Grid` class - */ - -#include -#include - -#include "ogr_spatialref.h" - -#include "types.hpp" -#include -#include - -namespace ctb { -class Grid; -} - -/** - * @brief A generic grid for cutting tile sets - * - * This class models a grid for use in cutting up an area into zoom levels and - * tiles. It provides functionality such as relating a coordinate in a native - * coordinate reference system (CRS) to a tile (see `Grid::crsToTile`) and - * getting the CRS bounds of a tile (see `Grid::tileBounds`). - * - * The `Grid` class should be able to model most grid systems. The - * `GlobalMercator` and `GlobalGeodetic` subclasses implement the specific Tile - * Mapping Service grid profiles. - * - * The code here generalises the logic in the `gdal2tiles.py` script available - * with the GDAL library. - * - * 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: - enum class Srs { - SphericalMercator = 3857, - WGS84 = 4326 - }; - - /// Initialise a grid tile - Grid(i_tile gridSize, - const radix::tile::SrsBounds extent, - const OGRSpatialReference &srs, - int epsgCode, - std::vector rootTiles, - double zoomFactor) - : mGridSize(gridSize), mExtent(extent), mSRS(srs), mEpsgCode(epsgCode), mInitialResolution((extent.size().x / rootTiles.size()) / gridSize), mXOriginShift(extent.size().x / 2), mYOriginShift(extent.size().y / 2), mZoomFactor(zoomFactor), mRootTiles(rootTiles) { - mSRS.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - } - - /// Override the equality operator - bool operator==(const Grid& other) const - { - return mGridSize == other.mGridSize - && mExtent == other.mExtent - && mSRS.IsSame(&(other.mSRS)) - && mInitialResolution == other.mInitialResolution - && mXOriginShift == other.mXOriginShift - && mYOriginShift == other.mYOriginShift - && mZoomFactor == other.mZoomFactor; - } - - /// Get the resolution for a particular zoom level - [[nodiscard]] inline double resolution(i_zoom zoom) const - { - return mInitialResolution / pow(mZoomFactor, zoom); - } - - /** - * @brief Get the zoom level for a particular resolution - * - * If the resolution does not exactly match a zoom level then the zoom level - * is 'rounded up' to the next level. - */ - [[nodiscard]] inline i_zoom zoomForResolution(double resolution) const - { - // if mZoomFactor == 2 the following is the same as using: - // log2(mInitialResolution) - log2(resolution) - return i_zoom(ceil((std::log(mInitialResolution) / std::log(mZoomFactor)) - (std::log(resolution) / std::log(mZoomFactor)))); - } - - /// Get the tile covering a pixel location - [[nodiscard]] inline TilePoint pixelsToTile(const PixelPoint& pixel) const - { - const auto tx = i_tile(pixel.x / mGridSize); - const auto ty = i_tile(pixel.y / mGridSize); - - return { tx, ty }; - } - - /// Convert pixel coordinates at a given zoom level to CRS coordinates - [[nodiscard]] inline CRSPoint pixelsToCrs(const PixelPoint& pixel, i_zoom zoom) const - { - double res = resolution(zoom); - - return { - (pixel.x * res) - mXOriginShift, - (pixel.y * res) - mYOriginShift - }; - } - - /// Get the pixel location represented by a CRS point and zoom level - [[nodiscard]] inline PixelPoint crsToPixels(const CRSPoint& coord, i_zoom zoom) const - { - const auto res = resolution(zoom); - const auto px = (mXOriginShift + coord.x) / res; - const auto py = (mYOriginShift + coord.y) / res; - - return { px, py }; - } - - /// 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); - const TilePoint tile = pixelsToTile(pixel); - const auto tile_count = i_tile(1u << zoom); - - 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 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(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, tile_id.zoom_level); - const CRSPoint maxRight = pixelsToCrs(pxMaxRight, tile_id.zoom_level); - - return { minLeft, maxRight }; - } - - [[nodiscard]] const std::vector &rootTiles() const { - return this->mRootTiles; - } - - // Searches for the smallest tile that encompasses the given bounding box. - [[nodiscard]] std::optional findSmallestEncompassingTile(const radix::tile::SrsBounds &bounds) const { - // We dont want to recurse indefinetely if the bounds are empty. - if (bounds.width() == 0 || bounds.height() == 0) { - throw std::invalid_argument("bounds cannot be empty"); - } - - const std::array points = { - bounds.min, - bounds.max, - glm::dvec2(bounds.min.x, bounds.max.y), - glm::dvec2(bounds.max.x, bounds.min.y)}; - - - // We start at the root tiles and repeatedly check every subtile until we find one that contains all - // of the bounding box. - for (const radix::tile::Id &root_tile : this->rootTiles()) { - // Get the bounds of the current root tile. - const radix::tile::SrsBounds root_tile_bounds = this->srsBounds(root_tile, false); - - // Check if all of the target bounding box is inside this tile. - bool all_points_inside = true; - for (const auto &point : points) { - if (!root_tile_bounds.contains_inclusive(point)) { - all_points_inside = false; - break; - } - } - - // If not all points are inside, continue to the next root tile. - if (!all_points_inside) { - continue; - } - - // Now find the smallest tile under this root. - radix::tile::Id current_smallest_encompassing_tile = root_tile; - while (true) { - const std::array children = current_smallest_encompassing_tile.children(); - - bool all_points_inside_any_child = false; - for (const auto &child : children) { - // Get the bounds of the current child - const radix::tile::SrsBounds tile_bounds = this->srsBounds(child, false); - - // Check if all of the target bounding box is inside this tile. - bool all_points_inside = true; - for (const auto &point : points) { - if (!tile_bounds.contains_inclusive(point)) { - all_points_inside = false; - break; - } - } - - // If all points are inside, we can recurse. - if (!all_points_inside) { - continue; - } - - all_points_inside_any_child = true; - current_smallest_encompassing_tile = child; - break; - } - - // Check if all of the points are contained in any children, so we can stop the recursion. - if (!all_points_inside_any_child) { - break; - } - } - - return current_smallest_encompassing_tile; - } - - return std::nullopt; - } - - // Searches for the smallest tile that encompasses the given bounding box. - [[nodiscard]] std::optional findLargestContainedTile(const radix::tile::SrsBounds &bounds) const { - const std::optional root = this->findSmallestEncompassingTile(bounds); - if (!root.has_value()) { - return std::nullopt; - } - - std::vector tiles_to_inspect; - const std::array root_children = root->children(); - tiles_to_inspect.insert(tiles_to_inspect.end(), root_children.begin(), root_children.end()); - - std::optional current_candidate; - while (!tiles_to_inspect.empty()) { - const radix::tile::Id tile = tiles_to_inspect.back(); - tiles_to_inspect.pop_back(); - - if (current_candidate.has_value() && current_candidate->zoom_level <= tile.zoom_level) { - // skip all tiles that dont have a chance of replacing the current candidate - continue; - } - - const radix::tile::SrsBounds tile_bounds = this->srsBounds(tile, false); - const std::array points = { - tile_bounds.min, - tile_bounds.max, - glm::dvec2(tile_bounds.min.x, tile_bounds.max.y), - glm::dvec2(tile_bounds.max.x, tile_bounds.min.y)}; - - const bool target_bounds_overlapping_tile = radix::geometry::intersect(tile_bounds, bounds); - bool target_bounds_fully_inside_tile = false; - if (target_bounds_overlapping_tile) { - target_bounds_fully_inside_tile = true; - - for (const glm::dvec2 &point : points) { - if (!bounds.contains_inclusive(point)) { - target_bounds_fully_inside_tile = false; - break; - } - } - } - - if (target_bounds_fully_inside_tile) { - assert(!current_candidate.has_value() || current_candidate->zoom_level > tile.zoom_level); - current_candidate = tile; - continue; - } - - if (target_bounds_overlapping_tile) { - const std::array children = tile.children(); - tiles_to_inspect.insert(tiles_to_inspect.end(), children.begin(), children.end()); - } - } - - return current_candidate; - } - - /// Get the tile size associated with this grid - [[nodiscard]] inline i_tile tileSize() const - { - return mGridSize; - } - - /// Get the tile size associated with this grid - [[nodiscard]] inline const OGRSpatialReference& getSRS() const - { - return mSRS; - } - - /// Get the extent covered by the grid in CRS coordinates - [[nodiscard]] inline const radix::tile::SrsBounds &getExtent() const { - return mExtent; - } - - [[nodiscard]] inline int getEpsgCode() const - { - return mEpsgCode; - } - -private: - /// The tile size associated with this grid - i_tile mGridSize; - - /// The area covered by the grid - radix::tile::SrsBounds mExtent; - - /// The spatial reference system covered by the grid - OGRSpatialReference mSRS; - int mEpsgCode = -1; - - double mInitialResolution; ///< The initial resolution of this particular profile - double mXOriginShift; ///< The shift in CRS coordinates to get to the origin from minx - double mYOriginShift; ///< The shift in CRS coordinates to get to the origin from miny - - /// By what factor will the scale increase at each zoom level? - double mZoomFactor; - - /// A list of all the root tiles of the grid. - std::vector mRootTiles; -}; - -#endif /* CTBGRID_HPP */ diff --git a/src/terrainlib/ctb/NOTICE b/src/terrainlib/ctb/NOTICE deleted file mode 100644 index 56232a8d..00000000 --- a/src/terrainlib/ctb/NOTICE +++ /dev/null @@ -1,24 +0,0 @@ -The files in this directory are from Cesium Terrain Builder : https : // github.com/adam-ce/cesium-terrain-builder - forked from - https : // github.com/ahuarte47/cesium-terrain-builder - forked from - https : // github.com/geo-data/cesium-terrain-builder - - -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - - - Copyright 2014 GeoData - - Licensed under the Apache License, - Version 2.0(the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -------------------------------------------------------------------------------- diff --git a/src/terrainlib/ctb/TileCoordinate.hpp b/src/terrainlib/ctb/TileCoordinate.hpp deleted file mode 100644 index fcd3c56c..00000000 --- a/src/terrainlib/ctb/TileCoordinate.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#ifndef TILECOORDINATE_HPP -#define TILECOORDINATE_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file TileCoordinate.hpp - * @brief This declares and defines the `TileCoordinate` class - */ - -#include "types.hpp" - -namespace ctb { -class TileCoordinate; -} - -/** - * @brief A `TileCoordinate` identifies a particular tile - * - * An instance of this class is composed of a tile point and a zoom level: - * together this identifies an individual tile. - */ -class ctb::TileCoordinate : public TilePoint { -public: - /// Create the 0-0-0 level tile coordinate - TileCoordinate() - : TilePoint(0, 0) - , zoom(0) - { - } - - /// Instantiate a tile coordinate from the zoom, x and y - TileCoordinate(i_zoom zoom, i_tile x, i_tile y) - : TilePoint(x, y) - , zoom(zoom) - { - } - - /// Instantiate a tile coordinate using the zoom and a tile point - TileCoordinate(i_zoom zoom, const TilePoint& coord) - : TilePoint(coord) - , zoom(zoom) - { - } - - /// Set the point - inline void - setPoint(const TilePoint& point) - { - TilePoint::operator=(point); - } - - i_zoom zoom; ///< The zoom level -}; - -#endif /* TILECOORDINATE_HPP */ diff --git a/src/terrainlib/ctb/types.hpp b/src/terrainlib/ctb/types.hpp deleted file mode 100644 index 05c204ed..00000000 --- a/src/terrainlib/ctb/types.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef CTBTYPES_HPP -#define CTBTYPES_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright 2021 Adam Celarek - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -/** - * @file types.hpp - * @brief This declares basic types used by libctb - */ - -#include // uint16_t -#include - -/// All terrain related data types reside in this namespace -namespace ctb { - -// Simple types -using i_pixel = unsigned int; ///< A pixel value -using i_tile = unsigned int; ///< A tile coordinate -using i_zoom = unsigned int; ///< A zoom level -using i_terrain_height = uint16_t; ///< A terrain tile height - -// Complex types -using PixelPoint = glm::tvec2; ///< The location of a pixel -using CRSPoint = glm::tvec2; ///< A Coordinate Reference System coordinate -using TilePoint = glm::tvec2; ///< The location of a tile - -} - -#endif /* CTBTYPES_HPP */ diff --git a/src/terrainlib/enumerate.h b/src/terrainlib/enumerate.h index 900f842c..a9a1a60d 100644 --- a/src/terrainlib/enumerate.h +++ b/src/terrainlib/enumerate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/fmt_impls.h b/src/terrainlib/fmt_impls.h index bba96f94..a2852c13 100644 --- a/src/terrainlib/fmt_impls.h +++ b/src/terrainlib/fmt_impls.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/geometry/CoordFrame.h b/src/terrainlib/geometry/CoordFrame.h index f1e096ec..15ec1cb5 100644 --- a/src/terrainlib/geometry/CoordFrame.h +++ b/src/terrainlib/geometry/CoordFrame.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/geometry/PlaneFrame.h b/src/terrainlib/geometry/PlaneFrame.h index 6acdafad..1a8c3665 100644 --- a/src/terrainlib/geometry/PlaneFrame.h +++ b/src/terrainlib/geometry/PlaneFrame.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/geometry/Rect.h b/src/terrainlib/geometry/Rect.h index 72a551d2..df92d957 100644 --- a/src/terrainlib/geometry/Rect.h +++ b/src/terrainlib/geometry/Rect.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/geometry/geometry.h b/src/terrainlib/geometry/geometry.h index 13ef32e0..7383069a 100644 --- a/src/terrainlib/geometry/geometry.h +++ b/src/terrainlib/geometry/geometry.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/geometry/geometry.inl b/src/terrainlib/geometry/geometry.inl index 1f35b402..5e1d727d 100644 --- a/src/terrainlib/geometry/geometry.inl +++ b/src/terrainlib/geometry/geometry.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/gis/Dataset.cpp similarity index 52% rename from src/terrainlib/Dataset.cpp rename to src/terrainlib/gis/Dataset.cpp index 2113bfa1..65e142e6 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/gis/Dataset.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -17,10 +17,11 @@ * along with this program. If not, see . *****************************************************************************/ -#include "Dataset.h" +#include "gis/Dataset.h" #include #include +#include #include #include #include @@ -29,10 +30,11 @@ #include #include -#include "ctb/Grid.hpp" -#include "init.h" -#include "log.h" -#include "srs.h" +#include "gis/gdal.h" +#include "gis/srs.h" +#include "cli_support/log.h" + +namespace gis { void GDALDatasetDeleter::operator()(GDALDataset *dataset) const { if (dataset) { @@ -41,33 +43,33 @@ void GDALDatasetDeleter::operator()(GDALDataset *dataset) const { } static GDALDataset *open_gdal_dataset(const std::filesystem::path &path, unsigned int flags) { - initialize_gdal_once(); + gdal::initialize_once(); const std::string path_str = path.string(); static std::mutex gdal_open_mutex; const std::lock_guard lock(gdal_open_mutex); return static_cast(GDALOpenEx(path_str.c_str(), flags, nullptr, nullptr, nullptr)); } -std::optional Dataset::open_raster(std::filesystem::path path) { +Expected Dataset::open_raster(std::filesystem::path path) +{ if (GDALDataset *dataset = open_gdal_dataset(path, GDAL_OF_RASTER)) { - return std::optional(Dataset(path, dataset)); + return Dataset(path, dataset); } - LOG_ERROR("Couldn't open raster dataset {}.\n", path); - return std::nullopt; + return Error::fail(Error::Code::Io, "open raster dataset", path); } -std::optional Dataset::open_vector(std::filesystem::path path) { +Expected Dataset::open_vector(std::filesystem::path path) +{ if (GDALDataset *dataset = open_gdal_dataset(path, GDAL_OF_VECTOR)) { - return std::optional(Dataset(path, dataset)); + return Dataset(path, dataset); } - LOG_ERROR("Couldn't open vector dataset {}.\n", path); - return std::nullopt; + return Error::fail(Error::Code::Io, "open vector dataset", path); } -std::optional> Dataset::open_shared_raster(std::filesystem::path path) { +Expected> Dataset::open_shared_raster(std::filesystem::path path) +{ if (GDALDataset *dataset = open_gdal_dataset(path, GDAL_OF_RASTER)) { return std::make_shared(Dataset(path, dataset)); } - LOG_ERROR("Couldn't open shared raster dataset {}.\n", path); - return std::nullopt; + return Error::fail(Error::Code::Io, "open raster dataset", path); } Dataset::Dataset(std::filesystem::path path) { @@ -118,10 +120,18 @@ std::string Dataset::name() const { Dataset::~Dataset() = default; -radix::tile::SrsBounds Dataset::bounds() const { +Expected Dataset::bounds() const +{ + // GDAL would silently prefer the geotransform over these, even when it is less accurate. + if (m_gdal_dataset->GetGCPCount() != 0 || CSLCount(m_gdal_dataset->GetMetadata("RPC")) != 0 || CSLCount(m_gdal_dataset->GetMetadata("GEOLOCATION")) != 0) { + return Error::fail(Error::Code::Unsupported, "dataset " + name() + " has GCP, RPC or geolocation-array georeferencing"); + } std::array adfGeoTransform = {}; if (m_gdal_dataset->GetGeoTransform(adfGeoTransform.data()) != CE_None) { - throw std::runtime_error("Could not get transformation information from source dataset"); + return Error::fail(Error::Code::Unsupported, "dataset " + name() + " has no geotransform"); + } + if (!std::ranges::all_of(adfGeoTransform, [](double value) { return std::isfinite(value); })) { + return Error::fail(Error::Code::InvalidInput, "dataset " + name() + " has a nonfinite geotransform"); } // https://gdal.org/user/raster_data_model.html @@ -131,7 +141,11 @@ radix::tile::SrsBounds Dataset::bounds() const { // we don't support sheering or rotation for now if (adfGeoTransform[2] != 0.0 || adfGeoTransform[4] != 0.0) { - throw std::runtime_error("Dataset geo transform contains sheering or rotation. This is not supported!"); + return Error::fail(Error::Code::Unsupported, "dataset " + name() + " geotransform contains sheering or rotation"); + } + // nor mirrored or south-up rasters + if (adfGeoTransform[1] <= 0.0 || adfGeoTransform[5] >= 0.0) { + return Error::fail(Error::Code::Unsupported, "dataset " + name() + " geotransform is not north-up with positive pixel width"); } const double westX = adfGeoTransform[0]; @@ -139,10 +153,11 @@ radix::tile::SrsBounds Dataset::bounds() const { const double eastX = adfGeoTransform[0] + (widthInPixels() * adfGeoTransform[1]); const double northY = adfGeoTransform[3]; - return {{westX, southY}, {eastX, northY}}; + return radix::tile::SrsBounds { { westX, southY }, { eastX, northY } }; } -radix::tile::SrsAndHeightBounds Dataset::bounds3d(bool approx_ok) const { +Expected Dataset::bounds3d(bool approx_ok) const +{ const auto band = this->m_gdal_dataset->GetRasterBand(1); glm::dvec2 height_range; @@ -154,66 +169,44 @@ radix::tile::SrsAndHeightBounds Dataset::bounds3d(bool approx_ok) const { height_range = {-11000.0, 9000.0}; // Mariana Trench and Mount Everest } - const auto bounds2d = this->bounds(); + auto bounds2d = this->bounds(); + if (!bounds2d) { + return Error::propagate(std::move(bounds2d)); + } radix::tile::SrsAndHeightBounds bounds3d; - bounds3d.min = glm::dvec3(bounds2d.min, height_range[0]); - bounds3d.max = glm::dvec3(bounds2d.max, height_range[1]); + bounds3d.min = glm::dvec3(bounds2d->min, height_range[0]); + bounds3d.max = glm::dvec3(bounds2d->max, height_range[1]); return bounds3d; } -radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) const { - const auto l_bounds = bounds(); - const auto west = l_bounds.min.x; - const auto east = l_bounds.max.x; - const auto north = l_bounds.max.y; - const auto south = l_bounds.min.y; - - const auto data_srs = srs(); - if (targetSrs.IsSame(&data_srs)) - return l_bounds; - - // We need to transform the bounds to the target SRS - // this might involve warping, i.e. some of the edges can be arcs. - // therefore we want to walk the perimiter and get min/max from there. - // a resolution of 2000 samples per border should give a good enough approximation. - - // hey, check out inline virtual int TransformBounds(const double xmin, const double ymin, const double xmax, const double ymax, double *out_xmin, double *out_ymin, double *out_xmax, double *out_ymax, const int densify_pts) - std::vector x; - std::vector y; - auto addCoordinate = [&](double xv, double yv) { x.emplace_back(xv); y.emplace_back(yv); }; - - const auto deltaX = l_bounds.width() / 2000.0; - if (deltaX <= 0.0) - throw std::runtime_error("west coordinate > east coordinate. This is not supported."); - for (double s = west; s < east; s += deltaX) { - addCoordinate(s, south); - addCoordinate(s, north); +Expected> Dataset::geographic_coverage() const +{ + auto reference = srs(); + if (!reference) { + return Error::propagate(std::move(reference)); } - const auto deltaY = (north - south) / 2000.0; - if (deltaY <= 0.0) - throw std::runtime_error("south coordinate > north coordinate. This is not supported."); - for (double s = south; s < north; s += deltaY) { - addCoordinate(west, s); - addCoordinate(east, s); + auto l_bounds = bounds(); + if (!l_bounds) { + return Error::propagate(std::move(l_bounds)); } - // don't wanna miss out the max/max edge vertex - addCoordinate(east, north); + return srs::geographic_coverage(*reference, *l_bounds); +} - const auto transformer = srs::transformation(srs(), targetSrs); - if (!transformer->Transform(int(x.size()), x.data(), y.data())) { - throw std::runtime_error("Could not transform dataset bounds to target SRS"); +Expected> Dataset::mercator_coverage() const +{ + auto reference = srs(); + if (!reference) { + return Error::propagate(std::move(reference)); } - - DEBUG_ASSERT(!x.empty()); - DEBUG_ASSERT(!y.empty()); - const double target_minX = *std::min_element(x.begin(), x.end()); - const double target_maxX = *std::max_element(x.begin(), x.end()); - const double target_minY = *std::min_element(y.begin(), y.end()); - const double target_maxY = *std::max_element(y.begin(), y.end()); - return {{target_minX, target_minY}, {target_maxX, target_maxY}}; + auto l_bounds = bounds(); + if (!l_bounds) { + return Error::propagate(std::move(l_bounds)); + } + return srs::mercator_coverage(*reference, *l_bounds); } -OGRSpatialReference Dataset::srs() const { +Expected Dataset::srs() const +{ 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)) { @@ -221,7 +214,7 @@ OGRSpatialReference Dataset::srs() const { } } if (source_srs == nullptr) { - throw std::runtime_error("The source dataset does not have a spatial reference system assigned"); + return Error::fail(Error::Code::InvalidInput, "dataset " + name() + " does not have a spatial reference system assigned"); } auto srs = *source_srs; srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); @@ -229,19 +222,11 @@ OGRSpatialReference Dataset::srs() const { } unsigned Dataset::widthInPixels() const { - return ctb::i_pixel(m_gdal_dataset->GetRasterXSize()); + return unsigned(m_gdal_dataset->GetRasterXSize()); } unsigned Dataset::heightInPixels() const { - return ctb::i_pixel(m_gdal_dataset->GetRasterYSize()); -} - -double Dataset::widthInPixels(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &bounds_srs) const { - return bounds.width() / pixelWidthIn(bounds_srs); -} - -double Dataset::heightInPixels(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &bounds_srs) const { - return bounds.height() / pixelHeightIn(bounds_srs); + return unsigned(m_gdal_dataset->GetRasterYSize()); } unsigned Dataset::n_bands() const { @@ -257,17 +242,4 @@ const GDALDataset *Dataset::gdalDataset() const { return m_gdal_dataset.get(); } -double Dataset::gridResolution(const OGRSpatialReference &target_srs) const { - return std::min(pixelWidthIn(target_srs), pixelHeightIn(target_srs)); -} - -double Dataset::pixelWidthIn(const OGRSpatialReference &target_srs) const { - const auto b0 = bounds(); - const auto b1 = srs::non_exact_bounds_transform(b0, srs(), target_srs); - return b1.width() / widthInPixels(); -} - -double Dataset::pixelHeightIn(const OGRSpatialReference &target_srs) const { - const auto b = srs::non_exact_bounds_transform(bounds(), srs(), target_srs); - return b.height() / heightInPixels(); -} +} // namespace gis diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/gis/Dataset.h similarity index 61% rename from src/terrainlib/Dataset.h rename to src/terrainlib/gis/Dataset.h index b96a6181..ad911e0d 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/gis/Dataset.h @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -19,17 +19,21 @@ #pragma once +#include #include -#include #include -#include +#include +#include +#include "Error.h" +#include "cli_support/log.h" #include -#include "log.h" class GDALDataset; class OGRSpatialReference; +namespace gis { + struct GDALDatasetDeleter { void operator()(GDALDataset *dataset) const; }; @@ -39,9 +43,9 @@ class Dataset { Dataset(std::filesystem::path path); Dataset(GDALDataset* dataset); // takes over ownership ~Dataset(); - static std::optional open_raster(std::filesystem::path path); - static std::optional open_vector(std::filesystem::path path); - static std::optional> open_shared_raster(std::filesystem::path path); + static Expected open_raster(std::filesystem::path path); + static Expected open_vector(std::filesystem::path path); + static Expected> open_shared_raster(std::filesystem::path path); Dataset clone(); Dataset(Dataset &&) noexcept = default; @@ -52,24 +56,23 @@ class Dataset { [[nodiscard]] std::string name() const; - [[nodiscard]] radix::tile::SrsBounds bounds() const; - [[nodiscard]] radix::tile::SrsAndHeightBounds bounds3d(bool approx_ok = false) const; - [[nodiscard]] radix::tile::SrsBounds bounds(const OGRSpatialReference &targetSrs) const; - [[nodiscard]] OGRSpatialReference srs() const; + // Bounds of a north-up raster without rotation or shear, in its own SRS. + [[nodiscard]] Expected bounds() const; + [[nodiscard]] Expected bounds3d(bool approx_ok = false) const; + // See srs::geographic_coverage and srs::mercator_coverage. + [[nodiscard]] Expected> geographic_coverage() const; + [[nodiscard]] Expected> mercator_coverage() const; + [[nodiscard]] Expected srs() const; [[nodiscard]] unsigned int widthInPixels() const; [[nodiscard]] unsigned int heightInPixels() const; - [[nodiscard]] double widthInPixels(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &bounds_srs) const; - [[nodiscard]] double heightInPixels(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &bounds_srs) const; [[nodiscard]] unsigned int n_bands() const; [[nodiscard]] GDALDataset *gdalDataset(); [[nodiscard]] const GDALDataset *gdalDataset() const; - [[nodiscard]] double gridResolution(const OGRSpatialReference &target_srs) const; - [[nodiscard]] double pixelWidthIn(const OGRSpatialReference &target_srs) const; - [[nodiscard]] double pixelHeightIn(const OGRSpatialReference &target_srs) const; - private: Dataset(const std::filesystem::path path, GDALDataset *dataset); std::unique_ptr m_gdal_dataset; std::optional m_path; }; + +} // namespace gis diff --git a/src/terrainlib/gis/DatasetReader.cpp b/src/terrainlib/gis/DatasetReader.cpp new file mode 100644 index 00000000..32da97bf --- /dev/null +++ b/src/terrainlib/gis/DatasetReader.cpp @@ -0,0 +1,378 @@ +/******************************************************************************* + * Copyright 2014 GeoData + * Copyright 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 Martin Braunsperger + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + *******************************************************************************/ + +#include "gis/DatasetReader.h" +#include "gis/Dataset.h" +#include "gis/srs.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "cli_support/log.h" + +namespace gis { + +namespace { +std::string toWkt(const OGRSpatialReference& srs) +{ + char* wkt_char_string = nullptr; + srs.exportToWkt(&wkt_char_string); + std::string wkt_string(wkt_char_string); + CPLFree(wkt_char_string); + return wkt_string; +} + +OGRSpatialReference target_srs(srs::Projection projection) +{ + switch (projection) { + case srs::Projection::WebMercator: + return srs::webmercator(); + case srs::Projection::Geographic: + return srs::wgs84(); + } + PANIC("unsupported projection", static_cast(projection)); +} + +using GdalWarpOptionsPtr = std::unique_ptr; +using ApproxTransformer = std::unique_ptr; + +// Maximum approximation error in source pixels. +constexpr double approximation_error = 0.125; + +template +constexpr GDALDataType source_type = std::is_same_v ? GDT_Float32 : GDT_Byte; + +static_assert(sizeof(glm::u8vec3) == 3 && alignof(glm::u8vec3) == 1 && std::is_trivially_copyable_v); +static_assert(offsetof(glm::u8vec3, x) == 0 && offsetof(glm::u8vec3, y) == 1 && offsetof(glm::u8vec3, z) == 2); + +// The selected bands, followed by an alpha band exposing their effective +// masks unless all of them are valid everywhere. +struct Source { + std::unique_ptr dataset; + int alpha_band = 0; +}; + +Expected make_source(GDALDataset& dataset, std::span bands) +{ + Source source { std::make_unique(dataset.GetRasterXSize(), dataset.GetRasterYSize()) }; + std::vector inputs; + bool all_valid = true; + for (const unsigned band : bands) { + auto* input = dataset.GetRasterBand(int(band)); + if (source.dataset->AddBand(input->GetRasterDataType(), nullptr) != CE_None + || static_cast(source.dataset->GetRasterBand(source.dataset->GetRasterCount()))->AddSimpleSource(input) != CE_None) { + return Error::fail(Error::Code::Io, fmt::format("expose source band {}: {}", band, CPLGetLastErrorMsg())); + } + auto* mask = input->GetMaskBand(); + if (!mask) { + return Error::fail(Error::Code::Io, fmt::format("get mask of source band {}: {}", band, CPLGetLastErrorMsg())); + } + if (mask->GetRasterDataType() != GDT_Byte) { + return Error::fail(Error::Code::Unsupported, fmt::format("mask of source band {} is not of type Byte", band)); + } + all_valid = all_valid && (input->GetMaskFlags() & GMF_ALL_VALID) != 0; + inputs.push_back(input); + } + if (all_valid) { + return source; + } + // GDAL returns per-dataset masks as one shared object. Otherwise, the + // minimum is the AND of binary masks. It must not have NoData, which min skips. + const bool shared = std::ranges::all_of(inputs, [&](auto* input) { return input->GetMaskBand() == inputs.front()->GetMaskBand(); }); + CPLStringList options; + if (!shared) { + options.SetNameValue("subclass", "VRTDerivedRasterBand"); + options.SetNameValue("PixelFunctionType", "min"); + } + if (source.dataset->AddBand(GDT_Byte, options.List()) != CE_None) { + return Error::fail(Error::Code::Io, fmt::format("create source validity band: {}", CPLGetLastErrorMsg())); + } + source.alpha_band = source.dataset->GetRasterCount(); + auto* alpha = static_cast(source.dataset->GetRasterBand(source.alpha_band)); + for (auto* input : shared ? std::span(inputs).first(1) : std::span(inputs)) { + if (alpha->AddMaskBandSource(input) != CE_None) { + return Error::fail(Error::Code::Io, fmt::format("expose source band mask: {}", CPLGetLastErrorMsg())); + } + } + return source; +} + +// Warps all source bands at once into samples, with destination alpha as validity. +template +Expected warp(const Source& source, const ApproxTransformer& transformer, typename DatasetReader::Samples& samples) +{ + constexpr int channel_count = int(DatasetReader::channel_count); + const glm::uvec2 size = samples.data.size(); + auto* driver = GetGDALDriverManager()->GetDriverByName("MEM"); + if (!driver) { + return Error::fail(Error::Code::Unsupported, "GDAL MEM driver is required"); + } + // The destination bands only borrow the sample buffers. + GDALDatasetUniquePtr destination(driver->Create("", int(size.x), int(size.y), 0, GDT_Unknown, nullptr)); + if (!destination) { + return Error::fail(Error::Code::Io, fmt::format("create warp destination: {}", CPLGetLastErrorMsg())); + } + const auto add_band = [&](GDALDataType type, std::byte* data, GSpacing pixel_offset) { + CPLStringList options; + options.SetNameValue("DATAPOINTER", fmt::format("{}", static_cast(data)).c_str()); + options.SetNameValue("PIXELOFFSET", fmt::format("{}", pixel_offset).c_str()); + options.SetNameValue("LINEOFFSET", fmt::format("{}", pixel_offset * GSpacing(size.x)).c_str()); + return destination->AddBand(type, options.List()) == CE_None; + }; + auto* data = samples.data.bytes().data(); + for (int channel = 0; channel < channel_count; ++channel) { + if (!add_band(source_type, data + channel * GDALGetDataTypeSizeBytes(source_type), GSpacing(sizeof(Pixel)))) { + return Error::fail(Error::Code::Io, fmt::format("attach warp destination data: {}", CPLGetLastErrorMsg())); + } + } + if (!add_band(GDT_Byte, samples.valid.bytes().data(), 1)) { + return Error::fail(Error::Code::Io, fmt::format("attach warp destination validity: {}", CPLGetLastErrorMsg())); + } + + auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); + options->hSrcDS = source.dataset.get(); + options->hDstDS = destination.get(); + GDALWarpInitDefaultBandMapping(options.get(), channel_count); + options->nSrcAlphaBand = source.alpha_band; + options->nDstAlphaBand = channel_count + 1; + options->eResampleAlg = GRA_Lanczos; + options->eWorkingDataType = source_type; + options->pfnTransformer = GDALApproxTransform; + options->pTransformerArg = transformer.get(); + // A window without valid source coverage is an empty read rather than a failure. + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "INIT_DEST", "0"); + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "ERROR_OUT_IF_EMPTY_SOURCE_WINDOW", "FALSE"); + // Callers parallelize across readers. + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "NUM_THREADS", "1"); + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "SRC_ALPHA_MAX", "255"); + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "DST_ALPHA_MAX", "255"); + GDALWarpOperation operation; + if (operation.Initialize(options.get()) != CE_None || operation.ChunkAndWarpImage(0, 0, int(size.x), int(size.y)) != CE_None) { + return Error::fail(Error::Code::Io, fmt::format("warp source bands: {}", CPLGetLastErrorMsg())); + } + return {}; +} +} + +template +void DatasetReader::TransformerDeleter::operator()(Transformer* transformer) const +{ + GDALDestroyGenImgProjTransformer(transformer); +} + +template +DatasetReader::DatasetReader(std::shared_ptr dataset, + srs::Projection projection, + std::array bands, + Pixel default_pixel, + std::unique_ptr transformer) + : m_dataset(std::move(dataset)) + , m_projection(projection) + , m_bands(bands) + , m_default_pixel(default_pixel) + , m_transformer(std::move(transformer)) +{ +} + +template +Expected> DatasetReader::make( + std::shared_ptr dataset, const srs::Projection projection, const std::array bands, const Pixel default_pixel) +{ + ASSERT(dataset); + if constexpr (std::is_same_v) { + if (!std::isfinite(default_pixel)) { + return Error::fail(Error::Code::InvalidInput, "default pixel must be finite"); + } + } + auto& gdal_dataset = *dataset->gdalDataset(); + for (const unsigned band : bands) { + if (band == 0 || band > unsigned(gdal_dataset.GetRasterCount())) { + return Error::fail(Error::Code::InvalidInput, + fmt::format("dataset {} does not contain band number {} (there are {} bands)", dataset->name(), band, gdal_dataset.GetRasterCount())); + } + const auto type = gdal_dataset.GetRasterBand(int(band))->GetRasterDataType(); + if (type != source_type) { + return Error::fail(Error::Code::Unsupported, + fmt::format("band {} of dataset {} has type {}, but the reader requires {}", + band, + dataset->name(), + GDALGetDataTypeName(type), + GDALGetDataTypeName(source_type))); + } + } + + auto source = dataset->srs(); + if (!source) { + return Error::propagate(std::move(source), "read SRS of dataset " + dataset->name()); + } + const auto target = target_srs(projection); + const bool requires_reprojection = !source->IsSame(&target); + if (source->IsGeographic() && std::abs(source->GetAngularUnits() - std::numbers::pi / 180) < 1e-12) { + // GDAL wraps longitudes around the source centre only for sources up to about 360° wide. + // Wider ones need it too, e.g. -0.5..360.5, as reprojected longitudes lie in -180..180. + if (const auto bounds = dataset->bounds()) { + source->SetExtension("GEOGCS", "CENTER_LONG", fmt::format("{}", (bounds->min.x + bounds->max.x) / 2).c_str()); + } + } + CPLStringList transformer_options; + if (requires_reprojection) { + transformer_options.SetNameValue("SRC_SRS", toWkt(*source).c_str()); + transformer_options.SetNameValue("DST_SRS", toWkt(target).c_str()); + } + // Creates both transformation directions, so reads rebuild no PROJ state. + std::unique_ptr transformer( + static_cast(GDALCreateGenImgProjTransformer2(&gdal_dataset, nullptr, transformer_options.List()))); + if (!transformer) { + return Error::fail(Error::Code::Unsupported, fmt::format("create transformer for dataset {}: {}", dataset->name(), CPLGetLastErrorMsg())); + } + return DatasetReader(std::move(dataset), projection, bands, default_pixel, std::move(transformer)); +} + +template +Expected::Samples> DatasetReader::read(const radix::tile::SrsBounds& bounds, const glm::uvec2 size) +{ + if (size.x == 0 || size.y == 0 || size.x > unsigned(INT_MAX) || size.y > unsigned(INT_MAX)) { + return Error::fail(Error::Code::InvalidInput, fmt::format("read size {}x{} is empty or exceeds GDAL dimensions", size.x, size.y)); + } + const std::uint64_t count = std::uint64_t(size.x) * size.y; + if (count > std::vector().max_size() || count > std::vector().max_size()) { + return Error::fail(Error::Code::ResourceExhausted, fmt::format("read size {}x{} exceeds raster capacity", size.x, size.y)); + } + const glm::dvec2 pixel_size = (bounds.max - bounds.min) / glm::dvec2(size); + const auto finite = [](glm::dvec2 value) { return std::isfinite(value.x) && std::isfinite(value.y); }; + if (!finite(bounds.min) || !finite(bounds.max) || !finite(pixel_size) || !(pixel_size.x > 0) || !(pixel_size.y > 0)) { + return Error::fail(Error::Code::InvalidInput, "read bounds must be finite and nonempty"); + } + + CPLErrorReset(); + const std::array geo_transform { bounds.min.x, pixel_size.x, 0, bounds.max.y, 0, -pixel_size.y }; + GDALSetGenImgProjTransformerDstGeoTransform(m_transformer.get(), geo_transform.data()); + ApproxTransformer transformer( + GDALCreateApproxTransformer(GDALGenImgProjTransform, m_transformer.get(), approximation_error), &GDALDestroyApproxTransformer); + if (!transformer) { + return Error::fail(Error::Code::Internal, fmt::format("create approximate transformer: {}", CPLGetLastErrorMsg())); + } + auto source = make_source(*m_dataset->gdalDataset(), m_bands); + if (!source) { + return Error::propagate(std::move(source), "prepare dataset " + m_dataset->name()); + } + Samples result { radix::Raster(size), radix::Raster(size) }; + if (auto warped = warp(*source, transformer, result); !warped) { + return Error::propagate(std::move(warped), "read dataset " + m_dataset->name()); + } + + // Neither destination NoData nor INIT_DEST can replace this pass: GDAL + // alters valid pixels equal to NoData and writes pixels with zero alpha. + auto data = result.data.buffer(); + auto valid = result.valid.buffer(); + for (std::size_t i = 0; i < data.size(); ++i) { + bool is_valid = valid[i] != 0; + if constexpr (std::is_same_v) { + is_valid = is_valid && std::isfinite(data[i]); + } + if (!is_valid) { + data[i] = m_default_pixel; + valid[i] = 0; + } + } + return result; +} + +template +Expected DatasetReader::min_pixel_size(const radix::tile::SrsBounds& bounds) const +{ + constexpr unsigned samples_per_axis = 5; + constexpr double step_fraction = 1e-3; + + auto coverage = m_projection == srs::Projection::WebMercator ? m_dataset->mercator_coverage() : m_dataset->geographic_coverage(); + if (!coverage) { + return Error::propagate(std::move(coverage), "compute dataset coverage"); + } + // Each sample is a point followed by its x and y neighbours. Steps point inwards, + // so neighbours stay within the coverage and never cross the antimeridian. + std::vector points; + std::vector steps; + for (const auto& box : *coverage) { + const radix::tile::SrsBounds region { glm::max(bounds.min, box.min), glm::min(bounds.max, box.max) }; + if (region.min.x >= region.max.x || region.min.y >= region.max.y) { + continue; + } + const glm::dvec2 centre = (region.min + region.max) / 2.0; + const glm::dvec2 step = step_fraction * (region.max - region.min); + for (unsigned y = 0; y < samples_per_axis; ++y) { + for (unsigned x = 0; x < samples_per_axis; ++x) { + const glm::dvec2 point = glm::mix(region.min, region.max, glm::dvec2(x, y) / double(samples_per_axis - 1)); + const glm::dvec2 inward_step = glm::mix(step, -step, glm::greaterThan(point, centre)); + points.insert(points.end(), { point, point + glm::dvec2(inward_step.x, 0), point + glm::dvec2(0, inward_step.y) }); + steps.push_back(inward_step); + } + } + } + if (steps.empty()) { + return Error::fail(Error::Code::InvalidInput, "bounds do not intersect the coverage of dataset " + m_dataset->name()); + } + + auto source_srs = m_dataset->srs(); + if (!source_srs) { + return Error::propagate(std::move(source_srs)); + } + auto transform = srs::transformation(target_srs(m_projection), *source_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + if (auto transformed = srs::transform_points_inplace(transform->get(), points); !transformed) { + return Error::propagate(std::move(transformed), "transform pixel size samples into dataset SRS"); + } + + // The coverage already required a north-up geotransform without rotation. + std::array geo_transform {}; + const auto geo_transform_result = m_dataset->gdalDataset()->GetGeoTransform(geo_transform.data()); + ASSERT(geo_transform_result == CE_None); + const glm::dvec2 source_pixel_size(geo_transform[1], geo_transform[5]); + glm::dvec2 result(std::numeric_limits::infinity()); + for (std::size_t i = 0; i < steps.size(); ++i) { + // Source pixels traversed per target unit along target x and y. + const glm::dvec2 along_x = glm::abs((points[3 * i + 1] - points[3 * i]) / (steps[i].x * source_pixel_size)); + const glm::dvec2 along_y = glm::abs((points[3 * i + 2] - points[3 * i]) / (steps[i].y * source_pixel_size)); + result = glm::min(result, 1.0 / glm::dvec2(along_x.x + along_x.y, along_y.x + along_y.y)); + } + return result; +} + +template class DatasetReader; +template class DatasetReader; + +} // namespace gis diff --git a/src/terrainlib/gis/DatasetReader.h b/src/terrainlib/gis/DatasetReader.h new file mode 100644 index 00000000..a99f2b3a --- /dev/null +++ b/src/terrainlib/gis/DatasetReader.h @@ -0,0 +1,103 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 . + *****************************************************************************/ + +#ifndef DATASETREADER_H +#define DATASETREADER_H + +#include +#include +#include +#include + +#include +#include +#include +#include "Error.h" +#include "gis/srs.h" + +namespace gis { + +class Dataset; + +// Reads selected dataset bands into a target projection, using Lanczos at base +// resolution and GDAL's approximate transformer. Validity comes from the +// effective GDAL masks of the selected bands; RGB bands share one combined +// mask. Sources must be Float32 for float and Byte for RGB; there is no type +// conversion. A reader and its dataset belong to one thread at a time. +template +class DatasetReader { + static_assert(std::is_same_v || std::is_same_v, "DatasetReader supports float and glm::u8vec3 pixels"); + +public: + static constexpr unsigned channel_count = std::is_same_v ? 1 : 3; + + // Data and validity have the same size. Validity is zero for invalid and + // nonzero for valid pixels; it is not image opacity. Invalid pixels hold + // the default pixel, which valid pixels may equal as well. + struct Samples { + radix::Raster data; + radix::Raster valid; + }; + + // Bands are one-based GDAL band numbers, in RGB order for colour. The + // default pixel of a float reader must be finite. + static Expected make( + std::shared_ptr dataset, srs::Projection projection, std::array bands, Pixel default_pixel); + + DatasetReader(DatasetReader&&) noexcept = default; + DatasetReader& operator=(DatasetReader&&) noexcept = default; + DatasetReader(const DatasetReader&) = delete; + DatasetReader& operator=(const DatasetReader&) = delete; + ~DatasetReader() = default; + + // Bounds are in the reader's projection. Windows without valid source + // coverage yield default pixels and zero validity. + Expected read(const radix::tile::SrsBounds& bounds, glm::uvec2 size); + // Smallest source pixel size within bounds, per axis, in units of the projection. + // Approximate: sampled on a grid within the dataset coverage. + Expected min_pixel_size(const radix::tile::SrsBounds& bounds) const; + + srs::Projection projection() const { return m_projection; } + +private: + // Opaque handle of GDAL's image-to-image transformer from the output pixels to the dataset pixels. + struct Transformer; + struct TransformerDeleter { + void operator()(Transformer* transformer) const; + }; + + DatasetReader(std::shared_ptr dataset, + srs::Projection projection, + std::array bands, + Pixel default_pixel, + std::unique_ptr transformer); + + std::shared_ptr m_dataset; + srs::Projection m_projection; + std::array m_bands; + Pixel m_default_pixel; + std::unique_ptr m_transformer; +}; + +extern template class DatasetReader; +extern template class DatasetReader; + +} // namespace gis + +#endif // DATASETREADER_H diff --git a/src/terrainlib/gis/gdal.cpp b/src/terrainlib/gis/gdal.cpp new file mode 100644 index 00000000..12b6f356 --- /dev/null +++ b/src/terrainlib/gis/gdal.cpp @@ -0,0 +1,71 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 + +#include + +#include "gis/gdal.h" +#include "cli_support/log.h" + +namespace gis::gdal { + +namespace { + +std::once_flag g_gdal_initialized_once_flag; + +inline void GdalErrorHandler(CPLErr eErrClass, int err_no, const char *msg) { + spdlog::level::level_enum level; + switch (eErrClass) { + case CE_None: + return; + case CE_Debug: + level = spdlog::level::debug; + break; + case CE_Warning: + level = spdlog::level::warn; + break; + case CE_Failure: + level = spdlog::level::err; + break; + case CE_Fatal: + level = spdlog::level::critical; + break; + default: + level = spdlog::level::err; + LOG_WARN("Unknown GDAL error class: {}", (int)eErrClass); + break; + } + + cli_support::Log::get_logger().get()->log(level, "GDAL({}): {}", err_no, msg); +} + +} // namespace + +void initialize_once() { + std::call_once(g_gdal_initialized_once_flag, []() { + LOG_DEBUG("calling GDALAllRegister..."); + CPLSetErrorHandler(GdalErrorHandler); + GDALAllRegister(); + // Initialize GDAL's global block-cache lock before concurrent reads. + GDALGetCacheMax64(); + }); +} + +} // namespace gis::gdal diff --git a/src/tile_builder/TopDownTiler.h b/src/terrainlib/gis/gdal.h similarity index 66% rename from src/tile_builder/TopDownTiler.h rename to src/terrainlib/gis/gdal.h index f078ca89..ad9e8e0e 100644 --- a/src/tile_builder/TopDownTiler.h +++ b/src/terrainlib/gis/gdal.h @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -17,14 +18,9 @@ *****************************************************************************/ #pragma once -#include -#include "Tiler.h" -#include "ctb/Grid.hpp" -#include -class TopDownTiler : public Tiler { -public: - TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); +namespace gis::gdal { - [[nodiscard]] std::vector generateTiles(const radix::tile::Id& parent_id) const; -}; +void initialize_once(); + +} // namespace gis::gdal diff --git a/src/terrainlib/gis/srs.cpp b/src/terrainlib/gis/srs.cpp new file mode 100644 index 00000000..cb156682 --- /dev/null +++ b/src/terrainlib/gis/srs.cpp @@ -0,0 +1,482 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 "gis/srs.h" +#include "gis/srs/detail.h" +#include "gis/webmercator.h" + +#include +#include +#include +#include +#include + +namespace gis::srs { + +std::optional epsg_code(const OGRSpatialReference& srs) +{ + OGRSpatialReference tmp = srs; + tmp.AutoIdentifyEPSG(); + + const char* authName = tmp.GetAuthorityName(nullptr); + const char* authCode = tmp.GetAuthorityCode(nullptr); + + if (authName == nullptr || authCode == nullptr) { + return std::nullopt; + } + + if (std::string(authName) != "EPSG") { + return std::nullopt; + } + + return std::atoi(authCode); +} + +std::string friendly_name(const OGRSpatialReference& srs) +{ + if (const auto code = epsg_code(srs)) { + return "EPSG:" + std::to_string(*code); + } + + const char* name = srs.GetName(); + + if (name != nullptr && *name != '\0') { + return std::string(name); + } + + return "unknown"; +} + +std::unique_ptr clone(const OGRSpatialReference& srs) +{ + auto cloned = srs.Clone(); + return std::unique_ptr(cloned); +} + +namespace { + struct TransformationCacheEntry { + OGRSpatialReference source; + OGRSpatialReference target; + std::shared_ptr transform; + }; +} // namespace + +Expected> uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) +{ + auto source = source_srs; + auto target = target_srs; + source.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + target.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + std::unique_ptr transform(OGRCreateCoordinateTransformation(&source, &target)); + if (!transform) { + return Error::fail(Error::Code::InvalidInput, "create SRS transformation from " + friendly_name(source_srs) + " to " + friendly_name(target_srs)); + } + return transform; +} + +Expected> transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) +{ + thread_local std::vector cache; + + // Check forward + for (auto& entry : cache) { + if (entry.source.IsSame(&source_srs) && entry.target.IsSame(&target_srs)) { + return entry.transform; + } + } + + // Check inverse + for (auto& entry : cache) { + if (entry.source.IsSame(&target_srs) && entry.target.IsSame(&source_srs)) { + std::shared_ptr inverse(entry.transform->GetInverse()); + if (!inverse) { + return Error::fail( + Error::Code::InvalidInput, "create inverse SRS transformation from " + friendly_name(source_srs) + " to " + friendly_name(target_srs)); + } + cache.push_back({ source_srs, target_srs, inverse }); + return inverse; + } + } + + // Not found + auto transform = uncached_transformation(source_srs, target_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + std::shared_ptr shared_transform = std::move(*transform); + cache.push_back({ source_srs, target_srs, shared_transform }); + return shared_transform; +} + +std::expected from_epsg(const uint32_t epsg) +{ + OGRSpatialReference srs; + if (srs.importFromEPSG(epsg) != OGRERR_NONE) { + return std::unexpected(fmt::format("Failed to import spatial reference from EPSG code: {}", epsg)); + } + srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + return srs; +} + +std::expected from_user_input(const std::string& user_input) +{ + OGRSpatialReference srs; + if (srs.SetFromUserInput(user_input.c_str()) != OGRERR_NONE) { + return std::unexpected(fmt::format("Failed to set spatial reference from user input: {}", user_input)); + } + srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + return srs; +} + +namespace { + // Points GDAL inserts between the corners of each edge when transforming bounds. + constexpr int boundary_samples = 257; + + // Transforms bounds with GDAL, which samples the densified boundary. Extrema of curved + // edges can lie between samples, so each side is widened by a full sampling interval. + // Geographic bounds crossing the antimeridian are unwrapped, i.e., max.x exceeds 180. + Expected guarded_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds) + { + radix::tile::SrsBounds result; + if (!detail::ogr_transform_bounds(transform, + bounds.min.x, + bounds.min.y, + bounds.max.x, + bounds.max.y, + &result.min.x, + &result.min.y, + &result.max.x, + &result.max.y, + boundary_samples)) { + return Error::fail(Error::Code::InvalidInput, "transform bounds"); + } + if (!std::isfinite(result.min.x) || !std::isfinite(result.max.x) || !std::isfinite(result.min.y) || !std::isfinite(result.max.y)) { + return Error::fail(Error::Code::InvalidInput, "nonfinite transformed bounds"); + } + // TransformBounds reports antimeridian-crossing bounds with max.x < min.x. + if (result.max.x < result.min.x) { + result.max.x += 360; + } + const glm::dvec2 guard = glm::max(glm::dvec2(1e-6), result.size() / double(boundary_samples + 1)); + result.min -= guard; + result.max += guard; + return result; + } + + // Splits longitude/latitude bounds into at most two bounds with longitudes in [-180, 180]. + // Bounds crossing the antimeridian may be given with max.x < min.x or max.x beyond 180. + std::vector split_at_antimeridian(const radix::tile::SrsBounds& bounds) + { + double west = bounds.min.x; + double east = bounds.max.x; + const double south = bounds.min.y; + const double north = bounds.max.y; + if (east < west) { + east += 360; + } + if (east - west >= 360) { + return { { { -180, south }, { 180, north } } }; + } + const double shift = 360 * std::floor((west + 180) / 360); + west -= shift; + east -= shift; + if (east > 180) { + return { { { west, south }, { 180, north } }, { { -180, south }, { east - 360, north } } }; + } + return { { { west, south }, { east, north } } }; + } + + bool finite_and_ordered(const radix::geometry::Aabb3d& bounds) + { + for (int axis = 0; axis < 3; ++axis) { + if (!std::isfinite(bounds.min[axis]) || !std::isfinite(bounds.max[axis]) || bounds.min[axis] > bounds.max[axis]) { + return false; + } + } + return true; + } + + // Distance in metres by which horizontal datum shifts at the given ellipsoidal heights in metres + // may differ from those at height zero, at which bounds are transformed. Empirical: a survey of + // the PROJ 9.6 operations of 27 datums to and from WGS84 found at most 0.19 mm per metre of + // height (Pulkovo 1942); this allows 1.6 times that. + double datum_margin(double min_height, double max_height) { return 0.0003 * std::max(std::abs(min_height), std::abs(max_height)); } + + // Longitude ranges in degrees of the points in an ECEF xy rectangle. Longitude is the angle + // of the point around the polar axis, so its range is spanned by the corners, unless the + // rectangle contains the polar axis (all longitudes) or crosses the antimeridian (split). + std::vector longitude_ranges(const glm::dvec2& min, const glm::dvec2& max) + { + const auto corner_range = [](double x0, double x1, double y0, double y1) { + glm::dvec2 range(std::numeric_limits::max(), std::numeric_limits::lowest()); + for (const double x : { x0, x1 }) { + for (const double y : { y0, y1 }) { + const double longitude = glm::degrees(std::atan2(y, x)); + range = { std::min(range.x, longitude), std::max(range.y, longitude) }; + } + } + return range; + }; + if (min.x <= 0 && max.x >= 0 && min.y <= 0 && max.y >= 0) { + return { { -180, 180 } }; + } + if (max.x < 0 && min.y < 0 && max.y >= 0) { + // The antimeridian is the negative x half-axis; atan2 maps y = +0 to 180 and y = -0 to -180. + return { corner_range(min.x, max.x, 0.0, max.y), corner_range(min.x, max.x, min.y, -0.0) }; + } + return { corner_range(min.x, max.x, min.y, max.y) }; + } +} // namespace + +Expected> geographic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) +{ + if ((bounds.min.x < -webmercator::half_extent || bounds.max.x > webmercator::half_extent) && webmercator().IsSame(&reference)) { + return Error::fail(Error::Code::Unsupported, "coverage of Web Mercator bounds outside the world extent"); + } + const auto geographic = wgs84(); + radix::tile::SrsBounds longitude_latitude = bounds; + if (!reference.IsSame(&geographic)) { + auto transform = transformation(reference, geographic); + if (!transform) { + return Error::propagate(std::move(transform)); + } + auto transformed = guarded_bounds_transform(transform->get(), bounds); + if (!transformed) { + return Error::propagate(std::move(transformed), "transform bounds to geographic coordinates"); + } + longitude_latitude = *transformed; + longitude_latitude.min.y = std::max(-90.0, longitude_latitude.min.y); + longitude_latitude.max.y = std::min(90.0, longitude_latitude.max.y); + } + if (!std::isfinite(longitude_latitude.min.x) || !std::isfinite(longitude_latitude.max.x) || !std::isfinite(longitude_latitude.min.y) + || !std::isfinite(longitude_latitude.max.y)) { + return Error::fail(Error::Code::InvalidInput, "nonfinite geographic coverage bounds"); + } + return split_at_antimeridian(longitude_latitude); +} + +Expected> mercator_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) +{ + const auto mercator = webmercator(); + const glm::dvec2 half_extent(webmercator::half_extent); + // Avoid the geographic round trip, it would widen exact Web Mercator bounds by a guard band. + if (reference.IsSame(&mercator) && bounds.min.x >= -webmercator::half_extent && bounds.max.x <= webmercator::half_extent) { + const radix::tile::SrsBounds clipped { glm::max(bounds.min, -half_extent), glm::min(bounds.max, half_extent) }; + if (clipped.min.x >= clipped.max.x || clipped.min.y >= clipped.max.y) { + return std::vector {}; + } + return std::vector { clipped }; + } + auto geographic = geographic_coverage(reference, bounds); + if (!geographic) { + return geographic; + } + std::vector result; + for (const auto& longitude_latitude : *geographic) { + // Web Mercator diverges towards the poles; PROJ returns large finite values there. + const double south = std::max(-webmercator::latitude_limit, longitude_latitude.min.y); + const double north = std::min(webmercator::latitude_limit, longitude_latitude.max.y); + if (south >= north) { + continue; + } + auto corners + = transform_points(wgs84(), mercator, std::array { glm::dvec2(longitude_latitude.min.x, south), glm::dvec2(longitude_latitude.max.x, north) }); + if (!corners) { + return Error::propagate(std::move(corners)); + } + result.push_back({ glm::clamp((*corners)[0], -half_extent, half_extent), glm::clamp((*corners)[1], -half_extent, half_extent) }); + } + return result; +} + +Expected ecef_coverage(const OGRSpatialReference& reference, const radix::geometry::Aabb3d& bounds) +{ + if (!finite_and_ordered(bounds)) { + return Error::fail(Error::Code::InvalidInput, "ECEF coverage requires finite, ordered bounds"); + } + const auto geocentric = ecef(); + if (reference.IsSame(&geocentric)) { + return bounds; + } + if (reference.IsCompound()) { + return Error::fail(Error::Code::Unsupported, "ECEF coverage of compound SRS " + friendly_name(reference)); + } + if (bounds.min.z < -20'000 || bounds.max.z > 200'000) { + return Error::fail(Error::Code::Unsupported, "ECEF coverage requires heights between -20000 and 200000 metres"); + } + auto geographic = geographic_coverage(reference, radix::tile::SrsBounds(bounds)); + if (!geographic) { + return Error::propagate(std::move(geographic)); + } + // x = (N + h) cos(latitude) cos(longitude), y = (N + h) cos(latitude) sin(longitude) and + // z = (N (1 - e^2) + h) sin(latitude). x turns at longitudes 0 and 180, y at -90 and 90, + // both at the equator, z nowhere. Splitting a box at the turning values inside it gives + // boxes whose extrema are at their corners, i.e., at combinations of these values. + std::vector candidates; + for (const auto& box : *geographic) { + std::vector longitudes { box.min.x, box.max.x }; + for (const double turn : { -90.0, 0.0, 90.0 }) { + if (box.min.x < turn && turn < box.max.x) { + longitudes.push_back(turn); + } + } + std::vector latitudes { box.min.y, box.max.y }; + if (box.min.y < 0 && 0 < box.max.y) { + latitudes.push_back(0); + } + for (const double longitude : longitudes) { + for (const double latitude : latitudes) { + for (const double height : { bounds.min.z, bounds.max.z }) { + candidates.emplace_back(longitude, latitude, height); + } + } + } + } + auto points = transform_points(wgs84(), geocentric, std::move(candidates)); + if (!points) { + return Error::propagate(std::move(points), "transform coverage candidates to ECEF"); + } + radix::geometry::Aabb3d result; + for (const auto& point : *points) { + result.expand_by(point); + } + // Allow for rounding in the transformation, and for datum shifts at the heights of the bounds. + double margin = 1e-3; + if (!wgs84().IsSame(&reference) && !webmercator().IsSame(&reference)) { + margin += datum_margin(bounds.min.z, bounds.max.z); + } + result.min -= glm::dvec3(margin); + result.max += glm::dvec3(margin); + return result; +} + +Expected> ecef2srs_coverage(const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& reference) +{ + if (!finite_and_ordered(bounds)) { + return Error::fail(Error::Code::InvalidInput, "coverage of ECEF bounds requires finite, ordered bounds"); + } + const auto geocentric = ecef(); + if (reference.IsSame(&geocentric)) { + return std::vector { bounds }; + } + if (reference.IsCompound()) { + return Error::fail(Error::Code::Unsupported, "coverage of ECEF bounds in compound SRS " + friendly_name(reference)); + } + if (reference.IsGeographic() && std::abs(reference.GetAngularUnits() - glm::radians(1.0)) > 1e-12) { + return Error::fail(Error::Code::Unsupported, "coverage of ECEF bounds in non-degree geographic SRS " + friendly_name(reference)); + } + + // Latitude and height depend only on z and the distance p to the polar axis; the box covers + // [p_min, p_max] x [z_min, z_max]. Latitude is monotonic in p and z, height in p, and in z + // within each hemisphere. The extrema are at the corners, the height minimum possibly at z = 0. + // This breaks within the evolute of the meridian ellipse around the centre, which extends + // 42.7 km from the polar axis and 42.8 km from the equatorial plane; PROJ is erratic there. + // Elsewhere, PROJ's inverse is approximate deep inside the earth (metres at 1000 km radius). + const glm::dvec2 closest = glm::clamp(glm::dvec2(0), glm::dvec2(bounds.min), glm::dvec2(bounds.max)); + constexpr double evolute_extent = 43000; + if (glm::length(closest) < evolute_extent && bounds.min.z < evolute_extent && bounds.max.z > -evolute_extent) { + return Error::fail(Error::Code::Unsupported, "coverage of ECEF bounds near the centre of the earth"); + } + double p_max = 0; + for (const double x : { bounds.min.x, bounds.max.x }) { + for (const double y : { bounds.min.y, bounds.max.y }) { + p_max = std::max(p_max, std::hypot(x, y)); + } + } + std::vector heights { bounds.min.z, bounds.max.z }; + if (bounds.min.z < 0 && 0 < bounds.max.z) { + heights.push_back(0); + } + std::vector candidates; + for (const double p : { glm::length(closest), p_max }) { + for (const double z : heights) { + candidates.emplace_back(p, 0, z); + } + } + auto points = transform_points(geocentric, wgs84(), std::move(candidates)); + if (!points) { + return Error::propagate(std::move(points), "transform coverage candidates to geographic coordinates"); + } + glm::dvec2 latitude_range(std::numeric_limits::max(), std::numeric_limits::lowest()); + glm::dvec2 height_range = latitude_range; + for (const auto& point : *points) { + latitude_range = { std::min(latitude_range.x, point.y), std::max(latitude_range.y, point.y) }; + height_range = { std::min(height_range.x, point.z), std::max(height_range.y, point.z) }; + } + // Allow for rounding in the transformation. + latitude_range = glm::clamp(latitude_range + glm::dvec2(-1e-9, 1e-9), -90.0, 90.0); + height_range += glm::dvec2(-1e-3, 1e-3); + + std::vector geographic; + for (const auto& longitude_range : longitude_ranges(glm::dvec2(bounds.min), glm::dvec2(bounds.max))) { + geographic.push_back( + { { std::max(-180.0, longitude_range.x - 1e-9), latitude_range.x }, { std::min(180.0, longitude_range.y + 1e-9), latitude_range.y } }); + } + + // Web Mercator x depends only on longitude and y only on latitude, so its bounds follow exactly. + // Other SRSes get GDAL's densified boundary transformation with a guard band. + std::vector covered; + const auto wgs84_srs = wgs84(); + const auto webmercator_srs = webmercator(); + if (reference.IsSame(&wgs84_srs)) { + covered = std::move(geographic); + } else if (reference.IsSame(&webmercator_srs)) { + for (const auto& box : geographic) { + auto mercator = mercator_coverage(wgs84_srs, box); + if (!mercator) { + return Error::propagate(std::move(mercator)); + } + covered.insert(covered.end(), mercator->begin(), mercator->end()); + } + } else { + // Allow for datum shifts at the heights of the box: the margin is a distance, so the + // geographic bounds of the expanded box include all points within it. + const double margin = datum_margin(height_range.x, height_range.y); + auto expanded = ecef2srs_coverage({ bounds.min - margin, bounds.max + margin }, wgs84_srs); + if (!expanded) { + return Error::propagate(std::move(expanded)); + } + auto transform = transformation(wgs84_srs, reference); + if (!transform) { + return Error::propagate(std::move(transform)); + } + for (const auto& box : *expanded) { + auto transformed = guarded_bounds_transform(transform->get(), radix::tile::SrsBounds(box)); + if (!transformed) { + return Error::propagate(std::move(transformed), "transform coverage to " + friendly_name(reference)); + } + if (!reference.IsGeographic()) { + covered.push_back(*transformed); + continue; + } + transformed->min.y = std::max(-90.0, transformed->min.y); + transformed->max.y = std::min(90.0, transformed->max.y); + const auto split = split_at_antimeridian(*transformed); + covered.insert(covered.end(), split.begin(), split.end()); + } + } + + std::vector result; + for (const auto& box : covered) { + result.push_back({ { box.min, height_range.x }, { box.max, height_range.y } }); + } + return result; +} + +} // namespace gis::srs diff --git a/src/terrainlib/gis/srs.h b/src/terrainlib/gis/srs.h new file mode 100644 index 00000000..946e00fd --- /dev/null +++ b/src/terrainlib/gis/srs.h @@ -0,0 +1,216 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Error.h" + +namespace gis::srs { + +std::optional epsg_code(const OGRSpatialReference& srs); + +std::string friendly_name(const OGRSpatialReference& srs); + +std::unique_ptr clone(const OGRSpatialReference& srs); + +Expected> uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); + +Expected> transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); + +template +inline Expected> transform_point(OGRCoordinateTransformation* transform, glm::tvec2 p) +{ + if (!transform->Transform(1, &p.x, &p.y)) + return Error::fail(Error::Code::InvalidInput, "transform 2d point"); + return p; +} +template +inline Expected> transform_point(OGRCoordinateTransformation* transform, glm::tvec3 p) +{ + if (!transform->Transform(1, &p.x, &p.y, &p.z)) + return Error::fail(Error::Code::InvalidInput, "transform 3d point"); + return p; +} + +template +inline Expected> transform_point(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, glm::tvec2 p) +{ + auto transform = transformation(source_srs, target_srs); + if (!transform) + return Error::propagate(std::move(transform)); + return transform_point(transform->get(), p); +} +template +inline Expected> transform_point(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, glm::tvec3 p) +{ + auto transform = transformation(source_srs, target_srs); + if (!transform) + return Error::propagate(std::move(transform)); + return transform_point(transform->get(), p); +} + +template +inline Expected transform_points_inplace(OGRCoordinateTransformation* transform, Container& points) +{ + using PointType = typename Container::value_type; + using T = typename PointType::value_type; + constexpr bool is_3d = (PointType::length() == 3); + constexpr bool is_array = std::is_array_v; + + const size_t size = points.size(); + + constexpr std::size_t array_size = is_array ? points.size() : 0; + using OutputContainer = std::conditional_t, std::vector>; + + OutputContainer xs, ys; + std::conditional_t zs; + + if constexpr (!is_array) { + xs.resize(size); + ys.resize(size); + if constexpr (PointType::length() == 3) { + zs.resize(size); + } + } + + for (size_t i = 0; i < size; i++) { + xs[i] = points[i].x; + ys[i] = points[i].y; + if constexpr (is_3d) { + zs[i] = points[i].z; + } + } + + bool success; + if constexpr (is_3d) { + success = transform->Transform(size, xs.data(), ys.data(), zs.data()); + } else { + success = transform->Transform(size, xs.data(), ys.data()); + } + + if (!success) { + return Error::fail(Error::Code::InvalidInput, "transform points"); + } + + for (size_t i = 0; i < size; i++) { + if constexpr (is_3d) { + points[i] = PointType(xs[i], ys[i], zs[i]); + } else { + points[i] = PointType(xs[i], ys[i]); + } + } + return {}; +} + +template +inline Expected transform_points(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, Container points) +{ + auto transform = transformation(source_srs, target_srs); + if (!transform) + return Error::propagate(std::move(transform)); + if (auto result = transform_points_inplace(transform->get(), points); !result) + return Error::propagate(std::move(result)); + return points; +} + +// TODO: somehow integrate into a single transform_points +template +inline Expected>> transform_points_to_2d(OGRCoordinateTransformation* transform, const std::vector>& points) +{ + std::vector xs; + std::vector ys; + std::vector zs; + + xs.resize(points.size()); + ys.resize(points.size()); + zs.resize(points.size()); + + for (size_t i = 0; i < points.size(); i++) { + xs[i] = points[i].x; + ys[i] = points[i].y; + zs[i] = points[i].z; + } + + if (!transform->Transform(points.size(), xs.data(), ys.data(), zs.data())) { + return Error::fail(Error::Code::InvalidInput, "transform points to 2d"); + } + + std::vector> transformed; + transformed.resize(points.size()); + for (size_t i = 0; i < points.size(); ++i) { + transformed[i] = { xs[i], ys[i] }; + } + + return transformed; +} + +std::expected from_epsg(const uint32_t epsg); + +std::expected from_user_input(const std::string& user_input); + +inline OGRSpatialReference ecef() { return from_epsg(4978).value(); } +inline OGRSpatialReference webmercator() { return from_epsg(3857).value(); } +inline OGRSpatialReference wgs84() { return from_epsg(4326).value(); } +inline OGRSpatialReference mgi() { return from_epsg(4312).value(); } + +enum class Projection { WebMercator, Geographic }; + +/// WGS84 longitude/latitude bounds in degrees covering the given bounds. +/// Longitudes lie in [-180, 180]; coverage crossing the antimeridian is split into two bounds. +/// Reprojected coverage includes a guard band, because edge sampling can miss extrema of curved edges. +Expected> geographic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds); +/// Web Mercator bounds covering the given bounds within the polar latitude limits. +/// Returns zero (polar-only coverage), one, or two (crossing the antimeridian) bounds. +Expected> mercator_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds); +/// ECEF bounds covering the given bounds of a 2D SRS, whose z is ellipsoidal height. +/// Goes to longitude/latitude via geographic_coverage, then analytically to ECEF: +/// within a longitude/latitude/height box, every ECEF coordinate is monotonic along each +/// axis, except at longitudes -90, 0, 90 and the equator. Evaluating all combinations of +/// the box limits and the turning values inside it gives the exact ECEF bounds of that box. +/// Compound SRSes are unsupported. +Expected ecef_coverage(const OGRSpatialReference& reference, const radix::geometry::Aabb3d& bounds); +/// Bounds in a 2D SRS covering the given ECEF bounds; z is ellipsoidal height. +/// Goes analytically to longitude/latitude first: longitude from the corners of the xy +/// rectangle, latitude and height from a few points given by the distance to the polar axis +/// and z. WGS84 and Web Mercator results are quasi exact (as tight as it gets using proj); other SRSes use GDAL's densified +/// boundary transformation with a guard band. Geographic bounds are split at the +/// antimeridian, so usually there is one bounds, or two when crossing it; Web Mercator +/// returns none outside its latitude limits. +/// Fails for compound SRSes, for bounds close to the earth's centre, where PROJ is +/// erratic, and if the transformation fails. +Expected> ecef2srs_coverage(const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& reference); + +} // namespace gis::srs diff --git a/src/terrainlib/gis/srs/detail.cpp b/src/terrainlib/gis/srs/detail.cpp new file mode 100644 index 00000000..ee20229e --- /dev/null +++ b/src/terrainlib/gis/srs/detail.cpp @@ -0,0 +1,152 @@ +/* + * Adapted from GDAL's ogr/ogrct.cpp. + * Copyright (c) 2000, Frank Warmerdam + * Copyright (c) 2008-2013, Even Rouault + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * SPDX-License-Identifier: MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#include "gis/srs/detail.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "gis/srs.h" +#include "gis/webmercator.h" + +namespace gis::srs::detail { + +// Extraction of GDAL 3.10.3's TransformBounds with fixed performance: cache the +// auxiliary transformation used by the projected-output pole and seam checks. +// Geographic output and cases outside the fast path retain GDAL's implementation. +int ogr_transform_bounds(OGRCoordinateTransformation* transform, + double xmin, + double ymin, + double xmax, + double ymax, + double* out_xmin, + double* out_ymin, + double* out_xmax, + double* out_ymax, + int densify_pts) +{ + const auto fallback = [&]() { return transform->TransformBounds(xmin, ymin, xmax, ymax, out_xmin, out_ymin, out_xmax, out_ymax, densify_pts); }; + const auto* source = transform->GetSourceCS(); + const auto* target = transform->GetTargetCS(); + // The cache normalizes axis mapping. Delegate other mappings/units, identity + // transformations, and invalid arguments to preserve GDAL's own handling. + if (!source || !target || !target->IsProjected() || source->GetAxisMappingStrategy() != OAMS_TRADITIONAL_GIS_ORDER + || (!source->IsGeographic() && !source->IsProjected()) || source->IsSame(target) + || (source->IsGeographic() && (std::abs(source->GetAngularUnits() - glm::radians(1.0)) >= 1e-8 || ymax < ymin)) || densify_pts < 0 + || densify_pts > 10000 || !std::isfinite(xmin) || !std::isfinite(ymin) || !std::isfinite(xmax) || !std::isfinite(ymax)) { + return fallback(); + } + + CPLErrorReset(); + *out_xmin = *out_ymin = *out_xmax = *out_ymax = HUGE_VAL; + const int side_points = densify_pts + 1; + const double delta_x = (xmax - xmin + (source->IsGeographic() && xmax < xmin ? 360.0 : 0.0)) / side_points; + const double delta_y = (ymax - ymin) / side_points; + std::vector boundary(4 * side_points); + for (int i = 0; i < side_points; ++i) { + boundary[i] = { xmin, ymax - i * delta_y }; + boundary[i + side_points] = { xmin + i * delta_x, ymin }; + boundary[i + 2 * side_points] = { xmax, ymin + i * delta_y }; + boundary[i + 3 * side_points] = { xmax - i * delta_x, ymax }; + } + { + CPLErrorHandlerPusher quiet(CPLQuietErrorHandler); + // GDAL may accept a partially valid boundary; our batch helper requires + // every point to succeed, so let GDAL handle those exceptional cases. + if (!transform_points_inplace(transform, boundary) + || std::ranges::any_of(boundary, [](const auto& point) { return !std::isfinite(point.x) || !std::isfinite(point.y); })) { + return fallback(); + } + CPLErrorReset(); + } + const auto expand = [&](const glm::dvec2& point) { + *out_xmin = (std::min)(*out_xmin, point.x); + *out_ymin = (std::min)(*out_ymin, point.y); + *out_xmax = *out_xmax == HUGE_VAL ? point.x : (std::max)(*out_xmax, point.x); + *out_ymax = *out_ymax == HUGE_VAL ? point.y : (std::max)(*out_ymax, point.y); + }; + for (const auto& point : boundary) { + expand(point); + } + + // Preserve GDAL's best-effort checks and suppress their transient errors. + CPLErrorStateBackuper saved_error(CPLQuietErrorHandler); + std::unique_ptr geographic_target(target->CloneGeogCS()); + geographic_target->SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + auto to_source = transformation(*geographic_target, *source); + if (to_source) { + const auto include = [&](glm::dvec2 point) { + auto input = transform_point(to_source->get(), point); + if (!input || !(input->x >= xmin && input->x <= xmax && input->y >= ymin && input->y <= ymax)) { + return false; + } + auto output = transform_point(transform, *input); + if (!output) { + return false; + } + expand(*output); + return true; + }; + const double central_meridian = target->GetNormProjParm(SRS_PP_CENTRAL_MERIDIAN, 0.0); + double pole_latitude = 90; + const char* projection = target->GetAttrValue("PROJECTION"); + if (projection && EQUAL(projection, SRS_PT_MERCATOR_1SP) && central_meridian == 0) { + pole_latitude = webmercator::latitude_limit; + } + constexpr double epsilon = 1e-8; + bool includes_pole = false; + double signed_pole_latitude = 0; + for (int sign : { -1, 1 }) { + if (include({ central_meridian, sign * (pole_latitude - epsilon) })) { + includes_pole = true; + signed_pole_latitude = sign * pole_latitude; + } + } + const auto include_seam = [&](double latitude) { + for (int sign : { -1, 1 }) { + include({ std::fmod(central_meridian + sign * (180 - epsilon) + 180, 360) - 180, latitude }); + } + }; + const double latitude_of_origin = target->GetNormProjParm(SRS_PP_LATITUDE_OF_ORIGIN, 0.0); + if (central_meridian != 0) { + include_seam(latitude_of_origin); + } + if (includes_pole && latitude_of_origin != signed_pole_latitude) { + include_seam(signed_pole_latitude); + } + } + return *out_xmin != HUGE_VAL && *out_ymin != HUGE_VAL && *out_xmax != HUGE_VAL && *out_ymax != HUGE_VAL; +} + +} // namespace gis::srs::detail diff --git a/src/terrainlib/gis/srs/detail.h b/src/terrainlib/gis/srs/detail.h new file mode 100644 index 00000000..48d46835 --- /dev/null +++ b/src/terrainlib/gis/srs/detail.h @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Adam Celarek-Litofcenko +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +class OGRCoordinateTransformation; + +namespace gis::srs::detail { + +// Drop-in replacement for OGRCoordinateTransformation::TransformBounds. +int ogr_transform_bounds(OGRCoordinateTransformation* transform, + double xmin, + double ymin, + double xmax, + double ymax, + double* out_xmin, + double* out_ymin, + double* out_xmax, + double* out_ymax, + int densify_pts); + +} // namespace gis::srs::detail diff --git a/src/terrainlib/gis/vector_mask.cpp b/src/terrainlib/gis/vector_mask.cpp new file mode 100644 index 00000000..bb403cfd --- /dev/null +++ b/src/terrainlib/gis/vector_mask.cpp @@ -0,0 +1,510 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "gis/vector_mask.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cli_support/log.h" +#include "geometry/geometry.h" +#include "gis/Dataset.h" +#include "gis/srs.h" +#include "mesh/convert.h" + +namespace gis::vector_mask { +namespace { + + Expected convert_ring(const OGRLinearRing& ring, const bool is_outer) + { + std::vector points; + for (int i = 0; i < ring.getNumPoints(); ++i) { + const Point2 point(ring.getX(i), ring.getY(i)); + // Repeated vertices, including the closing one, add no shape. + if (points.empty() || point != points.back()) { + points.push_back(point); + } + } + if (points.size() > 1 && points.front() == points.back()) { + points.pop_back(); + } + if (points.size() < 3) { + return Error::fail(Error::Code::CorruptData, "mask ring has fewer than 3 distinct vertices"); + } + + Polygon2 polygon(points.begin(), points.end()); + if (!polygon.is_simple()) { + return Error::fail(Error::Code::CorruptData, "mask ring is not simple"); + } + if (is_outer != polygon.is_counterclockwise_oriented()) { + polygon.reverse_orientation(); + } + return polygon; + } + + Expected append_polygons(const OGRGeometry& geometry, MultipolygonWithHoles2* polygons) + { + const OGRwkbGeometryType geometry_type = wkbFlatten(geometry.getGeometryType()); // map 2.5d to 2d + switch (geometry_type) { + case wkbPolygon: { + const OGRPolygon& ogr_polygon = *geometry.toPolygon(); + if (ogr_polygon.IsEmpty()) { + return {}; + } + auto outer = convert_ring(*ogr_polygon.getExteriorRing(), true); + if (!outer) { + return Error::propagate(std::move(outer)); + } + PolygonWithHoles2 polygon(std::move(*outer)); + for (int i = 0; i < ogr_polygon.getNumInteriorRings(); ++i) { + auto hole = convert_ring(*ogr_polygon.getInteriorRing(i), false); + if (!hole) { + return Error::propagate(std::move(hole)); + } + polygon.add_hole(std::move(*hole)); + } + polygons->add_polygon_with_holes(std::move(polygon)); + return {}; + } + + case wkbMultiPolygon: + case wkbGeometryCollection: { + const OGRGeometryCollection& collection = *geometry.toGeometryCollection(); + for (int i = 0; i < collection.getNumGeometries(); ++i) { + if (auto appended = append_polygons(*collection.getGeometryRef(i), polygons); !appended) { + return appended; + } + } + return {}; + } + + default: + return Error::fail(Error::Code::Unsupported, fmt::format("mask geometry type {}", OGRGeometryTypeToName(geometry_type))); + } + } + + std::size_t vertex_count(const MultipolygonWithHoles2& polygons) + { + std::size_t count = 0; + for (const auto& polygon : polygons.polygons_with_holes()) { + count += polygon.outer_boundary().size(); + for (const auto& hole : polygon.holes()) { + count += hole.size(); + } + } + return count; + } + + // Tolerances in SRS units on a coarse grid over the given bounds. Each cell holds the + // tolerance at the corner where the SRS stretches the most in any direction. + class ToleranceGrid { + public: + static Expected make(const OGRSpatialReference& srs, const CGAL::Bbox_2& bounds, double tolerance_m); + + double at(const glm::dvec2& point) const + { + const glm::dvec2 cell = glm::clamp(glm::floor((point - m_origin) / m_cell_size), glm::dvec2(0), glm::dvec2(m_cell_count - 1)); + return m_tolerances[std::size_t(cell.y) * m_cell_count.x + std::size_t(cell.x)]; + } + + private: + glm::dvec2 m_origin = {}; + glm::dvec2 m_cell_size = {}; + glm::ivec2 m_cell_count = {}; + std::vector m_tolerances; + }; + + Expected ToleranceGrid::make(const OGRSpatialReference& srs, const CGAL::Bbox_2& bounds, const double tolerance_m) + { + // About one degree, or 100 km. + double nominal_cell_size = 0; + if (srs.IsGeographic()) { + nominal_cell_size = std::numbers::pi / 180 / srs.GetAngularUnits(); + } else if (srs.IsProjected()) { + nominal_cell_size = 100'000 / srs.GetLinearUnits(); + } + if (!(nominal_cell_size > 0)) { + return Error::fail(Error::Code::Unsupported, "express the mask tolerance in " + srs::friendly_name(srs)); + } + + ToleranceGrid grid; + grid.m_origin = { bounds.xmin(), bounds.ymin() }; + const glm::dvec2 extent = { bounds.xmax() - bounds.xmin(), bounds.ymax() - bounds.ymin() }; + for (int axis = 0; axis < 2; ++axis) { + grid.m_cell_count[axis] = std::clamp(int(std::ceil(extent[axis] / nominal_cell_size)), 1, 1024); + grid.m_cell_size[axis] = extent[axis] > 0 ? extent[axis] / grid.m_cell_count[axis] : nominal_cell_size; + } + + // Per corner, the corner itself and one step along x and y. + const glm::ivec2 corner_count = grid.m_cell_count + 1; + const glm::dvec2 centre = grid.m_origin + extent / 2.0; + const double step = nominal_cell_size * 1e-3; + std::vector samples; + samples.reserve(3 * std::size_t(corner_count.x) * corner_count.y); + for (int y = 0; y < corner_count.y; ++y) { + for (int x = 0; x < corner_count.x; ++x) { + const glm::dvec2 corner = grid.m_origin + glm::dvec2(x, y) * grid.m_cell_size; + // Step towards the centre, to stay within the SRS's domain, e.g., at the poles. + const glm::dvec2 offset = { corner.x < centre.x ? step : -step, corner.y < centre.y ? step : -step }; + samples.emplace_back(corner, 0.0); + samples.emplace_back(corner.x + offset.x, corner.y, 0.0); + samples.emplace_back(corner.x, corner.y + offset.y, 0.0); + } + } + auto transformation = srs::transformation(srs, srs::ecef()); + if (!transformation) { + return Error::propagate(std::move(transformation)); + } + if (auto transformed = srs::transform_points_inplace(transformation->get(), samples); !transformed) { + return Error::propagate(std::move(transformed), "measure metres per SRS unit"); + } + + std::vector stretches(samples.size() / 3); + for (std::size_t i = 0; i < stretches.size(); ++i) { + const glm::dvec3 along_x = (samples[3 * i + 1] - samples[3 * i]) / step; + const glm::dvec3 along_y = (samples[3 * i + 2] - samples[3 * i]) / step; + // Largest singular value of the Jacobian: the most metres per unit in any direction. + const double xx = glm::dot(along_x, along_x); + const double yy = glm::dot(along_y, along_y); + const double xy = glm::dot(along_x, along_y); + stretches[i] = std::sqrt((xx + yy) / 2 + std::sqrt((xx - yy) * (xx - yy) / 4 + xy * xy)); + } + + grid.m_tolerances.reserve(std::size_t(grid.m_cell_count.x) * grid.m_cell_count.y); + for (int y = 0; y < grid.m_cell_count.y; ++y) { + for (int x = 0; x < grid.m_cell_count.x; ++x) { + const auto stretch = [&](int corner_x, int corner_y) { return stretches[std::size_t(corner_y) * corner_count.x + corner_x]; }; + const double max_stretch = std::max({ stretch(x, y), stretch(x + 1, y), stretch(x, y + 1), stretch(x + 1, y + 1) }); + grid.m_tolerances.push_back(tolerance_m / max_stretch); + } + } + return grid; + } + + using SimplificationKernel = cgal::kernel::epick::Kernel; + using SimplificationTriangulation = CGAL::Constrained_triangulation_plus_2, + CGAL::Constrained_triangulation_face_base_2>, + CGAL::Exact_predicates_tag>>; + + // Cost of removing a vertex: the largest deviation of the original points from the new edge, + // relative to their local tolerance. + struct RelativeDeviationCost { + const ToleranceGrid* tolerances; + + template + std::optional operator()(const CGAL::Constrained_triangulation_plus_2&, + typename CGAL::Constrained_triangulation_plus_2::Vertices_in_constraint_iterator vertex) const + { + using PointsInConstraintIterator = typename CGAL::Constrained_triangulation_plus_2::Points_in_constraint_iterator; + const auto previous = std::prev(vertex); + const auto next = std::next(vertex); + const radix::geometry::Edge<2, double> edge = { convert::to_glm_point((*previous)->point()), convert::to_glm_point((*next)->point()) }; + + double cost = 0; + PointsInConstraintIterator point(previous); + const PointsInConstraintIterator end(next); + for (++point; point != end; ++point) { + const glm::dvec2 original = convert::to_glm_point(*point); + cost = std::max(cost, geometry::distance_to_segment(original, edge) / tolerances->at(original)); + } + return cost; + } + }; + + SimplificationTriangulation::Constraint_id insert_ring(SimplificationTriangulation* triangulation, const Polygon2& ring) + { + std::vector points; + points.reserve(ring.size()); + for (const Point2& point : ring) { + points.push_back(convert::to_cgal_point(convert::to_glm_point(point))); + } + return triangulation->insert_constraint(points.begin(), points.end(), true); + } + + Polygon2 extract_ring(const SimplificationTriangulation& triangulation, const SimplificationTriangulation::Constraint_id id) + { + std::vector points; + for (auto vertex = triangulation.vertices_in_constraint_begin(id); vertex != triangulation.vertices_in_constraint_end(id); ++vertex) { + points.push_back(convert::to_cgal_point(convert::to_glm_point((*vertex)->point()))); + } + // Closed constraints repeat their first vertex. + points.pop_back(); + return Polygon2(points.begin(), points.end()); + } + + // A ring during reprojection, with its vertices in both SRSs. + struct ReprojectedRing { + std::vector source; + std::vector target; + // Whether the edge from each vertex to the next is within the tolerance. + std::vector accurate; + }; + + Expected> reproject(OGRCoordinateTransformation* transformation, std::vector points) + { + if (auto transformed = srs::transform_points_inplace(transformation, points); !transformed) { + return Error::propagate(std::move(transformed), "reproject mask vertices"); + } + return points; + } + + // Splits edges whose midpoint deviates by more than the tolerance until all edges are accurate. + // Each round reprojects all new midpoints at once. + Expected subdivide(OGRCoordinateTransformation* transformation, const ToleranceGrid& tolerances, std::vector* rings) + { + constexpr int max_rounds = 32; + for (int round = 0;; ++round) { + std::vector midpoints; + for (const ReprojectedRing& ring : *rings) { + for (std::size_t i = 0; i < ring.source.size(); ++i) { + if (!ring.accurate[i]) { + midpoints.push_back((ring.source[i] + ring.source[(i + 1) % ring.source.size()]) / 2.0); + } + } + } + if (midpoints.empty()) { + return {}; + } + if (round == max_rounds) { + return Error::fail(Error::Code::InvalidInput, "reprojected mask edges do not converge to the tolerance"); + } + auto reprojected_midpoints = reproject(transformation, midpoints); + if (!reprojected_midpoints) { + return Error::propagate(std::move(reprojected_midpoints)); + } + + std::size_t midpoint = 0; + for (ReprojectedRing& ring : *rings) { + ReprojectedRing refined; + for (std::size_t i = 0; i < ring.source.size(); ++i) { + refined.source.push_back(ring.source[i]); + refined.target.push_back(ring.target[i]); + if (ring.accurate[i]) { + refined.accurate.push_back(true); + continue; + } + const glm::dvec2& target_midpoint = (*reprojected_midpoints)[midpoint]; + const radix::geometry::Edge<2, double> edge = { ring.target[i], ring.target[(i + 1) % ring.target.size()] }; + if (geometry::distance_to_segment(target_midpoint, edge) <= tolerances.at(target_midpoint)) { + refined.accurate.push_back(true); + } else { + refined.accurate.push_back(false); + refined.source.push_back(midpoints[midpoint]); + refined.target.push_back(target_midpoint); + refined.accurate.push_back(false); + } + ++midpoint; + } + ring = std::move(refined); + } + } + } + + Expected to_polygon(const ReprojectedRing& ring, const bool is_outer) + { + Polygon2 polygon; + for (const glm::dvec2& point : ring.target) { + polygon.push_back(convert::to_cgal_point(point)); + } + if (!polygon.is_simple()) { + return Error::fail(Error::Code::CorruptData, "reprojected mask ring is not simple"); + } + if (is_outer != polygon.is_counterclockwise_oriented()) { + polygon.reverse_orientation(); + } + return polygon; + } + +} // namespace + +Expected load(Dataset& dataset) +{ + GDALDataset* gdal_dataset = dataset.gdalDataset(); + + OGRSpatialReference srs; + if (auto dataset_srs = dataset.srs()) { + srs = std::move(*dataset_srs); + } else { + LOG_WARN("Mask {} does not reference an SRS, assuming WGS84", dataset.name()); + srs = srs::wgs84(); + } + // All layers are read in the same SRS. + for (int i = 0; i < gdal_dataset->GetLayerCount(); ++i) { + OGRLayer* layer = gdal_dataset->GetLayer(i); + if (const OGRSpatialReference* layer_srs = layer ? layer->GetSpatialRef() : nullptr) { + OGRSpatialReference normalized = *layer_srs; + normalized.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + if (!srs.IsSame(&normalized)) { + return Error::fail(Error::Code::Unsupported, "mask " + dataset.name() + " has layers with different SRSs"); + } + } + } + + MultipolygonWithHoles2 polygons; + CPLErrorReset(); + for (auto&& feature_layer_pair : gdal_dataset->GetFeatures()) { + const OGRFeature& feature = *feature_layer_pair.feature; + const OGRGeometry* geometry = feature.GetGeometryRef(); + if (!geometry) { + return Error::fail(Error::Code::CorruptData, fmt::format("mask feature {} has no geometry", feature.GetFID())); + } + if (auto appended = append_polygons(*geometry, &polygons); !appended) { + return Error::propagate(std::move(appended), fmt::format("load mask feature {}", feature.GetFID())); + } + } + if (CPLGetLastErrorType() >= CE_Failure) { + return Error::fail(Error::Code::Io, "read features of mask " + dataset.name() + ": " + CPLGetLastErrorMsg()); + } + if (polygons.is_empty()) { + return Error::fail(Error::Code::InvalidInput, "mask " + dataset.name() + " contains no polygons"); + } + + return ReferencedPolygonMask { .polygons = std::move(polygons), .srs = std::move(srs) }; +} + +Expected transform(const Expected& polygons, const OGRSpatialReference& target_srs, const double tolerance_m) +{ + ASSERT(tolerance_m > 0); + if (!polygons) { + return std::unexpected(polygons.error()); + } + // Coordinates are always in the traditional GIS order, as with load. + OGRSpatialReference normalized_target_srs = target_srs; + normalized_target_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + if (polygons->srs.IsSame(&normalized_target_srs)) { + return *polygons; + } + auto transformation = srs::transformation(polygons->srs, normalized_target_srs); + if (!transformation) { + return Error::propagate(std::move(transformation), "reproject mask"); + } + + std::vector rings; + std::vector vertices; + const auto add_ring = [&](const Polygon2& polygon) { + ReprojectedRing& ring = rings.emplace_back(); + for (const Point2& point : polygon) { + ring.source.push_back(convert::to_glm_point(point)); + } + ring.accurate.resize(ring.source.size(), false); + vertices.insert(vertices.end(), ring.source.begin(), ring.source.end()); + }; + for (const auto& polygon : polygons->polygons.polygons_with_holes()) { + add_ring(polygon.outer_boundary()); + for (const auto& hole : polygon.holes()) { + add_ring(hole); + } + } + auto reprojected_vertices = reproject(transformation->get(), std::move(vertices)); + if (!reprojected_vertices) { + return Error::propagate(std::move(reprojected_vertices)); + } + CGAL::Bbox_2 target_bounds; + auto reprojected_vertex = reprojected_vertices->begin(); + for (ReprojectedRing& ring : rings) { + ring.target.assign(reprojected_vertex, reprojected_vertex + std::ptrdiff_t(ring.source.size())); + reprojected_vertex += std::ptrdiff_t(ring.source.size()); + for (const glm::dvec2& point : ring.target) { + target_bounds += CGAL::Bbox_2(point.x, point.y, point.x, point.y); + } + } + + auto tolerances = ToleranceGrid::make(normalized_target_srs, target_bounds, tolerance_m); + if (!tolerances) { + return Error::propagate(std::move(tolerances), "reproject mask"); + } + if (auto subdivided = subdivide(transformation->get(), *tolerances, &rings); !subdivided) { + return Error::propagate(std::move(subdivided), "reproject mask"); + } + + MultipolygonWithHoles2 result; + auto ring = rings.cbegin(); + for (const auto& polygon : polygons->polygons.polygons_with_holes()) { + auto outer = to_polygon(*ring++, true); + if (!outer) { + return Error::propagate(std::move(outer), "reproject mask"); + } + PolygonWithHoles2 reprojected(std::move(*outer)); + for (std::size_t i = 0; i < polygon.number_of_holes(); ++i) { + auto hole = to_polygon(*ring++, false); + if (!hole) { + return Error::propagate(std::move(hole), "reproject mask"); + } + reprojected.add_hole(std::move(*hole)); + } + result.add_polygon_with_holes(std::move(reprojected)); + } + + LOG_DEBUG("Reprojected mask from {} to {} vertices", vertex_count(polygons->polygons), vertex_count(result)); + return ReferencedPolygonMask { .polygons = std::move(result), .srs = std::move(normalized_target_srs) }; +} + +Expected simplify(const Expected& polygons, const double tolerance_m) +{ + ASSERT(tolerance_m > 0); + if (!polygons) { + return std::unexpected(polygons.error()); + } + auto tolerances = ToleranceGrid::make(polygons->srs, polygons->polygons.bbox(), tolerance_m); + if (!tolerances) { + return Error::propagate(std::move(tolerances), "simplify mask"); + } + + // All rings are constraints of one triangulation, which keeps them from crossing each other. + SimplificationTriangulation triangulation; + std::vector> ring_ids; + for (const auto& polygon : polygons->polygons.polygons_with_holes()) { + auto& ids = ring_ids.emplace_back(); + ids.push_back(insert_ring(&triangulation, polygon.outer_boundary())); + for (const auto& hole : polygon.holes()) { + ids.push_back(insert_ring(&triangulation, hole)); + } + } + CGAL::Polyline_simplification_2::simplify( + triangulation, RelativeDeviationCost { &*tolerances }, CGAL::Polyline_simplification_2::Stop_above_cost_threshold(1)); + + MultipolygonWithHoles2 result; + for (const auto& ids : ring_ids) { + PolygonWithHoles2 polygon(extract_ring(triangulation, ids.front())); + for (auto id = std::next(ids.begin()); id != ids.end(); ++id) { + polygon.add_hole(extract_ring(triangulation, *id)); + } + result.add_polygon_with_holes(std::move(polygon)); + } + + LOG_DEBUG("Simplified mask with {} m tolerance from {} to {} vertices", tolerance_m, vertex_count(polygons->polygons), vertex_count(result)); + return ReferencedPolygonMask { .polygons = std::move(result), .srs = polygons->srs }; +} + +} // namespace gis::vector_mask diff --git a/src/terrainlib/gis/vector_mask.h b/src/terrainlib/gis/vector_mask.h new file mode 100644 index 00000000..d67dbf99 --- /dev/null +++ b/src/terrainlib/gis/vector_mask.h @@ -0,0 +1,63 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include "mesh/cgal.h" + +namespace gis { +class Dataset; +} + +namespace gis::vector_mask { + +using Kernel = cgal::kernel::epeck::Kernel; +using Point2 = Kernel::Point_2; +using Polygon2 = CGAL::Polygon_2; +using PolygonWithHoles2 = CGAL::Polygon_with_holes_2; +using PolygonSet2 = CGAL::Polygon_set_2; +using MultipolygonWithHoles2 = CGAL::Multipolygon_with_holes_2; + +struct ReferencedPolygonMask { + MultipolygonWithHoles2 polygons; + OGRSpatialReference srs; +}; + +/// Loads the polygons of all features in all layers, without simplification or reprojection. +/// Outer boundaries are counterclockwise, holes clockwise. A dataset without a spatial +/// reference is assumed to be WGS84. Fails for layers with different spatial references, +/// non-polygonal geometries, rings that are not simple, read failures and empty masks. +Expected load(Dataset& dataset); + +/// Reprojects the polygons into target_srs. Straight edges in the source SRS are curved in the +/// target SRS; edges are subdivided until the result deviates from the curved edges by at most +/// tolerance_m metres. Fails if a ring is no longer simple after reprojection. +/// An error passed in is returned unchanged. +Expected transform(const Expected& polygons, const OGRSpatialReference& target_srs, double tolerance_m); + +/// Removes vertices while the result deviates from the original by less than tolerance_m metres. +/// Preserves the topology across all rings: rings neither start to intersect nor change their +/// nesting. An error passed in is returned unchanged. +Expected simplify(const Expected& polygons, double tolerance_m); + +} // namespace gis::vector_mask diff --git a/src/tile_builder/TopDownTiler.cpp b/src/terrainlib/gis/webmercator.cpp similarity index 55% rename from src/tile_builder/TopDownTiler.cpp rename to src/terrainlib/gis/webmercator.cpp index ca5ead02..01664b83 100644 --- a/src/tile_builder/TopDownTiler.cpp +++ b/src/terrainlib/gis/webmercator.cpp @@ -1,6 +1,6 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko * * 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 @@ -16,22 +16,18 @@ * along with this program. If not, see . *****************************************************************************/ -#include "TopDownTiler.h" +#include "gis/webmercator.h" -TopDownTiler::TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border) - : Tiler(grid, bounds, border) -{ -} +#include -std::vector TopDownTiler::generateTiles(const radix::tile::Id& parent_id) const -{ - 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); - if (intersect(bounds(), t.srsBounds)) - tiles.push_back(std::move(t)); - } +namespace gis::webmercator { - return tiles; +radix::tile::SrsBounds tile_bounds(const radix::tile::Id& key) +{ + // ldexp avoids both 1u << 32 and tile-coordinate * tile-dimension overflow. + const double side = std::ldexp(2 * half_extent, -int(key.zoom_level)); + return { { -half_extent + double(key.coords.x) * side, half_extent - (double(key.coords.y) + 1) * side }, + { -half_extent + (double(key.coords.x) + 1) * side, half_extent - double(key.coords.y) * side } }; } + +} // namespace gis::webmercator diff --git a/src/tile_builder/TileHeightsGenerator.h b/src/terrainlib/gis/webmercator.h similarity index 63% rename from src/tile_builder/TileHeightsGenerator.h rename to src/terrainlib/gis/webmercator.h index 703a1227..f0326de3 100644 --- a/src/tile_builder/TileHeightsGenerator.h +++ b/src/terrainlib/gis/webmercator.h @@ -1,6 +1,6 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko * * 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 @@ -18,20 +18,15 @@ #pragma once -#include -#include - -#include "ctb/Grid.hpp" #include +namespace gis::webmercator { + +/// Half the side length of the square Web Mercator world in metres. +constexpr double half_extent = 20037508.342789244; +/// Latitude in degrees at which Web Mercator y reaches half_extent. +constexpr double latitude_limit = 85.0511287798066; +/// Web Mercator bounds of an XYZ tile; valid up to zoom level 32. +radix::tile::SrsBounds tile_bounds(const radix::tile::Id& key); -class TileHeightsGenerator -{ - std::string m_input_data_path; - ctb::Grid::Srs m_srs; - radix::tile::Border m_border; - std::filesystem::path m_output_path; -public: - 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; -}; +} // namespace gis::webmercator diff --git a/src/terrainlib/glm_utils.h b/src/terrainlib/glm_utils.h index 2f6d4b1a..5b682bf1 100644 --- a/src/terrainlib/glm_utils.h +++ b/src/terrainlib/glm_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/hash_utils.h b/src/terrainlib/hash_utils.h index 80426620..031c5c94 100644 --- a/src/terrainlib/hash_utils.h +++ b/src/terrainlib/hash_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 namespace hash { diff --git a/src/terrainlib/init.cpp b/src/terrainlib/init.cpp deleted file mode 100644 index 41514ac3..00000000 --- a/src/terrainlib/init.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include - -#include - -#include "init.h" -#include "log.h" - -std::once_flag g_gdal_initialized_once_flag; - -inline void GdalErrorHandler(CPLErr eErrClass, int err_no, const char *msg) { - spdlog::level::level_enum level; - switch (eErrClass) { - case CE_None: - return; - case CE_Debug: - level = spdlog::level::debug; - break; - case CE_Warning: - level = spdlog::level::warn; - break; - case CE_Failure: - level = spdlog::level::err; - break; - case CE_Fatal: - level = spdlog::level::critical; - break; - default: - level = spdlog::level::err; - LOG_WARN("Unknown GDAL error class: {}", (int)eErrClass); - break; - } - - Log::get_logger().get()->log(level, "GDAL({}): {}", err_no, msg); -} - -void initialize_gdal_once() { - std::call_once(g_gdal_initialized_once_flag, []() { - LOG_DEBUG("calling GDALAllRegister..."); - CPLSetErrorHandler(GdalErrorHandler); - GDALAllRegister(); - }); -} diff --git a/src/terrainlib/init.h b/src/terrainlib/init.h deleted file mode 100644 index 960a324a..00000000 --- a/src/terrainlib/init.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -void initialize_gdal_once(); diff --git a/src/terrainlib/io/byte.cpp b/src/terrainlib/io/byte.cpp new file mode 100644 index 00000000..e64bf354 --- /dev/null +++ b/src/terrainlib/io/byte.cpp @@ -0,0 +1,117 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" +#include "io/utils.h" +#include "cli_support/log.h" + +namespace io::byte { + +Expected write(const span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) +{ + LOG_TRACE("Writing bytes to path {}", path); + + if (mode == WriteMode::Replace) { + auto temporary_path = path; + temporary_path += ".tmp"; + if (auto written = write(bytes, temporary_path, WriteMode::Overwrite, make_dirs); !written) { + return written; + } + if (auto synced = utils::sync_file(temporary_path); !synced) { + return synced; + } + std::error_code error; + std::filesystem::rename(temporary_path, path, error); + if (error) { + return Error::fail(Error::Code::Io, "replace file", temporary_path, path, error); + } + return {}; + } + + if (make_dirs) { + auto directories = utils::create_parent_directories(path); + if (!directories) { + return Error::propagate(std::move(directories), "write bytes"); + } + } + + std::ofstream file(path, std::ios::binary | (mode == WriteMode::CreateNew ? std::ios::noreplace : std::ios::trunc)); + if (!file.is_open()) { + std::error_code error; + if (mode == WriteMode::CreateNew && std::filesystem::exists(std::filesystem::symlink_status(path, error))) { + return Error::fail(Error::Code::AlreadyExists, "create file", path); + } + return Error::fail(Error::Code::Io, "open file for writing", path); + } + + file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!file.good()) { + return Error::fail(Error::Code::Io, "write bytes to", path); + } + + file.flush(); + if (!file.good()) { + return Error::fail(Error::Code::Io, "flush bytes to", path); + } + file.close(); + if (file.fail()) { + return Error::fail(Error::Code::Io, "close file after writing", path); + } + + return {}; +} + +Expected read(const std::filesystem::path& path, const std::size_t max_size) +{ + LOG_TRACE("Reading bytes from path {}", path); + + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file.is_open()) { + std::error_code existence_error; + const bool exists = std::filesystem::exists(path, existence_error); + if (!existence_error && !exists) { + return Error::fail(Error::Code::NotFound, "open file for reading", path); + } + return existence_error + ? Error::fail(Error::Code::Io, "check existence of", path, existence_error) + : Error::fail(Error::Code::Io, "open file for reading", path); + } + + const std::streamsize size = file.tellg(); + if (size < 0) { + return Error::fail(Error::Code::Io, "determine size of", path); + } + + const auto read_size = static_cast(std::min(static_cast(size), max_size)); + vector buffer(static_cast(read_size)); + file.seekg(0); + file.read(reinterpret_cast(buffer.data()), read_size); + + if (!file.good()) { + return Error::fail(Error::Code::Io, "read bytes from", path); + } + + return buffer; +} + +} diff --git a/src/terrainlib/io/byte.h b/src/terrainlib/io/byte.h new file mode 100644 index 00000000..e3065488 --- /dev/null +++ b/src/terrainlib/io/byte.h @@ -0,0 +1,46 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Error.h" + +namespace io::byte { + +using vector = std::vector; +using span = std::span; + +// Replace writes and syncs .tmp, then renames it over path, so path holds the old or the +// new bytes even after a crash, and hard links to the old file keep the old bytes. Replace writes +// of the same path must not run concurrently. +enum class WriteMode { Overwrite, CreateNew, Replace }; + +Expected write(span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs = true); +// Reads the whole file, or its first max_size bytes if it is longer. +Expected read(const std::filesystem::path& path, std::size_t max_size = std::numeric_limits::max()); + +} diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp deleted file mode 100644 index 7c81c557..00000000 --- a/src/terrainlib/io/bytes.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include -#include - -#include "io/bytes.h" -#include "io/utils.h" -#include "log.h" - -namespace io { - -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) { - const std::error_code error = utils::create_parent_directories(path); - if (error) { - return Error::fail(Error::Code::Io, "create parent directories for", path, error); - } - } - - std::ofstream file(path, std::ios::binary); - if (!file.is_open()) { - return Error::fail(Error::Code::Io, "open file for writing", path); - } - - file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - if (!file.good()) { - return Error::fail(Error::Code::Io, "write bytes to", path); - } - - return {}; -} - -Expected> 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()) { - std::error_code existence_error; - const bool exists = std::filesystem::exists(path, existence_error); - if (!existence_error && !exists) { - return Error::fail(Error::Code::NotFound, "open file for reading", path); - } - return existence_error - ? Error::fail(Error::Code::Io, "check existence of", path, existence_error) - : Error::fail(Error::Code::Io, "open file for reading", path); - } - - const std::streamsize size = file.tellg(); - if (size < 0) { - return Error::fail(Error::Code::Io, "determine size of", path); - } - - std::vector buffer(static_cast(size)); - file.seekg(0); - file.read(reinterpret_cast(buffer.data()), size); - - if (!file.good()) { - return Error::fail(Error::Code::Io, "read bytes from", path); - } - - return buffer; -} - -} diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/bytes.h deleted file mode 100644 index 4d1af334..00000000 --- a/src/terrainlib/io/bytes.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -#include "Error.h" - -namespace io { - -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs = true); -Expected> read_bytes_from_path(const std::filesystem::path& path); - -} diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp index ee2154fc..8cd528d2 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.cpp @@ -1,11 +1,31 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 . + *****************************************************************************/ + #define ZSTD_STATIC_LINKING_ONLY #include #include #include "io/compression.h" +#include +#include + #include -#include #include #include #include @@ -15,86 +35,25 @@ 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; - } - - Expected validate_algorithms(const CompressionAlgorithm compression_algorithm, const ChecksumAlgorithm checksum_algorithm) - { - switch (checksum_algorithm) { - case ChecksumAlgorithm::None: - case ChecksumAlgorithm::HandledByCompressionLib: - case ChecksumAlgorithm::Crc32c: - break; - default: - return Error::fail(Error::Code::Unsupported, "unsupported checksum algorithm " + std::to_string(static_cast(checksum_algorithm))); - } - - switch (compression_algorithm) { - case CompressionAlgorithm::None: - case CompressionAlgorithm::ZstdBestCompressionWithChecksum: - case CompressionAlgorithm::ZstdDefaultCompressionWithChecksum: - break; - default: - return Error::fail(Error::Code::Unsupported, "unsupported compression algorithm " + std::to_string(static_cast(compression_algorithm))); - } - - const bool no_compression = compression_algorithm == CompressionAlgorithm::None - && (checksum_algorithm == ChecksumAlgorithm::None || checksum_algorithm == ChecksumAlgorithm::Crc32c); - const bool zstd_with_checksum = (compression_algorithm == CompressionAlgorithm::ZstdBestCompressionWithChecksum - || compression_algorithm == CompressionAlgorithm::ZstdDefaultCompressionWithChecksum) - && (checksum_algorithm == ChecksumAlgorithm::HandledByCompressionLib || checksum_algorithm == ChecksumAlgorithm::Crc32c); - if (!no_compression && !zstd_with_checksum) { - return Error::fail(Error::Code::InvalidInput, "invalid compression and checksum algorithm combination"); - } - - return {}; - } - } // namespace -Expected compress_with_checksum( - const Bytes& uncompressed_data, const CompressionAlgorithm compression_algorithm, const ChecksumAlgorithm checksum_algorithm) +Expected compress(const byte::span uncompressed_data, const CompressionAlgorithm compression_algorithm) { - if (auto validation = validate_algorithms(compression_algorithm, checksum_algorithm); !validation) { - return Error::propagate(std::move(validation), "validate compression algorithms"); - } if (uncompressed_data.size() > default_max_decompressed_size) { return Error::fail(Error::Code::ResourceExhausted, "uncompressed data exceeds the compression size limit"); } - const std::string checksum = checksum_algorithm == ChecksumAlgorithm::Crc32c ? crc32c_checksum(uncompressed_data) : std::string {}; - - if (compression_algorithm == CompressionAlgorithm::None) { - return CompressedData { uncompressed_data, checksum }; + int compression_level = 0; + switch (compression_algorithm) { + case CompressionAlgorithm::None: + return byte::vector(uncompressed_data.begin(), uncompressed_data.end()); + case CompressionAlgorithm::ZstdBestCompression: + compression_level = ZSTD_maxCLevel(); + break; + case CompressionAlgorithm::ZstdDefaultCompression: + break; + default: + PANIC("unsupported compression algorithm", static_cast(compression_algorithm)); } CompressionContext context { ZSTD_createCCtx(), &ZSTD_freeCCtx }; @@ -102,14 +61,12 @@ Expected compress_with_checksum( return Error::fail(Error::Code::Internal, "failed to create the Zstandard compression context"); } - const int compression_level = compression_algorithm == CompressionAlgorithm::ZstdBestCompressionWithChecksum ? ZSTD_maxCLevel() : 0; - if (ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_compressionLevel, compression_level)) - || ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_checksumFlag, 1))) { + if (ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_compressionLevel, compression_level))) { return Error::fail(Error::Code::Internal, "failed to configure the Zstandard compression context"); } const std::size_t capacity = ZSTD_compressBound(uncompressed_data.size()); - Bytes compressed_data(capacity); + byte::vector 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)) { @@ -117,44 +74,28 @@ Expected compress_with_checksum( } compressed_data.resize(compressed_size); - return CompressedData { std::move(compressed_data), checksum }; + return compressed_data; } -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) +Expected decompress( + const byte::span compressed_data, const CompressionAlgorithm compression_algorithm, const std::size_t max_decompressed_size) { - if (auto validation = validate_algorithms(compression_algorithm, checksum_algorithm); !validation) { - return Error::propagate(std::move(validation), "validate decompression algorithms"); - } - if (checksum_algorithm != ChecksumAlgorithm::Crc32c && !checksum.empty()) { - return Error::fail(Error::Code::InvalidInput, "a checksum was supplied for an algorithm that does not accept one"); - } - 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 Error::fail(Error::Code::CorruptData, "invalid CRC32C checksum encoding"); - } + ASSERT(magic_enum::enum_contains(compression_algorithm), "unsupported compression algorithm", static_cast(compression_algorithm)); const std::size_t effective_max_decompressed_size = std::min(max_decompressed_size, default_max_decompressed_size); - Bytes uncompressed_data; + byte::vector uncompressed_data; if (compression_algorithm == CompressionAlgorithm::None) { if (compressed_data.size() > effective_max_decompressed_size) { return Error::fail(Error::Code::ResourceExhausted, "decompressed data exceeds the configured size limit"); } - uncompressed_data = compressed_data; + uncompressed_data.assign(compressed_data.begin(), compressed_data.end()); } 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 Error::fail(Error::Code::CorruptData, "invalid or incomplete Zstandard frame header"); } - if (frame_header.checksumFlag == 0) { - return Error::fail(Error::Code::CorruptData, "Zstandard frame does not contain the required checksum"); - } const unsigned long long frame_content_size = ZSTD_getFrameContentSize(compressed_data.data(), compressed_data.size()); if (frame_content_size == ZSTD_CONTENTSIZE_ERROR @@ -187,9 +128,6 @@ Expected checked_decompress(const Bytes& compressed_data, uncompressed_data.resize(decompressed_size); } - if (checksum_algorithm == ChecksumAlgorithm::Crc32c && crc32c_checksum(uncompressed_data) != checksum) { - return Error::fail(Error::Code::CorruptData, "CRC32C checksum mismatch"); - } return uncompressed_data; } diff --git a/src/terrainlib/io/compression.h b/src/terrainlib/io/compression.h index 5c5b45a2..40090d17 100644 --- a/src/terrainlib/io/compression.h +++ b/src/terrainlib/io/compression.h @@ -1,46 +1,48 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include #include "Error.h" +#include "io/byte.h" 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, - Crc32c, -}; - enum class CompressionAlgorithm : std::uint8_t { None, - ZstdBestCompressionWithChecksum, - ZstdDefaultCompressionWithChecksum, -}; - -struct CompressedData { - Bytes compressed_data; - std::string checksum; + ZstdBestCompression, + ZstdDefaultCompression, }; -Expected compress_with_checksum( - const Bytes& uncompressed_data, CompressionAlgorithm compression_algorithm, ChecksumAlgorithm checksum_algorithm); +// The algorithm must be an enumerator. Zstandard frames are written without their frame checksum. +Expected compress(byte::span uncompressed_data, CompressionAlgorithm compression_algorithm); -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); +// The algorithm must be an enumerator. Zstandard frames are accepted with or without their frame checksum. +Expected decompress( + byte::span compressed_data, CompressionAlgorithm compression_algorithm, std::size_t max_decompressed_size = default_max_decompressed_size); } // namespace io::envelope diff --git a/src/terrainlib/io/conversion.h b/src/terrainlib/io/conversion.h index 620ba9f8..634b11d5 100644 --- a/src/terrainlib/io/conversion.h +++ b/src/terrainlib/io/conversion.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -162,7 +180,8 @@ Expected> to_raster(const cv::Mat& source) } catch (const std::bad_alloc&) { return detail::allocation_error(); } catch (const cv::Exception& error) { - return Error::fail(Error::Code::Internal, "OpenCV raster conversion failed: " + error.msg); + return Error::fail( + error.code == cv::Error::StsNoMem ? Error::Code::ResourceExhausted : Error::Code::Internal, "OpenCV raster conversion failed: " + error.msg); } } } @@ -188,7 +207,8 @@ Expected to_mat(const radix::Raster& source) } catch (const std::bad_alloc&) { return detail::allocation_error(); } catch (const cv::Exception& error) { - return Error::fail(Error::Code::Internal, "OpenCV raster conversion failed: " + error.msg); + return Error::fail( + error.code == cv::Error::StsNoMem ? Error::Code::ResourceExhausted : Error::Code::Internal, "OpenCV raster conversion failed: " + error.msg); } } } diff --git a/src/terrainlib/io/envelope.cpp b/src/terrainlib/io/envelope.cpp new file mode 100644 index 00000000..75ceb7b2 --- /dev/null +++ b/src/terrainlib/io/envelope.cpp @@ -0,0 +1,65 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/envelope.h" + +#include + +namespace io::envelope { + +Expected
read_header(const std::filesystem::path& path) +{ + auto file_bytes = ::io::byte::read(path, max_header_size); + if (!file_bytes) { + return Error::propagate(std::move(file_bytes), "reading envelope file \"" + path.string() + "\""); + } + // zpp_bits resizes containers before checking their size against the input, so the limit bounds allocations. + Header header {}; + zpp::bits::in input(*file_bytes, zpp::bits::alloc_limit()); + if (const zpp::bits::errc result = input(header); zpp::bits::failure(result)) { + return Error::fail(Error::Code::CorruptData, "deserialize envelope header of \"" + path.string() + "\"", std::make_error_code(result.code)); + } + if (auto valid = detail::validate_header(header); !valid) { + return Error::propagate(std::move(valid), "decoding envelope header of \"" + path.string() + "\""); + } + return header; +} + +namespace detail { + + Expected validate_header(const Header& header) + { + if (header.magic != magic) { + return Error::fail(Error::Code::CorruptData, "invalid envelope magic"); + } + if (header.class_name.size() > max_class_name_size || header.hash.size() > max_hash_size) { + return Error::fail(Error::Code::CorruptData, "envelope header exceeds its size limits"); + } + if (!magic_enum::enum_contains(header.hash_algorithm)) { + return Error::fail(Error::Code::Unsupported, "unsupported hash algorithm " + std::to_string(static_cast(header.hash_algorithm))); + } + if (!magic_enum::enum_contains(header.compression_algorithm)) { + return Error::fail( + Error::Code::Unsupported, "unsupported compression algorithm " + std::to_string(static_cast(header.compression_algorithm))); + } + return {}; + } + +} // namespace detail + +} // namespace io::envelope diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index cf0e770e..b9cdd801 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -1,11 +1,39 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" #include "io/compression.h" +#include "io/glm_serialization.h" +#include "io/hash.h" + +#include +#include #include #include #include #include +#include +#include +#include +#include #include #include #include @@ -15,19 +43,31 @@ namespace io::envelope { -inline constexpr std::uint64_t magic = 0xF5FBD3EF919428CAULL; +inline constexpr std::uint64_t magic = 0xA6EFA707D12E4404ULL; +inline constexpr std::size_t max_class_name_size = 128; +inline constexpr std::size_t max_hash_size = 64; -struct Envelope { +struct Header { std::uint64_t magic; std::string class_name; std::uint32_t class_version; - ChecksumAlgorithm checksum_algorithm; - std::string checksum; + hash::Algorithm hash_algorithm; + // hash::data of the uncompressed payload. + byte::vector hash; CompressionAlgorithm compression_algorithm; std::uint64_t uncompressed_size; - Bytes compressed_data; }; +// The payload is the last field, so a file prefix of max_header_size bytes contains the header. +struct Envelope { + Header header; + byte::vector compressed_data; +}; + +// zpp_bits prefixes strings and vectors with their 32-bit size. +inline constexpr std::size_t max_header_size = sizeof(std::uint64_t) + sizeof(std::uint32_t) + max_class_name_size + sizeof(std::uint32_t) + + sizeof(hash::Algorithm) + sizeof(std::uint32_t) + max_hash_size + sizeof(CompressionAlgorithm) + sizeof(std::uint64_t); + template struct FixedString { char value[Size]; @@ -89,6 +129,7 @@ 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"); + static_assert(sizeof(ClassName.value) - 1 <= max_class_name_size, "the class name exceeds the envelope limit"); using version_tuple = std::tuple; static constexpr std::size_t version_count = sizeof...(Versions); @@ -115,18 +156,236 @@ struct PayloadSchema { }; template -Expected serialize(const typename Schema::template payload_type& payload, - CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, - ChecksumAlgorithm checksum_algorithm = ChecksumAlgorithm::HandledByCompressionLib); +Expected serialize(const typename Schema::template payload_type& payload, + CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, + hash::Algorithm hash_algorithm = hash::Algorithm::Xxh3_64); template -Expected serialize(const typename Schema::latest_type& payload, - CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, - ChecksumAlgorithm checksum_algorithm = ChecksumAlgorithm::HandledByCompressionLib); +Expected serialize(const typename Schema::latest_type& payload, + CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, + hash::Algorithm hash_algorithm = hash::Algorithm::Xxh3_64); +// A given expected hash must equal the header hash, which is verified against the payload. template -Expected deserialize(std::span bytes, std::size_t max_decompressed_size = default_max_decompressed_size); +Expected deserialize( + byte::span bytes, std::optional expected_hash = std::nullopt, std::size_t max_decompressed_size = default_max_decompressed_size); -} // namespace io::envelope +// Reads and decodes only the header of an envelope file. It checks the magic and the algorithm +// enumerators, but neither the class nor the payload, which it does not read. +Expected
read_header(const std::filesystem::path& path); + +namespace detail { + +// Checks the magic, the algorithm enumerators and the class name and hash size limits. +Expected validate_header(const Header& header); + +template +Expected serialize_to_bytes(const Value& value) +{ + byte::vector bytes; + zpp::bits::out output(bytes, zpp::bits::alloc_limit()); + const zpp::bits::errc result = output(value); + if (zpp::bits::failure(result)) { + const auto cause = std::make_error_code(result.code); + const auto code = result.code == std::errc::not_enough_memory ? Error::Code::ResourceExhausted : Error::Code::Internal; + return Error::fail(code, "serialize envelope data", cause); + } + return bytes; +} + +template +Expected deserialize_from_bytes(const byte::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()) { + const auto cause = std::make_error_code(zpp::bits::failure(result) ? result.code : std::errc::bad_message); + return Error::fail(Error::Code::CorruptData, "deserialize envelope data", cause); + } + 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 +Expected deserialize_version( + const std::uint32_t class_version, + const byte::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 Error::propagate(std::move(payload), "deserializing versioned envelope payload"); + } + return convert_to_latest(std::move(*payload)); + } + + if constexpr (Index + 1 < Schema::version_count) { + return deserialize_version(class_version, payload_bytes); + } else { + return Error::fail(Error::Code::Unsupported, + "unsupported envelope class version " + std::to_string(class_version)); + } +} + +template +Expected make_envelope(const typename Schema::template payload_type& payload, + const CompressionAlgorithm compression_algorithm, + const hash::Algorithm hash_algorithm) +{ + static_assert(Schema::template supports_version, "the requested payload version is not part of the schema"); + + auto payload_bytes = serialize_to_bytes(payload); + if (!payload_bytes) { + return Error::propagate(std::move(payload_bytes), "serializing envelope payload"); + } + + auto compressed = compress(*payload_bytes, compression_algorithm); + if (!compressed) { + return Error::propagate(std::move(compressed), "compressing envelope payload"); + } + + Envelope envelope { + .header = { + .magic = magic, + .class_name = std::string { Schema::class_name }, + .class_version = VersionNumber, + .hash_algorithm = hash_algorithm, + .hash = hash::data(*payload_bytes, hash_algorithm), + .compression_algorithm = compression_algorithm, + .uncompressed_size = payload_bytes->size(), + }, + .compressed_data = std::move(*compressed), + }; + ASSERT(envelope.header.hash.size() <= max_hash_size); + return envelope; +} + +} // namespace detail + +template +Expected serialize(const typename Schema::template payload_type& payload, + const CompressionAlgorithm compression_algorithm, + const hash::Algorithm hash_algorithm) +{ + auto envelope = detail::make_envelope(payload, compression_algorithm, hash_algorithm); + if (!envelope) { + return Error::propagate(std::move(envelope)); + } + return detail::serialize_to_bytes(*envelope); +} + +template +Expected serialize( + const typename Schema::latest_type &payload, + const CompressionAlgorithm compression_algorithm, + const hash::Algorithm hash_algorithm) +{ + return serialize( + payload, + compression_algorithm, + hash_algorithm); +} + +template +Expected deserialize( + const byte::span bytes, const std::optional expected_hash, const std::size_t max_decompressed_size) +{ + auto envelope = detail::deserialize_from_bytes(bytes); + if (!envelope) { + return Error::propagate(std::move(envelope), "reading envelope"); + } + if (auto valid = detail::validate_header(envelope->header); !valid) { + return Error::propagate(std::move(valid), "reading envelope header"); + } + const Header& header = envelope->header; + if (header.class_name != Schema::class_name) { + return Error::fail(Error::Code::CorruptData, + "unexpected envelope class \"" + header.class_name + "\", expected \"" + std::string(Schema::class_name) + "\""); + } + if (!Schema::supports_version_number(header.class_version)) { + return Error::fail(Error::Code::Unsupported, + "unsupported envelope class version " + std::to_string(header.class_version)); + } -#include "io/envelope.inl" + const std::size_t effective_max_decompressed_size = + std::min(max_decompressed_size, default_max_decompressed_size); + if (header.uncompressed_size > std::numeric_limits::max() + || header.uncompressed_size > effective_max_decompressed_size) { + return Error::fail(Error::Code::ResourceExhausted, "envelope payload exceeds the configured size limit"); + } + + auto payload_bytes = decompress(envelope->compressed_data, header.compression_algorithm, static_cast(header.uncompressed_size)); + if (!payload_bytes) { + if (payload_bytes.error().code() == Error::Code::ResourceExhausted) { + return Error::propagate(std::move(payload_bytes), + Error::Code::CorruptData, "decompressed payload exceeds the size declared by the envelope"); + } + return Error::propagate(std::move(payload_bytes), "decompressing envelope payload"); + } + if (payload_bytes->size() != header.uncompressed_size) { + return Error::fail(Error::Code::CorruptData, "decompressed payload size does not match the envelope declaration"); + } + if (hash::data(*payload_bytes, header.hash_algorithm) != header.hash) { + return Error::fail(Error::Code::CorruptData, "envelope payload hash mismatch"); + } + if (expected_hash.has_value() && !std::ranges::equal(header.hash, *expected_hash)) { + return Error::fail(Error::Code::CorruptData, "envelope payload hash does not match the expected hash"); + } + + return detail::deserialize_version(header.class_version, *payload_bytes); +} + +// A given expected hash must equal the header hash, which is verified against the payload. +template +Expected read(const std::filesystem::path& path, + const std::optional expected_hash = std::nullopt, + const std::size_t max_decompressed_size = default_max_decompressed_size) +{ + auto file_bytes = ::io::byte::read(path); + if (!file_bytes) { + return Error::propagate(std::move(file_bytes), "reading envelope file \"" + path.string() + "\""); + } + auto result = deserialize(*file_bytes, expected_hash, max_decompressed_size); + if (!result) { + return Error::propagate(std::move(result), "decoding envelope file \"" + path.string() + "\""); + } + return std::move(*result); +} + +// Returns the hash of the uncompressed payload recorded in the header. +template +Expected write(const typename Schema::latest_type& payload, + const std::filesystem::path& path, + const byte::WriteMode mode, + const bool make_dirs = true, + const CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, + const hash::Algorithm hash_algorithm = hash::Algorithm::Xxh3_64) +{ + auto envelope = detail::make_envelope(payload, compression_algorithm, hash_algorithm); + if (!envelope) { + return Error::propagate(std::move(envelope), "encoding envelope file \"" + path.string() + "\""); + } + auto serialized = detail::serialize_to_bytes(*envelope); + if (!serialized) { + return Error::propagate(std::move(serialized), "encoding envelope file \"" + path.string() + "\""); + } + auto result = ::io::byte::write(*serialized, path, mode, make_dirs); + if (!result) { + return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); + } + return std::move(envelope->header.hash); +} + +} // namespace io::envelope diff --git a/src/terrainlib/io/envelope.inl b/src/terrainlib/io/envelope.inl deleted file mode 100644 index f71348d0..00000000 --- a/src/terrainlib/io/envelope.inl +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include - -#include - -namespace io::envelope { -namespace detail { - -template -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)) { - const auto cause = std::make_error_code(result.code); - const auto code = result.code == std::errc::not_enough_memory ? Error::Code::ResourceExhausted : Error::Code::Internal; - return Error::fail(code, "serialize envelope data", cause); - } - return bytes; -} - -template -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()) { - const auto cause = std::make_error_code(zpp::bits::failure(result) ? result.code : std::errc::bad_message); - return Error::fail(Error::Code::CorruptData, "deserialize envelope data", cause); - } - 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 -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 Error::propagate(std::move(payload), "deserializing versioned envelope payload"); - } - return convert_to_latest(std::move(*payload)); - } - - if constexpr (Index + 1 < Schema::version_count) { - return deserialize_version(class_version, payload_bytes); - } else { - return Error::fail(Error::Code::Unsupported, - "unsupported envelope class version " + std::to_string(class_version)); - } -} - -} // namespace detail - -template -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 Error::propagate(std::move(payload_bytes), "serializing envelope payload"); - } - - auto compressed = compress_with_checksum(*payload_bytes, compression_algorithm, checksum_algorithm); - if (!compressed) { - return Error::propagate(std::move(compressed), "compressing envelope payload"); - } - - 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, - .uncompressed_size = payload_bytes->size(), - .compressed_data = std::move(compressed->compressed_data), - }; - return detail::serialize_to_bytes(envelope); -} - -template -Expected serialize( - const typename Schema::latest_type &payload, - const CompressionAlgorithm compression_algorithm, - const ChecksumAlgorithm checksum_algorithm) -{ - return serialize( - payload, - compression_algorithm, - checksum_algorithm); -} - -template -Expected deserialize( - const std::span bytes, - const std::size_t max_decompressed_size) -{ - auto envelope = detail::deserialize_from_bytes(bytes); - if (!envelope) { - return Error::propagate(std::move(envelope), "reading envelope header"); - } - if (envelope->magic != magic) { - return Error::fail(Error::Code::CorruptData, "invalid envelope magic"); - } - if (envelope->class_name != Schema::class_name) { - return Error::fail(Error::Code::CorruptData, - "unexpected envelope class \"" + envelope->class_name + "\", expected \"" + std::string(Schema::class_name) + "\""); - } - if (!Schema::supports_version_number(envelope->class_version)) { - return Error::fail(Error::Code::Unsupported, - "unsupported envelope class version " + std::to_string(envelope->class_version)); - } - - 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 Error::fail(Error::Code::ResourceExhausted, "envelope payload exceeds the configured size limit"); - } - - auto payload_bytes = checked_decompress( - envelope->compressed_data, - envelope->compression_algorithm, - envelope->checksum_algorithm, - envelope->checksum, - static_cast(envelope->uncompressed_size)); - if (!payload_bytes) { - if (payload_bytes.error().code() == Error::Code::ResourceExhausted) { - return Error::propagate(std::move(payload_bytes), - Error::Code::CorruptData, "decompressed payload exceeds the size declared by the envelope"); - } - if (payload_bytes.error().code() == Error::Code::InvalidInput) { - return Error::propagate(std::move(payload_bytes), - Error::Code::CorruptData, "envelope declares an invalid compression and checksum combination"); - } - return Error::propagate(std::move(payload_bytes), "decompressing envelope payload"); - } - if (payload_bytes->size() != envelope->uncompressed_size) { - return Error::fail(Error::Code::CorruptData, "decompressed payload size does not match the envelope declaration"); - } - - return detail::deserialize_version(envelope->class_version, *payload_bytes); -} - -} // namespace io::envelope diff --git a/src/terrainlib/io/envelope_file.h b/src/terrainlib/io/envelope_file.h deleted file mode 100644 index daa5d5c4..00000000 --- a/src/terrainlib/io/envelope_file.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include - -#include "io/bytes.h" -#include "io/envelope.h" - -namespace io::envelope { - -template -Expected read_from_path( - const std::filesystem::path& path, const std::size_t max_decompressed_size = default_max_decompressed_size) -{ - auto file_bytes = ::io::read_bytes_from_path(path); - if (!file_bytes) { - return Error::propagate(std::move(file_bytes), "reading envelope file \"" + path.string() + "\""); - } - const auto bytes = std::as_bytes(std::span { *file_bytes }); - auto result = deserialize(bytes, max_decompressed_size); - if (!result) { - return Error::propagate(std::move(result), "decoding envelope file \"" + path.string() + "\""); - } - return std::move(*result); -} - -template -Expected write_to_path(const typename Schema::latest_type& payload, - const std::filesystem::path& path, - const bool make_dirs = true, - const CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, - const ChecksumAlgorithm checksum_algorithm = ChecksumAlgorithm::HandledByCompressionLib) -{ - auto serialized = serialize(payload, compression_algorithm, checksum_algorithm); - if (!serialized) { - return Error::propagate(std::move(serialized), "encoding envelope file \"" + path.string() + "\""); - } - const auto bytes = std::span { - reinterpret_cast(serialized->data()), - serialized->size(), - }; - auto result = ::io::write_bytes_to_path(bytes, path, make_dirs); - if (!result) { - return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); - } - return {}; -} - -} // namespace io::envelope diff --git a/src/terrainlib/io/glm_serialization.h b/src/terrainlib/io/glm_serialization.h new file mode 100644 index 00000000..471759c2 --- /dev/null +++ b/src/terrainlib/io/glm_serialization.h @@ -0,0 +1,65 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +namespace io::glm_serialization { + +template +constexpr auto serialize_components(Archive& archive, Value& value, std::integer_sequence) +{ + return archive(value[Indices]...); +} + +} // namespace io::glm_serialization + +// zpp::bits discovers these overloads through argument-dependent lookup. +namespace glm { + +// Store scalar components only, excluding any alignment padding. +template +constexpr auto serialize(Archive& archive, vec& value) +{ + return io::glm_serialization::serialize_components(archive, value, std::make_integer_sequence {}); +} + +template +constexpr auto serialize(Archive& archive, const vec& value) +{ + return io::glm_serialization::serialize_components(archive, value, std::make_integer_sequence {}); +} + +// Matrix indexing yields columns, so the wire format is column-major. +template +constexpr auto serialize(Archive& archive, mat& value) +{ + return io::glm_serialization::serialize_components(archive, value, std::make_integer_sequence {}); +} + +template +constexpr auto serialize(Archive& archive, const mat& value) +{ + return io::glm_serialization::serialize_components(archive, value, std::make_integer_sequence {}); +} + +} // namespace glm diff --git a/src/terrainlib/io/hash.cpp b/src/terrainlib/io/hash.cpp new file mode 100644 index 00000000..b6672238 --- /dev/null +++ b/src/terrainlib/io/hash.cpp @@ -0,0 +1,50 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/hash.h" + +#define XXH_INLINE_ALL +#include + +#include + +namespace io::hash { +namespace { + + byte::vector xxh3_64(const byte::span bytes) + { + XXH64_canonical_t canonical; + XXH64_canonicalFromHash(&canonical, XXH3_64bits(bytes.data(), bytes.size())); + const auto* digest = reinterpret_cast(canonical.digest); + return { digest, digest + sizeof(canonical.digest) }; + } + +} // namespace + +byte::vector data(const byte::span bytes, const Algorithm algorithm) +{ + switch (algorithm) { + case Algorithm::None: + return {}; + case Algorithm::Xxh3_64: + return xxh3_64(bytes); + } + PANIC("unsupported hash algorithm", static_cast(algorithm)); +} + +} // namespace io::hash diff --git a/src/terrainlib/io/hash.h b/src/terrainlib/io/hash.h new file mode 100644 index 00000000..de516fd4 --- /dev/null +++ b/src/terrainlib/io/hash.h @@ -0,0 +1,39 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" + +namespace io::hash { + +enum class Algorithm : std::uint8_t { + None, + Xxh3_64, +}; + +// Returns no bytes for None and the 8 bytes of XXH3-64 with seed 0 in big-endian order, the canonical +// xxHash form, for Xxh3_64. The algorithm must be an enumerator. +byte::vector data(byte::span bytes, Algorithm algorithm); + +} // namespace io::hash diff --git a/src/terrainlib/io/image.cpp b/src/terrainlib/io/image.cpp new file mode 100644 index 00000000..b6fa6c9d --- /dev/null +++ b/src/terrainlib/io/image.cpp @@ -0,0 +1,348 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "byte.h" +#include "conversion.h" +#include "raster/algorithm/transform.h" + +namespace io::image { +namespace { + + Expected validate_encoding(Format format, int channels, EncodeOptions options) + { + if (options.jpeg_quality < 0 || options.jpeg_quality > 100 || options.png_compression < 0 || options.png_compression > 9) { + return Error::fail(Error::Code::InvalidInput, "JPEG quality must be 0..100 and PNG compression 0..9"); + } + if (!(options.jpegxl_distance >= 0 && options.jpegxl_distance <= 25) || options.jpegxl_effort < 1 || options.jpegxl_effort > 10) { + return Error::fail(Error::Code::InvalidInput, "JPEG XL distance must be 0..25 and effort 1..10"); + } + if (format != Format::Jpeg && format != Format::Png && format != Format::JpegXl) { + return Error::fail(Error::Code::Unsupported, "unsupported image format"); + } + if (format == Format::Jpeg && channels == 4) { + return Error::fail(Error::Code::Unsupported, "JPEG does not support alpha; remove alpha explicitly"); + } + return {}; + } + + template + Pixel swap_red_blue(Pixel pixel) + { + std::swap(pixel.x, pixel.z); + return pixel; + } + + template + Expected encode_opencv(const radix::Raster& image, Format format, EncodeOptions options) + try { + auto bgr = raster::algorithm::transform(image, swap_red_blue); + if (!bgr) { + return Error::propagate(std::move(bgr)); + } + auto converted = conversion::to_mat(*bgr); + if (!converted) { + return Error::propagate(std::move(converted)); + } + std::vector encoded; + const auto parameters = format == Format::Jpeg ? std::vector { cv::IMWRITE_JPEG_QUALITY, options.jpeg_quality } + : std::vector { cv::IMWRITE_PNG_COMPRESSION, options.png_compression }; + if (!cv::imencode(format == Format::Jpeg ? ".jpg" : ".png", *converted, encoded, parameters)) { + return Error::fail(Error::Code::Io, "image encoding failed"); + } + // OpenCV only encodes into a std::vector. + const auto bytes = std::as_bytes(std::span(encoded)); + return byte::vector(bytes.begin(), bytes.end()); + } catch (const cv::Exception& error) { + return Error::fail(error.code == cv::Error::StsNoMem ? Error::Code::ResourceExhausted : Error::Code::Io, "encode image: " + error.msg); + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "encode image"); + } + + template + Expected encode_jpegxl(const radix::Raster& image, EncodeOptions options) + try { + if (image.width() == 0 || image.height() == 0) { + return Error::fail(Error::Code::InvalidInput, "JPEG XL cannot encode an empty image"); + } + const auto encoder = JxlEncoderMake(nullptr); + if (!encoder) { + return Error::fail(Error::Code::ResourceExhausted, "create JPEG XL encoder"); + } + const auto failed = [&encoder] { + return Error::fail( + JxlEncoderGetError(encoder.get()) == JXL_ENC_ERR_OOM ? Error::Code::ResourceExhausted : Error::Code::Io, "JPEG XL encoding failed"); + }; + const bool lossless = options.jpegxl_distance == 0; + JxlBasicInfo info; + JxlEncoderInitBasicInfo(&info); + info.xsize = image.width(); + info.ysize = image.height(); + info.bits_per_sample = 8; + info.num_color_channels = 3; + info.alpha_bits = Pixel::length() == 4 ? 8 : 0; + info.num_extra_channels = Pixel::length() == 4 ? 1 : 0; + // Lossy coding converts to XYB; lossless coding must keep the original colour space. + info.uses_original_profile = lossless ? JXL_TRUE : JXL_FALSE; + JxlColorEncoding colour_encoding; + JxlColorEncodingSetToSRGB(&colour_encoding, JXL_FALSE); + if (JxlEncoderSetBasicInfo(encoder.get(), &info) != JXL_ENC_SUCCESS || JxlEncoderSetColorEncoding(encoder.get(), &colour_encoding) != JXL_ENC_SUCCESS) { + return failed(); + } + auto* settings = JxlEncoderFrameSettingsCreate(encoder.get(), nullptr); + const JxlPixelFormat pixel_format { Pixel::length(), JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0 }; + const auto pixels = image.bytes(); + if (!settings || JxlEncoderFrameSettingsSetOption(settings, JXL_ENC_FRAME_SETTING_EFFORT, options.jpegxl_effort) != JXL_ENC_SUCCESS + || JxlEncoderSetFrameDistance(settings, options.jpegxl_distance) != JXL_ENC_SUCCESS + // Alpha uses the colour distance; libjxl would otherwise keep it lossless. + || (Pixel::length() == 4 && JxlEncoderSetExtraChannelDistance(settings, 0, -1.0f) != JXL_ENC_SUCCESS) + || (lossless && JxlEncoderSetFrameLossless(settings, JXL_TRUE) != JXL_ENC_SUCCESS) + || JxlEncoderAddImageFrame(settings, &pixel_format, pixels.data(), pixels.size()) != JXL_ENC_SUCCESS) { + return failed(); + } + JxlEncoderCloseInput(encoder.get()); + byte::vector encoded(pixels.size() / 8 + 4096); + std::size_t used = 0; + JxlEncoderStatus status = JXL_ENC_NEED_MORE_OUTPUT; + while (status == JXL_ENC_NEED_MORE_OUTPUT) { + if (used == encoded.size()) { + encoded.resize(2 * encoded.size()); + } + auto* next = reinterpret_cast(encoded.data() + used); + std::size_t available = encoded.size() - used; + status = JxlEncoderProcessOutput(encoder.get(), &next, &available); + used = encoded.size() - available; + } + if (status != JXL_ENC_SUCCESS) { + return failed(); + } + encoded.resize(used); + return encoded; + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "encode JPEG XL image"); + } + + template + Expected encode_raster(const radix::Raster& image, Format format, EncodeOptions options) + { + if (auto valid = validate_encoding(format, Pixel::length(), options); !valid) { + return Error::propagate(std::move(valid)); + } + if (format == Format::JpegXl) { + return encode_jpegxl(image, options); + } + return encode_opencv(image, format, options); + } + + template + Expected> decode_opencv(byte::span bytes, std::optional expected_size) + try { + if (bytes.empty() || bytes.size() > static_cast((std::numeric_limits::max)())) { + return Error::fail(Error::Code::CorruptData, "empty or oversized encoded image"); + } + const cv::Mat buffer(1, static_cast(bytes.size()), CV_8UC1, const_cast(bytes.data())); + // Preserve alpha and stored row order, without applying EXIF orientation. + auto decoded = cv::imdecode(buffer, cv::IMREAD_UNCHANGED); + if (decoded.empty()) { + return Error::fail(Error::Code::CorruptData, "image decoding failed"); + } + if (expected_size && glm::uvec2(decoded.cols, decoded.rows) != *expected_size) { + return Error::fail(Error::Code::CorruptData, "image size differs from the expected size"); + } + if (decoded.depth() != CV_8U) { + return Error::fail(Error::Code::Unsupported, "RGB8/RGBA8 reading requires 8-bit image samples"); + } + constexpr bool alpha = std::same_as; + switch (decoded.channels()) { + case 1: + cv::cvtColor(decoded, decoded, alpha ? cv::COLOR_GRAY2BGRA : cv::COLOR_GRAY2BGR); + break; + case 3: + if constexpr (alpha) { + cv::cvtColor(decoded, decoded, cv::COLOR_BGR2BGRA); + } + break; + case 4: + if constexpr (!alpha) { + cv::cvtColor(decoded, decoded, cv::COLOR_BGRA2BGR); + } + break; + default: + return Error::fail(Error::Code::Unsupported, "unsupported image channel count"); + } + auto raster = conversion::to_raster(decoded); + if (!raster) { + return Error::propagate(std::move(raster)); + } + if (auto converted = raster::algorithm::transform(*raster, swap_red_blue, *raster); !converted) { + return Error::propagate(std::move(converted)); + } + return raster; + } catch (const cv::Exception& error) { + return Error::fail(error.code == cv::Error::StsNoMem ? Error::Code::ResourceExhausted : Error::Code::CorruptData, "decode image: " + error.msg); + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "decode image"); + } + + // Reads the first frame. libjxl drops or adds opaque alpha and expands grey to RGB. + template + Expected> decode_jpegxl(byte::span bytes, std::optional expected_size) + try { + const auto decoder = JxlDecoderMake(nullptr); + if (!decoder) { + return Error::fail(Error::Code::ResourceExhausted, "create JPEG XL decoder"); + } + // Keep the stored row order, as for EXIF orientation. + if (JxlDecoderSubscribeEvents(decoder.get(), JXL_DEC_BASIC_INFO | JXL_DEC_FULL_IMAGE) != JXL_DEC_SUCCESS + || JxlDecoderSetKeepOrientation(decoder.get(), JXL_TRUE) != JXL_DEC_SUCCESS + || JxlDecoderSetInput(decoder.get(), reinterpret_cast(bytes.data()), bytes.size()) != JXL_DEC_SUCCESS) { + return Error::fail(Error::Code::Internal, "set up JPEG XL decoder"); + } + JxlDecoderCloseInput(decoder.get()); + const JxlPixelFormat pixel_format { Pixel::length(), JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0 }; + std::optional> raster; + while (true) { + switch (JxlDecoderProcessInput(decoder.get())) { + case JXL_DEC_BASIC_INFO: { + JxlBasicInfo info; + if (JxlDecoderGetBasicInfo(decoder.get(), &info) != JXL_DEC_SUCCESS) { + return Error::fail(Error::Code::CorruptData, "read JPEG XL basic info"); + } + const glm::uvec2 size(info.xsize, info.ysize); + if (expected_size && size != *expected_size) { + return Error::fail(Error::Code::CorruptData, "JPEG XL image size differs from the expected size"); + } + raster.emplace(size); + break; + } + case JXL_DEC_NEED_IMAGE_OUT_BUFFER: { + if (!raster) { + return Error::fail(Error::Code::CorruptData, "JPEG XL image data precedes its basic info"); + } + const auto pixels = raster->bytes(); + if (JxlDecoderSetImageOutBuffer(decoder.get(), &pixel_format, pixels.data(), pixels.size()) != JXL_DEC_SUCCESS) { + return Error::fail(Error::Code::CorruptData, "JPEG XL image does not fit its basic info"); + } + break; + } + case JXL_DEC_FULL_IMAGE: + return std::move(*raster); + case JXL_DEC_NEED_MORE_INPUT: + return Error::fail(Error::Code::CorruptData, "truncated JPEG XL image"); + case JXL_DEC_SUCCESS: + return Error::fail(Error::Code::CorruptData, "JPEG XL image has no frame"); + default: + return Error::fail(Error::Code::CorruptData, "JPEG XL decoding failed"); + } + } + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "decode JPEG XL image"); + } + + template + Expected> decode_raster(byte::span bytes, std::optional expected_size = std::nullopt) + { + const auto signature = JxlSignatureCheck(reinterpret_cast(bytes.data()), bytes.size()); + if (signature == JXL_SIG_CODESTREAM || signature == JXL_SIG_CONTAINER) { + return decode_jpegxl(bytes, expected_size); + } + return decode_opencv(bytes, expected_size); + } + + template + Expected> read_raster(const std::filesystem::path& path) + try { + auto bytes = byte::read(path); + if (!bytes) { + return Error::propagate(std::move(bytes)); + } + return decode_raster(*bytes); + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "read image", path); + } + + Expected format_from_extension(const std::filesystem::path& path) + { + auto extension = path.extension().string(); + std::ranges::transform(extension, extension.begin(), [](unsigned char character) { return static_cast(std::tolower(character)); }); + if (extension == ".jpg" || extension == ".jpeg") { + return Format::Jpeg; + } + if (extension == ".png") { + return Format::Png; + } + if (extension == ".jxl") { + return Format::JpegXl; + } + return Error::fail(Error::Code::Unsupported, "unsupported image extension", path); + } + + template + Expected write_image(const radix::Raster& image, const std::filesystem::path& path, WriteOptions options) + { + auto format = format_from_extension(path); + if (!format) { + return Error::propagate(std::move(format)); + } + std::error_code error; + const auto status = std::filesystem::symlink_status(path, error); + if (error && error != std::errc::no_such_file_or_directory) { + return Error::fail(Error::Code::Io, "inspect image output", path, error); + } + if (std::filesystem::exists(status)) { + if (!options.overwrite) { + return Error::fail(Error::Code::AlreadyExists, "image output exists", path); + } + if (!std::filesystem::is_regular_file(status)) { + return Error::fail(Error::Code::InvalidInput, "image output must be a regular file", path); + } + } + auto bytes = encode(image, *format, options.encoding); + if (!bytes) { + return Error::propagate(std::move(bytes), "encode image for " + path.string()); + } + return byte::write(*bytes, path, options.overwrite ? byte::WriteMode::Overwrite : byte::WriteMode::CreateNew, options.make_dirs); + } + +} // namespace + +Expected encode(const RGB8& image, Format format, EncodeOptions options) { return encode_raster(image, format, options); } +Expected encode(const RGBA8& image, Format format, EncodeOptions options) { return encode_raster(image, format, options); } +Expected decode_rgb8(byte::span bytes, std::optional expected_size) { return decode_raster(bytes, expected_size); } +Expected decode_rgba8(byte::span bytes, std::optional expected_size) { return decode_raster(bytes, expected_size); } +Expected read_rgb8(const std::filesystem::path& path) { return read_raster(path); } +Expected read_rgba8(const std::filesystem::path& path) { return read_raster(path); } +Expected write(const RGB8& image, const std::filesystem::path& path, WriteOptions options) { return write_image(image, path, options); } +Expected write(const RGBA8& image, const std::filesystem::path& path, WriteOptions options) { return write_image(image, path, options); } + +} // namespace io::image diff --git a/src/terrainlib/io/image.h b/src/terrainlib/io/image.h new file mode 100644 index 00000000..c537fd37 --- /dev/null +++ b/src/terrainlib/io/image.h @@ -0,0 +1,71 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +#include "Error.h" +#include "io/byte.h" + +namespace io::image { + +using RGB8 = radix::Raster; +using RGBA8 = radix::Raster; + +enum class Format { Jpeg, Png, JpegXl }; + +struct EncodeOptions { + int jpeg_quality = 95; + int png_compression = 1; + // Butteraugli distance 0..25, where 0 selects lossless coding. + float jpegxl_distance = 0.6f; + // 1 (fastest) to 10. + int jpegxl_effort = 3; +}; + +struct WriteOptions { + EncodeOptions encoding {}; + bool overwrite = false; + bool make_dirs = true; +}; + +/// RGB(A) channels, first row at the top. No flipping, scaling or premultiplication. +Expected encode(const RGB8& image, Format format, EncodeOptions options = {}); +/// PNG and JPEG XL preserve all four channels. JPEG rejects RGBA input. JPEG XL tags the channels as sRGB. +Expected encode(const RGBA8& image, Format format, EncodeOptions options = {}); +/// RGB readers discard alpha. RGBA readers supply opaque alpha when absent. +/// Both return 8-bit channels and ignore EXIF orientation. Given an expected size, images of other sizes +/// are corrupt data; JPEG XL rejects them before allocating their pixels. +Expected decode_rgb8(byte::span bytes, std::optional expected_size = std::nullopt); +Expected decode_rgba8(byte::span bytes, std::optional expected_size = std::nullopt); +Expected read_rgb8(const std::filesystem::path& path); +Expected read_rgba8(const std::filesystem::path& path); + +/// Select JPEG/PNG/JPEG XL from the extension. Existing symlinks and nonregular files are rejected. +Expected write(const RGB8& image, const std::filesystem::path& path, WriteOptions options = {}); +Expected write(const RGBA8& image, const std::filesystem::path& path, WriteOptions options = {}); + +} // namespace io::image diff --git a/src/terrainlib/io/utils.cpp b/src/terrainlib/io/utils.cpp index 0d8f6298..67674bdd 100644 --- a/src/terrainlib/io/utils.cpp +++ b/src/terrainlib/io/utils.cpp @@ -1,15 +1,85 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "io/utils.h" +#include +#include + +#ifdef __linux__ +#include +#include +#endif + namespace io::utils { -std::error_code create_parent_directories(const std::filesystem::path &path) { +Expected create_parent_directories(const std::filesystem::path& path) +{ std::error_code error; const std::filesystem::path parent_path = std::filesystem::absolute(path, error).parent_path(); if (error) { - return error; + return Error::fail(Error::Code::Io, "resolve parent directory for", path, error); } std::filesystem::create_directories(parent_path, error); - return error; + if (error) { + return Error::fail(error == std::errc::file_exists ? Error::Code::AlreadyExists : Error::Code::Io, + "create parent directories for", path, error); + } + return {}; +} + +Expected rename_without_replacement(const std::filesystem::path& source, const std::filesystem::path& destination) +{ +#ifdef __linux__ + if (::renameat2(AT_FDCWD, source.c_str(), AT_FDCWD, destination.c_str(), RENAME_NOREPLACE) == 0) { + return {}; + } + const std::error_code error(errno, std::generic_category()); + if (error == std::errc::no_such_file_or_directory) { + return Error::fail(Error::Code::NotFound, "rename without replacing destination", source, destination, error); + } + return Error::fail(error == std::errc::file_exists ? Error::Code::AlreadyExists : Error::Code::Io, + "rename without replacing destination", source, destination, error); +#else + return Error::fail(Error::Code::Unsupported, "rename without replacement is not implemented on this platform", + source, destination, std::make_error_code(std::errc::operation_not_supported)); +#endif +} + +Expected sync_file(const std::filesystem::path& path) +{ +#ifdef __linux__ + const int file = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (file < 0) { + return Error::fail(Error::Code::Io, "open file for syncing", path, std::error_code(errno, std::generic_category())); + } + const int synced = ::fsync(file); + const std::error_code error(errno, std::generic_category()); + ::close(file); + if (synced != 0) { + return Error::fail(Error::Code::Io, "sync file", path, error); + } + return {}; +#else + return Error::fail( + Error::Code::Unsupported, "syncing files is not implemented on this platform", path, std::make_error_code(std::errc::operation_not_supported)); +#endif } } // namespace io::utils diff --git a/src/terrainlib/io/utils.h b/src/terrainlib/io/utils.h index aa369d69..05cfdf01 100644 --- a/src/terrainlib/io/utils.h +++ b/src/terrainlib/io/utils.h @@ -1,10 +1,34 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "Error.h" + namespace io::utils { -[[nodiscard]] std::error_code create_parent_directories(const std::filesystem::path &path); +[[nodiscard]] Expected create_parent_directories(const std::filesystem::path& path); +Expected rename_without_replacement(const std::filesystem::path& source, const std::filesystem::path& destination); +// Flushes the file's data to the storage device. +Expected sync_file(const std::filesystem::path& path); } // namespace io::utils diff --git a/src/terrainlib/log.cpp b/src/terrainlib/log.cpp deleted file mode 100644 index ddf059c1..00000000 --- a/src/terrainlib/log.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include -#include - -#include "log.h" - -std::shared_ptr Log::logger; - -void Log::init(spdlog::level::level_enum log_level) { - spdlog::set_pattern("[%Y-%m-%d %T.%e] [%=3n] [%^%l%$] %v"); - Log::logger = spdlog::stderr_color_mt("LOG"); - Log::logger->set_level(log_level); -} diff --git a/src/terrainlib/log.h b/src/terrainlib/log.h deleted file mode 100644 index 5925bd4d..00000000 --- a/src/terrainlib/log.h +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once - -#ifndef SPDLOG_FMT_EXTERNAL -#define SPDLOG_FMT_EXTERNAL -#define FMT_HEADER_ONLY -#endif -#include "fmt_impls.h" -#include "numeric/int_math.h" -#include -#include - -class Log { -private: - static std::shared_ptr logger; -public: - static void init(spdlog::level::level_enum level); - inline static std::shared_ptr& get_logger() { - if (Log::logger == nullptr) { - Log::init(spdlog::level::level_enum::info); - } - return Log::logger; - } -}; - -#define LOG_TRACE(...) SPDLOG_LOGGER_TRACE(::Log::get_logger(), __VA_ARGS__) -#define LOG_DEBUG(...) SPDLOG_LOGGER_DEBUG(::Log::get_logger(), __VA_ARGS__) -#define LOG_INFO(...) SPDLOG_LOGGER_INFO (::Log::get_logger(), __VA_ARGS__) -#define LOG_WARN(...) SPDLOG_LOGGER_WARN (::Log::get_logger(), __VA_ARGS__) -#define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(::Log::get_logger(), __VA_ARGS__) -#define LOG_ERROR_AND_EXIT(...) \ - do { \ - LOG_ERROR(__VA_ARGS__); \ - exit(1); \ - } while (false) - -// Logs at most once per call site for the lifetime of the process. -#define LOG_WARN_ONCE(...) \ - do { \ - static bool _log_warn_once_fired = false; \ - if (!_log_warn_once_fired) { \ - LOG_WARN(__VA_ARGS__); \ - _log_warn_once_fired = true; \ - } \ - } while (false) - -// Tracks call count per call site and reports whether the current call falls -// on a power-of-two occurrence (1, 2, 4, 8, 16, ...). -class LogBackoff { -public: - bool should_log() { - this->_count++; - return is_power_of_two(this->_count); - } - - uint64_t count() const { - return this->_count; - } - -private: - uint64_t _count = 0; -}; - -// Logs on occurrence 1, 2, 4, 8, 16, ... per call site, with the occurrence count appended. -#define LOG_WARN_BACKOFF(fmt_str, ...) \ - do { \ - static LogBackoff _log_warn_backoff; \ - if (_log_warn_backoff.should_log()) { \ - LOG_WARN(fmt_str " (occurrence {})", ##__VA_ARGS__, _log_warn_backoff.count()); \ - } \ - } while (false) diff --git a/src/terrainlib/macros.h b/src/terrainlib/macros.h index 45be2a25..d250dd59 100644 --- a/src/terrainlib/macros.h +++ b/src/terrainlib/macros.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #if defined(__has_builtin) diff --git a/src/terrainlib/mesh/EncodedMesh.h b/src/terrainlib/mesh/EncodedMesh.h index fd0927a6..53f427d4 100644 --- a/src/terrainlib/mesh/EncodedMesh.h +++ b/src/terrainlib/mesh/EncodedMesh.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/PointWithMeta.h b/src/terrainlib/mesh/PointWithMeta.h index 474a689c..657b2c13 100644 --- a/src/terrainlib/mesh/PointWithMeta.h +++ b/src/terrainlib/mesh/PointWithMeta.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/SimpleMesh.h b/src/terrainlib/mesh/SimpleMesh.h index e816ce6d..9d6938bf 100644 --- a/src/terrainlib/mesh/SimpleMesh.h +++ b/src/terrainlib/mesh/SimpleMesh.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/TriangleContainer.h b/src/terrainlib/mesh/TriangleContainer.h index 5cc0afd5..36357ddb 100644 --- a/src/terrainlib/mesh/TriangleContainer.h +++ b/src/terrainlib/mesh/TriangleContainer.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/TriangleSoup.h b/src/terrainlib/mesh/TriangleSoup.h index dfacc3ff..430b7228 100644 --- a/src/terrainlib/mesh/TriangleSoup.h +++ b/src/terrainlib/mesh/TriangleSoup.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/TriangleSoup.inl b/src/terrainlib/mesh/TriangleSoup.inl index 3020ad4b..a565159e 100644 --- a/src/terrainlib/mesh/TriangleSoup.inl +++ b/src/terrainlib/mesh/TriangleSoup.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/VertexMap.h b/src/terrainlib/mesh/VertexMap.h index 4671c9f1..978ecdc2 100644 --- a/src/terrainlib/mesh/VertexMap.h +++ b/src/terrainlib/mesh/VertexMap.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/View.h b/src/terrainlib/mesh/View.h index 3897f845..e278bcd0 100644 --- a/src/terrainlib/mesh/View.h +++ b/src/terrainlib/mesh/View.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/WindingOrder.h b/src/terrainlib/mesh/WindingOrder.h index 07410810..d4767a5b 100644 --- a/src/terrainlib/mesh/WindingOrder.h +++ b/src/terrainlib/mesh/WindingOrder.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/bfs.cpp b/src/terrainlib/mesh/bfs.cpp index 8b9a71e5..96e0515f 100644 --- a/src/terrainlib/mesh/bfs.cpp +++ b/src/terrainlib/mesh/bfs.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/bfs.h b/src/terrainlib/mesh/bfs.h index 4b24cf89..1dbda49d 100644 --- a/src/terrainlib/mesh/bfs.h +++ b/src/terrainlib/mesh/bfs.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/boolean.cpp b/src/terrainlib/mesh/boolean.cpp index 4d514493..726305c6 100644 --- a/src/terrainlib/mesh/boolean.cpp +++ b/src/terrainlib/mesh/boolean.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include diff --git a/src/terrainlib/mesh/boolean.h b/src/terrainlib/mesh/boolean.h index 9650c63b..8e2dd544 100644 --- a/src/terrainlib/mesh/boolean.h +++ b/src/terrainlib/mesh/boolean.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "mesh/SimpleMesh.h" diff --git a/src/terrainlib/mesh/bounds.h b/src/terrainlib/mesh/bounds.h index fd97afca..583e917d 100644 --- a/src/terrainlib/mesh/bounds.h +++ b/src/terrainlib/mesh/bounds.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/bounds.inl b/src/terrainlib/mesh/bounds.inl index 34bb59b0..a2f4b031 100644 --- a/src/terrainlib/mesh/bounds.inl +++ b/src/terrainlib/mesh/bounds.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/cgal.h b/src/terrainlib/mesh/cgal.h index 9b79b6e7..22672c27 100644 --- a/src/terrainlib/mesh/cgal.h +++ b/src/terrainlib/mesh/cgal.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/cleanup.cpp b/src/terrainlib/mesh/cleanup.cpp index efff7c86..284087be 100644 --- a/src/terrainlib/mesh/cleanup.cpp +++ b/src/terrainlib/mesh/cleanup.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/cleanup.h b/src/terrainlib/mesh/cleanup.h index 997ef69c..da28fc2e 100644 --- a/src/terrainlib/mesh/cleanup.h +++ b/src/terrainlib/mesh/cleanup.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/cleanup.inl b/src/terrainlib/mesh/cleanup.inl index 0c21c13d..6c6d4e9f 100644 --- a/src/terrainlib/mesh/cleanup.inl +++ b/src/terrainlib/mesh/cleanup.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/clip.cpp b/src/terrainlib/mesh/clip.cpp index c1b3a984..e5ef1e3f 100644 --- a/src/terrainlib/mesh/clip.cpp +++ b/src/terrainlib/mesh/clip.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -19,7 +38,7 @@ #include "build_config.h" #include "hash_utils.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/cgal.h" #include "mesh/clip.h" #include "mesh/convert.h" diff --git a/src/terrainlib/mesh/clip.h b/src/terrainlib/mesh/clip.h index 3624c42c..462e1c25 100644 --- a/src/terrainlib/mesh/clip.h +++ b/src/terrainlib/mesh/clip.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/codec/Gltf.h b/src/terrainlib/mesh/codec/Gltf.h index 61e5aa35..ea510721 100644 --- a/src/terrainlib/mesh/codec/Gltf.h +++ b/src/terrainlib/mesh/codec/Gltf.h @@ -1,8 +1,27 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -30,7 +49,9 @@ class Gltf final : public store::Codec { return { add_extension(node_path, m_container == GltfContainer::Binary ? ".glb" : ".gltf") }; } - Expected read(const std::filesystem::path& node_path) const override + // glTF nodes have no identities, so expected hashes are ignored. + Expected read( + const std::filesystem::path& node_path, std::optional /* expected_hash */ = std::nullopt) const override { const std::filesystem::path path = paths(node_path).front(); auto result = mesh::io::gltf::load_from_path(path); @@ -40,15 +61,15 @@ class Gltf final : public store::Codec { return std::move(*result); } - Expected write(const std::filesystem::path& node_path, const mesh::Simple& mesh) const override + Expected write(const std::filesystem::path& node_path, const mesh::Simple& mesh) const override { const std::filesystem::path path = paths(node_path).front(); try { - auto result = mesh::io::gltf::save_to_path(mesh, path); + auto result = mesh::io::gltf::save_to_path(mesh, path, mesh::io::SaveOptions { .write_mode = ::io::byte::WriteMode::CreateNew }); if (!result) { return Error::propagate(std::move(result), "write glTF mesh \"" + path.string() + "\""); } - return {}; + return store::NodeDataHash {}; } catch (const std::bad_alloc&) { return Error::fail(Error::Code::ResourceExhausted, "write glTF mesh \"" + path.string() + "\": out of memory"); } catch (const std::exception& error) { diff --git a/src/terrainlib/mesh/codec/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index 3c0de760..440dbb26 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -1,9 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/envelope_file.h" +#include "io/envelope.h" #include "mesh/SimpleMesh.h" #include "mesh/codec/SfMeshFormat.h" #include "store/Codec.h" @@ -14,10 +33,11 @@ class SfMesh final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override { return { add_extension(node_path, ".sfmesh") }; } - Expected read(const std::filesystem::path& node_path) const override + Expected read( + const std::filesystem::path& node_path, const std::optional expected_hash = std::nullopt) const override { const std::filesystem::path path = paths(node_path).front(); - auto payload = ::io::envelope::read_from_path(path); + auto payload = ::io::envelope::read(path, expected_hash); if (!payload) { return Error::propagate(std::move(payload), "read SF mesh"); } @@ -31,9 +51,13 @@ class SfMesh final : public store::Codec { return std::move(*decoded); } - Expected write(const std::filesystem::path& node_path, const mesh::Simple& mesh) const override { return write(node_path, mesh, {}); } + Expected write(const std::filesystem::path& node_path, const mesh::Simple& mesh) const override + { + return write(node_path, mesh, {}, ::io::byte::WriteMode::CreateNew); + } - Expected write(const std::filesystem::path& node_path, const mesh::Simple& mesh, const mesh::EncodeOptions options) const + Expected write( + const std::filesystem::path& node_path, const mesh::Simple& mesh, const mesh::EncodeOptions options, const ::io::byte::WriteMode mode) const { const std::filesystem::path path = paths(node_path).front(); auto payload = mesh::sf::encode_payload(mesh, options); @@ -43,12 +67,14 @@ class SfMesh final : public store::Codec { if (auto valid = mesh::sf::validate(*payload); !valid) { return Error::propagate(std::move(valid), "validate SF mesh for \"" + path.string() + "\""); } - auto result = ::io::envelope::write_to_path(*payload, path); - if (!result) { - return Error::propagate(std::move(result), "write SF mesh"); + auto hash = ::io::envelope::write(*payload, path, mode); + if (!hash) { + return Error::propagate(std::move(hash), "write SF mesh"); } - return {}; + return std::move(*hash); } + + bool supports_hashing() const override { return true; } }; } // namespace mesh::codec diff --git a/src/terrainlib/mesh/codec/SfMeshFormat.h b/src/terrainlib/mesh/codec/SfMeshFormat.h index d97de5cd..98edd05c 100644 --- a/src/terrainlib/mesh/codec/SfMeshFormat.h +++ b/src/terrainlib/mesh/codec/SfMeshFormat.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/codec/from_extension.h b/src/terrainlib/mesh/codec/from_extension.h index 58d91153..225d9ee1 100644 --- a/src/terrainlib/mesh/codec/from_extension.h +++ b/src/terrainlib/mesh/codec/from_extension.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/combine.h b/src/terrainlib/mesh/combine.h index 854717c4..b07009aa 100644 --- a/src/terrainlib/mesh/combine.h +++ b/src/terrainlib/mesh/combine.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/connectivity/adjacency.cpp b/src/terrainlib/mesh/connectivity/adjacency.cpp index 8a80d74d..86d49e81 100644 --- a/src/terrainlib/mesh/connectivity/adjacency.cpp +++ b/src/terrainlib/mesh/connectivity/adjacency.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/connectivity/adjacency.h b/src/terrainlib/mesh/connectivity/adjacency.h index 9000ef79..a69637bf 100644 --- a/src/terrainlib/mesh/connectivity/adjacency.h +++ b/src/terrainlib/mesh/connectivity/adjacency.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/connectivity/adjacency.inl b/src/terrainlib/mesh/connectivity/adjacency.inl index 155f0c44..f6d574e5 100644 --- a/src/terrainlib/mesh/connectivity/adjacency.inl +++ b/src/terrainlib/mesh/connectivity/adjacency.inl @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/connectivity/boundary.cpp b/src/terrainlib/mesh/connectivity/boundary.cpp index 54572790..770be011 100644 --- a/src/terrainlib/mesh/connectivity/boundary.cpp +++ b/src/terrainlib/mesh/connectivity/boundary.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/boundary.h b/src/terrainlib/mesh/connectivity/boundary.h index 5e6f54a7..ea88f8f4 100644 --- a/src/terrainlib/mesh/connectivity/boundary.h +++ b/src/terrainlib/mesh/connectivity/boundary.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/connectivity/boundary.inl b/src/terrainlib/mesh/connectivity/boundary.inl index 2535b236..70968aa0 100644 --- a/src/terrainlib/mesh/connectivity/boundary.inl +++ b/src/terrainlib/mesh/connectivity/boundary.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/connectivity/connected_components.h b/src/terrainlib/mesh/connectivity/connected_components.h index f5a24b6d..a80da4a0 100644 --- a/src/terrainlib/mesh/connectivity/connected_components.h +++ b/src/terrainlib/mesh/connectivity/connected_components.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/connectivity/edges.h b/src/terrainlib/mesh/connectivity/edges.h index 4bf62141..af6a8249 100644 --- a/src/terrainlib/mesh/connectivity/edges.h +++ b/src/terrainlib/mesh/connectivity/edges.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/connectivity/edges.inl b/src/terrainlib/mesh/connectivity/edges.inl index f22c56ed..5731e3d6 100644 --- a/src/terrainlib/mesh/connectivity/edges.inl +++ b/src/terrainlib/mesh/connectivity/edges.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/manifold.cpp b/src/terrainlib/mesh/connectivity/manifold.cpp index 82b675fa..a0e75cf8 100644 --- a/src/terrainlib/mesh/connectivity/manifold.cpp +++ b/src/terrainlib/mesh/connectivity/manifold.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/manifold.h b/src/terrainlib/mesh/connectivity/manifold.h index bf524625..a9b72454 100644 --- a/src/terrainlib/mesh/connectivity/manifold.h +++ b/src/terrainlib/mesh/connectivity/manifold.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/connectivity/manifold.inl b/src/terrainlib/mesh/connectivity/manifold.inl index 84646f0a..b3a883eb 100644 --- a/src/terrainlib/mesh/connectivity/manifold.inl +++ b/src/terrainlib/mesh/connectivity/manifold.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/topology.cpp b/src/terrainlib/mesh/connectivity/topology.cpp index 2bd580cd..f63fd3ad 100644 --- a/src/terrainlib/mesh/connectivity/topology.cpp +++ b/src/terrainlib/mesh/connectivity/topology.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/topology.h b/src/terrainlib/mesh/connectivity/topology.h index cfd4ce5a..4db6d96e 100644 --- a/src/terrainlib/mesh/connectivity/topology.h +++ b/src/terrainlib/mesh/connectivity/topology.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/connectivity/triangle_compare.h b/src/terrainlib/mesh/connectivity/triangle_compare.h index 0b075baf..9e48eddb 100644 --- a/src/terrainlib/mesh/connectivity/triangle_compare.h +++ b/src/terrainlib/mesh/connectivity/triangle_compare.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/connectivity/triangle_compare.inl b/src/terrainlib/mesh/connectivity/triangle_compare.inl index b0e03ff0..d1de769d 100644 --- a/src/terrainlib/mesh/connectivity/triangle_compare.inl +++ b/src/terrainlib/mesh/connectivity/triangle_compare.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/connectivity/vertex_index_range.cpp b/src/terrainlib/mesh/connectivity/vertex_index_range.cpp index 1e26a30e..b13f796a 100644 --- a/src/terrainlib/mesh/connectivity/vertex_index_range.cpp +++ b/src/terrainlib/mesh/connectivity/vertex_index_range.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/connectivity/vertex_index_range.h b/src/terrainlib/mesh/connectivity/vertex_index_range.h index 7763bebe..2ad7188a 100644 --- a/src/terrainlib/mesh/connectivity/vertex_index_range.h +++ b/src/terrainlib/mesh/connectivity/vertex_index_range.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/convert.cpp b/src/terrainlib/mesh/convert.cpp index 774c4a9a..e579406e 100644 --- a/src/terrainlib/mesh/convert.cpp +++ b/src/terrainlib/mesh/convert.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 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 #include #include diff --git a/src/terrainlib/mesh/convert.h b/src/terrainlib/mesh/convert.h index 6be180ba..0eb51661 100644 --- a/src/terrainlib/mesh/convert.h +++ b/src/terrainlib/mesh/convert.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/encode.h b/src/terrainlib/mesh/encode.h index eca66508..8cd3fadc 100644 --- a/src/terrainlib/mesh/encode.h +++ b/src/terrainlib/mesh/encode.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -6,7 +25,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include "mesh/EncodedMesh.h" #include "mesh/SimpleMesh.h" #include "mesh/io/texture.h" diff --git a/src/terrainlib/mesh/geometry.cpp b/src/terrainlib/mesh/geometry.cpp index 9dcf69e9..64780e09 100644 --- a/src/terrainlib/mesh/geometry.cpp +++ b/src/terrainlib/mesh/geometry.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/geometry.h b/src/terrainlib/mesh/geometry.h index dc40208a..2430b564 100644 --- a/src/terrainlib/mesh/geometry.h +++ b/src/terrainlib/mesh/geometry.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/geometry.inl b/src/terrainlib/mesh/geometry.inl index cd52114c..68ef2bb8 100644 --- a/src/terrainlib/mesh/geometry.inl +++ b/src/terrainlib/mesh/geometry.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/holes.cpp b/src/terrainlib/mesh/holes.cpp index cf23506b..ff98fbb5 100644 --- a/src/terrainlib/mesh/holes.cpp +++ b/src/terrainlib/mesh/holes.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include #include @@ -13,7 +31,7 @@ #include #include "containers/FixedVector.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/connectivity/connected_components.h" #include "mesh/holes.h" #include "mesh/connectivity/boundary.h" diff --git a/src/terrainlib/mesh/holes.h b/src/terrainlib/mesh/holes.h index ad56deea..71d77a5c 100644 --- a/src/terrainlib/mesh/holes.h +++ b/src/terrainlib/mesh/holes.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/igl/convert.h b/src/terrainlib/mesh/igl/convert.h index 14a50346..3933c6a6 100644 --- a/src/terrainlib/mesh/igl/convert.h +++ b/src/terrainlib/mesh/igl/convert.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/igl/cut.cpp b/src/terrainlib/mesh/igl/cut.cpp index 3b67101c..ee8d03f0 100644 --- a/src/terrainlib/mesh/igl/cut.cpp +++ b/src/terrainlib/mesh/igl/cut.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/igl/cut.h b/src/terrainlib/mesh/igl/cut.h index ff22d52e..487f056b 100644 --- a/src/terrainlib/mesh/igl/cut.h +++ b/src/terrainlib/mesh/igl/cut.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/igl/cut.inl b/src/terrainlib/mesh/igl/cut.inl index 459a3e60..b9d634e4 100644 --- a/src/terrainlib/mesh/igl/cut.inl +++ b/src/terrainlib/mesh/igl/cut.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/igl/cut_to_disk.cpp b/src/terrainlib/mesh/igl/cut_to_disk.cpp index 4c56a83f..3cd186e8 100644 --- a/src/terrainlib/mesh/igl/cut_to_disk.cpp +++ b/src/terrainlib/mesh/igl/cut_to_disk.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/igl/cut_to_disk.h b/src/terrainlib/mesh/igl/cut_to_disk.h index 1b519e3c..4cc0e374 100644 --- a/src/terrainlib/mesh/igl/cut_to_disk.h +++ b/src/terrainlib/mesh/igl/cut_to_disk.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/igl/cut_to_disk.inl b/src/terrainlib/mesh/igl/cut_to_disk.inl index 11462ce0..ac2518a8 100644 --- a/src/terrainlib/mesh/igl/cut_to_disk.inl +++ b/src/terrainlib/mesh/igl/cut_to_disk.inl @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/igl/igl.cpp b/src/terrainlib/mesh/igl/igl.cpp index 1ee6974a..31ff277e 100644 --- a/src/terrainlib/mesh/igl/igl.cpp +++ b/src/terrainlib/mesh/igl/igl.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 . + *****************************************************************************/ + // single file that includes all .cpp's that use libigl #include "cut.cpp" diff --git a/src/terrainlib/mesh/igl/manifold.cpp b/src/terrainlib/mesh/igl/manifold.cpp index c554d2cb..f7aa6ff5 100644 --- a/src/terrainlib/mesh/igl/manifold.cpp +++ b/src/terrainlib/mesh/igl/manifold.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/igl/manifold.h b/src/terrainlib/mesh/igl/manifold.h index 9262ff84..418f7b08 100644 --- a/src/terrainlib/mesh/igl/manifold.h +++ b/src/terrainlib/mesh/igl/manifold.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/igl/orient.cpp b/src/terrainlib/mesh/igl/orient.cpp index a61679bb..5760d807 100644 --- a/src/terrainlib/mesh/igl/orient.cpp +++ b/src/terrainlib/mesh/igl/orient.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/src/terrainlib/mesh/igl/orient.h b/src/terrainlib/mesh/igl/orient.h index 029c22db..bb37ae88 100644 --- a/src/terrainlib/mesh/igl/orient.h +++ b/src/terrainlib/mesh/igl/orient.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/io.cpp b/src/terrainlib/mesh/io.cpp index a80cc30f..cde918cc 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -1,5 +1,24 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adam Celarek-Litofcenko + * + * 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 "mesh/io.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/io/gltf.h" #include "mesh/codec/SfMesh.h" #include "mesh/validate.h" @@ -37,7 +56,7 @@ Expected save_to_path( std::filesystem::path node_path = path; node_path.replace_extension(); const mesh::codec::SfMesh codec; - return codec.write(node_path, mesh, mesh::EncodeOptions{.texture_format = options.texture_format}); + return codec.write(node_path, mesh, mesh::EncodeOptions { .texture_format = options.texture_format }, options.write_mode).transform([](auto&&) { }); } else { return Error::fail(Error::Code::Unsupported, "unsupported mesh output format: " + extension.string()); } diff --git a/src/terrainlib/mesh/io.h b/src/terrainlib/mesh/io.h index d05242ec..28e16c4e 100644 --- a/src/terrainlib/mesh/io.h +++ b/src/terrainlib/mesh/io.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index 25ed2c84..a8dc2972 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -1,3 +1,23 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adrian Gawor + * Copyright (C) 2023 Adam Celarek-Litofcenko + * + * 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 @@ -6,6 +26,7 @@ #include #include #include +#include #define CGLTF_IMPLEMENTATION #define CGLTF_WRITE_IMPLEMENTATION @@ -15,7 +36,7 @@ #undef CGLTF_WRITE_IMPLEMENTATION #include "io/utils.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/io/gltf.h" #include "mesh/io/texture.h" @@ -274,7 +295,8 @@ Expected write_serialized( const std::filesystem::path& path, const std::span json, const cgltf_data& data, - const bool binary_output) + const bool binary_output, + const ::io::byte::WriteMode mode) try { constexpr std::uint32_t glb_magic = 0x46546C67; constexpr std::uint32_t glb_version = 2; @@ -312,13 +334,20 @@ try { } } - const std::error_code directory_error = ::io::utils::create_parent_directories(path); - if (directory_error) { - return Error::fail(Error::Code::Io, "create parent directories for", path, directory_error); + if (mode == ::io::byte::WriteMode::Replace) { + return Error::fail(Error::Code::Unsupported, "replace glTF file", path); + } + auto directories = ::io::utils::create_parent_directories(path); + if (!directories) { + return Error::propagate(std::move(directories), "write glTF file"); } - std::ofstream file(path, std::ios::binary); + std::ofstream file(path, std::ios::binary | (mode == ::io::byte::WriteMode::CreateNew ? std::ios::noreplace : std::ios::trunc)); if (!file.is_open()) { + std::error_code error; + if (mode == ::io::byte::WriteMode::CreateNew && std::filesystem::exists(std::filesystem::symlink_status(path, error))) { + return Error::fail(Error::Code::AlreadyExists, "create glTF file", path); + } return Error::fail(Error::Code::Io, "open glTF file for writing", path); } @@ -874,7 +903,7 @@ Expected save_to_path( if (!serialized) { return Error::propagate(std::move(serialized), "serialize glTF output"); } - auto written = write_serialized(path, *serialized, data, binary_output); + auto written = write_serialized(path, *serialized, data, binary_output, options.write_mode); if (!written) { return Error::propagate(std::move(written), "write glTF output"); } diff --git a/src/terrainlib/mesh/io/gltf.h b/src/terrainlib/mesh/io/gltf.h index 02b5bf20..afd587e0 100644 --- a/src/terrainlib/mesh/io/gltf.h +++ b/src/terrainlib/mesh/io/gltf.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/io/options.h b/src/terrainlib/mesh/io/options.h index 332ade5e..55216cbf 100644 --- a/src/terrainlib/mesh/io/options.h +++ b/src/terrainlib/mesh/io/options.h @@ -1,8 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" namespace mesh::io { @@ -13,6 +33,7 @@ struct SaveOptions { std::string texture_format = ".jpeg"; std::string name = "Octree"; std::unordered_map metadata = {}; + ::io::byte::WriteMode write_mode = ::io::byte::WriteMode::Overwrite; }; } // namespace mesh::io diff --git a/src/terrainlib/mesh/io/texture.cpp b/src/terrainlib/mesh/io/texture.cpp index ff0116b0..e3733060 100644 --- a/src/terrainlib/mesh/io/texture.cpp +++ b/src/terrainlib/mesh/io/texture.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "mesh/io/texture.h" namespace mesh::io { diff --git a/src/terrainlib/mesh/io/texture.h b/src/terrainlib/mesh/io/texture.h index e110878e..8dd79f19 100644 --- a/src/terrainlib/mesh/io/texture.h +++ b/src/terrainlib/mesh/io/texture.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/io/utils.cpp b/src/terrainlib/mesh/io/utils.cpp index 97cd047c..7126798f 100644 --- a/src/terrainlib/mesh/io/utils.cpp +++ b/src/terrainlib/mesh/io/utils.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "mesh/io/utils.h" namespace mesh::io::utils { diff --git a/src/terrainlib/mesh/io/utils.h b/src/terrainlib/mesh/io/utils.h index 2b5cf8c0..b63c8e85 100644 --- a/src/terrainlib/mesh/io/utils.h +++ b/src/terrainlib/mesh/io/utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merge.h b/src/terrainlib/mesh/merge.h index 5d984185..294e51e8 100644 --- a/src/terrainlib/mesh/merge.h +++ b/src/terrainlib/mesh/merge.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/merging/EpsilonVertexDeduplicate.h b/src/terrainlib/mesh/merging/EpsilonVertexDeduplicate.h index eea505f5..98399d7e 100644 --- a/src/terrainlib/mesh/merging/EpsilonVertexDeduplicate.h +++ b/src/terrainlib/mesh/merging/EpsilonVertexDeduplicate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merging/ExactVertexDeduplicate.h b/src/terrainlib/mesh/merging/ExactVertexDeduplicate.h index d978e9c8..a06cd857 100644 --- a/src/terrainlib/mesh/merging/ExactVertexDeduplicate.h +++ b/src/terrainlib/mesh/merging/ExactVertexDeduplicate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merging/SphereProjectionVertexDeduplicate.h b/src/terrainlib/mesh/merging/SphereProjectionVertexDeduplicate.h index 5c63539e..b0335fb9 100644 --- a/src/terrainlib/mesh/merging/SphereProjectionVertexDeduplicate.h +++ b/src/terrainlib/mesh/merging/SphereProjectionVertexDeduplicate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "mesh/merging/EpsilonVertexDeduplicate.h" diff --git a/src/terrainlib/mesh/merging/VertexDeduplicate.h b/src/terrainlib/mesh/merging/VertexDeduplicate.h index f8cbc4b7..256f133f 100644 --- a/src/terrainlib/mesh/merging/VertexDeduplicate.h +++ b/src/terrainlib/mesh/merging/VertexDeduplicate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merging/VertexId.h b/src/terrainlib/mesh/merging/VertexId.h index b07793ba..ab1fdb50 100644 --- a/src/terrainlib/mesh/merging/VertexId.h +++ b/src/terrainlib/mesh/merging/VertexId.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merging/VertexMapping.h b/src/terrainlib/mesh/merging/VertexMapping.h index 6a33e8df..30b94b6a 100644 --- a/src/terrainlib/mesh/merging/VertexMapping.h +++ b/src/terrainlib/mesh/merging/VertexMapping.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 @@ -13,7 +32,7 @@ #include "containers/SegmentedBuffer.h" #include "containers/HybridIndexPairMap.h" #include "mesh/merging/VertexId.h" -#include "log.h" +#include "cli_support/log.h" namespace mesh::merging { diff --git a/src/terrainlib/mesh/merging/helpers.h b/src/terrainlib/mesh/merging/helpers.h index 1f0ccc72..dbfeb6e0 100644 --- a/src/terrainlib/mesh/merging/helpers.h +++ b/src/terrainlib/mesh/merging/helpers.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/merging/mapping.cpp b/src/terrainlib/mesh/merging/mapping.cpp index 1b8e0dbc..3b7e0a56 100644 --- a/src/terrainlib/mesh/merging/mapping.cpp +++ b/src/terrainlib/mesh/merging/mapping.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -17,7 +36,7 @@ #include "build_config.h" #include "containers/UnionFind.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/convert.h" #include "mesh/connectivity/boundary.h" #include "mesh/merging/EpsilonVertexDeduplicate.h" diff --git a/src/terrainlib/mesh/merging/mapping.h b/src/terrainlib/mesh/merging/mapping.h index ef242d1f..00714ecd 100644 --- a/src/terrainlib/mesh/merging/mapping.h +++ b/src/terrainlib/mesh/merging/mapping.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/normalize.h b/src/terrainlib/mesh/normalize.h index cf925093..441d7afa 100644 --- a/src/terrainlib/mesh/normalize.h +++ b/src/terrainlib/mesh/normalize.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/normalize.inl b/src/terrainlib/mesh/normalize.inl index 6c4feda5..71052280 100644 --- a/src/terrainlib/mesh/normalize.inl +++ b/src/terrainlib/mesh/normalize.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/reindex.h b/src/terrainlib/mesh/reindex.h index 42b60698..97dd60ee 100644 --- a/src/terrainlib/mesh/reindex.h +++ b/src/terrainlib/mesh/reindex.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/reindex.inl b/src/terrainlib/mesh/reindex.inl index ca33571c..74ba9106 100644 --- a/src/terrainlib/mesh/reindex.inl +++ b/src/terrainlib/mesh/reindex.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/mesh/split.h b/src/terrainlib/mesh/split.h index 8ca83ef2..003172d1 100644 --- a/src/terrainlib/mesh/split.h +++ b/src/terrainlib/mesh/split.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/mesh/split.inl b/src/terrainlib/mesh/split.inl index 0c961fc1..3a208e32 100644 --- a/src/terrainlib/mesh/split.inl +++ b/src/terrainlib/mesh/split.inl @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/storage.h b/src/terrainlib/mesh/storage.h index 8a84830d..84339565 100644 --- a/src/terrainlib/mesh/storage.h +++ b/src/terrainlib/mesh/storage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -7,6 +25,7 @@ #include "mesh/codec/from_extension.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" +#include "octree/storage/snapshot.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" @@ -33,4 +52,16 @@ inline Expected open_folder_indexed(const std::filesystem::path& return octree::storage::open_folder_indexed(path, std::string(payload_class), ".sfmesh", mesh::codec::from_extension, std::move(options)); } +// Creates an empty storage in .part; see store::snapshot. +inline Expected create(const std::filesystem::path& path, OpenOptions options = {}) +{ + return octree::storage::create(path, + std::string(payload_class), + options.preferred_extension.value_or(".sfmesh"), + mesh::codec::from_extension, + options.default_mapping.value_or(octree::storage::default_mapping())); +} + +inline Expected publish(IndexedStorage storage) { return octree::storage::publish(std::move(storage)); } + } // namespace mesh::storage diff --git a/src/terrainlib/mesh/texture_trim.cpp b/src/terrainlib/mesh/texture_trim.cpp index 17c70a41..c8d90e6f 100644 --- a/src/terrainlib/mesh/texture_trim.cpp +++ b/src/terrainlib/mesh/texture_trim.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include #include @@ -8,7 +26,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include "mesh/texture_trim.h" #include "range_utils.h" diff --git a/src/terrainlib/mesh/texture_trim.h b/src/terrainlib/mesh/texture_trim.h index 70d761ad..02a7c93a 100644 --- a/src/terrainlib/mesh/texture_trim.h +++ b/src/terrainlib/mesh/texture_trim.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/mesh/utils.h b/src/terrainlib/mesh/utils.h index c6e949d3..22eb5b7c 100644 --- a/src/terrainlib/mesh/utils.h +++ b/src/terrainlib/mesh/utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "mesh/connectivity/boundary.h" diff --git a/src/terrainlib/mesh/validate.cpp b/src/terrainlib/mesh/validate.cpp index 10f6e87a..7a87d829 100644 --- a/src/terrainlib/mesh/validate.cpp +++ b/src/terrainlib/mesh/validate.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -9,7 +28,7 @@ #include #include "build_config.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/cleanup.h" #include "mesh/connectivity/connected_components.h" #include "mesh/convert.h" diff --git a/src/terrainlib/mesh/validate.h b/src/terrainlib/mesh/validate.h index 59283361..0d3fd4e4 100644 --- a/src/terrainlib/mesh/validate.h +++ b/src/terrainlib/mesh/validate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/validate.inl b/src/terrainlib/mesh/validate.inl index 29f0d3a6..ed3b1514 100644 --- a/src/terrainlib/mesh/validate.inl +++ b/src/terrainlib/mesh/validate.inl @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/mesh/validate_cgal.h b/src/terrainlib/mesh/validate_cgal.h index 44949a3b..15426211 100644 --- a/src/terrainlib/mesh/validate_cgal.h +++ b/src/terrainlib/mesh/validate_cgal.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "cgal.h" diff --git a/src/terrainlib/mesh/validate_cgal.inl b/src/terrainlib/mesh/validate_cgal.inl index 1d0f0b25..c6d58cff 100644 --- a/src/terrainlib/mesh/validate_cgal.inl +++ b/src/terrainlib/mesh/validate_cgal.inl @@ -1,8 +1,26 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include "build_config.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/validate_cgal.h" namespace cgal { diff --git a/src/terrainlib/numeric/StrongDouble.h b/src/terrainlib/numeric/StrongDouble.h index d94ca684..8c6d4fe7 100644 --- a/src/terrainlib/numeric/StrongDouble.h +++ b/src/terrainlib/numeric/StrongDouble.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/numeric/glm_math.h b/src/terrainlib/numeric/glm_math.h index 6870e5f7..1f4523c0 100644 --- a/src/terrainlib/numeric/glm_math.h +++ b/src/terrainlib/numeric/glm_math.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "numeric/int_math.h" diff --git a/src/terrainlib/numeric/int_math.h b/src/terrainlib/numeric/int_math.h index 4dc29b1a..59a45d84 100644 --- a/src/terrainlib/numeric/int_math.h +++ b/src/terrainlib/numeric/int_math.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/numeric/number_types.h b/src/terrainlib/numeric/number_types.h index 218c700f..899eb0ed 100644 --- a/src/terrainlib/numeric/number_types.h +++ b/src/terrainlib/numeric/number_types.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 // -------------- [u]std::int128_t -------------- diff --git a/src/terrainlib/numeric/number_utils.h b/src/terrainlib/numeric/number_utils.h index 575bef17..36ae20a5 100644 --- a/src/terrainlib/numeric/number_utils.h +++ b/src/terrainlib/numeric/number_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/numeric/quantize.h b/src/terrainlib/numeric/quantize.h index 78e658cc..5c2eb04b 100644 --- a/src/terrainlib/numeric/quantize.h +++ b/src/terrainlib/numeric/quantize.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/numeric/wide_integer.h b/src/terrainlib/numeric/wide_integer.h index 64f46888..5c5bd5dc 100644 --- a/src/terrainlib/numeric/wide_integer.h +++ b/src/terrainlib/numeric/wide_integer.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 // Must precede the uintwide_t.h include: it is only honoured on the header's first diff --git a/src/terrainlib/octree/Id.h b/src/terrainlib/octree/Id.h index f1619cb3..b6bfdb37 100644 --- a/src/terrainlib/octree/Id.h +++ b/src/terrainlib/octree/Id.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/IdRect.h b/src/terrainlib/octree/IdRect.h index 3edb8b42..4fba61d8 100644 --- a/src/terrainlib/octree/IdRect.h +++ b/src/terrainlib/octree/IdRect.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/octree/OddLevelShifted.cpp b/src/terrainlib/octree/OddLevelShifted.cpp index a1d80670..f0ea86e9 100644 --- a/src/terrainlib/octree/OddLevelShifted.cpp +++ b/src/terrainlib/octree/OddLevelShifted.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/src/terrainlib/octree/OddLevelShifted.h b/src/terrainlib/octree/OddLevelShifted.h index 5e784d73..416ca0ed 100644 --- a/src/terrainlib/octree/OddLevelShifted.h +++ b/src/terrainlib/octree/OddLevelShifted.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/octree/Space.cpp b/src/terrainlib/octree/Space.cpp index 996a3e77..c33742b3 100644 --- a/src/terrainlib/octree/Space.cpp +++ b/src/terrainlib/octree/Space.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "octree/Space.h" namespace octree { diff --git a/src/terrainlib/octree/Space.h b/src/terrainlib/octree/Space.h index 94ee7894..eb1ac215 100644 --- a/src/terrainlib/octree/Space.h +++ b/src/terrainlib/octree/Space.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/octree/StoreTraits.h b/src/terrainlib/octree/StoreTraits.h index 67334210..35070ddb 100644 --- a/src/terrainlib/octree/StoreTraits.h +++ b/src/terrainlib/octree/StoreTraits.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index c1288e73..f993414b 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -1,12 +1,33 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 -#include "io/envelope_file.h" +#include "io/byte.h" +#include "io/envelope.h" #include "octree/StoreTraits.h" #include "octree/store_layout/Mappings.h" #include "store/Index.h" @@ -31,8 +52,15 @@ namespace v1 { std::uint8_t status; }; + struct HashEntry { + std::uint8_t level; + std::uint64_t index; + store::NodeDataHash hash; + }; + struct StoreIndex { std::vector entries; + std::vector hashes; }; } // namespace v1 @@ -57,8 +85,9 @@ inline Expected validate(const StoreMetadata& metadata) return {}; } -inline StoreIndex encode_index(const store::Index& index) +inline StoreIndex encode_index(const store::Index& index, const store::Key2Hash_Map& hashes) { + const auto by_id = [](const auto& left, const auto& right) { return std::tie(left.level, left.index) < std::tie(right.level, right.index); }; StoreIndex result; result.entries.reserve(index.size()); for (const auto& [id, status] : index) { @@ -68,12 +97,23 @@ inline StoreIndex encode_index(const store::Index& index) .status = static_cast(static_cast(status)), }); } + std::ranges::sort(result.entries, by_id); + result.hashes.reserve(hashes.size()); + for (const auto& [id, hash] : hashes) { + result.hashes.push_back({ + .level = id.level(), + .index = id.index_on_level(), + .hash = hash, + }); + } + std::ranges::sort(result.hashes, by_id); return result; } -inline Expected> decode_index(const StoreIndex& encoded) +inline Expected> decode_index(const StoreIndex& encoded) { - store::Index result; + store::IndexContents decoded; + store::Index& result = decoded.index; std::unordered_set seen; for (const v1::IndexEntry& entry : encoded.entries) { const auto id = Id::try_make(static_cast(entry.level), static_cast(entry.index)); @@ -92,44 +132,25 @@ inline Expected> decode_index(const StoreIndex& encode } } - for (const auto& [id, status] : result) { - const auto children = StoreTraits::children(id); - bool has_child = false; - if (children) { - for (const Id& child : *children) { - auto child_status = result.get(child); - if (!child_status) { - return Error::propagate(std::move(child_status), Error::Code::CorruptData, "look up child while validating store index"); - } - has_child = has_child || child_status->has_value(); - } - } - if ((status == store::NodeStatus::Leaf && has_child) || ((status == store::NodeStatus::Inner || status == store::NodeStatus::Virtual) && !has_child)) { - return Error::fail(Error::Code::CorruptData, "index contains inconsistent node topology"); - } - - const auto parent = StoreTraits::parent(id); - if (!parent) { - if (id != StoreTraits::root()) { - return Error::fail(Error::Code::CorruptData, "index contains a non-root node without a parent"); - } - continue; - } - auto parent_status = result.get(*parent); - if (!parent_status) { - return Error::propagate(std::move(parent_status), Error::Code::CorruptData, "look up parent while validating store index"); + if (auto valid = result.validate(); !valid) { + return Error::propagate(std::move(valid), "validate octree index topology"); + } + for (const v1::HashEntry& entry : encoded.hashes) { + const auto id = Id::try_make(static_cast(entry.level), static_cast(entry.index)); + if (!id) { + return Error::fail(Error::Code::CorruptData, "index contains a hash for an invalid octree ID"); } - if (!parent_status->has_value() || parent_status->value() == store::NodeStatus::Leaf) { - return Error::fail(Error::Code::CorruptData, "index contains a node without a valid indexed parent"); + if (!decoded.hashes.emplace(*id, entry.hash).second) { + return Error::fail(Error::Code::CorruptData, "index contains a duplicate octree node hash"); } } - return result; + return decoded; } inline Expected read_store_metadata(const std::filesystem::path& base_path) { const std::filesystem::path path = base_path / metadata_file_name; - auto metadata = io::envelope::read_from_path(path); + auto metadata = io::envelope::read(path); if (!metadata) { return metadata; } @@ -146,16 +167,17 @@ inline Expected> read_index_file(const std::fi return Error::propagate(std::move(metadata), "read octree store metadata"); } - auto encoded_index = io::envelope::read_from_path(path); + auto encoded_index = io::envelope::read(path); if (!encoded_index) { return Error::propagate(std::move(encoded_index), "read octree store index \"" + path.string() + "\""); } - auto index = decode_index(*encoded_index); - if (!index) { - return Error::propagate(std::move(index), "decode store index read from \"" + path.string() + "\""); + auto contents = decode_index(*encoded_index); + if (!contents) { + return Error::propagate(std::move(contents), "decode store index read from \"" + path.string() + "\""); } return store::IndexMetadata { - std::move(*index), + std::move(contents->index), + std::move(contents->hashes), std::move(metadata->layout_id), std::move(metadata->payload_class), std::move(metadata->codec_selector), @@ -173,12 +195,12 @@ inline Expected write_index_file(const std::filesystem::path& path, const return Error::propagate(std::move(valid), "validate store metadata for writing"); } const std::filesystem::path metadata_path = path.parent_path() / metadata_file_name; - if (auto written = io::envelope::write_to_path(store_metadata, metadata_path); !written) { + if (auto written = io::envelope::write(store_metadata, metadata_path, io::byte::WriteMode::Replace); !written) { return Error::propagate(std::move(written), "write octree store metadata " + metadata_path.string()); } - const StoreIndex encoded_index = encode_index(metadata.index); - if (auto written = io::envelope::write_to_path(encoded_index, path); !written) { + const StoreIndex encoded_index = encode_index(metadata.index, metadata.hashes); + if (auto written = io::envelope::write(encoded_index, path, io::byte::WriteMode::Replace); !written) { return Error::propagate(std::move(written), "write octree store index " + path.string()); } return {}; diff --git a/src/terrainlib/octree/storage/open_runtime.h b/src/terrainlib/octree/storage/open_runtime.h index f8cee12b..ab730521 100644 --- a/src/terrainlib/octree/storage/open_runtime.h +++ b/src/terrainlib/octree/storage/open_runtime.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/storage/snapshot.h b/src/terrainlib/octree/storage/snapshot.h new file mode 100644 index 00000000..c000aa69 --- /dev/null +++ b/src/terrainlib/octree/storage/snapshot.h @@ -0,0 +1,73 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "octree/StoreTraits.h" +#include "octree/storage/IndexFile.h" +#include "store/IndexedStorage.h" +#include "store/open.h" +#include "store/snapshot.h" + +namespace octree::storage { + +// Creates an empty storage in .part; see store::snapshot. +template +Expected> create(const std::filesystem::path& final_path, + std::string payload_class, + std::string codec_selector, + CodecResolver resolve_codec, + const store::path_layout::Mapping mapping) +{ + auto codec = resolve_codec(codec_selector); + if (!codec) { + return Error::propagate(std::move(codec), "resolve octree storage codec \"" + codec_selector + "\""); + } + auto partial = store::snapshot::create_partial(final_path); + if (!partial) { + return Error::propagate(std::move(partial)); + } + auto storage + = store::make_storage(*partial, index_format(), mapping, std::move(payload_class), std::move(codec_selector), std::move(*codec)); + if (!storage) { + return Error::propagate(std::move(storage)); + } + // Even an empty incomplete snapshot has a readable checkpoint. + if (auto saved = storage->save_index(); !saved) { + return Error::propagate(std::move(saved), "create initial octree index"); + } + return store::IndexedStorage(std::move(*storage)); +} + +// Saves the index and renames .part to . +template +Expected publish(store::IndexedStorage storage) +{ + if (auto saved = storage.save_index(); !saved) { + return Error::propagate(std::move(saved), "save octree index before publication"); + } + return store::snapshot::publish(storage.base_path()); +} + +} // namespace octree::storage diff --git a/src/terrainlib/octree/store_layout/Flat.h b/src/terrainlib/octree/store_layout/Flat.h index f7f6944e..c50b23e9 100644 --- a/src/terrainlib/octree/store_layout/Flat.h +++ b/src/terrainlib/octree/store_layout/Flat.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/store_layout/LevelAndCoordinateDirectories.h b/src/terrainlib/octree/store_layout/LevelAndCoordinateDirectories.h index 2dac849b..602910e0 100644 --- a/src/terrainlib/octree/store_layout/LevelAndCoordinateDirectories.h +++ b/src/terrainlib/octree/store_layout/LevelAndCoordinateDirectories.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/store_layout/Mappings.h b/src/terrainlib/octree/store_layout/Mappings.h index ce2bb437..39536a8a 100644 --- a/src/terrainlib/octree/store_layout/Mappings.h +++ b/src/terrainlib/octree/store_layout/Mappings.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/octree/utils.h b/src/terrainlib/octree/utils.h index 934bf0a1..de76f202 100644 --- a/src/terrainlib/octree/utils.h +++ b/src/terrainlib/octree/utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/opencv_utils.h b/src/terrainlib/opencv_utils.h index af93a658..f73088d3 100644 --- a/src/terrainlib/opencv_utils.h +++ b/src/terrainlib/opencv_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/optional_utils.h b/src/terrainlib/optional_utils.h index f99d7c84..8d6170c0 100644 --- a/src/terrainlib/optional_utils.h +++ b/src/terrainlib/optional_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/pch.h b/src/terrainlib/pch.h index a936589c..77dd0944 100644 --- a/src/terrainlib/pch.h +++ b/src/terrainlib/pch.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 // Standard library @@ -37,7 +56,7 @@ #include // Internal headers -#include "log.h" +#include "cli_support/log.h" #include "hash_utils.h" #include "mesh/SimpleMesh.h" #include "octree/Id.h" diff --git a/src/terrainlib/polygon/Polygon.h b/src/terrainlib/polygon/Polygon.h index 72c99856..bb67544d 100644 --- a/src/terrainlib/polygon/Polygon.h +++ b/src/terrainlib/polygon/Polygon.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/polygon/clip.cpp b/src/terrainlib/polygon/clip.cpp index a74e8427..0187aa95 100644 --- a/src/terrainlib/polygon/clip.cpp +++ b/src/terrainlib/polygon/clip.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/polygon/clip.h b/src/terrainlib/polygon/clip.h index 8a699156..e48e2ba6 100644 --- a/src/terrainlib/polygon/clip.h +++ b/src/terrainlib/polygon/clip.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/polygon/triangulate.cpp b/src/terrainlib/polygon/triangulate.cpp index 1ce9e8b7..44087c90 100644 --- a/src/terrainlib/polygon/triangulate.cpp +++ b/src/terrainlib/polygon/triangulate.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include @@ -13,7 +31,7 @@ #include #include "build_config.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/cgal.h" #include "mesh/convert.h" #include "polygon/triangulate.h" diff --git a/src/terrainlib/polygon/triangulate.h b/src/terrainlib/polygon/triangulate.h index e16e8b32..cfd0d0d1 100644 --- a/src/terrainlib/polygon/triangulate.h +++ b/src/terrainlib/polygon/triangulate.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/polygon/utils.cpp b/src/terrainlib/polygon/utils.cpp index 2db0829b..0f581ead 100644 --- a/src/terrainlib/polygon/utils.cpp +++ b/src/terrainlib/polygon/utils.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include diff --git a/src/terrainlib/polygon/utils.h b/src/terrainlib/polygon/utils.h index d38a849e..9af6c25d 100644 --- a/src/terrainlib/polygon/utils.h +++ b/src/terrainlib/polygon/utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "polygon/Polygon.h" diff --git a/src/terrainlib/range/AnyRange.h b/src/terrainlib/range/AnyRange.h index 91e94b40..970ceb1c 100644 --- a/src/terrainlib/range/AnyRange.h +++ b/src/terrainlib/range/AnyRange.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/range/Bound.h b/src/terrainlib/range/Bound.h index 0207925a..96a13ee2 100644 --- a/src/terrainlib/range/Bound.h +++ b/src/terrainlib/range/Bound.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/range/Range.h b/src/terrainlib/range/Range.h index a1902874..af2667b2 100644 --- a/src/terrainlib/range/Range.h +++ b/src/terrainlib/range/Range.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/range/RangeBounds.h b/src/terrainlib/range/RangeBounds.h index a9d1cb94..d12e206a 100644 --- a/src/terrainlib/range/RangeBounds.h +++ b/src/terrainlib/range/RangeBounds.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/range/RangeFrom.h b/src/terrainlib/range/RangeFrom.h index 7bb1ca77..aa89fb73 100644 --- a/src/terrainlib/range/RangeFrom.h +++ b/src/terrainlib/range/RangeFrom.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "Bound.h" diff --git a/src/terrainlib/range/RangeFull.h b/src/terrainlib/range/RangeFull.h index 5da536dc..e61cb8a6 100644 --- a/src/terrainlib/range/RangeFull.h +++ b/src/terrainlib/range/RangeFull.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "Bound.h" diff --git a/src/terrainlib/range/RangeInclusive.h b/src/terrainlib/range/RangeInclusive.h index baa247a4..f0a1f10f 100644 --- a/src/terrainlib/range/RangeInclusive.h +++ b/src/terrainlib/range/RangeInclusive.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "Bound.h" diff --git a/src/terrainlib/range/RangeTo.h b/src/terrainlib/range/RangeTo.h index ccdc7fc4..890ebec0 100644 --- a/src/terrainlib/range/RangeTo.h +++ b/src/terrainlib/range/RangeTo.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "Bound.h" diff --git a/src/terrainlib/range/RangeToInclusive.h b/src/terrainlib/range/RangeToInclusive.h index 71de400b..9d070156 100644 --- a/src/terrainlib/range/RangeToInclusive.h +++ b/src/terrainlib/range/RangeToInclusive.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "Bound.h" diff --git a/src/terrainlib/range_utils.h b/src/terrainlib/range_utils.h index 91e1beca..7ea6f779 100644 --- a/src/terrainlib/range_utils.h +++ b/src/terrainlib/range_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/raster/ClampedView.h b/src/terrainlib/raster/ClampedView.h new file mode 100644 index 00000000..766fd176 --- /dev/null +++ b/src/terrainlib/raster/ClampedView.h @@ -0,0 +1,140 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "View.h" + +namespace raster { + +namespace view_details { + struct ClampedAccess; +} + +/// Read-only logical rectangle whose source coordinates clamp to source edges. +template +class ClampedView { +public: + using value_type = std::remove_const_t; + using element_type = const value_type; + + [[nodiscard]] unsigned width() const { return m_size.x; } + [[nodiscard]] unsigned height() const { return m_size.y; } + [[nodiscard]] glm::uvec2 size() const { return m_size; } + + [[nodiscard]] const value_type& pixel(glm::uvec2 position) const + { + assert(position.x < width() && position.y < height()); + return m_source.pixel(source_position(position)); + } + +private: + friend struct view_details::ClampedAccess; + + ClampedView(View source, glm::i64vec2 origin, glm::uvec2 size) + : m_source(source) + , m_origin(origin) + , m_size(size) + { + } + + glm::uvec2 source_position(glm::uvec2 position) const + { + return { + std::clamp(m_origin.x + position.x, std::int64_t(0), std::int64_t(m_source.width()) - 1), + std::clamp(m_origin.y + position.y, std::int64_t(0), std::int64_t(m_source.height()) - 1), + }; + } + + View m_source; + glm::i64vec2 m_origin; + glm::uvec2 m_size; +}; + +namespace view_details { + struct ClampedAccess { + template + static ClampedView> create(const View& source, glm::i64vec2 origin, glm::uvec2 size) + { + // Origins beyond these limits have identical sampling behavior. + // Bounding them also makes subsequent coordinate additions safe. + origin.x = std::clamp(origin.x, -std::int64_t(size.x), std::int64_t(source.width())); + origin.y = std::clamp(origin.y, -std::int64_t(size.y), std::int64_t(source.height())); + return { source, origin, size }; + } + + template + static ClampedView region(const ClampedView& source, glm::uvec2 origin, glm::uvec2 size) + { + return { source.m_source, source.m_origin + glm::i64vec2(origin), size }; + } + + template + static Footprint footprint(const ClampedView& source) + { + auto result = Access::footprint(source.m_source); + if (source.width() == 0 || source.height() == 0) { + result.size = { 0, 0 }; + return result; + } + const auto first = source.source_position({ 0, 0 }); + const auto last = source.source_position(source.size() - glm::uvec2(1)); + result.origin += first; + result.size = last - first + glm::uvec2(1); + return result; + } + }; + +} // namespace view_details + +template +[[nodiscard]] ClampedView make_view(const ClampedView& source) +{ + return source; +} + +template +[[nodiscard]] Expected> make_view(const ClampedView& source, glm::i64vec2 origin, glm::uvec2 size) +{ + if (!view_details::region_fits(source.size(), origin, size)) { + return Error::fail(Error::Code::InvalidInput, "clamped view region is outside its logical bounds"); + } + return view_details::ClampedAccess::region(source, glm::uvec2(origin), size); +} + +template +requires requires(Source&& source) { + make_view(std::forward(source)); + requires view_details::is_ordinary_view(source)))>; +} +[[nodiscard]] auto make_clamped_view(Source&& source, glm::i64vec2 origin, glm::uvec2 size) + -> Expected(source)))::value_type>> +{ + const auto view = make_view(std::forward(source)); + if (size.x != 0 && size.y != 0 && (view.width() == 0 || view.height() == 0)) { + return Error::fail(Error::Code::InvalidInput, "a nonempty clamped view needs a nonempty source"); + } + return view_details::ClampedAccess::create(view, origin, size); +} + +} // namespace raster diff --git a/src/terrainlib/raster/View.h b/src/terrainlib/raster/View.h new file mode 100644 index 00000000..73037ba8 --- /dev/null +++ b/src/terrainlib/raster/View.h @@ -0,0 +1,167 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include + +#include "Error.h" + +namespace raster { + +namespace view_details { + struct Access; + + struct Footprint { + const void* storage; + glm::uvec2 origin; + glm::uvec2 size; + }; +} // namespace view_details + +/// Borrows a rectangular region. The backing raster must remain alive and stable. +template +class View { +public: + using element_type = T; + using value_type = std::remove_const_t; + + View() = default; + + template + requires std::is_const_v && (!std::is_const_v) && std::same_as + View(const View& source) + : m_data(source.m_data) + , m_origin(source.m_origin) + , m_size(source.m_size) + , m_stride(source.m_stride) + { + } + + [[nodiscard]] unsigned width() const { return m_size.x; } + [[nodiscard]] unsigned height() const { return m_size.y; } + [[nodiscard]] glm::uvec2 size() const { return m_size; } + [[nodiscard]] unsigned stride() const { return m_stride; } + [[nodiscard]] std::size_t offset() const { return std::size_t(m_origin.y) * m_stride + m_origin.x; } + + /// Descriptor constness does not change pixel constness. Coordinates must fit. + [[nodiscard]] T& pixel(glm::uvec2 position) const + { + assert(position.x < width() && position.y < height()); + return m_data[(std::size_t(m_origin.y) + position.y) * m_stride + m_origin.x + position.x]; + } + +private: + friend struct view_details::Access; + template + friend class View; + + View(T* data, glm::uvec2 origin, glm::uvec2 size, unsigned stride) + : m_data(data) + , m_origin(origin) + , m_size(size) + , m_stride(stride) + { + } + + T* m_data = nullptr; + glm::uvec2 m_origin { 0 }; + glm::uvec2 m_size { 0 }; + unsigned m_stride = 0; +}; + +namespace view_details { + template + inline constexpr bool is_ordinary_view = false; + template + inline constexpr bool is_ordinary_view> = true; + + struct Access { + template + static View whole(T* data, glm::uvec2 size) + { + return View(data, { 0, 0 }, size, size.x); + } + + // The caller has validated that the region fits the logical view. + template + static View region(const View& source, glm::uvec2 origin, glm::uvec2 size) + { + return View(source.m_data, source.m_origin + origin, size, source.m_stride); + } + + template + static Footprint footprint(const View& source) + { + return { source.m_data, source.m_origin, source.m_size }; + } + }; + + inline bool region_fits(glm::uvec2 source_size, glm::i64vec2 origin, glm::uvec2 size) + { + return origin.x >= 0 && origin.y >= 0 && origin.x <= source_size.x && origin.y <= source_size.y && size.x <= source_size.x - origin.x + && size.y <= source_size.y - origin.y; + } +} // namespace view_details + +template +[[nodiscard]] View make_view(radix::Raster& source) +{ + return view_details::Access::whole(source.data(), source.size()); +} + +template +[[nodiscard]] View make_view(const radix::Raster& source) +{ + return view_details::Access::whole(source.data(), source.size()); +} + +template +void make_view(radix::Raster&&) = delete; +template +void make_view(const radix::Raster&&) = delete; + +template +[[nodiscard]] View make_view(const View& source) +{ + return source; +} + +template +requires requires(Source&& source) { + make_view(std::forward(source)); + requires view_details::is_ordinary_view(source)))>; +} +[[nodiscard]] auto make_view(Source&& source, glm::i64vec2 origin, glm::uvec2 size) -> Expected(source)))> +{ + const auto view = make_view(std::forward(source)); + if (!view_details::region_fits(view.size(), origin, size)) { + return Error::fail(Error::Code::InvalidInput, "raster view region is outside its source"); + } + return view_details::Access::region(view, glm::uvec2(origin), size); +} + +} // namespace raster diff --git a/src/terrainlib/raster/algorithm.h b/src/terrainlib/raster/algorithm.h new file mode 100644 index 00000000..31ef81fa --- /dev/null +++ b/src/terrainlib/raster/algorithm.h @@ -0,0 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "algorithm/conversion.h" +#include "algorithm/copy.h" +#include "algorithm/fold.h" +#include "algorithm/reduce.h" +#include "algorithm/scale.h" +#include "algorithm/transform.h" +#include "algorithm/window_transform.h" +#include "algorithm/zip_transform.h" diff --git a/src/terrainlib/raster/algorithm/conversion.h b/src/terrainlib/raster/algorithm/conversion.h new file mode 100644 index 00000000..b99cad2f --- /dev/null +++ b/src/terrainlib/raster/algorithm/conversion.h @@ -0,0 +1,165 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include + +#include + +namespace raster::algorithm { + +namespace detail { + template + struct PixelTraits { + using Scalar = T; + template + using Rebind = S; + static constexpr unsigned components = 1; + static T& component(T& pixel, unsigned) { return pixel; } + static const T& component(const T& pixel, unsigned) { return pixel; } + }; + + template + struct PixelTraits> { + using Scalar = T; + template + using Rebind = glm::vec; + static constexpr unsigned components = N; + static T& component(glm::vec& pixel, unsigned i) { return pixel[static_cast(i)]; } + static const T& component(const glm::vec& pixel, unsigned i) { return pixel[static_cast(i)]; } + }; + + template + concept NumericPixel = std::same_as> + && ((std::is_integral_v::Scalar> && !std::same_as::Scalar, bool> + && sizeof(typename PixelTraits::Scalar) <= 8) + || std::is_floating_point_v::Scalar>); + + template + concept FloatingPixel = NumericPixel && std::is_floating_point_v::Scalar>; + + template + using LinearScalar = std::conditional_t || (std::is_integral_v && sizeof(T) <= 2), + float, + std::conditional_t || sizeof(T) <= 4, double, long double>>; + + template + using LinearPixel = typename PixelTraits::template Rebind::Scalar>>; + + template + inline constexpr bool srgb_pixel + = std::same_as::Scalar, std::uint8_t> && (PixelTraits::components == 3 || PixelTraits::components == 4); + + template + struct IsConversionTuple : std::false_type { }; + + template + struct IsConversionTuple> : std::true_type { }; + + template + using DecodedPixel = std::invoke_result_t&, const T&>; + + template + concept PixelConversion = IsConversionTuple::value && std::invocable&, const T&> + && NumericPixel> && std::invocable&, const DecodedPixel&> + && std::same_as&, const DecodedPixel&>, T>; + + template + concept ScalingConversion = PixelConversion && FloatingPixel>; + + template + T encode_linear(const W& pixel) + { + using S = typename PixelTraits::Scalar; + using F = typename PixelTraits::Scalar; + T result {}; + for (unsigned i = 0; i < PixelTraits::components; ++i) { + const F value = PixelTraits::component(pixel, i); + if constexpr (std::is_integral_v) { + const F rounded = std::round(value); + // Check before conversion: an integer maximum can round up when + // represented by the floating working type. + if (rounded <= static_cast((std::numeric_limits::lowest)())) { + PixelTraits::component(result, i) = (std::numeric_limits::lowest)(); + } else if (rounded >= static_cast((std::numeric_limits::max)())) { + PixelTraits::component(result, i) = (std::numeric_limits::max)(); + } else { + PixelTraits::component(result, i) = static_cast(rounded); + } + } else { + PixelTraits::component(result, i) = static_cast(value); + } + } + return result; + } +} // namespace detail + +/// No conversion; reduce uses this tuple by default. +inline auto identity_conversion() +{ + const auto identity = [](const auto& value) { return value; }; + return std::tuple { identity, identity }; +} + +/// Numeric conversion with floating working components and rounded/clamped integer output. +template +auto linear_conversion() +{ + using W = detail::LinearPixel; + return std::tuple { [](const T& value) { return W(value); }, [](const W& value) { return detail::encode_linear(value); } }; +} + +/// RGB(A) uint8 conversion to linear float values; alpha remains independent and linear. +template +requires detail::srgb_pixel +auto srgb_conversion() +{ + using W = detail::LinearPixel; + return std::tuple { [](const T& pixel) { + W result(pixel); + for (unsigned i = 0; i < detail::PixelTraits::components; ++i) { + auto& value = detail::PixelTraits::component(result, i); + value /= 255.f; + if (i < 3) { + value = value <= 0.04045f ? value / 12.92f : std::pow((value + 0.055f) / 1.055f, 2.4f); + } + } + return result; + }, + [](const W& pixel) { + W result {}; + for (unsigned i = 0; i < detail::PixelTraits::components; ++i) { + float value = std::clamp(detail::PixelTraits::component(pixel, i), 0.f, 1.f); + if (i < 3) { + value = value <= 0.0031308f ? 12.92f * value : 1.055f * std::pow(value, 1.f / 2.4f) - 0.055f; + } + detail::PixelTraits::component(result, i) = value * 255.f; + } + return detail::encode_linear(result); + } }; +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/copy.h b/src/terrainlib/raster/algorithm/copy.h new file mode 100644 index 00000000..0d84c408 --- /dev/null +++ b/src/terrainlib/raster/algorithm/copy.h @@ -0,0 +1,68 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "detail.h" + +namespace raster::algorithm { + +namespace detail { + template + requires(!std::is_const_v) && std::same_as + Expected copy_views(const InputView& source, const View& destination) + { + if (auto valid = validate_pointwise_views(source, destination); !valid) { + return valid; + } + if (destination.width() == 0 || destination.height() == 0 || identical_view_mapping(source, destination)) { + return {}; + } + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + if constexpr (std::is_trivially_copyable_v) { + std::memcpy(std::addressof(destination.pixel({ x, y })), std::addressof(source.pixel({ x, y })), sizeof(T)); + } else { + destination.pixel({ x, y }) = source.pixel({ x, y }); + } + } + } + return {}; + } +} // namespace detail + +/// Copies equal-sized regions of identical pixel types; rejects partial overlap. +template +requires std::same_as, detail::SourcePixel> +[[nodiscard]] Expected copy(Source&& source, Destination&& destination) +{ + return detail::copy_views(raster::make_view(source), raster::make_view(destination)); +} + +template +requires detail::RasterPixel> +[[nodiscard]] Expected>> copy(Source&& source) +{ + const auto input = raster::make_view(source); + return detail::produce_raster>(input.size(), [&](const auto& destination) { return detail::copy_views(input, destination); }); +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/detail.h b/src/terrainlib/raster/algorithm/detail.h new file mode 100644 index 00000000..3b1b4627 --- /dev/null +++ b/src/terrainlib/raster/algorithm/detail.h @@ -0,0 +1,166 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include + +#include "raster/ClampedView.h" + +namespace raster::algorithm::detail { + +template +concept ViewSource = requires(Source& source) { raster::make_view(source); }; + +template +using SourceView = decltype(raster::make_view(std::declval())); + +template +using SourcePixel = typename SourceView::value_type; + +template +concept WritableViewDestination + = ViewSource && view_details::is_ordinary_view> && (!std::is_const_v::element_type>); + +template +concept PixelFunction = std::invocable && std::same_as, Output>; + +template +concept RasterPixel = std::is_object_v && (!std::is_array_v) && std::same_as> && (!std::same_as) + && std::default_initializable && std::movable; + +template +using FunctionOutput = std::invoke_result_t; + +template +concept RasterFunction = std::invocable && RasterPixel>; + +template +Expected> allocate_output(glm::uvec2 size) +{ + if ((size.y != 0 && size.x > (std::numeric_limits::max)() / size.y) || std::size_t(size.x) * size.y > std::vector().max_size()) { + return Error::fail(Error::Code::ResourceExhausted, "raster output dimensions exceed storage capacity"); + } + try { + return radix::Raster(size); + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "allocate raster output"); + } +} + +template +Expected> produce_raster(glm::uvec2 size, const Operation& operation) +{ + auto output = allocate_output(size); + if (!output) { + return output; + } + if (auto result = std::invoke(operation, raster::make_view(*output)); !result) { + return Error::propagate(std::move(result)); + } + return output; +} + +template +View> read_only_view(const View& source) +{ + return source; +} + +template +ClampedView read_only_view(const ClampedView& source) +{ + return source; +} + +template +using ReadOnlyView = decltype(read_only_view(std::declval>())); + +template +view_details::Footprint view_footprint(const View& source) +{ + return view_details::Access::footprint(source); +} + +template +view_details::Footprint view_footprint(const ClampedView& source) +{ + return view_details::ClampedAccess::footprint(source); +} + +inline bool footprints_overlap(const view_details::Footprint& left, const view_details::Footprint& right) +{ + return left.storage == right.storage && left.size.x != 0 && left.size.y != 0 && right.size.x != 0 && right.size.y != 0 + && std::uint64_t(left.origin.x) < std::uint64_t(right.origin.x) + right.size.x + && std::uint64_t(right.origin.x) < std::uint64_t(left.origin.x) + left.size.x + && std::uint64_t(left.origin.y) < std::uint64_t(right.origin.y) + right.size.y + && std::uint64_t(right.origin.y) < std::uint64_t(left.origin.y) + left.size.y; +} + +template +bool identical_view_mapping(const InputView& source, const View& destination) +{ + if constexpr (!std::same_as>) { + return false; + } else { + const auto input = view_footprint(source); + const auto output = view_footprint(destination); + // Clamping is monotone: a footprint as large as the logical view has + // no replicated coordinates, so matching rectangles imply identity. + return source.size() == destination.size() && input.size == source.size() && input.storage == output.storage && input.origin == output.origin; + } +} + +template +Expected validate_view_overlap(const InputView& source, const View& destination, bool allow_identical) +{ + if (footprints_overlap(view_footprint(source), view_footprint(destination)) && !(allow_identical && identical_view_mapping(source, destination))) { + return Error::fail(Error::Code::InvalidInput, "raster input and output views overlap"); + } + return {}; +} + +template +Expected validate_pointwise_views(const InputView& source, const View& destination) +{ + if (source.size() != destination.size()) { + return Error::fail(Error::Code::InvalidInput, "raster input and output view dimensions differ"); + } + return validate_view_overlap(source, destination, true); +} + +template +auto window_view(const View& source, glm::uvec2 origin, glm::uvec2 size) +{ + return view_details::Access::region(source, origin, size); +} + +template +auto window_view(const ClampedView& source, glm::uvec2 origin, glm::uvec2 size) +{ + return view_details::ClampedAccess::region(source, origin, size); +} + +} // namespace raster::algorithm::detail diff --git a/src/terrainlib/raster/algorithm/fold.h b/src/terrainlib/raster/algorithm/fold.h new file mode 100644 index 00000000..26f04fda --- /dev/null +++ b/src/terrainlib/raster/algorithm/fold.h @@ -0,0 +1,40 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "detail.h" + +namespace raster::algorithm { + +/// Accumulate read-only pixels in row order. Empty sources return the initial state. +template +requires std::invocable&> + && std::same_as&>, State> +[[nodiscard]] State fold(Source&& source, State initial, const Function& function) +{ + const auto input = raster::make_view(source); + for (unsigned y = 0; y < input.height(); ++y) { + for (unsigned x = 0; x < input.width(); ++x) { + initial = std::invoke(function, std::move(initial), std::as_const(input.pixel({ x, y }))); + } + } + return initial; +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/reduce.h b/src/terrainlib/raster/algorithm/reduce.h new file mode 100644 index 00000000..766835c5 --- /dev/null +++ b/src/terrainlib/raster/algorithm/reduce.h @@ -0,0 +1,99 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "reducers.h" +#include "scaling_detail.h" + +namespace raster::algorithm { +namespace detail { + template + concept PixelReduction = NumericPixel && PixelConversion + && PixelFunction, std::span>>; + + template + Expected reduce_views( + const InputView& source, unsigned halo_width, unsigned levels, const Conversion& conversion, const Reducer& reducer, const View& destination) + { + auto geometry = scaling_geometry(source.size(), halo_width, levels, false, 0); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + if (auto valid = validate_scaling_destination(source, halo_width, *geometry, destination); !valid) { + return valid; + } + using W = DecodedPixel; + const auto step = [&](const auto& input, const auto& output) { + return window_transform( + input, + { 2, 2 }, + { 2, 2 }, + [&](const auto& window) -> T { + const std::array samples { std::invoke(std::get<0>(conversion), window.pixel({ 0, 0 })), + std::invoke(std::get<0>(conversion), window.pixel({ 1, 0 })), + std::invoke(std::get<0>(conversion), window.pixel({ 0, 1 })), + std::invoke(std::get<0>(conversion), window.pixel({ 1, 1 })) }; + const W value = std::invoke(reducer, std::span(samples)); + return std::invoke(std::get<1>(conversion), value); + }, + output); + }; + return reduce_levels(source, halo_width, *geometry, 0, step, destination); + } +} // namespace detail + +/// Repeated 2x2 reduction with four decoded row-major samples; zero levels only crop. +template +requires detail::PixelReduction> && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected reduce( + Source&& source, unsigned halo_width, unsigned n_zoom_levels, const Conversion& conversion, const Reducer& reducer, Destination&& destination) +{ + return detail::reduce_views( + detail::read_only_view(raster::make_view(source)), halo_width, n_zoom_levels, conversion, reducer, raster::make_view(destination)); +} + +template +requires detail::PixelReduction> +[[nodiscard]] Expected>> reduce( + Source&& source, unsigned halo_width, unsigned n_zoom_levels, const Conversion& conversion, const Reducer& reducer) +{ + const auto input = detail::read_only_view(raster::make_view(source)); + auto geometry = detail::scaling_geometry(input.size(), halo_width, n_zoom_levels, false, 0); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + return detail::produce_raster>( + geometry->output, [&](const auto& destination) { return detail::reduce_views(input, halo_width, n_zoom_levels, conversion, reducer, destination); }); +} + +template +requires detail::PixelReduction> + && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected reduce(Source&& source, unsigned halo_width, unsigned n_zoom_levels, const Reducer& reducer, Destination&& destination) +{ + return reduce(std::forward(source), halo_width, n_zoom_levels, identity_conversion(), reducer, std::forward(destination)); +} + +template +requires detail::PixelReduction> +[[nodiscard]] Expected>> reduce(Source&& source, unsigned halo_width, unsigned n_zoom_levels, const Reducer& reducer) +{ + return reduce(std::forward(source), halo_width, n_zoom_levels, identity_conversion(), reducer); +} +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/reducers.h b/src/terrainlib/raster/algorithm/reducers.h new file mode 100644 index 00000000..2557e6d8 --- /dev/null +++ b/src/terrainlib/raster/algorithm/reducers.h @@ -0,0 +1,123 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "conversion.h" + +namespace raster::algorithm { +namespace detail { + enum class Reduction { + Minimum, + Maximum, + Median, + }; + + template + T reduce_components(std::span samples) + { + assert(samples.size() == 4); + T result {}; + using S = typename PixelTraits::Scalar; + for (unsigned component = 0; component < PixelTraits::components; ++component) { + std::array values {}; + for (std::size_t i = 0; i < samples.size(); ++i) { + values[i] = PixelTraits::component(samples[i], component); + } + const auto end = values.begin() + samples.size(); + S value {}; + if constexpr (operation == Reduction::Minimum) { + value = *std::min_element(values.begin(), end); + } else if constexpr (operation == Reduction::Maximum) { + value = *std::max_element(values.begin(), end); + } else { + std::sort(values.begin(), end); + const S lower = values[1]; + const S upper = values[2]; + if constexpr (std::is_integral_v) { + // midpoint rounds toward its first argument. The sign of the + // mathematical mean chooses the direction for halfway cases. + bool negative = false; + if constexpr (std::is_signed_v) { + negative = upper < 0 || (lower < 0 && lower < -upper); + } + value = negative ? std::midpoint(lower, upper) : std::midpoint(upper, lower); + } else { + value = std::midpoint(lower, upper); + } + } + PixelTraits::component(result, component) = value; + } + return result; + } +} // namespace detail + +struct Min { + template + T operator()(std::span samples) const + { + return detail::reduce_components(samples); + } +}; + +struct Max { + template + T operator()(std::span samples) const + { + return detail::reduce_components(samples); + } +}; + +struct Median { + template + T operator()(std::span samples) const + { + return detail::reduce_components(samples); + } +}; + +/// Most frequent scalar-like value; ties choose the first occurrence, including zero. +struct Mode { + template + T operator()(std::span samples) const + { + assert(samples.size() == 4); + std::size_t best = 0; + unsigned best_count = 0; + for (std::size_t i = 0; i < samples.size(); ++i) { + unsigned count = 1; + for (std::size_t j = 0; j < samples.size(); ++j) { + if (i != j && samples[i] == samples[j]) { + ++count; + } + } + if (count > best_count) { + best = i; + best_count = count; + } + } + return samples[best]; + } +}; +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h new file mode 100644 index 00000000..bb643128 --- /dev/null +++ b/src/terrainlib/raster/algorithm/scale.h @@ -0,0 +1,264 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "conversion.h" +#include "scaling_detail.h" + +namespace raster::algorithm { +namespace detail { + template + S lanczos_weight(S distance, unsigned radius) + { + const auto sinc = [](S x) { + const S angle = std::numbers::pi_v * x; + return x == S(0) ? S(1) : std::sin(angle) / angle; + }; + return sinc(distance) * sinc(distance / S(radius)); + } + + template + void normalize_weights(std::span weights) + { + S normalization = 0; + for (const auto weight : weights) + normalization += weight; + for (std::size_t i = 0; i < weights.size(); ++i) + weights[i] /= normalization; + } + + template + std::array upscaling_weights(std::int64_t position, unsigned factor, unsigned radius) + { + const unsigned remainder = source_phase(position, factor); + const S phase = (S(remainder) + S(0.5)) / S(factor) - S(0.5); + const S fraction = remainder < factor / 2 ? phase + S(1) : phase; + std::array weights {}; + if (radius == 1) { + weights[0] = S(1) - fraction; + weights[1] = fraction; + } else { + for (unsigned i = 0; i < 2 * radius; ++i) + weights[i] = lanczos_weight(S(i) - S(radius - 1) - fraction, radius); + normalize_weights(std::span(weights).first(2 * radius)); + } + return weights; + } + + template + Expected upscale(const InputView& source, + unsigned halo_width, + const ScalingGeometry& geometry, + Resampling method, + const Conversion& conversion, + const View& destination, + glm::i64vec2 output_offset) + { + using W = DecodedPixel; + using S = typename PixelTraits::Scalar; + const unsigned factor = geometry.factor; + const unsigned radius = upscaling_radius(method); + if (radius == 0) { + for (unsigned y = 0; y < destination.height(); ++y) + for (unsigned x = 0; x < destination.width(); ++x) { + const glm::uvec2 nearest(halo_width + source_cell(output_offset.x + x, factor), halo_width + source_cell(output_offset.y + y, factor)); + std::memcpy(std::addressof(destination.pixel({ x, y })), std::addressof(source.pixel(nearest)), sizeof(T)); + } + return {}; + } + // Cache only the requested horizontal coefficients, not every possible phase. + auto horizontal = allocate_output>({ destination.width(), 1 }); + if (!horizontal) + return Error::propagate(std::move(horizontal)); + for (unsigned x = 0; x < destination.width(); ++x) + horizontal->pixel({ x, 0 }) = upscaling_weights(output_offset.x + x, factor, radius); + for (unsigned y = 0; y < destination.height(); ++y) { + const std::int64_t position_y = output_offset.y + y; + const auto weights_y = upscaling_weights(position_y, factor, radius); + const auto origin_y = unsigned(halo_width + upscaling_origin(position_y, factor, radius)); + for (unsigned x = 0; x < destination.width(); ++x) { + const auto origin_x = unsigned(halo_width + upscaling_origin(output_offset.x + x, factor, radius)); + const auto& weights_x = horizontal->pixel({ x, 0 }); + W value {}; + for (unsigned dy = 0; dy < 2 * radius; ++dy) + for (unsigned dx = 0; dx < 2 * radius; ++dx) + value += std::invoke(std::get<0>(conversion), source.pixel({ origin_x + dx, origin_y + dy })) * (weights_x[dx] * weights_y[dy]); + destination.pixel({ x, y }) = std::invoke(std::get<1>(conversion), std::as_const(value)); + } + } + return {}; + } + + template + Expected downscale(const InputView& source, + unsigned halo_width, + const ScalingGeometry& geometry, + unsigned radius, + const Conversion& conversion, + const View& destination) + { + using W = DecodedPixel; + using S = typename PixelTraits::Scalar; + std::array weights {}; + const unsigned count = radius == 0 ? 2 : 4 * radius; + for (unsigned i = 0; i < count; ++i) { + const S offset = S(i) - S(count - 1) / S(2); + weights[i] = radius == 0 ? S(1) : lanczos_weight(offset / S(2), radius); + } + normalize_weights(std::span(weights).first(count)); + const auto filter = [&](const auto& input, const auto& output) -> Expected { + auto horizontal = window_transform(input, { count, 1 }, { 2, 1 }, [&](const auto& window) -> W { + W value {}; + for (unsigned x = 0; x < count; ++x) { + value += std::invoke(std::get<0>(conversion), window.pixel({ x, 0 })) * weights[x]; + } + return value; + }); + if (!horizontal) { + return Error::propagate(std::move(horizontal)); + } + return window_transform( + *horizontal, + { 1, count }, + { 1, 2 }, + [&](const auto& window) -> T { + W value {}; + for (unsigned y = 0; y < count; ++y) { + value += window.pixel({ 0, y }) * weights[y]; + } + return std::invoke(std::get<1>(conversion), std::as_const(value)); + }, + output); + }; + return reduce_levels(source, halo_width, geometry, radius == 0 ? 0 : 2 * radius - 1, filter, destination); + } + + template + Expected scale_window(const InputView& source, + unsigned halo_width, + int levels, + Resampling method, + glm::i64vec2 offset, + const Conversion& conversion, + const View& destination) + { + auto geometry = window_geometry(source.size(), halo_width, levels, method, offset, destination.size()); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (levels == 0) + return copy_views(*raster::make_view(source, geometry->source_origin, geometry->source_size), destination); + if (auto valid = validate_view_overlap(source, destination, false); !valid) + return valid; + if (levels > 0) + return upscale(source, halo_width, *geometry, method, conversion, destination, offset); + const unsigned support = *required_halo(levels, method); + const auto region = *raster::make_view(source, geometry->source_origin, geometry->source_size); + const ScalingGeometry reduction { region.size() - glm::uvec2(2 * support), destination.size(), geometry->factor }; + return downscale(region, support, reduction, *filter_radius(method), conversion, destination); + } +} // namespace detail + +/// Select a window relative to the scaled interior; negative offsets include halo pixels. +template +requires detail::NumericPixel> && detail::ScalingConversion> + && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale( + Source&& source, unsigned halo_width, int levels, Resampling method, glm::i64vec2 output_offset, const Conversion& conversion, Destination&& destination) +{ + return detail::scale_window( + detail::read_only_view(raster::make_view(source)), halo_width, levels, method, output_offset, conversion, raster::make_view(destination)); +} + +template +requires detail::NumericPixel> && detail::ScalingConversion> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int levels, Resampling method, glm::i64vec2 output_offset, glm::uvec2 output_size, const Conversion& conversion) +{ + auto geometry = detail::window_geometry(source.size(), halo_width, levels, method, output_offset, output_size); + if (!geometry) + return Error::propagate(std::move(geometry)); + return detail::produce_raster>( + output_size, [&](const auto& destination) { return scale(source, halo_width, levels, method, output_offset, conversion, destination); }); +} + +template +requires detail::NumericPixel> && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, glm::i64vec2 output_offset, Destination&& destination) +{ + return scale(std::forward(source), + halo_width, + levels, + method, + output_offset, + linear_conversion>(), + std::forward(destination)); +} + +template +requires detail::NumericPixel> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int levels, Resampling method, glm::i64vec2 output_offset, glm::uvec2 output_size) +{ + return scale(std::forward(source), halo_width, levels, method, output_offset, output_size, linear_conversion>()); +} + +/// Full-output overloads select the complete scaled interior, excluding its halo. +template +requires detail::NumericPixel> && detail::ScalingConversion> + && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, const Conversion& conversion, Destination&& destination) +{ + auto geometry = detail::scale_geometry(source.size(), halo_width, levels, method); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (destination.size() != geometry->output) + return Error::fail(Error::Code::InvalidInput, "raster scaling output dimensions do not match"); + return scale(std::forward(source), halo_width, levels, method, glm::i64vec2(0), conversion, std::forward(destination)); +} + +template +requires detail::NumericPixel> && detail::ScalingConversion> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int levels, Resampling method, const Conversion& conversion) +{ + auto geometry = detail::scale_geometry(source.size(), halo_width, levels, method); + if (!geometry) + return Error::propagate(std::move(geometry)); + return scale(std::forward(source), halo_width, levels, method, glm::i64vec2(0), geometry->output, conversion); +} + +template +requires detail::NumericPixel> && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, Destination&& destination) +{ + return scale( + std::forward(source), halo_width, levels, method, linear_conversion>(), std::forward(destination)); +} + +template +requires detail::NumericPixel> +[[nodiscard]] Expected>> scale(Source&& source, unsigned halo_width, int levels, Resampling method) +{ + return scale(std::forward(source), halo_width, levels, method, linear_conversion>()); +} +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/scaling_detail.h b/src/terrainlib/raster/algorithm/scaling_detail.h new file mode 100644 index 00000000..a0703931 --- /dev/null +++ b/src/terrainlib/raster/algorithm/scaling_detail.h @@ -0,0 +1,252 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "copy.h" +#include "window_transform.h" + +namespace raster::algorithm { + +enum class Resampling { + NearestNeighbourAndBox, + BiliinearAndBox, + Lanczos2, + Lanczos3, + Lanczos4, +}; + +namespace detail { + inline Expected scale_factor(unsigned n_zoom_levels) + { + if (n_zoom_levels >= std::numeric_limits::digits) { + return Error::fail(Error::Code::InvalidInput, "raster zoom-level count exceeds the supported range"); + } + return 1u << n_zoom_levels; + } + + inline Expected filter_radius(Resampling method) + { + switch (method) { + case Resampling::NearestNeighbourAndBox: + case Resampling::BiliinearAndBox: + return 0; + case Resampling::Lanczos2: + return 2; + case Resampling::Lanczos3: + return 3; + case Resampling::Lanczos4: + return 4; + } + return Error::fail(Error::Code::InvalidInput, "invalid raster resampling method"); + } + + // Floor division, including negative output coordinates. + inline std::int64_t source_cell(std::int64_t position, unsigned factor) { return position >= 0 ? position / factor : -1 - (-(position + 1)) / factor; } + + inline unsigned source_phase(std::int64_t position, unsigned factor) { return unsigned(position - source_cell(position, factor) * factor); } + + inline std::int64_t upscaling_origin(std::int64_t position, unsigned factor, unsigned radius) + { + const std::int64_t cell = source_cell(position, factor); + if (radius == 0) + return cell; + return cell - (source_phase(position, factor) < factor / 2 ? 1 : 0) - int(radius) + 1; + } + + inline unsigned upscaling_radius(Resampling method) + { + return method == Resampling::NearestNeighbourAndBox ? 0 : method == Resampling::BiliinearAndBox ? 1 : *filter_radius(method); + } + + struct ScalingGeometry { + glm::uvec2 interior; + glm::uvec2 output; + unsigned factor; + glm::uvec2 source_origin {}; + glm::uvec2 source_size {}; + }; + + inline Expected scaling_geometry(glm::uvec2 input, unsigned halo_width, unsigned levels, bool up, unsigned required_width) + { + assert(input.x <= (1u << 30) && input.y <= (1u << 30)); + auto factor = scale_factor(levels); + if (!factor) { + return Error::propagate(std::move(factor)); + } + const auto padding = std::uint64_t(halo_width) * 2; + if (input.x <= padding || input.y <= padding) { + return Error::fail(Error::Code::InvalidInput, "raster must contain a nonempty interior"); + } + if (halo_width < required_width) { + return Error::fail(Error::Code::InvalidInput, "insufficient raster halo for scaling kernel"); + } + const glm::uvec2 interior = input - glm::uvec2(static_cast(padding)); + if (!up && (interior.x % *factor != 0 || interior.y % *factor != 0)) { + return Error::fail(Error::Code::InvalidInput, "raster interior dimensions must be divisible by the reduction factor"); + } + const auto width = up ? std::uint64_t(interior.x) * *factor : interior.x / *factor; + const auto height = up ? std::uint64_t(interior.y) * *factor : interior.y / *factor; + if (width > (std::numeric_limits::max)() || height > (std::numeric_limits::max)()) { + return Error::fail(Error::Code::ResourceExhausted, "scaled raster dimensions exceed representable limits"); + } + return ScalingGeometry { interior, { static_cast(width), static_cast(height) }, *factor }; + } + + template + Expected validate_scaling_destination(const InputView& source, unsigned halo_width, const ScalingGeometry& geometry, const View& destination) + { + if (destination.size() != geometry.output) { + return Error::fail(Error::Code::InvalidInput, "raster scaling output dimensions do not match"); + } + if (geometry.factor == 1) { + return validate_view_overlap(*raster::make_view(source, glm::uvec2(halo_width), geometry.interior), destination, true); + } + return validate_view_overlap(source, destination, false); + } + + // Each stage retains just enough halo for the remaining steps. The first + // source can be clamped; subsequent stages read ordinary intermediate rasters. + template + Expected reduce_levels( + const InputView& source, unsigned halo_width, const ScalingGeometry& geometry, unsigned base_halo, const Function& function, const View& destination) + { + if (geometry.factor == 1) { + return copy_views(*raster::make_view(source, glm::uvec2(halo_width), geometry.interior), destination); + } + auto interior = geometry.interior; + radix::Raster intermediate; + bool first = true; + for (unsigned remaining_factor = geometry.factor / 2;; remaining_factor /= 2) { + interior /= 2u; + const unsigned output_halo = base_halo * (remaining_factor - 1); + const glm::uvec2 output_size = interior + glm::uvec2(2 * output_halo); + const auto step = [&](const auto& input, const auto& output) { + const unsigned origin = halo_width - 2 * output_halo - base_halo; + const auto region = *raster::make_view(input, glm::uvec2(origin), 2u * output_size + glm::uvec2(2 * base_halo)); + return std::invoke(function, region, output); + }; + const auto process = [&](const auto& output) { + if (first) { + return step(source, output); + } + return step(read_only_view(raster::make_view(intermediate)), output); + }; + if (remaining_factor == 1) { + return process(destination); + } + auto output = produce_raster(output_size, process); + if (!output) { + return Error::propagate(std::move(output)); + } + intermediate = std::move(*output); + halo_width = output_halo; + first = false; + } + } +} // namespace detail + +/// Positive levels upscale, negative levels downscale, and zero only crops. +/// This query covers the full interior; a requested output halo needs additional support. +inline Expected required_halo(int n_zoom_levels, Resampling method) +{ + auto radius = detail::filter_radius(method); + if (!radius) + return Error::propagate(std::move(radius)); + auto factor = detail::scale_factor(static_cast(n_zoom_levels < 0 ? -std::int64_t(n_zoom_levels) : n_zoom_levels)); + if (!factor) + return Error::propagate(std::move(factor)); + if (n_zoom_levels >= 0) + return n_zoom_levels == 0 ? 0 : detail::upscaling_radius(method); + const auto width = *radius == 0 ? 0 : std::uint64_t(2 * *radius - 1) * (*factor - 1); + if (width > (std::numeric_limits::max)()) + return Error::fail(Error::Code::ResourceExhausted, "required raster halo exceeds representable dimensions"); + return static_cast(width); +} + +namespace detail { + inline Expected scale_geometry(glm::uvec2 input, unsigned halo_width, int levels, Resampling method) + { + auto required = required_halo(levels, method); + if (!required) + return Error::propagate(std::move(required)); + return scaling_geometry(input, halo_width, static_cast(levels < 0 ? -std::int64_t(levels) : levels), levels >= 0, *required); + } + + // Validate actual source support, including windows outside the scaled interior. + inline Expected window_geometry(glm::uvec2 input, unsigned halo_width, int levels, Resampling method, glm::i64vec2 offset, glm::uvec2 size) + { + assert(input.x <= (1u << 30) && input.y <= (1u << 30)); + assert(size.x <= (1u << 30) && size.y <= (1u << 30)); + auto required = required_halo(levels, method); + if (!required) + return Error::propagate(std::move(required)); + const auto factor = *scale_factor(static_cast(levels < 0 ? -std::int64_t(levels) : levels)); + // Downscaling multiplies the offset by the factor. + [[maybe_unused]] const std::int64_t limit = (std::int64_t(1) << 62) / (levels < 0 ? factor : 1); + assert(offset.x > -limit && offset.x < limit && offset.y > -limit && offset.y < limit); + if (input.x == 0 || input.y == 0 || halo_width > ((std::min)(input.x, input.y) - 1) / 2) + return Error::fail(Error::Code::InvalidInput, "invalid raster interior"); + ScalingGeometry result { input - glm::uvec2(2 * halo_width), size, factor }; + for (unsigned axis = 0; axis < 2; ++axis) { + if (size[axis] == 0) + return Error::fail(Error::Code::InvalidInput, "empty scaling output window"); + const std::int64_t last = offset[axis] + std::int64_t(size[axis] - 1); + std::int64_t first_source; + std::int64_t last_source; + if (levels > 0) { + const unsigned radius = upscaling_radius(method); + first_source = std::int64_t(halo_width) + upscaling_origin(offset[axis], factor, radius); + last_source = std::int64_t(halo_width) + upscaling_origin(last, factor, radius) + (radius == 0 ? 0 : 2 * radius - 1); + } else if (levels < 0) { + if (result.interior[axis] % factor != 0) + return Error::fail(Error::Code::InvalidInput, "raster interior dimensions must be divisible by the reduction factor"); + first_source = std::int64_t(halo_width) + offset[axis] * factor - *required; + last_source = std::int64_t(halo_width) + (last + 1) * factor + *required - 1; + } else { + first_source = std::int64_t(halo_width) + offset[axis]; + last_source = std::int64_t(halo_width) + last; + } + if (first_source < 0 || last_source >= input[axis]) + return Error::fail(Error::Code::InvalidInput, "insufficient raster support for scaling output window"); + result.source_origin[axis] = unsigned(first_source); + result.source_size[axis] = unsigned(last_source - first_source + 1); + } + return result; + } + +} // namespace detail + +struct SourceWindow { + glm::uvec2 origin; + glm::uvec2 size; +}; + +/// Query the source rectangle read by scaling an output window, without accessing pixels. +/// The returned origin includes the source halo; output_offset is relative to the scaled interior. +/// Rejects invalid geometry or insufficient source support, just like windowed scale(). +[[nodiscard]] inline Expected required_source_window( + glm::uvec2 input_size, unsigned halo_width, int levels, Resampling method, glm::i64vec2 output_offset, glm::uvec2 output_size) +{ + auto geometry = detail::window_geometry(input_size, halo_width, levels, method, output_offset, output_size); + if (!geometry) + return Error::propagate(std::move(geometry)); + return SourceWindow { geometry->source_origin, geometry->source_size }; +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/transform.h b/src/terrainlib/raster/algorithm/transform.h new file mode 100644 index 00000000..50a21c89 --- /dev/null +++ b/src/terrainlib/raster/algorithm/transform.h @@ -0,0 +1,63 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "detail.h" + +namespace raster::algorithm { + +namespace detail { + template + requires(!std::is_const_v) && PixelFunction + Expected transform_views(const InputView& source, const Function& function, const View& destination) + { + if (auto valid = validate_pointwise_views(source, destination); !valid) { + return valid; + } + if (destination.width() == 0 || destination.height() == 0) { + return {}; + } + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + destination.pixel({ x, y }) = std::invoke(function, std::as_const(source.pixel({ x, y }))); + } + } + return {}; + } +} // namespace detail + +/// The callable receives a read-only pixel and returns exactly the output type. +template +requires detail::PixelFunction, detail::SourcePixel> +[[nodiscard]] Expected transform(Source&& source, const Function& function, Destination&& destination) +{ + return detail::transform_views(raster::make_view(source), function, raster::make_view(destination)); +} + +template +requires detail::RasterFunction> +[[nodiscard]] auto transform(Source&& source, const Function& function) + -> Expected>>> +{ + const auto input = raster::make_view(source); + return detail::produce_raster>>( + input.size(), [&](const auto& destination) { return detail::transform_views(input, function, destination); }); +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/window_transform.h b/src/terrainlib/raster/algorithm/window_transform.h new file mode 100644 index 00000000..00dd7126 --- /dev/null +++ b/src/terrainlib/raster/algorithm/window_transform.h @@ -0,0 +1,119 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "detail.h" +#include +#include + +namespace raster::algorithm { + +/// Normalized one-dimensional Gaussian weights for an odd window; size one is identity. +[[nodiscard]] inline Expected> gaussian_kernel(unsigned size) +{ + if (size == 0 || size % 2 == 0) { + return Error::fail(Error::Code::InvalidInput, "Gaussian kernel size must be positive and odd"); + } + if (size == 1) { + return std::vector { 1 }; + } + const double sigma = double(size - 1) / 4; + std::vector weights(size); + double sum = 0; + for (unsigned i = 0; i < size; ++i) { + const double distance = (double(i) - double(size / 2)) / sigma; + weights[i] = std::exp(-0.5 * distance * distance); + sum += weights[i]; + } + std::ranges::transform(weights, weights.begin(), [sum](double weight) { return weight / sum; }); + return weights; +} + +namespace detail { + inline Expected window_output_size(glm::uvec2 input_size, glm::uvec2 kernel_size, glm::uvec2 stride = glm::uvec2(1)) + { + if (kernel_size.x == 0 || kernel_size.y == 0 || stride.x == 0 || stride.y == 0 || kernel_size.x > input_size.x || kernel_size.y > input_size.y) { + return Error::fail(Error::Code::InvalidInput, "window size and stride must be positive, and the window must fit the input view"); + } + return (input_size - kernel_size) / stride + glm::uvec2(1); + } + + template + requires(!std::is_const_v) && PixelFunction + Expected window_transform_views( + const InputView& source, glm::uvec2 kernel_size, glm::uvec2 stride, const Function& function, const View& destination) + { + auto size = window_output_size(source.size(), kernel_size, stride); + if (!size) { + return Error::propagate(std::move(size)); + } + if (destination.size() != *size) { + return Error::fail(Error::Code::InvalidInput, "window transform output dimensions do not match the input and kernel"); + } + if (auto valid = validate_view_overlap(source, destination, false); !valid) { + return valid; + } + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + const auto window = window_view(source, glm::uvec2(x, y) * stride, kernel_size); + destination.pixel({ x, y }) = std::invoke(function, window); + } + } + return {}; + } +} // namespace detail + +/// Applies complete rectangular windows at the given positive stride, without border handling. +template +requires detail::PixelFunction, detail::ReadOnlyView> +[[nodiscard]] Expected window_transform(Source&& source, glm::uvec2 kernel_size, glm::uvec2 stride, const Function& function, Destination&& destination) +{ + return detail::window_transform_views(detail::read_only_view(raster::make_view(source)), kernel_size, stride, function, raster::make_view(destination)); +} + +template +requires detail::RasterFunction> +[[nodiscard]] auto window_transform(Source&& source, glm::uvec2 kernel_size, glm::uvec2 stride, const Function& function) + -> Expected>>> +{ + const auto input = detail::read_only_view(raster::make_view(source)); + auto size = detail::window_output_size(input.size(), kernel_size, stride); + if (!size) { + return Error::propagate(std::move(size)); + } + return detail::produce_raster>>( + *size, [&](const auto& destination) { return detail::window_transform_views(input, kernel_size, stride, function, destination); }); +} + +/// Existing call forms use stride one. +template +requires detail::PixelFunction, detail::ReadOnlyView> +[[nodiscard]] Expected window_transform(Source&& source, glm::uvec2 kernel_size, const Function& function, Destination&& destination) +{ + return window_transform(std::forward(source), kernel_size, glm::uvec2(1), function, std::forward(destination)); +} + +template +requires detail::RasterFunction> +[[nodiscard]] auto window_transform(Source&& source, glm::uvec2 kernel_size, const Function& function) +{ + return window_transform(std::forward(source), kernel_size, glm::uvec2(1), function); +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/zip_transform.h b/src/terrainlib/raster/algorithm/zip_transform.h new file mode 100644 index 00000000..4840c330 --- /dev/null +++ b/src/terrainlib/raster/algorithm/zip_transform.h @@ -0,0 +1,123 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "detail.h" + +namespace raster::algorithm { + +namespace detail { + template + requires(!std::is_const_v) && PixelFunction + Expected zip_transform_views(const std::tuple& inputs, const Function& function, const View& destination) + { + return std::apply( + [&](const auto&... sources) -> Expected { + Expected valid; + const auto validate = [&](const auto& source) { + if (valid) { + valid = validate_pointwise_views(source, destination); + } + }; + (validate(sources), ...); + if (!valid) { + return valid; + } + if (destination.width() == 0 || destination.height() == 0) { + return {}; + } + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + destination.pixel({ x, y }) = std::invoke(function, std::as_const(sources.pixel({ x, y }))...); + } + } + return {}; + }, + inputs); + } + + template + requires RasterFunction + auto zip_transform_new(const std::tuple& inputs, const Function& function) + -> Expected>> + { + const auto size = std::get<0>(inputs).size(); + if (!std::apply([&](const auto&... sources) { return ((sources.size() == size) && ...); }, inputs)) { + return Error::fail(Error::Code::InvalidInput, "zip input view dimensions differ"); + } + return produce_raster>( + size, [&](const auto& destination) { return zip_transform_views(inputs, function, destination); }); + } +} // namespace detail + +template +requires detail::PixelFunction, detail::SourcePixel, detail::SourcePixel> +[[nodiscard]] Expected zip_transform(First&& first, Second&& second, const Function& function, Destination&& destination) +{ + return detail::zip_transform_views(std::tuple { raster::make_view(first), raster::make_view(second) }, function, raster::make_view(destination)); +} + +template +requires detail::PixelFunction, detail::SourcePixel, detail::SourcePixel, detail::SourcePixel> +[[nodiscard]] Expected zip_transform(First&& first, Second&& second, Third&& third, const Function& function, Destination&& destination) +{ + return detail::zip_transform_views( + std::tuple { raster::make_view(first), raster::make_view(second), raster::make_view(third) }, function, raster::make_view(destination)); +} + +/// Four or more heterogeneous inputs; tuples of raster references avoid copies. +template +requires(sizeof...(Sources) >= 4) && (detail::ViewSource && ...) + && detail::PixelFunction, detail::SourcePixel...> +[[nodiscard]] Expected zip_transform(const std::tuple& sources, const Function& function, Destination&& destination) +{ + return std::apply( + [&](const auto&... inputs) { + return detail::zip_transform_views(std::tuple { raster::make_view(inputs)... }, function, raster::make_view(destination)); + }, + sources); +} + +template +requires detail::RasterFunction, detail::SourcePixel> +[[nodiscard]] auto zip_transform(First&& first, Second&& second, const Function& function) + -> Expected, detail::SourcePixel>>> +{ + return detail::zip_transform_new(std::tuple { raster::make_view(first), raster::make_view(second) }, function); +} + +template +requires detail::RasterFunction, detail::SourcePixel, detail::SourcePixel> +[[nodiscard]] auto zip_transform(First&& first, Second&& second, Third&& third, const Function& function) + -> Expected, detail::SourcePixel, detail::SourcePixel>>> +{ + return detail::zip_transform_new(std::tuple { raster::make_view(first), raster::make_view(second), raster::make_view(third) }, function); +} + +template +requires(sizeof...(Sources) >= 4) && (detail::ViewSource && ...) && detail::RasterFunction...> +[[nodiscard]] auto zip_transform(const std::tuple& sources, const Function& function) + -> Expected...>>> +{ + return std::apply([&](const auto&... inputs) { return detail::zip_transform_new(std::tuple { raster::make_view(inputs)... }, function); }, sources); +} + +} // namespace raster::algorithm diff --git a/src/terrainlib/raster_store/StoreTraits.h b/src/terrainlib/raster_store/StoreTraits.h index 111f941b..aac25830 100644 --- a/src/terrainlib/raster_store/StoreTraits.h +++ b/src/terrainlib/raster_store/StoreTraits.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/raster_store/Tile.h b/src/terrainlib/raster_store/Tile.h new file mode 100644 index 00000000..dc7fea7c --- /dev/null +++ b/src/terrainlib/raster_store/Tile.h @@ -0,0 +1,51 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" + +namespace raster_store { + +inline constexpr unsigned default_tile_side = 4096; + +inline Expected validate_dimensions(const glm::uvec2 dimensions) +{ + if (dimensions.x == 0 || dimensions.x != dimensions.y) { + return Error::fail(Error::Code::InvalidInput, "raster tile dimensions must be positive and square"); + } + return {}; +} + +template +struct Tile { + explicit Tile(const unsigned side = default_tile_side) + : data(side) + , attribution(glm::uvec2(side), std::uint16_t { 0 }) + { + } + + radix::Raster data; + radix::Raster attribution; +}; + +} // namespace raster_store diff --git a/src/terrainlib/raster_store/TilePool.h b/src/terrainlib/raster_store/TilePool.h new file mode 100644 index 00000000..0624f86b --- /dev/null +++ b/src/terrainlib/raster_store/TilePool.h @@ -0,0 +1,193 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace raster_store { + +// RF tile scheduling shared by the RF builder and merger. The coordinator alone +// submits/takes results. +// Fixed slots bound queued + active + completed tiles, not merely the job queue. +// prepare may throw Error::Exception; the pool returns its error as the result. +// Any failure stops the pool, and failed results are returned in any order. +template +class TilePool { +public: + using Key = radix::tile::Id; + using Prepare = std::function; + struct Completed { + Key key; + Expected result; + }; + + TilePool(unsigned jobs, Prepare prepare) + : m_prepare(std::move(prepare)) + , m_slots(std::size_t(jobs) * 2) + { + m_threads.reserve(jobs); + for (unsigned worker = 0; worker < jobs; ++worker) { + m_threads.emplace_back([this, worker](std::stop_token stop) { run(worker, stop); }); + } + } + ~TilePool() { join(); } + TilePool(const TilePool&) = delete; + TilePool& operator=(const TilePool&) = delete; + + // Returns false once the pool is stopped. The coordinator must not submit + // more than twice the worker count of outstanding tiles. + bool submit(const Key& key) + { + std::lock_guard lock(m_mutex); + if (m_stopped) { + return false; + } + const auto slot = std::ranges::find(m_slots, State::Free, &Slot::state); + ASSERT(slot != m_slots.end(), to_string(key)); + slot->key = key; + slot->sequence = m_sequence++; + slot->state = State::Pending; + ++m_outstanding; + m_changed.notify_all(); + return true; + } + + std::optional take(std::chrono::milliseconds timeout) + { + std::unique_lock lock(m_mutex); + m_changed.wait_for(lock, timeout, [&] { return ready() || m_outstanding == 0; }); + for (auto& slot : m_slots) { + if (slot.state != State::Ready) { + continue; + } + Completed result { slot.key, std::move(*slot.result) }; + slot.result.reset(); + slot.state = State::Free; + --m_outstanding; + return result; + } + return std::nullopt; + } + + void stop() + { + std::lock_guard lock(m_mutex); + stop_locked(); + } + void join() + { + stop(); + m_threads.clear(); + } + std::size_t outstanding() const + { + std::lock_guard lock(m_mutex); + return m_outstanding; + } + +private: + enum class State { Free, Pending, Active, Ready }; + struct Slot { + State state = State::Free; + Key key {}; + std::uint64_t sequence = 0; + std::optional> result = std::nullopt; + }; + bool ready() const + { + return std::ranges::any_of(m_slots, [](const auto& slot) { return slot.state == State::Ready; }); + } + std::optional pending() const + { + std::optional first = std::nullopt; + for (std::size_t i = 0; i < m_slots.size(); ++i) { + if (m_slots[i].state == State::Pending && (!first || m_slots[i].sequence < m_slots[*first].sequence)) { + first = i; + } + } + return first; + } + void stop_locked() + { + m_stopped = true; + for (auto& slot : m_slots) { + if (slot.state == State::Pending) { + slot.state = State::Free; + --m_outstanding; + } + } + m_changed.notify_all(); + } + void run(unsigned worker, std::stop_token stop) + { + for (;;) { + std::unique_lock lock(m_mutex); + m_changed.wait(lock, stop, [&] { return m_stopped || pending().has_value(); }); + if (m_stopped || stop.stop_requested()) { + return; + } + auto& slot = m_slots[*pending()]; + slot.state = State::Active; + const auto key = slot.key; + lock.unlock(); + auto result = [&]() -> Expected { + try { + return m_prepare(worker, key); + } catch (const Error::Exception& exception) { + return Error::propagate(Error(exception.error()), "prepare RF tile " + to_string(key)); + } catch (const std::exception& error) { + return Error::fail(Error::Code::Internal, "prepare RF tile " + to_string(key) + ": " + error.what()); + } catch (...) { + return Error::fail(Error::Code::Internal, "unknown exception preparing RF tile " + to_string(key)); + } + }(); + lock.lock(); + if (!result) { + stop_locked(); + } + slot.result.emplace(std::move(result)); + slot.state = State::Ready; + m_changed.notify_all(); + } + } + Prepare m_prepare; + std::vector m_slots; + mutable std::mutex m_mutex; + std::condition_variable_any m_changed; + std::uint64_t m_sequence = 0; + std::size_t m_outstanding = 0; + bool m_stopped = false; + // Last: joins before any state used by workers is destroyed, including if + // thread creation throws halfway through construction. + std::vector m_threads; +}; +} // namespace raster_store diff --git a/src/terrainlib/raster_store/attribution.cpp b/src/terrainlib/raster_store/attribution.cpp new file mode 100644 index 00000000..3e5e2282 --- /dev/null +++ b/src/terrainlib/raster_store/attribution.cpp @@ -0,0 +1,142 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "raster_store/attribution.h" + +#include +#include +#include + +#include + +#include "io/byte.h" + +namespace raster_store::attribution { + +Expected Table::at(const std::uint32_t index) const +{ + if (index >= index_limit) { + return Error::fail(Error::Code::Unsupported, "attribution index must be less than 65535"); + } + if (index >= entities.size()) { + return Error::fail(Error::Code::InvalidInput, "attribution index is outside the source table"); + } + return &entities[index]; +} + +Expected parse(const std::string_view json) +{ + if (json.size() > static_cast(std::numeric_limits::max())) { + return Error::fail(Error::Code::ResourceExhausted, "attribution JSON exceeds parser capacity"); + } + CPLJSONDocument document; + if (!document.LoadMemory(reinterpret_cast(json.data()), static_cast(json.size()))) { + return Error::fail(Error::Code::CorruptData, "invalid attribution JSON"); + } + const auto root = document.GetRoot(); + if (root.GetType() != CPLJSONObject::Type::Array) { + return Error::fail(Error::Code::CorruptData, "attribution table must be a JSON array"); + } + const auto array = root.ToArray(); + if (array.Size() == 0) { + return Error::fail(Error::Code::CorruptData, "attribution table must include slot 0"); + } + if (static_cast(array.Size()) > index_limit) { + return Error::fail(Error::Code::Unsupported, "attribution table contains unsupported indices"); + } + Table table; + table.entities.reserve(static_cast(array.Size())); + for (int index = 0; index < array.Size(); ++index) { + const auto object = array[index]; + if (object.GetType() != CPLJSONObject::Type::Object) { + return Error::fail(Error::Code::CorruptData, "attribution slot " + std::to_string(index) + " must be an object"); + } + const auto resolution_type = object["spatial_resolution"].GetType(); + if (resolution_type != CPLJSONObject::Type::Double && resolution_type != CPLJSONObject::Type::Integer + && resolution_type != CPLJSONObject::Type::Long) { + return Error::fail(Error::Code::CorruptData, "attribution spatial_resolution must be numeric"); + } + constexpr std::array fields { "acquisition_date", "ingestion_date", "copyright", "copyright_link", "license" }; + for (const auto field : fields) { + if (object[field].GetType() != CPLJSONObject::Type::String) { + return Error::fail(Error::Code::CorruptData, "attribution field " + std::string(field) + " must be a string"); + } + } + table.entities.push_back({ object.GetDouble("spatial_resolution"), object.GetString("acquisition_date"), + object.GetString("ingestion_date"), object.GetString("copyright"), object.GetString("copyright_link"), object.GetString("license") }); + } + return table; +} + +Expected find_table(const std::filesystem::path& index_path) +{ + std::error_code error; + auto directory = std::filesystem::absolute(index_path, error).parent_path(); + if (error) { + return Error::fail(Error::Code::Io, "resolve attribution index path", index_path, error); + } + for (int level = 0; level < 3; ++level) { + const auto path = directory / file_name; + const auto status = std::filesystem::symlink_status(path, error); + if (error && error != std::errc::no_such_file_or_directory) { + return Error::fail(Error::Code::Io, "inspect attribution table", path, error); + } + if (std::filesystem::exists(status)) { + return path; + } + directory = directory.parent_path(); + } + return Error::fail(Error::Code::NotFound, "find attribution table beside index or in its two ancestor directories", index_path); +} + +Expected
read_table(const std::filesystem::path& index_path) +{ + auto path = find_table(index_path); + if (!path) { + return Error::propagate(std::move(path)); + } + auto bytes = ::io::byte::read(*path); + if (!bytes) { + return Error::propagate(std::move(bytes), "read source attribution table"); + } + auto table = parse(std::string_view(reinterpret_cast(bytes->data()), bytes->size())); + if (!table) { + return Error::propagate(std::move(table), "parse source attribution table \"" + path->string() + "\""); + } + return table; +} + +Expected copy_table(const std::filesystem::path& source_index_path, const std::filesystem::path& destination_directory) +{ + auto source = find_table(source_index_path); + if (!source) { + return Error::propagate(std::move(source)); + } + const auto destination = destination_directory / file_name; + std::error_code error; + if (!std::filesystem::copy_file(*source, destination, std::filesystem::copy_options::none, error)) { + if (error == std::errc::no_such_file_or_directory) { + return Error::fail(Error::Code::NotFound, "copy source attribution table", *source, destination, error); + } + return Error::fail(error == std::errc::file_exists ? Error::Code::AlreadyExists : Error::Code::Io, + "copy source attribution table", *source, destination, error); + } + return {}; +} + +} // namespace raster_store::attribution diff --git a/src/terrainlib/raster_store/attribution.h b/src/terrainlib/raster_store/attribution.h new file mode 100644 index 00000000..a5c188f3 --- /dev/null +++ b/src/terrainlib/raster_store/attribution.h @@ -0,0 +1,56 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" + +namespace raster_store::attribution { + +inline constexpr std::string_view file_name = "source_attribution_table.json"; +inline constexpr std::uint32_t index_limit = 65535; + +struct Entity { + double spatial_resolution; + std::string acquisition_date; + std::string ingestion_date; + std::string copyright; + std::string copyright_link; + std::string license; + + bool operator==(const Entity&) const = default; +}; + +struct Table { + std::vector entities; + + Expected at(std::uint32_t index) const; +}; + +Expected
parse(std::string_view json); +Expected find_table(const std::filesystem::path& index_path); +Expected
read_table(const std::filesystem::path& index_path); +Expected copy_table(const std::filesystem::path& source_index_path, const std::filesystem::path& destination_directory); + +} // namespace raster_store::attribution diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h new file mode 100644 index 00000000..40deb747 --- /dev/null +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -0,0 +1,168 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" +#include "io/envelope.h" +#include "io/image.h" +#include "raster_store/Tile.h" +#include "raster_store/pixel.h" +#include "store/Codec.h" + +namespace raster_store::io { + +namespace tile_codec { + + namespace detail::v1 { + + struct RasterTile { + std::uint32_t width; + std::uint32_t height; + // Native element bytes for AMORT, a JPEG XL codestream for AMOIT. + ::io::byte::vector data; + ::io::byte::vector source_attribution; + }; + + } // namespace detail::v1 + + using TileSchema = ::io::envelope::PayloadSchema<"raster_store.Tile", ::io::envelope::Version<1, detail::v1::RasterTile>>; + using ImageTileSchema = ::io::envelope::PayloadSchema<"raster_store.ImageTile", ::io::envelope::Version<1, detail::v1::RasterTile>>; + + // Storage of the data raster; attribution is always stored losslessly. + enum class DataEncoding { + Native, // AMORT: lossless native element bytes of any pixel type. + JpegXl, // AMOIT: lossy JPEG XL image of RGB8 pixels. + }; + +} // namespace tile_codec + +template +class TileCodec final : public store::Codec> { + static_assert(sizeof(pixel::detail::Format) > 0); + static constexpr bool is_image = Encoding == tile_codec::DataEncoding::JpegXl; + static_assert(!is_image || std::is_same_v, "AMOIT stores only RGB8 tiles"); + using Schema = std::conditional_t; + static constexpr std::string_view name = is_image ? "AMOIT" : "AMORT"; + +public: + explicit TileCodec(const glm::uvec2 dimensions = glm::uvec2(default_tile_side), + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression) + : m_dimensions(dimensions) + , m_compression_algorithm(compression_algorithm) + { + } + + std::vector paths(const std::filesystem::path& node_path) const override + { + return { this->add_extension(node_path, is_image ? ".amoit" : ".amort") }; + } + + Expected> read( + const std::filesystem::path& node_path, const std::optional expected_hash = std::nullopt) const override + { + if (auto valid = validate_dimensions(m_dimensions); !valid) { + return Error::propagate(std::move(valid)); + } + auto encoded = ::io::envelope::read(paths(node_path).front(), expected_hash); + if (!encoded) { + return Error::propagate(std::move(encoded), "read " + std::string(name) + " tile"); + } + if (glm::uvec2(encoded->width, encoded->height) != m_dimensions) { + return Error::fail(Error::Code::CorruptData, std::string(name) + " tile dimensions do not match snapshot metadata"); + } + const std::uint64_t count = std::uint64_t { encoded->width } * encoded->height; + if ((!is_image && (encoded->data.size() % sizeof(PixelType) != 0 || encoded->data.size() / sizeof(PixelType) != count)) + || encoded->source_attribution.size() % sizeof(std::uint16_t) != 0 + || encoded->source_attribution.size() / sizeof(std::uint16_t) != count) { + return Error::fail(Error::Code::CorruptData, std::string(name) + " raster buffer byte counts do not match dimensions and payload type"); + } + Tile tile(encoded->width); + if constexpr (is_image) { + auto data = ::io::image::decode_rgb8(encoded->data, m_dimensions); + if (!data) { + return Error::propagate(std::move(data), "decode AMOIT tile data"); + } + tile.data = std::move(*data); + } else { + std::memcpy(tile.data.bytes().data(), encoded->data.data(), encoded->data.size()); + } + std::memcpy(tile.attribution.bytes().data(), encoded->source_attribution.data(), encoded->source_attribution.size()); + return tile; + } + + Expected write(const std::filesystem::path& node_path, const Tile& tile) const override + { + if (auto valid = validate_dimensions(m_dimensions); !valid) { + return Error::propagate(std::move(valid)); + } + if (tile.data.size() != m_dimensions || tile.attribution.size() != m_dimensions) { + return Error::fail(Error::Code::InvalidInput, "both tile rasters must match snapshot dimensions"); + } + const auto source_attribution = tile.attribution.bytes(); + tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, {}, { source_attribution.begin(), source_attribution.end() } }; + if constexpr (is_image) { + auto data = ::io::image::encode(tile.data, ::io::image::Format::JpegXl); + if (!data) { + return Error::propagate(std::move(data), "encode AMOIT tile data"); + } + encoded.data = std::move(*data); + } else { + const auto data = tile.data.bytes(); + encoded.data.assign(data.begin(), data.end()); + } + return ::io::envelope::write(encoded, paths(node_path).front(), ::io::byte::WriteMode::CreateNew, true, m_compression_algorithm); + } + + bool supports_hashing() const override { return true; } + +private: + glm::uvec2 m_dimensions; + ::io::envelope::CompressionAlgorithm m_compression_algorithm; +}; + +namespace tile_codec { + + template + Expected>>> from_name(const std::string_view name, + const glm::uvec2 dimensions, + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression) + { + if (name == "amort") { + return std::make_unique>(dimensions, compression_algorithm); + } + if (name == "amoit") { + if constexpr (std::is_same_v) { + return std::make_unique>(dimensions, compression_algorithm); + } else { + return Error::fail(Error::Code::Unsupported, "the amoit raster codec requires " + pixel::identifier() + " pixels"); + } + } + return Error::fail(Error::Code::Unsupported, "unsupported raster codec selector: " + std::string(name)); + } + +} // namespace tile_codec + +} // namespace raster_store::io diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp new file mode 100644 index 00000000..72b54681 --- /dev/null +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -0,0 +1,152 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "raster_store/io/manifest.h" + +#include +#include +#include +#include +#include + +#include "raster_store/path_layout.h" + +namespace raster_store::io::manifest { + +Expected validate(const Metadata& metadata) +{ + if (metadata.layout_id.empty() || metadata.payload_type.empty() || metadata.codec_selector.empty()) { + return Error::fail(Error::Code::InvalidInput, "raster metadata requires layout, payload type, and codec selector"); + } + if (!std::has_single_bit(metadata.nominal_tile_size) || metadata.halo_width > metadata.nominal_tile_size + || metadata.halo_width > ((std::numeric_limits::max)() - metadata.nominal_tile_size) / 2 + || metadata.stored_tile_size != metadata.nominal_tile_size + 2 * metadata.halo_width) { + return Error::fail(Error::Code::InvalidInput, "invalid raster nominal size, stored size, or halo width"); + } + if (metadata.value_mapping != pixel::Mapping::Linear && metadata.value_mapping != pixel::Mapping::SRGBA) { + return Error::fail(Error::Code::InvalidInput, "unknown raster value mapping"); + } + return {}; +} + +Expected read_metadata(const std::filesystem::path& base_path) +{ + auto metadata = ::io::envelope::read(base_path / metadata_file_name); + if (!metadata) { + return metadata; + } + if (auto valid = validate(*metadata); !valid) { + return Error::propagate(std::move(valid), Error::Code::CorruptData, "validate raster metadata"); + } + return metadata; +} + +Expected write_metadata(const Metadata& metadata, const std::filesystem::path& base_path) +{ + return ::io::envelope::write(metadata, base_path / metadata_file_name, ::io::byte::WriteMode::Overwrite).transform([](auto&&) { }); +} + +detail::v1::Hierarchy encode_index(const store::Index& index, const store::Key2Hash_Map& hashes) +{ + detail::v1::Hierarchy encoded; + encoded.entries.reserve(index.size()); + for (const auto& [key, status] : index) { + encoded.entries.push_back({ key, status }); + } + std::ranges::sort(encoded.entries, [](const auto& left, const auto& right) { return left.id < right.id; }); + encoded.hashes.reserve(hashes.size()); + for (const auto& [key, hash] : hashes) { + encoded.hashes.push_back({ key, hash }); + } + std::ranges::sort(encoded.hashes, [](const auto& left, const auto& right) { return left.id < right.id; }); + return encoded; +} + +Expected> decode_index(const detail::v1::Hierarchy& encoded) +{ + store::IndexContents decoded; + store::Index& index = decoded.index; + std::unordered_set seen; + for (const auto& entry : encoded.entries) { + if (!StoreTraits::is_valid(entry.id)) { + return Error::fail(Error::Code::CorruptData, "raster index contains an invalid tile ID"); + } + if (!seen.insert(entry.id).second) { + return Error::fail(Error::Code::CorruptData, "raster index contains a duplicate tile ID"); + } + if (entry.status > store::NodeStatus::Virtual) { + return Error::fail(Error::Code::CorruptData, "raster index contains an invalid node status"); + } + auto added = index.set_raw(entry.id, store::NodeStatus { entry.status }); + if (!added) { + return Error::propagate(std::move(added), Error::Code::CorruptData, "decode raster index entry"); + } + } + if (auto valid = index.validate(); !valid) { + return Error::propagate(std::move(valid), "validate raster index topology"); + } + for (const auto& entry : encoded.hashes) { + if (!StoreTraits::is_valid(entry.id)) { + return Error::fail(Error::Code::CorruptData, "raster index contains a hash for an invalid tile ID"); + } + if (!decoded.hashes.emplace(entry.id, entry.hash).second) { + return Error::fail(Error::Code::CorruptData, "raster index contains a duplicate tile hash"); + } + } + return decoded; +} + +Expected> read_hierarchy(const std::filesystem::path& index_path) +{ + auto encoded = ::io::envelope::read(index_path); + if (!encoded) { + return Error::propagate(std::move(encoded), "read raster hierarchy"); + } + return decode_index(*encoded); +} + +Expected> read_index_file(const std::filesystem::path& index_path) +{ + auto metadata = read_metadata(index_path.parent_path()); + if (!metadata) { + return Error::propagate(std::move(metadata)); + } + auto contents = read_hierarchy(index_path); + if (!contents) { + return Error::propagate(std::move(contents)); + } + return store::IndexMetadata { + std::move(contents->index), std::move(contents->hashes), metadata->layout_id, metadata->payload_type, metadata->codec_selector + }; +} + +Expected write_index_file(const std::filesystem::path& index_path, const store::IndexMetadata& metadata) +{ + auto written = ::io::envelope::write(encode_index(metadata.index, metadata.hashes), index_path, ::io::byte::WriteMode::Replace); + if (!written) { + return Error::propagate(std::move(written), "checkpoint raster index"); + } + return {}; +} + +store::IndexFormat index_format() +{ + return { index_file_name, read_index_file, write_index_file, path_layout::zoom_xy_google::from_id, path_layout::zoom_xy_google::zoom_x_y_google }; +} + +} // namespace raster_store::io::manifest diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h new file mode 100644 index 00000000..2ca1f5b6 --- /dev/null +++ b/src/terrainlib/raster_store/io/manifest.h @@ -0,0 +1,82 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/envelope.h" +#include "raster_store/StoreTraits.h" +#include "raster_store/Tile.h" +#include "raster_store/pixel.h" +#include "store/IndexFormat.h" + +namespace raster_store::io::manifest { + +inline constexpr std::string_view metadata_file_name = "raster_store.metadata"; +inline constexpr std::string_view index_file_name = "raster_store.index"; + +namespace detail::v1 { + + static_assert(sizeof(unsigned) == sizeof(std::uint32_t), "raster index v1 requires 32-bit tile ID components"); + + struct Metadata { + std::string layout_id; + std::string payload_type; + std::string codec_selector; + unsigned stored_tile_size = default_tile_side; + unsigned nominal_tile_size = default_tile_side; + unsigned halo_width = 0; + pixel::Mapping value_mapping = pixel::Mapping::Linear; + }; + + struct IndexEntry { + radix::tile::Id id; + store::NodeStatus::Value status; + }; + + struct HashEntry { + radix::tile::Id id; + store::NodeDataHash hash; + }; + + struct Hierarchy { + std::vector entries; + std::vector hashes; + }; + +} // namespace detail::v1 + +using MetadataSchema = ::io::envelope::PayloadSchema<"raster_store.Metadata", ::io::envelope::Version<1, detail::v1::Metadata>>; +using IndexSchema = ::io::envelope::PayloadSchema<"raster_store.Index", ::io::envelope::Version<1, detail::v1::Hierarchy>>; +using Metadata = MetadataSchema::latest_type; + +Expected validate(const Metadata& metadata); +Expected read_metadata(const std::filesystem::path& base_path); +Expected write_metadata(const Metadata& metadata, const std::filesystem::path& base_path); +Expected> read_hierarchy(const std::filesystem::path& index_path); +Expected> decode_index(const detail::v1::Hierarchy& encoded); +detail::v1::Hierarchy encode_index(const store::Index& index, const store::Key2Hash_Map& hashes); +Expected> read_index_file(const std::filesystem::path& index_path); +Expected write_index_file(const std::filesystem::path& index_path, const store::IndexMetadata& metadata); +store::IndexFormat index_format(); + +} // namespace raster_store::io::manifest diff --git a/src/terrainlib/raster_store/path_layout.h b/src/terrainlib/raster_store/path_layout.h new file mode 100644 index 00000000..0d2a287a --- /dev/null +++ b/src/terrainlib/raster_store/path_layout.h @@ -0,0 +1,68 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "raster_store/StoreTraits.h" +#include "store/path_layout.h" +#include "string_utils.h" + +namespace raster_store::path_layout::zoom_xy_google { + +inline std::filesystem::path key_to_node_path(const radix::tile::Id& key) +{ + return std::to_string(key.zoom_level) + "/" + std::to_string(key.coords.x) + "/" + std::to_string(key.coords.y); +} + +inline std::optional node_path_to_key(const std::filesystem::path& path) +{ + if (path.is_absolute() || std::distance(path.begin(), path.end()) != 3) { + return std::nullopt; + } + auto part = path.begin(); + const auto zoom = from_chars(part->string()); + ++part; + const auto x = from_chars(part->string()); + ++part; + const auto y = from_chars(part->string()); + if (!zoom || !x || !y) { + return std::nullopt; + } + const radix::tile::Id key { *zoom, { *x, *y } }; + return StoreTraits::is_valid(key) ? std::optional(key) : std::nullopt; +} + +inline store::path_layout::Mapping zoom_x_y_google() +{ + return { "zoom/x/y_google", key_to_node_path, node_path_to_key }; +} + +inline std::optional> from_id(const std::string_view id) +{ + if (id == zoom_x_y_google().id) { + return zoom_x_y_google(); + } + return std::nullopt; +} + +} // namespace raster_store::path_layout::zoom_xy_google diff --git a/src/terrainlib/raster_store/pixel.h b/src/terrainlib/raster_store/pixel.h new file mode 100644 index 00000000..b278afe3 --- /dev/null +++ b/src/terrainlib/raster_store/pixel.h @@ -0,0 +1,75 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +namespace raster_store::pixel { + +enum class Mapping { + Linear, + SRGBA, +}; + +namespace detail { + + template + struct Format { + static_assert(std::is_trivially_copyable_v); + static_assert( + (std::is_integral_v && !std::is_same_v) || std::is_same_v || std::is_same_v); + + static std::string identifier() + { + if constexpr (std::is_floating_point_v) { + return "float" + std::to_string(sizeof(PixelType) * 8); + } else { + return std::string(std::is_signed_v ? "int" : "uint") + std::to_string(sizeof(PixelType) * 8); + } + } + }; + + template + struct Format> { + using PixelType = glm::vec; + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(PixelType) == Length * sizeof(Scalar), "AMORT requires unpadded GLM vectors"); + + static std::string identifier() { return "vec" + std::to_string(Length) + "<" + Format::identifier() + ">"; } + }; + +} // namespace detail + +template +inline constexpr Mapping default_mapping = Mapping::Linear; + +template +inline constexpr Mapping default_mapping> = Length == 3 || Length == 4 ? Mapping::SRGBA : Mapping::Linear; + +template +std::string identifier() +{ + return detail::Format::identifier(); +} + +} // namespace raster_store::pixel diff --git a/src/terrainlib/raster_store/scaler.h b/src/terrainlib/raster_store/scaler.h new file mode 100644 index 00000000..98366fac --- /dev/null +++ b/src/terrainlib/raster_store/scaler.h @@ -0,0 +1,306 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "pixel.h" +#include "raster/algorithm/reduce.h" +#include "raster/algorithm/scale.h" + +namespace raster_store { +namespace scale_details { + namespace algorithm = raster::algorithm; + namespace raster_detail = algorithm::detail; + + template + concept PairedSources = raster_detail::ViewSource && raster_detail::ViewSource + && raster_detail::NumericPixel> && std::same_as, std::uint16_t>; + + template + concept PairedDestinations = raster_detail::WritableViewDestination && raster_detail::WritableViewDestination + && std::same_as, raster_detail::SourcePixel> + && std::same_as, std::uint16_t>; + + template + Expected validate_mapping(pixel::Mapping mapping, bool numerical = true) + { + switch (mapping) { + case pixel::Mapping::Linear: + return {}; + case pixel::Mapping::SRGBA: + if (!numerical) + return {}; + if constexpr (raster_detail::srgb_pixel) { + return {}; + } + return Error::fail(Error::Code::Unsupported, "sRGB mapping requires RGB or RGBA uint8 pixels"); + } + return Error::fail(Error::Code::InvalidInput, "invalid raster value mapping"); + } + + template + Expected validate_destinations(const Data& data, + const Attribution& attribution, + unsigned halo_width, + const raster_detail::ScalingGeometry& geometry, + const raster::View& output, + const raster::View& output_attribution) + { + if (data.size() != attribution.size()) { + return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); + } + if (auto valid = raster_detail::validate_scaling_destination(data, halo_width, geometry, output); !valid) { + return valid; + } + if (auto valid = raster_detail::validate_scaling_destination(attribution, halo_width, geometry, output_attribution); !valid) { + return valid; + } + // Cross-pair aliases must be rejected before either operation writes. + if (auto valid = raster_detail::validate_view_overlap(data, output_attribution, false); !valid) { + return valid; + } + if (auto valid = raster_detail::validate_view_overlap(attribution, output, false); !valid) { + return valid; + } + return raster_detail::validate_view_overlap(output, output_attribution, false); + } + + template + Expected, radix::Raster>> produce_pair(glm::uvec2 size, const Operation& operation) + { + auto data = raster_detail::allocate_output(size); + if (!data) { + return Error::propagate(std::move(data)); + } + auto attribution = raster_detail::allocate_output(size); + if (!attribution) { + return Error::propagate(std::move(attribution)); + } + if (auto result = operation(*data, *attribution); !result) { + return Error::propagate(std::move(result)); + } + return std::pair { std::move(*data), std::move(*attribution) }; + } +} // namespace scale_details + +/// Scale only the selected conceptual output window. +template +requires scale_details::PairedSources && scale_details::PairedDestinations +[[nodiscard]] Expected scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int levels, + raster::algorithm::Resampling method, + glm::i64vec2 output_offset, + pixel::Mapping mapping, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + namespace algorithm = raster::algorithm; + using T = algorithm::detail::SourcePixel; + if (auto valid = scale_details::validate_mapping(mapping, levels < 0 || (levels > 0 && method != raster::algorithm::Resampling::NearestNeighbourAndBox)); + !valid) + return valid; + const auto input = algorithm::detail::read_only_view(raster::make_view(data)); + const auto input_attribution = algorithm::detail::read_only_view(raster::make_view(attribution)); + const auto output = raster::make_view(destination); + const auto output_attribution = raster::make_view(attribution_destination); + auto geometry = algorithm::detail::window_geometry(input.size(), halo_width, levels, method, output_offset, output.size()); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (input.size() != input_attribution.size() || output.size() != output_attribution.size()) + return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); + const auto check_input = [&](const auto& source, const auto& target) { + if (levels == 0) + return algorithm::detail::validate_view_overlap(*raster::make_view(source, geometry->source_origin, output.size()), target, true); + return algorithm::detail::validate_view_overlap(source, target, false); + }; + if (auto valid = check_input(input, output); !valid) + return valid; + if (auto valid = check_input(input_attribution, output_attribution); !valid) + return valid; + if (auto valid = algorithm::detail::validate_view_overlap(input, output_attribution, false); !valid) + return valid; + if (auto valid = algorithm::detail::validate_view_overlap(input_attribution, output, false); !valid) + return valid; + if (auto valid = algorithm::detail::validate_view_overlap(output, output_attribution, false); !valid) + return valid; + const auto scale_data = [&]() -> Expected { + if constexpr (algorithm::detail::srgb_pixel) { + if (mapping == pixel::Mapping::SRGBA) + return algorithm::scale(input, halo_width, levels, method, output_offset, algorithm::srgb_conversion(), output); + } + return algorithm::scale(input, halo_width, levels, method, output_offset, output); + }; + if (auto result = scale_data(); !result) + return result; + if (levels < 0) { + const auto attribution_geometry = *algorithm::detail::window_geometry( + input_attribution.size(), halo_width, levels, algorithm::Resampling::NearestNeighbourAndBox, output_offset, output.size()); + auto region = raster::make_view(input_attribution, attribution_geometry.source_origin, attribution_geometry.source_size); + if (!region) + return Error::propagate(std::move(region)); + return algorithm::reduce(*region, 0, static_cast(-std::int64_t(levels)), algorithm::Mode {}, output_attribution); + } + return algorithm::scale(input_attribution, halo_width, levels, algorithm::Resampling::NearestNeighbourAndBox, output_offset, output_attribution); +} + +template +requires scale_details::PairedSources +[[nodiscard]] Expected>, radix::Raster>> scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int levels, + raster::algorithm::Resampling method, + glm::i64vec2 output_offset, + glm::uvec2 output_size, + pixel::Mapping mapping) +{ + using T = raster::algorithm::detail::SourcePixel; + if (auto valid = scale_details::validate_mapping(mapping, levels < 0 || (levels > 0 && method != raster::algorithm::Resampling::NearestNeighbourAndBox)); + !valid) + return Error::propagate(std::move(valid)); + auto geometry = raster::algorithm::detail::window_geometry(data.size(), halo_width, levels, method, output_offset, output_size); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (data.size() != attribution.size()) + return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); + return scale_details::produce_pair(output_size, [&](auto& output, auto& output_attribution) { + return scale(data, attribution, halo_width, levels, method, output_offset, mapping, output, output_attribution); + }); +} + +/// Full-output convenience overloads use the same windowed scaling implementation. +template +requires scale_details::PairedSources && scale_details::PairedDestinations +[[nodiscard]] Expected scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int levels, + raster::algorithm::Resampling method, + pixel::Mapping mapping, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + auto geometry = raster::algorithm::detail::scale_geometry(data.size(), halo_width, levels, method); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (destination.size() != geometry->output || attribution_destination.size() != geometry->output) + return Error::fail(Error::Code::InvalidInput, "raster scaling output dimensions do not match"); + return scale(std::forward(data), + std::forward(attribution), + halo_width, + levels, + method, + glm::i64vec2(0), + mapping, + std::forward(destination), + std::forward(attribution_destination)); +} + +template +requires scale_details::PairedSources +[[nodiscard]] Expected>, radix::Raster>> scale( + Data&& data, Attribution&& attribution, unsigned halo_width, int levels, raster::algorithm::Resampling method, pixel::Mapping mapping) +{ + auto geometry = raster::algorithm::detail::scale_geometry(data.size(), halo_width, levels, method); + if (!geometry) + return Error::propagate(std::move(geometry)); + return scale(std::forward(data), std::forward(attribution), halo_width, levels, method, glm::i64vec2(0), geometry->output, mapping); +} + +/// Custom data reduction uses the supplied conversion; attribution always uses identity and Mode. +template +requires scale_details::PairedSources && scale_details::PairedDestinations + && raster::algorithm::detail::PixelReduction> +[[nodiscard]] Expected reduce(Data&& data, + Attribution&& attribution, + unsigned halo_width, + unsigned n_zoom_levels, + const Conversion& conversion, + const Reducer& reducer, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + namespace algorithm = raster::algorithm; + const auto input = algorithm::detail::read_only_view(raster::make_view(data)); + const auto input_attribution = algorithm::detail::read_only_view(raster::make_view(attribution)); + const auto output = raster::make_view(destination); + const auto output_attribution = raster::make_view(attribution_destination); + auto geometry = algorithm::detail::scaling_geometry(input.size(), halo_width, n_zoom_levels, false, 0); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + if (auto valid = scale_details::validate_destinations(input, input_attribution, halo_width, *geometry, output, output_attribution); !valid) { + return valid; + } + if (auto result = algorithm::reduce(input, halo_width, n_zoom_levels, conversion, reducer, output); !result) { + return result; + } + return algorithm::reduce(input_attribution, halo_width, n_zoom_levels, algorithm::Mode {}, output_attribution); +} + +template +requires scale_details::PairedSources + && raster::algorithm::detail::PixelReduction> +[[nodiscard]] Expected>, radix::Raster>> reduce( + Data&& data, Attribution&& attribution, unsigned halo_width, unsigned n_zoom_levels, const Conversion& conversion, const Reducer& reducer) +{ + if (data.size() != attribution.size()) { + return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); + } + auto geometry = raster::algorithm::detail::scaling_geometry(data.size(), halo_width, n_zoom_levels, false, 0); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + return scale_details::produce_pair>(geometry->output, [&](auto& output, auto& output_attribution) { + return reduce(data, attribution, halo_width, n_zoom_levels, conversion, reducer, output, output_attribution); + }); +} + +template +requires scale_details::PairedSources && scale_details::PairedDestinations + && raster::algorithm::detail::PixelReduction> +[[nodiscard]] Expected reduce(Data&& data, + Attribution&& attribution, + unsigned halo_width, + unsigned n_zoom_levels, + const Reducer& reducer, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + return reduce(std::forward(data), + std::forward(attribution), + halo_width, + n_zoom_levels, + raster::algorithm::identity_conversion(), + reducer, + std::forward(destination), + std::forward(attribution_destination)); +} + +template +requires scale_details::PairedSources + && raster::algorithm::detail::PixelReduction> +[[nodiscard]] auto reduce(Data&& data, Attribution&& attribution, unsigned halo_width, unsigned n_zoom_levels, const Reducer& reducer) + -> Expected>, radix::Raster>> +{ + return reduce( + std::forward(data), std::forward(attribution), halo_width, n_zoom_levels, raster::algorithm::identity_conversion(), reducer); +} +} // namespace raster_store diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h new file mode 100644 index 00000000..9d0e118f --- /dev/null +++ b/src/terrainlib/raster_store/storage.h @@ -0,0 +1,179 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +#include "raster_store/attribution.h" +#include "raster_store/io/TileCodec.h" +#include "raster_store/io/manifest.h" +#include "raster_store/path_layout.h" +#include "store/open.h" +#include "store/snapshot.h" + +namespace raster_store::storage { + +template +using IndexedStorage = store::IndexedStorage>; + +struct OpenOptions { + bool allow_incomplete = false; +}; + +struct CreateOptions { + unsigned nominal_tile_size = default_tile_side; + unsigned halo_width = 0; + std::optional value_mapping = std::nullopt; + std::string codec_selector = "amort"; + ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression; + std::optional copy_attribution_from_index; +}; + +namespace detail { + + // The owned handle is destroyed on return, before publication renames its directory. + template + Expected finish_output(std::unique_ptr> storage) + { + return storage->save_index(); + } + +} // namespace detail + +template +Expected>, std::unique_ptr>> open( + const std::filesystem::path& base_path, const OpenOptions options = {}) +{ + const auto normalized_path = store::snapshot::normalized(base_path); + if (!options.allow_incomplete && normalized_path.extension() == ".part") { + return Error::fail(Error::Code::InvalidInput, "opening an incomplete raster snapshot requires allow_incomplete", base_path); + } + auto metadata = io::manifest::read_metadata(normalized_path); + if (!metadata) { + return Error::propagate(std::move(metadata), "open raster snapshot"); + } + const auto index_path = normalized_path / io::manifest::index_file_name; + auto contents = io::manifest::read_hierarchy(index_path); + if (!contents) { + return Error::propagate(std::move(contents), "open raster snapshot"); + } + auto table = attribution::read_table(index_path); + if (!table) { + return Error::propagate(std::move(table), "open raster snapshot attribution"); + } + const auto resolve_codec + = [&metadata](const std::string_view name) { return io::tile_codec::from_name(name, glm::uvec2(metadata->stored_tile_size)); }; + auto storage = store::open_index>(index_path, + io::manifest::index_format(), + { std::move(contents->index), std::move(contents->hashes), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, + pixel::identifier(), + resolve_codec); + if (!storage) { + return Error::propagate(std::move(storage)); + } + return std::pair { std::make_unique>(std::move(*storage)), + std::make_unique(std::move(*metadata)) }; +} + +template +Expected>, std::unique_ptr>> create( + const std::filesystem::path& final_path, const CreateOptions options = {}) +{ + const auto destination = store::snapshot::normalized(final_path); + if (destination.filename().empty() || destination.filename() == "." || destination.filename() == ".." || destination.extension() == ".part") { + return Error::fail(Error::Code::InvalidInput, "creation requires a final snapshot path without a .part suffix", final_path); + } + if (options.halo_width > ((std::numeric_limits::max)() - options.nominal_tile_size) / 2) { + return Error::fail(Error::Code::InvalidInput, "stored raster size overflows"); + } + auto metadata = std::make_unique(std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), + pixel::identifier(), + options.codec_selector, + options.nominal_tile_size + 2 * options.halo_width, + options.nominal_tile_size, + options.halo_width, + options.value_mapping.value_or(pixel::default_mapping)); + if (auto valid = io::manifest::validate(*metadata); !valid) { + return Error::propagate(std::move(valid)); + } + auto codec = io::tile_codec::from_name(options.codec_selector, glm::uvec2(metadata->stored_tile_size), options.compression_algorithm); + if (!codec) { + return Error::propagate(std::move(codec)); + } + auto table + = attribution::read_table(options.copy_attribution_from_index.value_or(store::snapshot::partial_path(destination) / io::manifest::index_file_name)); + if (!table) { + return Error::propagate(std::move(table), "resolve attribution for new raster snapshot"); + } + auto created = store::snapshot::create_partial(destination); + if (!created) { + return Error::propagate(std::move(created)); + } + const auto& partial_path = *created; + if (options.copy_attribution_from_index) { + auto copied = attribution::copy_table(*options.copy_attribution_from_index, partial_path); + if (!copied) { + return Error::propagate(std::move(copied)); + } + } + auto written = io::manifest::write_metadata(*metadata, partial_path); + if (!written) { + return Error::propagate(std::move(written), "create raster metadata"); + } + auto storage = store::make_storage>(partial_path, + io::manifest::index_format(), + path_layout::zoom_xy_google::zoom_x_y_google(), + metadata->payload_type, + metadata->codec_selector, + std::move(*codec)); + if (!storage) { + return Error::propagate(std::move(storage)); + } + // Even an empty newly created snapshot has a readable checkpoint. + if (auto saved = storage->save_index(); !saved) { + return Error::propagate(std::move(saved), "create initial raster index"); + } + return std::pair { std::make_unique>(std::move(*storage)), std::move(metadata) }; +} + +// Consumes the output handle on success or failure. An unsuccessful publication +// leaves the .part snapshot available for explicit incomplete opening. +template +Expected publish(std::unique_ptr> storage) +{ + if (!storage) { + return Error::fail(Error::Code::InvalidInput, "publication requires an output handle"); + } + const auto partial_path = store::snapshot::normalized(storage->base_path()); + if (!store::snapshot::is_partial(partial_path)) { + return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); + } + if (auto finished = detail::finish_output(std::move(storage)); !finished) { + return Error::propagate(std::move(finished), "finish raster snapshot before publication"); + } + return store::snapshot::publish(partial_path); +} + +} // namespace raster_store::storage diff --git a/src/terrainlib/sf/finalize_storage.h b/src/terrainlib/sf/finalize_storage.h index f6ff379b..f1a5ec9b 100644 --- a/src/terrainlib/sf/finalize_storage.h +++ b/src/terrainlib/sf/finalize_storage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/sf/validate_index.h b/src/terrainlib/sf/validate_index.h index 09cb4083..8078e718 100644 --- a/src/terrainlib/sf/validate_index.h +++ b/src/terrainlib/sf/validate_index.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/spatial_lookup/CellBased.h b/src/terrainlib/spatial_lookup/CellBased.h index e834ebfb..da017421 100644 --- a/src/terrainlib/spatial_lookup/CellBased.h +++ b/src/terrainlib/spatial_lookup/CellBased.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 @@ -5,7 +23,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include "spatial_lookup/CellBasedStorage.h" #include "spatial_lookup/NDLoopHelper.h" #include "spatial_lookup/SpatialLookup.h" diff --git a/src/terrainlib/spatial_lookup/CellBasedStorage.h b/src/terrainlib/spatial_lookup/CellBasedStorage.h index 57ddd9ba..0c9354d2 100644 --- a/src/terrainlib/spatial_lookup/CellBasedStorage.h +++ b/src/terrainlib/spatial_lookup/CellBasedStorage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/spatial_lookup/Grid.h b/src/terrainlib/spatial_lookup/Grid.h index 9f86ca38..38a1c687 100644 --- a/src/terrainlib/spatial_lookup/Grid.h +++ b/src/terrainlib/spatial_lookup/Grid.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "spatial_lookup/GridStorage.h" diff --git a/src/terrainlib/spatial_lookup/GridStorage.h b/src/terrainlib/spatial_lookup/GridStorage.h index 7df68f90..4173fef1 100644 --- a/src/terrainlib/spatial_lookup/GridStorage.h +++ b/src/terrainlib/spatial_lookup/GridStorage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/spatial_lookup/Hashmap.h b/src/terrainlib/spatial_lookup/Hashmap.h index 22159aa7..488ccc1d 100644 --- a/src/terrainlib/spatial_lookup/Hashmap.h +++ b/src/terrainlib/spatial_lookup/Hashmap.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "spatial_lookup/CellBased.h" diff --git a/src/terrainlib/spatial_lookup/HashmapStorage.h b/src/terrainlib/spatial_lookup/HashmapStorage.h index e6feb072..ece92be8 100644 --- a/src/terrainlib/spatial_lookup/HashmapStorage.h +++ b/src/terrainlib/spatial_lookup/HashmapStorage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/spatial_lookup/NDLoopHelper.h b/src/terrainlib/spatial_lookup/NDLoopHelper.h index 3a79b7d6..c1e14f84 100644 --- a/src/terrainlib/spatial_lookup/NDLoopHelper.h +++ b/src/terrainlib/spatial_lookup/NDLoopHelper.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/spatial_lookup/QuantizedHash.h b/src/terrainlib/spatial_lookup/QuantizedHash.h index c728b94d..4640e821 100644 --- a/src/terrainlib/spatial_lookup/QuantizedHash.h +++ b/src/terrainlib/spatial_lookup/QuantizedHash.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/spatial_lookup/SpatialLookup.h b/src/terrainlib/spatial_lookup/SpatialLookup.h index 4cfde7ec..48ba5e40 100644 --- a/src/terrainlib/spatial_lookup/SpatialLookup.h +++ b/src/terrainlib/spatial_lookup/SpatialLookup.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h deleted file mode 100644 index a5e5ce5c..00000000 --- a/src/terrainlib/srs.h +++ /dev/null @@ -1,386 +0,0 @@ -/***************************************************************************** - * 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 . - *****************************************************************************/ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace srs { - -inline std::optional epsg_code(const OGRSpatialReference &srs) { - OGRSpatialReference tmp = srs; - tmp.AutoIdentifyEPSG(); - - const char *authName = tmp.GetAuthorityName(nullptr); - const char *authCode = tmp.GetAuthorityCode(nullptr); - - if (authName == nullptr || authCode == nullptr) { - return std::nullopt; - } - - if (std::string(authName) != "EPSG") { - return std::nullopt; - } - - return std::atoi(authCode); -} - -inline std::string friendly_name(const OGRSpatialReference &srs) { - if (const auto code = epsg_code(srs)) { - return "EPSG:" + std::to_string(*code); - } - - const char *name = srs.GetName(); - - if (name != nullptr && *name != '\0') { - return std::string(name); - } - - return "unknown"; -} - -inline std::unique_ptr clone(const OGRSpatialReference &srs) { - auto cloned = srs.Clone(); - return std::unique_ptr(cloned); -} - -namespace { -struct TransformationCacheEntry { - OGRSpatialReference source; - OGRSpatialReference target; - std::shared_ptr transform; -}; -} - -inline std::unique_ptr uncached_transformation(const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs) { - std::unique_ptr transform(OGRCreateCoordinateTransformation(&source_srs, &target_srs)); - if (!transform) { - throw std::runtime_error("Couldn't create SRS transformation"); - } - return transform; -} - -inline std::shared_ptr transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) { - thread_local std::vector cache; - - // Check forward - for (auto &entry : cache) { - if (entry.source.IsSame(&source_srs) && entry.target.IsSame(&target_srs)) { - return entry.transform; - } - } - - // Check inverse - for (auto &entry : cache) { - if (entry.source.IsSame(&target_srs) && entry.target.IsSame(&source_srs)) { - std::shared_ptr inverse(entry.transform->GetInverse()); - if (!inverse) { - throw std::runtime_error("Couldn't create inverse transformation"); - } - cache.push_back({source_srs, target_srs, inverse}); - return inverse; - } - } - - // Not found - auto transform = uncached_transformation(source_srs, target_srs); - std::shared_ptr shared_transform = std::move(transform); - cache.push_back({source_srs, target_srs, shared_transform}); - return shared_transform; -} - -template -inline glm::tvec2 transform_point(OGRCoordinateTransformation *transform, glm::tvec2 p) { - if (!transform->Transform(1, &p.x, &p.y)) - throw std::runtime_error("srs::transform_point(glm::tvec2) failed"); - return p; -} -template -inline glm::tvec3 transform_point(OGRCoordinateTransformation *transform, glm::tvec3 p) { - if (!transform->Transform(1, &p.x, &p.y, &p.z)) - throw std::runtime_error("srs::transform_point(glm::tvec3) failed"); - return p; -} - -template -inline glm::tvec2 transform_point(const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs, glm::tvec2 p) { - const auto transform = transformation(source_srs, target_srs); - return transform_point(transform.get(), p); -} -template -inline glm::tvec3 transform_point(const OGRSpatialReference &source_srs, const OGRSpatialReference& target_srs, glm::tvec3 p){ - const auto transform = transformation(source_srs, target_srs); - return transform_point(transform.get(), p); -} - -template -inline void transform_points_inplace(OGRCoordinateTransformation *transform, Container &points) { - using PointType = typename Container::value_type; - using T = typename PointType::value_type; - constexpr bool is_3d = (PointType::length() == 3); - constexpr bool is_array = std::is_array_v; - - const size_t size = points.size(); - - constexpr std::size_t array_size = is_array ? points.size() : 0; - using OutputContainer = std::conditional_t, std::vector>; - - OutputContainer xs, ys; - std::conditional_t zs; - - if constexpr (!is_array) { - xs.resize(size); - ys.resize(size); - if constexpr (PointType::length() == 3) { - zs.resize(size); - } - } - - for (size_t i = 0; i < size; i++) { - xs[i] = points[i].x; - ys[i] = points[i].y; - if constexpr (is_3d) { - zs[i] = points[i].z; - } - } - - bool success; - if constexpr (is_3d) { - success = transform->Transform(size, xs.data(), ys.data(), zs.data()); - } else { - success = transform->Transform(size, xs.data(), ys.data()); - } - - if (!success) { - throw std::runtime_error("srs::transform_points_inplace failed"); - } - - for (size_t i = 0; i < size; i++) { - if constexpr (is_3d) { - points[i] = PointType(xs[i], ys[i], zs[i]); - } else { - points[i] = PointType(xs[i], ys[i]); - } - } -} - -template -inline Container transform_points(const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs, Container points) { - const auto transform = transformation(source_srs, target_srs); - transform_points_inplace(transform.get(), points); - return points; -} - - -// TODO: somehow integrate into a single transform_points -template -inline std::vector> transform_points_to_2d(OGRCoordinateTransformation *transform, const std::vector> &points) { - std::vector xs; - std::vector ys; - std::vector zs; - - xs.resize(points.size()); - ys.resize(points.size()); - zs.resize(points.size()); - - for (size_t i = 0; i < points.size(); i++) { - xs[i] = points[i].x; - ys[i] = points[i].y; - zs[i] = points[i].z; - } - - if (!transform->Transform(points.size(), xs.data(), ys.data(), zs.data())) { - throw std::runtime_error("srs::transform_points_to_2d(OGRCoordinateTransformation *, std::vector>) failed"); - } - - std::vector> transformed; - transformed.resize(points.size()); - for (size_t i = 0; i < points.size(); ++i) { - transformed[i] = {xs[i], ys[i]}; - } - - return transformed; -} - -inline radix::tile::SrsBounds non_exact_bounds_transform(OGRCoordinateTransformation *transform, const radix::tile::SrsBounds &bounds) { - std::array xs = {bounds.min.x, bounds.max.x}; - std::array ys = {bounds.min.y, bounds.max.y}; - if (!transform->Transform(2, xs.data(), ys.data())) { - throw std::runtime_error("srs::non_exact_bounds_transform failed"); - } - return { - {std::min(xs[0], xs[1]), std::min(ys[0], ys[1])}, - {std::max(xs[0], xs[1]), std::max(ys[0], ys[1])}}; -} -inline radix::tile::SrsBounds non_exact_bounds_transform(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &sourceSrs, const OGRSpatialReference &targetSrs) { - const auto transform = transformation(sourceSrs, targetSrs); - return non_exact_bounds_transform(transform.get(), bounds); -} -inline radix::geometry::Aabb3d non_exact_bounds_transform(OGRCoordinateTransformation *transform, const radix::geometry::Aabb3d &bounds) { - std::array xs = {bounds.min.x, bounds.max.x}; - std::array ys = {bounds.min.y, bounds.max.y}; - std::array zs = {bounds.min.z, bounds.max.z}; - if (!transform->Transform(2, xs.data(), ys.data(), zs.data())) { - throw std::runtime_error("srs::non_exact_bounds_transform failed"); - } - return {{xs[0], ys[0], zs[0]}, {xs[1], ys[1], zs[1]}}; -} -inline radix::geometry::Aabb3d non_exact_bounds_transform(const radix::geometry::Aabb3d &bounds, const OGRSpatialReference &sourceSrs, const OGRSpatialReference &targetSrs) { - const auto transform = transformation(sourceSrs, targetSrs); - return non_exact_bounds_transform(transform.get(), bounds); -} - -/// Transforms bounds from one srs to another, -/// in such a way that all points inside the original bounds are guaranteed to also be in the new bounds. -/// But there can be points inside the new bounds that were not present in the original ones. -inline radix::tile::SrsBounds encompassing_bounds_transfer(OGRCoordinateTransformation *transform, const radix::tile::SrsBounds &source_bounds) { - radix::tile::SrsBounds target_bounds; - const int result = transform->TransformBounds( - source_bounds.min.x, source_bounds.min.y, source_bounds.max.x, source_bounds.max.y, - &target_bounds.min.x, &target_bounds.min.y, &target_bounds.max.x, &target_bounds.max.y, - 21); - if (result != TRUE) { - throw std::runtime_error("srs::encompassing_bounding_box_transfer failed"); - } - return target_bounds; -} -inline radix::tile::SrsBounds encompassing_bounds_transfer(const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs, const radix::tile::SrsBounds &source_bounds) { - if (source_srs.IsSame(&target_srs)) { - return source_bounds; - } - - const std::shared_ptr transformation = srs::transformation(source_srs, target_srs); - return encompassing_bounds_transfer(transformation.get(), source_bounds); -} - -inline radix::geometry::Aabb3d encompassing_bounds_transfer( - OGRCoordinateTransformation *transform, - const radix::geometry::Aabb3d &source_bounds, - const uint32_t intermediate_points_edges = 21, - const uint32_t intermediate_points_faces = 5) { - std::vector points; - - // Add corner points - const auto corners = radix::geometry::corners(source_bounds); - std::copy(corners.begin(), corners.end(), std::back_inserter(points)); - - // Sample points on the edges - const auto edges = radix::geometry::edges(source_bounds); - for (const auto &edge : edges) { - const auto &[p0, p1] = edge; - - for (uint32_t i = 1; i <= intermediate_points_edges; i++) { - const double t = static_cast(i) / (intermediate_points_edges + 1); - const auto p = glm::mix(p0, p1, t); - points.push_back(p); - } - } - - // TODO: do we need this? - // Sample points on the faces - const auto quads = radix::geometry::quads(source_bounds); - for (const auto &quad : quads) { - const auto &[p0, p1, p2, p3] = quad; - - for (uint32_t i = 1; i <= intermediate_points_faces; i++) { - const double u = static_cast(i) / (intermediate_points_faces + 1); - const auto edge_p0 = glm::mix(p0, p1, u); - const auto edge_p1 = glm::mix(p3, p2, u); - - for (uint32_t j = 1; j <= intermediate_points_faces; j++) { - const double v = static_cast(j) / (intermediate_points_faces + 1); - const auto point = glm::mix(edge_p0, edge_p1, v); - points.push_back(point); - } - } - } - - // Transform all collected points - transform_points_inplace(transform, points); - - // Compute bounds from transformed points - radix::geometry::Aabb3d target_bounds; - target_bounds.min = glm::dvec3(std::numeric_limits::max()); - target_bounds.max = glm::dvec3(std::numeric_limits::min()); - for (const auto &point : points) { - target_bounds.expand_by(point); - } - - return target_bounds; -} -inline radix::geometry::Aabb3d encompassing_bounds_transfer( - const OGRSpatialReference &source_srs, - const OGRSpatialReference &target_srs, - const radix::geometry::Aabb3d &source_bounds, - const uint32_t intermediate_points_edges = 21, - const uint32_t intermediate_points_faces = 5) { - if (source_srs.IsSame(&target_srs)) { - return source_bounds; - } - - const auto transform = srs::transformation(source_srs, target_srs); - return encompassing_bounds_transfer(transform.get(), source_bounds, intermediate_points_edges, intermediate_points_faces); -} - -inline std::expected from_epsg(const uint32_t epsg) { - OGRSpatialReference srs; - if (srs.importFromEPSG(epsg) != OGRERR_NONE) { - return std::unexpected(fmt::format("Failed to import spatial reference from EPSG code: {}", epsg)); - } - srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - return srs; -} - -inline std::expected from_user_input(const std::string &user_input) { - OGRSpatialReference srs; - if (srs.SetFromUserInput(user_input.c_str()) != OGRERR_NONE) { - return std::unexpected(fmt::format("Failed to set spatial reference from user input: {}", user_input)); - } - srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - return srs; -} - -inline OGRSpatialReference ecef() { - return from_epsg(4978).value(); -} -inline OGRSpatialReference webmercator() { - return from_epsg(3857).value(); -} -inline OGRSpatialReference wgs84() { - return from_epsg(4326).value(); -} -inline OGRSpatialReference mgi() { - return from_epsg(4312).value(); -} - -} diff --git a/src/terrainlib/store/Codec.h b/src/terrainlib/store/Codec.h index 56958e1a..2de7e911 100644 --- a/src/terrainlib/store/Codec.h +++ b/src/terrainlib/store/Codec.h @@ -1,15 +1,48 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include "io/byte.h" namespace store { +// Opaque identity of a whole node's stored content, defined by the codec. +using NodeDataHash = io::byte::vector; +using NodeDataHashView = io::byte::span; + +inline constexpr std::size_t min_hash_size = 4; + +// Codec methods must support concurrent calls on the same instance. +// write creates the node's files exclusively and fails with AlreadyExists if +// one of them exists. It never truncates or replaces a file, which may be a +// hard link shared with another snapshot. Reading a node requires its write to +// have completed; exclusive creation alone does not make an unfinished file +// readable. template class Codec { public: @@ -17,13 +50,22 @@ class Codec { virtual std::vector paths(const std::filesystem::path& node_path) const = 0; - virtual Expected read(const std::filesystem::path&) const { return Error::fail(Error::Code::Unsupported, "codec does not support reading"); } + // A given expected hash must equal the identity of the files read. Storage passes one only + // to codecs that support hashing. + virtual Expected read(const std::filesystem::path&, std::optional /* expected_hash */ = std::nullopt) const + { + return Error::fail(Error::Code::Unsupported, "codec does not support reading"); + } - virtual Expected write(const std::filesystem::path&, const NodeData&) const + // Returns the identity of the written node, or an empty hash if the codec does not support hashing. + virtual Expected write(const std::filesystem::path&, const NodeData&) const { return Error::fail(Error::Code::Unsupported, "codec does not support writing"); } + // Whether writes return whole-node identities of at least min_hash_size bytes and reads compare expected ones. + virtual bool supports_hashing() const { return false; } + protected: static std::filesystem::path add_extension(std::filesystem::path path, const std::string_view extension) { diff --git a/src/terrainlib/store/Index.h b/src/terrainlib/store/Index.h index d1e99098..ca13074e 100644 --- a/src/terrainlib/store/Index.h +++ b/src/terrainlib/store/Index.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -7,7 +25,7 @@ #include #include "Error.h" -#include "log.h" +#include "cli_support/log.h" #include "store/NodeStatus.h" #include "store/Traits.h" #include @@ -167,6 +185,47 @@ class Index { return {}; } + Expected validate() const + { + for (const auto& [key, status] : m_index) { + if (!Traits::is_valid(key)) { + return Error::fail(Error::Code::CorruptData, "index contains an invalid hierarchy key"); + } + if (static_cast(status) > NodeStatus::Virtual) { + return Error::fail(Error::Code::CorruptData, "index contains an invalid node status"); + } + bool has_child = false; + if (const auto children = Traits::children(key)) { + for (const auto& child : *children) { + auto child_status = get(child); + if (!child_status) { + return Error::propagate(std::move(child_status), Error::Code::CorruptData, "validate indexed child"); + } + has_child = has_child || child_status->has_value(); + } + } + if ((status == NodeStatus::Leaf && has_child) + || ((status == NodeStatus::Inner || status == NodeStatus::Virtual) && !has_child)) { + return Error::fail(Error::Code::CorruptData, "index contains inconsistent node topology"); + } + const auto parent = Traits::parent(key); + if (!parent) { + if (key != Traits::root()) { + return Error::fail(Error::Code::CorruptData, "index contains a non-root node without a parent"); + } + continue; + } + auto parent_status = get(*parent); + if (!parent_status) { + return Error::propagate(std::move(parent_status), Error::Code::CorruptData, "validate indexed parent"); + } + if (!parent_status->has_value() || parent_status->value() == NodeStatus::Leaf) { + return Error::fail(Error::Code::CorruptData, "index contains a node without a valid indexed parent"); + } + } + return {}; + } + void clear() { m_index.clear(); } bool empty() const { return m_index.empty(); } size_t size() const { return m_index.size(); } diff --git a/src/terrainlib/store/IndexFormat.h b/src/terrainlib/store/IndexFormat.h index 71c04944..fd82e5cc 100644 --- a/src/terrainlib/store/IndexFormat.h +++ b/src/terrainlib/store/IndexFormat.h @@ -1,26 +1,90 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include "Error.h" +#include "store/Codec.h" #include "store/Index.h" #include "store/path_layout.h" namespace store { +// Sparse content identities of physical nodes. +template +using Key2Hash_Map = std::unordered_map; + +// The contents of an index payload: topology and the identities of physical nodes. +template +struct IndexContents { + Index index; + Key2Hash_Map hashes; +}; + template struct IndexMetadata { Index index; + Key2Hash_Map hashes; std::string layout_id; std::string payload_class; std::string codec_selector; }; +// With a codec that hashes, every physical node has a hash of at least min_hash_size bytes +// and no other node has one. With a codec that does not hash, there are no hashes. +template +Expected validate_hashes(const Index& index, const Key2Hash_Map& hashes, const bool codec_hashes) +{ + if (!codec_hashes) { + if (!hashes.empty()) { + return Error::fail(Error::Code::CorruptData, "index contains hashes, but its codec does not support hashing"); + } + return {}; + } + std::size_t physical_count = 0; + for (const auto& [key, status] : index) { + if (status == NodeStatus::Virtual) { + continue; + } + ++physical_count; + const auto found = hashes.find(key); + if (found == hashes.end()) { + return Error::fail(Error::Code::CorruptData, "index lacks the hash of physical node " + Traits::key_to_string(key)); + } + if (found->second.size() < min_hash_size) { + return Error::fail(Error::Code::CorruptData, "index contains a hash of fewer than four bytes for node " + Traits::key_to_string(key)); + } + } + if (hashes.size() != physical_count) { + return Error::fail(Error::Code::CorruptData, "index contains hashes of nodes that are not physically present"); + } + return {}; +} + template struct IndexFormat { std::string_view index_filename; diff --git a/src/terrainlib/store/IndexedStorage.h b/src/terrainlib/store/IndexedStorage.h index 6aac6138..e5067e28 100644 --- a/src/terrainlib/store/IndexedStorage.h +++ b/src/terrainlib/store/IndexedStorage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -15,11 +33,10 @@ class IndexedStorage : public Storage { explicit IndexedStorage(Storage storage) : Base(std::move(storage)) { - this->ensure_indexed(); } - IndexedStorage(RawStorage raw, Index index, Persistence persistence) - : Base(std::move(raw), std::move(index), std::move(persistence)) + IndexedStorage(RawStorage raw, Index index, Key2Hash_Map hashes, Persistence persistence) + : Base(std::move(raw), std::move(index), std::move(hashes), std::move(persistence)) { } diff --git a/src/terrainlib/store/NodeStatus.h b/src/terrainlib/store/NodeStatus.h index 4cd17241..c7dadba4 100644 --- a/src/terrainlib/store/NodeStatus.h +++ b/src/terrainlib/store/NodeStatus.h @@ -1,10 +1,28 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "log.h" +#include "cli_support/log.h" #include #include diff --git a/src/terrainlib/store/NodeStatusOrMissing.h b/src/terrainlib/store/NodeStatusOrMissing.h index 1b69fd28..8dab27c3 100644 --- a/src/terrainlib/store/NodeStatusOrMissing.h +++ b/src/terrainlib/store/NodeStatusOrMissing.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -11,7 +29,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include "store/NodeStatus.h" namespace store { diff --git a/src/terrainlib/store/RawStorage.h b/src/terrainlib/store/RawStorage.h index 22e90011..39f36d75 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.h @@ -1,7 +1,26 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -32,15 +51,15 @@ class RawStorage { RawStorage(RawStorage&&) noexcept = default; RawStorage& operator=(RawStorage&&) noexcept = default; - Expected load(const Key& key) const + Expected load(const Key& key, const std::optional expected_hash = std::nullopt) const { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); } - return m_codec->read(m_layout.node_path(key)); + return m_codec->read(m_layout.node_path(key), expected_hash); } - Expected save(const Key& key, const NodeData& data) const + Expected save(const Key& key, const NodeData& data) const { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); @@ -48,6 +67,8 @@ class RawStorage { return m_codec->write(m_layout.node_path(key), data); } + bool supports_hashing() const { return m_codec->supports_hashing(); } + Expected> paths(const Key& key) const { if (!Traits::is_valid(key)) { @@ -78,57 +99,17 @@ class RawStorage { return true; } - Expected remove(const Key& key) const - { - auto node_paths = paths(key); - if (!node_paths) { - return Error::propagate(std::move(node_paths)); - } - bool removed = false; - for (const auto& path : node_paths.value()) { - std::error_code error; - removed = std::filesystem::remove(path, error) || removed; - if (error) { - return Error::fail(Error::Code::Io, "remove", path, error); - } - } - return removed; - } - - Expected copy_from(const Key& key, const RawStorage& source) const - { - return copy_from(key, source, []() -> Expected { return {}; }); - } - - template - Expected copy_from(const Key& key, const RawStorage& source, BeforeModify&& before_modify) const + // Hard-links the node's files from source, whose existence the caller has checked. Both + // storages must use the same codec, and the target files must not exist. + Expected link_from(const Key& key, const RawStorage& source) const { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); } - auto source_exists = source.has(key); - if (!source_exists) { - return Error::propagate(std::move(source_exists)); - } - if (!source_exists.value()) { - return Error::fail(Error::Code::NotFound, "source node " + Traits::key_to_string(key) + " is missing"); - } const std::filesystem::path probe("__codec_probe__/node"); if (m_codec->paths(probe) != source.m_codec->paths(probe)) { - auto loaded = source.m_codec->read(source.m_layout.node_path(key)); - if (!loaded) { - return Error::propagate(std::move(loaded), "read source node while copying"); - } - const auto prepared = before_modify(); - if (!prepared) { - return prepared; - } - auto written = m_codec->write(m_layout.node_path(key), loaded.value()); - if (!written) { - return Error::propagate(std::move(written), "write target node while copying"); - } - return {}; + return Error::fail(Error::Code::Unsupported, "link node " + Traits::key_to_string(key) + " between storages with different codecs"); } const auto source_paths = source.m_codec->paths(source.m_layout.node_path(key)); @@ -136,19 +117,8 @@ class RawStorage { if (source_paths.size() != target_paths.size()) { return Error::fail(Error::Code::Internal, "matching codec probes produced different actual path counts"); } - if (source_paths == target_paths) { - return {}; - } - const auto prepared = before_modify(); - if (!prepared) { - return prepared; - } for (size_t index = 0; index < source_paths.size(); ++index) { std::error_code error; - std::filesystem::remove(target_paths[index], error); - if (error) { - return Error::fail(Error::Code::Io, "remove", target_paths[index], error); - } std::filesystem::create_directories(target_paths[index].parent_path(), error); if (error) { return Error::fail(Error::Code::Io, "create directories", target_paths[index].parent_path(), error); @@ -162,7 +132,6 @@ class RawStorage { } const path_layout::Resolver& layout() const { return m_layout; } - const Codec& codec() const { return *m_codec; } private: path_layout::Resolver m_layout; diff --git a/src/terrainlib/store/Storage.h b/src/terrainlib/store/Storage.h index 2beb046e..d87515fe 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -8,16 +26,14 @@ #include -#include "log.h" +#include + +#include "cli_support/log.h" #include "store/IndexFormat.h" #include "store/RawStorage.h" namespace store { -struct StorageSettings { - bool allow_overwrite = false; -}; - template class Storage { public: @@ -33,14 +49,10 @@ class Storage { std::string codec_selector; }; - explicit Storage(RawStorage raw) - : m_raw(std::move(raw)) - { - } - - Storage(RawStorage raw, Index index, Persistence persistence, const bool dirty = false) + Storage(RawStorage raw, Index index, Key2Hash_Map hashes, Persistence persistence, const bool dirty = false) : m_raw(std::move(raw)) , m_index(std::move(index)) + , m_hashes(std::move(hashes)) , m_persistence(std::move(persistence)) , m_dirty(dirty) { @@ -54,11 +66,12 @@ class Storage { Storage(Storage&& other) noexcept : m_raw(std::move(other.m_raw)) , m_index(std::move(other.m_index)) + , m_hashes(std::move(other.m_hashes)) , m_persistence(std::move(other.m_persistence)) - , m_settings(other.m_settings) , m_dirty(std::exchange(other.m_dirty, false)) { other.m_index.reset(); + other.m_hashes.clear(); other.m_persistence.reset(); } @@ -70,29 +83,33 @@ class Storage { finalize_displaced_state(); m_raw = std::move(other.m_raw); m_index = std::move(other.m_index); + m_hashes = std::move(other.m_hashes); m_persistence = std::move(other.m_persistence); - m_settings = other.m_settings; m_dirty = std::exchange(other.m_dirty, false); other.m_index.reset(); + other.m_hashes.clear(); other.m_persistence.reset(); return *this; } + // Compares the node's recorded hash with the identity of the files read. Expected load(const Key& key) const { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); } - if (m_index.has_value()) { - auto status = m_index->get(key); - if (!status) { - return Error::propagate(std::move(status), "look up node " + Traits::key_to_string(key) + " in storage index before loading"); - } - if (!status->has_value() || status->value() == NodeStatus::Virtual) { - return Error::fail(Error::Code::NotFound, "node " + Traits::key_to_string(key) + " is not physically present in the index"); - } + auto status = m_index->get(key); + if (!status) { + return Error::propagate(std::move(status), "look up node " + Traits::key_to_string(key) + " in storage index before loading"); + } + if (!status->has_value() || status->value() == NodeStatus::Virtual) { + return Error::fail(Error::Code::NotFound, "node " + Traits::key_to_string(key) + " is not physically present in the index"); } - return m_raw.load(key); + std::optional expected_hash; + if (const NodeDataHash* hash = recorded_hash(key)) { + expected_hash = *hash; + } + return m_raw.load(key, expected_hash); } Expected save(const Key& key, const NodeData& data) @@ -104,78 +121,83 @@ class Storage { if (!exists) { return Error::propagate(std::move(exists), "check whether node " + Traits::key_to_string(key) + " exists before saving"); } - if (exists.value() && !m_settings.allow_overwrite) { + if (exists.value()) { const auto node_paths = m_raw.paths(key).value(); return Error::fail(Error::Code::AlreadyExists, "save node to", node_paths.empty() ? m_raw.layout().node_path(key) : node_paths.front()); } - auto result = m_raw.save(key, data); - if (!result) { - return result; + auto hash = write_payload(key, data); + if (!hash) { + return Error::propagate(std::move(hash)); } - if (m_index.has_value()) { - auto added = m_index->add(key); - if (!added) { - return Error::propagate(std::move(added), "add saved node " + Traits::key_to_string(key) + " to storage index"); - } - m_dirty = m_dirty || added.value(); + return commit(key, std::move(*hash)); + } + + // Writes the node's payload files without recording the node in the index and + // returns the codec's hash of them; commit records both. save is the existence + // check, write_payload and commit. + // Thread safety: may run concurrently with itself and with any other method + // except construction, move, assignment and destruction. It reads only the + // layout and the codec, which never change after construction, and never the + // index. Codecs create files exclusively, so a second write of a key, concurrent + // or later, fails with AlreadyExists and leaves the first write's files intact. + // Payload files must not be removed or replaced externally while writes are + // outstanding. Linking from this storage requires completed, immutable payloads. + Expected write_payload(const Key& key, const NodeData& data) const { return m_raw.save(key, data); } + + // Records a node and the hash returned by write_payload in the index after + // write_payload has succeeded for that key. Without hashing support, the hash is + // ignored. Not thread safe. Fails with AlreadyExists if the node is already + // physically present in the index. + Expected commit(const Key& key, NodeDataHash hash) + { + auto added = m_index->add(key); + if (!added) { + return Error::propagate(std::move(added), "add node " + Traits::key_to_string(key) + " to storage index"); + } + if (!added.value()) { + return Error::fail(Error::Code::AlreadyExists, "node " + Traits::key_to_string(key) + " is already physically present in the storage index"); } + if (supports_hashing()) { + ASSERT(hash.size() >= min_hash_size); + m_hashes.insert_or_assign(key, std::move(hash)); + } + m_dirty = true; return {}; } - Expected copy_from(const Key& key, const Storage& source) + // The source must list the node as physically present. Linking transfers the source's + // recorded hash; both storages use the same codec, so the source has one if the target needs it. + Expected link_from(const Key& key, const Storage& source) { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); } auto exists = has(key); if (!exists) { - return Error::propagate(std::move(exists), "check whether target node " + Traits::key_to_string(key) + " exists before copying"); + return Error::propagate(std::move(exists), "check whether target node " + Traits::key_to_string(key) + " exists before linking"); } - if (exists.value() && !m_settings.allow_overwrite) { + if (exists.value()) { const auto node_paths = m_raw.paths(key).value(); - return Error::fail(Error::Code::AlreadyExists, "copy node to", node_paths.empty() ? m_raw.layout().node_path(key) : node_paths.front()); + return Error::fail(Error::Code::AlreadyExists, "link node to", node_paths.empty() ? m_raw.layout().node_path(key) : node_paths.front()); } - const auto prepare_target = [&]() -> Expected { - if (!exists.value() || !m_index.has_value()) { - return {}; - } - auto removed = m_index->remove(key); - if (!removed) { - return Error::propagate(std::move(removed), "remove overwritten node " + Traits::key_to_string(key) + " from storage index"); - } - m_dirty = m_dirty || removed.value(); - return {}; - }; - - auto result = m_raw.copy_from(key, source.m_raw, prepare_target); - if (!result) { - return result; + auto source_exists = source.has(key); + if (!source_exists) { + return Error::propagate(std::move(source_exists), "check whether source node " + Traits::key_to_string(key) + " exists before linking"); } - if (m_index.has_value()) { - auto added = m_index->add(key); - if (!added) { - return Error::propagate(std::move(added), "add copied node " + Traits::key_to_string(key) + " to storage index"); - } - m_dirty = m_dirty || added.value(); + if (!source_exists.value()) { + return Error::fail(Error::Code::NotFound, "source node " + Traits::key_to_string(key) + " is missing"); } - return {}; - } - - Expected remove(const Key& key) - { - auto removed = m_raw.remove(key); - if (!removed) { - return removed; + NodeDataHash source_hash; + if (const NodeDataHash* hash = source.recorded_hash(key)) { + source_hash = *hash; } - if (m_index.has_value()) { - auto index_removed = m_index->remove(key); - if (!index_removed) { - return Error::propagate(std::move(index_removed), "remove node " + Traits::key_to_string(key) + " from storage index"); - } - m_dirty = m_dirty || index_removed.value(); + + auto result = m_raw.link_from(key, source.m_raw); + if (!result) { + return result; } - return removed.value(); + return commit(key, std::move(source_hash)); } Expected has(const Key& key) const @@ -183,9 +205,6 @@ class Storage { if (!Traits::is_valid(key)) { return store::invalid_key_error(key); } - if (!m_index.has_value()) { - return m_raw.has(key); - } auto status = m_index->get(key); if (!status) { return Error::propagate(std::move(status), "look up node " + Traits::key_to_string(key) + " in storage index"); @@ -204,9 +223,22 @@ class Storage { return node_paths->empty() ? m_raw.layout().node_path(key) : node_paths->front(); } + // Returns the recorded identity of a physical node without accessing its files. + // Storage whose codec does not support hashing reports no hashes. + Expected hash_for(const Key& key) const + { + if (!Traits::is_valid(key)) { + return store::invalid_key_error(key); + } + const NodeDataHash* hash = recorded_hash(key); + if (hash == nullptr) { + return Error::fail(Error::Code::NotFound, "no hash is recorded for node " + Traits::key_to_string(key)); + } + return *hash; + } + const std::filesystem::path& base_path() const { return m_raw.layout().base_path(); } const path_layout::Resolver& layout() const { return m_raw.layout(); } - const Codec& codec() const { return m_raw.codec(); } std::optional codec_selector() const { if (!m_persistence.has_value()) { @@ -216,13 +248,6 @@ class Storage { } bool is_indexed() const { return m_index.has_value(); } - void ensure_indexed() - { - if (!m_index.has_value()) { - m_index.emplace(); - m_dirty = true; - } - } std::optional>> index() const { @@ -232,7 +257,7 @@ class Storage { return std::cref(m_index.value()); } - Expected save_index() const + Expected save_index() { if (!m_dirty) { return {}; @@ -242,6 +267,7 @@ class Storage { } const IndexMetadata metadata { m_index.value(), + m_hashes, m_persistence->layout_id, m_persistence->payload_class, m_persistence->codec_selector, @@ -253,17 +279,7 @@ class Storage { return result; } - Expected save_or_create_index() - { - if (!m_index.has_value()) { - m_index.emplace(); - m_dirty = true; - } - return save_index(); - } - - const StorageSettings& settings() const { return m_settings; } - StorageSettings& settings() { return m_settings; } + Expected save_or_create_index() { return save_index(); } protected: Index& index_mut() { return m_index.value(); } @@ -272,6 +288,14 @@ class Storage { void set_index_dirty(const bool dirty = true) const { m_dirty = dirty; } private: + bool supports_hashing() const { return m_raw.supports_hashing(); } + + const NodeDataHash* recorded_hash(const Key& key) const + { + const auto found = m_hashes.find(key); + return found == m_hashes.end() ? nullptr : &found->second; + } + void finalize_displaced_state() noexcept { if (!m_dirty || !m_index.has_value()) { @@ -285,8 +309,9 @@ class Storage { RawStorage m_raw; std::optional> m_index; + // Identities of physical nodes; empty if the codec does not support hashing. + Key2Hash_Map m_hashes; std::optional m_persistence = std::nullopt; - StorageSettings m_settings; mutable bool m_dirty = false; }; diff --git a/src/terrainlib/store/ThreadSafeStorage.h b/src/terrainlib/store/ThreadSafeStorage.h index 13c30002..83af5d55 100644 --- a/src/terrainlib/store/ThreadSafeStorage.h +++ b/src/terrainlib/store/ThreadSafeStorage.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -8,6 +26,8 @@ #include +#include "Error.h" + namespace store { template @@ -42,12 +62,42 @@ class ThreadSafeStorage { return m_storage.has(key); } + auto hash_for(const key_type& key) const + { + std::shared_lock lock(m_mutex); + return m_storage.hash_for(key); + } + std::filesystem::path base_path() const noexcept { return m_storage.base_path(); } - auto save(const key_type& key, const value_type& value) const + // Saves of different keys encode and write their payloads in parallel. The + // existence check holds the shared lock and only the index update holds the + // exclusive lock. + Expected save(const key_type& key, const value_type& value) const + { + { + std::shared_lock lock(m_mutex); + auto exists = m_storage.has(key); + if (!exists) { + return Error::propagate(std::move(exists), "check whether node exists before saving"); + } + if (exists.value()) { + return Error::fail(Error::Code::AlreadyExists, "save node to", Error::asserting_unwrap(m_storage.path_for(key))); + } + } + auto hash = m_storage.write_payload(key, value); + if (!hash) { + return Error::propagate(std::move(hash)); + } + std::unique_lock lock(m_mutex); + return m_storage.commit(key, std::move(*hash)); + } + + template + auto link_from(const key_type& key, const Source& source) const { std::unique_lock lock(m_mutex); - return m_storage.save(key, value); + return m_storage.link_from(key, source); } auto save_or_create_index() const diff --git a/src/terrainlib/store/Traits.h b/src/terrainlib/store/Traits.h index 00fc23ae..1626899a 100644 --- a/src/terrainlib/store/Traits.h +++ b/src/terrainlib/store/Traits.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/store/cache/Dummy.h b/src/terrainlib/store/cache/Dummy.h index ab7c2990..33cc45af 100644 --- a/src/terrainlib/store/cache/Dummy.h +++ b/src/terrainlib/store/cache/Dummy.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "store/cache/Interface.h" diff --git a/src/terrainlib/store/cache/Interface.h b/src/terrainlib/store/cache/Interface.h index 7cc6ce73..09eb2c75 100644 --- a/src/terrainlib/store/cache/Interface.h +++ b/src/terrainlib/store/cache/Interface.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/store/cache/Lru.h b/src/terrainlib/store/cache/Lru.h index 60b33d76..f938991a 100644 --- a/src/terrainlib/store/cache/Lru.h +++ b/src/terrainlib/store/cache/Lru.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/store/inputs.cpp b/src/terrainlib/store/inputs.cpp new file mode 100644 index 00000000..29dda20a --- /dev/null +++ b/src/terrainlib/store/inputs.cpp @@ -0,0 +1,58 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "store/inputs.h" + +#include + +namespace store::inputs { + +namespace { + Expected<::io::byte::vector> recorded_hash(const std::filesystem::path& path) + { + auto header = ::io::envelope::read_header(path); + if (!header) { + return Error::propagate(std::move(header), "fingerprint input"); + } + // An empty hash would match any content. + if (header->hash.empty()) { + return Error::fail(Error::Code::Unsupported, "input file records no payload hash", path); + } + return std::move(header->hash); + } +} // namespace + +Expected fingerprint(const std::filesystem::path& snapshot, const std::string_view metadata_file_name, const std::string_view index_file_name) +{ + std::error_code error; + const auto canonical = std::filesystem::canonical(snapshot, error); + if (error) { + return Error::fail(Error::Code::Io, "resolve input path", snapshot, error); + } + auto metadata = recorded_hash(canonical / metadata_file_name); + if (!metadata) { + return Error::propagate(std::move(metadata)); + } + auto index = recorded_hash(canonical / index_file_name); + if (!index) { + return Error::propagate(std::move(index)); + } + return Fingerprint { canonical.string(), std::move(*metadata), std::move(*index) }; +} + +} // namespace store::inputs diff --git a/src/terrainlib/store/inputs.h b/src/terrainlib/store/inputs.h new file mode 100644 index 00000000..65807de7 --- /dev/null +++ b/src/terrainlib/store/inputs.h @@ -0,0 +1,75 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" +#include "io/byte.h" +#include "io/envelope.h" +#include "store/snapshot.h" + +// An incomplete snapshot records the inputs and settings that produced it, so that a +// later run can verify that the snapshot is a compatible cache before reusing its nodes. +namespace store::inputs { + +inline constexpr std::string_view file_name = "inputs.tmp"; + +// Identifies a published input snapshot by the recorded payload hashes of its metadata and index. +struct Fingerprint { + std::string path; + ::io::byte::vector metadata_hash; + ::io::byte::vector index_hash; + + bool operator==(const Fingerprint&) const = default; +}; + +Expected fingerprint(const std::filesystem::path& snapshot, std::string_view metadata_file_name, std::string_view index_file_name); + +template +Expected write(const std::filesystem::path& snapshot, const typename Schema::latest_type& record) +{ + auto written = ::io::envelope::write(record, snapshot / file_name, ::io::byte::WriteMode::Overwrite); + if (!written) { + return Error::propagate(std::move(written), "write input record"); + } + return {}; +} + +template +Expected validate_cache(const std::filesystem::path& cache, const typename Schema::latest_type& record) +{ + if (!snapshot::is_partial(cache)) { + return Error::fail(Error::Code::InvalidInput, "a cache must be an incomplete .part snapshot", cache); + } + auto saved = ::io::envelope::read(cache / file_name); + if (!saved) { + return Error::propagate(std::move(saved), "requested cache has a missing or corrupt " + std::string(file_name)); + } + if (*saved != record) { + return Error::fail(Error::Code::InvalidInput, "requested cache was produced from different inputs or settings", cache); + } + return {}; +} + +} // namespace store::inputs diff --git a/src/terrainlib/store/open.h b/src/terrainlib/store/open.h index 5ac295c9..ac626b2d 100644 --- a/src/terrainlib/store/open.h +++ b/src/terrainlib/store/open.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -13,14 +31,10 @@ namespace store { template Expected> open_index( - const std::filesystem::path& index_path, const IndexFormat format, const std::string_view expected_payload_class, CodecResolver&& resolve_codec) + const std::filesystem::path& index_path, const IndexFormat format, IndexMetadata metadata, + const std::string_view expected_payload_class, CodecResolver&& resolve_codec) { using Key = typename Traits::Key; - auto metadata_result = format.read(index_path); - if (!metadata_result) { - return Error::propagate(std::move(metadata_result), "read storage index \"" + index_path.string() + "\""); - } - IndexMetadata metadata = std::move(metadata_result.value()); if (metadata.payload_class != expected_payload_class) { return Error::fail(Error::Code::Unsupported, "unexpected payload class: " + metadata.payload_class); } @@ -39,11 +53,15 @@ Expected> open_index( if (!codec) { return Error::propagate(std::move(codec), "resolve storage codec \"" + metadata.codec_selector + "\""); } + if (auto valid = validate_hashes(metadata.index, metadata.hashes, codec.value()->supports_hashing()); !valid) { + return Error::propagate(std::move(valid), "validate storage index hashes"); + } const std::filesystem::path base_path = index_path.parent_path(); using Storage = store::Storage; return IndexedStorage(RawStorage(path_layout::Resolver(base_path, mapping.value()), std::move(codec.value())), std::move(metadata.index), + std::move(metadata.hashes), typename Storage::Persistence { format, index_path, @@ -53,6 +71,19 @@ Expected> open_index( }); } +template +Expected> open_index( + const std::filesystem::path& index_path, const IndexFormat format, + const std::string_view expected_payload_class, CodecResolver&& resolve_codec) +{ + auto metadata = format.read(index_path); + if (!metadata) { + return Error::propagate(std::move(metadata), "read storage index \"" + index_path.string() + "\""); + } + return open_index(index_path, format, std::move(*metadata), + expected_payload_class, std::forward(resolve_codec)); +} + template Expected> make_storage(const std::filesystem::path& base_path, const IndexFormat format, @@ -76,7 +107,7 @@ Expected> make_storage(const std::filesystem::path& ba std::move(codec_selector), }; RawStorage raw(path_layout::Resolver(base_path, mapping), std::move(codec)); - return Storage(std::move(raw), Index {}, std::move(persistence), true); + return Storage(std::move(raw), Index {}, Key2Hash_Map {}, std::move(persistence), true); } } // namespace store diff --git a/src/terrainlib/store/path_layout.h b/src/terrainlib/store/path_layout.h index d08423d7..8806c63d 100644 --- a/src/terrainlib/store/path_layout.h +++ b/src/terrainlib/store/path_layout.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/store/snapshot.cpp b/src/terrainlib/store/snapshot.cpp new file mode 100644 index 00000000..d1f41b66 --- /dev/null +++ b/src/terrainlib/store/snapshot.cpp @@ -0,0 +1,135 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "store/snapshot.h" + +#include +#include + +#include + +#include "cli_support/log.h" +#include "io/utils.h" +#include "store/inputs.h" + +namespace store::snapshot { + +std::filesystem::path normalized(const std::filesystem::path& path) +{ + auto result = path.lexically_normal(); + if (!result.has_filename() && result.has_relative_path()) { + result = result.parent_path(); + } + return result; +} + +std::filesystem::path partial_path(const std::filesystem::path& final_path) +{ + auto path = normalized(final_path); + path += ".part"; + return path; +} + +bool is_partial(const std::filesystem::path& path) { return normalized(path).extension() == ".part"; } + +Expected require_absent(const std::filesystem::path& path) +{ + std::error_code error; + const auto status = std::filesystem::symlink_status(path, error); + if (error && error != std::errc::no_such_file_or_directory) { + return Error::fail(Error::Code::Io, "inspect snapshot destination", path, error); + } + if (std::filesystem::exists(status)) { + return Error::fail(Error::Code::AlreadyExists, "create snapshot at", path); + } + return {}; +} + +Expected create_partial(const std::filesystem::path& final_path) +{ + const auto destination = normalized(final_path); + if (destination.filename().empty() || destination.filename() == "." || destination.filename() == ".." || is_partial(destination)) { + return Error::fail(Error::Code::InvalidInput, "creation requires a final snapshot path without a .part suffix", final_path); + } + if (auto absent = require_absent(destination); !absent) { + return Error::propagate(std::move(absent)); + } + const auto partial = partial_path(destination); + if (auto absent = require_absent(partial); !absent) { + return Error::propagate(std::move(absent)); + } + std::error_code error; + if (!destination.parent_path().empty()) { + std::filesystem::create_directories(destination.parent_path(), error); + if (error) { + return Error::fail(Error::Code::Io, "create snapshot parent directory", destination.parent_path(), error); + } + } + if (!std::filesystem::create_directory(partial, error)) { + return Error::fail(error ? Error::Code::Io : Error::Code::AlreadyExists, "create incomplete snapshot", partial, error); + } + return partial; +} + +Expected publish(const std::filesystem::path& partial_path) +{ + const auto partial = normalized(partial_path); + if (!is_partial(partial)) { + return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); + } + auto destination = partial; + destination.replace_extension(); + if (auto absent = require_absent(destination); !absent) { + return absent; + } + // The record stays until the rename succeeded, so a .part that failed to publish remains a cache. + auto renamed = ::io::utils::rename_without_replacement(partial, destination); + if (!renamed) { + return Error::propagate(std::move(renamed), "publish snapshot"); + } + // A leftover record is harmless, as a published snapshot is never accepted as a cache. + const auto inputs_path = destination / inputs::file_name; + std::error_code error; + std::filesystem::remove(inputs_path, error); + if (error) { + LOG_WARN("Failed to remove the input record {} from the published snapshot: {}", inputs_path, error.message()); + } + return {}; +} + +Expected check_link_filesystem(const std::filesystem::path& source, const std::filesystem::path& output) +{ + struct stat source_status {}; + if (::stat(source.c_str(), &source_status) != 0) { + return Error::fail(Error::Code::Io, "inspect link source filesystem", source, std::error_code(errno, std::generic_category())); + } + auto parent = std::filesystem::absolute(output).parent_path(); + struct stat output_status {}; + while (::stat(parent.c_str(), &output_status) != 0) { + if (errno != ENOENT || parent == parent.parent_path()) { + return Error::fail(Error::Code::Io, "inspect output filesystem", parent, std::error_code(errno, std::generic_category())); + } + parent = parent.parent_path(); + } + if (source_status.st_dev != output_status.st_dev) { + return Error::fail(Error::Code::Unsupported, "hard links require the source and the output to be on the same filesystem", source); + } + return {}; +} + +} // namespace store::snapshot diff --git a/src/terrainlib/store/snapshot.h b/src/terrainlib/store/snapshot.h new file mode 100644 index 00000000..20f12bd7 --- /dev/null +++ b/src/terrainlib/store/snapshot.h @@ -0,0 +1,47 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "Error.h" + +// A snapshot is produced in .part and renamed to once complete, so an +// existing is always complete. An incomplete .part can serve as a cache. +namespace store::snapshot { + +inline constexpr auto checkpoint_interval = std::chrono::minutes(2); + +// Lexically normal and without a trailing separator, so that "out/" names the snapshot "out". +std::filesystem::path normalized(const std::filesystem::path& path); +std::filesystem::path partial_path(const std::filesystem::path& final_path); +bool is_partial(const std::filesystem::path& path); +Expected require_absent(const std::filesystem::path& path); +// Requires that neither nor .part exists, then creates .part. +Expected create_partial(const std::filesystem::path& final_path); +// Renames .part to and removes the input record. The storage in it +// must have saved its index and must not be used afterwards. +Expected publish(const std::filesystem::path& partial_path); +// Hard links require the source and the output to be on the same filesystem. +Expected check_link_filesystem(const std::filesystem::path& source, const std::filesystem::path& output); + +} // namespace store::snapshot diff --git a/src/terrainlib/store/traverse.h b/src/terrainlib/store/traverse.h index b491d06b..881f721e 100644 --- a/src/terrainlib/store/traverse.h +++ b/src/terrainlib/store/traverse.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/string_utils.h b/src/terrainlib/string_utils.h index f2de1635..6ae89665 100644 --- a/src/terrainlib/string_utils.h +++ b/src/terrainlib/string_utils.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adrian Gawor + * + * 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 diff --git a/src/terrainlib/texture/Mask.h b/src/terrainlib/texture/Mask.h index cc950a41..62a003e6 100644 --- a/src/terrainlib/texture/Mask.h +++ b/src/terrainlib/texture/Mask.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/texture/dilate_colors.cpp b/src/terrainlib/texture/dilate_colors.cpp index 7f2f0edf..1911fed7 100644 --- a/src/terrainlib/texture/dilate_colors.cpp +++ b/src/terrainlib/texture/dilate_colors.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "texture/dilate_colors.h" #include diff --git a/src/terrainlib/texture/dilate_colors.h b/src/terrainlib/texture/dilate_colors.h index 0a0cfd07..79cf2d18 100644 --- a/src/terrainlib/texture/dilate_colors.h +++ b/src/terrainlib/texture/dilate_colors.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/tile_path.h b/src/terrainlib/tile_path.h index e669057e..7a4fd79e 100644 --- a/src/terrainlib/tile_path.h +++ b/src/terrainlib/tile_path.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/type_utils.h b/src/terrainlib/type_utils.h index 67516358..23297462 100644 --- a/src/terrainlib/type_utils.h +++ b/src/terrainlib/type_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/src/terrainlib/uv/atlas.cpp b/src/terrainlib/uv/atlas.cpp index a2f403fa..957b5b6c 100644 --- a/src/terrainlib/uv/atlas.cpp +++ b/src/terrainlib/uv/atlas.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include @@ -6,7 +24,7 @@ #include "enumerate.h" #include "geometry/geometry.h" #include "glm_utils.h" -#include "log.h" +#include "cli_support/log.h" namespace uv { diff --git a/src/terrainlib/uv/atlas.h b/src/terrainlib/uv/atlas.h index b67a32ad..bace107d 100644 --- a/src/terrainlib/uv/atlas.h +++ b/src/terrainlib/uv/atlas.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/uv/fit.h b/src/terrainlib/uv/fit.h index 2ed3ac27..4252a576 100644 --- a/src/terrainlib/uv/fit.h +++ b/src/terrainlib/uv/fit.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/uv/unwrap.cpp b/src/terrainlib/uv/unwrap.cpp index 9186c5de..d6c312d0 100644 --- a/src/terrainlib/uv/unwrap.cpp +++ b/src/terrainlib/uv/unwrap.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/uv/unwrap.h b/src/terrainlib/uv/unwrap.h index 98786483..43cbfbe5 100644 --- a/src/terrainlib/uv/unwrap.h +++ b/src/terrainlib/uv/unwrap.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/terrainlib/variant_utils.h b/src/terrainlib/variant_utils.h index bfe10dfe..c616284d 100644 --- a/src/terrainlib/variant_utils.h +++ b/src/terrainlib/variant_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/terrainlib/vector_utils.h b/src/terrainlib/vector_utils.h index c60918d0..a153c4fb 100644 --- a/src/terrainlib/vector_utils.h +++ b/src/terrainlib/vector_utils.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 diff --git a/src/tile_builder/CMakeLists.txt b/src/tile_builder/CMakeLists.txt deleted file mode 100644 index a05e9edb..00000000 --- a/src/tile_builder/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -add_library(tilebuilderlib - DatasetReader.h DatasetReader.cpp - Exception.h - ParallelTileGenerator.h ParallelTileGenerator.cpp - ParallelTiler.h ParallelTiler.cpp - TileHeightsGenerator.h TileHeightsGenerator.cpp - Tiler.h Tiler.cpp - TopDownTiler.h TopDownTiler.cpp - alpine_raster.h alpine_raster.cpp - depth_first_tile_traverser.h - image_writer.h image_writer.cpp -) - -target_include_directories(tilebuilderlib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(tilebuilderlib PUBLIC terrainlib ZLIB::ZLIB GDAL::GDAL fmt) - -add_executable(tile-builder main.cpp) -target_link_libraries(tile-builder PRIVATE tilebuilderlib) diff --git a/src/tile_builder/DatasetReader.cpp b/src/tile_builder/DatasetReader.cpp deleted file mode 100644 index a472ae54..00000000 --- a/src/tile_builder/DatasetReader.cpp +++ /dev/null @@ -1,222 +0,0 @@ -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - *******************************************************************************/ - -#include "DatasetReader.h" -#include "Dataset.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Dataset.h" -#include "Exception.h" -#include -#include "ctb/types.hpp" -#include "log.h" - -namespace { -std::string toWkt(const OGRSpatialReference& srs) -{ - char* wkt_char_string = nullptr; - srs.exportToWkt(&wkt_char_string); - std::string wkt_string(wkt_char_string); - CPLFree(wkt_char_string); - return wkt_string; -} - -std::array computeGeoTransform(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) -{ - return { bounds.min.x, bounds.width() / width, 0, - bounds.max.y, 0, -bounds.height() / height }; -} - -using GdalImageTransformArgsPtr = std::unique_ptr; -GdalImageTransformArgsPtr make_image_transform_args(const DatasetReader& reader, - Dataset* dataset, - const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) -{ - CPLStringList transformOptions; - if (reader.isReprojecting()) { - transformOptions.SetNameValue("SRC_SRS", reader.dataset_srs_wkt().c_str()); - transformOptions.SetNameValue("DST_SRS", reader.target_srs_wkt().c_str()); - } - auto args = GdalImageTransformArgsPtr(GDALCreateGenImgProjTransformer2(dataset->gdalDataset(), nullptr, transformOptions.List()), &GDALDestroyGenImgProjTransformer); - if (!args) { - throw Exception("GDALCreateGenImgProjTransformer2 failed."); - } - - const auto adfGeoTransform = computeGeoTransform(bounds, width, height); - GDALSetGenImgProjTransformerDstGeoTransform(args.get(), adfGeoTransform.data()); - - return args; -} - -using GdalWarpOptionsPtr = std::unique_ptr; -using WarpOptionData = std::pair; - -WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) -{ - auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); - options->hSrcDS = dataset->gdalDataset(); - options->nBandCount = 1; - options->eResampleAlg = GDALResampleAlg::GRA_Cubic; - options->panSrcBands = static_cast(CPLMalloc(sizeof(int) * 1)); - options->panDstBands = static_cast(CPLMalloc(sizeof(int) * 1)); - options->padfSrcNoDataReal = static_cast(CPLMalloc(sizeof(double) * 1)); - options->padfSrcNoDataImag = static_cast(CPLMalloc(sizeof(double) * 1)); - options->padfDstNoDataReal = static_cast(CPLMalloc(sizeof(double) * 1)); - options->padfDstNoDataImag = static_cast(CPLMalloc(sizeof(double) * 1)); - { - int bGotNoData = false; - double noDataValue = dataset->gdalDataset()->GetRasterBand(1)->GetNoDataValue(&bGotNoData); - if (!bGotNoData) - noDataValue = -32768; - - options->padfSrcNoDataReal[0] = noDataValue; - options->padfSrcNoDataImag[0] = 0; - options->padfDstNoDataReal[0] = noDataValue; - options->padfDstNoDataImag[0] = 0; - - options->panSrcBands[0] = int(reader.dataset_band()); - options->panDstBands[0] = 1; - } - constexpr auto use_approximation = true; - if (use_approximation) { - const auto error_threshold = 0.5; - auto image_transform_args = make_image_transform_args(reader, dataset, bounds, width, height); - options->pTransformerArg = GDALCreateApproxTransformer(GDALGenImgProjTransform, image_transform_args.get(), error_threshold); - options->pfnTransformer = GDALApproxTransform; - return { std::move(options), std::move(image_transform_args) }; - } - - options->pTransformerArg = make_image_transform_args(reader, dataset, bounds, width, height).release(); - options->pfnTransformer = GDALGenImgProjTransform; - - return { std::move(options), GdalImageTransformArgsPtr(nullptr, &GDALDestroyGenImgProjTransformer) }; -} - -#ifdef ALP_ENABLE_OVERVIEW_READING -std::shared_ptr getOverviewDataset(const std::shared_ptr& dataset, void* hTransformerArg, bool warn_on_missing_overviews) -{ - GDALDataset* poSrcDS = dataset->gdalDataset(); - int nOvLevel = -2; - int nOvCount = poSrcDS->GetRasterBand(1)->GetOverviewCount(); - - assert(nOvCount >= 0); - if (nOvCount == 0) { - if (warn_on_missing_overviews) - TNTN_LOG_WARN("No dataset overviews found."); - return dataset; - } - - std::array adfSuggestedGeoTransform; - std::array adfExtent; - int nPixels; - int nLines; - /* Compute what the "natural" output resolution (in pixels) would be for this */ - /* input dataset */ - if (GDALSuggestedWarpOutput2(poSrcDS, GDALGenImgProjTransform, hTransformerArg, - adfSuggestedGeoTransform.data(), &nPixels, &nLines, - adfExtent.data(), 0) - == CE_Failure) { - TNTN_LOG_WARN("GDALSuggestedWarpOutput2 failed. We won't use dataset overviews!"); - return dataset; - } - - double dfTargetRatio = 1.0 / adfSuggestedGeoTransform[1]; - // if( dfTargetRatio <= 1.0 ) { - // TNTN_LOG_WARN(fmt::format("dfTargetRatio {} <= 1.0. We won't use dataset overviews!\n", dfTargetRatio)); - // TNTN_LOG_WARN(fmt::format("Other values: nPixels={}, nLines={}, adfExtent={}/{}/{}/{}\n", - // nPixels, nLines, adfExtent[0], adfExtent[1], adfExtent[2], adfExtent[3])); - // TNTN_LOG_WARN(fmt::format("Other values: adfSuggestedGeoTransform={}/{}/{}/{}/{}/{}\n", - // adfSuggestedGeoTransform[0], adfSuggestedGeoTransform[1], adfSuggestedGeoTransform[2], - // adfSuggestedGeoTransform[3], adfSuggestedGeoTransform[4], adfSuggestedGeoTransform[5])); - // return dataset; - // } - - int iOvr; - for (iOvr = -1; iOvr < nOvCount - 1; iOvr++) { - const auto dfOvrRatio = (iOvr < 0) ? 1.0 : double(poSrcDS->GetRasterXSize()) / poSrcDS->GetRasterBand(1)->GetOverview(iOvr)->GetXSize(); - const auto dfNextOvrRatio = double(poSrcDS->GetRasterXSize()) / poSrcDS->GetRasterBand(1)->GetOverview(iOvr + 1)->GetXSize(); - if (dfOvrRatio < dfTargetRatio && dfNextOvrRatio > dfTargetRatio) - break; - if (std::abs(dfOvrRatio - dfTargetRatio) < 1e-1) - break; - } - iOvr += nOvLevel + 2; - if (iOvr >= 0) { - TNTN_LOG_DEBUG("WARPING: Selecting overview level {} for output dataset {}x{}\n", iOvr, nPixels, nLines); - return std::make_shared(static_cast(GDALCreateOverviewDataset(poSrcDS, iOvr, FALSE))); - } - return dataset; -} -#endif - -} - -DatasetReader::DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band, bool warn_on_missing_overviews) - : m_dataset(dataset) - , m_dataset_srs_wkt(toWkt(dataset->srs())) - , m_target_srs_wkt(toWkt(targetSRS)) - , m_requires_reprojection(!dataset->srs().IsSame(&targetSRS)) - , m_warn_on_missing_overviews(warn_on_missing_overviews) - , m_band(band) -{ - if (band > dataset->n_bands()) - throw Exception(fmt::format("Dataset does not contain band number {} (there are {} bands).", band, dataset->n_bands())); -} - -radix::Raster DatasetReader::read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const -{ - return readFrom(m_dataset, bounds, width, height); -} - -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); - auto source_dataset = getOverviewDataset(m_dataset, transformer_args.get(), m_warn_on_missing_overviews); - - return readFrom(source_dataset, bounds, width, height); -#else - return read(bounds, width, height); -#endif -} - -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"). - - auto warp_options = makeWarpOptions(*this, source_dataset.get(), bounds, width, height); - auto adfGeoTransform = computeGeoTransform(bounds, width, height); - 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 = radix::Raster({ width, height }); - if (heights_band->RasterIO(GF_Read, 0, 0, int(width), int(height), - static_cast(heights_data.buffer().data()), int(width), int(height), GDT_Float32, 0, 0) - != CE_None) - throw Exception("couldn't read data"); - - return heights_data; -} diff --git a/src/tile_builder/DatasetReader.h b/src/tile_builder/DatasetReader.h deleted file mode 100644 index a60400c6..00000000 --- a/src/tile_builder/DatasetReader.h +++ /dev/null @@ -1,56 +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 DATASETREADER_H -#define DATASETREADER_H - -#include -#include - -#include -#include - -class Dataset; -class OGRSpatialReference; - -class DatasetReader { -public: - DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band, bool warn_on_missing_overviews = true); - - 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; } - std::string dataset_srs_wkt() const { return m_dataset_srs_wkt; } - std::string target_srs_wkt() const { return m_target_srs_wkt; } - -protected: - radix::Raster readFrom(const std::shared_ptr& dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; - -private: - std::shared_ptr m_dataset; - std::string m_dataset_srs_wkt; - std::string m_target_srs_wkt; - bool m_requires_reprojection; - [[maybe_unused]] bool m_warn_on_missing_overviews; - unsigned m_band; -}; - -#endif // DATASETREADER_H diff --git a/src/tile_builder/ParallelTileGenerator.cpp b/src/tile_builder/ParallelTileGenerator.cpp deleted file mode 100644 index 6a52a497..00000000 --- a/src/tile_builder/ParallelTileGenerator.cpp +++ /dev/null @@ -1,112 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * - * 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 "ParallelTileGenerator.h" -#include "ProgressIndicator.h" - -#include -#include - -#include - -#include "Dataset.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" - -#include -#include - -ParallelTileGenerator::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) - : m_output_data_path(output_data_path) - , m_input_data_path(input_data_path) - , m_grid(grid) - , m_tiler(tiler) - , m_tile_writer(std::move(tile_writer)) -{ -} - -ParallelTileGenerator ParallelTileGenerator::make(const std::string& input_data_path, - ctb::Grid::Srs srs, - std::unique_ptr tile_writer, - const std::string& output_data_path, - unsigned grid_resolution) -{ - const auto dataset = std::make_shared(std::filesystem::path(input_data_path)); - ctb::Grid grid = ctb::GlobalGeodetic(grid_resolution); - 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), std::move(tile_writer), output_data_path }; -} - -const ParallelTiler& ParallelTileGenerator::tiler() const -{ - return m_tiler; -} - -const ctb::Grid& ParallelTileGenerator::grid() const -{ - return m_grid; -} - -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()); - std::filesystem::create_directories(dir_path); - m_tile_writer->write(file_path, tile, heights); -} - -void ParallelTileGenerator::process(const std::pair& zoom_range, bool progress_bar_on_console, bool generate_world_wide_tiles) const -{ - auto tiler = m_tiler; - if (generate_world_wide_tiles) - tiler.setBounds(grid().getExtent()); - - const auto tiles = tiler.generateTiles(zoom_range); - - auto pi = ProgressIndicator(tiles.size()); - std::jthread monitoring_thread; - - if (progress_bar_on_console) - monitoring_thread = pi.start_monitoring(); // destructor will join. - - const auto fun = [&pi, progress_bar_on_console, this](const radix::tile::Descriptor& tile) { - // Recreating Dataset for every tile. This was the easiest fix for multithreading, - // and it takes only 0.5% of the time (half a percent). - // most of the cpu time is used in 'readWithOverviews' (specificly 'RasterIO', and - // 'VRTWarpedRasterBand::IReadBlock') and a bit in 'write' (specifically 'FreeImage_Save'). - const auto dataset = Dataset::open_shared_raster(m_input_data_path).value(); - DatasetReader reader(dataset, m_grid.getSRS(), 1, m_warn_on_missing_overviews); - const auto heights = reader.readWithOverviews(tile.srsBounds, tile.tileSize, tile.tileSize); - write(tile, heights); - if (progress_bar_on_console) - pi.task_finished(); - }; - std::for_each(std::execution::par, tiles.begin(), tiles.end(), fun); -} - -radix::tile::Border ParallelTileWriterInterface::formatRequiresBorder() const -{ - return m_format_requires_border; -} - -const std::string& ParallelTileWriterInterface::formatFileEnding() const -{ - return m_file_ending; -} diff --git a/src/tile_builder/ParallelTileGenerator.h b/src/tile_builder/ParallelTileGenerator.h deleted file mode 100644 index cd6f6240..00000000 --- a/src/tile_builder/ParallelTileGenerator.h +++ /dev/null @@ -1,76 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * - * 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 PARALLELTILEGENERATOR_H -#define PARALLELTILEGENERATOR_H - -#include -#include - -#include -#include "ParallelTiler.h" -#include "ctb/Grid.hpp" -#include "ctb/types.hpp" -#include - -class ParallelTileWriterInterface; - -class ParallelTileGenerator { - std::string m_output_data_path; - std::string m_input_data_path; - ctb::Grid m_grid; - ParallelTiler m_tiler; - std::unique_ptr m_tile_writer; - bool m_warn_on_missing_overviews = true; - -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, - std::unique_ptr tile_writer, - const std::string& output_data_path, - unsigned grid_resolution = 256); - - 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 radix::Raster& heights) const; - void process(const std::pair& zoom_range, bool progress_bar_on_console = false, bool generate_world_wide_tiles = false) const; -}; - -class ParallelTileWriterInterface { - radix::tile::Border m_format_requires_border; - std::string m_file_ending; - -public: - ParallelTileWriterInterface(radix::tile::Border format_requires_border, const std::string& file_ending) - : m_format_requires_border(format_requires_border) - , m_file_ending(file_ending) - { - } - ParallelTileWriterInterface(const ParallelTileWriterInterface&) = default; - ParallelTileWriterInterface(ParallelTileWriterInterface&&) = default; - 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 radix::Raster& heights) const = 0; - [[nodiscard]] radix::tile::Border formatRequiresBorder() const; - [[nodiscard]] const std::string& formatFileEnding() const; -}; - -#endif // PARALLELTILEGENERATOR_H diff --git a/src/tile_builder/ParallelTiler.cpp b/src/tile_builder/ParallelTiler.cpp deleted file mode 100644 index fe6b2330..00000000 --- a/src/tile_builder/ParallelTiler.cpp +++ /dev/null @@ -1,73 +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 "ParallelTiler.h" - -#include "Exception.h" -#include - -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); -} - -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); -} - -std::vector ParallelTiler::generateTiles(unsigned zoom_level) const -{ - const auto sw = southWestTile(zoom_level).coords; - const auto ne = northEastTile(zoom_level).coords; - - std::vector tiles; - 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 } }; - 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. - throw Exception("Setting the zoom level so higher is probably not a good idea. This would generate more than 1'000 million tiles. " - "I'm aborting. If you really need this, then that means that the future is bright. But you'll have to edit the code..\n" - " . . \n / \\_/ \\ \n | O O | \n | ~V~ | _\n ~_ _ / // \n / \\ // \n" - " | || |/ \n | / \\ | \n || || \n \n"); - } - } - - return tiles; -} - -std::vector ParallelTiler::generateTiles(const std::pair& zoom_range) const -{ - std::vector tiles; - assert(zoom_range.first <= zoom_range.second); - for (ctb::i_zoom i = zoom_range.first; i <= zoom_range.second; ++i) { - auto zoom_level_tiles = generateTiles(i); - tiles.reserve(tiles.size() + zoom_level_tiles.size()); - std::move(zoom_level_tiles.begin(), zoom_level_tiles.end(), std::back_inserter(tiles)); - } - return tiles; -} diff --git a/src/tile_builder/TileHeightsGenerator.cpp b/src/tile_builder/TileHeightsGenerator.cpp deleted file mode 100644 index 0e535fab..00000000 --- a/src/tile_builder/TileHeightsGenerator.cpp +++ /dev/null @@ -1,83 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam - * - * 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 "TileHeightsGenerator.h" - -#include - -#include "Dataset.h" -#include "DatasetReader.h" -#include "TopDownTiler.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" -#include "depth_first_tile_traverser.h" -#include - -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_border(border) - , m_output_path(std::move(output_path)) -{ -} - -void TileHeightsGenerator::run(unsigned max_zoom_level) const -{ - struct MinMaxData { - radix::tile::Id tile_id; - std::pair min_max = std::make_pair(0, 9000); - }; - - const auto dataset = std::make_shared(std::filesystem::path(m_input_data_path)); - - ctb::Grid grid = ctb::GlobalGeodetic(64); - if (m_srs == ctb::Grid::Srs::SphericalMercator) - 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); - auto tile_heights = radix::TileHeights(); - - const auto read_function = [&](const radix::tile::Descriptor& tile) -> MinMaxData { - const auto tile_data = tile_reader.readWithOverviews(tile.srsBounds, tile.tileSize, tile.tileSize); - auto [min, max] = std::ranges::minmax(tile_data); - tile_heights.emplace(tile.id, std::make_pair(min, max)); - return { tile.id, std::make_pair(min, max) }; - }; - - std::vector>> aggregate_calls; - const auto aggregate_function = [&](const std::vector& data) -> MinMaxData { - const auto new_id = data.front().tile_id.parent(); - auto min_max = data.front().min_max; - for (const auto& d : data) { - min_max.first = std::min(min_max.first, d.min_max.first); - min_max.second = std::max(min_max.second, d.min_max.second); - } - tile_heights.emplace(new_id, min_max); - return { new_id, min_max }; - }; - - - 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 } }, max_zoom_level); - } - tile_heights.write_to(m_output_path); - -} diff --git a/src/tile_builder/Tiler.cpp b/src/tile_builder/Tiler.cpp deleted file mode 100644 index fafa3ba7..00000000 --- a/src/tile_builder/Tiler.cpp +++ /dev/null @@ -1,65 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam - * - * 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 "Tiler.h" - -#include - -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) -{ - -} - -const ctb::Grid& Tiler::grid() const -{ - return m_grid; -} - -ctb::i_tile Tiler::grid_size() const -{ - return grid().tileSize(); -} - -ctb::i_tile Tiler::tile_size() const -{ - return grid_size() + unsigned(border_south_east()); -} - -radix::tile::Border Tiler::border_south_east() const -{ - return m_border_south_east; -} - -radix::tile::Descriptor Tiler::tile_for(const radix::tile::Id& tile_id) const -{ - radix::tile::SrsBounds srs_bounds = grid().srsBounds(tile_id, border_south_east() == radix::tile::Border::Yes); - return {tile_id, srs_bounds, grid().getEpsgCode(), grid_size(), tile_size()}; -} - -const radix::tile::SrsBounds& Tiler::bounds() const -{ - return m_bounds; -} - -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 deleted file mode 100644 index 70b5049e..00000000 --- a/src/tile_builder/Tiler.h +++ /dev/null @@ -1,45 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 . - *****************************************************************************/ - -#pragma once - -#include "ctb/Grid.hpp" -#include "ctb/types.hpp" -#include - -class Tiler -{ -public: - Tiler(ctb::Grid grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); - - [[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; - -protected: - [[nodiscard]] const ctb::Grid& grid() const; - [[nodiscard]] ctb::i_tile grid_size() const; - [[nodiscard]] ctb::i_tile tile_size() const; - [[nodiscard]] radix::tile::Border border_south_east() const; - -private: - - const ctb::Grid m_grid; - radix::tile::SrsBounds m_bounds; - const radix::tile::Border m_border_south_east; -}; diff --git a/src/tile_builder/alpine_raster.cpp b/src/tile_builder/alpine_raster.cpp deleted file mode 100644 index 7e4b1a0c..00000000 --- a/src/tile_builder/alpine_raster.cpp +++ /dev/null @@ -1,46 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 "alpine_raster.h" - -#include -#include -#include -#include - -#include -#include - -#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::Border border, unsigned 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 -{ - auto result = image::save_image_as_png(radix::raster::transform(heights, radix::height_encoding::to_rgb), file_path); - if (!result) { - throw std::runtime_error(result.error().to_string()); - } -} diff --git a/src/tile_builder/alpine_raster.h b/src/tile_builder/alpine_raster.h deleted file mode 100644 index d82f05a6..00000000 --- a/src/tile_builder/alpine_raster.h +++ /dev/null @@ -1,51 +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 ALPINERASTERGENERATOR_H -#define ALPINERASTERGENERATOR_H - -#include - -#include -#include - -#include -#include "ParallelTileGenerator.h" -#include -#include "ctb/Grid.hpp" - -namespace alpine_raster { - -class TileWriter : public ParallelTileWriterInterface { -public: - TileWriter(radix::tile::Border border) - : ParallelTileWriterInterface(border, "png") - { - } - 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, - const std::string& output_data_path, - ctb::Grid::Srs srs, - radix::tile::Border border, - unsigned grid_resolution = 256); -}; - -#endif // ALPINERASTERGENERATOR_H diff --git a/src/tile_builder/depth_first_tile_traverser.h b/src/tile_builder/depth_first_tile_traverser.h deleted file mode 100644 index 46acb789..00000000 --- a/src/tile_builder/depth_first_tile_traverser.h +++ /dev/null @@ -1,41 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 . - *****************************************************************************/ - -#pragma once - -#include "TopDownTiler.h" - -template -auto traverse_depth_first_and_aggregate(const TopDownTiler& tiler, ReadFunction read, AggregateFunction aggregate, const radix::tile::Id& root_tile_id, unsigned max_zoom_level) - -> decltype(read(tiler.tile_for(root_tile_id))) -{ - using DataType = decltype(read(tiler.tile_for(root_tile_id))); - const auto subtiles = tiler.generateTiles(root_tile_id); - // subtiles can be empty if the parent tile had an overlap with the dataset extent only on the border pixels. - if (root_tile_id.zoom_level == max_zoom_level || subtiles.empty()) { - return read(tiler.tile_for(root_tile_id)); - } - - std::vector unaggregated_data; - for (const auto& tile : subtiles) { - auto tile_data = traverse_depth_first_and_aggregate(tiler, read, aggregate, tile.id, max_zoom_level); - unaggregated_data.emplace_back(std::move(tile_data)); - } - - return aggregate(unaggregated_data); -} diff --git a/src/tile_builder/image_writer.cpp b/src/tile_builder/image_writer.cpp deleted file mode 100644 index c0131610..00000000 --- a/src/tile_builder/image_writer.cpp +++ /dev/null @@ -1,51 +0,0 @@ -/***************************************************************************** - * 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 "io/conversion.h" - -#include - -Expected image::save_image_as_png(const radix::Raster& input_image, const std::string& path) -{ - auto converted = io::conversion::to_mat(input_image); - if (!converted) { - return Error::propagate(std::move(converted), "convert raster for PNG output"); - } - - cv::Mat flipped; - cv::Mat image; - try { - cv::flip(*converted, flipped, 0); - cv::cvtColor(flipped, image, cv::COLOR_RGB2BGR); - } catch (const cv::Exception& error) { - return Error::fail(Error::Code::Internal, "prepare PNG image: " + error.msg); - } - - try { - if (!cv::imwrite(path, image)) { - return Error::fail(Error::Code::Io, "write PNG image to \"" + path + "\""); - } - } catch (const cv::Exception& error) { - return Error::fail(Error::Code::Io, "write PNG image to \"" + path + "\": " + error.msg); - } - return {}; -} diff --git a/src/tile_builder/image_writer.h b/src/tile_builder/image_writer.h deleted file mode 100644 index 9c72598f..00000000 --- a/src/tile_builder/image_writer.h +++ /dev/null @@ -1,54 +0,0 @@ -/***************************************************************************** - * 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 "Error.h" - -namespace image { - -Expected save_image_as_png(const radix::Raster& image, const std::string& path); - -template -Expected debug_out(const radix::Raster& image, const std::string& path) -{ - if (image.buffer().empty()) { - return Error::fail(Error::Code::InvalidInput, "cannot write an empty debug raster to \"" + path + "\""); - } - - const auto [min, max] = std::ranges::minmax(image); - const auto range = float(max) - float(min); - return save_image_as_png(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/src/tile_builder/main.cpp b/src/tile_builder/main.cpp deleted file mode 100644 index 06ec1c93..00000000 --- a/src/tile_builder/main.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include -#include - -#include - -#include "ParallelTiler.h" -#include "TileHeightsGenerator.h" -#include "alpine_raster.h" -#include "ctb/Grid.hpp" - -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, radix::tile::Border::No); - //// generator.process({16, 16}); - // 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, 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::Border::Yes, 64); - // generator.process({0, 5}, true, true); - generator.process({ 15, 16 }, true, false); - - // 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::Border::Yes, base_path / file_name); -// generator.run(13); - - return 0; -} diff --git a/src/tile_downloader/CMakeLists.txt b/src/tile_downloader/CMakeLists.txt index ac21b220..425f06a0 100644 --- a/src/tile_downloader/CMakeLists.txt +++ b/src/tile_downloader/CMakeLists.txt @@ -1,3 +1,21 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2026 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + add_executable(tile-downloader HttpClient.h TileDownloader.h diff --git a/src/tile_downloader/HttpClient.h b/src/tile_downloader/HttpClient.h index 6eaeeb45..3903fc07 100644 --- a/src/tile_downloader/HttpClient.h +++ b/src/tile_downloader/HttpClient.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/tile_downloader/TileDownloader.h b/src/tile_downloader/TileDownloader.h index e1f49bf4..760366d3 100644 --- a/src/tile_downloader/TileDownloader.h +++ b/src/tile_downloader/TileDownloader.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/tile_downloader/TileLogger.h b/src/tile_downloader/TileLogger.h index c9d6c81b..d0b4e555 100644 --- a/src/tile_downloader/TileLogger.h +++ b/src/tile_downloader/TileLogger.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -15,8 +34,8 @@ #include #include -#include "ProgressIndicator.h" -#include "log.h" +#include "cli_support/ProgressIndicator.h" +#include "cli_support/log.h" struct TileResult { struct Downloaded {}; @@ -69,7 +88,7 @@ class TileLogger { static constexpr size_t PROGRESS_RESOLUTION = 10'000; unsigned _root_zoom_level; - ProgressIndicator _progress; + cli_support::ProgressIndicator _progress; std::jthread _progress_thread; size_t _steps_done = 0; std::map _level_counts; diff --git a/src/tile_downloader/TileUrlBuilder.h b/src/tile_downloader/TileUrlBuilder.h index 6e53cf84..d69aede5 100644 --- a/src/tile_downloader/TileUrlBuilder.h +++ b/src/tile_downloader/TileUrlBuilder.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/tile_downloader/cli.cpp b/src/tile_downloader/cli.cpp index bd1ca8af..9523c277 100644 --- a/src/tile_downloader/cli.cpp +++ b/src/tile_downloader/cli.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "cli.h" #include @@ -5,6 +24,8 @@ #include +#include "cli_support/log.h" + namespace cli { Args parse(int argc, const char *const *argv) { @@ -41,17 +62,9 @@ Args parse(int argc, const char *const *argv) { 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}, - {"critical", spdlog::level::critical}, - {"error", spdlog::level::err}, - {"warn", spdlog::level::warn}, - {"info", spdlog::level::info}, - {"debug", spdlog::level::debug}, - {"trace", spdlog::level::trace}}; args.log_level = spdlog::level::info; app.add_option("--verbosity", args.log_level, "Verbosity level of logging") - ->transform(CLI::CheckedTransformer(log_level_names, CLI::ignore_case)) + ->transform(CLI::CheckedTransformer(cli_support::log_level_names(), CLI::ignore_case)) ->default_val(spdlog::level::info); app.add_option("--max-zoom-level", args.max_zoom_level, "Maximum zoom level to descend to"); diff --git a/src/tile_downloader/cli.h b/src/tile_downloader/cli.h index 6e03207c..c9bf1f55 100644 --- a/src/tile_downloader/cli.h +++ b/src/tile_downloader/cli.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/src/tile_downloader/main.cpp b/src/tile_downloader/main.cpp index 17c25314..19eae88a 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -1,11 +1,31 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2023 Martin Braunsperger + * Copyright (C) 2023 Adam Celarek-Litofcenko + * Copyright (C) 2023 Adrian Gawor + * + * 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 "TileDownloader.h" #include "TileUrlBuilder.h" #include "cli.h" -#include "log.h" +#include "cli_support/log.h" int main(int argc, char *argv[]) { const auto args = cli::parse(argc, argv); - Log::init(args.log_level); + cli_support::Log::init(args.log_level); if (args.srs != 3857) { LOG_ERROR_AND_EXIT("unsupported srs EPSG \"{}\"", args.srs); diff --git a/src/tile_downloader/write_file.h b/src/tile_downloader/write_file.h index b74e7ebe..2aec8a7d 100644 --- a/src/tile_downloader/write_file.h +++ b/src/tile_downloader/write_file.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/thread_sanitizer_suppression.txt b/thread_sanitizer_suppression.txt deleted file mode 100644 index 9493e3d5..00000000 --- a/thread_sanitizer_suppression.txt +++ /dev/null @@ -1,6 +0,0 @@ -race:^__interceptor_memset* -race:^tbb::interface9::internal::adaptive_mode* -race:^tbb::blocked_range* -race:^tbb::interface9::internal::start_for* -race:^tbb::internal::machine_load_store* -race:^tbb::strict_ppl::internal::micro_queue* diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index f23bca02..72fb5b80 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -1,3 +1,22 @@ +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2023 Adam Celarek-Litofcenko +# Copyright (C) 2023 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 . +# ***************************************************************************** + if(NOT TARGET Catch2::Catch2) alp_add_git_repository(catch2 URL https://github.com/catchorg/Catch2.git COMMITISH v3.8.1) endif() @@ -5,8 +24,7 @@ endif() function(atb_configure_test target) target_compile_definitions(${target} PRIVATE CATCH_CONFIG_ENABLE_ALL_STRINGMAKERS - ALP_TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/unittests/data/" - ALP_UNITTESTS_AUSTRIA_HIGHRES="${ALP_UNITTESTS_AUSTRIA_HIGHRES}" + ALP_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data/" ALP_UNITTESTS_EXTENDED=$ ALP_UNITTESTS_DEBUG_IMAGES=$ ) @@ -19,17 +37,24 @@ add_executable(unittests_terrainlib terrainlib/convert.cpp terrainlib/cow.cpp - terrainlib/dataset.cpp terrainlib/enumerate.cpp terrainlib/envelope.cpp terrainlib/error.cpp terrainlib/fixed_vector.cpp terrainlib/geometry_frames.cpp - terrainlib/grid.cpp + terrainlib/gis_dataset.cpp + terrainlib/gis_dataset_reading.cpp + terrainlib/gis_srs_bounds.cpp + terrainlib/gis_srs_coverage.cpp + terrainlib/gis_vector_mask.cpp + terrainlib/gis_vector_mask_read_errors.cpp + terrainlib/glm_serialization.cpp terrainlib/hash_utils.cpp terrainlib/hybrid_index_pair_map.cpp terrainlib/hybrid_vector.cpp terrainlib/io_conversion.cpp + terrainlib/io_hash.cpp + terrainlib/io_image.cpp terrainlib/offset_table.cpp terrainlib/segmented_buffer.cpp terrainlib/sf_validate_index.cpp @@ -38,6 +63,7 @@ add_executable(unittests_terrainlib terrainlib/store_codec.cpp terrainlib/store_index.cpp terrainlib/store_layout.cpp + terrainlib/store_snapshot.cpp terrainlib/store_storage.cpp terrainlib/store_traverse.cpp terrainlib/mesh_boundary.cpp @@ -65,6 +91,11 @@ add_executable(unittests_terrainlib terrainlib/quantize.cpp terrainlib/range.cpp terrainlib/range_utils.cpp + terrainlib/raster_algorithm.cpp + terrainlib/raster_fold.cpp + terrainlib/raster_tile_pool.cpp + terrainlib/raster_view.cpp + terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp @@ -75,25 +106,18 @@ add_executable(unittests_terrainlib target_link_libraries(unittests_terrainlib PRIVATE terrainlib Catch2::Catch2WithMain) atb_configure_test(unittests_terrainlib) -if(TARGET tilebuilderlib) - add_executable(unittests_tilebuilder - catch2_helpers.h - tilebuilder/alpine_raster_format.cpp - tilebuilder/dataset_reading.cpp - tilebuilder/depth_first_tile_traverser.cpp - tilebuilder/image_writer.cpp - tilebuilder/parallel_tile_generator.cpp - tilebuilder/parallel_tiler.cpp - tilebuilder/tile_heights_generator.cpp - tilebuilder/top_down_tiler.cpp - ) - target_link_libraries(unittests_tilebuilder PRIVATE tilebuilderlib Catch2::Catch2WithMain) - atb_configure_test(unittests_tilebuilder) -endif() +# Keep the aligned GLM configuration isolated from terrainlib and its PCH. +add_executable(unittests_glm_serialization_aligned terrainlib/glm_serialization.cpp) +target_compile_features(unittests_glm_serialization_aligned PRIVATE cxx_std_23) +target_compile_definitions(unittests_glm_serialization_aligned PRIVATE GLM_FORCE_INTRINSICS GLM_FORCE_ALIGNED_GENTYPES) +target_include_directories(unittests_glm_serialization_aligned PRIVATE ${PROJECT_SOURCE_DIR}/src/terrainlib) +target_link_libraries(unittests_glm_serialization_aligned PRIVATE glm::glm zpp_bits Catch2::Catch2WithMain) if(TARGET sfbuilderlib) add_executable(unittests_sfbuilder catch2_helpers.h + temporary_directory.h + sf_builder/coverage.cpp sf_builder/mesh.cpp sf_builder/texture.cpp ) @@ -120,7 +144,7 @@ if(TARGET tile-downloader) tile_downloader/url_builder.cpp tile_downloader/write_file.cpp ) - target_include_directories(unittests_tile_downloader PRIVATE ${CMAKE_SOURCE_DIR}/src/tile_downloader) + target_include_directories(unittests_tile_downloader PRIVATE ${PROJECT_SOURCE_DIR}/src/tile_downloader) target_link_libraries(unittests_tile_downloader PRIVATE terrainlib Catch2::Catch2WithMain CURL::libcurl) atb_configure_test(unittests_tile_downloader) endif() @@ -165,3 +189,4 @@ if(TARGET sfmergerlib) endif() atb_configure_test(unittests_sfmerger) endif() + diff --git a/unittests/catch2_helpers.h b/unittests/catch2_helpers.h index 4be98de7..1c8040a3 100644 --- a/unittests/catch2_helpers.h +++ b/unittests/catch2_helpers.h @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 diff --git a/unittests/dag_builder/bake_cluster_texture.cpp b/unittests/dag_builder/bake_cluster_texture.cpp index e4bfb227..f31d2af0 100644 --- a/unittests/dag_builder/bake_cluster_texture.cpp +++ b/unittests/dag_builder/bake_cluster_texture.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/correspondence.cpp b/unittests/dag_builder/correspondence.cpp index f46b1714..14932900 100644 --- a/unittests/dag_builder/correspondence.cpp +++ b/unittests/dag_builder/correspondence.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/fixed_point.cpp b/unittests/dag_builder/fixed_point.cpp index 0d7af622..f51f9b91 100644 --- a/unittests/dag_builder/fixed_point.cpp +++ b/unittests/dag_builder/fixed_point.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include "../catch2_helpers.h" diff --git a/unittests/dag_builder/merge_clusterings.cpp b/unittests/dag_builder/merge_clusterings.cpp index d8c59f49..6acf3037 100644 --- a/unittests/dag_builder/merge_clusterings.cpp +++ b/unittests/dag_builder/merge_clusterings.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include diff --git a/unittests/dag_builder/multi_component.cpp b/unittests/dag_builder/multi_component.cpp index 7329ba21..092fe92c 100644 --- a/unittests/dag_builder/multi_component.cpp +++ b/unittests/dag_builder/multi_component.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/dag_builder/pull_reproject_texture.cpp b/unittests/dag_builder/pull_reproject_texture.cpp index 64b816e5..23780669 100644 --- a/unittests/dag_builder/pull_reproject_texture.cpp +++ b/unittests/dag_builder/pull_reproject_texture.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/reprojection.cpp b/unittests/dag_builder/reprojection.cpp index 1f452ba4..15e15f86 100644 --- a/unittests/dag_builder/reprojection.cpp +++ b/unittests/dag_builder/reprojection.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/slice.cpp b/unittests/dag_builder/slice.cpp index 5e6cf167..86cd6f91 100644 --- a/unittests/dag_builder/slice.cpp +++ b/unittests/dag_builder/slice.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/storage_compatibility.cpp b/unittests/dag_builder/storage_compatibility.cpp index 4762b5ff..25b102a6 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.cpp @@ -1,5 +1,25 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -7,6 +27,8 @@ #include "build.h" #include "codec.h" #include "dag_node.h" +#include "io/byte.h" +#include "io/envelope.h" #include "mesh/SimpleMesh.h" #include "mesh/storage.h" #include "storage.h" @@ -115,6 +137,27 @@ TEST_CASE("DAG codec rejects invalid batches and inconsistent files") } } +TEST_CASE("DAG codec never replaces existing files") +{ + TemporaryDirectory output; + dag::codec::ClusterBatch codec; + const std::filesystem::path node_path(output.path() / "node"); + const auto node_paths = codec.paths(node_path); + REQUIRE(codec.write(node_path, sample_batch()).has_value()); + const auto original_data = io::byte::read(node_paths[0]); + const auto original_metadata = io::byte::read(node_paths[1]); + REQUIRE(original_data.has_value()); + REQUIRE(original_metadata.has_value()); + + auto batch = sample_batch(); + batch.metadata.groups.front().error = 99.0; + const auto rejected = codec.write(node_path, batch); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().code() == Error::Code::AlreadyExists); + CHECK(io::byte::read(node_paths[0]).value() == original_data.value()); + CHECK(io::byte::read(node_paths[1]).value() == original_metadata.value()); +} + TEST_CASE("DAG resolvers expose writable batches and read-only metadata", "[store][open]") { const auto batch_codec = dag::codec::cluster_batch_from_extension(".dag"); @@ -177,7 +220,7 @@ TEST_CASE("DAG storage hard-links both files for the same format", "[store][copy auto target = std::move(*target_result); const octree::Id root = octree::Id::root(); REQUIRE(source.save(root, sample_batch()).has_value()); - REQUIRE(target.copy_from(root, source).has_value()); + REQUIRE(target.link_from(root, source).has_value()); const auto source_paths = source.paths(root); const auto target_paths = target.paths(root); @@ -217,3 +260,98 @@ TEST_CASE("DAG builder rejects invalid SF topology before processing", "[dag][sf CHECK(built.error().to_string().contains(root.to_string())); CHECK(output.index().empty()); } + +TEST_CASE("DAG builds stop on request and reuse cached nodes", "[dag][snapshot]") +{ + TemporaryDirectory input_directory; + TemporaryDirectory output_directory; + auto input_result = mesh::storage::open_folder_indexed(input_directory.path()); + REQUIRE(input_result.has_value()); + auto input = std::move(input_result.value()); + REQUIRE(input.save(octree::Id::root(), sample_mesh()).has_value()); + const dag::BuildOptions options { + .clusters_per_partition = 1, + .target_ratio = 1.0f, + .relative_target_error = std::nullopt, + .write_debug_meshes = false, + }; + + SECTION("stop") + { + auto output = dag::storage::create(output_directory.path() / "stopped").value(); + const auto built = dag::build_levels(input, output, options, RangeFull {}, nullptr, [] { return true; }); + REQUIRE_FALSE(built.has_value()); + CHECK(built.error().code() == Error::Code::Cancelled); + CHECK(output.index().empty()); + } + + SECTION("cache") + { + auto first = dag::storage::create(output_directory.path() / "first").value(); + REQUIRE(dag::build_levels(input, first, options, RangeFull {}).has_value()); + REQUIRE_FALSE(first.index().empty()); + + auto output = dag::storage::create(output_directory.path() / "second").value(); + REQUIRE(dag::build_levels(input, output, options, RangeFull {}, &first).has_value()); + CHECK(output.index().size() == first.index().size()); + for (const auto& [id, status] : first.index()) { + if (status != store::NodeStatus::Virtual) { + CHECK(std::filesystem::equivalent(first.paths(id)->front(), output.paths(id)->front())); + } + } + } +} + +TEST_CASE("DAG node hashes concatenate the clustering and metadata hashes", "[dag][codec][hash]") +{ + TemporaryDirectory output; + dag::codec::ClusterBatch codec; + const std::filesystem::path node_path(output.path() / "node"); + const auto hash = codec.write(node_path, sample_batch()); + REQUIRE(hash.has_value()); + REQUIRE(hash->size() == 16); + const auto node_paths = codec.paths(node_path); + CHECK(std::vector(hash->begin(), hash->begin() + 8) == io::envelope::read_header(node_paths[0])->hash); + CHECK(std::vector(hash->begin() + 8, hash->end()) == io::envelope::read_header(node_paths[1])->hash); +} + +TEST_CASE("DAG reads verify each half of an expected node hash", "[dag][codec][hash]") +{ + TemporaryDirectory output; + dag::codec::ClusterBatch codec; + const std::filesystem::path node_path(output.path() / "node"); + const auto node_paths = codec.paths(node_path); + const auto hash = codec.write(node_path, sample_batch()); + REQUIRE(hash.has_value()); + REQUIRE(codec.read(node_path, *hash).has_value()); + + auto expected = *hash; + SECTION("clustering half") { expected.front() ^= std::byte { 1 }; } + SECTION("metadata half") { expected.back() ^= std::byte { 1 }; } + SECTION("another length") { expected.resize(8); } + const auto result = codec.read(node_path, expected); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().code() == Error::Code::CorruptData); +} + +TEST_CASE("DAG metadata reads verify only the metadata half", "[dag][codec][hash]") +{ + TemporaryDirectory output; + dag::codec::ClusterBatch codec; + dag::codec::Metadata metadata_codec; + CHECK(metadata_codec.supports_hashing()); + const std::filesystem::path node_path(output.path() / "node"); + const auto hash = codec.write(node_path, sample_batch()); + REQUIRE(hash.has_value()); + REQUIRE(std::filesystem::remove(codec.paths(node_path).front())); + + CHECK(metadata_codec.read(node_path, *hash).has_value()); + auto wrong_clustering = *hash; + wrong_clustering.front() ^= std::byte { 1 }; + CHECK(metadata_codec.read(node_path, wrong_clustering).has_value()); + + auto wrong_metadata = *hash; + wrong_metadata.back() ^= std::byte { 1 }; + CHECK(metadata_codec.read(node_path, wrong_metadata).error().code() == Error::Code::CorruptData); + CHECK(metadata_codec.read(node_path, store::NodeDataHash(8)).error().code() == Error::Code::CorruptData); +} diff --git a/unittests/dag_builder/texture_reuse.cpp b/unittests/dag_builder/texture_reuse.cpp index 43f50997..17362802 100644 --- a/unittests/dag_builder/texture_reuse.cpp +++ b/unittests/dag_builder/texture_reuse.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/texture_sizing.cpp b/unittests/dag_builder/texture_sizing.cpp index 1064e87b..ca24ec47 100644 --- a/unittests/dag_builder/texture_sizing.cpp +++ b/unittests/dag_builder/texture_sizing.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/dag_builder/weld.cpp b/unittests/dag_builder/weld.cpp index e8b25478..00a3a570 100644 --- a/unittests/dag_builder/weld.cpp +++ b/unittests/dag_builder/weld.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include diff --git a/unittests/data/austria/gen_test_data.sh b/unittests/data/austria/gen_test_data.sh index 94c68d18..3c14290d 100755 --- a/unittests/data/austria/gen_test_data.sh +++ b/unittests/data/austria/gen_test_data.sh @@ -1,5 +1,23 @@ #!/bin/bash +# ***************************************************************************** +# AlpineMaps.org +# Copyright (C) 2022 Adam Celarek-Litofcenko +# +# 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 . +# ***************************************************************************** + austria_dem="/home/madam/rajaton/raw/Oe_2020/OeRect_01m_gt_31287.img" interpolation="cubic" diff --git a/unittests/data/capehorn/data_attribution b/unittests/data/capehorn/data_attribution deleted file mode 100644 index f1883d26..00000000 --- a/unittests/data/capehorn/data_attribution +++ /dev/null @@ -1,2 +0,0 @@ -European Space Agency, Sinergise (2021). Copernicus Global Digital Elevation Model. Distributed by OpenTopography. - diff --git a/unittests/data/capehorn/small.tif b/unittests/data/capehorn/small.tif deleted file mode 100644 index cf11d7f2..00000000 Binary files a/unittests/data/capehorn/small.tif and /dev/null differ diff --git a/unittests/opencv_helpers.h b/unittests/opencv_helpers.h index ccb0bc42..9b600332 100644 --- a/unittests/opencv_helpers.h +++ b/unittests/opencv_helpers.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 diff --git a/unittests/sf_builder/coverage.cpp b/unittests/sf_builder/coverage.cpp new file mode 100644 index 00000000..52138791 --- /dev/null +++ b/unittests/sf_builder/coverage.cpp @@ -0,0 +1,251 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +#include "../temporary_directory.h" +#include "gis/Dataset.h" +#include "gis/srs.h" +#include "mesh/io.h" +#include "mesh/storage.h" +#include "mesh_builder.h" +#include "store/inputs.h" +#include "terrainbuilder.h" + +using test::TemporaryDirectory; + +namespace { + +// Writes a north-up raster with a constant height of 100 m. +std::filesystem::path write_raster( + const std::filesystem::path& directory, const OGRSpatialReference& srs, const radix::tile::SrsBounds& bounds, const glm::uvec2 size) +{ + GDALAllRegister(); + const auto path = directory / "heights.tif"; + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("GTiff"); + REQUIRE(driver != nullptr); + GDALDataset* dataset = driver->Create(path.c_str(), int(size.x), int(size.y), 1, GDT_Float32, nullptr); + REQUIRE(dataset != nullptr); + std::array transform { bounds.min.x, bounds.width() / size.x, 0, bounds.max.y, 0, -bounds.height() / size.y }; + REQUIRE(dataset->SetGeoTransform(transform.data()) == CE_None); + REQUIRE(dataset->SetSpatialRef(&srs) == CE_None); + GDALRasterBand* band = dataset->GetRasterBand(1); + REQUIRE(band->SetNoDataValue(-9999) == CE_None); + REQUIRE(band->SetUnitType("m") == CE_None); + std::vector heights(std::size_t(size.x) * size.y, 100.0f); + REQUIRE(band->RasterIO(GF_Write, 0, 0, int(size.x), int(size.y), heights.data(), int(size.x), int(size.y), GDT_Float32, 0, 0) == CE_None); + double min = 0, max = 0, mean = 0, deviation = 0; + REQUIRE(band->ComputeStatistics(false, &min, &max, &mean, &deviation, nullptr, nullptr) == CE_None); + GDALClose(dataset); + return path; +} + +// ECEF bounds of the given longitude/latitude bounds in degrees, between heights 0 and 200 m. +radix::geometry::Aabb3d ecef_bounds(const glm::dvec2& longitudes, const glm::dvec2& latitudes) +{ + return gis::srs::ecef_coverage(gis::srs::wgs84(), { { longitudes.x, latitudes.x, 0 }, { longitudes.y, latitudes.y, 200 } }).value(); +} + +double longitude(const glm::dvec3& ecef_position) { return glm::degrees(std::atan2(ecef_position.y, ecef_position.x)); } + +terrainbuilder::inputs::Record batch_record(const std::uint32_t target_level) +{ + terrainbuilder::inputs::Record record; + record.dataset = "heights.tif"; + record.mesh_srs = "EPSG:4978"; + record.target_level = target_level; + record.format = ".sfmesh"; + return record; +} + +} // namespace + +TEST_CASE("native read windows follow the longitudes of the dataset", "[terrainbuilder][coverage]") +{ + const auto wgs84 = gis::srs::wgs84(); + const auto near_minus_175 = ecef_bounds({ -175.1, -174.9 }, { -0.1, 0.1 }); + + SECTION("dataset stored at [0, 360]") + { + const auto windows = terrainbuilder::native_read_windows(wgs84, { { 0, -10 }, { 360, 10 } }, near_minus_175).value(); + REQUIRE(windows.size() == 1); + CHECK(windows[0].min.x > 184.8); + CHECK(windows[0].max.x < 185.2); + } + + SECTION("dataset stored at [180, 190]") + { + const radix::tile::SrsBounds native_bounds { { 180, -5 }, { 190, 5 } }; + const auto windows = terrainbuilder::native_read_windows(wgs84, native_bounds, near_minus_175).value(); + REQUIRE(windows.size() == 1); + CHECK(windows[0].min.x > 184.8); + CHECK(windows[0].max.x < 185.2); + CHECK(terrainbuilder::native_read_windows(wgs84, native_bounds, ecef_bounds({ 174.9, 175.1 }, { -0.1, 0.1 })).value().empty()); + } + + SECTION("bounds across the antimeridian are read on both edges of the dataset") + { + const auto windows = terrainbuilder::native_read_windows(wgs84, { { -180, -10 }, { 180, 10 } }, ecef_bounds({ 179.9, 180.1 }, { -0.1, 0.1 })).value(); + REQUIRE(windows.size() == 2); + const auto& east = windows[0].max.x == 180 ? windows[0] : windows[1]; + const auto& west = windows[0].max.x == 180 ? windows[1] : windows[0]; + CHECK(east.max.x == 180); + CHECK(east.min.x > 179.8); + CHECK(west.min.x == -180); + CHECK(west.max.x < -179.8); + } + + SECTION("projected datasets are not shifted") + { + const auto austria_lambert = gis::srs::from_epsg(31287).value(); + const radix::tile::SrsBounds native_bounds { { 100000, 250000 }, { 700000, 600000 } }; + const glm::dvec3 vienna = gis::srs::transform_point(wgs84, austria_lambert, glm::dvec3(16.37, 48.2, 0)).value(); + const auto windows = terrainbuilder::native_read_windows(austria_lambert, native_bounds, ecef_bounds({ 16.36, 16.38 }, { 48.19, 48.21 })).value(); + REQUIRE(windows.size() == 1); + CHECK(windows[0].contains(glm::dvec2(vienna))); + CHECK(windows[0].width() < 3000); + } +} + +TEST_CASE("reference meshes are built across longitude seams", "[terrainbuilder][coverage]") +{ + TemporaryDirectory directory("sf-coverage"); + const auto wgs84 = gis::srs::wgs84(); + const auto ecef = gis::srs::ecef(); + const auto webmercator = gis::srs::webmercator(); + radix::tile::SrsBounds texture_bounds; + + SECTION("dataset stored at [180, 190]") + { + gis::Dataset dataset(write_raster(directory.path(), wgs84, { { 180, -5 }, { 190, 5 } }, { 1000, 1000 })); + const auto mesh + = terrainbuilder::build_reference_mesh_patch(dataset, ecef, ecef, ecef_bounds({ -175.2, -174.8 }, { -0.2, 0.2 }), webmercator, texture_bounds); + REQUIRE(mesh.has_value()); + CHECK(mesh->face_count() > 100); + for (const glm::dvec3& position : mesh->positions) { + REQUIRE(longitude(position) > -175.25); + REQUIRE(longitude(position) < -174.75); + } + } + + SECTION("dataset across the antimeridian") + { + gis::Dataset dataset(write_raster(directory.path(), wgs84, { { -180, -5 }, { 180, 5 } }, { 3600, 100 })); + const auto mesh + = terrainbuilder::build_reference_mesh_patch(dataset, ecef, ecef, ecef_bounds({ 179.5, 180.5 }, { -0.5, 0.5 }), webmercator, texture_bounds); + REQUIRE(mesh.has_value()); + std::size_t east = 0; + std::size_t west = 0; + for (const glm::dvec3& position : mesh->positions) { + REQUIRE(std::abs(longitude(position)) > 179.4); + (longitude(position) > 0 ? east : west)++; + } + CHECK(east > 10); + CHECK(west > 10); + } +} + +TEST_CASE("batch builds datasets crossing the prime meridian", "[terrainbuilder][coverage]") +{ + TemporaryDirectory directory("sf-coverage-batch"); + const auto wgs84 = gis::srs::wgs84(); + const auto ecef = gis::srs::ecef(); + const auto webmercator = gis::srs::webmercator(); + // Crosses the plane between two level-one nodes, so traversal starts at the root. + gis::Dataset dataset(write_raster(directory.path(), wgs84, { { -0.02, 51.47 }, { 0.02, 51.49 } }, { 400, 200 })); + const auto output = directory.path() / "output"; + + SECTION("target levels below the minimum are rejected") + { + CHECK_FALSE(terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, batch_record(1)).has_value()); + CHECK_FALSE(std::filesystem::exists(output)); + CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); + } + + SECTION("nodes on both sides are built") + { + REQUIRE(terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, batch_record(12)).has_value()); + CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); + std::size_t east = 0; + std::size_t west = 0; + for (const auto& entry : std::filesystem::recursive_directory_iterator(output)) { + if (entry.path().extension() != ".sfmesh") { + continue; + } + const auto mesh = mesh::io::load_from_path(entry.path()); + REQUIRE(mesh.has_value()); + for (const glm::dvec3& position : mesh->positions) { + (position.y > 0 ? east : west)++; + } + } + CHECK(east > 1000); + CHECK(west > 1000); + } +} + +TEST_CASE("batch builds stop on request and reuse cached nodes", "[terrainbuilder][snapshot]") +{ + TemporaryDirectory directory("sf-batch-snapshot"); + const auto wgs84 = gis::srs::wgs84(); + const auto ecef = gis::srs::ecef(); + const auto webmercator = gis::srs::webmercator(); + gis::Dataset dataset(write_raster(directory.path(), wgs84, { { -0.02, 51.47 }, { 0.02, 51.49 } }, { 400, 200 })); + const auto record = batch_record(12); + const auto output = directory.path() / "output"; + + SECTION("a stop request retains the incomplete snapshot") + { + const auto built = terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, record, std::nullopt, [] { return true; }); + REQUIRE_FALSE(built.has_value()); + CHECK(built.error().code() == Error::Code::Cancelled); + CHECK_FALSE(std::filesystem::exists(output)); + const auto partial = directory.path() / "output.part"; + CHECK(std::filesystem::exists(partial / store::inputs::file_name)); + CHECK(mesh::storage::open_index(partial / octree::storage::index_file_name).has_value()); + } + + SECTION("cached nodes are linked") + { + const auto first = directory.path() / "first"; + REQUIRE(terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, first, record).has_value()); + // Turn the published output into what an interrupted build leaves behind. + const auto cache = directory.path() / "cache.part"; + std::filesystem::rename(first, cache); + REQUIRE(store::inputs::write(cache, record).has_value()); + + REQUIRE(terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, record, cache).has_value()); + const auto cached = mesh::storage::open_index(cache / octree::storage::index_file_name).value(); + const auto built = mesh::storage::open_index(output / octree::storage::index_file_name).value(); + CHECK(built.index().size() == cached.index().size()); + std::size_t linked = 0; + for (const auto& [id, status] : cached.index()) { + if (status != store::NodeStatus::Virtual) { + CHECK(std::filesystem::equivalent(cached.path_for(id).value(), built.path_for(id).value())); + ++linked; + } + } + CHECK(linked > 0); + } +} diff --git a/unittests/sf_builder/mesh.cpp b/unittests/sf_builder/mesh.cpp index e4e11633..5904a95e 100644 --- a/unittests/sf_builder/mesh.cpp +++ b/unittests/sf_builder/mesh.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -30,7 +30,7 @@ #include #include "../catch2_helpers.h" -#include "Dataset.h" +#include "gis/Dataset.h" #include "mesh/merge.h" #include "mesh/SimpleMesh.h" #include "mesh_builder.h" @@ -41,7 +41,7 @@ #include "mesh/connectivity/connected_components.h" #include "octree/Id.h" #include "octree/Space.h" -#include "srs.h" +#include "gis/srs.h" #include "mesh/cgal.h" #include "mesh/convert.h" @@ -123,15 +123,15 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil double resolution; // in m }; - const auto mgi_srs = srs::mgi(); - const auto ecef_srs = srs::ecef(); - const auto wgs84_srs = srs::wgs84(); - const auto webmercator_srs = srs::webmercator(); + const auto mgi_srs = gis::srs::mgi(); + const auto ecef_srs = gis::srs::ecef(); + const auto wgs84_srs = gis::srs::wgs84(); + const auto webmercator_srs = gis::srs::webmercator(); const glm::dvec3 pizbuin_summit_wgs84(10.118333, 46.844167, 3312); - const glm::dvec3 pizbuin_summit_ecef = srs::transform_point(wgs84_srs, ecef_srs, pizbuin_summit_wgs84); + const glm::dvec3 pizbuin_summit_ecef = gis::srs::transform_point(wgs84_srs, ecef_srs, pizbuin_summit_wgs84).value(); const glm::dvec3 steffl_wgs84(16.3735655, 48.2083264, 204); - const glm::dvec3 steffl_ecef = srs::transform_point(wgs84_srs, ecef_srs, steffl_wgs84); + const glm::dvec3 steffl_ecef = gis::srs::transform_point(wgs84_srs, ecef_srs, steffl_wgs84).value(); const std::vector test_data{ {"/austria/pizbuin_1m_epsg4326.tif", @@ -157,8 +157,8 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil for (const auto &data : test_data) { DYNAMIC_SECTION(data.path_suffix) { - Dataset dataset(std::filesystem::path(ALP_TEST_DATA_DIR).concat(data.path_suffix)); - const auto source_srs = dataset.srs(); + gis::Dataset dataset(std::filesystem::path(ALP_TEST_DATA_DIR).concat(data.path_suffix)); + const auto source_srs = dataset.srs().value(); const auto &mesh_srs = data.mesh_srs; const auto &target_srs = data.target_srs; const auto &target_bounds = data.target_bounds; @@ -183,7 +183,7 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil check_mesh_basics(mesh); } - const std::vector positions_in_target_srs = srs::transform_points(mesh_srs, target_srs, mesh.positions); + const std::vector positions_in_target_srs = gis::srs::transform_points(mesh_srs, target_srs, mesh.positions).value(); SECTION("Vertices within target bounds") { for (const auto &position : positions_in_target_srs) { REQUIRE(radix::geometry::Aabb2d(target_bounds).contains_inclusive(glm::dvec2(position))); @@ -202,12 +202,12 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil } SECTION("Matches dataset resolution") { - const auto positions_in_source_srs = srs::transform_points(mesh_srs, source_srs, mesh.positions); + const auto positions_in_source_srs = gis::srs::transform_points(mesh_srs, source_srs, mesh.positions).value(); auto flat_positions_in_source_srs = positions_in_source_srs; for (auto &pos : flat_positions_in_source_srs) { pos.z = 0.0; } - const auto flat_positions_in_ecef_srs = srs::transform_points(source_srs, ecef_srs, flat_positions_in_source_srs); + const auto flat_positions_in_ecef_srs = gis::srs::transform_points(source_srs, ecef_srs, flat_positions_in_source_srs).value(); const auto target_bounds_2d = radix::geometry::Aabb2d(target_bounds); const auto padding = target_bounds_2d.size() * 0.1; @@ -240,13 +240,13 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil } TEST_CASE("neighbouring patches fit together", "[terrainbuilder]") { - const auto mgi_srs = srs::mgi(); - const auto ecef_srs = srs::ecef(); - const auto wgs84_srs = srs::wgs84(); - const auto webmercator_srs = srs::webmercator(); + const auto mgi_srs = gis::srs::mgi(); + const auto ecef_srs = gis::srs::ecef(); + const auto wgs84_srs = gis::srs::wgs84(); + const auto webmercator_srs = gis::srs::webmercator(); const glm::dvec3 pizbuin_summit_wgs84(10.118333, 46.844167, 3312); - const glm::dvec3 pizbuin_summit_ecef = srs::transform_point(wgs84_srs, ecef_srs, pizbuin_summit_wgs84); + const glm::dvec3 pizbuin_summit_ecef = gis::srs::transform_point(wgs84_srs, ecef_srs, pizbuin_summit_wgs84).value(); const octree::Space space = octree::Space::earth(); const octree::Id summit_node = space.find_node_at_level_containing_point(pizbuin_summit_ecef, 17).value(); std::vector nodes = summit_node.neighbours(); @@ -254,7 +254,7 @@ TEST_CASE("neighbouring patches fit together", "[terrainbuilder]") { const std::string dataset_suffix = "/austria/pizbuin_1m_mgi.tif"; const std::filesystem::path dataset_path = std::filesystem::path(ALP_TEST_DATA_DIR).concat(dataset_suffix); - Dataset dataset(dataset_path); + gis::Dataset dataset(dataset_path); std::vector node_meshes; for (const octree::Id &node : nodes) { diff --git a/unittests/sf_builder/storage_finalization.cpp b/unittests/sf_builder/storage_finalization.cpp index dbdc1a24..2e8e7df3 100644 --- a/unittests/sf_builder/storage_finalization.cpp +++ b/unittests/sf_builder/storage_finalization.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/sf_builder/texture.cpp b/unittests/sf_builder/texture.cpp index 3da052b4..163c365e 100644 --- a/unittests/sf_builder/texture.cpp +++ b/unittests/sf_builder/texture.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -24,18 +24,15 @@ #include #include "../catch2_helpers.h" -#include "Dataset.h" -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/webmercator.h" #include "mesh/SimpleMesh.h" #include "texture_assembler.h" TEST_CASE("estimate_zoom_level", "[terrainbuilder]") { - const ctb::Grid grid = ctb::GlobalMercator(); const radix::tile::Id tile(20, glm::uvec2(0, 1)); - const radix::tile::SrsBounds tile_bounds = grid.srsBounds(tile, false); + const radix::tile::SrsBounds tile_bounds = gis::webmercator::tile_bounds(tile); const radix::tile::SrsBounds shifted_bounds(tile_bounds.min + glm::dvec2(-100, 420), tile_bounds.max + glm::dvec2(-100, 420)); CHECK(terrainbuilder::estimate_zoom_level(tile.zoom_level, tile_bounds, shifted_bounds) == tile.zoom_level); @@ -75,11 +72,9 @@ TEST_CASE("texture assembler takes root tile if only available ", "[terrainbuild const AvailabilityListEmptyTileProvider tile_provider(available_tiles); - const ctb::Grid grid = ctb::GlobalMercator(); std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider); std::set actual_tiles(std::make_move_iterator(actual_tiles_vec.begin()), std::make_move_iterator(actual_tiles_vec.end())); @@ -103,11 +98,9 @@ TEST_CASE("texture assembler ignores parent if all children are present", "[terr const AvailabilityListEmptyTileProvider tile_provider(available_tiles); - const ctb::Grid grid = ctb::GlobalMercator(); std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider); std::set actual_tiles(std::make_move_iterator(actual_tiles_vec.begin()), std::make_move_iterator(actual_tiles_vec.end())); @@ -128,11 +121,9 @@ TEST_CASE("texture assembler considers max zoom level", "[terrainbuilder]") { const AvailabilityListEmptyTileProvider tile_provider(available_tiles); - const ctb::Grid grid = ctb::GlobalMercator(); std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider, 3); std::set actual_tiles(std::make_move_iterator(actual_tiles_vec.begin()), @@ -170,12 +161,10 @@ TEST_CASE("texture assembler works for arbitrary bounds", "[terrainbuilder]") { const AvailabilityListEmptyTileProvider tile_provider(available_tiles); - const ctb::Grid grid = ctb::GlobalMercator(); const radix::tile::SrsBounds target_bounds(glm::dvec2(0, 0), glm::dvec2(100, 1)); - const radix::tile::Id root_tile = grid.findSmallestEncompassingTile(target_bounds).value(); + const radix::tile::Id root_tile = terrainbuilder::find_smallest_encompassing_tile(target_bounds).value(); std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - grid, target_bounds, tile_provider, 23, @@ -192,12 +181,10 @@ TEST_CASE("texture assembler does not fail if there are not tiles", "[terrainbui const AvailabilityListEmptyTileProvider tile_provider(available_tiles); - const ctb::Grid grid = ctb::GlobalMercator(); const radix::tile::SrsBounds target_bounds(glm::dvec2(0, 0), glm::dvec2(100, 1)); - const radix::tile::Id root_tile = grid.findSmallestEncompassingTile(target_bounds).value(); + const radix::tile::Id root_tile = terrainbuilder::find_smallest_encompassing_tile(target_bounds).value(); std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - grid, target_bounds, tile_provider); std::set actual_tiles(std::make_move_iterator(actual_tiles_vec.begin()), @@ -216,12 +203,10 @@ 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}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -243,12 +228,10 @@ 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}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -274,12 +257,10 @@ 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}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - grid, - grid.srsBounds(root_tile, false), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -355,13 +336,11 @@ TEST_CASE("texture assembler reports the content region", "[terrainbuilder]") { const StaticTileProvider tile_provider({{root_tile, tile_image}}); const std::vector tiles_to_splatter = {root_tile}; - const ctb::Grid grid = ctb::GlobalMercator(); - const radix::tile::SrsBounds root_tile_bounds = grid.srsBounds(root_tile, false); + const radix::tile::SrsBounds root_tile_bounds = gis::webmercator::tile_bounds(root_tile); const radix::tile::SrsBounds target_bounds = bounds_of_root_tile_pixels(root_tile_bounds, glm::uvec2(100, 70), glm::uvec2(150, 160)); const terrainbuilder::AssembledTexture assembled = terrainbuilder::splatter_tiles_to_texture( root_tile, - grid, target_bounds, tile_provider, tiles_to_splatter, diff --git a/unittests/sf_merger/integration.cpp b/unittests/sf_merger/integration.cpp index bb7e68e0..95340c55 100644 --- a/unittests/sf_merger/integration.cpp +++ b/unittests/sf_merger/integration.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/sf_merger/mask.cpp b/unittests/sf_merger/mask.cpp index 4689fd80..ca1d60f0 100644 --- a/unittests/sf_merger/mask.cpp +++ b/unittests/sf_merger/mask.cpp @@ -1,8 +1,27 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include #include +#include #include #include @@ -19,58 +38,6 @@ #include "octree/Space.h" #include "utils.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); -} - namespace { Polygon2 make_square(const glm::dvec2 min, const glm::dvec2 max) { @@ -162,6 +129,28 @@ TEST_CASE("mask triangulation and extrusion preserve polygon components") { } } +TEST_CASE("masks split at the antimeridian are projected around their centre") +{ + MultipolygonWithHoles2 polygons; + polygons.add_polygon(make_square(glm::dvec2(170, -20), glm::dvec2(180, -15))); + polygons.add_polygon(make_square(glm::dvec2(-180, -20), glm::dvec2(-170, -15))); + const SpherePolygonMask mask = mask::project_onto_sphere(ReferencedPolygonMask { std::move(polygons), gis::srs::wgs84() }, earth::radius()); + REQUIRE(mask.polygons.number_of_polygons_with_holes() == 2); + radix::geometry::Aabb2d projected_bounds; + for (const auto& polygon : mask.polygons.polygons_with_holes()) { + for (const auto& point : polygon.outer_boundary()) { + const glm::dvec2 projected = convert::to_glm_point(point); + REQUIRE(std::isfinite(projected.x)); + REQUIRE(std::isfinite(projected.y)); + projected_bounds.expand_by(projected); + } + } + // In radians; the mask spans 20 by 5 degrees. A tangent point far from the mask would spread + // it over the projection, or, at a pole, make it degenerate. + CHECK(projected_bounds.width() < 0.5); + CHECK(projected_bounds.height() < 0.5); +} + TEST_CASE("multi-component masks use union semantics") { const SimpleMesh source = make_plane(); const MeshMask mask{ diff --git a/unittests/sf_merger/sphere_projector.cpp b/unittests/sf_merger/sphere_projector.cpp index 03f4076d..c1ecd429 100644 --- a/unittests/sf_merger/sphere_projector.cpp +++ b/unittests/sf_merger/sphere_projector.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "../catch2_helpers.h" #include @@ -5,7 +23,7 @@ #include #include "SphereProjector.h" -#include "log.h" +#include "cli_support/log.h" static constexpr double epsilon = 1e-6; inline void check_equal(const glm::dvec2& a, const glm::dvec2& b) { diff --git a/unittests/sf_merger/storage_boundaries.cpp b/unittests/sf_merger/storage_boundaries.cpp index 9f4f3a6a..3247e832 100644 --- a/unittests/sf_merger/storage_boundaries.cpp +++ b/unittests/sf_merger/storage_boundaries.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -6,6 +24,7 @@ #include "cut.h" #include "merge.h" #include "mesh/storage.h" +#include "store/inputs.h" namespace { @@ -176,3 +195,47 @@ TEST_CASE("SF cut hard-links unchanged leaves and writes clipped leaves", "[sfme CHECK_FALSE(clipped->is_empty()); } } + +TEST_CASE("SF merge stops on request and reuses cached nodes", "[sfmerger][sf][snapshot]") +{ + const octree::Id root = octree::Id::root(); + TemporaryDirectory directory("merge-snapshot"); + const auto left_path = directory.path() / "left"; + const auto right_path = directory.path() / "right"; + auto left = indexed_storage(left_path); + auto right = indexed_storage(right_path); + REQUIRE(left.save(root, triangle()).has_value()); + REQUIRE(right.save(root, triangle(10.0)).has_value()); + REQUIRE(left.save_index().has_value()); + REQUIRE(right.save_index().has_value()); + const auto record = sf_merger::inputs::make_record(left_path, right_path, std::nullopt); + REQUIRE(record.has_value()); + const auto output = directory.path() / "output"; + + SECTION("a stop request retains the incomplete snapshot") + { + const auto merged = merge_datasets(left, right, output, std::nullopt, *record, std::nullopt, [] { return true; }); + REQUIRE_FALSE(merged.has_value()); + CHECK(merged.error().code() == Error::Code::Cancelled); + CHECK_FALSE(std::filesystem::exists(output)); + const auto partial = directory.path() / "output.part"; + CHECK(std::filesystem::exists(partial / store::inputs::file_name)); + CHECK(mesh::storage::open_index(partial / octree::storage::index_file_name).has_value()); + } + + SECTION("cached nodes are linked") + { + const auto first = directory.path() / "first"; + REQUIRE(merge_datasets(left, right, first, std::nullopt, *record).has_value()); + CHECK_FALSE(std::filesystem::exists(first / store::inputs::file_name)); + // Turn the published output into what an interrupted merge leaves behind. + const auto cache = directory.path() / "cache.part"; + std::filesystem::rename(first, cache); + REQUIRE(store::inputs::write(cache, *record).has_value()); + + REQUIRE(merge_datasets(left, right, output, std::nullopt, *record, cache).has_value()); + const auto cached = indexed_storage(cache); + const auto merged = indexed_storage(output); + CHECK(std::filesystem::equivalent(cached.paths(root)->front(), merged.paths(root)->front())); + } +} diff --git a/unittests/temporary_directory.h b/unittests/temporary_directory.h index 10f2b60f..a08ed7c6 100644 --- a/unittests/temporary_directory.h +++ b/unittests/temporary_directory.h @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/terrainlib/convert.cpp b/unittests/terrainlib/convert.cpp index f3c5f6c3..967fd0ec 100644 --- a/unittests/terrainlib/convert.cpp +++ b/unittests/terrainlib/convert.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2024 Martin Braunsperger + * Copyright (C) 2024 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include diff --git a/unittests/terrainlib/cow.cpp b/unittests/terrainlib/cow.cpp index fed94c06..81ebd196 100644 --- a/unittests/terrainlib/cow.cpp +++ b/unittests/terrainlib/cow.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/Cow.h" diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp deleted file mode 100644 index c7f359d6..00000000 --- a/unittests/terrainlib/dataset.cpp +++ /dev/null @@ -1,160 +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 "../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; -using Catch::Approx; - -void checkBounds(const radix::tile::SrsBounds &a, const radix::tile::SrsBounds &b) { - CHECK(a.height() > 0); - CHECK(a.width() > 0); - CHECK(b.height() > 0); - CHECK(b.width() > 0); - - const auto heightErrorIn = std::abs(a.height() - b.height()) / a.height(); - const auto widthErrorIn = std::abs(a.width() - b.width()) / a.width(); - // fmt::print("height error = {}, width error = {}\n", heightErrorIn, widthErrorIn); - CHECK(heightErrorIn < 0.001); - CHECK(widthErrorIn < 0.001); -} - -TEST_CASE("datasets are as expected") { - auto d_mgi = Dataset(ALP_TEST_DATA_DIR "/austria/at_mgi.tif"); - auto d_wgs84 = Dataset(ALP_TEST_DATA_DIR "/austria/at_wgs84.tif"); - - OGRSpatialReference webmercator; - webmercator.importFromEPSG(3857); - webmercator.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - - OGRSpatialReference wgs84; - wgs84.importFromEPSG(4326); - wgs84.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - - SECTION("file name") { - CHECK(d_mgi.name() == "at_mgi"); - CHECK(d_wgs84.name() == "at_wgs84"); - } - - SECTION("SRS") { - REQUIRE_FALSE(d_mgi.srs().IsEmpty()); - REQUIRE_FALSE(d_wgs84.srs().IsEmpty()); - const auto wgs84 = d_wgs84.srs(); - REQUIRE_FALSE(d_mgi.srs().IsSame(&wgs84)); - } - - SECTION("bounds") { - { - // fmt::print("webmercator: \n"); - checkBounds(d_mgi.bounds(webmercator), d_wgs84.bounds(webmercator)); - } - { - // fmt::print("d_wgs84: \n"); - checkBounds(d_mgi.bounds(d_wgs84.srs()), d_wgs84.bounds(d_wgs84.srs())); - } - // { - // // doesn't work: wgs84 was created by projecting the original mgi data. since wgs84 is warped, the projection creates a border. - // // when backprojecting to mgi, there is still a border in the raster data (but widthout height information). - // // this is not an error in the code, and impossible to avoid when rerasterising warped raster data - // fmt::print("d_mgi: \n"); - // checkBounds(d_mgi.bounds(d_mgi.srs()), d_wgs84.bounds(d_mgi.srs())); - // } - } - SECTION("resolution") { - CHECK(d_mgi.widthInPixels() == 620); - CHECK(d_mgi.heightInPixels() == 350); - CHECK(d_mgi.n_bands() == 1); - - const auto northern = 49.222158096; - const auto southern = 46.077736128; - const auto eastern = 17.58967912; - const auto western = 9.323270147; - - CHECK(d_mgi.pixelWidthIn(wgs84) == Approx((eastern - western) / 620)); - CHECK(d_mgi.pixelHeightIn(wgs84) == Approx((northern - southern) / 350)); - CHECK(d_mgi.gridResolution(wgs84) == Approx((northern - southern) / 350)); - - const auto wgs84_grid = ctb::GlobalGeodetic(256); - CHECK(wgs84_grid.zoomForResolution(d_mgi.gridResolution(wgs84)) == 7); - - const auto webmercator_grid = ctb::GlobalMercator(); - CHECK(webmercator_grid.zoomForResolution(d_mgi.gridResolution(webmercator)) == 7); - } -} - -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"); - - OGRSpatialReference webmercator; - webmercator.importFromEPSG(3857); - webmercator.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - - CHECK(d_mgi.widthInPixels(d_mgi.bounds(), d_mgi.srs()) == Approx(620.0)); - CHECK(d_mgi.heightInPixels(d_mgi.bounds(), d_mgi.srs()) == Approx(350.0)); - CHECK(d_mgi.widthInPixels(srs::non_exact_bounds_transform(d_mgi.bounds(), d_mgi.srs(), webmercator), webmercator) == Approx(620.0)); - CHECK(d_mgi.heightInPixels(srs::non_exact_bounds_transform(d_mgi.bounds(), d_mgi.srs(), webmercator), webmercator) == Approx(350.0)); - - auto adjust_bounds = [](auto bounds) { - const auto unadjusted_width = bounds.width(); - const auto unadjusted_height = bounds.height(); - bounds.min += glm::dvec2{unadjusted_width * 0.2, unadjusted_height * 0.3}; - bounds.max -= glm::dvec2{unadjusted_width * 0.1, unadjusted_height * 0.2}; - return bounds; - }; - - CHECK(d_wgs84.widthInPixels(adjust_bounds(d_wgs84.bounds()), d_wgs84.srs()) == Approx(620.0 * 0.7)); - CHECK(d_wgs84.heightInPixels(adjust_bounds(d_wgs84.bounds()), d_wgs84.srs()) == Approx(350.0 * 0.5)); - - const auto webmercator_bounds = srs::non_exact_bounds_transform(d_wgs84.bounds(), d_wgs84.srs(), webmercator); - CHECK(d_wgs84.widthInPixels(webmercator_bounds, webmercator) == Approx(620.0)); - CHECK(d_wgs84.heightInPixels(webmercator_bounds, webmercator) == Approx(350.0)); - - const auto webmercator_adjusted_bounds = adjust_bounds(webmercator_bounds); - CHECK(d_wgs84.widthInPixels(webmercator_adjusted_bounds, webmercator) == Approx(620.0 * 0.7)); - CHECK(d_wgs84.heightInPixels(webmercator_adjusted_bounds, webmercator) == Approx(350.0 * 0.5)); -} diff --git a/unittests/terrainlib/enumerate.cpp b/unittests/terrainlib/enumerate.cpp index 974c9562..1689e4bd 100644 --- a/unittests/terrainlib/enumerate.cpp +++ b/unittests/terrainlib/enumerate.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "enumerate.h" diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 06b21391..28a0827e 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -1,5 +1,25 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" +#include "../temporary_directory.h" +#include "io/byte.h" #include "io/envelope.h" #include @@ -9,6 +29,7 @@ #include #include #include +#include namespace { @@ -71,8 +92,19 @@ 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>>; constexpr std::array zstd_compression_algorithms { - io::envelope::CompressionAlgorithm::ZstdBestCompressionWithChecksum, - io::envelope::CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, + io::envelope::CompressionAlgorithm::ZstdBestCompression, + io::envelope::CompressionAlgorithm::ZstdDefaultCompression, +}; + +constexpr std::array compression_algorithms { + io::envelope::CompressionAlgorithm::None, + io::envelope::CompressionAlgorithm::ZstdBestCompression, + io::envelope::CompressionAlgorithm::ZstdDefaultCompression, +}; + +constexpr std::array hash_algorithms { + io::hash::Algorithm::None, + io::hash::Algorithm::Xxh3_64, }; static_assert(std::is_aggregate_v); @@ -84,17 +116,17 @@ static_assert(std::same_as); static_assert(std::same_as, v1::Payload>); template -io::envelope::Bytes encode_value(const Value& value) +io::byte::vector encode_value(const Value& value) { - io::envelope::Bytes bytes; + io::byte::vector bytes; zpp::bits::out output(bytes); output(value).or_throw(); return bytes; } -io::envelope::Bytes encode_envelope(const io::envelope::Envelope& envelope) { return encode_value(envelope); } +io::byte::vector encode_envelope(const io::envelope::Envelope& envelope) { return encode_value(envelope); } -io::envelope::Envelope decode_envelope(const io::envelope::Bytes& bytes) +io::envelope::Envelope decode_envelope(const io::byte::vector& bytes) { io::envelope::Envelope envelope {}; zpp::bits::in input(bytes); @@ -102,7 +134,8 @@ 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) +// Writes a frame with a checksum, which the reader accepts but does not require. +io::byte::vector compress_without_content_size(const io::byte::vector& uncompressed_data) { const std::unique_ptr context { ZSTD_createCCtx(), &ZSTD_freeCCtx }; if (!context || ZSTD_isError(ZSTD_CCtx_setParameter(context.get(), ZSTD_c_contentSizeFlag, 0)) @@ -110,7 +143,7 @@ io::envelope::Bytes compress_without_content_size(const io::envelope::Bytes& unc throw std::runtime_error { "could not configure zstd test context" }; } - io::envelope::Bytes compressed_data(ZSTD_compressBound(uncompressed_data.size())); + io::byte::vector 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)) { @@ -127,15 +160,7 @@ void check_error(const Result& result, const Error::Code 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; -} +void write_file(const std::filesystem::path& path, const io::byte::span bytes) { REQUIRE(io::byte::write(bytes, path, io::byte::WriteMode::Overwrite)); } } // namespace @@ -152,19 +177,40 @@ TEST_CASE("Envelope round trips the latest payload version") 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::ZstdDefaultCompressionWithChecksum); - CHECK(envelope.uncompressed_size == encode_value(expected).size()); + CHECK(envelope.header.magic == io::envelope::magic); + CHECK(envelope.header.class_name == Schema::class_name); + CHECK(envelope.header.class_version == 3); + CHECK(envelope.header.hash_algorithm == io::hash::Algorithm::Xxh3_64); + CHECK(envelope.header.hash == io::hash::data(encode_value(expected), io::hash::Algorithm::Xxh3_64)); + CHECK(envelope.header.compression_algorithm == io::envelope::CompressionAlgorithm::ZstdDefaultCompression); + CHECK(envelope.header.uncompressed_size == encode_value(expected).size()); const auto result = io::envelope::deserialize(*bytes); REQUIRE(result.has_value()); CHECK(*result == expected); } +TEST_CASE("Envelope round trips payloads containing GLM types") +{ + struct Payload { + glm::dvec3 position; + glm::mat4 transform; + std::vector texture_coordinates; + bool operator==(const Payload&) const = default; + }; + using GlmSchema = io::envelope::PayloadSchema<"test.GlmPayload", io::envelope::Version<1, Payload>>; + const Payload expected { + .position = { 1.0, -2.0, 3.5 }, + .transform = glm::mat4(2.0f), + .texture_coordinates = { { 0.0f, 0.5f }, { 1.0f, 0.75f } }, + }; + const auto bytes = io::envelope::serialize(expected); + REQUIRE(bytes.has_value()); + 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") @@ -202,7 +248,7 @@ TEST_CASE("Envelope upgrades older payload versions") } } -TEST_CASE("Envelope supports uncompressed data without a checksum") +TEST_CASE("Envelope supports uncompressed data without a hash") { const v3::Payload expected { .id = 11, @@ -210,183 +256,123 @@ TEST_CASE("Envelope supports uncompressed data without a checksum") .enabled = true, .samples = { 5, 8 }, }; - const auto bytes = io::envelope::serialize(expected, io::envelope::CompressionAlgorithm::None, io::envelope::ChecksumAlgorithm::None); + const auto bytes = io::envelope::serialize(expected, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::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()); - CHECK(envelope.uncompressed_size == envelope.compressed_data.size()); + CHECK(envelope.header.compression_algorithm == io::envelope::CompressionAlgorithm::None); + CHECK(envelope.header.hash_algorithm == io::hash::Algorithm::None); + CHECK(envelope.header.hash.empty()); + CHECK(envelope.header.uncompressed_size == envelope.compressed_data.size()); const auto result = io::envelope::deserialize(*bytes); REQUIRE(result.has_value()); CHECK(*result == expected); } -TEST_CASE("CRC-32C uses its canonical hexadecimal representation") +TEST_CASE("Envelope round trips every compression and hash combination") { - 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"); + const v3::Payload expected { + .id = 12, + .label = "combination", + .enabled = true, + .samples = { 3, 5, 8 }, + }; + for (const auto compression_algorithm : compression_algorithms) { + for (const auto hash_algorithm : hash_algorithms) { + CAPTURE(compression_algorithm, hash_algorithm); + const auto bytes = io::envelope::serialize(expected, compression_algorithm, hash_algorithm); + REQUIRE(bytes.has_value()); + + const auto envelope = decode_envelope(*bytes); + CHECK(envelope.header.compression_algorithm == compression_algorithm); + CHECK(envelope.header.hash_algorithm == hash_algorithm); + CHECK(envelope.header.hash == io::hash::data(encode_value(expected), hash_algorithm)); + + const auto result = io::envelope::deserialize(*bytes); + REQUIRE(result.has_value()); + CHECK(*result == expected); + } } } -TEST_CASE("CRC-32C protects independently compressed data") +TEST_CASE("XXH3-64 detects corrupted envelope payloads") { - const auto original = bytes_from_string("payload protected by CRC-32C"); + const v3::Payload payload { .id = 15, .label = "protected by XXH3-64", .enabled = true, .samples = { 13, 21 } }; 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), - Error::Code::CorruptData); + const auto bytes = io::envelope::serialize(payload, io::envelope::CompressionAlgorithm::None); + REQUIRE(bytes.has_value()); + auto envelope = decode_envelope(*bytes); + envelope.compressed_data.back() ^= std::byte { 0x01 }; + check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("with zstd compression") { for (const auto compression_algorithm : zstd_compression_algorithms) { CAPTURE(compression_algorithm); - const auto compressed = io::envelope::compress_with_checksum(original, compression_algorithm, io::envelope::ChecksumAlgorithm::Crc32c); - REQUIRE(compressed.has_value()); - REQUIRE(compressed->checksum.size() == 8); - - const auto result = io::envelope::checked_decompress( - compressed->compressed_data, compression_algorithm, 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, compression_algorithm, io::envelope::ChecksumAlgorithm::Crc32c, wrong_checksum), - Error::Code::CorruptData); + const auto bytes = io::envelope::serialize(payload, compression_algorithm); + REQUIRE(bytes.has_value()); + auto envelope = decode_envelope(*bytes); + envelope.compressed_data.back() ^= std::byte { 0x01 }; + check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } } - SECTION("malformed checksum") - { - check_error(io::envelope::checked_decompress(original, io::envelope::CompressionAlgorithm::None, io::envelope::ChecksumAlgorithm::Crc32c, "E3069283"), - Error::Code::CorruptData); - check_error(io::envelope::checked_decompress(original, io::envelope::CompressionAlgorithm::None, io::envelope::ChecksumAlgorithm::Crc32c, {}), - Error::Code::CorruptData); - } -} - -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)), Error::Code::CorruptData); - } - 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); + const v1::Payload original { .id = 16, .name = "xxh3 version one" }; + const auto bytes = io::envelope::serialize(original, io::envelope::CompressionAlgorithm::None); 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 = {}, - }); + auto envelope = decode_envelope(*bytes); + REQUIRE(io::envelope::deserialize(encode_envelope(envelope)).has_value()); + envelope.compressed_data.front() ^= std::byte { 0x01 }; + check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } } -TEST_CASE("Checked compression round trips and validates its checksum") +TEST_CASE("Compression round trips and enforces its size limit") { - const io::envelope::Bytes original(4096, std::byte { 0x2a }); + const io::byte::vector original(4096, std::byte { 0x2a }); for (const auto compression_algorithm : zstd_compression_algorithms) { CAPTURE(compression_algorithm); - const auto compressed = io::envelope::compress_with_checksum(original, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib); + const auto compressed = io::envelope::compress(original, compression_algorithm); REQUIRE(compressed.has_value()); - CHECK(compressed->compressed_data.size() < original.size()); - CHECK(compressed->checksum.empty()); + CHECK(compressed->size() < original.size()); - const auto result = io::envelope::checked_decompress( - compressed->compressed_data, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, compressed->checksum); + const auto result = io::envelope::decompress(*compressed, compression_algorithm); REQUIRE(result.has_value()); CHECK(*result == original); - const auto empty_compressed = io::envelope::compress_with_checksum({}, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib); + const auto empty_compressed = io::envelope::compress({}, compression_algorithm); REQUIRE(empty_compressed.has_value()); - const auto empty_result = io::envelope::checked_decompress( - empty_compressed->compressed_data, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, empty_compressed->checksum); + const auto empty_result = io::envelope::decompress(*empty_compressed, compression_algorithm); REQUIRE(empty_result.has_value()); CHECK(empty_result->empty()); - auto corrupted = compressed->compressed_data; - corrupted.back() ^= std::byte { 0x01 }; - check_error(io::envelope::checked_decompress(corrupted, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, {}), - Error::Code::CorruptData); - - check_error(io::envelope::checked_decompress( - compressed->compressed_data, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, {}, original.size() - 1), - Error::Code::ResourceExhausted); + check_error(io::envelope::decompress(*compressed, compression_algorithm, original.size() - 1), Error::Code::ResourceExhausted); } + + const auto uncompressed = io::envelope::compress(original, io::envelope::CompressionAlgorithm::None); + REQUIRE(uncompressed.has_value()); + CHECK(*uncompressed == original); + check_error(io::envelope::decompress(*uncompressed, io::envelope::CompressionAlgorithm::None, original.size() - 1), Error::Code::ResourceExhausted); } -TEST_CASE("Checked decompression uses its maximum when the format omits the content size") +TEST_CASE("Decompression uses its maximum when the format omits the content size") { - const io::envelope::Bytes original(4096, std::byte { 0x37 }); + const io::byte::vector original(4096, std::byte { 0x37 }); const auto compressed_data = compress_without_content_size(original); for (const auto compression_algorithm : zstd_compression_algorithms) { CAPTURE(compression_algorithm); - const auto result = io::envelope::checked_decompress( - compressed_data, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, {}, original.size()); + const auto result = io::envelope::decompress(compressed_data, compression_algorithm, original.size()); REQUIRE(result.has_value()); CHECK(*result == original); - check_error(io::envelope::checked_decompress( - compressed_data, compression_algorithm, io::envelope::ChecksumAlgorithm::HandledByCompressionLib, {}, original.size() - 1), - Error::Code::ResourceExhausted); + check_error(io::envelope::decompress(compressed_data, compression_algorithm, original.size() - 1), Error::Code::ResourceExhausted); } } @@ -399,72 +385,84 @@ TEST_CASE("Envelope rejects incompatible metadata") SECTION("magic") { auto envelope = decode_envelope(*serialized); - ++envelope.magic; + ++envelope.header.magic; + check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); + } + + SECTION("magic of the previous format") + { + auto envelope = decode_envelope(*serialized); + envelope.header.magic = 0xF5FBD3EF919428CAULL; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("class name") { auto envelope = decode_envelope(*serialized); - envelope.class_name = "other.Payload"; + envelope.header.class_name = "other.Payload"; + check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); + } + + SECTION("class name exceeds its limit") + { + auto envelope = decode_envelope(*serialized); + envelope.header.class_name = std::string(io::envelope::max_class_name_size + 1, 'x'); check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("class version") { auto envelope = decode_envelope(*serialized); - envelope.class_version = 99; + envelope.header.class_version = 99; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::Unsupported); } - SECTION("checksum algorithm") + SECTION("hash algorithm") { auto envelope = decode_envelope(*serialized); - envelope.checksum_algorithm = static_cast(99); + envelope.header.hash_algorithm = static_cast(99); check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::Unsupported); } SECTION("compression algorithm") { auto envelope = decode_envelope(*serialized); - envelope.compression_algorithm = static_cast(99); + envelope.header.compression_algorithm = static_cast(99); check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::Unsupported); } - SECTION("algorithm combination") + SECTION("hash for the None algorithm") { auto envelope = decode_envelope(*serialized); - envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::None; + envelope.header.hash_algorithm = io::hash::Algorithm::None; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } - SECTION("external checksum") + SECTION("incorrect hash") { auto envelope = decode_envelope(*serialized); - envelope.checksum = "not used by zstd"; + envelope.header.hash.front() ^= std::byte { 0x01 }; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } - SECTION("malformed CRC-32C checksum") + SECTION("hash with the wrong length") { auto envelope = decode_envelope(*serialized); - envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::Crc32c; - envelope.checksum = "1234"; + envelope.header.hash.pop_back(); check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } - SECTION("incorrect CRC-32C checksum") + SECTION("hash exceeds its limit") { auto envelope = decode_envelope(*serialized); - envelope.checksum_algorithm = io::envelope::ChecksumAlgorithm::Crc32c; - envelope.checksum = "00000000"; + envelope.header.hash.resize(io::envelope::max_hash_size + 1); check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("uncompressed size is smaller than the payload") { auto envelope = decode_envelope(*serialized); - --envelope.uncompressed_size; + --envelope.header.uncompressed_size; const auto result = io::envelope::deserialize(encode_envelope(envelope)); check_error(result, Error::Code::CorruptData); CHECK(result.error().to_string().find("reclassified ResourceExhausted -> CorruptData") != std::string::npos); @@ -473,28 +471,28 @@ TEST_CASE("Envelope rejects incompatible metadata") SECTION("uncompressed size is larger than the payload") { auto envelope = decode_envelope(*serialized); - ++envelope.uncompressed_size; + ++envelope.header.uncompressed_size; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("zero uncompressed size does not mean unspecified") { auto envelope = decode_envelope(*serialized); - envelope.uncompressed_size = 0; + envelope.header.uncompressed_size = 0; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("uncompressed size exceeds the hard limit") { auto envelope = decode_envelope(*serialized); - envelope.uncompressed_size = io::envelope::default_max_decompressed_size + 1; + envelope.header.uncompressed_size = io::envelope::default_max_decompressed_size + 1; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::ResourceExhausted); } 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)), + check_error(io::envelope::deserialize(encode_envelope(envelope), std::nullopt, static_cast(envelope.header.uncompressed_size - 1)), Error::Code::ResourceExhausted); } } @@ -502,20 +500,20 @@ TEST_CASE("Envelope rejects incompatible metadata") 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); + const auto serialized = io::envelope::serialize(payload, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::None); REQUIRE(serialized.has_value()); SECTION("declared size is smaller") { auto envelope = decode_envelope(*serialized); - --envelope.uncompressed_size; + --envelope.header.uncompressed_size; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } SECTION("declared size is larger") { auto envelope = decode_envelope(*serialized); - ++envelope.uncompressed_size; + ++envelope.header.uncompressed_size; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } } @@ -524,22 +522,170 @@ TEST_CASE("Envelope reports malformed serialized data") { SECTION("envelope") { - const io::envelope::Bytes malformed { std::byte { 0x01 }, std::byte { 0x02 } }; + const io::byte::vector malformed { std::byte { 0x01 }, std::byte { 0x02 } }; check_error(io::envelope::deserialize(malformed), Error::Code::CorruptData); } + SECTION("trailing bytes") + { + const v3::Payload payload { .id = 3, .label = "trailing", .enabled = true, .samples = {} }; + auto serialized = io::envelope::serialize(payload); + REQUIRE(serialized.has_value()); + serialized->push_back(std::byte { 0 }); + check_error(io::envelope::deserialize(*serialized), Error::Code::CorruptData); + } + 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, - .uncompressed_size = 1, + .header = { + .magic = io::envelope::magic, + .class_name = std::string { Schema::class_name }, + .class_version = 3, + .hash_algorithm = io::hash::Algorithm::None, + .hash = {}, + .compression_algorithm = io::envelope::CompressionAlgorithm::None, + .uncompressed_size = 1, + }, .compressed_data = { std::byte { 0x01 } }, }; check_error(io::envelope::deserialize(encode_envelope(envelope)), Error::Code::CorruptData); } } + +TEST_CASE("Envelope headers are read without the payload") +{ + test::TemporaryDirectory directory("envelope-header"); + const auto path = directory.path() / "payload.envelope"; + + SECTION("the header matches a full read") + { + const v3::Payload payload { .id = 21, .label = std::string(4096, 'h'), .enabled = true, .samples = { 1, 2, 3 } }; + REQUIRE(io::envelope::write(payload, path, io::byte::WriteMode::Overwrite)); + const auto header = io::envelope::read_header(path); + REQUIRE(header.has_value()); + CHECK(header->magic == io::envelope::magic); + CHECK(header->class_name == Schema::class_name); + CHECK(header->class_version == Schema::latest_version); + CHECK(header->hash_algorithm == io::hash::Algorithm::Xxh3_64); + CHECK(header->compression_algorithm == io::envelope::CompressionAlgorithm::ZstdDefaultCompression); + + const auto full = io::envelope::read(path); + REQUIRE(full.has_value()); + CHECK(header->uncompressed_size == encode_value(*full).size()); + CHECK(header->hash == io::hash::data(encode_value(*full), io::hash::Algorithm::Xxh3_64)); + } + + SECTION("the class name is not checked") + { + io::envelope::Envelope envelope = decode_envelope(io::envelope::serialize({}).value()); + envelope.header.class_name = "other.Payload"; + write_file(path, encode_envelope(envelope)); + const auto header = io::envelope::read_header(path); + REQUIRE(header.has_value()); + CHECK(header->class_name == "other.Payload"); + } + + SECTION("a header of the maximum size") + { + io::envelope::Envelope envelope = decode_envelope(io::envelope::serialize({}).value()); + envelope.header.class_name = std::string(io::envelope::max_class_name_size, 'n'); + envelope.header.hash = io::byte::vector(io::envelope::max_hash_size, std::byte { 0x5a }); + envelope.compressed_data = io::byte::vector(1024, std::byte { 0x11 }); + const auto bytes = encode_envelope(envelope); + CHECK(encode_value(envelope.header).size() == io::envelope::max_header_size); + write_file(path, bytes); + const auto header = io::envelope::read_header(path); + REQUIRE(header.has_value()); + CHECK(header->class_name == envelope.header.class_name); + CHECK(header->hash == envelope.header.hash); + } + + SECTION("a file shorter than the maximum header") + { + const io::envelope::Envelope envelope { + .header = { + .magic = io::envelope::magic, + .class_name = "a", + .class_version = 1, + .hash_algorithm = io::hash::Algorithm::None, + .hash = {}, + .compression_algorithm = io::envelope::CompressionAlgorithm::None, + .uncompressed_size = 0, + }, + .compressed_data = {}, + }; + const auto bytes = encode_envelope(envelope); + REQUIRE(bytes.size() < io::envelope::max_header_size); + write_file(path, bytes); + const auto header = io::envelope::read_header(path); + REQUIRE(header.has_value()); + CHECK(header->class_name == "a"); + CHECK(header->hash.empty()); + } + + SECTION("a truncated header") + { + const auto bytes = io::envelope::serialize({}).value(); + write_file(path, std::span(bytes).first(20)); + check_error(io::envelope::read_header(path), Error::Code::CorruptData); + } + + SECTION("a corrupt class name length is rejected") + { + auto bytes = io::envelope::serialize({}).value(); + // The class name length follows the 8-byte magic. + std::fill_n(bytes.begin() + 8, 4, std::byte { 0xff }); + write_file(path, bytes); + check_error(io::envelope::read_header(path), Error::Code::CorruptData); + check_error(io::envelope::deserialize(bytes), Error::Code::CorruptData); + } + + SECTION("an invalid magic") + { + io::envelope::Envelope envelope = decode_envelope(io::envelope::serialize({}).value()); + envelope.header.magic = 0xF5FBD3EF919428CAULL; + write_file(path, encode_envelope(envelope)); + check_error(io::envelope::read_header(path), Error::Code::CorruptData); + } + + SECTION("unknown enumerators") + { + io::envelope::Envelope envelope = decode_envelope(io::envelope::serialize({}).value()); + SECTION("hash algorithm") { envelope.header.hash_algorithm = static_cast(2); } + SECTION("compression algorithm") { envelope.header.compression_algorithm = static_cast(3); } + write_file(path, encode_envelope(envelope)); + check_error(io::envelope::read_header(path), Error::Code::Unsupported); + } + + SECTION("a missing file") { check_error(io::envelope::read_header(directory.path() / "missing"), Error::Code::NotFound); } +} + +TEST_CASE("Envelope files return and verify their payload hash", "[io][envelope][hash]") +{ + test::TemporaryDirectory directory("envelope-hash"); + const auto path = directory.path() / "payload.envelope"; + const v3::Payload payload { .id = 5, .label = "identity", .enabled = true, .samples = { 4, 5 } }; + const auto hash = io::envelope::write(payload, path, io::byte::WriteMode::Overwrite); + REQUIRE(hash.has_value()); + CHECK(*hash == io::envelope::read_header(path)->hash); + CHECK(*hash == io::hash::data(encode_value(payload), io::hash::Algorithm::Xxh3_64)); + CHECK(io::envelope::read(path, *hash).value() == payload); + + SECTION("a different expected hash") + { + auto different = *hash; + different.front() ^= std::byte { 1 }; + check_error(io::envelope::read(path, different), Error::Code::CorruptData); + } + + SECTION("a hashless payload with an expected hash") + { + const auto hashless = io::envelope::write( + payload, path, io::byte::WriteMode::Overwrite, true, io::envelope::CompressionAlgorithm::ZstdDefaultCompression, io::hash::Algorithm::None); + REQUIRE(hashless.has_value()); + CHECK(hashless->empty()); + CHECK(io::envelope::read(path).has_value()); + check_error(io::envelope::read(path, *hash), Error::Code::CorruptData); + } +} diff --git a/unittests/terrainlib/error.cpp b/unittests/terrainlib/error.cpp index c2b7a611..537c9f00 100644 --- a/unittests/terrainlib/error.cpp +++ b/unittests/terrainlib/error.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -50,3 +68,51 @@ TEST_CASE("Error::propagate adds supplied context to an Error", "[error]") CHECK(description.find("unittests/terrainlib/error.cpp") != std::string::npos); CHECK(description.find("open input") != std::string::npos); } + +TEST_CASE("Error::raise throws an exception carrying the error", "[error]") +{ + try { + Error::raise(Error::Code::NotFound, "find input"); + FAIL("raise returned"); + } catch (const Error::Exception& exception) { + CHECK(exception.error().code() == Error::Code::NotFound); + CHECK(std::string(exception.what()) == exception.error().to_string()); + CHECK(std::string(exception.what()).find("find input") != std::string::npos); + } +} + +TEST_CASE("Error::throwing_unwrap returns values and throws failures", "[error]") +{ + CHECK(Error::throwing_unwrap(Expected(7)) == 7); + Error::throwing_unwrap(Expected()); + + const auto thrown = [](Expected result, std::string message) { + try { + Error::throwing_unwrap(std::move(result), std::move(message)); + } catch (const Error::Exception& exception) { + return exception.error(); + } + FAIL("throwing_unwrap returned"); + return Error::make(Error::Code::Internal, "unreachable"); + }; + const auto plain = thrown(Error::fail(Error::Code::Io, "open input"), {}); + CHECK(plain.code() == Error::Code::Io); + CHECK(plain.to_string().find("caused by") == std::string::npos); + const auto context = thrown(Error::fail(Error::Code::Io, "open input"), "read tile"); + CHECK(context.to_string().find("read tile") != std::string::npos); + CHECK(context.to_string().find("caused by: open input") != std::string::npos); +} + +TEST_CASE("Error keeps the stack trace where it was made", "[error]") +{ + Expected source = Error::fail(Error::Code::Io, "open input"); + const auto origin = source.error().stacktrace(); + CHECK_FALSE(origin.empty()); + const Expected propagated = Error::propagate(std::move(source), "read input"); + CHECK(propagated.error().stacktrace() == origin); + try { + Error::throwing_unwrap(Expected(propagated), "decode input"); + } catch (const Error::Exception& exception) { + CHECK(exception.error().stacktrace() == origin); + } +} diff --git a/unittests/terrainlib/fixed_vector.cpp b/unittests/terrainlib/fixed_vector.cpp index 88a24ef1..8f5bcda1 100644 --- a/unittests/terrainlib/fixed_vector.cpp +++ b/unittests/terrainlib/fixed_vector.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include "containers/FixedVector.h" diff --git a/unittests/terrainlib/geometry_frames.cpp b/unittests/terrainlib/geometry_frames.cpp index aa519988..15439b84 100644 --- a/unittests/terrainlib/geometry_frames.cpp +++ b/unittests/terrainlib/geometry_frames.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/gis_dataset.cpp b/unittests/terrainlib/gis_dataset.cpp new file mode 100644 index 00000000..b303c7eb --- /dev/null +++ b/unittests/terrainlib/gis_dataset.cpp @@ -0,0 +1,208 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 "../catch2_helpers.h" + +#include +#include + +#include "gis/Dataset.h" +#include "gis/gdal.h" +#include "gis/webmercator.h" + +using namespace radix; +using Catch::Approx; + +void checkBounds(const radix::tile::SrsBounds &a, const radix::tile::SrsBounds &b) { + CHECK(a.height() > 0); + CHECK(a.width() > 0); + CHECK(b.height() > 0); + CHECK(b.width() > 0); + + const auto heightErrorIn = std::abs(a.height() - b.height()) / a.height(); + const auto widthErrorIn = std::abs(a.width() - b.width()) / a.width(); + // fmt::print("height error = {}, width error = {}\n", heightErrorIn, widthErrorIn); + // Reprojected coverage includes a guard band of 1/258 of the extent per side. + CHECK(heightErrorIn < 0.01); + CHECK(widthErrorIn < 0.01); +} + +TEST_CASE("datasets are as expected") { + auto d_mgi = gis::Dataset(ALP_TEST_DATA_DIR "/austria/at_mgi.tif"); + auto d_wgs84 = gis::Dataset(ALP_TEST_DATA_DIR "/austria/at_wgs84.tif"); + + SECTION("file name") { + CHECK(d_mgi.name() == "at_mgi"); + CHECK(d_wgs84.name() == "at_wgs84"); + } + + SECTION("SRS") { + REQUIRE_FALSE(d_mgi.srs().value().IsEmpty()); + REQUIRE_FALSE(d_wgs84.srs().value().IsEmpty()); + const auto wgs84 = d_wgs84.srs().value(); + REQUIRE_FALSE(d_mgi.srs().value().IsSame(&wgs84)); + } + + SECTION("coverage") + { + { + const auto mgi_coverage = d_mgi.mercator_coverage().value(); + const auto wgs84_coverage = d_wgs84.mercator_coverage().value(); + REQUIRE(mgi_coverage.size() == 1); + REQUIRE(wgs84_coverage.size() == 1); + checkBounds(mgi_coverage.front(), wgs84_coverage.front()); + } + { + const auto mgi_coverage = d_mgi.geographic_coverage().value(); + const auto wgs84_coverage = d_wgs84.geographic_coverage().value(); + REQUIRE(mgi_coverage.size() == 1); + REQUIRE(wgs84_coverage.size() == 1); + checkBounds(mgi_coverage.front(), wgs84_coverage.front()); + } + } + SECTION("resolution") { + CHECK(d_mgi.widthInPixels() == 620); + CHECK(d_mgi.heightInPixels() == 350); + CHECK(d_mgi.n_bands() == 1); + } +} + +TEST_CASE("vector datasets use the layer spatial reference") { + gis::gdal::initialize_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); + + gis::Dataset dataset(raw_dataset); + const OGRSpatialReference actual_srs = dataset.srs().value(); + CHECK(actual_srs.IsSame(&expected_srs)); +} + +namespace { +gis::Dataset memory_raster(std::array geotransform, glm::uvec2 size, int epsg) +{ + gis::gdal::initialize_once(); + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("MEM"); + REQUIRE(driver != nullptr); + gis::Dataset dataset(driver->Create("", int(size.x), int(size.y), 1, GDT_Float32, nullptr)); + REQUIRE(dataset.gdalDataset()->SetGeoTransform(geotransform.data()) == CE_None); + OGRSpatialReference reference; + REQUIRE(reference.importFromEPSG(epsg) == OGRERR_NONE); + reference.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + REQUIRE(dataset.gdalDataset()->SetSpatialRef(&reference) == CE_None); + return dataset; +} + +void check_bounds_near(const radix::tile::SrsBounds& actual, const radix::tile::SrsBounds& expected, double margin) +{ + CHECK(actual.min.x == Approx(expected.min.x).margin(margin)); + CHECK(actual.min.y == Approx(expected.min.y).margin(margin)); + CHECK(actual.max.x == Approx(expected.max.x).margin(margin)); + CHECK(actual.max.y == Approx(expected.max.y).margin(margin)); +} + +glm::dvec2 mercator(double longitude, double latitude) +{ + constexpr double radius = 6378137; + return { radius * glm::radians(longitude), radius * std::log(std::tan(glm::radians(45 + latitude / 2))) }; +} +} // namespace + +TEST_CASE("dataset coverage is split at the antimeridian") +{ + const auto dataset = memory_raster({ 170, 0.625, 0, 5, 0, -0.3125 }, { 32, 32 }, 4326); + + const auto geographic = dataset.geographic_coverage(); + REQUIRE(geographic); + REQUIRE(geographic->size() == 2); + check_bounds_near((*geographic)[0], { { 170, -5 }, { 180, 5 } }, 1e-9); + check_bounds_near((*geographic)[1], { { -180, -5 }, { -170, 5 } }, 1e-9); + + const auto mercator_bounds = dataset.mercator_coverage(); + REQUIRE(mercator_bounds); + REQUIRE(mercator_bounds->size() == 2); + check_bounds_near((*mercator_bounds)[0], { mercator(170, -5), mercator(180, 5) }, 1e-3); + check_bounds_near((*mercator_bounds)[1], { mercator(-180, -5), mercator(-170, 5) }, 1e-3); +} + +TEST_CASE("dataset coverage beyond the polar limit is empty in web mercator") +{ + const auto dataset = memory_raster({ 10, 0.1, 0, 89, 0, -0.02 }, { 32, 32 }, 4326); + + const auto geographic = dataset.geographic_coverage(); + REQUIRE(geographic); + REQUIRE(geographic->size() == 1); + check_bounds_near(geographic->front(), { { 10, 88.36 }, { 13.2, 89 } }, 1e-9); + + const auto mercator_bounds = dataset.mercator_coverage(); + REQUIRE(mercator_bounds); + CHECK(mercator_bounds->empty()); +} + +TEST_CASE("web mercator dataset coverage is exact") +{ + // Tile (3, {4, 3}) in XYZ order. + const double side = gis::webmercator::half_extent / 4; + const auto dataset = memory_raster({ 0, side / 32, 0, side, 0, -side / 32 }, { 32, 32 }, 3857); + + const auto mercator_bounds = dataset.mercator_coverage(); + REQUIRE(mercator_bounds); + REQUIRE(mercator_bounds->size() == 1); + CHECK(mercator_bounds->front().min == glm::dvec2(0, 0)); + CHECK(mercator_bounds->front().max == glm::dvec2(side, side)); +} + +TEST_CASE("global dataset coverage is a single world rectangle") +{ + const auto dataset = memory_raster({ -180, 0.5, 0, 90, 0, -0.5 }, { 720, 360 }, 4326); + + const auto geographic = dataset.geographic_coverage(); + REQUIRE(geographic); + REQUIRE(geographic->size() == 1); + check_bounds_near(geographic->front(), { { -180, -90 }, { 180, 90 } }, 1e-9); + + const auto mercator_bounds = dataset.mercator_coverage(); + REQUIRE(mercator_bounds); + REQUIRE(mercator_bounds->size() == 1); + const double half = gis::webmercator::half_extent; + check_bounds_near(mercator_bounds->front(), { { -half, -half }, { half, half } }, 1e-3); +} + +TEST_CASE("dataset bounds reject GCP, RPC and geolocation-array georeferencing") +{ + auto dataset = memory_raster({ 10, 0.1, 0, 50, 0, -0.1 }, { 32, 32 }, 4326); + REQUIRE(dataset.bounds()); + SECTION("GCPs") + { + GDAL_GCP gcp {}; + REQUIRE(dataset.gdalDataset()->SetGCPs(1, &gcp, dataset.gdalDataset()->GetSpatialRef()) == CE_None); + } + SECTION("RPC") { REQUIRE(dataset.gdalDataset()->SetMetadataItem("LINE_OFF", "1", "RPC") == CE_None); } + SECTION("geolocation arrays") { REQUIRE(dataset.gdalDataset()->SetMetadataItem("X_DATASET", "longitudes.tif", "GEOLOCATION") == CE_None); } + const auto bounds = dataset.bounds(); + REQUIRE(!bounds); + CHECK(bounds.error().code() == Error::Code::Unsupported); +} diff --git a/unittests/terrainlib/gis_dataset_reading.cpp b/unittests/terrainlib/gis_dataset_reading.cpp new file mode 100644 index 00000000..9896d3ec --- /dev/null +++ b/unittests/terrainlib/gis_dataset_reading.cpp @@ -0,0 +1,882 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../temporary_directory.h" +#include "gis/Dataset.h" +#include "gis/DatasetReader.h" +#include "gis/gdal.h" +#include "gis/srs.h" +#include "gis/webmercator.h" +#include "io/image.h" + +using namespace radix; + +namespace { +// Writes the raster as a grayscale PNG, scaled from its minimum to its maximum. +template +Expected debug_out(const radix::Raster& image, const std::filesystem::path& path) +{ + if (image.buffer().empty()) { + return Error::fail(Error::Code::InvalidInput, "cannot write an empty debug raster", path); + } + const auto [min, max] = std::ranges::minmax(image); + const auto range = float(max) - float(min); + const auto gray = 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); + }); + return io::image::write(gray, path, { .overwrite = true }); +} + +void require_projection_available(const gis::Dataset& dataset, const OGRSpatialReference& target_srs) +{ + const auto dataset_srs = dataset.srs().value(); + const auto source_bounds = dataset.bounds().value(); + + std::shared_ptr transform; + REQUIRE_NOTHROW(transform = gis::srs::transformation(dataset_srs, target_srs).value()); + REQUIRE(transform != nullptr); + + std::array xs = { (source_bounds.min.x + source_bounds.max.x) / 2.0 }; + std::array ys = { (source_bounds.min.y + source_bounds.max.y) / 2.0 }; + REQUIRE(transform->Transform(static_cast(xs.size()), xs.data(), ys.data())); +} + +template +gis::DatasetReader make_reader( + std::shared_ptr dataset, gis::srs::Projection projection, std::array::channel_count> bands, Pixel default_pixel) +{ + auto reader = gis::DatasetReader::make(std::move(dataset), projection, bands, default_pixel); + INFO((reader ? "" : reader.error().to_string())); + REQUIRE(reader); + return std::move(*reader); +} + +template +typename gis::DatasetReader::Samples read_samples(gis::DatasetReader& reader, const radix::tile::SrsBounds& bounds, glm::uvec2 size) +{ + auto samples = reader.read(bounds, size); + INFO((samples ? "" : samples.error().to_string())); + REQUIRE(samples); + REQUIRE(samples->data.size() == size); + REQUIRE(samples->valid.size() == size); + return std::move(*samples); +} + +bool all_valid(const radix::Raster& valid) +{ + return std::ranges::all_of(valid, [](std::uint8_t value) { return value != 0; }); +} + +std::array geo_transform_for(const radix::tile::SrsBounds& bounds, glm::uvec2 size) +{ + return { bounds.min.x, bounds.width() / size.x, 0, bounds.max.y, 0, -bounds.height() / size.y }; +} + +// Each band is filled with 10 times its number. An empty path creates an +// in-memory raster, otherwise a GeoTIFF. +std::shared_ptr make_raster( + const std::filesystem::path& path, glm::uvec2 size, unsigned bands, GDALDataType type, std::array geo_transform, int epsg = 3857) +{ + gis::gdal::initialize_once(); + auto* driver = GetGDALDriverManager()->GetDriverByName(path.empty() ? "MEM" : "GTiff"); + REQUIRE(driver != nullptr); + auto dataset = std::make_shared(driver->Create(path.c_str(), int(size.x), int(size.y), int(bands), type, nullptr)); + const auto reference = gis::srs::from_epsg(epsg).value(); + REQUIRE(dataset->gdalDataset()->SetSpatialRef(&reference) == CE_None); + REQUIRE(dataset->gdalDataset()->SetGeoTransform(geo_transform.data()) == CE_None); + for (unsigned band = 1; band <= bands; ++band) { + REQUIRE(dataset->gdalDataset()->GetRasterBand(int(band))->Fill(10.0 * band) == CE_None); + } + return dataset; +} + +std::vector generate(glm::uvec2 size, const std::function& value) +{ + std::vector values(std::size_t(size.x) * size.y); + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + values[std::size_t(y) * size.x + x] = value(x, y); + } + } + return values; +} + +void write(GDALRasterBand* band, std::vector values) +{ + const int width = band->GetXSize(); + const int height = band->GetYSize(); + REQUIRE(values.size() == std::size_t(width) * std::size_t(height)); + REQUIRE(band->RasterIO(GF_Write, 0, 0, width, height, values.data(), width, height, GDT_Float32, 0, 0) == CE_None); +} + +void write_pixel(GDALRasterBand* band, glm::uvec2 position, float value) +{ + REQUIRE(band->RasterIO(GF_Write, int(position.x), int(position.y), 1, 1, &value, 1, 1, GDT_Float32, 0, 0) == CE_None); +} + +class FailingBand final : public GDALRasterBand { +public: + explicit FailingBand(GDALDataset* owner) + { + poDS = owner; + nBand = 1; + eDataType = GDT_Float32; + nBlockXSize = 16; + nBlockYSize = 1; + } + +protected: + CPLErr IReadBlock(int, int, void*) override + { + CPLError(CE_Failure, CPLE_FileIO, "injected read failure"); + return CE_Failure; + } +}; + +class FailingDataset final : public GDALDataset { +public: + FailingDataset() + : m_reference(gis::srs::webmercator()) + { + nRasterXSize = 16; + nRasterYSize = 16; + SetBand(1, new FailingBand(this)); + } + const OGRSpatialReference* GetSpatialRef() const override { return &m_reference; } + CPLErr GetGeoTransform(double* affine) override + { + const std::array values { 0, 1, 0, 16, 0, -1 }; + std::ranges::copy(values, affine); + return CE_None; + } + +private: + OGRSpatialReference m_reference; +}; +} + +TEST_CASE("reading") +{ + const std::vector at100m = { + "/austria/at_100m_mgi.tif", + "/austria/at_100m_epsg3857.tif", + "/austria/at_100m_epsg4326.tif", + "/austria/at_x149m_y100m_epsg4326.tif", + }; + + const std::vector vienna20m = { + "/austria/vienna_20m_mgi.tif", + "/austria/vienna_20m_epsg3857.tif", + "/austria/vienna_20m_epsg4326.tif", + }; + + const std::vector tauern10m = { + "/austria/tauern_10m_mgi.tif", + "/austria/tauern_10m_epsg3857.tif", + "/austria/tauern_10m_epsg4326.tif", + }; + + const std::vector pizbuin1m = { + "/austria/pizbuin_1m_mgi.tif", + "/austria/pizbuin_1m_epsg3857.tif", + "/austria/pizbuin_1m_epsg4326.tif", + }; + + OGRSpatialReference geographic_srs; + geographic_srs.importFromEPSG(4326); + geographic_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + + SECTION("check for min and max heights") + { + const std::array test_projections = { + std::make_pair(gis::srs::Projection::Geographic, 4326), + std::make_pair(gis::srs::Projection::WebMercator, 3857), + }; + const std::array test_locations = { + // CRS bounds, [lower limit, at least one value smaller, at least one value larger, upper limit] +#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED + std::make_tuple( + "at100m", + at100m, + radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, + std::make_tuple(100.0f, 120.0f, 3000.0f, 3800.0f)), // Austria is between 115 and 3798m + std::make_tuple( + "vienna20m", + vienna20m, + radix::tile::SrsBounds{{16.17, 48.14}, {16.59, 48.33}}, + std::make_tuple(140.0f, 180.0f, 500.0f, 560.0f)), // vienna, between 151 and 542m +#endif + std::make_tuple( + "tauern10m", + tauern10m, + radix::tile::SrsBounds{{12.6934117, 47.0739300}, {12.6944580, 47.0748649}}, + std::make_tuple(3700.0f, 3720.0f, 3790.0f, 3800.0f)), // Grossglockner, 3798m with some surroundings + }; + + for (const auto& test : test_locations) { + const auto [test_name, test_datasets, geographic_bounds, limits] = test; + + for (std::string dataset_name : test_datasets) { + const auto dataset = Error::throwing_unwrap(gis::Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); + for (const auto& [test_projection, test_srs] : test_projections) { + OGRSpatialReference srs; + srs.importFromEPSG(test_srs); + srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + + const auto srs_corners = gis::srs::transform_points(geographic_srs, srs, std::array { geographic_bounds.min, geographic_bounds.max }).value(); + const radix::tile::SrsBounds srs_bounds { srs_corners[0], srs_corners[1] }; + + require_projection_available(*dataset, srs); + auto reader = make_reader(dataset, test_projection, { 1 }, 0.0F); + REQUIRE(reader.projection() == test_projection); + if (ALP_UNITTESTS_DEBUG_IMAGES) { + const auto debug_samples = read_samples(reader, srs_bounds, { 1000, 1000 }); + const auto s = std::string("/austria/").length(); + const auto l = dataset_name.length() - std::string(".tif").length() - s; + const auto debug_file = fmt::format("./heights_{}_srs{}_{}.png", test_name, test_srs, dataset_name.substr(s, l)); + REQUIRE(debug_out(debug_samples.data, debug_file).has_value()); + } + + const auto samples = read_samples(reader, srs_bounds, { 100, 111 }); + CHECK(all_valid(samples.valid)); + const auto& heights = samples.data; + REQUIRE(heights.width() == 100); + REQUIRE(heights.height() == 111); + const auto [lower_bound, lower_than, higher_than, higher_bound] = limits; + auto [min, max] = std::ranges::minmax(heights); + CHECK(min > lower_bound); + CHECK(min < lower_than); + CHECK(max < higher_bound); + CHECK(max > higher_than); + } + } + } + } + + SECTION("compare with ref render") + { + const std::array test_data = { +#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED + std::make_tuple( + "at100m", + at100m, + radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, + 620U, 350U, 42.0, 22.0), +#endif + std::make_tuple( + "pizbuin1m", + pizbuin1m, + // Both reads use the approximate transformer; exact transforms stay below 2.7. + radix::tile::SrsBounds{{10.105646780, 46.839864531}, {10.129815588, 46.847626067}}, + 740U, 315U, 3.2, 0.006), +#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED + std::make_tuple( + "pizbuin1m_highres", + pizbuin1m, + radix::tile::SrsBounds{{10.105646780, 46.839864531}, {10.129815588, 46.847626067}}, + 2000U, 850U, 10.0, 0.008), +#endif + }; + + for (const auto& test : test_data) { + auto [test_name, datasets, ref_bounds, render_width, render_height, max_abs_diff, max_mse] = test; + + const auto ref_dataset = Error::throwing_unwrap(gis::Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(datasets.front()))); + require_projection_available(*ref_dataset, geographic_srs); + auto ref_reader = make_reader(ref_dataset, gis::srs::Projection::Geographic, { 1 }, 0.0F); + const auto ref_samples = read_samples(ref_reader, ref_bounds, { render_width, render_height }); + CHECK(all_valid(ref_samples.valid)); + const auto& ref_heights = ref_samples.data; + if (ALP_UNITTESTS_DEBUG_IMAGES) { + REQUIRE(debug_out(ref_heights, fmt::format("./heights_ref.png")).has_value()); + } + + for (std::string dataset_name : datasets) { + const auto dataset = Error::throwing_unwrap(gis::Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); + require_projection_available(*dataset, geographic_srs); + auto reader = make_reader(dataset, gis::srs::Projection::Geographic, { 1 }, 0.0F); + const auto samples = read_samples(reader, ref_bounds, { render_width, render_height }); + CHECK(all_valid(samples.valid)); + const auto& heights = samples.data; + + const auto s = std::string("/austria/").length(); + const auto l = dataset_name.length() - std::string(".tif").length() - s; + + if (ALP_UNITTESTS_DEBUG_IMAGES) { + REQUIRE(debug_out(ref_heights, fmt::format("./heights_{}_{}.png", test_name, dataset_name.substr(s, l))).has_value()); + + 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)); + REQUIRE(debug_out(height_diffs, path).has_value()); + } + auto largest_abs_diff = 0.0; + const auto mse = std::transform_reduce(ref_heights.begin(), ref_heights.end(), heights.begin(), 0.0, std::plus<>(), [&largest_abs_diff](auto a, auto b) { + const auto t = std::abs(double(a) - double(b)); + largest_abs_diff = std::max(t, largest_abs_diff); + return t * t; + }) / 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); + } + } + } +} + +TEST_CASE("min pixel size") +{ + using Projection = gis::srs::Projection; + const auto open = [](const char* name) { return Error::throwing_unwrap(gis::Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(name))); }; + const auto reader = [](const std::shared_ptr& dataset, Projection projection) { return make_reader(dataset, projection, { 1 }, 0.0F); }; + const auto pixel_size = [](const std::shared_ptr& dataset) { + std::array geo_transform {}; + REQUIRE(dataset->gdalDataset()->GetGeoTransform(geo_transform.data()) == CE_None); + return glm::dvec2(geo_transform[1], -geo_transform[5]); + }; + const double metres_per_degree = 6378137.0 * std::numbers::pi / 180; + // Within all Austrian test datasets. + const radix::tile::SrsBounds geographic_bounds { { 12.0, 47.0 }, { 14.0, 48.0 } }; + const auto mercator_corners = gis::srs::transform_points(gis::srs::wgs84(), gis::srs::webmercator(), std::array { geographic_bounds.min, geographic_bounds.max }).value(); + const radix::tile::SrsBounds mercator_bounds { mercator_corners[0], mercator_corners[1] }; + + SECTION("same srs as the dataset") + { + const auto mercator = open("/austria/at_100m_epsg3857.tif"); + const auto mercator_size = reader(mercator, Projection::WebMercator).min_pixel_size(mercator_bounds); + REQUIRE(mercator_size); + CHECK(mercator_size->x == Catch::Approx(pixel_size(mercator).x)); + CHECK(mercator_size->y == Catch::Approx(pixel_size(mercator).y)); + + const auto anisotropic = open("/austria/at_x149m_y100m_epsg4326.tif"); + const auto geographic_size = reader(anisotropic, Projection::Geographic).min_pixel_size(geographic_bounds); + REQUIRE(geographic_size); + CHECK(geographic_size->x == Catch::Approx(pixel_size(anisotropic).x)); + CHECK(geographic_size->y == Catch::Approx(pixel_size(anisotropic).y)); + } + + SECTION("geographic dataset in web mercator") + { + // Web Mercator stretches y by 1 / cos(latitude), the minimum is at the southern edge. + const auto dataset = open("/austria/at_100m_epsg4326.tif"); + const auto size = reader(dataset, Projection::WebMercator).min_pixel_size(mercator_bounds); + REQUIRE(size); + const double south = geographic_bounds.min.y * std::numbers::pi / 180; + CHECK(size->x == Catch::Approx(pixel_size(dataset).x * metres_per_degree).epsilon(1e-3)); + CHECK(size->y == Catch::Approx(pixel_size(dataset).y * metres_per_degree / std::cos(south)).epsilon(1e-3)); + } + + SECTION("bounds larger than the dataset are clipped to its coverage") + { + const auto dataset = open("/austria/at_100m_epsg4326.tif"); + const radix::tile::SrsBounds world { glm::dvec2(-gis::webmercator::half_extent), glm::dvec2(gis::webmercator::half_extent) }; + const auto size = reader(dataset, Projection::WebMercator).min_pixel_size(world); + REQUIRE(size); + const double south = dataset->bounds().value().min.y * std::numbers::pi / 180; + CHECK(size->y == Catch::Approx(pixel_size(dataset).y * metres_per_degree / std::cos(south)).epsilon(1e-3)); + } + + SECTION("projected dataset in web mercator") + { + // About 100 m on the ground, i.e. 1 / cos(47°) * 100 m in Web Mercator, reduced slightly by the grid rotation. + const auto dataset = open("/austria/at_100m_mgi.tif"); + const auto size = reader(dataset, Projection::WebMercator).min_pixel_size(mercator_bounds); + REQUIRE(size); + CHECK(size->x > 130); + CHECK(size->x < 155); + CHECK(size->y > 130); + CHECK(size->y < 155); + } + + SECTION("bounds outside the coverage") + { + const auto dataset = open("/austria/at_100m_epsg4326.tif"); + CHECK(!reader(dataset, Projection::Geographic).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); + } +} + +TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") +{ + gis::gdal::initialize_once(); + // 0.5° pixels from -0.5 to 360.5, each storing its centre longitude. GDAL only wraps + // sources overlapping themselves by at most about one pixel. + constexpr int width = 722; + constexpr int height = 240; + auto* driver = GetGDALDriverManager()->GetDriverByName("MEM"); + REQUIRE(driver != nullptr); + const auto dataset = std::make_shared(driver->Create("", width, height, 1, GDT_Float32, nullptr)); + std::array geo_transform { -0.5, 0.5, 0, 60, 0, -0.5 }; + REQUIRE(dataset->gdalDataset()->SetGeoTransform(geo_transform.data()) == CE_None); + const auto reference = gis::srs::wgs84(); + REQUIRE(dataset->gdalDataset()->SetSpatialRef(&reference) == CE_None); + std::vector longitudes(std::size_t(width) * height); + for (std::size_t i = 0; i < longitudes.size(); ++i) { + longitudes[i] = float(-0.25 + 0.5 * double(i % width)); + } + REQUIRE(dataset->gdalDataset()->GetRasterBand(1)->RasterIO(GF_Write, 0, 0, width, height, longitudes.data(), width, height, GDT_Float32, 0, 0) == CE_None); + + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const double degree = gis::webmercator::half_extent / 180; + for (const double longitude : { -90.0, 90.0 }) { + const auto samples = read_samples(reader, { { (longitude - 1) * degree, -degree }, { (longitude + 1) * degree, degree } }, { 4, 4 }); + CHECK(all_valid(samples.valid)); + for (const float value : samples.data.buffer()) { + CHECK(value == Catch::Approx(longitude < 0 ? longitude + 360 : longitude).margin(1.5)); + } + } +} + +TEST_CASE("dataset reader reads the selected bands in order", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + const auto geo_transform = geo_transform_for(bounds, glm::uvec2(32)); + SECTION("scalar") + { + auto reader = make_reader(make_raster({}, glm::uvec2(32), 3, GDT_Float32, geo_transform), gis::srs::Projection::WebMercator, { 2 }, 0.0F); + for (const auto size : { glm::uvec2(32), glm::uvec2(24, 40) }) { + const auto samples = read_samples(reader, bounds, size); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](float value) { return value == Catch::Approx(20); })); + } + } + SECTION("RGB") + { + auto reader = make_reader(make_raster({}, glm::uvec2(32), 4, GDT_Byte, geo_transform), gis::srs::Projection::WebMercator, { 3, 1, 4 }, glm::u8vec3(0)); + for (const auto size : { glm::uvec2(32), glm::uvec2(24, 40) }) { + const auto samples = read_samples(reader, bounds, size); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](glm::u8vec3 value) { return value == glm::u8vec3(30, 10, 40); })); + } + } +} + +TEST_CASE("dataset reader rejects unsupported sources and invalid requests", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + const auto geo_transform = geo_transform_for(bounds, glm::uvec2(16)); + const auto raster = [&](GDALDataType type) { return make_raster({}, glm::uvec2(16), 3, type, geo_transform); }; + const auto require_code = [](const auto& result, Error::Code code) { + REQUIRE_FALSE(result); + CHECK(result.error().code() == code); + }; + using Projection = gis::srs::Projection; + + // No source type conversion. + for (const auto type : { GDT_Byte, GDT_Int16, GDT_Float64 }) { + require_code(gis::DatasetReader::make(raster(type), Projection::WebMercator, { 1 }, 0.0F), Error::Code::Unsupported); + } + for (const auto type : { GDT_Float32, GDT_UInt16 }) { + require_code(gis::DatasetReader::make(raster(type), Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)), Error::Code::Unsupported); + } + for (const unsigned band : { 0u, 4u }) { + require_code(gis::DatasetReader::make(raster(GDT_Float32), Projection::WebMercator, { band }, 0.0F), Error::Code::InvalidInput); + require_code(gis::DatasetReader::make(raster(GDT_Byte), Projection::WebMercator, { 1, band, 3 }, glm::u8vec3(0)), Error::Code::InvalidInput); + } + for (const float default_pixel : { std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity() }) { + require_code(gis::DatasetReader::make(raster(GDT_Float32), Projection::WebMercator, { 1 }, default_pixel), Error::Code::InvalidInput); + } + auto unreferenced = raster(GDT_Float32); + REQUIRE(unreferenced->gdalDataset()->SetSpatialRef(nullptr) == CE_None); + require_code(gis::DatasetReader::make(unreferenced, Projection::WebMercator, { 1 }, 0.0F), Error::Code::InvalidInput); + + // Sizes and bounds are validated before allocation and narrowing to GDAL's int. + auto reader = make_reader(raster(GDT_Float32), Projection::WebMercator, { 1 }, 0.0F); + const unsigned beyond_int = unsigned(INT_MAX) + 1; + const double nan = std::numeric_limits::quiet_NaN(); + const double infinity = std::numeric_limits::infinity(); + const std::array, 10> requests { { + { bounds, { 0, 16 }, Error::Code::InvalidInput }, + { bounds, { 16, 0 }, Error::Code::InvalidInput }, + { bounds, { beyond_int, 1 }, Error::Code::InvalidInput }, + { bounds, { 1, beyond_int }, Error::Code::InvalidInput }, + { bounds, { INT_MAX, INT_MAX }, Error::Code::ResourceExhausted }, + { { bounds.min, bounds.min }, { 16, 16 }, Error::Code::InvalidInput }, + { { bounds.max, bounds.min }, { 16, 16 }, Error::Code::InvalidInput }, + { { { nan, 0 }, { 1, 1 } }, { 16, 16 }, Error::Code::InvalidInput }, + { { { -infinity, 0 }, { 1, 1 } }, { 16, 16 }, Error::Code::InvalidInput }, + { { { -DBL_MAX, 0 }, { DBL_MAX, 1 } }, { 16, 16 }, Error::Code::InvalidInput }, + } }; + for (const auto& [request_bounds, size, code] : requests) { + require_code(reader.read(request_bounds, size), code); + } + auto rgb = make_reader(raster(GDT_Byte), Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)); + require_code(rgb.read(bounds, { INT_MAX, INT_MAX }), Error::Code::ResourceExhausted); +} + +TEST_CASE("dataset reader returns GDAL read failures", "[dataset_reader]") +{ + gis::gdal::initialize_once(); + auto reader = make_reader(std::make_shared(new FailingDataset()), gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = reader.read({ { 0, 0 }, { 16, 16 } }, { 8, 8 }); + REQUIRE_FALSE(samples); + CHECK(samples.error().code() == Error::Code::Io); +} + +TEST_CASE("dataset reader combines the effective source masks", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + const glm::uvec2 size(32); + const glm::u8vec3 fallback(1, 2, 3); + auto dataset = make_raster({}, size, 4, GDT_Byte, geo_transform_for(bounds, size)); + auto* gdal_dataset = dataset->gdalDataset(); + using Region = std::function; + const Region left = [](unsigned x, unsigned) { return x < 16; }; + const Region top = [](unsigned, unsigned y) { return y < 16; }; + const auto values = [&](const Region& valid) { return generate(size, [&](unsigned x, unsigned y) { return valid(x, y) ? 255.f : 0.f; }); }; + const auto mask = [&](int band, int flags, const Region& valid) { + REQUIRE(gdal_dataset->GetRasterBand(band)->CreateMaskBand(flags) == CE_None); + write(gdal_dataset->GetRasterBand(band)->GetMaskBand(), values(valid)); + }; + Region expected = [](unsigned, unsigned) { return true; }; + SECTION("all valid") { } + SECTION("band NoData") + { + REQUIRE(gdal_dataset->GetRasterBand(2)->SetNoDataValue(20) == CE_None); + expected = [](unsigned, unsigned) { return false; }; + } + SECTION("explicit mask of one band") + { + mask(2, 0, left); + expected = left; + } + SECTION("shared per-dataset mask") + { + mask(1, GMF_PER_DATASET, top); + REQUIRE(gdal_dataset->GetRasterBand(3)->GetMaskBand() == gdal_dataset->GetRasterBand(1)->GetMaskBand()); + expected = top; + } + SECTION("alpha band") + { + REQUIRE(gdal_dataset->GetRasterBand(4)->SetColorInterpretation(GCI_AlphaBand) == CE_None); + write(gdal_dataset->GetRasterBand(4), values(top)); + REQUIRE((gdal_dataset->GetRasterBand(1)->GetMaskFlags() & GMF_ALPHA) != 0); + expected = top; + } + SECTION("different masks per band") + { + mask(1, 0, left); + mask(3, 0, top); + expected = [&](unsigned x, unsigned y) { return left(x, y) && top(x, y); }; + } + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1, 2, 3 }, fallback); + const auto samples = read_samples(reader, bounds, size); + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + INFO(fmt::format("pixel {}, {}", x, y)); + CHECK((samples.valid.pixel({ x, y }) != 0) == expected(x, y)); + CHECK(samples.data.pixel({ x, y }) == (expected(x, y) ? glm::u8vec3(10, 20, 30) : fallback)); + } + } +} + +TEST_CASE("dataset reader prefers an explicit mask over a conflicting NoData value", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + auto dataset = make_raster({}, glm::uvec2(32), 1, GDT_Float32, geo_transform_for(bounds, glm::uvec2(32))); + auto* band = dataset->gdalDataset()->GetRasterBand(1); + REQUIRE(band->SetNoDataValue(10) == CE_None); + REQUIRE(band->CreateMaskBand(0) == CE_None); + REQUIRE(band->GetMaskBand()->Fill(255) == CE_None); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, bounds, glm::uvec2(24)); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](float value) { return value == Catch::Approx(10); })); +} + +TEST_CASE("dataset reader keeps valid pixels equal to sentinels or the default", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + const auto geo_transform = geo_transform_for(bounds, glm::uvec2(32)); + SECTION("black RGB with a black default") + { + auto dataset = make_raster({}, glm::uvec2(32), 3, GDT_Byte, geo_transform); + for (int band = 1; band <= 3; ++band) { + REQUIRE(dataset->gdalDataset()->GetRasterBand(band)->Fill(0) == CE_None); + } + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)); + const auto samples = read_samples(reader, bounds, glm::uvec2(32)); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](glm::u8vec3 value) { return value == glm::u8vec3(0); })); + } + SECTION("scalar equal to the default") + { + auto reader = make_reader(make_raster({}, glm::uvec2(32), 1, GDT_Float32, geo_transform), gis::srs::Projection::WebMercator, { 1 }, 10.0F); + const auto samples = read_samples(reader, bounds, glm::uvec2(32)); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](float value) { return value == 10; })); + } + SECTION("scalar -32768 without NoData") + { + auto dataset = make_raster({}, glm::uvec2(32), 1, GDT_Float32, geo_transform); + REQUIRE(dataset->gdalDataset()->GetRasterBand(1)->Fill(-32768) == CE_None); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, bounds, glm::uvec2(32)); + CHECK(all_valid(samples.valid)); + CHECK(std::ranges::all_of(samples.data, [](float value) { return value == -32768; })); + } +} + +namespace { +template +void check_coverage(GDALDataType type, std::array::channel_count> bands, Pixel inside, Pixel fallback) +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + const glm::uvec2 size(32); + // The source covers the western half of the output. + auto dataset = make_raster({}, { 16, 32 }, unsigned(bands.size()), type, geo_transform_for(bounds, size)); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, bands, fallback); + const auto partial = read_samples(reader, bounds, size); + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + INFO(fmt::format("pixel {}, {}", x, y)); + CHECK((partial.valid.pixel({ x, y }) != 0) == (x < 16)); + CHECK(partial.data.pixel({ x, y }) == (x < 16 ? inside : fallback)); + } + } + const auto absent = read_samples(reader, gis::webmercator::tile_bounds({ 5, { 2, 2 } }), size); + CHECK(std::ranges::none_of(absent.valid, [](std::uint8_t value) { return value != 0; })); + CHECK(std::ranges::all_of(absent.data, [&](Pixel value) { return value == fallback; })); +} +} // namespace + +TEST_CASE("dataset reader returns defaults outside the source coverage", "[dataset_reader]") +{ + SECTION("scalar") { check_coverage(GDT_Float32, { 1 }, 10.0F, 7.5F); } + SECTION("RGB") { check_coverage(GDT_Byte, { 1, 2, 3 }, glm::u8vec3(10, 20, 30), glm::u8vec3(1, 2, 3)); } +} + +TEST_CASE("dataset reader replaces nonfinite float results with the default", "[dataset_reader]") +{ + const float nonfinite = GENERATE(std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()); + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + auto dataset = make_raster({}, glm::uvec2(32), 1, GDT_Float32, geo_transform_for(bounds, glm::uvec2(32))); + auto* band = dataset->gdalDataset()->GetRasterBand(1); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 7.5F); + SECTION("everywhere") + { + REQUIRE(band->Fill(nonfinite) == CE_None); + for (const auto size : { glm::uvec2(32), glm::uvec2(24) }) { + const auto samples = read_samples(reader, bounds, size); + CHECK(std::ranges::none_of(samples.valid, [](std::uint8_t value) { return value != 0; })); + CHECK(std::ranges::all_of(samples.data, [](float value) { return value == 7.5F; })); + } + } + SECTION("one unmasked sample contaminates its filter support") + { + write_pixel(band, { 16, 16 }, nonfinite); + const auto samples = read_samples(reader, bounds, glm::uvec2(24)); + for (std::size_t i = 0; i < samples.data.buffer().size(); ++i) { + if (samples.valid.buffer()[i]) { + CHECK(samples.data.buffer()[i] == Catch::Approx(10)); + } else { + CHECK(samples.data.buffer()[i] == 7.5F); + } + } + // Centred on the nonfinite sample, and far from it. + CHECK(samples.valid.pixel({ 12, 12 }) == 0); + CHECK(samples.valid.pixel({ 0, 0 }) != 0); + } +} + +TEST_CASE("dataset reader excludes a sample invalid in one channel from all channels", "[dataset_reader]") +{ + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + auto dataset = make_raster({}, glm::uvec2(32), 3, GDT_Byte, geo_transform_for(bounds, glm::uvec2(32))); + auto* gdal_dataset = dataset->gdalDataset(); + for (int band = 1; band <= 3; ++band) { + REQUIRE(gdal_dataset->GetRasterBand(band)->Fill(100) == CE_None); + } + // A red outlier, invalid only through the green NoData. + write_pixel(gdal_dataset->GetRasterBand(1), { 16, 16 }, 250); + write_pixel(gdal_dataset->GetRasterBand(2), { 16, 16 }, 0); + REQUIRE(gdal_dataset->GetRasterBand(2)->SetNoDataValue(0) == CE_None); + const glm::u8vec3 fallback(1, 2, 3); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1, 2, 3 }, fallback); + // GDAL does not write outputs centred on an invalid sample. The Lanczos + // supports of the others contain valid samples. Warping each channel with + // its own mask, red would include the outlier around it. + const auto samples = read_samples(reader, bounds, glm::uvec2(24)); + for (unsigned y = 0; y < 24; ++y) { + for (unsigned x = 0; x < 24; ++x) { + INFO(fmt::format("pixel {}, {}", x, y)); + const bool on_outlier = x == 12 && y == 12; + CHECK((samples.valid.pixel({ x, y }) != 0) == !on_outlier); + CHECK(samples.data.pixel({ x, y }) == (on_outlier ? fallback : glm::u8vec3(100))); + } + } +} + +TEST_CASE("dataset reader reprojects within the approximation tolerance", "[dataset_reader]") +{ + // 0.1° pixels storing the longitude and latitude of their centres. + constexpr unsigned side = 200; + auto dataset = make_raster({}, glm::uvec2(side), 2, GDT_Float32, { 0, 0.1, 0, 20, 0, -0.1 }, 4326); + write(dataset->gdalDataset()->GetRasterBand(1), generate(glm::uvec2(side), [](unsigned x, unsigned) { return float((x + 0.5) * 0.1); })); + write(dataset->gdalDataset()->GetRasterBand(2), generate(glm::uvec2(side), [](unsigned, unsigned y) { return float(20 - (y + 0.5) * 0.1); })); + const auto corners = gis::srs::transform_points(gis::srs::wgs84(), gis::srs::webmercator(), std::array { glm::dvec2(5, 5), glm::dvec2(15, 15) }).value(); + const radix::tile::SrsBounds bounds { corners[0], corners[1] }; + const glm::uvec2 size(64, 80); + const double radius = 6378137; + for (const unsigned band : { 1u, 2u }) { + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { band }, 0.0F); + const auto samples = read_samples(reader, bounds, size); + CHECK(all_valid(samples.valid)); + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + const double mercator_x = bounds.min.x + (x + 0.5) * bounds.width() / size.x; + const double mercator_y = bounds.max.y - (y + 0.5) * bounds.height() / size.y; + const double expected = band == 1 ? mercator_x / radius * 180 / std::numbers::pi + : (2 * std::atan(std::exp(mercator_y / radius)) - std::numbers::pi / 2) * 180 / std::numbers::pi; + // Within 0.2 source pixels. + CHECK(samples.data.pixel({ x, y }) == Catch::Approx(expected).margin(0.02)); + } + } + } +} + +TEST_CASE("dataset reader reads a VRT mosaic like a single raster at base resolution", "[dataset_reader]") +{ + test::TemporaryDirectory directory("dataset-reader"); + { + make_raster(directory.path() / "left.tif", glm::uvec2(16), 1, GDT_Float32, { 0, 1, 0, 16, 0, -1 }); + auto right = make_raster(directory.path() / "right.tif", glm::uvec2(16), 1, GDT_Float32, { 16, 1, 0, 16, 0, -1 }); + REQUIRE(right->gdalDataset()->GetRasterBand(1)->Fill(50) == CE_None); + } + const auto vrt_path = directory.path() / "mosaic.vrt"; + { + std::ofstream vrt(vrt_path); + vrt << fmt::format(R"( +EPSG:38570,1,0,16,0,-1 + +{}1 +{}1 +)", + (directory.path() / "left.tif").string(), + (directory.path() / "right.tif").string()); + REQUIRE(vrt.good()); + } + auto mosaic = make_reader(Error::throwing_unwrap(gis::Dataset::open_shared_raster(vrt_path)), gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const radix::tile::SrsBounds window { { 8.25, 0.25 }, { 24.25, 16.25 } }; + const auto combined = read_samples(mosaic, window, glm::uvec2(16)); + auto reference = make_raster({}, glm::uvec2(32), 1, GDT_Float32, { 0, 1, 0, 16, 0, -1 }); + write(reference->gdalDataset()->GetRasterBand(1), generate(glm::uvec2(32), [](unsigned x, unsigned) { return x < 16 ? 10.f : 50.f; })); + auto single = make_reader(reference, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto expected = read_samples(single, window, glm::uvec2(16)); + // Compare the source assembly, not GDAL's filter formula. + for (unsigned x = 0; x < 16; ++x) { + CHECK(combined.valid.pixel({ x, 8 }) != 0); + CHECK(combined.data.pixel({ x, 8 }) == Catch::Approx(expected.data.pixel({ x, 8 }))); + } + const int level = 2; + REQUIRE(reference->gdalDataset()->BuildOverviews("NEAREST", 1, &level, 0, nullptr, nullptr, nullptr) == CE_None); + REQUIRE(reference->gdalDataset()->GetRasterBand(1)->GetOverview(0)->Fill(222) == CE_None); + const auto base = read_samples(single, { { 0, -16 }, { 32, 16 } }, glm::uvec2(16)); + CHECK(base.valid.pixel({ 4, 8 }) != 0); + CHECK(base.data.pixel({ 4, 8 }) != 222); +} + +TEST_CASE("dataset reader agrees across adjacent output windows", "[dataset_reader]") +{ + // 0.25° pixels storing the sum of their column and row, away from the antimeridian. + auto dataset = make_raster({}, glm::uvec2(64), 1, GDT_Float32, { 0, 0.25, 0, 8, 0, -0.25 }, 4326); + write(dataset->gdalDataset()->GetRasterBand(1), generate(glm::uvec2(64), [](unsigned x, unsigned y) { return float(x + y); })); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const double width = 2 * gis::webmercator::half_extent / 180; + const glm::dvec2 centre(4 * width, 0); + const auto west = read_samples(reader, { centre - glm::dvec2(width, width / 2), centre + glm::dvec2(0, width / 2) }, glm::uvec2(16)); + const auto east = read_samples(reader, { centre - glm::dvec2(0, width / 2), centre + glm::dvec2(width, width / 2) }, glm::uvec2(16)); + const auto joined = read_samples(reader, { centre - glm::dvec2(width), centre + glm::dvec2(width) }, glm::uvec2(32)); + CHECK(all_valid(west.valid)); + CHECK(all_valid(east.valid)); + CHECK(all_valid(joined.valid)); + // Each window may deviate by the approximation error of 0.125 source pixels per axis. + for (unsigned row = 0; row < 16; ++row) { + for (unsigned column = 0; column < 16; ++column) { + CHECK(west.data.pixel({ column, row }) == Catch::Approx(joined.data.pixel({ column, row + 8 })).margin(0.5)); + CHECK(east.data.pixel({ column, row }) == Catch::Approx(joined.data.pixel({ column + 16, row + 8 })).margin(0.5)); + } + } +} + +TEST_CASE("dataset reader ignores the GDAL threading environment", "[dataset_reader]") +{ + struct Configuration { + const char* name; + std::optional previous = std::nullopt; + Configuration(const char* option, const char* value) + : name(option) + { + if (const auto* old = CPLGetConfigOption(name, nullptr)) { + previous = old; + } + CPLSetConfigOption(name, value); + } + ~Configuration() { CPLSetConfigOption(name, previous ? previous->c_str() : nullptr); } + }; + const Configuration threads("GDAL_NUM_THREADS", "2"); + const Configuration chunks("WARP_THREAD_CHUNK_SIZE", "1"); + const auto bounds = gis::webmercator::tile_bounds({ 5, { 16, 15 } }); + auto reader + = make_reader(make_raster({}, glm::uvec2(32), 1, GDT_Float32, geo_transform_for(bounds, glm::uvec2(32))), gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, bounds, glm::uvec2(24)); + CHECK(CPLGetLastErrorType() < CE_Failure); + CHECK(all_valid(samples.valid)); +} + +TEST_CASE("dataset reader finds a coarse projected source in a world tile", "[dataset_reader]") +{ + // Many probes of the world tile fail to transform into UTM. + auto dataset = make_raster({}, glm::uvec2(4), 1, GDT_Float32, { -3500000, 2000000, 0, 4000000, 0, -2000000 }, 32632); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, gis::webmercator::tile_bounds({ 0, { 0, 0 } }), glm::uvec2(16)); + // Around 11° east and north, inside the footprint. + CHECK(samples.valid.pixel({ 8, 7 }) != 0); + CHECK(samples.data.pixel({ 8, 7 }) == Catch::Approx(10)); +} + +TEST_CASE("dataset reader finds a source smaller than GDAL's probe spacing", "[dataset_reader]") +{ + auto reader = make_reader(make_raster({}, glm::uvec2(32), 1, GDT_Float32, { 100, 1, 0, 200, 0, -1 }), gis::srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, { { 0, 0 }, { 4096, 4096 } }, glm::uvec2(4096)); + CHECK(samples.valid.pixel({ 116, 4096 - 184 }) != 0); + CHECK(samples.data.pixel({ 116, 4096 - 184 }) == Catch::Approx(10)); +} diff --git a/unittests/terrainlib/gis_srs_bounds.cpp b/unittests/terrainlib/gis_srs_bounds.cpp new file mode 100644 index 00000000..ec94b6b6 --- /dev/null +++ b/unittests/terrainlib/gis_srs_bounds.cpp @@ -0,0 +1,140 @@ +// Copyright (C) 2026 Adam Celarek-Litofcenko +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "gis/srs.h" +#include "gis/srs/detail.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { +struct BoundsCase { + std::string name; + std::string source; + std::string target; + std::array bounds; + OSRAxisMappingStrategy axis_mapping = OAMS_TRADITIONAL_GIS_ORDER; +}; + +void compare_bounds(const BoundsCase& scenario, int samples) +{ + CAPTURE(scenario.name, samples); + auto source = gis::srs::from_user_input(scenario.source).value(); + auto target = gis::srs::from_user_input(scenario.target).value(); + source.SetAxisMappingStrategy(scenario.axis_mapping); + target.SetAxisMappingStrategy(scenario.axis_mapping); + std::unique_ptr reference(OGRCreateCoordinateTransformation(&source, &target)); + std::unique_ptr extracted(OGRCreateCoordinateTransformation(&source, &target)); + REQUIRE(reference); + REQUIRE(extracted); + CPLErrorHandlerPusher quiet(CPLQuietErrorHandler); + const auto& b = scenario.bounds; + std::array expected {}, actual {}; + const int expected_success = reference->TransformBounds(b[0], b[1], b[2], b[3], &expected[0], &expected[1], &expected[2], &expected[3], samples); + const auto expected_error = CPLGetLastErrorType(); + const int actual_success + = gis::srs::detail::ogr_transform_bounds(extracted.get(), b[0], b[1], b[2], b[3], &actual[0], &actual[1], &actual[2], &actual[3], samples); + CHECK(actual_success == expected_success); + CHECK(CPLGetLastErrorType() == expected_error); + for (size_t i = 0; i < actual.size(); ++i) { + CAPTURE(i, expected[i], actual[i]); + if (std::isnan(expected[i])) { + CHECK(std::isnan(actual[i])); + } else if (std::isinf(expected[i])) { + CHECK(actual[i] == expected[i]); + } else { + CHECK(actual[i] == Catch::Approx(expected[i]).epsilon(1e-10).margin(1e-7)); + } + } +} +} // namespace + +TEST_CASE("Cached projected bounds agree with GDAL", "[srs][bounds]") +{ + const std::array cases { + BoundsCase { "Austria Lambert", "EPSG:4326", "EPSG:31287", { 12.6, 47.0, 12.61, 47.01 } }, + BoundsCase { "Austria extent", "EPSG:4326", "EPSG:31287", { 9.5, 46.3, 17.2, 49.1 } }, + BoundsCase { "UTM", "EPSG:4326", "EPSG:32633", { 14, 46, 17, 49 } }, + BoundsCase { "Mercator", "EPSG:4326", "EPSG:3857", { -170, -80, 170, 80 } }, + BoundsCase { "Mercator polar limits", "EPSG:4326", "EPSG:3857", { -180, -90, 180, 90 } }, + BoundsCase { "north pole", "EPSG:4326", "EPSG:3413", { -180, 80, 180, 90 } }, + BoundsCase { "south pole", "EPSG:4326", "EPSG:3031", { -180, -90, 180, -80 } }, + BoundsCase { "pole and seam", "EPSG:4326", "ESRI:53037", { -180, 70, 180, 90 } }, + BoundsCase { "shifted seam", "EPSG:4326", "+proj=sinu +lon_0=10 +datum=WGS84 +units=m +type=crs", { -175, -80, -165, 80 } }, + BoundsCase { "negative meridian", "EPSG:4326", "+proj=sinu +lon_0=-10 +datum=WGS84 +units=m +type=crs", { 165, -80, 175, 80 } }, + BoundsCase { "crossing antimeridian", "EPSG:4326", "EPSG:3857", { 170, -10, -170, 10 } }, + BoundsCase { "unwrapped antimeridian", "EPSG:4326", "EPSG:3857", { 170, -10, 190, 10 } }, + BoundsCase { "point", "EPSG:4326", "EPSG:31287", { 12.6, 47, 12.6, 47 } }, + BoundsCase { "line", "EPSG:4326", "EPSG:31287", { 12.6, 47, 12.6, 48 } }, + BoundsCase { "projected source", "EPSG:3857", "EPSG:31287", { 1400000, 5900000, 1500000, 6000000 } }, + BoundsCase { "different geographic datum", "EPSG:4312", "EPSG:32633", { 14, 46, 17, 49 } }, + BoundsCase { "identity", "EPSG:31287", "EPSG:31287", { 300000, 300000, 400000, 400000 } }, + BoundsCase { "geographic output", "EPSG:3857", "EPSG:4326", { 1400000, 5900000, 1500000, 6000000 } }, + BoundsCase { "geographic pole", "EPSG:3413", "EPSG:4326", { -100000, -100000, 100000, 100000 } }, + BoundsCase { "authority axes", "EPSG:4326", "EPSG:31287", { 47, 12.6, 47.01, 12.61 }, OAMS_AUTHORITY_COMPLIANT }, + BoundsCase { "grads", "EPSG:4807", "EPSG:3857", { -5, 50, 10, 60 } }, + BoundsCase { "partial domain", "EPSG:4326", "+proj=ortho +lat_0=0 +lon_0=0 +datum=WGS84 +type=crs", { -10, -20, 100, 20 } }, + BoundsCase { "outside domain", "EPSG:4326", "+proj=ortho +lat_0=0 +lon_0=0 +datum=WGS84 +type=crs", { 100, -20, 150, 20 } }, + }; + for (const auto& scenario : cases) { + for (int samples : { 0, 2, 21, 257 }) { + compare_bounds(scenario, samples); + } + } +} + +TEST_CASE("Cached bounds preserve GDAL failure handling", "[srs][bounds]") +{ + BoundsCase scenario { "invalid densification", "EPSG:4326", "EPSG:31287", { 12.6, 47, 12.61, 47.01 } }; + for (int samples : { -1, 10001 }) { + compare_bounds(scenario, samples); + } + scenario.name = "inverted latitude"; + scenario.bounds = { 12, 48, 13, 47 }; + compare_bounds(scenario, 21); + scenario.name = "nonfinite bounds"; + scenario.bounds = { 12, 47, (std::numeric_limits::infinity)(), 48 }; + compare_bounds(scenario, 21); +} + +TEST_CASE("Cached bounds include interior points at a shifted projection seam", "[srs][bounds]") +{ + const auto source = gis::srs::wgs84(); + const auto target = gis::srs::from_user_input("+proj=sinu +lon_0=10 +datum=WGS84 +units=m +type=crs").value(); + const auto transform = gis::srs::transformation(source, target).value(); + std::array bounds {}; + REQUIRE(gis::srs::detail::ogr_transform_bounds(transform.get(), -175, -80, -165, 80, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257)); + for (double longitude : { -170 - 1e-8, -170 + 1e-8 }) { + const auto point = gis::srs::transform_point(transform.get(), glm::dvec2(longitude, 0)).value(); + CHECK(point.x >= bounds[0] - 1e-7); + CHECK(point.x <= bounds[2] + 1e-7); + CHECK(point.y >= bounds[1]); + CHECK(point.y <= bounds[3]); + } +} + +TEST_CASE("Projected bounds transformation benchmark", "[srs][!benchmark][.]") +{ + const auto transform = gis::srs::transformation(gis::srs::wgs84(), gis::srs::from_epsg(31287).value()).value(); + std::array bounds {}; + REQUIRE(gis::srs::detail::ogr_transform_bounds(transform.get(), 12.6, 47, 12.61, 47.01, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257)); + BENCHMARK("GDAL TransformBounds") + { + transform->TransformBounds(12.6, 47, 12.61, 47.01, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257); + return bounds; + }; + BENCHMARK("cached projected bounds") + { + gis::srs::detail::ogr_transform_bounds(transform.get(), 12.6, 47, 12.61, 47.01, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257); + return bounds; + }; +} diff --git a/unittests/terrainlib/gis_srs_coverage.cpp b/unittests/terrainlib/gis_srs_coverage.cpp new file mode 100644 index 00000000..3e407c50 --- /dev/null +++ b/unittests/terrainlib/gis_srs_coverage.cpp @@ -0,0 +1,282 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "gis/srs.h" +#include "gis/webmercator.h" + +#include +#include + +#include +#include +#include +#include + +namespace { +using Bounds = radix::geometry::Aabb3d; +using Catch::Approx; + +// Regular samples of a box, including its interior. +std::vector box_samples(const Bounds& bounds) +{ + constexpr int horizontal = 40; + constexpr int vertical = 4; + std::vector points; + for (int z = 0; z <= vertical; ++z) { + for (int y = 0; y <= horizontal; ++y) { + for (int x = 0; x <= horizontal; ++x) { + points.push_back(glm::mix(bounds.min, bounds.max, glm::dvec3(x, y, 0) / double(horizontal) + glm::dvec3(0, 0, z / double(vertical)))); + } + } + } + return points; +} + +Bounds bounds_of(const std::vector& points) +{ + Bounds bounds; + for (const auto& point : points) { + bounds.expand_by(point); + } + return bounds; +} + +size_t count_uncovered(const std::vector& coverage, const std::vector& points) +{ + return std::ranges::count_if(points, + [&](const glm::dvec3& point) { return std::ranges::none_of(coverage, [&](const Bounds& bounds) { return bounds.contains_inclusive(point); }); }); +} + +glm::dvec3 ecef_point(double longitude, double latitude, double height) +{ + return gis::srs::transform_point(gis::srs::wgs84(), gis::srs::ecef(), glm::dvec3(longitude, latitude, height)).value(); +} + +Bounds cube(const glm::dvec3& centre, double half_size) { return { centre - half_size, centre + half_size }; } + +struct Reference { + std::string name; + OGRSpatialReference srs; +}; +} // namespace + +TEST_CASE("ECEF coverage encloses the transformed box", "[srs][coverage]") +{ + struct Case { + std::string name; + OGRSpatialReference srs; + Bounds bounds; + }; + const std::vector cases { + { "geographic_1km", gis::srs::wgs84(), { { 16.36, 48.20, 0 }, { 16.37, 48.21, 1000 } } }, + { "austria", gis::srs::wgs84(), { { 9.5, 46.3, -100 }, { 17.2, 49.1, 4000 } } }, + { "equator", gis::srs::wgs84(), { { -0.37, -0.28, 0 }, { 0.59, 0.44, 1000 } } }, + { "near_pole", gis::srs::wgs84(), { { 10, 89.0, 0 }, { 20, 89.9, 500 } } }, + { "antimeridian", gis::srs::wgs84(), { { 170, -5, 0 }, { 190, 5, 100 } } }, + { "mercator_10km", gis::srs::webmercator(), { { 1810000, 6130000, 0 }, { 1820000, 6140000, 1000 } } }, + { "austria_lambert", gis::srs::from_epsg(31287).value(), { { 400000, 400000, 0 }, { 500000, 480000, 3000 } } }, + { "utm_33n", gis::srs::from_epsg(32633).value(), { { 500000, 5300000, 0 }, { 600000, 5400000, 3000 } } }, + }; + for (const auto& scenario : cases) { + CAPTURE(scenario.name); + const auto coverage = gis::srs::ecef_coverage(scenario.srs, scenario.bounds); + REQUIRE(coverage); + const auto samples = gis::srs::transform_points(scenario.srs, gis::srs::ecef(), box_samples(scenario.bounds)).value(); + CHECK(count_uncovered({ *coverage }, samples) == 0); + // Going through a longitude/latitude box loosens projected boxes by their rotation + // against the meridians, a few percent here. + const auto sampled = bounds_of(samples); + CAPTURE(coverage->size() / sampled.size()); + CHECK(glm::all(glm::lessThanEqual(coverage->size(), sampled.size() * 1.05 + 0.01))); + } +} + +TEST_CASE("ECEF coverage of geographic boxes is exact", "[srs][coverage]") +{ + SECTION("extrema at corners") + { + const Bounds austria { { 9.5, 46.3, -100 }, { 17.2, 49.1, 4000 } }; + Bounds corners; + for (const double longitude : { austria.min.x, austria.max.x }) { + for (const double latitude : { austria.min.y, austria.max.y }) { + for (const double height : { austria.min.z, austria.max.z }) { + corners.expand_by(ecef_point(longitude, latitude, height)); + } + } + } + const auto coverage = gis::srs::ecef_coverage(gis::srs::wgs84(), austria); + REQUIRE(coverage); + for (int axis = 0; axis < 3; ++axis) { + CHECK(coverage->min[axis] == Approx(corners.min[axis]).margin(0.01)); + CHECK(coverage->max[axis] == Approx(corners.max[axis]).margin(0.01)); + } + } + SECTION("extremum inside the top face, at the equator and the prime meridian") + { + const auto coverage = gis::srs::ecef_coverage(gis::srs::wgs84(), { { -0.37, -0.28, 0 }, { 0.59, 0.44, 1000 } }); + REQUIRE(coverage); + CHECK(coverage->max.x == Approx(6378137.0 + 1000).margin(0.01)); + } + SECTION("extremum inside an edge, at the prime meridian") + { + const auto coverage = gis::srs::ecef_coverage(gis::srs::wgs84(), { { -1, 10, 0 }, { 1, 11, 100 } }); + REQUIRE(coverage); + CHECK(coverage->max.x == Approx(ecef_point(0, 10, 100).x).margin(0.01)); + } +} + +TEST_CASE("Coverage of ECEF boxes encloses the transformed box", "[srs][coverage]") +{ + const auto vienna = ecef_point(16.37, 48.21, 250); + const std::vector references { + { "wgs84", gis::srs::wgs84() }, + { "webmercator", gis::srs::webmercator() }, + { "mgi", gis::srs::mgi() }, + { "austria_lambert", gis::srs::from_epsg(31287).value() }, + { "utm_33n", gis::srs::from_epsg(32633).value() }, + }; + for (const auto& [box_name, box] : + { std::pair { "1km", cube(vienna, 500) }, std::pair { "100km", cube(vienna, 50000) }, std::pair { "1000km", cube(vienna, 500000) } }) { + for (const auto& reference : references) { + CAPTURE(box_name, reference.name); + const auto coverage = gis::srs::ecef2srs_coverage(box, reference.srs); + REQUIRE(coverage); + REQUIRE(coverage->size() == 1); + const auto samples = gis::srs::transform_points(gis::srs::ecef(), reference.srs, box_samples(box)).value(); + CHECK(count_uncovered(*coverage, samples) == 0); + // The longitude/latitude box is rotated against the projected axes by the meridian + // convergence, about 2 degrees at Vienna for UTM 33N and Austria Lambert. + const auto sampled = bounds_of(samples); + const auto looseness = (coverage->front().size() - sampled.size()) / sampled.size(); + CAPTURE(looseness); + CHECK(glm::all(glm::lessThanEqual(looseness, glm::dvec3(0.1)))); + } + } +} + +TEST_CASE("Coverage of ECEF boxes is exact in WGS84", "[srs][coverage]") +{ + SECTION("extrema at corners") + { + const auto box = cube(ecef_point(16.37, 48.21, 250), 50000); + const auto coverage = gis::srs::ecef2srs_coverage(box, gis::srs::wgs84()); + REQUIRE(coverage); + REQUIRE(coverage->size() == 1); + // The extrema are at corners of the box, which are among the samples. + const auto sampled = bounds_of(gis::srs::transform_points(gis::srs::ecef(), gis::srs::wgs84(), box_samples(box)).value()); + for (int axis = 0; axis < 3; ++axis) { + const double margin = axis < 2 ? 1e-8 : 0.01; + CHECK(coverage->front().min[axis] == Approx(sampled.min[axis]).margin(margin)); + CHECK(coverage->front().max[axis] == Approx(sampled.max[axis]).margin(margin)); + } + } + SECTION("height minimum inside a face, at the equator") + { + const auto coverage = gis::srs::ecef2srs_coverage(cube({ 6378137, 0, 0 }, 50000), gis::srs::wgs84()); + REQUIRE(coverage); + REQUIRE(coverage->size() == 1); + CHECK(coverage->front().min.z == Approx(-50000).margin(0.01)); + } +} + +TEST_CASE("Coverage of ECEF boxes stays within the bounds of the SRS", "[srs][coverage]") +{ + SECTION("antimeridian") + { + const auto box = cube({ -6378137, 0, 0 }, 50000); + for (const auto& reference : { gis::srs::wgs84(), gis::srs::webmercator() }) { + const auto coverage = gis::srs::ecef2srs_coverage(box, reference); + REQUIRE(coverage); + REQUIRE(coverage->size() == 2); + const double half_width = reference.IsGeographic() ? 180 : gis::webmercator::half_extent; + for (const auto& bounds : *coverage) { + CHECK(bounds.min.x >= -half_width); + CHECK(bounds.max.x <= half_width); + } + const auto samples = gis::srs::transform_points(gis::srs::ecef(), reference, box_samples(box)).value(); + CHECK(count_uncovered(*coverage, samples) == 0); + } + } + SECTION("north pole") + { + const auto box = cube({ 0, 0, 6356752.3 }, 50000); + const auto geographic = gis::srs::ecef2srs_coverage(box, gis::srs::wgs84()); + REQUIRE(geographic); + REQUIRE(geographic->size() == 1); + CHECK(geographic->front().min.x == -180); + CHECK(geographic->front().max.x == 180); + CHECK(geographic->front().max.y == 90); + CHECK(count_uncovered(*geographic, gis::srs::transform_points(gis::srs::ecef(), gis::srs::wgs84(), box_samples(box)).value()) == 0); + const auto mercator = gis::srs::ecef2srs_coverage(box, gis::srs::webmercator()); + REQUIRE(mercator); + CHECK(mercator->empty()); + } + SECTION("centre of the earth") + { + const auto coverage = gis::srs::ecef2srs_coverage(cube(glm::dvec3(0), 1000), gis::srs::wgs84()); + REQUIRE_FALSE(coverage); + CHECK(coverage.error().code() == Error::Code::Unsupported); + } +} + +TEST_CASE("Coverage allows for height-dependent datum shifts", "[srs][coverage]") +{ + // MGI shifts by about 0.3 m between heights 0 and 20 km at Vienna. Degenerate boxes in + // Austria Lambert get a tiny guard band, which would hide less than that. + const auto lambert = gis::srs::from_epsg(31287).value(); + for (const double height : { -20000.0, 20000.0 }) { + CAPTURE(height); + const auto ecef = ecef_point(16.37, 48.21, height); + const auto projected = gis::srs::transform_point(gis::srs::ecef(), lambert, ecef).value(); + const auto backward = gis::srs::ecef2srs_coverage({ ecef, ecef }, lambert); + REQUIRE(backward); + CHECK(count_uncovered(*backward, { projected }) == 0); + const auto forward = gis::srs::ecef_coverage(lambert, { projected, projected }); + REQUIRE(forward); + CHECK(forward->contains_inclusive(gis::srs::transform_point(lambert, gis::srs::ecef(), projected).value())); + } +} + +TEST_CASE("Coverage functions handle ECEF, compound SRSes and invalid bounds", "[srs][coverage]") +{ + const Bounds box = cube(ecef_point(16.37, 48.21, 250), 500); + CHECK(gis::srs::ecef_coverage(gis::srs::ecef(), box).value() == box); + const auto unchanged = gis::srs::ecef2srs_coverage(box, gis::srs::ecef()).value(); + REQUIRE(unchanged.size() == 1); + CHECK(unchanged.front() == box); + + const auto compound = gis::srs::from_user_input("EPSG:4326+3855").value(); + REQUIRE(compound.IsCompound()); + const auto forward = gis::srs::ecef_coverage(compound, { { 16.36, 48.20, 0 }, { 16.37, 48.21, 1000 } }); + REQUIRE_FALSE(forward); + CHECK(forward.error().code() == Error::Code::Unsupported); + const auto backward = gis::srs::ecef2srs_coverage(box, compound); + REQUIRE_FALSE(backward); + CHECK(backward.error().code() == Error::Code::Unsupported); + + for (const double invalid : { -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity() }) { + Bounds bad = box; + bad.max.x = invalid; + const auto forward_result = gis::srs::ecef_coverage(gis::srs::wgs84(), bad); + REQUIRE_FALSE(forward_result); + CHECK(forward_result.error().code() == Error::Code::InvalidInput); + const auto backward_result = gis::srs::ecef2srs_coverage(bad, gis::srs::wgs84()); + REQUIRE_FALSE(backward_result); + CHECK(backward_result.error().code() == Error::Code::InvalidInput); + } +} diff --git a/unittests/terrainlib/gis_vector_mask.cpp b/unittests/terrainlib/gis_vector_mask.cpp new file mode 100644 index 00000000..dba82229 --- /dev/null +++ b/unittests/terrainlib/gis_vector_mask.cpp @@ -0,0 +1,337 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +#include +#include +#include +#include +#include + +#include "geometry/geometry.h" +#include "gis/Dataset.h" +#include "gis/gdal.h" +#include "gis/srs.h" +#include "gis/vector_mask.h" +#include "mesh/convert.h" + +namespace { +using gis::vector_mask::Polygon2; +using gis::vector_mask::PolygonWithHoles2; +using gis::vector_mask::ReferencedPolygonMask; + +struct Layer { + std::optional epsg; + std::vector wkt_geometries; +}; + +gis::Dataset memory_dataset(const std::vector& layers) +{ + gis::gdal::initialize_once(); + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("Memory"); + REQUIRE(driver); + gis::Dataset dataset(driver->Create("", 0, 0, 0, GDT_Unknown, nullptr)); + for (std::size_t i = 0; i < layers.size(); ++i) { + OGRSpatialReference srs; + if (layers[i].epsg) { + REQUIRE(srs.importFromEPSG(*layers[i].epsg) == OGRERR_NONE); + } + OGRLayer* layer = dataset.gdalDataset()->CreateLayer(fmt::format("mask{}", i).c_str(), layers[i].epsg ? &srs : nullptr, wkbUnknown, nullptr); + REQUIRE(layer); + for (const std::string& wkt : layers[i].wkt_geometries) { + OGRGeometry* geometry = nullptr; + REQUIRE(OGRGeometryFactory::createFromWkt(wkt.c_str(), nullptr, &geometry) == OGRERR_NONE); + OGRFeature feature(layer->GetLayerDefn()); + REQUIRE(feature.SetGeometryDirectly(geometry) == OGRERR_NONE); + REQUIRE(layer->CreateFeature(&feature) == OGRERR_NONE); + } + } + return dataset; +} + +Polygon2 to_polygon(const std::vector& ring) +{ + Polygon2 polygon; + for (const glm::dvec2& point : ring) { + polygon.push_back(convert::to_cgal_point(point)); + } + return polygon; +} + +std::vector to_points(const Polygon2& polygon) +{ + std::vector points; + for (const auto& point : polygon) { + points.push_back(convert::to_glm_point(point)); + } + return points; +} + +// The first ring is the counterclockwise outer boundary, the others are clockwise holes. +ReferencedPolygonMask make_mask(const std::vector>& rings, const int epsg) +{ + PolygonWithHoles2 polygon(to_polygon(rings.front())); + for (std::size_t i = 1; i < rings.size(); ++i) { + polygon.add_hole(to_polygon(rings[i])); + } + ReferencedPolygonMask mask { .polygons = {}, .srs = gis::srs::from_epsg(epsg).value() }; + mask.polygons.add_polygon_with_holes(std::move(polygon)); + return mask; +} + +const PolygonWithHoles2& single_polygon(const ReferencedPolygonMask& mask) +{ + REQUIRE(mask.polygons.number_of_polygons_with_holes() == 1); + return mask.polygons.polygons_with_holes().front(); +} + +double distance_to_ring(const glm::dvec2& point, const std::vector& ring) +{ + double nearest = std::numeric_limits::max(); + for (std::size_t i = 0; i < ring.size(); ++i) { + nearest = std::min(nearest, geometry::distance_to_segment(point, radix::geometry::Edge<2, double> { ring[i], ring[(i + 1) % ring.size()] })); + } + return nearest; +} + +// A square whose bottom edge has a middle vertex offset outwards. +std::vector square_with_offset_vertex(const glm::dvec2& origin, const double size, const double offset) +{ + return { origin, origin + glm::dvec2(size / 2, -offset), origin + glm::dvec2(size, 0), origin + glm::dvec2(size, size), origin + glm::dvec2(0, size) }; +} + +bool has_srs(const ReferencedPolygonMask& mask, const OGRSpatialReference& srs) { return mask.srs.IsSame(&srs); } +} // namespace + +TEST_CASE("vector mask load keeps polygons and orients their rings", "[gis][vector_mask]") +{ + auto dataset = memory_dataset({ { 3035, + { + "POLYGON ((0 0,0 100,100 100,100 0,0 0),(30 30,70 30,70 70,30 70,30 30))", + "MULTIPOLYGON (((200 0,300 0,300 100,200 100,200 0)),((400 0,500 0,500 100,400 100,400 0)))", + } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE(loaded); + CHECK(has_srs(*loaded, gis::srs::from_epsg(3035).value())); + REQUIRE(loaded->polygons.number_of_polygons_with_holes() == 3); + const PolygonWithHoles2& polygon = loaded->polygons.polygons_with_holes().front(); + CHECK(polygon.outer_boundary().size() == 4); + CHECK(polygon.outer_boundary().is_counterclockwise_oriented()); + REQUIRE(polygon.number_of_holes() == 1); + CHECK(polygon.holes().front().size() == 4); + CHECK(polygon.holes().front().is_clockwise_oriented()); +} + +TEST_CASE("vector mask load assumes WGS84 without an SRS", "[gis][vector_mask]") +{ + auto dataset = memory_dataset({ { std::nullopt, { "POLYGON ((10 46,11 46,11 47,10 47,10 46))" } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE(loaded); + CHECK(has_srs(*loaded, gis::srs::wgs84())); +} + +TEST_CASE("vector mask load rejects broken input", "[gis][vector_mask]") +{ + const std::string square = "POLYGON ((0 0,100 0,100 100,0 100,0 0))"; + SECTION("self-intersecting ring") + { + auto dataset = memory_dataset({ { 3035, { "POLYGON ((0 0,100 100,100 0,0 100,0 0))" } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::CorruptData); + } + SECTION("degenerate ring") + { + auto dataset = memory_dataset({ { 3035, { "POLYGON ((0 0,100 100,100 100,0 0))" } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::CorruptData); + } + SECTION("layers with different SRSs") + { + auto dataset = memory_dataset({ { 3035, { square } }, { 31287, { square } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::Unsupported); + } + SECTION("line geometry") + { + auto dataset = memory_dataset({ { 3035, { "LINESTRING (0 0,100 100)" } } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::Unsupported); + } + SECTION("no polygons") + { + auto dataset = memory_dataset({ { 3035, {} } }); + const auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::InvalidInput); + } +} + +TEST_CASE("vector mask simplification removes vertices within the tolerance", "[gis][vector_mask]") +{ + // A 1 km square in the Alps with 1000 vertices per side, each up to 2 cm off the straight side. + const glm::dvec2 origin = { 4'300'000, 2'600'000 }; + const std::vector directions = { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 } }; + std::vector ring; + glm::dvec2 corner = origin; + for (const glm::dvec2& direction : directions) { + for (int i = 0; i < 1000; ++i) { + const glm::dvec2 normal = { direction.y, -direction.x }; + ring.push_back(corner + direction * double(i) + (i == 0 ? 0.0 : 0.02 * std::sin(i * 0.7)) * normal); + } + corner += direction * 1000.0; + } + + const auto simplified = gis::vector_mask::simplify(make_mask({ ring }, 3035), 0.1); + REQUIRE(simplified); + CHECK(has_srs(*simplified, gis::srs::from_epsg(3035).value())); + const std::vector result = to_points(single_polygon(*simplified).outer_boundary()); + CHECK(result.size() <= 8); + double max_deviation = 0; + for (const glm::dvec2& point : ring) { + max_deviation = std::max(max_deviation, distance_to_ring(point, result)); + } + CHECK(max_deviation <= 0.1); +} + +TEST_CASE("vector mask simplification keeps rings from crossing", "[gis][vector_mask]") +{ + // Removing the peak of the outer boundary would cut through the hole below it. + const glm::dvec2 origin = { 4'300'000, 2'600'000 }; + const std::vector outer + = { origin, origin + glm::dvec2(100, 0), origin + glm::dvec2(100, 100), origin + glm::dvec2(50, 102), origin + glm::dvec2(0, 100) }; + const std::vector hole + = { origin + glm::dvec2(45, 99), origin + glm::dvec2(45, 101), origin + glm::dvec2(55, 101), origin + glm::dvec2(55, 99) }; + + SECTION("without the hole the peak goes") + { + const auto simplified = gis::vector_mask::simplify(make_mask({ outer }, 3035), 3); + REQUIRE(simplified); + CHECK(single_polygon(*simplified).outer_boundary().size() == 4); + } + SECTION("with the hole the peak stays") + { + const auto simplified = gis::vector_mask::simplify(make_mask({ outer, hole }, 3035), 3); + REQUIRE(simplified); + const PolygonWithHoles2& polygon = single_polygon(*simplified); + CHECK(polygon.outer_boundary().size() == 5); + REQUIRE(polygon.number_of_holes() == 1); + for (const auto& point : polygon.holes().front()) { + CHECK(polygon.outer_boundary().bounded_side(point) == CGAL::ON_BOUNDED_SIDE); + } + } +} + +TEST_CASE("vector mask simplification tolerance follows the local scale", "[gis][vector_mask]") +{ + const auto vertex_count = [](const ReferencedPolygonMask& mask, const double tolerance_m) { + const auto simplified = gis::vector_mask::simplify(mask, tolerance_m); + REQUIRE(simplified); + return single_polygon(*simplified).outer_boundary().size(); + }; + + SECTION("web mercator, where a unit is half a metre at 60 degrees latitude") + { + const auto equator = gis::srs::transform_point(gis::srs::wgs84(), gis::srs::webmercator(), glm::dvec2(10, 0)); + const auto north = gis::srs::transform_point(gis::srs::wgs84(), gis::srs::webmercator(), glm::dvec2(10, 60)); + REQUIRE(equator); + REQUIRE(north); + CHECK(vertex_count(make_mask({ square_with_offset_vertex(*equator, 100, 0.05) }, 3857), 0.1) == 4); + CHECK(vertex_count(make_mask({ square_with_offset_vertex(*equator, 100, 0.15) }, 3857), 0.1) == 5); + CHECK(vertex_count(make_mask({ square_with_offset_vertex(*north, 100, 0.15) }, 3857), 0.1) == 4); + CHECK(vertex_count(make_mask({ square_with_offset_vertex(*north, 100, 0.25) }, 3857), 0.1) == 5); + } + SECTION("geographic, with the offset along a meridian") + { + // About the length of a degree of latitude at 47 degrees. + const double degrees_per_metre = 1 / 111'200.0; + const auto ring = [&](const double offset_m) { return square_with_offset_vertex({ 10, 47 }, 100 * degrees_per_metre, offset_m * degrees_per_metre); }; + CHECK(vertex_count(make_mask({ ring(0.05) }, 4326), 0.1) == 4); + CHECK(vertex_count(make_mask({ ring(0.2) }, 4326), 0.1) == 5); + } +} + +TEST_CASE("vector mask reprojection subdivides long edges within the tolerance", "[gis][vector_mask]") +{ + const std::vector ring = { { 10, 46 }, { 12, 46 }, { 12, 48 }, { 10, 48 } }; + const OGRSpatialReference target_srs = gis::srs::from_epsg(3035).value(); + const double tolerance_m = 0.5; + const auto reprojected = gis::vector_mask::transform(make_mask({ ring }, 4326), target_srs, tolerance_m); + REQUIRE(reprojected); + CHECK(has_srs(*reprojected, target_srs)); + const Polygon2& outer = single_polygon(*reprojected).outer_boundary(); + CHECK(outer.is_counterclockwise_oriented()); + CHECK(outer.size() > 4); + CHECK(outer.size() < 2000); + + // Densely sampled source edges, reprojected, stay within the tolerance of the result. + std::vector reference; + for (std::size_t i = 0; i < ring.size(); ++i) { + for (int step = 0; step < 1000; ++step) { + reference.push_back(glm::mix(ring[i], ring[(i + 1) % ring.size()], step / 1000.0)); + } + } + const auto reprojected_reference = gis::srs::transform_points(gis::srs::wgs84(), target_srs, reference); + REQUIRE(reprojected_reference); + const std::vector result = to_points(outer); + double max_deviation = 0; + for (const glm::dvec2& point : *reprojected_reference) { + max_deviation = std::max(max_deviation, distance_to_ring(point, result)); + } + CHECK(max_deviation <= tolerance_m * 1.05); +} + +TEST_CASE("vector mask reprojection keeps short edges", "[gis][vector_mask]") +{ + const std::vector ring = { { 10, 46 }, { 10.001, 46 }, { 10.001, 46.001 }, { 10, 46.001 } }; + const auto reprojected = gis::vector_mask::transform(make_mask({ ring }, 4326), gis::srs::from_epsg(3035).value(), 0.1); + REQUIRE(reprojected); + CHECK(single_polygon(*reprojected).outer_boundary().size() == 4); +} + +TEST_CASE("vector mask functions pass errors through", "[gis][vector_mask]") +{ + const Expected failed = std::unexpected(Error::make(Error::Code::NotFound, "find mask")); + const auto reprojected = gis::vector_mask::transform(failed, gis::srs::webmercator(), 1); + REQUIRE_FALSE(reprojected); + CHECK(reprojected.error().code() == Error::Code::NotFound); + const auto simplified = gis::vector_mask::simplify(failed, 1); + REQUIRE_FALSE(simplified); + CHECK(simplified.error().code() == Error::Code::NotFound); +} + +TEST_CASE("vector mask functions chain from load", "[gis][vector_mask]") +{ + // Parallels are curved in EPSG:3035, so subdivided vertices survive the simplification. + auto dataset = memory_dataset({ { 4326, { "POLYGON ((10 46,12 46,12 48,10 48,10 46))" } } }); + const OGRSpatialReference target_srs = gis::srs::from_epsg(3035).value(); + const auto mask = gis::vector_mask::simplify(gis::vector_mask::transform(gis::vector_mask::load(dataset), target_srs, 0.5), 0.5); + REQUIRE(mask); + CHECK(has_srs(*mask, target_srs)); + CHECK(single_polygon(*mask).outer_boundary().size() > 4); +} diff --git a/unittests/terrainlib/gis_vector_mask_read_errors.cpp b/unittests/terrainlib/gis_vector_mask_read_errors.cpp new file mode 100644 index 00000000..753afabd --- /dev/null +++ b/unittests/terrainlib/gis_vector_mask_read_errors.cpp @@ -0,0 +1,79 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "gis/Dataset.h" +#include "gis/vector_mask.h" +#include +#include + +namespace { +class FailingLayer final : public OGRLayer { +public: + FailingLayer() + { + m_definition.Reference(); + m_reference.importFromEPSG(3857); + m_reference.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + } + void ResetReading() override { m_read = false; } + OGRFeatureDefn* GetLayerDefn() override { return &m_definition; } + OGRSpatialReference* GetSpatialRef() override { return &m_reference; } + int TestCapability(const char*) override { return FALSE; } + OGRFeature* GetNextFeature() override + { + if (m_read) { + CPLError(CE_Failure, CPLE_AppDefined, "injected feature-read failure"); + return nullptr; + } + m_read = true; + auto feature = std::make_unique(&m_definition); + OGRLinearRing ring; + ring.addPoint(0, 0); + ring.addPoint(10, 0); + ring.addPoint(10, 10); + ring.addPoint(0, 10); + ring.addPoint(0, 0); + OGRPolygon polygon; + REQUIRE(polygon.addRing(&ring) == OGRERR_NONE); + REQUIRE(feature->SetGeometry(&polygon) == OGRERR_NONE); + return feature.release(); + } + +private: + OGRFeatureDefn m_definition { "mask" }; + OGRSpatialReference m_reference; + bool m_read = false; +}; + +class FailingDataset final : public GDALDataset { +public: + int GetLayerCount() override { return 1; } + OGRLayer* GetLayer(int index) override { return index == 0 ? &m_layer : nullptr; } + +private: + FailingLayer m_layer; +}; +} // namespace + +TEST_CASE("RF shared mask loading distinguishes a read failure from normal EOF", "[rf-builder]") +{ + gis::Dataset dataset(new FailingDataset); + auto loaded = gis::vector_mask::load(dataset); + REQUIRE_FALSE(loaded); + CHECK(loaded.error().code() == Error::Code::Io); +} diff --git a/unittests/terrainlib/glm_serialization.cpp b/unittests/terrainlib/glm_serialization.cpp new file mode 100644 index 00000000..41d8365a --- /dev/null +++ b/unittests/terrainlib/glm_serialization.cpp @@ -0,0 +1,173 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/glm_serialization.h" +#include "io/byte.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +template +auto encode(const Value& value) +{ + io::byte::vector bytes; + zpp::bits::out output(bytes); + REQUIRE(!zpp::bits::failure(output(value))); + return bytes; +} + +template +void check_encoding(const Value& value, const std::array& components) +{ + const auto bytes = encode(value); + CHECK(bytes.size() == Size * sizeof(Scalar)); + CHECK(bytes == encode(components)); + + Value decoded {}; + zpp::bits::in input(bytes); + REQUIRE(!zpp::bits::failure(input(decoded))); + CHECK(decoded == value); + CHECK(input.position() == bytes.size()); + + // Non-const values must also work with output archives. + io::byte::vector mutable_bytes; + zpp::bits::out output(mutable_bytes); + REQUIRE(!zpp::bits::failure(output(decoded))); + CHECK(mutable_bytes == bytes); +} + +} // namespace + +TEMPLATE_TEST_CASE("GLM vectors serialize only their components", + "[glm_serialization]", + glm::vec1, + glm::vec2, + glm::vec3, + glm::vec4, + glm::dvec3, + glm::ivec3, + glm::uvec4, + glm::bvec2, + (glm::vec<3, float, glm::packed_lowp>)) +{ + [](glm::vec value) { + std::array components {}; + for (glm::length_t index = 0; index < Length; ++index) { + value[index] = components[index] = static_cast(index % 2 == 0 ? index + 1 : 0); + } + check_encoding(value, components); + }(TestType {}); +} + +TEMPLATE_TEST_CASE("GLM matrices serialize scalars in column-major order", + "[glm_serialization]", + glm::mat2, + glm::mat2x3, + glm::mat2x4, + glm::mat3x2, + glm::mat3, + glm::mat3x4, + glm::mat4x2, + glm::mat4x3, + glm::mat4, + glm::dmat3x2, + (glm::mat<2, 3, float, glm::packed_mediump>)) +{ + [](glm::mat value) { + std::array components {}; + for (glm::length_t column = 0; column < Columns; ++column) { + for (glm::length_t row = 0; row < Rows; ++row) { + const auto index = column * Rows + row; + value[column][row] = components[index] = static_cast(index - 3); + } + } + check_encoding(value, components); + }(TestType {}); +} + +TEST_CASE("GLM serialization works inside nested aggregates and containers", "[glm_serialization]") +{ + struct Vertex { + glm::dvec3 position; + glm::vec2 texture_coordinates; + bool operator==(const Vertex&) const = default; + }; + struct Payload { + std::uint32_t id; + std::vector vertices; + std::array transforms; + bool operator==(const Payload&) const = default; + }; + const Payload value { + 42, + { { { 1.0, -2.0, 3.5 }, { 0.25f, 0.75f } }, { { -4.0, 5.0, 6.0 }, { 1.0f, 0.0f } } }, + { glm::mat2x3(1.0f), glm::mat2x3(2.0f) }, + }; + const auto bytes = encode(value); + Payload decoded {}; + zpp::bits::in input(bytes); + REQUIRE(!zpp::bits::failure(input(decoded))); + CHECK(decoded == value); + CHECK(input.position() == bytes.size()); +} + +TEMPLATE_TEST_CASE("GLM serialization propagates truncated buffer errors", "[glm_serialization]", glm::vec3, glm::mat2x3) +{ + const TestType value(1.0f); + const auto bytes = encode(value); + for (std::size_t size = 0; size < bytes.size(); ++size) { + CAPTURE(size); + TestType decoded {}; + zpp::bits::in input(io::byte::span(bytes.data(), size)); + CHECK(zpp::bits::failure(input(decoded))); + + io::byte::vector buffer(size); + zpp::bits::out output { std::span(buffer) }; + CHECK(zpp::bits::failure(output(value))); + } +} + +#if GLM_CONFIG_ALIGNED_GENTYPES == GLM_ENABLE +TEST_CASE("Aligned GLM vectors and matrices omit padding", "[glm_serialization]") +{ + using Vector = glm::vec<3, float, glm::aligned_highp>; + using Matrix = glm::mat<2, 3, float, glm::aligned_highp>; + static_assert(sizeof(Vector) > 3 * sizeof(float)); + static_assert(sizeof(Matrix) > 6 * sizeof(float)); + + check_encoding(Vector(1.0f, 2.0f, 3.0f), std::array { 1.0f, 2.0f, 3.0f }); + check_encoding(Matrix(Vector(1.0f, 2.0f, 3.0f), Vector(4.0f, 5.0f, 6.0f)), std::array { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f }); + + const std::vector aligned { Vector(1.0f, 2.0f, 3.0f), Vector(4.0f, 5.0f, 6.0f) }; + const std::vector packed { { 1.0f, 2.0f, 3.0f }, { 4.0f, 5.0f, 6.0f } }; + const auto bytes = encode(aligned); + CHECK(bytes == encode(packed)); + std::vector decoded; + zpp::bits::in input(bytes); + REQUIRE(!zpp::bits::failure(input(decoded))); + CHECK(decoded == aligned); +} +#endif diff --git a/unittests/terrainlib/grid.cpp b/unittests/terrainlib/grid.cpp deleted file mode 100644 index d8df7af1..00000000 --- a/unittests/terrainlib/grid.cpp +++ /dev/null @@ -1,58 +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 "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" - -// word of warning: -// the Grid class is taken from ctb, which didn't use unit tests. -// Therefore it's unlikely, that it will be fully covered by tests. -// New functions should be tested though! - -using Catch::Approx; - -TEST_CASE("grid") { - SECTION("epsg codes") { - const auto geodetic = ctb::GlobalGeodetic(256); - CHECK(geodetic.getEpsgCode() == 4326); - - const auto webmercator = ctb::GlobalMercator(256); - CHECK(webmercator.getEpsgCode() == 3857); - - CHECK(!geodetic.getSRS().IsSame(&webmercator.getSRS())); - } - - SECTION("bounds") { - const auto geodetic = ctb::GlobalGeodetic(256); - CHECK(geodetic.getExtent().min.x == Approx(-180.0)); - CHECK(geodetic.getExtent().max.x == Approx(180.0)); - CHECK(geodetic.getExtent().min.y == Approx(-90.0)); - CHECK(geodetic.getExtent().max.y == Approx(90.0)); - - const auto webmercator = ctb::GlobalMercator(256); - const auto lim = double(20037508); - CHECK(webmercator.getExtent().min.x == Approx(-lim)); - CHECK(webmercator.getExtent().max.x == Approx(lim)); - CHECK(webmercator.getExtent().min.y == Approx(-lim)); - CHECK(webmercator.getExtent().max.y == Approx(lim)); - } -} diff --git a/unittests/terrainlib/hash_utils.cpp b/unittests/terrainlib/hash_utils.cpp index 1b4ea7b6..72c1c7d9 100644 --- a/unittests/terrainlib/hash_utils.cpp +++ b/unittests/terrainlib/hash_utils.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include "hash_utils.h" diff --git a/unittests/terrainlib/hybrid_index_pair_map.cpp b/unittests/terrainlib/hybrid_index_pair_map.cpp index f1d86891..d56c4b0f 100644 --- a/unittests/terrainlib/hybrid_index_pair_map.cpp +++ b/unittests/terrainlib/hybrid_index_pair_map.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/HybridIndexPairMap.h" diff --git a/unittests/terrainlib/hybrid_vector.cpp b/unittests/terrainlib/hybrid_vector.cpp index b8d00c91..0340e4a9 100644 --- a/unittests/terrainlib/hybrid_vector.cpp +++ b/unittests/terrainlib/hybrid_vector.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "../catch2_helpers.h" #include "containers/HybridVector.h" #include diff --git a/unittests/terrainlib/io_conversion.cpp b/unittests/terrainlib/io_conversion.cpp index e93f6ddc..6ec1e4ac 100644 --- a/unittests/terrainlib/io_conversion.cpp +++ b/unittests/terrainlib/io_conversion.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include "io/conversion.h" diff --git a/unittests/terrainlib/io_hash.cpp b/unittests/terrainlib/io_hash.cpp new file mode 100644 index 00000000..49378db9 --- /dev/null +++ b/unittests/terrainlib/io_hash.cpp @@ -0,0 +1,90 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "io/byte.h" +#include "io/hash.h" + +#include +#include + +#include +#include +#include + +namespace { + +// XSUM_fillTestBuffer from xxHash's cli/xsum_sanity_check.c, which generates the input of its test vectors. +io::byte::vector sanity_buffer(const std::size_t size) +{ + io::byte::vector buffer(size); + std::uint64_t generator = 2654435761U; + for (auto& byte : buffer) { + byte = std::byte(generator >> 56); + generator *= 11400714785074694797ULL; + } + return buffer; +} + +io::byte::vector big_endian(const std::uint64_t value) +{ + io::byte::vector bytes(8); + for (std::size_t index = 0; index < bytes.size(); ++index) { + bytes[index] = std::byte(value >> (56 - 8 * index)); + } + return bytes; +} + +} // namespace + +TEST_CASE("XXH3-64 matches the xxHash sanity test vectors in canonical byte order", "[io][hash]") +{ + struct Vector { + std::size_t size; + std::uint64_t hash; + }; + // Seed 0 entries of XSUM_XXH3_testdata in xxHash v0.8.3, covering every input size class. + constexpr std::array vectors { + Vector { 0, 0x2D06800538D394C2ULL }, + Vector { 1, 0xC44BDFF4074EECDBULL }, + Vector { 6, 0x27B56A84CD2D7325ULL }, + Vector { 12, 0xA713DAF0DFBB77E7ULL }, + Vector { 24, 0xA3FE70BF9D3510EBULL }, + Vector { 48, 0x397DA259ECBA1F11ULL }, + Vector { 80, 0xBCDEFBBB2C47C90AULL }, + Vector { 195, 0xCD94217EE362EC3AULL }, + Vector { 403, 0xCDEB804D65C6DEA4ULL }, + Vector { 512, 0x617E49599013CB6BULL }, + Vector { 2048, 0xDD59E2C3A5F038E0ULL }, + Vector { 2099, 0xC6B9D9B3FC9AC765ULL }, + Vector { 2240, 0x6E73A90539CF2948ULL }, + Vector { 2367, 0xCB37AEB9E5D361EDULL }, + }; + const auto buffer = sanity_buffer(vectors.back().size); + for (const auto& vector : vectors) { + CAPTURE(vector.size); + CHECK(io::hash::data(std::span(buffer).first(vector.size), io::hash::Algorithm::Xxh3_64) == big_endian(vector.hash)); + } +} + +TEST_CASE("the None hash algorithm produces no bytes", "[io][hash]") { CHECK(io::hash::data(sanity_buffer(16), io::hash::Algorithm::None).empty()); } + +TEST_CASE("XXH3-64 throughput over 1 GiB", "[!benchmark][io][hash]") +{ + const auto buffer = sanity_buffer(std::size_t { 1 } << 30); + BENCHMARK("XXH3-64, 1 GiB") { return io::hash::data(buffer, io::hash::Algorithm::Xxh3_64); }; +} diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp new file mode 100644 index 00000000..129fb460 --- /dev/null +++ b/unittests/terrainlib/io_image.cpp @@ -0,0 +1,233 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../temporary_directory.h" +#include "io/byte.h" +#include "io/image.h" + +namespace { +namespace image = io::image; + +image::RGBA8 colours() +{ + image::RGBA8 result({ 2, 2 }); + result.pixel({ 0, 0 }) = { 255, 0, 0, 255 }; + result.pixel({ 1, 0 }) = { 0, 255, 0, 128 }; + result.pixel({ 0, 1 }) = { 0, 0, 255, 0 }; + result.pixel({ 1, 1 }) = { 17, 31, 47, 0 }; + return result; +} + +cv::Mat encoded_mat(const io::byte::vector& bytes) { return { 1, int(bytes.size()), CV_8UC1, const_cast(bytes.data()) }; } +} // namespace + +TEST_CASE("PNG preserves RGBA including hidden colour and row order", "[io-image]") +{ + const auto source = colours(); + const auto bytes = image::encode(source, image::Format::Png); + REQUIRE(bytes); + const auto decoded = image::decode_rgba8(*bytes); + REQUIRE(decoded); + CHECK(decoded->size() == source.size()); + CHECK(std::ranges::equal(decoded->bytes(), source.bytes())); + // Independent decoder verifies that both sides of our API cannot hide a channel/row swap. + const auto reference = cv::imdecode(encoded_mat(*bytes), cv::IMREAD_UNCHANGED); + REQUIRE(reference.type() == CV_8UC4); + CHECK(reference.at(0, 0) == cv::Vec4b(0, 0, 255, 255)); + CHECK(reference.at(1, 1) == cv::Vec4b(47, 31, 17, 0)); + const auto rgb = image::decode_rgb8(*bytes); + REQUIRE(rgb); + CHECK(rgb->pixel({ 1, 1 }) == glm::u8vec3(17, 31, 47)); + const auto rgb_bytes = image::encode(*rgb, image::Format::Png); + REQUIRE(rgb_bytes); + const auto opaque = image::decode_rgba8(*rgb_bytes); + REQUIRE(opaque); + CHECK(opaque->pixel({ 1, 1 }) == glm::u8vec4(17, 31, 47, 255)); + + const cv::Mat grey(2, 2, CV_8UC1, cv::Scalar(37)); + std::vector grey_bytes; + REQUIRE(cv::imencode(".png", grey, grey_bytes)); + const auto grey_rgb = image::decode_rgb8(std::as_bytes(std::span(grey_bytes))); + const auto grey_rgba = image::decode_rgba8(std::as_bytes(std::span(grey_bytes))); + REQUIRE(grey_rgb); + REQUIRE(grey_rgba); + CHECK(grey_rgb->pixel({ 0, 0 }) == glm::u8vec3(37, 37, 37)); + CHECK(grey_rgba->pixel({ 0, 0 }) == glm::u8vec4(37, 37, 37, 255)); +} + +TEST_CASE("PNG compression and JPEG quality options reach encoders", "[io-image]") +{ + image::RGB8 source({ 128, 128 }); + for (unsigned y = 0; y < source.height(); ++y) { + for (unsigned x = 0; x < source.width(); ++x) { + source.pixel({ x, y }) = glm::u8vec3(x * 2, y * 2, (x * 7 + y * 11) % 256); + } + } + const auto plain = image::encode(source, image::Format::Png, { .png_compression = 0 }); + const auto compressed = image::encode(source, image::Format::Png, { .png_compression = 9 }); + REQUIRE(plain); + REQUIRE(compressed); + CHECK(compressed->size() < plain->size()); + for (const auto& bytes : { *plain, *compressed }) { + const auto decoded = image::decode_rgb8(bytes); + REQUIRE(decoded); + CHECK(std::ranges::equal(decoded->bytes(), source.bytes())); + } + const auto low = image::encode(source, image::Format::Jpeg, { .jpeg_quality = 10 }); + const auto high = image::encode(source, image::Format::Jpeg, { .jpeg_quality = 95 }); + REQUIRE(low); + REQUIRE(high); + CHECK(low->size() < high->size()); + source.fill({ 255, 0, 0 }); + const auto jpeg = image::encode(source, image::Format::Jpeg); + REQUIRE(jpeg); + const auto reference = cv::imdecode(encoded_mat(*jpeg), cv::IMREAD_COLOR); + const auto red = reference.at(0, 0); + CHECK(red[0] <= 2); + CHECK(red[1] <= 2); + CHECK(red[2] >= 253); + const auto rgba = image::decode_rgba8(*jpeg); + REQUIRE(rgba); + CHECK(rgba->pixel({ 0, 0 }).w == 255); +} + +TEST_CASE("JPEG XL is lossy by default, lossless at distance zero and keeps row order", "[io-image]") +{ + image::RGB8 source({ 64, 48 }); + for (unsigned y = 0; y < source.height(); ++y) { + for (unsigned x = 0; x < source.width(); ++x) { + source.pixel({ x, y }) = glm::u8vec3(100 + x, 80 + y, 120); + } + } + const auto lossy = image::encode(source, image::Format::JpegXl); + REQUIRE(lossy); + // A bare codestream, not the container format. + REQUIRE(lossy->size() > 2); + CHECK((*lossy)[0] == std::byte { 0xff }); + CHECK((*lossy)[1] == std::byte { 0x0a }); + const auto decoded = image::decode_rgb8(*lossy); + REQUIRE(decoded); + REQUIRE(decoded->size() == source.size()); + int max_error = 0; + for (std::size_t i = 0; i < source.buffer_length(); ++i) { + const auto difference = glm::abs(glm::ivec3(decoded->buffer()[i]) - glm::ivec3(source.buffer()[i])); + max_error = std::max({ max_error, difference.x, difference.y, difference.z }); + } + // JPEG XL optimises perceived error; strongly saturated colours deviate more. + CHECK(max_error > 0); + CHECK(max_error <= 4); + const auto opaque = image::decode_rgba8(*lossy); + REQUIRE(opaque); + CHECK(opaque->pixel({ 0, 0 }).w == 255); + + const auto coarse = image::encode(source, image::Format::JpegXl, { .jpegxl_distance = 4 }); + const auto fast = image::encode(source, image::Format::JpegXl, { .jpegxl_effort = 1 }); + REQUIRE(coarse); + REQUIRE(fast); + CHECK(coarse->size() < lossy->size()); + CHECK(*fast != *lossy); + + const auto rgba = colours(); + const auto lossless = image::encode(rgba, image::Format::JpegXl, { .jpegxl_distance = 0 }); + REQUIRE(lossless); + const auto exact = image::decode_rgba8(*lossless); + REQUIRE(exact); + CHECK(std::ranges::equal(exact->bytes(), rgba.bytes())); + const auto rgb = image::decode_rgb8(*lossless); + REQUIRE(rgb); + CHECK(rgb->pixel({ 1, 0 }) == glm::u8vec3(0, 255, 0)); + + test::TemporaryDirectory directory; + const auto path = directory.path() / "image.JXL"; + REQUIRE(image::write(source, path, { .encoding = { .jpegxl_distance = 0 } })); + const auto read = image::read_rgb8(path); + REQUIRE(read); + CHECK(std::ranges::equal(read->bytes(), source.bytes())); +} + +TEST_CASE("image I/O rejects invalid inputs without silent alpha loss", "[io-image]") +{ + const auto source = colours(); + CHECK(image::encode(source, image::Format::Jpeg).error().code() == Error::Code::Unsupported); + CHECK_FALSE(image::encode(source, image::Format::Png, { .png_compression = -1 })); + CHECK_FALSE(image::encode(source, image::Format::Png, { .png_compression = 10 })); + CHECK_FALSE(image::encode(source, image::Format::Png, { .jpeg_quality = -1 })); + CHECK_FALSE(image::encode(source, image::Format::Png, { .jpeg_quality = 101 })); + CHECK_FALSE(image::encode(source, image::Format::JpegXl, { .jpegxl_distance = -0.5f })); + CHECK_FALSE(image::encode(source, image::Format::JpegXl, { .jpegxl_distance = 25.5f })); + CHECK_FALSE(image::encode(source, image::Format::JpegXl, { .jpegxl_effort = 0 })); + CHECK_FALSE(image::encode(source, image::Format::JpegXl, { .jpegxl_effort = 11 })); + CHECK_FALSE(image::encode(image::RGB8 {}, image::Format::Png)); + CHECK(image::encode(image::RGB8 {}, image::Format::JpegXl).error().code() == Error::Code::InvalidInput); + CHECK_FALSE(image::decode_rgb8({})); + const std::array junk { std::byte { 1 }, std::byte { 2 }, std::byte { 3 }, std::byte { 4 } }; + CHECK(image::decode_rgba8(junk).error().code() == Error::Code::CorruptData); + auto truncated = image::encode(source, image::Format::JpegXl).value(); + truncated.resize(truncated.size() / 2); + CHECK(image::decode_rgba8(truncated).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("disk image I/O preserves RGBA and honours overwrite and directory policies", "[io-image]") +{ + test::TemporaryDirectory directory; + const auto path = directory.path() / "nested" / "image.PNG"; + auto source = colours(); + CHECK_FALSE(image::write(source, path, { .make_dirs = false })); + REQUIRE(image::write(source, path)); + const auto original = io::byte::read(path); + REQUIRE(original); + CHECK(image::write(source, path).error().code() == Error::Code::AlreadyExists); + CHECK(*io::byte::read(path) == *original); + source.pixel({ 0, 0 }) = { 8, 9, 10, 11 }; + REQUIRE(image::write(source, path, { .overwrite = true })); + const auto decoded = image::read_rgba8(path); + REQUIRE(decoded); + CHECK(std::ranges::equal(decoded->bytes(), source.bytes())); + const auto rgb = image::read_rgb8(path); + REQUIRE(rgb); + REQUIRE(image::write(*rgb, directory.path() / "image.jpeg")); + CHECK_FALSE(image::write(source, directory.path() / "image.unknown")); + CHECK(image::read_rgb8(directory.path() / "missing.png").error().code() == Error::Code::NotFound); + const auto link = directory.path() / "link.png"; + std::filesystem::create_symlink(path, link); + CHECK_FALSE(image::write(source, link, { .overwrite = true })); + const auto folder = directory.path() / "folder.png"; + REQUIRE(std::filesystem::create_directory(folder)); + CHECK_FALSE(image::write(source, folder, { .overwrite = true })); +} + +TEST_CASE("byte writer retains legacy overwrite and supports exclusive creation", "[io-image]") +{ + test::TemporaryDirectory directory; + const auto path = directory.path() / "bytes"; + const std::array first { std::byte { 1 }, std::byte { 2 }, std::byte { 3 } }; + const std::array second { std::byte { 4 }, std::byte { 5 } }; + REQUIRE(io::byte::write(first, path, io::byte::WriteMode::CreateNew)); + CHECK(io::byte::write(second, path, io::byte::WriteMode::CreateNew).error().code() == Error::Code::AlreadyExists); + CHECK(*io::byte::read(path) == std::vector(first.begin(), first.end())); + REQUIRE(io::byte::write(second, path, io::byte::WriteMode::Overwrite)); + CHECK(*io::byte::read(path) == std::vector(second.begin(), second.end())); +} diff --git a/unittests/terrainlib/mesh_boundary.cpp b/unittests/terrainlib/mesh_boundary.cpp index dd5888f5..3132bde7 100644 --- a/unittests/terrainlib/mesh_boundary.cpp +++ b/unittests/terrainlib/mesh_boundary.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/mesh_bounds.cpp b/unittests/terrainlib/mesh_bounds.cpp index 9bc53e0c..541775cd 100644 --- a/unittests/terrainlib/mesh_bounds.cpp +++ b/unittests/terrainlib/mesh_bounds.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include "../catch2_helpers.h" diff --git a/unittests/terrainlib/mesh_cleanup.cpp b/unittests/terrainlib/mesh_cleanup.cpp index 2d952695..c4ea4b96 100644 --- a/unittests/terrainlib/mesh_cleanup.cpp +++ b/unittests/terrainlib/mesh_cleanup.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/unittests/terrainlib/mesh_clip.cpp b/unittests/terrainlib/mesh_clip.cpp index a5646c0e..1a0696e8 100644 --- a/unittests/terrainlib/mesh_clip.cpp +++ b/unittests/terrainlib/mesh_clip.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 #define CATCH_CONFIG_ENABLE_BENCHMARKING diff --git a/unittests/terrainlib/mesh_combine.cpp b/unittests/terrainlib/mesh_combine.cpp index f9568550..d94d6e96 100644 --- a/unittests/terrainlib/mesh_combine.cpp +++ b/unittests/terrainlib/mesh_combine.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include "mesh/combine.h" #include "mesh/TriangleSoup.h" diff --git a/unittests/terrainlib/mesh_connected_components.cpp b/unittests/terrainlib/mesh_connected_components.cpp index f5cff783..de57339e 100644 --- a/unittests/terrainlib/mesh_connected_components.cpp +++ b/unittests/terrainlib/mesh_connected_components.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include diff --git a/unittests/terrainlib/mesh_cut.cpp b/unittests/terrainlib/mesh_cut.cpp index ed13702b..dc197b47 100644 --- a/unittests/terrainlib/mesh_cut.cpp +++ b/unittests/terrainlib/mesh_cut.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/mesh_cut_to_disk.cpp b/unittests/terrainlib/mesh_cut_to_disk.cpp index f6e7c737..ea394d3a 100644 --- a/unittests/terrainlib/mesh_cut_to_disk.cpp +++ b/unittests/terrainlib/mesh_cut_to_disk.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/mesh_holes.cpp b/unittests/terrainlib/mesh_holes.cpp index 18e9f917..de62be50 100644 --- a/unittests/terrainlib/mesh_holes.cpp +++ b/unittests/terrainlib/mesh_holes.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2025 Martin Braunsperger + * Copyright (C) 2025 Adam Celarek-Litofcenko + * + * 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 @@ -12,7 +31,7 @@ #include "mesh/connectivity/boundary.h" #include "mesh/connectivity/topology.h" #include "mesh/connectivity/manifold.h" -#include "log.h" +#include "cli_support/log.h" std::string vec2d_to_string(const std::vector> &v) { std::ostringstream oss; diff --git a/unittests/terrainlib/mesh_io.cpp b/unittests/terrainlib/mesh_io.cpp index f0186921..a43281b8 100644 --- a/unittests/terrainlib/mesh_io.cpp +++ b/unittests/terrainlib/mesh_io.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 diff --git a/unittests/terrainlib/mesh_manifold.cpp b/unittests/terrainlib/mesh_manifold.cpp index ec14c8ae..06369a05 100644 --- a/unittests/terrainlib/mesh_manifold.cpp +++ b/unittests/terrainlib/mesh_manifold.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/unittests/terrainlib/mesh_merge.cpp b/unittests/terrainlib/mesh_merge.cpp index 8347a1d0..5e0bb1e0 100644 --- a/unittests/terrainlib/mesh_merge.cpp +++ b/unittests/terrainlib/mesh_merge.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 diff --git a/unittests/terrainlib/mesh_merge_mapping.cpp b/unittests/terrainlib/mesh_merge_mapping.cpp index 7bf69a55..4d8773ba 100644 --- a/unittests/terrainlib/mesh_merge_mapping.cpp +++ b/unittests/terrainlib/mesh_merge_mapping.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 diff --git a/unittests/terrainlib/mesh_reindex.cpp b/unittests/terrainlib/mesh_reindex.cpp index cbe35018..6ba550ae 100644 --- a/unittests/terrainlib/mesh_reindex.cpp +++ b/unittests/terrainlib/mesh_reindex.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include "../catch2_helpers.h" diff --git a/unittests/terrainlib/mesh_split.cpp b/unittests/terrainlib/mesh_split.cpp index 685805d9..ae8e10ee 100644 --- a/unittests/terrainlib/mesh_split.cpp +++ b/unittests/terrainlib/mesh_split.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/mesh_topology.cpp b/unittests/terrainlib/mesh_topology.cpp index 146184e3..7aaab6e5 100644 --- a/unittests/terrainlib/mesh_topology.cpp +++ b/unittests/terrainlib/mesh_topology.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include #include diff --git a/unittests/terrainlib/octree_id.cpp b/unittests/terrainlib/octree_id.cpp index 23f49917..53cf574f 100644 --- a/unittests/terrainlib/octree_id.cpp +++ b/unittests/terrainlib/octree_id.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include "../catch2_helpers.h" diff --git a/unittests/terrainlib/octree_odd_level_shifted.cpp b/unittests/terrainlib/octree_odd_level_shifted.cpp index a373cf65..5ad62795 100644 --- a/unittests/terrainlib/octree_odd_level_shifted.cpp +++ b/unittests/terrainlib/octree_odd_level_shifted.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/octree_space.cpp b/unittests/terrainlib/octree_space.cpp index 39f2ac35..40266829 100644 --- a/unittests/terrainlib/octree_space.cpp +++ b/unittests/terrainlib/octree_space.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include "../catch2_helpers.h" diff --git a/unittests/terrainlib/offset_table.cpp b/unittests/terrainlib/offset_table.cpp index ea7fede8..8b7d873b 100644 --- a/unittests/terrainlib/offset_table.cpp +++ b/unittests/terrainlib/offset_table.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/OffsetTable.h" diff --git a/unittests/terrainlib/polygon_clip.cpp b/unittests/terrainlib/polygon_clip.cpp index e6d2ed66..5417fa0d 100644 --- a/unittests/terrainlib/polygon_clip.cpp +++ b/unittests/terrainlib/polygon_clip.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/polygon_triangulate.cpp b/unittests/terrainlib/polygon_triangulate.cpp index 22910c1d..d64f241e 100644 --- a/unittests/terrainlib/polygon_triangulate.cpp +++ b/unittests/terrainlib/polygon_triangulate.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 "../catch2_helpers.h" #include diff --git a/unittests/terrainlib/progress_indicator_test.cpp b/unittests/terrainlib/progress_indicator_test.cpp index 826c6e8d..f2c312ab 100644 --- a/unittests/terrainlib/progress_indicator_test.cpp +++ b/unittests/terrainlib/progress_indicator_test.cpp @@ -1,7 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * Copyright (C) 2022 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 @@ -17,7 +17,7 @@ * along with this program. If not, see . *****************************************************************************/ -#include "ProgressIndicator.h" +#include "cli_support/ProgressIndicator.h" #include #include #include @@ -34,11 +34,11 @@ TEST_CASE("progress indicator") SECTION("throws on overflow") { { - auto pi = ProgressIndicator(0); + auto pi = cli_support::ProgressIndicator(0); CHECK_THROWS(pi.task_finished()); } { - auto pi = ProgressIndicator(1); + auto pi = cli_support::ProgressIndicator(1); pi.task_finished(); CHECK_THROWS(pi.task_finished()); } @@ -46,7 +46,7 @@ TEST_CASE("progress indicator") SECTION("message") { - ProgressIndicator pi(4); + cli_support::ProgressIndicator pi(4); // Step 0 CHECK(pi.x_of_y_done_message() == "0/4"); @@ -69,7 +69,7 @@ TEST_CASE("progress indicator") SECTION("bar small") { - ProgressIndicator pi(2); + cli_support::ProgressIndicator pi(2); // Step 0 CHECK(pi.progress_bar(2) == "[]"); CHECK(pi.progress_bar(3) == "[ ]"); @@ -88,7 +88,7 @@ TEST_CASE("progress indicator") SECTION("task_finished is thread safe") { const auto tasks = std::vector(1000000); - auto pi = ProgressIndicator(tasks.size()); + auto pi = cli_support::ProgressIndicator(tasks.size()); std::for_each(std::execution::par, tasks.begin(), tasks.end(), [&](const auto&) { pi.task_finished(); }); CHECK_THROWS(pi.task_finished()); } @@ -97,7 +97,7 @@ TEST_CASE("progress indicator") { // no real test, because console output can't be easily tested. but at least we are test compiling the interface. const auto tasks = std::vector(151); - auto pi = ProgressIndicator(tasks.size()); + auto pi = cli_support::ProgressIndicator(tasks.size()); auto monitoring_thread = pi.start_monitoring(); std::for_each(std::execution::par, tasks.begin(), tasks.end(), [&](const auto&) { std::this_thread::sleep_for(10ms); pi.task_finished(); }); monitoring_thread.join(); @@ -106,7 +106,7 @@ TEST_CASE("progress indicator") SECTION("monitoring can be stopped before all tasks finish") { - ProgressIndicator pi(1); + cli_support::ProgressIndicator pi(1); auto monitoring_thread = pi.start_monitoring(); std::condition_variable_any fallback_condition; diff --git a/unittests/terrainlib/quantize.cpp b/unittests/terrainlib/quantize.cpp index 0dfa5299..c84b2874 100644 --- a/unittests/terrainlib/quantize.cpp +++ b/unittests/terrainlib/quantize.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/range.cpp b/unittests/terrainlib/range.cpp index 61466f04..becf64b5 100644 --- a/unittests/terrainlib/range.cpp +++ b/unittests/terrainlib/range.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "Range.h" diff --git a/unittests/terrainlib/range_utils.cpp b/unittests/terrainlib/range_utils.cpp index 29ec6067..273d0d35 100644 --- a/unittests/terrainlib/range_utils.cpp +++ b/unittests/terrainlib/range_utils.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "range_utils.h" diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp new file mode 100644 index 00000000..fc203fd1 --- /dev/null +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -0,0 +1,1004 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "raster/algorithm.h" +#include "raster_store/scaler.h" + +namespace { + +namespace algorithm = raster::algorithm; +using algorithm::Resampling; +using ValueMapping = raster_store::pixel::Mapping; +using Catch::Matchers::WithinAbs; + +template +radix::Raster samples(glm::uvec2 size, std::initializer_list values) +{ + return radix::Raster(size, std::vector(values)); +} + +template +auto scale(const radix::Raster& data, + const radix::Raster& attribution, + int levels, + unsigned halo = 0, + Resampling method = Resampling::NearestNeighbourAndBox, + ValueMapping mapping = ValueMapping::Linear) +{ + return raster_store::scale(data, attribution, halo, levels, method, mapping); +} + +template +void same_raster(const radix::Raster& left, const radix::Raster& right) +{ + REQUIRE(left.size() == right.size()); + CHECK(std::ranges::equal(left.bytes(), right.bytes())); +} + +auto field(glm::uvec2 interior, unsigned halo, int offset_x = 0, int offset_y = 0) +{ + const auto size = interior + glm::uvec2(2 * halo); + std::pair result { radix::Raster(size), radix::Raster(size) }; + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + const int gx = static_cast(x) - static_cast(halo) + offset_x; + const int gy = static_cast(y) - static_cast(halo) + offset_y; + result.first.pixel({ x, y }) = float(std::sin(gx * 0.7) + std::cos(gy * 0.3) + gx * 0.01); + result.second.pixel({ x, y }) = (3 * gx + gy) % 11 == 0 ? 0 : (gx < 8 ? 1 : 2); + } + } + return result; +} + +double reference_lanczos(const radix::Raster& data, const radix::Raster&, unsigned halo, unsigned x, unsigned y, int radius) +{ + const auto weight = [radius](double distance) { + const auto sinc = [](double value) { return value == 0 ? 1. : std::sin(std::numbers::pi * value) / (std::numbers::pi * value); }; + return std::abs(distance) >= 2 * radius ? 0. : sinc(distance / 2) * sinc(distance / (2 * radius)); + }; + const double centre_x = halo + 2 * x + 0.5; + const double centre_y = halo + 2 * y + 0.5; + double sum = 0; + double normalization = 0; + for (unsigned row = 0; row < data.height(); ++row) { + for (unsigned column = 0; column < data.width(); ++column) { + const double contribution = weight(column - centre_x) * weight(row - centre_y); + sum += contribution * data.pixel({ column, row }); + normalization += contribution; + } + } + return sum / normalization; +} + +static_assert(std::same_as, float>); +static_assert(std::same_as, float>); +static_assert(std::same_as, float>); +static_assert(std::same_as, double>); +static_assert(std::same_as, double>); +static_assert(std::same_as, long double>); +static_assert(std::same_as>, glm::vec4>); +static_assert(std::same_as>, glm::vec<3, long double>>); + +} // namespace + +TEST_CASE("raster halo queries use signed zoom levels and complete filter chains", "[raster-algorithm]") +{ + for (auto method : { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { + CHECK(algorithm::required_halo(0, method).value() == 0); + CHECK(algorithm::required_halo(4, method).value() == algorithm::detail::upscaling_radius(method)); + } + for (int levels = 1; levels <= 4; ++levels) { + CHECK(algorithm::required_halo(-levels, Resampling::NearestNeighbourAndBox).value() == 0); + CHECK(algorithm::required_halo(-levels, Resampling::BiliinearAndBox).value() == 0); + CHECK(algorithm::required_halo(-levels, Resampling::Lanczos2).value() == 3u * ((1u << levels) - 1)); + CHECK(algorithm::required_halo(-levels, Resampling::Lanczos3).value() == 5u * ((1u << levels) - 1)); + CHECK(algorithm::required_halo(-levels, Resampling::Lanczos4).value() == 7u * ((1u << levels) - 1)); + } + CHECK_FALSE(algorithm::required_halo((std::numeric_limits::min)(), Resampling::BiliinearAndBox)); + CHECK_FALSE(algorithm::required_halo((std::numeric_limits::max)(), Resampling::BiliinearAndBox)); + CHECK_FALSE(algorithm::required_halo(0, static_cast(99))); + CHECK(algorithm::required_halo(-31, Resampling::Lanczos4).error().code() == Error::Code::ResourceExhausted); +} + +TEST_CASE("raster source window query includes kernel support and source halo", "[raster-algorithm]") +{ + const auto up = algorithm::required_source_window({ 10, 10 }, 3, 1, Resampling::Lanczos3, { 0, 3 }, { 1, 1 }); + REQUIRE(up); + CHECK(up->origin == glm::uvec2(0, 2)); + CHECK(up->size == glm::uvec2(6)); + const auto down = algorithm::required_source_window({ 14, 14 }, 5, -1, Resampling::Lanczos3, { 0, 0 }, { 2, 2 }); + REQUIRE(down); + CHECK(down->origin == glm::uvec2(0)); + CHECK(down->size == glm::uvec2(14)); + const auto copy = algorithm::required_source_window({ 10, 10 }, 3, 0, Resampling::Lanczos3, { -2, 1 }, { 2, 3 }); + REQUIRE(copy); + CHECK(copy->origin == glm::uvec2(1, 4)); + CHECK(copy->size == glm::uvec2(2, 3)); + CHECK_FALSE(algorithm::required_source_window({ 10, 10 }, 2, 1, Resampling::Lanczos3, { 0, 0 }, { 1, 1 })); + CHECK_FALSE(algorithm::required_source_window({ 10, 10 }, 3, 32, Resampling::Lanczos3, { 0, 0 }, { 1, 1 })); +} + +TEST_CASE("raster scaling rejects invalid geometry and dimensions before allocation", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(2), 1.f); + radix::Raster attribution(glm::uvec2(2), 1); + CHECK_FALSE(scale(data, radix::Raster(3), 0)); + CHECK_FALSE(scale(radix::Raster(), radix::Raster(), 0)); + CHECK_FALSE(scale(data, attribution, 0, 1)); + CHECK_FALSE(scale(data, attribution, 0, (std::numeric_limits::max)())); + CHECK_FALSE(scale(data, attribution, -2)); + CHECK_FALSE(scale(data, attribution, 1, 0, Resampling::BiliinearAndBox)); + CHECK_FALSE(scale(data, attribution, -1, 0, Resampling::Lanczos2)); + CHECK_FALSE(scale(data, attribution, 0, 0, Resampling::BiliinearAndBox, static_cast(99))); + CHECK(scale(data, attribution, 0, 0, Resampling::BiliinearAndBox, ValueMapping::SRGBA)); + CHECK(scale(data, attribution, -1, 0, Resampling::BiliinearAndBox, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); + CHECK(scale(data, attribution, 31).error().code() == Error::Code::ResourceExhausted); + CHECK_FALSE(raster_store::reduce(data, attribution, 0, (std::numeric_limits::max)(), algorithm::Max {})); +} + +TEST_CASE("zero zoom levels crop both rasters preserving payload bits", "[raster-algorithm]") +{ + auto [data, attribution] = field({ 3, 2 }, 2); + data.pixel({ 2, 2 }) = std::bit_cast(std::uint32_t(0x7fc01234)); + data.pixel({ 3, 2 }) = -0.f; + attribution.pixel({ 2, 2 }) = 0; + auto output = scale(data, attribution, 0, 2, Resampling::Lanczos4); + REQUIRE(output); + CHECK(output->first.size() == glm::uvec2(3, 2)); + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 0; x < 3; ++x) { + CHECK(std::bit_cast(output->first.pixel({ x, y })) == std::bit_cast(data.pixel({ x + 2, y + 2 }))); + CHECK(output->second.pixel({ x, y }) == attribution.pixel({ x + 2, y + 2 })); + } + } + unsigned calls = 0; + const auto reducer = [&calls](std::span) { + ++calls; + return 0.f; + }; + auto reduced = raster_store::reduce(data, attribution, 2, 0, algorithm::linear_conversion>(), reducer); + REQUIRE(reduced); + CHECK(calls == 0); + same_raster(output->first, reduced->first); + same_raster(output->second, reduced->second); +} + +TEST_CASE("nearest raster upscaling copies payloads and attribution directly", "[raster-algorithm]") +{ + const auto nan = std::bit_cast(std::uint32_t(0x7fc03210)); + const auto data = samples({ 3, 2 }, { 1, nan, -0.f, 4, 5, 6 }); + const auto attribution = samples({ 3, 2 }, { 7, 0, 9, 4, 5, 6 }); + auto result = scale(data, attribution, 2); + REQUIRE(result); + CHECK(result->first.size() == glm::uvec2(12, 8)); + for (unsigned y = 0; y < 8; ++y) { + for (unsigned x = 0; x < 12; ++x) { + CHECK(std::bit_cast(result->first.pixel({ x, y })) == std::bit_cast(data.pixel({ x / 4, y / 4 }))); + CHECK(result->second.pixel({ x, y }) == attribution.pixel({ x / 4, y / 4 })); + } + } +} + +TEST_CASE("bilinear raster upscaling respects area phase and nearest attribution", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(5, 4)); + radix::Raster attribution(glm::uvec2(5, 4)); + for (unsigned y = 0; y < 4; ++y) { + for (unsigned x = 0; x < 5; ++x) { + data.pixel({ x, y }) = 2. * x + 3. * y; + attribution.pixel({ x, y }) = static_cast(1 + x + 5 * y); + } + } + const auto output = scale(data, attribution, 2, 1, Resampling::BiliinearAndBox); + REQUIRE(output); + CHECK(output->first.size() == glm::uvec2(12, 8)); + for (unsigned y = 0; y < 8; ++y) { + for (unsigned x = 0; x < 12; ++x) { + const double expected = 2 * (1 + (x + 0.5) / 4 - 0.5) + 3 * (1 + (y + 0.5) / 4 - 0.5); + CHECK_THAT(output->first.pixel({ x, y }), WithinAbs(expected, 1e-12)); + CHECK(output->second.pixel({ x, y }) == attribution.pixel({ x / 4 + 1, y / 4 + 1 })); + } + } +} + +TEST_CASE("bilinear interpolation includes unattributed samples", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(3), 100); + radix::Raster ids(glm::uvec2(3), 0); + data.pixel({ 1, 1 }) = 0; + const auto result = scale(data, ids, 1, 1, Resampling::BiliinearAndBox); + REQUIRE(result); + for (const auto value : result->first) { + CHECK(value == 43.75f); + } + for (const auto id : result->second) { + CHECK(id == 0); + } + same_raster(result->first, algorithm::scale(data, 1, 1, Resampling::BiliinearAndBox).value()); +} + +TEST_CASE("box reduction votes locally and includes every payload", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 2, 6, 6, 10 }); + auto attribution = samples({ 2, 2 }, { 1, 0, 2, 2 }); + const auto result = scale(data, attribution, -1); + REQUIRE(result); + CHECK(result->first.pixel({ 0, 0 }) == 6); + CHECK(result->second.pixel({ 0, 0 }) == 2); + attribution = samples({ 2, 2 }, { 5, 0, 3, 9 }); + CHECK(scale(data, attribution, -1)->second.pixel({ 0, 0 }) == 5); + attribution = samples({ 2, 2 }, { 5, 3, 3, 5 }); + CHECK(scale(data, attribution, -1)->second.pixel({ 0, 0 }) == 5); + attribution.fill(0); + const auto empty = scale(data, attribution, -1); + REQUIRE(empty); + CHECK(empty->second.pixel({ 0, 0 }) == 0); + CHECK(empty->first.pixel({ 0, 0 }) == 6); +} + +TEST_CASE("repeated reduction uses stage rounding and attribution", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(4), 0); + radix::Raster attribution(glm::uvec2(4), 0); + attribution.pixel({ 0, 0 }) = 5; + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 2; x < 4; ++x) { + data.pixel({ x, y }) = 100; + attribution.pixel({ x, y }) = 8; + } + } + const auto direct = scale(data, attribution, -2); + REQUIRE(direct); + CHECK(direct->first.pixel({ 0, 0 }) == 25); + CHECK(direct->second.pixel({ 0, 0 }) == 0); + const auto first = scale(data, attribution, -1); + REQUIRE(first); + const auto second = scale(first->first, first->second, -1); + REQUIRE(second); + same_raster(direct->first, second->first); + same_raster(direct->second, second->second); +} + +TEST_CASE("custom reducers receive four decoded row-major samples and immutable configuration", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 1, 999, 3, 4 }); + radix::Raster ids(glm::uvec2(2), 0); + const auto conversion = std::tuple { [](const std::uint16_t& value) { return double(value) * 2; }, + [](const double& value) { return static_cast(value / 2); } }; + const auto reducer = [bias = 2.](std::span values) { + REQUIRE(values.size() == 4); + CHECK(values[0] == 2); + CHECK(values[1] == 1998); + CHECK(values[2] == 6); + CHECK(values[3] == 8); + return values[2] + bias; + }; + const auto result = raster_store::reduce(data, ids, 0, 1, conversion, reducer); + REQUIRE(result); + CHECK(result->first.pixel({ 0, 0 }) == 4); + CHECK(result->second.pixel({ 0, 0 }) == 0); + CHECK(algorithm::reduce(data, 0, 1, algorithm::Max {})->pixel({ 0, 0 }) == 999); +} + +TEST_CASE("provided raster reducers are component-wise with even median", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { { 8, 1, 10 }, { 2, 7, 20 }, { 6, 3, 30 }, { 4, 5, 40 } }); + CHECK(algorithm::reduce(data, 0, 1, algorithm::Min {})->pixel({ 0, 0 }) == glm::vec3(2, 1, 10)); + CHECK(algorithm::reduce(data, 0, 1, algorithm::Max {})->pixel({ 0, 0 }) == glm::vec3(8, 7, 40)); + CHECK(algorithm::reduce(data, 0, 1, algorithm::Median {})->pixel({ 0, 0 }) == glm::vec3(5, 4, 25)); +} + +TEMPLATE_TEST_CASE("raster scalar working types support integer endpoints", + "[raster-algorithm]", + std::int8_t, + std::uint8_t, + std::int16_t, + std::uint16_t, + std::int32_t, + std::uint32_t, + std::int64_t, + std::uint64_t, + float, + double) +{ + radix::Raster ids(glm::uvec2(2), 1); + for (const auto value : { (std::numeric_limits::lowest)(), (std::numeric_limits::max)() }) { + radix::Raster data(glm::uvec2(2), value); + const auto result = scale(data, ids, -1); + REQUIRE(result); + CHECK(result->first.pixel({ 0, 0 }) == value); + const auto maximum = raster_store::reduce(data, ids, 0, 1, algorithm::Max {}); + REQUIRE(maximum); + CHECK(maximum->first.pixel({ 0, 0 }) == value); + } +} + +TEST_CASE("integer raster conversion rounds and clamps finite custom values", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(2), 0); + radix::Raster ids(glm::uvec2(2), 1); + const auto convert = [&](float value) { + const auto reducer = [value](std::span) { return value; }; + return raster_store::reduce(data, ids, 0, 1, algorithm::linear_conversion>(), reducer); + }; + CHECK(convert(0.5f)->first.pixel({ 0, 0 }) == 1); + CHECK(convert(-0.5f)->first.pixel({ 0, 0 }) == -1); + CHECK(convert(1e20f)->first.pixel({ 0, 0 }) == 32767); + CHECK(convert(-1e20f)->first.pixel({ 0, 0 }) == -32768); + radix::Raster large(glm::uvec2(2), 0); + const auto upper = [](std::span) { return std::ldexp(1.L, 64); }; + CHECK(raster_store::reduce(large, ids, 0, 1, algorithm::linear_conversion(), upper)->first.pixel({ 0, 0 }) + == (std::numeric_limits::max)()); +} + +TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with independent alpha", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { { 0, 0, 0, 255 }, { 255, 255, 255, 0 }, { 0, 0, 0, 128 }, { 255, 255, 255, 127 } }); + radix::Raster ids(glm::uvec2(2), 1); + const auto result = scale(data, ids, -1, 0, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + REQUIRE(result); + CHECK(result->first.pixel({ 0, 0 }) == glm::u8vec4(188, 188, 188, 128)); + const auto reducer = [](std::span values) { + CHECK(values[0].x == 0); + CHECK(values[1].x == 1); + CHECK(values[2].w == 128.f / 255.f); + return algorithm::Median {}(values); + }; + const auto custom = raster_store::reduce(data, ids, 0, 1, algorithm::srgb_conversion(), reducer); + REQUIRE(custom); + same_raster(custom->first, result->first); + const auto nearest = scale(data, ids, 1, 0, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + REQUIRE(nearest); + CHECK(nearest->first.pixel({ 2, 0 }) == data.pixel({ 1, 0 })); + + radix::Raster rgb(glm::uvec2(6), glm::u8vec3(0)); + radix::Raster rgb_ids(glm::uvec2(6), 1); + for (unsigned y = 0; y < 6; ++y) { + for (unsigned x = 0; x < 6; ++x) { + rgb.pixel({ x, y }) = glm::u8vec3((13 * x + 79 * y) % 256); + } + } + const auto direct = scale(rgb, rgb_ids, -2, 1, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + const auto first = scale(rgb, rgb_ids, -1, 1, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + REQUIRE(direct); + REQUIRE(first); + const auto second = scale(first->first, first->second, -1, 0, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + REQUIRE(second); + same_raster(direct->first, second->first); +} + +TEST_CASE("Lanczos reduction matches an independent two-dimensional reference", "[raster-algorithm]") +{ + for (int radius = 2; radius <= 4; ++radius) { + const auto filter = radius == 2 ? Resampling::Lanczos2 : (radius == 3 ? Resampling::Lanczos3 : Resampling::Lanczos4); + const unsigned halo = 2 * radius - 1; + auto [data, ids] = field({ 6, 4 }, halo); + const auto result = scale(data, ids, -1, halo, filter); + REQUIRE(result); + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 0; x < 3; ++x) { + CHECK_THAT(result->first.pixel({ x, y }), WithinAbs(reference_lanczos(data, ids, halo, x, y, radius), 3e-6)); + } + } + CHECK_FALSE(scale(data, ids, -1, halo - 1, filter)); + } +} + +TEST_CASE("Lanczos includes outer support when the local block is unattributed", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(8), 100); + radix::Raster ids(glm::uvec2(8), 1); + for (unsigned y = 3; y < 5; ++y) { + for (unsigned x = 3; x < 5; ++x) { + ids.pixel({ x, y }) = 0; + data.pixel({ x, y }) = -17; + } + } + const auto result = scale(data, ids, -1, 3, Resampling::Lanczos2); + REQUIRE(result); + CHECK(result->second.pixel({ 0, 0 }) == 0); + CHECK_THAT(result->first.pixel({ 0, 0 }), WithinAbs(reference_lanczos(data, ids, 3, 0, 0, 2), 3e-5)); +} + +TEST_CASE("repeated Lanczos retains intermediate halo and matches separately supplied stages", "[raster-algorithm]") +{ + auto [data, ids] = field({ 8, 4 }, 15); + const auto direct = scale(data, ids, -2, 15, Resampling::Lanczos3); + REQUIRE(direct); + // Treat the extra source region as interior in the first call, retaining + // five output pixels around the original interior for the second call. + const auto first = scale(data, ids, -1, 5, Resampling::Lanczos3); + REQUIRE(first); + const auto second = scale(first->first, first->second, -1, 5, Resampling::Lanczos3); + REQUIRE(second); + same_raster(direct->first, second->first); + same_raster(direct->second, second->second); + auto [too_small, small_ids] = field({ 8, 4 }, 14); + CHECK_FALSE(scale(too_small, small_ids, -2, 14, Resampling::Lanczos3)); +} + +TEST_CASE("independent area tiles match their metatile through repeated scaling", "[raster-algorithm]") +{ + for (const auto filter : { Resampling::NearestNeighbourAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { + const auto halo = algorithm::required_halo(-2, filter).value(); + auto [whole, whole_ids] = field({ 16, 8 }, halo); + const auto reference = scale(whole, whole_ids, -2, halo, filter); + REQUIRE(reference); + for (unsigned tile = 0; tile < 2; ++tile) { + auto [data, ids] = field({ 8, 8 }, halo, int(tile * 8)); + const auto result = scale(data, ids, -2, halo, filter); + REQUIRE(result); + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 0; x < 2; ++x) { + CHECK(result->first.pixel({ x, y }) == reference->first.pixel({ x + tile * 2, y })); + CHECK(result->second.pixel({ x, y }) == reference->second.pixel({ x + tile * 2, y })); + } + } + } + } +} + +TEST_CASE("Lanczos reduction preserves constants and suppresses high frequencies", "[raster-algorithm]") +{ + for (const auto filter : { Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { + const auto halo = algorithm::required_halo(-1, filter).value(); + const glm::uvec2 size(32 + 2 * halo, 2 + 2 * halo); + radix::Raster data(size, 7.f); + radix::Raster ids(size, 1); + const auto constant = scale(data, ids, -1, halo, filter); + REQUIRE(constant); + for (const auto value : constant->first) { + CHECK_THAT(value, WithinAbs(7, 3e-5)); + } + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + data.pixel({ x, y }) = float(std::cos(0.8 * std::numbers::pi * (int(x) - int(halo)))); + } + } + const auto filtered = scale(data, ids, -1, halo, filter); + REQUIRE(filtered); + for (const auto value : filtered->first) { + CHECK(std::abs(value) < 0.03f); + } + } +} + +TEMPLATE_TEST_CASE("raster vector arithmetic instantiates every working precision", "[raster-algorithm]", glm::u16vec4, glm::i32vec3, glm::u64vec2, glm::dvec4) +{ + radix::Raster data(glm::uvec2(8), TestType(12)); + radix::Raster ids(glm::uvec2(8), 1); + const auto filtered = scale(data, ids, -1, 3, Resampling::Lanczos2); + const auto median = raster_store::reduce(data, ids, 3, 1, algorithm::Median {}); + REQUIRE(filtered); + REQUIRE(median); + for (glm::length_t i = 0; i < TestType::length(); ++i) { + CHECK_THAT(static_cast(filtered->first.pixel({ 0, 0 })[i]), WithinAbs(12, 1e-12)); + CHECK(median->first.pixel({ 0, 0 })[i] == 12); + } +} + +TEST_CASE("weighted filters retain floating overshoot and clamp integers", "[raster-algorithm]") +{ + radix::Raster impulse(glm::uvec2(8), 0); + radix::Raster impulse_ids(glm::uvec2(8), 1); + impulse.pixel({ 1, 3 }) = 1; + const auto ringing = scale(impulse, impulse_ids, -1, 3, Resampling::Lanczos2); + REQUIRE(ringing); + CHECK(ringing->first.pixel({ 0, 0 }) < 0); + CHECK_THAT(ringing->first.pixel({ 0, 0 }), WithinAbs(reference_lanczos(impulse, impulse_ids, 3, 0, 0, 2), 1e-7)); + radix::Raster integer_impulse(glm::uvec2(8), 0); + integer_impulse.pixel({ 1, 3 }) = 255; + const auto clamped = scale(integer_impulse, impulse_ids, -1, 3, Resampling::Lanczos2); + REQUIRE(clamped); + CHECK(clamped->first.pixel({ 0, 0 }) == 0); +} + +TEST_CASE("bilinear colour interpolation decodes sRGB before combining samples", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(3), glm::u8vec3(255)); + radix::Raster ids(glm::uvec2(3), 1); + data.pixel({ 1, 1 }) = glm::u8vec3(0); + const auto output = scale(data, ids, 1, 1, Resampling::BiliinearAndBox, ValueMapping::SRGBA); + REQUIRE(output); + const auto expected = std::round(255 * (1.055 * std::pow(1. - 0.75 * 0.75, 1. / 2.4) - 0.055)); + for (const auto value : output->first) { + CHECK(value == glm::u8vec3(static_cast(expected))); + } +} + +TEMPLATE_TEST_CASE("identity integer median avoids overflow and rounds halves away from zero", + "[raster-algorithm]", + std::int16_t, + std::uint16_t, + std::int32_t, + std::uint32_t, + std::int64_t, + std::uint64_t) +{ + using T = TestType; + const auto check = [](T lower, T upper, T expected) { + const auto data = samples({ 2, 2 }, { lower, upper, lower, upper }); + const auto result = algorithm::reduce(data, 0, 1, algorithm::Median {}); + REQUIRE(result); + CHECK(result->pixel({ 0, 0 }) == expected); + }; + const T highest = (std::numeric_limits::max)(); + const T lowest = (std::numeric_limits::lowest)(); + check(highest, highest, highest); + check(lowest, lowest, lowest); + check(highest - 1, highest, highest); + check(0, 1, 1); + if constexpr (std::is_signed_v) { + check(-1, 0, -1); + check(-2, 1, -1); + check(-1, 2, 1); + check(lowest, highest, -1); + check(lowest, lowest + 1, lowest); + } +} + +TEST_CASE("mode treats zero as ordinary and equality ties select first occurrence", "[raster-algorithm]") +{ + const auto check = [](std::array values, float expected) { + const float result = algorithm::Mode {}(std::span(values)); + if (std::isnan(expected)) { + CHECK(std::isnan(result)); + } else { + CHECK(result == expected); + } + }; + check({ 0, 0, 0, 7 }, 0); + check({ 4, 2, 2, 4 }, 4); + check({ 3, 0, 2, 4 }, 3); + const float nan = std::numeric_limits::quiet_NaN(); + check({ nan, nan, nan, nan }, nan); + check({ nan, 2, nan, 2 }, 2); + check({ nan, 2, 3, 4 }, nan); +} + +TEST_CASE("scalers accept ordinary and clamped views with matching destination results", "[raster-algorithm]") +{ + auto [backing, ids] = field({ 12, 8 }, 0); + const auto source = raster::make_view(backing, { 2, 2 }, { 8, 4 }).value(); + const auto padded = raster::make_clamped_view(source, { -9, -9 }, { 26, 22 }).value(); + const auto materialized = algorithm::copy(padded).value(); + radix::Raster output_backing(glm::uvec2(6, 5), -100); + const auto output = raster::make_view(output_backing, { 2, 2 }, { 2, 1 }).value(); + for (const auto filter : { Resampling::NearestNeighbourAndBox, Resampling::Lanczos2 }) { + const auto expected = algorithm::scale(materialized, 9, -2, filter); + const auto actual = algorithm::scale(padded, 9, -2, filter); + REQUIRE(expected); + REQUIRE(actual); + same_raster(*actual, *expected); + REQUIRE(algorithm::scale(padded, 9, -2, filter, output)); + same_raster(algorithm::copy(output).value(), *expected); + } + CHECK(output_backing.pixel({ 0, 0 }) == -100); + const auto reduced = algorithm::reduce(source, 0, 2, algorithm::Max {}); + REQUIRE(reduced); + REQUIRE(algorithm::reduce(source, 0, 2, algorithm::Max {}, output)); + same_raster(algorithm::copy(output).value(), *reduced); + const auto padded_reduction = algorithm::reduce(padded, 9, 2, algorithm::Max {}); + REQUIRE(padded_reduction); + same_raster(*padded_reduction, *reduced); + const auto up_padded = raster::make_clamped_view(source, { -1, -1 }, { 10, 6 }).value(); + const auto up = algorithm::scale(up_padded, 1, 1, Resampling::BiliinearAndBox); + REQUIRE(up); + radix::Raster up_output(up->size()); + REQUIRE(algorithm::scale(up_padded, 1, 1, Resampling::BiliinearAndBox, up_output)); + same_raster(*up, up_output); +} + +TEST_CASE("scaling validates geometry and overlap before invoking callables or writing", "[raster-algorithm]") +{ + radix::Raster source(glm::uvec2(6), 1); + radix::Raster output(glm::uvec2(2), -10); + const auto never = [](std::span) -> float { + FAIL("invalid input must not invoke reducer"); + return 0; + }; + CHECK_FALSE(algorithm::reduce(source, 0, 1, never, output)); + CHECK_FALSE(algorithm::reduce(source, 3, 1, never)); + CHECK_FALSE(algorithm::reduce(radix::Raster(), 0, 0, never)); + const auto overlapping = raster::make_view(source, { 0, 0 }, { 2, 2 }).value(); + CHECK_FALSE(algorithm::reduce(source, 1, 1, never, overlapping)); + CHECK_FALSE(algorithm::scale(source, 1, -1, Resampling::BiliinearAndBox, overlapping)); + const auto identical_crop = raster::make_view(source, { 1, 1 }, { 4, 4 }).value(); + REQUIRE(algorithm::reduce(source, 1, 0, never, identical_crop)); + REQUIRE(algorithm::scale(source, 1, 0, Resampling::BiliinearAndBox, identical_crop)); + const auto partial_crop = raster::make_view(source, { 0, 0 }, { 4, 4 }).value(); + CHECK_FALSE(algorithm::reduce(source, 1, 0, never, partial_crop)); + const auto clamped = raster::make_clamped_view(source, { -1, -1 }, { 8, 8 }).value(); + CHECK_FALSE(algorithm::reduce(clamped, 2, 1, never, overlapping)); + CHECK(std::ranges::all_of(output, [](float value) { return value == -10; })); +} + +TEST_CASE("paired destination validation prevents partial writes on invalid attribution output", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(4), 12); + radix::Raster ids(glm::uvec2(4), 0); + radix::Raster output(glm::uvec2(2), 99); + radix::Raster wrong(glm::uvec2(3), 77); + CHECK_FALSE(raster_store::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, wrong)); + CHECK_FALSE(raster_store::reduce(data, ids, 0, 1, algorithm::Max {}, output, wrong)); + const auto cross = raster::make_view(data, { 0, 0 }, { 2, 2 }).value(); + CHECK_FALSE(raster_store::reduce(data, ids, 0, 1, algorithm::Max {}, output, cross)); + CHECK_FALSE(raster_store::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, cross)); + CHECK_FALSE(raster_store::reduce(data, ids, 0, 1, algorithm::Max {}, output, output)); + CHECK(std::ranges::all_of(output, [](auto value) { return value == 99; })); + radix::Raster out_ids(glm::uvec2(2)); + REQUIRE(raster_store::reduce(data, ids, 0, 1, algorithm::Max {}, output, out_ids)); + CHECK(std::ranges::all_of(output, [](auto value) { return value == 12; })); + CHECK(std::ranges::all_of(out_ids, [](auto value) { return value == 0; })); +} + +TEST_CASE("nearest and crop bypass custom conversion and reducers", "[raster-algorithm]") +{ + const auto bits = std::uint32_t(0x7fc01234); + radix::Raster data(glm::uvec2(2), std::bit_cast(bits)); + const auto conversion = std::tuple { [](const float&) -> double { + FAIL("copy must not decode"); + return 0; + }, + [](const double&) -> float { + FAIL("copy must not encode"); + return 0; + } }; + for (const int level : { 0, 2 }) { + const auto result = algorithm::scale(data, 0, level, Resampling::NearestNeighbourAndBox, conversion); + REQUIRE(result); + for (const auto value : *result) { + CHECK(std::bit_cast(value) == bits); + } + } + const auto reduced = algorithm::reduce(data, 0, 0, conversion, [](std::span) -> double { + FAIL("crop must not reduce"); + return 0; + }); + REQUIRE(reduced); + same_raster(data, *reduced); +} + +TEST_CASE("separable filtering encodes once per two-dimensional reduction step", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 0, 1, 0, 0 }); + const auto rounded = algorithm::scale(data, 0, -1, Resampling::NearestNeighbourAndBox); + REQUIRE(rounded); + CHECK(rounded->pixel({ 0, 0 }) == 0); // Rounding horizontal rows first would produce 1. + const auto conversion = std::tuple { [](const std::int16_t& value) { return double(value) + 10; }, + [](const double& value) { return static_cast(std::round(value - 10)); } }; + const auto custom = algorithm::scale(data, 0, -1, Resampling::NearestNeighbourAndBox, conversion); + REQUIRE(custom); + same_raster(*rounded, *custom); +} + +namespace { +template +concept CanScaleFloat + = requires(radix::Raster& source, const Conversion& conversion) { algorithm::scale(source, 0, -1, Resampling::NearestNeighbourAndBox, conversion); }; +template +concept CanReduceFloat + = requires(radix::Raster& source, const Conversion& conversion) { algorithm::reduce(source, 0, 1, conversion, algorithm::Max {}); }; +using ExpectedDecoder = decltype(std::tuple { [](const float& v) -> Expected { return v; }, [](const float& v) { return v; } }); +using ExpectedEncoder = decltype(std::tuple { [](const float& v) { return v; }, [](const float& v) -> Expected { return v; } }); +using WrongEncoder = decltype(std::tuple { [](const float& v) { return double(v); }, [](const double& v) { return v; } }); +using IntegerWorking = decltype(std::tuple { [](const float& v) { return int(v); }, [](const int& v) { return float(v); } }); +static_assert(!CanScaleFloat && !CanReduceFloat); +static_assert(!CanScaleFloat && !CanReduceFloat); +static_assert(!CanScaleFloat && !CanReduceFloat); +static_assert(!CanScaleFloat && CanReduceFloat); +} // namespace + +TEST_CASE("scaling invokes encoders with const working values", "[raster-algorithm]") +{ + struct Encoder { + float operator()(const double& value) const { return static_cast(value); } + float operator()(double&) const + { + FAIL("encoder must receive a const working value"); + return 0; + } + }; + const auto conversion = std::tuple { [](const float& value) { return double(value); }, Encoder {} }; + radix::Raster source(glm::uvec2(4), 7); + const auto down = algorithm::scale(source, 0, -1, Resampling::NearestNeighbourAndBox, conversion); + const auto up = algorithm::scale(source, 1, 1, Resampling::BiliinearAndBox, conversion); + REQUIRE(down); + REQUIRE(up); + for (const auto value : *down) { + CHECK(value == 7); + } + for (const auto value : *up) { + CHECK(value == 7); + } +} + +TEST_CASE("scaling windows match full output crops across stages and kernels", "[raster-algorithm]") +{ + for (int levels : { -2, 0, 2 }) + for (auto method : + { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { + const unsigned halo = *algorithm::required_halo(levels, method); + auto [data, attribution] = field({ 32, 24 }, halo); + auto full = raster_store::scale(data, attribution, halo, levels, method, ValueMapping::Linear); + REQUIRE(full); + const glm::i64vec2 offset(3, 1); + const glm::uvec2 size(3, 4); + auto window = raster_store::scale(data, attribution, halo, levels, method, offset, size, ValueMapping::Linear); + REQUIRE(window); + same_raster(window->first, *algorithm::copy(*raster::make_view(full->first, offset, size))); + same_raster(window->second, *algorithm::copy(*raster::make_view(full->second, offset, size))); + } +} + +TEST_CASE("scaling windows retain conversion tuples and per-stage encoded rounding", "[raster-algorithm]") +{ + radix::Raster data({ 32, 32 }); + radix::Raster attribution({ 32, 32 }); + for (unsigned y = 0; y < 32; ++y) + for (unsigned x = 0; x < 32; ++x) { + data.pixel({ x, y }) = { x * 7, y * 7, (x * y) % 256, (x + 3 * y) % 256 }; + attribution.pixel({ x, y }) = (x + y) % 3; + } + auto full = raster_store::scale(data, attribution, 0, -2, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + auto window = raster_store::scale(data, attribution, 0, -2, Resampling::NearestNeighbourAndBox, { 1, 2 }, { 4, 3 }, ValueMapping::SRGBA); + REQUIRE(full); + REQUIRE(window); + same_raster(window->first, *algorithm::copy(*raster::make_view(full->first, { 1, 2 }, { 4, 3 }))); + same_raster(window->second, *algorithm::copy(*raster::make_view(full->second, { 1, 2 }, { 4, 3 }))); +} + +TEST_CASE("small upscale windows do not require a representable full raster", "[raster-algorithm]") +{ + radix::Raster source({ 4096, 2 }); + source.fill(17); + auto result = algorithm::scale(source, 0, 20, Resampling::NearestNeighbourAndBox, { 1000000000, 37 }, { 2, 3 }); + REQUIRE(result); + CHECK(result->pixel({ 1, 2 }) == 17); + auto clamped = *raster::make_clamped_view(source, { -1, -1 }, { 4098, 4 }); + auto linear = algorithm::scale(clamped, 1, 20, Resampling::BiliinearAndBox, { 1000000000, 37 }, { 2, 3 }); + REQUIRE(linear); + CHECK(linear->pixel({ 1, 2 }) == 17); +} + +TEST_CASE("scaling validates full destinations and windows before writing", "[raster-algorithm]") +{ + radix::Raster source({ 4, 4 }); + source.fill(1); + radix::Raster destination({ 3, 3 }); + destination.fill(99); + CHECK_FALSE(algorithm::scale(source, 0, 1, Resampling::NearestNeighbourAndBox, destination)); + CHECK_FALSE(algorithm::scale(source, 0, 0, Resampling::NearestNeighbourAndBox, { 2, 2 }, destination)); + CHECK_FALSE(algorithm::scale(source, 0, 0, Resampling::NearestNeighbourAndBox, { 1 << 29, 0 }, destination)); + CHECK(destination.pixel({ 0, 0 }) == 99); + radix::Raster attribution({ 4, 4 }); + radix::Raster wrong({ 2, 2 }); + CHECK_FALSE(raster_store::scale(source, attribution, 0, 1, Resampling::NearestNeighbourAndBox, { 0, 0 }, ValueMapping::Linear, destination, wrong)); + CHECK(destination.pixel({ 0, 0 }) == 99); +} + +TEST_CASE("windowed scaling writes strided destinations and rejects overlap before writes", "[raster-algorithm]") +{ + auto [data, attribution] = field({ 16, 16 }, 1); + auto expected = raster_store::scale(data, attribution, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, { 5, 7 }, ValueMapping::Linear); + REQUIRE(expected); + radix::Raster output({ 10, 12 }); + output.fill(-99); + radix::Raster output_attribution({ 10, 12 }); + output_attribution.fill(99); + auto view = *raster::make_view(output, { 2, 3 }, { 5, 7 }); + auto attribution_view = *raster::make_view(output_attribution, { 2, 3 }, { 5, 7 }); + REQUIRE(raster_store::scale(data, attribution, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, ValueMapping::Linear, view, attribution_view)); + same_raster(*algorithm::copy(view), expected->first); + same_raster(*algorithm::copy(attribution_view), expected->second); + CHECK(output.pixel({ 0, 0 }) == -99); + const auto before = data; + CHECK_FALSE(algorithm::scale(data, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, *raster::make_view(data, { 2, 3 }, { 5, 7 }))); + same_raster(data, before); + radix::Raster oversized({ 40, 40 }); + oversized.fill(99); + CHECK_FALSE(algorithm::scale(data, 1, 1, Resampling::BiliinearAndBox, oversized)); + CHECK(oversized.pixel({ 0, 0 }) == 99); +} + +TEST_CASE("Gaussian kernels are normalized symmetric and reject invalid sizes", "[raster][algorithm]") +{ + CHECK_FALSE(raster::algorithm::gaussian_kernel(0)); + CHECK_FALSE(raster::algorithm::gaussian_kernel(4)); + REQUIRE(*raster::algorithm::gaussian_kernel(1) == std::vector { 1 }); + for (unsigned size : { 3u, 5u, 9u }) { + auto kernel = raster::algorithm::gaussian_kernel(size); + REQUIRE(kernel); + double sum = 0; + for (unsigned i = 0; i < size; ++i) { + CHECK((*kernel)[i] > 0); + CHECK((*kernel)[i] == (*kernel)[size - i - 1]); + sum += (*kernel)[i]; + } + CHECK(sum == Catch::Approx(1)); + } +} + +TEST_CASE("Lanczos upscaling preserves halos and matches independent sinc reconstruction", "[raster-algorithm]") +{ + for (int levels : { 1, 2, 3 }) { + const unsigned factor = 1u << levels; + for (unsigned radius : { 2u, 3u, 4u }) { + const auto method = radius == 2 ? Resampling::Lanczos2 : radius == 3 ? Resampling::Lanczos3 : Resampling::Lanczos4; + const unsigned halo = unsigned(std::ceil(double(factor) * (radius - 1) / (factor - 1) + 0.5)); + auto [data, ids] = field({ 4, 4 }, halo); + const glm::uvec2 size = glm::uvec2(4 * factor + 2 * halo); + const glm::i64vec2 offset(-std::int64_t(halo)); + auto output = raster_store::scale(data, ids, halo, levels, method, offset, size, ValueMapping::Linear); + REQUIRE(output); + const auto sinc = [](double x) { return x == 0 ? 1. : std::sin(std::numbers::pi * x) / (std::numbers::pi * x); }; + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + const double cx = halo + (int(x) - int(halo) + 0.5) / factor - 0.5; + const double cy = halo + (int(y) - int(halo) + 0.5) / factor - 0.5; + double total = 0, normalization = 0; + for (unsigned iy = 0; iy < data.height(); ++iy) { + for (unsigned ix = 0; ix < data.width(); ++ix) { + const double dx = ix - cx, dy = iy - cy; + if (std::abs(dx) >= radius || std::abs(dy) >= radius) + continue; + const double weight = sinc(dx) * sinc(dx / radius) * sinc(dy) * sinc(dy / radius); + total += weight * data.pixel({ ix, iy }); + normalization += weight; + } + } + CHECK_THAT(output->first.pixel({ x, y }), WithinAbs(total / normalization, 3e-6)); + const glm::uvec2 nearest( + halo + int(std::floor((int(x) - int(halo) + 0.5) / factor)), halo + int(std::floor((int(y) - int(halo) + 0.5) / factor))); + CHECK(output->second.pixel({ x, y }) == ids.pixel(nearest)); + } + } + auto insufficient = *raster::make_view(data, { 1, 1 }, data.size() - glm::uvec2(2)); + CHECK_FALSE(algorithm::scale(insufficient, halo - 1, levels, method, offset, size)); + data.fill(7); + auto constant = algorithm::scale(data, halo, levels, method, offset, size); + REQUIRE(constant); + for (auto value : *constant) + CHECK_THAT(value, WithinAbs(7, 8e-6)); + } + } +} + +TEST_CASE("Lanczos3 enlarges a 256 tile while preserving five halo pixels", "[raster-algorithm]") +{ + auto [tile, ids] = field({ 256, 256 }, 5); + auto output = algorithm::scale(tile, 5, 1, Resampling::Lanczos3, { -5, -5 }, { 522, 522 }); + REQUIRE(output); + CHECK(output->size() == glm::uvec2(522)); + // The same spatial samples described as a larger interior give the same output. + auto [larger, larger_ids] = field({ 266, 266 }, 3, -5, -5); + auto full = algorithm::scale(larger, 3, 1, Resampling::Lanczos3); + REQUIRE(full); + same_raster(*output, *algorithm::copy(*raster::make_view(*full, { 5, 5 }, { 522, 522 }))); +} + +TEST_CASE("halo windows match larger raster crops in both scaling directions", "[raster-algorithm]") +{ + for (auto method : { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { + for (int levels : { -2, -1, 0, 1, 2 }) { + const unsigned factor = 1u << std::abs(levels); + const unsigned support = *algorithm::required_halo(levels, method); + const unsigned margin = levels < 0 ? 2 * factor : 2; + const unsigned halo = support + margin; + auto [data, ids] = field({ 16, 16 }, halo); + const unsigned interior_side = levels < 0 ? 16 / factor : 16 * factor; + const glm::uvec2 output_size(interior_side + 4); + auto window = raster_store::scale(data, ids, halo, levels, method, { -2, -2 }, output_size, ValueMapping::Linear); + auto reference = raster_store::scale(data, ids, support, levels, method, ValueMapping::Linear); + REQUIRE(window); + REQUIRE(reference); + const unsigned crop = levels < 0 ? margin / factor - 2 : margin * factor - 2; + same_raster(window->first, *algorithm::copy(*raster::make_view(reference->first, glm::uvec2(crop), output_size))); + same_raster(window->second, *algorithm::copy(*raster::make_view(reference->second, glm::uvec2(crop), output_size))); + } + } +} + +TEST_CASE("negative halo windows validate support before touching paired strided destinations", "[raster-algorithm]") +{ + auto [data, ids] = field({ 8, 8 }, 5); + radix::Raster backing({ 30, 30 }); + radix::Raster backing_ids({ 30, 30 }); + backing.fill(-99); + backing_ids.fill(99); + auto output = *raster::make_view(backing, { 2, 2 }, { 26, 26 }); + auto attribution = *raster::make_view(backing_ids, { 2, 2 }, { 26, 26 }); + CHECK_FALSE(raster_store::scale(data, ids, 5, 1, Resampling::Lanczos3, { -6, -5 }, ValueMapping::Linear, output, attribution)); + CHECK(std::ranges::all_of(backing, [](float value) { return value == -99; })); + CHECK(std::ranges::all_of(backing_ids, [](unsigned value) { return value == 99; })); + REQUIRE(raster_store::scale(data, ids, 5, 1, Resampling::Lanczos3, { -5, -5 }, ValueMapping::Linear, output, attribution)); + auto expected = algorithm::scale(data, 5, 1, Resampling::Lanczos3, { -5, -5 }, { 26, 26 }); + REQUIRE(expected); + same_raster(*algorithm::copy(output), *expected); + CHECK(backing.pixel({ 0, 0 }) == -99); + CHECK(backing_ids.pixel({ 0, 0 }) == 99); +} + +TEST_CASE("Lanczos upscaling retains floating overshoot and encodes bounded integers", "[raster-algorithm]") +{ + radix::Raster source({ 12, 12 }); + radix::Raster encoded({ 12, 12 }); + for (unsigned y = 0; y < 12; ++y) + for (unsigned x = 0; x < 12; ++x) { + source.pixel({ x, y }) = x < 6 ? 0 : 1; + encoded.pixel({ x, y }) = x < 6 ? 0 : 255; + } + auto floating = algorithm::scale(source, 3, 2, Resampling::Lanczos3); + auto integer = algorithm::scale(encoded, 3, 2, Resampling::Lanczos3); + REQUIRE(floating); + REQUIRE(integer); + bool negative = false, above_one = false; + for (unsigned y = 0; y < floating->height(); ++y) + for (unsigned x = 0; x < floating->width(); ++x) { + const float value = floating->pixel({ x, y }); + if (value < -0.01) { + negative = true; + CHECK(integer->pixel({ x, y }) == 0); + } + if (value > 1.01) { + above_one = true; + CHECK(integer->pixel({ x, y }) == 255); + } + } + CHECK(negative); + CHECK(above_one); +} + +TEST_CASE("scaling accepts raster dimensions whose pixel count exceeds 32 bits", "[raster-algorithm]") +{ + constexpr unsigned side = 1u << 17; + auto geometry = algorithm::detail::scale_geometry({ side, side }, 0, 0, Resampling::NearestNeighbourAndBox); + REQUIRE(geometry); + CHECK(geometry->output == glm::uvec2(side)); + CHECK(std::size_t(geometry->output.x) * geometry->output.y == (std::size_t(1) << 34)); + // Exercise a large logical raster without requiring a 16 GiB test allocation. + radix::Raster sample(1); + sample.fill(73); + auto large = *raster::make_clamped_view(sample, { 0, 0 }, { side, side }); + auto corner = algorithm::scale(large, 0, 0, Resampling::NearestNeighbourAndBox, { side - 2, side - 2 }, { 2, 2 }); + REQUIRE(corner); + CHECK(std::ranges::all_of(*corner, [](unsigned value) { return value == 73; })); + auto magnified = algorithm::detail::scale_geometry({ side, side }, 0, 13, Resampling::NearestNeighbourAndBox); + REQUIRE(magnified); + CHECK(magnified->output == glm::uvec2(1u << 30)); + CHECK_FALSE(algorithm::detail::scale_geometry({ side, side }, 0, 16, Resampling::NearestNeighbourAndBox)); +} diff --git a/unittests/terrainlib/raster_fold.cpp b/unittests/terrainlib/raster_fold.cpp new file mode 100644 index 00000000..46616483 --- /dev/null +++ b/unittests/terrainlib/raster_fold.cpp @@ -0,0 +1,72 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 + +#include "raster/algorithm/fold.h" + +TEST_CASE("fold visits raster and view pixels read-only in row order", "[raster-fold]") +{ + radix::Raster values({ 3, 2 }, std::vector { 1, 2, 3, 4, 5, 6 }); + const auto append = [](std::string state, const int& pixel) { return state + std::to_string(pixel); }; + CHECK(raster::algorithm::fold(values, std::string {}, append) == "123456"); + CHECK(raster::algorithm::fold(std::as_const(values), std::string {}, append) == "123456"); + auto view = raster::make_view(values, { 1, 0 }, { 2, 2 }); + REQUIRE(view); + CHECK(raster::algorithm::fold(*view, std::string {}, append) == "2356"); + auto clamped = raster::make_clamped_view(values, { -1, 0 }, { 3, 2 }); + REQUIRE(clamped); + CHECK(raster::algorithm::fold(*clamped, std::string {}, append) == "112445"); + CHECK(std::ranges::equal(values.buffer(), std::vector { 1, 2, 3, 4, 5, 6 })); +} + +TEST_CASE("fold supports empty rasters and move-only accumulators", "[raster-fold]") +{ + const auto sum = [](std::unique_ptr state, const int& value) { + *state += value; + return state; + }; + radix::Raster empty; + CHECK(*raster::algorithm::fold(empty, std::make_unique(42), sum) == 42); + radix::Raster values({ 2, 2 }, std::vector { 1, 2, 3, 4 }); + CHECK(*raster::algorithm::fold(values, std::make_unique(5), sum) == 15); +} + +TEST_CASE("fold accumulates finite min and max together", "[raster-fold]") +{ + const radix::Raster values( + { 3, 2 }, std::vector { std::numeric_limits::quiet_NaN(), -7, 12, 3, std::numeric_limits::infinity(), -2 }); + const auto extrema = raster::algorithm::fold(values, + std::pair { std::numeric_limits::infinity(), -std::numeric_limits::infinity() }, + [](std::pair state, const double& value) { + if (std::isfinite(value)) { + state.first = (std::min)(state.first, value); + state.second = (std::max)(state.second, value); + } + return state; + }); + CHECK(extrema.first == -7); + CHECK(extrema.second == 12); +} diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp new file mode 100644 index 00000000..dfc92fd0 --- /dev/null +++ b/unittests/terrainlib/raster_store.cpp @@ -0,0 +1,747 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include + +#include +#include +#include +#include + +#include "../temporary_directory.h" +#include "io/byte.h" +#include "io/image.h" +#include "io/utils.h" +#include "raster_store/storage.h" + +namespace { + +using test::TemporaryDirectory; +namespace attribution = raster_store::attribution; +namespace storage = raster_store::storage; +namespace manifest = raster_store::io::manifest; +namespace tile_codec = raster_store::io::tile_codec; +using raster_store::io::TileCodec; + +constexpr std::string_view entity_json = + R"({"spatial_resolution":0.25,"acquisition_date":"summer 2020","ingestion_date":"2026-09-08",)" + R"("copyright":"Example","copyright_link":"https://example.org","license":"Example license"})"; + +void write_text(const std::filesystem::path& path, const std::string_view text) +{ + REQUIRE(io::byte::write(std::as_bytes(std::span(text)), path, io::byte::WriteMode::Overwrite)); +} + +void write_table(const std::filesystem::path& directory) +{ + write_text(directory / attribution::file_name, "[" + std::string(entity_json) + "]"); +} + +storage::CreateOptions small_options() +{ + storage::CreateOptions options; + options.nominal_tile_size = 4; + return options; +} + +template +void check_bytes(const radix::Raster& left, const radix::Raster& right) +{ + CHECK(left.size() == right.size()); + CHECK(std::ranges::equal(left.bytes(), right.bytes())); +} + +} // namespace + +TEST_CASE("attribution tables preserve complete ordinary objects", "[raster-store][attribution]") +{ + auto table = attribution::parse("[" + std::string(entity_json) + "]"); + REQUIRE(table); + REQUIRE(table->entities.size() == 1); + REQUIRE(table->at(0)); + CHECK((*table->at(0))->spatial_resolution == 0.25); + CHECK((*table->at(0))->acquisition_date == "summer 2020"); + CHECK(table->at(1).error().code() == Error::Code::InvalidInput); + CHECK(table->at(65535).error().code() == Error::Code::Unsupported); + CHECK(table->at(UINT32_MAX).error().code() == Error::Code::Unsupported); + table->entities.resize(65535); + CHECK(table->at(65534)); + + const auto ordinary_empty_values = attribution::parse( + R"([{"spatial_resolution":0,"acquisition_date":"","ingestion_date":"","copyright":"","copyright_link":"","license":""}])"); + REQUIRE(ordinary_empty_values); + CHECK(ordinary_empty_values->entities[0].copyright.empty()); +} + +TEST_CASE("attribution tables reject malformed slots and excessive indices", "[raster-store][attribution]") +{ + for (const auto json : { "{}", "[]", "[null]", "[{}]", "[1]", "not json" }) { + INFO(json); + auto table = attribution::parse(json); + REQUIRE_FALSE(table); + CHECK(table.error().code() == Error::Code::CorruptData); + } + auto wrong_resolution = std::string(entity_json); + wrong_resolution.replace(wrong_resolution.find("0.25"), 4, "\"0.25\""); + CHECK_FALSE(attribution::parse("[" + wrong_resolution + "]")); + auto wrong_date = std::string(entity_json); + wrong_date.replace(wrong_date.find("\"summer 2020\""), 13, "null"); + CHECK_FALSE(attribution::parse("[" + wrong_date + "]")); + + std::string excessive = "[null"; + for (unsigned index = 1; index < 65536; ++index) { + excessive += ",null"; + } + excessive += "]"; + CHECK(attribution::parse(excessive).error().code() == Error::Code::Unsupported); +} + +TEST_CASE("attribution lookup stops at the nearest of exactly three locations", "[raster-store][attribution]") +{ + TemporaryDirectory directory; + const auto grandparent = directory.path() / "collection"; + const auto parent = grandparent / "layer"; + const auto snapshot = parent / "snapshot"; + const auto index = snapshot / manifest::index_file_name; + write_table(directory.path()); + CHECK(attribution::find_table(index).error().code() == Error::Code::NotFound); + write_table(grandparent); + CHECK(attribution::find_table(index).value() == grandparent / attribution::file_name); + write_table(parent); + CHECK(attribution::find_table(index).value() == parent / attribution::file_name); + write_table(snapshot); + CHECK(attribution::find_table(index).value() == snapshot / attribution::file_name); + write_text(snapshot / attribution::file_name, "[null]"); + CHECK(attribution::read_table(index).error().code() == Error::Code::CorruptData); + std::filesystem::remove(snapshot / attribution::file_name); + std::filesystem::create_symlink(snapshot / "missing", snapshot / attribution::file_name); + CHECK(attribution::find_table(index).value() == snapshot / attribution::file_name); + CHECK_FALSE(attribution::read_table(index)); +} + +TEMPLATE_TEST_CASE("AMORT preserves native scalar and packed vector bytes", "[raster-store][codec]", + float, double, std::int16_t, std::uint32_t, glm::vec3, glm::u8vec3) +{ + TemporaryDirectory directory; + raster_store::Tile tile(3); + CHECK(std::ranges::all_of(tile.attribution, [](const auto value) { return value == 0; })); + std::uint8_t value = 0; + for (auto& byte : tile.data.bytes()) { + byte = std::byte(value); + value = static_cast(value + 37); + } + tile.attribution.pixel({ 1, 1 }) = 65534; + const auto path = directory.path() / "tile"; + TileCodec writer({ 3, 3 }); + REQUIRE(writer.write(path, tile)); + auto read = writer.read(path); + REQUIRE(read); + check_bytes(read->data, tile.data); + check_bytes(read->attribution, tile.attribution); + + TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None); + const auto uncompressed_path = directory.path() / "uncompressed"; + REQUIRE(uncompressed.write(uncompressed_path, tile)); + read = writer.read(uncompressed_path); + REQUIRE(read); + check_bytes(read->data, tile.data); +} + +TEST_CASE("AMORT never replaces an existing tile file", "[raster-store][codec]") +{ + TemporaryDirectory directory; + const auto path = directory.path() / "tile"; + TileCodec amort({ 3, 3 }); + raster_store::Tile tile(3); + tile.data.pixel({ 0, 0 }) = 1.0f; + REQUIRE(amort.write(path, tile)); + const auto original = io::byte::read(path.string() + ".amort"); + REQUIRE(original); + + tile.data.pixel({ 0, 0 }) = 2.0f; + const auto rejected = amort.write(path, tile); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().code() == Error::Code::AlreadyExists); + CHECK(io::byte::read(path.string() + ".amort").value() == original.value()); +} + +TEST_CASE("AMORT preserves NaN bits and data beneath NoData pixels", "[raster-store][codec]") +{ + TemporaryDirectory directory; + raster_store::Tile tile(3); + tile.data.pixel({ 0, 0 }) = std::bit_cast(std::uint32_t { 0x7fc01234 }); + tile.attribution.pixel({ 0, 0 }) = 1; + tile.data.pixel({ 1, 0 }) = 42.0f; + tile.data.pixel({ 2, 0 }) = std::bit_cast(std::uint32_t { 0x80000000 }); + TileCodec amort({ 3, 3 }); + REQUIRE(amort.write(directory.path() / "tile", tile)); + auto read = amort.read(directory.path() / "tile"); + REQUIRE(read); + check_bytes(read->data, tile.data); + check_bytes(read->attribution, tile.attribution); +} + +TEST_CASE("AMORT validates dimensions and byte counts before allocating typed rasters", "[raster-store][codec]") +{ + TemporaryDirectory directory; + const auto path = directory.path() / "tile"; + TileCodec amort({ 3, 3 }); + raster_store::Tile tile(3); + tile.attribution = radix::Raster(2); + CHECK(amort.write(path, tile).error().code() == Error::Code::InvalidInput); + TileCodec rectangular({ 3, 2 }); + CHECK(rectangular.write(path, tile).error().code() == Error::Code::InvalidInput); + + tile_codec::detail::v1::RasterTile encoded { 3, 3, io::byte::vector(36), io::byte::vector(18) }; + SECTION("wrong dimensions") { encoded.width = 4; } + SECTION("short data") { encoded.data.pop_back(); } + SECTION("long attribution") { encoded.source_attribution.push_back(std::byte { 0 }); } + REQUIRE(io::envelope::write(encoded, path.string() + ".amort", io::byte::WriteMode::Overwrite)); + CHECK(amort.read(path).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("AMOIT stores RGB8 data lossily and attribution exactly", "[raster-store][codec]") +{ + TemporaryDirectory directory; + raster_store::Tile tile(32); + for (unsigned y = 0; y < 32; ++y) { + for (unsigned x = 0; x < 32; ++x) { + tile.data.pixel({ x, y }) = glm::u8vec3(100 + 2 * x, 80 + 2 * y, 120); + tile.attribution.pixel({ x, y }) = static_cast(x + 32 * y); + } + } + tile.attribution.pixel({ 31, 31 }) = 65534; + const auto path = directory.path() / "tile"; + TileCodec amoit({ 32, 32 }); + CHECK(amoit.paths(path) == std::vector { std::filesystem::path(path.string() + ".amoit") }); + const auto hash = amoit.write(path, tile); + REQUIRE(hash); + CHECK(hash->size() >= store::min_hash_size); + auto read = amoit.read(path, *hash); + REQUIRE(read); + check_bytes(read->attribution, tile.attribution); + REQUIRE(read->data.size() == tile.data.size()); + int max_error = 0; + for (std::size_t i = 0; i < tile.data.buffer_length(); ++i) { + const auto difference = glm::abs(glm::ivec3(read->data.buffer()[i]) - glm::ivec3(tile.data.buffer()[i])); + max_error = std::max({ max_error, difference.x, difference.y, difference.z }); + } + CHECK(max_error > 0); + CHECK(max_error <= 4); + CHECK(amoit.write(path, tile).error().code() == Error::Code::AlreadyExists); +} + +TEST_CASE("AMOIT validates images and attribution against the tile dimensions", "[raster-store][codec]") +{ + TemporaryDirectory directory; + const auto path = directory.path() / "tile"; + TileCodec amoit({ 3, 3 }); + tile_codec::detail::v1::RasterTile encoded { 3, 3, io::image::encode(io::image::RGB8(3), io::image::Format::JpegXl).value(), io::byte::vector(18) }; + bool valid = false; + SECTION("valid") { valid = true; } + SECTION("wrong dimensions") { encoded.width = 4; } + SECTION("image of other dimensions") { encoded.data = io::image::encode(io::image::RGB8(4), io::image::Format::JpegXl).value(); } + SECTION("truncated image") { encoded.data.resize(encoded.data.size() / 2); } + SECTION("long attribution") { encoded.source_attribution.push_back(std::byte { 0 }); } + REQUIRE(io::envelope::write(encoded, path.string() + ".amoit", io::byte::WriteMode::Overwrite)); + const auto read = amoit.read(path); + REQUIRE(read.has_value() == valid); + if (!valid) { + CHECK(read.error().code() == Error::Code::CorruptData); + } + // AMORT payloads use another envelope class. + REQUIRE(io::envelope::write(encoded, path.string() + ".amoit", io::byte::WriteMode::Overwrite)); + CHECK(amoit.read(path).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("AMOIT snapshots are available only for RGB8 payloads", "[raster-store][storage][codec]") +{ + CHECK(tile_codec::from_name("amoit", glm::uvec2(4)).error().code() == Error::Code::Unsupported); + CHECK(tile_codec::from_name("amoit", glm::uvec2(4)).error().code() == Error::Code::Unsupported); + + TemporaryDirectory directory; + write_table(directory.path()); + auto options = small_options(); + options.codec_selector = "amoit"; + CHECK(storage::create(directory.path() / "scalar", options).error().code() == Error::Code::Unsupported); + const auto final = directory.path() / "colour"; + auto created_result = storage::create(final, options); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + raster_store::Tile tile(4); + tile.data.fill(glm::u8vec3(255)); + tile.attribution.fill(1); + REQUIRE(created->save({ 0, { 0, 0 } }, tile)); + REQUIRE(storage::publish(std::move(created))); + CHECK(std::filesystem::exists(final / "0/0/0.amoit")); + auto opened = storage::open(final); + REQUIRE(opened); + auto [reader, metadata] = std::move(*opened); + CHECK(metadata->codec_selector == "amoit"); + const auto loaded = reader->load({ 0, { 0, 0 } }); + REQUIRE(loaded); + check_bytes(loaded->attribution, tile.attribution); +} + +TEST_CASE("raster XYZ paths round trip boundary IDs and reject malformed paths", "[raster-store][layout]") +{ + const auto mapping = raster_store::path_layout::zoom_xy_google::zoom_x_y_google(); + CHECK(mapping.id == "zoom/x/y_google"); + for (const auto key : { radix::tile::Id { 0, { 0, 0 } }, radix::tile::Id { 32, { UINT32_MAX, UINT32_MAX } } }) { + CHECK(mapping.node_path_to_key(mapping.key_to_node_path(key)) == key); + } + for (const auto path : { "", "/0/0/0", "0/1/0", "33/0/0", "1/0/0.amort", "1/0/0/extra", "1/0/0junk", "1/../0" }) { + INFO(path); + CHECK_FALSE(mapping.node_path_to_key(path)); + } +} + +TEST_CASE("raster index adapters preserve mixed hierarchy and reject corrupt topology", "[raster-store][index]") +{ + store::Index index; + const auto root = raster_store::StoreTraits::root(); + REQUIRE(index.add(root)); + REQUIRE(index.add({ 2, { 1, 1 } })); + auto encoded = manifest::encode_index(index, {}); + auto decoded = manifest::decode_index(encoded); + REQUIRE(decoded); + CHECK(decoded->index.is(store::NodeStatus::Inner, root).value()); + CHECK(decoded->index.is(store::NodeStatus::Virtual, { 1, { 0, 0 } }).value()); + CHECK(decoded->index.is(store::NodeStatus::Leaf, { 2, { 1, 1 } }).value()); + REQUIRE(encoded.entries.size() == 3); + CHECK(encoded.entries[0].id == root); + CHECK(encoded.entries[1].id == radix::tile::Id { 1, { 0, 0 } }); + CHECK(encoded.entries[2].id == radix::tile::Id { 2, { 1, 1 } }); + + SECTION("duplicate key") { encoded.entries.push_back(encoded.entries[0]); } + SECTION("invalid key") { encoded.entries[0].id.coords.x = 1; } + SECTION("invalid status") { encoded.entries[0].status = static_cast(255); } + SECTION("missing parent") { encoded.entries.erase(encoded.entries.begin() + 1); } + SECTION("leaf with descendant") { encoded.entries[0].status = store::NodeStatus::Leaf; } + SECTION("virtual without children") { encoded.entries.pop_back(); } + CHECK(manifest::decode_index(encoded).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitly", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + const auto partial = directory.path() / "snapshot.part"; + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + CHECK_FALSE(storage::open(partial)); + CHECK_FALSE(storage::open(partial / "")); + CHECK(storage::open(partial, { .allow_incomplete = true })); + const auto metadata_before = io::byte::read(partial / manifest::metadata_file_name).value(); + raster_store::Tile tile(4); + tile.data.fill(12.5f); + // Reference checks belong to RF builder, not storage reads or writes. + tile.attribution.fill(65534); + REQUIRE(created->save({ 0, { 0, 0 } }, tile)); + REQUIRE(created->save_index()); + CHECK(io::byte::read(partial / manifest::metadata_file_name).value() == metadata_before); + auto checkpoint_result = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(checkpoint_result); + auto [checkpoint, checkpoint_metadata] = std::move(*checkpoint_result); + REQUIRE(checkpoint->load({ 0, { 0, 0 } })); + CHECK(checkpoint->codec_selector().value() == "amort"); + auto metadata = manifest::read_metadata(partial); + REQUIRE(metadata); + CHECK(metadata->stored_tile_size == 4); + CHECK(metadata->nominal_tile_size == 4); + CHECK(metadata->payload_type == "float32"); + CHECK(metadata->codec_selector == "amort"); + REQUIRE(created_metadata); + CHECK(created_metadata->stored_tile_size == metadata->stored_tile_size); + CHECK(created_metadata->nominal_tile_size == metadata->nominal_tile_size); + CHECK(created_metadata->layout_id == metadata->layout_id); + CHECK(created_metadata->payload_type == metadata->payload_type); + CHECK(created_metadata->codec_selector == metadata->codec_selector); + + REQUIRE(storage::publish(std::move(created))); + CHECK_FALSE(std::filesystem::exists(partial)); + auto published_result = storage::open(final); + REQUIRE(published_result); + auto [published, published_metadata] = std::move(*published_result); + CHECK(published->base_path() == final); + check_bytes(published->load({ 0, { 0, 0 } })->data, tile.data); + CHECK(storage::open(final).error().code() == Error::Code::Unsupported); +} + +TEST_CASE("failed checkpoints retain the previous index and ignore unindexed files", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + auto created_result = storage::create(directory.path() / "snapshot", small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + const auto partial = created->base_path(); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(4))); + std::filesystem::create_directory(partial / "raster_store.index.tmp"); + CHECK_FALSE(created->save_index()); + auto checkpoint_result = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(checkpoint_result); + auto [checkpoint, checkpoint_metadata] = std::move(*checkpoint_result); + CHECK(checkpoint->index().empty()); + CHECK(checkpoint->load({ 0, { 0, 0 } }).error().code() == Error::Code::NotFound); + std::filesystem::remove(partial / "raster_store.index.tmp"); + REQUIRE(created->save_index()); + auto reopened_result = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(reopened_result); + auto [reopened, reopened_metadata] = std::move(*reopened_result); + CHECK(reopened->index().size() == 1); +} + +TEST_CASE("raster publication does not scan payloads or reopen output", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(4))); + REQUIRE(std::filesystem::remove(created->path_for({ 0, { 0, 0 } }).value())); + REQUIRE(storage::publish(std::move(created))); + auto opened_result = storage::open(final); + REQUIRE(opened_result); + auto [opened, opened_metadata] = std::move(*opened_result); + CHECK(opened->load({ 0, { 0, 0 } }).error().code() == Error::Code::NotFound); +} + +TEST_CASE("raster reader selection follows metadata instead of file endings", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(4))); + REQUIRE(storage::publish(std::move(created))); + auto metadata = manifest::read_metadata(final).value(); + metadata.codec_selector = ".amort"; + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name, io::byte::WriteMode::Overwrite)); + CHECK(storage::open(final).error().code() == Error::Code::Unsupported); + metadata.codec_selector = "amort"; + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name, io::byte::WriteMode::Overwrite)); + REQUIRE(storage::open(final)); +} + +TEST_CASE("cross-root hard links and independent attribution survive RF removal", "[raster-store][storage][copy]") +{ + TemporaryDirectory directory; + const auto rf_root = directory.path() / "rf"; + const auto tb_root = directory.path() / "tb"; + const auto rf = rf_root / "snapshot"; + const auto tb = tb_root / "snapshot"; + write_table(rf_root); + auto created_result = storage::create(rf, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + raster_store::Tile tile(4); + tile.data.fill(12.5f); + REQUIRE(created->save({ 0, { 0, 0 } }, tile)); + REQUIRE(created->save({ 2, { 1, 1 } }, tile)); + REQUIRE(storage::publish(std::move(created))); + { + auto source_result = storage::open(rf); + REQUIRE(source_result); + auto [source, source_metadata] = std::move(*source_result); + auto options = small_options(); + options.copy_attribution_from_index = rf / manifest::index_file_name; + auto target_result = storage::create(tb, options); + REQUIRE(target_result); + auto [target, target_metadata] = std::move(*target_result); + for (const auto key : { radix::tile::Id { 0, { 0, 0 } }, radix::tile::Id { 2, { 1, 1 } } }) { + REQUIRE(target->link_from(key, *source)); + CHECK(std::filesystem::equivalent(source->path_for(key).value(), target->path_for(key).value())); + } + CHECK_FALSE(std::filesystem::equivalent(rf_root / attribution::file_name, target->base_path() / attribution::file_name)); + CHECK(io::byte::read(rf_root / attribution::file_name).value() + == io::byte::read(target->base_path() / attribution::file_name).value()); + REQUIRE(storage::publish(std::move(target))); + } + std::filesystem::remove_all(rf_root); + auto target_result = storage::open(tb); + REQUIRE(target_result); + auto [target, target_metadata] = std::move(*target_result); + CHECK(target->index().is(store::NodeStatus::Inner, { 0, { 0, 0 } }).value()); + CHECK(target->load({ 2, { 1, 1 } })->data.pixel({ 0, 0 }) == 12.5f); + CHECK(attribution::read_table(tb / manifest::index_file_name)); +} + +TEST_CASE("byte writes report buffered device failures", "[io][bytes][raster-store]") +{ +#ifdef __linux__ + const std::array bytes { std::byte { 42 } }; + auto written = io::byte::write(bytes, "/dev/full", io::byte::WriteMode::Overwrite, false); + REQUIRE_FALSE(written); + CHECK(written.error().code() == Error::Code::Io); +#endif +} + +TEST_CASE("snapshot rename itself refuses an existing empty destination", "[raster-store][storage]") +{ + TemporaryDirectory directory; + const auto source = directory.path() / "source"; + const auto destination = directory.path() / "destination"; + write_text(source / "keep", "payload"); + std::filesystem::create_directory(destination); + auto renamed = io::utils::rename_without_replacement(source, destination); + REQUIRE_FALSE(renamed); + CHECK(renamed.error().code() == Error::Code::AlreadyExists); + CHECK(std::filesystem::exists(source / "keep")); + CHECK(std::filesystem::is_empty(destination)); +} + +TEST_CASE("snapshot rename reports missing paths", "[raster-store][storage]") +{ + TemporaryDirectory directory; + const auto source = directory.path() / "source"; + auto destination = directory.path() / "destination"; + SECTION("missing source") { } + SECTION("missing destination parent") + { + write_text(source / "keep", "payload"); + destination = directory.path() / "missing" / "destination"; + } + auto renamed = io::utils::rename_without_replacement(source, destination); + REQUIRE_FALSE(renamed); + CHECK(renamed.error().code() == Error::Code::NotFound); +} + +TEST_CASE("attribution copy reports missing paths", "[raster-store][attribution]") +{ + TemporaryDirectory directory; + const auto source = directory.path() / "source"; + const auto destination = directory.path() / "destination"; + write_table(source); + SECTION("dangling source symlink") + { + REQUIRE(std::filesystem::remove(source / attribution::file_name)); + std::filesystem::create_symlink(source / "missing", source / attribution::file_name); + std::filesystem::create_directory(destination); + } + SECTION("missing destination directory") { } + auto copied = attribution::copy_table(source / manifest::index_file_name, destination); + REQUIRE_FALSE(copied); + CHECK(copied.error().code() == Error::Code::NotFound); +} + +TEST_CASE("raster opening retains metadata and index errors without creating a dataset", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + CHECK(storage::open(final).error().code() == Error::Code::NotFound); + CHECK_FALSE(std::filesystem::exists(final)); + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + REQUIRE(storage::publish(std::move(created))); + SECTION("missing metadata") + { + std::filesystem::remove(final / manifest::metadata_file_name); + CHECK(storage::open(final).error().code() == Error::Code::NotFound); + } + SECTION("missing index") + { + std::filesystem::remove(final / manifest::index_file_name); + CHECK(storage::open(final).error().code() == Error::Code::NotFound); + } + SECTION("unknown layout") + { + auto metadata = manifest::read_metadata(final).value(); + metadata.layout_id = "unknown"; + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name, io::byte::WriteMode::Overwrite)); + CHECK(storage::open(final).error().code() == Error::Code::Unsupported); + } + SECTION("rectangular metadata") + { + auto metadata = manifest::read_metadata(final).value(); + metadata.stored_tile_size = 2; + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name, io::byte::WriteMode::Overwrite)); + CHECK(storage::open(final).error().code() == Error::Code::CorruptData); + } + SECTION("wrong envelope class") + { + std::filesystem::copy_file(final / manifest::metadata_file_name, final / manifest::index_file_name, + std::filesystem::copy_options::overwrite_existing); + CHECK(storage::open(final).error().code() == Error::Code::CorruptData); + } +} + +TEST_CASE("AMORT propagates envelope checksum and version errors", "[raster-store][codec]") +{ + TemporaryDirectory directory; + const auto path = directory.path() / "tile"; + const auto payload_path = directory.path() / "tile.amort"; + TileCodec amort({ 3, 3 }); + REQUIRE(amort.write(path, raster_store::Tile(3))); + const auto bytes = io::byte::read(payload_path).value(); + auto envelope = io::envelope::detail::deserialize_from_bytes(bytes).value(); + auto expected_error = Error::Code::CorruptData; + SECTION("hash") { envelope.compressed_data.back() ^= std::byte { 1 }; } + SECTION("version") + { + envelope.header.class_version = 999; + expected_error = Error::Code::Unsupported; + } + const auto modified = io::envelope::detail::serialize_to_bytes(envelope).value(); + REQUIRE(io::byte::write(modified, payload_path, io::byte::WriteMode::Overwrite)); + CHECK(amort.read(path).error().code() == expected_error); +} + +TEST_CASE("abandoned raster output checkpoints but never publishes", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + const auto partial = directory.path() / "snapshot.part"; + { + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(4))); + } + CHECK_FALSE(std::filesystem::exists(final)); + auto abandoned_result = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(abandoned_result); + auto [abandoned, abandoned_metadata] = std::move(*abandoned_result); + CHECK(abandoned->load({ 0, { 0, 0 } })); +} + +TEMPLATE_TEST_CASE("snapshot mappings and stored halos round trip", "[raster-store]", float, glm::u8vec3, glm::u8vec4) +{ + TemporaryDirectory directory; + write_table(directory.path()); + auto options = small_options(); + options.halo_width = 1; + SECTION("type default") { } + SECTION("explicit linear") { options.value_mapping = raster_store::pixel::Mapping::Linear; } + SECTION("explicit srgba") { options.value_mapping = raster_store::pixel::Mapping::SRGBA; } + auto created = storage::create(directory.path() / "snapshot", options); + REQUIRE(created); + CHECK(created->second->nominal_tile_size == 4); + CHECK(created->second->stored_tile_size == 6); + CHECK(created->second->halo_width == 1); + CHECK(created->second->value_mapping == options.value_mapping.value_or(raster_store::pixel::default_mapping)); + raster_store::Tile tile(6); + tile.data.fill(TestType(7)); + REQUIRE(created->first->save({ 0, { 0, 0 } }, tile)); + REQUIRE(storage::publish(std::move(created->first))); + auto opened = storage::open(directory.path() / "snapshot"); + REQUIRE(opened); + CHECK(opened->second->value_mapping == created->second->value_mapping); + check_bytes(opened->first->load({ 0, { 0, 0 } })->data, tile.data); +} + +TEST_CASE("snapshot geometry and mapping validation precede creation", "[raster-store]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + auto options = small_options(); + SECTION("nonpower interior") { options.nominal_tile_size = 3; } + SECTION("zero interior") { options.nominal_tile_size = 0; } + SECTION("excessive halo") { options.halo_width = 5; } + SECTION("overflow") + { + options.nominal_tile_size = 1u << 31; + options.halo_width = 1u << 31; + } + SECTION("unknown mapping") { options.value_mapping = static_cast(99); } + CHECK_FALSE(storage::create(directory.path() / "snapshot", options)); + CHECK_FALSE(std::filesystem::exists(directory.path() / "snapshot.part")); +} + +TEST_CASE("AMORT tiles return and verify their payload hash", "[raster-store][codec][hash]") +{ + TemporaryDirectory directory; + const auto path = directory.path() / "tile"; + raster_store::Tile tile(3); + tile.data.fill(2.5f); + const TileCodec amort({ 3, 3 }); + const auto hash = amort.write(path, tile); + REQUIRE(hash); + CHECK(hash->size() == 8); + CHECK(*hash == io::envelope::read_header(path.string() + ".amort")->hash); + CHECK(amort.read(path, *hash)); + + auto different = *hash; + different.front() ^= std::byte { 1 }; + CHECK(amort.read(path, different).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("raster index adapters round-trip sorted tile hashes and reject duplicate and invalid keys", "[raster-store][index][hash]") +{ + store::Index index; + const radix::tile::Id first { 1, { 1, 0 } }; + const radix::tile::Id second { 1, { 0, 1 } }; + REQUIRE(index.add(first)); + REQUIRE(index.add(second)); + store::Key2Hash_Map hashes { { second, store::NodeDataHash(8, std::byte { 2 }) }, + { first, store::NodeDataHash(5, std::byte { 1 }) } }; + auto encoded = manifest::encode_index(index, hashes); + REQUIRE(encoded.hashes.size() == 2); + CHECK(encoded.hashes[0].id == std::min(first, second)); + CHECK(encoded.hashes[1].id == std::max(first, second)); + const auto decoded = manifest::decode_index(encoded); + REQUIRE(decoded); + CHECK(decoded->hashes == hashes); + + SECTION("duplicate key") { encoded.hashes.push_back(encoded.hashes.front()); } + SECTION("invalid key") { encoded.hashes.front().id.coords.x = 2; } + CHECK(manifest::decode_index(encoded).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("raster snapshots checkpoint tile hashes and verify loads against them", "[raster-store][storage][hash]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + const radix::tile::Id key { 0, { 0, 0 } }; + raster_store::Tile tile(4); + tile.data.fill(1.5f); + { + auto created = storage::create(final, small_options()); + REQUIRE(created); + REQUIRE(created->first->save(key, tile)); + CHECK(created->first->hash_for(key).value() == io::envelope::read_header(created->first->path_for(key).value())->hash); + REQUIRE(storage::publish(std::move(created->first))); + } + + auto opened = storage::open(final); + REQUIRE(opened); + const auto path = opened->first->path_for(key).value(); + CHECK(opened->first->hash_for(key).value() == io::envelope::read_header(path)->hash); + + tile.data.fill(2.5f); + REQUIRE(std::filesystem::remove(path)); + REQUIRE(TileCodec({ 4, 4 }).write(path.parent_path() / path.stem(), tile)); + CHECK(opened->first->load(key).error().code() == Error::Code::CorruptData); +} diff --git a/unittests/terrainlib/raster_tile_pool.cpp b/unittests/terrainlib/raster_tile_pool.cpp new file mode 100644 index 00000000..a8a4efb9 --- /dev/null +++ b/unittests/terrainlib/raster_tile_pool.cpp @@ -0,0 +1,135 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "raster_store/TilePool.h" +#include +#include +#include +#include + +namespace { +using Key = radix::tile::Id; +using namespace std::chrono_literals; +struct Release { + std::promise& promise; + bool opened = false; + void open() + { + if (!std::exchange(opened, true)) { + promise.set_value(); + } + } + ~Release() { open(); } +}; +bool wait_for(const std::function& predicate) +{ + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (!predicate() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + return predicate(); +} +} // namespace + +TEST_CASE("RF workers return ready tiles without waiting for the first tile", "[rf-builder][parallel]") +{ + std::promise release; + const auto gate = release.get_future().share(); + raster_store::TilePool pool(2, [&](unsigned, const Key& key) -> unsigned { + if (key.coords.x == 0) { + gate.wait(); + } + return key.coords.x; + }); + Release cleanup { release }; + REQUIRE(pool.submit({ 2, { 0, 0 } })); + REQUIRE(pool.submit({ 2, { 1, 0 } })); + auto done = pool.take(3s); + REQUIRE(done); + REQUIRE(done->result); + CHECK(*done->result == 1); +} + +TEST_CASE("RF cancellation drops queued tiles and finishes active tiles within the bound", "[rf-builder][parallel]") +{ + std::promise release; + const auto gate = release.get_future().share(); + std::atomic_uint started = 0; + raster_store::TilePool pool(2, [&](unsigned, const Key& key) -> unsigned { + ++started; + gate.wait(); + return key.coords.x; + }); + { + Release cleanup { release }; + REQUIRE(pool.submit({ 3, { 0, 0 } })); + REQUIRE(pool.submit({ 3, { 1, 0 } })); + REQUIRE(wait_for([&] { return started == 2; })); + REQUIRE(pool.submit({ 3, { 2, 0 } })); + REQUIRE(pool.submit({ 3, { 3, 0 } })); + CHECK(pool.outstanding() == 4); + pool.stop(); + CHECK(pool.outstanding() == 2); + CHECK_FALSE(pool.submit({ 3, { 5, 0 } })); + } + for (unsigned i = 0; i < 2; ++i) { + auto done = pool.take(3s); + REQUIRE(done); + REQUIRE(done->result); + CHECK(*done->result < 2); + } + pool.join(); + CHECK(started == 2); + CHECK(pool.outstanding() == 0); +} + +TEST_CASE("RF worker errors and exceptions stop queued work and join active workers", "[rf-builder][parallel]") +{ + const bool error_exception = GENERATE(true, false); + std::promise release; + const auto gate = release.get_future().share(); + std::atomic_uint started = 0; + raster_store::TilePool pool(2, [&](unsigned, const Key& key) -> unsigned { + ++started; + gate.wait(); + if (error_exception) { + Error::raise(Error::Code::Io, "injected failure for " + to_string(key)); + } + throw std::runtime_error("injected worker failure"); + }); + { + Release cleanup { release }; + REQUIRE(pool.submit({ 3, { 0, 0 } })); + REQUIRE(pool.submit({ 3, { 1, 0 } })); + REQUIRE(wait_for([&] { return started == 2; })); + REQUIRE(pool.submit({ 3, { 2, 0 } })); + REQUIRE(pool.submit({ 3, { 3, 0 } })); + } + auto first = pool.take(3s); + REQUIRE(first); + REQUIRE_FALSE(first->result); + CHECK(first->result.error().code() == (error_exception ? Error::Code::Io : Error::Code::Internal)); + CHECK(first->result.error().to_string().find("prepare RF tile " + to_string(first->key)) != std::string::npos); + CHECK_FALSE(pool.submit({ 3, { 4, 0 } })); + auto second = pool.take(3s); + REQUIRE(second); + CHECK_FALSE(second->result); + pool.join(); + CHECK(started == 2); + CHECK(pool.outstanding() == 0); +} diff --git a/unittests/terrainlib/raster_view.cpp b/unittests/terrainlib/raster_view.cpp new file mode 100644 index 00000000..ed37491e --- /dev/null +++ b/unittests/terrainlib/raster_view.cpp @@ -0,0 +1,563 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 +#include +#include + +#include + +#include "raster/algorithm/copy.h" +#include "raster/algorithm/transform.h" +#include "raster/algorithm/window_transform.h" +#include "raster/algorithm/zip_transform.h" + +namespace { + +namespace algorithm = raster::algorithm; +using raster::make_clamped_view; +using raster::make_view; +using Raster = radix::Raster; +using View = raster::View; +using ConstView = raster::View; +using ClampedView = raster::ClampedView; + +template +concept CanMakeView = requires(Source&& source) { make_view(std::forward(source)); }; + +template +concept CanMakeRegion = requires(Source&& source) { make_view(std::forward(source), glm::i64vec2(0), glm::uvec2(1)); }; + +template +concept CanMakeClampedView = requires(Source&& source) { make_clamped_view(std::forward(source), glm::i64vec2(-1), glm::uvec2(3)); }; + +template +concept CanCopy + = requires(Source&& source, Destination&& destination) { algorithm::copy(std::forward(source), std::forward(destination)); }; + +template +concept CanTransform = requires(Source&& source, const Function& function, Destination&& destination) { + algorithm::transform(std::forward(source), function, std::forward(destination)); +}; + +template +concept CanWindowTransform = requires(Source&& source, const Function& function, Destination&& destination) { + algorithm::window_transform(std::forward(source), glm::uvec2(3), function, std::forward(destination)); +}; + +template +concept CanZipTuple = requires(const Sources& sources, const Function& function, Destination&& destination) { + algorithm::zip_transform(sources, function, std::forward(destination)); +}; + +template +concept CanAllocateTransform = requires(Raster& source, const Function& function) { algorithm::transform(source, function); }; + +struct Identity { + int operator()(const int& value) const { return value; } +}; + +struct ReferenceResult { + const int& operator()(const int& value) const { return value; } +}; + +struct MutableInput { + int operator()(int& value) const { return ++value; } +}; + +struct Sum { + template + int operator()(const T&... values) const + { + return (values + ...); + } +}; + +struct Centre { + template + int operator()(const Window& window) const + { + return window.pixel(window.size() / 2u); + } +}; + +struct MutableWindow { + int operator()(const View& window) const { return ++window.pixel({ 0, 0 }); } +}; + +static_assert(CanMakeView && CanMakeView); +static_assert(!CanMakeView && !CanMakeView); +static_assert(CanMakeView && CanMakeView && CanMakeView); +static_assert(CanMakeRegion && CanMakeRegion && CanMakeRegion); +static_assert(!CanMakeRegion && !CanMakeRegion); +static_assert(CanMakeClampedView && CanMakeClampedView && CanMakeClampedView); +static_assert(!CanMakeClampedView && !CanMakeClampedView); +static_assert(std::is_convertible_v && !std::is_convertible_v); +static_assert(std::same_as().pixel({ 0, 0 })), int&>); +static_assert(std::same_as().pixel({ 0, 0 })), const int&>); +static_assert(CanCopy && CanCopy && CanCopy); +static_assert(!CanCopy && !CanCopy && !CanCopy); +static_assert(!CanCopy&> && !CanCopy); +static_assert(CanTransform && CanTransform); +static_assert(!CanTransform && !CanTransform); +static_assert(!CanTransform && !CanTransform); +static_assert(!CanTransform&>); +static_assert(CanWindowTransform && CanWindowTransform); +static_assert(!CanWindowTransform && !CanWindowTransform); +static_assert(CanZipTuple, Sum, Raster>); +static_assert(!CanZipTuple, Sum, Raster> && !CanZipTuple, Sum, Raster>); +static_assert(!CanZipTuple, Sum, ClampedView>); +static_assert(CanAllocateTransform && !CanAllocateTransform && !CanAllocateTransform); +static_assert(!CanAllocateTransform); +static_assert(!CanAllocateTransform); + +Raster numbered(glm::uvec2 size) +{ + Raster result(size); + for (unsigned y = 0; y < size.y; ++y) { + for (unsigned x = 0; x < size.x; ++x) { + result.pixel({ x, y }) = int(10 * y + x); + } + } + return result; +} + +} // namespace + +TEST_CASE("raster views preserve stride and compose relative regions", "[raster-view]") +{ + auto raster = numbered({ 7, 5 }); + auto region = make_view(raster, { 2, 1 }, { 4, 3 }); + REQUIRE(region); + CHECK(region->size() == glm::uvec2(4, 3)); + CHECK(region->stride() == 7); + CHECK(region->offset() == 9); + auto nested = make_view(*region, { 1, 1 }, { 2, 2 }); + REQUIRE(nested); + CHECK(nested->stride() == 7); + CHECK(nested->offset() == 17); + CHECK(nested->pixel({ 1, 1 }) == 34); + const auto writable = *nested; + writable.pixel({ 0, 0 }) = 99; + CHECK(raster.pixel({ 3, 2 }) == 99); + const ConstView read_only = writable; + CHECK(&read_only.pixel({ 1, 1 }) == &raster.pixel({ 4, 3 })); + CHECK_FALSE(make_view(*region, { 3, 0 }, { 2, 1 })); + CHECK_FALSE(make_view(raster, { -1, 0 }, { 1, 1 })); + CHECK_FALSE(make_view(raster, { (std::numeric_limits::max)(), 0 }, { 0, 0 })); + CHECK_FALSE(make_view(raster, { 1, 0 }, { (std::numeric_limits::max)(), 1 })); + auto empty = make_view(raster, { 7, 5 }, { 0, 0 }); + REQUIRE(empty); + CHECK(empty->size() == glm::uvec2(0)); + CHECK(make_view(*empty, { 0, 0 }, { 0, 0 })); +} + +TEST_CASE("clamped views repeat the supplied source region and preserve nested mapping", "[raster-view]") +{ + auto raster = numbered({ 6, 5 }); + auto source = make_view(raster, { 2, 1 }, { 2, 3 }); + REQUIRE(source); + auto padded = make_clamped_view(*source, { -2, -1 }, { 6, 5 }); + REQUIRE(padded); + for (unsigned y = 0; y < padded->height(); ++y) { + for (unsigned x = 0; x < padded->width(); ++x) { + const auto sx = unsigned(std::clamp(int(x) - 2, 0, 1)) + 2; + const auto sy = unsigned(std::clamp(int(y) - 1, 0, 2)) + 1; + CHECK(padded->pixel({ x, y }) == raster.pixel({ sx, sy })); + } + } + auto nested = make_view(*padded, { 1, 1 }, { 4, 3 }); + REQUIRE(nested); + auto twice = make_view(*nested, { 1, 0 }, { 3, 3 }); + REQUIRE(twice); + for (unsigned y = 0; y < 3; ++y) { + for (unsigned x = 0; x < 3; ++x) { + CHECK(twice->pixel({ x, y }) == padded->pixel({ x + 2, y + 1 })); + } + } + CHECK_FALSE(make_view(*padded, { -1, 0 }, { 1, 1 })); + CHECK_FALSE(make_view(*padded, { 5, 0 }, { 2, 1 })); + auto far = make_clamped_view(raster, { (std::numeric_limits::min)(), (std::numeric_limits::max)() }, { 4, 4 }); + REQUIRE(far); + CHECK(far->pixel({ 0, 0 }) == 40); + CHECK(far->pixel({ 3, 3 }) == 40); + Raster empty; + CHECK_FALSE(make_clamped_view(empty, { -1, -1 }, { 3, 3 })); + CHECK(make_clamped_view(empty, { -1, -1 }, { 0, 3 })); +} + +TEST_CASE("view copy handles raster wrappers strided regions and raw payloads", "[raster-view]") +{ + auto source = numbered({ 5, 4 }); + Raster destination({ 6, 5 }, -1); + auto input = make_view(source, { 1, 1 }, { 3, 2 }); + auto output = make_view(destination, { 2, 2 }, { 3, 2 }); + REQUIRE(input); + REQUIRE(output); + REQUIRE(algorithm::copy(*input, *output)); + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + CHECK(destination.pixel({ x, y }) == (x >= 2 && x < 5 && y >= 2 && y < 4 ? int(10 * (y - 1) + x - 1) : -1)); + } + } + Raster whole(source.size()); + REQUIRE(algorithm::copy(source, whole)); + REQUIRE(algorithm::copy(source, make_view(whole))); + REQUIRE(algorithm::copy(make_view(source), whole)); + CHECK(std::ranges::equal(source, whole)); + REQUIRE(algorithm::copy(numbered({ 5, 4 }), whole)); + REQUIRE(algorithm::copy(source, Raster(source.size()))); + + radix::Raster bits({ 2, 1 }); + bits.pixel({ 0, 0 }) = std::bit_cast(std::uint32_t(0x7fc01234)); + bits.pixel({ 1, 0 }) = -0.f; + radix::Raster copied(bits.size()); + REQUIRE(algorithm::copy(bits, copied)); + CHECK(std::ranges::equal(bits.bytes(), copied.bytes())); +} + +TEST_CASE("copy checks actual rectangular overlap before writing", "[raster-view]") +{ + auto raster = numbered({ 6, 4 }); + const auto original = raster; + auto left = make_view(raster, { 0, 0 }, { 2, 4 }).value(); + auto right = make_view(raster, { 4, 0 }, { 2, 4 }).value(); + REQUIRE(algorithm::copy(left, right)); + for (unsigned y = 0; y < 4; ++y) { + CHECK(raster.pixel({ 4, y }) == original.pixel({ 0, y })); + CHECK(raster.pixel({ 5, y }) == original.pixel({ 1, y })); + } + REQUIRE(algorithm::copy(left, left)); + const auto before_error = raster; + auto shifted = make_view(raster, { 1, 0 }, { 2, 4 }).value(); + auto result = algorithm::copy(left, shifted); + REQUIRE_FALSE(result); + CHECK(result.error().code() == Error::Code::InvalidInput); + CHECK(std::ranges::equal(raster, before_error)); + auto wrong_size = make_view(raster, { 4, 0 }, { 1, 4 }).value(); + CHECK_FALSE(algorithm::copy(left, wrong_size)); + CHECK(std::ranges::equal(raster, before_error)); +} + +TEST_CASE("pointwise transforms support different pixel types and exact in-place mappings", "[raster-view]") +{ + auto source = numbered({ 3, 2 }); + radix::Raster output(source.size()); + const float factor = 0.5f; + REQUIRE(algorithm::transform(source, [factor](int value) { return glm::vec2(value * factor, -value); }, output)); + CHECK(output.pixel({ 2, 1 }) == glm::vec2(6.f, -12.f)); + REQUIRE(algorithm::transform(source, [](int value) { return value + 1; }, source)); + CHECK(source.pixel({ 2, 1 }) == 13); + + struct Overloaded { + int operator()(int&) const { return -100; } + int operator()(const int& value) const { return value; } + }; + Raster copied(source.size()); + REQUIRE(algorithm::transform(source, Overloaded {}, copied)); + CHECK(std::ranges::equal(source, copied)); + + Raster small(1); + auto never = [](int) -> int { + FAIL("invalid arguments must not invoke the callback"); + return 0; + }; + CHECK_FALSE(algorithm::transform(source, never, small)); + auto overlapping = make_view(source, { 0, 0 }, { 2, 2 }).value(); + auto shifted = make_view(source, { 1, 0 }, { 2, 2 }).value(); + const auto before = source; + CHECK_FALSE(algorithm::transform(overlapping, never, shifted)); + CHECK(std::ranges::equal(source, before)); +} + +TEST_CASE("zip transforms mix input types and view kinds in two three and tuple forms", "[raster-view]") +{ + auto first = numbered({ 3, 2 }); + radix::Raster second(first.size(), 0.5f); + Raster third(first.size(), 3); + auto clamped = make_clamped_view(third, { -5, -5 }, first.size()).value(); + radix::Raster destination(first.size()); + REQUIRE(algorithm::zip_transform(first, second, [](int a, float b) -> double { return a + 2. * b; }, destination)); + CHECK(destination.pixel({ 2, 1 }) == 13.); + REQUIRE(algorithm::zip_transform(make_view(first), second, clamped, [](int a, float b, int c) -> double { return a + 2. * b + 3. * c; }, destination)); + CHECK(destination.pixel({ 2, 1 }) == 22.); + REQUIRE(algorithm::zip_transform( + std::tuple { make_view(first), make_view(second), clamped, make_view(third) }, + [](int a, float b, int c, int d) -> double { return a + 2. * b + 3. * c + 4. * d; }, + destination)); + CHECK(destination.pixel({ 2, 1 }) == 34.); + REQUIRE(algorithm::zip_transform(std::tie(first, third, third, third, third), Sum {}, first)); + CHECK(first.pixel({ 2, 1 }) == 24); + REQUIRE(algorithm::zip_transform(first, first, [](int a, int b) { return a + b; }, first)); + CHECK(first.pixel({ 2, 1 }) == 48); +} + +TEST_CASE("zip validates every input before invoking the callable", "[raster-view]") +{ + auto raster = numbered({ 5, 2 }); + auto input = make_view(raster, { 0, 0 }, { 3, 2 }).value(); + auto overlapping = make_view(raster, { 1, 0 }, { 3, 2 }).value(); + Raster independent(input.size(), -1); + Raster wrong({ 2, 2 }, -1); + auto never = [](const auto&...) -> int { + FAIL("zip validation must finish before invoking the callback"); + return 0; + }; + const auto before = raster; + CHECK_FALSE(algorithm::zip_transform(independent, independent, overlapping, never, input)); + CHECK(std::ranges::equal(raster, before)); + CHECK_FALSE(algorithm::zip_transform(std::tie(independent, independent, independent, wrong), never, independent)); + CHECK(std::ranges::all_of(independent, [](int value) { return value == -1; })); + CHECK_FALSE(algorithm::zip_transform(wrong, independent, never, independent)); +} + +TEST_CASE("clamped overlap uses sampled storage and rejects repeated-pixel aliases", "[raster-view]") +{ + auto raster = numbered({ 5, 2 }); + auto source = make_view(raster, { 0, 0 }, { 1, 2 }).value(); + auto replicated = make_clamped_view(source, { -2, 0 }, { 3, 2 }).value(); + auto disjoint = make_view(raster, { 2, 0 }, { 3, 2 }).value(); + REQUIRE(algorithm::copy(replicated, disjoint)); + CHECK(raster.pixel({ 4, 1 }) == 10); + auto overlapping = make_view(raster, { 0, 0 }, { 3, 2 }).value(); + const auto before = raster; + CHECK_FALSE(algorithm::copy(replicated, overlapping)); + CHECK_FALSE(algorithm::transform(replicated, Identity {}, overlapping)); + CHECK(std::ranges::equal(raster, before)); + auto identity = make_clamped_view(raster, { 2, 0 }, { 3, 2 }).value(); + REQUIRE(algorithm::transform(identity, [](int value) { return value + 1; }, disjoint)); + CHECK(raster.pixel({ 4, 1 }) == 11); +} + +TEST_CASE("window transforms traverse strided kernels and convert output types", "[raster-view]") +{ + auto raster = numbered({ 8, 6 }); + auto source = make_view(raster, { 1, 1 }, { 5, 4 }).value(); + radix::Raster destination({ 5, 4 }, -1.); + auto output = make_view(destination, { 1, 1 }, { 3, 2 }).value(); + auto sum = [](const auto& window) -> double { + static_assert(std::is_const_v>); + double result = 0; + for (unsigned y = 0; y < window.height(); ++y) { + for (unsigned x = 0; x < window.width(); ++x) { + result += window.pixel({ x, y }); + } + } + return result; + }; + REQUIRE(algorithm::window_transform(source, glm::uvec2(3), sum, output)); + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 0; x < 3; ++x) { + CHECK(output.pixel({ x, y }) == 9. * (10 * (y + 2) + x + 2)); + } + } + CHECK(destination.pixel({ 0, 0 }) == -1.); + CHECK(destination.pixel({ 4, 3 }) == -1.); + Raster identity(source.size()); + REQUIRE(algorithm::window_transform(source, glm::uvec2(1), Centre {}, identity)); + CHECK(identity.pixel({ 4, 3 }) == 45); +} + +TEST_CASE("clamped window kernels retain the original source bounds", "[raster-view]") +{ + auto source = numbered({ 4, 3 }); + auto padded = make_clamped_view(source, { -3, -3 }, source.size() + glm::uvec2(6)).value(); + Raster output(source.size()); + auto sum = [](const auto& window) { + int result = 0; + for (unsigned y = 0; y < window.height(); ++y) { + for (unsigned x = 0; x < window.width(); ++x) { + result += window.pixel({ x, y }); + } + } + return result; + }; + REQUIRE(algorithm::window_transform(padded, glm::uvec2(7), sum, output)); + for (unsigned y = 0; y < source.height(); ++y) { + for (unsigned x = 0; x < source.width(); ++x) { + int expected = 0; + for (int dy = -3; dy <= 3; ++dy) { + for (int dx = -3; dx <= 3; ++dx) { + const auto sx = unsigned(std::clamp(int(x) + dx, 0, 3)); + const auto sy = unsigned(std::clamp(int(y) + dy, 0, 2)); + expected += source.pixel({ sx, sy }); + } + } + CHECK(output.pixel({ x, y }) == expected); + } + } +} + +TEST_CASE("window validation precedes processing and rejects all overlapping output", "[raster-view]") +{ + auto source = numbered({ 5, 5 }); + Raster output({ 3, 3 }, -1); + auto never = [](const auto&) -> int { + FAIL("invalid windows must not invoke the callback"); + return 0; + }; + for (const auto kernel : { 0u, 2u, 4u, 7u, (std::numeric_limits::max)() }) { + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(kernel), never, output)); + } + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(1), never, output)); + CHECK(std::ranges::all_of(output, [](int value) { return value == -1; })); + auto overlapping = make_view(source, { 1, 1 }, { 3, 3 }).value(); + const auto before = source; + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(3), never, overlapping)); + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(1), never, source)); + CHECK(std::ranges::equal(source, before)); +} + +TEST_CASE("matching empty views are pointwise no-ops but cannot supply a kernel", "[raster-view]") +{ + Raster empty({ 0, 3 }); + Raster output({ 0, 3 }); + Raster mismatched({ 0, 4 }); + auto never = [](const auto&...) -> int { + FAIL("empty views must not invoke the callback"); + return 0; + }; + REQUIRE(algorithm::copy(empty, output)); + REQUIRE(algorithm::transform(empty, never, output)); + REQUIRE(algorithm::zip_transform(empty, empty, never, output)); + auto clamped = make_clamped_view(empty, { -3, -3 }, { 0, 3 }).value(); + REQUIRE(algorithm::copy(clamped, output)); + CHECK_FALSE(algorithm::copy(empty, mismatched)); + CHECK_FALSE(algorithm::window_transform(empty, glm::uvec2(1), never, output)); +} + +TEST_CASE("destination-free operations infer output types and allocate matching rasters", "[raster-view]") +{ + auto source = numbered({ 4, 3 }); + auto copied = algorithm::copy(source); + STATIC_REQUIRE(std::same_as>); + REQUIRE(copied); + CHECK(std::ranges::equal(source, *copied)); + copied->pixel({ 0, 0 }) = -1; + CHECK(source.pixel({ 0, 0 }) == 0); + + auto region = make_view(source, { 1, 1 }, { 2, 2 }).value(); + auto transformed = algorithm::transform(region, [](int value) { return glm::vec2(value, value * 2); }); + STATIC_REQUIRE(std::same_as>>); + REQUIRE(transformed); + CHECK(transformed->size() == glm::uvec2(2)); + CHECK(transformed->pixel({ 1, 1 }) == glm::vec2(22, 44)); + + auto padded = make_clamped_view(source, { -1, -1 }, source.size() + glm::uvec2(2)).value(); + auto replicated = algorithm::copy(padded); + REQUIRE(replicated); + CHECK(replicated->size() == padded.size()); + CHECK(replicated->pixel({ 5, 4 }) == 23); + auto filtered = algorithm::window_transform(padded, glm::uvec2(3), Centre {}); + REQUIRE(filtered); + CHECK(filtered->size() == source.size()); + CHECK(std::ranges::equal(*filtered, source)); + auto cropped = algorithm::window_transform(source, glm::uvec2(3), [](const auto& window) { return double(window.pixel({ 1, 1 })); }); + STATIC_REQUIRE(std::same_as>>); + REQUIRE(cropped); + CHECK(cropped->size() == glm::uvec2(2, 1)); + CHECK(cropped->pixel({ 1, 0 }) == 12.); + + auto two = algorithm::zip_transform(source, source, [](int a, int b) { return double(a + b); }); + STATIC_REQUIRE(std::same_as>>); + REQUIRE(two); + CHECK(two->pixel({ 3, 2 }) == 46.); + auto three = algorithm::zip_transform(source, source, source, Sum {}); + REQUIRE(three); + CHECK(three->pixel({ 3, 2 }) == 69); + auto five = algorithm::zip_transform(std::tie(source, source, source, source, source), Sum {}); + REQUIRE(five); + CHECK(five->pixel({ 3, 2 }) == 115); + auto temporary = algorithm::copy(numbered({ 2, 2 })); + REQUIRE(temporary); + CHECK(temporary->pixel({ 1, 1 }) == 11); +} + +TEST_CASE("allocating overloads reject invalid geometry and unrepresentable storage before callbacks", "[raster-view]") +{ + Raster source({ 3, 3 }, 1); + Raster other({ 2, 3 }, 2); + auto never = [](const auto&...) -> int { + FAIL("invalid allocation arguments must not invoke callbacks"); + return 0; + }; + CHECK_FALSE(algorithm::zip_transform(source, other, never)); + CHECK_FALSE(algorithm::zip_transform(source, source, other, never)); + CHECK_FALSE(algorithm::zip_transform(std::tie(source, source, source, other), never)); + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(0), never)); + CHECK_FALSE(algorithm::window_transform(source, { 2, 0 }, never)); + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(5), never)); + + const auto maximum = (std::numeric_limits::max)(); + auto huge = make_clamped_view(source, { 0, 0 }, { maximum, maximum }).value(); + auto failed = algorithm::copy(huge); + REQUIRE_FALSE(failed); + CHECK(failed.error().code() == Error::Code::ResourceExhausted); + CHECK_FALSE(algorithm::transform(huge, never)); + CHECK_FALSE(algorithm::zip_transform(huge, huge, never)); + CHECK_FALSE(algorithm::window_transform(huge, glm::uvec2(1), never)); + + Raster empty({ 0, maximum }); + auto copied = algorithm::copy(empty); + REQUIRE(copied); + CHECK(copied->size() == empty.size()); + REQUIRE(algorithm::transform(empty, never)); + REQUIRE(algorithm::zip_transform(empty, empty, never)); +} + +TEST_CASE("rectangular windows use independent strides and omit incomplete trailing windows", "[raster-view]") +{ + auto source = numbered({ 8, 6 }); + const auto top_left = [](const auto& window) { return window.pixel({ 0, 0 }); }; + auto result = algorithm::window_transform(source, { 2, 2 }, { 2, 2 }, top_left); + REQUIRE(result); + CHECK(result->size() == glm::uvec2(4, 3)); + for (unsigned y = 0; y < 3; ++y) { + for (unsigned x = 0; x < 4; ++x) { + CHECK(result->pixel({ x, y }) == source.pixel({ 2 * x, 2 * y })); + } + } + Raster output({ 4, 3 }, -1); + REQUIRE(algorithm::window_transform(source, { 2, 2 }, { 2, 2 }, top_left, output)); + CHECK(std::ranges::equal(output, *result)); + const auto horizontal = algorithm::window_transform(source, { 3, 1 }, { 2, 1 }, top_left); + REQUIRE(horizontal); + CHECK(horizontal->size() == glm::uvec2(3, 6)); + CHECK(horizontal->pixel({ 2, 5 }) == source.pixel({ 4, 5 })); + const auto vertical = algorithm::window_transform(source, { 1, 4 }, { 1, 2 }, top_left); + REQUIRE(vertical); + CHECK(vertical->size() == glm::uvec2(8, 2)); + CHECK(vertical->pixel({ 7, 1 }) == source.pixel({ 7, 2 })); + const auto never = [](const auto&) -> int { + FAIL("invalid geometry must not invoke callback"); + return 0; + }; + CHECK_FALSE(algorithm::window_transform(source, { 0, 1 }, { 1, 1 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 1, 0 }, { 1, 1 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 1, 1 }, { 0, 1 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 1, 1 }, { 1, 0 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 9, 1 }, { 1, 1 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 1, 7 }, { 1, 1 }, never)); + CHECK_FALSE(algorithm::window_transform(source, { 2, 2 }, { 1, 2 }, never, output)); +} diff --git a/unittests/terrainlib/segmented_buffer.cpp b/unittests/terrainlib/segmented_buffer.cpp index d2a92fc3..afb616a5 100644 --- a/unittests/terrainlib/segmented_buffer.cpp +++ b/unittests/terrainlib/segmented_buffer.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/SegmentedBuffer.h" diff --git a/unittests/terrainlib/sf_validate_index.cpp b/unittests/terrainlib/sf_validate_index.cpp index 9b6de302..244dccce 100644 --- a/unittests/terrainlib/sf_validate_index.cpp +++ b/unittests/terrainlib/sf_validate_index.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "octree/StoreTraits.h" diff --git a/unittests/terrainlib/spatial_lookup.cpp b/unittests/terrainlib/spatial_lookup.cpp index 710b3b98..8ad624ea 100644 --- a/unittests/terrainlib/spatial_lookup.cpp +++ b/unittests/terrainlib/spatial_lookup.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * 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 #include #include diff --git a/unittests/terrainlib/stamp_set.cpp b/unittests/terrainlib/stamp_set.cpp index d612a1d0..780e2ae7 100644 --- a/unittests/terrainlib/stamp_set.cpp +++ b/unittests/terrainlib/stamp_set.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/store_codec.cpp b/unittests/terrainlib/store_codec.cpp index 2dcc9b12..726de9c3 100644 --- a/unittests/terrainlib/store_codec.cpp +++ b/unittests/terrainlib/store_codec.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 @@ -7,7 +25,8 @@ #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" +#include "io/envelope.h" #include "mesh/codec/Gltf.h" #include "mesh/codec/SfMesh.h" #include "store/Codec.h" @@ -27,10 +46,10 @@ class MultiFileCodec final : public store::Codec { class WriteOnlyCodec final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override { return { add_extension(node_path, ".out") }; } - Expected write(const std::filesystem::path&, const int&) const override + Expected write(const std::filesystem::path&, const int&) const override { ++writes; - return {}; + return store::NodeDataHash {}; } mutable std::atomic_uint32_t writes = 0; @@ -65,6 +84,20 @@ void check_mesh_codec_reentrancy(const Codec& codec, const std::filesystem::path } } +template +void check_mesh_codec_creates_exclusively(const Codec& codec, const std::filesystem::path& node_path) +{ + REQUIRE(codec.write(node_path, triangle_mesh(0.0)).has_value()); + const auto path = codec.paths(node_path).front(); + const auto original = io::byte::read(path); + REQUIRE(original.has_value()); + + const auto rejected = codec.write(node_path, triangle_mesh(5.0)); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().code() == Error::Code::AlreadyExists); + CHECK(io::byte::read(path).value() == original.value()); +} + } // namespace TEST_CASE("runtime codec defaults report unsupported operations", "[store][codec]") @@ -105,14 +138,43 @@ TEST_CASE("runtime mesh codecs are reentrant", "[store][codec]") SECTION("JSON glTF") { check_mesh_codec_reentrancy(mesh::codec::Gltf(mesh::codec::GltfContainer::Json), directory.path() / "gltf"); } } +TEST_CASE("runtime mesh codecs never replace existing files", "[store][codec]") +{ + TemporaryDirectory directory("mesh-exclusive"); + + SECTION("SF mesh") { check_mesh_codec_creates_exclusively(mesh::codec::SfMesh {}, directory.path() / "node"); } + SECTION("binary glTF") { check_mesh_codec_creates_exclusively(mesh::codec::Gltf(mesh::codec::GltfContainer::Binary), directory.path() / "node"); } + SECTION("JSON glTF") { check_mesh_codec_creates_exclusively(mesh::codec::Gltf(mesh::codec::GltfContainer::Json), directory.path() / "node"); } +} + TEST_CASE("runtime glTF codec reports writer IO errors", "[store][codec]") { TemporaryDirectory directory("gltf-error"); mesh::codec::Gltf codec(mesh::codec::GltfContainer::Binary); - const std::filesystem::path node_path(directory.path() / "blocked"); - REQUIRE(std::filesystem::create_directory(codec.paths(node_path).front())); + const std::filesystem::path blocker(directory.path() / "blocker"); + REQUIRE(io::byte::write(io::byte::span {}, blocker, io::byte::WriteMode::Overwrite).has_value()); - const auto result = codec.write(node_path, triangle_mesh(0.0)); + const auto result = codec.write(blocker / "node", triangle_mesh(0.0)); REQUIRE_FALSE(result.has_value()); CHECK(result.error().code() == Error::Code::Io); } + +TEST_CASE("SF mesh codec returns and verifies its payload hash", "[store][codec][hash]") +{ + TemporaryDirectory directory("sfmesh-hash"); + const mesh::codec::SfMesh codec; + CHECK(codec.supports_hashing()); + const std::filesystem::path node_path = directory.path() / "node"; + const auto hash = codec.write(node_path, triangle_mesh(0.0)); + REQUIRE(hash.has_value()); + CHECK(*hash == io::envelope::read_header(codec.paths(node_path).front())->hash); + CHECK(codec.read(node_path, *hash).has_value()); + + REQUIRE(std::filesystem::remove(codec.paths(node_path).front())); + const auto other_hash = codec.write(node_path, triangle_mesh(1.0)); + REQUIRE(other_hash.has_value()); + CHECK(*other_hash != *hash); + const auto mismatched = codec.read(node_path, *hash); + REQUIRE_FALSE(mismatched.has_value()); + CHECK(mismatched.error().code() == Error::Code::CorruptData); +} diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 7579354e..91db8e19 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -1,14 +1,36 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include +#include #include #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" #include "mesh/SimpleMesh.h" #include "mesh/codec/SfMeshFormat.h" #include "mesh/codec/from_extension.h" @@ -33,6 +55,7 @@ mesh::storage::IndexedStorage make_storage( return mesh::storage::IndexedStorage( store::RawStorage(store::path_layout::Resolver(path, mapping), std::move(codec.value())), store::Index {}, + store::Key2Hash_Map {}, mesh::storage::Storage::Persistence { octree::storage::index_format(), path / "octree.storeindex", @@ -65,11 +88,14 @@ TEST_CASE("octree layouts round-trip boundary IDs") TEST_CASE("versioned octree and SF payload validation is free-standing") { - const octree::storage::StoreIndex invalid_index { { { - .level = std::numeric_limits::max(), - .index = 0, - .status = static_cast(store::NodeStatus::Leaf), - } } }; + const octree::storage::StoreIndex invalid_index { + .entries = { { + .level = std::numeric_limits::max(), + .index = 0, + .status = static_cast(store::NodeStatus::Leaf), + } }, + .hashes = {}, + }; const auto decoded_index = octree::storage::decode_index(invalid_index); REQUIRE_FALSE(decoded_index.has_value()); CHECK(decoded_index.error().code() == Error::Code::CorruptData); @@ -96,46 +122,12 @@ TEST_CASE("storage hard-links matching payload formats") const octree::Id root = octree::Id::root(); REQUIRE(source.save(root, triangle_mesh(0.0)).has_value()); - REQUIRE(target.copy_from(root, source).has_value()); + REQUIRE(target.link_from(root, source).has_value()); CHECK(std::filesystem::equivalent(source.path_for(root).value(), target.path_for(root).value())); REQUIRE(source.save_index().has_value()); REQUIRE(target.save_index().has_value()); } -TEST_CASE("storage re-encodes differing payload formats") -{ - TemporaryDirectory source_directory("reencode-source"); - TemporaryDirectory target_directory("reencode-target"); - auto source = make_storage(source_directory.path(), octree::store_layout::flat(), ".sfmesh"); - auto target = make_storage(target_directory.path(), octree::store_layout::flat(), ".glb"); - - const octree::Id root = octree::Id::root(); - REQUIRE(source.save(root, triangle_mesh(4.0)).has_value()); - REQUIRE(target.copy_from(root, source).has_value()); - CHECK_FALSE(std::filesystem::equivalent(source.path_for(root).value(), target.path_for(root).value())); - const auto loaded = target.load(root); - REQUIRE(loaded.has_value()); - CHECK(loaded->face_count() == 1); - REQUIRE(source.save_index().has_value()); - REQUIRE(target.save_index().has_value()); -} - -TEST_CASE("storage supports enabled overwrites") -{ - TemporaryDirectory directory("overwrite"); - auto storage = make_storage(directory.path(), octree::store_layout::flat(), ".sfmesh"); - storage.settings().allow_overwrite = true; - - const octree::Id root = octree::Id::root(); - REQUIRE(storage.save(root, triangle_mesh(1.0)).has_value()); - REQUIRE(storage.save(root, triangle_mesh(9.0)).has_value()); - const auto loaded = storage.load(root); - REQUIRE(loaded.has_value()); - REQUIRE_FALSE(loaded->positions.empty()); - CHECK(loaded->positions.front().x == 9.0); - REQUIRE(storage.save_index().has_value()); -} - TEST_CASE("folder opening persists and reopens metadata-driven storage") { TemporaryDirectory directory("folder-open"); @@ -195,6 +187,7 @@ TEST_CASE("octree opening rejects a mismatched metadata payload class", "[store] const std::filesystem::path index_path = directory.path() / "octree.storeindex"; const store::IndexMetadata index_file { .index = {}, + .hashes = {}, .layout_id = "flat", .payload_class = "dag.ClusterBatch", .codec_selector = ".sfmesh", @@ -213,6 +206,7 @@ TEST_CASE("octree opening retains index failures without directory fallback", "[ store::IndexMetadata index_file { .index = {}, + .hashes = {}, .layout_id = "unknown-layout", .payload_class = std::string(mesh::storage::payload_class), .codec_selector = ".sfmesh", @@ -231,8 +225,8 @@ TEST_CASE("octree opening retains index failures without directory fallback", "[ REQUIRE_FALSE(unknown_codec.has_value()); CHECK(unknown_codec.error().code() == Error::Code::Unsupported); - const std::array malformed_bytes { 0xff, 0x00, 0x01 }; - REQUIRE(io::write_bytes_to_path(malformed_bytes, index_path).has_value()); + const std::array malformed_bytes { std::byte { 0xff }, std::byte { 0x00 }, std::byte { 0x01 } }; + REQUIRE(io::byte::write(malformed_bytes, index_path, io::byte::WriteMode::Overwrite).has_value()); const auto malformed = mesh::storage::open_folder_indexed(directory.path()); REQUIRE_FALSE(malformed.has_value()); CHECK(malformed.error().code() == Error::Code::CorruptData); @@ -241,3 +235,42 @@ TEST_CASE("octree opening retains index failures without directory fallback", "[ REQUIRE(independent_metadata.has_value()); CHECK(independent_metadata->codec_selector == ".unknown"); } + +TEST_CASE("octree indexes round-trip sorted sparse hashes", "[store][index][hash]") +{ + const octree::Id root = octree::Id::root(); + const std::vector children { root.child(0).value(), root.child(5).value(), root.child(7).value() }; + store::Index index; + store::Key2Hash_Map hashes; + for (std::size_t position = 0; position < children.size(); ++position) { + REQUIRE(index.add(children[position])); + hashes.emplace(children[position], store::NodeDataHash(4 + position, std::byte(position))); + } + + const auto encoded = octree::storage::encode_index(index, hashes); + REQUIRE(encoded.hashes.size() == children.size()); + for (std::size_t position = 0; position < children.size(); ++position) { + CHECK(encoded.hashes[position].index == children[position].index_on_level()); + } + CHECK(std::ranges::is_sorted(encoded.entries, {}, [](const auto& entry) { return std::pair(entry.level, entry.index); })); + + const auto decoded = octree::storage::decode_index(encoded); + REQUIRE(decoded.has_value()); + CHECK(decoded->hashes == hashes); +} + +TEST_CASE("octree index decoding rejects duplicate and invalid hash keys", "[store][index][hash]") +{ + const octree::Id root = octree::Id::root(); + const octree::Id child = root.child(2).value(); + store::Index index; + REQUIRE(index.add(child)); + auto encoded = octree::storage::encode_index(index, { { child, store::NodeDataHash(8, std::byte { 1 }) } }); + REQUIRE(octree::storage::decode_index(encoded).has_value()); + + SECTION("duplicate key") { encoded.hashes.push_back(encoded.hashes.front()); } + SECTION("invalid key") { encoded.hashes.front().level = std::numeric_limits::max(); } + const auto decoded = octree::storage::decode_index(encoded); + REQUIRE_FALSE(decoded.has_value()); + CHECK(decoded.error().code() == Error::Code::CorruptData); +} diff --git a/unittests/terrainlib/store_index.cpp b/unittests/terrainlib/store_index.cpp index e3a20065..49ea7936 100644 --- a/unittests/terrainlib/store_index.cpp +++ b/unittests/terrainlib/store_index.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/terrainlib/store_layout.cpp b/unittests/terrainlib/store_layout.cpp index 9a0cc837..eabe1d3a 100644 --- a/unittests/terrainlib/store_layout.cpp +++ b/unittests/terrainlib/store_layout.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp new file mode 100644 index 00000000..b246b120 --- /dev/null +++ b/unittests/terrainlib/store_snapshot.cpp @@ -0,0 +1,205 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../temporary_directory.h" +#include "io/byte.h" +#include "io/envelope.h" +#include "mesh/storage.h" +#include "octree/storage/snapshot.h" +#include "octree/store_layout/Mappings.h" +#include "store/inputs.h" +#include "store/snapshot.h" + +namespace { + +using test::TemporaryDirectory; + +struct Record { + std::string input; + int setting = 0; + + bool operator==(const Record&) const = default; +}; + +using Schema = io::envelope::PayloadSchema<"test.Inputs", io::envelope::Version<1, Record>>; + +mesh::Simple triangle() { return mesh::Simple({ { 0, 1, 2 } }, { { 0.0, 0.0, 0.0 }, { 1.0, 0.0, 0.0 }, { 0.0, 1.0, 0.0 } }); } + +Expected create_mesh_storage(const std::filesystem::path& final_path) +{ + return octree::storage::create( + final_path, std::string(mesh::storage::payload_class), ".sfmesh", mesh::codec::from_extension, octree::store_layout::flat()); +} + +} // namespace + +TEST_CASE("snapshots are created as .part and published by renaming", "[store][snapshot]") +{ + TemporaryDirectory directory("snapshot"); + const auto final_path = directory.path() / "nested" / "output"; + const auto partial_path = store::snapshot::partial_path(final_path); + CHECK(partial_path.filename() == "output.part"); + CHECK(store::snapshot::is_partial(partial_path)); + CHECK_FALSE(store::snapshot::is_partial(final_path)); + + const auto created = store::snapshot::create_partial(final_path); + REQUIRE(created.has_value()); + CHECK(*created == partial_path); + CHECK(std::filesystem::is_directory(partial_path)); + + SECTION("an existing .part is rejected") + { + const auto again = store::snapshot::create_partial(final_path); + REQUIRE_FALSE(again.has_value()); + CHECK(again.error().code() == Error::Code::AlreadyExists); + } + + SECTION("a .part final path is rejected") + { + const auto rejected = store::snapshot::create_partial(partial_path); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().code() == Error::Code::InvalidInput); + } + + SECTION("publication removes the input record and renames") + { + REQUIRE(store::inputs::write(partial_path, Record { "input", 1 }).has_value()); + REQUIRE(std::filesystem::exists(partial_path / store::inputs::file_name)); + REQUIRE(store::snapshot::publish(partial_path).has_value()); + CHECK_FALSE(std::filesystem::exists(partial_path)); + CHECK(std::filesystem::is_directory(final_path)); + CHECK_FALSE(std::filesystem::exists(final_path / store::inputs::file_name)); + + const auto again = store::snapshot::create_partial(final_path); + REQUIRE_FALSE(again.has_value()); + CHECK(again.error().code() == Error::Code::AlreadyExists); + } + + SECTION("publication does not replace an existing destination") + { + SECTION("empty directory") { REQUIRE(std::filesystem::create_directory(final_path)); } + SECTION("nonempty directory") { REQUIRE(io::byte::write(io::byte::span {}, final_path / "keep", io::byte::WriteMode::Overwrite)); } + SECTION("dangling symlink") { std::filesystem::create_symlink(directory.path() / "missing", final_path); } + REQUIRE(store::inputs::write(partial_path, Record { "input", 1 }).has_value()); + const auto published = store::snapshot::publish(partial_path); + REQUIRE_FALSE(published.has_value()); + CHECK(published.error().code() == Error::Code::AlreadyExists); + // The retained .part stays usable as a cache. + CHECK(store::inputs::validate_cache(partial_path, Record { "input", 1 }).has_value()); + } +} + +TEST_CASE("caches must be .part snapshots with a matching input record", "[store][snapshot]") +{ + TemporaryDirectory directory("snapshot-cache"); + const Record record { "input", 1 }; + const auto cache = directory.path() / "cache.part"; + REQUIRE(std::filesystem::create_directory(cache)); + + SECTION("missing record") + { + const auto validated = store::inputs::validate_cache(cache, record); + REQUIRE_FALSE(validated.has_value()); + } + + SECTION("matching record") + { + REQUIRE(store::inputs::write(cache, record).has_value()); + CHECK(store::inputs::validate_cache(cache, record).has_value()); + CHECK(store::inputs::validate_cache(cache.string() + "/", record).has_value()); + } + + SECTION("different record") + { + REQUIRE(store::inputs::write(cache, record).has_value()); + const auto validated = store::inputs::validate_cache(cache, Record { "input", 2 }); + REQUIRE_FALSE(validated.has_value()); + CHECK(validated.error().code() == Error::Code::InvalidInput); + } + + SECTION("not a .part snapshot") + { + const auto published = directory.path() / "published"; + REQUIRE(std::filesystem::create_directory(published)); + REQUIRE(store::inputs::write(published, record).has_value()); + const auto validated = store::inputs::validate_cache(published, record); + REQUIRE_FALSE(validated.has_value()); + CHECK(validated.error().code() == Error::Code::InvalidInput); + } +} + +TEST_CASE("octree snapshots are created empty, published, and fingerprinted", "[store][snapshot][octree]") +{ + TemporaryDirectory directory("octree-snapshot"); + const auto final_path = directory.path() / "output"; + const octree::Id root = octree::Id::root(); + { + auto created = create_mesh_storage(final_path); + REQUIRE(created.has_value()); + auto storage = std::move(*created); + CHECK(storage.base_path() == store::snapshot::partial_path(final_path)); + CHECK(std::filesystem::is_regular_file(storage.base_path() / octree::storage::index_file_name)); + REQUIRE(storage.save(root, triangle()).has_value()); + REQUIRE(octree::storage::publish(std::move(storage)).has_value()); + } + CHECK_FALSE(std::filesystem::exists(store::snapshot::partial_path(final_path))); + + const auto reopened = mesh::storage::open_index(final_path / octree::storage::index_file_name); + REQUIRE(reopened.has_value()); + CHECK(reopened->has(root).value()); + CHECK(reopened->codec_selector() == ".sfmesh"); + + const auto existing = create_mesh_storage(final_path); + REQUIRE_FALSE(existing.has_value()); + CHECK(existing.error().code() == Error::Code::AlreadyExists); + + const auto fingerprint = store::inputs::fingerprint(final_path, octree::storage::metadata_file_name, octree::storage::index_file_name); + REQUIRE(fingerprint.has_value()); + CHECK(fingerprint->path == std::filesystem::canonical(final_path).string()); + CHECK(fingerprint->index_hash == io::envelope::read_header(final_path / octree::storage::index_file_name)->hash); + CHECK(fingerprint->metadata_hash == io::envelope::read_header(final_path / octree::storage::metadata_file_name)->hash); + + const auto metadata_path = final_path / octree::storage::metadata_file_name; + const auto metadata = io::envelope::read(metadata_path); + REQUIRE(metadata.has_value()); + SECTION("recompression keeps the fingerprint") + { + REQUIRE(io::envelope::write( + *metadata, metadata_path, io::byte::WriteMode::Overwrite, true, io::envelope::CompressionAlgorithm::None)); + const auto recompressed = store::inputs::fingerprint(final_path, octree::storage::metadata_file_name, octree::storage::index_file_name); + REQUIRE(recompressed.has_value()); + CHECK(*recompressed == *fingerprint); + } + SECTION("files without a hash are rejected") + { + REQUIRE(io::envelope::write(*metadata, + metadata_path, + io::byte::WriteMode::Overwrite, + true, + io::envelope::CompressionAlgorithm::ZstdDefaultCompression, + io::hash::Algorithm::None)); + const auto unhashed = store::inputs::fingerprint(final_path, octree::storage::metadata_file_name, octree::storage::index_file_name); + REQUIRE_FALSE(unhashed.has_value()); + CHECK(unhashed.error().code() == Error::Code::Unsupported); + } +} diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index a70c1638..124b7d76 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -1,19 +1,47 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 #include +#include #include #include +#include #include #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" +#include "io/utils.h" #include "octree/StoreTraits.h" +#include "octree/storage/IndexFile.h" #include "octree/store_layout/Mappings.h" #include "raster_store/StoreTraits.h" #include "store/IndexedStorage.h" +#include "store/ThreadSafeStorage.h" +#include "store/open.h" #include "string_utils.h" namespace { @@ -58,9 +86,21 @@ store::path_layout::Mapping test_mapping read_int(const std::filesystem::path& path) +using store::NodeDataHash; +using store::NodeDataHashView; + +// The test codecs identify an integer by its bytes, followed by filler up to the hash size. +NodeDataHash int_hash(const int value, const std::size_t size = sizeof(int)) { - auto bytes = io::read_bytes_from_path(path); + const auto bytes = std::as_bytes(std::span(&value, 1)); + NodeDataHash hash(bytes.begin(), bytes.end()); + hash.resize(size, std::byte { 0x5a }); + return hash; +} + +Expected read_int(const std::filesystem::path& path, const std::optional expected_hash = std::nullopt) +{ + auto bytes = io::byte::read(path); if (!bytes) { return Error::propagate(std::move(bytes), "read test integer"); } @@ -69,13 +109,16 @@ Expected read_int(const std::filesystem::path& path) } int value; std::memcpy(&value, bytes->data(), sizeof(value)); + if (expected_hash.has_value() && !std::ranges::equal(*expected_hash, int_hash(value, expected_hash->size()))) { + return Error::fail(Error::Code::CorruptData, "test integer does not match its expected hash"); + } return value; } Expected write_int(const int value, const std::filesystem::path& path) { - const auto bytes = std::span(reinterpret_cast(&value), sizeof(value)); - auto written = io::write_bytes_to_path(bytes, path); + const auto bytes = std::as_bytes(std::span(&value, 1)); + auto written = io::byte::write(bytes, path, io::byte::WriteMode::CreateNew); if (!written) { return Error::propagate(std::move(written), "write test integer"); } @@ -86,17 +129,21 @@ class IntCodec final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override { return { add_extension(node_path, ".data") }; } - Expected read(const std::filesystem::path& node_path) const override { return read_int(paths(node_path).front()); } + Expected read(const std::filesystem::path& node_path, std::optional expected_hash = std::nullopt) const override + { + return read_int(paths(node_path).front(), expected_hash); + } - Expected write(const std::filesystem::path& node_path, const int& value) const override { return write_int(value, paths(node_path).front()); } -}; + Expected write(const std::filesystem::path& node_path, const int& value) const override + { + if (auto written = write_int(value, paths(node_path).front()); !written) { + return Error::propagate(std::move(written)); + } + return int_hash(value); + } -template -store::Storage make_storage(const std::filesystem::path& path) -{ - return store::Storage( - store::RawStorage(store::path_layout::Resolver(path, test_mapping()), std::make_unique())); -} + bool supports_hashing() const override { return true; } +}; class MultiFileCodec final : public store::Codec { public: @@ -105,102 +152,94 @@ class MultiFileCodec final : public store::Codec { return { add_extension(node_path, ".data"), add_extension(node_path, ".metadata") }; } - Expected read(const std::filesystem::path& node_path) const override + Expected read(const std::filesystem::path& node_path, std::optional expected_hash = std::nullopt) const override { - auto value = read_int(paths(node_path).front()); + auto value = read_int(paths(node_path).front(), expected_hash); if (!value) { return Error::propagate(std::move(value), "read multi-file test value"); } return value.value(); } - Expected write(const std::filesystem::path& node_path, const int& value) const override + Expected write(const std::filesystem::path& node_path, const int& value) const override { const auto node_paths = paths(node_path); if (!write_int(value, node_paths[0]) || !write_int(value + 1, node_paths[1])) { return Error::fail(Error::Code::Io, "multi-file test write failed"); } - return {}; + return int_hash(value); } -}; -class ConfigurableCodec final : public store::Codec { -public: - explicit ConfigurableCodec(std::string extension) - : extension(std::move(extension)) - { - } - - std::vector paths(const std::filesystem::path& node_path) const override { return { add_extension(node_path, extension) }; } - - Expected read(const std::filesystem::path& node_path) const override - { - if (fail_read) { - return Error::fail(Error::Code::CorruptData, "injected decode failure"); - } - auto value = read_int(paths(node_path).front()); - if (!value) { - return Error::propagate(std::move(value), "read configurable test value"); - } - return value.value(); - } - - Expected write(const std::filesystem::path& node_path, const int& value) const override - { - if (fail_write) { - return Error::fail(Error::Code::InvalidInput, "injected encode failure"); - } - if (!write_int(value, paths(node_path).front())) { - return Error::fail(Error::Code::Io, "configurable test write failed"); - } - return {}; - } - - std::string extension; - bool fail_read = false; - bool fail_write = false; + bool supports_hashing() const override { return true; } }; std::atomic_uint32_t index_writes = 0; -Expected> unused_index_read(const std::filesystem::path& path) +template +Expected> unused_index_read(const std::filesystem::path& path) { return Error::fail(Error::Code::NotFound, "read unused test index from", path); } -Expected count_index_write(const std::filesystem::path&, const store::IndexMetadata&) +template +Expected count_index_write(const std::filesystem::path&, const store::IndexMetadata&) { ++index_writes; return {}; } -std::optional> fake_mapping_from_id(const std::string_view id) +template +std::optional> test_mapping_from_id(const std::string_view id) { - return id == "flat" ? std::optional>(octree::store_layout::flat()) : std::nullopt; + const auto mapping = test_mapping(); + return id == mapping.id ? std::optional(mapping) : std::nullopt; } -store::path_layout::Mapping fake_default_mapping() { return octree::store_layout::flat(); } - -store::IndexFormat counting_index_format() +template +store::IndexFormat counting_index_format() { return { - "octree.storeindex", - unused_index_read, - count_index_write, - fake_mapping_from_id, - fake_default_mapping, + "test.storeindex", + unused_index_read, + count_index_write, + test_mapping_from_id, + test_mapping, }; } -store::IndexedStorage make_indexed_storage( +template +store::IndexedStorage make_indexed_storage( const std::filesystem::path& path, std::unique_ptr> codec = std::make_unique()) { - using Storage = store::Storage; + const auto mapping = test_mapping(); + return store::IndexedStorage( + store::RawStorage(store::path_layout::Resolver(path, mapping), std::move(codec)), + store::Index {}, + store::Key2Hash_Map {}, + typename store::Storage::Persistence { + counting_index_format(), + path / "test.storeindex", + std::string(mapping.id), + "test.Int", + ".data", + }); +} + +std::vector root_children() +{ + const auto children = octree::Id::root().children().value(); + return { children.begin(), children.end() }; +} + +// Uses the octree index format, so that the storage can be reopened. +store::IndexedStorage make_persistent_storage(const std::filesystem::path& path) +{ return store::IndexedStorage( - store::RawStorage(store::path_layout::Resolver(path, octree::store_layout::flat()), std::move(codec)), + store::RawStorage(store::path_layout::Resolver(path, octree::store_layout::flat()), std::make_unique()), store::Index {}, - Storage::Persistence { - counting_index_format(), + store::Key2Hash_Map {}, + store::Storage::Persistence { + octree::storage::index_format(), path / "octree.storeindex", "flat", "test.Int", @@ -208,24 +247,64 @@ store::IndexedStorage make_indexed_storage( }); } +Expected> reopen(const std::filesystem::path& path) +{ + return store::open_index( + path / "octree.storeindex", octree::storage::index_format(), "test.Int", [](std::string_view) -> Expected>> { + return std::make_unique(); + }); +} + } // namespace +TEST_CASE("parent directory creation reports success for new and existing directories", "[io][bytes]") +{ + TemporaryDirectory directory("create-parents"); + const auto path = directory.path() / "parent" / "nested" / "payload"; + REQUIRE(io::utils::create_parent_directories(path)); + CHECK(std::filesystem::is_directory(path.parent_path())); + CHECK_FALSE(std::filesystem::exists(path)); + REQUIRE(io::utils::create_parent_directories(path)); +} + TEST_CASE("byte writes report directory creation errors", "[io][bytes]") { TemporaryDirectory directory("write-parent-error"); const std::filesystem::path blocker = directory.path() / "blocker"; REQUIRE(write_int(1, blocker).has_value()); - const auto result = io::write_bytes_to_path(std::span {}, blocker / "payload"); + const auto result = io::byte::write(io::byte::span {}, blocker / "payload", io::byte::WriteMode::Overwrite); REQUIRE_FALSE(result.has_value()); CHECK(result.error().code() == Error::Code::Io); } -TEMPLATE_TEST_CASE("shared raw storage has no hidden octree dependency", "[store][storage]", octree::StoreTraits, raster_store::StoreTraits) +TEST_CASE("byte replace swaps in a new file and leaves hard links to the old one alone", "[io][bytes]") +{ + TemporaryDirectory directory("write-replace"); + const auto path = directory.path() / "index"; + const auto temporary_path = directory.path() / "index.tmp"; + const auto link = directory.path() / "link"; + const io::byte::vector first { std::byte { 1 }, std::byte { 2 }, std::byte { 3 } }; + const io::byte::vector second { std::byte { 4 }, std::byte { 5 } }; + const io::byte::vector stale { std::byte { 6 }, std::byte { 7 }, std::byte { 8 }, std::byte { 9 } }; + + REQUIRE(io::byte::write(first, path, io::byte::WriteMode::Replace)); + CHECK(*io::byte::read(path) == first); + std::filesystem::create_hard_link(path, link); + // A temporary file left behind by a crash is truncated. + REQUIRE(io::byte::write(stale, temporary_path, io::byte::WriteMode::CreateNew)); + + REQUIRE(io::byte::write(second, path, io::byte::WriteMode::Replace)); + CHECK(*io::byte::read(path) == second); + CHECK(*io::byte::read(link) == first); + CHECK_FALSE(std::filesystem::exists(temporary_path)); +} + +TEMPLATE_TEST_CASE("shared storage has no hidden octree dependency", "[store][storage]", octree::StoreTraits, raster_store::StoreTraits) { using Traits = TestType; TemporaryDirectory directory("traits"); - auto storage = make_storage(directory.path()); + auto storage = make_indexed_storage(directory.path()); const auto root = Traits::root(); CHECK_FALSE(storage.has(root).value()); @@ -233,26 +312,23 @@ TEMPLATE_TEST_CASE("shared raw storage has no hidden octree dependency", "[store CHECK(storage.has(root).value()); REQUIRE(storage.load(root).has_value()); CHECK(storage.load(root).value() == 42); - REQUIRE(storage.remove(root).has_value()); - CHECK_FALSE(storage.has(root).value()); } TEST_CASE("shared storage rejects invalid 2D keys", "[store][storage]") { TemporaryDirectory directory("invalid-key"); - auto storage = make_storage(directory.path()); + auto storage = make_indexed_storage(directory.path()); const radix::tile::Id invalid { 3, { 8, 0 } }; CHECK(storage.load(invalid).error().code() == Error::Code::InvalidInput); CHECK(storage.save(invalid, 1).error().code() == Error::Code::InvalidInput); CHECK(storage.has(invalid).error().code() == Error::Code::InvalidInput); - CHECK(storage.remove(invalid).error().code() == Error::Code::InvalidInput); } -TEST_CASE("shared storage preserves overwrite settings", "[store][storage]") +TEST_CASE("shared storage rejects saving existing nodes", "[store][storage]") { TemporaryDirectory directory("overwrite"); - auto storage = make_storage(directory.path()); + auto storage = make_indexed_storage(directory.path()); const octree::Id root = octree::Id::root(); REQUIRE(storage.save(root, 1).has_value()); @@ -260,13 +336,9 @@ TEST_CASE("shared storage preserves overwrite settings", "[store][storage]") REQUIRE_FALSE(rejected.has_value()); CHECK(rejected.error().code() == Error::Code::AlreadyExists); CHECK(storage.load(root).value() == 1); - - storage.settings().allow_overwrite = true; - REQUIRE(storage.save(root, 2).has_value()); - CHECK(storage.load(root).value() == 2); } -TEST_CASE("raw storage requires and removes every codec path", "[store][storage]") +TEST_CASE("raw storage requires every codec path", "[store][storage]") { TemporaryDirectory directory("multi-file"); store::RawStorage storage( @@ -279,9 +351,91 @@ TEST_CASE("raw storage requires and removes every codec path", "[store][storage] CHECK_FALSE(storage.has(root).value()); REQUIRE(write_int(2, paths[1]).has_value()); CHECK(storage.has(root).value()); - REQUIRE(storage.remove(root).value()); - CHECK_FALSE(std::filesystem::exists(paths[0])); - CHECK_FALSE(std::filesystem::exists(paths[1])); +} + +TEST_CASE("written payloads become available after exactly one commit", "[store][storage][write]") +{ + TemporaryDirectory directory("uncommitted"); + auto storage = make_indexed_storage(directory.path()); + const octree::Id root = octree::Id::root(); + + const auto hash = storage.write_payload(root, 7); + REQUIRE(hash.has_value()); + CHECK_FALSE(storage.has(root).value()); + const auto uncommitted = storage.load(root); + REQUIRE_FALSE(uncommitted.has_value()); + CHECK(uncommitted.error().code() == Error::Code::NotFound); + + REQUIRE(storage.commit(root, *hash).has_value()); + CHECK(storage.has(root).value()); + CHECK(storage.load(root).value() == 7); + + const auto recommitted = storage.commit(root, *hash); + REQUIRE_FALSE(recommitted.has_value()); + CHECK(recommitted.error().code() == Error::Code::AlreadyExists); +} + +TEST_CASE("payload writes reject linked nodes and keep the source file", "[store][storage][write][link]") +{ + TemporaryDirectory source_directory("write-linked-source"); + TemporaryDirectory target_directory("write-linked-target"); + auto source = make_indexed_storage(source_directory.path()); + auto target = make_indexed_storage(target_directory.path()); + const octree::Id root = octree::Id::root(); + REQUIRE(source.save(root, 41).has_value()); + REQUIRE(target.link_from(root, source).has_value()); + + const auto rejected = target.write_payload(root, 2); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().code() == Error::Code::AlreadyExists); + CHECK(source.load(root).value() == 41); + CHECK(target.load(root).value() == 41); +} + +TEST_CASE("thread-safe storage saves different keys concurrently and one key once", "[store][storage][write]") +{ + TemporaryDirectory directory("thread-safe"); + store::ThreadSafeStorage storage(make_indexed_storage(directory.path())); + + SECTION("different keys") + { + const auto keys = root_children(); + std::vector>> saves; + for (std::size_t index = 0; index < keys.size(); ++index) { + saves.push_back(std::async(std::launch::async, [&storage, &keys, index] { return storage.save(keys[index], int(index)); })); + } + for (auto& save : saves) { + REQUIRE(save.get().has_value()); + } + for (std::size_t index = 0; index < keys.size(); ++index) { + CHECK(storage.load(keys[index]).value() == int(index)); + } + } + + SECTION("same key") + { + const octree::Id root = octree::Id::root(); + constexpr int writers = 8; + std::latch start(writers); + std::vector>> saves; + for (int value = 0; value < writers; ++value) { + saves.push_back(std::async(std::launch::async, [&storage, &start, root, value] { + start.arrive_and_wait(); + return storage.save(root, value); + })); + } + std::vector saved; + for (int value = 0; value < writers; ++value) { + const auto result = saves[std::size_t(value)].get(); + if (result) { + saved.push_back(value); + } else { + CHECK(result.error().code() == Error::Code::AlreadyExists); + } + } + REQUIRE(saved.size() == 1); + CHECK(storage.load(root).value() == saved.front()); + } } TEST_CASE("storage moves transfer and finalize dirty index state", "[store][storage]") @@ -289,6 +443,7 @@ TEST_CASE("storage moves transfer and finalize dirty index state", "[store][stor index_writes = 0; TemporaryDirectory first_directory("move-first"); TemporaryDirectory second_directory("move-second"); + TemporaryDirectory third_directory("move-third"); { auto original = make_indexed_storage(first_directory.path()); @@ -301,10 +456,9 @@ TEST_CASE("storage moves transfer and finalize dirty index state", "[store][stor CHECK(index_writes == 1); { - auto destination = make_indexed_storage(first_directory.path()); - destination.settings().allow_overwrite = true; + auto destination = make_indexed_storage(second_directory.path()); REQUIRE(destination.save(octree::Id::root(), 2).has_value()); - auto source = make_indexed_storage(second_directory.path()); + auto source = make_indexed_storage(third_directory.path()); REQUIRE(source.save(octree::Id::root(), 3).has_value()); destination = std::move(source); CHECK(index_writes == 2); @@ -312,19 +466,19 @@ TEST_CASE("storage moves transfer and finalize dirty index state", "[store][stor CHECK(index_writes == 3); } -TEST_CASE("copy_from hard-links every matching codec file", "[store][storage][copy]") +TEST_CASE("link_from hard-links every codec file", "[store][storage][link]") { const octree::Id root = octree::Id::root(); SECTION("one file") { - TemporaryDirectory source_directory("copy-one-source"); - TemporaryDirectory target_directory("copy-one-target"); + TemporaryDirectory source_directory("link-one-source"); + TemporaryDirectory target_directory("link-one-target"); auto source = make_indexed_storage(source_directory.path()); auto target = make_indexed_storage(target_directory.path()); REQUIRE(source.save(root, 41).has_value()); - REQUIRE(target.copy_from(root, source).has_value()); + REQUIRE(target.link_from(root, source).has_value()); REQUIRE(target.has(root).value()); CHECK(target.load(root).value() == 41); CHECK(std::filesystem::equivalent(source.paths(root)->front(), target.paths(root)->front())); @@ -332,13 +486,13 @@ TEST_CASE("copy_from hard-links every matching codec file", "[store][storage][co SECTION("multiple files") { - TemporaryDirectory source_directory("copy-many-source"); - TemporaryDirectory target_directory("copy-many-target"); + TemporaryDirectory source_directory("link-many-source"); + TemporaryDirectory target_directory("link-many-target"); auto source = make_indexed_storage(source_directory.path(), std::make_unique()); auto target = make_indexed_storage(target_directory.path(), std::make_unique()); REQUIRE(source.save(root, 42).has_value()); - REQUIRE(target.copy_from(root, source).has_value()); + REQUIRE(target.link_from(root, source).has_value()); const auto source_paths = source.paths(root).value(); const auto target_paths = target.paths(root).value(); REQUIRE(source_paths.size() == 2); @@ -346,137 +500,189 @@ TEST_CASE("copy_from hard-links every matching codec file", "[store][storage][co CHECK(std::filesystem::equivalent(source_paths[0], target_paths[0])); CHECK(std::filesystem::equivalent(source_paths[1], target_paths[1])); } +} - SECTION("same storage") +TEST_CASE("link_from rejects existing targets, missing sources and different codecs", "[store][storage][link]") +{ + const octree::Id root = octree::Id::root(); + + SECTION("existing target") + { + TemporaryDirectory source_directory("link-existing-source"); + TemporaryDirectory target_directory("link-existing-target"); + auto source = make_indexed_storage(source_directory.path()); + auto target = make_indexed_storage(target_directory.path()); + REQUIRE(source.save(root, 44).has_value()); + REQUIRE(target.save(root, 1).has_value()); + + const auto linked = target.link_from(root, source); + REQUIRE_FALSE(linked.has_value()); + CHECK(linked.error().code() == Error::Code::AlreadyExists); + CHECK(target.load(root).value() == 1); + } + + SECTION("missing source") { - TemporaryDirectory directory("copy-self"); - auto storage = make_indexed_storage(directory.path()); - REQUIRE(storage.save(root, 43).has_value()); - storage.settings().allow_overwrite = true; + TemporaryDirectory source_directory("link-missing-source"); + TemporaryDirectory target_directory("link-missing-target"); + auto source = make_indexed_storage(source_directory.path()); + auto target = make_indexed_storage(target_directory.path()); - REQUIRE(storage.copy_from(root, storage).has_value()); - CHECK(storage.load(root).value() == 43); + const auto linked = target.link_from(root, source); + REQUIRE_FALSE(linked.has_value()); + CHECK(linked.error().code() == Error::Code::NotFound); + CHECK_FALSE(target.has(root).value()); + } + + SECTION("different codecs") + { + TemporaryDirectory source_directory("link-codec-source"); + TemporaryDirectory target_directory("link-codec-target"); + auto source = make_indexed_storage(source_directory.path(), std::make_unique()); + auto target = make_indexed_storage(target_directory.path()); + REQUIRE(source.save(root, 43).has_value()); + + const auto linked = target.link_from(root, source); + REQUIRE_FALSE(linked.has_value()); + CHECK(linked.error().code() == Error::Code::Unsupported); + CHECK_FALSE(target.has(root).value()); } } -TEST_CASE("copy_from re-encodes differing path lists", "[store][storage][copy]") +TEST_CASE("storage records codec hashes and checkpoints them without node-file access", "[store][storage][hash]") { + TemporaryDirectory directory("hash-checkpoint"); const octree::Id root = octree::Id::root(); - TemporaryDirectory source_directory("copy-reencode-source"); - TemporaryDirectory target_directory("copy-reencode-target"); - auto source = make_indexed_storage(source_directory.path(), std::make_unique()); - auto target = make_indexed_storage(target_directory.path()); - REQUIRE(source.save(root, 43).has_value()); + const octree::Id child = root.child(2).value(); + { + auto storage = make_persistent_storage(directory.path()); + REQUIRE(storage.save(root, 0).has_value()); + REQUIRE(storage.save(child, 7).has_value()); + // The all-zero hash of zero is an identity like any other. + CHECK(storage.hash_for(root).value() == NodeDataHash(sizeof(int), std::byte { 0 })); + CHECK(storage.hash_for(child).value() == int_hash(7)); + REQUIRE(storage.save_index().has_value()); + } - REQUIRE(target.copy_from(root, source).has_value()); - CHECK(target.load(root).value() == 43); - CHECK(target.paths(root)->size() == 1); - CHECK(source.paths(root)->size() == 2); - CHECK_FALSE(std::filesystem::equivalent(source.paths(root)->front(), target.paths(root)->front())); + auto reopened = reopen(directory.path()); + REQUIRE(reopened.has_value()); + CHECK(reopened->load(child).value() == 7); + REQUIRE(std::filesystem::remove(reopened->path_for(child).value())); + CHECK(reopened->hash_for(child).value() == int_hash(7)); + CHECK(reopened->hash_for(root).value() == NodeDataHash(sizeof(int), std::byte { 0 })); } -TEST_CASE("copy_from enforces overwrite settings", "[store][storage][copy]") +TEST_CASE("hash lookups report missing and virtual nodes", "[store][storage][hash]") { + TemporaryDirectory directory("hash-lookup"); const octree::Id root = octree::Id::root(); - TemporaryDirectory source_directory("copy-overwrite-source"); - TemporaryDirectory target_directory("copy-overwrite-target"); + auto storage = make_indexed_storage(directory.path()); + REQUIRE(storage.save(root.child(3).value(), 1).has_value()); + REQUIRE(storage.index().is(store::NodeStatus::Virtual, root).value()); + CHECK(storage.hash_for(root).error().code() == Error::Code::NotFound); + CHECK(storage.hash_for(root.child(1).value()).error().code() == Error::Code::NotFound); + CHECK(storage.hash_for(root.child(3).value()).value() == int_hash(1)); +} + +TEST_CASE("links transfer recorded hashes", "[store][storage][link][hash]") +{ + const octree::Id root = octree::Id::root(); + TemporaryDirectory source_directory("hash-link-source"); + TemporaryDirectory target_directory("hash-link-target"); auto source = make_indexed_storage(source_directory.path()); auto target = make_indexed_storage(target_directory.path()); - REQUIRE(source.save(root, 44).has_value()); - REQUIRE(target.save(root, 1).has_value()); - - const auto rejected = target.copy_from(root, source); - REQUIRE_FALSE(rejected.has_value()); - CHECK(rejected.error().code() == Error::Code::AlreadyExists); - CHECK(target.load(root).value() == 1); - - target.settings().allow_overwrite = true; - REQUIRE(target.copy_from(root, source).has_value()); - CHECK(target.load(root).value() == 44); - CHECK(std::filesystem::equivalent(source.paths(root)->front(), target.paths(root)->front())); + REQUIRE(source.save(root, 5).has_value()); + REQUIRE(target.link_from(root, source).has_value()); + CHECK(std::filesystem::equivalent(source.path_for(root).value(), target.path_for(root).value())); + CHECK(target.hash_for(root).value() == source.hash_for(root).value()); } -TEST_CASE("failed multi-file overwrite leaves the target node unindexed", "[store][storage][copy]") +TEST_CASE("indexed sources are authoritative for links", "[store][storage][link][hash]") { const octree::Id root = octree::Id::root(); - TemporaryDirectory source_directory("copy-partial-source"); - TemporaryDirectory target_directory("copy-partial-target"); - auto source = make_indexed_storage(source_directory.path(), std::make_unique()); - auto target = make_indexed_storage(target_directory.path(), std::make_unique()); - REQUIRE(source.save(root, 45).has_value()); - REQUIRE(target.save(root, 1).has_value()); - target.settings().allow_overwrite = true; - - const auto target_paths = target.paths(root).value(); - REQUIRE(std::filesystem::remove(target_paths[1])); - REQUIRE(std::filesystem::create_directory(target_paths[1])); - REQUIRE(write_int(9, target_paths[1] / "blocker.data").has_value()); - - const auto copied = target.copy_from(root, source); - REQUIRE_FALSE(copied.has_value()); - CHECK(copied.error().code() == Error::Code::Io); + TemporaryDirectory source_directory("hash-authority-source"); + TemporaryDirectory target_directory("hash-authority-target"); + auto source = make_indexed_storage(source_directory.path()); + auto target = make_indexed_storage(target_directory.path()); + REQUIRE(write_int(4, source.path_for(root).value()).has_value()); + const auto linked = target.link_from(root, source); + REQUIRE_FALSE(linked.has_value()); + CHECK(linked.error().code() == Error::Code::NotFound); CHECK_FALSE(target.has(root).value()); - CHECK_FALSE(target.index().get(root).value().has_value()); - CHECK(std::filesystem::equivalent(source.paths(root)->front(), target_paths.front())); - CHECK(std::filesystem::is_directory(target_paths[1])); } -TEST_CASE("copy_from propagates source and codec failures", "[store][storage][copy]") +TEST_CASE("indexed loads reject valid payloads that differ from the index", "[store][storage][hash]") { + TemporaryDirectory directory("hash-mismatch"); const octree::Id root = octree::Id::root(); + auto storage = make_indexed_storage(directory.path()); + REQUIRE(storage.save(root, 13).has_value()); + REQUIRE(std::filesystem::remove(storage.path_for(root).value())); + REQUIRE(write_int(14, storage.path_for(root).value()).has_value()); + CHECK(storage.load(root).error().code() == Error::Code::CorruptData); +} - SECTION("missing source") - { - TemporaryDirectory source_directory("copy-missing-source"); - TemporaryDirectory target_directory("copy-missing-target"); - auto source = make_indexed_storage(source_directory.path()); - auto target = make_indexed_storage(target_directory.path()); - REQUIRE(target.save(root, 7).has_value()); - target.settings().allow_overwrite = true; - - const auto copied = target.copy_from(root, source); - REQUIRE_FALSE(copied.has_value()); - CHECK(copied.error().code() == Error::Code::NotFound); - CHECK(target.has(root).value()); - CHECK(target.load(root).value() == 7); - } +TEST_CASE("storage moves, destruction and thread-safe access keep hashes", "[store][storage][hash]") +{ + const octree::Id root = octree::Id::root(); + const octree::Id child = root.child(4).value(); + TemporaryDirectory first_directory("hash-move-first"); + TemporaryDirectory second_directory("hash-move-second"); - SECTION("decode failure") { - TemporaryDirectory source_directory("copy-decode-source"); - TemporaryDirectory target_directory("copy-decode-target"); - auto source_codec = std::make_unique(".source"); - ConfigurableCodec* source_codec_ptr = source_codec.get(); - auto source = make_indexed_storage(source_directory.path(), std::move(source_codec)); - auto target = make_indexed_storage(target_directory.path(), std::make_unique(".target")); - REQUIRE(source.save(root, 46).has_value()); - REQUIRE(target.save(root, 7).has_value()); - target.settings().allow_overwrite = true; - source_codec_ptr->fail_read = true; - - const auto copied = target.copy_from(root, source); - REQUIRE_FALSE(copied.has_value()); - CHECK(copied.error().code() == Error::Code::CorruptData); - CHECK(target.has(root).value()); - CHECK(target.load(root).value() == 7); - } + auto original = make_persistent_storage(first_directory.path()); + REQUIRE(original.save(root, 30).has_value()); + auto moved = std::move(original); + CHECK(moved.hash_for(root).value() == int_hash(30)); - SECTION("encode failure") - { - TemporaryDirectory source_directory("copy-encode-source"); - TemporaryDirectory target_directory("copy-encode-target"); - auto source = make_indexed_storage(source_directory.path(), std::make_unique(".source")); - auto target_codec = std::make_unique(".target"); - ConfigurableCodec* target_codec_ptr = target_codec.get(); - auto target = make_indexed_storage(target_directory.path(), std::move(target_codec)); - REQUIRE(source.save(root, 47).has_value()); - REQUIRE(target.save(root, 7).has_value()); - target.settings().allow_overwrite = true; - target_codec_ptr->fail_write = true; - - const auto copied = target.copy_from(root, source); - REQUIRE_FALSE(copied.has_value()); - CHECK(copied.error().code() == Error::Code::InvalidInput); - CHECK_FALSE(target.has(root).value()); + auto destination = make_indexed_storage(second_directory.path()); + destination = std::move(moved); + CHECK(destination.hash_for(root).value() == int_hash(30)); + + store::ThreadSafeStorage synchronized(std::move(destination)); + REQUIRE(synchronized.save(child, 31).has_value()); + CHECK(synchronized.hash_for(child).value() == int_hash(31)); } + + // Destruction checkpoints the dirty state. + auto reopened = reopen(first_directory.path()); + REQUIRE(reopened.has_value()); + CHECK(reopened->hash_for(root).value() == int_hash(30)); + CHECK(reopened->hash_for(child).value() == int_hash(31)); +} + +TEST_CASE("opening rejects indexes that lack required hashes", "[store][storage][hash]") +{ + TemporaryDirectory directory("hash-required"); + store::IndexMetadata index_file { + .index = {}, + .hashes = {}, + .layout_id = "flat", + .payload_class = "test.Int", + .codec_selector = ".data", + }; + REQUIRE(index_file.index.add(octree::Id::root())); + REQUIRE(octree::storage::write_index_file(directory.path() / "octree.storeindex", index_file).has_value()); + CHECK(reopen(directory.path()).error().code() == Error::Code::CorruptData); +} + +TEST_CASE("index hashes must match the physical nodes and the codec", "[store][index][hash]") +{ + const octree::Id root = octree::Id::root(); + const octree::Id child = root.child(1).value(); + store::Index index; + REQUIRE(index.add(child)); + store::Key2Hash_Map hashes { { child, int_hash(1) } }; + REQUIRE(store::validate_hashes(index, hashes, true).has_value()); + REQUIRE(store::validate_hashes(index, {}, false).has_value()); + + bool codec_hashes = true; + SECTION("missing hash") { hashes.clear(); } + SECTION("short hash") { hashes[child].resize(store::min_hash_size - 1); } + SECTION("hash of a virtual node") { hashes.emplace(root, int_hash(2)); } + SECTION("hash of an absent node") { hashes.emplace(root.child(2).value(), int_hash(3)); } + SECTION("hash with a codec that does not support hashing") { codec_hashes = false; } + const auto valid = store::validate_hashes(index, hashes, codec_hashes); + REQUIRE_FALSE(valid.has_value()); + CHECK(valid.error().code() == Error::Code::CorruptData); } diff --git a/unittests/terrainlib/store_traverse.cpp b/unittests/terrainlib/store_traverse.cpp index 465867d6..7eddfc0b 100644 --- a/unittests/terrainlib/store_traverse.cpp +++ b/unittests/terrainlib/store_traverse.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 diff --git a/unittests/terrainlib/string_utils.cpp b/unittests/terrainlib/string_utils.cpp index 9ef3699a..593a4da4 100644 --- a/unittests/terrainlib/string_utils.cpp +++ b/unittests/terrainlib/string_utils.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Martin Braunsperger + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "../catch2_helpers.h" #include "string_utils.h" diff --git a/unittests/terrainlib/texture_dilate_colors.cpp b/unittests/terrainlib/texture_dilate_colors.cpp index 39492f5e..43640d4e 100644 --- a/unittests/terrainlib/texture_dilate_colors.cpp +++ b/unittests/terrainlib/texture_dilate_colors.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/terrainlib/tiny_vector.cpp b/unittests/terrainlib/tiny_vector.cpp index f418f513..b2829545 100644 --- a/unittests/terrainlib/tiny_vector.cpp +++ b/unittests/terrainlib/tiny_vector.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/TinyVector.h" diff --git a/unittests/terrainlib/union_find.cpp b/unittests/terrainlib/union_find.cpp index 0a1a2e61..c08ab44f 100644 --- a/unittests/terrainlib/union_find.cpp +++ b/unittests/terrainlib/union_find.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 "../catch2_helpers.h" #include "containers/UnionFind.h" diff --git a/unittests/terrainlib/uv_atlas.cpp b/unittests/terrainlib/uv_atlas.cpp index 6bd9e9ee..f4f289ba 100644 --- a/unittests/terrainlib/uv_atlas.cpp +++ b/unittests/terrainlib/uv_atlas.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 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 #include diff --git a/unittests/tile_downloader/downloader.cpp b/unittests/tile_downloader/downloader.cpp index a2dcd67c..2ccefa24 100644 --- a/unittests/tile_downloader/downloader.cpp +++ b/unittests/tile_downloader/downloader.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "TileDownloader.h" #include diff --git a/unittests/tile_downloader/http_client.cpp b/unittests/tile_downloader/http_client.cpp index 7b036cf6..c6a4e147 100644 --- a/unittests/tile_downloader/http_client.cpp +++ b/unittests/tile_downloader/http_client.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "HttpClient.h" #include diff --git a/unittests/tile_downloader/logger.cpp b/unittests/tile_downloader/logger.cpp index 5a31d33a..a7b86730 100644 --- a/unittests/tile_downloader/logger.cpp +++ b/unittests/tile_downloader/logger.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "TileLogger.h" #include diff --git a/unittests/tile_downloader/url_builder.cpp b/unittests/tile_downloader/url_builder.cpp index e6bdaccf..e33e0f8f 100644 --- a/unittests/tile_downloader/url_builder.cpp +++ b/unittests/tile_downloader/url_builder.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "TileUrlBuilder.h" diff --git a/unittests/tile_downloader/write_file.cpp b/unittests/tile_downloader/write_file.cpp index 07c12ebb..61fda469 100644 --- a/unittests/tile_downloader/write_file.cpp +++ b/unittests/tile_downloader/write_file.cpp @@ -1,3 +1,21 @@ +/***************************************************************************** + * AlpineMaps.org + * Copyright (C) 2026 Adam Celarek-Litofcenko + * + * 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 "write_file.h" #include diff --git a/unittests/tilebuilder/alpine_raster_format.cpp b/unittests/tilebuilder/alpine_raster_format.cpp deleted file mode 100644 index a0d98223..00000000 --- a/unittests/tilebuilder/alpine_raster_format.cpp +++ /dev/null @@ -1,85 +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 -#include - -#include -#include "alpine_raster.h" -#include "ctb/Grid.hpp" - -using namespace radix; - -radix::tile::Border testTypeValue2Border(bool v) -{ - return v ? radix::tile::Border::Yes : radix::tile::Border::No; -} - -TEMPLATE_TEST_CASE("alpine raster format, border ", "", std::true_type, std::false_type) -{ - std::filesystem::remove_all("./unittest_tiles/"); - - 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::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")); - - 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 }, 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. - // it'll be checked in cesium.js - } - - SECTION("process all tiles") - { - 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 }); - - const auto check = [](const radix::tile::Descriptor& t) { - return std::filesystem::exists(fmt::format("./unittest_tiles/{}/{}/{}.png", t.id.zoom_level, t.id.coords.x, t.id.coords.y)); - }; - CHECK(std::transform_reduce(tiles.begin(), tiles.end(), true, std::logical_and<>(), check) == true); - } -#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, testTypeValue2Border(TestType::value)); - generator.setWarnOnMissingOverviews(false); - generator.process({ 4, 8 }); - const auto tiles = generator.tiler().generateTiles({ 4, 8 }); - - const auto check = [](const radix::tile::Descriptor& t) { - return std::filesystem::exists(fmt::format("./unittest_tiles/{}/{}/{}.png", t.id.zoom_level, t.id.coords.x, t.id.coords.y)); - }; - CHECK(std::transform_reduce(tiles.begin(), tiles.end(), true, std::logical_and<>(), check) == true); - } -#endif -} diff --git a/unittests/tilebuilder/dataset_reading.cpp b/unittests/tilebuilder/dataset_reading.cpp deleted file mode 100644 index 2b505789..00000000 --- a/unittests/tilebuilder/dataset_reading.cpp +++ /dev/null @@ -1,352 +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 -#include -#include -#include -#include "Dataset.h" -#include "DatasetReader.h" -#include "ctb/types.hpp" -#include "image_writer.h" -#include "srs.h" - -using namespace radix; - -namespace { -void require_projection_available(const Dataset& dataset, const OGRSpatialReference& target_srs) -{ - const auto dataset_srs = dataset.srs(); - const auto source_bounds = dataset.bounds(); - - std::shared_ptr transform; - REQUIRE_NOTHROW(transform = srs::transformation(dataset_srs, target_srs)); - REQUIRE(transform != nullptr); - - std::array xs = { (source_bounds.min.x + source_bounds.max.x) / 2.0 }; - std::array ys = { (source_bounds.min.y + source_bounds.max.y) / 2.0 }; - REQUIRE(transform->Transform(static_cast(xs.size()), xs.data(), ys.data())); -} -} - -TEST_CASE("reading") -{ - const std::vector at100m = { - "/austria/at_100m_mgi.tif", - "/austria/at_100m_epsg3857.tif", - "/austria/at_100m_epsg4326.tif", - "/austria/at_x149m_y100m_epsg4326.tif", - }; - - const std::vector vienna20m = { - "/austria/vienna_20m_mgi.tif", - "/austria/vienna_20m_epsg3857.tif", - "/austria/vienna_20m_epsg4326.tif", - }; - - const std::vector tauern10m = { - "/austria/tauern_10m_mgi.tif", - "/austria/tauern_10m_epsg3857.tif", - "/austria/tauern_10m_epsg4326.tif", - }; - - const std::vector pizbuin1m = { - "/austria/pizbuin_1m_mgi.tif", - "/austria/pizbuin_1m_epsg3857.tif", - "/austria/pizbuin_1m_epsg4326.tif", - }; - - OGRSpatialReference geodetic_srs; - geodetic_srs.importFromEPSG(4326); - geodetic_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - - SECTION("check for min and max heights") - { - const std::array test_srses = { 4326, 3857 }; - const std::array test_locations = { - // CRS bounds, [lower limit, at least one value smaller, at least one value larger, upper limit] -#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED - std::make_tuple( - "at100m", - at100m, - radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, - std::make_tuple(100.0f, 120.0f, 3000.0f, 3800.0f)), // Austria is between 115 and 3798m - std::make_tuple( - "vienna20m", - vienna20m, - radix::tile::SrsBounds{{16.17, 48.14}, {16.59, 48.33}}, - std::make_tuple(140.0f, 180.0f, 500.0f, 560.0f)), // vienna, between 151 and 542m -#endif - std::make_tuple( - "tauern10m", - tauern10m, - radix::tile::SrsBounds{{12.6934117, 47.0739300}, {12.6944580, 47.0748649}}, - std::make_tuple(3700.0f, 3720.0f, 3790.0f, 3800.0f)), // Grossglockner, 3798m with some surroundings - }; - - for (const auto& test : test_locations) { - const auto [test_name, test_datasets, geodetic_bounds, limits] = test; - - for (std::string dataset_name : test_datasets) { - const auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name)).value(); - for (const auto& test_srs : test_srses) { - OGRSpatialReference srs; - srs.importFromEPSG(test_srs); - srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - - const auto srs_bounds = srs::non_exact_bounds_transform(geodetic_bounds, geodetic_srs, srs); - - require_projection_available(*dataset, srs); - const DatasetReader reader(dataset, srs, 1); - if (ALP_UNITTESTS_DEBUG_IMAGES) { - const auto heights = reader.read(srs_bounds, 1000, 1000); - const auto s = std::string("/austria/").length(); - const auto l = dataset_name.length() - std::string(".tif").length() - s; - const auto debug_file = fmt::format("./heights_{}_srs{}_{}.png", test_name, test_srs, dataset_name.substr(s, l)); - REQUIRE(image::debug_out(heights, debug_file).has_value()); - } - - const auto heights = reader.read(srs_bounds, 100, 111); - REQUIRE(heights.width() == 100); - REQUIRE(heights.height() == 111); - const auto [lower_bound, lower_than, higher_than, higher_bound] = limits; - auto [min, max] = std::ranges::minmax(heights); - CHECK(min > lower_bound); - CHECK(min < lower_than); - CHECK(max < higher_bound); - CHECK(max > higher_than); - } - } - } - } - - SECTION("compare with ref render") - { - const std::array test_data = { -#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED - std::make_tuple( - "at100m", - at100m, - radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, - 620U, 350U, 42.0, 22.0), -#endif - std::make_tuple( - "pizbuin1m", - pizbuin1m, - radix::tile::SrsBounds{{10.105646780, 46.839864531}, {10.129815588, 46.847626067}}, - 740U, 315U, 3.01, 0.006), -#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED - std::make_tuple( - "pizbuin1m_highres", - pizbuin1m, - radix::tile::SrsBounds{{10.105646780, 46.839864531}, {10.129815588, 46.847626067}}, - 2000U, 850U, 10.0, 0.008), -#endif - }; - - for (const auto& test : test_data) { - auto [test_name, datasets, ref_bounds, render_width, render_height, max_abs_diff, max_mse] = test; - - const auto ref_dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(datasets.front())).value(); - require_projection_available(*ref_dataset, geodetic_srs); - const auto ref_reader = DatasetReader(ref_dataset, geodetic_srs, 1); - const auto ref_heights = ref_reader.read(ref_bounds, render_width, render_height); - if (ALP_UNITTESTS_DEBUG_IMAGES) { - REQUIRE(image::debug_out(ref_heights, fmt::format("./heights_ref.png")).has_value()); - } - - for (std::string dataset_name : datasets) { - const auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name)).value(); - require_projection_available(*dataset, geodetic_srs); - const auto reader = DatasetReader(dataset, geodetic_srs, 1); - const auto heights = reader.read(ref_bounds, render_width, render_height); - - const auto s = std::string("/austria/").length(); - const auto l = dataset_name.length() - std::string(".tif").length() - s; - - if (ALP_UNITTESTS_DEBUG_IMAGES) { - REQUIRE(image::debug_out(ref_heights, fmt::format("./heights_{}_{}.png", test_name, dataset_name.substr(s, l))).has_value()); - - 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)); - REQUIRE(image::debug_out(height_diffs, path).has_value()); - } - auto largest_abs_diff = 0.0; - const auto mse = std::transform_reduce(ref_heights.begin(), ref_heights.end(), heights.begin(), 0.0, std::plus<>(), [&largest_abs_diff](auto a, auto b) { - const auto t = std::abs(double(a) - double(b)); - largest_abs_diff = std::max(t, largest_abs_diff); - return t * t; - }) / 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); - } - } - } - -#if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED - SECTION("overview without warping") - { - REQUIRE(std::string(ALP_UNITTESTS_AUSTRIA_HIGHRES).length() > 5); - const auto border = 5; - const auto low_res_ds = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_100m_mgi.tif").value(); - const auto high_res_ds = Dataset::open_shared_raster(ALP_UNITTESTS_AUSTRIA_HIGHRES).value(); - const auto srs = low_res_ds->srs(); - - const auto low_res_reader = DatasetReader(low_res_ds, srs, 1); - const auto high_res_reader = DatasetReader(high_res_ds, srs, 1); - const auto render_width = low_res_ds->widthInPixels() - 2 * border; - const auto render_height = low_res_ds->heightInPixels() - 2 * border; - - // the error is not 0, apparently there is still some resampling going on. the diff image at least looks like sampling artifacts. - // in theory, gdalwarp should do the exact same thing as this unit test, but for some reason this is not the case. That's also - // indicated by the errors on the borders (which we are cutting away). - // i'm ignoring it for now, as the error seems low enouugh. - const auto max_abs_diff = 18.0; - const auto max_mse = 0.6; - - const auto pixel_width = low_res_ds->pixelWidthIn(srs); - const auto pixel_height = low_res_ds->pixelHeightIn(srs); - auto srs_bounds = srs::non_exact_bounds_transform(low_res_ds->bounds(), low_res_ds->srs(), srs); - srs_bounds.min = { srs_bounds.min.x + border * pixel_width, srs_bounds.min.y + border * pixel_height }; - srs_bounds.max = { srs_bounds.max.x - border * pixel_width, srs_bounds.max.y - border * pixel_height }; - - const auto t0 = std::chrono::high_resolution_clock::now(); - const auto low_res_heights = low_res_reader.read(srs_bounds, render_width, render_height); - const auto t1 = std::chrono::high_resolution_clock::now(); - const auto high_res_heights = high_res_reader.readWithOverviews(srs_bounds, render_width, render_height); - const auto t2 = std::chrono::high_resolution_clock::now(); - - REQUIRE(low_res_heights.width() == render_width); - REQUIRE(low_res_heights.height() == render_height); - REQUIRE(high_res_heights.width() == render_width); - REQUIRE(high_res_heights.height() == render_height); - - const auto low_res_time = std::chrono::duration_cast(t1 - t0).count(); - const auto high_res_time = std::chrono::duration_cast(t2 - t1).count(); - fmt::print("low res time: {}; high res time: {}\n", double(low_res_time) / 1000.0, double(high_res_time) / 1000.0); - - if (ALP_UNITTESTS_DEBUG_IMAGES) { - REQUIRE(image::debug_out(low_res_heights, fmt::format("./low_res_heights.png")).has_value()); - REQUIRE(image::debug_out(high_res_heights, fmt::format("./high_res_heights.png")).has_value()); - - 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); }); - REQUIRE(image::debug_out(height_diffs, "./diff_low_res_high_res.png").has_value()); - } - auto largest_abs_diff = 0.0; - const auto mse = std::transform_reduce(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), 0.0, std::plus<>(), [&largest_abs_diff](auto a, auto b) { - 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.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); - } - - SECTION("overview with warping") - { - REQUIRE(std::string(ALP_UNITTESTS_AUSTRIA_HIGHRES).length() > 5); - const auto low_res_ds = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_100m_epsg4326.tif").value(); - const auto high_res_ds = Dataset::open_shared_raster(ALP_UNITTESTS_AUSTRIA_HIGHRES).value(); - const auto srs = low_res_ds->srs(); - const auto low_res_reader = DatasetReader(low_res_ds, srs, 1); - const auto high_res_reader = DatasetReader(high_res_ds, srs, 1); - const auto srs_bounds = srs::non_exact_bounds_transform(radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, geodetic_srs, srs); - - const auto render_width = unsigned(low_res_ds->widthInPixels(srs_bounds, srs)); - const auto render_height = unsigned(low_res_ds->heightInPixels(srs_bounds, srs)); - const auto max_abs_diff = 70.0; - const auto max_mse = 8.0; - - const auto low_res_heights = low_res_reader.read(srs_bounds, render_width, render_height); - const auto high_res_heights = high_res_reader.readWithOverviews(srs_bounds, render_width, render_height); - - REQUIRE(low_res_heights.width() == render_width); - REQUIRE(low_res_heights.height() == render_height); - REQUIRE(high_res_heights.width() == render_width); - REQUIRE(high_res_heights.height() == render_height); - - if (ALP_UNITTESTS_DEBUG_IMAGES) { - REQUIRE(image::debug_out(low_res_heights, fmt::format("./ov_with_warping_low_res_heights.png")).has_value()); - REQUIRE(image::debug_out(high_res_heights, fmt::format("./ov_with_warping_high_res_heights.png")).has_value()); - - 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); }); - REQUIRE(image::debug_out(height_diffs, "./ov_with_warping_diff_low_res_high_res.png").has_value()); - } - auto largest_abs_diff = 0.0; - const auto mse = std::transform_reduce(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), 0.0, std::plus<>(), [&largest_abs_diff](auto a, auto b) { - 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.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); - } - - SECTION("lowres overview with warping") - { - REQUIRE(std::string(ALP_UNITTESTS_AUSTRIA_HIGHRES).length() > 5); - const auto low_res_ds = Dataset::open_shared_raster(ALP_TEST_DATA_DIR "/austria/at_100m_epsg4326.tif").value(); - const auto high_res_ds = Dataset::open_shared_raster(ALP_UNITTESTS_AUSTRIA_HIGHRES).value(); - const auto srs = low_res_ds->srs(); - const auto low_res_reader = DatasetReader(low_res_ds, srs, 1); - const auto high_res_reader = DatasetReader(high_res_ds, srs, 1); - const auto srs_bounds = srs::non_exact_bounds_transform(radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, geodetic_srs, srs); - - const auto render_width = unsigned(low_res_ds->widthInPixels(srs_bounds, srs)) / 10; - const auto render_height = unsigned(low_res_ds->heightInPixels(srs_bounds, srs)) / 10; - const auto max_abs_diff = 170; - const auto max_mse = 300.0; - - const auto low_res_heights = low_res_reader.read(srs_bounds, render_width, render_height); - const auto high_res_heights = high_res_reader.readWithOverviews(srs_bounds, render_width, render_height); - - REQUIRE(low_res_heights.width() == render_width); - REQUIRE(low_res_heights.height() == render_height); - REQUIRE(high_res_heights.width() == render_width); - REQUIRE(high_res_heights.height() == render_height); - - if (ALP_UNITTESTS_DEBUG_IMAGES) { - REQUIRE(image::debug_out(low_res_heights, fmt::format("./lowres_ov_with_warping_low_res_heights.png")).has_value()); - REQUIRE(image::debug_out(high_res_heights, fmt::format("./lowres_ov_with_warping_high_res_heights.png")).has_value()); - - 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); }); - REQUIRE(image::debug_out(height_diffs, "./lowres_ov_with_warping_diff_low_res_high_res.png").has_value()); - } - auto largest_abs_diff = 0.0; - const auto mse = std::transform_reduce(low_res_heights.begin(), low_res_heights.end(), high_res_heights.begin(), 0.0, std::plus<>(), [&largest_abs_diff](auto a, auto b) { - 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.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); - } -#endif -} diff --git a/unittests/tilebuilder/depth_first_tile_traverser.cpp b/unittests/tilebuilder/depth_first_tile_traverser.cpp deleted file mode 100644 index 5cf7fb5e..00000000 --- a/unittests/tilebuilder/depth_first_tile_traverser.cpp +++ /dev/null @@ -1,202 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 "Dataset.h" -#include "DatasetReader.h" -#include "TopDownTiler.h" -#include "ctb/GlobalMercator.hpp" -#include "depth_first_tile_traverser.h" -#include - -using namespace radix; - -TEST_CASE("depth_first_tile_traverser interface") -{ - struct ReadType { }; - const auto read_function = [&](radix::tile::Descriptor) { return ReadType {}; }; - - const auto aggregate_function = [](std::vector) { return ReadType {}; }; - - const auto grid = ctb::GlobalMercator(); - 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); -} - -TEST_CASE("depth_first_tile_traverser basics") -{ - struct ReadType { - glm::uvec2 d; - }; - std::set read_tiles; - const auto read_function = [&](const radix::tile::Descriptor& tile) { - read_tiles.insert(tile.id); - return ReadType {tile.id.coords}; - }; - - std::vector> aggregate_calls; - const auto aggregate_function = [&](const std::vector& data) { - aggregate_calls.push_back(data); - REQUIRE(data.size() > 0); - ReadType aggr = data.front(); - for (const auto& d : data) { - aggr.d.x = std::min(aggr.d.x, d.d.x); - aggr.d.y = std::max(aggr.d.y, d.d.y); - } - return aggr; - }; - - const auto grid = ctb::GlobalMercator(); - 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}})); - CHECK(result.d == glm::uvec2 { 0, 0 }); - } - - SECTION("reads root tile #2") - { - 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}})); - CHECK(result.d == glm::uvec2 { 1, 3 }); - } - - SECTION("reads only leaf tiles #1") - { - 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}})); - 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") - { - const auto result = traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, { 0, { 0, 0 } }, 1); - REQUIRE(aggregate_calls.size() == 1); - REQUIRE(aggregate_calls[0].size() == 4); - REQUIRE(aggregate_calls[0][0].d == glm::uvec2 { 0, 0 }); - REQUIRE(aggregate_calls[0][1].d == glm::uvec2 { 1, 0 }); - REQUIRE(aggregate_calls[0][2].d == glm::uvec2 { 0, 1 }); - REQUIRE(aggregate_calls[0][3].d == glm::uvec2 { 1, 1 }); - CHECK(result.d.x == 0); - CHECK(result.d.y == 1); - } -} - - -TEST_CASE("depth_first_tile_traverser austrian heights") -{ - const auto grid = ctb::GlobalMercator(); - 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); - const auto tile_reader = DatasetReader(dataset, grid.getSRS(), 1, false); - // const auto dataset_reader = DatasetReader() - std::set read_tiles; - const auto read_function = [&](const radix::tile::Descriptor& tile) -> std::pair { - read_tiles.insert(tile.id); - const auto tile_data = tile_reader.read(tile.srsBounds, tile.tileSize, tile.tileSize); - auto [min, max] = std::ranges::minmax(tile_data); - return std::make_pair(min, max); - }; - - std::vector>> aggregate_calls; - const auto aggregate_function = [&](const std::vector>& data) { - aggregate_calls.push_back(data); - REQUIRE(data.size() > 0); - auto aggr = data.front(); - for (const auto& d : data) { - aggr.first = std::min(aggr.first, d.first); - aggr.second = std::max(aggr.second, d.second); - } - return aggr; - }; - - 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}})); - CHECK(result.first >= 0); - CHECK(result.first <= 4000); - CHECK(result.second >= 0); - CHECK(result.second <= 4000); - } - - SECTION("aggregate is called correctly") - { - 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, 22}})); - CHECK(read_tiles.contains(radix::tile::Id{6, {33, 21}})); - - 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, 22}})); - CHECK(read_tiles.contains(radix::tile::Id{6, {35, 21}})); - CHECK(result.first >= 0); - CHECK(result.first <= 500); - CHECK(result.second >= 2000); - CHECK(result.second <= 4000); - } -} -TEST_CASE("depth_first_tile_traverser aggregate is not called with an empty vector") -{ - SECTION("web mercator") - { - const auto grid = ctb::GlobalMercator(); - // const auto bounds = radix::tile::SrsBounds{ -180, -90, 0, 90 }; - auto bounds = grid.getExtent(); - - // this provokes the following situation: - // 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); - const radix::tile::Id root_id = { 0, { 0, 0 } }; - - const auto read_function = [&](const radix::tile::Descriptor&) -> int { - return 0; - }; - const auto aggregate_function = [&](const std::vector& data) -> int { - REQUIRE(!data.empty()); - return 0; - }; - traverse_depth_first_and_aggregate(tiler, read_function, aggregate_function, root_id, 2); - } -} diff --git a/unittests/tilebuilder/image_writer.cpp b/unittests/tilebuilder/image_writer.cpp deleted file mode 100644 index 8fc548f2..00000000 --- a/unittests/tilebuilder/image_writer.cpp +++ /dev/null @@ -1,89 +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 -#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 }; - - REQUIRE(image::save_image_as_png(raster, output_path.string()).has_value()); - - 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") - { - const auto result = image::debug_out(radix::Raster {}, "empty.png"); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().code() == Error::Code::InvalidInput); - } - - 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); - REQUIRE(image::debug_out(raster, output_path.string()).has_value()); - - 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"; - - const auto result = image::save_image_as_png(radix::Raster({ 1, 1 }), output_path.string()); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().code() == Error::Code::Io); - } -} diff --git a/unittests/tilebuilder/parallel_tile_generator.cpp b/unittests/tilebuilder/parallel_tile_generator.cpp deleted file mode 100644 index f9326334..00000000 --- a/unittests/tilebuilder/parallel_tile_generator.cpp +++ /dev/null @@ -1,102 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * - * 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 "ParallelTileGenerator.h" -#include - -using namespace radix; - -TEST_CASE("parallel tile generator") -{ - std::atomic tile_counter = 0; - std::atomic validation_error_counter = 0; - std::filesystem::remove_all("./unittest_tiles/"); - - class MockTileWriter : public ParallelTileWriterInterface { - std::atomic* m_tile_counter = nullptr; - std::atomic* m_validation_error_counter = nullptr; - - public: - MockTileWriter(std::atomic* tile_counter, std::atomic* validation_error_counter) - : ParallelTileWriterInterface(radix::tile::Border::No, "empty") - , m_tile_counter(tile_counter) - , m_validation_error_counter(validation_error_counter) - { - } - 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)++; - (*m_tile_counter)++; - - std::ofstream ofs(file_path); - ofs << "this is some text in the new file\n"; - } - }; - - std::filesystem::path base_path = "./unittest_tiles/"; - 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") - { - generator.process({ 0, 7 }); - 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" / "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") - { - generator.process({ 0, 2 }, false, true); - CHECK(validation_error_counter == 0); - CHECK(tile_counter == 21); - CHECK(std::filesystem::exists(base_path / "0" / "0" / "0.empty")); - - CHECK(std::filesystem::exists(base_path / "1" / "0" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "1" / "0" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "1" / "1" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "1" / "1" / "1.empty")); - - CHECK(std::filesystem::exists(base_path / "2" / "0" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "0" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "0" / "2.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "0" / "3.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "1" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "1" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "1" / "2.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "1" / "3.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "2" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "2" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "2" / "2.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "2" / "3.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "3" / "0.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "3" / "1.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "3" / "2.empty")); - CHECK(std::filesystem::exists(base_path / "2" / "3" / "3.empty")); - } -} diff --git a/unittests/tilebuilder/parallel_tiler.cpp b/unittests/tilebuilder/parallel_tiler.cpp deleted file mode 100644 index b18124cf..00000000 --- a/unittests/tilebuilder/parallel_tiler.cpp +++ /dev/null @@ -1,324 +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 "Dataset.h" -#include "ParallelTiler.h" -#include "../catch2_helpers.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" -#include -#include -#include -#include -#include - -using Catch::Approx; -using namespace radix; - -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); - - CHECK(tiler.northEastTile(0).coords == glm::uvec2(0, 0)); - CHECK(tiler.southWestTile(0).coords == glm::uvec2(0, 0)); - - const auto l0_tiles = tiler.generateTiles(0); - REQUIRE(l0_tiles.size() == 1); - const auto t = l0_tiles.front(); - CHECK(t.id.zoom_level == 0); - CHECK(t.id.coords == glm::uvec2(0, 0)); - CHECK(t.gridSize == 256); - CHECK(t.tileSize == 256); - - // all extents are equal, because we have only one tile and no border - CHECK(t.srsBounds.min.x == Approx(grid.getExtent().min.x)); - CHECK(t.srsBounds.max.y == Approx(grid.getExtent().max.y)); - CHECK(t.srsBounds.min.y == Approx(grid.getExtent().min.y)); - CHECK(t.srsBounds.max.x == Approx(grid.getExtent().max.x)); - } - - 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); - - 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, 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 - 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, 0)); - CHECK(t.gridSize == 256); - CHECK(t.tileSize == 256); - - CHECK(t.srsBounds.min.x == Approx(0)); - CHECK(t.srsBounds.min.y == Approx(0)); - CHECK(t.srsBounds.max.y == Approx(grid.getExtent().max.y)); - CHECK(t.srsBounds.max.x == Approx(grid.getExtent().max.x)); - } - { - const auto l2_tiles = tiler.generateTiles(2); - REQUIRE(l2_tiles.size() == 1); - const auto t = l2_tiles.front(); - CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(2, 1)); - CHECK(t.gridSize == 256); - CHECK(t.tileSize == 256); - - CHECK(t.srsBounds.min.x == Approx(0)); - CHECK(t.srsBounds.min.y == Approx(0)); - CHECK(t.srsBounds.max.y == Approx(grid.getExtent().max.y / 2)); - CHECK(t.srsBounds.max.x == Approx(grid.getExtent().max.x / 2)); - } - } - - SECTION("geodetic / level 0") - { - const auto grid = ctb::GlobalGeodetic(64); - 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)); - - const auto l0_tiles = tiler.generateTiles(0); - REQUIRE(l0_tiles.size() == 2); - const auto t0 = l0_tiles[0]; - CHECK(t0.id.zoom_level == 0); - CHECK(t0.id.coords == glm::uvec2(0, 0)); - CHECK(t0.gridSize == 64); - CHECK(t0.tileSize == 65); - // north, south and west are equal to the extents (no overflow) - CHECK(t0.srsBounds.min.y == Approx(grid.getExtent().min.y)); - CHECK(t0.srsBounds.max.y == Approx(grid.getExtent().max.y + grid.getExtent().height() / 64)); - CHECK(t0.srsBounds.min.x == Approx(grid.getExtent().min.x)); - // east should be somewher close to the 0 meridian, 1 pixel east of it to be exact) - CHECK(t0.srsBounds.max.x == Approx(180.0 / 64)); - - const auto t1 = l0_tiles[1]; - CHECK(t1.id.zoom_level == 0); - CHECK(t1.id.coords == glm::uvec2(1, 0)); - CHECK(t1.gridSize == 64); - CHECK(t1.tileSize == 65); - // north and south are equal to the extent (no overflow) - CHECK(t1.srsBounds.min.y == Approx(grid.getExtent().min.y)); - CHECK(t1.srsBounds.max.y == Approx(grid.getExtent().max.y + grid.getExtent().height() / 64)); - // west is the 0 meriaidan - CHECK(t1.srsBounds.min.x == Approx(0)); - // east should be wrapped around. but lets make things easier for now, and clamp it. - // it goes mostly through the pacific anyways, and I wonder whether gdal even supports wrapping around the back.. - CHECK(t1.srsBounds.max.x == Approx(grid.getExtent().max.x + grid.resolution(0))); - } - - 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); - - 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, 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, 0)); - CHECK(t.gridSize == 64); - CHECK(t.tileSize == 65); - - constexpr auto tileSizeX = 360 / 4.0; - constexpr auto tileSizeY = 180 / 2.0; - CHECK(t.srsBounds.min.x == Approx(0)); - CHECK(t.srsBounds.min.y == Approx(0)); - CHECK(t.srsBounds.max.x == Approx(tileSizeX + tileSizeX / t.gridSize)); - CHECK(t.srsBounds.max.y == Approx(90 + tileSizeY / t.gridSize)); - } - { - const auto l2_tiles = tiler.generateTiles(2); - REQUIRE(l2_tiles.size() == 1); - const auto t = l2_tiles.front(); - CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(4, 0)); - CHECK(t.gridSize == 64); - CHECK(t.tileSize == 65); - - constexpr auto tileSizeX = 360 / 8.0; - constexpr auto tileSizeY = 180 / 4.0; - CHECK(t.srsBounds.min.x == Approx(0)); - CHECK(t.srsBounds.min.y == Approx(tileSizeY)); - CHECK(t.srsBounds.max.x == Approx(tileSizeX + tileSizeX / t.gridSize)); - CHECK(t.srsBounds.max.y == Approx(90 + tileSizeY / t.gridSize)); - } - } - - 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); - - 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, 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 - 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, 1)); - CHECK(t.gridSize == 256); - CHECK(t.tileSize == 256); - - CHECK(t.srsBounds.min.x == Approx(grid.getExtent().min.x)); - CHECK(t.srsBounds.min.y == Approx(grid.getExtent().min.y)); - CHECK(t.srsBounds.max.x == Approx(0)); - CHECK(t.srsBounds.max.y == Approx(0)); - } - { - const auto l2_tiles = tiler.generateTiles(2); - REQUIRE(l2_tiles.size() == 1); - const auto t = l2_tiles.front(); - CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(1, 2)); - CHECK(t.gridSize == 256); - CHECK(t.tileSize == 256); - - CHECK(t.srsBounds.min.x == Approx(grid.getExtent().min.x + grid.getExtent().width() / 4.0)); - CHECK(t.srsBounds.min.y == Approx(grid.getExtent().min.y + grid.getExtent().height() / 4.0)); - CHECK(t.srsBounds.max.x == Approx(0)); - CHECK(t.srsBounds.max.y == Approx(0)); - } - } - - 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); - - 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, 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, 1)); - CHECK(t.gridSize == 64); - CHECK(t.tileSize == 65); - - constexpr auto tileSizeX = 360 / 4.0; - constexpr auto tileSizeY = 180 / 2.0; - CHECK(t.srsBounds.min.x == Approx(-tileSizeX)); - CHECK(t.srsBounds.min.y == Approx(-tileSizeY)); - CHECK(t.srsBounds.max.x == Approx(tileSizeX / 64.0)); - CHECK(t.srsBounds.max.y == Approx(tileSizeX / 64.0)); - } - { - const auto l2_tiles = tiler.generateTiles(2); - REQUIRE(l2_tiles.size() == 1); - const auto t = l2_tiles.front(); - CHECK(t.id.zoom_level == 2); - CHECK(t.id.coords == glm::uvec2(2, 3)); - CHECK(t.gridSize == 64); - CHECK(t.tileSize == 65); - - constexpr auto tileSizeX = 360 / 8.0; - constexpr auto tileSizeY = 180 / 4.0; - CHECK(t.srsBounds.min.x == Approx(-2 * tileSizeX)); - CHECK(t.srsBounds.min.y == Approx(-90)); - CHECK(t.srsBounds.max.x == Approx(-tileSizeX + tileSizeX / 64.0)); - CHECK(t.srsBounds.max.y == Approx(-90 + tileSizeY + tileSizeX / 64.0)); - } - } -} - -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); - - SECTION("generate from 0 to 7") - { - const auto tiles = tiler.generateTiles({ 0, 7 }); - CHECK(!tiles.empty()); - std::map n_tiles; - for (const auto& t : tiles) { - n_tiles[t.id.zoom_level]++; - } - REQUIRE(n_tiles.size() == 8); - CHECK(n_tiles[0] == 1); - CHECK(n_tiles[1] == 1); - CHECK(n_tiles[2] == 1); - CHECK(n_tiles[3] == 1); - CHECK(n_tiles[4] == 1); - CHECK(n_tiles[5] == 4); - CHECK(n_tiles[6] == 6); - CHECK(n_tiles[7] == 12); - - const auto check = [](const radix::tile::Descriptor& t) { - return t.tileSize == 257 && t.gridSize == 256; - }; - CHECK(std::transform_reduce(tiles.begin(), tiles.end(), true, std::logical_and<>(), check) == true); - } - - SECTION("generate from 4 to 6") - { - const auto tiles = tiler.generateTiles({ 4, 6 }); - CHECK(!tiles.empty()); - std::map n_tiles; - for (const auto& t : tiles) { - n_tiles[t.id.zoom_level]++; - } - REQUIRE(n_tiles.size() == 3); - CHECK(n_tiles[4] == 1); - CHECK(n_tiles[5] == 4); - CHECK(n_tiles[6] == 6); - - const auto check = [](const radix::tile::Descriptor& t) { - return t.tileSize == 257 && t.gridSize == 256; - }; - CHECK(std::transform_reduce(tiles.begin(), tiles.end(), true, std::logical_and<>(), check) == true); - } -} diff --git a/unittests/tilebuilder/tile_heights_generator.cpp b/unittests/tilebuilder/tile_heights_generator.cpp deleted file mode 100644 index 89c532ae..00000000 --- a/unittests/tilebuilder/tile_heights_generator.cpp +++ /dev/null @@ -1,71 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 "TileHeightsGenerator.h" -#include -#include - -using namespace radix; - -TEST_CASE("TileHeightsGenerator") -{ - const auto base_path = std::filesystem::path("./unittest_tile_heights"); - std::filesystem::remove_all(base_path); - - 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::Border::Yes, base_path / file_name); - generator.run(8); - - const auto heights = TileHeights::read_from(base_path / file_name); - { - auto [min, max] = heights.query({ 0, { 0, 0 } }); - CHECK(min == 0); - CHECK(max > 2500); - } - - { - 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); - CHECK(max < 2500); - } - } - - SECTION("geodetic") { - 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); - { - auto [min, max] = heights.query({ 0, { 1, 0 } }); - CHECK(min == 0); - CHECK(max > 2500); - } - - { - 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); - CHECK(max < 3800); - } - } -} diff --git a/unittests/tilebuilder/top_down_tiler.cpp b/unittests/tilebuilder/top_down_tiler.cpp deleted file mode 100644 index 61645361..00000000 --- a/unittests/tilebuilder/top_down_tiler.cpp +++ /dev/null @@ -1,101 +0,0 @@ -/***************************************************************************** - * Alpine Terrain Builder - * 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 "Dataset.h" -#include "ParallelTiler.h" -#include "TopDownTiler.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" -#include -#include - -using namespace radix; - -namespace { -void compare_tile_lists(const std::vector& a_tiles, std::vector b_tiles) -{ - CHECK(a_tiles.size() == b_tiles.size()); - - for (const auto& tile : a_tiles) { - const auto matching_tile_iter = std::find_if(b_tiles.begin(), b_tiles.end(), [&](const radix::tile::Descriptor& t) { - return t.id == tile.id; - }); - REQUIRE(matching_tile_iter != b_tiles.end()); - - CHECK(tile.tileSize == matching_tile_iter->tileSize); - CHECK(tile.gridSize == matching_tile_iter->gridSize); - CHECK(tile.srsBounds == matching_tile_iter->srsBounds); - CHECK(tile.srs_epsg == matching_tile_iter->srs_epsg); - - b_tiles.erase(matching_tile_iter); - } - CHECK(b_tiles.empty()); -} - -} - -TEST_CASE("TopDownTiler") -{ - SECTION("mercator / level 0 all") - { - const auto grid = ctb::GlobalMercator(); - const auto tiler = TopDownTiler(grid, grid.getExtent(), radix::tile::Border::No); - - const auto tiles = tiler.generateTiles({0, { 0, 0 }}); - REQUIRE(tiles.size() == 4); - const auto parallel_tiler = ParallelTiler(grid, grid.getExtent(), radix::tile::Border::No); - compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); - } - - SECTION("mercator / level 0 austria") - { - 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); - - const auto tiles = tiler.generateTiles({0, { 0, 0 }}); - REQUIRE(tiles.size() == 1); - 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); - - 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); - compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); - } - - SECTION("geodetic / level 0 austria") - { - 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); - - const auto tiles = tiler.generateTiles({0, { 1, 0 }}); - REQUIRE(tiles.size() == 1); - const auto parallel_tiler = ParallelTiler(grid, bounds, radix::tile::Border::No); - compare_tile_lists(tiles, parallel_tiler.generateTiles(1)); - } -}