From 31a324b2e4446c6ef9ed570e0ab5b2e88ad30794 Mon Sep 17 00:00:00 2001 From: Adam Celarek <5292991+adam-ce@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:26:03 +0200 Subject: [PATCH 01/83] worked on raster store arch --- docs/raster-store/architecture.md | 168 ++++++++----------- docs/raster-store/sampling-and-generation.md | 2 +- docs/raster-store/storage-format.md | 94 ++--------- 3 files changed, 90 insertions(+), 174 deletions(-) diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 392c02e9..af1c8d91 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -1,37 +1,51 @@ # Architecture +This document describes the intended raster-store (RS), raster-fundamentalis (RF), tile-base (TB), and tile-server architecture. + +Raster-store (RS) is a storage mechanism for geospatial raster data tiles for the alpine maps project. +It is build on top of the store architecture, which is used for 3d meshes as well. +Tile data is made available as templetized radix::Raster<> objects, and referenced with radix::tile::Id (a Google/Mapbox/XYZ tile identifier). +Supported template parameters are in particular basic data types (float, double, all int types etc), and glm vector types. +Every texel contains a data value and attribution reference into a global attribution table. +The attribution table contains meta information like accuracy and date in addition to the copyright information. +[storage-format.md](storage-format.md) notes down the details. + +Raster-fundamentalis (RS) is our world-wide authoritative dataset and uses RS for storage. +RF is build from many different sources, some of which contain world wide data, others contain more accurate but only local data. +Any data-source is first transformed into a RF dataset using rf-builder. +The generated RF dataset will have only one attribution. +Two data-sources can be combined using rf-merger, and an attribution priority list. +The merger selects tiles with the higher priority, or resolves conflicts on a per-texel basis, if necessary. + +Tile-base is built from RF by adding overviews (lower resolution versions of the original data). +Several aggregation methods for the lower resolutions must be supported (at least min/max/avg). +Tile-base is consumed by the tile-server, which generates tiles for the alpine-maps renderer client. +The tile-server can combine several tile-base datasets into one tile, e.g. a digital surface and terrain model (dsm and dtm). +The client can ask for different resolutions (e.g. 65px or 256px tiles), for different pixel types (area or vertex pixels with overlapping borders, see [sampling-and-generation.md](sampling-and-generation.md) for details), and for different encodings (e.g. JPEG, PNG, compressed ETC2, compressed DXT1, comrpessed raw, etc). +Raster-store (RF, and TB) always use area pixels. + +RS datasets are potentially several TiB in size, so building and merging can take several days or weeks. +In order to prevent premature overengineering, the following policies should be used for rf-builder, rf-merger, and tb-builder: +- every build or merge generates a new dataset +- errors abort the build +- the index is updated at least every few minutes to keep the data readable if there is an abort. +- for builds and merges, it's possible to submit a cache store (e.g. from an earlier or aborted run), which allows to skip processing of already existant nodes. +- if a node can be reused (either from cache, or when merging 2 datasets), a hard link should be set to prevent storage cost explosion. ## 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: - +So far, a refactor delivered the dimension-neutral mechanisms to index RS: - `store::Index`, `store::traverse`, layouts, runtime codecs, storage, typed errors, and cache interfaces; -- `octree::StoreTraits` plus legacy 3D layout/index/open adapters; +- `octree::StoreTraits` 3D layout/index/open adapters (for reference only); - `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. + `radix::tile::Id`; ### 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. +`store::Storage`, and `store::IndexedStorage`. -A legacy 3D mesh dataset is opened through the 3D adapter: +A 3D mesh dataset is opened through the 3D adapter (for reference): ```cpp #include "mesh/storage.h" @@ -64,18 +78,17 @@ auto walked = store::traverse(index, [](const auto &id, store::NodeStatus status 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) ├── read by tile-server @@ -84,24 +97,21 @@ 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: +A store directory is an immutable snapshot. Each snapshot contains an index, a meta-data file (e.g. the used codec), a source attribution table, and the data: ```text -store/ -└── snapshot-id/ - ├── source_attribution_table.ard - ├── raster_store.index - └── //.amort +store-snapshot-id/ + ├── raster_store.metadata + ├── raster_store.index + ├── source_attribution_table.json + ├── build_log.txt + └── //.amort ``` The shown payload path is the default `zoom/x/y_google` layout. Other layouts -may map the same tile IDs differently; there is no mandatory `chunks/` -directory. +may map the same tile IDs differently; -## Sparse quadtree index +### Sparse quadtree index The index uses the same four logical states as the octree index: @@ -115,48 +125,26 @@ The index uses the same four logical states as the octree index: `Missing` is represented by absence from the index, not serialized as an entry. -The index answers structural questions only. It does not claim that every -child exists and does not mark a virtual subtree as spatially complete. - -### Required operations - -- Look up a node without probing the filesystem. -- Add and remove physical nodes while maintaining virtual ancestors. -- Traverse only indexed branches. -- Enumerate physical descendants of a subtree. -- Find the nearest physical ancestor for fallback. -- Determine whether descendants may improve a requested output. -- Serialize and validate a versioned 2D topology. +### Chunk (tile) model -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. +Each Chunk is one radix::Raster<> tile and metadata. +Both are specified by the codec. +Different codecs can be used for writing and reading if they are compatible (e.g. there can be a read-only codec only returning the metadata). -## Chunk model - -Each physical node owns one logical chunk, see storage-format.md - -## Source selection and fallback - -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. - -At the beginning the selection will be binary, later we may introduce blending (over pixels of one zoom level, or several zoom levels). +Codecs used for writing should be lossless in order not to degenerate the data during repeated encdoding (e.g. merging, overview generation etc.). ## Snapshot lifecycle A snapshot is never mutated, instead, operations build new snapshots, while reducing disk usage by using hard links: -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. +E.g., when adding data, we would: +1. define a validity mask for the new data (in vector format, everything inside is attributed to the dataset). +2. create a new sf from the new data and validity mask using rf-builder +3. define an attribution source merging priority (an ordering of attribution sources) +4. using these two, a new snapshot is generated using rf-merger. most tiles will be hard-linked from one of the datasets, the border tiles will have the higher resolution of the two, and be merged, new chunks/tiles. 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. ### Publication @@ -164,9 +152,8 @@ A new snapshot is assembled in a sibling directory named `.part`. Publication follows this protocol: 1. Write all payload and metadata files into the `.part` directory. -2. Write the index last and validate the completed snapshot. -3. Flush and close every file. -4. Atomically rename the directory to `` on the same filesystem. +2. Write the final index last and validate the completed snapshot. +3. Rename the directory to `` on the same filesystem. The final destination must not already exist. A `.part` directory is incomplete and is never considered published. The rename removes the suffix; @@ -184,30 +171,19 @@ 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 +directory or a final snapshot may be unusable and must be validated or 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. +## Tools -The store owns: +### tb_builder +tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. +- lives in src/tb-builder +- see also [sampling-and-generation.md](sampling-and-generation.md) -- chunk location and decoding; -- sparse hierarchy and physical fallback; -- exact stored source map; -- source catalog lookup; and -- consistent window access across chunk boundaries. +### tile_server +The tile-server consumes tile-base and generates 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 +- some tile layers will want to pack several tile-bases (e.g. dtm+dsm, percentage larger than slope angle (PLaTSA) + phong shading + AO shading) +- extra ECEF bounding sphere tree (later) +- lives in src/tile-server/* diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index 83309521..2f6feecc 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -187,7 +187,7 @@ 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 | +| Orthophoto | linear-light filtering; no alpha support needed | | Categorical | mode, coverage, or another categorical policy | | Probability/coverage | conservative area averaging may be appropriate | | Vector/normal | component filtering followed by normalization where needed | diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 5bfaf28e..5cc9ef99 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -1,22 +1,16 @@ # 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) +## Implementation status +The 3D octree stores now use the versioned envelope formats. 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. + + ## 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 @@ -28,12 +22,10 @@ The source attribution table is used in raster-fundamentalis, tile-base, and in - 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). +- the index must be checked to be smaller than 2^16-1, and we produce an unsupported error if it becomes larger (this is because of the storage format of tiles). +- the source-attributino table is stored in a file named source_attribution_table.json +- there is one per directory tree +- given a tile or dataset (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.json 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. @@ -42,70 +34,18 @@ this is the binary format used to store rf and tb tiles. both share the same bas - 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. +- the file ending is .amort (AlpineMapsOrg raster tile), it is serialised using io::envelope. ## raster-fundamentalis (rf) format -raster-fundamentalis is our authoritative raster-store, containing only the data and no overviews / downsampled version. +raster-fundamentalis is our authoritative raster-store. - 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. +tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. - 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/* +## 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) +- needs at least support for dxt1 / etc2 compression, jpeg / png, compressed radix::Rasters<> +- also support for metadata (like bounding sphere tree in ECEF) From 4f730e686c221b9247e46ce396b791911cf97d86 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:33:21 +0200 Subject: [PATCH 02/83] Consolidate envelope implementation into envelope.h --- src/dag_builder/codec.cpp | 1 - src/terrainlib/CMakeLists.txt | 1 - src/terrainlib/io/envelope.h | 206 +++++++++++++++++++++- src/terrainlib/io/envelope.inl | 168 ------------------ src/terrainlib/io/envelope_file.h | 54 ------ src/terrainlib/mesh/codec/SfMesh.h | 2 +- src/terrainlib/octree/storage/IndexFile.h | 2 +- 7 files changed, 206 insertions(+), 228 deletions(-) delete mode 100644 src/terrainlib/io/envelope.inl delete mode 100644 src/terrainlib/io/envelope_file.h diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index f5a5badf..31fa9804 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -15,7 +15,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" diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 11c40f01..f4d80cab 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -58,7 +58,6 @@ add_library(terrainlib io/compression.h io/compression.cpp io/conversion.h io/envelope.h - io/envelope_file.h io/utils.h io/utils.cpp mesh/EncodedMesh.h diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index cf0e770e..0f53e5d3 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -1,11 +1,17 @@ #pragma once +#include "io/bytes.h" #include "io/compression.h" +#include + #include #include #include #include +#include +#include +#include #include #include #include @@ -127,6 +133,202 @@ Expected serialize(const typename Schema::latest_type& payload, template Expected deserialize(std::span bytes, std::size_t max_decompressed_size = default_max_decompressed_size); -} // 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)); + } -#include "io/envelope.inl" + 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); +} + +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/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/mesh/codec/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index 3c0de760..3b509c3f 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -3,7 +3,7 @@ #include #include -#include "io/envelope_file.h" +#include "io/envelope.h" #include "mesh/SimpleMesh.h" #include "mesh/codec/SfMeshFormat.h" #include "store/Codec.h" diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index c1288e73..67a463e6 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -6,7 +6,7 @@ #include #include -#include "io/envelope_file.h" +#include "io/envelope.h" #include "octree/StoreTraits.h" #include "octree/store_layout/Mappings.h" #include "store/Index.h" From ed0905f6fe9c41a575ca7c6070557aff822e2190 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:13:55 +0200 Subject: [PATCH 03/83] Document raster-store storage design and implementation plan --- .../0001-shared-raster-attribution-table.md | 16 ++ docs/adr/0002-native-raster-pixel-buffers.md | 11 + docs/raster-store/architecture.md | 186 +++++++++-------- docs/raster-store/implementation-plan.md | 60 ++++++ docs/raster-store/storage-format.md | 190 +++++++++++++----- docs/raster-store/terminology.md | 11 +- 6 files changed, 346 insertions(+), 128 deletions(-) create mode 100644 docs/adr/0001-shared-raster-attribution-table.md create mode 100644 docs/adr/0002-native-raster-pixel-buffers.md create mode 100644 docs/raster-store/implementation-plan.md 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/raster-store/architecture.md b/docs/raster-store/architecture.md index af1c8d91..abbdedfc 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -1,44 +1,37 @@ # Architecture -This document describes the intended raster-store (RS), raster-fundamentalis (RF), tile-base (TB), and tile-server architecture. - -Raster-store (RS) is a storage mechanism for geospatial raster data tiles for the alpine maps project. -It is build on top of the store architecture, which is used for 3d meshes as well. -Tile data is made available as templetized radix::Raster<> objects, and referenced with radix::tile::Id (a Google/Mapbox/XYZ tile identifier). -Supported template parameters are in particular basic data types (float, double, all int types etc), and glm vector types. -Every texel contains a data value and attribution reference into a global attribution table. -The attribution table contains meta information like accuracy and date in addition to the copyright information. -[storage-format.md](storage-format.md) notes down the details. - -Raster-fundamentalis (RS) is our world-wide authoritative dataset and uses RS for storage. -RF is build from many different sources, some of which contain world wide data, others contain more accurate but only local data. -Any data-source is first transformed into a RF dataset using rf-builder. -The generated RF dataset will have only one attribution. -Two data-sources can be combined using rf-merger, and an attribution priority list. -The merger selects tiles with the higher priority, or resolves conflicts on a per-texel basis, if necessary. - -Tile-base is built from RF by adding overviews (lower resolution versions of the original data). -Several aggregation methods for the lower resolutions must be supported (at least min/max/avg). -Tile-base is consumed by the tile-server, which generates tiles for the alpine-maps renderer client. -The tile-server can combine several tile-base datasets into one tile, e.g. a digital surface and terrain model (dsm and dtm). -The client can ask for different resolutions (e.g. 65px or 256px tiles), for different pixel types (area or vertex pixels with overlapping borders, see [sampling-and-generation.md](sampling-and-generation.md) for details), and for different encodings (e.g. JPEG, PNG, compressed ETC2, compressed DXT1, comrpessed raw, etc). -Raster-store (RF, and TB) always use area pixels. - -RS datasets are potentially several TiB in size, so building and merging can take several days or weeks. -In order to prevent premature overengineering, the following policies should be used for rf-builder, rf-merger, and tb-builder: -- every build or merge generates a new dataset -- errors abort the build -- the index is updated at least every few minutes to keep the data readable if there is an abort. -- for builds and merges, it's possible to submit a cache store (e.g. from an earlier or aborted run), which allows to skip processing of already existant nodes. -- if a node can be reused (either from cache, or when merging 2 datasets), a hard link should be set to prevent storage cost explosion. + +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. + +[Storage format](storage-format.md) defines the directory layout, serialized +data, and format invariants. [Terminology](terminology.md) defines the domain +language, and [sampling and generation](sampling-and-generation.md) covers +sampling and filtering policy. + +## Current implementation scope + +The first raster-store library implementation covers tile storage only: +typed tiles, attribution tables, codecs, persistent index and metadata, +dataset opening, and snapshot publication. Spatial window reads, ancestor +fallback, builders, merging, and sampling are outside this implementation. +It remains part of the existing `terrainlib` target. ## Implementation status + So far, a refactor delivered the dimension-neutral mechanisms to index RS: + - `store::Index`, `store::traverse`, layouts, runtime codecs, storage, typed errors, and cache interfaces; - `octree::StoreTraits` 3D layout/index/open adapters (for reference only); - `raster_store::StoreTraits` for in-memory topology keyed by `radix::tile::Id`; +The 3D octree stores use versioned envelopes. Persistent raster index and +metadata adapters, the `.amort` codec, snapshot publication, and raster +opening APIs are not implemented yet. + ### Final public names The shared API uses `store::NodeStatus`, `store::NodeStatusOrMissing`, @@ -97,54 +90,71 @@ tile-base store (one per layer, one per data version. user visible server should ``` ## Dataset organization -A store directory is an immutable snapshot. Each snapshot contains an index, a meta-data file (e.g. the used codec), a source attribution table, and the data: - -```text -store-snapshot-id/ - ├── raster_store.metadata - ├── raster_store.index - ├── source_attribution_table.json - ├── build_log.txt - └── //.amort -``` - -The shown payload path is the default `zoom/x/y_google` layout. Other layouts -may map the same tile IDs differently; - -### Sparse quadtree index -The index uses the same four logical states as the octree index: - -| State | Chunk exists | Indexed descendants | -|-----------|-------------:|--------------------:| -| `Leaf` | yes | no | -| `Inner` | yes | yes | -| `Virtual` | no | yes | -| `Missing` | no | no | - -`Missing` is represented by absence from the index, not serialized as an -entry. - -### Chunk (tile) model - -Each Chunk is one radix::Raster<> tile and metadata. -Both are specified by the codec. -Different codecs can be used for writing and reading if they are compatible (e.g. there can be a read-only codec only returning the metadata). - -Codecs used for writing should be lossless in order not to degenerate the data during repeated encdoding (e.g. merging, overview generation etc.). +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). ## Snapshot lifecycle -A snapshot is never mutated, instead, operations build new snapshots, while reducing disk usage by using hard links: +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: -E.g., when adding data, we would: -1. define a validity mask for the new data (in vector format, everything inside is attributed to the dataset). -2. create a new sf from the new data and validity mask using rf-builder -3. define an attribution source merging priority (an ordering of attribution sources) -4. using these two, a new snapshot is generated using rf-merger. most tiles will be hard-linked from one of the datasets, the border tiles will have the higher resolution of the two, and be merged, new chunks/tiles. +- 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. 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. +in-place modification. Obsolete snapshots can be deleted; other hard links +keep the data alive. + +Published snapshots are opened through `IndexedStorage`. Its API currently +also exposes mutation, so immutability relies on caller discipline. Making +the type truly read-only belongs to a separate work package. + +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 to decide whether reuse is +valid. 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. + +### Opening and checkpoints + +Normal opening rejects `.part` snapshot directories. The explicit +`allow_incomplete` option opens one as `IndexedStorage` for cache reuse, using +the last saved index and ignoring unindexed files. Checkpoint timing belongs +to the builder, which explicitly saves the index periodically. + +Opening and publication do not perform an additional whole-snapshot +validation pass or scan payload files for existence. Errors from envelope +decoding and the normal format-adapter/opening path are propagated when the +index or a tile is read. Generic envelope checks cover the serialized +representation, version, compression, and checksum; index invariants belong +to the format adapter. The attribution table is parsed separately as JSON. ### Publication @@ -152,9 +162,11 @@ A new snapshot is assembled in a sibling directory named `.part`. Publication follows this protocol: 1. Write all payload and metadata files into the `.part` directory. -2. Write the final index last and validate the completed snapshot. +2. Write the final index last and flush and close the output files. 3. Rename the directory to `` on the same filesystem. +Publication returns success after the rename without reopening the snapshot. + 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 @@ -177,13 +189,25 @@ rebuilt. ## Tools ### tb_builder -tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. -- lives in src/tb-builder -- see also [sampling-and-generation.md](sampling-and-generation.md) + +Tile-base adds overviews to RF and retains the data. 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. +See [sampling and generation](sampling-and-generation.md) for filtering and +source-selection policy. + +Planned location: `src/tb-builder`. ### tile_server -The tile-server consumes tile-base and generates 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 -- some tile layers will want to pack several tile-bases (e.g. dtm+dsm, percentage larger than slope angle (PLaTSA) + phong shading + AO shading) -- extra ECEF bounding sphere tree (later) -- lives in src/tile-server/* + +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. + +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. + +Planned location: `src/tile-server/*`. diff --git a/docs/raster-store/implementation-plan.md b/docs/raster-store/implementation-plan.md new file mode 100644 index 00000000..71c4952e --- /dev/null +++ b/docs/raster-store/implementation-plan.md @@ -0,0 +1,60 @@ +# Raster-store tile-storage implementation plan + +Status: proposed implementation plan, awaiting approval. The accepted storage +contract is in [architecture.md](architecture.md) and +[storage-format.md](storage-format.md). + +## Scope + +Implement persistent tile storage in `terrainlib`, using the existing shared +store types. RF/TB builders, merging, spatial window reads, sampling, and +enforcing read-only access through the type system are separate work. + +## Implementation sequence + +1. Add the templated tile type and JSON attribution-table reader. Default + attribution rasters to 0; keep every table slot a complete object. Implement + the agreed three-location lookup, string dates, and strict index limit. +2. Add the XYZ layout and separate raster-specific index and metadata + envelopes. The index contains hierarchy entries; metadata contains layout, + codec, payload type, and runtime tile dimensions. Raster-local opening + reads both and configures the codec and existing `IndexedStorage`. + Creation writes immutable snapshot metadata; checkpoints write only the + hierarchy index. +3. Add the templated `.amort` codec for raster dimensions and native buffer + bytes, with the agreed layout/byte-count checks and configurable envelope + compression. Instantiate only types used by callers and focused tests. +4. Add create/open, explicit checkpointing, incomplete-snapshot opening, and + publication. Publication flushes and closes the `.part` handle, then renames + the directory to its final path and returns success. It does not reopen the + snapshot or scan the payloads. Table copying supports self-contained + snapshots without editing attribution entries. +5. Use the existing copy machinery for tiles the caller has decided can be + reused. The caller owns payload type, dimension, encoding, and attribution + compatibility decisions. Cross-root hard links remain supported on the + same filesystem. + +## Shared-store integration + +Hard-link eligibility is a caller decision. No new codec compatibility hook +or automatic metadata-compatibility gate is planned for `copy_from()`. +Storage continues to report failures from the actual copy/link operation. + +Dimensions are stored in the separately written metadata file. The index +writer remains a function pointer and persists only the current hierarchy; +it needs no capturing callback. No shared-store API changes are currently +required by this plan. + +## Verification + +Use focused tests for scalar and packed-GLM round trips, exact preservation +of payload bits, non-power-of-two square dimensions, attribution lookup and +slot-0 behavior, and the retained attribution-index limit. Exercise normal +envelope/adapter errors, dimensions retained across checkpoints, explicit +incomplete opening, publication, and rejected destination collisions. + +Verify cross-root hard-link reuse with a copied local attribution table, +including continued TB reads after removing the source RF fixture. Verify +that existing shared-store and octree persistence/copy tests retain their +behavior. Run the relevant Linux build and tests through the configured +project workflow. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 5cc9ef99..183d1a17 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -1,51 +1,149 @@ # Storage format -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) -## Implementation status -The 3D octree stores now use the versioned envelope formats. 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. +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, implementation status, 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/ + ├── raster_store.metadata + ├── raster_store.index + ├── build_log.txt + └── //.amort +``` + +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`. 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 produce an unsupported error if it becomes larger (this is because of the storage format of tiles). -- the source-attributino table is stored in a file named source_attribution_table.json -- there is one per directory tree -- given a tile or dataset (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.json 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 using io::envelope. - -## raster-fundamentalis (rf) format -raster-fundamentalis is our authoritative raster-store. -- 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). - -## Tile-base Format (tb) -tile-base is a hierarchy build from raster-fundamentalis, containing all data and its overviews / downsampled versions. -- every level is occupied, and every level selects an adequate data source - -## tile-server -- the delivery tile format is not yet defined -- needs at least support for dxt1 / etc2 compression, jpeg / png, compressed radix::Rasters<> -- also support for metadata (like bounding sphere tree in ECEF) + +`source_attribution_table.json` stores a vector of attribution objects +(`Table`, containing `Entity` objects). Each entry has these fields: + +- Spatial resolution in pixel-width at the equator (EPSG:3857). +- Date of data acquisition, stored as a string. +- Date of ingestion, stored as a 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. +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. Pixel validity and the reserved meaning of +index 0 are defined under [NoData](#nodata-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. Metadata +contains the layout ID, codec selector, exact payload-type identifier, and +tile dimensions. Tile dimensions default to 4096x4096 pixels and apply to all +tiles in that snapshot. Any positive square dimensions are permitted; side +lengths need not be powers of two. Both tile rasters must match the metadata +dimensions. + +### 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: + +```cpp +namespace raster_store { + +template +struct Tile { + radix::Raster data; + radix::Raster source_attribution; +}; + +} // namespace raster_store +``` + +Tile files use the `.amort` extension (AlpineMapsOrg raster tile) and +`io::envelope`. The writer exposes the envelope compression option, defaulting +to standard Zstandard compression with checksum. Readers accept the +algorithms supported by `io::envelope`; each envelope records its selected +algorithm. Writing is lossless to preserve data through repeated merging and +overview generation. 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). + +### NoData and payload preservation + +Attribution rasters default to index 0, the sole indicator of NoData. The +fields of attribution entry 0 do not determine pixel validity. A NaN with a +nonzero attribution index remains an attributed value, while an ordinary +numeric value with index 0 is NoData. +Storage preserves payloads losslessly, including NaNs and values underneath +NoData pixels. Consumers are responsible for interpreting these values. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index eb98976b..5b8f580b 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -3,7 +3,16 @@ : 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. + +**NoData pixel** +: A stored pixel with attribution index 0, regardless of its payload value. A numeric sentinel such as NaN alone does not make a pixel NoData. + +**Validity mask** +: A temporary RF-builder input defining the spatial region in which an input dataset is accepted. It is not retained in the RF. **raster-fundamentalis (rf)** : The authoritative raster store From cac8ff0aa535ca1e055afc26fb645939e0c6c9eb Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:17:40 +0200 Subject: [PATCH 04/83] Implement persistent raster tile storage and snapshot publication --- docs/raster-store/README.md | 12 +- docs/raster-store/architecture.md | 56 ++- docs/raster-store/implementation-plan.md | 42 +- docs/raster-store/implementation-status.md | 84 ++++ docs/raster-store/storage-format.md | 50 +- docs/raster-store/todo.md | 4 + src/terrainlib/CMakeLists.txt | 7 + src/terrainlib/io/bytes.cpp | 15 +- src/terrainlib/io/utils.cpp | 36 +- src/terrainlib/io/utils.h | 5 +- src/terrainlib/mesh/io/gltf.cpp | 6 +- src/terrainlib/octree/storage/IndexFile.h | 32 +- src/terrainlib/raster_store/Tile.h | 33 ++ src/terrainlib/raster_store/attribution.cpp | 124 +++++ src/terrainlib/raster_store/attribution.h | 38 ++ src/terrainlib/raster_store/io/TileCodec.h | 110 +++++ src/terrainlib/raster_store/io/manifest.cpp | 115 +++++ src/terrainlib/raster_store/io/manifest.h | 54 +++ src/terrainlib/raster_store/path_layout.h | 50 ++ src/terrainlib/raster_store/pixel_type.h | 44 ++ src/terrainlib/raster_store/storage.h | 172 +++++++ src/terrainlib/store/Index.h | 41 ++ src/terrainlib/store/open.h | 21 +- unittests/CMakeLists.txt | 1 + unittests/terrainlib/raster_store.cpp | 504 ++++++++++++++++++++ unittests/terrainlib/store_storage.cpp | 11 + 26 files changed, 1598 insertions(+), 69 deletions(-) create mode 100644 docs/raster-store/implementation-status.md create mode 100644 src/terrainlib/raster_store/Tile.h create mode 100644 src/terrainlib/raster_store/attribution.cpp create mode 100644 src/terrainlib/raster_store/attribution.h create mode 100644 src/terrainlib/raster_store/io/TileCodec.h create mode 100644 src/terrainlib/raster_store/io/manifest.cpp create mode 100644 src/terrainlib/raster_store/io/manifest.h create mode 100644 src/terrainlib/raster_store/path_layout.h create mode 100644 src/terrainlib/raster_store/pixel_type.h create mode 100644 src/terrainlib/raster_store/storage.h create mode 100644 unittests/terrainlib/raster_store.cpp diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index ae885f41..573b12d1 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -1,21 +1,21 @@ # 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. Storage format version 1 is +documented alongside the implementation status; builders and generators +remain future work. ## Documents - [Terminology](terminology.md) -- [Status quo and reuse assessment](status-quo.md) - [Architecture](architecture.md) - [Storage format](storage-format.md) - [Sampling and pyramid generation](sampling-and-generation.md) -- [Implemented refactor status](refactor-status.md) +- [Implementation status](implementation-status.md) ## Plans -- [Store refactoring plan](refactor-plan.md) +- [Tile-storage implementation plan](implementation-plan.md) - [Raster store TODO](todo.md) ## Scope diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index abbdedfc..637cc3ee 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -28,9 +28,51 @@ So far, a refactor delivered the dimension-neutral mechanisms to index RS: - `raster_store::StoreTraits` for in-memory topology keyed by `radix::tile::Id`; -The 3D octree stores use versioned envelopes. Persistent raster index and -metadata adapters, the `.amort` codec, snapshot publication, and raster -opening APIs are not implemented yet. +The 3D octree stores use versioned envelopes. Raster persistence implementation +and verification progress is tracked in +[implementation-status.md](implementation-status.md). + +### Raster storage API + +Include `raster_store/storage.h`. `raster_store::storage::create()` +takes the final snapshot path and creates its sibling `.part` directory. +The attribution table must already be available through the agreed lookup, +or `CreateOptions::copy_attribution_from_index` can identify an input index +whose selected table is copied unchanged into the output. + +```cpp +raster_store::storage::CreateOptions options; +options.tile_dimensions = { 3, 3 }; +auto output = raster_store::storage::create(snapshot_path, options); +if (!output) { + return Error::propagate(std::move(output)); +} +raster_store::Tile tile(3); // Attribution defaults to NoData. +if (auto saved = output->save({ 0, { 0, 0 } }, tile); !saved) { + return saved; +} +return raster_store::storage::publish(std::move(*output)); +``` + +Call `save_index()` explicitly for intermediate checkpoints. `open()` +returns `IndexedStorage`; pass `{ .allow_incomplete = true }` for +cache access to a `.part` snapshot. `attribution::read_table(index_path)` +provides the selected table separately. `publish()` consumes the output handle +on success or failure and returns `Expected`. + +Raster opening supplies the decoded metadata to the shared `store::open_index` +overload. The shared opener resolves the metadata's codec selector through +the supplied callback; raster codec names and dimensions are handled in +`raster_store`, while mesh callers retain their extension resolver. + +Manifest I/O lives in `raster_store/io/manifest.h` under +`raster_store::io::manifest`. `raster_store/io/TileCodec.h` defines +`raster_store::io::TileCodec` and its supporting `tile_codec` +namespace. Versioned payload structs belong to `manifest::detail::v1` and +`tile_codec::detail::v1`, respectively. The XYZ layout lives in +`raster_store/path_layout.h` under `raster_store::path_layout::zoom_xy_google`. +Both raster and octree index decoding use `store::Index::validate() const` +to validate the decoded hierarchy. ### Final public names @@ -149,6 +191,11 @@ Normal opening rejects `.part` snapshot directories. The explicit the last saved index and ignoring unindexed files. Checkpoint timing belongs to the builder, which explicitly saves the index periodically. +Checkpoints replace the index through a temporary file so a failed write does +not truncate the previous checkpoint. The shared storage destructor may also +save a dirty index; destruction never publishes a snapshot. Checkpointing and +publication require the caller to finish outstanding writes first. + Opening and publication do not perform an additional whole-snapshot validation pass or scan payload files for existence. Errors from envelope decoding and the normal format-adapter/opening path are propagated when the @@ -166,6 +213,9 @@ A new snapshot is assembled in a sibling directory named 3. Rename the directory to `` on the same filesystem. Publication returns success after the rename without reopening the snapshot. +The current publication implementation uses Linux `renameat2` with +`RENAME_NOREPLACE` to reject destination collisions at the rename itself. +Other platforms currently report `Unsupported` for publication. The final destination must not already exist. A `.part` directory is incomplete and is never considered published. The rename removes the suffix; diff --git a/docs/raster-store/implementation-plan.md b/docs/raster-store/implementation-plan.md index 71c4952e..515aaba0 100644 --- a/docs/raster-store/implementation-plan.md +++ b/docs/raster-store/implementation-plan.md @@ -1,6 +1,7 @@ # Raster-store tile-storage implementation plan -Status: proposed implementation plan, awaiting approval. The accepted storage +Status: implemented and verified; see [implementation-status.md](implementation-status.md). +The accepted storage contract is in [architecture.md](architecture.md) and [storage-format.md](storage-format.md). @@ -10,22 +11,46 @@ Implement persistent tile storage in `terrainlib`, using the existing shared store types. RF/TB builders, merging, spatial window reads, sampling, and enforcing read-only access through the type system are separate work. +Track each stage and its verification evidence in +[implementation-status.md](implementation-status.md). + +## Accepted implementation decisions + +- Attribution JSON is a top-level array of complete objects with snake_case + keys, numeric resolution in metres per pixel, and verbatim date strings. + There is no clearing operation or special interpretation of cleared entries. +- Tile reads and writes do not scan attribution pixels to validate references. + The later RF builder rejects attribution indices outside the table or the + supported index range before producing tiles. +- Do not add raster allocation limits derived from the envelope's current + size limit. Envelope limits remain an envelope concern. +- Fix flush and close error reporting in `io/bytes.cpp` for all callers. +- Shared-store changes are allowed where useful, retaining mesh-store + compatibility. Keep general mechanisms in `store` and raster policy and + format adapters in `raster_store`. +- Persist a raster codec name in metadata and resolve the reader from that + string, independently of filename extensions. Preserve the existing mesh + extension-based resolver. Use the existing resolver mechanism rather than + adding a registry of hypothetical future codecs. + ## Implementation sequence 1. Add the templated tile type and JSON attribution-table reader. Default attribution rasters to 0; keep every table slot a complete object. Implement - the agreed three-location lookup, string dates, and strict index limit. + the agreed three-location lookup, string dates, and strict index limit at + table/API boundaries, without scanning tile attribution rasters. 2. Add the XYZ layout and separate raster-specific index and metadata envelopes. The index contains hierarchy entries; metadata contains layout, codec, payload type, and runtime tile dimensions. Raster-local opening reads both and configures the codec and existing `IndexedStorage`. Creation writes immutable snapshot metadata; checkpoints write only the - hierarchy index. + hierarchy index through a temporary file followed by replacement. 3. Add the templated `.amort` codec for raster dimensions and native buffer bytes, with the agreed layout/byte-count checks and configurable envelope compression. Instantiate only types used by callers and focused tests. 4. Add create/open, explicit checkpointing, incomplete-snapshot opening, and - publication. Publication flushes and closes the `.part` handle, then renames + publication. First fix shared byte writes to report flush and close errors. + Publication saves the final index and closes the `.part` handle, then renames the directory to its final path and returns success. It does not reopen the snapshot or scan the payloads. Table copying supports self-contained snapshots without editing attribution entries. @@ -42,8 +67,9 @@ Storage continues to report failures from the actual copy/link operation. Dimensions are stored in the separately written metadata file. The index writer remains a function pointer and persists only the current hierarchy; -it needs no capturing callback. No shared-store API changes are currently -required by this plan. +it needs no capturing callback. Extend shared opening only as needed to use +already decoded metadata and the existing codec resolver, retaining existing +mesh entry points and selectors. ## Verification @@ -58,3 +84,7 @@ including continued TB reads after removing the source RF fixture. Verify that existing shared-store and octree persistence/copy tests retain their behavior. Run the relevant Linux build and tests through the configured project workflow. + +The renderer-only `dev_driver.py` rejects this repository. Use the existing +terrain-builder CMake build and this repository's Linux CI test configuration; +do not extend the renderer driver as part of raster storage. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md new file mode 100644 index 00000000..157c57c3 --- /dev/null +++ b/docs/raster-store/implementation-status.md @@ -0,0 +1,84 @@ +# Raster-store implementation status + +The scope and sequence are in [implementation-plan.md](implementation-plan.md). +Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. + +| Stage | Status | Implementation and verification evidence | +|---|---|---| +| Shared foundation | Verified | Shared index, storage, codecs, envelopes, and raster traits; mesh/octree regressions pass | +| Accepted decisions and documentation | Verified | Attribution ownership, JSON contract, version-1 encoding, codec selection, API usage, and shared I/O fix recorded | +| Typed tiles and attribution tables | Verified | `raster_store/Tile.h`, `attribution.h/.cpp`; zero attribution, verbatim fields, malformed slots, lookup precedence, and boundary indices | +| XYZ layout, index, and metadata | Verified | `raster_store/path_layout.h`, `raster_store/io/manifest.h/.cpp`; boundary paths, mixed hierarchy, corrupt topology, metadata errors, and checkpoint persistence | +| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, NoData payload preservation, dimensions, byte counts, compression, checksum, and version errors | +| Creation, opening, checkpoints, publication | Verified | `raster_store/storage.h`; metadata codec selection, incomplete opening, checkpoint failures, no implicit publication, collision rejection, no payload scan, and `/dev/full` write failure | +| Hard-link integration and regression tests | Verified | `unittests/terrainlib/raster_store.cpp`; cross-root hard links and independent attribution survive RF deletion; all six regression suites pass | + +## Deferred work + +RF builder validation must reject unsupported attribution indices and indices +outside the selected table before producing tiles. Storage does not scan tile +attribution rasters on reads or writes. Clearing entries is not a library +operation. RF/TB builders, merging, sampling, window reads, and enforced +read-only access remain separate work. + +Publication currently uses Linux `renameat2(RENAME_NOREPLACE)`; other platforms +return `Unsupported`. The supported native pixel representation remains x86 +with unpadded GLM. Publication provides normal-operation atomic visibility, +without a crash-durability guarantee. + +## Verification log + +- Initial working tree: clean, branch `main`. +- `dev_driver.py doctor`: rejects this terrain-builder checkout because the + driver supports only `alpine-renderer`. +- Existing build: `build/Desktop_Debug`, configured for this checkout, GCC, + with unit tests enabled. +- Build passed: + + ```sh + cmake --build build/Desktop_Debug --target unittests_terrainlib unittests_tilebuilder unittests_sfbuilder unittests_sfbuilder_finalization unittests_dagbuilder unittests_sfmerger --parallel 6 + ``` + +- Test executables are under `build/Desktop_Debug/unittests`. Linux test runs + used `PROJ_DATA` and `PROJ_LIB` pointing to the build's + `alp_external/proj/share/proj`, and `GDAL_DATA` pointing to + `alp_external/gdal/share/gdal`. + +| Executable and filter | Test cases passed | Assertions passed | +|---|---:|---:| +| `unittests_terrainlib '[raster-store]'` (focused subset) | 24 | 356 | +| `unittests_terrainlib '~mesh::clip_on_bounds benchmark'` | 458 | 24496 | +| `unittests_tilebuilder` | 14 | 389 | +| `unittests_sfbuilder` | 16 | 176102 | +| `unittests_sfbuilder_finalization` | 2 | 16 | +| `unittests_dagbuilder` | 76 | 479 | +| `unittests_sfmerger` (including SF build/merge integration) | 13 | 195 | + +- Total across the six suites: 579 test cases and 201677 assertions, excluding + the separately repeated focused subset. +- Initial tilebuilder and SF-builder runs encountered absent Austrian raster + fixtures. Missing files were restored from the existing CI archive at + `https://gataki.cg.tuwien.ac.at/raw/terrain_builder_unittest_data.tar.gz`; + the affected suites subsequently passed. Existing fixtures were preserved. +- `git diff --check` passed. All changed text files have LF working-tree line + endings matching `attr/text=auto eol=lf`, checked with + `git ls-files --eol --cached --others --exclude-standard`. +- No commits, branches, or pushes were made. + +### 2026-09-10 code-style follow-up + +- Moved manifest I/O to `raster_store::io::manifest`, the AMORT implementation + to `raster_store::io::TileCodec`, and the layout to + `raster_store::path_layout::zoom_xy_google`. Versioned payload types now live + in the corresponding `detail::v1` namespaces; serialized identifiers and + payload layouts are unchanged. +- Moved shared hierarchy validation into `store::Index::validate() const`. +- Missing paths during snapshot rename and attribution copying report + `NotFound`. `io::utils::create_parent_directories` returns `Expected`; + byte and glTF writers propagate its errors. +- `cmake --build build/Desktop_Debug --target all --parallel 6` passed. +- The same six regression suites passed: 582 test cases and 201698 assertions. + Terrainlib now covers 461 cases and 24517 assertions, including missing-path + rename/copy checks and successful repeated parent-directory creation. +- Qt lint reported no new warnings on the lines changed in this follow-up. + `git diff --check` and the Git-attribute line-ending check passed. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 183d1a17..225f5e5b 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -40,25 +40,31 @@ explains shared RF tables and independent TB copies. ## Source-attribution table `source_attribution_table.json` stores a vector of attribution objects -(`Table`, containing `Entity` objects). Each entry has these fields: +(`Table`, containing `Entity` objects), as a top-level JSON array. Each entry +has these required fields: -- Spatial resolution in pixel-width at the equator (EPSG:3857). -- Date of data acquisition, stored as a string. -- Date of ingestion, stored as a string. -- Copyright string. -- Copyright link string. -- License string. +- `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. Pixel validity and the reserved meaning of +65535 produce an `Unsupported` error at checked table/API boundaries. The RF +builder, a later work package, 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. +Pixel validity and the reserved meaning of index 0 are defined under [NoData](#nodata-and-payload-preservation). Table lookup checks beside the index first, then the index directory's parent, @@ -76,6 +82,34 @@ tiles in that snapshot. Any positive square dimensions are permitted; side lengths need not be powers of two. Both tile rasters must match the metadata dimensions. +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`; payload filenames end in `.amort`. +The versioned payloads use the existing `io::envelope` serialization: + +| Envelope class | Version | Payload fields in serialization order | +|---|---|---| +| `raster_store.Metadata` | 1 | layout ID string, payload-type string, codec-selector string, `uint32_t` width, `uint32_t` height | +| `raster_store.Index` | 1 | vector of entries: `uint32_t` zoom, x, y, then `uint8_t` status | +| `raster_store.Tile` | 1 | `uint32_t` width, height, data byte vector, attribution byte vector | + +Index entries 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`. + +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. + ### Sparse quadtree index The hierarchy is a Web Mercator (EPSG:3857) quadtree keyed by diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index fe9bf14a..3381e096 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -1,5 +1,9 @@ # Raster store TODO +- RF builder: reject attribution indices at or above 65535 and references + outside the selected attribution table before producing tiles. Tile storage + does not scan attribution rasters on each read or write. + - 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. diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index f4d80cab..d74df36f 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -156,6 +156,13 @@ add_library(terrainlib range/RangeToInclusive.h raster_store/StoreTraits.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_type.h + raster_store/storage.h sf/finalize_storage.h sf/validate_index.h diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp index 7c81c557..5b96634e 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/bytes.cpp @@ -12,9 +12,9 @@ Expected write_bytes_to_path(const std::span bytes, const s 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); + auto directories = utils::create_parent_directories(path); + if (!directories) { + return Error::propagate(std::move(directories), "write bytes"); } } @@ -28,6 +28,15 @@ Expected write_bytes_to_path(const std::span bytes, const s 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 {}; } diff --git a/src/terrainlib/io/utils.cpp b/src/terrainlib/io/utils.cpp index 0d8f6298..e485d65f 100644 --- a/src/terrainlib/io/utils.cpp +++ b/src/terrainlib/io/utils.cpp @@ -1,15 +1,45 @@ #include "io/utils.h" +#include +#include + +#ifdef __linux__ +#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 } } // namespace io::utils diff --git a/src/terrainlib/io/utils.h b/src/terrainlib/io/utils.h index aa369d69..215bec22 100644 --- a/src/terrainlib/io/utils.h +++ b/src/terrainlib/io/utils.h @@ -3,8 +3,11 @@ #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); } // namespace io::utils diff --git a/src/terrainlib/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index 25ed2c84..c0d42ad9 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -312,9 +312,9 @@ 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); + 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); diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index 67a463e6..755f3994 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -92,36 +92,8 @@ 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 (!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 (auto valid = result.validate(); !valid) { + return Error::propagate(std::move(valid), "validate octree index topology"); } return result; } diff --git a/src/terrainlib/raster_store/Tile.h b/src/terrainlib/raster_store/Tile.h new file mode 100644 index 00000000..4bf22c1b --- /dev/null +++ b/src/terrainlib/raster_store/Tile.h @@ -0,0 +1,33 @@ +#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) + , source_attribution(glm::uvec2(side), std::uint16_t { 0 }) + { + } + + radix::Raster data; + radix::Raster source_attribution; +}; + +} // 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..3dbda6eb --- /dev/null +++ b/src/terrainlib/raster_store/attribution.cpp @@ -0,0 +1,124 @@ +#include "raster_store/attribution.h" + +#include +#include +#include + +#include + +#include "io/bytes.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::read_bytes_from_path(*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..a3fb4159 --- /dev/null +++ b/src/terrainlib/raster_store/attribution.h @@ -0,0 +1,38 @@ +#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..4a9ba117 --- /dev/null +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include + +#include "io/envelope.h" +#include "raster_store/Tile.h" +#include "raster_store/pixel_type.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; + ::io::envelope::Bytes data; + ::io::envelope::Bytes source_attribution; + }; + + } // namespace detail::v1 + + using TileSchema = ::io::envelope::PayloadSchema<"raster_store.Tile", ::io::envelope::Version<1, detail::v1::RasterTile>>; + +} // namespace tile_codec + +template +class TileCodec final : public store::Codec> { + static_assert(sizeof(pixel_type::Format) > 0); + +public: + explicit TileCodec(const glm::uvec2 dimensions = glm::uvec2(default_tile_side), + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, + const ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib) + : m_dimensions(dimensions) + , m_compression_algorithm(compression_algorithm) + , m_checksum_algorithm(checksum_algorithm) + { + } + + std::vector paths(const std::filesystem::path& node_path) const override + { + return { this->add_extension(node_path, ".amort") }; + } + + Expected> read(const std::filesystem::path& node_path) const override + { + if (auto valid = validate_dimensions(m_dimensions); !valid) { + return Error::propagate(std::move(valid)); + } + auto encoded = ::io::envelope::read_from_path(paths(node_path).front()); + if (!encoded) { + return Error::propagate(std::move(encoded), "read AMORT tile"); + } + if (glm::uvec2(encoded->width, encoded->height) != m_dimensions) { + return Error::fail(Error::Code::CorruptData, "AMORT tile dimensions do not match snapshot metadata"); + } + const std::uint64_t count = std::uint64_t { encoded->width } * encoded->height; + if (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, "AMORT raster buffer byte counts do not match dimensions and payload type"); + } + Tile tile(encoded->width); + std::memcpy(tile.data.bytes().data(), encoded->data.data(), encoded->data.size()); + std::memcpy(tile.source_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 valid; + } + if (tile.data.size() != m_dimensions || tile.source_attribution.size() != m_dimensions) { + return Error::fail(Error::Code::InvalidInput, "both tile rasters must match snapshot dimensions"); + } + const auto data = tile.data.bytes(); + const auto source_attribution = tile.source_attribution.bytes(); + const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, + { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; + return ::io::envelope::write_to_path(encoded, paths(node_path).front(), true, m_compression_algorithm, m_checksum_algorithm); + } + +private: + glm::uvec2 m_dimensions; + ::io::envelope::CompressionAlgorithm m_compression_algorithm; + ::io::envelope::ChecksumAlgorithm m_checksum_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::ZstdDefaultCompressionWithChecksum, + const ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib) + { + if (name == "amort") { + return std::make_unique>(dimensions, compression_algorithm, checksum_algorithm); + } + 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..6ba1c76d --- /dev/null +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -0,0 +1,115 @@ +#include "raster_store/io/manifest.h" + +#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"); + } + return validate_dimensions({ metadata.width, metadata.height }); +} + +Expected read_metadata(const std::filesystem::path& base_path) +{ + auto metadata = ::io::envelope::read_from_path(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; +} + +detail::v1::Hierarchy encode_index(const store::Index& index) +{ + detail::v1::Hierarchy encoded; + encoded.entries.reserve(index.size()); + for (const auto& [key, status] : index) { + encoded.entries.push_back({ key.zoom_level, key.coords.x, key.coords.y, + static_cast(static_cast(status)) }); + } + std::ranges::sort(encoded.entries, [](const auto& left, const auto& right) { + return std::tie(left.zoom, left.x, left.y) < std::tie(right.zoom, right.x, right.y); + }); + return encoded; +} + +Expected> decode_index(const detail::v1::Hierarchy& encoded) +{ + store::Index index; + std::unordered_set seen; + for (const auto& entry : encoded.entries) { + const radix::tile::Id key { entry.zoom, { entry.x, entry.y } }; + if (!StoreTraits::is_valid(key)) { + return Error::fail(Error::Code::CorruptData, "raster index contains an invalid tile ID"); + } + if (!seen.insert(key).second) { + return Error::fail(Error::Code::CorruptData, "raster index contains a duplicate tile ID"); + } + if (entry.status > static_cast(store::NodeStatus::Virtual)) { + return Error::fail(Error::Code::CorruptData, "raster index contains an invalid node status"); + } + auto added = index.set_raw(key, store::NodeStatus { static_cast(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"); + } + return index; +} + +Expected> read_hierarchy(const std::filesystem::path& index_path) +{ + auto encoded = ::io::envelope::read_from_path(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 index = read_hierarchy(index_path); + if (!index) { + return Error::propagate(std::move(index)); + } + return store::IndexMetadata { std::move(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }; +} + +Expected write_index_file(const std::filesystem::path& index_path, const store::IndexMetadata& metadata) +{ + auto temporary_path = index_path; + temporary_path += ".tmp"; + auto written = ::io::envelope::write_to_path(encode_index(metadata.index), temporary_path); + if (!written) { + return Error::propagate(std::move(written), "checkpoint raster index"); + } + std::error_code error; + std::filesystem::rename(temporary_path, index_path, error); + if (error) { + return Error::fail(Error::Code::Io, "replace raster index checkpoint", temporary_path, index_path, error); + } + 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..50b1b68f --- /dev/null +++ b/src/terrainlib/raster_store/io/manifest.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include "io/envelope.h" +#include "raster_store/StoreTraits.h" +#include "raster_store/Tile.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 { + + struct Metadata { + std::string layout_id; + std::string payload_type; + std::string codec_selector; + std::uint32_t width = default_tile_side; + std::uint32_t height = default_tile_side; + }; + + struct IndexEntry { + std::uint32_t zoom; + std::uint32_t x; + std::uint32_t y; + std::uint8_t status; + }; + + struct Hierarchy { + std::vector entries; + }; + +} // 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> 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); +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..1cabd453 --- /dev/null +++ b/src/terrainlib/raster_store/path_layout.h @@ -0,0 +1,50 @@ +#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_type.h b/src/terrainlib/raster_store/pixel_type.h new file mode 100644 index 00000000..ae01565a --- /dev/null +++ b/src/terrainlib/raster_store/pixel_type.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include + +namespace raster_store::pixel_type { + +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() + ">"; + } +}; + +template +std::string identifier() +{ + return Format::identifier(); +} + +} // namespace raster_store::pixel_type diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h new file mode 100644 index 00000000..3a08fa57 --- /dev/null +++ b/src/terrainlib/raster_store/storage.h @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include + +#include "io/utils.h" +#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" + +namespace raster_store::storage { + +template +using IndexedStorage = store::IndexedStorage>; + +struct OpenOptions { + bool allow_incomplete = false; +}; + +struct CreateOptions { + glm::uvec2 tile_dimensions { default_tile_side }; + std::string codec_selector = "amort"; + ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompressionWithChecksum; + ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib; + std::optional copy_attribution_from_index; +}; + +namespace detail { + + inline 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 {}; + } + + // The owned handle is destroyed on return, before publication renames its directory. + template + Expected finish_output(IndexedStorage storage) + { + return storage.save_index(); + } + +} // namespace detail + +template +Expected> open(const std::filesystem::path& base_path, const OpenOptions options = {}) +{ + auto normalized_path = base_path.lexically_normal(); + if (!normalized_path.has_filename()) { + normalized_path = normalized_path.parent_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 index = io::manifest::read_hierarchy(index_path); + if (!index) { + return Error::propagate(std::move(index), "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, { metadata->width, metadata->height }); + }; + return store::open_index>(index_path, io::manifest::index_format(), + { std::move(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, + pixel_type::identifier(), resolve_codec); +} + +template +Expected> create(const std::filesystem::path& final_path, const CreateOptions options = {}) +{ + const auto destination = final_path.lexically_normal(); + 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 (auto valid = validate_dimensions(options.tile_dimensions); !valid) { + return Error::propagate(std::move(valid)); + } + auto codec = io::tile_codec::from_name( + options.codec_selector, options.tile_dimensions, options.compression_algorithm, options.checksum_algorithm); + if (!codec) { + return Error::propagate(std::move(codec)); + } + if (auto absent = detail::require_absent(destination); !absent) { + return Error::propagate(std::move(absent)); + } + auto partial_path = destination; + partial_path += ".part"; + if (auto absent = detail::require_absent(partial_path); !absent) { + return Error::propagate(std::move(absent)); + } + auto table = attribution::read_table(options.copy_attribution_from_index.value_or(partial_path / io::manifest::index_file_name)); + if (!table) { + return Error::propagate(std::move(table), "resolve attribution for new raster snapshot"); + } + 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_path, error)) { + return Error::fail(error ? Error::Code::Io : Error::Code::AlreadyExists, "create incomplete snapshot", partial_path, error); + } + 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)); + } + } + const io::manifest::Metadata metadata { std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), pixel_type::identifier(), + options.codec_selector, options.tile_dimensions.x, options.tile_dimensions.y }; + auto written = ::io::envelope::write_to_path(metadata, partial_path / io::manifest::metadata_file_name); + 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 IndexedStorage(std::move(*storage)); +} + +// Consumes the output handle on success or failure. An unsuccessful publication +// leaves the .part snapshot available for explicit incomplete opening. +template +Expected publish(IndexedStorage storage) +{ + const auto partial_path = storage.base_path().lexically_normal(); + if (partial_path.extension() != ".part") { + return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); + } + auto destination = partial_path; + destination.replace_extension(); + if (auto absent = detail::require_absent(destination); !absent) { + return absent; + } + if (auto finished = detail::finish_output(std::move(storage)); !finished) { + return Error::propagate(std::move(finished), "finish raster snapshot before publication"); + } + auto renamed = ::io::utils::rename_without_replacement(partial_path, destination); + if (!renamed) { + return Error::propagate(std::move(renamed), "publish raster snapshot"); + } + return {}; +} + +} // namespace raster_store::storage diff --git a/src/terrainlib/store/Index.h b/src/terrainlib/store/Index.h index d1e99098..4646b268 100644 --- a/src/terrainlib/store/Index.h +++ b/src/terrainlib/store/Index.h @@ -167,6 +167,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/open.h b/src/terrainlib/store/open.h index 5ac295c9..f265e811 100644 --- a/src/terrainlib/store/open.h +++ b/src/terrainlib/store/open.h @@ -13,14 +13,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); } @@ -53,6 +49,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, diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index f23bca02..8dd85a42 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -65,6 +65,7 @@ add_executable(unittests_terrainlib terrainlib/quantize.cpp terrainlib/range.cpp terrainlib/range_utils.cpp + terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp new file mode 100644 index 00000000..18c8b6bf --- /dev/null +++ b/unittests/terrainlib/raster_store.cpp @@ -0,0 +1,504 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../temporary_directory.h" +#include "io/bytes.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::write_bytes_to_path(std::span(reinterpret_cast(text.data()), text.size()), path)); +} + +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.tile_dimensions = { 3, 3 }; + 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.source_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.source_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->source_attribution, tile.source_attribution); + + TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::envelope::ChecksumAlgorithm::Crc32c); + REQUIRE(uncompressed.write(path, tile)); + read = writer.read(path); + REQUIRE(read); + check_bytes(read->data, tile.data); +} + +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.source_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->source_attribution, tile.source_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.source_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::envelope::Bytes(36), io::envelope::Bytes(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_to_path(encoded, path.string() + ".amort")); + CHECK(amort.read(path).error().code() == Error::Code::CorruptData); +} + +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->is(store::NodeStatus::Inner, root).value()); + CHECK(decoded->is(store::NodeStatus::Virtual, { 1, { 0, 0 } }).value()); + CHECK(decoded->is(store::NodeStatus::Leaf, { 2, { 1, 1 } }).value()); + REQUIRE(encoded.entries.size() == 3); + CHECK(encoded.entries[0].zoom == 0); + CHECK(encoded.entries[2].zoom == 2); + + SECTION("duplicate key") { encoded.entries.push_back(encoded.entries[0]); } + SECTION("invalid key") { encoded.entries[0].x = 1; } + SECTION("invalid status") { encoded.entries[0].status = 255; } + SECTION("missing parent") { encoded.entries.erase(encoded.entries.begin() + 1); } + SECTION("leaf with descendant") { encoded.entries[0].status = static_cast(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 = storage::create(final, small_options()); + REQUIRE(created); + CHECK_FALSE(storage::open(partial)); + CHECK_FALSE(storage::open(partial / "")); + CHECK(storage::open(partial, { .allow_incomplete = true })); + const auto metadata_before = io::read_bytes_from_path(partial / manifest::metadata_file_name).value(); + raster_store::Tile tile(3); + tile.data.fill(12.5f); + // Reference checks belong to RF builder, not storage reads or writes. + tile.source_attribution.fill(65534); + REQUIRE(created->save({ 0, { 0, 0 } }, tile)); + REQUIRE(created->save_index()); + CHECK(io::read_bytes_from_path(partial / manifest::metadata_file_name).value() == metadata_before); + auto checkpoint = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(checkpoint); + REQUIRE(checkpoint->load({ 0, { 0, 0 } })); + CHECK(checkpoint->codec_selector().value() == "amort"); + auto metadata = manifest::read_metadata(partial); + REQUIRE(metadata); + CHECK(metadata->width == 3); + CHECK(metadata->height == 3); + CHECK(metadata->payload_type == "float32"); + CHECK(metadata->codec_selector == "amort"); + + REQUIRE(storage::publish(std::move(*created))); + CHECK_FALSE(std::filesystem::exists(partial)); + auto published = storage::open(final); + REQUIRE(published); + 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 = storage::create(directory.path() / "snapshot", small_options()); + REQUIRE(created); + const auto partial = created->base_path(); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(3))); + std::filesystem::create_directory(partial / "raster_store.index.tmp"); + CHECK_FALSE(created->save_index()); + auto checkpoint = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(checkpoint); + 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()); + CHECK(storage::open(partial, { .allow_incomplete = true })->index().size() == 1); +} + +TEST_CASE("raster creation and publication reject destination collisions", "[raster-store][storage]") +{ + TemporaryDirectory directory; + write_table(directory.path()); + const auto final = directory.path() / "snapshot"; + auto created = storage::create(final, small_options()); + REQUIRE(created); + CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); + SECTION("empty destination") { std::filesystem::create_directory(final); } + SECTION("nonempty destination") { write_text(final / "keep", "keep"); } + SECTION("dangling symlink") { std::filesystem::create_symlink(directory.path() / "missing", final); } + CHECK(storage::publish(std::move(*created)).error().code() == Error::Code::AlreadyExists); + CHECK(std::filesystem::exists(directory.path() / "snapshot.part")); + CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); +} + +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 = storage::create(final, small_options()); + REQUIRE(created); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(3))); + REQUIRE(std::filesystem::remove(created->path_for({ 0, { 0, 0 } }).value())); + REQUIRE(storage::publish(std::move(*created))); + auto opened = storage::open(final); + REQUIRE(opened); + 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 = storage::create(final, small_options()); + REQUIRE(created); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(3))); + REQUIRE(storage::publish(std::move(*created))); + auto metadata = manifest::read_metadata(final).value(); + metadata.codec_selector = ".amort"; + REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + CHECK(storage::open(final).error().code() == Error::Code::Unsupported); + metadata.codec_selector = "amort"; + REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + 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 = storage::create(rf, small_options()); + REQUIRE(created); + raster_store::Tile tile(3); + 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 = storage::open(rf); + REQUIRE(source); + auto options = small_options(); + options.copy_attribution_from_index = rf / manifest::index_file_name; + auto target = storage::create(tb, options); + REQUIRE(target); + for (const auto key : { radix::tile::Id { 0, { 0, 0 } }, radix::tile::Id { 2, { 1, 1 } } }) { + REQUIRE(target->copy_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::read_bytes_from_path(rf_root / attribution::file_name).value() + == io::read_bytes_from_path(target->base_path() / attribution::file_name).value()); + REQUIRE(storage::publish(std::move(*target))); + } + std::filesystem::remove_all(rf_root); + auto target = storage::open(tb); + REQUIRE(target); + 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 { 42 }; + auto written = io::write_bytes_to_path(bytes, "/dev/full", 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 = storage::create(final, small_options()); + REQUIRE(created); + 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_to_path(metadata, final / manifest::metadata_file_name)); + CHECK(storage::open(final).error().code() == Error::Code::Unsupported); + } + SECTION("rectangular metadata") + { + auto metadata = manifest::read_metadata(final).value(); + metadata.height = 2; + REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + 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::read_bytes_from_path(payload_path).value(); + auto envelope = io::envelope::detail::deserialize_from_bytes(std::as_bytes(std::span(bytes))).value(); + auto expected_error = Error::Code::CorruptData; + SECTION("checksum") { envelope.compressed_data.back() ^= std::byte { 1 }; } + SECTION("version") + { + envelope.class_version = 999; + expected_error = Error::Code::Unsupported; + } + const auto modified = io::envelope::detail::serialize_to_bytes(envelope).value(); + REQUIRE(io::write_bytes_to_path(std::span(reinterpret_cast(modified.data()), modified.size()), payload_path)); + 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 = storage::create(final, small_options()); + REQUIRE(created); + REQUIRE(created->save({ 0, { 0, 0 } }, raster_store::Tile(3))); + } + CHECK_FALSE(std::filesystem::exists(final)); + auto abandoned = storage::open(partial, { .allow_incomplete = true }); + REQUIRE(abandoned); + CHECK(abandoned->load({ 0, { 0, 0 } })); +} diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index a70c1638..81220562 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -10,6 +10,7 @@ #include "../temporary_directory.h" #include "io/bytes.h" +#include "io/utils.h" #include "octree/StoreTraits.h" #include "octree/store_layout/Mappings.h" #include "raster_store/StoreTraits.h" @@ -210,6 +211,16 @@ store::IndexedStorage make_indexed_storage( } // 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"); From d3d405edf772ecd48f46b562cccc0aacf590b288 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:39:04 +0200 Subject: [PATCH 05/83] Implement RF snapshot builder with validity, caching, and progress logging --- README.md | 2 +- cmake/SetupGDAL.cmake | 1 + docs/raster-store/README.md | 11 +- docs/raster-store/implementation-status.md | 88 +++- docs/raster-store/storage-format.md | 8 +- docs/raster-store/terminology.md | 15 +- docs/raster-store/todo.md | 4 - src/sf_merger/mask.h | 261 +----------- src/terrainlib/CMakeLists.txt | 3 + src/terrainlib/DatasetReader.cpp | 403 ++++++++++++++++++ .../DatasetReader.h | 15 + src/terrainlib/RasterTransform.cpp | 165 +++++++ src/terrainlib/RasterTransform.h | 37 ++ src/terrainlib/vector_mask.h | 270 ++++++++++++ src/tile_builder/CMakeLists.txt | 1 - src/tile_builder/DatasetReader.cpp | 222 ---------- unittests/CMakeLists.txt | 1 + 17 files changed, 1023 insertions(+), 484 deletions(-) create mode 100644 src/terrainlib/DatasetReader.cpp rename src/{tile_builder => terrainlib}/DatasetReader.h (74%) create mode 100644 src/terrainlib/RasterTransform.cpp create mode 100644 src/terrainlib/RasterTransform.h create mode 100644 src/terrainlib/vector_mask.h delete mode 100644 src/tile_builder/DatasetReader.cpp diff --git a/README.md b/README.md index 3cbf664e..4b00f545 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # alpine-terrain-builder -See the project [code style](docs/code-style.md) before contributing. +See the project [code style](docs/code-style.md) before contributing. \ No newline at end of file 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/docs/raster-store/README.md b/docs/raster-store/README.md index 573b12d1..8c1cfc68 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -2,8 +2,8 @@ This directory describes authoritative raster tile storage and the planned generation of delivery tile pyramids from it. Storage format version 1 is -documented alongside the implementation status; builders and generators -remain future work. +documented alongside the implementation status. RF import is implemented; +TB generation and the tile server remain future work. ## Documents @@ -15,11 +15,12 @@ remain future work. ## Plans + - [Tile-storage implementation plan](implementation-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. +The documents mostly hold format information. The `rf_merger` document is +explicitly a non-authoritative idea parking lot, not a tool specification or +implementation plan. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 157c57c3..1a38937a 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -15,11 +15,11 @@ Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. ## Deferred work -RF builder validation must reject unsupported attribution indices and indices +RF builder validation rejects unsupported attribution indices and indices outside the selected table before producing tiles. Storage does not scan tile attribution rasters on reads or writes. Clearing entries is not a library -operation. RF/TB builders, merging, sampling, window reads, and enforced -read-only access remain separate work. +operation. TB builders, merging, sampling, window reads, and enforced read-only access +remain separate work. RF import is tracked below. Publication currently uses Linux `renameat2(RENAME_NOREPLACE)`; other platforms return `Unsupported`. The supported native pixel representation remains x86 @@ -82,3 +82,85 @@ without a crash-durability guarantee. rename/copy checks and successful repeated parent-directory creation. - Qt lint reported no new warnings on the lines changed in this follow-up. `git diff --check` and the Git-attribute line-ending check passed. + +## RF builder — 2026-09-10 + +The [RF-builder plan](rf-builder-design.md) is approved. RGB attribution requires +all three selected channels to be valid. Existing documentation edits were +present before implementation and are preserved. + +| Stage | Status | Evidence | +|---|---|---| +| Shared referenced mask loading | Verified | `terrainlib/vector_mask.h`, mesh compatibility aliases | +| Shared scalar/RGB reader and validity | Verified | `terrainlib/DatasetReader`, per-band validity, all-channel RGB acceptance | +| Adaptive planning and world boundaries | Verified | `RasterTransform`, `rf_builder/planning`, affine and seam fixtures | +| Command and cache input records | Verified | `rf-builder`, `rf_builder/inputs`, CURL enabled | +| Writes, checkpoints, publication, reporting | Verified | `rf_builder/build`, serialized writes/checkpoints, hard links, empty publication | +| File logging and progress estimates | Verified | stderr plus appended `.log`; candidate percentage, remaining time and UTC finish estimate; CLI checks cover populated, all-invalid and no-candidate results | +| Integration and regression verification | Verified | 21 RF cases plus all six existing regression suites; full build and new-code lint pass | + +Baseline existing binaries: raster storage 26 cases/372 assertions; dataset +reading 1 case/72 assertions; mask simplification 2 cases/15 assertions. +The renderer driver rejects this repository; use the existing terrain-builder +CMake configuration and Linux test environment documented above. + +### RF-builder verification + +- `cmake --build build/Desktop_Debug --target all --parallel 6` passed. +- Tests used the existing Linux PROJ/GDAL data environment described above. + +| Executable | Test cases passed | Assertions passed | +|---|---:|---:| +| `unittests_rfbuilder` | 21 | 3768 | +| `unittests_terrainlib` (excluding the existing clip benchmark) | 461 | 24517 | +| `unittests_tilebuilder` | 14 | 389 | +| `unittests_sfbuilder` | 16 | 176102 | +| `unittests_sfbuilder_finalization` | 2 | 16 | +| `unittests_dagbuilder` | 76 | 479 | +| `unittests_sfmerger` | 13 | 195 | +| **Total** | **603** | **205466** | + +- RF fixtures cover JPEG/YCbCr tiled RGB, scalar values, per-channel NoData and + masks, valid black and legacy sentinel values, prepared VRT file boundaries, + original-resolution reads despite overviews, mask holes/boundaries, affine + rotation/skew, mixed zooms, narrow masks, maximum zoom rejection, both + antimeridian sides and filter neighbourhoods, polar-only empty results, + invalid inputs, missing/corrupt/mismatching cache records, hard-link reuse, + unindexed payloads, failed links, command reporting, curved projected edges, + projection-domain exclusions, GDAL threading settings, feature-read errors, + and small sources inside default-size output tiles. +- The rebuilt GDAL opened the Swissimage reference URL through `/vsicurl/`, + reported 10000x10000 pixels and three bands, and successfully read a 32x32 + native-resolution window. The source was not added to the repository. +- Qt lint passes for the new and extracted code. Findings in unchanged mesh + code were left alone. Six read-only review passes covered model/index + contracts, ownership, threading, C++/API correctness, error handling, and + performance. Identified issues were corrected and regression-tested. +- `git diff --check` passed. Working-tree text line endings agree with Git + attributes, verified using `git ls-files --eol --cached --others + --exclude-standard`. +- Initial user documentation edits were preserved. No commits, branches, or + pushes were made. + +### Logging/progress follow-up and Vienna run + +- Rebuilt `rf-builder` and `unittests_rfbuilder`; all 21 RF cases passed with + 3834 assertions after adding persistent success/error and progress checks. +- Six focused review passes found no issues in this follow-up. Qt lint reports + DEP-10 on `std::chrono::duration::count()`; these calls use the required chrono + API, so the container `.size()` recommendation does not apply. +- A full-resolution Vienna DSM import is being run under + `/data/scratch/codex/rf-vienna-20260910`. Inputs are Austria's + `OeRect_01m_gs_31287.img` and the union of Stadt Wien's official district + polygons. The user approved a scratch attribution entry with unknown source + metadata left blank. The boundary source and exact invocation are recorded + in the scratch directory. Initial planning selected 80 candidate + 4096-square tiles. Partitioning the city polygon on a 2 km grid reduced this + to 63 candidates; its union differs from the original by only + 1.64e-8 square metres before the builder's normal simplification. +- The initial Debug run exposed costly detailed-boundary centre tests. The + user stopped the partitioned-mask import and the optimized build at 14:57 + CEST. The last checkpoint contains 4 of 63 candidates (6.3%), with + 221459620 payload bytes. The incomplete snapshot and logs are retained; + no final Vienna snapshot was published. Whole-tile containment currently + has no fast path: accepted source pixels still receive individual mask tests. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 225f5e5b..c74b1797 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -13,13 +13,16 @@ 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 - ├── build_log.txt └── //.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 @@ -61,8 +64,7 @@ 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, a later work package, validates attribution references before -producing tiles, including references outside the selected table. Storage does +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. Pixel validity and the reserved meaning of index 0 are defined under [NoData](#nodata-and-payload-preservation). diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 5b8f580b..de94e8ed 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -12,10 +12,21 @@ : A stored pixel with attribution index 0, regardless of its payload value. A numeric sentinel such as NaN alone does not make a pixel NoData. **Validity mask** -: A temporary RF-builder input defining the spatial region in which an input dataset is accepted. It is not retained in the RF. +: 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 source dataset under one validity mask + and attributed to one source-attribution entry. + +**Local sampling ratio** +: The largest directional stretch from an output pixel spacing into source-pixel + coordinates at a location. It expresses the local reduction in source detail + without treating a pure rotation as a change in resolution. **tile-base (tb)** : basically rf with overviews, used to generate derived tiles diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index 3381e096..fe9bf14a 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -1,9 +1,5 @@ # Raster store TODO -- RF builder: reject attribution indices at or above 65535 and references - outside the selected attribution table before producing tiles. Tile storage - does not scan attribution rasters on each read or write. - - 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. diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 304cbce3..6b16a835 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -10,6 +10,7 @@ #include #include "Dataset.h" +#include "vector_mask.h" #include "mesh/SimpleMesh.h" #include "mesh/cgal.h" #include "mesh/convert.h" @@ -31,17 +32,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 vector_mask::Kernel; +using vector_mask::Point2; +using vector_mask::Polygon2; +using vector_mask::PolygonWithHoles2; +using vector_mask::PolygonSet2; +using vector_mask::MultipolygonWithHoles2; +using vector_mask::ReferencedPolygonMask; struct SpherePolygonMask { MultipolygonWithHoles2 polygons; @@ -89,192 +86,18 @@ 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; -}; +using vector_mask::LoadErrorKind; +using vector_mask::LoadError; +using vector_mask::simplification_tolerance_metres; +using vector_mask::simplification_tolerance; +using vector_mask::point_count; +using vector_mask::simplify_geometry; +using vector_mask::convert_ring; +using vector_mask::convert_polygon; +using vector_mask::process_geometry; +using vector_mask::load_referenced_from_dataset; 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()) { @@ -332,54 +155,6 @@ 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); diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index d74df36f..889cb51b 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -1,5 +1,8 @@ add_library(terrainlib Error.h Error.cpp + DatasetReader.h DatasetReader.cpp + RasterTransform.h RasterTransform.cpp + vector_mask.h Dataset.h Dataset.cpp ImageKey.h ProgressIndicator.h ProgressIndicator.cpp diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp new file mode 100644 index 00000000..03c11b00 --- /dev/null +++ b/src/terrainlib/DatasetReader.cpp @@ -0,0 +1,403 @@ +/******************************************************************************* + * 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 +#include +#include + +#include "Dataset.h" +#include +#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 std::runtime_error("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 std::runtime_error(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 std::runtime_error("couldn't read data"); + + return heights_data; +} + +namespace { +struct WarpCoordinates { + const RasterTransform* source; + radix::tile::SrsBounds bounds; + unsigned side; + bool failed = false; + unsigned column_padding = 0; +}; + +int transform_import(void* argument, int destination_to_source, int count, + double* x, double* y, double*, int* success) +{ + auto& coordinates = *static_cast(argument); + const double spacing = coordinates.bounds.width() / coordinates.side; + for (int i = 0; i < count; ++i) { + const glm::dvec2 destination { coordinates.bounds.min.x + x[i] * spacing, + coordinates.bounds.max.y - y[i] * spacing }; + glm::dvec2 canonical = destination; + const double world_period = 2 * RasterTransform::world_half_extent; + canonical.x -= world_period * std::floor((canonical.x + RasterTransform::world_half_extent) / world_period); + const bool in_coverage = std::ranges::any_of(coordinates.source->bounds(), [&](const auto& bounds) { + return canonical.x >= bounds.min.x && canonical.x <= bounds.max.x + && canonical.y >= bounds.min.y && canonical.y <= bounds.max.y; + }); + const auto point = destination_to_source + ? coordinates.source->source_pixel(destination) + : coordinates.source->mercator({ x[i] - coordinates.column_padding, y[i] }); + success[i] = bool(point); + if (!point) { + // Successful out-of-coverage probes are needed for GDAL's source + // window estimation. Only failed excluded probes are nonfatal. + coordinates.failed = coordinates.failed || !destination_to_source || in_coverage; + x[i] = y[i] = HUGE_VAL; + } else if (destination_to_source) { + x[i] = point->x + coordinates.column_padding; + y[i] = point->y; + } else { + // Choose the equivalent Mercator branch nearest this output window. + const double period = 2 * RasterTransform::world_half_extent; + const double centre = (coordinates.bounds.min.x + coordinates.bounds.max.x) / 2; + const double world_x = point->x + period * std::round((centre - point->x) / period); + x[i] = (world_x - coordinates.bounds.min.x) / spacing; + y[i] = (coordinates.bounds.max.y - point->y) / spacing; + } + } + return TRUE; +} +} + +Expected> DatasetReader::read_scalar(GDALDataset& dataset, + const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const unsigned side, const unsigned band) +{ + if (side == 0 || side > unsigned((std::numeric_limits::max)()) + || band == 0 || band > unsigned(dataset.GetRasterCount())) { + return Error::fail(Error::Code::InvalidInput, "invalid RF read dimensions or source band"); + } + auto* source_band = dataset.GetRasterBand(int(band)); + if (GDALDataTypeIsComplex(source_band->GetRasterDataType())) { + return Error::fail(Error::Code::Unsupported, "complex raster bands cannot be imported as scalar or RGB data"); + } + // A single-band VRT exposes that band's own mask as alpha, including + // per-band masks which the low-level warper does not automatically apply. + const auto& affine = transform.affine(); + const double period = transform.reference().IsGeographic() + ? 2 * std::numbers::pi / transform.reference().GetAngularUnits() + : 2 * RasterTransform::world_half_extent; + const bool periodic = affine[2] == 0 && affine[4] == 0 + && (transform.reference().IsGeographic() || (transform.reference().GetAuthorityCode(nullptr) && std::string_view(transform.reference().GetAuthorityCode(nullptr)) == "3857")) + && std::abs(std::abs(affine[1]) * dataset.GetRasterXSize() - period) < period * 1e-10; + const unsigned padding = periodic ? (std::min)(8, dataset.GetRasterXSize()) : 0; + VRTDataset source(dataset.GetRasterXSize() + int(2 * padding), dataset.GetRasterYSize()); + if (source.AddBand(source_band->GetRasterDataType(), nullptr) != CE_None + || source.AddBand(GDT_Byte, nullptr) != CE_None) { + return Error::fail(Error::Code::Io, "create RF source-band view"); + } + auto* values = static_cast(source.GetRasterBand(1)); + auto* validity = static_cast(source.GetRasterBand(2)); + const auto attach = [&](VRTSourcedRasterBand* destination_band, GDALRasterBand* input_band) { + const int width = dataset.GetRasterXSize(); + const int height = dataset.GetRasterYSize(); + if (destination_band->AddSimpleSource(input_band, 0, 0, width, height, padding, 0, width, height) != CE_None) { + return false; + } + // A periodic global grid needs the opposite edge in the filter halo. + // Seam-crossing regional grids already have contiguous source columns. + return padding == 0 + || (destination_band->AddSimpleSource(input_band, width - int(padding), 0, padding, height, + 0, 0, padding, height) == CE_None + && destination_band->AddSimpleSource(input_band, 0, 0, padding, height, + width + padding, 0, padding, height) == CE_None); + }; + if (!attach(values, source_band) || !attach(validity, source_band->GetMaskBand())) { + return Error::fail(Error::Code::Io, "attach source values and validity to RF view"); + } + auto* driver = GetGDALDriverManager()->GetDriverByName("MEM"); + if (!driver) { + return Error::fail(Error::Code::Unsupported, "GDAL MEM driver is required"); + } + Dataset destination(driver->Create("", int(side), int(side), 2, GDT_Float32, nullptr)); + auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); + options->hSrcDS = &source; + options->hDstDS = destination.gdalDataset(); + options->nBandCount = 1; + options->panSrcBands = static_cast(CPLMalloc(sizeof(int))); + options->panDstBands = static_cast(CPLMalloc(sizeof(int))); + options->panSrcBands[0] = options->panDstBands[0] = 1; + options->nSrcAlphaBand = options->nDstAlphaBand = 2; + options->eResampleAlg = GRA_Lanczos; + options->eWorkingDataType = GDT_Float32; + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "INIT_DEST", "0"); + // Our borrowed transformer context is synchronous and cannot be cloned by + // GDAL workers. Do not inherit GDAL_NUM_THREADS from the environment. + 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"); + // Sample the interior too: a rotated source may lie wholly inside a window. + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "SAMPLE_GRID", "YES"); + options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "SAMPLE_STEPS", "21"); + int has_nodata = FALSE; + const double nodata = source_band->GetNoDataValue(&has_nodata); + if (has_nodata) { + options->padfSrcNoDataReal = static_cast(CPLMalloc(sizeof(double))); + options->padfSrcNoDataReal[0] = nodata; + } + WarpCoordinates coordinates { &transform, bounds, side, false, padding }; + options->pfnTransformer = transform_import; + options->pTransformerArg = &coordinates; + GDALWarpOperation operation; + if (operation.Initialize(options.get()) != CE_None + || operation.ChunkAndWarpImage(0, 0, int(side), int(side)) != CE_None || coordinates.failed) { + return Error::fail(Error::Code::Io, "warp RF source band: " + std::string(CPLGetLastErrorMsg())); + } + Samples result { radix::Raster(side), radix::Raster(side) }; + auto* output = destination.gdalDataset(); + if (output->GetRasterBand(1)->RasterIO(GF_Read, 0, 0, int(side), int(side), result.data.buffer().data(), + int(side), int(side), GDT_Float32, 0, 0) != CE_None) { + return Error::fail(Error::Code::Io, "read RF warped values and validity"); + } + std::vector alpha(side); + for (unsigned row = 0; row < side; ++row) { + if (output->GetRasterBand(2)->RasterIO(GF_Read, 0, int(row), int(side), 1, alpha.data(), + int(side), 1, GDT_Float32, 0, 0) != CE_None) { + return Error::fail(Error::Code::Io, "read RF destination validity"); + } + for (unsigned column = 0; column < side; ++column) { + result.valid.buffer()[std::size_t(row) * side + column] = alpha[column] > 0; + } + } + return result; +} + +Expected> DatasetReader::read_colour(GDALDataset& dataset, + const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const unsigned side, + const std::array& bands) +{ + if (side == 0 || side > unsigned((std::numeric_limits::max)())) { + return Error::fail(Error::Code::InvalidInput, "invalid RF RGB read dimensions"); + } + Samples result { radix::Raster(side), + radix::Raster(glm::uvec2(side), 255) }; + for (unsigned channel = 0; channel < 3; ++channel) { + auto samples = read_scalar(dataset, transform, bounds, side, bands[channel]); + if (!samples) { + return Error::propagate(std::move(samples), "read RGB channel " + std::to_string(channel)); + } + // Convert one row at a time to keep GDAL's signed word count in range. + for (unsigned row = 0; row < side; ++row) { + const std::size_t offset = std::size_t(row) * side; + GDALCopyWords(samples->data.buffer().data() + offset, GDT_Float32, sizeof(float), + &result.data.buffer()[offset][channel], GDT_Byte, sizeof(glm::u8vec3), int(side)); + } + for (std::size_t pixel = 0; pixel < result.data.buffer().size(); ++pixel) { + result.valid.buffer()[pixel] = result.valid.buffer()[pixel] && samples->valid.buffer()[pixel]; + } + } + return result; +} diff --git a/src/tile_builder/DatasetReader.h b/src/terrainlib/DatasetReader.h similarity index 74% rename from src/tile_builder/DatasetReader.h rename to src/terrainlib/DatasetReader.h index a60400c6..666b99a4 100644 --- a/src/tile_builder/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -25,12 +25,27 @@ #include #include +#include +#include "RasterTransform.h" class Dataset; class OGRSpatialReference; class DatasetReader { public: + template + struct Samples { + radix::Raster data; + radix::Raster valid; + }; + + // RF import: Lanczos at base resolution, exact coordinates, declared source + // validity. RGB requires a valid filtered result in every selected channel. + static Expected> read_scalar(GDALDataset& dataset, const RasterTransform& transform, + const radix::tile::SrsBounds& bounds, unsigned side, unsigned band); + static Expected> read_colour(GDALDataset& dataset, const RasterTransform& transform, + const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands); + 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; diff --git a/src/terrainlib/RasterTransform.cpp b/src/terrainlib/RasterTransform.cpp new file mode 100644 index 00000000..a2f3d0c8 --- /dev/null +++ b/src/terrainlib/RasterTransform.cpp @@ -0,0 +1,165 @@ +#include "RasterTransform.h" + +#include +#include +#include +#include "srs.h" + +namespace { +OGRSpatialReference mercator_reference() +{ + OGRSpatialReference result; + result.importFromEPSG(3857); + result.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + return result; +} +} + +Expected> RasterTransform::coverage( + const OGRSpatialReference& reference, const Bounds& bounds) +{ + const auto mercator = mercator_reference(); + if (reference.IsSame(&mercator) && bounds.min.x >= -world_half_extent && bounds.max.x <= world_half_extent) { + Bounds clipped { glm::max(bounds.min, glm::dvec2(-world_half_extent)), + glm::min(bounds.max, glm::dvec2(world_half_extent)) }; + if (clipped.min.x >= clipped.max.x || clipped.min.y >= clipped.max.y) { return std::vector {}; } + return std::vector { clipped }; + } + auto geographic = srs::wgs84(); + std::unique_ptr transform(OGRCreateCoordinateTransformation(&reference, &geographic)); + if (!transform) { + return Error::fail(Error::Code::InvalidInput, "create coverage transformation"); + } + Bounds longitude_latitude; + constexpr int boundary_samples = 257; + const bool reprojected = !reference.IsSame(&geographic); + if (reference.IsSame(&geographic)) { + longitude_latitude = bounds; + } else if (!transform->TransformBounds(bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y, + &longitude_latitude.min.x, &longitude_latitude.min.y, + &longitude_latitude.max.x, &longitude_latitude.max.y, boundary_samples)) { + return Error::fail(Error::Code::InvalidInput, "transform source coverage bounds"); + } + const double south = (std::max)(-latitude_limit, longitude_latitude.min.y); + const double north = (std::min)(latitude_limit, longitude_latitude.max.y); + if (south >= north) { + return std::vector {}; + } + double west = longitude_latitude.min.x; + double east = longitude_latitude.max.x; + if (!std::isfinite(west) || !std::isfinite(east) || !std::isfinite(south) || !std::isfinite(north)) { + return Error::fail(Error::Code::InvalidInput, "nonfinite source coverage bounds"); + } + if (east < west) { + east += 360; + } + // Densified transformation samples are not exact extrema of curved edges. + // Retain a full sampling interval as a guard band rather than pruning at + // the sampled extremum. Final pixel acceptance uses original-CRS geometry. + const double longitude_guard = reprojected ? (std::max)(1e-6, (east - west) / (boundary_samples + 1)) : 0; + const double latitude_guard = reprojected ? (std::max)(1e-6, + (longitude_latitude.max.y - longitude_latitude.min.y) / (boundary_samples + 1)) : 0; + west -= longitude_guard; + east += longitude_guard; + if (east - west >= 360) { + west = -180; + east = 180; + } else { + const double shift = 360 * std::floor((west + 180) / 360); + west -= shift; + east -= shift; + } + const auto latitude_y = [](double latitude) { + return std::clamp(world_half_extent / std::numbers::pi + * std::asinh(std::tan(latitude * std::numbers::pi / 180)), + -world_half_extent, world_half_extent); + }; + const auto rectangle = [&](double left, double right) -> Bounds { + return { { left * world_half_extent / 180, latitude_y((std::max)(-latitude_limit, south - latitude_guard)) }, + { right * world_half_extent / 180, latitude_y((std::min)(latitude_limit, north + latitude_guard)) } }; + }; + if (east > 180) { + return std::vector { rectangle(west, 180), rectangle(-180, east - 360) }; + } + return std::vector { rectangle(west, east) }; +} + +Expected RasterTransform::create(GDALDataset& dataset) +{ + RasterTransform result; + const auto* reference = dataset.GetSpatialRef(); + if (!reference || dataset.GetGeoTransform(result.m_affine.data()) != CE_None + || dataset.GetGCPCount() != 0 || CSLCount(dataset.GetMetadata("RPC")) != 0 + || CSLCount(dataset.GetMetadata("GEOLOCATION")) != 0) { + return Error::fail(Error::Code::Unsupported, "RF inputs require an explicit CRS and affine geotransform; GCP/RPC/geolocation inputs are unsupported"); + } + if (dataset.GetRasterXSize() <= 0 || dataset.GetRasterYSize() <= 0 + || !std::ranges::all_of(result.m_affine, [](double value) { return std::isfinite(value); }) + || !GDALInvGeoTransform(result.m_affine.data(), result.m_inverse.data())) { + return Error::fail(Error::Code::InvalidInput, "invalid raster dimensions or singular affine geotransform"); + } + result.m_reference = *reference; + result.m_reference.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + const auto mercator = mercator_reference(); + result.m_to_source.reset(OGRCreateCoordinateTransformation(&mercator, &result.m_reference)); + result.m_to_mercator.reset(OGRCreateCoordinateTransformation(&result.m_reference, &mercator)); + if (!result.m_to_source || !result.m_to_mercator) { + return Error::fail(Error::Code::InvalidInput, "create affine raster CRS transformations"); + } + if (result.m_reference.IsGeographic()) { + result.m_period = 2 * std::numbers::pi / result.m_reference.GetAngularUnits(); + } else if (result.m_reference.IsSame(&mercator)) { + result.m_period = 2 * world_half_extent; + } + Bounds bounds { { INFINITY, INFINITY }, { -INFINITY, -INFINITY } }; + for (double column : { 0., double(dataset.GetRasterXSize()) }) { + for (double row : { 0., double(dataset.GetRasterYSize()) }) { + glm::dvec2 point; + GDALApplyGeoTransform(result.m_affine.data(), column, row, &point.x, &point.y); + bounds.min = glm::min(bounds.min, point); + bounds.max = glm::max(bounds.max, point); + } + } + result.m_centre_x = (bounds.min.x + bounds.max.x) / 2; + auto coverage_bounds = coverage(result.m_reference, bounds); + if (!coverage_bounds) { + return Error::propagate(std::move(coverage_bounds)); + } + result.m_bounds = std::move(*coverage_bounds); + return result; +} + +RasterTransform::Bounds RasterTransform::tile_bounds(const radix::tile::Id& key) +{ + // ldexp avoids both 1u << 32 and tile-coordinate * tile-dimension overflow. + const double side = std::ldexp(2 * world_half_extent, -int(key.zoom_level)); + return { { -world_half_extent + double(key.coords.x) * side, + world_half_extent - (double(key.coords.y) + 1) * side }, + { -world_half_extent + (double(key.coords.x) + 1) * side, + world_half_extent - double(key.coords.y) * side } }; +} + +Expected RasterTransform::source_pixel(glm::dvec2 point) const +{ + if (!m_to_source->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { + return Error::fail(Error::Code::InvalidInput, "transform RF position into source CRS"); + } + // Keep a seam-crossing affine grid continuous in its own source coordinates. + // This is source addressing only; RF coordinates remain canonical. + if (m_period > 0) { + point.x = m_centre_x + std::remainder(point.x - m_centre_x, m_period); + } + glm::dvec2 pixel; + GDALApplyGeoTransform(const_cast(m_inverse.data()), point.x, point.y, &pixel.x, &pixel.y); + return pixel; +} + +Expected RasterTransform::mercator(glm::dvec2 pixel) const +{ + glm::dvec2 point; + GDALApplyGeoTransform(const_cast(m_affine.data()), pixel.x, pixel.y, &point.x, &point.y); + if (!m_to_mercator->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { + return Error::fail(Error::Code::InvalidInput, "transform source pixel into RF CRS"); + } + return point; +} diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h new file mode 100644 index 00000000..f2b750f1 --- /dev/null +++ b/src/terrainlib/RasterTransform.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "Error.h" + +// Exact affine raster coordinates, independent of delivery-tile dimensions. +class RasterTransform { +public: + static constexpr double world_half_extent = 20037508.342789244; + static constexpr double latitude_limit = 85.0511287798066; + using Bounds = radix::tile::SrsBounds; + + static Expected create(GDALDataset& dataset); + static Expected> coverage(const OGRSpatialReference& reference, const Bounds& bounds); + static Bounds tile_bounds(const radix::tile::Id& key); + + Expected source_pixel(glm::dvec2 mercator) const; + Expected mercator(glm::dvec2 source_pixel) const; + const std::vector& bounds() const { return m_bounds; } + const OGRSpatialReference& reference() const { return m_reference; } + const std::array& affine() const { return m_affine; } + +private: + OGRSpatialReference m_reference; + std::array m_affine {}; + std::array m_inverse {}; + std::unique_ptr m_to_source; + std::unique_ptr m_to_mercator; + std::vector m_bounds; + double m_period = 0; + double m_centre_x = 0; +}; diff --git a/src/terrainlib/vector_mask.h b/src/terrainlib/vector_mask.h new file mode 100644 index 00000000..c7b7bb2f --- /dev/null +++ b/src/terrainlib/vector_mask.h @@ -0,0 +1,270 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "Dataset.h" +#include "mesh/cgal.h" +#include "srs.h" + +namespace 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; +}; + +enum class LoadErrorKind { + UnsupportedFormat, + FileNotFound, + EmptySource, + InvalidGeometry, + UnsupportedSpatialReference, + ReadFailure, +}; + +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::ReadFailure: + return "feature read failure"; + 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_metres = 0.1; + +inline 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; +} + +inline 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; +} + +inline std::unique_ptr simplify_geometry( + const OGRGeometry &geometry, + const double tolerance +) { + return std::unique_ptr(geometry.SimplifyPreserveTopology(tolerance)); +} + +inline 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; +} + +inline 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); + + 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); + polygon.add_hole(std::move(hole)); + } + + return polygon; +} + +inline 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; + } +} + +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; + CPLErrorReset(); + 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 (CPLGetLastErrorType() >= CE_Failure) { + return std::unexpected(LoadErrorKind::ReadFailure); + } + + 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)}; +} + + +} // namespace vector_mask diff --git a/src/tile_builder/CMakeLists.txt b/src/tile_builder/CMakeLists.txt index a05e9edb..8c1a87a1 100644 --- a/src/tile_builder/CMakeLists.txt +++ b/src/tile_builder/CMakeLists.txt @@ -1,5 +1,4 @@ add_library(tilebuilderlib - DatasetReader.h DatasetReader.cpp Exception.h ParallelTileGenerator.h ParallelTileGenerator.cpp ParallelTiler.h ParallelTiler.cpp 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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 8dd85a42..5c9bb52e 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -166,3 +166,4 @@ if(TARGET sfmergerlib) endif() atb_configure_test(unittests_sfmerger) endif() + From 8e7fe98309b9ebade1202db17da144560e53a1b6 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:25:17 +0200 Subject: [PATCH 06/83] Accelerate RF mask selection with persistent point location and boundary BVH --- docs/raster-store/implementation-status.md | 52 ++++++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 1a38937a..38d20c6b 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -149,7 +149,7 @@ CMake configuration and Linux test environment documented above. - Six focused review passes found no issues in this follow-up. Qt lint reports DEP-10 on `std::chrono::duration::count()`; these calls use the required chrono API, so the container `.size()` recommendation does not apply. -- A full-resolution Vienna DSM import is being run under +- A full-resolution Vienna DSM import was attempted under `/data/scratch/codex/rf-vienna-20260910`. Inputs are Austria's `OeRect_01m_gs_31287.img` and the union of Stadt Wien's official district polygons. The user approved a scratch attribution entry with unknown source @@ -161,6 +161,50 @@ CMake configuration and Linux test environment documented above. - The initial Debug run exposed costly detailed-boundary centre tests. The user stopped the partitioned-mask import and the optimized build at 14:57 CEST. The last checkpoint contains 4 of 63 candidates (6.3%), with - 221459620 payload bytes. The incomplete snapshot and logs are retained; - no final Vienna snapshot was published. Whole-tile containment currently - has no fast path: accepted source pixels still receive individual mask tests. + 221459620 payload bytes. No final Vienna snapshot was published. + At that point, every accepted source + pixel still received an individual mask test. + +### Mask acceleration follow-up + +- Committed the preceding RF implementation and logging work as `b354cb3` + before starting this separately reviewable change. +- Replaced per-centre ring scans with a persistent CGAL point-location index + over the exact union of the simplified mask polygons. A static boundary-edge + BVH proves when a span of transformed centres lies entirely in one face; + those spans need only one point query. Boundary spans subdivide and fall back + to individual indexed queries. No mask-CRS approximation, pre-transform tile + shortcut, or parallel imports were introduced. +- Differential tests compare against the preceding ring-test behavior for + holes, overlaps, shared edges, concave and disconnected regions, random point + order, long rows, enclosed holes, source-invalid pixels, and moved masks. + The RF suite passes in both Debug and Release: 22 cases, 50955 assertions. + The separate original-Vienna-mask benchmark also matches the reference. +- On the same 4096-point Vienna workload, Release selection time was 33.319 ms + before acceleration, 6.010 ms with persistent point location alone, and + 4.364 ms with the boundary BVH and bulk selection (about 7.6 times faster + than the baseline). Debug times were 2905.693, 156.352, and 86.517 ms. + These are individual measurements including centre transformation, excluding + mask opening and the reference check; they are not full-import speedups. +- Three 4096-square Release tile benchmarks used the original official Vienna + boundary and the Austria DSM. Western boundary tile `13/4464/2840`, central + tile `13/4468/2840`, and eastern exterior tile `13/4473/2840` took 22.127, + 20.651, and 20.891 seconds respectively. Reading/resampling took 13.090–14.396 + seconds, masking 7.079–7.288 seconds, and packing/writing 0.057–0.483 seconds. + The exterior tile had no accepted pixels and was not written. Timings exclude + planning, initial mask loading, index checkpointing, and final publication. +- Both RF targets build in Debug and Release. Six focused review passes found + no defects. Qt lint flags bounded BVH construction, intentional validity + mutation, and chrono `count()` calls; the generic container recommendations + do not apply to these uses. Reproduction instructions are retained under + `/data/scratch/codex/rf-vienna-20260910/mask-acceleration`. +- A fresh Release import using the original official boundary and no cache + started at 18:09 CEST. The user cancelled it after 38/80 candidates (47.5%); + its last checkpoint contained 25 tiles and 1311434910 payload bytes. No final + snapshot was published. Throughput was about 20 seconds per candidate, with + a projected total of 27 minutes. A 45-second sample during the run measured + 99.8% of one CPU core, 2.21 MiB/s of file reads, and no physical reads charged + to the process; those reads were served from cache. +- After cancellation, the user requested deletion of all Vienna run outputs + and logs. Partial snapshots and run/benchmark logs were removed; the Release + build, source inputs, attribution tables, and reproduction scripts were kept. From a7257aa7e98474417dc52251ee99e0552990d463 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:16:51 +0200 Subject: [PATCH 07/83] Parallelize RF tile preparation and configure sanitizer exclusions --- .github/workflows/ci.yml | 4 +- README.md | 28 ++++++++++- cmake/PatchGdalBlockCache.cmake | 27 +++++++++++ cmake/SetupGDAL.cmake | 6 +++ docs/raster-store/implementation-status.md | 56 ++++++++++++++++++++++ misc/suppression/tsan.txt | 4 ++ misc/suppression/ubsan.txt | 12 +++++ src/terrainlib/DatasetReader.cpp | 13 +++-- src/terrainlib/Error.cpp | 2 + src/terrainlib/Error.h | 2 + src/terrainlib/init.cpp | 2 + thread_sanitizer_suppression.txt | 6 --- 12 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 cmake/PatchGdalBlockCache.cmake create mode 100644 misc/suppression/tsan.txt create mode 100644 misc/suppression/ubsan.txt delete mode 100644 thread_sanitizer_suppression.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0fd500..41e2dc5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,9 +188,11 @@ 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=$? diff --git a/README.md b/README.md index 4b00f545..596e3de0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ # alpine-terrain-builder -See the project [code style](docs/code-style.md) before contributing. \ No newline at end of file +See the project [code style](docs/code-style.md) before contributing. + +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 only the two +oneTBB initialization exclusions previously used by CI. 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/PatchGdalBlockCache.cmake b/cmake/PatchGdalBlockCache.cmake new file mode 100644 index 00000000..520993ab --- /dev/null +++ b/cmake/PatchGdalBlockCache.cmake @@ -0,0 +1,27 @@ +# GDAL 3.10 reads the global LRU head outside its cache lock in Touch(). +# Compile a patched build-local copy; leave the shared dependency source intact. +function(alp_patch_gdal_block_cache) + set(_source "${CMAKE_SOURCE_DIR}/gcore/gdalrasterblock.cpp") + file(READ "${_source}" _contents) + set(_unlocked_check " // Can be safely tested outside the lock\n if (poNewest == this)\n return;\n\n") + string(FIND "${_contents}" "${_unlocked_check}" _position) + if(_position EQUAL -1) + message(FATAL_ERROR "GDAL block-cache source changed; review the Touch() race patch") + endif() + # Touch_unlocked() repeats this check after TAKE_LOCK. + string(REPLACE "${_unlocked_check}" "" _contents "${_contents}") + set(_patched "${CMAKE_BINARY_DIR}/alp_patched/gdalrasterblock.cpp") + file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/alp_patched") + file(WRITE "${_patched}" "${_contents}") + get_target_property(_sources gcore SOURCES) + list(FIND _sources "gdalrasterblock.cpp" _index) + if(_index EQUAL -1) + message(FATAL_ERROR "GDAL gcore source list changed; review the block-cache patch") + endif() + list(REMOVE_AT _sources ${_index}) + list(APPEND _sources "${_patched}") + set_property(TARGET gcore PROPERTY SOURCES "${_sources}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_source}") +endfunction() + +cmake_language(DEFER CALL alp_patch_gdal_block_cache) diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index 27e2b6b6..f82becac 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -41,10 +41,16 @@ function(alp_setup_gdal) -DCMAKE_POSITION_INDEPENDENT_CODE=ON ) + # The hook's content hash is part of the dependency cache key via its path. + set(_patch_source "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/PatchGdalBlockCache.cmake") + file(SHA256 "${_patch_source}" _patch_hash) + set(_patch_hook "${CMAKE_BINARY_DIR}/alp_patches/gdal-block-cache-${_patch_hash}.cmake") + configure_file("${_patch_source}" "${_patch_hook}" COPYONLY) alp_setup_cmake_project(gdal URL https://github.com/OSGeo/gdal.git COMMITISH ${ARG_GDAL_VERSION} CMAKE_ARGUMENTS + "-DCMAKE_PROJECT_gdal_INCLUDE=${_patch_hook}" -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF -DGDAL_ENABLE_DRIVER_HFA=ON -DOGR_BUILD_OPTIONAL_DRIVERS=OFF diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 38d20c6b..9cb08cb3 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -208,3 +208,59 @@ CMake configuration and Linux test environment documented above. - After cancellation, the user requested deletion of all Vienna run outputs and logs. Partial snapshots and run/benchmark logs were removed; the Release build, source inputs, attribution tables, and reproduction scripts were kept. + + +### Parallel tile processing + +- Implemented `--jobs N` (default one), persistent private worker contexts and + a pool bounded to twice the worker count across queued, active and completed + tiles. The coordinator retains all output/index ownership and processes ready + results without waiting for earlier tiles. +- Cancellation discards queued work, finishes active tiles, saves their results, + checkpoints, and retains the unpublished `.part` snapshot and input record. + SIGINT and SIGTERM CLI checks both preserved eight tiles and exited with + statuses 130 and 143. The library cancellation test reopened and reused its + checkpoint successfully. +- Debug and Release RF suites pass: 30 cases and approximately 51458 assertions + (the cancellation test's assertion count varies with the number of active + results saved). Error/storage regressions pass: 61 cases, 674 assertions. + Coverage includes scalar/RGB serial equivalence, holes, cache reuse, bounded + outstanding work, out-of-order completion, worker errors/exceptions, cancelled + queued jobs, first-error preservation, and writer failure without publication. +- Review identified and resolved first-error selection across out-of-order + failures and missing direct header includes. New/changed C++ sections were + formatted with clang-format-21. +- Instrumented dependency testing exposed GDAL 3.10 races in MEM driver + creation, lazy block-cache lock initialization, and the unlocked LRU-head + check. Destination creation is serialized, cache setup runs before workers, + and a content-hashed CMake hook compiles a build-local GDAL source copy with + the redundant unlocked check removed. No TSan suppressions are used. +- UBSan also found CGAL's trapezoidal locator reading an uninitialized Boolean + in `set_with_guarantees()`. The constructors ignore the returned old value. + At the user's request, the initialization patch and its CMake hook were removed; + `misc/suppression/ubsan.txt` excludes Boolean diagnostics only in that CGAL setter. + It also excludes the four arrangement type families affected by the known + downcast issue (CGAL #9140). ASan and leak detection remain enabled. Negative + controls confirm unrelated Boolean reads, invalid downcasts, address errors, + leaks and integer overflow still fail. These exclusions leave the CGAL reads + and downcasts unfixed. +- After removing the Boolean patch, CMake restored the original CGAL header; + the rebuilt ASan RF suite passes all 30 cases and 51603 assertions with the + updated scratch exclusions and leak detection enabled. +- CI's ASan test step loads the tracked UBSan list with leak detection and + halt-on-error enabled. The same list passes all 30 ASan RF cases locally and + a Clang 21 CGAL fixture; all five unrelated sanitizer negative controls still + fail as intended. CI uses Clang 23, which was unavailable for local validation. +- CI's existing two oneTBB TSan exclusions now live in + `misc/suppression/tsan.txt`. The unused root suppression file was removed; + its broader legacy rules were not carried over. +- Full-Vienna validation completed on 2026-09-11: ASan and TSan at 4 and 16 + workers all passed. Release runs at 1, 2, 4, 8 and 12 workers passed, executed + serially after all sanitizer runs finished. Every run published 62 tiles; + all 64 published files are byte-identical across all nine configurations, + verified by file sets, sizes, hashes and direct byte comparison. Elapsed time, + peak RSS, exact invocations, source/executable hashes and the scratch-only + harness are retained under `/data/scratch/codex/rf-parallel-20260910`. + Those full runs used the former Boolean initialization patch; their saved + results and suppression files describe that configuration. The default + remains one worker. diff --git a/misc/suppression/tsan.txt b/misc/suppression/tsan.txt new file mode 100644 index 00000000..743395f8 --- /dev/null +++ b/misc/suppression/tsan.txt @@ -0,0 +1,4 @@ +# 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 diff --git a/misc/suppression/ubsan.txt b/misc/suppression/ubsan.txt new file mode 100644 index 00000000..576de114 --- /dev/null +++ b/misc/suppression/ubsan.txt @@ -0,0 +1,12 @@ +# 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) diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 03c11b00..748f3b53 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -23,12 +23,13 @@ #include #include #include -#include #include -#include #include +#include +#include #include #include +#include #include "Dataset.h" #include @@ -322,7 +323,13 @@ Expected> DatasetReader::read_scalar(GDALDataset& if (!driver) { return Error::fail(Error::Code::Unsupported, "GDAL MEM driver is required"); } - Dataset destination(driver->Create("", int(side), int(side), 2, GDT_Float32, nullptr)); + Dataset destination([&] { + // GDAL 3.10 rewrites the shared driver's create callback on every call. + // Only creation needs serialization; each resulting dataset is private. + static std::mutex creation_mutex; + const std::lock_guard lock(creation_mutex); + return driver->Create("", int(side), int(side), 2, GDT_Float32, nullptr); + }()); auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); options->hSrcDS = &source; options->hDstDS = destination.gdalDataset(); diff --git a/src/terrainlib/Error.cpp b/src/terrainlib/Error.cpp index e5acb8dd..e0e3f7d7 100644 --- a/src/terrainlib/Error.cpp +++ b/src/terrainlib/Error.cpp @@ -23,6 +23,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"; } diff --git a/src/terrainlib/Error.h b/src/terrainlib/Error.h index a304f8da..7624388b 100644 --- a/src/terrainlib/Error.h +++ b/src/terrainlib/Error.h @@ -26,6 +26,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, }; diff --git a/src/terrainlib/init.cpp b/src/terrainlib/init.cpp index 41514ac3..91955736 100644 --- a/src/terrainlib/init.cpp +++ b/src/terrainlib/init.cpp @@ -38,5 +38,7 @@ void initialize_gdal_once() { LOG_DEBUG("calling GDALAllRegister..."); CPLSetErrorHandler(GdalErrorHandler); GDALAllRegister(); + // Initialize GDAL's global block-cache lock before concurrent reads. + GDALGetCacheMax64(); }); } 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* From d3c021c79f578903bd85ebc9e3990120dc1eeb47 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:46:17 +0200 Subject: [PATCH 08/83] Replace GDAL block-cache patch with TSan suppression --- README.md | 8 ++++--- cmake/PatchGdalBlockCache.cmake | 27 ---------------------- cmake/SetupGDAL.cmake | 6 ----- docs/raster-store/implementation-status.md | 18 ++++++++++----- misc/suppression/tsan.txt | 6 +++++ 5 files changed, 23 insertions(+), 42 deletions(-) delete mode 100644 cmake/PatchGdalBlockCache.cmake diff --git a/README.md b/README.md index 596e3de0..0b4d9fdc 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1:suppressions=$PWD/misc/ 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 only the two -oneTBB initialization exclusions previously used by CI. For local TSan tests, -set this variable from the repository root: +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/PatchGdalBlockCache.cmake b/cmake/PatchGdalBlockCache.cmake deleted file mode 100644 index 520993ab..00000000 --- a/cmake/PatchGdalBlockCache.cmake +++ /dev/null @@ -1,27 +0,0 @@ -# GDAL 3.10 reads the global LRU head outside its cache lock in Touch(). -# Compile a patched build-local copy; leave the shared dependency source intact. -function(alp_patch_gdal_block_cache) - set(_source "${CMAKE_SOURCE_DIR}/gcore/gdalrasterblock.cpp") - file(READ "${_source}" _contents) - set(_unlocked_check " // Can be safely tested outside the lock\n if (poNewest == this)\n return;\n\n") - string(FIND "${_contents}" "${_unlocked_check}" _position) - if(_position EQUAL -1) - message(FATAL_ERROR "GDAL block-cache source changed; review the Touch() race patch") - endif() - # Touch_unlocked() repeats this check after TAKE_LOCK. - string(REPLACE "${_unlocked_check}" "" _contents "${_contents}") - set(_patched "${CMAKE_BINARY_DIR}/alp_patched/gdalrasterblock.cpp") - file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/alp_patched") - file(WRITE "${_patched}" "${_contents}") - get_target_property(_sources gcore SOURCES) - list(FIND _sources "gdalrasterblock.cpp" _index) - if(_index EQUAL -1) - message(FATAL_ERROR "GDAL gcore source list changed; review the block-cache patch") - endif() - list(REMOVE_AT _sources ${_index}) - list(APPEND _sources "${_patched}") - set_property(TARGET gcore PROPERTY SOURCES "${_sources}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_source}") -endfunction() - -cmake_language(DEFER CALL alp_patch_gdal_block_cache) diff --git a/cmake/SetupGDAL.cmake b/cmake/SetupGDAL.cmake index f82becac..27e2b6b6 100644 --- a/cmake/SetupGDAL.cmake +++ b/cmake/SetupGDAL.cmake @@ -41,16 +41,10 @@ function(alp_setup_gdal) -DCMAKE_POSITION_INDEPENDENT_CODE=ON ) - # The hook's content hash is part of the dependency cache key via its path. - set(_patch_source "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/PatchGdalBlockCache.cmake") - file(SHA256 "${_patch_source}" _patch_hash) - set(_patch_hook "${CMAKE_BINARY_DIR}/alp_patches/gdal-block-cache-${_patch_hash}.cmake") - configure_file("${_patch_source}" "${_patch_hook}" COPYONLY) alp_setup_cmake_project(gdal URL https://github.com/OSGeo/gdal.git COMMITISH ${ARG_GDAL_VERSION} CMAKE_ARGUMENTS - "-DCMAKE_PROJECT_gdal_INCLUDE=${_patch_hook}" -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF -DGDAL_ENABLE_DRIVER_HFA=ON -DOGR_BUILD_OPTIONAL_DRIVERS=OFF diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 9cb08cb3..af203684 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -232,9 +232,15 @@ CMake configuration and Linux test environment documented above. formatted with clang-format-21. - Instrumented dependency testing exposed GDAL 3.10 races in MEM driver creation, lazy block-cache lock initialization, and the unlocked LRU-head - check. Destination creation is serialized, cache setup runs before workers, - and a content-hashed CMake hook compiles a build-local GDAL source copy with - the redundant unlocked check removed. No TSan suppressions are used. + check. Destination creation is serialized and cache setup runs before workers. + The unlocked LRU-head comparison in `GDALRasterBlock::Touch()` is now accepted + through `misc/suppression/tsan.txt`, at the user's request. The former GDAL + source patch and CMake hook were removed to avoid maintaining a dependency + patch. TSan suppressions match stack frames, so this rule also excludes other + races passing through `Touch()`. + After rebuilding with unpatched GDAL, the RF suite reports this race without + the suppression and passes all 30 cases (51602 assertions) with it enabled. + A separate negative control still reports an unrelated data race. - UBSan also found CGAL's trapezoidal locator reading an uninitialized Boolean in `set_with_guarantees()`. The constructors ignore the returned old value. At the user's request, the initialization patch and its CMake hook were removed; @@ -261,6 +267,6 @@ CMake configuration and Linux test environment documented above. verified by file sets, sizes, hashes and direct byte comparison. Elapsed time, peak RSS, exact invocations, source/executable hashes and the scratch-only harness are retained under `/data/scratch/codex/rf-parallel-20260910`. - Those full runs used the former Boolean initialization patch; their saved - results and suppression files describe that configuration. The default - remains one worker. + Those full runs used the former CGAL Boolean and GDAL cache-head patches; + their saved results and suppression files describe that configuration. + The default remains one worker. diff --git a/misc/suppression/tsan.txt b/misc/suppression/tsan.txt index 743395f8..67b0f120 100644 --- a/misc/suppression/tsan.txt +++ b/misc/suppression/tsan.txt @@ -2,3 +2,9 @@ # 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() From cdf4d7e705c401d4bbcd039e85baac8205c0fb41 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:08:36 +0200 Subject: [PATCH 09/83] Address RF record initialization warning and OpenCV UBSan failures --- misc/suppression/ubsan.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/misc/suppression/ubsan.txt b/misc/suppression/ubsan.txt index 576de114..faa75719 100644 --- a/misc/suppression/ubsan.txt +++ b/misc/suppression/ubsan.txt @@ -10,3 +10,11 @@ vptr_check:N4CGAL24Arrangement_on_surface_2I # 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 resize fallback uses -1 as an unsupported-interpolation sentinel. +# Suppress only enum diagnostics in that implementation. +enum:*opencv/modules/imgproc/src/resize.cpp From 48a8fa489a2a8cfcf7a384c91494e616c73b9380 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:10:07 +0200 Subject: [PATCH 10/83] docs: design RF tile downloader import --- docs/raster-store/terminology.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index de94e8ed..6d5d8719 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -20,8 +20,36 @@ : The authoritative raster store from which delivery data is derived. **RF import** -: Raster data accepted from one prepared source dataset under one validity mask - and attributed to one source-attribution entry. +: 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. + +**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 largest directional stretch from an output pixel spacing into source-pixel From 98f5584776c2ec4f333de2594ded2f61bc794874 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:27:30 +0200 Subject: [PATCH 11/83] Disable GCC 16 maybe-uninitialized warning in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41e2dc5f..7b03c228 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 From b4f15b548b2043c705cd7a57147259f91c380b7b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:29:07 +0200 Subject: [PATCH 12/83] Add online tile import to RF builder --- cmake/SetupOpenCV.cmake | 2 +- docs/raster-store/implementation-status.md | 99 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) 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/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index af203684..0dc47d3d 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -1,5 +1,31 @@ # Raster-store implementation status +## Online RF import — 2026-09-12 + +The [online import plan](rf-builder-downloader-design.md) was approved in the +implementation session. Initial working tree: clean on `main`, one commit ahead +of `origin/main`. Implementation and verification completed before the later, +separately authorized commit and push. + +Decoded JPEG RGB remains nonlinear. Ancestor fallback uses the standard sRGB +transfer function before/after linear-light bilinear interpolation; native +copies retain decoded bytes. Retry waits start at 500 ms and double, with a one-hour total deadline per +failing tile request (request time plus waits), as confirmed by the user. + +| Stage | Status | Evidence / remaining work | +|---|---|---| +| Baseline and decisions | Verified | Baseline: 30 RF cases, 51459 assertions; sRGB and one-hour total retry deadline confirmed | +| Coordinator and subcommands | Verified | Shared lifecycle, explicit GDAL/tiles commands and bounded parallel subdivision; 47 RF cases pass | +| Provider, HTTP and JPEG | Verified | Strict JSON, bounded retries, decoder and HTTP fixtures pass; Basemap and Gataki configure zooms 4..20, side 256 | +| Adaptive selection and preparation | Verified | Mixed-resolution leaves, native bytes, linear-light fallback, narrow masks, holes, seams and poles | +| Cache, cancellation and progress | Verified | Zero-HTTP cache reuse, partial restart, bounded work, cancellation and out-of-order weighted progress fixtures | +| Integration verification | Verified | Debug RF, full Release/ASan/TSan RF, shared-dependency regressions, downloader-off builds, lint and Release measurement | +| Documentation | Verified | Subcommand examples, accepted provider limits, sRGB/retry behavior, operational bounds and final evidence recorded | + +Stages advance through Not started, In progress, Implemented and Verified; +Blocked records an unresolved dependency. Verification commands, results and +limitations are recorded here as work proceeds. + The scope and sequence are in [implementation-plan.md](implementation-plan.md). Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. @@ -270,3 +296,76 @@ CMake configuration and Linux test environment documented above. Those full runs used the former CGAL Boolean and GDAL cache-head patches; their saved results and suppression files describe that configuration. The default remains one worker. + +### Online import verification log + +- Baseline: existing `build/Desktop_Debug/unittests/unittests_rfbuilder`, with + the documented PROJ/GDAL environment: 30 cases, 51459 assertions passed. +- After GDAL extraction: `cmake --build build/Desktop_Debug --target rf-builder + unittests_rfbuilder --parallel 6`, then the same RF suite/environment: + 30 cases, 51603 assertions passed. Assertion totals vary with cancellation + timing. Build/test transcripts: `/tmp/rf-online-baseline/build-gdal.log` and + `/tmp/rf-online-baseline/tests-gdal.log`. +- Provider GET probes on 2026-09-12 at 16.3738 E, 48.2082 N, zooms 0..22: + Basemap returned JPEG 256x256 at 1..20; Gataki at 4..20. Other probed + levels returned 404. This samples Vienna, not exhaustive coverage. + Exact URLs/statuses are in `/tmp/rf-online-baseline/provider-probes.json`. + +- Complete Debug RF suite: 47 cases, 53812 assertions passed, including 17 + online cases. Tests use a bounded loopback HTTP fixture, with no live provider + dependency. The out-of-order progress test exposed serial processing of + descendants from a single root; idle lanes now take pending sibling subtrees. + The corrected fixture verifies 25%, idle 75%, and final 100% progress. +- Shared-dependency regressions passed: reader/storage/image roundtrips + (33 cases, 480 assertions), tilebuilder (14 cases, 389 assertions), SF builder + (16 cases, 176102 assertions), and retained downloader (13 cases, 49 assertions). +- The configured OpenCV JPEG decoder uses fetched libjpeg-turbo 3.0.3-70. + CMake reports `BUILD_JPEG=ON`; JPEG decoding symbols are local to OpenCV's + image-codec library. System JPEG may still load indirectly through TIFF. +- New/changed C++ was formatted with clang-format-21. The Qt C++ skill's six + review missions found no confirmed remaining defects. Deterministic warnings + about chrono `count()` and mutation of a `std::vector` reference do not apply + to these APIs; existing GDAL aggregate/container warnings are unchanged. + +- Optimized validation exposed a temporary-lifetime error in online refinement: + the optional child array is now retained before range iteration. The CLI HTTP + fixture now publishes configuration through its handler mutex before the + subprocess starts. After these fixes, full Release and TSan RF suites pass + all 47 cases (53811 and 53812 assertions respectively), with no TSan reports. + TSan uses the existing tracked dependency suppressions; none were added. +- The shared mask regression filter in SF merger passes 6 cases / 85 assertions. + +- Basemap's configured minimum is 4, explicitly confirmed by the user; the + available lower levels 1..3 are deliberately excluded to keep RF's default + side 4096. Both shipped provider files pass CLI provider/ratio validation + before the expected rejection of an empty mask; this check issues no HTTP. +- Release measurement (`[online-benchmark] --benchmark-samples 20`): source + side 256, RF side 4096, two workers, synthetic whole-world coverage at source + zoom 4 with one available zoom-5 tile and one zoom-6 tile. The import produces + seven disjoint leaves (7x the dense pixels of the coarse single-root output), + 54494 stored bytes and 1671 requests in 6.203 seconds. Overall test process: + 8.146 seconds, 818180 KiB peak RSS measured with Linux `getrusage`. + The constant-colour JPEG fixture compresses unusually well; its stored byte + count is not representative of aerial photographs. Geographic completion + weight costs about 6.91 ns per lookup for one rectangle in the Catch2 + benchmark. These are one-run measurements; dependency compilation was + concurrent on this 24-logical-CPU host. +- RF builds and tests pass in both Release and TSan configurations with + `ALP_BUILD_TILE_DOWNLOADER=OFF`. The final Debug online subset passes + 17 cases / 2209 assertions after the optimized-build fixes. + +- Final ASan/LSan/UBSan suite: all 47 RF cases / 53811 assertions passed. + `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1`; `UBSAN_OPTIONS` enables + halt-on-error, stack traces and the existing `misc/suppression/ubsan.txt`. + No new suppressions were introduced. The final incremental build had no + additional work. TSan likewise uses only `misc/suppression/tsan.txt`. +- Whitespace and Git-attribute line-ending checks pass for all 43 changed/new + text files. Generated regression outputs were removed. The initial `main` + checkout was retained throughout implementation and verification. +- Final evidence is retained under `/data/scratch/codex/rf-online-20260912`: + build/test logs, provider probes, benchmark, lint/review report, exact build + roots/test environment in `verification.json`, and source/executable hashes. + Earlier `/tmp/rf-online-baseline` paths in this log are mirrored there. + All planned implementation stages are verified; no open product decisions + remain. Live provider evidence is limited to the regional JPEG probes; the + end-to-end correctness and performance checks use deterministic fixtures. From 3e9941655ae03eb63b22854bad8646a1d8db6a68 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:34:53 +0200 Subject: [PATCH 13/83] Add zpp::bits serialization adapters for GLM vectors and matrices --- src/terrainlib/CMakeLists.txt | 1 + src/terrainlib/io/envelope.h | 1 + src/terrainlib/io/glm_serialization.h | 47 +++++++ unittests/CMakeLists.txt | 8 ++ unittests/terrainlib/envelope.cpp | 21 +++ unittests/terrainlib/glm_serialization.cpp | 154 +++++++++++++++++++++ 6 files changed, 232 insertions(+) create mode 100644 src/terrainlib/io/glm_serialization.h create mode 100644 unittests/terrainlib/glm_serialization.cpp diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 889cb51b..23a2c757 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -61,6 +61,7 @@ add_library(terrainlib io/compression.h io/compression.cpp io/conversion.h io/envelope.h + io/glm_serialization.h io/utils.h io/utils.cpp mesh/EncodedMesh.h diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index 0f53e5d3..de5c7844 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -2,6 +2,7 @@ #include "io/bytes.h" #include "io/compression.h" +#include "io/glm_serialization.h" #include diff --git a/src/terrainlib/io/glm_serialization.h b/src/terrainlib/io/glm_serialization.h new file mode 100644 index 00000000..af2f3432 --- /dev/null +++ b/src/terrainlib/io/glm_serialization.h @@ -0,0 +1,47 @@ +#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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 5c9bb52e..f4101694 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(unittests_terrainlib terrainlib/error.cpp terrainlib/fixed_vector.cpp terrainlib/geometry_frames.cpp + terrainlib/glm_serialization.cpp terrainlib/grid.cpp terrainlib/hash_utils.cpp terrainlib/hybrid_index_pair_map.cpp @@ -76,6 +77,13 @@ add_executable(unittests_terrainlib target_link_libraries(unittests_terrainlib PRIVATE terrainlib Catch2::Catch2WithMain) atb_configure_test(unittests_terrainlib) +# 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 ${CMAKE_SOURCE_DIR}/src/terrainlib) +target_link_libraries(unittests_glm_serialization_aligned PRIVATE glm::glm zpp_bits Catch2::Catch2WithMain) + if(TARGET tilebuilderlib) add_executable(unittests_tilebuilder catch2_helpers.h diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 06b21391..9f6452ca 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -165,6 +165,27 @@ TEST_CASE("Envelope round trips the latest payload version") 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") diff --git a/unittests/terrainlib/glm_serialization.cpp b/unittests/terrainlib/glm_serialization.cpp new file mode 100644 index 00000000..0618b415 --- /dev/null +++ b/unittests/terrainlib/glm_serialization.cpp @@ -0,0 +1,154 @@ +#include "io/glm_serialization.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +template +auto encode(const Value& value) +{ + std::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. + std::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(std::span(bytes.data(), size)); + CHECK(zpp::bits::failure(input(decoded))); + + std::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 From 982b56d6b0adb16a9a7cd6124f447884923a3273 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:40:46 +0200 Subject: [PATCH 14/83] Use tile IDs and typed statuses in raster index entries --- src/terrainlib/raster_store/io/manifest.cpp | 17 ++++++----------- src/terrainlib/raster_store/io/manifest.h | 8 ++++---- unittests/terrainlib/raster_store.cpp | 11 ++++++----- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index 6ba1c76d..654d5b33 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include "raster_store/path_layout.h" @@ -34,12 +33,9 @@ detail::v1::Hierarchy encode_index(const store::Index& index) detail::v1::Hierarchy encoded; encoded.entries.reserve(index.size()); for (const auto& [key, status] : index) { - encoded.entries.push_back({ key.zoom_level, key.coords.x, key.coords.y, - static_cast(static_cast(status)) }); + encoded.entries.push_back({ key, status }); } - std::ranges::sort(encoded.entries, [](const auto& left, const auto& right) { - return std::tie(left.zoom, left.x, left.y) < std::tie(right.zoom, right.x, right.y); - }); + std::ranges::sort(encoded.entries, [](const auto& left, const auto& right) { return left.id < right.id; }); return encoded; } @@ -48,17 +44,16 @@ Expected> decode_index(const detail::v1::Hierarchy& en store::Index index; std::unordered_set seen; for (const auto& entry : encoded.entries) { - const radix::tile::Id key { entry.zoom, { entry.x, entry.y } }; - if (!StoreTraits::is_valid(key)) { + if (!StoreTraits::is_valid(entry.id)) { return Error::fail(Error::Code::CorruptData, "raster index contains an invalid tile ID"); } - if (!seen.insert(key).second) { + if (!seen.insert(entry.id).second) { return Error::fail(Error::Code::CorruptData, "raster index contains a duplicate tile ID"); } - if (entry.status > static_cast(store::NodeStatus::Virtual)) { + if (entry.status > store::NodeStatus::Virtual) { return Error::fail(Error::Code::CorruptData, "raster index contains an invalid node status"); } - auto added = index.set_raw(key, store::NodeStatus { static_cast(entry.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"); } diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h index 50b1b68f..62e7af76 100644 --- a/src/terrainlib/raster_store/io/manifest.h +++ b/src/terrainlib/raster_store/io/manifest.h @@ -17,6 +17,8 @@ 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; @@ -26,10 +28,8 @@ namespace detail::v1 { }; struct IndexEntry { - std::uint32_t zoom; - std::uint32_t x; - std::uint32_t y; - std::uint8_t status; + radix::tile::Id id; + store::NodeStatus::Value status; }; struct Hierarchy { diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 18c8b6bf..a7ffb864 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -207,14 +207,15 @@ TEST_CASE("raster index adapters preserve mixed hierarchy and reject corrupt top CHECK(decoded->is(store::NodeStatus::Virtual, { 1, { 0, 0 } }).value()); CHECK(decoded->is(store::NodeStatus::Leaf, { 2, { 1, 1 } }).value()); REQUIRE(encoded.entries.size() == 3); - CHECK(encoded.entries[0].zoom == 0); - CHECK(encoded.entries[2].zoom == 2); + 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].x = 1; } - SECTION("invalid status") { encoded.entries[0].status = 255; } + 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 = static_cast(store::NodeStatus::Leaf); } + 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); } From ba4974c253e1f0eda0d7c3a7f6f0c58b11ea4383 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:00:21 +0200 Subject: [PATCH 15/83] Return raster storage handles with read-only metadata --- docs/raster-store/architecture.md | 38 ++++--- src/terrainlib/raster_store/io/manifest.cpp | 5 + src/terrainlib/raster_store/io/manifest.h | 1 + src/terrainlib/raster_store/storage.h | 49 ++++++--- src/terrainlib/store/RawStorage.h | 1 - src/terrainlib/store/Storage.h | 3 +- unittests/terrainlib/raster_store.cpp | 108 ++++++++++++-------- 7 files changed, 133 insertions(+), 72 deletions(-) diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 637cc3ee..1d2159e4 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -43,22 +43,30 @@ whose selected table is copied unchanged into the output. ```cpp raster_store::storage::CreateOptions options; options.tile_dimensions = { 3, 3 }; -auto output = raster_store::storage::create(snapshot_path, options); -if (!output) { - return Error::propagate(std::move(output)); +auto created = raster_store::storage::create(snapshot_path, options); +if (!created) { + return Error::propagate(std::move(created)); } +auto [output, metadata] = std::move(*created); raster_store::Tile tile(3); // Attribution defaults to NoData. if (auto saved = output->save({ 0, { 0, 0 } }, tile); !saved) { return saved; } -return raster_store::storage::publish(std::move(*output)); +return raster_store::storage::publish(std::move(output)); ``` -Call `save_index()` explicitly for intermediate checkpoints. `open()` -returns `IndexedStorage`; pass `{ .allow_incomplete = true }` for -cache access to a `.part` snapshot. `attribution::read_table(index_path)` -provides the selected table separately. `publish()` consumes the output handle -on success or failure and returns `Expected`. +Both factories return `Expected>` with owning `std::unique_ptr`s +to the storage first and metadata second. `create()` returns writable +`IndexedStorage` and const metadata; `open()` returns const +storage and const metadata. Both pointers are non-null on success and can be +moved independently. + +Call the non-const `save_index()` explicitly for intermediate checkpoints on +created storage. Pass `{ .allow_incomplete = true }` to `open()` for +read-only cache access to a `.part` snapshot. `attribution::read_table(index_path)` +provides the selected table separately. `publish()` consumes the writable storage +pointer on success or failure and returns `Expected`; it rejects null +pointers. The metadata pointer remains available after publication. Raster opening supplies the decoded metadata to the shared `store::open_index` overload. The shared opener resolves the metadata's codec selector through @@ -66,7 +74,9 @@ the supplied callback; raster codec names and dimensions are handled in `raster_store`, while mesh callers retain their extension resolver. Manifest I/O lives in `raster_store/io/manifest.h` under -`raster_store::io::manifest`. `raster_store/io/TileCodec.h` defines +`raster_store::io::manifest`. `read_metadata(base_path)` and +`write_metadata(metadata, base_path)` read and write the snapshot metadata file. +`raster_store/io/TileCodec.h` defines `raster_store::io::TileCodec` and its supporting `tile_codec` namespace. Versioned payload structs belong to `manifest::detail::v1` and `tile_codec::detail::v1`, respectively. The XYZ layout lives in @@ -171,9 +181,9 @@ 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 through `IndexedStorage`. Its API currently -also exposes mutation, so immutability relies on caller discipline. Making -the type truly read-only belongs to a separate work package. +Published snapshots are opened through `std::unique_ptr`; +normal access permits reads but not tile mutations or index checkpoints. +Created snapshots retain writable storage until publication consumes it. 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 @@ -187,7 +197,7 @@ the independent attribution table remain valid after removing the RF. ### Opening and checkpoints Normal opening rejects `.part` snapshot directories. The explicit -`allow_incomplete` option opens one as `IndexedStorage` for cache reuse, using +`allow_incomplete` option opens one as const `IndexedStorage` for cache reuse, using the last saved index and ignoring unindexed files. Checkpoint timing belongs to the builder, which explicitly saves the index periodically. diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index 6ba1c76d..973a038c 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -29,6 +29,11 @@ Expected read_metadata(const std::filesystem::path& base_path) return metadata; } +Expected write_metadata(const Metadata& metadata, const std::filesystem::path& base_path) +{ + return ::io::envelope::write_to_path(metadata, base_path / metadata_file_name); +} + detail::v1::Hierarchy encode_index(const store::Index& index) { detail::v1::Hierarchy encoded; diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h index 50b1b68f..6362ec11 100644 --- a/src/terrainlib/raster_store/io/manifest.h +++ b/src/terrainlib/raster_store/io/manifest.h @@ -44,6 +44,7 @@ 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); diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index 3a08fa57..a435ee48 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -1,9 +1,11 @@ #pragma once #include +#include #include #include #include +#include #include "io/utils.h" #include "raster_store/attribution.h" @@ -46,15 +48,16 @@ namespace detail { // The owned handle is destroyed on return, before publication renames its directory. template - Expected finish_output(IndexedStorage storage) + Expected finish_output(std::unique_ptr> storage) { - return storage.save_index(); + return storage->save_index(); } } // namespace detail template -Expected> open(const std::filesystem::path& base_path, const OpenOptions options = {}) +Expected>, std::unique_ptr>> open( + const std::filesystem::path& base_path, const OpenOptions options = {}) { auto normalized_path = base_path.lexically_normal(); if (!normalized_path.has_filename()) { @@ -79,13 +82,21 @@ Expected> open(const std::filesystem::path& base_path, const auto resolve_codec = [&metadata](const std::string_view name) { return io::tile_codec::from_name(name, { metadata->width, metadata->height }); }; - return store::open_index>(index_path, io::manifest::index_format(), + auto storage = store::open_index>(index_path, + io::manifest::index_format(), { std::move(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, - pixel_type::identifier(), resolve_codec); + pixel_type::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> create(const std::filesystem::path& final_path, const CreateOptions options = {}) +Expected>, std::unique_ptr>> create( + const std::filesystem::path& final_path, const CreateOptions options = {}) { const auto destination = final_path.lexically_normal(); if (destination.filename().empty() || destination.filename() == "." || destination.filename() == ".." || destination.extension() == ".part") { @@ -127,14 +138,21 @@ Expected> create(const std::filesystem::path& final_pa return Error::propagate(std::move(copied)); } } - const io::manifest::Metadata metadata { std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), pixel_type::identifier(), - options.codec_selector, options.tile_dimensions.x, options.tile_dimensions.y }; - auto written = ::io::envelope::write_to_path(metadata, partial_path / io::manifest::metadata_file_name); + auto metadata = std::make_unique(std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), + pixel_type::identifier(), + options.codec_selector, + options.tile_dimensions.x, + options.tile_dimensions.y); + 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)); + 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)); } @@ -142,15 +160,18 @@ Expected> create(const std::filesystem::path& final_pa if (auto saved = storage->save_index(); !saved) { return Error::propagate(std::move(saved), "create initial raster index"); } - return IndexedStorage(std::move(*storage)); + 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(IndexedStorage storage) +Expected publish(std::unique_ptr> storage) { - const auto partial_path = storage.base_path().lexically_normal(); + if (!storage) { + return Error::fail(Error::Code::InvalidInput, "publication requires an output handle"); + } + const auto partial_path = storage->base_path().lexically_normal(); if (partial_path.extension() != ".part") { return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); } diff --git a/src/terrainlib/store/RawStorage.h b/src/terrainlib/store/RawStorage.h index 22e90011..298f9af5 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.h @@ -162,7 +162,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..4987af1f 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -206,7 +206,6 @@ class Storage { 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()) { @@ -232,7 +231,7 @@ class Storage { return std::cref(m_index.value()); } - Expected save_index() const + Expected save_index() { if (!m_dirty) { return {}; diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 18c8b6bf..b3432ebf 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -225,8 +225,9 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl write_table(directory.path()); const auto final = directory.path() / "snapshot"; const auto partial = directory.path() / "snapshot.part"; - auto created = storage::create(final, small_options()); - REQUIRE(created); + 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 })); @@ -238,8 +239,9 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl REQUIRE(created->save({ 0, { 0, 0 } }, tile)); REQUIRE(created->save_index()); CHECK(io::read_bytes_from_path(partial / manifest::metadata_file_name).value() == metadata_before); - auto checkpoint = storage::open(partial, { .allow_incomplete = true }); - REQUIRE(checkpoint); + 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); @@ -248,11 +250,18 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl CHECK(metadata->height == 3); CHECK(metadata->payload_type == "float32"); CHECK(metadata->codec_selector == "amort"); - - REQUIRE(storage::publish(std::move(*created))); + REQUIRE(created_metadata); + CHECK(created_metadata->width == metadata->width); + CHECK(created_metadata->height == metadata->height); + 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 = storage::open(final); - REQUIRE(published); + 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); @@ -262,19 +271,24 @@ TEST_CASE("failed checkpoints retain the previous index and ignore unindexed fil { TemporaryDirectory directory; write_table(directory.path()); - auto created = storage::create(directory.path() / "snapshot", small_options()); - REQUIRE(created); + 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(3))); std::filesystem::create_directory(partial / "raster_store.index.tmp"); CHECK_FALSE(created->save_index()); - auto checkpoint = storage::open(partial, { .allow_incomplete = true }); - REQUIRE(checkpoint); + 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()); - CHECK(storage::open(partial, { .allow_incomplete = true })->index().size() == 1); + 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 creation and publication reject destination collisions", "[raster-store][storage]") @@ -282,13 +296,15 @@ TEST_CASE("raster creation and publication reject destination collisions", "[ras TemporaryDirectory directory; write_table(directory.path()); const auto final = directory.path() / "snapshot"; - auto created = storage::create(final, small_options()); - REQUIRE(created); + auto created_result = storage::create(final, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); SECTION("empty destination") { std::filesystem::create_directory(final); } SECTION("nonempty destination") { write_text(final / "keep", "keep"); } SECTION("dangling symlink") { std::filesystem::create_symlink(directory.path() / "missing", final); } - CHECK(storage::publish(std::move(*created)).error().code() == Error::Code::AlreadyExists); + CHECK(storage::publish(std::move(created)).error().code() == Error::Code::AlreadyExists); + CHECK_FALSE(created); CHECK(std::filesystem::exists(directory.path() / "snapshot.part")); CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); } @@ -298,13 +314,15 @@ TEST_CASE("raster publication does not scan payloads or reopen output", "[raster TemporaryDirectory directory; write_table(directory.path()); const auto final = directory.path() / "snapshot"; - auto created = storage::create(final, small_options()); - REQUIRE(created); + 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(3))); REQUIRE(std::filesystem::remove(created->path_for({ 0, { 0, 0 } }).value())); - REQUIRE(storage::publish(std::move(*created))); - auto opened = storage::open(final); - REQUIRE(opened); + 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); } @@ -313,10 +331,11 @@ TEST_CASE("raster reader selection follows metadata instead of file endings", "[ TemporaryDirectory directory; write_table(directory.path()); const auto final = directory.path() / "snapshot"; - auto created = storage::create(final, small_options()); - REQUIRE(created); + 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(3))); - REQUIRE(storage::publish(std::move(*created))); + REQUIRE(storage::publish(std::move(created))); auto metadata = manifest::read_metadata(final).value(); metadata.codec_selector = ".amort"; REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); @@ -334,20 +353,23 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" const auto rf = rf_root / "snapshot"; const auto tb = tb_root / "snapshot"; write_table(rf_root); - auto created = storage::create(rf, small_options()); - REQUIRE(created); + auto created_result = storage::create(rf, small_options()); + REQUIRE(created_result); + auto [created, created_metadata] = std::move(*created_result); raster_store::Tile tile(3); 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))); + REQUIRE(storage::publish(std::move(created))); { - auto source = storage::open(rf); - REQUIRE(source); + 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 = storage::create(tb, options); - REQUIRE(target); + 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->copy_from(key, *source)); CHECK(std::filesystem::equivalent(source->path_for(key).value(), target->path_for(key).value())); @@ -355,11 +377,12 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" CHECK_FALSE(std::filesystem::equivalent(rf_root / attribution::file_name, target->base_path() / attribution::file_name)); CHECK(io::read_bytes_from_path(rf_root / attribution::file_name).value() == io::read_bytes_from_path(target->base_path() / attribution::file_name).value()); - REQUIRE(storage::publish(std::move(*target))); + REQUIRE(storage::publish(std::move(target))); } std::filesystem::remove_all(rf_root); - auto target = storage::open(tb); - REQUIRE(target); + 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)); @@ -430,9 +453,10 @@ TEST_CASE("raster opening retains metadata and index errors without creating a d const auto final = directory.path() / "snapshot"; CHECK(storage::open(final).error().code() == Error::Code::NotFound); CHECK_FALSE(std::filesystem::exists(final)); - auto created = storage::create(final, small_options()); - REQUIRE(created); - REQUIRE(storage::publish(std::move(*created))); + 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); @@ -493,12 +517,14 @@ TEST_CASE("abandoned raster output checkpoints but never publishes", "[raster-st const auto final = directory.path() / "snapshot"; const auto partial = directory.path() / "snapshot.part"; { - auto created = storage::create(final, small_options()); - REQUIRE(created); + 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(3))); } CHECK_FALSE(std::filesystem::exists(final)); - auto abandoned = storage::open(partial, { .allow_incomplete = true }); - REQUIRE(abandoned); + 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 } })); } From e56945900556ce57da20ca0d55b757d941e6a407 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:15:20 +0200 Subject: [PATCH 16/83] Document raster scaling API and halo integration --- ...esentative-attribution-for-halo-samples.md | 10 + docs/raster-store/README.md | 1 + .../gdal-value-mapping-research.md | 121 ++++++ docs/raster-store/sampling-and-generation.md | 32 +- docs/raster-store/scaling.md | 381 ++++++++++++++++++ docs/raster-store/terminology.md | 39 ++ docs/raster-store/tiles-with-halo.md | 200 +++++++++ 7 files changed, 755 insertions(+), 29 deletions(-) create mode 100644 docs/adr/0004-representative-attribution-for-halo-samples.md create mode 100644 docs/raster-store/gdal-value-mapping-research.md create mode 100644 docs/raster-store/scaling.md create mode 100644 docs/raster-store/tiles-with-halo.md diff --git a/docs/adr/0004-representative-attribution-for-halo-samples.md b/docs/adr/0004-representative-attribution-for-halo-samples.md new file mode 100644 index 00000000..cfbb9aaa --- /dev/null +++ b/docs/adr/0004-representative-attribution-for-halo-samples.md @@ -0,0 +1,10 @@ +# Retain one representative attribution for halo samples + +Halo extraction retains one representative attribution per resampled pixel, +following the rules in [Raster scaling](../raster-store/scaling.md). +Attribution indices are categorical and are never averaged. + +This preserves the existing single-index attribution raster, including +persisted TB tiles, at the cost of discarding other contributing source IDs. +The retained index is representative attribution, not complete provenance. +A contributing-source set was considered and rejected. diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 8c1cfc68..7ae9626b 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -15,6 +15,7 @@ TB generation and the tile server remain future work. ## Plans +- [Tiles with halo: design in progress](tiles-with-halo.md) - [Tile-storage implementation plan](implementation-plan.md) - [Raster store TODO](todo.md) diff --git a/docs/raster-store/gdal-value-mapping-research.md b/docs/raster-store/gdal-value-mapping-research.md new file mode 100644 index 00000000..20f990f8 --- /dev/null +++ b/docs/raster-store/gdal-value-mapping-research.md @@ -0,0 +1,121 @@ +# GDAL value-mapping research + +Research date: 2026-09-13. This note informs the halo design; recommendations +below are not accepted implementation decisions. Source inspection targets +GDAL 3.10.3, matching the locally installed `gdalinfo` and project build. + +## Can GDAL identify linear versus sRGB samples? + +Sometimes, through optional format metadata. There is no general +`Linear`/`SRGB` answer in band colour interpretation: GDAL's enum identifies +roles such as red, green, blue, alpha and spectral bands, without a transfer +function. RGB bands therefore do not establish sRGB encoding. +[GDAL colour-interpretation enum](https://github.com/OSGeo/gdal/blob/v3.10.3/gcore/gdal.h) + +The dataset's `COLOR_PROFILE` metadata domain can provide evidence: + +| Driver | Available evidence | +| --- | --- | +| GeoTIFF | Base64 `SOURCE_ICC_PROFILE`, or primaries/white point and per-channel TIFF transfer-function tables. An ICC profile takes precedence over the other profile tags. | +| PNG | ICC profile and profile name, an explicit sRGB marker, or `PNG_GAMMA` and chromaticities. | +| JPEG | Base64 embedded ICC profile; EXIF metadata can also be exposed. | + +These are optional source descriptions, not a classification into our two +mapping values. GDAL also warns that profile metadata describes original raw +samples and may not apply after its automatic conversion to RGB. +[GeoTIFF driver](https://gdal.org/en/stable/drivers/raster/gtiff.html#color-profile-metadata), +[PNG driver](https://gdal.org/en/stable/drivers/raster/png.html#color-profile-metadata), +[JPEG driver](https://gdal.org/en/stable/drivers/raster/jpeg.html#color-profile-metadata) + +In GDAL 3.10.3, PNG's `LoadICCProfile()` first reads an ICC profile; otherwise +an sRGB chunk produces `SOURCE_ICC_PROFILE_NAME=sRGB`; otherwise it exposes +gamma/chromaticity metadata. If an ICC payload exists, its arbitrary profile +name alone is insufficient evidence. The application would need to interpret +the profile itself. +[PNG implementation](https://github.com/OSGeo/gdal/blob/v3.10.3/frmts/png/pngdataset.cpp) + +PNG can describe linear samples: a gamma of 1 represents a linear transfer. +Other gamma values and ICC profiles need not be sRGB. In particular, an +approximate gamma of 0.45455 alone does not establish the exact piecewise sRGB +transfer function; the sRGB chunk specifies that separately. +[PNG gamma and sRGB specification](https://www.w3.org/TR/png-3/#11gAMA) + +JPEG/YCbCr decoding and sRGB linearization are separate operations. GDAL's +JPEG driver describes conversion from YCbCr/CMYK to RGB and reports the source +model through `IMAGE_STRUCTURE:SOURCE_COLOR_SPACE`. This does not promise +linear-light output. Compression and channel roles are therefore insufficient +evidence for selecting `ValueMapping`. +[JPEG reading behaviour](https://gdal.org/en/stable/drivers/raster/jpeg.html) + +## VRT mosaics + +VRT can store dataset metadata in named domains. That capability does not +mean a mosaic automatically inherits or reconciles source profiles. +[VRT format](https://gdal.org/en/stable/drivers/raster/vrt.html) + +In GDAL 3.10.3, `gdalbuildvrt` checks band colour interpretation but does not +copy or compare `COLOR_PROFILE`. `VRTDataset::GetMetadata()` returns the +VRT dataset's own metadata, apart from its special XML domain; it does not +aggregate underlying source colour profiles. Consequently, a VRT containing +RGB sources with different transfer functions cannot be assumed homogeneous +merely because its creation succeeded. This conclusion follows from the +versioned implementations, rather than a guarantee about every VRT-producing +tool. +[VRT builder](https://github.com/OSGeo/gdal/blob/v3.10.3/apps/gdalbuildvrt_lib.cpp), +[VRT metadata implementation](https://github.com/OSGeo/gdal/blob/v3.10.3/frmts/vrt/vrtdataset.cpp) + +## Swissimage evidence + +Swisstopo documents SWISSIMAGE download imagery as COG, three 8-bit RGB bands, +with JPEG95 compression. That product page does not specify sRGB or a +transfer function. +[Official SWISSIMAGE product specification](https://www.swisstopo.admin.ch/en/orthoimage-swissimage-10) + +The existing RF design's +[reference TIFF](https://data.geo.admin.ch/ch.swisstopo.swissimage-dop10/swissimage-dop10_2024_2682-1199/swissimage-dop10_2024_2682-1199_0.1_2056.tif) +was inspected again on the research date with GDAL 3.10.3 using +`gdalinfo -json -mdd all` on its `/vsicurl/` URL. Observed results: + +- 10000 by 10000 pixels; bands identified as Red, Green and Blue. +- `LAYOUT=COG`, `SOURCE_COLOR_SPACE=YCbCr`, + `COMPRESSION=YCbCr JPEG`, `JPEG_QUALITY=95`. +- No `COLOR_PROFILE` domain or explicit transfer-function metadata reported. + +This specific file therefore supplies no GDAL profile evidence that proves +sRGB. Its absence does not prove Linear either. The observation applies to +this reference file, not every swisstopo product or delivery. + +## Selected policy — 2026-09-15 + +The user selected a type-based convention: default three- and four-component +unsigned 8-bit pixels to `ValueMapping::SRGBA`, otherwise `Linear`, with an +explicit override. RGB channels use the sRGB transfer function; alpha, if +present, remains linear. Persist the resolved mapping in store metadata and +document the defaults and override in RF CLI help. See +[Tiles with halo](tiles-with-halo.md) for the accepted contract. + +This replaces the earlier recommendation below; it does not change the +findings about what GDAL metadata can establish. + +## Earlier recommendation — superseded + +Allow an explicit RF input mapping for the prepared dataset, including a VRT. +Persist the resolved `ValueMapping::{Linear, SRGB}` in the raster-store +manifest. Keep unknown/automatic selection at the import boundary if needed; +it need not become a third stored mapping value. + +Use trustworthy metadata when available. Treat the agreed JPEG/PNG imagery +default as a policy assumption, not format-based proof, and allow a declared +linear input to override it. Do not silently infer Linear for an untagged TIFF +or VRT. An explicit SRGB choice is a practical way to import the intended +Swissimage VRT, but this research has not established its transfer function. + +A prepared mosaic should have one agreed mapping across its sources. Mixed +mappings require normalization before treating it as one dataset. Arbitrary +ICC/gamma encodings cannot all be represented by the proposed two-value enum; +converting those inputs to a supported mapping is a separate scope decision. + +Reading profile metadata does not itself linearize samples for subsequent +filtering. If linear-light filtering is also required during RF reprojection, +that is an additional design question beyond using the mapping during halo +downsampling. diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index 2f6feecc..d258c1c8 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -152,34 +152,6 @@ resolves: 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: @@ -240,7 +212,9 @@ Before production filtering is implemented, synthetic fixtures should prove: 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. +6. A source boundary blends valid payloads and reports representative + attribution according to the [scaling contract](scaling.md): local 2x2 + selection for reduction and nearest-neighbour attribution for upscaling. 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. diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md new file mode 100644 index 00000000..3a6cd5f1 --- /dev/null +++ b/docs/raster-store/scaling.md @@ -0,0 +1,381 @@ +# Raster scaling + +Status: behavioral design agreed on 2026-09-18, with the unified zoom-level +API convention agreed on 2026-09-19. All design interview questions are +resolved. The implementation plan below is proposed for +approval; implementation has not been authorized. The sampling document, +halo design, and representative attribution ADR have been aligned with the +accepted decisions. + +## Scope + +Implement scaling in `terrainlib/raster/algorithm.h`, operating on data and +attribution rasters together. Only power-of-two scale factors are supported. +Input and output use area-pixel placement; vertex-pixel generation and +area/vertex conversion are outside this facility. + +The caller supplies a raster with halo and an explicit halo width. Return +data and attribution rasters with the halo removed. World wrapping, dataset +boundary handling, source selection, and ancestor fallback belong to the +caller or halo extractor. The scaler does not independently clamp internal +tile edges. + +See [Sampling and pyramid generation](sampling-and-generation.md) for the +broader generator requirements, including vertex outputs outside this scope. + +## Interface and geometry + +Accept matching rectangular data and attribution rasters, with one halo width +on all sides. Scale both axes by the same factor. Expose a unified +`raster::algorithm::scale` entry point. Keep directional `upscale` and +`downscale` helpers in `raster::algorithm::detail`. + +Express scaling amounts as `n_zoom_levels`, the exponent of two, rather than +as the factor itself. The public `scale` and `required_halo` functions use +a signed count: positive means upscaling, negative means downscaling, and +zero means cropping only. For example, `+2` upscales by four and `-2` +downscales by four. `required_halo` returns zero for zero levels. + +Directional helpers and custom reduction use nonnegative exponent counts: +zero means factor one and two means factor four in the respective direction. + +Accept both input rasters by const reference. Return +`Expected, radix::Raster>>`, +with data first and attribution second; do not introduce a custom result +struct. The implementation plan will present exact function declarations. + +For downscaling, both interior dimensions must be divisible by the factor. +Factor one simply crops the halo from both rasters. Positive dimensions, +matching raster sizes, halo bounds, representable zoom-level counts, and +dimension overflow need validation. Invalid geometry, zoom-level counts, +and insufficient halos return +errors through `Expected`; concrete error codes follow project conventions. + +With input interior pixel centres indexed by integers, direct upscaling by +factor `F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input +coordinates. A reduction by two evaluates pixel `j` at `2j + 0.5`. +Apply these positions independently on each axis, relative to the interior; +the halo offsets buffer addressing rather than changing the sampling phase. + +## Algorithms + +Upscaling offers nearest-neighbour and bilinear interpolation. Cubic and +Lanczos upscaling have been removed from the proposed scope. Algorithm +selection is caller policy; this facility supplies the implementations. +Upscale directly from the input to the requested final resolution rather than +repeating interpolation by two. + +Downscaling offers box filtering and Lanczos with lobe count/support radius +2, 3, or 4. Lanczos support is widened for the reduction ratio to low-pass +before decimation. Implement reduction by two and repeat it for larger +power-of-two reductions. + +For Lanczos radius `a`, the one-dimensional reduction kernel is proportional +to `sinc(t / 2) * sinc(t / (2a))` for `abs(t) < 2a` and zero otherwise, where +`sinc(x) = sin(pi*x) / (pi*x)` and `sinc(0) = 1`. Two-dimensional weights +are the products of the axis weights. Normalize over valid contributions +as specified below; a constant kernel multiplier cancels in normalization. + +Custom reduction also operates on a 2x2 block at each reduction step. Expose +templated interfaces accepting lambdas and functors, without requiring a +function pointer or type-erased callable. Provide min, max, and median +functors. A reducer receives only valid samples, already decoded to linear +values; the scaler encodes its result as needed. It is never called with an +empty sample range. + +The callable accepts `std::span` containing one to four +valid decoded samples in row-major order, with invalid samples omitted, and +returns one `WorkingPixel`. Its component type follows the working-type +table below; vector length is preserved. The supplied min, max, and median +functors operate component-wise. For an even number of samples, median is +the average of the two middle component values. A NaN in a component +propagates to that result component in these supplied functors. Custom +functors control their own treatment of attributed NaNs. + +## Validity and representative attribution + +Attribution zero is the sole NoData marker. A nonzero attribution remains +valid regardless of its payload, including a nonfinite floating-point value. +Exclude NoData samples from interpolation, filtering, and custom reduction. +Their payload values must not contribute to arithmetic or normalization. + +For every upscaling mode, sample attribution with nearest-neighbour sampling. +Do not vote among neighbouring attribution IDs during upscaling. When data +also uses nearest-neighbour sampling, copy the selected input pixel's payload +and attribution exactly, including NoData, without a colour-conversion round +trip. For bilinear interpolation, if the nearest pixel's attribution is zero, +copy that pixel's payload and skip interpolation, even if other neighbours +are valid. Otherwise interpolate using only nonzero-attribution neighbours +and normalize over their weights. Never mix NoData payloads into a valid +output value. + +For reduction by two, the local block is the 2x2 input block covered by the +output cell. Select attribution using this local block, independently of the +larger Lanczos support: + +1. Ignore attribution zero. +2. Prefer a nonzero ID occurring at least twice in the local block. +3. Otherwise choose a nonzero ID occurring at least once. +4. Resolve multiple eligible IDs deterministically; the particular tie order + is not prescribed. + +If this reduction block contains no valid pixel, return attribution zero and copy +the payload of one pixel from that block, even if valid samples exist farther +out in the filter support. The payload is unspecified to callers when +attribution is zero, but remains initialized; no C++ undefined behavior is +intended. + +Otherwise, reduction filters use valid samples from their filter support and +normalize by the sum of their weights. If that denominator is zero or +numerically unstable, copy a valid pixel from the local block. Attribution +still follows the local voting rule. The numerical stability threshold is +not yet fixed. + +Keep one representative attribution per output pixel. Complete provenance +and contributing-source sets are not required. Apply attribution selection +again at every reduction step; do not retain original-source weight totals. + +## Value mapping and numeric behavior + +Support the existing arithmetic scalar and GLM-vector pixel types, returning +the same stored pixel type. Numeric filtering is component-wise. Normal +normalization and other layer-specific postprocessing belong to callers. + +Accept an explicit value mapping. Linear mapping applies to scalar and vector +data, including elevations. Initially support sRGB mapping only for +three- and four-component unsigned 8-bit pixels. Decode RGB to linear light +before filtering or custom reduction, and encode the resulting RGB values +afterwards. For RGBA, alpha is linear and filtered independently; alpha does +not weight RGB or determine NoData validity. + +Choose the working component type from the stored component type: + +| Stored component type | Working component type | +|---|---| +| 32-bit `float` | `float` | +| Signed or unsigned integers up to 16 bits | `float` | +| `double` | `double` | +| Signed or unsigned 32-bit integers | `double` | +| Signed or unsigned 64-bit integers | `long double` | + +Apply the same mapping to GLM-vector components. For example, +`glm::vec<4, uint16_t>` uses four `float` working components. This mapping +also determines the decoded samples and results used by custom functors. + +Round integer results, with half-way cases rounded away from zero, and clamp +them to their representable range before conversion. Preserve floating-point +overshoot, NaN, and infinity results. Return an error if a custom reducer +produces a nonfinite result destined for integer storage. + +Filtering uses the specified working type, followed by rounding and +clamping as applicable; results are subject to that type's floating-point +precision. This also applies to weighted 64-bit integer results. Assess +weight cancellation relative to the sum of absolute weights. Choose and +verify the cancellation threshold and numerical test tolerances during +implementation. + +Each reduction step stores its result in the output pixel type, including +rounding and sRGB re-encoding. A reduction by four therefore follows the +same stage semantics as two explicit reductions by two, with enough halo +available for the second step. This includes custom reduction and attribution. + +Repeated validity-aware reduction is deliberately not a direct reduction +over all original valid samples. For example, one 2x2 block containing one +valid zero and another containing four valid values of 100, with the other +blocks empty, produces 50 after two box reductions rather than the direct +five-sample average of 80. Repeated median likewise need not equal the median +of the original larger block. + +## Halo contract + +Reject an insufficient input halo. Determine the requirement from the +complete operation, including every step of repeated reduction, rather than +only the final kernel. Provide a query for the required input halo. + +Retain enough intermediate halo for subsequent reductions and crop only the +final result to the requested interior. Internal tile edges must not change +filter support or results. + +The area-grid geometry and compact kernel support imply these minimum input +halo widths, in input pixels on each side, including corners: + +| Operation | Required halo | +|---|---| +| Factor one (crop only) | 0 | +| Nearest-neighbour upscaling | 0 | +| Bilinear upscaling, factor greater than one | 1 | +| Box or custom 2x2 reduction, including repeated steps | 0 | +| Lanczos reduction by total factor `F`, fixed radius `a` | `(2a - 1) * (F - 1)` | + +For one Lanczos step, the first output centre is `0.5` and its contributing +input indices range from `1 - 2a` through `2a`, giving a halo of `2a - 1`. +If that step must retain an output halo `h`, its input halo must be +`2h + (2a - 1)`. Repeating this relation gives the total-factor formula. +For example, Lanczos-3 needs 5 input halo pixels for reduction by two and +15 for reduction by four. The required-halo query must check arithmetic +overflow as well as the operation's parameters. The public query takes +`n_zoom_levels`; `F` in the formulas above is the derived power-of-two +factor. Validate the exponent before constructing that factor, including +handling the signed count without overflowing when obtaining its magnitude. + +There is no `required_reduction_halo` function: custom block reduction +always requires zero halo. Its entry point still validates the zoom-level +count and dimensions. + +## Relationship to halo extraction + +The [halo extractor](tiles-with-halo.md) uses this facility with box +downscaling only. It has no downscaling-algorithm parameter. The upscaling +interpolation algorithm remains an extraction parameter. Scaling behavior +is defined here rather than repeated in the halo design. + +World wrapping and other boundary behavior remain the halo extractor's +responsibility. Its central-tile payload replication for uncovered or invalid +halo pixels is a separate caller policy that must not be silently replaced +by the scaler's local fallback. The +[attribution ADR](../adr/0004-representative-attribution-for-halo-samples.md) +records why one representative source ID is retained rather than complete +provenance, referring here for the selection rules. + +## Proposed implementation plan + +This plan is awaiting approval. Implement the scaler and its tests in the +current terrain-builder worktree. Halo extraction and its storage metadata +remain a separate implementation task; their documentation already describes +use of this facility. + +### Public API proposal + +Place the API in `src/terrainlib/raster/algorithm.h`, in namespace +`raster::algorithm`. The following declarations are proposed. The public +`scale` and `required_halo` use the agreed signed direction convention; +directional helpers and custom reduction use unsigned exponent counts. + +```cpp +namespace raster::algorithm { + +enum class Interpolation { NearestNeighbour, Bilinear }; +enum class Filter { Box, Lanczos2, Lanczos3, Lanczos4 }; +enum class ValueMapping { Linear, SRGBA }; + +template +Expected, radix::Raster>> +scale(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, int n_zoom_levels, + Interpolation interpolation, Filter filter, ValueMapping value_mapping); + +template +Expected, radix::Raster>> +reduce(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, unsigned n_zoom_levels, + ValueMapping value_mapping, Reducer&& reducer); + +Expected required_halo( + int n_zoom_levels, Interpolation interpolation, Filter filter); + +namespace detail { + +template +Expected, radix::Raster>> +upscale(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, unsigned n_zoom_levels, + Interpolation interpolation, ValueMapping value_mapping); + +template +Expected, radix::Raster>> +downscale(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, unsigned n_zoom_levels, + Filter filter, ValueMapping value_mapping); + +Expected required_upscaling_halo( + unsigned n_zoom_levels, Interpolation interpolation); +Expected required_downscaling_halo( + unsigned n_zoom_levels, Filter filter); + +} // namespace detail +} // namespace raster::algorithm +``` + +The filter enum exposes all three Lanczos radius choices without a separate +parameter that would be meaningless for box filtering. The unified functions +receive interpolation and filter selections and use the one corresponding +to the requested direction. Directional halo calculations remain detail +helpers. Custom block reduction has no halo-query function. + +Expose `WorkingPixel` as a type alias implementing the agreed +component mapping, and `Min`, `Max`, and `Median` as stateless functors with +templated call operators accepting the agreed spans. Constrain the custom +reduction overload to a callable with that input and working-pixel result. +Reuse the supplied functor through the repeated stages without type erasure. + +Keep the proposed scaler `ValueMapping` independent of snapshot metadata. +The planned metadata enum does not exist in code yet; the future halo +extractor can explicitly map its persisted selection to the scaler's enum. +This implementation does not require changing the manifest schema. + +### Implementation sequence + +1. Add the public declarations, working-pixel traits, parameter validation, + the unified dispatcher and required-halo query, zero-level cropping, and + safe numeric conversions. +2. Implement direct upscaling and a shared 2x reduction path for built-in + filters and templated reducers. Keep validity and attribution handling + explicit, precompute reusable filter coefficients, and retain required + intermediate halos. Normalize valid two-dimensional contributions rather + than independently normalizing rows and columns around NoData holes. +3. Add the supplied reduction functors and focused Catch2 coverage in + `unittests/terrainlib/raster_algorithm.cpp`. Register the new header and + tests in the existing terrainlib CMake targets. +4. Build the existing Debug terrainlib test target, run the focused raster + cases and the terrainlib regression suite, and inspect the numerical + fixtures before fixing tolerances. Use the Qt C++ skill for linting and + `clang-format-21` on the new C++ portions. Check whitespace and Git-attribute + line endings before delivery. + +Use `InvalidInput` for invalid geometry, zoom-level counts, enum selections, +insufficient halos, and nonfinite custom results destined for integers. Use `Unsupported` +for a value mapping incompatible with the pixel format, and +`ResourceExhausted` for unrepresentable output/allocation sizes. Validate +counts before constructing raster buffers and use `Error::fail` and +`Error::propagate` according to the project style. + +Tests should compare against independent reference calculations and exercise +the behavioral cases below. Include scalar and vector type-mapping checks, +integer boundary conversions, an actual lambda and stateful functor, +different halo widths, and multi-stage tile/metatile equivalence. Numeric +fixtures should measure constant preservation and high-frequency attenuation +with tolerances appropriate to the selected working type. + +## Verification to cover + +The implementation plan should include: + +- Constant preservation, impulse/frequency response for Lanczos reduction, + and area-tile/metatile equivalence with sufficient halos. +- Invariance under different input chunk partitions and absence of internal + edge clamping. +- Local reduction attribution voting, deterministic ties, all-invalid local + blocks with valid outer support, and unstable weighted normalization. +- Nearest-neighbour attribution for every upscaling mode, exact data copies + for nearest-neighbour interpolation, and direct multi-factor upscaling. +- Bilinear NoData exclusion and nearest-NoData payload copying even when + other bilinear neighbours contain valid data. +- Excluding NoData from custom functor input and bypassing empty reductions. +- Row-major functor samples, working component types, component-wise + min/max/median, even-count median, and supplied-functor NaN propagation. +- Repeated reduction semantics for data, attribution, rounding, and sRGB. +- Linear-light RGB filtering and independent linear alpha filtering. +- Integer limits, rounding, floating-point overshoot, and attributed + nonfinite values under the final numerical contract. +- Exact sufficient halo, insufficient halo errors, and final halo cropping. +- Rectangular inputs, factor-one cropping, divisibility checks, and invalid + zoom-level counts, dimensions, and overflow under the final API. +- Unified dispatch and halo queries, exponent-to-factor conversion, and + rejection of extreme counts before signed negation or shifting can overflow. + +Vertex shared-edge tests, physical-source selection, world wrapping, and +input tile-scheme normalization remain broader generator/extractor tests. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 6d5d8719..77592087 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -1,4 +1,43 @@ # Terminology + +The planned extraction and storage contract is in +[Tiles with halo](tiles-with-halo.md). + +**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. + +**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 diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md new file mode 100644 index 00000000..a2138c78 --- /dev/null +++ b/docs/raster-store/tiles-with-halo.md @@ -0,0 +1,200 @@ +# Tiles with halo + +Status: design interview in progress. Decisions were recorded during +2026-09-13 through 2026-09-15, with scaling integration revised on 2026-09-18; +implementation has not been authorized. This design revises version 1 +directly; no existing snapshots require compatibility. + +## Accepted contract + +Use halo and `halo_width`, replacing the proposed border terminology. +Return the existing `Tile`, containing data and attribution rasters. +Keep `radix::Raster` and `raster_store::Tile` unchanged. Do not introduce a +separate `TileWithHalo` type or add halo width to `Tile`: +the caller knows the requested width, and metadata supplies the stored width. +The extraction result is in memory; the storage format must also gain support +for persisted halos in TB snapshots. + +Let `N` be the interior side, `H` the stored halo width, and `h` the requested +halo width. The interior is square and has a power-of-two side of at least +64 pixels; assert the minimum-size precondition. Both stored +rasters have side `N + 2H`; both result rasters have side `N + 2h`. A halo +extends the sampling region beyond the tile ID's interior geographic extent. +The requested width satisfies `0 <= h <= N`, including the corners. Reject +larger requests and dimension overflow. + +RF snapshots always have `H = 0`. TB snapshots may have `H > 0`, with +`0 <= H <= N`. RF builders enforce zero stored halo width; generic raster +storage does not need an RF/TB discriminator for this restriction. + +### Snapshot metadata and creation + +The metadata here is `raster_store::io::manifest::Metadata`, persisted in +`raster_store.metadata`, not members of the raster or tile objects. +Replace metadata `width` and `height` with `stored_tile_size`, +`nominal_tile_size`, and `halo_width`, where +`stored_tile_size = nominal_tile_size + 2 * halo_width`. +Nominal tile size defaults to 4096 pixels; halo width defaults to zero. +Both rasters remain square. + +Revise the existing version-1 representation directly. No metadata upgrade +or legacy-snapshot compatibility path is required. + +Define `ValueMapping` with values `Linear` and `SRGBA` in +`raster_store::io::manifest::detail::v1`, export it into the `manifest` +namespace, and use it as `value_mapping` in metadata and +`storage::CreateOptions`. This is a +value mapping rather than a colour-space label: linear data also includes +elevation. `SRGBA` applies to RGB as well as RGBA: RGB channels use the sRGB +transfer function; alpha, when present, is linear. + +Default three- and four-component unsigned 8-bit pixel types to `SRGBA`; +default other pixel types to `Linear`. Allow an explicit mapping override, +which takes precedence over the type-based default. Persist the resolved +mapping in metadata so readers do not infer it again. This convention +supersedes file-format-based selection and mandatory declarations for +untagged TIFF/VRT imagery. + +Add an RF CLI override, `--value-mapping linear|srgba`. CLI help must explain +the type-based defaults, the explicit override, and that SRGBA leaves alpha +linear and also applies to three-channel RGB. This is an implementation +requirement; the CLI has not been changed during the interview. + +See [GDAL value-mapping research](gdal-value-mapping-research.md) for the +metadata available from GDAL and the inspected Swissimage reference TIFF. + +Keep the `storage::CreateOptions` struct and replace its `tile_dimensions` +member with `unsigned nominal_tile_size = 4096`. Add `halo_width = 0` to +creation options as well. Creation consumes these options to derive +`stored_tile_size` and write metadata, checking for dimension overflow. +This supersedes the earlier decision to specify stored size in the options. + +### Extraction API + +Use `read_tile_with_halo(storage, metadata, tile_id, halo_width, +interpolation_algorithm)`, returning `Expected>`. +Downscaling always uses box filtering; there is no downscaling-algorithm +parameter. Upscaling interpolation is caller-supplied and its parameter type +follows the scaling API. Pass metadata explicitly as +`const io::manifest::Metadata&`, allowing callers to read it once and reuse +it across extractions. No metadata member is added to `Raster` or `Tile`. + +Perform resolution changes through `terrainlib/raster/algorithm.h`, using +these selections. The [scaling contract](scaling.md) owns algorithm +behavior, attribution selection, value mapping, numeric conversion, and +required source halos; do not duplicate those rules here. The extractor +provides the source windows and required halos for the selected operation. + +Validate the requested tile ID and require a physical tile. Both `Leaf` and +`Inner` qualify; `Virtual` and missing tiles do not. Reading or decoding an +indexed payload can fail; propagate those errors rather than treating them +as missing coverage. + +### Stores without a stored halo + +For `H = 0`: + +1. Allocate the two result rasters with side `N + 2h`. +2. Copy the central tile's data and attribution into the interior exactly, + including payload values under NoData pixels. Do not resample the centre. +3. Fill the halo from spatial neighbours, including diagonal neighbours. + Prefer a physical tile at the requested zoom. +4. If that tile is not physical, descend into intersecting child regions, + stopping at the first physical tile on each branch. Downscale finer data + to the requested pixel spacing through the scaling facility. Limit descent + to four levels. If further + descent would be required, use the nearest available physical ancestor. +5. For regions without a physical descendant, use the nearest physical + ancestor and upscale its corresponding region through the scaling facility. + Descendant coverage in one region does not suppress fallback in another. + +Fetch each supplying ancestor at most once per extraction. Ancestor samples +must not overwrite regions supplied by selected same-zoom or descendant +tiles. This protection includes selected NoData samples: NoData is not an +invitation to fill the region from another level. + +Resolve the source regions before reading their payloads. Partition the +halo into disjoint regions assigned to source tile IDs using the accepted +selection and depth-limit rules. Group those regions by ID, load each source +once, and fill only its assigned regions. This avoids ancestor overwrites +and the need to retain all decoded ancestors simultaneously. Regions without +a selected physical source use central-tile replication as described below. + +With a minimum nominal side of 64 and at most four levels of descent, +descendant tile boundaries align with requested output-pixel boundaries: +even a tile four levels finer spans at least four output pixels per side. +Consequently an output cell's geometric footprint need not combine different +selected tile resolutions. The scaling facility determines any additional +source samples required by the selected operation. + +Selection is based on physical coverage, not pixel validity. NoData holes +inside a selected physical tile do not trigger a search at other levels. +Attribution zero is the sole NoData marker, regardless of the payload value. +Resampled validity and representative attribution follow the scaling +contract. Complete provenance is not required. + +If a generated halo pixel has attribution zero, retain that attribution and copy +the data value of the closest pixel in the central tile. In coordinates +relative to the interior, this is one constant-time lookup at +`(clamp(x, 0, N-1), clamp(y, 0, N-1))`. Do not search for a valid replacement: +the chosen central pixel may itself be NoData. Do not copy its attribution +into the halo pixel. + +Wrap horizontally at the antimeridian. Do not wrap vertically. Beyond the +vertical world limits, and wherever no neighbour representation supplies a +valid contribution, use the same central-tile replication with attribution +zero. The central interior remains unchanged. + +Pass the metadata's value mapping to the scaling facility. Native copies +remain bit-preserving. Rescaling uses the facility's numeric and validity +contract; no separate radix rescaling refactor is required for extraction. + +### Stores with a stored halo + +For `H > 0`, fail if `h > H`. Otherwise crop both stored rasters to the +requested halo width, removing `H-h` pixels on each side. Preserve the +retained payload and attribution exactly, including NoData and stored halo +values. Do not fetch neighbours or repair the stored halo. A request for +zero halo width returns the interior; requesting the full stored width +returns all stored pixels. + +## Relationship to existing documents + +The previously documented allowance for arbitrary positive square dimensions +was intended to accommodate halo-inclusive rasters. The revised model makes +the interior power-of-two and expresses halo width separately. Existing +version-1 snapshots do not need preserving. + +The `Mixed sources` section has been removed from +`sampling-and-generation.md` as requested. Halo samples use representative +attribution. Further alignment of the existing storage/sampling documents +with the final halo contract belongs to the implementation plan. + +## Open decisions + +- Remaining scaling details are tracked in [Raster scaling](scaling.md). +- Cache reuse and validation/error details. +- Implementation sequence and approval. + +## Required verification + +- Invalid, missing, and virtual centre IDs; physical leaf and inner centres. +- Bit-exact central copies, including NoData payloads, and zero-width results. +- Same-zoom edges/corners, first-physical descendant selection, four-level + descent limit with ancestor fallback, and partial coverage. +- At-most-once ancestor fetches and preservation of selected finer samples, + including NoData regions, when ancestor fallback is also used. +- Validity-aware averaging, representative attribution, all-invalid support, + and replication from central pixels that may themselves be NoData. +- Horizontal wrapping, vertical limits, and uncovered dataset regions. +- Using box downscaling and forwarding RF value mapping and the selected + interpolation parameter to the scaling facility, and exact native copies. +- Type-based mapping defaults, explicit overrides, persistence of the + resolved mapping, linear alpha, and CLI help documenting the policy. +- Stored-halo cropping at zero, partial, and full width; rejection of requests + exceeding the stored halo; preservation of stored invalid samples. +- Power-of-two interiors, the minimum side of 64, halo-width bounds, and + dimension overflow. +- Revised version-1 metadata and payload round trips. +- Integration with the scaling facility, including sufficient source halos, + multi-level reductions, and the extractor's invalid-pixel replication. From 2a879d4d8b81654e602486fa4501223813be6150 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:10:07 +0200 Subject: [PATCH 17/83] Implement validity-aware raster scaling algorithms --- docs/raster-store/scaling.md | 58 +- src/terrainlib/CMakeLists.txt | 2 + src/terrainlib/raster/algorithm.h | 634 ++++++++++++++++++++++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/raster_algorithm.cpp | 581 ++++++++++++++++++++ 5 files changed, 1256 insertions(+), 20 deletions(-) create mode 100644 src/terrainlib/raster/algorithm.h create mode 100644 unittests/terrainlib/raster_algorithm.cpp diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md index 3a6cd5f1..3ea2516a 100644 --- a/docs/raster-store/scaling.md +++ b/docs/raster-store/scaling.md @@ -1,11 +1,9 @@ # Raster scaling Status: behavioral design agreed on 2026-09-18, with the unified zoom-level -API convention agreed on 2026-09-19. All design interview questions are -resolved. The implementation plan below is proposed for -approval; implementation has not been authorized. The sampling document, -halo design, and representative attribution ADR have been aligned with the -accepted decisions. +API convention agreed on 2026-09-19. Implementation was authorized and +completed on 2026-09-19. The scaler and its tests are implemented; halo +extraction and its storage metadata remain separate work. ## Scope @@ -128,8 +126,10 @@ intended. Otherwise, reduction filters use valid samples from their filter support and normalize by the sum of their weights. If that denominator is zero or numerically unstable, copy a valid pixel from the local block. Attribution -still follows the local voting rule. The numerical stability threshold is -not yet fixed. +still follows the local voting rule. The implementation treats the denominator +as unstable when `abs(sum(weights)) <= 32 * epsilon * sum(abs(weights))`, +where `epsilon` belongs to the working component type. Negative stable sums +are permitted. Zero-weight samples do not contribute payload arithmetic. Keep one representative attribution per output pixel. Complete provenance and contributing-source sets are not required. Apply attribution selection @@ -170,9 +170,9 @@ produces a nonfinite result destined for integer storage. Filtering uses the specified working type, followed by rounding and clamping as applicable; results are subject to that type's floating-point precision. This also applies to weighted 64-bit integer results. Assess -weight cancellation relative to the sum of absolute weights. Choose and -verify the cancellation threshold and numerical test tolerances during -implementation. +weight cancellation relative to the sum of absolute weights using the +threshold above. Numerical tests use tolerances appropriate to the working +type and fixture, as recorded below. Each reduction step stores its result in the output pixel type, including rounding and sRGB re-encoding. A reduction by four therefore follows the @@ -237,17 +237,16 @@ by the scaler's local fallback. The records why one representative source ID is retained rather than complete provenance, referring here for the selection rules. -## Proposed implementation plan +## Implementation -This plan is awaiting approval. Implement the scaler and its tests in the -current terrain-builder worktree. Halo extraction and its storage metadata -remain a separate implementation task; their documentation already describes -use of this facility. +The scaler and tests were implemented in the terrain-builder worktree. +Halo extraction and its storage metadata remain a separate implementation +task; their documentation already describes use of this facility. -### Public API proposal +### Public API -Place the API in `src/terrainlib/raster/algorithm.h`, in namespace -`raster::algorithm`. The following declarations are proposed. The public +The API is in `src/terrainlib/raster/algorithm.h`, in namespace +`raster::algorithm`. The public `scale` and `required_halo` use the agreed signed direction convention; directional helpers and custom reduction use unsigned exponent counts. @@ -312,12 +311,12 @@ templated call operators accepting the agreed spans. Constrain the custom reduction overload to a callable with that input and working-pixel result. Reuse the supplied functor through the repeated stages without type erasure. -Keep the proposed scaler `ValueMapping` independent of snapshot metadata. +The scaler `ValueMapping` is independent of snapshot metadata. The planned metadata enum does not exist in code yet; the future halo extractor can explicitly map its persisted selection to the scaler's enum. This implementation does not require changing the manifest schema. -### Implementation sequence +### Implementation sequence followed 1. Add the public declarations, working-pixel traits, parameter validation, the unified dispatcher and required-halo query, zero-level cropping, and @@ -379,3 +378,22 @@ The implementation plan should include: Vertex shared-edge tests, physical-source selection, world wrapping, and input tile-scheme normalization remain broader generator/extractor tests. + +### Verification results — 2026-09-19 + +- Existing GCC Debug build: `unittests_terrainlib` builds successfully. +- Focused `[raster-algorithm]` suite: 34 cases, 904 assertions, all passing. +- Terrainlib regression suite, excluding the existing + `mesh::clip_on_bounds benchmark`: 519 cases, 25698 assertions, all passing. +- The independent double-precision Lanczos reference agrees with the float + implementation within `3e-6` for the validity-aware fixture. Constant-7 + Lanczos fixtures remain within `3e-5`; the high-frequency fixture at + 0.4 cycles per input pixel has output magnitude below 0.03 for every radius. +- Separately supplied reduction stages and tile/metatile results agree + exactly in the exercised fixtures. Endpoint, nonfinite, vector-precision, + sRGB, and invalid-input cases pass. +- Qt C++ deterministic lint reports no findings; all six review missions + report no confirmed defects. An optional future profiling target is the + repeated sRGB decoding of overlapping Lanczos samples. +- New C++ is formatted with `clang-format-21`; whitespace and Git-attribute + line-ending checks pass. diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 23a2c757..c821af7f 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -159,6 +159,8 @@ add_library(terrainlib range/RangeTo.h range/RangeToInclusive.h + raster/algorithm.h + raster_store/StoreTraits.h raster_store/Tile.h raster_store/attribution.h raster_store/attribution.cpp diff --git a/src/terrainlib/raster/algorithm.h b/src/terrainlib/raster/algorithm.h new file mode 100644 index 00000000..71563541 --- /dev/null +++ b/src/terrainlib/raster/algorithm.h @@ -0,0 +1,634 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "Error.h" + +namespace raster::algorithm { + +enum class Interpolation { + NearestNeighbour, + Bilinear, +}; +enum class Filter { + Box, + Lanczos2, + Lanczos3, + Lanczos4, +}; +enum class ValueMapping { + Linear, + SRGBA, +}; + +namespace detail { + + template + struct PixelTraits { + static_assert((std::is_integral_v && !std::same_as && sizeof(T) <= 8) + || std::same_as || std::same_as || std::same_as); + using Scalar = T; + using Working = std::conditional_t || (std::is_integral_v && sizeof(T) <= 2), + float, + std::conditional_t || sizeof(T) <= 4, double, long double>>; + 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; + using Working = glm::vec::Working, Q>; + 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)]; } + }; + +} // namespace detail + +/// Floating-point pixel representation passed to custom reducers. +template +using WorkingPixel = typename detail::PixelTraits::Working; + +namespace detail { + + template + using Rasters = std::pair, radix::Raster>; + + template + using WorkingScalar = typename PixelTraits>::Scalar; + + template + Expected validate_mapping(ValueMapping mapping) + { + static_assert(!std::same_as::Scalar, long double>, "long double is a working type, not a stored pixel type"); + switch (mapping) { + case ValueMapping::Linear: + return {}; + case ValueMapping::SRGBA: + if constexpr (std::same_as::Scalar, std::uint8_t> && (PixelTraits::components == 3 || PixelTraits::components == 4)) { + 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"); + } + + 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(Filter filter) + { + switch (filter) { + case Filter::Box: + return 0; + case Filter::Lanczos2: + return 2; + case Filter::Lanczos3: + return 3; + case Filter::Lanczos4: + return 4; + } + return Error::fail(Error::Code::InvalidInput, "invalid raster reduction filter"); + } + + inline Expected required_upscaling_halo(unsigned n_zoom_levels, Interpolation interpolation) + { + if (auto factor = scale_factor(n_zoom_levels); !factor) { + return Error::propagate(std::move(factor)); + } + switch (interpolation) { + case Interpolation::NearestNeighbour: + return 0; + case Interpolation::Bilinear: + return n_zoom_levels == 0 ? 0u : 1u; + } + return Error::fail(Error::Code::InvalidInput, "invalid raster interpolation"); + } + + inline Expected required_downscaling_halo(unsigned n_zoom_levels, Filter filter) + { + auto factor = scale_factor(n_zoom_levels); + if (!factor) { + return Error::propagate(std::move(factor)); + } + auto radius = filter_radius(filter); + if (!radius) { + return Error::propagate(std::move(radius)); + } + 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); + } + + template + Expected validate_input( + const radix::Raster& data, const radix::Raster& attribution, unsigned halo_width, unsigned required_width, ValueMapping mapping) + { + if (auto valid = validate_mapping(mapping); !valid) { + return Error::propagate(std::move(valid)); + } + const auto padding = std::uint64_t(halo_width) * 2; + if (data.size() != attribution.size() || data.width() <= padding || data.height() <= padding) { + return Error::fail(Error::Code::InvalidInput, "raster dimensions must match and contain a nonempty interior"); + } + if (halo_width < required_width) { + return Error::fail(Error::Code::InvalidInput, "insufficient raster halo for the requested scaling operation"); + } + return data.size() - glm::uvec2(static_cast(padding)); + } + + template + Expected> allocate(std::uint64_t width, std::uint64_t height) + { + const auto max_dimension = (std::numeric_limits::max)(); + if (width == 0 || height == 0 || width > max_dimension || height > max_dimension || width > (std::numeric_limits::max)() / height) { + return Error::fail(Error::Code::ResourceExhausted, "scaled raster dimensions exceed representable limits"); + } + const auto count = width * height; + const auto maximum_bytes = static_cast((std::numeric_limits::max)()); + if (count > maximum_bytes / sizeof(T) || count > maximum_bytes / sizeof(std::uint16_t)) { + return Error::fail(Error::Code::ResourceExhausted, "scaled raster buffers exceed allocation limits"); + } + const glm::uvec2 size(static_cast(width), static_cast(height)); + try { + return Rasters { radix::Raster(size), radix::Raster(size) }; + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "allocate scaled raster buffers"); + } + } + + template + Expected> crop(const radix::Raster& data, const radix::Raster& attribution, unsigned halo_width, glm::uvec2 interior) + { + auto output = allocate(interior.x, interior.y); + if (!output) { + return output; + } + for (unsigned y = 0; y < interior.y; ++y) { + const auto source = std::size_t(y + halo_width) * data.width() + halo_width; + const auto destination = std::size_t(y) * interior.x; + std::copy_n(data.buffer().begin() + source, interior.x, output->first.buffer().begin() + destination); + std::copy_n(attribution.buffer().begin() + source, interior.x, output->second.buffer().begin() + destination); + } + return output; + } + + template + WorkingPixel decode(const T& pixel, ValueMapping mapping) + { + using W = WorkingScalar; + WorkingPixel result {}; + for (unsigned i = 0; i < PixelTraits::components; ++i) { + W value = static_cast(PixelTraits::component(pixel, i)); + if (mapping == ValueMapping::SRGBA) { + value /= W(255); + if (i < 3) { + value = value <= W(0.04045) ? value / W(12.92) : std::pow((value + W(0.055)) / W(1.055), W(2.4)); + } + } + PixelTraits>::component(result, i) = value; + } + return result; + } + + template + Expected encode(const WorkingPixel& pixel, ValueMapping mapping) + { + using S = typename PixelTraits::Scalar; + using W = WorkingScalar; + T result {}; + for (unsigned i = 0; i < PixelTraits::components; ++i) { + W value = PixelTraits>::component(pixel, i); + if constexpr (std::is_integral_v) { + if (!std::isfinite(value)) { + return Error::fail(Error::Code::InvalidInput, "nonfinite raster result cannot be stored as an integer"); + } + if (mapping == ValueMapping::SRGBA) { + value = std::clamp(value, W(0), W(1)); + if (i < 3) { + value = value <= W(0.0031308) ? W(12.92) * value : W(1.055) * std::pow(value, W(1) / W(2.4)) - W(0.055); + } + value *= W(255); + } + value = std::round(value); + // Compare before conversion: a floating representation of an integer + // maximum can itself round up to the first unrepresentable integer. + if (value <= static_cast((std::numeric_limits::lowest)())) { + PixelTraits::component(result, i) = (std::numeric_limits::lowest)(); + } else if (value >= static_cast((std::numeric_limits::max)())) { + PixelTraits::component(result, i) = (std::numeric_limits::max)(); + } else { + PixelTraits::component(result, i) = static_cast(value); + } + } else { + PixelTraits::component(result, i) = value; + } + } + return result; + } + + enum class Reduction { + Minimum, + Maximum, + Median, + }; + + template + T reduce_components(std::span samples) + { + assert(!samples.empty() && samples.size() <= 4); + T result {}; + using S = typename PixelTraits::Scalar; + for (unsigned component = 0; component < PixelTraits::components; ++component) { + std::array values {}; + bool has_nan = false; + for (std::size_t i = 0; i < samples.size(); ++i) { + values[i] = PixelTraits::component(samples[i], component); + has_nan = has_nan || std::isnan(values[i]); + } + S value {}; + if (has_nan) { + value = std::numeric_limits::quiet_NaN(); + } else { + auto end = values.begin() + samples.size(); + 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); + value = values[samples.size() / 2]; + if (samples.size() % 2 == 0) { + const auto lower = values[samples.size() / 2 - 1]; + value = std::isfinite(lower) && std::isfinite(value) ? std::midpoint(lower, value) : (lower + value) / S(2); + } + } + } + PixelTraits::component(result, component) = value; + } + return result; + } + + struct LocalBlock { + std::array positions {}; + unsigned count = 0; + std::uint16_t attribution = 0; + }; + + inline LocalBlock local_block(const radix::Raster& attribution, glm::uvec2 top_left) + { + LocalBlock result; + std::array ids {}; + for (unsigned y = 0; y < 2; ++y) { + for (unsigned x = 0; x < 2; ++x) { + const auto position = top_left + glm::uvec2(x, y); + const auto id = attribution.pixel(position); + if (id != 0) { + ids[result.count] = id; + result.positions[result.count++] = position; + } + } + } + if (result.count != 0) { + result.attribution = ids[0]; + for (unsigned i = 0; i < result.count; ++i) { + if (std::count(ids.begin(), ids.begin() + result.count, ids[i]) >= 2) { + result.attribution = ids[i]; + break; + } + } + } + return result; + } + + template + struct Kernel { + using W = WorkingScalar; + std::array weights {}; + int first = 0; + unsigned count = 2; + + explicit Kernel(unsigned radius) + { + if (radius == 0) { + weights[0] = weights[1] = W(1); + return; + } + first = 1 - 2 * static_cast(radius); + count = 4 * radius; + const auto sinc = [](W x) { + const W angle = std::numbers::pi_v * x; + return x == W(0) ? W(1) : std::sin(angle) / angle; + }; + for (unsigned i = 0; i < count; ++i) { + const W offset = W(first + static_cast(i)) - W(0.5); + weights[i] = sinc(offset / W(2)) * sinc(offset / W(2 * radius)); + } + } + + Expected operator()(const radix::Raster& data, + const radix::Raster& attribution, + glm::uvec2 top_left, + const LocalBlock& local, + ValueMapping mapping) const + { + const auto origin_x = std::int64_t(top_left.x) + first; + const auto origin_y = std::int64_t(top_left.y) + first; + W sum = 0; + W absolute_sum = 0; + for (unsigned y = 0; y < count; ++y) { + for (unsigned x = 0; x < count; ++x) { + const glm::uvec2 position(static_cast(origin_x + x), static_cast(origin_y + y)); + if (attribution.pixel(position) == 0) { + continue; + } + const W weight = weights[x] * weights[y]; + sum += weight; + absolute_sum += std::abs(weight); + } + } + constexpr W cancellation_tolerance = W(32) * std::numeric_limits::epsilon(); + if (std::abs(sum) <= cancellation_tolerance * absolute_sum) { + return data.pixel(local.positions[0]); + } + WorkingPixel value {}; + for (unsigned y = 0; y < count; ++y) { + for (unsigned x = 0; x < count; ++x) { + const W weight = weights[x] * weights[y]; + const glm::uvec2 position(static_cast(origin_x + x), static_cast(origin_y + y)); + if (weight != W(0) && attribution.pixel(position) != 0) { + value += decode(data.pixel(position), mapping) * (weight / sum); + } + } + } + return encode(value, mapping); + } + }; + + // The callback supplies a single reduction step; this driver owns validity, + // attribution, intermediate storage, and the shrinking filter halos. + template + Expected> reduce_levels(const radix::Raster& data, + const radix::Raster& attribution, + unsigned halo_width, + unsigned n_zoom_levels, + unsigned base_halo, + ValueMapping mapping, + PixelReducer&& reducer) + { + auto factor = scale_factor(n_zoom_levels); + if (!factor) { + return Error::propagate(std::move(factor)); + } + const auto required = std::uint64_t(base_halo) * (*factor - 1); + if (required > (std::numeric_limits::max)()) { + return Error::fail(Error::Code::ResourceExhausted, "required raster halo exceeds representable dimensions"); + } + auto interior = validate_input(data, attribution, halo_width, static_cast(required), mapping); + if (!interior) { + return Error::propagate(std::move(interior)); + } + if (interior->x % *factor != 0 || interior->y % *factor != 0) { + return Error::fail(Error::Code::InvalidInput, "raster interior dimensions must be divisible by the reduction factor"); + } + if (n_zoom_levels == 0) { + return crop(data, attribution, halo_width, *interior); + } + const auto* current_data = &data; + const auto* current_attribution = &attribution; + Rasters intermediate; + for (unsigned remaining_factor = *factor / 2;; remaining_factor /= 2) { + *interior /= 2u; + const unsigned output_halo = base_halo * (remaining_factor - 1); + auto output = allocate(std::uint64_t(interior->x) + 2ull * output_halo, std::uint64_t(interior->y) + 2ull * output_halo); + if (!output) { + return output; + } + for (unsigned y = 0; y < output->first.height(); ++y) { + for (unsigned x = 0; x < output->first.width(); ++x) { + const glm::uvec2 top_left(static_cast(2 * (std::int64_t(x) - output_halo) + halo_width), + static_cast(2 * (std::int64_t(y) - output_halo) + halo_width)); + const auto local = local_block(*current_attribution, top_left); + output->second.pixel({ x, y }) = local.attribution; + if (local.count == 0) { + output->first.pixel({ x, y }) = current_data->pixel(top_left + glm::uvec2(1)); + } else { + auto pixel = reducer(*current_data, *current_attribution, top_left, local, mapping); + if (!pixel) { + return Error::propagate(std::move(pixel), "reduce raster pixel"); + } + output->first.pixel({ x, y }) = *pixel; + } + } + } + intermediate = std::move(*output); + if (remaining_factor == 1) { + return intermediate; + } + current_data = &intermediate.first; + current_attribution = &intermediate.second; + halo_width = output_halo; + } + } + + template + Expected> upscale(const radix::Raster& data, + const radix::Raster& attribution, + unsigned halo_width, + unsigned n_zoom_levels, + Interpolation interpolation, + ValueMapping mapping) + { + auto required = required_upscaling_halo(n_zoom_levels, interpolation); + if (!required) { + return Error::propagate(std::move(required)); + } + auto interior = validate_input(data, attribution, halo_width, *required, mapping); + if (!interior) { + return Error::propagate(std::move(interior)); + } + if (n_zoom_levels == 0) { + return crop(data, attribution, halo_width, *interior); + } + const unsigned factor = *scale_factor(n_zoom_levels); + auto output = allocate(std::uint64_t(interior->x) * factor, std::uint64_t(interior->y) * factor); + if (!output) { + return output; + } + using W = WorkingScalar; + for (unsigned y = 0; y < output->first.height(); ++y) { + for (unsigned x = 0; x < output->first.width(); ++x) { + const glm::uvec2 nearest(x / factor + halo_width, y / factor + halo_width); + const auto id = attribution.pixel(nearest); + output->second.pixel({ x, y }) = id; + if (interpolation == Interpolation::NearestNeighbour || id == 0) { + output->first.pixel({ x, y }) = data.pixel(nearest); + continue; + } + // Compute the phase separately from the integer index so large + // raster coordinates do not consume the working pixel's precision. + const double phase_x = (double(x % factor) + 0.5) / factor - 0.5; + const double phase_y = (double(y % factor) + 0.5) / factor - 0.5; + const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); + const W fraction_x = W(phase_x < 0 ? phase_x + 1 : phase_x); + const W fraction_y = W(phase_y < 0 ? phase_y + 1 : phase_y); + const std::array weights_x { W(1) - fraction_x, fraction_x }; + const std::array weights_y { W(1) - fraction_y, fraction_y }; + std::array weights {}; + W sum = 0; + for (unsigned dy = 0; dy < 2; ++dy) { + for (unsigned dx = 0; dx < 2; ++dx) { + if (attribution.pixel(origin + glm::uvec2(dx, dy)) != 0) { + weights[2 * dy + dx] = weights_x[dx] * weights_y[dy]; + sum += weights[2 * dy + dx]; + } + } + } + WorkingPixel value {}; + for (unsigned dy = 0; dy < 2; ++dy) { + for (unsigned dx = 0; dx < 2; ++dx) { + const W weight = weights[2 * dy + dx]; + if (weight != W(0)) { + value += decode(data.pixel(origin + glm::uvec2(dx, dy)), mapping) * (weight / sum); + } + } + } + auto pixel = encode(value, mapping); + if (!pixel) { + return Error::propagate(std::move(pixel), "interpolate raster pixel"); + } + output->first.pixel({ x, y }) = *pixel; + } + } + return output; + } + + template + Expected> downscale(const radix::Raster& data, + const radix::Raster& attribution, + unsigned halo_width, + unsigned n_zoom_levels, + Filter filter, + ValueMapping mapping) + { + auto radius = filter_radius(filter); + if (!radius) { + return Error::propagate(std::move(radius)); + } + return reduce_levels(data, attribution, halo_width, n_zoom_levels, *radius == 0 ? 0u : 2 * *radius - 1, mapping, Kernel(*radius)); + } + +} // namespace detail + +/// Component-wise reducers for the nonempty, at-most-four-sample working spans. +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); + } +}; + +/// Positive levels upscale, negative levels downscale, and zero only crops. +inline Expected required_halo(int n_zoom_levels, Interpolation interpolation, Filter filter) +{ + if (auto valid = detail::required_upscaling_halo(0, interpolation); !valid) { + return Error::propagate(std::move(valid)); + } + if (auto valid = detail::filter_radius(filter); !valid) { + return Error::propagate(std::move(valid)); + } + if (n_zoom_levels < 0) { + return detail::required_downscaling_halo(static_cast(-std::int64_t(n_zoom_levels)), filter); + } + return detail::required_upscaling_halo(static_cast(n_zoom_levels), interpolation); +} + +/// Returns data first and attribution second, with the complete halo removed. +template +Expected, radix::Raster>> scale(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, + int n_zoom_levels, + Interpolation interpolation, + Filter filter, + ValueMapping value_mapping) +{ + if (auto required = required_halo(n_zoom_levels, interpolation, filter); !required) { + return Error::propagate(std::move(required)); + } + if (n_zoom_levels < 0) { + return detail::downscale(data, source_attribution, halo_width, static_cast(-std::int64_t(n_zoom_levels)), filter, value_mapping); + } + return detail::upscale(data, source_attribution, halo_width, static_cast(n_zoom_levels), interpolation, value_mapping); +} + +/// Repeated 2x2 reduction. The same callable receives valid, linear samples at +/// every stage. Zero levels crop without invoking it; empty blocks bypass it. +template +requires std::invocable>> + && std::same_as>>, WorkingPixel> +Expected, radix::Raster>> reduce(const radix::Raster& data, + const radix::Raster& source_attribution, + unsigned halo_width, + unsigned n_zoom_levels, + ValueMapping value_mapping, + Reducer&& reducer) +{ + const auto apply_reducer = [&reducer](const radix::Raster& input, + const radix::Raster&, + glm::uvec2, + const detail::LocalBlock& local, + ValueMapping mapping) -> Expected { + std::array, 4> samples {}; + for (unsigned i = 0; i < local.count; ++i) { + samples[i] = detail::decode(input.pixel(local.positions[i]), mapping); + } + return detail::encode(std::invoke(reducer, std::span>(samples.data(), local.count)), mapping); + }; + return detail::reduce_levels(data, source_attribution, halo_width, n_zoom_levels, 0, value_mapping, apply_reducer); +} + +} // namespace raster::algorithm diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index f4101694..f98fe33f 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -66,6 +66,7 @@ add_executable(unittests_terrainlib terrainlib/quantize.cpp terrainlib/range.cpp terrainlib/range_utils.cpp + terrainlib/raster_algorithm.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp new file mode 100644 index 00000000..e847ee7e --- /dev/null +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -0,0 +1,581 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "raster/algorithm.h" + +namespace { + +namespace algorithm = raster::algorithm; +using algorithm::Filter; +using algorithm::Interpolation; +using algorithm::ValueMapping; +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, + Filter filter = Filter::Box, + Interpolation interpolation = Interpolation::NearestNeighbour, + ValueMapping mapping = ValueMapping::Linear) +{ + return algorithm::scale(data, attribution, halo, levels, interpolation, filter, 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& attribution, 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) { + if (attribution.pixel({ column, row }) == 0) { + continue; + } + 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 (const auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + for (const auto filter : { Filter::Box, Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) { + CHECK(algorithm::required_halo(0, interpolation, filter).value() == 0); + CHECK(algorithm::required_halo(4, interpolation, filter).value() == (interpolation == Interpolation::Bilinear ? 1 : 0)); + } + } + for (int levels = 1; levels <= 4; ++levels) { + CHECK(algorithm::required_halo(-levels, Interpolation::Bilinear, Filter::Box).value() == 0); + CHECK(algorithm::required_halo(-levels, Interpolation::Bilinear, Filter::Lanczos2).value() == 3u * ((1u << levels) - 1)); + CHECK(algorithm::required_halo(-levels, Interpolation::Bilinear, Filter::Lanczos3).value() == 5u * ((1u << levels) - 1)); + CHECK(algorithm::required_halo(-levels, Interpolation::Bilinear, Filter::Lanczos4).value() == 7u * ((1u << levels) - 1)); + } + CHECK_FALSE(algorithm::required_halo((std::numeric_limits::min)(), Interpolation::Bilinear, Filter::Box)); + CHECK_FALSE(algorithm::required_halo((std::numeric_limits::max)(), Interpolation::Bilinear, Filter::Box)); + CHECK_FALSE(algorithm::required_halo(0, static_cast(99), Filter::Box)); + CHECK_FALSE(algorithm::required_halo(0, Interpolation::Bilinear, static_cast(99))); + CHECK(algorithm::required_halo(-31, Interpolation::Bilinear, Filter::Lanczos4).error().code() == Error::Code::ResourceExhausted); +} + +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, Filter::Box, Interpolation::Bilinear)); + CHECK_FALSE(scale(data, attribution, -1, 0, Filter::Lanczos2)); + CHECK_FALSE(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, static_cast(99))); + CHECK(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); + CHECK(scale(data, attribution, 31).error().code() == Error::Code::ResourceExhausted); + CHECK_FALSE(algorithm::reduce(data, attribution, 0, (std::numeric_limits::max)(), ValueMapping::Linear, 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, Filter::Lanczos4, Interpolation::Bilinear); + 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 = algorithm::reduce(data, attribution, 2, 0, ValueMapping::Linear, 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, Filter::Box, Interpolation::Bilinear); + 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 excludes NoData and preserves nearest NoData", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(3), std::numeric_limits::quiet_NaN()); + radix::Raster attribution(glm::uvec2(3), 0); + data.pixel({ 1, 1 }) = 17; + attribution.pixel({ 1, 1 }) = 9; + const auto valid = scale(data, attribution, 2, 1, Filter::Box, Interpolation::Bilinear); + REQUIRE(valid); + for (auto value : valid->first) { + CHECK(value == 17); + } + for (auto id : valid->second) { + CHECK(id == 9); + } + data.fill(100); + attribution.fill(2); + data.pixel({ 1, 1 }) = -999; + attribution.pixel({ 1, 1 }) = 0; + const auto invalid = scale(data, attribution, 1, 1, Filter::Box, Interpolation::Bilinear); + REQUIRE(invalid); + CHECK(std::ranges::all_of(invalid->first, [](float value) { return value == -999; })); + CHECK(std::ranges::all_of(invalid->second, [](auto id) { return id == 0; })); +} + +TEST_CASE("box reduction votes locally and ignores invalid payloads", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 2, std::numeric_limits::quiet_NaN(), 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 }) == 10); +} + +TEST_CASE("repeated reduction uses stage validity 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 }) == 50); + CHECK(direct->second.pixel({ 0, 0 }) == 5); + 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 raster reducers receive valid decoded spans and preserve callable state", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 1, 999, 3, 4 }); + auto attribution = samples({ 2, 2 }, { 5, 0, 7, 7 }); + auto calls = std::make_unique(0); + const auto reducer = [&calls](std::span values) { + ++*calls; + REQUIRE(values.size() == 3); + CHECK(values[0] == 1); + CHECK(values[1] == 3); + CHECK(values[2] == 4); + return 2.5f; + }; + const auto result = algorithm::reduce(data, attribution, 0, 1, ValueMapping::Linear, reducer); + REQUIRE(result); + CHECK(result->first.pixel({ 0, 0 }) == 3); + CHECK(result->second.pixel({ 0, 0 }) == 7); + CHECK(*calls == 1); + attribution.fill(0); + REQUIRE(algorithm::reduce(data, attribution, 0, 1, ValueMapping::Linear, reducer)); + CHECK(*calls == 1); + + struct Counter { + std::unique_ptr count = std::make_unique(0); + float operator()(std::span values) + { + ++*count; + return algorithm::Max {}(values); + } + } counter; + radix::Raster larger(glm::uvec2(4), 42); + radix::Raster ids(glm::uvec2(4), 1); + const auto repeated = algorithm::reduce(larger, ids, 0, 2, ValueMapping::Linear, counter); + REQUIRE(repeated); + CHECK(*counter.count == 5); + CHECK(repeated->first.pixel({ 0, 0 }) == 42); + REQUIRE(algorithm::reduce(larger, ids, 0, 2, ValueMapping::Linear, Counter {})); +} + +TEST_CASE("provided raster reducers are component-wise with even median and NaN propagation", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { { 8, 1, 10 }, { 2, 7, 20 }, { 6, 3, 30 }, { 4, 5, 40 } }); + radix::Raster ids(glm::uvec2(2), 1); + CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Min {})->first.pixel({ 0, 0 }) == glm::vec3(2, 1, 10)); + CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Max {})->first.pixel({ 0, 0 }) == glm::vec3(8, 7, 40)); + CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Median {})->first.pixel({ 0, 0 }) == glm::vec3(5, 4, 25)); + ids.pixel({ 1, 1 }) = 0; + CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Median {})->first.pixel({ 0, 0 }) == glm::vec3(6, 3, 20)); + auto nonfinite = data; + nonfinite.pixel({ 0, 0 }).y = std::numeric_limits::quiet_NaN(); + const auto check_nan = [&](auto reducer) { + auto result = algorithm::reduce(nonfinite, ids, 0, 1, ValueMapping::Linear, reducer); + REQUIRE(result); + CHECK(std::isfinite(result->first.pixel({ 0, 0 }).x)); + CHECK(std::isnan(result->first.pixel({ 0, 0 }).y)); + }; + check_nan(algorithm::Min {}); + check_nan(algorithm::Max {}); + check_nan(algorithm::Median {}); + const auto replace_nan = [](std::span values) { + CHECK(std::isnan(values[0].y)); + return glm::vec3(42); + }; + CHECK(algorithm::reduce(nonfinite, ids, 0, 1, ValueMapping::Linear, replace_nan)->first.pixel({ 0, 0 }) == glm::vec3(42)); +} + +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 = algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Max {}); + REQUIRE(maximum); + CHECK(maximum->first.pixel({ 0, 0 }) == value); + } +} + +TEST_CASE("integer raster conversion rounds clamps and rejects nonfinite 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 algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, 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); + CHECK(convert(std::numeric_limits::infinity()).error().code() == Error::Code::InvalidInput); + CHECK(convert(std::numeric_limits::quiet_NaN()).error().code() == Error::Code::InvalidInput); + radix::Raster large(glm::uvec2(2), 0); + const auto upper = [](std::span) { return std::ldexp(1.L, 64); }; + CHECK(algorithm::reduce(large, ids, 0, 1, ValueMapping::Linear, 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, Filter::Box, Interpolation::NearestNeighbour, 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 = algorithm::reduce(data, ids, 0, 1, ValueMapping::SRGBA, reducer); + REQUIRE(custom); + same_raster(custom->first, result->first); + const auto nearest = scale(data, ids, 1, 0, Filter::Box, Interpolation::NearestNeighbour, 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, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + const auto first = scale(rgb, rgb_ids, -1, 1, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + REQUIRE(direct); + REQUIRE(first); + const auto second = scale(first->first, first->second, -1, 0, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + REQUIRE(second); + same_raster(direct->first, second->first); +} + +TEST_CASE("Lanczos reduction matches an independent two-dimensional validity-aware reference", "[raster-algorithm]") +{ + for (int radius = 2; radius <= 4; ++radius) { + const auto filter = radius == 2 ? Filter::Lanczos2 : (radius == 3 ? Filter::Lanczos3 : Filter::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 discards valid outer support when the local block is NoData", "[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, Filter::Lanczos2); + REQUIRE(result); + CHECK(result->second.pixel({ 0, 0 }) == 0); + CHECK(result->first.pixel({ 0, 0 }) == -17); +} + +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, Filter::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, Filter::Lanczos3); + REQUIRE(first); + const auto second = scale(first->first, first->second, -1, 5, Filter::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, Filter::Lanczos3)); +} + +TEST_CASE("independent area tiles match their metatile through repeated scaling", "[raster-algorithm]") +{ + for (const auto filter : { Filter::Box, Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) { + const auto halo = algorithm::required_halo(-2, Interpolation::Bilinear, 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 : { Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) { + const auto halo = algorithm::required_halo(-1, Interpolation::Bilinear, 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); + } + } +} + +TEST_CASE("weight normalization falls back on cancellation but allows negative sums", "[raster-algorithm]") +{ + const auto data = samples({ 2, 2 }, { 2, 4, 0, 0 }); + const auto ids = samples({ 2, 2 }, { 1, 1, 0, 0 }); + const auto local = algorithm::detail::local_block(ids, { 0, 0 }); + algorithm::detail::Kernel kernel(0); + // Synthetic weights isolate the numerical threshold independently of + // the selected Lanczos radius and the platform's sine approximation. + kernel.weights[1] = -1 + std::numeric_limits::epsilon(); + CHECK(kernel(data, ids, { 0, 0 }, local, ValueMapping::Linear).value() == 2); + kernel.weights[1] = -2; + CHECK(kernel(data, ids, { 0, 0 }, local, ValueMapping::Linear).value() == 6); +} + +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, Filter::Lanczos2); + const auto median = algorithm::reduce(data, ids, 3, 1, ValueMapping::Linear, 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 raster filters preserve attributed nonfinite values and floating overshoot", "[raster-algorithm]") +{ + radix::Raster data(glm::uvec2(2), 1); + radix::Raster ids(glm::uvec2(2), 1); + data.pixel({ 0, 0 }) = std::numeric_limits::quiet_NaN(); + const auto nan = scale(data, ids, -1); + REQUIRE(nan); + CHECK(std::isnan(nan->first.pixel({ 0, 0 }))); + CHECK(nan->second.pixel({ 0, 0 }) == 1); + data.pixel({ 0, 0 }) = std::numeric_limits::infinity(); + const auto infinite = scale(data, ids, -1); + REQUIRE(infinite); + CHECK(std::isinf(infinite->first.pixel({ 0, 0 }))); + + 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, Filter::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, Filter::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, Filter::Box, Interpolation::Bilinear, 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))); + } +} From 837954020a2b0587df90f0e75cf8d5ce299e795b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:30:26 +0200 Subject: [PATCH 18/83] Add raster views and composable pixel algorithms --- docs/raster-view-algorithms.md | 276 ++++++++++ src/terrainlib/CMakeLists.txt | 7 + src/terrainlib/raster/ClampedView.h | 122 +++++ src/terrainlib/raster/View.h | 149 +++++ src/terrainlib/raster/algorithm.h | 4 + src/terrainlib/raster/algorithm/copy.h | 50 ++ src/terrainlib/raster/algorithm/detail.h | 148 +++++ src/terrainlib/raster/algorithm/transform.h | 45 ++ .../raster/algorithm/window_transform.h | 62 +++ .../raster/algorithm/zip_transform.h | 105 ++++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/raster_view.cpp | 509 ++++++++++++++++++ 12 files changed, 1478 insertions(+) create mode 100644 docs/raster-view-algorithms.md create mode 100644 src/terrainlib/raster/ClampedView.h create mode 100644 src/terrainlib/raster/View.h create mode 100644 src/terrainlib/raster/algorithm/copy.h create mode 100644 src/terrainlib/raster/algorithm/detail.h create mode 100644 src/terrainlib/raster/algorithm/transform.h create mode 100644 src/terrainlib/raster/algorithm/window_transform.h create mode 100644 src/terrainlib/raster/algorithm/zip_transform.h create mode 100644 unittests/terrainlib/raster_view.cpp diff --git a/docs/raster-view-algorithms.md b/docs/raster-view-algorithms.md new file mode 100644 index 00000000..9fb83f13 --- /dev/null +++ b/docs/raster-view-algorithms.md @@ -0,0 +1,276 @@ +# Raster views and view algorithms + +Status: design agreed on 2026-09-21 and extended and implemented on 2026-09-22, +including overloads that allocate and return rasters. + +## Scope + +Add rectangular views of `radix::Raster` and operations over caller-supplied +input and output views or whole rasters: + +- `copy`: copy pixels from one view into another. +- `transform`: invoke a callable on each input pixel to produce an output pixel. +- `zip_transform`: invoke a callable with one pixel from each of two or more + input views to produce an output pixel. Input pixel types may differ. +- `window_transform`: invoke a callable with a square, kernel-sized input + view to produce one output pixel. + +Inputs may also be read-only clamped views that replicate source-edge pixels. +Destinations must be writable ordinary views or rasters. + +Pointwise operations require matching view widths and heights. Source and +destination pixel types may differ for transforms. The windowed operation +uses an odd kernel size and no border treatment: for a kernel side length +`k`, output dimensions are `input_width - (k - 1)` by +`input_height - (k - 1)`. + +## Agreed view model + +Ordinary views are statically typed as `raster::View` or +`raster::View`, defined in `terrainlib/raster/View.h`. Clamped views +are defined in `terrainlib/raster/ClampedView.h`. Operations belong to +`raster::algorithm`, with implementations in `terrainlib/raster/algorithm/copy.h`, +`transform.h`, `zip_transform.h`, and `window_transform.h`. The existing +`terrainlib/raster/algorithm.h` includes those headers. +There is no type erasure or runtime datatype field. A pixel type denotes the +complete stored pixel, including all components of a vector pixel. + +Support rectangular regions of `radix::Raster` and subviews of those +regions. Pixels within a row remain contiguous, and a subview inherits its +backing raster's row stride. Offset and row stride are measured in pixels. +External buffers, flipped rows, and every-nth-pixel sampling are outside scope. + +Views borrow storage without extending its lifetime. The caller must keep the +backing storage alive and stable; destroying or replacing it invalidates its +views. Algorithm inputs expose read-only pixels. An ordinary descriptor carries +the backing storage pointer, pixel offset, row stride, width, and height; its +template argument supplies the pixel type. Representation details remain an +implementation choice within this model. + +## Construction and dimensions + +Use the free factory `raster::make_view` for raster and ordinary view sources; +there is no `subview` member method. Whole-raster or whole-view construction +returns a view directly. Construction with an origin and size returns +`Expected>`, with pixel constness preserved. Coordinates are relative +to the supplied raster or view. A requested region must fit completely; +construction does not clip it. + +Mutable views can convert to read-only views, but not the reverse. +Both view factories reject temporary rasters. A temporary view is only a +borrowed descriptor, and its destruction does not destroy the backing raster. + +Zero-width or zero-height views are valid. Pointwise operations with matching +empty dimensions succeed without invoking the callable. Window transforms +require a positive, odd kernel size that fits both input dimensions. A kernel +size of one is valid; an oversized kernel is an error. + +## Clamped views + +Provide a separate, read-only `raster::ClampedView` type, constructed by +`make_clamped_view(source, origin, size)`. The source is a raster or ordinary +view. The signed origin and logical dimensions may describe a region extending +beyond the source. Reads clamp each source coordinate to the supplied source +region's edges, repeating edge and corner pixels without allocating padding. +An ordinary source view defines its own clamping bounds, rather than those of +its entire backing raster. A nonempty clamped view requires a nonempty source. + +Clamped views expose the same `pixel`, `width`, `height`, and `size` interface +as ordinary views, but pixel access is always read-only. Callers must still +address within the clamped view's logical dimensions. Clamping applies to the +mapping into the source; it does not permit arbitrary out-of-view coordinates. +Ordinary views retain their direct addressing without a runtime clamping mode. + +`make_view(clamped_view)` preserves the clamped view type and mapping. Creating +a region of a clamped view through `make_view` requires that region to fit its +logical dimensions and preserves the original clamping bounds. In particular, +a window-transform kernel must not clamp independently at its own edges. + +The window transform itself retains its existing geometry and border behavior. +For example, extending an input by three pixels on each side allows a 7x7 +window transform to produce output with the original input's dimensions. + +## Access and call syntax + +Expose `pixel({x, y})`, `width()`, `height()`, and `size()` in the style of +`radix::Raster`. Pixel coordinates are relative to the view. Individual pixel +access has an in-bounds precondition checked by debug assertions; it does not +return `Expected`. + +For ordinary views, `pixel(...) const` returns `T&`: constness of the descriptor +does not determine pixel writability. `View` exposes read-only pixels, whereas even a +const `View` descriptor can expose writable pixels. Internal algorithm view +parameters are const references; public wrappers accept rasters or views as +described below. The output pixel type must be non-const. The algorithms do +not change the supplied descriptors. + +Use these call shapes: + +```cpp +copy(source, destination); +transform(source, function, destination); +zip_transform(a, b, function, destination); +zip_transform(a, b, c, function, destination); +zip_transform(std::tuple{a, b, c, d}, function, destination); +window_transform(source, kernel_size, function, destination); +``` + +Each call also has an overload omitting the destination, returning +`Expected>`. Copy preserves the source pixel type; transforms +infer `T` from the callable's exact return type. Pointwise output dimensions +match the inputs; window output dimensions follow the kernel shrinkage rule. +Validate input dimensions and kernel parameters before allocating. Invalid +geometry returns an error; unrepresentable storage sizes or allocation failure +return `ResourceExhausted`. Callable exceptions retain their existing contract. + +```cpp +auto copied = copy(source); +auto transformed = transform(source, function); +auto combined = zip_transform(a, b, function); +auto filtered = window_transform(source, kernel_size, function); +``` + +Zip exposes direct overloads for exactly two and three inputs. Four or +more inputs use the tuple overload, accepting the tuple by const +reference. Input order is the callable's pixel-argument order. All overloads +share validation and processing logic internally. + +When the tuple contains views, constructing it copies only view descriptors, +not raster pixels. For raster inputs, use a tuple of references such as +`std::tie(a, b, c, d)`, or explicitly create views before constructing the +tuple. `std::tuple{...}` containing raster objects by value would copy their +pixel buffers at the call site. +A bare braced list cannot deduce the heterogeneous input types for this +function-template interface; `std::tuple{...}` provides that deduction. +This deduction behavior was verified with a standalone C++23 compiler probe +and follows the language's +[initializer-list argument deduction rules](https://eel.is/c++draft/temp.deduct.call). + +## Raster and view argument wrappers + +Each public operation accepts a raster, ordinary view, or clamped view +independently for each input. The destination accepts a writable raster or +ordinary view. Normalize these arguments through `make_view` +and delegate to one internal implementation operating on views. A raster +becomes a view covering the whole raster; an existing view becomes an +equivalent view. Normalization does not copy raster buffers. Overloads with +an explicit destination do not allocate output storage; destination-free +overloads allocate a raster and use the same processing implementation. + +Internal implementations template their input-view types and read through +`pixel`, so ordinary and clamped inputs use the same processing logic without +inheritance or runtime dispatch. Zip inputs may mix both view types. Both +types provide backing-storage information internally for overlap validation. + +Constrain the normalized destination to `View` with non-const `T`, rejecting +read-only and clamped destinations at compile time. For example: + +```cpp +template + requires (!std::is_const_v) +Expected copy_views(const SourceView& source, const View& destination); +``` + +For example, the public copy wrapper has this structure, with the supported +argument and pixel-type constraints applied in the implementation: + +```cpp +template +Expected copy(Source&& source, Destination&& destination) +{ + auto source_view = make_view(source); + auto destination_view = make_view(destination); + + return detail::copy_views(source_view, destination_view); +} +``` + +The single public template supports all four combinations: + +```cpp +copy(source_raster, destination_raster); +copy(source_raster, destination_view); +copy(source_view, destination_raster); +copy(source_view, destination_view); +``` + +`detail::copy_views` owns dimension and overlap validation and the copying +loop. Apply the same wrapper structure to `transform`, `window_transform`, +and the three `zip_transform` overloads. Normalize each zip input, including +tuple elements, independently, preserving pixel constness. Temporary raster +arguments are valid for these synchronous operations. + +There are twelve public function-template overloads: six taking a destination +and six returning a raster. Each group has one copy, one transform, one window +transform, and three zip transforms. Internal implementation helpers are +additional. Do not multiply public overloads for +raster/view combinations or introduce a converting constructor to make +algorithm calls work. The compiler can instantiate the templates for the +argument combinations actually used. + +## Types and errors + +Pixel-type and callable compatibility are checked at compile time. +Dimensions, region bounds, kernel parameters, and prohibited input/output +overlap are checked at runtime. Runtime validation errors follow terrainlib's +`Expected` / `Error` conventions; operations return `Expected`. + +All argument validation completes before writing any output pixels or invoking +the transform callable. Invalid arguments leave the output unchanged. + +Copy requires identical pixel types, ignoring source constness. Each transform +callable must return exactly the destination pixel type by value. There is +no implicit output conversion; callers express conversions inside the callable. + +## Callables + +The callable is a template parameter, with no type erasure or machinery to +inspect its state. Immutable configuration, including captured values, is +permitted. Callbacks must not change state between invocations or depend on +invocation order. This is a caller contract; const invocation does not prove +the absence of external side effects. + +Pointwise callbacks receive stored input pixels through const references. +Window callbacks receive a read-only input view with local coordinates +starting at `(0, 0)`. The kernel for output pixel `(x, y)` starts at input-view +coordinate `(x, y)` and covers `k` pixels along each axis. +The kernel preserves the source's ordinary or clamped view type; a callback +such as `[](const auto& window) { ... }` can handle either. + +Execution order is not part of the public contract. The implementation visits +pixels in row-major order internally. Exceptions have no specified API +guarantees or special treatment; no catching, translation, or rollback is +introduced for callback exceptions. + +## Overlap + +Input views may overlap each other. Reject partial overlap between any input +and the output. For copy and pointwise transforms, permit an identical +input/output region when pixel types match. Other inputs to a zip transform +must independently satisfy the same rule relative to the output. + +For clamped inputs, overlap checks account for the backing pixels read through +clamping. Identical input/output regions require corresponding logical pixels +to address the same stored pixels, not merely equal logical dimensions. + +Reject all input/output overlap for windowed transforms. These operations do +not promise snapshot behavior through temporary buffers. + +## Pixel interpretation + +These are raw pixel operations. They do not interpret attribution, NoData, +colour encoding, or numeric conversion policy. Callables receive stored +pixels unchanged and control interpretation and conversion. Copy preserves +pixels exactly. + +The existing scaling algorithms retain their separate attribution and value +mapping semantics. + +## Verification + +The GCC Debug build passes all 48 raster test cases (1,140 assertions), +including 14 new view/algorithm cases. The terrainlib regression suite passes +533 cases (25,934 assertions), excluding the existing mesh clipping benchmark. +Each public header also compiles independently without a precompiled header +under GCC with `-Wall -Wextra -Werror`. New C++ is formatted with +`clang-format-21`; Qt C++ lint and Git-attribute line-ending checks pass. diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index c821af7f..9cbff9e5 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -160,6 +160,13 @@ add_library(terrainlib range/RangeToInclusive.h raster/algorithm.h + raster/View.h + raster/ClampedView.h + raster/algorithm/detail.h + raster/algorithm/copy.h + raster/algorithm/transform.h + raster/algorithm/window_transform.h + raster/algorithm/zip_transform.h raster_store/StoreTraits.h raster_store/Tile.h diff --git a/src/terrainlib/raster/ClampedView.h b/src/terrainlib/raster/ClampedView.h new file mode 100644 index 00000000..16680c41 --- /dev/null +++ b/src/terrainlib/raster/ClampedView.h @@ -0,0 +1,122 @@ +#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..7b611bb7 --- /dev/null +++ b/src/terrainlib/raster/View.h @@ -0,0 +1,149 @@ +#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 index 71563541..f4cb2bf4 100644 --- a/src/terrainlib/raster/algorithm.h +++ b/src/terrainlib/raster/algorithm.h @@ -20,6 +20,10 @@ #include #include "Error.h" +#include "algorithm/copy.h" +#include "algorithm/transform.h" +#include "algorithm/window_transform.h" +#include "algorithm/zip_transform.h" 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..689ff228 --- /dev/null +++ b/src/terrainlib/raster/algorithm/copy.h @@ -0,0 +1,50 @@ +#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..0d6f45e9 --- /dev/null +++ b/src/terrainlib/raster/algorithm/detail.h @@ -0,0 +1,148 @@ +#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, unsigned size) +{ + return view_details::Access::region(source, origin, glm::uvec2(size)); +} + +template +auto window_view(const ClampedView& source, glm::uvec2 origin, unsigned size) +{ + return view_details::ClampedAccess::region(source, origin, glm::uvec2(size)); +} + +} // namespace raster::algorithm::detail diff --git a/src/terrainlib/raster/algorithm/transform.h b/src/terrainlib/raster/algorithm/transform.h new file mode 100644 index 00000000..fc3d916b --- /dev/null +++ b/src/terrainlib/raster/algorithm/transform.h @@ -0,0 +1,45 @@ +#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..550a5d9a --- /dev/null +++ b/src/terrainlib/raster/algorithm/window_transform.h @@ -0,0 +1,62 @@ +#pragma once + +#include "detail.h" + +namespace raster::algorithm { + +namespace detail { + inline Expected window_output_size(glm::uvec2 input_size, unsigned kernel_size) + { + if (kernel_size == 0 || kernel_size % 2 == 0 || kernel_size > input_size.x || kernel_size > input_size.y) { + return Error::fail(Error::Code::InvalidInput, "window size must be positive, odd, and fit the input view"); + } + return input_size - glm::uvec2(kernel_size - 1); + } + + template + requires(!std::is_const_v) && PixelFunction + Expected window_transform_views(const InputView& source, unsigned kernel_size, const Function& function, const View& destination) + { + auto size = window_output_size(source.size(), kernel_size); + 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, { x, y }, kernel_size); + destination.pixel({ x, y }) = std::invoke(function, window); + } + } + return {}; + } +} // namespace detail + +/// Applies a square window without border handling; output shrinks by kernel_size - 1. +template +requires detail::PixelFunction, detail::ReadOnlyView> +[[nodiscard]] Expected window_transform(Source&& source, unsigned kernel_size, const Function& function, Destination&& destination) +{ + return detail::window_transform_views(detail::read_only_view(raster::make_view(source)), kernel_size, function, raster::make_view(destination)); +} + +template +requires detail::RasterFunction> +[[nodiscard]] auto window_transform(Source&& source, unsigned kernel_size, 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); + if (!size) { + return Error::propagate(std::move(size)); + } + return detail::produce_raster>>( + *size, [&](const auto& destination) { return detail::window_transform_views(input, kernel_size, function, destination); }); +} + +} // 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..8384ed49 --- /dev/null +++ b/src/terrainlib/raster/algorithm/zip_transform.h @@ -0,0 +1,105 @@ +#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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index f98fe33f..d8f1ed51 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -67,6 +67,7 @@ add_executable(unittests_terrainlib terrainlib/range.cpp terrainlib/range_utils.cpp terrainlib/raster_algorithm.cpp + terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp diff --git a/unittests/terrainlib/raster_view.cpp b/unittests/terrainlib/raster_view.cpp new file mode 100644 index 00000000..39582098 --- /dev/null +++ b/unittests/terrainlib/raster_view.cpp @@ -0,0 +1,509 @@ +#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), 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, 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, 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, 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, kernel, never, output)); + } + CHECK_FALSE(algorithm::window_transform(source, 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, 3, never, overlapping)); + CHECK_FALSE(algorithm::window_transform(source, 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, 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, 3, Centre {}); + REQUIRE(filtered); + CHECK(filtered->size() == source.size()); + CHECK(std::ranges::equal(*filtered, source)); + auto cropped = algorithm::window_transform(source, 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, 0, never)); + CHECK_FALSE(algorithm::window_transform(source, 2, never)); + CHECK_FALSE(algorithm::window_transform(source, 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, 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)); +} From 48d061612ed27337c8ee770157355a9e2b6e9dff Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:24:50 +0200 Subject: [PATCH 19/83] Refactor raster scaling into view-based single-raster algorithms --- ...esentative-attribution-for-halo-samples.md | 7 +- docs/raster-store/architecture.md | 20 +- .../gdal-value-mapping-research.md | 10 +- docs/raster-store/implementation-status.md | 3 +- docs/raster-store/sampling-and-generation.md | 45 +- docs/raster-store/scaling.md | 895 ++++++++++-------- docs/raster-store/storage-format.md | 32 +- docs/raster-store/terminology.md | 11 +- docs/raster-store/tiles-with-halo.md | 100 +- docs/raster-view-algorithms.md | 76 +- src/terrainlib/CMakeLists.txt | 8 +- src/terrainlib/raster/algorithm.h | 635 +------------ src/terrainlib/raster/algorithm/conversion.h | 147 +++ src/terrainlib/raster/algorithm/detail.h | 8 +- src/terrainlib/raster/algorithm/reduce.h | 81 ++ src/terrainlib/raster/algorithm/reducers.h | 105 ++ src/terrainlib/raster/algorithm/scale.h | 178 ++++ .../raster/algorithm/scaling_detail.h | 183 ++++ .../raster/algorithm/window_transform.h | 42 +- src/terrainlib/raster_store/io/TileCodec.h | 4 +- src/terrainlib/raster_store/pixel.h | 50 + src/terrainlib/raster_store/pixel_type.h | 44 - src/terrainlib/raster_store/scaler.h | 233 +++++ src/terrainlib/raster_store/storage.h | 4 +- unittests/terrainlib/raster_algorithm.cpp | 404 +++++--- unittests/terrainlib/raster_view.cpp | 66 +- 26 files changed, 2083 insertions(+), 1308 deletions(-) create mode 100644 src/terrainlib/raster/algorithm/conversion.h create mode 100644 src/terrainlib/raster/algorithm/reduce.h create mode 100644 src/terrainlib/raster/algorithm/reducers.h create mode 100644 src/terrainlib/raster/algorithm/scale.h create mode 100644 src/terrainlib/raster/algorithm/scaling_detail.h create mode 100644 src/terrainlib/raster_store/pixel.h delete mode 100644 src/terrainlib/raster_store/pixel_type.h create mode 100644 src/terrainlib/raster_store/scaler.h diff --git a/docs/adr/0004-representative-attribution-for-halo-samples.md b/docs/adr/0004-representative-attribution-for-halo-samples.md index cfbb9aaa..4eb47e6c 100644 --- a/docs/adr/0004-representative-attribution-for-halo-samples.md +++ b/docs/adr/0004-representative-attribution-for-halo-samples.md @@ -2,7 +2,12 @@ Halo extraction retains one representative attribution per resampled pixel, following the rules in [Raster scaling](../raster-store/scaling.md). -Attribution indices are categorical and are never averaged. +Attribution indices are categorical and are never averaged. The refactor +agreed on 2026-09-22 delegates paired operations to `raster_store::scaler`, +whose thin wrappers call the generic single-raster algorithms independently +for data and attribution: nearest-neighbour upscaling and repeated scalar +mode reduction for attribution. Zero participates as an ordinary unattributed +category and never controls which data samples contribute to arithmetic. This preserves the existing single-index attribution raster, including persisted TB tiles, at the cost of discarding other contributing source IDs. diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 1d2159e4..67f19afb 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -32,6 +32,24 @@ The 3D octree stores use versioned envelopes. Raster persistence implementation and verification progress is tracked in [implementation-status.md](implementation-status.md). +### Scaling responsibility + +The [scaling refactor agreed on 2026-09-22](scaling.md) separates numerical +operations from attribution policy. Generic `raster::algorithm` operations +resample a single raster through ordinary or clamped views; paired +`raster_store::scaler` wrappers compose those operations for data and +representative attribution. Zero attribution means unattributed and does not +mask numerical inputs. Callers must prepare usable payloads before scaling. +Paired `scale` accepts `raster_store::pixel::Mapping` from the shared +`raster_store/pixel.h`; paired `reduce` and generic algorithms receive +decoder/encoder tuples. Paired `reduce` defaults to identity conversion. +The same header provides `pixel::identifier()` with its `Format` machinery +in `pixel::detail`, replacing the former `pixel_type.h` API. Planned snapshot +metadata uses the shared `pixel::Mapping` type as well. +Source NoData handling stays with importers, while physical-source selection +and missing-coverage replication belong to the planned halo extractor. +The scaler refactor is implemented; halo extraction and its metadata remain planned. + ### Raster storage API Include `raster_store/storage.h`. `raster_store::storage::create()` @@ -48,7 +66,7 @@ if (!created) { return Error::propagate(std::move(created)); } auto [output, metadata] = std::move(*created); -raster_store::Tile tile(3); // Attribution defaults to NoData. +raster_store::Tile tile(3); // Attribution defaults to zero (unattributed). if (auto saved = output->save({ 0, { 0, 0 } }, tile); !saved) { return saved; } diff --git a/docs/raster-store/gdal-value-mapping-research.md b/docs/raster-store/gdal-value-mapping-research.md index 20f990f8..0f78831d 100644 --- a/docs/raster-store/gdal-value-mapping-research.md +++ b/docs/raster-store/gdal-value-mapping-research.md @@ -44,7 +44,7 @@ JPEG/YCbCr decoding and sRGB linearization are separate operations. GDAL's JPEG driver describes conversion from YCbCr/CMYK to RGB and reports the source model through `IMAGE_STRUCTURE:SOURCE_COLOR_SPACE`. This does not promise linear-light output. Compression and channel roles are therefore insufficient -evidence for selecting `ValueMapping`. +evidence for selecting `raster_store::pixel::Mapping`. [JPEG reading behaviour](https://gdal.org/en/stable/drivers/raster/jpeg.html) ## VRT mosaics @@ -88,10 +88,12 @@ this reference file, not every swisstopo product or delivery. ## Selected policy — 2026-09-15 The user selected a type-based convention: default three- and four-component -unsigned 8-bit pixels to `ValueMapping::SRGBA`, otherwise `Linear`, with an +unsigned 8-bit pixels to `pixel::Mapping::SRGBA`, otherwise `Linear`, with an explicit override. RGB channels use the sRGB transfer function; alpha, if present, remains linear. Persist the resolved mapping in store metadata and -document the defaults and override in RF CLI help. See +document the defaults and override in RF CLI help. The shared mapping type +belongs in `raster_store/pixel.h` and is used by both the planned snapshot +metadata and raster-store scaler wrappers. See [Tiles with halo](tiles-with-halo.md) for the accepted contract. This replaces the earlier recommendation below; it does not change the @@ -100,7 +102,7 @@ findings about what GDAL metadata can establish. ## Earlier recommendation — superseded Allow an explicit RF input mapping for the prepared dataset, including a VRT. -Persist the resolved `ValueMapping::{Linear, SRGB}` in the raster-store +Persist the resolved linear or sRGB interpretation in the raster-store manifest. Keep unknown/automatic selection at the import boundary if needed; it need not become a third stored mapping value. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 0dc47d3d..f1efe6d5 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -35,7 +35,8 @@ Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. | Accepted decisions and documentation | Verified | Attribution ownership, JSON contract, version-1 encoding, codec selection, API usage, and shared I/O fix recorded | | Typed tiles and attribution tables | Verified | `raster_store/Tile.h`, `attribution.h/.cpp`; zero attribution, verbatim fields, malformed slots, lookup precedence, and boundary indices | | XYZ layout, index, and metadata | Verified | `raster_store/path_layout.h`, `raster_store/io/manifest.h/.cpp`; boundary paths, mixed hierarchy, corrupt topology, metadata errors, and checkpoint persistence | -| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, NoData payload preservation, dimensions, byte counts, compression, checksum, and version errors | +| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, zero-attribution payload preservation, dimensions, byte counts, compression, checksum, and version errors | +| Raster scaling and paired attribution | Verified | `raster/algorithm/*.h`, `raster_store/scaler.h`, and `pixel.h`; single-raster conversion/reduction, rectangular strided windows, separable filtering, independent attribution; see [scaling verification](scaling.md#refactor-verification--2026-09-22) | | Creation, opening, checkpoints, publication | Verified | `raster_store/storage.h`; metadata codec selection, incomplete opening, checkpoint failures, no implicit publication, collision rejection, no payload scan, and `/dev/full` write failure | | Hard-link integration and regression tests | Verified | `unittests/terrainlib/raster_store.cpp`; cross-root hard links and independent attribution survive RF deletion; all six regression suites pass | diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index d258c1c8..fd2b28c2 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -149,8 +149,13 @@ resolves: - 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. +The generator determines the requested halo. The planned raster-store halo +extractor applies replication only where physical coverage is missing, +including vertical world limits, using clamped views as described in +[Tiles with halo](tiles-with-halo.md). Zero attribution within a physical +source is not a boundary and does not trigger replacement of its data. +Callers prepare usable values before invoking the generic scaler; the scaler +neither assembles neighbours nor invents boundary conditions. ## Layer-specific filtering @@ -163,7 +168,12 @@ Sampling placement alone does not determine a correct filter: | 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 | +| Source mask/NoData | importer validity and coverage rules before generic scaling | + +Source NoData handling remains an importer concern. Once a raster is supplied +to the [single-raster scaling facility](scaling.md), attribution does not mask +any numerical input. Missing attribution and missing physical coverage are +distinct; neither should be inferred from a numeric sentinel by the scaler. 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 @@ -197,11 +207,16 @@ 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 +- zero-attribution pixels inside physically 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. +The planned raster-store halo rules are specified in +[Tiles with halo](tiles-with-halo.md): preserve physically supplied +zero-attribution payloads and replicate only missing physical coverage through +clamped views. Other generator-specific boundary policies remain separate +design work. Tests must distinguish true coverage boundaries from ordinary +internal chunk and delivery-tile boundaries. Source dataset NoData/mask +boundaries are handled during import, not by generic scaling. ## Required golden tests @@ -212,12 +227,18 @@ Before production filtering is implemented, synthetic fixtures should prove: 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 valid payloads and reports representative - attribution according to the [scaling contract](scaling.md): local 2x2 - selection for reduction and nearest-neighbour attribution for upscaling. -7. A NoData boundary follows the configured validity rule. +6. A source boundary blends supplied payloads independently of attribution. + Paired wrappers report representative attribution according to the + [scaling contract](scaling.md): repeated local 2x2 mode reduction, including + zero in the vote, and nearest-neighbour attribution for upscaling. +7. Existing zero-attribution pixels contribute normally and retain their + supplied or resampled payloads; missing physical coverage follows the + caller's explicit clamped-view policy. Import fixtures separately verify + source NoData and mask handling. 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. +The [scaling contract](scaling.md) specifies coefficients and verification +requirements for its area-pixel operations. Other layer-specific and +vertex-pixel filters remain separate design decisions; tests should fix their +numeric tolerances only after representative evaluation. diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md index 3ea2516a..c0735828 100644 --- a/docs/raster-store/scaling.md +++ b/docs/raster-store/scaling.md @@ -1,156 +1,317 @@ # Raster scaling -Status: behavioral design agreed on 2026-09-18, with the unified zoom-level -API convention agreed on 2026-09-19. Implementation was authorized and -completed on 2026-09-19. The scaler and its tests are implemented; halo -extraction and its storage metadata remain separate work. +Status: the single-raster, view-based refactor was implemented on 2026-09-22, +including paired raster-store wrappers, conversion tuples, rectangular strided +windows, and separable box/Lanczos filtering. Halo extraction and its storage +metadata remain separate work. + +## Scope and ownership + +Generic scaling and reduction operate on one raster, without interpreting +attribution or treating any sample as NoData. Every supplied pixel participates +according to the selected operation. Callers must supply usable payloads, +including where their separate attribution raster contains zero. For numerical +paths, callers must ensure finite decoded values, finite intermediate and +reducer results, and values accepted by their encoder. These are preconditions, +not runtime validity scans or per-pixel errors. Nearest-neighbour sampling and +zero-level cropping bypass conversion and preserve stored pixels exactly, +including nonfinite floating-point values. + +Only power-of-two scale factors are supported. Input and output use area-pixel +placement; vertex-pixel generation and area/vertex conversion are outside this +facility. World wrapping, physical-source selection, ancestor fallback, and +preparation of missing coverage belong to callers or the halo extractor. + +Implementations live under `src/terrainlib/raster/algorithm/*.h` and are exposed +through the `raster/algorithm.h` umbrella in namespace `raster::algorithm`. +Paired convenience wrappers live in `raster_store/scaler.h`, in namespace +`raster_store::scaler`. They compose generic operations for data and +attribution; they do not implement filtering, interpolation, or pixel loops. + +See [Raster views and view algorithms](../raster-view-algorithms.md) for the +view model and [Sampling and pyramid generation](sampling-and-generation.md) +for broader generator requirements. -## Scope +## Interface and geometry -Implement scaling in `terrainlib/raster/algorithm.h`, operating on data and -attribution rasters together. Only power-of-two scale factors are supported. -Input and output use area-pixel placement; vertex-pixel generation and -area/vertex conversion are outside this facility. +Accept a raster, ordinary view, or clamped view as a source. Normalize it with +`raster::make_view`, as the existing algorithms do, and expose read-only input +to the implementation. Public operations are synchronous and may accept a +temporary raster for the duration of the call. No borrowed view escapes. + +Each operation has an explicit-destination overload returning `Expected` +and an allocating overload returning `Expected>`. An explicit +destination may be a writable raster or ordinary view, must have exactly the +computed output dimensions, and must use the same stored pixel type as the +source. Read-only and clamped destinations are rejected at compile time. +Normalize source and destination independently; do not multiply overloads for +every raster/view combination. Both overloads share a view-based implementation. + +Inputs are rectangular, with a symmetric `halo_width` on all four sides. Both +axes scale by the same factor. The output contains only the scaled interior; +all input halo pixels are excluded from its extent. Reject empty inputs and +empty interiors after removing the halo, even though the view model permits +empty views. Destination overloads avoid allocating the final output, but +separable passes and repeated reductions still allocate intermediate rasters. + +Express scaling amounts as `n_zoom_levels`, the exponent of two. Public +`scale` and `required_halo` use a signed count: positive means upscaling, +negative means downscaling, and zero means cropping only. For example, `+2` +upscales by four and `-2` downscales by four. Directional helpers and custom +`reduce` use unsigned exponent counts. Zero levels crop without invoking a +reducer. Keep `upscale`, `downscale`, and directional halo queries in `detail`. + +For reduction, both interior dimensions must be divisible by the factor. +Validate geometry, halo bounds, algorithm enum selections, zoom-level counts, +and dimension/allocation overflow. Check pixel and conversion-callable type +compatibility at compile time. Validate exponents before shifting, and obtain +a negative signed count's magnitude without signed overflow. + +With interior pixel centres indexed by integers, direct upscaling by factor +`F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input coordinates. +A reduction by two evaluates pixel `j` at `2j + 0.5`. Apply these positions +independently on each axis. Halo offsets affect addressing, not sampling phase. + +### Generic API + +The algorithm enums remain `Interpolation { NearestNeighbour, Bilinear }` and +`Filter { Box, Lanczos2, Lanczos3, Lanczos4 }`. Remove the scaler's `ValueMapping` +enum and the fixed source-to-working-type policy from generic `scale` and +`reduce`. Instead accept a tuple `(value_decoder, value_encoder)`; the decoder's +return type determines the working pixel type. The encoder returns exactly +the original stored pixel type, so neither operation changes the raster's type. + +Expose the following call shapes in `raster::algorithm`. `conversion` is the +tuple. Convenience overloads omit it and supply the operation's default; all +forms delegate to the same processing implementation. Keep destinations last +and constrain conversion tuples and destinations so the forms are unambiguous. -The caller supplies a raster with halo and an explicit halo width. Return -data and attribution rasters with the halo removed. World wrapping, dataset -boundary handling, source selection, and ancestor fallback belong to the -caller or halo extractor. The scaler does not independently clamp internal -tile edges. +```cpp +scale(source, halo_width, n_zoom_levels, interpolation, filter); +scale(source, halo_width, n_zoom_levels, interpolation, filter, destination); +scale(source, halo_width, n_zoom_levels, interpolation, filter, conversion); +scale(source, halo_width, n_zoom_levels, interpolation, filter, conversion, + destination); + +reduce(source, halo_width, n_zoom_levels, reducer); +reduce(source, halo_width, n_zoom_levels, reducer, destination); +reduce(source, halo_width, n_zoom_levels, conversion, reducer); +reduce(source, halo_width, n_zoom_levels, conversion, reducer, destination); +``` -See [Sampling and pyramid generation](sampling-and-generation.md) for the -broader generator requirements, including vertex outputs outside this scope. +Allocating forms return `Expected>`, where `T` is the source +pixel type. Explicit-destination forms return `Expected`. The default +tuple is the supplied linear numeric conversion for `scale` and identity for +`reduce`, as specified below. A custom reducer is invoked through `const Reducer&`. +The halo queries are independent of conversion: -## Interface and geometry +```cpp +namespace raster::algorithm { + +Expected required_halo( + int n_zoom_levels, Interpolation interpolation, Filter filter); + +namespace detail { +Expected required_upscaling_halo( + unsigned n_zoom_levels, Interpolation interpolation); +Expected required_downscaling_halo( + unsigned n_zoom_levels, Filter filter); +} // namespace detail +} // namespace raster::algorithm +``` + +`scale` and `required_halo` receive both algorithm selections and use the one +corresponding to the requested direction. The filter enum represents the three +Lanczos support radii without a separate parameter for box filtering. -Accept matching rectangular data and attribution rasters, with one halo width -on all sides. Scale both axes by the same factor. Expose a unified -`raster::algorithm::scale` entry point. Keep directional `upscale` and -`downscale` helpers in `raster::algorithm::detail`. - -Express scaling amounts as `n_zoom_levels`, the exponent of two, rather than -as the factor itself. The public `scale` and `required_halo` functions use -a signed count: positive means upscaling, negative means downscaling, and -zero means cropping only. For example, `+2` upscales by four and `-2` -downscales by four. `required_halo` returns zero for zero levels. - -Directional helpers and custom reduction use nonnegative exponent counts: -zero means factor one and two means factor four in the respective direction. - -Accept both input rasters by const reference. Return -`Expected, radix::Raster>>`, -with data first and attribution second; do not introduce a custom result -struct. The implementation plan will present exact function declarations. - -For downscaling, both interior dimensions must be divisible by the factor. -Factor one simply crops the halo from both rasters. Positive dimensions, -matching raster sizes, halo bounds, representable zoom-level counts, and -dimension overflow need validation. Invalid geometry, zoom-level counts, -and insufficient halos return -errors through `Expected`; concrete error codes follow project conventions. - -With input interior pixel centres indexed by integers, direct upscaling by -factor `F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input -coordinates. A reduction by two evaluates pixel `j` at `2j + 0.5`. -Apply these positions independently on each axis, relative to the interior; -the halo offsets buffer addressing rather than changing the sampling phase. - -## Algorithms - -Upscaling offers nearest-neighbour and bilinear interpolation. Cubic and -Lanczos upscaling have been removed from the proposed scope. Algorithm -selection is caller policy; this facility supplies the implementations. -Upscale directly from the input to the requested final resolution rather than -repeating interpolation by two. +## Algorithms and reducers + +Upscaling offers nearest-neighbour and bilinear interpolation. Upscale directly +from the input to the final resolution rather than repeating interpolation by +two. Nearest-neighbour sampling copies the selected stored pixel exactly, without +a decoder/encoder round trip; neither callable is invoked. Bilinear interpolation +uses all four neighbours; there is no attribution-based bypass or exclusion. Downscaling offers box filtering and Lanczos with lobe count/support radius -2, 3, or 4. Lanczos support is widened for the reduction ratio to low-pass -before decimation. Implement reduction by two and repeat it for larger -power-of-two reductions. - -For Lanczos radius `a`, the one-dimensional reduction kernel is proportional -to `sinc(t / 2) * sinc(t / (2a))` for `abs(t) < 2a` and zero otherwise, where -`sinc(x) = sin(pi*x) / (pi*x)` and `sinc(0) = 1`. Two-dimensional weights -are the products of the axis weights. Normalize over valid contributions -as specified below; a constant kernel multiplier cancels in normalization. - -Custom reduction also operates on a 2x2 block at each reduction step. Expose -templated interfaces accepting lambdas and functors, without requiring a -function pointer or type-erased callable. Provide min, max, and median -functors. A reducer receives only valid samples, already decoded to linear -values; the scaler encodes its result as needed. It is never called with an -empty sample range. - -The callable accepts `std::span` containing one to four -valid decoded samples in row-major order, with invalid samples omitted, and -returns one `WorkingPixel`. Its component type follows the working-type -table below; vector length is preserved. The supplied min, max, and median -functors operate component-wise. For an even number of samples, median is -the average of the two middle component values. A NaN in a component -propagates to that result component in these supplied functors. Custom -functors control their own treatment of attributed NaNs. - -## Validity and representative attribution - -Attribution zero is the sole NoData marker. A nonzero attribution remains -valid regardless of its payload, including a nonfinite floating-point value. -Exclude NoData samples from interpolation, filtering, and custom reduction. -Their payload values must not contribute to arithmetic or normalization. - -For every upscaling mode, sample attribution with nearest-neighbour sampling. -Do not vote among neighbouring attribution IDs during upscaling. When data -also uses nearest-neighbour sampling, copy the selected input pixel's payload -and attribution exactly, including NoData, without a colour-conversion round -trip. For bilinear interpolation, if the nearest pixel's attribution is zero, -copy that pixel's payload and skip interpolation, even if other neighbours -are valid. Otherwise interpolate using only nonzero-attribution neighbours -and normalize over their weights. Never mix NoData payloads into a valid -output value. - -For reduction by two, the local block is the 2x2 input block covered by the -output cell. Select attribution using this local block, independently of the -larger Lanczos support: - -1. Ignore attribution zero. -2. Prefer a nonzero ID occurring at least twice in the local block. -3. Otherwise choose a nonzero ID occurring at least once. -4. Resolve multiple eligible IDs deterministically; the particular tie order - is not prescribed. - -If this reduction block contains no valid pixel, return attribution zero and copy -the payload of one pixel from that block, even if valid samples exist farther -out in the filter support. The payload is unspecified to callers when -attribution is zero, but remains initialized; no C++ undefined behavior is -intended. - -Otherwise, reduction filters use valid samples from their filter support and -normalize by the sum of their weights. If that denominator is zero or -numerically unstable, copy a valid pixel from the local block. Attribution -still follows the local voting rule. The implementation treats the denominator -as unstable when `abs(sum(weights)) <= 32 * epsilon * sum(abs(weights))`, -where `epsilon` belongs to the working component type. Negative stable sums -are permitted. Zero-weight samples do not contribute payload arithmetic. - -Keep one representative attribution per output pixel. Complete provenance -and contributing-source sets are not required. Apply attribution selection -again at every reduction step; do not retain original-source weight totals. - -## Value mapping and numeric behavior - -Support the existing arithmetic scalar and GLM-vector pixel types, returning -the same stored pixel type. Numeric filtering is component-wise. Normal -normalization and other layer-specific postprocessing belong to callers. - -Accept an explicit value mapping. Linear mapping applies to scalar and vector -data, including elevations. Initially support sRGB mapping only for -three- and four-component unsigned 8-bit pixels. Decode RGB to linear light -before filtering or custom reduction, and encode the resulting RGB values -afterwards. For RGBA, alpha is linear and filtered independently; alpha does -not weight RGB or determine NoData validity. - -Choose the working component type from the stored component type: - -| Stored component type | Working component type | +2, 3, or 4. Implement reduction by two and repeat it for larger reductions. +Box filtering averages the complete local 2x2 block. Lanczos support is widened +for the reduction ratio to low-pass before decimation. + +For Lanczos radius `a`, the one-dimensional kernel is proportional to +`sinc(t / 2) * sinc(t / (2a))` for `abs(t) < 2a` and zero otherwise, where +`sinc(x) = sin(pi*x) / (pi*x)` and `sinc(0) = 1`. Two-dimensional weights are +products of the axis weights. Normalize over the complete kernel support, +independently of payloads or attribution. Zero-weight samples do not contribute +payload arithmetic. The former masked-weight cancellation and empty-block +payload fallbacks do not apply: there is no validity mask changing the support. + +Custom reduction also operates on a complete 2x2 block at each step. Accept +lambdas and functors through templates, without type erasure or a required +function pointer. Let `W` be the value type returned by the decoder. The +reducer receives `std::span` containing exactly four decoded values +in row-major order and returns exactly one `W` by value. Encode that result +into the original stored pixel type after each step. With the default identity +tuple, `W` is the stored type, including integer types. No samples are omitted +and no blocks bypass the callable during a nonzero reduction. + +Invoke reducers through `const Reducer&`. Immutable captured configuration is +allowed, but callbacks must not change state between calls or depend on +invocation order. This matches the existing view-algorithm contract and +supersedes the scaler's earlier support for stateful reducers. Const invocation +alone does not prove the absence of external side effects. + +Provide these stateless functors with templated const call operators: + +- `Min` and `Max`: operate component-wise for scalar and GLM-vector pixels. +- `Median`: operates component-wise and averages the two middle values of the + four samples. For integer components, calculate without overflow and round + halfway cases away from zero; this rule belongs to the functor and also + applies with identity conversion. `Min`, `Max`, and `Mode` select input values. +- `Mode`: operates on scalar-like values. Select the sample with the greatest + frequency under ordinary equality, breaking ties by the first occurrence in + row-major order. Each sample is a candidate with one occurrence of its own; + equal other samples add to its frequency. Zero is an ordinary value. There + is no NaN special case: NaNs do not compare equal to other samples, including + other NaNs, and therefore do not form a repeated group. A first NaN can win + an all-distinct tie; a repeated ordinary value beats it. + +`Mode` is a categorical frequency operation, not a numeric median. Its ordinary +equality semantics above also describe direct functor calls with NaNs; they do +not waive the finite-value precondition for numerical `scale`/`reduce` paths. +The scalar-like value support belongs to the functor itself. Generic `reduce` +does not inspect which functor was supplied or add a `Mode`-specific vector +restriction; no explicit vector-exclusion checks or tests are required. + +## Paired raster-store wrappers + +The paired wrappers accept matching rectangular data and `uint16_t` attribution +sources, each independently supplied as a raster, ordinary view, or clamped +view. Both use the same logical dimensions, halo width, and zoom-level count. +The wrappers do not fill coverage gaps or repair payloads. A caller choosing +clamped inputs is responsible for their source coverage and boundary policy. + +Data follows the requested generic operation. Attribution follows: + +- Upscaling: generic nearest-neighbour scaling, bypassing conversion. +- Downscaling: generic custom reduction with scalar `Mode` and identity conversion, + repeated by two, independently of the data filter's larger support. +- Zero levels: crop both sources exactly. + +Every attribution value participates, including zero. For example, +`[0, 0, 0, 7]` reduces to zero. Attribution never changes the data samples, +weights, interpolation path, or result. Attribution reduction operates directly +on `uint16_t` IDs without floating-point conversion. Keep one representative ID; +complete provenance and original-source weight totals are not retained. + +Provide these call shapes under `raster_store::scaler`. Paired `scale` accepts +an explicit `raster_store::pixel::Mapping` for data interpretation. Paired +`reduce` accepts a decoder/encoder tuple of lambdas or functors, defaulting to +identity conversion, and has no mapping enum parameter: + +```cpp +scale(data, attribution, halo_width, n_zoom_levels, + interpolation, filter, value_mapping); +scale(data, attribution, halo_width, n_zoom_levels, + interpolation, filter, value_mapping, data_destination, attribution_destination); + +reduce(data, attribution, halo_width, n_zoom_levels, reducer); +reduce(data, attribution, halo_width, n_zoom_levels, reducer, + data_destination, attribution_destination); +reduce(data, attribution, halo_width, n_zoom_levels, conversion, reducer); +reduce(data, attribution, halo_width, n_zoom_levels, conversion, reducer, + data_destination, attribution_destination); +``` + +Allocating wrappers return +`Expected, radix::Raster>>`, with data +first. Explicit-destination wrappers return `Expected`. The `scale` wrapper maps +`pixel::Mapping` to a supplied data conversion tuple and invokes the generic +algorithm. Custom scaling tuples remain available through the generic +single-raster API. The `reduce` wrapper forwards its data conversion tuple and +reducer to generic `reduce`. Attribution reduction always uses `Mode` with +identity conversion, independently of the data tuple. +Reuse generic validation and processing helpers rather than duplicate kernels. + +`Mapping::Linear` selects the supplied linear conversion for `scale`; +`Mapping::SRGBA` selects sRGB conversion for `scale` and requires RGB/RGBA +unsigned 8-bit pixels. Validate mapping enumerators and compatibility before +writing either output. Callers may explicitly supply the sRGB conversion tuple +to paired `reduce`, just as they can supply custom lambdas. + +### Shared pixel description + +The former `raster_store/pixel_type.h` is now `raster_store/pixel.h`, with namespace +`raster_store::pixel`. `pixel::identifier()` is public; `Format` +and its specializations live in `pixel::detail::Format`. Existing identifier +strings and pixel-layout requirements are preserved. Storage and codec use +the new header and names. + +Define `pixel::Mapping { Linear, SRGBA }` in that same header. It replaces the +former scaler `ValueMapping` name while retaining runtime mapping selection at +the paired `scale` boundary. The planned snapshot metadata and creation options +use this same type for `value_mapping`; do not define a separate enum in the +manifest's versioned namespace. Generic raster algorithms accept conversion +tuples and do not depend on the raster-store pixel header or mapping enum. +Adding the planned metadata field remains part of the separate halo/metadata +implementation, not an implicit schema change in this refactor. + +## Conversion tuples and numeric behavior + +A conversion is a `std::tuple` of two lambdas or functors, in decoder/encoder +order. Invoke both through const references, without type erasure. Like the +reducers, they may capture immutable configuration but must not change state +or depend on invocation order. + +For source pixel type `T`, the decoder accepts `const T&` and returns a plain +working pixel value `W`. The encoder accepts `const W&` and returns exactly +`T` by value. Neither callable returns `Expected`, a reference, or a different +output pixel type. Infer `W` from the decoder; generic algorithms do not choose +it through a `WorkingPixel` mapping. The reducer also returns exactly `W`. + +`scale` supports floating-point working components, including floating-point +GLM vectors. Integer source and destination rasters remain supported through +their conversion tuples. `reduce` supports integer and floating-point working +components, including GLM vectors. Operations +work on the decoder's result; callers choose any layer-specific interpretation +or postprocessing through their callables. + +For numerical paths, the caller must ensure that decoded values, intermediate +arithmetic, and reducer results remain finite, and that values passed to the +encoder are within its supported domain. Finite decoder output alone is not +sufficient: arithmetic can overflow or a custom reducer can produce infinity. +No runtime validity scanning, per-pixel conversion errors, or special recovery +is promised for precondition violations. This supersedes the former promise +to preserve NaN/Inf through numerical filtering or report nonfinite-to-integer +conversion errors. Nearest-neighbour sampling and zero-level cropping instead +copy stored pixels exactly, never invoke decoder/encoder callables, and have +no finite-value requirement. + +### Supplied conversion tuples + +Provide reusable tuple factories alongside the algorithms: + +- `identity_conversion()`: decoder and encoder return their input value + unchanged. This is the default for `reduce`, preserving integer working + values exactly. It can also be used with `scale` when the resulting working + components are floating point. +- `linear_conversion()`: default for `scale`. The decoder casts stored + components to the former working precision shown below, without changing + the interpretation or scale of linear values. Its encoder converts back to + `T`; for integer components it rounds halfway cases away from zero and + clamps finite results to the representable range before conversion. For + floating-point storage it preserves representable finite overshoot. These + conversions belong to the supplied tuple, not generic scaling/reduction. +- `srgb_conversion()`: supports RGB/RGBA unsigned 8-bit stored pixels. Decode + RGB through the sRGB transfer function into linear-light float components + and encode back afterwards. This is sRGB, not an arbitrary gamma exponent. + Decode alpha, when present, linearly into `[0, 1]` and encode it independently; + alpha neither weights RGB nor excludes samples. Encoding rounds and clamps + to the stored channel range. Use this tuple with either operation explicitly. + +The default linear tuple selects its decoder component type as follows, +preserving GLM vector length: + +| Stored component type | Linear decoder component type | |---|---| | 32-bit `float` | `float` | | Signed or unsigned integers up to 16 bits | `float` | @@ -158,242 +319,234 @@ Choose the working component type from the stored component type: | Signed or unsigned 32-bit integers | `double` | | Signed or unsigned 64-bit integers | `long double` | -Apply the same mapping to GLM-vector components. For example, -`glm::vec<4, uint16_t>` uses four `float` working components. This mapping -also determines the decoded samples and results used by custom functors. - -Round integer results, with half-way cases rounded away from zero, and clamp -them to their representable range before conversion. Preserve floating-point -overshoot, NaN, and infinity results. Return an error if a custom reducer -produces a nonfinite result destined for integer storage. - -Filtering uses the specified working type, followed by rounding and -clamping as applicable; results are subject to that type's floating-point -precision. This also applies to weighted 64-bit integer results. Assess -weight cancellation relative to the sum of absolute weights using the -threshold above. Numerical tests use tolerances appropriate to the working -type and fixture, as recorded below. - -Each reduction step stores its result in the output pixel type, including -rounding and sRGB re-encoding. A reduction by four therefore follows the -same stage semantics as two explicit reductions by two, with enough halo -available for the second step. This includes custom reduction and attribution. +For example, `linear_conversion>()` decodes to four +`float` components, whereas identity conversion keeps four `uint16_t` +components for reduction. A caller may supply a different decoder precision. +Numerical results are subject to the chosen working type; exact weighted +64-bit integer arithmetic is not promised. Identity reduction avoids an +unnecessary conversion for integer min/max/mode, and integer median uses its +own overflow-safe rounding rule. + +Every nonzero reduction step encodes its result into the original stored +pixel type. Subsequent steps decode that stored result again, including any +rounding or sRGB conversion. Reduction by four therefore has the same stage +semantics as two explicit reductions by two with sufficient halos and the same +tuple. Repeated median and mode need not equal a direct reduction over the +original larger block. Repeated rounding and encoding also prevent a general +promise of agreement with a single larger numeric filter. + +The planned snapshot metadata uses `raster_store::pixel::Mapping` to describe +linear or sRGB interpretation. The future halo extractor passes that value to +paired `scale`, which selects the corresponding tuple. Runtime mapping +selection stays at this store boundary; generic `scale` and `reduce` receive +only conversion callables. This refactor does not change the manifest schema. + +## Halo and clamped-view contract + +Require an explicit `halo_width` for every source representation. It identifies +the interior within the logical input rectangle, whether halo pixels occupy +physical storage or are supplied by a clamped view. Clamped views still have +finite logical bounds; they do not grant arbitrary out-of-bounds access. + +For example, bilinearly upscaling a 4x4 interior requires a logical 6x6 source +with a one-pixel halo. A caller choosing edge replication can construct: -Repeated validity-aware reduction is deliberately not a direct reduction -over all original valid samples. For example, one 2x2 block containing one -valid zero and another containing four valid values of 100, with the other -blocks empty, produces 50 after two box reductions rather than the direct -five-sample average of 80. Repeated median likewise need not equal the median -of the original larger block. - -## Halo contract - -Reject an insufficient input halo. Determine the requirement from the -complete operation, including every step of repeated reduction, rather than -only the final kernel. Provide a query for the required input halo. +```cpp +auto padded = raster::make_clamped_view(source, {-1, -1}, {6, 6}); +// After checking padded, pass *padded with halo_width = 1. +// At n_zoom_levels = +1 the output is 8x8, with no output halo. +``` -Retain enough intermediate halo for subsequent reductions and crop only the -final result to the requested interior. Internal tile edges must not change -filter support or results. +An ordinary 6x6 source view can instead supply physical neighbours. The scaler +does not construct clamped views implicitly. It rejects an insufficient logical +halo for either source kind. Internal tile edges must not change filter support. -The area-grid geometry and compact kernel support imply these minimum input -halo widths, in input pixels on each side, including corners: +Compute the required halo for the complete operation, including all reduction +steps. Retain enough intermediate halo for later steps and crop only the final +result. Minimum widths, in input pixels on each side including corners, are: | Operation | Required halo | |---|---| -| Factor one (crop only) | 0 | +| Zero levels (crop only) | 0 | | Nearest-neighbour upscaling | 0 | -| Bilinear upscaling, factor greater than one | 1 | +| Bilinear upscaling, positive levels | 1 | | Box or custom 2x2 reduction, including repeated steps | 0 | -| Lanczos reduction by total factor `F`, fixed radius `a` | `(2a - 1) * (F - 1)` | - -For one Lanczos step, the first output centre is `0.5` and its contributing -input indices range from `1 - 2a` through `2a`, giving a halo of `2a - 1`. -If that step must retain an output halo `h`, its input halo must be -`2h + (2a - 1)`. Repeating this relation gives the total-factor formula. -For example, Lanczos-3 needs 5 input halo pixels for reduction by two and -15 for reduction by four. The required-halo query must check arithmetic -overflow as well as the operation's parameters. The public query takes -`n_zoom_levels`; `F` in the formulas above is the derived power-of-two -factor. Validate the exponent before constructing that factor, including -handling the signed count without overflowing when obtaining its magnitude. - -There is no `required_reduction_halo` function: custom block reduction -always requires zero halo. Its entry point still validates the zoom-level -count and dimensions. +| Lanczos reduction by total factor `F`, radius `a` | `(2a - 1) * (F - 1)` | + +For one Lanczos step, the first output centre is `0.5` and input indices range +from `1 - 2a` through `2a`, giving a halo of `2a - 1`. Retaining output halo +`h` needs input halo `2h + (2a - 1)`; repeated application yields the formula. +Lanczos-3 therefore requires 5 input halo pixels for reduction by two and 15 +for reduction by four. Halo queries validate parameters and arithmetic overflow. + +There is no `required_reduction_halo`: custom block reduction always needs zero +halo, though its supplied halo is still cropped and its geometry validated. +Paired wrappers use the data operation's requirement, which also suffices for +nearest-neighbour or mode attribution. + +## Overlap and failure behavior + +Reject input/output storage overlap for nonzero scaling or reduction, using the +existing view helpers and physical footprints for clamped views. Zero-level +cropping follows `copy`: permit an identical interior/destination mapping, +reject partial overlap. Copying into an overlapping displaced region is not +promised to behave like a snapshot. + +Paired wrappers validate both output geometries and input/output relationships +before writing either output. Check cross-overlap as well as each corresponding +pair, including when data is also `uint16_t`; reject overlapping output rasters. +Inputs may overlap each other when they do not violate these output constraints. + +Complete argument validation before writing output or invoking reducers or +conversion callables. Invalid arguments leave destinations unchanged. A later +failure, such as an allocation failure while producing paired outputs, has no +rollback guarantee; paired outputs are not transactional. An allocating overload +returns an error without exposing its partial raster. Callback exceptions +receive no special catching, translation, or rollback guarantee, matching the +view algorithms. Numerical precondition violations are not returned as errors. + +Use `InvalidInput` for invalid geometry, parameters, overlap, and insufficient +halos, and `ResourceExhausted` for unrepresentable allocation sizes or allocation +failure. Pixel, decoder, encoder, and reducer type incompatibilities are +compile-time errors. Follow the project's `Error::fail`/`Error::propagate` style. ## Relationship to halo extraction -The [halo extractor](tiles-with-halo.md) uses this facility with box -downscaling only. It has no downscaling-algorithm parameter. The upscaling -interpolation algorithm remains an extraction parameter. Scaling behavior -is defined here rather than repeated in the halo design. - -World wrapping and other boundary behavior remain the halo extractor's -responsibility. Its central-tile payload replication for uncovered or invalid -halo pixels is a separate caller policy that must not be silently replaced -by the scaler's local fallback. The -[attribution ADR](../adr/0004-representative-attribution-for-halo-samples.md) -records why one representative source ID is retained rather than complete -provenance, referring here for the selection rules. - -## Implementation - -The scaler and tests were implemented in the terrain-builder worktree. -Halo extraction and its storage metadata remain a separate implementation -task; their documentation already describes use of this facility. - -### Public API - -The API is in `src/terrainlib/raster/algorithm.h`, in namespace -`raster::algorithm`. The public -`scale` and `required_halo` use the agreed signed direction convention; -directional helpers and custom reduction use unsigned exponent counts. - -```cpp -namespace raster::algorithm { - -enum class Interpolation { NearestNeighbour, Bilinear }; -enum class Filter { Box, Lanczos2, Lanczos3, Lanczos4 }; -enum class ValueMapping { Linear, SRGBA }; - -template -Expected, radix::Raster>> -scale(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, int n_zoom_levels, - Interpolation interpolation, Filter filter, ValueMapping value_mapping); - -template -Expected, radix::Raster>> -reduce(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, unsigned n_zoom_levels, - ValueMapping value_mapping, Reducer&& reducer); - -Expected required_halo( - int n_zoom_levels, Interpolation interpolation, Filter filter); - -namespace detail { - -template -Expected, radix::Raster>> -upscale(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, unsigned n_zoom_levels, - Interpolation interpolation, ValueMapping value_mapping); - -template -Expected, radix::Raster>> -downscale(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, unsigned n_zoom_levels, - Filter filter, ValueMapping value_mapping); - -Expected required_upscaling_halo( - unsigned n_zoom_levels, Interpolation interpolation); -Expected required_downscaling_halo( - unsigned n_zoom_levels, Filter filter); - -} // namespace detail -} // namespace raster::algorithm -``` - -The filter enum exposes all three Lanczos radius choices without a separate -parameter that would be meaningless for box filtering. The unified functions -receive interpolation and filter selections and use the one corresponding -to the requested direction. Directional halo calculations remain detail -helpers. Custom block reduction has no halo-query function. - -Expose `WorkingPixel` as a type alias implementing the agreed -component mapping, and `Min`, `Max`, and `Median` as stateless functors with -templated call operators accepting the agreed spans. Constrain the custom -reduction overload to a callable with that input and working-pixel result. -Reuse the supplied functor through the repeated stages without type erasure. - -The scaler `ValueMapping` is independent of snapshot metadata. -The planned metadata enum does not exist in code yet; the future halo -extractor can explicitly map its persisted selection to the scaler's enum. -This implementation does not require changing the manifest schema. - -### Implementation sequence followed - -1. Add the public declarations, working-pixel traits, parameter validation, - the unified dispatcher and required-halo query, zero-level cropping, and - safe numeric conversions. -2. Implement direct upscaling and a shared 2x reduction path for built-in - filters and templated reducers. Keep validity and attribution handling - explicit, precompute reusable filter coefficients, and retain required - intermediate halos. Normalize valid two-dimensional contributions rather - than independently normalizing rows and columns around NoData holes. -3. Add the supplied reduction functors and focused Catch2 coverage in - `unittests/terrainlib/raster_algorithm.cpp`. Register the new header and - tests in the existing terrainlib CMake targets. -4. Build the existing Debug terrainlib test target, run the focused raster - cases and the terrainlib regression suite, and inspect the numerical - fixtures before fixing tolerances. Use the Qt C++ skill for linting and - `clang-format-21` on the new C++ portions. Check whitespace and Git-attribute - line endings before delivery. - -Use `InvalidInput` for invalid geometry, zoom-level counts, enum selections, -insufficient halos, and nonfinite custom results destined for integers. Use `Unsupported` -for a value mapping incompatible with the pixel format, and -`ResourceExhausted` for unrepresentable output/allocation sizes. Validate -counts before constructing raster buffers and use `Error::fail` and -`Error::propagate` according to the project style. - -Tests should compare against independent reference calculations and exercise -the behavioral cases below. Include scalar and vector type-mapping checks, -integer boundary conversions, an actual lambda and stateful functor, -different halo widths, and multi-stage tile/metatile equivalence. Numeric -fixtures should measure constant preservation and high-frequency attenuation -with tolerances appropriate to the selected working type. - -## Verification to cover - -The implementation plan should include: - -- Constant preservation, impulse/frequency response for Lanczos reduction, - and area-tile/metatile equivalence with sufficient halos. -- Invariance under different input chunk partitions and absence of internal - edge clamping. -- Local reduction attribution voting, deterministic ties, all-invalid local - blocks with valid outer support, and unstable weighted normalization. -- Nearest-neighbour attribution for every upscaling mode, exact data copies - for nearest-neighbour interpolation, and direct multi-factor upscaling. -- Bilinear NoData exclusion and nearest-NoData payload copying even when - other bilinear neighbours contain valid data. -- Excluding NoData from custom functor input and bypassing empty reductions. -- Row-major functor samples, working component types, component-wise - min/max/median, even-count median, and supplied-functor NaN propagation. -- Repeated reduction semantics for data, attribution, rounding, and sRGB. -- Linear-light RGB filtering and independent linear alpha filtering. -- Integer limits, rounding, floating-point overshoot, and attributed - nonfinite values under the final numerical contract. -- Exact sufficient halo, insufficient halo errors, and final halo cropping. -- Rectangular inputs, factor-one cropping, divisibility checks, and invalid - zoom-level counts, dimensions, and overflow under the final API. -- Unified dispatch and halo queries, exponent-to-factor conversion, and - rejection of extreme counts before signed negation or shifting can overflow. - -Vertex shared-edge tests, physical-source selection, world wrapping, and -input tile-scheme normalization remain broader generator/extractor tests. - -### Verification results — 2026-09-19 - -- Existing GCC Debug build: `unittests_terrainlib` builds successfully. -- Focused `[raster-algorithm]` suite: 34 cases, 904 assertions, all passing. -- Terrainlib regression suite, excluding the existing - `mesh::clip_on_bounds benchmark`: 519 cases, 25698 assertions, all passing. -- The independent double-precision Lanczos reference agrees with the float - implementation within `3e-6` for the validity-aware fixture. Constant-7 - Lanczos fixtures remain within `3e-5`; the high-frequency fixture at - 0.4 cycles per input pixel has output magnitude below 0.03 for every radius. -- Separately supplied reduction stages and tile/metatile results agree - exactly in the exercised fixtures. Endpoint, nonfinite, vector-precision, - sRGB, and invalid-input cases pass. -- Qt C++ deterministic lint reports no findings; all six review missions - report no confirmed defects. An optional future profiling target is the - repeated sRGB decoding of overlapping Lanczos samples. -- New C++ is formatted with `clang-format-21`; whitespace and Git-attribute - line-ending checks pass. +The planned [halo extractor](tiles-with-halo.md) calls the paired store wrappers +with box downscaling; it has no downscaling-algorithm parameter. Upscaling +interpolation remains an extraction parameter. Algorithm details are owned here. + +The extractor resolves physical coverage, source windows, world wrapping, and +required halos. It preserves supplied and resampled zero-attribution payloads. +Only missing physical coverage, including vertical world limits, uses a clamped +view of the central interior to replicate data with synthesized attribution zero. +There is no attribution-based replacement after scaling. See the +[attribution ADR](../adr/0004-representative-attribution-for-halo-samples.md) for +the decision to retain one representative ID rather than complete provenance. + +## Implementation and verification plan + +1. Move the scaler into headers under `raster/algorithm/`, retaining the umbrella + includes. Reuse view normalization, read-only access, allocation, and overlap + helpers, and `copy` for zero-level cropping. Extend + `window_transform` to even kernels and a stride parameter to share + downscaling traversal, as agreed below. Direct upscaling retains its + coordinate mapping over the shared view interface. +2. Remove coupled validity/attribution paths, implement both generic overload + patterns, retain intermediate halos, and add const-callable four-sample + reduction and scalar `Mode`. Replace `ValueMapping` and the generic fixed + working-type policy with decoder/encoder tuples; provide linear numeric, + sRGB, and identity factories with the agreed operation defaults. +3. Add thin paired wrappers with `pixel::Mapping` selection for `scale`, + identity-default conversion tuples for `reduce`, and validation of + both destinations before writes. Consolidate the pixel identifier and + mapping type in `raster_store/pixel.h`. Update CMake registrations and + existing tests for the revised contracts. +4. Build and run focused and terrainlib regression tests. Verify GCC 16 Release + compilation with warnings as errors: the existing median reducer produced + an array-bounds diagnostic in `std::sort`. An explicit `std::min(size, 4)` + bound did not eliminate it. Report whether the refactor incidentally fixes + the warning or it still occurs. Do not take additional steps specifically + to fix or suppress it; any such fix requires the user's separate decision. + Apply Qt C++ lint and format new C++ with `clang-format-21`. + +### Agreed window-transform reuse + +The extension accepts `glm::uvec2` kernel sizes and strides, with positive +components and even or odd rectangular kernels. There are no scalar/square +shorthand overloads. Omitting the stride selects `{1, 1}`. Per-axis output size is +`1 + (input_size - kernel_size) / stride`, using integer division after checking +that the kernel fits. Windows start at `output_coordinate * stride`, +component-wise; incomplete trailing strides are not sampled. The scaler retains its stricter divisibility +checks before invoking this primitive. + +For a 2x2 kernel and stride two, even input dimensions produce output dimensions +exactly half as large. For example, 8x6 becomes 4x3. Larger kernels also consume +border pixels: an 8x8 input with a 4x4 kernel and stride two produces 3x3 output. +Both the allocated raster and explicit destination use these same dimensions; +the caller supplies a destination of that size, and the operation does not +resize its view. + +The extension expresses every factor-two reduction step: + +| Operation | Kernel size | Stride | +|---|---|---| +| Custom reduction, including attribution mode | `{2, 2}` | `{2, 2}` | +| Box, horizontal then vertical | `{2, 1}`, then `{1, 2}` | `{2, 1}`, then `{1, 2}` | +| Lanczos radius `a`, horizontal then vertical | `{4*a, 1}`, then `{1, 4*a}` | `{2, 1}`, then `{1, 2}` | + +Box and Lanczos use separable passes. The horizontal pass decodes stored pixels +and produces a raster of working pixels; the vertical pass filters those values +and encodes the final output. Encode only after both passes, never between them. +The next zoom level decodes that stored output again. Rectangular windows also +support caller-supplied separable filters such as Gaussian; no Gaussian scaler +mode is introduced. + +Custom reduction callbacks decode the 2x2 window, invoke the reducer, and encode +the output pixel. Separable filter callbacks split that work across two passes. The shared operation owns spatial traversal and destination assignment, +including ordinary/clamped input support. The scaler still owns repeated levels, +intermediate rasters, retained halos, and cropping the stage input to the exact +required window. Validate the public source/destination overlap before narrowing +that input; keep paired cross-overlap validation outside the primitive. + +Direct upscaling still requires separate coordinate mapping. The code +savings are modest: the reusable traversal replaces output loops and coordinate +setup, but not filter arithmetic or the level driver. Verification covers rectangular/even kernels, per-axis positive strides, +sample origins, dimensions, ordinary/clamped equivalence, and encoding only +after both filter passes. + +### Required coverage + +- Raster, ordinary subview with row stride, and clamped-view sources; allocating + and explicit-destination routes; read-only input and writable output constraints. +- Output equivalence across routes and view forms, unchanged pixels outside + destination subviews, empty-input/interior rejection, dimensions, divisibility, + enums, overflow, extreme zoom counts, and insufficient logical halos. +- Clamped logical halos versus equivalent materialized padding, no implicit edge + clamping, and tile/metatile equivalence with sufficient physical halos. +- Overlap rejection, clamped physical-footprint overlap, zero-level identical + copies, paired cross-overlap, and argument errors before any output writes. +- Independent numeric references, constant preservation, Lanczos impulse and + frequency response, direct multi-factor upscaling, and repeated-step equality. +- Exactly four decoded reducer samples in row-major order, const-callable lambdas + and configured functors, decoder-selected working types, and the distinct + scale/reduce defaults. Check integer identity reduction, including 64-bit + min/max/mode and overflow-safe, halfway-away-from-zero integer median. +- Supplied and custom tuples, same stored input/output type, and compile-time + rejection of incompatible types and `Expected`-returning converters. Verify + that NN/crop bypass both callables and preserve nonfinite payloads exactly. +- Scalar mode, first-occurrence ties and zeros. Direct functor tests may check + distinct NaNs and repeated ordinary values beating NaNs; numerical pipeline + tests respect the finite-value + precondition. +- Paired output matching two independent generic calls; NN attribution on every + upscaling path, mode at every downscaling step, and all-zero attribution having + no effect on data arithmetic, including when Lanczos uses outer support. +- Linear-light RGB, independent alpha, integer limits, finite rounding/clamping, + representable floating overshoot, and repeated sRGB stage semantics. + +Vertex shared edges, physical-source selection, world wrapping, and tile-scheme +normalization remain broader generator/extractor tests. + +### Refactor verification — 2026-09-22 + +- Focused scaler/view tests: 61 cases, 1,347 assertions passed. +- Terrainlib: 546 cases, 26,141 assertions; RF builder: 47 cases, 53,812 assertions; + DAG builder: 76 cases, 479 assertions. +- Eleven affected public headers compile independently with warnings as errors. +- GCC 16 Release compilation of the scaler test translation unit at `-O3` + with warnings as errors passed. The previous median `std::sort` array-bounds + diagnostic did not recur; no warning-specific fix or suppression was added. +- New and changed C++ is formatted with `clang-format-21`. Qt lint reports only + an existing warning on unchanged `storage.h` code about a default-constructed + `std::optional`. + +### Historical verification of the previous implementation + +The coupled scaler passed 34 cases/904 assertions and the terrainlib suite +passed 519 cases/25,698 assertions, excluding the existing mesh clipping +benchmark, on 2026-09-19. Its independent Lanczos fixtures, type conversions, +Qt review, formatting, and line-ending checks passed. CI for commit `2a75fb4` +subsequently passed GCC 14, Clang unity, ASan, and TSan; GCC 16 failed on the +median sort diagnostic. These results are a baseline, not verification of the +refactor specified above. Halo extraction remains unimplemented. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index c74b1797..df9553f3 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -66,8 +66,8 @@ 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. -Pixel validity and the reserved meaning of -index 0 are defined under [NoData](#nodata-and-payload-preservation). +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 @@ -84,6 +84,15 @@ tiles in that snapshot. Any positive square dimensions are permitted; side lengths need not be powers of two. Both tile rasters must match the metadata dimensions. +The pixel API consolidates the payload identifier in +`raster_store/pixel.h`, exposed as `raster_store::pixel::identifier()`, with +its `Format` implementation in `pixel::detail`. The same header defines +`pixel::Mapping { Linear, SRGBA }` for the raster-store scaler wrappers and +the planned snapshot `value_mapping` field. This mapping describes value +interpretation separately from the payload-type identifier. The metadata +extension is specified in [Tiles with halo](tiles-with-halo.md); it is not +part of the currently implemented version 1 representation below. + 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 @@ -175,11 +184,16 @@ equally sized types such as `float` and `uint32_t`. See [the native-buffer decision](../adr/0002-native-raster-pixel-buffers.md). -### NoData and payload preservation +### 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. -Attribution rasters default to index 0, the sole indicator of NoData. The -fields of attribution entry 0 do not determine pixel validity. A NaN with a -nonzero attribution index remains an attributed value, while an ordinary -numeric value with index 0 is NoData. -Storage preserves payloads losslessly, including NaNs and values underneath -NoData pixels. Consumers are responsible for interpreting these values. +Storage 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. The single-raster +[scaling refactor](scaling.md) specifies this separation; the design is agreed, +while the existing coupled scaler still awaits replacement. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 77592087..8e7f4dba 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -47,8 +47,15 @@ The halo width of the tile requested by a consumer of the raster store. **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. -**NoData pixel** -: A stored pixel with attribution index 0, regardless of its payload value. A numeric sentinel such as NaN alone does not make a pixel NoData. +**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 diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md index a2138c78..f3d1d33f 100644 --- a/docs/raster-store/tiles-with-halo.md +++ b/docs/raster-store/tiles-with-halo.md @@ -1,7 +1,8 @@ # Tiles with halo Status: design interview in progress. Decisions were recorded during -2026-09-13 through 2026-09-15, with scaling integration revised on 2026-09-18; +2026-09-13 through 2026-09-15, with scaling integration revised on 2026-09-18 +and physical-coverage filling revised on 2026-09-22; implementation has not been authorized. This design revises version 1 directly; no existing snapshots require compatibility. @@ -40,10 +41,12 @@ Both rasters remain square. Revise the existing version-1 representation directly. No metadata upgrade or legacy-snapshot compatibility path is required. -Define `ValueMapping` with values `Linear` and `SRGBA` in -`raster_store::io::manifest::detail::v1`, export it into the `manifest` -namespace, and use it as `value_mapping` in metadata and -`storage::CreateOptions`. This is a +Use `raster_store::pixel::Mapping`, with values `Linear` and `SRGBA`, as +`value_mapping` in metadata and `storage::CreateOptions`. Define it in +`raster_store/pixel.h`, alongside the public `pixel::identifier()` and +internal `pixel::detail::Format`; do not define a duplicate mapping enum in +the versioned manifest namespace. The paired wrappers in +`raster_store/scaler.h` use this same type. This is a value mapping rather than a colour-space label: linear data also includes elevation. `SRGBA` applies to RGB as well as RGBA: RGB channels use the sRGB transfer function; alpha, when present, is linear. @@ -79,10 +82,12 @@ follows the scaling API. Pass metadata explicitly as `const io::manifest::Metadata&`, allowing callers to read it once and reuse it across extractions. No metadata member is added to `Raster` or `Tile`. -Perform resolution changes through `terrainlib/raster/algorithm.h`, using -these selections. The [scaling contract](scaling.md) owns algorithm -behavior, attribution selection, value mapping, numeric conversion, and -required source halos; do not duplicate those rules here. The extractor +Perform resolution changes through the planned paired wrappers in +`terrainlib/raster_store/scaler.h`, which delegate to the generic algorithms +exposed by `terrainlib/raster/algorithm.h`, using these selections. +The [scaling contract](scaling.md) owns algorithm behavior, attribution +selection, conversion-tuple contracts, and required source halos; +do not duplicate those rules here. The extractor provides the source windows and required halos for the selected operation. Validate the requested tile ID and require a physical tile. Both `Leaf` and @@ -96,7 +101,7 @@ For `H = 0`: 1. Allocate the two result rasters with side `N + 2h`. 2. Copy the central tile's data and attribution into the interior exactly, - including payload values under NoData pixels. Do not resample the centre. + including payload values with attribution zero. Do not resample the centre. 3. Fill the halo from spatial neighbours, including diagonal neighbours. Prefer a physical tile at the requested zoom. 4. If that tile is not physical, descend into intersecting child regions, @@ -110,8 +115,8 @@ For `H = 0`: Fetch each supplying ancestor at most once per extraction. Ancestor samples must not overwrite regions supplied by selected same-zoom or descendant -tiles. This protection includes selected NoData samples: NoData is not an -invitation to fill the region from another level. +tiles. This protection includes selected pixels with attribution zero: +missing attribution does not trigger fallback to another level. Resolve the source regions before reading their payloads. Partition the halo into disjoint regions assigned to source tile IDs using the accepted @@ -127,36 +132,43 @@ Consequently an output cell's geometric footprint need not combine different selected tile resolutions. The scaling facility determines any additional source samples required by the selected operation. -Selection is based on physical coverage, not pixel validity. NoData holes -inside a selected physical tile do not trigger a search at other levels. -Attribution zero is the sole NoData marker, regardless of the payload value. -Resampled validity and representative attribution follow the scaling -contract. Complete provenance is not required. - -If a generated halo pixel has attribution zero, retain that attribution and copy -the data value of the closest pixel in the central tile. In coordinates -relative to the interior, this is one constant-time lookup at -`(clamp(x, 0, N-1), clamp(y, 0, N-1))`. Do not search for a valid replacement: -the chosen central pixel may itself be NoData. Do not copy its attribution -into the halo pixel. +Selection is based on physical coverage, not attribution. Pixels with +attribution zero in a selected physical tile are valid inputs to scaling and +do not trigger a search at other levels. Preserve their supplied or resampled +payloads. The caller is responsible for supplying usable data values; +attribution does not mask numerical contributions. Representative attribution +follows the scaling contract. Complete provenance is not required. + +Only regions without a selected physical source use central-tile replication. +Read their data through a `raster::ClampedView` of the central tile's interior, +with the uncovered region's coordinates relative to that interior. The view +replicates the closest central pixel without allocating a padded source. +Set attribution to zero for these synthesized pixels, independently of the +central pixel's attribution. Do not replace a supplied or resampled payload +merely because its attribution is zero. Wrap horizontally at the antimeridian. Do not wrap vertically. Beyond the -vertical world limits, and wherever no neighbour representation supplies a -valid contribution, use the same central-tile replication with attribution +vertical world limits, and wherever no selected physical representation covers +a region, use the same clamped-view replication with attribution zero. The central interior remains unchanged. -Pass the metadata's value mapping to the scaling facility. Native copies -remain bit-preserving. Rescaling uses the facility's numeric and validity -contract; no separate radix rescaling refactor is required for extraction. +Pass the metadata's `pixel::Mapping` to the paired scaling wrappers, which +select the supplied linear or sRGB decoder/encoder tuple. Do not pass a +value-mapping enum into the generic algorithms. Native and +nearest-neighbour copies bypass conversion and remain bit-preserving. +For numerical resampling, callers must supply finite decoded values and keep +intermediate results finite and encodable. Rescaling follows the facility's +numeric and attribution contract; no separate radix rescaling refactor is +required for extraction. ### Stores with a stored halo For `H > 0`, fail if `h > H`. Otherwise crop both stored rasters to the requested halo width, removing `H-h` pixels on each side. Preserve the -retained payload and attribution exactly, including NoData and stored halo -values. Do not fetch neighbours or repair the stored halo. A request for -zero halo width returns the interior; requesting the full stored width -returns all stored pixels. +retained payload and attribution exactly, including zero-attribution pixels +and stored halo values. Do not fetch neighbours or repair the stored halo. +A request for zero halo width returns the interior; requesting the full +stored width returns all stored pixels. ## Relationship to existing documents @@ -179,22 +191,28 @@ with the final halo contract belongs to the implementation plan. ## Required verification - Invalid, missing, and virtual centre IDs; physical leaf and inner centres. -- Bit-exact central copies, including NoData payloads, and zero-width results. +- Bit-exact central copies, including zero-attribution payloads, and requests + for zero halo width. - Same-zoom edges/corners, first-physical descendant selection, four-level descent limit with ancestor fallback, and partial coverage. - At-most-once ancestor fetches and preservation of selected finer samples, - including NoData regions, when ancestor fallback is also used. -- Validity-aware averaging, representative attribution, all-invalid support, - and replication from central pixels that may themselves be NoData. + including zero-attribution regions, when ancestor fallback is also used. +- Numerical contributions from zero-attribution pixels, representative + attribution, and preservation of supplied or resampled zero-attribution + payloads even when all source attributions are zero. +- Clamped-view replication only for missing physical coverage, including + replication from zero-attribution central pixels; synthesized attribution + remains zero even when the replicated central pixel has nonzero attribution. - Horizontal wrapping, vertical limits, and uncovered dataset regions. -- Using box downscaling and forwarding RF value mapping and the selected - interpolation parameter to the scaling facility, and exact native copies. +- Using box downscaling, forwarding the shared `pixel::Mapping` and the + interpolation parameter to the paired scaling wrappers, + and exact native/nearest-neighbour copies without invoking conversion. - Type-based mapping defaults, explicit overrides, persistence of the resolved mapping, linear alpha, and CLI help documenting the policy. - Stored-halo cropping at zero, partial, and full width; rejection of requests - exceeding the stored halo; preservation of stored invalid samples. + exceeding the stored halo; preservation of stored zero-attribution samples. - Power-of-two interiors, the minimum side of 64, halo-width bounds, and dimension overflow. - Revised version-1 metadata and payload round trips. - Integration with the scaling facility, including sufficient source halos, - multi-level reductions, and the extractor's invalid-pixel replication. + multi-level reductions, and the extractor's missing-coverage replication. diff --git a/docs/raster-view-algorithms.md b/docs/raster-view-algorithms.md index 9fb83f13..b26a31aa 100644 --- a/docs/raster-view-algorithms.md +++ b/docs/raster-view-algorithms.md @@ -2,6 +2,7 @@ Status: design agreed on 2026-09-21 and extended and implemented on 2026-09-22, including overloads that allocate and return rasters. +The scaling refactor also implements rectangular kernels and per-axis strides. ## Scope @@ -12,7 +13,7 @@ input and output views or whole rasters: - `transform`: invoke a callable on each input pixel to produce an output pixel. - `zip_transform`: invoke a callable with one pixel from each of two or more input views to produce an output pixel. Input pixel types may differ. -- `window_transform`: invoke a callable with a square, kernel-sized input +- `window_transform`: invoke a callable with a rectangular, kernel-sized input view to produce one output pixel. Inputs may also be read-only clamped views that replicate source-edge pixels. @@ -20,9 +21,10 @@ Destinations must be writable ordinary views or rasters. Pointwise operations require matching view widths and heights. Source and destination pixel types may differ for transforms. The windowed operation -uses an odd kernel size and no border treatment: for a kernel side length -`k`, output dimensions are `input_width - (k - 1)` by -`input_height - (k - 1)`. +uses positive `glm::uvec2` kernel sizes and strides with no border treatment. +Each output dimension is `1 + (input_size - kernel_size) / stride`, using +integer division independently on each axis. Omitted stride means `{1, 1}`. +There are no scalar kernel-size or scalar-stride overloads. ## Agreed view model @@ -62,8 +64,9 @@ borrowed descriptor, and its destruction does not destroy the backing raster. Zero-width or zero-height views are valid. Pointwise operations with matching empty dimensions succeed without invoking the callable. Window transforms -require a positive, odd kernel size that fits both input dimensions. A kernel -size of one is valid; an oversized kernel is an error. +require positive kernel dimensions that fit the corresponding input dimensions +and positive strides on both axes. A `{1, 1}` kernel is valid; an oversized +kernel is an error. ## Clamped views @@ -86,9 +89,33 @@ a region of a clamped view through `make_view` requires that region to fit its logical dimensions and preserves the original clamping bounds. In particular, a window-transform kernel must not clamp independently at its own edges. -The window transform itself retains its existing geometry and border behavior. +The window transform does not add border treatment. For example, extending an input by three pixels on each side allows a 7x7 -window transform to produce output with the original input's dimensions. +window transform at stride `{1, 1}` to produce output with the original input's dimensions. + +### Agreed window-transform extension + +The [scaling refactor](raster-store/scaling.md#agreed-window-transform-reuse) +uses rectangular kernels for separable passes. Both `kernel_size` and `stride` +are `glm::uvec2`; explicit-stride forms place the stride after the kernel size: + +```cpp +window_transform(source, kernel_size, stride, function, destination); +auto filtered = window_transform(source, kernel_size, stride, function); +``` + +For each input dimension `n`, kernel dimension `k`, and stride `s`, the output +dimension is `1 + (n - k) / s`, using integer division. Validate that the kernel +fits and that the stride is positive before calculating dimensions. Windows +start at `(x * stride.x, y * stride.y)`; trailing pixels that cannot form a complete window +are omitted. Both overload forms use the same rule, and supplied destinations +must already have the resulting dimensions. No destination view is resized. +A 2x2 kernel with stride two maps an 8x6 input to a 4x3 output. Larger kernels +consume more border pixels; stride two alone does not guarantee half-size output. + +Box and Lanczos downscaling use horizontal and vertical passes with an intermediate +raster of working pixels. Encoding happens only after both passes. Overlap +and clamped-view semantics are unchanged. ## Access and call syntax @@ -118,7 +145,7 @@ window_transform(source, kernel_size, function, destination); Each call also has an overload omitting the destination, returning `Expected>`. Copy preserves the source pixel type; transforms infer `T` from the callable's exact return type. Pointwise output dimensions -match the inputs; window output dimensions follow the kernel shrinkage rule. +match the inputs; window output dimensions follow the kernel-and-stride rule. Validate input dimensions and kernel parameters before allocating. Invalid geometry returns an error; unrepresentable storage sizes or allocation failure return `ResourceExhausted`. Callable exceptions retain their existing contract. @@ -233,7 +260,7 @@ the absence of external side effects. Pointwise callbacks receive stored input pixels through const references. Window callbacks receive a read-only input view with local coordinates starting at `(0, 0)`. The kernel for output pixel `(x, y)` starts at input-view -coordinate `(x, y)` and covers `k` pixels along each axis. +coordinate `(x * stride.x, y * stride.y)` and covers `kernel_size` pixels. The kernel preserves the source's ordinary or clamped view type; a callback such as `[](const auto& window) { ... }` can handle either. @@ -263,14 +290,27 @@ colour encoding, or numeric conversion policy. Callables receive stored pixels unchanged and control interpretation and conversion. Copy preserves pixels exactly. -The existing scaling algorithms retain their separate attribution and value -mapping semantics. +The [scaling refactor agreed on 2026-09-22](raster-store/scaling.md) adopts +the same raster/view normalization, clamped sources, destination and allocating +overloads, and header placement. Generic scaling has no attribution, validity +mask, or value-mapping enum. A supplied decoder/encoder tuple determines the +working type and conversion; linear numeric conversion is the default for +`scale`, and identity is the default for `reduce`. Reducers receive exactly +four decoded working pixels through a read-only span. Numerical paths require +finite, encodable values as a caller precondition; NN/crop bypass conversions +and preserve pixels exactly. Empty inputs and interiors remain errors for +scaling. Paired `raster_store::scaler::scale` accepts the shared +`raster_store::pixel::Mapping` and selects the data conversion tuple; paired +`reduce` accepts a custom conversion tuple with identity as its default. +Both compose independent data and attribution operations. ## Verification -The GCC Debug build passes all 48 raster test cases (1,140 assertions), -including 14 new view/algorithm cases. The terrainlib regression suite passes -533 cases (25,934 assertions), excluding the existing mesh clipping benchmark. -Each public header also compiles independently without a precompiled header -under GCC with `-Wall -Wextra -Werror`. New C++ is formatted with -`clang-format-21`; Qt C++ lint and Git-attribute line-ending checks pass. +The refactor's focused scaler/view suite passes 61 cases (1,347 assertions). +The terrainlib, RF builder, and DAG builder regression suites pass. Eleven +public headers compile independently with warnings as errors. GCC 16 Release +compilation of the scaler tests at `-O3 -Werror` passes, including median +instantiations that previously triggered an array-bounds diagnostic. +Changed C++ is formatted with `clang-format-21`; Qt lint reports only an +existing warning on unchanged `storage.h` code. See the +[scaler verification record](raster-store/scaling.md#refactor-verification--2026-09-22). diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 9cbff9e5..8b2c47de 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -163,6 +163,11 @@ add_library(terrainlib 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/transform.h raster/algorithm/window_transform.h @@ -174,7 +179,8 @@ add_library(terrainlib raster_store/io/TileCodec.h raster_store/io/manifest.h raster_store/io/manifest.cpp raster_store/path_layout.h - raster_store/pixel_type.h + raster_store/pixel.h + raster_store/scaler.h raster_store/storage.h sf/finalize_storage.h diff --git a/src/terrainlib/raster/algorithm.h b/src/terrainlib/raster/algorithm.h index f4cb2bf4..bea456a2 100644 --- a/src/terrainlib/raster/algorithm.h +++ b/src/terrainlib/raster/algorithm.h @@ -1,638 +1,9 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "Error.h" +#include "algorithm/conversion.h" #include "algorithm/copy.h" +#include "algorithm/reduce.h" +#include "algorithm/scale.h" #include "algorithm/transform.h" #include "algorithm/window_transform.h" #include "algorithm/zip_transform.h" - -namespace raster::algorithm { - -enum class Interpolation { - NearestNeighbour, - Bilinear, -}; -enum class Filter { - Box, - Lanczos2, - Lanczos3, - Lanczos4, -}; -enum class ValueMapping { - Linear, - SRGBA, -}; - -namespace detail { - - template - struct PixelTraits { - static_assert((std::is_integral_v && !std::same_as && sizeof(T) <= 8) - || std::same_as || std::same_as || std::same_as); - using Scalar = T; - using Working = std::conditional_t || (std::is_integral_v && sizeof(T) <= 2), - float, - std::conditional_t || sizeof(T) <= 4, double, long double>>; - 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; - using Working = glm::vec::Working, Q>; - 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)]; } - }; - -} // namespace detail - -/// Floating-point pixel representation passed to custom reducers. -template -using WorkingPixel = typename detail::PixelTraits::Working; - -namespace detail { - - template - using Rasters = std::pair, radix::Raster>; - - template - using WorkingScalar = typename PixelTraits>::Scalar; - - template - Expected validate_mapping(ValueMapping mapping) - { - static_assert(!std::same_as::Scalar, long double>, "long double is a working type, not a stored pixel type"); - switch (mapping) { - case ValueMapping::Linear: - return {}; - case ValueMapping::SRGBA: - if constexpr (std::same_as::Scalar, std::uint8_t> && (PixelTraits::components == 3 || PixelTraits::components == 4)) { - 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"); - } - - 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(Filter filter) - { - switch (filter) { - case Filter::Box: - return 0; - case Filter::Lanczos2: - return 2; - case Filter::Lanczos3: - return 3; - case Filter::Lanczos4: - return 4; - } - return Error::fail(Error::Code::InvalidInput, "invalid raster reduction filter"); - } - - inline Expected required_upscaling_halo(unsigned n_zoom_levels, Interpolation interpolation) - { - if (auto factor = scale_factor(n_zoom_levels); !factor) { - return Error::propagate(std::move(factor)); - } - switch (interpolation) { - case Interpolation::NearestNeighbour: - return 0; - case Interpolation::Bilinear: - return n_zoom_levels == 0 ? 0u : 1u; - } - return Error::fail(Error::Code::InvalidInput, "invalid raster interpolation"); - } - - inline Expected required_downscaling_halo(unsigned n_zoom_levels, Filter filter) - { - auto factor = scale_factor(n_zoom_levels); - if (!factor) { - return Error::propagate(std::move(factor)); - } - auto radius = filter_radius(filter); - if (!radius) { - return Error::propagate(std::move(radius)); - } - 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); - } - - template - Expected validate_input( - const radix::Raster& data, const radix::Raster& attribution, unsigned halo_width, unsigned required_width, ValueMapping mapping) - { - if (auto valid = validate_mapping(mapping); !valid) { - return Error::propagate(std::move(valid)); - } - const auto padding = std::uint64_t(halo_width) * 2; - if (data.size() != attribution.size() || data.width() <= padding || data.height() <= padding) { - return Error::fail(Error::Code::InvalidInput, "raster dimensions must match and contain a nonempty interior"); - } - if (halo_width < required_width) { - return Error::fail(Error::Code::InvalidInput, "insufficient raster halo for the requested scaling operation"); - } - return data.size() - glm::uvec2(static_cast(padding)); - } - - template - Expected> allocate(std::uint64_t width, std::uint64_t height) - { - const auto max_dimension = (std::numeric_limits::max)(); - if (width == 0 || height == 0 || width > max_dimension || height > max_dimension || width > (std::numeric_limits::max)() / height) { - return Error::fail(Error::Code::ResourceExhausted, "scaled raster dimensions exceed representable limits"); - } - const auto count = width * height; - const auto maximum_bytes = static_cast((std::numeric_limits::max)()); - if (count > maximum_bytes / sizeof(T) || count > maximum_bytes / sizeof(std::uint16_t)) { - return Error::fail(Error::Code::ResourceExhausted, "scaled raster buffers exceed allocation limits"); - } - const glm::uvec2 size(static_cast(width), static_cast(height)); - try { - return Rasters { radix::Raster(size), radix::Raster(size) }; - } catch (const std::bad_alloc&) { - return Error::fail(Error::Code::ResourceExhausted, "allocate scaled raster buffers"); - } - } - - template - Expected> crop(const radix::Raster& data, const radix::Raster& attribution, unsigned halo_width, glm::uvec2 interior) - { - auto output = allocate(interior.x, interior.y); - if (!output) { - return output; - } - for (unsigned y = 0; y < interior.y; ++y) { - const auto source = std::size_t(y + halo_width) * data.width() + halo_width; - const auto destination = std::size_t(y) * interior.x; - std::copy_n(data.buffer().begin() + source, interior.x, output->first.buffer().begin() + destination); - std::copy_n(attribution.buffer().begin() + source, interior.x, output->second.buffer().begin() + destination); - } - return output; - } - - template - WorkingPixel decode(const T& pixel, ValueMapping mapping) - { - using W = WorkingScalar; - WorkingPixel result {}; - for (unsigned i = 0; i < PixelTraits::components; ++i) { - W value = static_cast(PixelTraits::component(pixel, i)); - if (mapping == ValueMapping::SRGBA) { - value /= W(255); - if (i < 3) { - value = value <= W(0.04045) ? value / W(12.92) : std::pow((value + W(0.055)) / W(1.055), W(2.4)); - } - } - PixelTraits>::component(result, i) = value; - } - return result; - } - - template - Expected encode(const WorkingPixel& pixel, ValueMapping mapping) - { - using S = typename PixelTraits::Scalar; - using W = WorkingScalar; - T result {}; - for (unsigned i = 0; i < PixelTraits::components; ++i) { - W value = PixelTraits>::component(pixel, i); - if constexpr (std::is_integral_v) { - if (!std::isfinite(value)) { - return Error::fail(Error::Code::InvalidInput, "nonfinite raster result cannot be stored as an integer"); - } - if (mapping == ValueMapping::SRGBA) { - value = std::clamp(value, W(0), W(1)); - if (i < 3) { - value = value <= W(0.0031308) ? W(12.92) * value : W(1.055) * std::pow(value, W(1) / W(2.4)) - W(0.055); - } - value *= W(255); - } - value = std::round(value); - // Compare before conversion: a floating representation of an integer - // maximum can itself round up to the first unrepresentable integer. - if (value <= static_cast((std::numeric_limits::lowest)())) { - PixelTraits::component(result, i) = (std::numeric_limits::lowest)(); - } else if (value >= static_cast((std::numeric_limits::max)())) { - PixelTraits::component(result, i) = (std::numeric_limits::max)(); - } else { - PixelTraits::component(result, i) = static_cast(value); - } - } else { - PixelTraits::component(result, i) = value; - } - } - return result; - } - - enum class Reduction { - Minimum, - Maximum, - Median, - }; - - template - T reduce_components(std::span samples) - { - assert(!samples.empty() && samples.size() <= 4); - T result {}; - using S = typename PixelTraits::Scalar; - for (unsigned component = 0; component < PixelTraits::components; ++component) { - std::array values {}; - bool has_nan = false; - for (std::size_t i = 0; i < samples.size(); ++i) { - values[i] = PixelTraits::component(samples[i], component); - has_nan = has_nan || std::isnan(values[i]); - } - S value {}; - if (has_nan) { - value = std::numeric_limits::quiet_NaN(); - } else { - auto end = values.begin() + samples.size(); - 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); - value = values[samples.size() / 2]; - if (samples.size() % 2 == 0) { - const auto lower = values[samples.size() / 2 - 1]; - value = std::isfinite(lower) && std::isfinite(value) ? std::midpoint(lower, value) : (lower + value) / S(2); - } - } - } - PixelTraits::component(result, component) = value; - } - return result; - } - - struct LocalBlock { - std::array positions {}; - unsigned count = 0; - std::uint16_t attribution = 0; - }; - - inline LocalBlock local_block(const radix::Raster& attribution, glm::uvec2 top_left) - { - LocalBlock result; - std::array ids {}; - for (unsigned y = 0; y < 2; ++y) { - for (unsigned x = 0; x < 2; ++x) { - const auto position = top_left + glm::uvec2(x, y); - const auto id = attribution.pixel(position); - if (id != 0) { - ids[result.count] = id; - result.positions[result.count++] = position; - } - } - } - if (result.count != 0) { - result.attribution = ids[0]; - for (unsigned i = 0; i < result.count; ++i) { - if (std::count(ids.begin(), ids.begin() + result.count, ids[i]) >= 2) { - result.attribution = ids[i]; - break; - } - } - } - return result; - } - - template - struct Kernel { - using W = WorkingScalar; - std::array weights {}; - int first = 0; - unsigned count = 2; - - explicit Kernel(unsigned radius) - { - if (radius == 0) { - weights[0] = weights[1] = W(1); - return; - } - first = 1 - 2 * static_cast(radius); - count = 4 * radius; - const auto sinc = [](W x) { - const W angle = std::numbers::pi_v * x; - return x == W(0) ? W(1) : std::sin(angle) / angle; - }; - for (unsigned i = 0; i < count; ++i) { - const W offset = W(first + static_cast(i)) - W(0.5); - weights[i] = sinc(offset / W(2)) * sinc(offset / W(2 * radius)); - } - } - - Expected operator()(const radix::Raster& data, - const radix::Raster& attribution, - glm::uvec2 top_left, - const LocalBlock& local, - ValueMapping mapping) const - { - const auto origin_x = std::int64_t(top_left.x) + first; - const auto origin_y = std::int64_t(top_left.y) + first; - W sum = 0; - W absolute_sum = 0; - for (unsigned y = 0; y < count; ++y) { - for (unsigned x = 0; x < count; ++x) { - const glm::uvec2 position(static_cast(origin_x + x), static_cast(origin_y + y)); - if (attribution.pixel(position) == 0) { - continue; - } - const W weight = weights[x] * weights[y]; - sum += weight; - absolute_sum += std::abs(weight); - } - } - constexpr W cancellation_tolerance = W(32) * std::numeric_limits::epsilon(); - if (std::abs(sum) <= cancellation_tolerance * absolute_sum) { - return data.pixel(local.positions[0]); - } - WorkingPixel value {}; - for (unsigned y = 0; y < count; ++y) { - for (unsigned x = 0; x < count; ++x) { - const W weight = weights[x] * weights[y]; - const glm::uvec2 position(static_cast(origin_x + x), static_cast(origin_y + y)); - if (weight != W(0) && attribution.pixel(position) != 0) { - value += decode(data.pixel(position), mapping) * (weight / sum); - } - } - } - return encode(value, mapping); - } - }; - - // The callback supplies a single reduction step; this driver owns validity, - // attribution, intermediate storage, and the shrinking filter halos. - template - Expected> reduce_levels(const radix::Raster& data, - const radix::Raster& attribution, - unsigned halo_width, - unsigned n_zoom_levels, - unsigned base_halo, - ValueMapping mapping, - PixelReducer&& reducer) - { - auto factor = scale_factor(n_zoom_levels); - if (!factor) { - return Error::propagate(std::move(factor)); - } - const auto required = std::uint64_t(base_halo) * (*factor - 1); - if (required > (std::numeric_limits::max)()) { - return Error::fail(Error::Code::ResourceExhausted, "required raster halo exceeds representable dimensions"); - } - auto interior = validate_input(data, attribution, halo_width, static_cast(required), mapping); - if (!interior) { - return Error::propagate(std::move(interior)); - } - if (interior->x % *factor != 0 || interior->y % *factor != 0) { - return Error::fail(Error::Code::InvalidInput, "raster interior dimensions must be divisible by the reduction factor"); - } - if (n_zoom_levels == 0) { - return crop(data, attribution, halo_width, *interior); - } - const auto* current_data = &data; - const auto* current_attribution = &attribution; - Rasters intermediate; - for (unsigned remaining_factor = *factor / 2;; remaining_factor /= 2) { - *interior /= 2u; - const unsigned output_halo = base_halo * (remaining_factor - 1); - auto output = allocate(std::uint64_t(interior->x) + 2ull * output_halo, std::uint64_t(interior->y) + 2ull * output_halo); - if (!output) { - return output; - } - for (unsigned y = 0; y < output->first.height(); ++y) { - for (unsigned x = 0; x < output->first.width(); ++x) { - const glm::uvec2 top_left(static_cast(2 * (std::int64_t(x) - output_halo) + halo_width), - static_cast(2 * (std::int64_t(y) - output_halo) + halo_width)); - const auto local = local_block(*current_attribution, top_left); - output->second.pixel({ x, y }) = local.attribution; - if (local.count == 0) { - output->first.pixel({ x, y }) = current_data->pixel(top_left + glm::uvec2(1)); - } else { - auto pixel = reducer(*current_data, *current_attribution, top_left, local, mapping); - if (!pixel) { - return Error::propagate(std::move(pixel), "reduce raster pixel"); - } - output->first.pixel({ x, y }) = *pixel; - } - } - } - intermediate = std::move(*output); - if (remaining_factor == 1) { - return intermediate; - } - current_data = &intermediate.first; - current_attribution = &intermediate.second; - halo_width = output_halo; - } - } - - template - Expected> upscale(const radix::Raster& data, - const radix::Raster& attribution, - unsigned halo_width, - unsigned n_zoom_levels, - Interpolation interpolation, - ValueMapping mapping) - { - auto required = required_upscaling_halo(n_zoom_levels, interpolation); - if (!required) { - return Error::propagate(std::move(required)); - } - auto interior = validate_input(data, attribution, halo_width, *required, mapping); - if (!interior) { - return Error::propagate(std::move(interior)); - } - if (n_zoom_levels == 0) { - return crop(data, attribution, halo_width, *interior); - } - const unsigned factor = *scale_factor(n_zoom_levels); - auto output = allocate(std::uint64_t(interior->x) * factor, std::uint64_t(interior->y) * factor); - if (!output) { - return output; - } - using W = WorkingScalar; - for (unsigned y = 0; y < output->first.height(); ++y) { - for (unsigned x = 0; x < output->first.width(); ++x) { - const glm::uvec2 nearest(x / factor + halo_width, y / factor + halo_width); - const auto id = attribution.pixel(nearest); - output->second.pixel({ x, y }) = id; - if (interpolation == Interpolation::NearestNeighbour || id == 0) { - output->first.pixel({ x, y }) = data.pixel(nearest); - continue; - } - // Compute the phase separately from the integer index so large - // raster coordinates do not consume the working pixel's precision. - const double phase_x = (double(x % factor) + 0.5) / factor - 0.5; - const double phase_y = (double(y % factor) + 0.5) / factor - 0.5; - const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); - const W fraction_x = W(phase_x < 0 ? phase_x + 1 : phase_x); - const W fraction_y = W(phase_y < 0 ? phase_y + 1 : phase_y); - const std::array weights_x { W(1) - fraction_x, fraction_x }; - const std::array weights_y { W(1) - fraction_y, fraction_y }; - std::array weights {}; - W sum = 0; - for (unsigned dy = 0; dy < 2; ++dy) { - for (unsigned dx = 0; dx < 2; ++dx) { - if (attribution.pixel(origin + glm::uvec2(dx, dy)) != 0) { - weights[2 * dy + dx] = weights_x[dx] * weights_y[dy]; - sum += weights[2 * dy + dx]; - } - } - } - WorkingPixel value {}; - for (unsigned dy = 0; dy < 2; ++dy) { - for (unsigned dx = 0; dx < 2; ++dx) { - const W weight = weights[2 * dy + dx]; - if (weight != W(0)) { - value += decode(data.pixel(origin + glm::uvec2(dx, dy)), mapping) * (weight / sum); - } - } - } - auto pixel = encode(value, mapping); - if (!pixel) { - return Error::propagate(std::move(pixel), "interpolate raster pixel"); - } - output->first.pixel({ x, y }) = *pixel; - } - } - return output; - } - - template - Expected> downscale(const radix::Raster& data, - const radix::Raster& attribution, - unsigned halo_width, - unsigned n_zoom_levels, - Filter filter, - ValueMapping mapping) - { - auto radius = filter_radius(filter); - if (!radius) { - return Error::propagate(std::move(radius)); - } - return reduce_levels(data, attribution, halo_width, n_zoom_levels, *radius == 0 ? 0u : 2 * *radius - 1, mapping, Kernel(*radius)); - } - -} // namespace detail - -/// Component-wise reducers for the nonempty, at-most-four-sample working spans. -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); - } -}; - -/// Positive levels upscale, negative levels downscale, and zero only crops. -inline Expected required_halo(int n_zoom_levels, Interpolation interpolation, Filter filter) -{ - if (auto valid = detail::required_upscaling_halo(0, interpolation); !valid) { - return Error::propagate(std::move(valid)); - } - if (auto valid = detail::filter_radius(filter); !valid) { - return Error::propagate(std::move(valid)); - } - if (n_zoom_levels < 0) { - return detail::required_downscaling_halo(static_cast(-std::int64_t(n_zoom_levels)), filter); - } - return detail::required_upscaling_halo(static_cast(n_zoom_levels), interpolation); -} - -/// Returns data first and attribution second, with the complete halo removed. -template -Expected, radix::Raster>> scale(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, - int n_zoom_levels, - Interpolation interpolation, - Filter filter, - ValueMapping value_mapping) -{ - if (auto required = required_halo(n_zoom_levels, interpolation, filter); !required) { - return Error::propagate(std::move(required)); - } - if (n_zoom_levels < 0) { - return detail::downscale(data, source_attribution, halo_width, static_cast(-std::int64_t(n_zoom_levels)), filter, value_mapping); - } - return detail::upscale(data, source_attribution, halo_width, static_cast(n_zoom_levels), interpolation, value_mapping); -} - -/// Repeated 2x2 reduction. The same callable receives valid, linear samples at -/// every stage. Zero levels crop without invoking it; empty blocks bypass it. -template -requires std::invocable>> - && std::same_as>>, WorkingPixel> -Expected, radix::Raster>> reduce(const radix::Raster& data, - const radix::Raster& source_attribution, - unsigned halo_width, - unsigned n_zoom_levels, - ValueMapping value_mapping, - Reducer&& reducer) -{ - const auto apply_reducer = [&reducer](const radix::Raster& input, - const radix::Raster&, - glm::uvec2, - const detail::LocalBlock& local, - ValueMapping mapping) -> Expected { - std::array, 4> samples {}; - for (unsigned i = 0; i < local.count; ++i) { - samples[i] = detail::decode(input.pixel(local.positions[i]), mapping); - } - return detail::encode(std::invoke(reducer, std::span>(samples.data(), local.count)), mapping); - }; - return detail::reduce_levels(data, source_attribution, halo_width, n_zoom_levels, 0, value_mapping, apply_reducer); -} - -} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/conversion.h b/src/terrainlib/raster/algorithm/conversion.h new file mode 100644 index 00000000..b5e68028 --- /dev/null +++ b/src/terrainlib/raster/algorithm/conversion.h @@ -0,0 +1,147 @@ +#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/detail.h b/src/terrainlib/raster/algorithm/detail.h index 0d6f45e9..411d6539 100644 --- a/src/terrainlib/raster/algorithm/detail.h +++ b/src/terrainlib/raster/algorithm/detail.h @@ -134,15 +134,15 @@ Expected validate_pointwise_views(const InputView& source, const View& } template -auto window_view(const View& source, glm::uvec2 origin, unsigned size) +auto window_view(const View& source, glm::uvec2 origin, glm::uvec2 size) { - return view_details::Access::region(source, origin, glm::uvec2(size)); + return view_details::Access::region(source, origin, size); } template -auto window_view(const ClampedView& source, glm::uvec2 origin, unsigned size) +auto window_view(const ClampedView& source, glm::uvec2 origin, glm::uvec2 size) { - return view_details::ClampedAccess::region(source, origin, glm::uvec2(size)); + return view_details::ClampedAccess::region(source, origin, size); } } // namespace raster::algorithm::detail diff --git a/src/terrainlib/raster/algorithm/reduce.h b/src/terrainlib/raster/algorithm/reduce.h new file mode 100644 index 00000000..fb955eff --- /dev/null +++ b/src/terrainlib/raster/algorithm/reduce.h @@ -0,0 +1,81 @@ +#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..6c6adfd0 --- /dev/null +++ b/src/terrainlib/raster/algorithm/reducers.h @@ -0,0 +1,105 @@ +#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..05a66ef7 --- /dev/null +++ b/src/terrainlib/raster/algorithm/scale.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include + +#include "conversion.h" +#include "scaling_detail.h" + +namespace raster::algorithm { +namespace detail { + template + Expected upscale(const InputView& source, + unsigned halo_width, + const ScalingGeometry& geometry, + Interpolation interpolation, + const Conversion& conversion, + const View& destination) + { + using W = DecodedPixel; + using S = typename PixelTraits::Scalar; + const unsigned factor = geometry.factor; + for (unsigned y = 0; y < destination.height(); ++y) { + for (unsigned x = 0; x < destination.width(); ++x) { + const glm::uvec2 nearest(x / factor + halo_width, y / factor + halo_width); + if (interpolation == Interpolation::NearestNeighbour) { + std::memcpy(std::addressof(destination.pixel({ x, y })), std::addressof(source.pixel(nearest)), sizeof(T)); + continue; + } + // Keep integer coordinates out of the working pixel's phase precision. + const double phase_x = (double(x % factor) + 0.5) / factor - 0.5; + const double phase_y = (double(y % factor) + 0.5) / factor - 0.5; + const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); + const S fraction_x = S(phase_x < 0 ? phase_x + 1 : phase_x); + const S fraction_y = S(phase_y < 0 ? phase_y + 1 : phase_y); + const std::array weights_x { S(1) - fraction_x, fraction_x }; + const std::array weights_y { S(1) - fraction_y, fraction_y }; + W value {}; + for (unsigned dy = 0; dy < 2; ++dy) { + for (unsigned dx = 0; dx < 2; ++dx) { + value += std::invoke(std::get<0>(conversion), source.pixel(origin + glm::uvec2(dx, 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; + S normalization = 0; + const auto sinc = [](S x) { + const S angle = std::numbers::pi_v * x; + return x == S(0) ? S(1) : std::sin(angle) / angle; + }; + for (unsigned i = 0; i < count; ++i) { + const S offset = S(i) - S(count - 1) / S(2); + weights[i] = radius == 0 ? S(1) : sinc(offset / S(2)) * sinc(offset / S(2 * radius)); + normalization += weights[i]; + } + for (unsigned i = 0; i < count; ++i) { + weights[i] /= normalization; + } + 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_views(const InputView& source, + unsigned halo_width, + int levels, + Interpolation interpolation, + Filter filter, + const Conversion& conversion, + const View& destination) + { + auto geometry = scale_geometry(source.size(), halo_width, levels, interpolation, filter); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + if (auto valid = validate_scaling_destination(source, halo_width, *geometry, destination); !valid) { + return valid; + } + if (levels == 0) { + return copy_views(*raster::make_view(source, glm::uvec2(halo_width), geometry->interior), destination); + } + if (levels < 0) { + return downscale(source, halo_width, *geometry, *filter_radius(filter), conversion, destination); + } + return upscale(source, halo_width, *geometry, interpolation, conversion, destination); + } +} // namespace detail + +/// All supplied pixels participate. Decoder/intermediate values must be finite and encodable. +template +requires detail::NumericPixel> && detail::ScalingConversion> + && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale(Source&& source, + unsigned halo_width, + int n_zoom_levels, + Interpolation interpolation, + Filter filter, + const Conversion& conversion, + Destination&& destination) +{ + return detail::scale_views( + detail::read_only_view(raster::make_view(source)), halo_width, n_zoom_levels, interpolation, filter, conversion, raster::make_view(destination)); +} + +template +requires detail::NumericPixel> && detail::ScalingConversion> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int n_zoom_levels, Interpolation interpolation, Filter filter, const Conversion& conversion) +{ + const auto input = detail::read_only_view(raster::make_view(source)); + auto geometry = detail::scale_geometry(input.size(), halo_width, n_zoom_levels, interpolation, filter); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + return detail::produce_raster>(geometry->output, + [&](const auto& destination) { return detail::scale_views(input, halo_width, n_zoom_levels, interpolation, filter, conversion, destination); }); +} + +template +requires detail::NumericPixel> && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale( + Source&& source, unsigned halo_width, int n_zoom_levels, Interpolation interpolation, Filter filter, Destination&& destination) +{ + return scale(std::forward(source), + halo_width, + n_zoom_levels, + interpolation, + filter, + linear_conversion>(), + std::forward(destination)); +} + +template +requires detail::NumericPixel> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int n_zoom_levels, Interpolation interpolation, Filter filter) +{ + return scale(std::forward(source), halo_width, n_zoom_levels, interpolation, filter, 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..0fba6d6c --- /dev/null +++ b/src/terrainlib/raster/algorithm/scaling_detail.h @@ -0,0 +1,183 @@ +#pragma once + +#include "copy.h" +#include "window_transform.h" + +namespace raster::algorithm { + +enum class Interpolation { + NearestNeighbour, + Bilinear, +}; +enum class Filter { + Box, + 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(Filter filter) + { + switch (filter) { + case Filter::Box: + return 0; + case Filter::Lanczos2: + return 2; + case Filter::Lanczos3: + return 3; + case Filter::Lanczos4: + return 4; + } + return Error::fail(Error::Code::InvalidInput, "invalid raster reduction filter"); + } + + inline Expected required_upscaling_halo(unsigned n_zoom_levels, Interpolation interpolation) + { + if (auto factor = scale_factor(n_zoom_levels); !factor) { + return Error::propagate(std::move(factor)); + } + switch (interpolation) { + case Interpolation::NearestNeighbour: + return 0; + case Interpolation::Bilinear: + return n_zoom_levels == 0 ? 0u : 1u; + } + return Error::fail(Error::Code::InvalidInput, "invalid raster interpolation"); + } + + inline Expected required_downscaling_halo(unsigned n_zoom_levels, Filter filter) + { + auto factor = scale_factor(n_zoom_levels); + if (!factor) { + return Error::propagate(std::move(factor)); + } + auto radius = filter_radius(filter); + if (!radius) { + return Error::propagate(std::move(radius)); + } + 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); + } + + struct ScalingGeometry { + glm::uvec2 interior; + glm::uvec2 output; + unsigned factor; + }; + + inline Expected scaling_geometry(glm::uvec2 input, unsigned halo_width, unsigned levels, bool up, unsigned required_width) + { + 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. +inline Expected required_halo(int n_zoom_levels, Interpolation interpolation, Filter filter) +{ + if (auto valid = detail::required_upscaling_halo(0, interpolation); !valid) { + return Error::propagate(std::move(valid)); + } + if (auto valid = detail::filter_radius(filter); !valid) { + return Error::propagate(std::move(valid)); + } + if (n_zoom_levels < 0) { + return detail::required_downscaling_halo(static_cast(-std::int64_t(n_zoom_levels)), filter); + } + return detail::required_upscaling_halo(static_cast(n_zoom_levels), interpolation); +} + +namespace detail { + inline Expected scale_geometry(glm::uvec2 input, unsigned halo_width, int levels, Interpolation interpolation, Filter filter) + { + auto required = required_halo(levels, interpolation, filter); + 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); + } +} // namespace detail +} // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/window_transform.h b/src/terrainlib/raster/algorithm/window_transform.h index 550a5d9a..f4e4dde0 100644 --- a/src/terrainlib/raster/algorithm/window_transform.h +++ b/src/terrainlib/raster/algorithm/window_transform.h @@ -5,19 +5,20 @@ namespace raster::algorithm { namespace detail { - inline Expected window_output_size(glm::uvec2 input_size, unsigned kernel_size) + inline Expected window_output_size(glm::uvec2 input_size, glm::uvec2 kernel_size, glm::uvec2 stride = glm::uvec2(1)) { - if (kernel_size == 0 || kernel_size % 2 == 0 || kernel_size > input_size.x || kernel_size > input_size.y) { - return Error::fail(Error::Code::InvalidInput, "window size must be positive, odd, and fit the input view"); + 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 - glm::uvec2(kernel_size - 1); + return (input_size - kernel_size) / stride + glm::uvec2(1); } template requires(!std::is_const_v) && PixelFunction - Expected window_transform_views(const InputView& source, unsigned kernel_size, const Function& function, const View& destination) + 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); + auto size = window_output_size(source.size(), kernel_size, stride); if (!size) { return Error::propagate(std::move(size)); } @@ -29,7 +30,7 @@ namespace detail { } for (unsigned y = 0; y < destination.height(); ++y) { for (unsigned x = 0; x < destination.width(); ++x) { - const auto window = window_view(source, { x, y }, kernel_size); + const auto window = window_view(source, glm::uvec2(x, y) * stride, kernel_size); destination.pixel({ x, y }) = std::invoke(function, window); } } @@ -37,26 +38,41 @@ namespace detail { } } // namespace detail -/// Applies a square window without border handling; output shrinks by kernel_size - 1. +/// Applies complete rectangular windows at the given positive stride, without border handling. template requires detail::PixelFunction, detail::ReadOnlyView> -[[nodiscard]] Expected window_transform(Source&& source, unsigned kernel_size, const Function& function, Destination&& destination) +[[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, function, raster::make_view(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, unsigned kernel_size, const Function& function) +[[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); + 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, function, destination); }); + *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_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index 4a9ba117..c0c8add7 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -6,7 +6,7 @@ #include "io/envelope.h" #include "raster_store/Tile.h" -#include "raster_store/pixel_type.h" +#include "raster_store/pixel.h" #include "store/Codec.h" namespace raster_store::io { @@ -30,7 +30,7 @@ namespace tile_codec { template class TileCodec final : public store::Codec> { - static_assert(sizeof(pixel_type::Format) > 0); + static_assert(sizeof(pixel::detail::Format) > 0); public: explicit TileCodec(const glm::uvec2 dimensions = glm::uvec2(default_tile_side), diff --git a/src/terrainlib/raster_store/pixel.h b/src/terrainlib/raster_store/pixel.h new file mode 100644 index 00000000..d611f7e1 --- /dev/null +++ b/src/terrainlib/raster_store/pixel.h @@ -0,0 +1,50 @@ +#pragma once + +#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 +std::string identifier() +{ + return detail::Format::identifier(); +} + +} // namespace raster_store::pixel diff --git a/src/terrainlib/raster_store/pixel_type.h b/src/terrainlib/raster_store/pixel_type.h deleted file mode 100644 index ae01565a..00000000 --- a/src/terrainlib/raster_store/pixel_type.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace raster_store::pixel_type { - -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() + ">"; - } -}; - -template -std::string identifier() -{ - return Format::identifier(); -} - -} // namespace raster_store::pixel_type diff --git a/src/terrainlib/raster_store/scaler.h b/src/terrainlib/raster_store/scaler.h new file mode 100644 index 00000000..51a3c41d --- /dev/null +++ b/src/terrainlib/raster_store/scaler.h @@ -0,0 +1,233 @@ +#pragma once + +#include "pixel.h" +#include "raster/algorithm/reduce.h" +#include "raster/algorithm/scale.h" + +namespace raster_store::scaler { +namespace detail { + 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) + { + switch (mapping) { + case pixel::Mapping::Linear: + return {}; + case pixel::Mapping::SRGBA: + 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 detail + +/// Scale data numerically and attribution independently, including attribution zero. +template +requires detail::PairedSources && detail::PairedDestinations +[[nodiscard]] Expected scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int n_zoom_levels, + raster::algorithm::Interpolation interpolation, + raster::algorithm::Filter filter, + pixel::Mapping mapping, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + namespace algorithm = raster::algorithm; + using T = algorithm::detail::SourcePixel; + if (auto valid = detail::validate_mapping(mapping); !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::scale_geometry(input.size(), halo_width, n_zoom_levels, interpolation, filter); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + if (auto valid = detail::validate_destinations(input, input_attribution, halo_width, *geometry, output, output_attribution); !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, n_zoom_levels, interpolation, filter, algorithm::srgb_conversion(), output); + } + } + return algorithm::scale(input, halo_width, n_zoom_levels, interpolation, filter, output); + }; + if (auto result = scale_data(); !result) { + return result; + } + if (n_zoom_levels < 0) { + return algorithm::reduce(input_attribution, halo_width, static_cast(-std::int64_t(n_zoom_levels)), algorithm::Mode {}, output_attribution); + } + return algorithm::scale( + input_attribution, halo_width, n_zoom_levels, algorithm::Interpolation::NearestNeighbour, algorithm::Filter::Box, output_attribution); +} + +template +requires detail::PairedSources +[[nodiscard]] Expected>, radix::Raster>> scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int n_zoom_levels, + raster::algorithm::Interpolation interpolation, + raster::algorithm::Filter filter, + pixel::Mapping mapping) +{ + using T = raster::algorithm::detail::SourcePixel; + if (auto valid = detail::validate_mapping(mapping); !valid) { + return Error::propagate(std::move(valid)); + } + if (data.size() != attribution.size()) { + return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); + } + auto geometry = raster::algorithm::detail::scale_geometry(data.size(), halo_width, n_zoom_levels, interpolation, filter); + if (!geometry) { + return Error::propagate(std::move(geometry)); + } + return detail::produce_pair(geometry->output, [&](auto& output, auto& output_attribution) { + return scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, mapping, output, output_attribution); + }); +} + +/// Custom data reduction uses the supplied conversion; attribution always uses identity and Mode. +template +requires detail::PairedSources && detail::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 = detail::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 detail::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 detail::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 detail::PairedSources && detail::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 detail::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::scaler diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index a435ee48..da128628 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -85,7 +85,7 @@ Expected>, std::unique auto storage = store::open_index>(index_path, io::manifest::index_format(), { std::move(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, - pixel_type::identifier(), + pixel::identifier(), resolve_codec); if (!storage) { return Error::propagate(std::move(storage)); @@ -139,7 +139,7 @@ Expected>, std::unique_ptr(std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), - pixel_type::identifier(), + pixel::identifier(), options.codec_selector, options.tile_dimensions.x, options.tile_dimensions.y); diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp index e847ee7e..8a6bfdb5 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -17,13 +17,15 @@ #include #include "raster/algorithm.h" +#include "raster_store/scaler.h" namespace { namespace algorithm = raster::algorithm; using algorithm::Filter; using algorithm::Interpolation; -using algorithm::ValueMapping; +namespace scaler = raster_store::scaler; +using ValueMapping = raster_store::pixel::Mapping; using Catch::Matchers::WithinAbs; template @@ -41,7 +43,7 @@ auto scale(const radix::Raster& data, Interpolation interpolation = Interpolation::NearestNeighbour, ValueMapping mapping = ValueMapping::Linear) { - return algorithm::scale(data, attribution, halo, levels, interpolation, filter, mapping); + return scaler::scale(data, attribution, halo, levels, interpolation, filter, mapping); } template @@ -66,7 +68,7 @@ auto field(glm::uvec2 interior, unsigned halo, int offset_x = 0, int offset_y = return result; } -double reference_lanczos(const radix::Raster& data, const radix::Raster& attribution, unsigned halo, unsigned x, unsigned y, int radius) +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); }; @@ -78,9 +80,6 @@ double reference_lanczos(const radix::Raster& data, const radix::Raster& data, const radix::Raster, 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>>); +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 @@ -135,7 +134,7 @@ TEST_CASE("raster scaling rejects invalid geometry and dimensions before allocat CHECK_FALSE(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, static_cast(99))); CHECK(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); CHECK(scale(data, attribution, 31).error().code() == Error::Code::ResourceExhausted); - CHECK_FALSE(algorithm::reduce(data, attribution, 0, (std::numeric_limits::max)(), ValueMapping::Linear, algorithm::Max {})); + CHECK_FALSE(scaler::reduce(data, attribution, 0, (std::numeric_limits::max)(), algorithm::Max {})); } TEST_CASE("zero zoom levels crop both rasters preserving payload bits", "[raster-algorithm]") @@ -158,7 +157,7 @@ TEST_CASE("zero zoom levels crop both rasters preserving payload bits", "[raster ++calls; return 0.f; }; - auto reduced = algorithm::reduce(data, attribution, 2, 0, ValueMapping::Linear, reducer); + auto reduced = scaler::reduce(data, attribution, 2, 0, algorithm::linear_conversion>(), reducer); REQUIRE(reduced); CHECK(calls == 0); same_raster(output->first, reduced->first); @@ -203,33 +202,25 @@ TEST_CASE("bilinear raster upscaling respects area phase and nearest attribution } } -TEST_CASE("bilinear interpolation excludes NoData and preserves nearest NoData", "[raster-algorithm]") +TEST_CASE("bilinear interpolation includes unattributed samples", "[raster-algorithm]") { - radix::Raster data(glm::uvec2(3), std::numeric_limits::quiet_NaN()); - radix::Raster attribution(glm::uvec2(3), 0); - data.pixel({ 1, 1 }) = 17; - attribution.pixel({ 1, 1 }) = 9; - const auto valid = scale(data, attribution, 2, 1, Filter::Box, Interpolation::Bilinear); - REQUIRE(valid); - for (auto value : valid->first) { - CHECK(value == 17); + 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, Filter::Box, Interpolation::Bilinear); + REQUIRE(result); + for (const auto value : result->first) { + CHECK(value == 43.75f); } - for (auto id : valid->second) { - CHECK(id == 9); + for (const auto id : result->second) { + CHECK(id == 0); } - data.fill(100); - attribution.fill(2); - data.pixel({ 1, 1 }) = -999; - attribution.pixel({ 1, 1 }) = 0; - const auto invalid = scale(data, attribution, 1, 1, Filter::Box, Interpolation::Bilinear); - REQUIRE(invalid); - CHECK(std::ranges::all_of(invalid->first, [](float value) { return value == -999; })); - CHECK(std::ranges::all_of(invalid->second, [](auto id) { return id == 0; })); + same_raster(result->first, algorithm::scale(data, 1, 1, Interpolation::Bilinear, Filter::Box).value()); } -TEST_CASE("box reduction votes locally and ignores invalid payloads", "[raster-algorithm]") +TEST_CASE("box reduction votes locally and includes every payload", "[raster-algorithm]") { - const auto data = samples({ 2, 2 }, { 2, std::numeric_limits::quiet_NaN(), 6, 10 }); + 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); @@ -243,10 +234,10 @@ TEST_CASE("box reduction votes locally and ignores invalid payloads", "[raster-a const auto empty = scale(data, attribution, -1); REQUIRE(empty); CHECK(empty->second.pixel({ 0, 0 }) == 0); - CHECK(empty->first.pixel({ 0, 0 }) == 10); + CHECK(empty->first.pixel({ 0, 0 }) == 6); } -TEST_CASE("repeated reduction uses stage validity rounding and attribution", "[raster-algorithm]") +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); @@ -259,8 +250,8 @@ TEST_CASE("repeated reduction uses stage validity rounding and attribution", "[r } const auto direct = scale(data, attribution, -2); REQUIRE(direct); - CHECK(direct->first.pixel({ 0, 0 }) == 50); - CHECK(direct->second.pixel({ 0, 0 }) == 5); + 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); @@ -269,70 +260,33 @@ TEST_CASE("repeated reduction uses stage validity rounding and attribution", "[r same_raster(direct->second, second->second); } -TEST_CASE("custom raster reducers receive valid decoded spans and preserve callable state", "[raster-algorithm]") +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 }); - auto attribution = samples({ 2, 2 }, { 5, 0, 7, 7 }); - auto calls = std::make_unique(0); - const auto reducer = [&calls](std::span values) { - ++*calls; - REQUIRE(values.size() == 3); - CHECK(values[0] == 1); - CHECK(values[1] == 3); - CHECK(values[2] == 4); - return 2.5f; + 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 = algorithm::reduce(data, attribution, 0, 1, ValueMapping::Linear, reducer); + const auto result = scaler::reduce(data, ids, 0, 1, conversion, reducer); REQUIRE(result); - CHECK(result->first.pixel({ 0, 0 }) == 3); - CHECK(result->second.pixel({ 0, 0 }) == 7); - CHECK(*calls == 1); - attribution.fill(0); - REQUIRE(algorithm::reduce(data, attribution, 0, 1, ValueMapping::Linear, reducer)); - CHECK(*calls == 1); - - struct Counter { - std::unique_ptr count = std::make_unique(0); - float operator()(std::span values) - { - ++*count; - return algorithm::Max {}(values); - } - } counter; - radix::Raster larger(glm::uvec2(4), 42); - radix::Raster ids(glm::uvec2(4), 1); - const auto repeated = algorithm::reduce(larger, ids, 0, 2, ValueMapping::Linear, counter); - REQUIRE(repeated); - CHECK(*counter.count == 5); - CHECK(repeated->first.pixel({ 0, 0 }) == 42); - REQUIRE(algorithm::reduce(larger, ids, 0, 2, ValueMapping::Linear, Counter {})); + 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 and NaN propagation", "[raster-algorithm]") +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 } }); - radix::Raster ids(glm::uvec2(2), 1); - CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Min {})->first.pixel({ 0, 0 }) == glm::vec3(2, 1, 10)); - CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Max {})->first.pixel({ 0, 0 }) == glm::vec3(8, 7, 40)); - CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Median {})->first.pixel({ 0, 0 }) == glm::vec3(5, 4, 25)); - ids.pixel({ 1, 1 }) = 0; - CHECK(algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Median {})->first.pixel({ 0, 0 }) == glm::vec3(6, 3, 20)); - auto nonfinite = data; - nonfinite.pixel({ 0, 0 }).y = std::numeric_limits::quiet_NaN(); - const auto check_nan = [&](auto reducer) { - auto result = algorithm::reduce(nonfinite, ids, 0, 1, ValueMapping::Linear, reducer); - REQUIRE(result); - CHECK(std::isfinite(result->first.pixel({ 0, 0 }).x)); - CHECK(std::isnan(result->first.pixel({ 0, 0 }).y)); - }; - check_nan(algorithm::Min {}); - check_nan(algorithm::Max {}); - check_nan(algorithm::Median {}); - const auto replace_nan = [](std::span values) { - CHECK(std::isnan(values[0].y)); - return glm::vec3(42); - }; - CHECK(algorithm::reduce(nonfinite, ids, 0, 1, ValueMapping::Linear, replace_nan)->first.pixel({ 0, 0 }) == glm::vec3(42)); + 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", @@ -354,29 +308,28 @@ TEMPLATE_TEST_CASE("raster scalar working types support integer endpoints", const auto result = scale(data, ids, -1); REQUIRE(result); CHECK(result->first.pixel({ 0, 0 }) == value); - const auto maximum = algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, algorithm::Max {}); + const auto maximum = scaler::reduce(data, ids, 0, 1, algorithm::Max {}); REQUIRE(maximum); CHECK(maximum->first.pixel({ 0, 0 }) == value); } } -TEST_CASE("integer raster conversion rounds clamps and rejects nonfinite custom values", "[raster-algorithm]") +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 algorithm::reduce(data, ids, 0, 1, ValueMapping::Linear, reducer); + return scaler::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); - CHECK(convert(std::numeric_limits::infinity()).error().code() == Error::Code::InvalidInput); - CHECK(convert(std::numeric_limits::quiet_NaN()).error().code() == Error::Code::InvalidInput); radix::Raster large(glm::uvec2(2), 0); const auto upper = [](std::span) { return std::ldexp(1.L, 64); }; - CHECK(algorithm::reduce(large, ids, 0, 1, ValueMapping::Linear, upper)->first.pixel({ 0, 0 }) == (std::numeric_limits::max)()); + CHECK(scaler::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]") @@ -392,7 +345,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with CHECK(values[2].w == 128.f / 255.f); return algorithm::Median {}(values); }; - const auto custom = algorithm::reduce(data, ids, 0, 1, ValueMapping::SRGBA, reducer); + const auto custom = scaler::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, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); @@ -415,7 +368,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with same_raster(direct->first, second->first); } -TEST_CASE("Lanczos reduction matches an independent two-dimensional validity-aware reference", "[raster-algorithm]") +TEST_CASE("Lanczos reduction matches an independent two-dimensional reference", "[raster-algorithm]") { for (int radius = 2; radius <= 4; ++radius) { const auto filter = radius == 2 ? Filter::Lanczos2 : (radius == 3 ? Filter::Lanczos3 : Filter::Lanczos4); @@ -432,7 +385,7 @@ TEST_CASE("Lanczos reduction matches an independent two-dimensional validity-awa } } -TEST_CASE("Lanczos discards valid outer support when the local block is NoData", "[raster-algorithm]") +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); @@ -445,7 +398,7 @@ TEST_CASE("Lanczos discards valid outer support when the local block is NoData", const auto result = scale(data, ids, -1, 3, Filter::Lanczos2); REQUIRE(result); CHECK(result->second.pixel({ 0, 0 }) == 0); - CHECK(result->first.pixel({ 0, 0 }) == -17); + 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]") @@ -511,26 +464,12 @@ TEST_CASE("Lanczos reduction preserves constants and suppresses high frequencies } } -TEST_CASE("weight normalization falls back on cancellation but allows negative sums", "[raster-algorithm]") -{ - const auto data = samples({ 2, 2 }, { 2, 4, 0, 0 }); - const auto ids = samples({ 2, 2 }, { 1, 1, 0, 0 }); - const auto local = algorithm::detail::local_block(ids, { 0, 0 }); - algorithm::detail::Kernel kernel(0); - // Synthetic weights isolate the numerical threshold independently of - // the selected Lanczos radius and the platform's sine approximation. - kernel.weights[1] = -1 + std::numeric_limits::epsilon(); - CHECK(kernel(data, ids, { 0, 0 }, local, ValueMapping::Linear).value() == 2); - kernel.weights[1] = -2; - CHECK(kernel(data, ids, { 0, 0 }, local, ValueMapping::Linear).value() == 6); -} - 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, Filter::Lanczos2); - const auto median = algorithm::reduce(data, ids, 3, 1, ValueMapping::Linear, algorithm::Median {}); + const auto median = scaler::reduce(data, ids, 3, 1, algorithm::Median {}); REQUIRE(filtered); REQUIRE(median); for (glm::length_t i = 0; i < TestType::length(); ++i) { @@ -539,20 +478,8 @@ TEMPLATE_TEST_CASE("raster vector arithmetic instantiates every working precisio } } -TEST_CASE("weighted raster filters preserve attributed nonfinite values and floating overshoot", "[raster-algorithm]") +TEST_CASE("weighted filters retain floating overshoot and clamp integers", "[raster-algorithm]") { - radix::Raster data(glm::uvec2(2), 1); - radix::Raster ids(glm::uvec2(2), 1); - data.pixel({ 0, 0 }) = std::numeric_limits::quiet_NaN(); - const auto nan = scale(data, ids, -1); - REQUIRE(nan); - CHECK(std::isnan(nan->first.pixel({ 0, 0 }))); - CHECK(nan->second.pixel({ 0, 0 }) == 1); - data.pixel({ 0, 0 }) = std::numeric_limits::infinity(); - const auto infinite = scale(data, ids, -1); - REQUIRE(infinite); - CHECK(std::isinf(infinite->first.pixel({ 0, 0 }))); - radix::Raster impulse(glm::uvec2(8), 0); radix::Raster impulse_ids(glm::uvec2(8), 1); impulse.pixel({ 1, 3 }) = 1; @@ -579,3 +506,210 @@ TEST_CASE("bilinear colour interpolation decodes sRGB before combining samples", 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 : { Filter::Box, Filter::Lanczos2 }) { + const auto expected = algorithm::scale(materialized, 9, -2, Interpolation::Bilinear, filter); + const auto actual = algorithm::scale(padded, 9, -2, Interpolation::Bilinear, filter); + REQUIRE(expected); + REQUIRE(actual); + same_raster(*actual, *expected); + REQUIRE(algorithm::scale(padded, 9, -2, Interpolation::Bilinear, 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, Interpolation::Bilinear, Filter::Box); + REQUIRE(up); + radix::Raster up_output(up->size()); + REQUIRE(algorithm::scale(up_padded, 1, 1, Interpolation::Bilinear, Filter::Box, 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, Interpolation::Bilinear, Filter::Box, 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, Interpolation::Bilinear, Filter::Box, 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(scaler::scale(data, ids, 0, -1, Interpolation::Bilinear, Filter::Box, ValueMapping::Linear, output, wrong)); + CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, wrong)); + const auto cross = raster::make_view(data, { 0, 0 }, { 2, 2 }).value(); + CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, cross)); + CHECK_FALSE(scaler::scale(data, ids, 0, -1, Interpolation::Bilinear, Filter::Box, ValueMapping::Linear, output, cross)); + CHECK_FALSE(scaler::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(scaler::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, Interpolation::NearestNeighbour, Filter::Box, 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, Interpolation::NearestNeighbour, Filter::Box); + 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, Interpolation::NearestNeighbour, Filter::Box, conversion); + REQUIRE(custom); + same_raster(*rounded, *custom); +} + +namespace { +template +concept CanScaleFloat = requires( + radix::Raster& source, const Conversion& conversion) { algorithm::scale(source, 0, -1, Interpolation::NearestNeighbour, Filter::Box, 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, Interpolation::NearestNeighbour, Filter::Box, conversion); + const auto up = algorithm::scale(source, 1, 1, Interpolation::Bilinear, Filter::Box, conversion); + REQUIRE(down); + REQUIRE(up); + for (const auto value : *down) { + CHECK(value == 7); + } + for (const auto value : *up) { + CHECK(value == 7); + } +} diff --git a/unittests/terrainlib/raster_view.cpp b/unittests/terrainlib/raster_view.cpp index 39582098..867f9d42 100644 --- a/unittests/terrainlib/raster_view.cpp +++ b/unittests/terrainlib/raster_view.cpp @@ -45,7 +45,7 @@ concept CanTransform = requires(Source&& source, const Function& function, Desti template concept CanWindowTransform = requires(Source&& source, const Function& function, Destination&& destination) { - algorithm::window_transform(std::forward(source), 3, function, std::forward(destination)); + algorithm::window_transform(std::forward(source), glm::uvec2(3), function, std::forward(destination)); }; template @@ -348,7 +348,7 @@ TEST_CASE("window transforms traverse strided kernels and convert output types", } return result; }; - REQUIRE(algorithm::window_transform(source, 3, sum, output)); + 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)); @@ -357,7 +357,7 @@ TEST_CASE("window transforms traverse strided kernels and convert output types", CHECK(destination.pixel({ 0, 0 }) == -1.); CHECK(destination.pixel({ 4, 3 }) == -1.); Raster identity(source.size()); - REQUIRE(algorithm::window_transform(source, 1, Centre {}, identity)); + REQUIRE(algorithm::window_transform(source, glm::uvec2(1), Centre {}, identity)); CHECK(identity.pixel({ 4, 3 }) == 45); } @@ -375,7 +375,7 @@ TEST_CASE("clamped window kernels retain the original source bounds", "[raster-v } return result; }; - REQUIRE(algorithm::window_transform(padded, 7, sum, output)); + 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; @@ -400,14 +400,14 @@ TEST_CASE("window validation precedes processing and rejects all overlapping out return 0; }; for (const auto kernel : { 0u, 2u, 4u, 7u, (std::numeric_limits::max)() }) { - CHECK_FALSE(algorithm::window_transform(source, kernel, never, output)); + CHECK_FALSE(algorithm::window_transform(source, glm::uvec2(kernel), never, output)); } - CHECK_FALSE(algorithm::window_transform(source, 1, 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, 3, never, overlapping)); - CHECK_FALSE(algorithm::window_transform(source, 1, never, 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)); } @@ -426,7 +426,7 @@ TEST_CASE("matching empty views are pointwise no-ops but cannot supply a kernel" 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, 1, never, output)); + 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]") @@ -451,11 +451,11 @@ TEST_CASE("destination-free operations infer output types and allocate matching REQUIRE(replicated); CHECK(replicated->size() == padded.size()); CHECK(replicated->pixel({ 5, 4 }) == 23); - auto filtered = algorithm::window_transform(padded, 3, Centre {}); + 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, 3, [](const auto& window) { return double(window.pixel({ 1, 1 })); }); + 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)); @@ -487,9 +487,9 @@ TEST_CASE("allocating overloads reject invalid geometry and unrepresentable stor 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, 0, never)); - CHECK_FALSE(algorithm::window_transform(source, 2, never)); - CHECK_FALSE(algorithm::window_transform(source, 5, 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(); @@ -498,7 +498,7 @@ TEST_CASE("allocating overloads reject invalid geometry and unrepresentable stor 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, 1, never)); + CHECK_FALSE(algorithm::window_transform(huge, glm::uvec2(1), never)); Raster empty({ 0, maximum }); auto copied = algorithm::copy(empty); @@ -507,3 +507,39 @@ TEST_CASE("allocating overloads reject invalid geometry and unrepresentable stor 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)); +} From 855a7089e3175e4e05167699ce40d9077ad01770 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:57:42 +0200 Subject: [PATCH 20/83] Implement raster tile halo extraction and windowed scaling --- docs/raster-store/README.md | 6 +- docs/raster-store/architecture.md | 37 ++- docs/raster-store/halo-implementation-plan.md | 259 +++++++++++++++++ docs/raster-store/implementation-status.md | 31 ++ docs/raster-store/sampling-and-generation.md | 15 +- docs/raster-store/scaling.md | 100 ++++++- docs/raster-store/storage-format.md | 33 ++- docs/raster-store/tiles-with-halo.md | 57 +++- src/terrainlib/CMakeLists.txt | 1 + src/terrainlib/raster/algorithm/scale.h | 78 ++++- .../raster/algorithm/scaling_detail.h | 26 ++ src/terrainlib/raster_store/io/manifest.cpp | 12 +- src/terrainlib/raster_store/io/manifest.h | 7 +- src/terrainlib/raster_store/pixel.h | 7 + .../raster_store/read_tile_with_halo.h | 221 ++++++++++++++ src/terrainlib/raster_store/scaler.h | 98 ++++++- src/terrainlib/raster_store/storage.h | 29 +- unittests/CMakeLists.txt | 1 + unittests/terrainlib/raster_algorithm.cpp | 90 +++++- unittests/terrainlib/raster_halo.cpp | 273 ++++++++++++++++++ unittests/terrainlib/raster_store.cpp | 67 ++++- 21 files changed, 1362 insertions(+), 86 deletions(-) create mode 100644 docs/raster-store/halo-implementation-plan.md create mode 100644 src/terrainlib/raster_store/read_tile_with_halo.h create mode 100644 unittests/terrainlib/raster_halo.cpp diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 7ae9626b..be4ce061 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -2,7 +2,8 @@ This directory describes authoritative raster tile storage and the planned generation of delivery tile pyramids from it. Storage format version 1 is -documented alongside the implementation status. RF import is implemented; +documented alongside the implementation status. Halo extraction, windowed +scaling, and RF import are implemented; TB generation and the tile server remain future work. ## Documents @@ -15,7 +16,8 @@ TB generation and the tile server remain future work. ## Plans -- [Tiles with halo: design in progress](tiles-with-halo.md) +- [Tiles with halo: design](tiles-with-halo.md) +- [Halo extraction implementation plan](halo-implementation-plan.md) - [Tile-storage implementation plan](implementation-plan.md) - [Raster store TODO](todo.md) diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index 67f19afb..2d1ff125 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -12,10 +12,11 @@ sampling and filtering policy. ## Current implementation scope -The first raster-store library implementation covers tile storage only: -typed tiles, attribution tables, codecs, persistent index and metadata, -dataset opening, and snapshot publication. Spatial window reads, ancestor -fallback, builders, merging, and sampling are outside this implementation. +The raster-store library provides typed tiles, attribution tables, codecs, +persistent index and metadata, snapshot publication, windowed scaling, and +physical-tile halo extraction with neighbour/ancestor fallback. RF importers +are implemented; arbitrary geographic window reads, TB generation, and merging +remain future work. It remains part of the existing `terrainlib` target. ## Implementation status @@ -44,11 +45,10 @@ Paired `scale` accepts `raster_store::pixel::Mapping` from the shared `raster_store/pixel.h`; paired `reduce` and generic algorithms receive decoder/encoder tuples. Paired `reduce` defaults to identity conversion. The same header provides `pixel::identifier()` with its `Format` machinery -in `pixel::detail`, replacing the former `pixel_type.h` API. Planned snapshot -metadata uses the shared `pixel::Mapping` type as well. +in `pixel::detail`, replacing the former `pixel_type.h` API. Snapshot metadata +uses the shared `pixel::Mapping` type as well. Source NoData handling stays with importers, while physical-source selection -and missing-coverage replication belong to the planned halo extractor. -The scaler refactor is implemented; halo extraction and its metadata remain planned. +and missing-coverage replication belong to the halo extractor. ### Raster storage API @@ -60,13 +60,14 @@ whose selected table is copied unchanged into the output. ```cpp raster_store::storage::CreateOptions options; -options.tile_dimensions = { 3, 3 }; +options.nominal_tile_size = 64; +options.halo_width = 0; auto created = raster_store::storage::create(snapshot_path, options); if (!created) { return Error::propagate(std::move(created)); } auto [output, metadata] = std::move(*created); -raster_store::Tile tile(3); // Attribution defaults to zero (unattributed). +raster_store::Tile tile(metadata->stored_tile_size); // Attribution defaults to zero (unattributed). if (auto saved = output->save({ 0, { 0, 0 } }, tile); !saved) { return saved; } @@ -102,6 +103,22 @@ namespace. Versioned payload structs belong to `manifest::detail::v1` and Both raster and octree index decoding use `store::Index::validate() const` to validate the decoded hierarchy. +### Halo reads + +Include `raster_store/read_tile_with_halo.h` and call: + +```cpp +auto tile = raster_store::read_tile_with_halo(*storage, *metadata, tile_id, + 2, raster::algorithm::Interpolation::Bilinear); +``` + +The result is an ordinary `Tile` with a two-pixel halo. The centre must be +physical. Stored halos are cropped exactly; zero-halo snapshots resolve +same-zoom neighbours, descend at most four levels, and use physical ancestors +for remaining regions. Uncovered data replicates the central edge with zero +attribution. Source assignment precedes payload reads, so each source is read +at most once. See [Tiles with halo](tiles-with-halo.md) for the complete contract. + ### Final public names The shared API uses `store::NodeStatus`, `store::NodeStatusOrMissing`, diff --git a/docs/raster-store/halo-implementation-plan.md b/docs/raster-store/halo-implementation-plan.md new file mode 100644 index 00000000..06825a13 --- /dev/null +++ b/docs/raster-store/halo-implementation-plan.md @@ -0,0 +1,259 @@ +# Halo extraction implementation plan + +Status: implementation approved and completed, 2026-09-22; final verification +is recorded below. Commit and push were explicitly authorized for halo work; +GDAL NoData filling and driver research are excluded. +Conversion tuples stay in the scaling interface. The accepted behavior is +defined by [Tiles with halo](tiles-with-halo.md) and +[Raster scaling](scaling.md), including its windowed-scaling extension. + +## Scope and baseline + +Implement windowed scaling, snapshot halo/value-mapping metadata, halo +extraction, and RF CLI propagation of the mapping. Reuse the implemented +views, copies, conversion tuples, and paired scaler. `radix::Raster` and +`raster_store::Tile` retain their current members. Work stays in terrain-builder; +the earlier proposed radix refactor has been superseded by the existing +`terrainlib/raster/algorithm` implementation. + +The current storage API returns storage and metadata together from `open` +and `create`. Preserve that interface. Keep format version 1 and change its +metadata representation directly, as agreed. Tile buffers still describe +their complete stored raster; no new tile payload fields are needed. + +The working tree already contains edits to the raster-store README, scaling +contract, and halo design, plus the untracked GDAL NoData-filling plan. Recheck +and report status before implementation, preserve existing work, and stay on +the current branch. GDAL NoData filling belongs to the separate plan. + +## 1. Add windowed scaling + +Primary files: `src/terrainlib/raster/algorithm/scale.h`, +`scaling_detail.h`, `src/terrainlib/raster_store/scaler.h`, and +`unittests/terrainlib/raster_algorithm.cpp`. + +1. Add the agreed `output_offset` overloads to generic and paired `scale`. + Allocating overloads also take `output_size`; both are `glm::uvec2`. + Keep conversion-tuple overloads and destination-last call conventions. +2. Separate validation of a requested window from validation of a full + output allocation. Calls without an offset still require the exact full + destination size. Validate both paired destinations and overlap before + either is written. Use unsigned, overflow-safe bounds and shift checks; + do not require construction of the full conceptual output dimensions + when only a small window is requested. +3. Extend the existing interpolation implementation to use the conceptual + output offset. Preserve integer quotient/remainder phase calculation, + exact nearest-neighbour copies, and zero-level copying. Compute only the + requested pixels. Keep whole-output calls on the same implementation. +4. For reduction, derive the required source/intermediate rectangles + backwards from the requested output. Preserve global block alignment, + the complete Lanczos support, separable pass ordering, and encoding after + every factor-two step. Restrict intermediates to the necessary rectangles. + Reuse `window_transform`, ordinary views, and clamped-view semantics. +5. Window attribution with the same output coordinates: nearest neighbour + for upscaling and repeated mode reduction for downscaling. Keep zero as + an ordinary attribution value. Reuse reduction internals where needed; + a new public windowed custom-reducer interface is not required. + +Stage validation: + +- Compare allocated and destination-view windows with full-scale-then-crop, + including non-aligned offsets, rectangular and strided views, zero levels, + nearest neighbour, bilinear, box, and every Lanczos support radius. +- Check multi-level integer rounding, SRGBA encoding, and mode attribution + against the existing full-output behavior, including zero attribution. +- Check invalid offsets/extents, insufficient halo, overlap, and paired + destination mismatches before writes. Without an offset, reject both + undersized and oversized destinations. +- Request a tiny window across a 20-level upscale, including a case whose + conceptual full result cannot be allocated as an ordinary raster. Verify + the expected samples without allocating or visiting the complete result. +- Retain existing exact-copy/conversion-bypass and independent kernel tests. + +## 2. Extend snapshot metadata and creation + +Primary files: `src/terrainlib/raster_store/io/manifest.h/.cpp`, +`pixel.h`, `storage.h`, codec construction call sites, and +`unittests/terrainlib/raster_store.cpp`. + +1. Replace metadata `width`/`height` with `stored_tile_size`, + `nominal_tile_size`, and `halo_width`; add the existing `pixel::Mapping` + as `value_mapping`. Nominal/stored defaults are 4096 and halo defaults to + zero. Keep one shared mapping enum. +2. Replace `CreateOptions::tile_dimensions` with + `unsigned nominal_tile_size = 4096` and add `unsigned halo_width = 0`. + Represent the optional explicit mapping choice with + `std::optional value_mapping`; resolve an omitted choice + in `create()`. RGB8/RGBA8 default to SRGBA; other types default + to Linear. Metadata always contains the resolved enum, not an optional. +3. Validate positive power-of-two nominal size, `halo_width <= nominal_tile_size`, + and the checked equality + `stored_tile_size = nominal_tile_size + 2 * halo_width`. Use the existing + metadata validation/error conventions. The extractor asserts its + minimum nominal side of 64; this does not require making small standalone + codec/view test fixtures larger. +4. Construct codecs using square dimensions derived from stored size. Update + affected callers and metadata fixtures. Preserve the existing open/create + ownership model, explicit checkpoints, and publication behavior. +5. Validate metadata representation and known enum values, but do not reject + a valid mapping merely because a downstream scaler cannot process that + mapping/pixel-type combination. Such rejection stays in that operation. + +Stage validation: + +- Round-trip metadata and tile payloads with zero and nonzero halos; include + a power-of-two interior whose stored side is not a power of two. +- Verify type-based defaults, explicit overrides, and persistence of the + resolved mapping, including overrides unsupported by numerical scaling. +- Reject inconsistent metadata sizes, invalid nominal sizes/halos, arithmetic + overflow, and unknown mapping enumerators through the appropriate API. +- Preserve payload bits, read/write dimension checks, storage ownership, + checkpoint, hard-link, and publication regressions. No legacy upgrade tests. + +## 3. Implement physical-source halo extraction + +Add `src/terrainlib/raster_store/read_tile_with_halo.h`, exposing +`raster_store::read_tile_with_halo`, with implementation helpers kept private. +Register the header in `src/terrainlib/CMakeLists.txt`. Add focused tests in +`unittests/terrainlib/raster_halo.cpp`, registered in `unittests/CMakeLists.txt`. + +The function takes storage and metadata by const reference, a tile ID, the +requested halo width, and `raster::algorithm::Interpolation`. It returns +`Expected>`. Callers supply metadata associated with the store; +perform structural/dimension validation without reopening metadata per read. + +1. Validate the ID, physical centre, metadata, and requested width. Require + `Leaf` or `Inner`; reuse existing missing/invalid-ID error handling. + Assert nominal side at least 64. Propagate indexed-payload read failures. +2. For a stored halo, reject requests exceeding it and copy the exact + requested crop from both rasters. For a zero-width request, return the + exact interior. These paths do not fetch neighbours or invoke scaling. +3. Otherwise allocate the output once and copy the centre through views. + Partition the surrounding strips/corners into disjoint output rectangles. + Use tile-local addressing, horizontal wrap, and explicit vertical limits. +4. Resolve each rectangle from the index before loading its payload: + same-zoom physical tile first; otherwise first physical descendants per + branch, at most four levels; nearest physical ancestor for unresolved + coverage, including branches stopped by the limit. Zero attribution does + not affect source selection. +5. Group assigned rectangles by physical tile ID. Load each supplying tile + once, process all its assignments, then release it. Reuse the already + loaded central tile if wrapping selects it. The grouping is per extraction; + no persistent cache or cache configuration is added. +6. Use view copies for equal-resolution regions. Use paired windowed scaling + with Box for descendants and the requested interpolation for ancestors. + Clamp bilinear support to the complete supplying ancestor interior; do not + load support neighbours or clamp independently at processing cutouts. +7. For missing physical coverage only, copy through a clamped view of the + central interior and leave synthesized attribution zero. Supplied or + resampled zero-attribution payloads remain untouched by this fallback. + +Stage validation uses synthetic stores and a counting/failing test codec at +the existing storage seam, without adding production test hooks: + +- Exact centre/crop results, physical inner centres, all edges/corners, + zero/full permitted widths, invalid requests, and physical read errors. +- Same-zoom preference, stopping at physical inner descendants, mixed-depth + sibling branches, four-level cutoff, ancestor fallback, and missing coverage. +- One read per supplying tile across multiple assigned regions, no reads + beyond the descent cutoff, and no neighbour reads for stored-halo crops. +- Horizontal seam/root wrapping, vertical limits, and corners crossing them. +- Zero-attribution numerical contributions and preservation, synthesized + zero attribution independent of the central pixel's attribution, and + exact nonfinite copies where conversion is bypassed. +- Both ancestor interpolation choices, full-ancestor clamping, distant + ancestor windows, and mapping errors only when an unsupported operation + is actually used. Verify scaler integration without duplicating its kernel + mathematics in extractor tests. + +## 4. Propagate value mapping through RF creation and CLI + +Primary files: `src/rf_builder/run.h/.cpp`, `gdal/build.h/.cpp`, +`gdal/cli.cpp`, `tiles/build.cpp`, `tiles/cli.cpp`, and the GDAL/tiles +`inputs.h` records. Extend existing tests in +`unittests/rf_builder/import.cpp` and `online.cpp`. + +1. Add `--value-mapping linear|srgba` to both commands and carry the optional + override through their existing options, without restructuring the options + hierarchy. Resolve the default from the produced pixel type. Document + RGB8/RGBA8 defaults, Linear otherwise, override precedence, and linear alpha. +2. Pass nominal size, explicit zero halo, and the selected mapping into store + creation. Update RF metadata checks to the revised fields. +3. Include the resolved mapping in both input records and cache checks. Compare + nominal/stored dimensions, zero halo, mapping, and codec before hard-link + reuse. An omitted default and an explicit equivalent mapping remain + compatible; a different effective mapping fails before production/HTTP. + Revise current version-1 records directly, consistent with the agreed + absence of compatibility requirements, and update aggregate fixtures. +4. This stage declares the mapping of stored payloads. It retains existing + import filtering: GDAL's current raster sampling and the online JPEG + worker's current linear-light fallback interpolation. The override does + not add source-profile conversion or change these import kernels. This + scope is explicit because changing import filtering would be additional + behavior beyond metadata propagation and halo extraction. + +Stage validation: scalar/RGB defaults, explicit overrides, zero RF halo, +persisted metadata, CLI help and invalid values, equivalent/mismatched cache +records, inconsistent cache metadata, and online rejection before HTTP. +Retain current import pixel-equivalence, cancellation, and cache regressions. + +## 5. Integration, documentation, and verification + +1. Update storage-format fields and examples, architecture, sampling/halo + references, RF CLI documentation, and implementation status to describe the + completed behavior. Keep this plan as the stage checklist and record actual + validation results rather than treating historical results as new evidence. +2. Run focused Catch2 tests after each stage. Once stages are integrated, run + the terrainlib, RF-builder, and DAG-builder regression suites; include + dependent build targets affected by the metadata API rename. Run affected + tests under existing sanitizer configurations when available and compile + the changed template code in an existing optimized configuration. +3. Use the Qt C++ skill for lint/review and `clang-format-21` on changed C++ + sections. Check standalone public-header compilation, `git diff --check`, + and changed-file line endings with + `git ls-files --eol --cached --others --exclude-standard`. + +The verified local starting configuration is `build/Desktop_Debug` (GCC, +tests enabled). The relevant targets are `rf-builder`, +`unittests_terrainlib`, `unittests_rfbuilder`, and `unittests_dagbuilder`. +Reuse this configuration and the documented PROJ/GDAL test environment. +The renderer-only `dev_driver.py` workflow does not apply to this project. + +Build/test entry points: + +```sh +cmake --build build/Desktop_Debug --target rf-builder unittests_terrainlib unittests_rfbuilder unittests_dagbuilder --parallel 6 +build/Desktop_Debug/unittests/unittests_terrainlib '[raster-algorithm],[raster-store],[raster-halo]' +build/Desktop_Debug/unittests/unittests_rfbuilder +``` + +Use `[raster-halo]` for the new extraction fixtures. Full regressions follow +the focused tests, using the existing exclusion for the unrelated mesh clipping +benchmark. No runtime timing threshold is required: tiny-window large-gap +tests and exact source-read counts establish the key bounded-work properties. + +## Completion criteria + +- Each stage's behavior and focused checks pass; integrated regressions and + required formatting/lint checks are reported with their actual outcomes. +- Halo extraction uses the shared algorithms, preserves the central/cropped + payload exactly, obeys physical-source selection, and avoids full upscaled + intermediates and duplicate source reads. +- Metadata and CLI defaults/overrides round-trip and cache reuse checks the + effective mapping and halo geometry. +- Documentation matches the implemented format and interface. Report any + remaining limitations, final Git status, and a reminder to commit; do not + commit, push, or change branches without the corresponding authorization. + +## Completion record — 2026-09-22 + +All five implementation stages are complete. Window reduction uses a +factor-aligned source view with the cumulative kernel halo, then the existing +staged reducer; this limits intermediates while preserving the full result's +stage alignment and encoding. Ancestor windows rebase into source coordinates +before scaling, retaining unsigned phase arithmetic and full-source clamping. + +The current validation results are recorded in +[implementation status](implementation-status.md#halo-extraction--2026-09-22). +The user authorized committing and pushing halo extraction and related docs, +while leaving GDAL NoData handling and driver research uncommitted. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index f1efe6d5..f1f50fea 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -1,5 +1,36 @@ # Raster-store implementation status +## Halo extraction — 2026-09-22 + +Implemented windowed generic/paired scaling, physical-tile halo extraction, +V1 nominal/stored/halo metadata, and resolved value mappings. Both RF commands +accept the mapping override and validate its effective value for cache reuse. +Conversion tuples remain supported. GDAL NoData filling and driver research +are separate work and are excluded from this change. + +Verification: + +- Focused raster algorithms/storage/halo tests: 93 cases, 206,738 assertions. +- Terrainlib regression: 567 cases, 231,409 assertions, excluding the existing + `mesh::clip_on_bounds benchmark`. +- RF builder: 48 cases, 54,030 assertions; DAG builder: 76 cases, 479 assertions. +- ASan/LSan/UBSan: the same 93 focused cases pass, plus all 48 RF cases + (54,029 assertions), with leak detection, halt-on-error, and the existing + `misc/suppression/ubsan.txt` enabled. An initial RF run without that list + reported the already documented CGAL facade downcasts; the configured run + has no sanitizer diagnostics. +- Seven affected public headers compile independently with warnings as errors. +- GCC 16 compiles both algorithm and halo test translation units at `-O3` + with warnings as errors. An additional GCC 14 `-O3 -Werror` probe hits the + previously documented `std::sort`/median array-bounds warning in unchanged + reducer code; no unrelated reducer change or warning suppression was added. +- Qt C++ lint and six read-only review passes found no confirmed new defects. + Existing lint notices remain on unchanged code; the new optional ancestor + is explicitly initialized. Nonconstant integration tests verify box samples, + bilinear phase/full-ancestor clamping, exact payload bits, and strided windows. +- Changed C++ sections use `clang-format-21`; diff checks and changed-file + Git line-ending checks pass. + ## Online RF import — 2026-09-12 The [online import plan](rf-builder-downloader-design.md) was approved in the diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index fd2b28c2..d0b0f630 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -112,7 +112,9 @@ 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. +RF snapshots store non-overlapping interiors. TB snapshots may persist filter +halos, described by metadata, without changing interior geographic extents. +These halos are distinct from overlapping vertex-pixel rendering borders. Pyramid generation constructs a vertex-pixel output only after reconstruction and filtering. @@ -141,8 +143,8 @@ 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: +The physical-tile halo reader resolves a tile-centred window over the +quadtree. It resolves: - physical chunks selected for the requested accuracy; - ancestor fallback where finer data is absent; @@ -242,3 +244,10 @@ The [scaling contract](scaling.md) specifies coefficients and verification requirements for its area-pixel operations. Other layer-specific and vertex-pixel filters remain separate design decisions; tests should fix their numeric tolerances only after representative evaluation. + +The implemented [halo reader](tiles-with-halo.md) clamps bilinear ancestor +support at the complete supplying physical tile's edge. This is its explicit +fallback policy; it does not promise the seam-free vertex-generation invariant +above. A future generator needing that invariant must assemble common support +or generate metatiles. Halo attribution is representative and never masks +numerical contributions; zero attribution triggers no resolution fallback. diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md index c0735828..09ec33a6 100644 --- a/docs/raster-store/scaling.md +++ b/docs/raster-store/scaling.md @@ -2,8 +2,8 @@ Status: the single-raster, view-based refactor was implemented on 2026-09-22, including paired raster-store wrappers, conversion tuples, rectangular strided -windows, and separable box/Lanczos filtering. Halo extraction and its storage -metadata remain separate work. +windows, and separable box/Lanczos filtering. Output-window scaling, halo +extraction, and snapshot halo metadata were implemented later the same day. ## Scope and ownership @@ -124,6 +124,84 @@ Expected required_downscaling_halo( corresponding to the requested direction. The filter enum represents the three Lanczos support radii without a separate parameter for box filtering. +## Agreed windowed scaling extension + +Implemented on 2026-09-22 after the view-based scaler refactor. Overloads of generic and paired +`scale` select a window of their conceptual full output, in either +scaling direction. + +Use `output_offset` (`glm::uvec2`) and, for allocating overloads, +`output_size` (`glm::uvec2`) as direct parameters. The offset is measured in +pixels of the conceptual scaled output, excluding the source halo; writing +starts at destination-local `(0,0)`. Destination-taking overloads infer the +window size from the destination. Paired destinations must have matching +sizes and use the same offset. + +Calls without `output_offset` require destinations whose dimensions exactly +match the full scaled output, excluding the source halo. A smaller destination +does not request cropping; any size mismatch returns `InvalidInput` before +writing. This applies to both generic and paired overloads. Only overloads +with an explicit `output_offset` infer a crop size from the destination. + +Generic call shapes extend the existing convention: + +```cpp +scale(source, halo_width, n_zoom_levels, interpolation, filter, + output_offset, destination); +scale(source, halo_width, n_zoom_levels, interpolation, filter, + output_offset, output_size); +scale(source, halo_width, n_zoom_levels, interpolation, filter, + output_offset, conversion, destination); +scale(source, halo_width, n_zoom_levels, interpolation, filter, + output_offset, output_size, conversion); +``` + +The corresponding paired forms are: + +```cpp +scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, + output_offset, value_mapping, data_destination, attribution_destination); +scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, + output_offset, output_size, value_mapping); +``` + +The selected rectangle is `[output_offset, output_offset + output_size)`. +Results must equal full scaling followed by cropping this rectangle, while +computing only the necessary samples. Existing full-output overloads retain +their behavior and share the processing implementation with windowed forms. +Preserve existing return types, conversion defaults, signed zoom-level +convention, interpolation/filter selection, and pixel semantics. Zero levels +select an exact crop within the source interior, after excluding its halo. + +For upscaling, evaluate only requested output pixels with the sampling phase +of their position in the conceptual full result. A source view alone cannot +express arbitrary output offsets: they need not align with source-pixel +boundaries. Use integer quotient/remainder calculations before converting +the local interpolation phase to floating point. + +For downscaling, work backwards from the requested output window to the +source and intermediate samples needed by each reduction step, including +filter support. Preserve the existing globally aligned reduction stages, +encoding/rounding after each stage, and independent attribution reduction. +Do not clamp at processing-window edges or replace repeated reductions with +a single larger filter. This extension does not add window overloads to +custom `reduce` as a separate public feature. + +Validate the selected rectangle before writing; it must fit inside the +conceptual full output. Use unsigned factor and coordinate arithmetic with +overflow-safe shift and bounds checks. The full conceptual image +need not fit raster dimensions or be allocated; only the requested output +and necessary intermediate storage must fit their allocation limits. +Preserve source-halo requirements and the existing overlap rules. + +Verification must compare windows against full-scale-then-crop for zero, +positive, and negative levels, including non-aligned offsets, multi-step +rounding, Lanczos support, paired attribution, and strided destinations. +Verify that calls without `output_offset` reject both smaller and larger +destinations without modifying them. +Test bounded work for distant ancestors without allocating their conceptual +full output, including a 20-level ancestor gap. + ## Algorithms and reducers Upscaling offers nearest-neighbour and bilinear interpolation. Upscale directly @@ -234,8 +312,9 @@ Reuse generic validation and processing helpers rather than duplicate kernels. `Mapping::Linear` selects the supplied linear conversion for `scale`; `Mapping::SRGBA` selects sRGB conversion for `scale` and requires RGB/RGBA -unsigned 8-bit pixels. Validate mapping enumerators and compatibility before -writing either output. Callers may explicitly supply the sRGB conversion tuple +unsigned 8-bit pixels for numerical resampling. Zero-level and nearest-neighbour +copies bypass conversion and do not impose that type restriction. Validate +mapping enumerators, and compatibility when needed, before writing either output. Callers may explicitly supply the sRGB conversion tuple to paired `reduce`, just as they can supply custom lambdas. ### Shared pixel description @@ -248,12 +327,11 @@ the new header and names. Define `pixel::Mapping { Linear, SRGBA }` in that same header. It replaces the former scaler `ValueMapping` name while retaining runtime mapping selection at -the paired `scale` boundary. The planned snapshot metadata and creation options +the paired `scale` boundary. Snapshot metadata and creation options use this same type for `value_mapping`; do not define a separate enum in the manifest's versioned namespace. Generic raster algorithms accept conversion tuples and do not depend on the raster-store pixel header or mapping enum. -Adding the planned metadata field remains part of the separate halo/metadata -implementation, not an implicit schema change in this refactor. +The halo implementation added this field to version 1 metadata. ## Conversion tuples and numeric behavior @@ -336,7 +414,7 @@ original larger block. Repeated rounding and encoding also prevent a general promise of agreement with a single larger numeric filter. The planned snapshot metadata uses `raster_store::pixel::Mapping` to describe -linear or sRGB interpretation. The future halo extractor passes that value to +linear or sRGB interpretation. The halo extractor passes that value to paired `scale`, which selects the corresponding tuple. Runtime mapping selection stays at this store boundary; generic `scale` and `reduce` receive only conversion callables. This refactor does not change the manifest schema. @@ -359,7 +437,9 @@ auto padded = raster::make_clamped_view(source, {-1, -1}, {6, 6}); An ordinary 6x6 source view can instead supply physical neighbours. The scaler does not construct clamped views implicitly. It rejects an insufficient logical -halo for either source kind. Internal tile edges must not change filter support. +halo for either source kind. Clamping bounds are caller policy: the halo +extractor deliberately clamps interpolation support at the full supplying +ancestor's edges. A smaller processing cutout must preserve those bounds. Compute the required halo for the complete operation, including all reduction steps. Retain enough intermediate halo for later steps and crop only the final @@ -549,4 +629,4 @@ benchmark, on 2026-09-19. Its independent Lanczos fixtures, type conversions, Qt review, formatting, and line-ending checks passed. CI for commit `2a75fb4` subsequently passed GCC 14, Clang unity, ASan, and TSan; GCC 16 failed on the median sort diagnostic. These results are a baseline, not verification of the -refactor specified above. Halo extraction remains unimplemented. +refactor or halo implementation specified above. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index df9553f3..1466d9d4 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -79,19 +79,19 @@ tiles use an abbreviated form of attribution information. `raster_store.index` and `raster_store.metadata` use separate versioned `io::envelope` payloads. The index contains the sparse hierarchy. Metadata contains the layout ID, codec selector, exact payload-type identifier, and -tile dimensions. Tile dimensions default to 4096x4096 pixels and apply to all -tiles in that snapshot. Any positive square dimensions are permitted; side -lengths need not be powers of two. Both tile rasters must match the metadata -dimensions. - -The pixel API consolidates the payload identifier in -`raster_store/pixel.h`, exposed as `raster_store::pixel::identifier()`, with -its `Format` implementation in `pixel::detail`. The same header defines -`pixel::Mapping { Linear, SRGBA }` for the raster-store scaler wrappers and -the planned snapshot `value_mapping` field. This mapping describes value -interpretation separately from the payload-type identifier. The metadata -extension is specified in [Tiles with halo](tiles-with-halo.md); it is not -part of the currently implemented version 1 representation below. +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. Halo extraction asserts a nominal size of at least 64. + +`raster_store/pixel.h` exposes `pixel::identifier()` and +`pixel::Mapping { Linear, 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. +Storage validates the enum, while numerical operations validate whether the +pixel type supports the requested mapping. Exact copies do not need conversion. The codec selector is a codec name string, independent of file extensions. Opening resolves a reader from this metadata string. A future resolver may @@ -101,11 +101,14 @@ for the first codec. Existing mesh stores retain extension-based selectors. ### Version 1 representation The metadata codec selector is `amort`; payload filenames end in `.amort`. -The versioned payloads use the existing `io::envelope` serialization: +The versioned payloads use the existing `io::envelope` serialization. Version 1 +was revised directly for halo metadata; no compatibility with earlier V1 +metadata 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, `uint32_t` width, `uint32_t` height | +| `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 | | `raster_store.Tile` | 1 | `uint32_t` width, height, data byte vector, attribution byte vector | diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md index f3d1d33f..1668b172 100644 --- a/docs/raster-store/tiles-with-halo.md +++ b/docs/raster-store/tiles-with-halo.md @@ -1,10 +1,9 @@ # Tiles with halo -Status: design interview in progress. Decisions were recorded during -2026-09-13 through 2026-09-15, with scaling integration revised on 2026-09-18 -and physical-coverage filling revised on 2026-09-22; -implementation has not been authorized. This design revises version 1 -directly; no existing snapshots require compatibility. +Status: implemented on 2026-09-22. Decisions were recorded during +2026-09-13 through 2026-09-15, with scaling and physical-coverage integration +revised on 2026-09-22. Version 1 is revised directly; no existing snapshots +require compatibility. ## Accepted contract @@ -60,8 +59,8 @@ untagged TIFF/VRT imagery. Add an RF CLI override, `--value-mapping linear|srgba`. CLI help must explain the type-based defaults, the explicit override, and that SRGBA leaves alpha -linear and also applies to three-channel RGB. This is an implementation -requirement; the CLI has not been changed during the interview. +linear and also applies to three-channel RGB. Both RF commands implement this option. It declares the stored payload mapping; +it does not alter import filtering or perform source-profile conversion. See [GDAL value-mapping research](gdal-value-mapping-research.md) for the metadata available from GDAL and the inspected Swissimage reference TIFF. @@ -82,7 +81,7 @@ follows the scaling API. Pass metadata explicitly as `const io::manifest::Metadata&`, allowing callers to read it once and reuse it across extractions. No metadata member is added to `Raster` or `Tile`. -Perform resolution changes through the planned paired wrappers in +Perform resolution changes through the implemented paired wrappers in `terrainlib/raster_store/scaler.h`, which delegate to the generic algorithms exposed by `terrainlib/raster/algorithm.h`, using these selections. The [scaling contract](scaling.md) owns algorithm behavior, attribution @@ -90,6 +89,35 @@ selection, conversion-tuple contracts, and required source halos; do not duplicate those rules here. The extractor provides the source windows and required halos for the selected operation. +Use `raster::make_view` and `raster::algorithm::copy` for central and +same-zoom copies and for stored-halo cropping. Use a `raster::ClampedView` +of the complete central interior with `copy` for missing-coverage replication; +do not make a narrow source strip its own clamping boundary. Use the scaler's +explicit-destination overloads with output subviews wherever the geometry +matches, keeping fetched source tiles alive until their views are consumed. + +Extend the existing `scale` operations with output-window overloads as +specified in [Windowed scaling](scaling.md#agreed-windowed-scaling-extension). +Use `output_offset` and infer window size from the destination; allocating +overloads take `output_size` explicitly. This permits whole source tiles to +feed destination subviews without full scaled intermediates or a separate +crop/copy step. The extension applies to upscaling and downscaling. The +four-level descent limit bounds downscaling from finer tiles; it does not +bound upscaling from ancestors. Verify bounded work with a 20-level ancestor gap. + +Bilinear ancestor interpolation requires a one-source-pixel halo. Source +support uses a clamped view of the supplying ancestor's complete interior. +Do not fetch neighbouring tiles to extend interpolation support. If processing +a smaller cutout, preserve the full ancestor's clamping bounds rather than +clamping at the cutout edges. Clamping at the supplying ancestor's physical +edge is intentional, even if another tile exists beyond it. This avoids +recursive support dependencies and preserves once-per-source reads. + +Reject unsupported mapping/type combinations only in the operation that +does not support them. Do not impose the paired scaler's RGB8/RGBA8 SRGBA +restriction on metadata creation/opening or exact-copy paths. Other metadata +and geometry validation remains applicable. + Validate the requested tile ID and require a physical tile. Both `Leaf` and `Inner` qualify; `Virtual` and missing tiles do not. Reading or decoding an indexed payload can fail; propagate those errors rather than treating them @@ -182,11 +210,12 @@ The `Mixed sources` section has been removed from attribution. Further alignment of the existing storage/sampling documents with the final halo contract belongs to the implementation plan. -## Open decisions +## Implementation -- Remaining scaling details are tracked in [Raster scaling](scaling.md). -- Cache reuse and validation/error details. -- Implementation sequence and approval. +Conversion tuples remain in the scaling interface. The staged +[implementation plan](halo-implementation-plan.md) records the completed stages +and verification results. The public implementation is +`src/terrainlib/raster_store/read_tile_with_halo.h`. ## Required verification @@ -197,6 +226,8 @@ with the final halo contract belongs to the implementation plan. descent limit with ancestor fallback, and partial coverage. - At-most-once ancestor fetches and preservation of selected finer samples, including zero-attribution regions, when ancestor fallback is also used. +- Bilinear support clamps at the full supplying ancestor's edges, not a + processing cutout's edges, without reading neighbouring support tiles. - Numerical contributions from zero-attribution pixels, representative attribution, and preservation of supplied or resampled zero-attribution payloads even when all source attributions are zero. @@ -216,3 +247,5 @@ with the final halo contract belongs to the implementation plan. - Revised version-1 metadata and payload round trips. - Integration with the scaling facility, including sufficient source halos, multi-level reductions, and the extractor's missing-coverage replication. +- Windowed upscaling/downscaling into assigned output views, preserving full + result sampling phase and reduction stages while limiting computed samples. diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 8b2c47de..0c98dbe9 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -181,6 +181,7 @@ add_library(terrainlib raster_store/path_layout.h raster_store/pixel.h raster_store/scaler.h + raster_store/read_tile_with_halo.h raster_store/storage.h sf/finalize_storage.h diff --git a/src/terrainlib/raster/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h index 05a66ef7..0e7b2584 100644 --- a/src/terrainlib/raster/algorithm/scale.h +++ b/src/terrainlib/raster/algorithm/scale.h @@ -14,21 +14,23 @@ namespace detail { const ScalingGeometry& geometry, Interpolation interpolation, const Conversion& conversion, - const View& destination) + const View& destination, + glm::uvec2 output_offset = {}) { using W = DecodedPixel; using S = typename PixelTraits::Scalar; const unsigned factor = geometry.factor; for (unsigned y = 0; y < destination.height(); ++y) { for (unsigned x = 0; x < destination.width(); ++x) { - const glm::uvec2 nearest(x / factor + halo_width, y / factor + halo_width); + const glm::uvec2 position = output_offset + glm::uvec2(x, y); + const glm::uvec2 nearest = position / factor + glm::uvec2(halo_width); if (interpolation == Interpolation::NearestNeighbour) { std::memcpy(std::addressof(destination.pixel({ x, y })), std::addressof(source.pixel(nearest)), sizeof(T)); continue; } // Keep integer coordinates out of the working pixel's phase precision. - const double phase_x = (double(x % factor) + 0.5) / factor - 0.5; - const double phase_y = (double(y % factor) + 0.5) / factor - 0.5; + const double phase_x = (double(position.x % factor) + 0.5) / factor - 0.5; + const double phase_y = (double(position.y % factor) + 0.5) / factor - 0.5; const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); const S fraction_x = S(phase_x < 0 ? phase_x + 1 : phase_x); const S fraction_y = S(phase_y < 0 ? phase_y + 1 : phase_y); @@ -175,4 +177,72 @@ requires detail::NumericPixel> { return scale(std::forward(source), halo_width, n_zoom_levels, interpolation, filter, linear_conversion>()); } +/// Select a window of the conceptual full output; the destination determines its size. +template +requires detail::NumericPixel> && detail::ScalingConversion> + && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale(Source&& source, + unsigned halo_width, + int levels, + Interpolation interpolation, + Filter filter, + glm::uvec2 output_offset, + const Conversion& conversion, + Destination&& destination) +{ + const auto input = detail::read_only_view(raster::make_view(source)); + const auto output = raster::make_view(destination); + auto geometry = detail::window_geometry(input.size(), halo_width, levels, interpolation, filter, output_offset, output.size()); + if (!geometry) + return Error::propagate(std::move(geometry)); + if (levels == 0) { + return detail::copy_views(*raster::make_view(input, output_offset + glm::uvec2(halo_width), output.size()), output); + } + if (auto valid = detail::validate_view_overlap(input, output, false); !valid) + return valid; + if (levels > 0) + return detail::upscale(input, halo_width, *geometry, interpolation, conversion, output, output_offset); + // An aligned source crop with the complete cumulative kernel support has + // exactly the same stage alignment and rounding as full-output reduction. + const unsigned support = *required_halo(levels, interpolation, filter); + auto region = raster::make_view( + input, output_offset * geometry->factor + glm::uvec2(halo_width - support), output.size() * geometry->factor + glm::uvec2(2 * support)); + if (!region) + return Error::propagate(std::move(region)); + return detail::scale_views(*region, support, levels, interpolation, filter, conversion, output); +} + +template +requires detail::NumericPixel> && detail::ScalingConversion> +[[nodiscard]] Expected>> scale(Source&& source, + unsigned halo_width, + int levels, + Interpolation interpolation, + Filter filter, + glm::uvec2 output_offset, + glm::uvec2 output_size, + const Conversion& conversion) +{ + auto geometry = detail::window_geometry(source.size(), halo_width, levels, interpolation, filter, 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, interpolation, filter, output_offset, conversion, destination); }); +} + +template +requires detail::NumericPixel> && std::same_as, detail::SourcePixel> +[[nodiscard]] Expected scale( + Source&& source, unsigned halo_width, int levels, Interpolation interpolation, Filter filter, glm::uvec2 output_offset, Destination&& destination) +{ + return scale(source, halo_width, levels, interpolation, filter, output_offset, linear_conversion>(), destination); +} + +template +requires detail::NumericPixel> +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int levels, Interpolation interpolation, Filter filter, glm::uvec2 output_offset, glm::uvec2 output_size) +{ + return scale(source, halo_width, levels, interpolation, filter, output_offset, output_size, linear_conversion>()); +} } // namespace raster::algorithm diff --git a/src/terrainlib/raster/algorithm/scaling_detail.h b/src/terrainlib/raster/algorithm/scaling_detail.h index 0fba6d6c..e22ab1da 100644 --- a/src/terrainlib/raster/algorithm/scaling_detail.h +++ b/src/terrainlib/raster/algorithm/scaling_detail.h @@ -179,5 +179,31 @@ namespace detail { } return scaling_geometry(input, halo_width, static_cast(levels < 0 ? -std::int64_t(levels) : levels), levels >= 0, *required); } + // Bounds checks use division so a small window does not require representable full dimensions. + inline Expected window_geometry( + glm::uvec2 input, unsigned halo_width, int levels, Interpolation interpolation, Filter filter, glm::uvec2 offset, glm::uvec2 size) + { + auto required = required_halo(levels, interpolation, filter); + if (!required) + return Error::propagate(std::move(required)); + auto factor = scale_factor(static_cast(levels < 0 ? -std::int64_t(levels) : levels)); + if (!factor) + return Error::propagate(std::move(factor)); + if (input.x == 0 || input.y == 0 || halo_width > ((std::min)(input.x, input.y) - 1) / 2 || halo_width < *required) { + return Error::fail(Error::Code::InvalidInput, "invalid raster interior or insufficient halo"); + } + const auto interior = input - glm::uvec2(2 * halo_width); + for (unsigned axis = 0; axis < 2; ++axis) { + if (size[axis] == 0 || offset[axis] > (std::numeric_limits::max)() - (size[axis] - 1)) { + return Error::fail(Error::Code::InvalidInput, "invalid scaling output window"); + } + const unsigned last = offset[axis] + size[axis] - 1; + if (levels >= 0 ? last / *factor >= interior[axis] : interior[axis] % *factor != 0 || last >= interior[axis] / *factor) { + return Error::fail(Error::Code::InvalidInput, "scaling output window exceeds output bounds"); + } + } + return ScalingGeometry { interior, size, *factor }; + } + } // namespace detail } // namespace raster::algorithm diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index c4534996..5c5dd353 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -1,6 +1,8 @@ #include "raster_store/io/manifest.h" #include +#include +#include #include #include @@ -13,7 +15,15 @@ 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"); } - return validate_dimensions({ metadata.width, metadata.height }); + 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) diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h index cce6ef9f..f6765988 100644 --- a/src/terrainlib/raster_store/io/manifest.h +++ b/src/terrainlib/raster_store/io/manifest.h @@ -8,6 +8,7 @@ #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 { @@ -23,8 +24,10 @@ namespace detail::v1 { std::string layout_id; std::string payload_type; std::string codec_selector; - std::uint32_t width = default_tile_side; - std::uint32_t height = default_tile_side; + 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 { diff --git a/src/terrainlib/raster_store/pixel.h b/src/terrainlib/raster_store/pixel.h index d611f7e1..bf866425 100644 --- a/src/terrainlib/raster_store/pixel.h +++ b/src/terrainlib/raster_store/pixel.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -41,6 +42,12 @@ namespace detail { } // 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() { diff --git a/src/terrainlib/raster_store/read_tile_with_halo.h b/src/terrainlib/raster_store/read_tile_with_halo.h new file mode 100644 index 00000000..62330715 --- /dev/null +++ b/src/terrainlib/raster_store/read_tile_with_halo.h @@ -0,0 +1,221 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "raster/ClampedView.h" +#include +#include + +#include "raster_store/scaler.h" +#include "raster_store/storage.h" + +namespace raster_store { +namespace halo_detail { + struct Rectangle { + glm::uvec2 origin; + glm::uvec2 size; + }; + struct Assignment { + radix::tile::Id neighbour; + Rectangle region; + glm::uvec2 destination; + glm::uvec2 source_origin; + }; + inline bool physical(const store::Index& index, const radix::tile::Id& id) + { + const auto status = index.get(id); + return status && *status && **status != store::NodeStatus::Virtual; + } + inline std::optional intersect(Rectangle left, Rectangle right) + { + const auto origin = glm::max(left.origin, right.origin); + const auto end = glm::min(left.origin + left.size, right.origin + right.size); + if (origin.x >= end.x || origin.y >= end.y) + return std::nullopt; + return Rectangle { origin, end - origin }; + } +} // namespace halo_detail + +/// Read a physical tile and its halo. Metadata must belong to the supplied storage. +template +Expected> read_tile_with_halo(const storage::IndexedStorage& storage, + const io::manifest::Metadata& metadata, + const radix::tile::Id& id, + unsigned halo_width, + raster::algorithm::Interpolation interpolation) +{ + namespace algorithm = raster::algorithm; + using namespace halo_detail; + if (auto valid = io::manifest::validate(metadata); !valid) + return Error::propagate(std::move(valid)); + if (!StoreTraits::is_valid(id)) + return Error::fail(Error::Code::InvalidInput, "invalid halo centre tile ID"); + const unsigned side = metadata.nominal_tile_size; + DEBUG_ASSERT(side >= 64); + if (halo_width > side || halo_width > ((std::numeric_limits::max)() - side) / 2 || (metadata.halo_width != 0 && halo_width > metadata.halo_width)) + return Error::fail(Error::Code::InvalidInput, "requested halo exceeds available width"); + auto centre = storage.load(id); + if (!centre) + return Error::propagate(std::move(centre), "read halo centre"); + const auto validate_tile = [&](const Tile& tile) -> Expected { + if (tile.data.size() != glm::uvec2(metadata.stored_tile_size) || tile.source_attribution.size() != tile.data.size()) + return Error::fail(Error::Code::CorruptData, "tile dimensions disagree with halo metadata"); + return {}; + }; + if (auto valid = validate_tile(*centre); !valid) + return Error::propagate(std::move(valid)); + const unsigned output_side = side + 2 * halo_width; + auto pair = scaler::detail::produce_pair(glm::uvec2(output_side), [](auto&, auto&) -> Expected { return {}; }); + if (!pair) + return Error::propagate(std::move(pair)); + Tile output(0); + output.data = std::move(pair->first); + output.source_attribution = std::move(pair->second); + output.source_attribution.fill(0); + if (metadata.halo_width != 0 || halo_width == 0) { + const auto origin = glm::uvec2(metadata.halo_width - halo_width); + if (auto result = algorithm::copy(*raster::make_view(centre->data, origin, output.data.size()), output.data); !result) + return Error::propagate(std::move(result)); + if (auto result = algorithm::copy(*raster::make_view(centre->source_attribution, origin, output.data.size()), output.source_attribution); !result) + return Error::propagate(std::move(result)); + return output; + } + if (auto result = algorithm::copy(centre->data, *raster::make_view(output.data, glm::uvec2(halo_width), glm::uvec2(side))); !result) + return Error::propagate(std::move(result)); + if (auto result = algorithm::copy(centre->source_attribution, *raster::make_view(output.source_attribution, glm::uvec2(halo_width), glm::uvec2(side))); + !result) + return Error::propagate(std::move(result)); + + std::map> assignments; + const auto replicate = [&](Rectangle region, glm::uvec2 destination) -> Expected { + auto source = raster::make_clamped_view(centre->data, glm::ivec2(destination) - glm::ivec2(halo_width), region.size); + if (!source) + return Error::propagate(std::move(source)); + return algorithm::copy(*source, *raster::make_view(output.data, destination, region.size)); + }; + // Every neighbour covers at most one side/corner rectangle. Resolve index + // coverage first, then group disjoint assignments to guarantee one source read. + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + const glm::uvec2 size(dx == 0 ? side : halo_width, dy == 0 ? side : halo_width); + const glm::uvec2 destination(dx < 0 ? 0 : dx == 0 ? halo_width : halo_width + side, dy < 0 ? 0 : dy == 0 ? halo_width : halo_width + side); + const Rectangle region { { dx < 0 ? side - halo_width : 0, dy < 0 ? side - halo_width : 0 }, size }; + const unsigned maximum = id.zoom_level == 32 ? (std::numeric_limits::max)() : (1u << id.zoom_level) - 1; + if ((dy < 0 && id.coords.y == 0) || (dy > 0 && id.coords.y == maximum)) { + if (auto result = replicate(region, destination); !result) + return Error::propagate(std::move(result)); + continue; + } + const radix::tile::Id neighbour { id.zoom_level, { (id.coords.x + static_cast(dx)) & maximum, id.coords.y + static_cast(dy) } }; + std::optional ancestor = std::nullopt; + for (auto candidate = StoreTraits::parent(neighbour); candidate; candidate = StoreTraits::parent(*candidate)) { + if (physical(storage.index(), *candidate)) { + ancestor = candidate; + break; + } + } + const auto visit = [&](const auto& self, const radix::tile::Id& node, glm::uvec2 origin, unsigned extent, unsigned depth) -> Expected { + const auto overlap = intersect(region, { origin, glm::uvec2(extent) }); + if (!overlap) + return {}; + const auto target = destination + overlap->origin - region.origin; + if (physical(storage.index(), node)) { + assignments[node].push_back({ neighbour, *overlap, target, origin }); + return {}; + } + const auto status = storage.index().get(node); + const auto children = StoreTraits::children(node); + if (depth < 4 && status && *status && children) { + for (const auto& child : *children) { + const auto child_origin = origin + (child.coords & glm::uvec2(1)) * (extent / 2); + if (auto result = self(self, child, child_origin, extent / 2, depth + 1); !result) + return result; + } + return {}; + } + if (ancestor) + assignments[*ancestor].push_back({ neighbour, *overlap, target, {} }); + else + return replicate(*overlap, target); + return {}; + }; + if (auto result = visit(visit, neighbour, {}, side, 0); !result) + return Error::propagate(std::move(result)); + } + } + for (const auto& [source_id, regions] : assignments) { + std::optional> loaded; + if (source_id != id) { + auto tile = storage.load(source_id); + if (!tile) + return Error::propagate(std::move(tile), "read halo source"); + if (auto valid = validate_tile(*tile); !valid) + return Error::propagate(std::move(valid)); + loaded.emplace(std::move(*tile)); + } + const auto& source = loaded ? *loaded : *centre; + for (const auto& assignment : regions) { + const auto data = *raster::make_view(output.data, assignment.destination, assignment.region.size); + const auto attribution = *raster::make_view(output.source_attribution, assignment.destination, assignment.region.size); + if (source_id.zoom_level == id.zoom_level) { + if (auto result = algorithm::copy(*raster::make_view(source.data, assignment.region.origin, data.size()), data); !result) + return Error::propagate(std::move(result)); + if (auto result = algorithm::copy(*raster::make_view(source.source_attribution, assignment.region.origin, data.size()), attribution); !result) + return Error::propagate(std::move(result)); + } else if (source_id.zoom_level > id.zoom_level) { + auto result = scaler::scale(source.data, + source.source_attribution, + 0, + -int(source_id.zoom_level - id.zoom_level), + interpolation, + algorithm::Filter::Box, + assignment.region.origin - assignment.source_origin, + metadata.value_mapping, + data, + attribution); + if (!result) + return Error::propagate(std::move(result)); + } else { + const unsigned gap = id.zoom_level - source_id.zoom_level; + auto factor = algorithm::detail::scale_factor(gap); + if (!factor) + return Error::propagate(std::move(factor)); + const auto tile_offset = assignment.neighbour.coords & glm::uvec2(*factor - 1); + const unsigned side_bits = std::countr_zero(side); + glm::uvec2 source_origin; + glm::uvec2 phase; + if (gap <= side_bits) { + source_origin = tile_offset * (side >> gap) + assignment.region.origin / *factor; + phase = assignment.region.origin % *factor; + } else { + source_origin = tile_offset >> (gap - side_bits); + phase = (tile_offset & glm::uvec2((1u << (gap - side_bits)) - 1)) * side + assignment.region.origin; + } + const auto last = phase + assignment.region.size - glm::uvec2(1); + const auto interior = last / *factor + glm::uvec2(1); + const unsigned support = interpolation == algorithm::Interpolation::Bilinear ? 1 : 0; + // Clamping bounds remain those of the complete supplying tile. + auto input = raster::make_clamped_view(source.data, glm::ivec2(source_origin) - glm::ivec2(support), interior + glm::uvec2(2 * support)); + auto input_attribution + = raster::make_clamped_view(source.source_attribution, glm::ivec2(source_origin) - glm::ivec2(support), interior + glm::uvec2(2 * support)); + if (!input) + return Error::propagate(std::move(input)); + if (!input_attribution) + return Error::propagate(std::move(input_attribution)); + auto result = scaler::scale( + *input, *input_attribution, support, int(gap), interpolation, algorithm::Filter::Box, phase, metadata.value_mapping, data, attribution); + if (!result) + return Error::propagate(std::move(result)); + } + } + } + return output; +} +} // namespace raster_store diff --git a/src/terrainlib/raster_store/scaler.h b/src/terrainlib/raster_store/scaler.h index 51a3c41d..785424a2 100644 --- a/src/terrainlib/raster_store/scaler.h +++ b/src/terrainlib/raster_store/scaler.h @@ -19,12 +19,14 @@ namespace detail { && std::same_as, std::uint16_t>; template - Expected validate_mapping(pixel::Mapping mapping) + 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 {}; } @@ -93,7 +95,9 @@ requires detail::PairedSources && detail::PairedDestinations< { namespace algorithm = raster::algorithm; using T = algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping); !valid) { + if (auto valid + = detail::validate_mapping(mapping, n_zoom_levels < 0 || (n_zoom_levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); + !valid) { return valid; } const auto input = algorithm::detail::read_only_view(raster::make_view(data)); @@ -136,7 +140,9 @@ requires detail::PairedSources pixel::Mapping mapping) { using T = raster::algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping); !valid) { + if (auto valid + = detail::validate_mapping(mapping, n_zoom_levels < 0 || (n_zoom_levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); + !valid) { return Error::propagate(std::move(valid)); } if (data.size() != attribution.size()) { @@ -151,6 +157,92 @@ requires detail::PairedSources }); } +/// Scale only the selected conceptual output window. +template +requires detail::PairedSources && detail::PairedDestinations +[[nodiscard]] Expected scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int levels, + raster::algorithm::Interpolation interpolation, + raster::algorithm::Filter filter, + glm::uvec2 output_offset, + pixel::Mapping mapping, + Destination&& destination, + AttributionDestination&& attribution_destination) +{ + namespace algorithm = raster::algorithm; + using T = algorithm::detail::SourcePixel; + if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); !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, interpolation, filter, 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, output_offset + glm::uvec2(halo_width), 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, interpolation, filter, output_offset, algorithm::srgb_conversion(), output); + } + return algorithm::scale(input, halo_width, levels, interpolation, filter, output_offset, output); + }; + if (auto result = scale_data(); !result) + return result; + if (levels < 0) { + auto region = raster::make_view(input_attribution, output_offset * geometry->factor + glm::uvec2(halo_width), output.size() * geometry->factor); + 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::Interpolation::NearestNeighbour, algorithm::Filter::Box, output_offset, output_attribution); +} + +template +requires detail::PairedSources +[[nodiscard]] Expected>, radix::Raster>> scale(Data&& data, + Attribution&& attribution, + unsigned halo_width, + int levels, + raster::algorithm::Interpolation interpolation, + raster::algorithm::Filter filter, + glm::uvec2 output_offset, + glm::uvec2 output_size, + pixel::Mapping mapping) +{ + using T = raster::algorithm::detail::SourcePixel; + if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); !valid) + return Error::propagate(std::move(valid)); + auto geometry = raster::algorithm::detail::window_geometry(data.size(), halo_width, levels, interpolation, filter, 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 detail::produce_pair(output_size, [&](auto& output, auto& output_attribution) { + return scale(data, attribution, halo_width, levels, interpolation, filter, output_offset, mapping, output, output_attribution); + }); +} + /// Custom data reduction uses the supplied conversion; attribution always uses identity and Mode. template requires detail::PairedSources && detail::PairedDestinations diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index da128628..995e81c4 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -24,7 +25,9 @@ struct OpenOptions { }; struct CreateOptions { - glm::uvec2 tile_dimensions { default_tile_side }; + 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::ZstdDefaultCompressionWithChecksum; ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib; @@ -79,9 +82,8 @@ Expected>, std::unique 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, { metadata->width, metadata->height }); - }; + 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(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, @@ -102,11 +104,21 @@ Expected>, std::unique_ptr ((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, options.tile_dimensions, options.compression_algorithm, options.checksum_algorithm); + options.codec_selector, glm::uvec2(metadata->stored_tile_size), options.compression_algorithm, options.checksum_algorithm); if (!codec) { return Error::propagate(std::move(codec)); } @@ -138,11 +150,6 @@ Expected>, std::unique_ptr(std::string(path_layout::zoom_xy_google::zoom_x_y_google().id), - pixel::identifier(), - options.codec_selector, - options.tile_dimensions.x, - options.tile_dimensions.y); auto written = io::manifest::write_metadata(*metadata, partial_path); if (!written) { return Error::propagate(std::move(written), "create raster metadata"); diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index d8f1ed51..34282de5 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -69,6 +69,7 @@ add_executable(unittests_terrainlib terrainlib/raster_algorithm.cpp terrainlib/raster_view.cpp terrainlib/raster_store.cpp + terrainlib/raster_halo.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp index 8a6bfdb5..3d4f2ef7 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -132,7 +132,8 @@ TEST_CASE("raster scaling rejects invalid geometry and dimensions before allocat CHECK_FALSE(scale(data, attribution, 1, 0, Filter::Box, Interpolation::Bilinear)); CHECK_FALSE(scale(data, attribution, -1, 0, Filter::Lanczos2)); CHECK_FALSE(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, static_cast(99))); - CHECK(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); + CHECK(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA)); + CHECK(scale(data, attribution, -1, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); CHECK(scale(data, attribution, 31).error().code() == Error::Code::ResourceExhausted); CHECK_FALSE(scaler::reduce(data, attribution, 0, (std::numeric_limits::max)(), algorithm::Max {})); } @@ -713,3 +714,90 @@ TEST_CASE("scaling invokes encoders with const working values", "[raster-algorit 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 filter : { Filter::Box, Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) + for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + const unsigned halo = *algorithm::required_halo(levels, interpolation, filter); + auto [data, attribution] = field({ 32, 24 }, halo); + auto full = scaler::scale(data, attribution, halo, levels, interpolation, filter, ValueMapping::Linear); + REQUIRE(full); + const glm::uvec2 offset(3, 1), size(3, 4); + auto window = scaler::scale(data, attribution, halo, levels, interpolation, filter, 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 = scaler::scale(data, attribution, 0, -2, Interpolation::NearestNeighbour, Filter::Box, ValueMapping::SRGBA); + auto window = scaler::scale(data, attribution, 0, -2, Interpolation::NearestNeighbour, Filter::Box, { 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, Interpolation::NearestNeighbour, Filter::Box, { 4000000000u, 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, Interpolation::Bilinear, Filter::Box, { 4000000000u, 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, Interpolation::NearestNeighbour, Filter::Box, destination)); + CHECK_FALSE(algorithm::scale(source, 0, 0, Interpolation::NearestNeighbour, Filter::Box, { 2, 2 }, destination)); + CHECK_FALSE(algorithm::scale(source, 0, 0, Interpolation::NearestNeighbour, Filter::Box, { UINT_MAX, 0 }, destination)); + CHECK(destination.pixel({ 0, 0 }) == 99); + radix::Raster attribution({ 4, 4 }); + radix::Raster wrong({ 2, 2 }); + CHECK_FALSE(scaler::scale(source, attribution, 0, 1, Interpolation::NearestNeighbour, Filter::Box, { 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 = scaler::scale(data, attribution, 1, 1, Interpolation::Bilinear, Filter::Box, { 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(scaler::scale(data, attribution, 1, 1, Interpolation::Bilinear, Filter::Box, { 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, Interpolation::Bilinear, Filter::Box, { 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, Interpolation::Bilinear, Filter::Box, oversized)); + CHECK(oversized.pixel({ 0, 0 }) == 99); +} diff --git a/unittests/terrainlib/raster_halo.cpp b/unittests/terrainlib/raster_halo.cpp new file mode 100644 index 00000000..280ce985 --- /dev/null +++ b/unittests/terrainlib/raster_halo.cpp @@ -0,0 +1,273 @@ +#include + +#include "../temporary_directory.h" +#include "raster_store/read_tile_with_halo.h" + +namespace { +using Tile = raster_store::Tile; +using Id = radix::tile::Id; +using Interpolation = raster::algorithm::Interpolation; +struct MemoryCodec final : store::Codec { + mutable std::map tiles; + mutable std::map reads; + std::vector paths(const std::filesystem::path& path) const override { return { path }; } + Expected read(const std::filesystem::path& path) const override + { + ++reads[path]; + if (!tiles.contains(path)) + return Error::fail(Error::Code::Io, "injected missing payload"); + return tiles.at(path); + } + Expected write(const std::filesystem::path& path, const Tile& tile) const override + { + tiles.insert_or_assign(path, tile); + return {}; + } +}; +struct Fixture { + test::TemporaryDirectory directory; + MemoryCodec* codec; + std::unique_ptr> storage; + raster_store::io::manifest::Metadata metadata { "zoom/x/y_google", "float32", "memory", 64, 64, 0 }; + Fixture() + { + auto owned = std::make_unique(); + codec = owned.get(); + auto made = store::make_storage(directory.path(), + raster_store::io::manifest::index_format(), + raster_store::path_layout::zoom_xy_google::zoom_x_y_google(), + "float32", + "memory", + std::move(owned)); + REQUIRE(made); + storage = std::make_unique>(std::move(*made)); + } + void add(Id id, float value, unsigned attribution = 1) + { + Tile tile(metadata.stored_tile_size); + tile.data.fill(value); + tile.source_attribution.fill(attribution); + REQUIRE(storage->save(id, tile)); + } + auto read(Id id, unsigned halo = 2, Interpolation interpolation = Interpolation::NearestNeighbour) + { + return raster_store::read_tile_with_halo(*storage, metadata, id, halo, interpolation); + } + void once() const + { + for (const auto& [path, count] : codec->reads) { + INFO(path); + CHECK(count == 1); + } + } +}; +} // namespace + +TEST_CASE("halo centre and missing coverage preserve payload bits", "[raster-halo]") +{ + Fixture f; + const Id centre { 2, { 1, 1 } }; + f.add(centre, 42, 7); + auto tile = f.read(centre); + REQUIRE(tile); + CHECK(tile->data.size() == glm::uvec2(68)); + CHECK(tile->data.pixel({ 0, 0 }) == 42); + CHECK(tile->source_attribution.pixel({ 0, 0 }) == 0); + CHECK(tile->source_attribution.pixel({ 2, 2 }) == 7); + f.once(); + CHECK_FALSE(f.read({ 2, { 4, 0 } })); + CHECK_FALSE(f.read({ 2, { 0, 0 } })); + CHECK_FALSE(f.read({ 0, { 0, 0 } })); + CHECK_FALSE(f.read(centre, 65)); +} + +TEST_CASE("halo neighbours supply every edge and corner including zero attribution", "[raster-halo]") +{ + Fixture f; + for (unsigned y = 0; y < 3; ++y) + for (unsigned x = 0; x < 3; ++x) + f.add({ 2, { x, y } }, float(10 * y + x), 0); + auto tile = f.read({ 2, { 1, 1 } }, 64); + REQUIRE(tile); + for (unsigned y = 0; y < 192; ++y) + for (unsigned x = 0; x < 192; ++x) { + CHECK(tile->data.pixel({ x, y }) == float(10 * (y / 64) + x / 64)); + CHECK(tile->source_attribution.pixel({ x, y }) == 0); + } + f.once(); +} + +TEST_CASE("halo descendants stop at physical tiles and ancestors fill only uncovered regions", "[raster-halo]") +{ + Fixture f; + const Id root { 0, { 0, 0 } }, centre { 2, { 1, 1 } }; + f.add(root, 5, 5); + f.add(centre, 10, 10); + f.add({ 3, { 4, 2 } }, 20, 0); + f.add({ 4, { 8, 4 } }, 90, 9); // Physical inner at zoom 3 must win. + auto tile = f.read(centre, 64); + REQUIRE(tile); + CHECK(tile->data.pixel({ 128, 64 }) == 20); + CHECK(tile->source_attribution.pixel({ 128, 64 }) == 0); + CHECK(tile->data.pixel({ 160, 64 }) == 5); + CHECK(tile->data.pixel({ 128, 96 }) == 5); + CHECK(f.codec->reads.size() == 3); + f.once(); +} + +TEST_CASE("halo descent cutoff uses an ancestor without loading deep payloads", "[raster-halo]") +{ + Fixture f; + f.add({ 0, { 0, 0 } }, 7); + f.add({ 2, { 1, 1 } }, 11); + f.add({ 7, { 64, 32 } }, 99); + auto tile = f.read({ 2, { 1, 1 } }, 64); + REQUIRE(tile); + CHECK(tile->data.pixel({ 128, 64 }) == 7); + CHECK(f.codec->reads.size() == 2); + f.once(); +} + +TEST_CASE("halo root wraps horizontally and replicates beyond vertical limits", "[raster-halo]") +{ + Fixture f; + f.add({ 0, { 0, 0 } }, 12, 4); + auto tile = f.read({ 0, { 0, 0 } }); + REQUIRE(tile); + CHECK(tile->source_attribution.pixel({ 0, 2 }) == 4); + CHECK(tile->source_attribution.pixel({ 0, 0 }) == 0); + CHECK(tile->source_attribution.pixel({ 2, 67 }) == 0); + f.once(); +} + +TEST_CASE("stored halo crops exactly without fetching neighbours or validating numerical mapping", "[raster-halo]") +{ + Fixture f; + f.metadata.halo_width = 3; + f.metadata.stored_tile_size = 70; + f.metadata.value_mapping = raster_store::pixel::Mapping::SRGBA; + f.add({ 1, { 0, 0 } }, std::bit_cast(0x7fc01234u), 0); + f.add({ 1, { 1, 0 } }, 1); + for (unsigned halo : { 0u, 1u, 3u }) { + f.codec->reads.clear(); + auto tile = f.read({ 1, { 0, 0 } }, halo); + REQUIRE(tile); + CHECK(tile->data.size() == glm::uvec2(64 + 2 * halo)); + CHECK(std::bit_cast(tile->data.pixel({ 0, 0 })) == 0x7fc01234u); + CHECK(f.codec->reads.size() == 1); + f.once(); + } + CHECK_FALSE(f.read({ 1, { 0, 0 } }, 4)); +} + +TEST_CASE("halo propagates indexed payload errors", "[raster-halo]") +{ + Fixture f; + f.add({ 1, { 0, 0 } }, 1); + f.add({ 1, { 1, 0 } }, 2); + f.codec->tiles.erase(f.directory.path() / "1/1/0"); + auto result = f.read({ 1, { 0, 0 } }); + REQUIRE_FALSE(result); + CHECK(result.error().code() == Error::Code::Io); +} + +TEST_CASE("halo distant ancestor samples use bounded windows with either interpolation", "[raster-halo]") +{ + for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + Fixture f; + f.add({ 0, { 0, 0 } }, 23, 3); + f.add({ 20, { 400000, 500000 } }, 42, 4); + auto result = f.read({ 20, { 400000, 500000 } }, 2, interpolation); + REQUIRE(result); + CHECK(result->data.pixel({ 0, 0 }) == 23); + CHECK(result->source_attribution.pixel({ 0, 0 }) == 3); + CHECK(result->data.pixel({ 2, 2 }) == 42); + f.once(); + } +} + +TEST_CASE("halo ancestor windows match complete-ancestor scaling at edges and cutouts", "[raster-halo]") +{ + for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + Fixture f; + Tile ancestor(64); + for (unsigned y = 0; y < 64; ++y) + for (unsigned x = 0; x < 64; ++x) { + ancestor.data.pixel({ x, y }) = float(x + 100 * y); + ancestor.source_attribution.pixel({ x, y }) = (x + 2 * y) % 7; + } + REQUIRE(f.storage->save({ 0, { 0, 0 } }, ancestor)); + const Id centre { 2, { 0, 1 } }; + f.add(centre, -1, 9); + auto tile = f.read(centre, 64, interpolation); + REQUIRE(tile); + const auto padded = *raster::make_clamped_view(ancestor.data, { -1, -1 }, { 66, 66 }); + const auto padded_attribution = *raster::make_clamped_view(ancestor.source_attribution, { -1, -1 }, { 66, 66 }); + auto full = raster_store::scaler::scale( + padded, padded_attribution, 1, 2, interpolation, raster::algorithm::Filter::Box, raster_store::pixel::Mapping::Linear); + REQUIRE(full); + for (unsigned y = 0; y < 192; ++y) + for (unsigned x = 0; x < 192; ++x) { + if (x >= 64 && x < 128 && y >= 64 && y < 128) + continue; + const glm::uvec2 position((x + 192) % 256, y); + CHECK(tile->data.pixel({ x, y }) == full->first.pixel(position)); + CHECK(tile->source_attribution.pixel({ x, y }) == full->second.pixel(position)); + } + f.once(); + } +} + +TEST_CASE("halo box reductions use physical zero-attribution samples", "[raster-halo]") +{ + Fixture f; + f.add({ 2, { 1, 1 } }, -1); + Tile finer(64); + for (unsigned y = 0; y < 64; ++y) + for (unsigned x = 0; x < 64; ++x) + finer.data.pixel({ x, y }) = float(x + 100 * y); + REQUIRE(f.storage->save({ 3, { 4, 2 } }, finer)); + auto tile = f.read({ 2, { 1, 1 } }, 64); + REQUIRE(tile); + CHECK(tile->data.pixel({ 128, 64 }) == 50.5f); + CHECK(tile->data.pixel({ 159, 95 }) == 6312.5f); + CHECK(tile->source_attribution.pixel({ 128, 64 }) == 0); + CHECK(tile->data.pixel({ 160, 64 }) == -1); + CHECK(tile->source_attribution.pixel({ 160, 64 }) == 0); + f.once(); +} + +TEST_CASE("halo exact centre and replication preserve nonfinite and signed-zero bits", "[raster-halo]") +{ + Fixture f; + Tile centre(64); + centre.data.fill(std::bit_cast(0x7fc01234u)); + centre.data.pixel({ 63, 63 }) = -0.f; + REQUIRE(f.storage->save({ 2, { 1, 1 } }, centre)); + auto tile = f.read({ 2, { 1, 1 } }, 2); + REQUIRE(tile); + CHECK(std::bit_cast(tile->data.pixel({ 0, 0 })) == 0x7fc01234u); + CHECK(std::bit_cast(tile->data.pixel({ 2, 2 })) == 0x7fc01234u); + CHECK(std::bit_cast(tile->data.pixel({ 67, 67 })) == 0x80000000u); + auto exact = f.read({ 2, { 1, 1 } }, 0); + REQUIRE(exact); + CHECK(std::ranges::equal(exact->data.bytes(), centre.data.bytes())); +} + +TEST_CASE("halo nonconstant distant ancestor preserves fractional phase", "[raster-halo]") +{ + Fixture f; + Tile ancestor(64); + for (unsigned y = 0; y < 64; ++y) + for (unsigned x = 0; x < 64; ++x) + ancestor.data.pixel({ x, y }) = float(x); + REQUIRE(f.storage->save({ 0, { 0, 0 } }, ancestor)); + const Id centre { 20, { 400000, 500000 } }; + f.add(centre, -1); + auto tile = f.read(centre, 2, Interpolation::Bilinear); + REQUIRE(tile); + // The left halo begins two requested pixels before the centre's boundary. + const double expected = (400000.0 * 64 - 2 + 0.5) / double(1u << 20) - 0.5; + CHECK(std::abs(tile->data.pixel({ 0, 2 }) - expected) < 0.00001); + f.once(); +} diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 987ca69e..09c74309 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -40,7 +40,7 @@ void write_table(const std::filesystem::path& directory) storage::CreateOptions small_options() { storage::CreateOptions options; - options.tile_dimensions = { 3, 3 }; + options.nominal_tile_size = 4; return options; } @@ -233,7 +233,7 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl CHECK_FALSE(storage::open(partial / "")); CHECK(storage::open(partial, { .allow_incomplete = true })); const auto metadata_before = io::read_bytes_from_path(partial / manifest::metadata_file_name).value(); - raster_store::Tile tile(3); + raster_store::Tile tile(4); tile.data.fill(12.5f); // Reference checks belong to RF builder, not storage reads or writes. tile.source_attribution.fill(65534); @@ -247,13 +247,13 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl CHECK(checkpoint->codec_selector().value() == "amort"); auto metadata = manifest::read_metadata(partial); REQUIRE(metadata); - CHECK(metadata->width == 3); - CHECK(metadata->height == 3); + 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->width == metadata->width); - CHECK(created_metadata->height == metadata->height); + 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); @@ -276,7 +276,7 @@ TEST_CASE("failed checkpoints retain the previous index and ignore unindexed fil 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(3))); + 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 }); @@ -318,7 +318,7 @@ TEST_CASE("raster publication does not scan payloads or reopen output", "[raster 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(3))); + 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); @@ -335,7 +335,7 @@ TEST_CASE("raster reader selection follows metadata instead of file endings", "[ 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(3))); + 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"; @@ -357,7 +357,7 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" auto created_result = storage::create(rf, small_options()); REQUIRE(created_result); auto [created, created_metadata] = std::move(*created_result); - raster_store::Tile tile(3); + raster_store::Tile tile(4); tile.data.fill(12.5f); REQUIRE(created->save({ 0, { 0, 0 } }, tile)); REQUIRE(created->save({ 2, { 1, 1 } }, tile)); @@ -478,7 +478,7 @@ TEST_CASE("raster opening retains metadata and index errors without creating a d SECTION("rectangular metadata") { auto metadata = manifest::read_metadata(final).value(); - metadata.height = 2; + metadata.stored_tile_size = 2; REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); CHECK(storage::open(final).error().code() == Error::Code::CorruptData); } @@ -521,7 +521,7 @@ TEST_CASE("abandoned raster output checkpoints but never publishes", "[raster-st 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(3))); + 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 }); @@ -529,3 +529,46 @@ TEST_CASE("abandoned raster output checkpoints but never publishes", "[raster-st 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")); +} From e93be05037e8e9acc36787e38c997f03cacd6b67 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:44:34 +0200 Subject: [PATCH 21/83] Fill GDAL RF import gaps with bounded interpolation and Gaussian smoothing --- docs/raster-store/README.md | 1 + docs/raster-store/implementation-status.md | 21 +++ misc/suppression/tsan.txt | 5 + src/terrainlib/DatasetReader.cpp | 130 ++++++++++++------ src/terrainlib/DatasetReader.h | 19 ++- src/terrainlib/RasterTransform.cpp | 4 +- src/terrainlib/RasterTransform.h | 4 +- .../raster/algorithm/window_transform.h | 23 ++++ unittests/terrainlib/raster_algorithm.cpp | 19 +++ 9 files changed, 176 insertions(+), 50 deletions(-) diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index be4ce061..6f02b0da 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -18,6 +18,7 @@ TB generation and the tile server remain future work. - [Tiles with halo: design](tiles-with-halo.md) - [Halo extraction implementation plan](halo-implementation-plan.md) +- [GDAL import NoData filling: policy and verification](gdal-nodata-filling.md) - [Tile-storage implementation plan](implementation-plan.md) - [Raster store TODO](todo.md) diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index f1f50fea..894bf611 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -1,5 +1,26 @@ # Raster-store implementation status +## GDAL import NoData filling — 2026-09-23 + +Implemented the [agreed filling policy](gdal-nodata-filling.md): configurable +five-pixel GDAL fill search, five-pixel separable Gaussian kernel, and scalar +or RGB fallback (default zero). Original valid values and attribution are +preserved; replacements remain unattributed. Planning and reads include the +required temporary halo, world borders are handled explicitly, and periodic +source addressing avoids full-width seam reads. Input schema and processing +versions are 2, rejecting older caches. The Gaussian-kernel helper lives in +`raster/algorithm/window_transform.h`. + +Verification: all targets build in the existing Debug configuration; RF builder +57 cases, terrainlib 568 cases, tile builder 14 cases, SF builder 16 cases, +DAG builder 76 cases, and SF merger 13 cases pass. Focused fixtures verify +source-read counts, in-memory fill scratch, original-value preservation, +nonfinite samples, RGB validity, split windows, polar borders, mixed zooms, +cache rejection/reuse, and parallel operation. Debug timing and peak-memory +measurements, with their scope and limitations, are recorded in the filling +policy document. The accepted driver callback race has a TSan stack suppression; +the existing warp-destination creation lock is unchanged. + ## Halo extraction — 2026-09-22 Implemented windowed generic/paired scaling, physical-tile halo extraction, diff --git a/misc/suppression/tsan.txt b/misc/suppression/tsan.txt index 67b0f120..c18f0c76 100644 --- a/misc/suppression/tsan.txt +++ b/misc/suppression/tsan.txt @@ -8,3 +8,8 @@ race:__itt_init_ittlib # 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/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 748f3b53..7b139e87 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -229,19 +229,20 @@ namespace { struct WarpCoordinates { const RasterTransform* source; radix::tile::SrsBounds bounds; - unsigned side; + glm::uvec2 size; bool failed = false; unsigned column_padding = 0; + double source_columns_per_metre = 0; + double centre_source_column = 0; }; int transform_import(void* argument, int destination_to_source, int count, double* x, double* y, double*, int* success) { auto& coordinates = *static_cast(argument); - const double spacing = coordinates.bounds.width() / coordinates.side; + const glm::dvec2 spacing { coordinates.bounds.width() / coordinates.size.x, coordinates.bounds.height() / coordinates.size.y }; for (int i = 0; i < count; ++i) { - const glm::dvec2 destination { coordinates.bounds.min.x + x[i] * spacing, - coordinates.bounds.max.y - y[i] * spacing }; + const glm::dvec2 destination { coordinates.bounds.min.x + x[i] * spacing.x, coordinates.bounds.max.y - y[i] * spacing.y }; glm::dvec2 canonical = destination; const double world_period = 2 * RasterTransform::world_half_extent; canonical.x -= world_period * std::floor((canonical.x + RasterTransform::world_half_extent) / world_period); @@ -259,26 +260,33 @@ int transform_import(void* argument, int destination_to_source, int count, coordinates.failed = coordinates.failed || !destination_to_source || in_coverage; x[i] = y[i] = HUGE_VAL; } else if (destination_to_source) { - x[i] = point->x + coordinates.column_padding; + const double centre = (coordinates.bounds.min.x + coordinates.bounds.max.x) / 2; + x[i] = point->x; + if (coordinates.source_columns_per_metre != 0) { + const double expected = coordinates.centre_source_column + (destination.x - centre) * coordinates.source_columns_per_metre; + const double columns = std::abs(coordinates.source_columns_per_metre) * world_period; + x[i] += columns * std::round((expected - x[i]) / columns); + } + x[i] += coordinates.column_padding; y[i] = point->y; } else { // Choose the equivalent Mercator branch nearest this output window. const double period = 2 * RasterTransform::world_half_extent; const double centre = (coordinates.bounds.min.x + coordinates.bounds.max.x) / 2; const double world_x = point->x + period * std::round((centre - point->x) / period); - x[i] = (world_x - coordinates.bounds.min.x) / spacing; - y[i] = (coordinates.bounds.max.y - point->y) / spacing; + x[i] = (world_x - coordinates.bounds.min.x) / spacing.x; + y[i] = (coordinates.bounds.max.y - point->y) / spacing.y; } } return TRUE; } } -Expected> DatasetReader::read_scalar(GDALDataset& dataset, - const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const unsigned side, const unsigned band) +Expected> DatasetReader::read_scalar( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const unsigned band) { - if (side == 0 || side > unsigned((std::numeric_limits::max)()) - || band == 0 || band > unsigned(dataset.GetRasterCount())) { + if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) + || std::size_t(size.x) > std::vector().max_size() / size.y || band == 0 || band > unsigned(dataset.GetRasterCount())) { return Error::fail(Error::Code::InvalidInput, "invalid RF read dimensions or source band"); } auto* source_band = dataset.GetRasterBand(int(band)); @@ -294,7 +302,28 @@ Expected> DatasetReader::read_scalar(GDALDataset& const bool periodic = affine[2] == 0 && affine[4] == 0 && (transform.reference().IsGeographic() || (transform.reference().GetAuthorityCode(nullptr) && std::string_view(transform.reference().GetAuthorityCode(nullptr)) == "3857")) && std::abs(std::abs(affine[1]) * dataset.GetRasterXSize() - period) < period * 1e-10; - const unsigned padding = periodic ? (std::min)(8, dataset.GetRasterXSize()) : 0; + double columns_per_metre = 0; + double centre_column = 0; + unsigned padding = 0; + if (periodic) { + // Keep global source addressing continuous across its longitude seam. + // The VRT repeats just the required edge strips, including filter support. + const double centre = (bounds.min.x + bounds.max.x) / 2; + auto pixel = transform.source_pixel({ centre, (bounds.min.y + bounds.max.y) / 2 }); + if (!pixel) { + return Error::propagate(std::move(pixel)); + } + centre_column = pixel->x; + columns_per_metre = std::copysign(double(dataset.GetRasterXSize()) / (2 * RasterTransform::world_half_extent), affine[1]); + const double half_columns = std::abs(bounds.width() * columns_per_metre) / 2; + const double filter_support = 3 * (std::max)(1., 2 * half_columns / size.x); + const double required = std::ceil( + (std::max)({ 8., half_columns - centre_column + filter_support, centre_column + half_columns - dataset.GetRasterXSize() + filter_support })); + if (!std::isfinite(required) || required > ((std::numeric_limits::max)() - dataset.GetRasterXSize()) / 2) { + return Error::fail(Error::Code::InvalidInput, "periodic RF source view exceeds GDAL dimensions"); + } + padding = unsigned(required); + } VRTDataset source(dataset.GetRasterXSize() + int(2 * padding), dataset.GetRasterYSize()); if (source.AddBand(source_band->GetRasterDataType(), nullptr) != CE_None || source.AddBand(GDT_Byte, nullptr) != CE_None) { @@ -308,13 +337,17 @@ Expected> DatasetReader::read_scalar(GDALDataset& if (destination_band->AddSimpleSource(input_band, 0, 0, width, height, padding, 0, width, height) != CE_None) { return false; } - // A periodic global grid needs the opposite edge in the filter halo. - // Seam-crossing regional grids already have contiguous source columns. - return padding == 0 - || (destination_band->AddSimpleSource(input_band, width - int(padding), 0, padding, height, - 0, 0, padding, height) == CE_None - && destination_band->AddSimpleSource(input_band, 0, 0, padding, height, - width + padding, 0, padding, height) == CE_None); + for (const auto& [begin, end] : { std::pair { 0, int(padding) }, std::pair { width + int(padding), width + int(2 * padding) } }) { + for (int destination = begin; destination < end;) { + const int source_column = int(((std::int64_t(destination) - padding) % width + width) % width); + const int count = (std::min)(end - destination, width - source_column); + if (destination_band->AddSimpleSource(input_band, source_column, 0, count, height, destination, 0, count, height) != CE_None) { + return false; + } + destination += count; + } + } + return true; }; if (!attach(values, source_band) || !attach(validity, source_band->GetMaskBand())) { return Error::fail(Error::Code::Io, "attach source values and validity to RF view"); @@ -328,8 +361,11 @@ Expected> DatasetReader::read_scalar(GDALDataset& // Only creation needs serialization; each resulting dataset is private. static std::mutex creation_mutex; const std::lock_guard lock(creation_mutex); - return driver->Create("", int(side), int(side), 2, GDT_Float32, nullptr); + return driver->Create("", int(size.x), int(size.y), 2, GDT_Float32, nullptr); }()); + if (!destination.gdalDataset()) { + return Error::fail(Error::Code::Io, "create RF warp destination"); + } auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); options->hSrcDS = &source; options->hDstDS = destination.gdalDataset(); @@ -355,52 +391,60 @@ Expected> DatasetReader::read_scalar(GDALDataset& options->padfSrcNoDataReal = static_cast(CPLMalloc(sizeof(double))); options->padfSrcNoDataReal[0] = nodata; } - WarpCoordinates coordinates { &transform, bounds, side, false, padding }; + WarpCoordinates coordinates { &transform, bounds, size, false, padding, columns_per_metre, centre_column }; options->pfnTransformer = transform_import; options->pTransformerArg = &coordinates; GDALWarpOperation operation; - if (operation.Initialize(options.get()) != CE_None - || operation.ChunkAndWarpImage(0, 0, int(side), int(side)) != CE_None || coordinates.failed) { + if (operation.Initialize(options.get()) != CE_None || operation.ChunkAndWarpImage(0, 0, int(size.x), int(size.y)) != CE_None || coordinates.failed) { return Error::fail(Error::Code::Io, "warp RF source band: " + std::string(CPLGetLastErrorMsg())); } - Samples result { radix::Raster(side), radix::Raster(side) }; + Samples result { radix::Raster(size), radix::Raster(size) }; auto* output = destination.gdalDataset(); - if (output->GetRasterBand(1)->RasterIO(GF_Read, 0, 0, int(side), int(side), result.data.buffer().data(), - int(side), int(side), GDT_Float32, 0, 0) != CE_None) { + if (output->GetRasterBand(1)->RasterIO(GF_Read, 0, 0, int(size.x), int(size.y), result.data.buffer().data(), int(size.x), int(size.y), GDT_Float32, 0, 0) + != CE_None) { return Error::fail(Error::Code::Io, "read RF warped values and validity"); } - std::vector alpha(side); - for (unsigned row = 0; row < side; ++row) { - if (output->GetRasterBand(2)->RasterIO(GF_Read, 0, int(row), int(side), 1, alpha.data(), - int(side), 1, GDT_Float32, 0, 0) != CE_None) { + std::vector alpha(size.x); + for (unsigned row = 0; row < size.y; ++row) { + if (output->GetRasterBand(2)->RasterIO(GF_Read, 0, int(row), int(size.x), 1, alpha.data(), int(size.x), 1, GDT_Float32, 0, 0) != CE_None) { return Error::fail(Error::Code::Io, "read RF destination validity"); } - for (unsigned column = 0; column < side; ++column) { - result.valid.buffer()[std::size_t(row) * side + column] = alpha[column] > 0; + for (unsigned column = 0; column < size.x; ++column) { + result.valid.buffer()[std::size_t(row) * size.x + column] + = alpha[column] > 0 && std::isfinite(result.data.buffer()[std::size_t(row) * size.x + column]); } } return result; } -Expected> DatasetReader::read_colour(GDALDataset& dataset, - const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const unsigned side, - const std::array& bands) +Expected> DatasetReader::read_colour( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const std::array& bands) { - if (side == 0 || side > unsigned((std::numeric_limits::max)())) { + if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) + || std::size_t(size.x) > std::vector().max_size() / size.y) { return Error::fail(Error::Code::InvalidInput, "invalid RF RGB read dimensions"); } - Samples result { radix::Raster(side), - radix::Raster(glm::uvec2(side), 255) }; + Samples result { radix::Raster(size), radix::Raster(size, 255) }; for (unsigned channel = 0; channel < 3; ++channel) { - auto samples = read_scalar(dataset, transform, bounds, side, bands[channel]); + auto samples = read_scalar(dataset, transform, bounds, size, bands[channel]); if (!samples) { return Error::propagate(std::move(samples), "read RGB channel " + std::to_string(channel)); } + for (std::size_t i = 0; i < samples->data.buffer().size(); ++i) { + if (!samples->valid.buffer()[i]) { + samples->data.buffer()[i] = 0; + } + } // Convert one row at a time to keep GDAL's signed word count in range. - for (unsigned row = 0; row < side; ++row) { - const std::size_t offset = std::size_t(row) * side; - GDALCopyWords(samples->data.buffer().data() + offset, GDT_Float32, sizeof(float), - &result.data.buffer()[offset][channel], GDT_Byte, sizeof(glm::u8vec3), int(side)); + for (unsigned row = 0; row < size.y; ++row) { + const std::size_t offset = std::size_t(row) * size.x; + GDALCopyWords(samples->data.buffer().data() + offset, + GDT_Float32, + sizeof(float), + &result.data.buffer()[offset][channel], + GDT_Byte, + sizeof(glm::u8vec3), + int(size.x)); } for (std::size_t pixel = 0; pixel < result.data.buffer().size(); ++pixel) { result.valid.buffer()[pixel] = result.valid.buffer()[pixel] && samples->valid.buffer()[pixel]; diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index 666b99a4..4e4bfcae 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -41,10 +41,21 @@ class DatasetReader { // RF import: Lanczos at base resolution, exact coordinates, declared source // validity. RGB requires a valid filtered result in every selected channel. - static Expected> read_scalar(GDALDataset& dataset, const RasterTransform& transform, - const radix::tile::SrsBounds& bounds, unsigned side, unsigned band); - static Expected> read_colour(GDALDataset& dataset, const RasterTransform& transform, - const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands); + static Expected> read_scalar( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, unsigned band) + { + return read_scalar(dataset, transform, bounds, glm::uvec2(side), band); + } + static Expected> read_colour( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands) + { + return read_colour(dataset, transform, bounds, glm::uvec2(side), bands); + } + + static Expected> read_scalar( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, unsigned band); + static Expected> read_colour( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band, bool warn_on_missing_overviews = true); diff --git a/src/terrainlib/RasterTransform.cpp b/src/terrainlib/RasterTransform.cpp index a2f3d0c8..47bcac78 100644 --- a/src/terrainlib/RasterTransform.cpp +++ b/src/terrainlib/RasterTransform.cpp @@ -139,14 +139,14 @@ RasterTransform::Bounds RasterTransform::tile_bounds(const radix::tile::Id& key) world_half_extent - double(key.coords.y) * side } }; } -Expected RasterTransform::source_pixel(glm::dvec2 point) const +Expected RasterTransform::source_pixel(glm::dvec2 point, bool wrap_longitude) const { if (!m_to_source->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { return Error::fail(Error::Code::InvalidInput, "transform RF position into source CRS"); } // Keep a seam-crossing affine grid continuous in its own source coordinates. // This is source addressing only; RF coordinates remain canonical. - if (m_period > 0) { + if (wrap_longitude && m_period > 0) { point.x = m_centre_x + std::remainder(point.x - m_centre_x, m_period); } glm::dvec2 pixel; diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h index f2b750f1..69d82a45 100644 --- a/src/terrainlib/RasterTransform.h +++ b/src/terrainlib/RasterTransform.h @@ -19,7 +19,9 @@ class RasterTransform { static Expected> coverage(const OGRSpatialReference& reference, const Bounds& bounds); static Bounds tile_bounds(const radix::tile::Id& key); - Expected source_pixel(glm::dvec2 mercator) const; + // Sampling estimates can omit source-branch wrapping to keep derivatives + // continuous at a global raster's longitude seam. + Expected source_pixel(glm::dvec2 mercator, bool wrap_longitude = true) const; Expected mercator(glm::dvec2 source_pixel) const; const std::vector& bounds() const { return m_bounds; } const OGRSpatialReference& reference() const { return m_reference; } diff --git a/src/terrainlib/raster/algorithm/window_transform.h b/src/terrainlib/raster/algorithm/window_transform.h index f4e4dde0..04746436 100644 --- a/src/terrainlib/raster/algorithm/window_transform.h +++ b/src/terrainlib/raster/algorithm/window_transform.h @@ -1,9 +1,32 @@ #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)) { diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp index 3d4f2ef7..1c53b268 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -801,3 +802,21 @@ TEST_CASE("windowed scaling writes strided destinations and rejects overlap befo CHECK_FALSE(algorithm::scale(data, 1, 1, Interpolation::Bilinear, Filter::Box, 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)); + } +} From c3bc9b0b9713f6149e1367086c0f2f5a95845649 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:22:15 +0200 Subject: [PATCH 22/83] Add raster fold and shared image I/O --- src/terrainlib/CMakeLists.txt | 2 + src/terrainlib/io/bytes.cpp | 11 +- src/terrainlib/io/bytes.h | 3 + src/terrainlib/io/conversion.h | 6 +- src/terrainlib/io/image.cpp | 176 +++++++++++++++++++++++++ src/terrainlib/io/image.h | 46 +++++++ src/terrainlib/raster/algorithm.h | 1 + src/terrainlib/raster/algorithm/fold.h | 22 ++++ src/tile_builder/image_writer.cpp | 33 ++--- unittests/CMakeLists.txt | 2 + unittests/terrainlib/io_image.cpp | 150 +++++++++++++++++++++ unittests/terrainlib/raster_fold.cpp | 54 ++++++++ 12 files changed, 482 insertions(+), 24 deletions(-) create mode 100644 src/terrainlib/io/image.cpp create mode 100644 src/terrainlib/io/image.h create mode 100644 src/terrainlib/raster/algorithm/fold.h create mode 100644 unittests/terrainlib/io_image.cpp create mode 100644 unittests/terrainlib/raster_fold.cpp diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 0c98dbe9..8a04b381 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -60,6 +60,7 @@ add_library(terrainlib io/bytes.h io/bytes.cpp io/compression.h io/compression.cpp io/conversion.h + io/image.h io/image.cpp io/envelope.h io/glm_serialization.h io/utils.h io/utils.cpp @@ -169,6 +170,7 @@ add_library(terrainlib 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 diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp index 5b96634e..df892ab5 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/bytes.cpp @@ -8,6 +8,11 @@ namespace io { Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs) +{ + return write_bytes_to_path(bytes, path, WriteMode::Overwrite, make_dirs); +} + +Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) { LOG_TRACE("Writing bytes to path {}", path); @@ -18,8 +23,12 @@ Expected write_bytes_to_path(const std::span bytes, const s } } - std::ofstream file(path, std::ios::binary); + 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); } diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/bytes.h index 4d1af334..6ca56dcb 100644 --- a/src/terrainlib/io/bytes.h +++ b/src/terrainlib/io/bytes.h @@ -10,7 +10,10 @@ namespace io { +enum class WriteMode { Overwrite, CreateNew }; + Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs = true); +Expected write_bytes_to_path(std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs = true); Expected> read_bytes_from_path(const std::filesystem::path& path); } diff --git a/src/terrainlib/io/conversion.h b/src/terrainlib/io/conversion.h index 620ba9f8..ffb8689d 100644 --- a/src/terrainlib/io/conversion.h +++ b/src/terrainlib/io/conversion.h @@ -162,7 +162,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 +189,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/image.cpp b/src/terrainlib/io/image.cpp new file mode 100644 index 00000000..ee18e919 --- /dev/null +++ b/src/terrainlib/io/image.cpp @@ -0,0 +1,176 @@ +#include "image.h" + +#include +#include +#include +#include + +#include +#include + +#include "bytes.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 (format != Format::Jpeg && format != Format::Png) { + 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_raster(const radix::Raster& image, Format format, EncodeOptions options) + try { + if (auto valid = validate_encoding(format, Pixel::length(), options); !valid) { + return Error::propagate(std::move(valid)); + } + 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 bytes; + 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, bytes, parameters)) { + return Error::fail(Error::Code::Io, "image encoding failed"); + } + return bytes; + } 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> decode_raster(std::span bytes) + 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 (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"); + } + + template + Expected> read_raster(const std::filesystem::path& path) + try { + auto bytes = read_bytes_from_path(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; + } + 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 write_bytes_to_path(*bytes, path, options.overwrite ? WriteMode::Overwrite : 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(std::span bytes) { return decode_raster(bytes); } +Expected decode_rgba8(std::span bytes) { return decode_raster(bytes); } +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..1579e42e --- /dev/null +++ b/src/terrainlib/io/image.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "Error.h" + +namespace io::image { + +using RGB8 = radix::Raster; +using RGBA8 = radix::Raster; + +enum class Format { Jpeg, Png }; + +struct EncodeOptions { + int jpeg_quality = 95; + int png_compression = 1; +}; + +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 preserves all four channels. JPEG rejects RGBA input. +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. +Expected decode_rgb8(std::span bytes); +Expected decode_rgba8(std::span bytes); +Expected read_rgb8(const std::filesystem::path& path); +Expected read_rgba8(const std::filesystem::path& path); + +/// Select JPEG/PNG 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/raster/algorithm.h b/src/terrainlib/raster/algorithm.h index bea456a2..b62f6ca7 100644 --- a/src/terrainlib/raster/algorithm.h +++ b/src/terrainlib/raster/algorithm.h @@ -2,6 +2,7 @@ #include "algorithm/conversion.h" #include "algorithm/copy.h" +#include "algorithm/fold.h" #include "algorithm/reduce.h" #include "algorithm/scale.h" #include "algorithm/transform.h" diff --git a/src/terrainlib/raster/algorithm/fold.h b/src/terrainlib/raster/algorithm/fold.h new file mode 100644 index 00000000..480364c0 --- /dev/null +++ b/src/terrainlib/raster/algorithm/fold.h @@ -0,0 +1,22 @@ +#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/tile_builder/image_writer.cpp b/src/tile_builder/image_writer.cpp index c0131610..1a03aa5a 100644 --- a/src/tile_builder/image_writer.cpp +++ b/src/tile_builder/image_writer.cpp @@ -20,32 +20,23 @@ #include "image_writer.h" -#include "io/conversion.h" - -#include +#include "io/image.h" 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"); + if (input_image.width() == 0 || input_image.height() == 0) { + return Error::fail(Error::Code::InvalidInput, "cannot write an empty tile builder image"); } - - 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 + "\""); + // The legacy tile builder stores its first row at the bottom. + auto flipped = input_image; + for (unsigned y = 0; y < input_image.height(); ++y) { + std::copy_n(input_image.data() + std::size_t(y) * input_image.width(), + input_image.width(), + flipped.data() + std::size_t(input_image.height() - 1 - y) * input_image.width()); } - } catch (const cv::Exception& error) { - return Error::fail(Error::Code::Io, "write PNG image to \"" + path + "\": " + error.msg); + return io::image::write(flipped, path, { .overwrite = true, .make_dirs = false }); + } catch (const std::bad_alloc&) { + return Error::fail(Error::Code::ResourceExhausted, "flip tile builder image"); } - return {}; } diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 34282de5..93a4cbc4 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(unittests_terrainlib terrainlib/hybrid_index_pair_map.cpp terrainlib/hybrid_vector.cpp terrainlib/io_conversion.cpp + terrainlib/io_image.cpp terrainlib/offset_table.cpp terrainlib/segmented_buffer.cpp terrainlib/sf_validate_index.cpp @@ -67,6 +68,7 @@ add_executable(unittests_terrainlib terrainlib/range.cpp terrainlib/range_utils.cpp terrainlib/raster_algorithm.cpp + terrainlib/raster_fold.cpp terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/raster_halo.cpp diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp new file mode 100644 index 00000000..3687b6c6 --- /dev/null +++ b/unittests/terrainlib/io_image.cpp @@ -0,0 +1,150 @@ +#include +#include +#include + +#include +#include + +#include "../temporary_directory.h" +#include "io/bytes.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; +} +} // 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(*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(grey_bytes); + const auto grey_rgba = image::decode_rgba8(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(*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("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(image::RGB8 {}, image::Format::Png)); + CHECK_FALSE(image::decode_rgb8({})); + const std::array junk { 1, 2, 3, 4 }; + CHECK(image::decode_rgba8(junk).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::read_bytes_from_path(path); + REQUIRE(original); + CHECK(image::write(source, path).error().code() == Error::Code::AlreadyExists); + CHECK(*io::read_bytes_from_path(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 { 1, 2, 3 }; + const std::array second { 4, 5 }; + REQUIRE(io::write_bytes_to_path(first, path, io::WriteMode::CreateNew)); + CHECK(io::write_bytes_to_path(second, path, io::WriteMode::CreateNew).error().code() == Error::Code::AlreadyExists); + CHECK(*io::read_bytes_from_path(path) == std::vector(first.begin(), first.end())); + REQUIRE(io::write_bytes_to_path(second, path)); + CHECK(*io::read_bytes_from_path(path) == std::vector(second.begin(), second.end())); +} diff --git a/unittests/terrainlib/raster_fold.cpp b/unittests/terrainlib/raster_fold.cpp new file mode 100644 index 00000000..c827c7d1 --- /dev/null +++ b/unittests/terrainlib/raster_fold.cpp @@ -0,0 +1,54 @@ +#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); +} From 3918ca3bfbbbf68688bfb029c3c48453b4ddb8bf Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:55:36 +0200 Subject: [PATCH 23/83] Use shared raster scaling for RF image fallback --- src/terrainlib/raster/algorithm/scale.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terrainlib/raster/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h index 0e7b2584..a092474c 100644 --- a/src/terrainlib/raster/algorithm/scale.h +++ b/src/terrainlib/raster/algorithm/scale.h @@ -29,8 +29,8 @@ namespace detail { continue; } // Keep integer coordinates out of the working pixel's phase precision. - const double phase_x = (double(position.x % factor) + 0.5) / factor - 0.5; - const double phase_y = (double(position.y % factor) + 0.5) / factor - 0.5; + const S phase_x = (S(position.x % factor) + S(0.5)) / S(factor) - S(0.5); + const S phase_y = (S(position.y % factor) + S(0.5)) / S(factor) - S(0.5); const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); const S fraction_x = S(phase_x < 0 ? phase_x + 1 : phase_x); const S fraction_y = S(phase_y < 0 ? phase_y + 1 : phase_y); From 17d7567252a98107ba66e68c93f2022adbdbdf94 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:04:46 +0200 Subject: [PATCH 24/83] Unify raster resampling and add Lanczos halo upscaling --- docs/raster-store/scaling.md | 130 ++++--- docs/raster-store/tiles-with-halo.md | 10 +- src/terrainlib/raster/algorithm/scale.h | 227 ++++++------ .../raster/algorithm/scaling_detail.h | 171 +++++---- .../raster_store/read_tile_with_halo.h | 27 +- src/terrainlib/raster_store/scaler.h | 151 +++----- unittests/terrainlib/raster_algorithm.cpp | 341 +++++++++++++----- unittests/terrainlib/raster_halo.cpp | 55 ++- 8 files changed, 676 insertions(+), 436 deletions(-) diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md index 09ec33a6..35d007fa 100644 --- a/docs/raster-store/scaling.md +++ b/docs/raster-store/scaling.md @@ -5,6 +5,44 @@ including paired raster-store wrappers, conversion tuples, rectangular strided windows, and separable box/Lanczos filtering. Output-window scaling, halo extraction, and snapshot halo metadata were implemented later the same day. +## Lanczos upscaling and halo windows + +Implemented on 2026-09-25. One `Resampling` enum selects behavior in both +scaling directions: + +| Selection | Upscaling | Downscaling | +|---|---|---| +| `NearestNeighbourAndBox` | Nearest-neighbour | Box | +| `BiliinearAndBox` | Bilinear | Box | +| `Lanczos2` | Lanczos-2 | Lanczos-2 | +| `Lanczos3` | Lanczos-3 | Lanczos-3 | +| `Lanczos4` | Lanczos-4 | Lanczos-4 | + +Window offsets are `glm::ivec2`, relative to the scaled interior. They can +include the source halo in either scaling direction and at zero levels. +Every contributing source sample must be available within the supplied +logical source; otherwise validation fails before any destination writes. +Full-output overloads are convenience wrappers around the windowed operation. + +`required_source_window(input_size, halo_width, levels, method, output_offset, +output_size)` queries the same source-support calculation without reading pixels. +It returns a `SourceWindow` with `origin` and `size` in source pixels, measured +from the supplied raster's top-left corner, including its halo. Invalid geometry +or insufficient support returns an error. RF-builder uses this public query to +fetch only contributing source pixels before scaling. + +Tile dimensions are at most 2^30 and offset components lie in +[-2^30, 2^30), enforced as assertions. Ancestor fallback in RF-builder and the +halo reader supports gaps up to 30 levels; larger gaps return an error. +Products, source-footprint calculations, pixel +counts, and storage offsets use wider arithmetic. In particular, dimensions +such as 2^17 by 2^17 are supported subject to actual storage capacity. + +`rf-builder tiles` uses fixed Lanczos-3 for enlarged ancestor fallback, with +no command-line selector. Its existing fallback description records that +choice; there is no new cache field or migration mechanism. The standalone +`tile-downloader` is unchanged. + ## Scope and ownership Generic scaling and reduction operate on one raster, without interpreting @@ -48,8 +86,8 @@ Normalize source and destination independently; do not multiply overloads for every raster/view combination. Both overloads share a view-based implementation. Inputs are rectangular, with a symmetric `halo_width` on all four sides. Both -axes scale by the same factor. The output contains only the scaled interior; -all input halo pixels are excluded from its extent. Reject empty inputs and +axes scale by the same factor. Full-output calls contain only the scaled +interior; explicit output windows may also include halo pixels. Reject empty inputs and empty interiors after removing the halo, even though the view model permits empty views. Destination overloads avoid allocating the final output, but separable passes and repeated reductions still allocate intermediate rasters. @@ -59,7 +97,7 @@ Express scaling amounts as `n_zoom_levels`, the exponent of two. Public negative means downscaling, and zero means cropping only. For example, `+2` upscales by four and `-2` downscales by four. Directional helpers and custom `reduce` use unsigned exponent counts. Zero levels crop without invoking a -reducer. Keep `upscale`, `downscale`, and directional halo queries in `detail`. +reducer. Keep `upscale` and `downscale` in `detail`. For reduction, both interior dimensions must be divisible by the factor. Validate geometry, halo bounds, algorithm enum selections, zoom-level counts, @@ -74,10 +112,8 @@ independently on each axis. Halo offsets affect addressing, not sampling phase. ### Generic API -The algorithm enums remain `Interpolation { NearestNeighbour, Bilinear }` and -`Filter { Box, Lanczos2, Lanczos3, Lanczos4 }`. Remove the scaler's `ValueMapping` -enum and the fixed source-to-working-type policy from generic `scale` and -`reduce`. Instead accept a tuple `(value_decoder, value_encoder)`; the decoder's +The `Resampling` enum above selects the paired upscaling/downscaling method. +Generic `scale` and `reduce` accept a tuple `(value_decoder, value_encoder)`; the decoder's return type determines the working pixel type. The encoder returns exactly the original stored pixel type, so neither operation changes the raster's type. @@ -87,10 +123,10 @@ forms delegate to the same processing implementation. Keep destinations last and constrain conversion tuples and destinations so the forms are unambiguous. ```cpp -scale(source, halo_width, n_zoom_levels, interpolation, filter); -scale(source, halo_width, n_zoom_levels, interpolation, filter, destination); -scale(source, halo_width, n_zoom_levels, interpolation, filter, conversion); -scale(source, halo_width, n_zoom_levels, interpolation, filter, conversion, +scale(source, halo_width, n_zoom_levels, method); +scale(source, halo_width, n_zoom_levels, method, destination); +scale(source, halo_width, n_zoom_levels, method, conversion); +scale(source, halo_width, n_zoom_levels, method, conversion, destination); reduce(source, halo_width, n_zoom_levels, reducer); @@ -109,20 +145,14 @@ The halo queries are independent of conversion: namespace raster::algorithm { Expected required_halo( - int n_zoom_levels, Interpolation interpolation, Filter filter); - -namespace detail { -Expected required_upscaling_halo( - unsigned n_zoom_levels, Interpolation interpolation); -Expected required_downscaling_halo( - unsigned n_zoom_levels, Filter filter); -} // namespace detail + int n_zoom_levels, Resampling method); + } // namespace raster::algorithm ``` -`scale` and `required_halo` receive both algorithm selections and use the one -corresponding to the requested direction. The filter enum represents the three -Lanczos support radii without a separate parameter for box filtering. +`scale` and `required_halo` receive one method selection. The halo query +covers a full interior output; explicit windows validate their actual source +footprint, including any additional support needed by output halo pixels. ## Agreed windowed scaling extension @@ -130,10 +160,10 @@ Implemented on 2026-09-22 after the view-based scaler refactor. Overloads of gen `scale` select a window of their conceptual full output, in either scaling direction. -Use `output_offset` (`glm::uvec2`) and, for allocating overloads, +Use `output_offset` (`glm::ivec2`) and, for allocating overloads, `output_size` (`glm::uvec2`) as direct parameters. The offset is measured in -pixels of the conceptual scaled output, excluding the source halo; writing -starts at destination-local `(0,0)`. Destination-taking overloads infer the +pixels relative to the conceptual scaled interior; negative coordinates lie +before its top/left edges. Writing starts at destination-local `(0,0)`. Destination-taking overloads infer the window size from the destination. Paired destinations must have matching sizes and use the same offset. @@ -146,32 +176,32 @@ with an explicit `output_offset` infer a crop size from the destination. Generic call shapes extend the existing convention: ```cpp -scale(source, halo_width, n_zoom_levels, interpolation, filter, +scale(source, halo_width, n_zoom_levels, method, output_offset, destination); -scale(source, halo_width, n_zoom_levels, interpolation, filter, +scale(source, halo_width, n_zoom_levels, method, output_offset, output_size); -scale(source, halo_width, n_zoom_levels, interpolation, filter, +scale(source, halo_width, n_zoom_levels, method, output_offset, conversion, destination); -scale(source, halo_width, n_zoom_levels, interpolation, filter, +scale(source, halo_width, n_zoom_levels, method, output_offset, output_size, conversion); ``` The corresponding paired forms are: ```cpp -scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, +scale(data, attribution, halo_width, n_zoom_levels, method, output_offset, value_mapping, data_destination, attribution_destination); -scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, +scale(data, attribution, halo_width, n_zoom_levels, method, output_offset, output_size, value_mapping); ``` The selected rectangle is `[output_offset, output_offset + output_size)`. -Results must equal full scaling followed by cropping this rectangle, while -computing only the necessary samples. Existing full-output overloads retain +Results must equal filtering a sufficiently large surrounding raster and +cropping this rectangle, while computing only the necessary samples. Existing full-output overloads retain their behavior and share the processing implementation with windowed forms. Preserve existing return types, conversion defaults, signed zoom-level -convention, interpolation/filter selection, and pixel semantics. Zero levels -select an exact crop within the source interior, after excluding its halo. +convention and pixel semantics. Zero levels select an exact crop, including +supplied halo pixels when requested. For upscaling, evaluate only requested output pixels with the sampling phase of their position in the conceptual full result. A source view alone cannot @@ -187,12 +217,14 @@ Do not clamp at processing-window edges or replace repeated reductions with a single larger filter. This extension does not add window overloads to custom `reduce` as a separate public feature. -Validate the selected rectangle before writing; it must fit inside the -conceptual full output. Use unsigned factor and coordinate arithmetic with -overflow-safe shift and bounds checks. The full conceptual image +Validate the selected rectangle's complete source footprint before writing; +it must fit inside the supplied logical source. Use signed 32-bit offsets, +unsigned dimensions/factors, and wider products with checked shifts and bounds. +The full conceptual image need not fit raster dimensions or be allocated; only the requested output and necessary intermediate storage must fit their allocation limits. -Preserve source-halo requirements and the existing overlap rules. +Preserve the existing overlap rules. An interior window may require less +source halo than a full-output operation; validate the actual requested taps. Verification must compare windows against full-scale-then-crop for zero, positive, and negative levels, including non-aligned offsets, multi-step @@ -204,7 +236,14 @@ full output, including a 20-level ancestor gap. ## Algorithms and reducers -Upscaling offers nearest-neighbour and bilinear interpolation. Upscale directly +Lanczos upscaling uses normalized `sinc(x) * sinc(x/a)` weights with `2a` +taps per axis. Kernel evaluation and normalization are shared with downscaling; +2x downscaling evaluates that kernel at half the input distance and uses `4a` +taps. Horizontal upscaling coefficients are cached only for the requested +output width. Fractional phases use integer quotient/remainder calculations, +including floor division for negative output positions. + +Upscaling offers nearest-neighbour, bilinear, and Lanczos-2/3/4 interpolation. Upscale directly from the input to the final resolution rather than repeating interpolation by two. Nearest-neighbour sampling copies the selected stored pixel exactly, without a decoder/encoder round trip; neither callable is invoked. Bilinear interpolation @@ -288,9 +327,9 @@ identity conversion, and has no mapping enum parameter: ```cpp scale(data, attribution, halo_width, n_zoom_levels, - interpolation, filter, value_mapping); + method, value_mapping); scale(data, attribution, halo_width, n_zoom_levels, - interpolation, filter, value_mapping, data_destination, attribution_destination); + method, value_mapping, data_destination, attribution_destination); reduce(data, attribution, halo_width, n_zoom_levels, reducer); reduce(data, attribution, halo_width, n_zoom_levels, reducer, @@ -450,6 +489,7 @@ result. Minimum widths, in input pixels on each side including corners, are: | Zero levels (crop only) | 0 | | Nearest-neighbour upscaling | 0 | | Bilinear upscaling, positive levels | 1 | +| Lanczos upscaling, radius `a`, positive levels | `a` | | Box or custom 2x2 reduction, including repeated steps | 0 | | Lanczos reduction by total factor `F`, radius `a` | `(2a - 1) * (F - 1)` | @@ -459,6 +499,12 @@ from `1 - 2a` through `2a`, giving a halo of `2a - 1`. Retaining output halo Lanczos-3 therefore requires 5 input halo pixels for reduction by two and 15 for reduction by four. Halo queries validate parameters and arithmetic overflow. +For Lanczos upscaling by factor `F`, a symmetric output halo `h` needs input +halo `ceil(a - 0.5 + (h - 0.5) / F)`. Thus Lanczos-3 at 2x can preserve five +halo pixels: a 266x266 source with a 256x256 interior produces a 522x522 +window at offset `{-5, -5}`. Filtering uses the original interior's sampling +phase; it does not resize the whole padded rectangle onto the output extent. + There is no `required_reduction_halo`: custom block reduction always needs zero halo, though its supplied halo is still cropped and its geometry validated. Paired wrappers use the data operation's requirement, which also suffices for diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md index 1668b172..1992a884 100644 --- a/docs/raster-store/tiles-with-halo.md +++ b/docs/raster-store/tiles-with-halo.md @@ -103,9 +103,13 @@ overloads take `output_size` explicitly. This permits whole source tiles to feed destination subviews without full scaled intermediates or a separate crop/copy step. The extension applies to upscaling and downscaling. The four-level descent limit bounds downscaling from finer tiles; it does not -bound upscaling from ancestors. Verify bounded work with a 20-level ancestor gap. +bound upscaling from ancestors. Ancestor fallback supports gaps up to 30 levels; +larger gaps return an error before calculating local scaling offsets. +Verify bounded work with a 20-level ancestor gap and the 30-level limit. -Bilinear ancestor interpolation requires a one-source-pixel halo. Source +Bilinear ancestor interpolation requires one source pixel of support; +Lanczos-2/3/4 requires two/three/four respectively. Select the method with +`Resampling`. Finer-source reduction remains Box. Source support uses a clamped view of the supplying ancestor's complete interior. Do not fetch neighbouring tiles to extend interpolation support. If processing a smaller cutout, preserve the full ancestor's clamping bounds rather than @@ -226,7 +230,7 @@ and verification results. The public implementation is descent limit with ancestor fallback, and partial coverage. - At-most-once ancestor fetches and preservation of selected finer samples, including zero-attribution regions, when ancestor fallback is also used. -- Bilinear support clamps at the full supplying ancestor's edges, not a +- Bilinear and Lanczos support clamp at the full supplying ancestor's edges, not a processing cutout's edges, without reading neighbouring support tiles. - Numerical contributions from zero-attribution pixels, representative attribution, and preservation of supplied or resampled zero-attribution diff --git a/src/terrainlib/raster/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h index a092474c..0926c1a5 100644 --- a/src/terrainlib/raster/algorithm/scale.h +++ b/src/terrainlib/raster/algorithm/scale.h @@ -2,46 +2,90 @@ #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(int 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, - Interpolation interpolation, + Resampling method, const Conversion& conversion, const View& destination, - glm::uvec2 output_offset = {}) + glm::ivec2 output_offset) { using W = DecodedPixel; using S = typename PixelTraits::Scalar; const unsigned factor = geometry.factor; - for (unsigned y = 0; y < destination.height(); ++y) { - for (unsigned x = 0; x < destination.width(); ++x) { - const glm::uvec2 position = output_offset + glm::uvec2(x, y); - const glm::uvec2 nearest = position / factor + glm::uvec2(halo_width); - if (interpolation == Interpolation::NearestNeighbour) { + 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( + int(halo_width) + source_cell(output_offset.x + int(x), factor), int(halo_width) + source_cell(output_offset.y + int(y), factor)); std::memcpy(std::addressof(destination.pixel({ x, y })), std::addressof(source.pixel(nearest)), sizeof(T)); - continue; } - // Keep integer coordinates out of the working pixel's phase precision. - const S phase_x = (S(position.x % factor) + S(0.5)) / S(factor) - S(0.5); - const S phase_y = (S(position.y % factor) + S(0.5)) / S(factor) - S(0.5); - const glm::uvec2 origin(nearest.x - (phase_x < 0 ? 1u : 0u), nearest.y - (phase_y < 0 ? 1u : 0u)); - const S fraction_x = S(phase_x < 0 ? phase_x + 1 : phase_x); - const S fraction_y = S(phase_y < 0 ? phase_y + 1 : phase_y); - const std::array weights_x { S(1) - fraction_x, fraction_x }; - const std::array weights_y { S(1) - fraction_y, fraction_y }; + 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 + int(x), factor, radius); + for (unsigned y = 0; y < destination.height(); ++y) { + const int position_y = output_offset.y + int(y); + const auto weights_y = upscaling_weights(position_y, factor, radius); + const unsigned origin_y = int(halo_width) + upscaling_origin(position_y, factor, radius); + for (unsigned x = 0; x < destination.width(); ++x) { + const unsigned origin_x = int(halo_width) + upscaling_origin(output_offset.x + int(x), factor, radius); + const auto& weights_x = horizontal->pixel({ x, 0 }); W value {}; - for (unsigned dy = 0; dy < 2; ++dy) { - for (unsigned dx = 0; dx < 2; ++dx) { - value += std::invoke(std::get<0>(conversion), source.pixel(origin + glm::uvec2(dx, dy))) * (weights_x[dx] * weights_y[dy]); - } - } + 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)); } } @@ -60,19 +104,11 @@ namespace detail { using S = typename PixelTraits::Scalar; std::array weights {}; const unsigned count = radius == 0 ? 2 : 4 * radius; - S normalization = 0; - const auto sinc = [](S x) { - const S angle = std::numbers::pi_v * x; - return x == S(0) ? S(1) : std::sin(angle) / angle; - }; for (unsigned i = 0; i < count; ++i) { const S offset = S(i) - S(count - 1) / S(2); - weights[i] = radius == 0 ? S(1) : sinc(offset / S(2)) * sinc(offset / S(2 * radius)); - normalization += weights[i]; - } - for (unsigned i = 0; i < count; ++i) { - weights[i] /= normalization; + 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 {}; @@ -101,71 +137,62 @@ namespace detail { } template - Expected scale_views(const InputView& source, + Expected scale_window(const InputView& source, unsigned halo_width, int levels, - Interpolation interpolation, - Filter filter, + Resampling method, + glm::ivec2 offset, const Conversion& conversion, const View& destination) { - auto geometry = scale_geometry(source.size(), halo_width, levels, interpolation, filter); - if (!geometry) { + auto geometry = window_geometry(source.size(), halo_width, levels, method, offset, destination.size()); + if (!geometry) return Error::propagate(std::move(geometry)); - } - if (auto valid = validate_scaling_destination(source, halo_width, *geometry, destination); !valid) { + 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 copy_views(*raster::make_view(source, glm::uvec2(halo_width), geometry->interior), destination); - } - if (levels < 0) { - return downscale(source, halo_width, *geometry, *filter_radius(filter), conversion, destination); - } - return upscale(source, halo_width, *geometry, interpolation, conversion, destination); + 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 -/// All supplied pixels participate. Decoder/intermediate values must be finite and encodable. +/// 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 n_zoom_levels, - Interpolation interpolation, - Filter filter, - const Conversion& conversion, - Destination&& destination) +[[nodiscard]] Expected scale( + Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, const Conversion& conversion, Destination&& destination) { - return detail::scale_views( - detail::read_only_view(raster::make_view(source)), halo_width, n_zoom_levels, interpolation, filter, conversion, raster::make_view(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 n_zoom_levels, Interpolation interpolation, Filter filter, const Conversion& conversion) + Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, glm::uvec2 output_size, const Conversion& conversion) { - const auto input = detail::read_only_view(raster::make_view(source)); - auto geometry = detail::scale_geometry(input.size(), halo_width, n_zoom_levels, interpolation, filter); - if (!geometry) { + 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>(geometry->output, - [&](const auto& destination) { return detail::scale_views(input, halo_width, n_zoom_levels, interpolation, filter, conversion, destination); }); + 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 n_zoom_levels, Interpolation interpolation, Filter filter, Destination&& destination) +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, Destination&& destination) { return scale(std::forward(source), halo_width, - n_zoom_levels, - interpolation, - filter, + levels, + method, + output_offset, linear_conversion>(), std::forward(destination)); } @@ -173,76 +200,48 @@ requires detail::NumericPixel> && std::same_as requires detail::NumericPixel> [[nodiscard]] Expected>> scale( - Source&& source, unsigned halo_width, int n_zoom_levels, Interpolation interpolation, Filter filter) + Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, glm::uvec2 output_size) { - return scale(std::forward(source), halo_width, n_zoom_levels, interpolation, filter, linear_conversion>()); + return scale(std::forward(source), halo_width, levels, method, output_offset, output_size, linear_conversion>()); } -/// Select a window of the conceptual full output; the destination determines its size. + +/// 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, - Interpolation interpolation, - Filter filter, - glm::uvec2 output_offset, - const Conversion& conversion, - Destination&& destination) +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, const Conversion& conversion, Destination&& destination) { - const auto input = detail::read_only_view(raster::make_view(source)); - const auto output = raster::make_view(destination); - auto geometry = detail::window_geometry(input.size(), halo_width, levels, interpolation, filter, output_offset, output.size()); + auto geometry = detail::scale_geometry(source.size(), halo_width, levels, method); if (!geometry) return Error::propagate(std::move(geometry)); - if (levels == 0) { - return detail::copy_views(*raster::make_view(input, output_offset + glm::uvec2(halo_width), output.size()), output); - } - if (auto valid = detail::validate_view_overlap(input, output, false); !valid) - return valid; - if (levels > 0) - return detail::upscale(input, halo_width, *geometry, interpolation, conversion, output, output_offset); - // An aligned source crop with the complete cumulative kernel support has - // exactly the same stage alignment and rounding as full-output reduction. - const unsigned support = *required_halo(levels, interpolation, filter); - auto region = raster::make_view( - input, output_offset * geometry->factor + glm::uvec2(halo_width - support), output.size() * geometry->factor + glm::uvec2(2 * support)); - if (!region) - return Error::propagate(std::move(region)); - return detail::scale_views(*region, support, levels, interpolation, filter, conversion, output); + 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::ivec2(0), conversion, std::forward(destination)); } template requires detail::NumericPixel> && detail::ScalingConversion> -[[nodiscard]] Expected>> scale(Source&& source, - unsigned halo_width, - int levels, - Interpolation interpolation, - Filter filter, - glm::uvec2 output_offset, - glm::uvec2 output_size, - const Conversion& conversion) +[[nodiscard]] Expected>> scale( + Source&& source, unsigned halo_width, int levels, Resampling method, const Conversion& conversion) { - auto geometry = detail::window_geometry(source.size(), halo_width, levels, interpolation, filter, output_offset, output_size); + auto geometry = detail::scale_geometry(source.size(), halo_width, levels, method); if (!geometry) return Error::propagate(std::move(geometry)); - return detail::produce_raster>( - output_size, [&](const auto& destination) { return scale(source, halo_width, levels, interpolation, filter, output_offset, conversion, destination); }); + return scale(std::forward(source), halo_width, levels, method, glm::ivec2(0), geometry->output, conversion); } template requires detail::NumericPixel> && std::same_as, detail::SourcePixel> -[[nodiscard]] Expected scale( - Source&& source, unsigned halo_width, int levels, Interpolation interpolation, Filter filter, glm::uvec2 output_offset, Destination&& destination) +[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, Destination&& destination) { - return scale(source, halo_width, levels, interpolation, filter, output_offset, linear_conversion>(), 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, Interpolation interpolation, Filter filter, glm::uvec2 output_offset, glm::uvec2 output_size) +[[nodiscard]] Expected>> scale(Source&& source, unsigned halo_width, int levels, Resampling method) { - return scale(source, halo_width, levels, interpolation, filter, output_offset, output_size, linear_conversion>()); + 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 index e22ab1da..b447d32c 100644 --- a/src/terrainlib/raster/algorithm/scaling_detail.h +++ b/src/terrainlib/raster/algorithm/scaling_detail.h @@ -5,12 +5,9 @@ namespace raster::algorithm { -enum class Interpolation { - NearestNeighbour, - Bilinear, -}; -enum class Filter { - Box, +enum class Resampling { + NearestNeighbourAndBox, + BiliinearAndBox, Lanczos2, Lanczos3, Lanczos4, @@ -25,60 +22,57 @@ namespace detail { return 1u << n_zoom_levels; } - inline Expected filter_radius(Filter filter) + inline Expected filter_radius(Resampling method) { - switch (filter) { - case Filter::Box: + switch (method) { + case Resampling::NearestNeighbourAndBox: + case Resampling::BiliinearAndBox: return 0; - case Filter::Lanczos2: + case Resampling::Lanczos2: return 2; - case Filter::Lanczos3: + case Resampling::Lanczos3: return 3; - case Filter::Lanczos4: + case Resampling::Lanczos4: return 4; } - return Error::fail(Error::Code::InvalidInput, "invalid raster reduction filter"); + return Error::fail(Error::Code::InvalidInput, "invalid raster resampling method"); } - inline Expected required_upscaling_halo(unsigned n_zoom_levels, Interpolation interpolation) + // Floor division, including negative output coordinates and factors above INT_MAX. + inline int source_cell(int position, unsigned factor) { - if (auto factor = scale_factor(n_zoom_levels); !factor) { - return Error::propagate(std::move(factor)); - } - switch (interpolation) { - case Interpolation::NearestNeighbour: - return 0; - case Interpolation::Bilinear: - return n_zoom_levels == 0 ? 0u : 1u; - } - return Error::fail(Error::Code::InvalidInput, "invalid raster interpolation"); + return position >= 0 ? int(unsigned(position) / factor) : -1 - int(unsigned(-(position + 1)) / factor); } - inline Expected required_downscaling_halo(unsigned n_zoom_levels, Filter filter) + inline unsigned source_phase(int position, unsigned factor) { - auto factor = scale_factor(n_zoom_levels); - if (!factor) { - return Error::propagate(std::move(factor)); - } - auto radius = filter_radius(filter); - if (!radius) { - return Error::propagate(std::move(radius)); - } - 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); + return unsigned(std::int64_t(position) - std::int64_t(source_cell(position, factor)) * factor); + } + + inline int upscaling_origin(int position, unsigned factor, unsigned radius) + { + const int 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)); @@ -156,54 +150,89 @@ namespace detail { } // namespace detail /// Positive levels upscale, negative levels downscale, and zero only crops. -inline Expected required_halo(int n_zoom_levels, Interpolation interpolation, Filter filter) +/// This query covers the full interior; a requested output halo needs additional support. +inline Expected required_halo(int n_zoom_levels, Resampling method) { - if (auto valid = detail::required_upscaling_halo(0, interpolation); !valid) { - return Error::propagate(std::move(valid)); - } - if (auto valid = detail::filter_radius(filter); !valid) { - return Error::propagate(std::move(valid)); - } - if (n_zoom_levels < 0) { - return detail::required_downscaling_halo(static_cast(-std::int64_t(n_zoom_levels)), filter); - } - return detail::required_upscaling_halo(static_cast(n_zoom_levels), interpolation); + 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, Interpolation interpolation, Filter filter) + inline Expected scale_geometry(glm::uvec2 input, unsigned halo_width, int levels, Resampling method) { - auto required = required_halo(levels, interpolation, filter); - if (!required) { + 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); } - // Bounds checks use division so a small window does not require representable full dimensions. - inline Expected window_geometry( - glm::uvec2 input, unsigned halo_width, int levels, Interpolation interpolation, Filter filter, glm::uvec2 offset, glm::uvec2 size) + + // 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::ivec2 offset, glm::uvec2 size) { - auto required = required_halo(levels, interpolation, filter); + assert(input.x <= (1u << 30) && input.y <= (1u << 30)); + assert(size.x <= (1u << 30) && size.y <= (1u << 30)); + assert(offset.x >= -(1 << 30) && offset.x < (1 << 30) && offset.y >= -(1 << 30) && offset.y < (1 << 30)); + auto required = required_halo(levels, method); if (!required) return Error::propagate(std::move(required)); - auto factor = scale_factor(static_cast(levels < 0 ? -std::int64_t(levels) : levels)); - if (!factor) - return Error::propagate(std::move(factor)); - if (input.x == 0 || input.y == 0 || halo_width > ((std::min)(input.x, input.y) - 1) / 2 || halo_width < *required) { - return Error::fail(Error::Code::InvalidInput, "invalid raster interior or insufficient halo"); - } - const auto interior = input - glm::uvec2(2 * halo_width); + const auto factor = *scale_factor(static_cast(levels < 0 ? -std::int64_t(levels) : levels)); + 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 || offset[axis] > (std::numeric_limits::max)() - (size[axis] - 1)) { - return Error::fail(Error::Code::InvalidInput, "invalid scaling output window"); - } - const unsigned last = offset[axis] + size[axis] - 1; - if (levels >= 0 ? last / *factor >= interior[axis] : interior[axis] % *factor != 0 || last >= interior[axis] / *factor) { - return Error::fail(Error::Code::InvalidInput, "scaling output window exceeds output bounds"); + if (size[axis] == 0) + return Error::fail(Error::Code::InvalidInput, "empty scaling output window"); + const int last = offset[axis] + int(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) + std::int64_t(offset[axis]) * factor - *required; + last_source = std::int64_t(halo_width) + (std::int64_t(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 ScalingGeometry { interior, size, *factor }; + 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::ivec2 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_store/read_tile_with_halo.h b/src/terrainlib/raster_store/read_tile_with_halo.h index 62330715..9e99f9e1 100644 --- a/src/terrainlib/raster_store/read_tile_with_halo.h +++ b/src/terrainlib/raster_store/read_tile_with_halo.h @@ -47,10 +47,12 @@ Expected> read_tile_with_halo(const storage::IndexedStorage& storage, const io::manifest::Metadata& metadata, const radix::tile::Id& id, unsigned halo_width, - raster::algorithm::Interpolation interpolation) + raster::algorithm::Resampling method) { namespace algorithm = raster::algorithm; using namespace halo_detail; + if (auto valid = algorithm::required_halo(0, method); !valid) + return Error::propagate(std::move(valid)); if (auto valid = io::manifest::validate(metadata); !valid) return Error::propagate(std::move(valid)); if (!StoreTraits::is_valid(id)) @@ -174,8 +176,7 @@ Expected> read_tile_with_halo(const storage::IndexedStorage& storage, source.source_attribution, 0, -int(source_id.zoom_level - id.zoom_level), - interpolation, - algorithm::Filter::Box, + algorithm::Resampling::NearestNeighbourAndBox, assignment.region.origin - assignment.source_origin, metadata.value_mapping, data, @@ -184,23 +185,24 @@ Expected> read_tile_with_halo(const storage::IndexedStorage& storage, return Error::propagate(std::move(result)); } else { const unsigned gap = id.zoom_level - source_id.zoom_level; - auto factor = algorithm::detail::scale_factor(gap); - if (!factor) - return Error::propagate(std::move(factor)); - const auto tile_offset = assignment.neighbour.coords & glm::uvec2(*factor - 1); + if (gap > 30) + return Error::fail(Error::Code::InvalidInput, "ancestor fallback zoom gap exceeds 30 levels"); + const unsigned factor = 1u << gap; + const auto tile_offset = assignment.neighbour.coords & glm::uvec2(factor - 1); const unsigned side_bits = std::countr_zero(side); glm::uvec2 source_origin; glm::uvec2 phase; if (gap <= side_bits) { - source_origin = tile_offset * (side >> gap) + assignment.region.origin / *factor; - phase = assignment.region.origin % *factor; + source_origin = tile_offset * (side >> gap) + assignment.region.origin / factor; + phase = assignment.region.origin % factor; } else { source_origin = tile_offset >> (gap - side_bits); phase = (tile_offset & glm::uvec2((1u << (gap - side_bits)) - 1)) * side + assignment.region.origin; } const auto last = phase + assignment.region.size - glm::uvec2(1); - const auto interior = last / *factor + glm::uvec2(1); - const unsigned support = interpolation == algorithm::Interpolation::Bilinear ? 1 : 0; + const auto interior = last / factor + glm::uvec2(1); + const unsigned support = *algorithm::required_halo(int(gap), method); + const glm::ivec2 offset(phase); // Clamping bounds remain those of the complete supplying tile. auto input = raster::make_clamped_view(source.data, glm::ivec2(source_origin) - glm::ivec2(support), interior + glm::uvec2(2 * support)); auto input_attribution @@ -209,8 +211,7 @@ Expected> read_tile_with_halo(const storage::IndexedStorage& storage, return Error::propagate(std::move(input)); if (!input_attribution) return Error::propagate(std::move(input_attribution)); - auto result = scaler::scale( - *input, *input_attribution, support, int(gap), interpolation, algorithm::Filter::Box, phase, metadata.value_mapping, data, attribution); + auto result = scaler::scale(*input, *input_attribution, support, int(gap), method, offset, metadata.value_mapping, data, attribution); if (!result) return Error::propagate(std::move(result)); } diff --git a/src/terrainlib/raster_store/scaler.h b/src/terrainlib/raster_store/scaler.h index 785424a2..3fdea30e 100644 --- a/src/terrainlib/raster_store/scaler.h +++ b/src/terrainlib/raster_store/scaler.h @@ -80,83 +80,6 @@ namespace detail { } } // namespace detail -/// Scale data numerically and attribution independently, including attribution zero. -template -requires detail::PairedSources && detail::PairedDestinations -[[nodiscard]] Expected scale(Data&& data, - Attribution&& attribution, - unsigned halo_width, - int n_zoom_levels, - raster::algorithm::Interpolation interpolation, - raster::algorithm::Filter filter, - pixel::Mapping mapping, - Destination&& destination, - AttributionDestination&& attribution_destination) -{ - namespace algorithm = raster::algorithm; - using T = algorithm::detail::SourcePixel; - if (auto valid - = detail::validate_mapping(mapping, n_zoom_levels < 0 || (n_zoom_levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); - !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::scale_geometry(input.size(), halo_width, n_zoom_levels, interpolation, filter); - if (!geometry) { - return Error::propagate(std::move(geometry)); - } - if (auto valid = detail::validate_destinations(input, input_attribution, halo_width, *geometry, output, output_attribution); !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, n_zoom_levels, interpolation, filter, algorithm::srgb_conversion(), output); - } - } - return algorithm::scale(input, halo_width, n_zoom_levels, interpolation, filter, output); - }; - if (auto result = scale_data(); !result) { - return result; - } - if (n_zoom_levels < 0) { - return algorithm::reduce(input_attribution, halo_width, static_cast(-std::int64_t(n_zoom_levels)), algorithm::Mode {}, output_attribution); - } - return algorithm::scale( - input_attribution, halo_width, n_zoom_levels, algorithm::Interpolation::NearestNeighbour, algorithm::Filter::Box, output_attribution); -} - -template -requires detail::PairedSources -[[nodiscard]] Expected>, radix::Raster>> scale(Data&& data, - Attribution&& attribution, - unsigned halo_width, - int n_zoom_levels, - raster::algorithm::Interpolation interpolation, - raster::algorithm::Filter filter, - pixel::Mapping mapping) -{ - using T = raster::algorithm::detail::SourcePixel; - if (auto valid - = detail::validate_mapping(mapping, n_zoom_levels < 0 || (n_zoom_levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); - !valid) { - return Error::propagate(std::move(valid)); - } - if (data.size() != attribution.size()) { - return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); - } - auto geometry = raster::algorithm::detail::scale_geometry(data.size(), halo_width, n_zoom_levels, interpolation, filter); - if (!geometry) { - return Error::propagate(std::move(geometry)); - } - return detail::produce_pair(geometry->output, [&](auto& output, auto& output_attribution) { - return scale(data, attribution, halo_width, n_zoom_levels, interpolation, filter, mapping, output, output_attribution); - }); -} - /// Scale only the selected conceptual output window. template requires detail::PairedSources && detail::PairedDestinations @@ -164,29 +87,29 @@ requires detail::PairedSources && detail::PairedDestinations< Attribution&& attribution, unsigned halo_width, int levels, - raster::algorithm::Interpolation interpolation, - raster::algorithm::Filter filter, - glm::uvec2 output_offset, + raster::algorithm::Resampling method, + glm::ivec2 output_offset, pixel::Mapping mapping, Destination&& destination, AttributionDestination&& attribution_destination) { namespace algorithm = raster::algorithm; using T = algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); !valid) + if (auto valid = detail::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, interpolation, filter, output_offset, output.size()); + 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, output_offset + glm::uvec2(halo_width), output.size()), target, true); + 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) @@ -202,20 +125,21 @@ requires detail::PairedSources && detail::PairedDestinations< const auto scale_data = [&]() -> Expected { if constexpr (algorithm::detail::srgb_pixel) { if (mapping == pixel::Mapping::SRGBA) - return algorithm::scale(input, halo_width, levels, interpolation, filter, output_offset, algorithm::srgb_conversion(), output); + return algorithm::scale(input, halo_width, levels, method, output_offset, algorithm::srgb_conversion(), output); } - return algorithm::scale(input, halo_width, levels, interpolation, filter, output_offset, output); + return algorithm::scale(input, halo_width, levels, method, output_offset, output); }; if (auto result = scale_data(); !result) return result; if (levels < 0) { - auto region = raster::make_view(input_attribution, output_offset * geometry->factor + glm::uvec2(halo_width), output.size() * geometry->factor); + 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::Interpolation::NearestNeighbour, algorithm::Filter::Box, output_offset, output_attribution); + return algorithm::scale(input_attribution, halo_width, levels, algorithm::Resampling::NearestNeighbourAndBox, output_offset, output_attribution); } template @@ -224,25 +148,64 @@ requires detail::PairedSources Attribution&& attribution, unsigned halo_width, int levels, - raster::algorithm::Interpolation interpolation, - raster::algorithm::Filter filter, - glm::uvec2 output_offset, + raster::algorithm::Resampling method, + glm::ivec2 output_offset, glm::uvec2 output_size, pixel::Mapping mapping) { using T = raster::algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && interpolation == raster::algorithm::Interpolation::Bilinear)); !valid) + if (auto valid = detail::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, interpolation, filter, output_offset, output_size); + 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 detail::produce_pair(output_size, [&](auto& output, auto& output_attribution) { - return scale(data, attribution, halo_width, levels, interpolation, filter, output_offset, mapping, output, 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 detail::PairedSources && detail::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::ivec2(0), + mapping, + std::forward(destination), + std::forward(attribution_destination)); +} + +template +requires detail::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::ivec2(0), geometry->output, mapping); +} + /// Custom data reduction uses the supplied conversion; attribution always uses identity and Mode. template requires detail::PairedSources && detail::PairedDestinations diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp index 1c53b268..e369ad5f 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -23,8 +23,7 @@ namespace { namespace algorithm = raster::algorithm; -using algorithm::Filter; -using algorithm::Interpolation; +using algorithm::Resampling; namespace scaler = raster_store::scaler; using ValueMapping = raster_store::pixel::Mapping; using Catch::Matchers::WithinAbs; @@ -40,11 +39,10 @@ auto scale(const radix::Raster& data, const radix::Raster& attribution, int levels, unsigned halo = 0, - Filter filter = Filter::Box, - Interpolation interpolation = Interpolation::NearestNeighbour, + Resampling method = Resampling::NearestNeighbourAndBox, ValueMapping mapping = ValueMapping::Linear) { - return scaler::scale(data, attribution, halo, levels, interpolation, filter, mapping); + return scaler::scale(data, attribution, halo, levels, method, mapping); } template @@ -102,23 +100,39 @@ static_assert(std::same_as::min)(), Interpolation::Bilinear, Filter::Box)); - CHECK_FALSE(algorithm::required_halo((std::numeric_limits::max)(), Interpolation::Bilinear, Filter::Box)); - CHECK_FALSE(algorithm::required_halo(0, static_cast(99), Filter::Box)); - CHECK_FALSE(algorithm::required_halo(0, Interpolation::Bilinear, static_cast(99))); - CHECK(algorithm::required_halo(-31, Interpolation::Bilinear, Filter::Lanczos4).error().code() == Error::Code::ResourceExhausted); + 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]") @@ -130,11 +144,11 @@ TEST_CASE("raster scaling rejects invalid geometry and dimensions before allocat 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, Filter::Box, Interpolation::Bilinear)); - CHECK_FALSE(scale(data, attribution, -1, 0, Filter::Lanczos2)); - CHECK_FALSE(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, static_cast(99))); - CHECK(scale(data, attribution, 0, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA)); - CHECK(scale(data, attribution, -1, 0, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA).error().code() == Error::Code::Unsupported); + 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(scaler::reduce(data, attribution, 0, (std::numeric_limits::max)(), algorithm::Max {})); } @@ -145,7 +159,7 @@ TEST_CASE("zero zoom levels crop both rasters preserving payload bits", "[raster 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, Filter::Lanczos4, Interpolation::Bilinear); + 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) { @@ -192,7 +206,7 @@ TEST_CASE("bilinear raster upscaling respects area phase and nearest attribution attribution.pixel({ x, y }) = static_cast(1 + x + 5 * y); } } - const auto output = scale(data, attribution, 2, 1, Filter::Box, Interpolation::Bilinear); + 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) { @@ -209,7 +223,7 @@ TEST_CASE("bilinear interpolation includes unattributed samples", "[raster-algor 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, Filter::Box, Interpolation::Bilinear); + const auto result = scale(data, ids, 1, 1, Resampling::BiliinearAndBox); REQUIRE(result); for (const auto value : result->first) { CHECK(value == 43.75f); @@ -217,7 +231,7 @@ TEST_CASE("bilinear interpolation includes unattributed samples", "[raster-algor for (const auto id : result->second) { CHECK(id == 0); } - same_raster(result->first, algorithm::scale(data, 1, 1, Interpolation::Bilinear, Filter::Box).value()); + same_raster(result->first, algorithm::scale(data, 1, 1, Resampling::BiliinearAndBox).value()); } TEST_CASE("box reduction votes locally and includes every payload", "[raster-algorithm]") @@ -338,7 +352,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with { 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, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + 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) { @@ -350,7 +364,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with const auto custom = scaler::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, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + const auto nearest = scale(data, ids, 1, 0, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); REQUIRE(nearest); CHECK(nearest->first.pixel({ 2, 0 }) == data.pixel({ 1, 0 })); @@ -361,11 +375,11 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with rgb.pixel({ x, y }) = glm::u8vec3((13 * x + 79 * y) % 256); } } - const auto direct = scale(rgb, rgb_ids, -2, 1, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); - const auto first = scale(rgb, rgb_ids, -1, 1, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + 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, Filter::Box, Interpolation::NearestNeighbour, ValueMapping::SRGBA); + const auto second = scale(first->first, first->second, -1, 0, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); REQUIRE(second); same_raster(direct->first, second->first); } @@ -373,7 +387,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with TEST_CASE("Lanczos reduction matches an independent two-dimensional reference", "[raster-algorithm]") { for (int radius = 2; radius <= 4; ++radius) { - const auto filter = radius == 2 ? Filter::Lanczos2 : (radius == 3 ? Filter::Lanczos3 : Filter::Lanczos4); + 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); @@ -397,7 +411,7 @@ TEST_CASE("Lanczos includes outer support when the local block is unattributed", data.pixel({ x, y }) = -17; } } - const auto result = scale(data, ids, -1, 3, Filter::Lanczos2); + 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)); @@ -406,24 +420,24 @@ TEST_CASE("Lanczos includes outer support when the local block is unattributed", 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, Filter::Lanczos3); + 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, Filter::Lanczos3); + const auto first = scale(data, ids, -1, 5, Resampling::Lanczos3); REQUIRE(first); - const auto second = scale(first->first, first->second, -1, 5, Filter::Lanczos3); + 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, Filter::Lanczos3)); + 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 : { Filter::Box, Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) { - const auto halo = algorithm::required_halo(-2, Interpolation::Bilinear, filter).value(); + 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); @@ -443,8 +457,8 @@ TEST_CASE("independent area tiles match their metatile through repeated scaling" TEST_CASE("Lanczos reduction preserves constants and suppresses high frequencies", "[raster-algorithm]") { - for (const auto filter : { Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) { - const auto halo = algorithm::required_halo(-1, Interpolation::Bilinear, filter).value(); + 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); @@ -470,7 +484,7 @@ TEMPLATE_TEST_CASE("raster vector arithmetic instantiates every working precisio { radix::Raster data(glm::uvec2(8), TestType(12)); radix::Raster ids(glm::uvec2(8), 1); - const auto filtered = scale(data, ids, -1, 3, Filter::Lanczos2); + const auto filtered = scale(data, ids, -1, 3, Resampling::Lanczos2); const auto median = scaler::reduce(data, ids, 3, 1, algorithm::Median {}); REQUIRE(filtered); REQUIRE(median); @@ -485,13 +499,13 @@ TEST_CASE("weighted filters retain floating overshoot and clamp integers", "[ras 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, Filter::Lanczos2); + 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, Filter::Lanczos2); + const auto clamped = scale(integer_impulse, impulse_ids, -1, 3, Resampling::Lanczos2); REQUIRE(clamped); CHECK(clamped->first.pixel({ 0, 0 }) == 0); } @@ -501,7 +515,7 @@ TEST_CASE("bilinear colour interpolation decodes sRGB before combining samples", 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, Filter::Box, Interpolation::Bilinear, ValueMapping::SRGBA); + 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) { @@ -567,13 +581,13 @@ TEST_CASE("scalers accept ordinary and clamped views with matching destination r 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 : { Filter::Box, Filter::Lanczos2 }) { - const auto expected = algorithm::scale(materialized, 9, -2, Interpolation::Bilinear, filter); - const auto actual = algorithm::scale(padded, 9, -2, Interpolation::Bilinear, filter); + 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, Interpolation::Bilinear, filter, output)); + REQUIRE(algorithm::scale(padded, 9, -2, filter, output)); same_raster(algorithm::copy(output).value(), *expected); } CHECK(output_backing.pixel({ 0, 0 }) == -100); @@ -585,10 +599,10 @@ TEST_CASE("scalers accept ordinary and clamped views with matching destination r 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, Interpolation::Bilinear, Filter::Box); + 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, Interpolation::Bilinear, Filter::Box, up_output)); + REQUIRE(algorithm::scale(up_padded, 1, 1, Resampling::BiliinearAndBox, up_output)); same_raster(*up, up_output); } @@ -605,10 +619,10 @@ TEST_CASE("scaling validates geometry and overlap before invoking callables or w 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, Interpolation::Bilinear, Filter::Box, 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, Interpolation::Bilinear, Filter::Box, 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(); @@ -622,11 +636,11 @@ TEST_CASE("paired destination validation prevents partial writes on invalid attr radix::Raster ids(glm::uvec2(4), 0); radix::Raster output(glm::uvec2(2), 99); radix::Raster wrong(glm::uvec2(3), 77); - CHECK_FALSE(scaler::scale(data, ids, 0, -1, Interpolation::Bilinear, Filter::Box, ValueMapping::Linear, output, wrong)); + CHECK_FALSE(scaler::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, wrong)); CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, wrong)); const auto cross = raster::make_view(data, { 0, 0 }, { 2, 2 }).value(); CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, cross)); - CHECK_FALSE(scaler::scale(data, ids, 0, -1, Interpolation::Bilinear, Filter::Box, ValueMapping::Linear, output, cross)); + CHECK_FALSE(scaler::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, cross)); CHECK_FALSE(scaler::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)); @@ -648,7 +662,7 @@ TEST_CASE("nearest and crop bypass custom conversion and reducers", "[raster-alg return 0; } }; for (const int level : { 0, 2 }) { - const auto result = algorithm::scale(data, 0, level, Interpolation::NearestNeighbour, Filter::Box, conversion); + const auto result = algorithm::scale(data, 0, level, Resampling::NearestNeighbourAndBox, conversion); REQUIRE(result); for (const auto value : *result) { CHECK(std::bit_cast(value) == bits); @@ -665,20 +679,20 @@ TEST_CASE("nearest and crop bypass custom conversion and reducers", "[raster-alg 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, Interpolation::NearestNeighbour, Filter::Box); + 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, Interpolation::NearestNeighbour, Filter::Box, conversion); + 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, Interpolation::NearestNeighbour, Filter::Box, conversion); }; +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 {}); }; @@ -704,8 +718,8 @@ TEST_CASE("scaling invokes encoders with const working values", "[raster-algorit }; 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, Interpolation::NearestNeighbour, Filter::Box, conversion); - const auto up = algorithm::scale(source, 1, 1, Interpolation::Bilinear, Filter::Box, conversion); + 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) { @@ -719,18 +733,19 @@ TEST_CASE("scaling invokes encoders with const working values", "[raster-algorit TEST_CASE("scaling windows match full output crops across stages and kernels", "[raster-algorithm]") { for (int levels : { -2, 0, 2 }) - for (auto filter : { Filter::Box, Filter::Lanczos2, Filter::Lanczos3, Filter::Lanczos4 }) - for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { - const unsigned halo = *algorithm::required_halo(levels, interpolation, filter); - auto [data, attribution] = field({ 32, 24 }, halo); - auto full = scaler::scale(data, attribution, halo, levels, interpolation, filter, ValueMapping::Linear); - REQUIRE(full); - const glm::uvec2 offset(3, 1), size(3, 4); - auto window = scaler::scale(data, attribution, halo, levels, interpolation, filter, 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))); - } + 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 = scaler::scale(data, attribution, halo, levels, method, ValueMapping::Linear); + REQUIRE(full); + const glm::ivec2 offset(3, 1); + const glm::uvec2 size(3, 4); + auto window = scaler::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]") @@ -742,8 +757,8 @@ TEST_CASE("scaling windows retain conversion tuples and per-stage encoded roundi data.pixel({ x, y }) = { x * 7, y * 7, (x * y) % 256, (x + 3 * y) % 256 }; attribution.pixel({ x, y }) = (x + y) % 3; } - auto full = scaler::scale(data, attribution, 0, -2, Interpolation::NearestNeighbour, Filter::Box, ValueMapping::SRGBA); - auto window = scaler::scale(data, attribution, 0, -2, Interpolation::NearestNeighbour, Filter::Box, { 1, 2 }, { 4, 3 }, ValueMapping::SRGBA); + auto full = scaler::scale(data, attribution, 0, -2, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); + auto window = scaler::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 }))); @@ -754,11 +769,11 @@ TEST_CASE("small upscale windows do not require a representable full raster", "[ { radix::Raster source({ 4096, 2 }); source.fill(17); - auto result = algorithm::scale(source, 0, 20, Interpolation::NearestNeighbour, Filter::Box, { 4000000000u, 37 }, { 2, 3 }); + 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, Interpolation::Bilinear, Filter::Box, { 4000000000u, 37 }, { 2, 3 }); + auto linear = algorithm::scale(clamped, 1, 20, Resampling::BiliinearAndBox, { 1000000000, 37 }, { 2, 3 }); REQUIRE(linear); CHECK(linear->pixel({ 1, 2 }) == 17); } @@ -769,20 +784,20 @@ TEST_CASE("scaling validates full destinations and windows before writing", "[ra source.fill(1); radix::Raster destination({ 3, 3 }); destination.fill(99); - CHECK_FALSE(algorithm::scale(source, 0, 1, Interpolation::NearestNeighbour, Filter::Box, destination)); - CHECK_FALSE(algorithm::scale(source, 0, 0, Interpolation::NearestNeighbour, Filter::Box, { 2, 2 }, destination)); - CHECK_FALSE(algorithm::scale(source, 0, 0, Interpolation::NearestNeighbour, Filter::Box, { UINT_MAX, 0 }, destination)); + 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(scaler::scale(source, attribution, 0, 1, Interpolation::NearestNeighbour, Filter::Box, { 0, 0 }, ValueMapping::Linear, destination, wrong)); + CHECK_FALSE(scaler::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 = scaler::scale(data, attribution, 1, 1, Interpolation::Bilinear, Filter::Box, { 3, 5 }, { 5, 7 }, ValueMapping::Linear); + auto expected = scaler::scale(data, attribution, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, { 5, 7 }, ValueMapping::Linear); REQUIRE(expected); radix::Raster output({ 10, 12 }); output.fill(-99); @@ -790,16 +805,16 @@ TEST_CASE("windowed scaling writes strided destinations and rejects overlap befo 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(scaler::scale(data, attribution, 1, 1, Interpolation::Bilinear, Filter::Box, { 3, 5 }, ValueMapping::Linear, view, attribution_view)); + REQUIRE(scaler::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, Interpolation::Bilinear, Filter::Box, { 3, 5 }, *raster::make_view(data, { 2, 3 }, { 5, 7 }))); + 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, Interpolation::Bilinear, Filter::Box, oversized)); + CHECK_FALSE(algorithm::scale(data, 1, 1, Resampling::BiliinearAndBox, oversized)); CHECK(oversized.pixel({ 0, 0 }) == 99); } @@ -820,3 +835,153 @@ TEST_CASE("Gaussian kernels are normalized symmetric and reject invalid sizes", 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::ivec2 offset(-int(halo)); + auto output = scaler::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 = scaler::scale(data, ids, halo, levels, method, { -2, -2 }, output_size, ValueMapping::Linear); + auto reference = scaler::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(scaler::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(scaler::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_halo.cpp b/unittests/terrainlib/raster_halo.cpp index 280ce985..dfcc73ea 100644 --- a/unittests/terrainlib/raster_halo.cpp +++ b/unittests/terrainlib/raster_halo.cpp @@ -1,3 +1,4 @@ +#include #include #include "../temporary_directory.h" @@ -6,7 +7,7 @@ namespace { using Tile = raster_store::Tile; using Id = radix::tile::Id; -using Interpolation = raster::algorithm::Interpolation; +using Resampling = raster::algorithm::Resampling; struct MemoryCodec final : store::Codec { mutable std::map tiles; mutable std::map reads; @@ -49,7 +50,7 @@ struct Fixture { tile.source_attribution.fill(attribution); REQUIRE(storage->save(id, tile)); } - auto read(Id id, unsigned halo = 2, Interpolation interpolation = Interpolation::NearestNeighbour) + auto read(Id id, unsigned halo = 2, Resampling interpolation = Resampling::NearestNeighbourAndBox) { return raster_store::read_tile_with_halo(*storage, metadata, id, halo, interpolation); } @@ -171,15 +172,16 @@ TEST_CASE("halo propagates indexed payload errors", "[raster-halo]") CHECK(result.error().code() == Error::Code::Io); } -TEST_CASE("halo distant ancestor samples use bounded windows with either interpolation", "[raster-halo]") +TEST_CASE("halo distant ancestor samples use bounded windows with every resampling method", "[raster-halo]") { - for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + for (auto interpolation : + { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { Fixture f; f.add({ 0, { 0, 0 } }, 23, 3); f.add({ 20, { 400000, 500000 } }, 42, 4); auto result = f.read({ 20, { 400000, 500000 } }, 2, interpolation); REQUIRE(result); - CHECK(result->data.pixel({ 0, 0 }) == 23); + CHECK(result->data.pixel({ 0, 0 }) == Catch::Approx(23).margin(1e-4)); CHECK(result->source_attribution.pixel({ 0, 0 }) == 3); CHECK(result->data.pixel({ 2, 2 }) == 42); f.once(); @@ -188,7 +190,8 @@ TEST_CASE("halo distant ancestor samples use bounded windows with either interpo TEST_CASE("halo ancestor windows match complete-ancestor scaling at edges and cutouts", "[raster-halo]") { - for (auto interpolation : { Interpolation::NearestNeighbour, Interpolation::Bilinear }) { + for (auto interpolation : + { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { Fixture f; Tile ancestor(64); for (unsigned y = 0; y < 64; ++y) @@ -201,10 +204,10 @@ TEST_CASE("halo ancestor windows match complete-ancestor scaling at edges and cu f.add(centre, -1, 9); auto tile = f.read(centre, 64, interpolation); REQUIRE(tile); - const auto padded = *raster::make_clamped_view(ancestor.data, { -1, -1 }, { 66, 66 }); - const auto padded_attribution = *raster::make_clamped_view(ancestor.source_attribution, { -1, -1 }, { 66, 66 }); - auto full = raster_store::scaler::scale( - padded, padded_attribution, 1, 2, interpolation, raster::algorithm::Filter::Box, raster_store::pixel::Mapping::Linear); + const unsigned support = *raster::algorithm::required_halo(2, interpolation); + const auto padded = *raster::make_clamped_view(ancestor.data, glm::ivec2(-int(support)), glm::uvec2(64 + 2 * support)); + const auto padded_attribution = *raster::make_clamped_view(ancestor.source_attribution, glm::ivec2(-int(support)), glm::uvec2(64 + 2 * support)); + auto full = raster_store::scaler::scale(padded, padded_attribution, support, 2, interpolation, raster_store::pixel::Mapping::Linear); REQUIRE(full); for (unsigned y = 0; y < 192; ++y) for (unsigned x = 0; x < 192; ++x) { @@ -264,10 +267,40 @@ TEST_CASE("halo nonconstant distant ancestor preserves fractional phase", "[rast REQUIRE(f.storage->save({ 0, { 0, 0 } }, ancestor)); const Id centre { 20, { 400000, 500000 } }; f.add(centre, -1); - auto tile = f.read(centre, 2, Interpolation::Bilinear); + auto tile = f.read(centre, 2, Resampling::BiliinearAndBox); REQUIRE(tile); // The left halo begins two requested pixels before the centre's boundary. const double expected = (400000.0 * 64 - 2 + 0.5) / double(1u << 20) - 0.5; CHECK(std::abs(tile->data.pixel({ 0, 2 }) - expected) < 0.00001); f.once(); } + +TEST_CASE("halo ancestor fallback supports a zoom gap of 30 without rebasing", "[raster-halo]") +{ + for (auto method : { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos3 }) { + Fixture f; + f.add({ 0, { 0, 0 } }, 23, 3); + const Id centre { 30, { (1u << 24) - 1, (1u << 24) - 1 } }; + f.add(centre, 42, 4); + auto result = f.read(centre, 2, method); + REQUIRE(result); + CHECK(result->data.pixel({ 0, 0 }) == Catch::Approx(23).margin(1e-4)); + CHECK(result->data.pixel({ 67, 32 }) == Catch::Approx(23).margin(1e-4)); + CHECK(result->source_attribution.pixel({ 67, 32 }) == 3); + f.once(); + } +} + +TEST_CASE("halo ancestor fallback rejects zoom gaps above 30", "[raster-halo]") +{ + for (unsigned gap : { 31u, 32u }) { + Fixture f; + f.add({ 0, { 0, 0 } }, 23, 3); + const Id centre { gap, { 1u << 24, 1u << 24 } }; + f.add(centre, 42, 4); + auto result = f.read(centre, 2, Resampling::Lanczos3); + REQUIRE_FALSE(result); + CHECK(result.error().code() == Error::Code::InvalidInput); + CHECK(result.error().to_string().find("ancestor fallback zoom gap") != std::string::npos); + } +} From e9c3c1d00975606c3a217a27d31692bcdd12de6c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:10:45 +0200 Subject: [PATCH 25/83] Finalize RF merger design and shared-function integration --- docs/raster-store/README.md | 5 ++--- docs/raster-store/terminology.md | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 6f02b0da..8f7e39f3 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -25,6 +25,5 @@ TB generation and the tile server remain future work. ## Scope -The documents mostly hold format information. The `rf_merger` document is -explicitly a non-authoritative idea parking lot, not a tool specification or -implementation plan. +The documents describe the storage format, implemented import tools, and +proposed merger and delivery-generation behavior. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 8e7f4dba..4b7ed0fe 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -93,6 +93,23 @@ _Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) : 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](scaling.md#paired-raster-store-wrappers). 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. From db134a6ecfbff36df25320a1606d98812f0ffa47 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:31:06 +0200 Subject: [PATCH 26/83] Suppress OpenCV IPP color conversion function-type diagnostic --- misc/suppression/ubsan.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/misc/suppression/ubsan.txt b/misc/suppression/ubsan.txt index faa75719..3899c176 100644 --- a/misc/suppression/ubsan.txt +++ b/misc/suppression/ubsan.txt @@ -15,6 +15,10 @@ bool:CGAL::Trapezoidal_decomposition_2<*::set_with_guarantees(bool) # 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 From 50fc1007bb78c9f1b90642f6b9401542be097a11 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:06:40 +0200 Subject: [PATCH 27/83] Add RF merger with topology partitioning and statistics Add rf-merger, which merges two published RF snapshots by attribution priority. The output partition follows both input indices, coarse suppliers are upscaled with the shared halo reader and paired scaler, and unchanged tiles are hard-linked. Checkpoints record per-category statistics for recovery and the final report. Move TilePool to raster_store so the RF builder and merger share it, and run the merger suite in CI. --- docs/raster-store/README.md | 4 +- docs/raster-store/implementation-status.md | 40 ++++- src/terrainlib/CMakeLists.txt | 1 + src/terrainlib/raster_store/TilePool.h | 179 +++++++++++++++++++++ 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/terrainlib/raster_store/TilePool.h diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 8f7e39f3..347b993e 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -25,5 +25,5 @@ TB generation and the tile server remain future work. ## Scope -The documents describe the storage format, implemented import tools, and -proposed merger and delivery-generation behavior. +The documents describe the storage format, implemented import and merge +tools, and proposed delivery-generation behavior. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 894bf611..3ca58d38 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -1,5 +1,43 @@ # Raster-store implementation status +## RF merger — 2026-09-28 + +Implemented the [merger design](rf-merger-design.md) as `src/rf_merger` with +the `rf-merger` executable, `rfmergerlib` and `unittests_rfmerger`. +`rf_builder::TilePool` moved unchanged to `raster_store::TilePool`. + +- The partition is a lazy depth-first cursor over both indices, + `rf_merger::partition::Cursor`, rather than `store::traverse`: the generic + traversal visits one index through callbacks and cannot feed the pool + lazily. An index-only count pass supplies progress totals. +- Leaves with a single native supplier and completed cache leaves are + hard-linked by the coordinator without payload reads; all other leaves are + produced by workers. +- A supplier that must be upscaled is read with `read_tile_with_halo` only when + the Lanczos-3 support window leaves its interior; otherwise an ordinary load + suffices. Zoom gaps above 30 levels are rejected, as in the halo reader. +- Fingerprints hash the complete metadata and index files with 64-bit FNV-1a. +- `--compression` accepts `zstd` (default), `zstd-best` and `none`; `none` + writes a CRC32C checksum. +- If cache statistics are missing or corrupt, restored tiles are reported as + "restored without statistics" and the statistics as incomplete. +- After a production error, statistics and index are checkpointed on a + best-effort basis before returning the error; nothing is published. + +Verification: Debug build of all merger and RF builder targets. The merger +suite has 15 cases covering priority parsing, selection, partitioning, +whole-tile precedence, overridden and single-pixel fine tiles, attribution +holes, agreement with direct halo-reader and paired-scaler calls, RGB8, +input and destination rejection, serial/parallel agreement, hard links with +different output compression, cancellation, recovery with restored, missing +and mismatching records, empty inputs, statistics formatting and the command +line. RF builder: 57 cases pass after the pool move. clazy (levels 0-2) and +clang-tidy (bugprone, performance) report only the codebase's existing +`const Key&` convention and the exception-escape note shared with `rf-builder`'s +`main`. A RelWithDebInfo ThreadSanitizer build passes the merger suite and the +10 RF builder `[parallel]` cases with the existing suppressions. Not measured: +memory and runtime at production tile sizes. + ## GDAL import NoData filling — 2026-09-23 Implemented the [agreed filling policy](gdal-nodata-filling.md): configurable @@ -97,7 +135,7 @@ Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. RF builder validation rejects unsupported attribution indices and indices outside the selected table before producing tiles. Storage does not scan tile attribution rasters on reads or writes. Clearing entries is not a library -operation. TB builders, merging, sampling, window reads, and enforced read-only access +operation. TB builders, sampling, window reads, and enforced read-only access remain separate work. RF import is tracked below. Publication currently uses Linux `renameat2(RENAME_NOREPLACE)`; other platforms diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 8a04b381..c251cb63 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -176,6 +176,7 @@ add_library(terrainlib 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 diff --git a/src/terrainlib/raster_store/TilePool.h b/src/terrainlib/raster_store/TilePool.h new file mode 100644 index 00000000..e7509b52 --- /dev/null +++ b/src/terrainlib/raster_store/TilePool.h @@ -0,0 +1,179 @@ +#pragma once + +#include "Error.h" +#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. +template +class TilePool { +public: + using Key = radix::tile::Id; + using Prepare = std::function(unsigned, const Key&)>; + 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; + + bool submit(const Key& key) + { + std::lock_guard lock(m_mutex); + if (m_stopped) { + return false; + } + for (auto& slot : m_slots) { + if (slot.state != State::Free) { + continue; + } + slot.key = key; + slot.sequence = m_sequence++; + slot.state = State::Pending; + ++m_outstanding; + m_changed.notify_all(); + return true; + } + return false; + } + + 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; + } + std::optional failure() const + { + std::lock_guard lock(m_mutex); + return m_failure; + } + +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 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 && !m_failure) { + m_failure = result.error(); + 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; + std::optional m_failure = std::nullopt; + // 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 From 87121c6e786d9d59a3f8414dbded6ba73cc0e11b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:55:20 +0200 Subject: [PATCH 28/83] Add Error exceptions and port rf-merger to three-tier error handling Error records a stack trace where it is made and gains Error::Exception, raise, throwing_unwrap and asserting_unwrap. The code style now separates bugs (assertions), recoverable failures (Expected, including all of terrainlib) and unrecoverable failures in tools (exceptions). rf-merger throws for invalid input and production failures, asserts bug-only results, and keeps Expected in its reusable functions. Preflight rejects sRGB on scalar payloads and zoom gaps above 30 levels. A statistics guard keeps a failed merge recoverable, and cancellation saves active tiles before throwing Cancelled. The priority list is parsed with CPLJSON. --- docs/code-style.md | 10 ++- docs/raster-store/implementation-status.md | 31 +++++++-- src/terrainlib/CMakeLists.txt | 3 + src/terrainlib/Error.cpp | 47 +++++++++++++ src/terrainlib/Error.h | 78 ++++++++++++++++++++++ unittests/terrainlib/error.cpp | 48 +++++++++++++ 6 files changed, 211 insertions(+), 6 deletions(-) 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/implementation-status.md b/docs/raster-store/implementation-status.md index 3ca58d38..9ed418f3 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -21,17 +21,38 @@ the `rf-merger` executable, `rfmergerlib` and `unittests_rfmerger`. writes a CRC32C checksum. - If cache statistics are missing or corrupt, restored tiles are reported as "restored without statistics" and the statistics as incomplete. -- After a production error, statistics and index are checkpointed on a - best-effort basis before returning the error; nothing is published. +- Error handling follows the three tiers in the + [code style](../code-style.md#error-handling). Reusable merger functions + (priorities, input records, statistics files, partitioning) return + `Expected` or plain values. The merge itself throws `Error::Exception` via + `Error::raise` and `Error::throwing_unwrap`, and `rf_merger::merge::run` + returns the report directly. Results that fail only through a merger bug + use `ASSERT` or `Error::asserting_unwrap`. +- Preflight rejects the sRGB mapping for scalar payloads and zoom gaps above 30 + levels before creating the output. A failure to read or hard-link cache + tiles stops the merge. +- SIGINT and SIGTERM cancel gracefully: queued work is discarded, active + tiles are finished and saved, statistics and index are checkpointed, and + `run` throws `Cancelled`, which `rf-merger` maps to exit status 128 plus the + signal number. When any other exception unwinds, a guard writes the + statistics before the output storage saves its index, so a failed merge is + recoverable with consistent statistics. +- Pool workers catch `Error::Exception` and return the `Error`, which the + coordinator rethrows; `TilePool` is unchanged. +- `rf-merger` logs errors with their chain and the stack trace captured where + the error was made, and logs assertion failures with their stack trace, to + stderr and `.log`. Verification: Debug build of all merger and RF builder targets. The merger suite has 15 cases covering priority parsing, selection, partitioning, whole-tile precedence, overridden and single-pixel fine tiles, attribution holes, agreement with direct halo-reader and paired-scaler calls, RGB8, input and destination rejection, serial/parallel agreement, hard links with -different output compression, cancellation, recovery with restored, missing -and mismatching records, empty inputs, statistics formatting and the command -line. RF builder: 57 cases pass after the pool move. clazy (levels 0-2) and +different output compression, cancellation, recovery after a production failure with +restored, missing and mismatching records, empty inputs, statistics +formatting and the command line. RF builder: 57 cases pass after the pool +move. `Error` tests cover `raise`, `throwing_unwrap` and stack traces that +survive propagation; `asserting_unwrap` aborts and has no automated test. clazy (levels 0-2) and clang-tidy (bugprone, performance) report only the codebase's existing `const Key&` convention and the exception-escape note shared with `rf-builder`'s `main`. A RelWithDebInfo ThreadSanitizer build passes the merger suite and the diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index c251cb63..128afa20 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -264,6 +264,9 @@ target_link_libraries(terrainlib PUBLIC xatlas ) +# Error captures stack traces; libassert already links cpptrace publicly. +target_link_libraries(terrainlib PRIVATE cpptrace::cpptrace) + if(ALP_ENABLE_OVERVIEW_READING) target_compile_definitions(terrainlib PUBLIC ALP_ENABLE_OVERVIEW_READING) endif() diff --git a/src/terrainlib/Error.cpp b/src/terrainlib/Error.cpp index e0e3f7d7..14b8940b 100644 --- a/src/terrainlib/Error.cpp +++ b/src/terrainlib/Error.cpp @@ -1,8 +1,11 @@ #include "Error.h" +#include #include +#include #include +#include namespace { @@ -37,6 +40,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)); } @@ -105,6 +110,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); @@ -139,3 +184,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 7624388b..af758191 100644 --- a/src/terrainlib/Error.h +++ b/src/terrainlib/Error.h @@ -1,14 +1,21 @@ #pragma once +#include #include #include +#include #include #include #include #include +#include #include #include +namespace cpptrace { +struct raw_trace; +} + class Error { public: enum class Code { @@ -37,6 +44,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()); @@ -78,16 +88,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 @@ -108,3 +161,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/unittests/terrainlib/error.cpp b/unittests/terrainlib/error.cpp index c2b7a611..011b5e3a 100644 --- a/unittests/terrainlib/error.cpp +++ b/unittests/terrainlib/error.cpp @@ -50,3 +50,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); + } +} From d9bb647b1c1de74c80292e132c37400527b89823 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:58:24 +0200 Subject: [PATCH 29/83] Plan envelope content hashes with XXH3-64 --- docs/raster-store/README.md | 1 + docs/raster-store/content-hash-plan.md | 178 +++++++++++++++++++++++++ docs/raster-store/todo.md | 11 ++ 3 files changed, 190 insertions(+) create mode 100644 docs/raster-store/content-hash-plan.md diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 347b993e..a1a0666e 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -19,6 +19,7 @@ TB generation and the tile server remain future work. - [Tiles with halo: design](tiles-with-halo.md) - [Halo extraction implementation plan](halo-implementation-plan.md) - [GDAL import NoData filling: policy and verification](gdal-nodata-filling.md) +- [Envelope content hashes](content-hash-plan.md) - [Tile-storage implementation plan](implementation-plan.md) - [Raster store TODO](todo.md) diff --git a/docs/raster-store/content-hash-plan.md b/docs/raster-store/content-hash-plan.md new file mode 100644 index 00000000..387da070 --- /dev/null +++ b/docs/raster-store/content-hash-plan.md @@ -0,0 +1,178 @@ +# Envelope content hashes: plan + +Status: plan, 2026-09-30. Agreed; no decisions are open. Per-tile hashes in +the raster-store index are deferred; see the [TODO](todo.md). + +## Motivation + +TB tiles should record which source tiles produced each lower zoom level, so +that a data change triggers only a partial rebuild. That needs a cheap content +identity per tile, without separate per-tile metadata files that would slow +reading. The RF merger needs the same for its recovery fingerprints of input +metadata and index files, which it currently computes by hashing the whole +files with a local 64-bit FNV-1a. + +The current envelope checksums do not provide such an identity: + +- `HandledByCompressionLib` is the Zstandard frame checksum, the low 32 bits of + XXH64, stored at the end of the frame rather than in the envelope header. +- `Crc32c` is 32 bits, computed with a byte-wise table at about 0.5 GB/s. +- `None` stores nothing. + +## Hash + +Use XXH3-64 (`XXH3_64bits`, seed 0) over the uncompressed payload bytes. +Hashing uncompressed bytes makes the identity independent of the Zstandard +settings and of recompression; hard-linked payloads share it. + +Measured single-threaded on an AMD Ryzen 9 3900X over 1 GiB in memory: + +| Algorithm | Output | Throughput | +|---|---:|---:| +| 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, envelope table | 32 bit | 0.5 GB/s | + +XXH3 is expected at 10–30 GB/s and is measured during implementation. A +4096-pixel float tile with attribution is about 96 MiB uncompressed, so FNV-1a +would add about 90 ms to every write and verified read, XXH3 a few +milliseconds. XXH3 also mixes much better than FNV-1a. With 64 bits, a changed +payload keeps its hash with probability about 2⁻⁶⁴; there is no adversary, and +each check compares against one recorded value. + +Vendor xxHash (header-only, BSD-2-Clause) with `alp_add_git_repository` at a +pinned release. The copy bundled in Zstandard cannot be reused: it disables +XXH3 and prefixes its symbols with `ZSTD_`. + +Add `io/hash.h` with the enumeration `io::hash::Algorithm { None, Xxh3_64 }` +and `io::hash::data(std::span bytes, Algorithm algorithm)`, +returning the hash as `std::vector`: empty for `None` and 8 bytes +in big-endian order, the canonical xxHash form, for `Xxh3_64`. A value +outside the enumeration is a programming error and fails an `ASSERT`. The +algorithm-specific functions stay in `io/hash.cpp`, the only file that +includes xxHash. Longer or stronger hashes are added later as new `Algorithm` +values. + +## Envelope changes + +1. Change the envelope magic. Envelope files of all stores change: raster + tiles, metadata and index, mesh and octree stores, DAG files and tool + records. Existing files are no longer readable. No real datasets exist yet, + so no migration is needed. +2. Replace `ChecksumAlgorithm` with `io::hash::Algorithm` and renumber + `CompressionAlgorithm` from zero as + `{ None, ZstdBestCompression, ZstdDefaultCompression }`. `Crc32c`, + `HandledByCompressionLib`, `ZstdBestCompressionWithChecksum` and + `ZstdDefaultCompressionWithChecksum` are removed, together with the CRC-32C + implementation. `checksum_algorithm` parameters and fields become + `hash_algorithm`. +3. The envelope field `checksum` becomes `hash` of type + `std::vector`, holding `hash::data` of the uncompressed payload. + The reader recomputes it and compares; a mismatch, including a hash of the + wrong length, is `CorruptData`. The Zstandard algorithms write frames + without the frame checksum; the reader no longer requires one. +4. Separate compression from hashing. Combining them saves no work: the + current `compress_with_checksum` already hashes and compresses in two + passes, and a fused streaming pass would save at most a few milliseconds + per tile against a few hundred for Zstandard. `io/compression.h` keeps only + `CompressionAlgorithm`, `compress(data, algorithm)` returning the + compressed bytes and `decompress(data, algorithm, max_size)`; + `CompressedData`, `compress_with_checksum` and `checked_decompress` are + removed. Both functions keep returning `Expected` for Zstandard and + size-limit failures, but an algorithm outside the enumeration fails an + `ASSERT`. The envelope computes the payload hash with `hash::data` when + writing and verifies it after decompressing; `io/hash.h` keeps xxHash out + of `envelope.h`. +5. Every hash algorithm is valid with every compression algorithm, including + `None` with `None`, which stores no integrity check. The combination check + goes. Enumerator values read from files are untrusted, so the envelope + checks both ranges while decoding the header, before `hash::data` or + `decompress` sees them; a value outside the enumeration is `Unsupported`. + A `Header` returned by the envelope therefore always holds valid + enumerators. +6. Split the envelope into `Header` (magic, class name, class version, hash + algorithm, hash, compression algorithm, uncompressed size) and + `Envelope { Header header; std::vector compressed_data; }`. + zpp_bits serializes the nested aggregate field by field, so this does not + change the layout. Class names are limited to 128 bytes by a + `static_assert` in `PayloadSchema`, hashes to 64 bytes. `max_header_size` + is derived from these limits and the field sizes. +7. Make `Xxh3_64` with `ZstdDefaultCompression` the default for `serialize`, + `write_to_path`, `TileCodec`, `tile_codec::from_name` and the raster-store + `CreateOptions`. `terrainlib/store` does not handle envelopes; its codecs + call `io::envelope` directly, so every envelope-based store, including the + mesh stores, gets the new default without store changes. +8. Add `io::envelope::read_header(path)`, returning the `Header`. It reads at + most `max_header_size` bytes; a shorter file is fine as long as it contains + the header, and payload bytes in the read prefix are ignored. It decodes + with a zpp_bits allocation limit of `max_header_size`, because zpp_bits + resizes containers before checking their length against the input. A header + that fails to decode is `CorruptData`. It validates the magic and the + enumerator ranges, but not the class name. Callers take the recorded + payload hash from `Header::hash`; it is neither read from nor verified + against the payload. + +## Byte buffers + +Remove the `io::envelope::Bytes` typedef; its users spell +`std::vector`. Byte buffers use `std::byte` instead of `uint8_t` +where they hold raw bytes: `io::read_bytes_from_path` returns +`std::vector`, and `io::write_bytes_to_path` takes +`std::span`. Their callers switch accordingly, including +`io/image.cpp`, the JSON readers of the attribution table and the RF merger +priorities, the RF tile provider and the tests. Casts move to interfaces that +require other types, such as OpenCV buffers; `envelope.h` no longer needs one. + +## Raster tiles + +Raster-store tiles keep a configurable hash algorithm in `CreateOptions`, with +`Xxh3_64` as the default. A comment at that default states that the tile +hashes serve as content identities, for example for TB dependency records, and +that `None` leaves tiles without one. Tiles that `copy_from` hard-links keep +the hash of their source. + +The RF merger has no hash option. `merge::Options::checksum_algorithm` is +removed, and the merger uses the `CreateOptions` default. `--compression` maps +`zstd`, `zstd-best` and `none` to `ZstdDefaultCompression`, +`ZstdBestCompression` and `None`; its help text no longer mentions CRC32C. + +## RF merger + +Replace the FNV-1a fingerprints with the header hashes of +`raster_store.metadata` and `raster_store.index`, read with `read_header`. +`Fingerprint` keeps the canonical path and stores both hashes as +`std::vector`. A file whose header records no hash is an +`Unsupported` error, because an empty hash would match any content. The source +attribution table stays out of the fingerprint. Remove the local hash. + +## Documentation + +- Update the envelope and tile codec sections of + [storage format](storage-format.md). +- Add ADR 0005 recording the XXH3-64 decision, the measurements and the + rejected alternatives: FNV-1a, CRC-32C, the Zstandard frame checksum, a + snapshot UUID and the SHA family. It also records the removal of CRC-32C and + of the Zstandard frame checksum variants, and the binary hash field. +- Update [implementation status](implementation-status.md) after + implementation. + +## Verification + +- `hash::data` with `Xxh3_64` against known xxHash test vectors. +- Envelope: round trips for every compression and hash combination, rejection + of the old magic and of unknown enumerator values by both `deserialize` and + `read_header`, detection of corrupted payloads with `Xxh3_64` and of hashes + with the wrong length. +- Header reads: a class name at the length limit, a file with a short class + name and an empty payload, a truncated header, and a corrupt length prefix, + which fails without a large allocation. +- The header hash agrees with `hash::data` of the payload from a full read, + and is empty for `None`. +- A Catch2 benchmark of `hash::data` with `Xxh3_64` on the same machine and + data size as the table above, with the project's build flags. +- Raster store and RF merger suites, then all CI suites; tests that compare + envelope bytes or sizes may need updates. diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index fe9bf14a..e0a12c58 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -3,3 +3,14 @@ - 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. +- Idea: store each tile's XXH3-64 content hash (see the + [content-hash plan](content-hash-plan.md)) in its raster-store index entry. + TB dependency checks would become in-memory lookups instead of one envelope + header read per source tile, which matters for millions of tiles and on + network filesystems. Saving a tile knows the hash from the envelope write, + and `copy_from` takes it from the source index. The cost is 8 bytes per + entry, about 1.6 times the current index size. Either the generic + `store::Index` carries per-node data beyond `NodeStatus`, or the raster + store keeps a separate hash table beside it. Header reads remain available + for verifying or rebuilding an index. Decide with the TB builder design that + defines the dependency records. From 451b2d4653edab128a42b355a7a085fbcaca9d02 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:41:56 +0200 Subject: [PATCH 30/83] Hash envelope payloads with XXH3-64 and expose envelope headers Envelopes get a new magic and a Header with a binary hash of the uncompressed payload, verified on read. Hash and compression enumerators are renumbered from zero: hash::Algorithm { None, Xxh3_64 } and CompressionAlgorithm { None, ZstdBestCompression, ZstdDefaultCompression }. CRC-32C and the Zstandard frame-checksum variants are removed, and Xxh3_64 with ZstdDefaultCompression is the default for every envelope writer. io/hash.h provides hash::data over byte spans; only io/hash.cpp includes the vendored xxHash v0.8.3. io/compression.h only compresses and decompresses. read_header decodes a header from a file prefix of at most 222 bytes without reading the payload; class names are limited to 128 bytes and hashes to 64. Invalid enumerators from files are Unsupported, and invalid ones passed by callers PANIC. The RF merger fingerprints its inputs with the header hashes of their metadata and index files and has no checksum option. Byte buffers of file I/O, image encoding and decoding and the RF builder HTTP client are std::byte. ADR 0005 records the decision and the measured 18.3 GB/s. --- docs/adr/0005-xxh3-64-envelope-hashes.md | 45 ++ docs/raster-store/architecture.md | 2 +- docs/raster-store/content-hash-plan.md | 8 +- docs/raster-store/content-hash-status.md | 41 ++ docs/raster-store/implementation-status.md | 30 +- docs/raster-store/storage-format.md | 33 +- src/CMakeLists.txt | 4 + src/terrainlib/CMakeLists.txt | 5 +- src/terrainlib/io/bytes.cpp | 12 +- src/terrainlib/io/bytes.h | 9 +- src/terrainlib/io/compression.cpp | 126 +----- src/terrainlib/io/compression.h | 33 +- src/terrainlib/io/envelope.cpp | 47 ++ src/terrainlib/io/envelope.h | 131 +++--- src/terrainlib/io/hash.cpp | 32 ++ src/terrainlib/io/hash.h | 19 + src/terrainlib/io/image.cpp | 22 +- src/terrainlib/io/image.h | 8 +- src/terrainlib/raster_store/io/TileCodec.h | 20 +- src/terrainlib/raster_store/storage.h | 7 +- unittests/CMakeLists.txt | 1 + unittests/terrainlib/envelope.cpp | 435 +++++++++++-------- unittests/terrainlib/io_hash.cpp | 71 +++ unittests/terrainlib/io_image.cpp | 20 +- unittests/terrainlib/raster_store.cpp | 16 +- unittests/terrainlib/store_compatibility.cpp | 2 +- unittests/terrainlib/store_storage.cpp | 4 +- 27 files changed, 749 insertions(+), 434 deletions(-) create mode 100644 docs/adr/0005-xxh3-64-envelope-hashes.md create mode 100644 docs/raster-store/content-hash-status.md create mode 100644 src/terrainlib/io/envelope.cpp create mode 100644 src/terrainlib/io/hash.cpp create mode 100644 src/terrainlib/io/hash.h create mode 100644 unittests/terrainlib/io_hash.cpp 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..d736f382 --- /dev/null +++ b/docs/adr/0005-xxh3-64-envelope-hashes.md @@ -0,0 +1,45 @@ +# 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. It was agreed on 2026-09-30 in the +[content-hash plan](../raster-store/content-hash-plan.md). + +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/raster-store/architecture.md b/docs/raster-store/architecture.md index 2d1ff125..6f36413b 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -245,7 +245,7 @@ Opening and publication do not perform an additional whole-snapshot validation pass or scan payload files for existence. Errors from envelope decoding and the normal format-adapter/opening path are propagated when the index or a tile is read. Generic envelope checks cover the serialized -representation, version, compression, and checksum; index invariants belong +representation, version, compression, and payload hash; index invariants belong to the format adapter. The attribution table is parsed separately as JSON. ### Publication diff --git a/docs/raster-store/content-hash-plan.md b/docs/raster-store/content-hash-plan.md index 387da070..28c1ee06 100644 --- a/docs/raster-store/content-hash-plan.md +++ b/docs/raster-store/content-hash-plan.md @@ -37,10 +37,10 @@ Measured single-threaded on an AMD Ryzen 9 3900X over 1 GiB in memory: | MD5 (`md5sum`) | 128 bit | 0.8 GB/s | | CRC-32C, envelope table | 32 bit | 0.5 GB/s | -XXH3 is expected at 10–30 GB/s and is measured during implementation. A -4096-pixel float tile with attribution is about 96 MiB uncompressed, so FNV-1a -would add about 90 ms to every write and verified read, XXH3 a few -milliseconds. XXH3 also mixes much better than FNV-1a. With 64 bits, a changed +XXH3-64 measured 18.3 GB/s in a Release build (see +[ADR 0005](../adr/0005-xxh3-64-envelope-hashes.md)). A 4096-pixel float tile +with attribution is about 96 MiB uncompressed, so FNV-1a would add about 90 ms +to every write and verified read, XXH3 about 5 ms. XXH3 also mixes much better than FNV-1a. With 64 bits, a changed payload keeps its hash with probability about 2⁻⁶⁴; there is no adversary, and each check compares against one recorded value. diff --git a/docs/raster-store/content-hash-status.md b/docs/raster-store/content-hash-status.md new file mode 100644 index 00000000..de3c65fa --- /dev/null +++ b/docs/raster-store/content-hash-status.md @@ -0,0 +1,41 @@ +# Envelope content hashes: status + +Implementation checklist for the [content-hash plan](content-hash-plan.md). + +- [x] Vendor xxHash and add `io/hash.h` with `hash::Algorithm` and `hash::data` +- [x] Switch `io::read_bytes_from_path` and `io::write_bytes_to_path` and their + callers to `std::byte` +- [x] Reduce `io/compression.h` to `compress` and `decompress`; remove CRC-32C + and the frame-checksum variants +- [x] Envelope: new magic, `Header` split, binary `hash`, enumerator range + checks, name and hash limits, defaults, `read_header`; remove `Bytes` +- [x] Raster tiles: `TileCodec`, `tile_codec::from_name` and `CreateOptions` + use `hash_algorithm` +- [x] RF merger: no hash option, `--compression` mapping, fingerprints from + header hashes +- [x] Tests: envelope, header reads, hash vectors, benchmark; adapt existing + suites +- [x] Documentation: storage format, ADR 0005, implementation status +- [x] Build, all CI suites, formatting (Linux GCC Debug, 2026-09-30) + +Notes: + +- xxHash is pinned at v0.8.3 and linked privately into terrainlib. +- `read_header` and `detail::validate_header` live in the new `io/envelope.cpp`. + `deserialize` decodes the whole envelope in one pass and both paths validate + the header with the same function. Only `read_header` decodes with an + allocation limit of `max_header_size`; a corrupt length prefix can still + cause a transient allocation of up to 1 GiB in `deserialize`, as before. +- `io::read_bytes_from_path` gained an optional maximum size for the header + read. +- The image API (`io::image::encode`, `decode_rgb8`, `decode_rgba8`), the RF + builder's `HttpClient` and its test server also use `std::byte`. `encode` + copies once from OpenCV's `std::vector`. +- Invalid enumerators in `hash::data` and `compress` fail with `PANIC`, which, + unlike `UNREACHABLE`, also aborts with a message in release builds. +- XXH3-64 measured 18.3 GB/s over 1 GiB in a Release build, recorded in + ADR 0005. +- Added an RF merger test: fingerprints equal the header hashes, survive + recompression, and files without a hash are rejected. +- clang-format was applied to new files and functions. `envelope.h` keeps its + existing, unformatted style, and one-line changes were left as they were. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md index 9ed418f3..28f0c588 100644 --- a/docs/raster-store/implementation-status.md +++ b/docs/raster-store/implementation-status.md @@ -1,5 +1,26 @@ # Raster-store implementation status +## Envelope content hashes — 2026-09-30 + +Implemented the [content-hash plan](content-hash-plan.md); progress is tracked +in the [status checklist](content-hash-status.md) and the decision in +[ADR 0005](../adr/0005-xxh3-64-envelope-hashes.md). + +- Envelopes have a new magic, a `Header` with a binary `hash` of the + uncompressed payload, and renumbered enumerators: + `io::hash::Algorithm { None, Xxh3_64 }` and + `CompressionAlgorithm { None, ZstdBestCompression, ZstdDefaultCompression }`. + Files written before this change are unreadable. +- `io::hash::data` computes hashes; xxHash v0.8.3 is included only by + `io/hash.cpp`. `io/compression.h` only compresses and decompresses. +- `io::envelope::read_header` reads at most 222 bytes and bounds its + allocations to that size. It shares the magic, enumerator and size-limit + checks with `deserialize`. +- The RF merger fingerprints its inputs with the header hashes of their + metadata and index files. +- Byte buffers of `io::read_bytes_from_path`, `io::write_bytes_to_path`, the + image encoder and decoder and the RF builder's `HttpClient` are `std::byte`. + ## RF merger — 2026-09-28 Implemented the [merger design](rf-merger-design.md) as `src/rf_merger` with @@ -16,9 +37,10 @@ the `rf-merger` executable, `rfmergerlib` and `unittests_rfmerger`. - A supplier that must be upscaled is read with `read_tile_with_halo` only when the Lanczos-3 support window leaves its interior; otherwise an ordinary load suffices. Zoom gaps above 30 levels are rejected, as in the halo reader. -- Fingerprints hash the complete metadata and index files with 64-bit FNV-1a. -- `--compression` accepts `zstd` (default), `zstd-best` and `none`; `none` - writes a CRC32C checksum. +- Fingerprints store the XXH3-64 payload hashes recorded in the envelope + headers of the metadata and index files; a file without a hash is rejected. +- `--compression` accepts `zstd` (default), `zstd-best` and `none`; every + choice writes XXH3-64 tile hashes. - If cache statistics are missing or corrupt, restored tiles are reported as "restored without statistics" and the statistics as incomplete. - Error handling follows the three tiers in the @@ -146,7 +168,7 @@ Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. | Accepted decisions and documentation | Verified | Attribution ownership, JSON contract, version-1 encoding, codec selection, API usage, and shared I/O fix recorded | | Typed tiles and attribution tables | Verified | `raster_store/Tile.h`, `attribution.h/.cpp`; zero attribution, verbatim fields, malformed slots, lookup precedence, and boundary indices | | XYZ layout, index, and metadata | Verified | `raster_store/path_layout.h`, `raster_store/io/manifest.h/.cpp`; boundary paths, mixed hierarchy, corrupt topology, metadata errors, and checkpoint persistence | -| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, zero-attribution payload preservation, dimensions, byte counts, compression, checksum, and version errors | +| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, zero-attribution payload preservation, dimensions, byte counts, compression, hash, and version errors | | Raster scaling and paired attribution | Verified | `raster/algorithm/*.h`, `raster_store/scaler.h`, and `pixel.h`; single-raster conversion/reduction, rectangular strided windows, separable filtering, independent attribution; see [scaling verification](scaling.md#refactor-verification--2026-09-22) | | Creation, opening, checkpoints, publication | Verified | `raster_store/storage.h`; metadata codec selection, incomplete opening, checkpoint failures, no implicit publication, collision rejection, no payload scan, and `/dev/full` write failure | | Hard-link integration and regression tests | Verified | `unittests/terrainlib/raster_store.cpp`; cross-root hard links and independent attribution survive RF deletion; all six regression suites pass | diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 1466d9d4..6423efe1 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -124,6 +124,30 @@ 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, for example for TB dependency records, +but that read does not verify it. 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 @@ -160,10 +184,11 @@ struct Tile { ``` Tile files use the `.amort` extension (AlpineMapsOrg raster tile) and -`io::envelope`. The writer exposes the envelope compression option, defaulting -to standard Zstandard compression with checksum. Readers accept the -algorithms supported by `io::envelope`; each envelope records its selected -algorithm. Writing is lossless to preserve data through repeated merging and +`io::envelope`. The writer exposes the envelope compression and hash options, +defaulting to standard Zstandard compression and 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. Writing is lossless to preserve data through repeated merging and overview generation. 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. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aeea6394..6c50c4fd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -65,6 +65,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) diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 128afa20..417edb1b 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -61,8 +61,9 @@ add_library(terrainlib io/compression.h io/compression.cpp io/conversion.h io/image.h io/image.cpp - io/envelope.h + 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 @@ -266,6 +267,8 @@ target_link_libraries(terrainlib PUBLIC # 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) if(ALP_ENABLE_OVERVIEW_READING) target_compile_definitions(terrainlib PUBLIC ALP_ENABLE_OVERVIEW_READING) diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp index df892ab5..a0e73f1a 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/bytes.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -7,12 +8,12 @@ namespace io { -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs) +Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs) { return write_bytes_to_path(bytes, path, WriteMode::Overwrite, make_dirs); } -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) +Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) { LOG_TRACE("Writing bytes to path {}", path); @@ -49,7 +50,7 @@ Expected write_bytes_to_path(const std::span bytes, const s return {}; } -Expected> read_bytes_from_path(const std::filesystem::path& path) +Expected> read_bytes_from_path(const std::filesystem::path& path, const std::size_t max_size) { LOG_TRACE("Reading bytes from path {}", path); @@ -70,9 +71,10 @@ Expected> read_bytes_from_path(const std::filesystem::path& return Error::fail(Error::Code::Io, "determine size of", path); } - std::vector buffer(static_cast(size)); + const auto read_size = static_cast(std::min(static_cast(size), max_size)); + std::vector buffer(static_cast(read_size)); file.seekg(0); - file.read(reinterpret_cast(buffer.data()), size); + file.read(reinterpret_cast(buffer.data()), read_size); if (!file.good()) { return Error::fail(Error::Code::Io, "read bytes from", path); diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/bytes.h index 6ca56dcb..b5cfe024 100644 --- a/src/terrainlib/io/bytes.h +++ b/src/terrainlib/io/bytes.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -12,8 +14,9 @@ namespace io { enum class WriteMode { Overwrite, CreateNew }; -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs = true); -Expected write_bytes_to_path(std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs = true); -Expected> read_bytes_from_path(const std::filesystem::path& path); +Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs = true); +Expected write_bytes_to_path(std::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_bytes_from_path(const std::filesystem::path& path, std::size_t max_size = std::numeric_limits::max()); } diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp index ee2154fc..18d9f9f7 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.cpp @@ -4,8 +4,10 @@ #include "io/compression.h" +#include +#include + #include -#include #include #include #include @@ -15,86 +17,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 std::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 std::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 +43,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); + std::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 +56,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 std::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; + std::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 +110,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..1e50fcb6 100644 --- a/src/terrainlib/io/compression.h +++ b/src/terrainlib/io/compression.h @@ -2,9 +2,7 @@ #include #include -#include -#include -#include +#include #include #include @@ -13,34 +11,19 @@ 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(std::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( + std::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/envelope.cpp b/src/terrainlib/io/envelope.cpp new file mode 100644 index 00000000..26468168 --- /dev/null +++ b/src/terrainlib/io/envelope.cpp @@ -0,0 +1,47 @@ +#include "io/envelope.h" + +#include + +namespace io::envelope { + +Expected
read_header(const std::filesystem::path& path) +{ + auto file_bytes = ::io::read_bytes_from_path(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 de5c7844..480a0cdc 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -3,7 +3,9 @@ #include "io/bytes.h" #include "io/compression.h" #include "io/glm_serialization.h" +#include "io/hash.h" +#include #include #include @@ -22,19 +24,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. + std::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; + std::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]; @@ -96,6 +110,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); @@ -122,24 +137,31 @@ 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); template Expected deserialize(std::span bytes, std::size_t max_decompressed_size = default_max_decompressed_size); +// 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) +Expected> serialize_to_bytes(const Value& value) { - Bytes bytes; + std::vector bytes; zpp::bits::out output(bytes, zpp::bits::alloc_limit()); const zpp::bits::errc result = output(value); if (zpp::bits::failure(result)) { @@ -199,10 +221,10 @@ Expected deserialize_version( } // namespace detail template -Expected serialize( +Expected> serialize( const typename Schema::template payload_type &payload, const CompressionAlgorithm compression_algorithm, - const ChecksumAlgorithm checksum_algorithm) + const hash::Algorithm hash_algorithm) { static_assert(Schema::template supports_version, "the requested payload version is not part of the schema"); @@ -212,34 +234,37 @@ Expected serialize( return Error::propagate(std::move(payload_bytes), "serializing envelope payload"); } - auto compressed = compress_with_checksum(*payload_bytes, compression_algorithm, checksum_algorithm); + auto compressed = compress(*payload_bytes, compression_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), + .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 detail::serialize_to_bytes(envelope); } template -Expected serialize( +Expected> serialize( const typename Schema::latest_type &payload, const CompressionAlgorithm compression_algorithm, - const ChecksumAlgorithm checksum_algorithm) + const hash::Algorithm hash_algorithm) { return serialize( payload, compression_algorithm, - checksum_algorithm); + hash_algorithm); } template @@ -249,49 +274,44 @@ Expected deserialize( { auto envelope = detail::deserialize_from_bytes(bytes); if (!envelope) { - return Error::propagate(std::move(envelope), "reading envelope header"); + return Error::propagate(std::move(envelope), "reading envelope"); } - if (envelope->magic != magic) { - return Error::fail(Error::Code::CorruptData, "invalid envelope magic"); + if (auto valid = detail::validate_header(envelope->header); !valid) { + return Error::propagate(std::move(valid), "reading envelope header"); } - if (envelope->class_name != Schema::class_name) { + const Header& header = envelope->header; + if (header.class_name != Schema::class_name) { return Error::fail(Error::Code::CorruptData, - "unexpected envelope class \"" + envelope->class_name + "\", expected \"" + std::string(Schema::class_name) + "\""); + "unexpected envelope class \"" + header.class_name + "\", expected \"" + std::string(Schema::class_name) + "\""); } - if (!Schema::supports_version_number(envelope->class_version)) { + if (!Schema::supports_version_number(header.class_version)) { return Error::fail(Error::Code::Unsupported, - "unsupported envelope class version " + std::to_string(envelope->class_version)); + "unsupported envelope class version " + std::to_string(header.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) { + 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 = checked_decompress( - envelope->compressed_data, - envelope->compression_algorithm, - envelope->checksum_algorithm, - envelope->checksum, - static_cast(envelope->uncompressed_size)); + 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"); } - 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) { + 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"); + } - return detail::deserialize_version(envelope->class_version, *payload_bytes); + return detail::deserialize_version(header.class_version, *payload_bytes); } template @@ -302,8 +322,7 @@ Expected read_from_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); + auto result = deserialize(*file_bytes, max_decompressed_size); if (!result) { return Error::propagate(std::move(result), "decoding envelope file \"" + path.string() + "\""); } @@ -314,18 +333,14 @@ 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) + const CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, + const hash::Algorithm hash_algorithm = hash::Algorithm::Xxh3_64) { - auto serialized = serialize(payload, compression_algorithm, checksum_algorithm); + auto serialized = serialize(payload, compression_algorithm, hash_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); + auto result = ::io::write_bytes_to_path(*serialized, path, make_dirs); if (!result) { return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); } diff --git a/src/terrainlib/io/hash.cpp b/src/terrainlib/io/hash.cpp new file mode 100644 index 00000000..b601bd52 --- /dev/null +++ b/src/terrainlib/io/hash.cpp @@ -0,0 +1,32 @@ +#include "io/hash.h" + +#define XXH_INLINE_ALL +#include + +#include + +namespace io::hash { +namespace { + + std::vector xxh3_64(const std::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 + +std::vector data(const std::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..fc1cfb67 --- /dev/null +++ b/src/terrainlib/io/hash.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +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. +std::vector data(std::span bytes, Algorithm algorithm); + +} // namespace io::hash diff --git a/src/terrainlib/io/image.cpp b/src/terrainlib/io/image.cpp index ee18e919..67b28797 100644 --- a/src/terrainlib/io/image.cpp +++ b/src/terrainlib/io/image.cpp @@ -37,7 +37,7 @@ namespace { } template - Expected> encode_raster(const radix::Raster& image, Format format, EncodeOptions options) + Expected> encode_raster(const radix::Raster& image, Format format, EncodeOptions options) try { if (auto valid = validate_encoding(format, Pixel::length(), options); !valid) { return Error::propagate(std::move(valid)); @@ -50,13 +50,15 @@ namespace { if (!converted) { return Error::propagate(std::move(converted)); } - std::vector bytes; + 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, bytes, parameters)) { + if (!cv::imencode(format == Format::Jpeg ? ".jpg" : ".png", *converted, encoded, parameters)) { return Error::fail(Error::Code::Io, "image encoding failed"); } - return bytes; + // OpenCV only encodes into a std::vector. + const auto bytes = std::as_bytes(std::span(encoded)); + return std::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&) { @@ -64,12 +66,12 @@ namespace { } template - Expected> decode_raster(std::span bytes) + Expected> decode_raster(std::span bytes) 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())); + 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()) { @@ -164,10 +166,10 @@ namespace { } // 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(std::span bytes) { return decode_raster(bytes); } -Expected decode_rgba8(std::span bytes) { return decode_raster(bytes); } +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(std::span bytes) { return decode_raster(bytes); } +Expected decode_rgba8(std::span bytes) { return decode_raster(bytes); } 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); } diff --git a/src/terrainlib/io/image.h b/src/terrainlib/io/image.h index 1579e42e..5d9b9ca0 100644 --- a/src/terrainlib/io/image.h +++ b/src/terrainlib/io/image.h @@ -29,13 +29,13 @@ struct WriteOptions { }; /// RGB(A) channels, first row at the top. No flipping, scaling or premultiplication. -Expected> encode(const RGB8& image, Format format, EncodeOptions options = {}); +Expected> encode(const RGB8& image, Format format, EncodeOptions options = {}); /// PNG preserves all four channels. JPEG rejects RGBA input. -Expected> encode(const RGBA8& image, Format format, EncodeOptions options = {}); +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. -Expected decode_rgb8(std::span bytes); -Expected decode_rgba8(std::span bytes); +Expected decode_rgb8(std::span bytes); +Expected decode_rgba8(std::span bytes); Expected read_rgb8(const std::filesystem::path& path); Expected read_rgba8(const std::filesystem::path& path); diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index c0c8add7..667018ca 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -18,8 +18,8 @@ namespace tile_codec { struct RasterTile { std::uint32_t width; std::uint32_t height; - ::io::envelope::Bytes data; - ::io::envelope::Bytes source_attribution; + std::vector data; + std::vector source_attribution; }; } // namespace detail::v1 @@ -34,11 +34,11 @@ class TileCodec final : public store::Codec> { public: explicit TileCodec(const glm::uvec2 dimensions = glm::uvec2(default_tile_side), - const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompressionWithChecksum, - const ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib) + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression, + const ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64) : m_dimensions(dimensions) , m_compression_algorithm(compression_algorithm) - , m_checksum_algorithm(checksum_algorithm) + , m_hash_algorithm(hash_algorithm) { } @@ -83,24 +83,24 @@ class TileCodec final : public store::Codec> { const auto source_attribution = tile.source_attribution.bytes(); const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; - return ::io::envelope::write_to_path(encoded, paths(node_path).front(), true, m_compression_algorithm, m_checksum_algorithm); + return ::io::envelope::write_to_path(encoded, paths(node_path).front(), true, m_compression_algorithm, m_hash_algorithm); } private: glm::uvec2 m_dimensions; ::io::envelope::CompressionAlgorithm m_compression_algorithm; - ::io::envelope::ChecksumAlgorithm m_checksum_algorithm; + ::io::hash::Algorithm m_hash_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::ZstdDefaultCompressionWithChecksum, - const ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib) + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression, + const ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64) { if (name == "amort") { - return std::make_unique>(dimensions, compression_algorithm, checksum_algorithm); + return std::make_unique>(dimensions, compression_algorithm, hash_algorithm); } return Error::fail(Error::Code::Unsupported, "unsupported raster codec selector: " + std::string(name)); } diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index 995e81c4..bf0a1058 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -29,8 +29,9 @@ struct CreateOptions { unsigned halo_width = 0; std::optional value_mapping = std::nullopt; std::string codec_selector = "amort"; - ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompressionWithChecksum; - ::io::envelope::ChecksumAlgorithm checksum_algorithm = ::io::envelope::ChecksumAlgorithm::HandledByCompressionLib; + ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression; + // Tile hashes serve as content identities, for example for TB dependency records; with None, tiles have none. + ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64; std::optional copy_attribution_from_index; }; @@ -118,7 +119,7 @@ Expected>, std::unique_ptr( - options.codec_selector, glm::uvec2(metadata->stored_tile_size), options.compression_algorithm, options.checksum_algorithm); + options.codec_selector, glm::uvec2(metadata->stored_tile_size), options.compression_algorithm, options.hash_algorithm); if (!codec) { return Error::propagate(std::move(codec)); } diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 93a4cbc4..ade3fcea 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(unittests_terrainlib 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 diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 9f6452ca..2b628441 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -1,4 +1,5 @@ #include "../catch2_helpers.h" +#include "../temporary_directory.h" #include "io/envelope.h" @@ -9,6 +10,7 @@ #include #include #include +#include namespace { @@ -71,8 +73,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 +97,17 @@ static_assert(std::same_as); static_assert(std::same_as, v1::Payload>); template -io::envelope::Bytes encode_value(const Value& value) +std::vector encode_value(const Value& value) { - io::envelope::Bytes bytes; + std::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); } +std::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 std::vector& bytes) { io::envelope::Envelope envelope {}; zpp::bits::in input(bytes); @@ -102,7 +115,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. +std::vector compress_without_content_size(const std::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 +124,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())); + std::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 +141,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 std::span bytes) { REQUIRE(io::write_bytes_to_path(bytes, path)); } } // namespace @@ -152,13 +158,13 @@ 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()); @@ -223,7 +229,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, @@ -231,183 +237,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 std::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 std::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); } } @@ -420,72 +366,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); @@ -494,28 +452,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), static_cast(envelope.header.uncompressed_size - 1)), Error::Code::ResourceExhausted); } } @@ -523,20 +481,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); } } @@ -545,22 +503,141 @@ TEST_CASE("Envelope reports malformed serialized data") { SECTION("envelope") { - const io::envelope::Bytes malformed { std::byte { 0x01 }, std::byte { 0x02 } }; + const std::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_to_path(payload, path)); + 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_from_path(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 = std::vector(io::envelope::max_hash_size, std::byte { 0x5a }); + envelope.compressed_data = std::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); } +} diff --git a/unittests/terrainlib/io_hash.cpp b/unittests/terrainlib/io_hash.cpp new file mode 100644 index 00000000..9d51f5d3 --- /dev/null +++ b/unittests/terrainlib/io_hash.cpp @@ -0,0 +1,71 @@ +#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. +std::vector sanity_buffer(const std::size_t size) +{ + std::vector buffer(size); + std::uint64_t generator = 2654435761U; + for (auto& byte : buffer) { + byte = std::byte(generator >> 56); + generator *= 11400714785074694797ULL; + } + return buffer; +} + +std::vector big_endian(const std::uint64_t value) +{ + std::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 index 3687b6c6..9146cc12 100644 --- a/unittests/terrainlib/io_image.cpp +++ b/unittests/terrainlib/io_image.cpp @@ -21,6 +21,8 @@ image::RGBA8 colours() result.pixel({ 1, 1 }) = { 17, 31, 47, 0 }; return result; } + +cv::Mat encoded_mat(const std::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]") @@ -33,7 +35,7 @@ TEST_CASE("PNG preserves RGBA including hidden colour and row order", "[io-image 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(*bytes, cv::IMREAD_UNCHANGED); + 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)); @@ -49,8 +51,8 @@ TEST_CASE("PNG preserves RGBA including hidden colour and row order", "[io-image 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(grey_bytes); - const auto grey_rgba = image::decode_rgba8(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)); @@ -83,7 +85,7 @@ TEST_CASE("PNG compression and JPEG quality options reach encoders", "[io-image] source.fill({ 255, 0, 0 }); const auto jpeg = image::encode(source, image::Format::Jpeg); REQUIRE(jpeg); - const auto reference = cv::imdecode(*jpeg, cv::IMREAD_COLOR); + 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); @@ -103,7 +105,7 @@ TEST_CASE("image I/O rejects invalid inputs without silent alpha loss", "[io-ima CHECK_FALSE(image::encode(source, image::Format::Png, { .jpeg_quality = 101 })); CHECK_FALSE(image::encode(image::RGB8 {}, image::Format::Png)); CHECK_FALSE(image::decode_rgb8({})); - const std::array junk { 1, 2, 3, 4 }; + 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); } @@ -140,11 +142,11 @@ TEST_CASE("byte writer retains legacy overwrite and supports exclusive creation" { test::TemporaryDirectory directory; const auto path = directory.path() / "bytes"; - const std::array first { 1, 2, 3 }; - const std::array second { 4, 5 }; + 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::write_bytes_to_path(first, path, io::WriteMode::CreateNew)); CHECK(io::write_bytes_to_path(second, path, io::WriteMode::CreateNew).error().code() == Error::Code::AlreadyExists); - CHECK(*io::read_bytes_from_path(path) == std::vector(first.begin(), first.end())); + CHECK(*io::read_bytes_from_path(path) == std::vector(first.begin(), first.end())); REQUIRE(io::write_bytes_to_path(second, path)); - CHECK(*io::read_bytes_from_path(path) == std::vector(second.begin(), second.end())); + CHECK(*io::read_bytes_from_path(path) == std::vector(second.begin(), second.end())); } diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 09c74309..33b4c553 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -29,7 +29,7 @@ constexpr std::string_view entity_json = void write_text(const std::filesystem::path& path, const std::string_view text) { - REQUIRE(io::write_bytes_to_path(std::span(reinterpret_cast(text.data()), text.size()), path)); + REQUIRE(io::write_bytes_to_path(std::as_bytes(std::span(text)), path)); } void write_table(const std::filesystem::path& directory) @@ -139,7 +139,7 @@ TEMPLATE_TEST_CASE("AMORT preserves native scalar and packed vector bytes", "[ra check_bytes(read->data, tile.data); check_bytes(read->source_attribution, tile.source_attribution); - TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::envelope::ChecksumAlgorithm::Crc32c); + TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::Xxh3_64); REQUIRE(uncompressed.write(path, tile)); read = writer.read(path); REQUIRE(read); @@ -173,7 +173,7 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra TileCodec rectangular({ 3, 2 }); CHECK(rectangular.write(path, tile).error().code() == Error::Code::InvalidInput); - tile_codec::detail::v1::RasterTile encoded { 3, 3, io::envelope::Bytes(36), io::envelope::Bytes(18) }; + tile_codec::detail::v1::RasterTile encoded { 3, 3, std::vector(36), std::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 }); } @@ -392,7 +392,7 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" TEST_CASE("byte writes report buffered device failures", "[io][bytes][raster-store]") { #ifdef __linux__ - const std::array bytes { 42 }; + const std::array bytes { std::byte { 42 } }; auto written = io::write_bytes_to_path(bytes, "/dev/full", false); REQUIRE_FALSE(written); CHECK(written.error().code() == Error::Code::Io); @@ -498,16 +498,16 @@ TEST_CASE("AMORT propagates envelope checksum and version errors", "[raster-stor TileCodec amort({ 3, 3 }); REQUIRE(amort.write(path, raster_store::Tile(3))); const auto bytes = io::read_bytes_from_path(payload_path).value(); - auto envelope = io::envelope::detail::deserialize_from_bytes(std::as_bytes(std::span(bytes))).value(); + auto envelope = io::envelope::detail::deserialize_from_bytes(bytes).value(); auto expected_error = Error::Code::CorruptData; - SECTION("checksum") { envelope.compressed_data.back() ^= std::byte { 1 }; } + SECTION("hash") { envelope.compressed_data.back() ^= std::byte { 1 }; } SECTION("version") { - envelope.class_version = 999; + envelope.header.class_version = 999; expected_error = Error::Code::Unsupported; } const auto modified = io::envelope::detail::serialize_to_bytes(envelope).value(); - REQUIRE(io::write_bytes_to_path(std::span(reinterpret_cast(modified.data()), modified.size()), payload_path)); + REQUIRE(io::write_bytes_to_path(modified, payload_path)); CHECK(amort.read(path).error().code() == expected_error); } diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 7579354e..8db1439e 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -231,7 +231,7 @@ 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 }; + const std::array malformed_bytes { std::byte { 0xff }, std::byte { 0x00 }, std::byte { 0x01 } }; REQUIRE(io::write_bytes_to_path(malformed_bytes, index_path).has_value()); const auto malformed = mesh::storage::open_folder_indexed(directory.path()); REQUIRE_FALSE(malformed.has_value()); diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index 81220562..18b6981f 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -75,7 +75,7 @@ Expected read_int(const std::filesystem::path& path) Expected write_int(const int value, const std::filesystem::path& path) { - const auto bytes = std::span(reinterpret_cast(&value), sizeof(value)); + const auto bytes = std::as_bytes(std::span(&value, 1)); auto written = io::write_bytes_to_path(bytes, path); if (!written) { return Error::propagate(std::move(written), "write test integer"); @@ -227,7 +227,7 @@ TEST_CASE("byte writes report directory creation errors", "[io][bytes]") 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::write_bytes_to_path(std::span {}, blocker / "payload"); REQUIRE_FALSE(result.has_value()); CHECK(result.error().code() == Error::Code::Io); } From 1a7488bf4c73380679c53cde080ef037106be461 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:11:36 +0200 Subject: [PATCH 31/83] fix copyrights --- src/CMakeLists.txt | 20 +++++++++++++++++++ src/dag_builder/CMakeLists.txt | 19 ++++++++++++++++++ src/dag_builder/ContinuationMode.h | 18 +++++++++++++++++ src/dag_builder/Partitioning.h | 18 +++++++++++++++++ src/dag_builder/TextureSet.h | 19 ++++++++++++++++++ src/dag_builder/VertexInCluster.h | 18 +++++++++++++++++ src/dag_builder/VertexInClustering.h | 18 +++++++++++++++++ src/dag_builder/atlas/BakeSource.h | 18 +++++++++++++++++ src/dag_builder/atlas/bake_cluster_texture.h | 18 +++++++++++++++++ .../atlas/pull_reproject_texture.h | 18 +++++++++++++++++ .../atlas/rect/HorizontalStripPlanner.cpp | 18 +++++++++++++++++ .../atlas/rect/HorizontalStripPlanner.h | 18 +++++++++++++++++ src/dag_builder/atlas/rect/PackingPlanner.cpp | 18 +++++++++++++++++ src/dag_builder/atlas/rect/PackingPlanner.h | 18 +++++++++++++++++ src/dag_builder/atlas/rect/Plan.h | 18 +++++++++++++++++ src/dag_builder/atlas/rect/Planner.h | 18 +++++++++++++++++ src/dag_builder/atlas/rect/atlas.cpp | 19 ++++++++++++++++++ src/dag_builder/atlas/rect/atlas.h | 18 +++++++++++++++++ src/dag_builder/atlas/rect/create.h | 18 +++++++++++++++++ src/dag_builder/atlas/reprojection.h | 18 +++++++++++++++++ src/dag_builder/build.cpp | 19 ++++++++++++++++++ src/dag_builder/build.h | 19 ++++++++++++++++++ src/dag_builder/centroids.h | 18 +++++++++++++++++ src/dag_builder/cli.cpp | 19 ++++++++++++++++++ src/dag_builder/cli.h | 18 +++++++++++++++++ src/dag_builder/cluster.h | 18 +++++++++++++++++ src/dag_builder/clusterize.h | 18 +++++++++++++++++ src/dag_builder/codec.cpp | 18 +++++++++++++++++ src/dag_builder/codec.h | 18 +++++++++++++++++ src/dag_builder/compact.h | 18 +++++++++++++++++ src/dag_builder/correspondence.h | 18 +++++++++++++++++ src/dag_builder/dag_id.h | 19 ++++++++++++++++++ src/dag_builder/dag_node.h | 19 ++++++++++++++++++ src/dag_builder/fixed.h | 18 +++++++++++++++++ src/dag_builder/fixed_point.h | 18 +++++++++++++++++ src/dag_builder/main.cpp | 19 ++++++++++++++++++ src/dag_builder/merge/clusterings.cpp | 19 ++++++++++++++++++ src/dag_builder/merge/clusterings.h | 18 +++++++++++++++++ src/dag_builder/merge/clusters.h | 19 ++++++++++++++++++ src/dag_builder/merge/weld.h | 18 +++++++++++++++++ src/dag_builder/meshopt.h | 18 +++++++++++++++++ src/dag_builder/metadata.h | 19 ++++++++++++++++++ src/dag_builder/optimize.h | 18 +++++++++++++++++ src/dag_builder/parallel.h | 18 +++++++++++++++++ src/dag_builder/partition.h | 18 +++++++++++++++++ src/dag_builder/simplify.h | 18 +++++++++++++++++ src/dag_builder/slice.h | 19 ++++++++++++++++++ src/dag_builder/split.h | 18 +++++++++++++++++ src/dag_builder/storage.h | 19 ++++++++++++++++++ src/dag_builder/texture_reuse.h | 18 +++++++++++++++++ src/dag_builder/texture_sizing.h | 18 +++++++++++++++++ src/dag_builder/texturing.h | 18 +++++++++++++++++ src/dag_builder/utils.h | 18 +++++++++++++++++ src/dag_builder/validate.h | 18 +++++++++++++++++ src/dag_builder/vertex_lock.h | 19 ++++++++++++++++++ src/dag_convert_debug/CMakeLists.txt | 18 +++++++++++++++++ src/dag_convert_debug/cli.cpp | 18 +++++++++++++++++ src/dag_convert_debug/cli.h | 18 +++++++++++++++++ src/dag_convert_debug/main.cpp | 19 ++++++++++++++++++ src/mesh_convert/CMakeLists.txt | 18 +++++++++++++++++ src/mesh_convert/cli.cpp | 18 +++++++++++++++++ src/mesh_convert/cli.h | 18 +++++++++++++++++ src/mesh_convert/main.cpp | 19 ++++++++++++++++++ src/sf_browser/CMakeLists.txt | 20 +++++++++++++++++++ src/sf_browser/core/Application.cpp | 20 +++++++++++++++++++ src/sf_browser/core/Application.h | 19 ++++++++++++++++++ src/sf_browser/core/Buffer.cpp | 19 ++++++++++++++++++ src/sf_browser/core/Buffer.h | 19 ++++++++++++++++++ src/sf_browser/core/Camera.cpp | 19 ++++++++++++++++++ src/sf_browser/core/Camera.h | 19 ++++++++++++++++++ src/sf_browser/core/geometry/UnitCube.h | 19 ++++++++++++++++++ .../core/rendering/OctreeRenderManager.cpp | 19 ++++++++++++++++++ .../core/rendering/OctreeRenderManager.h | 20 +++++++++++++++++++ .../core/shader/GLUniformAbstractions.h | 19 ++++++++++++++++++ src/sf_browser/core/shader/Shader.cpp | 19 ++++++++++++++++++ src/sf_browser/core/shader/Shader.h | 19 ++++++++++++++++++ src/sf_browser/core/shader/ShaderProgram.cpp | 19 ++++++++++++++++++ src/sf_browser/core/shader/ShaderProgram.h | 19 ++++++++++++++++++ src/sf_browser/core/shader/Uniform.h | 19 ++++++++++++++++++ src/sf_browser/core/window/Window.cpp | 19 ++++++++++++++++++ src/sf_browser/core/window/Window.h | 19 ++++++++++++++++++ src/sf_browser/main.cpp | 19 ++++++++++++++++++ src/sf_browser/res/CMakeLists.txt | 19 ++++++++++++++++++ src/sf_browser/res/shaders/octree_lines.frag | 19 ++++++++++++++++++ src/sf_browser/res/shaders/octree_lines.vert | 19 ++++++++++++++++++ src/sf_builder/CMakeLists.txt | 18 +++++++++++++++++ src/sf_builder/border.h | 19 ++++++++++++++++++ src/sf_builder/main.cpp | 19 ++++++++++++++++++ src/sf_builder/mesh_builder.cpp | 19 ++++++++++++++++++ src/sf_builder/mesh_builder.h | 19 ++++++++++++++++++ src/sf_builder/raw_dataset_reader.h | 19 ++++++++++++++++++ src/sf_builder/terrainbuilder.cpp | 19 ++++++++++++++++++ src/sf_builder/terrainbuilder.h | 19 ++++++++++++++++++ src/sf_builder/texture_assembler.h | 19 ++++++++++++++++++ src/sf_builder/tile_provider.h | 19 ++++++++++++++++++ src/sf_index_browser/CMakeLists.txt | 18 +++++++++++++++++ src/sf_index_browser/cli.cpp | 19 ++++++++++++++++++ src/sf_index_browser/cli.h | 19 ++++++++++++++++++ src/sf_index_browser/main.cpp | 19 ++++++++++++++++++ src/sf_merger/CMakeLists.txt | 18 +++++++++++++++++ src/sf_merger/NodeLoader.h | 19 ++++++++++++++++++ src/sf_merger/NodeWriter.h | 19 ++++++++++++++++++ .../atlas/HorizontalStripLayoutPlanner.h | 18 +++++++++++++++++ src/sf_merger/atlas/Layout.h | 18 +++++++++++++++++ src/sf_merger/atlas/LayoutPlanner.h | 18 +++++++++++++++++ src/sf_merger/atlas/Sheet.h | 18 +++++++++++++++++ src/sf_merger/atlas/build.h | 18 +++++++++++++++++ src/sf_merger/cli.cpp | 18 +++++++++++++++++ src/sf_merger/cli.h | 18 +++++++++++++++++ src/sf_merger/cut.h | 19 ++++++++++++++++++ src/sf_merger/earth.h | 18 +++++++++++++++++ src/sf_merger/main.cpp | 20 +++++++++++++++++++ src/sf_merger/mask.h | 19 ++++++++++++++++++ src/sf_merger/merge.h | 19 ++++++++++++++++++ src/sf_merger/merge/NodeData.h | 19 ++++++++++++++++++ src/sf_merger/merge/Result.h | 18 +++++++++++++++++ src/sf_merger/merge/visitor/Masked.h | 19 ++++++++++++++++++ src/sf_merger/merge/visitor/Simple.h | 19 ++++++++++++++++++ src/sf_merger/merge/visitor/Visitor.h | 19 ++++++++++++++++++ src/sf_merger/utils.h | 19 ++++++++++++++++++ src/terrainlib/CMakeLists.txt | 19 ++++++++++++++++++ src/terrainlib/Dataset.cpp | 6 +++--- src/terrainlib/Dataset.h | 6 +++--- src/terrainlib/DatasetReader.cpp | 3 ++- src/terrainlib/DatasetReader.h | 6 +++--- src/terrainlib/Error.cpp | 18 +++++++++++++++++ src/terrainlib/Error.h | 18 +++++++++++++++++ src/terrainlib/ImageKey.h | 18 +++++++++++++++++ src/terrainlib/ProgressIndicator.cpp | 6 +++--- src/terrainlib/ProgressIndicator.h | 6 +++--- src/terrainlib/Range.h | 18 +++++++++++++++++ src/terrainlib/RasterTransform.cpp | 18 +++++++++++++++++ src/terrainlib/RasterTransform.h | 18 +++++++++++++++++ src/terrainlib/SphereProjector.h | 18 +++++++++++++++++ src/terrainlib/VecHash.h | 18 +++++++++++++++++ src/terrainlib/VecRange.h | 18 +++++++++++++++++ src/terrainlib/array_utils.h | 18 +++++++++++++++++ src/terrainlib/build_config.h | 19 ++++++++++++++++++ src/terrainlib/containers/Buffer.h | 18 +++++++++++++++++ src/terrainlib/containers/Cow.h | 18 +++++++++++++++++ src/terrainlib/containers/FixedVector.h | 18 +++++++++++++++++ .../containers/HybridIndexPairMap.h | 19 ++++++++++++++++++ src/terrainlib/containers/HybridVector.h | 18 +++++++++++++++++ src/terrainlib/containers/OffsetTable.h | 18 +++++++++++++++++ src/terrainlib/containers/OffsetVector.h | 18 +++++++++++++++++ src/terrainlib/containers/SegmentedBuffer.h | 18 +++++++++++++++++ src/terrainlib/containers/Size.h | 18 +++++++++++++++++ src/terrainlib/containers/StampSet.h | 18 +++++++++++++++++ src/terrainlib/containers/ThreadLocalCache.h | 18 +++++++++++++++++ src/terrainlib/containers/TinyVector.h | 18 +++++++++++++++++ src/terrainlib/containers/UnionFind.h | 18 +++++++++++++++++ src/terrainlib/containers/Vector2D.h | 18 +++++++++++++++++ src/terrainlib/ctb/CTBException.hpp | 1 + src/terrainlib/ctb/GlobalGeodetic.cpp | 2 ++ src/terrainlib/ctb/GlobalGeodetic.hpp | 2 ++ src/terrainlib/ctb/GlobalMercator.cpp | 2 ++ src/terrainlib/ctb/GlobalMercator.hpp | 2 ++ src/terrainlib/ctb/Grid.hpp | 3 ++- src/terrainlib/ctb/TileCoordinate.hpp | 2 +- src/terrainlib/ctb/types.hpp | 2 +- src/terrainlib/enumerate.h | 18 +++++++++++++++++ src/terrainlib/fmt_impls.h | 18 +++++++++++++++++ src/terrainlib/geometry/CoordFrame.h | 18 +++++++++++++++++ src/terrainlib/geometry/PlaneFrame.h | 18 +++++++++++++++++ src/terrainlib/geometry/Rect.h | 18 +++++++++++++++++ src/terrainlib/geometry/geometry.h | 18 +++++++++++++++++ src/terrainlib/geometry/geometry.inl | 18 +++++++++++++++++ src/terrainlib/glm_utils.h | 18 +++++++++++++++++ src/terrainlib/hash_utils.h | 18 +++++++++++++++++ src/terrainlib/init.cpp | 19 ++++++++++++++++++ src/terrainlib/init.h | 19 ++++++++++++++++++ src/terrainlib/io/bytes.cpp | 19 ++++++++++++++++++ src/terrainlib/io/bytes.h | 19 ++++++++++++++++++ src/terrainlib/io/compression.cpp | 18 +++++++++++++++++ src/terrainlib/io/compression.h | 18 +++++++++++++++++ src/terrainlib/io/conversion.h | 18 +++++++++++++++++ src/terrainlib/io/envelope.cpp | 18 +++++++++++++++++ src/terrainlib/io/envelope.h | 18 +++++++++++++++++ src/terrainlib/io/glm_serialization.h | 18 +++++++++++++++++ src/terrainlib/io/hash.cpp | 18 +++++++++++++++++ src/terrainlib/io/hash.h | 18 +++++++++++++++++ src/terrainlib/io/image.cpp | 18 +++++++++++++++++ src/terrainlib/io/image.h | 18 +++++++++++++++++ src/terrainlib/io/utils.cpp | 19 ++++++++++++++++++ src/terrainlib/io/utils.h | 19 ++++++++++++++++++ src/terrainlib/log.cpp | 18 +++++++++++++++++ src/terrainlib/log.h | 18 +++++++++++++++++ src/terrainlib/macros.h | 18 +++++++++++++++++ src/terrainlib/mesh/EncodedMesh.h | 19 ++++++++++++++++++ src/terrainlib/mesh/PointWithMeta.h | 18 +++++++++++++++++ src/terrainlib/mesh/SimpleMesh.h | 18 +++++++++++++++++ src/terrainlib/mesh/TriangleContainer.h | 18 +++++++++++++++++ src/terrainlib/mesh/TriangleSoup.h | 18 +++++++++++++++++ src/terrainlib/mesh/TriangleSoup.inl | 18 +++++++++++++++++ src/terrainlib/mesh/VertexMap.h | 18 +++++++++++++++++ src/terrainlib/mesh/View.h | 18 +++++++++++++++++ src/terrainlib/mesh/WindingOrder.h | 18 +++++++++++++++++ src/terrainlib/mesh/bfs.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/bfs.h | 18 +++++++++++++++++ src/terrainlib/mesh/boolean.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/boolean.h | 18 +++++++++++++++++ src/terrainlib/mesh/bounds.h | 18 +++++++++++++++++ src/terrainlib/mesh/bounds.inl | 18 +++++++++++++++++ src/terrainlib/mesh/cgal.h | 19 ++++++++++++++++++ src/terrainlib/mesh/cleanup.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/cleanup.h | 18 +++++++++++++++++ src/terrainlib/mesh/cleanup.inl | 18 +++++++++++++++++ src/terrainlib/mesh/clip.cpp | 19 ++++++++++++++++++ src/terrainlib/mesh/clip.h | 18 +++++++++++++++++ src/terrainlib/mesh/codec/Gltf.h | 18 +++++++++++++++++ src/terrainlib/mesh/codec/SfMesh.h | 18 +++++++++++++++++ src/terrainlib/mesh/codec/SfMeshFormat.h | 18 +++++++++++++++++ src/terrainlib/mesh/codec/from_extension.h | 18 +++++++++++++++++ src/terrainlib/mesh/combine.h | 18 +++++++++++++++++ .../mesh/connectivity/adjacency.cpp | 19 ++++++++++++++++++ src/terrainlib/mesh/connectivity/adjacency.h | 18 +++++++++++++++++ .../mesh/connectivity/adjacency.inl | 19 ++++++++++++++++++ src/terrainlib/mesh/connectivity/boundary.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/boundary.h | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/boundary.inl | 18 +++++++++++++++++ .../mesh/connectivity/connected_components.h | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/edges.h | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/edges.inl | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/manifold.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/manifold.h | 19 ++++++++++++++++++ src/terrainlib/mesh/connectivity/manifold.inl | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/topology.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/connectivity/topology.h | 18 +++++++++++++++++ .../mesh/connectivity/triangle_compare.h | 18 +++++++++++++++++ .../mesh/connectivity/triangle_compare.inl | 18 +++++++++++++++++ .../mesh/connectivity/vertex_index_range.cpp | 18 +++++++++++++++++ .../mesh/connectivity/vertex_index_range.h | 18 +++++++++++++++++ src/terrainlib/mesh/convert.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/convert.h | 18 +++++++++++++++++ src/terrainlib/mesh/encode.h | 19 ++++++++++++++++++ src/terrainlib/mesh/geometry.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/geometry.h | 18 +++++++++++++++++ src/terrainlib/mesh/geometry.inl | 18 +++++++++++++++++ src/terrainlib/mesh/holes.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/holes.h | 18 +++++++++++++++++ src/terrainlib/mesh/igl/convert.h | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut.h | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut.inl | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut_to_disk.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut_to_disk.h | 18 +++++++++++++++++ src/terrainlib/mesh/igl/cut_to_disk.inl | 19 ++++++++++++++++++ src/terrainlib/mesh/igl/igl.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/igl/manifold.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/igl/manifold.h | 18 +++++++++++++++++ src/terrainlib/mesh/igl/orient.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/igl/orient.h | 18 +++++++++++++++++ src/terrainlib/mesh/io.cpp | 19 ++++++++++++++++++ src/terrainlib/mesh/io.h | 19 ++++++++++++++++++ src/terrainlib/mesh/io/gltf.cpp | 20 +++++++++++++++++++ src/terrainlib/mesh/io/gltf.h | 19 ++++++++++++++++++ src/terrainlib/mesh/io/options.h | 19 ++++++++++++++++++ src/terrainlib/mesh/io/texture.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/io/texture.h | 19 ++++++++++++++++++ src/terrainlib/mesh/io/utils.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/io/utils.h | 18 +++++++++++++++++ src/terrainlib/mesh/merge.h | 19 ++++++++++++++++++ .../mesh/merging/EpsilonVertexDeduplicate.h | 18 +++++++++++++++++ .../mesh/merging/ExactVertexDeduplicate.h | 18 +++++++++++++++++ .../SphereProjectionVertexDeduplicate.h | 18 +++++++++++++++++ .../mesh/merging/VertexDeduplicate.h | 18 +++++++++++++++++ src/terrainlib/mesh/merging/VertexId.h | 18 +++++++++++++++++ src/terrainlib/mesh/merging/VertexMapping.h | 19 ++++++++++++++++++ src/terrainlib/mesh/merging/helpers.h | 18 +++++++++++++++++ src/terrainlib/mesh/merging/mapping.cpp | 19 ++++++++++++++++++ src/terrainlib/mesh/merging/mapping.h | 18 +++++++++++++++++ src/terrainlib/mesh/normalize.h | 18 +++++++++++++++++ src/terrainlib/mesh/normalize.inl | 18 +++++++++++++++++ src/terrainlib/mesh/reindex.h | 18 +++++++++++++++++ src/terrainlib/mesh/reindex.inl | 18 +++++++++++++++++ src/terrainlib/mesh/split.h | 18 +++++++++++++++++ src/terrainlib/mesh/split.inl | 19 ++++++++++++++++++ src/terrainlib/mesh/storage.h | 18 +++++++++++++++++ src/terrainlib/mesh/texture_trim.cpp | 18 +++++++++++++++++ src/terrainlib/mesh/texture_trim.h | 19 ++++++++++++++++++ src/terrainlib/mesh/utils.h | 18 +++++++++++++++++ src/terrainlib/mesh/validate.cpp | 19 ++++++++++++++++++ src/terrainlib/mesh/validate.h | 18 +++++++++++++++++ src/terrainlib/mesh/validate.inl | 18 +++++++++++++++++ src/terrainlib/mesh/validate_cgal.h | 18 +++++++++++++++++ src/terrainlib/mesh/validate_cgal.inl | 18 +++++++++++++++++ src/terrainlib/numeric/StrongDouble.h | 18 +++++++++++++++++ src/terrainlib/numeric/glm_math.h | 18 +++++++++++++++++ src/terrainlib/numeric/int_math.h | 18 +++++++++++++++++ src/terrainlib/numeric/number_types.h | 18 +++++++++++++++++ src/terrainlib/numeric/number_utils.h | 18 +++++++++++++++++ src/terrainlib/numeric/quantize.h | 18 +++++++++++++++++ src/terrainlib/numeric/wide_integer.h | 18 +++++++++++++++++ src/terrainlib/octree/Id.h | 19 ++++++++++++++++++ src/terrainlib/octree/IdRect.h | 18 +++++++++++++++++ src/terrainlib/octree/OddLevelShifted.cpp | 18 +++++++++++++++++ src/terrainlib/octree/OddLevelShifted.h | 18 +++++++++++++++++ src/terrainlib/octree/Space.cpp | 18 +++++++++++++++++ src/terrainlib/octree/Space.h | 18 +++++++++++++++++ src/terrainlib/octree/StoreTraits.h | 18 +++++++++++++++++ src/terrainlib/octree/storage/IndexFile.h | 18 +++++++++++++++++ src/terrainlib/octree/storage/open_runtime.h | 18 +++++++++++++++++ src/terrainlib/octree/store_layout/Flat.h | 18 +++++++++++++++++ .../LevelAndCoordinateDirectories.h | 18 +++++++++++++++++ src/terrainlib/octree/store_layout/Mappings.h | 18 +++++++++++++++++ src/terrainlib/octree/utils.h | 18 +++++++++++++++++ src/terrainlib/opencv_utils.h | 18 +++++++++++++++++ src/terrainlib/optional_utils.h | 18 +++++++++++++++++ src/terrainlib/pch.h | 19 ++++++++++++++++++ src/terrainlib/polygon/Polygon.h | 18 +++++++++++++++++ src/terrainlib/polygon/clip.cpp | 19 ++++++++++++++++++ src/terrainlib/polygon/clip.h | 18 +++++++++++++++++ src/terrainlib/polygon/triangulate.cpp | 18 +++++++++++++++++ src/terrainlib/polygon/triangulate.h | 18 +++++++++++++++++ src/terrainlib/polygon/utils.cpp | 18 +++++++++++++++++ src/terrainlib/polygon/utils.h | 18 +++++++++++++++++ src/terrainlib/range/AnyRange.h | 18 +++++++++++++++++ src/terrainlib/range/Bound.h | 18 +++++++++++++++++ src/terrainlib/range/Range.h | 18 +++++++++++++++++ src/terrainlib/range/RangeBounds.h | 18 +++++++++++++++++ src/terrainlib/range/RangeFrom.h | 18 +++++++++++++++++ src/terrainlib/range/RangeFull.h | 18 +++++++++++++++++ src/terrainlib/range/RangeInclusive.h | 18 +++++++++++++++++ src/terrainlib/range/RangeTo.h | 18 +++++++++++++++++ src/terrainlib/range/RangeToInclusive.h | 18 +++++++++++++++++ src/terrainlib/range_utils.h | 18 +++++++++++++++++ src/terrainlib/raster/ClampedView.h | 18 +++++++++++++++++ src/terrainlib/raster/View.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/conversion.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/copy.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/detail.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/fold.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/reduce.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/reducers.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/scale.h | 18 +++++++++++++++++ .../raster/algorithm/scaling_detail.h | 18 +++++++++++++++++ src/terrainlib/raster/algorithm/transform.h | 18 +++++++++++++++++ .../raster/algorithm/window_transform.h | 18 +++++++++++++++++ .../raster/algorithm/zip_transform.h | 18 +++++++++++++++++ src/terrainlib/raster_store/StoreTraits.h | 18 +++++++++++++++++ src/terrainlib/raster_store/Tile.h | 18 +++++++++++++++++ src/terrainlib/raster_store/TilePool.h | 18 +++++++++++++++++ src/terrainlib/raster_store/attribution.cpp | 18 +++++++++++++++++ src/terrainlib/raster_store/attribution.h | 18 +++++++++++++++++ src/terrainlib/raster_store/io/TileCodec.h | 18 +++++++++++++++++ src/terrainlib/raster_store/io/manifest.cpp | 18 +++++++++++++++++ src/terrainlib/raster_store/io/manifest.h | 18 +++++++++++++++++ src/terrainlib/raster_store/path_layout.h | 18 +++++++++++++++++ src/terrainlib/raster_store/pixel.h | 18 +++++++++++++++++ .../raster_store/read_tile_with_halo.h | 18 +++++++++++++++++ src/terrainlib/raster_store/scaler.h | 18 +++++++++++++++++ src/terrainlib/raster_store/storage.h | 18 +++++++++++++++++ src/terrainlib/sf/finalize_storage.h | 18 +++++++++++++++++ src/terrainlib/sf/validate_index.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/CellBased.h | 18 +++++++++++++++++ .../spatial_lookup/CellBasedStorage.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/Grid.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/GridStorage.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/Hashmap.h | 18 +++++++++++++++++ .../spatial_lookup/HashmapStorage.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/NDLoopHelper.h | 18 +++++++++++++++++ src/terrainlib/spatial_lookup/QuantizedHash.h | 19 ++++++++++++++++++ src/terrainlib/spatial_lookup/SpatialLookup.h | 18 +++++++++++++++++ src/terrainlib/srs.h | 3 ++- src/terrainlib/store/Codec.h | 18 +++++++++++++++++ src/terrainlib/store/Index.h | 18 +++++++++++++++++ src/terrainlib/store/IndexFormat.h | 18 +++++++++++++++++ src/terrainlib/store/IndexedStorage.h | 18 +++++++++++++++++ src/terrainlib/store/NodeStatus.h | 18 +++++++++++++++++ src/terrainlib/store/NodeStatusOrMissing.h | 18 +++++++++++++++++ src/terrainlib/store/RawStorage.h | 18 +++++++++++++++++ src/terrainlib/store/Storage.h | 18 +++++++++++++++++ src/terrainlib/store/ThreadSafeStorage.h | 18 +++++++++++++++++ src/terrainlib/store/Traits.h | 18 +++++++++++++++++ src/terrainlib/store/cache/Dummy.h | 18 +++++++++++++++++ src/terrainlib/store/cache/Interface.h | 18 +++++++++++++++++ src/terrainlib/store/cache/Lru.h | 18 +++++++++++++++++ src/terrainlib/store/open.h | 18 +++++++++++++++++ src/terrainlib/store/path_layout.h | 18 +++++++++++++++++ src/terrainlib/store/traverse.h | 18 +++++++++++++++++ src/terrainlib/string_utils.h | 19 ++++++++++++++++++ src/terrainlib/texture/Mask.h | 18 +++++++++++++++++ src/terrainlib/texture/dilate_colors.cpp | 18 +++++++++++++++++ src/terrainlib/texture/dilate_colors.h | 18 +++++++++++++++++ src/terrainlib/tile_path.h | 18 +++++++++++++++++ src/terrainlib/type_utils.h | 18 +++++++++++++++++ src/terrainlib/uv/atlas.cpp | 18 +++++++++++++++++ src/terrainlib/uv/atlas.h | 18 +++++++++++++++++ src/terrainlib/uv/fit.h | 18 +++++++++++++++++ src/terrainlib/uv/unwrap.cpp | 19 ++++++++++++++++++ src/terrainlib/uv/unwrap.h | 19 ++++++++++++++++++ src/terrainlib/variant_utils.h | 18 +++++++++++++++++ src/terrainlib/vector_mask.h | 18 +++++++++++++++++ src/terrainlib/vector_utils.h | 18 +++++++++++++++++ src/tile_builder/CMakeLists.txt | 18 +++++++++++++++++ src/tile_builder/Exception.h | 6 +++--- src/tile_builder/ParallelTileGenerator.cpp | 5 +++-- src/tile_builder/ParallelTileGenerator.h | 5 +++-- src/tile_builder/ParallelTiler.cpp | 6 +++--- src/tile_builder/ParallelTiler.h | 6 +++--- src/tile_builder/TileHeightsGenerator.cpp | 5 +++-- src/tile_builder/TileHeightsGenerator.h | 5 +++-- src/tile_builder/Tiler.cpp | 5 +++-- src/tile_builder/Tiler.h | 5 +++-- src/tile_builder/TopDownTiler.cpp | 5 +++-- src/tile_builder/TopDownTiler.h | 5 +++-- src/tile_builder/alpine_raster.cpp | 5 +++-- src/tile_builder/alpine_raster.h | 6 +++--- src/tile_builder/depth_first_tile_traverser.h | 5 +++-- src/tile_builder/image_writer.cpp | 7 +++---- src/tile_builder/image_writer.h | 7 +++---- src/tile_builder/main.cpp | 19 ++++++++++++++++++ src/tile_downloader/CMakeLists.txt | 18 +++++++++++++++++ src/tile_downloader/HttpClient.h | 19 ++++++++++++++++++ src/tile_downloader/TileDownloader.h | 19 ++++++++++++++++++ src/tile_downloader/TileLogger.h | 19 ++++++++++++++++++ src/tile_downloader/TileUrlBuilder.h | 19 ++++++++++++++++++ src/tile_downloader/cli.cpp | 19 ++++++++++++++++++ src/tile_downloader/cli.h | 19 ++++++++++++++++++ src/tile_downloader/main.cpp | 20 +++++++++++++++++++ src/tile_downloader/write_file.h | 18 +++++++++++++++++ unittests/CMakeLists.txt | 19 ++++++++++++++++++ unittests/catch2_helpers.h | 6 +++--- .../dag_builder/bake_cluster_texture.cpp | 18 +++++++++++++++++ unittests/dag_builder/correspondence.cpp | 18 +++++++++++++++++ unittests/dag_builder/fixed_point.cpp | 18 +++++++++++++++++ unittests/dag_builder/merge_clusterings.cpp | 18 +++++++++++++++++ unittests/dag_builder/multi_component.cpp | 18 +++++++++++++++++ .../dag_builder/pull_reproject_texture.cpp | 18 +++++++++++++++++ unittests/dag_builder/reprojection.cpp | 18 +++++++++++++++++ unittests/dag_builder/slice.cpp | 18 +++++++++++++++++ .../dag_builder/storage_compatibility.cpp | 18 +++++++++++++++++ unittests/dag_builder/texture_reuse.cpp | 18 +++++++++++++++++ unittests/dag_builder/texture_sizing.cpp | 18 +++++++++++++++++ unittests/dag_builder/weld.cpp | 18 +++++++++++++++++ unittests/data/austria/gen_test_data.sh | 18 +++++++++++++++++ unittests/opencv_helpers.h | 18 +++++++++++++++++ unittests/sf_builder/mesh.cpp | 6 +++--- unittests/sf_builder/storage_finalization.cpp | 18 +++++++++++++++++ unittests/sf_builder/texture.cpp | 6 +++--- unittests/sf_merger/integration.cpp | 18 +++++++++++++++++ unittests/sf_merger/mask.cpp | 18 +++++++++++++++++ unittests/sf_merger/sphere_projector.cpp | 18 +++++++++++++++++ unittests/sf_merger/storage_boundaries.cpp | 18 +++++++++++++++++ unittests/temporary_directory.h | 18 +++++++++++++++++ unittests/terrainlib/convert.cpp | 19 ++++++++++++++++++ unittests/terrainlib/cow.cpp | 18 +++++++++++++++++ unittests/terrainlib/dataset.cpp | 6 +++--- unittests/terrainlib/enumerate.cpp | 18 +++++++++++++++++ unittests/terrainlib/envelope.cpp | 18 +++++++++++++++++ unittests/terrainlib/error.cpp | 18 +++++++++++++++++ unittests/terrainlib/fixed_vector.cpp | 19 ++++++++++++++++++ unittests/terrainlib/geometry_frames.cpp | 18 +++++++++++++++++ unittests/terrainlib/glm_serialization.cpp | 18 +++++++++++++++++ unittests/terrainlib/grid.cpp | 6 +++--- unittests/terrainlib/hash_utils.cpp | 19 ++++++++++++++++++ .../terrainlib/hybrid_index_pair_map.cpp | 18 +++++++++++++++++ unittests/terrainlib/hybrid_vector.cpp | 18 +++++++++++++++++ unittests/terrainlib/io_conversion.cpp | 18 +++++++++++++++++ unittests/terrainlib/io_hash.cpp | 18 +++++++++++++++++ unittests/terrainlib/io_image.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_boundary.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_bounds.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_cleanup.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_clip.cpp | 19 ++++++++++++++++++ unittests/terrainlib/mesh_combine.cpp | 19 ++++++++++++++++++ .../terrainlib/mesh_connected_components.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_cut.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_cut_to_disk.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_holes.cpp | 19 ++++++++++++++++++ unittests/terrainlib/mesh_io.cpp | 6 +++--- unittests/terrainlib/mesh_manifold.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_merge.cpp | 6 +++--- unittests/terrainlib/mesh_merge_mapping.cpp | 6 +++--- unittests/terrainlib/mesh_reindex.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_split.cpp | 18 +++++++++++++++++ unittests/terrainlib/mesh_topology.cpp | 18 +++++++++++++++++ unittests/terrainlib/octree_id.cpp | 18 +++++++++++++++++ .../terrainlib/octree_odd_level_shifted.cpp | 18 +++++++++++++++++ unittests/terrainlib/octree_space.cpp | 18 +++++++++++++++++ unittests/terrainlib/offset_table.cpp | 18 +++++++++++++++++ unittests/terrainlib/polygon_clip.cpp | 18 +++++++++++++++++ unittests/terrainlib/polygon_triangulate.cpp | 18 +++++++++++++++++ .../terrainlib/progress_indicator_test.cpp | 6 +++--- unittests/terrainlib/quantize.cpp | 18 +++++++++++++++++ unittests/terrainlib/range.cpp | 18 +++++++++++++++++ unittests/terrainlib/range_utils.cpp | 18 +++++++++++++++++ unittests/terrainlib/raster_algorithm.cpp | 18 +++++++++++++++++ unittests/terrainlib/raster_fold.cpp | 18 +++++++++++++++++ unittests/terrainlib/raster_halo.cpp | 18 +++++++++++++++++ unittests/terrainlib/raster_store.cpp | 18 +++++++++++++++++ unittests/terrainlib/raster_view.cpp | 18 +++++++++++++++++ unittests/terrainlib/segmented_buffer.cpp | 18 +++++++++++++++++ unittests/terrainlib/sf_validate_index.cpp | 18 +++++++++++++++++ unittests/terrainlib/spatial_lookup.cpp | 18 +++++++++++++++++ unittests/terrainlib/stamp_set.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_codec.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_compatibility.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_index.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_layout.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_storage.cpp | 18 +++++++++++++++++ unittests/terrainlib/store_traverse.cpp | 18 +++++++++++++++++ unittests/terrainlib/string_utils.cpp | 19 ++++++++++++++++++ .../terrainlib/texture_dilate_colors.cpp | 18 +++++++++++++++++ unittests/terrainlib/tiny_vector.cpp | 18 +++++++++++++++++ unittests/terrainlib/union_find.cpp | 18 +++++++++++++++++ unittests/terrainlib/uv_atlas.cpp | 18 +++++++++++++++++ unittests/tile_downloader/downloader.cpp | 18 +++++++++++++++++ unittests/tile_downloader/http_client.cpp | 18 +++++++++++++++++ unittests/tile_downloader/logger.cpp | 18 +++++++++++++++++ unittests/tile_downloader/url_builder.cpp | 18 +++++++++++++++++ unittests/tile_downloader/write_file.cpp | 18 +++++++++++++++++ .../tilebuilder/alpine_raster_format.cpp | 6 +++--- unittests/tilebuilder/dataset_reading.cpp | 6 +++--- .../depth_first_tile_traverser.cpp | 5 +++-- unittests/tilebuilder/image_writer.cpp | 5 ++--- .../tilebuilder/parallel_tile_generator.cpp | 5 +++-- unittests/tilebuilder/parallel_tiler.cpp | 6 +++--- .../tilebuilder/tile_heights_generator.cpp | 5 +++-- unittests/tilebuilder/top_down_tiler.cpp | 5 +++-- 521 files changed, 8766 insertions(+), 107 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6c50c4fd..d626a004 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}") diff --git a/src/dag_builder/CMakeLists.txt b/src/dag_builder/CMakeLists.txt index 5acf10af..9b8af87f 100644 --- a/src/dag_builder/CMakeLists.txt +++ b/src/dag_builder/CMakeLists.txt @@ -1,3 +1,22 @@ +# ***************************************************************************** +# 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 diff --git a/src/dag_builder/ContinuationMode.h b/src/dag_builder/ContinuationMode.h index 46a707ab..9f1fa4be 100644 --- a/src/dag_builder/ContinuationMode.h +++ b/src/dag_builder/ContinuationMode.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 enum class ContinuationMode { 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..85b03815 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.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/build.h b/src/dag_builder/build.h index 041fad1a..c8b2015c 100644 --- a/src/dag_builder/build.h +++ b/src/dag_builder/build.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/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..f491b664 100644 --- a/src/dag_builder/cli.cpp +++ b/src/dag_builder/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 diff --git a/src/dag_builder/cli.h b/src/dag_builder/cli.h index 3c903c6b..57dce287 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 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 31fa9804..26ecf07f 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/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 diff --git a/src/dag_builder/codec.h b/src/dag_builder/codec.h index 8510cac7..2fc792e4 100644 --- a/src/dag_builder/codec.h +++ b/src/dag_builder/codec.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/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/main.cpp b/src/dag_builder/main.cpp index 177924b7..2ab78ef5 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/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 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..2a46e62a 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 diff --git a/src/dag_builder/merge/weld.h b/src/dag_builder/merge/weld.h index adaa002f..9e74f3d3 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 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..bde5128c 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 diff --git a/src/dag_builder/storage.h b/src/dag_builder/storage.h index 76b89b5f..67963abe 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 diff --git a/src/dag_builder/texture_reuse.h b/src/dag_builder/texture_reuse.h index 08222fa0..d974c579 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 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..879f9d74 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 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..4096d246 100644 --- a/src/dag_convert_debug/cli.cpp +++ b/src/dag_convert_debug/cli.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 "cli.h" #include 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..45e05e40 100644 --- a/src/dag_convert_debug/main.cpp +++ b/src/dag_convert_debug/main.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/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..48fe4254 100644 --- a/src/mesh_convert/cli.cpp +++ b/src/mesh_convert/cli.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 "cli.h" 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..f7653f40 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 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..ad517dd2 100644 --- a/src/sf_browser/core/Application.cpp +++ b/src/sf_browser/core/Application.cpp @@ -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 . + *****************************************************************************/ + #include "Application.h" #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..9a29b3de 100644 --- a/src/sf_browser/core/Buffer.cpp +++ b/src/sf_browser/core/Buffer.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 "Buffer.h" #include 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..3241dff4 100644 --- a/src/sf_browser/core/Camera.cpp +++ b/src/sf_browser/core/Camera.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 "Camera.h" #include 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..4ecb7807 100644 --- a/src/sf_browser/core/shader/Shader.cpp +++ b/src/sf_browser/core/shader/Shader.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 "Shader.h" #include 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..25f54b0c 100644 --- a/src/sf_browser/core/shader/ShaderProgram.cpp +++ b/src/sf_browser/core/shader/ShaderProgram.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 "ShaderProgram.h" #include #include 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..7826b66d 100644 --- a/src/sf_browser/core/window/Window.cpp +++ b/src/sf_browser/core/window/Window.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 "Window.h" #include #include 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..fc56013b 100644 --- a/src/sf_browser/main.cpp +++ b/src/sf_browser/main.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 "core/Application.h" #include 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..a55c3a65 100644 --- a/src/sf_builder/CMakeLists.txt +++ b/src/sf_builder/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_library(sfbuilderlib border.h mesh_builder.h mesh_builder.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/main.cpp b/src/sf_builder/main.cpp index 888c2af4..4ef6bf2e 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 diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index be440a94..13b8d280 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.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 diff --git a/src/sf_builder/mesh_builder.h b/src/sf_builder/mesh_builder.h index 2979042d..826b0a98 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.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/raw_dataset_reader.h b/src/sf_builder/raw_dataset_reader.h index 096526be..c1424bbb 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 diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 6195b213..420846fd 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.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 #include diff --git a/src/sf_builder/terrainbuilder.h b/src/sf_builder/terrainbuilder.h index 64ad66d0..eef07f06 100644 --- a/src/sf_builder/terrainbuilder.h +++ b/src/sf_builder/terrainbuilder.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_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 8de959f1..3396559d 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.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 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..ad602f49 100644 --- a/src/sf_index_browser/cli.cpp +++ b/src/sf_index_browser/cli.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 "cli.h" #include 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..03ff728d 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 diff --git a/src/sf_merger/CMakeLists.txt b/src/sf_merger/CMakeLists.txt index 2f647ab4..5dfba5a7 100644 --- a/src/sf_merger/CMakeLists.txt +++ b/src/sf_merger/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_library(sfmergerlib INTERFACE NodeLoader.h NodeWriter.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..e0952da1 100644 --- a/src/sf_merger/NodeWriter.h +++ b/src/sf_merger/NodeWriter.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/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..0a4c352d 100644 --- a/src/sf_merger/cli.cpp +++ b/src/sf_merger/cli.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 "cli.h" #include diff --git a/src/sf_merger/cli.h b/src/sf_merger/cli.h index d8fb6f8e..5f0bdb55 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 diff --git a/src/sf_merger/cut.h b/src/sf_merger/cut.h index b06437f0..29b6feed 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 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/main.cpp b/src/sf_merger/main.cpp index 3cc7b7bc..cb685c5a 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.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 diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 6b16a835..edda05f7 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.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/merge.h b/src/sf_merger/merge.h index 8f902a02..ef81c58a 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/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/sf_merger/merge/NodeData.h b/src/sf_merger/merge/NodeData.h index 2bdd72f1..ee135e18 100644 --- a/src/sf_merger/merge/NodeData.h +++ b/src/sf_merger/merge/NodeData.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/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 417edb1b..4510aa66 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -1,3 +1,22 @@ +# ***************************************************************************** +# 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 DatasetReader.h DatasetReader.cpp diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 2113bfa1..36d77da5 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/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 diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index b96a6181..391b799b 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/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 diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 7b139e87..4be75888 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -1,6 +1,7 @@ /******************************************************************************* * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek + * 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 diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index 4e4bfcae..53181fe8 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.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/src/terrainlib/Error.cpp b/src/terrainlib/Error.cpp index 14b8940b..57d61ca7 100644 --- a/src/terrainlib/Error.cpp +++ b/src/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 "Error.h" #include diff --git a/src/terrainlib/Error.h b/src/terrainlib/Error.h index af758191..811c0c8e 100644 --- a/src/terrainlib/Error.h +++ b/src/terrainlib/Error.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/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/ProgressIndicator.cpp b/src/terrainlib/ProgressIndicator.cpp index cd9a4dcf..85f7e1e1 100644 --- a/src/terrainlib/ProgressIndicator.cpp +++ b/src/terrainlib/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 diff --git a/src/terrainlib/ProgressIndicator.h b/src/terrainlib/ProgressIndicator.h index ca16e36e..737a8237 100644 --- a/src/terrainlib/ProgressIndicator.h +++ b/src/terrainlib/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 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/RasterTransform.cpp b/src/terrainlib/RasterTransform.cpp index 47bcac78..3f1a7d04 100644 --- a/src/terrainlib/RasterTransform.cpp +++ b/src/terrainlib/RasterTransform.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 "RasterTransform.h" #include diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h index 69d82a45..7c62045c 100644 --- a/src/terrainlib/RasterTransform.h +++ b/src/terrainlib/RasterTransform.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/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/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 index 7e306d19..bcd7e7f1 100644 --- a/src/terrainlib/ctb/CTBException.hpp +++ b/src/terrainlib/ctb/CTBException.hpp @@ -3,6 +3,7 @@ /******************************************************************************* * Copyright 2014 GeoData + * Copyright (C) 2022 Adam Celarek-Litofcenko * * 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 diff --git a/src/terrainlib/ctb/GlobalGeodetic.cpp b/src/terrainlib/ctb/GlobalGeodetic.cpp index 37c08df2..0ca8010a 100644 --- a/src/terrainlib/ctb/GlobalGeodetic.cpp +++ b/src/terrainlib/ctb/GlobalGeodetic.cpp @@ -1,5 +1,7 @@ /******************************************************************************* * Copyright 2014 GeoData + * Copyright (C) 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 diff --git a/src/terrainlib/ctb/GlobalGeodetic.hpp b/src/terrainlib/ctb/GlobalGeodetic.hpp index 917690b9..ef1d8180 100644 --- a/src/terrainlib/ctb/GlobalGeodetic.hpp +++ b/src/terrainlib/ctb/GlobalGeodetic.hpp @@ -3,6 +3,8 @@ /******************************************************************************* * Copyright 2014 GeoData + * Copyright (C) 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 diff --git a/src/terrainlib/ctb/GlobalMercator.cpp b/src/terrainlib/ctb/GlobalMercator.cpp index ebe8f7a8..a6166cf3 100644 --- a/src/terrainlib/ctb/GlobalMercator.cpp +++ b/src/terrainlib/ctb/GlobalMercator.cpp @@ -1,5 +1,7 @@ /******************************************************************************* * Copyright 2014 GeoData + * Copyright (C) 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 diff --git a/src/terrainlib/ctb/GlobalMercator.hpp b/src/terrainlib/ctb/GlobalMercator.hpp index 9de4af42..219aacde 100644 --- a/src/terrainlib/ctb/GlobalMercator.hpp +++ b/src/terrainlib/ctb/GlobalMercator.hpp @@ -3,6 +3,8 @@ /******************************************************************************* * Copyright 2014 GeoData + * Copyright (C) 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 diff --git a/src/terrainlib/ctb/Grid.hpp b/src/terrainlib/ctb/Grid.hpp index 8d81488c..0129f96a 100644 --- a/src/terrainlib/ctb/Grid.hpp +++ b/src/terrainlib/ctb/Grid.hpp @@ -3,7 +3,8 @@ /******************************************************************************* * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek + * 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 diff --git a/src/terrainlib/ctb/TileCoordinate.hpp b/src/terrainlib/ctb/TileCoordinate.hpp index fcd3c56c..7349b3e9 100644 --- a/src/terrainlib/ctb/TileCoordinate.hpp +++ b/src/terrainlib/ctb/TileCoordinate.hpp @@ -3,7 +3,7 @@ /******************************************************************************* * Copyright 2014 GeoData - * Copyright 2022 Adam Celarek + * Copyright 2022 Adam Celarek-Litofcenko * * 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 diff --git a/src/terrainlib/ctb/types.hpp b/src/terrainlib/ctb/types.hpp index 05c204ed..8dad025a 100644 --- a/src/terrainlib/ctb/types.hpp +++ b/src/terrainlib/ctb/types.hpp @@ -3,7 +3,7 @@ /******************************************************************************* * Copyright 2014 GeoData - * Copyright 2021 Adam Celarek + * Copyright 2021 Adam Celarek-Litofcenko * * 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 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/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 index 91955736..cbb89c06 100644 --- a/src/terrainlib/init.cpp +++ b/src/terrainlib/init.cpp @@ -1,3 +1,22 @@ +/***************************************************************************** + * 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 diff --git a/src/terrainlib/init.h b/src/terrainlib/init.h index 960a324a..dfd56b23 100644 --- a/src/terrainlib/init.h +++ b/src/terrainlib/init.h @@ -1,3 +1,22 @@ +/***************************************************************************** + * 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 void initialize_gdal_once(); diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/bytes.cpp index a0e73f1a..f3935aa6 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/bytes.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 diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/bytes.h index b5cfe024..388e4713 100644 --- a/src/terrainlib/io/bytes.h +++ b/src/terrainlib/io/bytes.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/io/compression.cpp b/src/terrainlib/io/compression.cpp index 18d9f9f7..a3a72d14 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.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 . + *****************************************************************************/ + #define ZSTD_STATIC_LINKING_ONLY #include #include diff --git a/src/terrainlib/io/compression.h b/src/terrainlib/io/compression.h index 1e50fcb6..08db75e3 100644 --- a/src/terrainlib/io/compression.h +++ b/src/terrainlib/io/compression.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/io/conversion.h b/src/terrainlib/io/conversion.h index ffb8689d..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 diff --git a/src/terrainlib/io/envelope.cpp b/src/terrainlib/io/envelope.cpp index 26468168..ba447df0 100644 --- a/src/terrainlib/io/envelope.cpp +++ b/src/terrainlib/io/envelope.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 "io/envelope.h" #include diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index 480a0cdc..6986516e 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.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 "io/bytes.h" diff --git a/src/terrainlib/io/glm_serialization.h b/src/terrainlib/io/glm_serialization.h index af2f3432..471759c2 100644 --- a/src/terrainlib/io/glm_serialization.h +++ b/src/terrainlib/io/glm_serialization.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/io/hash.cpp b/src/terrainlib/io/hash.cpp index b601bd52..33f5d3dd 100644 --- a/src/terrainlib/io/hash.cpp +++ b/src/terrainlib/io/hash.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 "io/hash.h" #define XXH_INLINE_ALL diff --git a/src/terrainlib/io/hash.h b/src/terrainlib/io/hash.h index fc1cfb67..0aa7c5bf 100644 --- a/src/terrainlib/io/hash.h +++ b/src/terrainlib/io/hash.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/io/image.cpp b/src/terrainlib/io/image.cpp index 67b28797..2001ae61 100644 --- a/src/terrainlib/io/image.cpp +++ b/src/terrainlib/io/image.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 "image.h" #include diff --git a/src/terrainlib/io/image.h b/src/terrainlib/io/image.h index 5d9b9ca0..67c86005 100644 --- a/src/terrainlib/io/image.h +++ b/src/terrainlib/io/image.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/io/utils.cpp b/src/terrainlib/io/utils.cpp index e485d65f..46164fda 100644 --- a/src/terrainlib/io/utils.cpp +++ b/src/terrainlib/io/utils.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 "io/utils.h" #include diff --git a/src/terrainlib/io/utils.h b/src/terrainlib/io/utils.h index 215bec22..4dd3d144 100644 --- a/src/terrainlib/io/utils.h +++ b/src/terrainlib/io/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/log.cpp b/src/terrainlib/log.cpp index ddf059c1..99eb8e8e 100644 --- a/src/terrainlib/log.cpp +++ b/src/terrainlib/log.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 diff --git a/src/terrainlib/log.h b/src/terrainlib/log.h index 5925bd4d..47f9ea22 100644 --- a/src/terrainlib/log.h +++ b/src/terrainlib/log.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 #ifndef SPDLOG_FMT_EXTERNAL 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..18210c8d 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 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..fa6b4272 100644 --- a/src/terrainlib/mesh/codec/Gltf.h +++ b/src/terrainlib/mesh/codec/Gltf.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/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index 3b509c3f..40969602 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.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/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..a7a511d5 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 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..a3e8c4f8 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 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..a1f32405 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -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 . + *****************************************************************************/ + #include "mesh/io.h" #include "log.h" #include "mesh/io/gltf.h" 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 c0d42ad9..724d7097 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 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..7806e6af 100644 --- a/src/terrainlib/mesh/io/options.h +++ b/src/terrainlib/mesh/io/options.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/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..6727aa72 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 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..c445b1ab 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 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..98220a38 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 diff --git a/src/terrainlib/mesh/texture_trim.cpp b/src/terrainlib/mesh/texture_trim.cpp index 17c70a41..46a39e42 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 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..79e8960e 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 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..59a9b256 100644 --- a/src/terrainlib/mesh/validate_cgal.inl +++ b/src/terrainlib/mesh/validate_cgal.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/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 755f3994..d445391c 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.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/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/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..63d27427 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 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..e7154140 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 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 index 16680c41..766fd176 100644 --- a/src/terrainlib/raster/ClampedView.h +++ b/src/terrainlib/raster/ClampedView.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/View.h b/src/terrainlib/raster/View.h index 7b611bb7..73037ba8 100644 --- a/src/terrainlib/raster/View.h +++ b/src/terrainlib/raster/View.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/algorithm.h b/src/terrainlib/raster/algorithm.h index b62f6ca7..31ef81fa 100644 --- a/src/terrainlib/raster/algorithm.h +++ b/src/terrainlib/raster/algorithm.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 "algorithm/conversion.h" diff --git a/src/terrainlib/raster/algorithm/conversion.h b/src/terrainlib/raster/algorithm/conversion.h index b5e68028..b99cad2f 100644 --- a/src/terrainlib/raster/algorithm/conversion.h +++ b/src/terrainlib/raster/algorithm/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 diff --git a/src/terrainlib/raster/algorithm/copy.h b/src/terrainlib/raster/algorithm/copy.h index 689ff228..0d84c408 100644 --- a/src/terrainlib/raster/algorithm/copy.h +++ b/src/terrainlib/raster/algorithm/copy.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/algorithm/detail.h b/src/terrainlib/raster/algorithm/detail.h index 411d6539..3b1b4627 100644 --- a/src/terrainlib/raster/algorithm/detail.h +++ b/src/terrainlib/raster/algorithm/detail.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/algorithm/fold.h b/src/terrainlib/raster/algorithm/fold.h index 480364c0..26f04fda 100644 --- a/src/terrainlib/raster/algorithm/fold.h +++ b/src/terrainlib/raster/algorithm/fold.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 "detail.h" diff --git a/src/terrainlib/raster/algorithm/reduce.h b/src/terrainlib/raster/algorithm/reduce.h index fb955eff..766835c5 100644 --- a/src/terrainlib/raster/algorithm/reduce.h +++ b/src/terrainlib/raster/algorithm/reduce.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 "reducers.h" diff --git a/src/terrainlib/raster/algorithm/reducers.h b/src/terrainlib/raster/algorithm/reducers.h index 6c6adfd0..2557e6d8 100644 --- a/src/terrainlib/raster/algorithm/reducers.h +++ b/src/terrainlib/raster/algorithm/reducers.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/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h index 0926c1a5..05a6bbd5 100644 --- a/src/terrainlib/raster/algorithm/scale.h +++ b/src/terrainlib/raster/algorithm/scale.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/algorithm/scaling_detail.h b/src/terrainlib/raster/algorithm/scaling_detail.h index b447d32c..449cd361 100644 --- a/src/terrainlib/raster/algorithm/scaling_detail.h +++ b/src/terrainlib/raster/algorithm/scaling_detail.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 "copy.h" diff --git a/src/terrainlib/raster/algorithm/transform.h b/src/terrainlib/raster/algorithm/transform.h index fc3d916b..50a21c89 100644 --- a/src/terrainlib/raster/algorithm/transform.h +++ b/src/terrainlib/raster/algorithm/transform.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 "detail.h" diff --git a/src/terrainlib/raster/algorithm/window_transform.h b/src/terrainlib/raster/algorithm/window_transform.h index 04746436..00dd7126 100644 --- a/src/terrainlib/raster/algorithm/window_transform.h +++ b/src/terrainlib/raster/algorithm/window_transform.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 "detail.h" diff --git a/src/terrainlib/raster/algorithm/zip_transform.h b/src/terrainlib/raster/algorithm/zip_transform.h index 8384ed49..4840c330 100644 --- a/src/terrainlib/raster/algorithm/zip_transform.h +++ b/src/terrainlib/raster/algorithm/zip_transform.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/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 index 4bf22c1b..1ed7578e 100644 --- a/src/terrainlib/raster_store/Tile.h +++ b/src/terrainlib/raster_store/Tile.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/TilePool.h b/src/terrainlib/raster_store/TilePool.h index e7509b52..9d4b3749 100644 --- a/src/terrainlib/raster_store/TilePool.h +++ b/src/terrainlib/raster_store/TilePool.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 "Error.h" diff --git a/src/terrainlib/raster_store/attribution.cpp b/src/terrainlib/raster_store/attribution.cpp index 3dbda6eb..2919c32d 100644 --- a/src/terrainlib/raster_store/attribution.cpp +++ b/src/terrainlib/raster_store/attribution.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 "raster_store/attribution.h" #include diff --git a/src/terrainlib/raster_store/attribution.h b/src/terrainlib/raster_store/attribution.h index a3fb4159..a5c188f3 100644 --- a/src/terrainlib/raster_store/attribution.h +++ b/src/terrainlib/raster_store/attribution.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/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index 667018ca..fd731a3f 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.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/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index 5c5dd353..c2960636 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.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 "raster_store/io/manifest.h" #include diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h index f6765988..dd8e3849 100644 --- a/src/terrainlib/raster_store/io/manifest.h +++ b/src/terrainlib/raster_store/io/manifest.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/path_layout.h b/src/terrainlib/raster_store/path_layout.h index 1cabd453..0d2a287a 100644 --- a/src/terrainlib/raster_store/path_layout.h +++ b/src/terrainlib/raster_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/raster_store/pixel.h b/src/terrainlib/raster_store/pixel.h index bf866425..b278afe3 100644 --- a/src/terrainlib/raster_store/pixel.h +++ b/src/terrainlib/raster_store/pixel.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/read_tile_with_halo.h b/src/terrainlib/raster_store/read_tile_with_halo.h index 9e99f9e1..1cdece1f 100644 --- a/src/terrainlib/raster_store/read_tile_with_halo.h +++ b/src/terrainlib/raster_store/read_tile_with_halo.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/scaler.h b/src/terrainlib/raster_store/scaler.h index 3fdea30e..c010373f 100644 --- a/src/terrainlib/raster_store/scaler.h +++ b/src/terrainlib/raster_store/scaler.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 "pixel.h" diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index bf0a1058..5826db0e 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_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 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..6bee284a 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 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 index a5e5ce5c..c0f9699f 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -1,6 +1,7 @@ /***************************************************************************** * AlpineMaps.org - * Copyright (C) 2022 Adam Celarek + * 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/src/terrainlib/store/Codec.h b/src/terrainlib/store/Codec.h index 56958e1a..0de7cfa2 100644 --- a/src/terrainlib/store/Codec.h +++ b/src/terrainlib/store/Codec.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/Index.h b/src/terrainlib/store/Index.h index 4646b268..e2d15457 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 diff --git a/src/terrainlib/store/IndexFormat.h b/src/terrainlib/store/IndexFormat.h index 71c04944..e4464de7 100644 --- a/src/terrainlib/store/IndexFormat.h +++ b/src/terrainlib/store/IndexFormat.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/IndexedStorage.h b/src/terrainlib/store/IndexedStorage.h index 6aac6138..248c0d32 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 diff --git a/src/terrainlib/store/NodeStatus.h b/src/terrainlib/store/NodeStatus.h index 4cd17241..885a4151 100644 --- a/src/terrainlib/store/NodeStatus.h +++ b/src/terrainlib/store/NodeStatus.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/NodeStatusOrMissing.h b/src/terrainlib/store/NodeStatusOrMissing.h index 1b69fd28..fab68328 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 diff --git a/src/terrainlib/store/RawStorage.h b/src/terrainlib/store/RawStorage.h index 298f9af5..7fcc400e 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.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/Storage.h b/src/terrainlib/store/Storage.h index 4987af1f..da1b27fb 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 diff --git a/src/terrainlib/store/ThreadSafeStorage.h b/src/terrainlib/store/ThreadSafeStorage.h index 13c30002..ed567d1b 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 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/open.h b/src/terrainlib/store/open.h index f265e811..cf2feacd 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 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/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..dcc1aace 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 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_mask.h b/src/terrainlib/vector_mask.h index c7b7bb2f..414fd295 100644 --- a/src/terrainlib/vector_mask.h +++ b/src/terrainlib/vector_mask.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/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 index 8c1a87a1..d7e76c5c 100644 --- a/src/tile_builder/CMakeLists.txt +++ b/src/tile_builder/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_library(tilebuilderlib Exception.h ParallelTileGenerator.h ParallelTileGenerator.cpp diff --git a/src/tile_builder/Exception.h b/src/tile_builder/Exception.h index 6984a400..c1f18bfb 100644 --- a/src/tile_builder/Exception.h +++ b/src/tile_builder/Exception.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/src/tile_builder/ParallelTileGenerator.cpp b/src/tile_builder/ParallelTileGenerator.cpp index 6a52a497..774a4ea5 100644 --- a/src/tile_builder/ParallelTileGenerator.cpp +++ b/src/tile_builder/ParallelTileGenerator.cpp @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Martin Braunsperger + * 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 diff --git a/src/tile_builder/ParallelTileGenerator.h b/src/tile_builder/ParallelTileGenerator.h index cd6f6240..74ea1586 100644 --- a/src/tile_builder/ParallelTileGenerator.h +++ b/src/tile_builder/ParallelTileGenerator.h @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org + * AlpineMaps.org + * Copyright (C) 2022 Martin Braunsperger + * 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 diff --git a/src/tile_builder/ParallelTiler.cpp b/src/tile_builder/ParallelTiler.cpp index fe6b2330..d89cf64d 100644 --- a/src/tile_builder/ParallelTiler.cpp +++ b/src/tile_builder/ParallelTiler.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 diff --git a/src/tile_builder/ParallelTiler.h b/src/tile_builder/ParallelTiler.h index 62cd07ac..2c6f776c 100644 --- a/src/tile_builder/ParallelTiler.h +++ b/src/tile_builder/ParallelTiler.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/src/tile_builder/TileHeightsGenerator.cpp b/src/tile_builder/TileHeightsGenerator.cpp index 0e535fab..6e8555e5 100644 --- a/src/tile_builder/TileHeightsGenerator.cpp +++ b/src/tile_builder/TileHeightsGenerator.cpp @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam + * 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/src/tile_builder/TileHeightsGenerator.h b/src/tile_builder/TileHeightsGenerator.h index 703a1227..cec68fd2 100644 --- a/src/tile_builder/TileHeightsGenerator.h +++ b/src/tile_builder/TileHeightsGenerator.h @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam + * 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/src/tile_builder/Tiler.cpp b/src/tile_builder/Tiler.cpp index fafa3ba7..56d07de1 100644 --- a/src/tile_builder/Tiler.cpp +++ b/src/tile_builder/Tiler.cpp @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 madam + * 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/src/tile_builder/Tiler.h b/src/tile_builder/Tiler.h index 70b5049e..ca32e5f6 100644 --- a/src/tile_builder/Tiler.h +++ b/src/tile_builder/Tiler.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 diff --git a/src/tile_builder/TopDownTiler.cpp b/src/tile_builder/TopDownTiler.cpp index ca5ead02..68d34716 100644 --- a/src/tile_builder/TopDownTiler.cpp +++ b/src/tile_builder/TopDownTiler.cpp @@ -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 diff --git a/src/tile_builder/TopDownTiler.h b/src/tile_builder/TopDownTiler.h index f078ca89..f20397b8 100644 --- a/src/tile_builder/TopDownTiler.h +++ b/src/tile_builder/TopDownTiler.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 diff --git a/src/tile_builder/alpine_raster.cpp b/src/tile_builder/alpine_raster.cpp index 7e4b1a0c..789f7f64 100644 --- a/src/tile_builder/alpine_raster.cpp +++ b/src/tile_builder/alpine_raster.cpp @@ -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 diff --git a/src/tile_builder/alpine_raster.h b/src/tile_builder/alpine_raster.h index d82f05a6..5d374807 100644 --- a/src/tile_builder/alpine_raster.h +++ b/src/tile_builder/alpine_raster.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/src/tile_builder/depth_first_tile_traverser.h b/src/tile_builder/depth_first_tile_traverser.h index 46acb789..ab107102 100644 --- a/src/tile_builder/depth_first_tile_traverser.h +++ b/src/tile_builder/depth_first_tile_traverser.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 diff --git a/src/tile_builder/image_writer.cpp b/src/tile_builder/image_writer.cpp index 1a03aa5a..e122f6a1 100644 --- a/src/tile_builder/image_writer.cpp +++ b/src/tile_builder/image_writer.cpp @@ -1,8 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2025 Martin Braunsperger + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * 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 diff --git a/src/tile_builder/image_writer.h b/src/tile_builder/image_writer.h index 9c72598f..dbceb4f1 100644 --- a/src/tile_builder/image_writer.h +++ b/src/tile_builder/image_writer.h @@ -1,8 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek - * Copyright (C) 2025 Martin Braunsperger + * AlpineMaps.org + * Copyright (C) 2022 Adam Celarek-Litofcenko + * 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 diff --git a/src/tile_builder/main.cpp b/src/tile_builder/main.cpp index 06ec1c93..19511997 100644 --- a/src/tile_builder/main.cpp +++ b/src/tile_builder/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 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..47281bba 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 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..df63bc1f 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 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..100211a0 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -1,3 +1,23 @@ +/***************************************************************************** + * 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" 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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index ade3fcea..60396075 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() 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..e40c4364 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.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/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/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/mesh.cpp b/unittests/sf_builder/mesh.cpp index e4e11633..dda49d45 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 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..b798ba54 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 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..6f4342ff 100644 --- a/unittests/sf_merger/mask.cpp +++ b/unittests/sf_merger/mask.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 diff --git a/unittests/sf_merger/sphere_projector.cpp b/unittests/sf_merger/sphere_projector.cpp index 03f4076d..542a23e2 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 diff --git a/unittests/sf_merger/storage_boundaries.cpp b/unittests/sf_merger/storage_boundaries.cpp index 9f4f3a6a..13411e49 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 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 index c7f359d6..17084efb 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/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 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 2b628441..ec59860e 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.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 "../temporary_directory.h" diff --git a/unittests/terrainlib/error.cpp b/unittests/terrainlib/error.cpp index 011b5e3a..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 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/glm_serialization.cpp b/unittests/terrainlib/glm_serialization.cpp index 0618b415..c857050a 100644 --- a/unittests/terrainlib/glm_serialization.cpp +++ b/unittests/terrainlib/glm_serialization.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 "io/glm_serialization.h" #include diff --git a/unittests/terrainlib/grid.cpp b/unittests/terrainlib/grid.cpp index d8df7af1..2ef0f16e 100644 --- a/unittests/terrainlib/grid.cpp +++ b/unittests/terrainlib/grid.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 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 index 9d51f5d3..24e57af4 100644 --- a/unittests/terrainlib/io_hash.cpp +++ b/unittests/terrainlib/io_hash.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 "io/hash.h" #include diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp index 9146cc12..e17ed280 100644 --- a/unittests/terrainlib/io_image.cpp +++ b/unittests/terrainlib/io_image.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 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..8357e7e2 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 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..5826b686 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 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 index e369ad5f..84b08f11 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.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 diff --git a/unittests/terrainlib/raster_fold.cpp b/unittests/terrainlib/raster_fold.cpp index c827c7d1..46616483 100644 --- a/unittests/terrainlib/raster_fold.cpp +++ b/unittests/terrainlib/raster_fold.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 diff --git a/unittests/terrainlib/raster_halo.cpp b/unittests/terrainlib/raster_halo.cpp index dfcc73ea..029057a7 100644 --- a/unittests/terrainlib/raster_halo.cpp +++ b/unittests/terrainlib/raster_halo.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/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 33b4c553..26985eac 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.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 diff --git a/unittests/terrainlib/raster_view.cpp b/unittests/terrainlib/raster_view.cpp index 867f9d42..ed37491e 100644 --- a/unittests/terrainlib/raster_view.cpp +++ b/unittests/terrainlib/raster_view.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 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..bd2ff794 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 diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 8db1439e..0b3d132a 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.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 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_storage.cpp b/unittests/terrainlib/store_storage.cpp index 18b6981f..c07a962d 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.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 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 index a0d98223..aaf0f4b4 100644 --- a/unittests/tilebuilder/alpine_raster_format.cpp +++ b/unittests/tilebuilder/alpine_raster_format.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 diff --git a/unittests/tilebuilder/dataset_reading.cpp b/unittests/tilebuilder/dataset_reading.cpp index 2b505789..e0d4d2ff 100644 --- a/unittests/tilebuilder/dataset_reading.cpp +++ b/unittests/tilebuilder/dataset_reading.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 diff --git a/unittests/tilebuilder/depth_first_tile_traverser.cpp b/unittests/tilebuilder/depth_first_tile_traverser.cpp index 5cf7fb5e..db437ef4 100644 --- a/unittests/tilebuilder/depth_first_tile_traverser.cpp +++ b/unittests/tilebuilder/depth_first_tile_traverser.cpp @@ -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 diff --git a/unittests/tilebuilder/image_writer.cpp b/unittests/tilebuilder/image_writer.cpp index 8fc548f2..420c221a 100644 --- a/unittests/tilebuilder/image_writer.cpp +++ b/unittests/tilebuilder/image_writer.cpp @@ -1,7 +1,6 @@ /***************************************************************************** - * Alpine Terrain Builder - * Copyright (C) 2022 alpinemaps.org - * Copyright (C) 2022 Adam Celarek + * 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 diff --git a/unittests/tilebuilder/parallel_tile_generator.cpp b/unittests/tilebuilder/parallel_tile_generator.cpp index f9326334..c0ff28c3 100644 --- a/unittests/tilebuilder/parallel_tile_generator.cpp +++ b/unittests/tilebuilder/parallel_tile_generator.cpp @@ -1,6 +1,7 @@ /***************************************************************************** - * Alpine Terrain Builder - * 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/tilebuilder/parallel_tiler.cpp b/unittests/tilebuilder/parallel_tiler.cpp index b18124cf..26fe400b 100644 --- a/unittests/tilebuilder/parallel_tiler.cpp +++ b/unittests/tilebuilder/parallel_tiler.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 diff --git a/unittests/tilebuilder/tile_heights_generator.cpp b/unittests/tilebuilder/tile_heights_generator.cpp index 89c532ae..335c8391 100644 --- a/unittests/tilebuilder/tile_heights_generator.cpp +++ b/unittests/tilebuilder/tile_heights_generator.cpp @@ -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 diff --git a/unittests/tilebuilder/top_down_tiler.cpp b/unittests/tilebuilder/top_down_tiler.cpp index 61645361..75394da2 100644 --- a/unittests/tilebuilder/top_down_tiler.cpp +++ b/unittests/tilebuilder/top_down_tiler.cpp @@ -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 From 9c242fcbf60d71e96143b1d084b65be4b23430c0 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:36:09 +0200 Subject: [PATCH 32/83] simplify error handling for TilePool --- src/terrainlib/raster_store/TilePool.h | 38 ++++++++++++-------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/terrainlib/raster_store/TilePool.h b/src/terrainlib/raster_store/TilePool.h index e7509b52..d64c4090 100644 --- a/src/terrainlib/raster_store/TilePool.h +++ b/src/terrainlib/raster_store/TilePool.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -19,11 +20,13 @@ 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(unsigned, const Key&)>; + using Prepare = std::function; struct Completed { Key key; Expected result; @@ -42,24 +45,22 @@ class TilePool { 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; } - for (auto& slot : m_slots) { - if (slot.state != State::Free) { - continue; - } - slot.key = key; - slot.sequence = m_sequence++; - slot.state = State::Pending; - ++m_outstanding; - m_changed.notify_all(); - return true; - } - 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) @@ -94,11 +95,6 @@ class TilePool { std::lock_guard lock(m_mutex); return m_outstanding; } - std::optional failure() const - { - std::lock_guard lock(m_mutex); - return m_failure; - } private: enum class State { Free, Pending, Active, Ready }; @@ -148,6 +144,8 @@ class TilePool { 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 (...) { @@ -155,8 +153,7 @@ class TilePool { } }(); lock.lock(); - if (!result && !m_failure) { - m_failure = result.error(); + if (!result) { stop_locked(); } slot.result.emplace(std::move(result)); @@ -171,7 +168,6 @@ class TilePool { std::uint64_t m_sequence = 0; std::size_t m_outstanding = 0; bool m_stopped = false; - std::optional m_failure = std::nullopt; // Last: joins before any state used by workers is destroyed, including if // thread creation throws halfway through construction. std::vector m_threads; From 867d6c28ca7bb3b2c7d7639b7d42d0cbff466c62 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:14:00 +0200 Subject: [PATCH 33/83] clean up docs --- ...esentative-attribution-for-halo-samples.md | 2 +- docs/adr/0005-xxh3-64-envelope-hashes.md | 3 +- docs/raster-store/README.md | 30 +- docs/raster-store/architecture.md | 203 +----- docs/raster-store/content-hash-plan.md | 178 ----- docs/raster-store/content-hash-status.md | 41 -- .../gdal-value-mapping-research.md | 123 ---- docs/raster-store/halo-implementation-plan.md | 259 ------- docs/raster-store/implementation-plan.md | 90 --- docs/raster-store/implementation-status.md | 505 ------------- docs/raster-store/sampling-and-generation.md | 125 +++- docs/raster-store/scaling.md | 678 ------------------ docs/raster-store/storage-format.md | 35 +- docs/raster-store/terminology.md | 4 +- docs/raster-store/tiles-with-halo.md | 299 ++------ docs/raster-store/todo.md | 2 +- docs/raster-view-algorithms.md | 316 -------- docs/sf-merger-multipolygon-mask-topology.md | 12 - 18 files changed, 225 insertions(+), 2680 deletions(-) delete mode 100644 docs/raster-store/content-hash-plan.md delete mode 100644 docs/raster-store/content-hash-status.md delete mode 100644 docs/raster-store/gdal-value-mapping-research.md delete mode 100644 docs/raster-store/halo-implementation-plan.md delete mode 100644 docs/raster-store/implementation-plan.md delete mode 100644 docs/raster-store/implementation-status.md delete mode 100644 docs/raster-store/scaling.md delete mode 100644 docs/raster-view-algorithms.md diff --git a/docs/adr/0004-representative-attribution-for-halo-samples.md b/docs/adr/0004-representative-attribution-for-halo-samples.md index 4eb47e6c..eb97187a 100644 --- a/docs/adr/0004-representative-attribution-for-halo-samples.md +++ b/docs/adr/0004-representative-attribution-for-halo-samples.md @@ -1,7 +1,7 @@ # Retain one representative attribution for halo samples Halo extraction retains one representative attribution per resampled pixel, -following the rules in [Raster scaling](../raster-store/scaling.md). +following the [scaling rules](../raster-store/sampling-and-generation.md#implemented-scaling-rules). Attribution indices are categorical and are never averaged. The refactor agreed on 2026-09-22 delegates paired operations to `raster_store::scaler`, whose thin wrappers call the generic single-raster algorithms independently diff --git a/docs/adr/0005-xxh3-64-envelope-hashes.md b/docs/adr/0005-xxh3-64-envelope-hashes.md index d736f382..7452c175 100644 --- a/docs/adr/0005-xxh3-64-envelope-hashes.md +++ b/docs/adr/0005-xxh3-64-envelope-hashes.md @@ -6,8 +6,7 @@ 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. It was agreed on 2026-09-30 in the -[content-hash plan](../raster-store/content-hash-plan.md). +leaves room for longer or stronger hashes. Single-threaded throughput on an AMD Ryzen 9 3900X over 1 GiB in memory: diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index a1a0666e..20ecc00b 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -1,30 +1,18 @@ # Raster store design This directory describes authoritative raster tile storage and the planned -generation of delivery tile pyramids from it. Storage format version 1 is -documented alongside the implementation status. Halo extraction, windowed -scaling, and RF import are implemented; -TB generation and the tile server remain future work. +generation of delivery tile pyramids from it. RF import and merging are +implemented; TB generation and the tile server remain future work. ## Documents - [Terminology](terminology.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) -- [Implementation status](implementation-status.md) +- [Sampling and pyramid generation](sampling-and-generation.md): sampling + theory, TB generation requirements, and the implemented scaling rules +- [Tiles with halo](tiles-with-halo.md) +- [TODO](todo.md) -## Plans - -- [Tiles with halo: design](tiles-with-halo.md) -- [Halo extraction implementation plan](halo-implementation-plan.md) -- [GDAL import NoData filling: policy and verification](gdal-nodata-filling.md) -- [Envelope content hashes](content-hash-plan.md) - -- [Tile-storage implementation plan](implementation-plan.md) -- [Raster store TODO](todo.md) - -## Scope - -The documents describe the storage format, implemented import and merge -tools, and proposed delivery-generation behavior. +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 6f36413b..e95c733c 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -3,156 +3,14 @@ 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. +project on top of the shared store mechanisms also used for 3D meshes. It is +part of the `terrainlib` target. [Storage format](storage-format.md) defines the directory layout, serialized data, and format invariants. [Terminology](terminology.md) defines the domain language, and [sampling and generation](sampling-and-generation.md) covers sampling and filtering policy. -## Current implementation scope - -The raster-store library provides typed tiles, attribution tables, codecs, -persistent index and metadata, snapshot publication, windowed scaling, and -physical-tile halo extraction with neighbour/ancestor fallback. RF importers -are implemented; arbitrary geographic window reads, TB generation, and merging -remain future work. -It remains part of the existing `terrainlib` target. - -## Implementation status - -So far, a refactor delivered the dimension-neutral mechanisms to index RS: - -- `store::Index`, `store::traverse`, layouts, runtime codecs, storage, - typed errors, and cache interfaces; -- `octree::StoreTraits` 3D layout/index/open adapters (for reference only); -- `raster_store::StoreTraits` for in-memory topology keyed by - `radix::tile::Id`; - -The 3D octree stores use versioned envelopes. Raster persistence implementation -and verification progress is tracked in -[implementation-status.md](implementation-status.md). - -### Scaling responsibility - -The [scaling refactor agreed on 2026-09-22](scaling.md) separates numerical -operations from attribution policy. Generic `raster::algorithm` operations -resample a single raster through ordinary or clamped views; paired -`raster_store::scaler` wrappers compose those operations for data and -representative attribution. Zero attribution means unattributed and does not -mask numerical inputs. Callers must prepare usable payloads before scaling. -Paired `scale` accepts `raster_store::pixel::Mapping` from the shared -`raster_store/pixel.h`; paired `reduce` and generic algorithms receive -decoder/encoder tuples. Paired `reduce` defaults to identity conversion. -The same header provides `pixel::identifier()` with its `Format` machinery -in `pixel::detail`, replacing the former `pixel_type.h` API. Snapshot metadata -uses the shared `pixel::Mapping` type as well. -Source NoData handling stays with importers, while physical-source selection -and missing-coverage replication belong to the halo extractor. - -### Raster storage API - -Include `raster_store/storage.h`. `raster_store::storage::create()` -takes the final snapshot path and creates its sibling `.part` directory. -The attribution table must already be available through the agreed lookup, -or `CreateOptions::copy_attribution_from_index` can identify an input index -whose selected table is copied unchanged into the output. - -```cpp -raster_store::storage::CreateOptions options; -options.nominal_tile_size = 64; -options.halo_width = 0; -auto created = raster_store::storage::create(snapshot_path, options); -if (!created) { - return Error::propagate(std::move(created)); -} -auto [output, metadata] = std::move(*created); -raster_store::Tile tile(metadata->stored_tile_size); // Attribution defaults to zero (unattributed). -if (auto saved = output->save({ 0, { 0, 0 } }, tile); !saved) { - return saved; -} -return raster_store::storage::publish(std::move(output)); -``` - -Both factories return `Expected>` with owning `std::unique_ptr`s -to the storage first and metadata second. `create()` returns writable -`IndexedStorage` and const metadata; `open()` returns const -storage and const metadata. Both pointers are non-null on success and can be -moved independently. - -Call the non-const `save_index()` explicitly for intermediate checkpoints on -created storage. Pass `{ .allow_incomplete = true }` to `open()` for -read-only cache access to a `.part` snapshot. `attribution::read_table(index_path)` -provides the selected table separately. `publish()` consumes the writable storage -pointer on success or failure and returns `Expected`; it rejects null -pointers. The metadata pointer remains available after publication. - -Raster opening supplies the decoded metadata to the shared `store::open_index` -overload. The shared opener resolves the metadata's codec selector through -the supplied callback; raster codec names and dimensions are handled in -`raster_store`, while mesh callers retain their extension resolver. - -Manifest I/O lives in `raster_store/io/manifest.h` under -`raster_store::io::manifest`. `read_metadata(base_path)` and -`write_metadata(metadata, base_path)` read and write the snapshot metadata file. -`raster_store/io/TileCodec.h` defines -`raster_store::io::TileCodec` and its supporting `tile_codec` -namespace. Versioned payload structs belong to `manifest::detail::v1` and -`tile_codec::detail::v1`, respectively. The XYZ layout lives in -`raster_store/path_layout.h` under `raster_store::path_layout::zoom_xy_google`. -Both raster and octree index decoding use `store::Index::validate() const` -to validate the decoded hierarchy. - -### Halo reads - -Include `raster_store/read_tile_with_halo.h` and call: - -```cpp -auto tile = raster_store::read_tile_with_halo(*storage, *metadata, tile_id, - 2, raster::algorithm::Interpolation::Bilinear); -``` - -The result is an ordinary `Tile` with a two-pixel halo. The centre must be -physical. Stored halos are cropped exactly; zero-halo snapshots resolve -same-zoom neighbours, descend at most four levels, and use physical ancestors -for remaining regions. Uncovered data replicates the central edge with zero -attribution. Source assignment precedes payload reads, so each source is read -at most once. See [Tiles with halo](tiles-with-halo.md) for the complete contract. - -### Final public names - -The shared API uses `store::NodeStatus`, `store::NodeStatusOrMissing`, -`store::Index`, `store::traverse`, `store::RawStorage`, -`store::Storage`, and `store::IndexedStorage`. - -A 3D mesh dataset is opened through the 3D adapter (for reference): - -```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: - -```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. -}); -``` - ## System boundary The design separates authoritative data management from delivery generation: @@ -214,39 +72,31 @@ weeks. The RF builder, RF merger, and TB builder follow these policies: 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 through `std::unique_ptr`; -normal access permits reads but not tile mutations or index checkpoints. -Created snapshots retain writable storage until publication consumes it. +keep the data alive. Published snapshots are opened read-only. 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 to decide whether reuse is -valid. A TB with different tile dimensions needs newly generated tiles +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. ### Opening and checkpoints -Normal opening rejects `.part` snapshot directories. The explicit -`allow_incomplete` option opens one as const `IndexedStorage` for cache reuse, using -the last saved index and ignoring unindexed files. Checkpoint timing belongs -to the builder, which explicitly saves the index periodically. +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. Checkpoints replace the index through a temporary file so a failed write does -not truncate the previous checkpoint. The shared storage destructor may also -save a dirty index; destruction never publishes a snapshot. Checkpointing and -publication require the caller to finish outstanding writes first. +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. Opening and publication do not perform an additional whole-snapshot -validation pass or scan payload files for existence. Errors from envelope -decoding and the normal format-adapter/opening path are propagated when the -index or a tile is read. Generic envelope checks cover the serialized -representation, version, compression, and payload hash; index invariants belong -to the format adapter. The attribution table is parsed separately as JSON. +validation pass or scan payload files for existence. Errors are reported when +the index or a tile is read. ### Publication @@ -258,28 +108,21 @@ A new snapshot is assembled in a sibling directory named 3. Rename the directory to `` on the same filesystem. Publication returns success after the rename without reopening the snapshot. -The current publication implementation uses Linux `renameat2` with -`RENAME_NOREPLACE` to reject destination collisions at the rename itself. -Other platforms currently report `Unsupported` for publication. +The rename uses Linux `renameat2` with `RENAME_NOREPLACE` to reject +destination collisions; other platforms currently report `Unsupported`. -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. +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. Cross-filesystem publication is unsupported because the final rename and any hard links must remain on one filesystem. A builder or merger must reject that configuration before starting a long operation. Publication does not guarantee durability or safe recovery across a power -failure, operating-system crash, or storage failure. 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 or -rebuilt. +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. ## Tools diff --git a/docs/raster-store/content-hash-plan.md b/docs/raster-store/content-hash-plan.md deleted file mode 100644 index 28c1ee06..00000000 --- a/docs/raster-store/content-hash-plan.md +++ /dev/null @@ -1,178 +0,0 @@ -# Envelope content hashes: plan - -Status: plan, 2026-09-30. Agreed; no decisions are open. Per-tile hashes in -the raster-store index are deferred; see the [TODO](todo.md). - -## Motivation - -TB tiles should record which source tiles produced each lower zoom level, so -that a data change triggers only a partial rebuild. That needs a cheap content -identity per tile, without separate per-tile metadata files that would slow -reading. The RF merger needs the same for its recovery fingerprints of input -metadata and index files, which it currently computes by hashing the whole -files with a local 64-bit FNV-1a. - -The current envelope checksums do not provide such an identity: - -- `HandledByCompressionLib` is the Zstandard frame checksum, the low 32 bits of - XXH64, stored at the end of the frame rather than in the envelope header. -- `Crc32c` is 32 bits, computed with a byte-wise table at about 0.5 GB/s. -- `None` stores nothing. - -## Hash - -Use XXH3-64 (`XXH3_64bits`, seed 0) over the uncompressed payload bytes. -Hashing uncompressed bytes makes the identity independent of the Zstandard -settings and of recompression; hard-linked payloads share it. - -Measured single-threaded on an AMD Ryzen 9 3900X over 1 GiB in memory: - -| Algorithm | Output | Throughput | -|---|---:|---:| -| 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, envelope table | 32 bit | 0.5 GB/s | - -XXH3-64 measured 18.3 GB/s in a Release build (see -[ADR 0005](../adr/0005-xxh3-64-envelope-hashes.md)). A 4096-pixel float tile -with attribution is about 96 MiB uncompressed, so FNV-1a would add about 90 ms -to every write and verified read, XXH3 about 5 ms. XXH3 also mixes much better than FNV-1a. With 64 bits, a changed -payload keeps its hash with probability about 2⁻⁶⁴; there is no adversary, and -each check compares against one recorded value. - -Vendor xxHash (header-only, BSD-2-Clause) with `alp_add_git_repository` at a -pinned release. The copy bundled in Zstandard cannot be reused: it disables -XXH3 and prefixes its symbols with `ZSTD_`. - -Add `io/hash.h` with the enumeration `io::hash::Algorithm { None, Xxh3_64 }` -and `io::hash::data(std::span bytes, Algorithm algorithm)`, -returning the hash as `std::vector`: empty for `None` and 8 bytes -in big-endian order, the canonical xxHash form, for `Xxh3_64`. A value -outside the enumeration is a programming error and fails an `ASSERT`. The -algorithm-specific functions stay in `io/hash.cpp`, the only file that -includes xxHash. Longer or stronger hashes are added later as new `Algorithm` -values. - -## Envelope changes - -1. Change the envelope magic. Envelope files of all stores change: raster - tiles, metadata and index, mesh and octree stores, DAG files and tool - records. Existing files are no longer readable. No real datasets exist yet, - so no migration is needed. -2. Replace `ChecksumAlgorithm` with `io::hash::Algorithm` and renumber - `CompressionAlgorithm` from zero as - `{ None, ZstdBestCompression, ZstdDefaultCompression }`. `Crc32c`, - `HandledByCompressionLib`, `ZstdBestCompressionWithChecksum` and - `ZstdDefaultCompressionWithChecksum` are removed, together with the CRC-32C - implementation. `checksum_algorithm` parameters and fields become - `hash_algorithm`. -3. The envelope field `checksum` becomes `hash` of type - `std::vector`, holding `hash::data` of the uncompressed payload. - The reader recomputes it and compares; a mismatch, including a hash of the - wrong length, is `CorruptData`. The Zstandard algorithms write frames - without the frame checksum; the reader no longer requires one. -4. Separate compression from hashing. Combining them saves no work: the - current `compress_with_checksum` already hashes and compresses in two - passes, and a fused streaming pass would save at most a few milliseconds - per tile against a few hundred for Zstandard. `io/compression.h` keeps only - `CompressionAlgorithm`, `compress(data, algorithm)` returning the - compressed bytes and `decompress(data, algorithm, max_size)`; - `CompressedData`, `compress_with_checksum` and `checked_decompress` are - removed. Both functions keep returning `Expected` for Zstandard and - size-limit failures, but an algorithm outside the enumeration fails an - `ASSERT`. The envelope computes the payload hash with `hash::data` when - writing and verifies it after decompressing; `io/hash.h` keeps xxHash out - of `envelope.h`. -5. Every hash algorithm is valid with every compression algorithm, including - `None` with `None`, which stores no integrity check. The combination check - goes. Enumerator values read from files are untrusted, so the envelope - checks both ranges while decoding the header, before `hash::data` or - `decompress` sees them; a value outside the enumeration is `Unsupported`. - A `Header` returned by the envelope therefore always holds valid - enumerators. -6. Split the envelope into `Header` (magic, class name, class version, hash - algorithm, hash, compression algorithm, uncompressed size) and - `Envelope { Header header; std::vector compressed_data; }`. - zpp_bits serializes the nested aggregate field by field, so this does not - change the layout. Class names are limited to 128 bytes by a - `static_assert` in `PayloadSchema`, hashes to 64 bytes. `max_header_size` - is derived from these limits and the field sizes. -7. Make `Xxh3_64` with `ZstdDefaultCompression` the default for `serialize`, - `write_to_path`, `TileCodec`, `tile_codec::from_name` and the raster-store - `CreateOptions`. `terrainlib/store` does not handle envelopes; its codecs - call `io::envelope` directly, so every envelope-based store, including the - mesh stores, gets the new default without store changes. -8. Add `io::envelope::read_header(path)`, returning the `Header`. It reads at - most `max_header_size` bytes; a shorter file is fine as long as it contains - the header, and payload bytes in the read prefix are ignored. It decodes - with a zpp_bits allocation limit of `max_header_size`, because zpp_bits - resizes containers before checking their length against the input. A header - that fails to decode is `CorruptData`. It validates the magic and the - enumerator ranges, but not the class name. Callers take the recorded - payload hash from `Header::hash`; it is neither read from nor verified - against the payload. - -## Byte buffers - -Remove the `io::envelope::Bytes` typedef; its users spell -`std::vector`. Byte buffers use `std::byte` instead of `uint8_t` -where they hold raw bytes: `io::read_bytes_from_path` returns -`std::vector`, and `io::write_bytes_to_path` takes -`std::span`. Their callers switch accordingly, including -`io/image.cpp`, the JSON readers of the attribution table and the RF merger -priorities, the RF tile provider and the tests. Casts move to interfaces that -require other types, such as OpenCV buffers; `envelope.h` no longer needs one. - -## Raster tiles - -Raster-store tiles keep a configurable hash algorithm in `CreateOptions`, with -`Xxh3_64` as the default. A comment at that default states that the tile -hashes serve as content identities, for example for TB dependency records, and -that `None` leaves tiles without one. Tiles that `copy_from` hard-links keep -the hash of their source. - -The RF merger has no hash option. `merge::Options::checksum_algorithm` is -removed, and the merger uses the `CreateOptions` default. `--compression` maps -`zstd`, `zstd-best` and `none` to `ZstdDefaultCompression`, -`ZstdBestCompression` and `None`; its help text no longer mentions CRC32C. - -## RF merger - -Replace the FNV-1a fingerprints with the header hashes of -`raster_store.metadata` and `raster_store.index`, read with `read_header`. -`Fingerprint` keeps the canonical path and stores both hashes as -`std::vector`. A file whose header records no hash is an -`Unsupported` error, because an empty hash would match any content. The source -attribution table stays out of the fingerprint. Remove the local hash. - -## Documentation - -- Update the envelope and tile codec sections of - [storage format](storage-format.md). -- Add ADR 0005 recording the XXH3-64 decision, the measurements and the - rejected alternatives: FNV-1a, CRC-32C, the Zstandard frame checksum, a - snapshot UUID and the SHA family. It also records the removal of CRC-32C and - of the Zstandard frame checksum variants, and the binary hash field. -- Update [implementation status](implementation-status.md) after - implementation. - -## Verification - -- `hash::data` with `Xxh3_64` against known xxHash test vectors. -- Envelope: round trips for every compression and hash combination, rejection - of the old magic and of unknown enumerator values by both `deserialize` and - `read_header`, detection of corrupted payloads with `Xxh3_64` and of hashes - with the wrong length. -- Header reads: a class name at the length limit, a file with a short class - name and an empty payload, a truncated header, and a corrupt length prefix, - which fails without a large allocation. -- The header hash agrees with `hash::data` of the payload from a full read, - and is empty for `None`. -- A Catch2 benchmark of `hash::data` with `Xxh3_64` on the same machine and - data size as the table above, with the project's build flags. -- Raster store and RF merger suites, then all CI suites; tests that compare - envelope bytes or sizes may need updates. diff --git a/docs/raster-store/content-hash-status.md b/docs/raster-store/content-hash-status.md deleted file mode 100644 index de3c65fa..00000000 --- a/docs/raster-store/content-hash-status.md +++ /dev/null @@ -1,41 +0,0 @@ -# Envelope content hashes: status - -Implementation checklist for the [content-hash plan](content-hash-plan.md). - -- [x] Vendor xxHash and add `io/hash.h` with `hash::Algorithm` and `hash::data` -- [x] Switch `io::read_bytes_from_path` and `io::write_bytes_to_path` and their - callers to `std::byte` -- [x] Reduce `io/compression.h` to `compress` and `decompress`; remove CRC-32C - and the frame-checksum variants -- [x] Envelope: new magic, `Header` split, binary `hash`, enumerator range - checks, name and hash limits, defaults, `read_header`; remove `Bytes` -- [x] Raster tiles: `TileCodec`, `tile_codec::from_name` and `CreateOptions` - use `hash_algorithm` -- [x] RF merger: no hash option, `--compression` mapping, fingerprints from - header hashes -- [x] Tests: envelope, header reads, hash vectors, benchmark; adapt existing - suites -- [x] Documentation: storage format, ADR 0005, implementation status -- [x] Build, all CI suites, formatting (Linux GCC Debug, 2026-09-30) - -Notes: - -- xxHash is pinned at v0.8.3 and linked privately into terrainlib. -- `read_header` and `detail::validate_header` live in the new `io/envelope.cpp`. - `deserialize` decodes the whole envelope in one pass and both paths validate - the header with the same function. Only `read_header` decodes with an - allocation limit of `max_header_size`; a corrupt length prefix can still - cause a transient allocation of up to 1 GiB in `deserialize`, as before. -- `io::read_bytes_from_path` gained an optional maximum size for the header - read. -- The image API (`io::image::encode`, `decode_rgb8`, `decode_rgba8`), the RF - builder's `HttpClient` and its test server also use `std::byte`. `encode` - copies once from OpenCV's `std::vector`. -- Invalid enumerators in `hash::data` and `compress` fail with `PANIC`, which, - unlike `UNREACHABLE`, also aborts with a message in release builds. -- XXH3-64 measured 18.3 GB/s over 1 GiB in a Release build, recorded in - ADR 0005. -- Added an RF merger test: fingerprints equal the header hashes, survive - recompression, and files without a hash are rejected. -- clang-format was applied to new files and functions. `envelope.h` keeps its - existing, unformatted style, and one-line changes were left as they were. diff --git a/docs/raster-store/gdal-value-mapping-research.md b/docs/raster-store/gdal-value-mapping-research.md deleted file mode 100644 index 0f78831d..00000000 --- a/docs/raster-store/gdal-value-mapping-research.md +++ /dev/null @@ -1,123 +0,0 @@ -# GDAL value-mapping research - -Research date: 2026-09-13. This note informs the halo design; recommendations -below are not accepted implementation decisions. Source inspection targets -GDAL 3.10.3, matching the locally installed `gdalinfo` and project build. - -## Can GDAL identify linear versus sRGB samples? - -Sometimes, through optional format metadata. There is no general -`Linear`/`SRGB` answer in band colour interpretation: GDAL's enum identifies -roles such as red, green, blue, alpha and spectral bands, without a transfer -function. RGB bands therefore do not establish sRGB encoding. -[GDAL colour-interpretation enum](https://github.com/OSGeo/gdal/blob/v3.10.3/gcore/gdal.h) - -The dataset's `COLOR_PROFILE` metadata domain can provide evidence: - -| Driver | Available evidence | -| --- | --- | -| GeoTIFF | Base64 `SOURCE_ICC_PROFILE`, or primaries/white point and per-channel TIFF transfer-function tables. An ICC profile takes precedence over the other profile tags. | -| PNG | ICC profile and profile name, an explicit sRGB marker, or `PNG_GAMMA` and chromaticities. | -| JPEG | Base64 embedded ICC profile; EXIF metadata can also be exposed. | - -These are optional source descriptions, not a classification into our two -mapping values. GDAL also warns that profile metadata describes original raw -samples and may not apply after its automatic conversion to RGB. -[GeoTIFF driver](https://gdal.org/en/stable/drivers/raster/gtiff.html#color-profile-metadata), -[PNG driver](https://gdal.org/en/stable/drivers/raster/png.html#color-profile-metadata), -[JPEG driver](https://gdal.org/en/stable/drivers/raster/jpeg.html#color-profile-metadata) - -In GDAL 3.10.3, PNG's `LoadICCProfile()` first reads an ICC profile; otherwise -an sRGB chunk produces `SOURCE_ICC_PROFILE_NAME=sRGB`; otherwise it exposes -gamma/chromaticity metadata. If an ICC payload exists, its arbitrary profile -name alone is insufficient evidence. The application would need to interpret -the profile itself. -[PNG implementation](https://github.com/OSGeo/gdal/blob/v3.10.3/frmts/png/pngdataset.cpp) - -PNG can describe linear samples: a gamma of 1 represents a linear transfer. -Other gamma values and ICC profiles need not be sRGB. In particular, an -approximate gamma of 0.45455 alone does not establish the exact piecewise sRGB -transfer function; the sRGB chunk specifies that separately. -[PNG gamma and sRGB specification](https://www.w3.org/TR/png-3/#11gAMA) - -JPEG/YCbCr decoding and sRGB linearization are separate operations. GDAL's -JPEG driver describes conversion from YCbCr/CMYK to RGB and reports the source -model through `IMAGE_STRUCTURE:SOURCE_COLOR_SPACE`. This does not promise -linear-light output. Compression and channel roles are therefore insufficient -evidence for selecting `raster_store::pixel::Mapping`. -[JPEG reading behaviour](https://gdal.org/en/stable/drivers/raster/jpeg.html) - -## VRT mosaics - -VRT can store dataset metadata in named domains. That capability does not -mean a mosaic automatically inherits or reconciles source profiles. -[VRT format](https://gdal.org/en/stable/drivers/raster/vrt.html) - -In GDAL 3.10.3, `gdalbuildvrt` checks band colour interpretation but does not -copy or compare `COLOR_PROFILE`. `VRTDataset::GetMetadata()` returns the -VRT dataset's own metadata, apart from its special XML domain; it does not -aggregate underlying source colour profiles. Consequently, a VRT containing -RGB sources with different transfer functions cannot be assumed homogeneous -merely because its creation succeeded. This conclusion follows from the -versioned implementations, rather than a guarantee about every VRT-producing -tool. -[VRT builder](https://github.com/OSGeo/gdal/blob/v3.10.3/apps/gdalbuildvrt_lib.cpp), -[VRT metadata implementation](https://github.com/OSGeo/gdal/blob/v3.10.3/frmts/vrt/vrtdataset.cpp) - -## Swissimage evidence - -Swisstopo documents SWISSIMAGE download imagery as COG, three 8-bit RGB bands, -with JPEG95 compression. That product page does not specify sRGB or a -transfer function. -[Official SWISSIMAGE product specification](https://www.swisstopo.admin.ch/en/orthoimage-swissimage-10) - -The existing RF design's -[reference TIFF](https://data.geo.admin.ch/ch.swisstopo.swissimage-dop10/swissimage-dop10_2024_2682-1199/swissimage-dop10_2024_2682-1199_0.1_2056.tif) -was inspected again on the research date with GDAL 3.10.3 using -`gdalinfo -json -mdd all` on its `/vsicurl/` URL. Observed results: - -- 10000 by 10000 pixels; bands identified as Red, Green and Blue. -- `LAYOUT=COG`, `SOURCE_COLOR_SPACE=YCbCr`, - `COMPRESSION=YCbCr JPEG`, `JPEG_QUALITY=95`. -- No `COLOR_PROFILE` domain or explicit transfer-function metadata reported. - -This specific file therefore supplies no GDAL profile evidence that proves -sRGB. Its absence does not prove Linear either. The observation applies to -this reference file, not every swisstopo product or delivery. - -## Selected policy — 2026-09-15 - -The user selected a type-based convention: default three- and four-component -unsigned 8-bit pixels to `pixel::Mapping::SRGBA`, otherwise `Linear`, with an -explicit override. RGB channels use the sRGB transfer function; alpha, if -present, remains linear. Persist the resolved mapping in store metadata and -document the defaults and override in RF CLI help. The shared mapping type -belongs in `raster_store/pixel.h` and is used by both the planned snapshot -metadata and raster-store scaler wrappers. See -[Tiles with halo](tiles-with-halo.md) for the accepted contract. - -This replaces the earlier recommendation below; it does not change the -findings about what GDAL metadata can establish. - -## Earlier recommendation — superseded - -Allow an explicit RF input mapping for the prepared dataset, including a VRT. -Persist the resolved linear or sRGB interpretation in the raster-store -manifest. Keep unknown/automatic selection at the import boundary if needed; -it need not become a third stored mapping value. - -Use trustworthy metadata when available. Treat the agreed JPEG/PNG imagery -default as a policy assumption, not format-based proof, and allow a declared -linear input to override it. Do not silently infer Linear for an untagged TIFF -or VRT. An explicit SRGB choice is a practical way to import the intended -Swissimage VRT, but this research has not established its transfer function. - -A prepared mosaic should have one agreed mapping across its sources. Mixed -mappings require normalization before treating it as one dataset. Arbitrary -ICC/gamma encodings cannot all be represented by the proposed two-value enum; -converting those inputs to a supported mapping is a separate scope decision. - -Reading profile metadata does not itself linearize samples for subsequent -filtering. If linear-light filtering is also required during RF reprojection, -that is an additional design question beyond using the mapping during halo -downsampling. diff --git a/docs/raster-store/halo-implementation-plan.md b/docs/raster-store/halo-implementation-plan.md deleted file mode 100644 index 06825a13..00000000 --- a/docs/raster-store/halo-implementation-plan.md +++ /dev/null @@ -1,259 +0,0 @@ -# Halo extraction implementation plan - -Status: implementation approved and completed, 2026-09-22; final verification -is recorded below. Commit and push were explicitly authorized for halo work; -GDAL NoData filling and driver research are excluded. -Conversion tuples stay in the scaling interface. The accepted behavior is -defined by [Tiles with halo](tiles-with-halo.md) and -[Raster scaling](scaling.md), including its windowed-scaling extension. - -## Scope and baseline - -Implement windowed scaling, snapshot halo/value-mapping metadata, halo -extraction, and RF CLI propagation of the mapping. Reuse the implemented -views, copies, conversion tuples, and paired scaler. `radix::Raster` and -`raster_store::Tile` retain their current members. Work stays in terrain-builder; -the earlier proposed radix refactor has been superseded by the existing -`terrainlib/raster/algorithm` implementation. - -The current storage API returns storage and metadata together from `open` -and `create`. Preserve that interface. Keep format version 1 and change its -metadata representation directly, as agreed. Tile buffers still describe -their complete stored raster; no new tile payload fields are needed. - -The working tree already contains edits to the raster-store README, scaling -contract, and halo design, plus the untracked GDAL NoData-filling plan. Recheck -and report status before implementation, preserve existing work, and stay on -the current branch. GDAL NoData filling belongs to the separate plan. - -## 1. Add windowed scaling - -Primary files: `src/terrainlib/raster/algorithm/scale.h`, -`scaling_detail.h`, `src/terrainlib/raster_store/scaler.h`, and -`unittests/terrainlib/raster_algorithm.cpp`. - -1. Add the agreed `output_offset` overloads to generic and paired `scale`. - Allocating overloads also take `output_size`; both are `glm::uvec2`. - Keep conversion-tuple overloads and destination-last call conventions. -2. Separate validation of a requested window from validation of a full - output allocation. Calls without an offset still require the exact full - destination size. Validate both paired destinations and overlap before - either is written. Use unsigned, overflow-safe bounds and shift checks; - do not require construction of the full conceptual output dimensions - when only a small window is requested. -3. Extend the existing interpolation implementation to use the conceptual - output offset. Preserve integer quotient/remainder phase calculation, - exact nearest-neighbour copies, and zero-level copying. Compute only the - requested pixels. Keep whole-output calls on the same implementation. -4. For reduction, derive the required source/intermediate rectangles - backwards from the requested output. Preserve global block alignment, - the complete Lanczos support, separable pass ordering, and encoding after - every factor-two step. Restrict intermediates to the necessary rectangles. - Reuse `window_transform`, ordinary views, and clamped-view semantics. -5. Window attribution with the same output coordinates: nearest neighbour - for upscaling and repeated mode reduction for downscaling. Keep zero as - an ordinary attribution value. Reuse reduction internals where needed; - a new public windowed custom-reducer interface is not required. - -Stage validation: - -- Compare allocated and destination-view windows with full-scale-then-crop, - including non-aligned offsets, rectangular and strided views, zero levels, - nearest neighbour, bilinear, box, and every Lanczos support radius. -- Check multi-level integer rounding, SRGBA encoding, and mode attribution - against the existing full-output behavior, including zero attribution. -- Check invalid offsets/extents, insufficient halo, overlap, and paired - destination mismatches before writes. Without an offset, reject both - undersized and oversized destinations. -- Request a tiny window across a 20-level upscale, including a case whose - conceptual full result cannot be allocated as an ordinary raster. Verify - the expected samples without allocating or visiting the complete result. -- Retain existing exact-copy/conversion-bypass and independent kernel tests. - -## 2. Extend snapshot metadata and creation - -Primary files: `src/terrainlib/raster_store/io/manifest.h/.cpp`, -`pixel.h`, `storage.h`, codec construction call sites, and -`unittests/terrainlib/raster_store.cpp`. - -1. Replace metadata `width`/`height` with `stored_tile_size`, - `nominal_tile_size`, and `halo_width`; add the existing `pixel::Mapping` - as `value_mapping`. Nominal/stored defaults are 4096 and halo defaults to - zero. Keep one shared mapping enum. -2. Replace `CreateOptions::tile_dimensions` with - `unsigned nominal_tile_size = 4096` and add `unsigned halo_width = 0`. - Represent the optional explicit mapping choice with - `std::optional value_mapping`; resolve an omitted choice - in `create()`. RGB8/RGBA8 default to SRGBA; other types default - to Linear. Metadata always contains the resolved enum, not an optional. -3. Validate positive power-of-two nominal size, `halo_width <= nominal_tile_size`, - and the checked equality - `stored_tile_size = nominal_tile_size + 2 * halo_width`. Use the existing - metadata validation/error conventions. The extractor asserts its - minimum nominal side of 64; this does not require making small standalone - codec/view test fixtures larger. -4. Construct codecs using square dimensions derived from stored size. Update - affected callers and metadata fixtures. Preserve the existing open/create - ownership model, explicit checkpoints, and publication behavior. -5. Validate metadata representation and known enum values, but do not reject - a valid mapping merely because a downstream scaler cannot process that - mapping/pixel-type combination. Such rejection stays in that operation. - -Stage validation: - -- Round-trip metadata and tile payloads with zero and nonzero halos; include - a power-of-two interior whose stored side is not a power of two. -- Verify type-based defaults, explicit overrides, and persistence of the - resolved mapping, including overrides unsupported by numerical scaling. -- Reject inconsistent metadata sizes, invalid nominal sizes/halos, arithmetic - overflow, and unknown mapping enumerators through the appropriate API. -- Preserve payload bits, read/write dimension checks, storage ownership, - checkpoint, hard-link, and publication regressions. No legacy upgrade tests. - -## 3. Implement physical-source halo extraction - -Add `src/terrainlib/raster_store/read_tile_with_halo.h`, exposing -`raster_store::read_tile_with_halo`, with implementation helpers kept private. -Register the header in `src/terrainlib/CMakeLists.txt`. Add focused tests in -`unittests/terrainlib/raster_halo.cpp`, registered in `unittests/CMakeLists.txt`. - -The function takes storage and metadata by const reference, a tile ID, the -requested halo width, and `raster::algorithm::Interpolation`. It returns -`Expected>`. Callers supply metadata associated with the store; -perform structural/dimension validation without reopening metadata per read. - -1. Validate the ID, physical centre, metadata, and requested width. Require - `Leaf` or `Inner`; reuse existing missing/invalid-ID error handling. - Assert nominal side at least 64. Propagate indexed-payload read failures. -2. For a stored halo, reject requests exceeding it and copy the exact - requested crop from both rasters. For a zero-width request, return the - exact interior. These paths do not fetch neighbours or invoke scaling. -3. Otherwise allocate the output once and copy the centre through views. - Partition the surrounding strips/corners into disjoint output rectangles. - Use tile-local addressing, horizontal wrap, and explicit vertical limits. -4. Resolve each rectangle from the index before loading its payload: - same-zoom physical tile first; otherwise first physical descendants per - branch, at most four levels; nearest physical ancestor for unresolved - coverage, including branches stopped by the limit. Zero attribution does - not affect source selection. -5. Group assigned rectangles by physical tile ID. Load each supplying tile - once, process all its assignments, then release it. Reuse the already - loaded central tile if wrapping selects it. The grouping is per extraction; - no persistent cache or cache configuration is added. -6. Use view copies for equal-resolution regions. Use paired windowed scaling - with Box for descendants and the requested interpolation for ancestors. - Clamp bilinear support to the complete supplying ancestor interior; do not - load support neighbours or clamp independently at processing cutouts. -7. For missing physical coverage only, copy through a clamped view of the - central interior and leave synthesized attribution zero. Supplied or - resampled zero-attribution payloads remain untouched by this fallback. - -Stage validation uses synthetic stores and a counting/failing test codec at -the existing storage seam, without adding production test hooks: - -- Exact centre/crop results, physical inner centres, all edges/corners, - zero/full permitted widths, invalid requests, and physical read errors. -- Same-zoom preference, stopping at physical inner descendants, mixed-depth - sibling branches, four-level cutoff, ancestor fallback, and missing coverage. -- One read per supplying tile across multiple assigned regions, no reads - beyond the descent cutoff, and no neighbour reads for stored-halo crops. -- Horizontal seam/root wrapping, vertical limits, and corners crossing them. -- Zero-attribution numerical contributions and preservation, synthesized - zero attribution independent of the central pixel's attribution, and - exact nonfinite copies where conversion is bypassed. -- Both ancestor interpolation choices, full-ancestor clamping, distant - ancestor windows, and mapping errors only when an unsupported operation - is actually used. Verify scaler integration without duplicating its kernel - mathematics in extractor tests. - -## 4. Propagate value mapping through RF creation and CLI - -Primary files: `src/rf_builder/run.h/.cpp`, `gdal/build.h/.cpp`, -`gdal/cli.cpp`, `tiles/build.cpp`, `tiles/cli.cpp`, and the GDAL/tiles -`inputs.h` records. Extend existing tests in -`unittests/rf_builder/import.cpp` and `online.cpp`. - -1. Add `--value-mapping linear|srgba` to both commands and carry the optional - override through their existing options, without restructuring the options - hierarchy. Resolve the default from the produced pixel type. Document - RGB8/RGBA8 defaults, Linear otherwise, override precedence, and linear alpha. -2. Pass nominal size, explicit zero halo, and the selected mapping into store - creation. Update RF metadata checks to the revised fields. -3. Include the resolved mapping in both input records and cache checks. Compare - nominal/stored dimensions, zero halo, mapping, and codec before hard-link - reuse. An omitted default and an explicit equivalent mapping remain - compatible; a different effective mapping fails before production/HTTP. - Revise current version-1 records directly, consistent with the agreed - absence of compatibility requirements, and update aggregate fixtures. -4. This stage declares the mapping of stored payloads. It retains existing - import filtering: GDAL's current raster sampling and the online JPEG - worker's current linear-light fallback interpolation. The override does - not add source-profile conversion or change these import kernels. This - scope is explicit because changing import filtering would be additional - behavior beyond metadata propagation and halo extraction. - -Stage validation: scalar/RGB defaults, explicit overrides, zero RF halo, -persisted metadata, CLI help and invalid values, equivalent/mismatched cache -records, inconsistent cache metadata, and online rejection before HTTP. -Retain current import pixel-equivalence, cancellation, and cache regressions. - -## 5. Integration, documentation, and verification - -1. Update storage-format fields and examples, architecture, sampling/halo - references, RF CLI documentation, and implementation status to describe the - completed behavior. Keep this plan as the stage checklist and record actual - validation results rather than treating historical results as new evidence. -2. Run focused Catch2 tests after each stage. Once stages are integrated, run - the terrainlib, RF-builder, and DAG-builder regression suites; include - dependent build targets affected by the metadata API rename. Run affected - tests under existing sanitizer configurations when available and compile - the changed template code in an existing optimized configuration. -3. Use the Qt C++ skill for lint/review and `clang-format-21` on changed C++ - sections. Check standalone public-header compilation, `git diff --check`, - and changed-file line endings with - `git ls-files --eol --cached --others --exclude-standard`. - -The verified local starting configuration is `build/Desktop_Debug` (GCC, -tests enabled). The relevant targets are `rf-builder`, -`unittests_terrainlib`, `unittests_rfbuilder`, and `unittests_dagbuilder`. -Reuse this configuration and the documented PROJ/GDAL test environment. -The renderer-only `dev_driver.py` workflow does not apply to this project. - -Build/test entry points: - -```sh -cmake --build build/Desktop_Debug --target rf-builder unittests_terrainlib unittests_rfbuilder unittests_dagbuilder --parallel 6 -build/Desktop_Debug/unittests/unittests_terrainlib '[raster-algorithm],[raster-store],[raster-halo]' -build/Desktop_Debug/unittests/unittests_rfbuilder -``` - -Use `[raster-halo]` for the new extraction fixtures. Full regressions follow -the focused tests, using the existing exclusion for the unrelated mesh clipping -benchmark. No runtime timing threshold is required: tiny-window large-gap -tests and exact source-read counts establish the key bounded-work properties. - -## Completion criteria - -- Each stage's behavior and focused checks pass; integrated regressions and - required formatting/lint checks are reported with their actual outcomes. -- Halo extraction uses the shared algorithms, preserves the central/cropped - payload exactly, obeys physical-source selection, and avoids full upscaled - intermediates and duplicate source reads. -- Metadata and CLI defaults/overrides round-trip and cache reuse checks the - effective mapping and halo geometry. -- Documentation matches the implemented format and interface. Report any - remaining limitations, final Git status, and a reminder to commit; do not - commit, push, or change branches without the corresponding authorization. - -## Completion record — 2026-09-22 - -All five implementation stages are complete. Window reduction uses a -factor-aligned source view with the cumulative kernel halo, then the existing -staged reducer; this limits intermediates while preserving the full result's -stage alignment and encoding. Ancestor windows rebase into source coordinates -before scaling, retaining unsigned phase arithmetic and full-source clamping. - -The current validation results are recorded in -[implementation status](implementation-status.md#halo-extraction--2026-09-22). -The user authorized committing and pushing halo extraction and related docs, -while leaving GDAL NoData handling and driver research uncommitted. diff --git a/docs/raster-store/implementation-plan.md b/docs/raster-store/implementation-plan.md deleted file mode 100644 index 515aaba0..00000000 --- a/docs/raster-store/implementation-plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Raster-store tile-storage implementation plan - -Status: implemented and verified; see [implementation-status.md](implementation-status.md). -The accepted storage -contract is in [architecture.md](architecture.md) and -[storage-format.md](storage-format.md). - -## Scope - -Implement persistent tile storage in `terrainlib`, using the existing shared -store types. RF/TB builders, merging, spatial window reads, sampling, and -enforcing read-only access through the type system are separate work. - -Track each stage and its verification evidence in -[implementation-status.md](implementation-status.md). - -## Accepted implementation decisions - -- Attribution JSON is a top-level array of complete objects with snake_case - keys, numeric resolution in metres per pixel, and verbatim date strings. - There is no clearing operation or special interpretation of cleared entries. -- Tile reads and writes do not scan attribution pixels to validate references. - The later RF builder rejects attribution indices outside the table or the - supported index range before producing tiles. -- Do not add raster allocation limits derived from the envelope's current - size limit. Envelope limits remain an envelope concern. -- Fix flush and close error reporting in `io/bytes.cpp` for all callers. -- Shared-store changes are allowed where useful, retaining mesh-store - compatibility. Keep general mechanisms in `store` and raster policy and - format adapters in `raster_store`. -- Persist a raster codec name in metadata and resolve the reader from that - string, independently of filename extensions. Preserve the existing mesh - extension-based resolver. Use the existing resolver mechanism rather than - adding a registry of hypothetical future codecs. - -## Implementation sequence - -1. Add the templated tile type and JSON attribution-table reader. Default - attribution rasters to 0; keep every table slot a complete object. Implement - the agreed three-location lookup, string dates, and strict index limit at - table/API boundaries, without scanning tile attribution rasters. -2. Add the XYZ layout and separate raster-specific index and metadata - envelopes. The index contains hierarchy entries; metadata contains layout, - codec, payload type, and runtime tile dimensions. Raster-local opening - reads both and configures the codec and existing `IndexedStorage`. - Creation writes immutable snapshot metadata; checkpoints write only the - hierarchy index through a temporary file followed by replacement. -3. Add the templated `.amort` codec for raster dimensions and native buffer - bytes, with the agreed layout/byte-count checks and configurable envelope - compression. Instantiate only types used by callers and focused tests. -4. Add create/open, explicit checkpointing, incomplete-snapshot opening, and - publication. First fix shared byte writes to report flush and close errors. - Publication saves the final index and closes the `.part` handle, then renames - the directory to its final path and returns success. It does not reopen the - snapshot or scan the payloads. Table copying supports self-contained - snapshots without editing attribution entries. -5. Use the existing copy machinery for tiles the caller has decided can be - reused. The caller owns payload type, dimension, encoding, and attribution - compatibility decisions. Cross-root hard links remain supported on the - same filesystem. - -## Shared-store integration - -Hard-link eligibility is a caller decision. No new codec compatibility hook -or automatic metadata-compatibility gate is planned for `copy_from()`. -Storage continues to report failures from the actual copy/link operation. - -Dimensions are stored in the separately written metadata file. The index -writer remains a function pointer and persists only the current hierarchy; -it needs no capturing callback. Extend shared opening only as needed to use -already decoded metadata and the existing codec resolver, retaining existing -mesh entry points and selectors. - -## Verification - -Use focused tests for scalar and packed-GLM round trips, exact preservation -of payload bits, non-power-of-two square dimensions, attribution lookup and -slot-0 behavior, and the retained attribution-index limit. Exercise normal -envelope/adapter errors, dimensions retained across checkpoints, explicit -incomplete opening, publication, and rejected destination collisions. - -Verify cross-root hard-link reuse with a copied local attribution table, -including continued TB reads after removing the source RF fixture. Verify -that existing shared-store and octree persistence/copy tests retain their -behavior. Run the relevant Linux build and tests through the configured -project workflow. - -The renderer-only `dev_driver.py` rejects this repository. Use the existing -terrain-builder CMake build and this repository's Linux CI test configuration; -do not extend the renderer driver as part of raster storage. diff --git a/docs/raster-store/implementation-status.md b/docs/raster-store/implementation-status.md deleted file mode 100644 index 28f0c588..00000000 --- a/docs/raster-store/implementation-status.md +++ /dev/null @@ -1,505 +0,0 @@ -# Raster-store implementation status - -## Envelope content hashes — 2026-09-30 - -Implemented the [content-hash plan](content-hash-plan.md); progress is tracked -in the [status checklist](content-hash-status.md) and the decision in -[ADR 0005](../adr/0005-xxh3-64-envelope-hashes.md). - -- Envelopes have a new magic, a `Header` with a binary `hash` of the - uncompressed payload, and renumbered enumerators: - `io::hash::Algorithm { None, Xxh3_64 }` and - `CompressionAlgorithm { None, ZstdBestCompression, ZstdDefaultCompression }`. - Files written before this change are unreadable. -- `io::hash::data` computes hashes; xxHash v0.8.3 is included only by - `io/hash.cpp`. `io/compression.h` only compresses and decompresses. -- `io::envelope::read_header` reads at most 222 bytes and bounds its - allocations to that size. It shares the magic, enumerator and size-limit - checks with `deserialize`. -- The RF merger fingerprints its inputs with the header hashes of their - metadata and index files. -- Byte buffers of `io::read_bytes_from_path`, `io::write_bytes_to_path`, the - image encoder and decoder and the RF builder's `HttpClient` are `std::byte`. - -## RF merger — 2026-09-28 - -Implemented the [merger design](rf-merger-design.md) as `src/rf_merger` with -the `rf-merger` executable, `rfmergerlib` and `unittests_rfmerger`. -`rf_builder::TilePool` moved unchanged to `raster_store::TilePool`. - -- The partition is a lazy depth-first cursor over both indices, - `rf_merger::partition::Cursor`, rather than `store::traverse`: the generic - traversal visits one index through callbacks and cannot feed the pool - lazily. An index-only count pass supplies progress totals. -- Leaves with a single native supplier and completed cache leaves are - hard-linked by the coordinator without payload reads; all other leaves are - produced by workers. -- A supplier that must be upscaled is read with `read_tile_with_halo` only when - the Lanczos-3 support window leaves its interior; otherwise an ordinary load - suffices. Zoom gaps above 30 levels are rejected, as in the halo reader. -- Fingerprints store the XXH3-64 payload hashes recorded in the envelope - headers of the metadata and index files; a file without a hash is rejected. -- `--compression` accepts `zstd` (default), `zstd-best` and `none`; every - choice writes XXH3-64 tile hashes. -- If cache statistics are missing or corrupt, restored tiles are reported as - "restored without statistics" and the statistics as incomplete. -- Error handling follows the three tiers in the - [code style](../code-style.md#error-handling). Reusable merger functions - (priorities, input records, statistics files, partitioning) return - `Expected` or plain values. The merge itself throws `Error::Exception` via - `Error::raise` and `Error::throwing_unwrap`, and `rf_merger::merge::run` - returns the report directly. Results that fail only through a merger bug - use `ASSERT` or `Error::asserting_unwrap`. -- Preflight rejects the sRGB mapping for scalar payloads and zoom gaps above 30 - levels before creating the output. A failure to read or hard-link cache - tiles stops the merge. -- SIGINT and SIGTERM cancel gracefully: queued work is discarded, active - tiles are finished and saved, statistics and index are checkpointed, and - `run` throws `Cancelled`, which `rf-merger` maps to exit status 128 plus the - signal number. When any other exception unwinds, a guard writes the - statistics before the output storage saves its index, so a failed merge is - recoverable with consistent statistics. -- Pool workers catch `Error::Exception` and return the `Error`, which the - coordinator rethrows; `TilePool` is unchanged. -- `rf-merger` logs errors with their chain and the stack trace captured where - the error was made, and logs assertion failures with their stack trace, to - stderr and `.log`. - -Verification: Debug build of all merger and RF builder targets. The merger -suite has 15 cases covering priority parsing, selection, partitioning, -whole-tile precedence, overridden and single-pixel fine tiles, attribution -holes, agreement with direct halo-reader and paired-scaler calls, RGB8, -input and destination rejection, serial/parallel agreement, hard links with -different output compression, cancellation, recovery after a production failure with -restored, missing and mismatching records, empty inputs, statistics -formatting and the command line. RF builder: 57 cases pass after the pool -move. `Error` tests cover `raise`, `throwing_unwrap` and stack traces that -survive propagation; `asserting_unwrap` aborts and has no automated test. clazy (levels 0-2) and -clang-tidy (bugprone, performance) report only the codebase's existing -`const Key&` convention and the exception-escape note shared with `rf-builder`'s -`main`. A RelWithDebInfo ThreadSanitizer build passes the merger suite and the -10 RF builder `[parallel]` cases with the existing suppressions. Not measured: -memory and runtime at production tile sizes. - -## GDAL import NoData filling — 2026-09-23 - -Implemented the [agreed filling policy](gdal-nodata-filling.md): configurable -five-pixel GDAL fill search, five-pixel separable Gaussian kernel, and scalar -or RGB fallback (default zero). Original valid values and attribution are -preserved; replacements remain unattributed. Planning and reads include the -required temporary halo, world borders are handled explicitly, and periodic -source addressing avoids full-width seam reads. Input schema and processing -versions are 2, rejecting older caches. The Gaussian-kernel helper lives in -`raster/algorithm/window_transform.h`. - -Verification: all targets build in the existing Debug configuration; RF builder -57 cases, terrainlib 568 cases, tile builder 14 cases, SF builder 16 cases, -DAG builder 76 cases, and SF merger 13 cases pass. Focused fixtures verify -source-read counts, in-memory fill scratch, original-value preservation, -nonfinite samples, RGB validity, split windows, polar borders, mixed zooms, -cache rejection/reuse, and parallel operation. Debug timing and peak-memory -measurements, with their scope and limitations, are recorded in the filling -policy document. The accepted driver callback race has a TSan stack suppression; -the existing warp-destination creation lock is unchanged. - -## Halo extraction — 2026-09-22 - -Implemented windowed generic/paired scaling, physical-tile halo extraction, -V1 nominal/stored/halo metadata, and resolved value mappings. Both RF commands -accept the mapping override and validate its effective value for cache reuse. -Conversion tuples remain supported. GDAL NoData filling and driver research -are separate work and are excluded from this change. - -Verification: - -- Focused raster algorithms/storage/halo tests: 93 cases, 206,738 assertions. -- Terrainlib regression: 567 cases, 231,409 assertions, excluding the existing - `mesh::clip_on_bounds benchmark`. -- RF builder: 48 cases, 54,030 assertions; DAG builder: 76 cases, 479 assertions. -- ASan/LSan/UBSan: the same 93 focused cases pass, plus all 48 RF cases - (54,029 assertions), with leak detection, halt-on-error, and the existing - `misc/suppression/ubsan.txt` enabled. An initial RF run without that list - reported the already documented CGAL facade downcasts; the configured run - has no sanitizer diagnostics. -- Seven affected public headers compile independently with warnings as errors. -- GCC 16 compiles both algorithm and halo test translation units at `-O3` - with warnings as errors. An additional GCC 14 `-O3 -Werror` probe hits the - previously documented `std::sort`/median array-bounds warning in unchanged - reducer code; no unrelated reducer change or warning suppression was added. -- Qt C++ lint and six read-only review passes found no confirmed new defects. - Existing lint notices remain on unchanged code; the new optional ancestor - is explicitly initialized. Nonconstant integration tests verify box samples, - bilinear phase/full-ancestor clamping, exact payload bits, and strided windows. -- Changed C++ sections use `clang-format-21`; diff checks and changed-file - Git line-ending checks pass. - -## Online RF import — 2026-09-12 - -The [online import plan](rf-builder-downloader-design.md) was approved in the -implementation session. Initial working tree: clean on `main`, one commit ahead -of `origin/main`. Implementation and verification completed before the later, -separately authorized commit and push. - -Decoded JPEG RGB remains nonlinear. Ancestor fallback uses the standard sRGB -transfer function before/after linear-light bilinear interpolation; native -copies retain decoded bytes. Retry waits start at 500 ms and double, with a one-hour total deadline per -failing tile request (request time plus waits), as confirmed by the user. - -| Stage | Status | Evidence / remaining work | -|---|---|---| -| Baseline and decisions | Verified | Baseline: 30 RF cases, 51459 assertions; sRGB and one-hour total retry deadline confirmed | -| Coordinator and subcommands | Verified | Shared lifecycle, explicit GDAL/tiles commands and bounded parallel subdivision; 47 RF cases pass | -| Provider, HTTP and JPEG | Verified | Strict JSON, bounded retries, decoder and HTTP fixtures pass; Basemap and Gataki configure zooms 4..20, side 256 | -| Adaptive selection and preparation | Verified | Mixed-resolution leaves, native bytes, linear-light fallback, narrow masks, holes, seams and poles | -| Cache, cancellation and progress | Verified | Zero-HTTP cache reuse, partial restart, bounded work, cancellation and out-of-order weighted progress fixtures | -| Integration verification | Verified | Debug RF, full Release/ASan/TSan RF, shared-dependency regressions, downloader-off builds, lint and Release measurement | -| Documentation | Verified | Subcommand examples, accepted provider limits, sRGB/retry behavior, operational bounds and final evidence recorded | - -Stages advance through Not started, In progress, Implemented and Verified; -Blocked records an unresolved dependency. Verification commands, results and -limitations are recorded here as work proceeds. - -The scope and sequence are in [implementation-plan.md](implementation-plan.md). -Tile storage is implemented and verified on Linux/GCC as of 2026-09-08. - -| Stage | Status | Implementation and verification evidence | -|---|---|---| -| Shared foundation | Verified | Shared index, storage, codecs, envelopes, and raster traits; mesh/octree regressions pass | -| Accepted decisions and documentation | Verified | Attribution ownership, JSON contract, version-1 encoding, codec selection, API usage, and shared I/O fix recorded | -| Typed tiles and attribution tables | Verified | `raster_store/Tile.h`, `attribution.h/.cpp`; zero attribution, verbatim fields, malformed slots, lookup precedence, and boundary indices | -| XYZ layout, index, and metadata | Verified | `raster_store/path_layout.h`, `raster_store/io/manifest.h/.cpp`; boundary paths, mixed hierarchy, corrupt topology, metadata errors, and checkpoint persistence | -| AMORT codec | Verified | `raster_store/io/TileCodec.h`; native scalar/packed-GLM bytes, NaN bits, zero-attribution payload preservation, dimensions, byte counts, compression, hash, and version errors | -| Raster scaling and paired attribution | Verified | `raster/algorithm/*.h`, `raster_store/scaler.h`, and `pixel.h`; single-raster conversion/reduction, rectangular strided windows, separable filtering, independent attribution; see [scaling verification](scaling.md#refactor-verification--2026-09-22) | -| Creation, opening, checkpoints, publication | Verified | `raster_store/storage.h`; metadata codec selection, incomplete opening, checkpoint failures, no implicit publication, collision rejection, no payload scan, and `/dev/full` write failure | -| Hard-link integration and regression tests | Verified | `unittests/terrainlib/raster_store.cpp`; cross-root hard links and independent attribution survive RF deletion; all six regression suites pass | - -## Deferred work - -RF builder validation rejects unsupported attribution indices and indices -outside the selected table before producing tiles. Storage does not scan tile -attribution rasters on reads or writes. Clearing entries is not a library -operation. TB builders, sampling, window reads, and enforced read-only access -remain separate work. RF import is tracked below. - -Publication currently uses Linux `renameat2(RENAME_NOREPLACE)`; other platforms -return `Unsupported`. The supported native pixel representation remains x86 -with unpadded GLM. Publication provides normal-operation atomic visibility, -without a crash-durability guarantee. - -## Verification log - -- Initial working tree: clean, branch `main`. -- `dev_driver.py doctor`: rejects this terrain-builder checkout because the - driver supports only `alpine-renderer`. -- Existing build: `build/Desktop_Debug`, configured for this checkout, GCC, - with unit tests enabled. -- Build passed: - - ```sh - cmake --build build/Desktop_Debug --target unittests_terrainlib unittests_tilebuilder unittests_sfbuilder unittests_sfbuilder_finalization unittests_dagbuilder unittests_sfmerger --parallel 6 - ``` - -- Test executables are under `build/Desktop_Debug/unittests`. Linux test runs - used `PROJ_DATA` and `PROJ_LIB` pointing to the build's - `alp_external/proj/share/proj`, and `GDAL_DATA` pointing to - `alp_external/gdal/share/gdal`. - -| Executable and filter | Test cases passed | Assertions passed | -|---|---:|---:| -| `unittests_terrainlib '[raster-store]'` (focused subset) | 24 | 356 | -| `unittests_terrainlib '~mesh::clip_on_bounds benchmark'` | 458 | 24496 | -| `unittests_tilebuilder` | 14 | 389 | -| `unittests_sfbuilder` | 16 | 176102 | -| `unittests_sfbuilder_finalization` | 2 | 16 | -| `unittests_dagbuilder` | 76 | 479 | -| `unittests_sfmerger` (including SF build/merge integration) | 13 | 195 | - -- Total across the six suites: 579 test cases and 201677 assertions, excluding - the separately repeated focused subset. -- Initial tilebuilder and SF-builder runs encountered absent Austrian raster - fixtures. Missing files were restored from the existing CI archive at - `https://gataki.cg.tuwien.ac.at/raw/terrain_builder_unittest_data.tar.gz`; - the affected suites subsequently passed. Existing fixtures were preserved. -- `git diff --check` passed. All changed text files have LF working-tree line - endings matching `attr/text=auto eol=lf`, checked with - `git ls-files --eol --cached --others --exclude-standard`. -- No commits, branches, or pushes were made. - -### 2026-09-10 code-style follow-up - -- Moved manifest I/O to `raster_store::io::manifest`, the AMORT implementation - to `raster_store::io::TileCodec`, and the layout to - `raster_store::path_layout::zoom_xy_google`. Versioned payload types now live - in the corresponding `detail::v1` namespaces; serialized identifiers and - payload layouts are unchanged. -- Moved shared hierarchy validation into `store::Index::validate() const`. -- Missing paths during snapshot rename and attribution copying report - `NotFound`. `io::utils::create_parent_directories` returns `Expected`; - byte and glTF writers propagate its errors. -- `cmake --build build/Desktop_Debug --target all --parallel 6` passed. -- The same six regression suites passed: 582 test cases and 201698 assertions. - Terrainlib now covers 461 cases and 24517 assertions, including missing-path - rename/copy checks and successful repeated parent-directory creation. -- Qt lint reported no new warnings on the lines changed in this follow-up. - `git diff --check` and the Git-attribute line-ending check passed. - -## RF builder — 2026-09-10 - -The [RF-builder plan](rf-builder-design.md) is approved. RGB attribution requires -all three selected channels to be valid. Existing documentation edits were -present before implementation and are preserved. - -| Stage | Status | Evidence | -|---|---|---| -| Shared referenced mask loading | Verified | `terrainlib/vector_mask.h`, mesh compatibility aliases | -| Shared scalar/RGB reader and validity | Verified | `terrainlib/DatasetReader`, per-band validity, all-channel RGB acceptance | -| Adaptive planning and world boundaries | Verified | `RasterTransform`, `rf_builder/planning`, affine and seam fixtures | -| Command and cache input records | Verified | `rf-builder`, `rf_builder/inputs`, CURL enabled | -| Writes, checkpoints, publication, reporting | Verified | `rf_builder/build`, serialized writes/checkpoints, hard links, empty publication | -| File logging and progress estimates | Verified | stderr plus appended `.log`; candidate percentage, remaining time and UTC finish estimate; CLI checks cover populated, all-invalid and no-candidate results | -| Integration and regression verification | Verified | 21 RF cases plus all six existing regression suites; full build and new-code lint pass | - -Baseline existing binaries: raster storage 26 cases/372 assertions; dataset -reading 1 case/72 assertions; mask simplification 2 cases/15 assertions. -The renderer driver rejects this repository; use the existing terrain-builder -CMake configuration and Linux test environment documented above. - -### RF-builder verification - -- `cmake --build build/Desktop_Debug --target all --parallel 6` passed. -- Tests used the existing Linux PROJ/GDAL data environment described above. - -| Executable | Test cases passed | Assertions passed | -|---|---:|---:| -| `unittests_rfbuilder` | 21 | 3768 | -| `unittests_terrainlib` (excluding the existing clip benchmark) | 461 | 24517 | -| `unittests_tilebuilder` | 14 | 389 | -| `unittests_sfbuilder` | 16 | 176102 | -| `unittests_sfbuilder_finalization` | 2 | 16 | -| `unittests_dagbuilder` | 76 | 479 | -| `unittests_sfmerger` | 13 | 195 | -| **Total** | **603** | **205466** | - -- RF fixtures cover JPEG/YCbCr tiled RGB, scalar values, per-channel NoData and - masks, valid black and legacy sentinel values, prepared VRT file boundaries, - original-resolution reads despite overviews, mask holes/boundaries, affine - rotation/skew, mixed zooms, narrow masks, maximum zoom rejection, both - antimeridian sides and filter neighbourhoods, polar-only empty results, - invalid inputs, missing/corrupt/mismatching cache records, hard-link reuse, - unindexed payloads, failed links, command reporting, curved projected edges, - projection-domain exclusions, GDAL threading settings, feature-read errors, - and small sources inside default-size output tiles. -- The rebuilt GDAL opened the Swissimage reference URL through `/vsicurl/`, - reported 10000x10000 pixels and three bands, and successfully read a 32x32 - native-resolution window. The source was not added to the repository. -- Qt lint passes for the new and extracted code. Findings in unchanged mesh - code were left alone. Six read-only review passes covered model/index - contracts, ownership, threading, C++/API correctness, error handling, and - performance. Identified issues were corrected and regression-tested. -- `git diff --check` passed. Working-tree text line endings agree with Git - attributes, verified using `git ls-files --eol --cached --others - --exclude-standard`. -- Initial user documentation edits were preserved. No commits, branches, or - pushes were made. - -### Logging/progress follow-up and Vienna run - -- Rebuilt `rf-builder` and `unittests_rfbuilder`; all 21 RF cases passed with - 3834 assertions after adding persistent success/error and progress checks. -- Six focused review passes found no issues in this follow-up. Qt lint reports - DEP-10 on `std::chrono::duration::count()`; these calls use the required chrono - API, so the container `.size()` recommendation does not apply. -- A full-resolution Vienna DSM import was attempted under - `/data/scratch/codex/rf-vienna-20260910`. Inputs are Austria's - `OeRect_01m_gs_31287.img` and the union of Stadt Wien's official district - polygons. The user approved a scratch attribution entry with unknown source - metadata left blank. The boundary source and exact invocation are recorded - in the scratch directory. Initial planning selected 80 candidate - 4096-square tiles. Partitioning the city polygon on a 2 km grid reduced this - to 63 candidates; its union differs from the original by only - 1.64e-8 square metres before the builder's normal simplification. -- The initial Debug run exposed costly detailed-boundary centre tests. The - user stopped the partitioned-mask import and the optimized build at 14:57 - CEST. The last checkpoint contains 4 of 63 candidates (6.3%), with - 221459620 payload bytes. No final Vienna snapshot was published. - At that point, every accepted source - pixel still received an individual mask test. - -### Mask acceleration follow-up - -- Committed the preceding RF implementation and logging work as `b354cb3` - before starting this separately reviewable change. -- Replaced per-centre ring scans with a persistent CGAL point-location index - over the exact union of the simplified mask polygons. A static boundary-edge - BVH proves when a span of transformed centres lies entirely in one face; - those spans need only one point query. Boundary spans subdivide and fall back - to individual indexed queries. No mask-CRS approximation, pre-transform tile - shortcut, or parallel imports were introduced. -- Differential tests compare against the preceding ring-test behavior for - holes, overlaps, shared edges, concave and disconnected regions, random point - order, long rows, enclosed holes, source-invalid pixels, and moved masks. - The RF suite passes in both Debug and Release: 22 cases, 50955 assertions. - The separate original-Vienna-mask benchmark also matches the reference. -- On the same 4096-point Vienna workload, Release selection time was 33.319 ms - before acceleration, 6.010 ms with persistent point location alone, and - 4.364 ms with the boundary BVH and bulk selection (about 7.6 times faster - than the baseline). Debug times were 2905.693, 156.352, and 86.517 ms. - These are individual measurements including centre transformation, excluding - mask opening and the reference check; they are not full-import speedups. -- Three 4096-square Release tile benchmarks used the original official Vienna - boundary and the Austria DSM. Western boundary tile `13/4464/2840`, central - tile `13/4468/2840`, and eastern exterior tile `13/4473/2840` took 22.127, - 20.651, and 20.891 seconds respectively. Reading/resampling took 13.090–14.396 - seconds, masking 7.079–7.288 seconds, and packing/writing 0.057–0.483 seconds. - The exterior tile had no accepted pixels and was not written. Timings exclude - planning, initial mask loading, index checkpointing, and final publication. -- Both RF targets build in Debug and Release. Six focused review passes found - no defects. Qt lint flags bounded BVH construction, intentional validity - mutation, and chrono `count()` calls; the generic container recommendations - do not apply to these uses. Reproduction instructions are retained under - `/data/scratch/codex/rf-vienna-20260910/mask-acceleration`. -- A fresh Release import using the original official boundary and no cache - started at 18:09 CEST. The user cancelled it after 38/80 candidates (47.5%); - its last checkpoint contained 25 tiles and 1311434910 payload bytes. No final - snapshot was published. Throughput was about 20 seconds per candidate, with - a projected total of 27 minutes. A 45-second sample during the run measured - 99.8% of one CPU core, 2.21 MiB/s of file reads, and no physical reads charged - to the process; those reads were served from cache. -- After cancellation, the user requested deletion of all Vienna run outputs - and logs. Partial snapshots and run/benchmark logs were removed; the Release - build, source inputs, attribution tables, and reproduction scripts were kept. - - -### Parallel tile processing - -- Implemented `--jobs N` (default one), persistent private worker contexts and - a pool bounded to twice the worker count across queued, active and completed - tiles. The coordinator retains all output/index ownership and processes ready - results without waiting for earlier tiles. -- Cancellation discards queued work, finishes active tiles, saves their results, - checkpoints, and retains the unpublished `.part` snapshot and input record. - SIGINT and SIGTERM CLI checks both preserved eight tiles and exited with - statuses 130 and 143. The library cancellation test reopened and reused its - checkpoint successfully. -- Debug and Release RF suites pass: 30 cases and approximately 51458 assertions - (the cancellation test's assertion count varies with the number of active - results saved). Error/storage regressions pass: 61 cases, 674 assertions. - Coverage includes scalar/RGB serial equivalence, holes, cache reuse, bounded - outstanding work, out-of-order completion, worker errors/exceptions, cancelled - queued jobs, first-error preservation, and writer failure without publication. -- Review identified and resolved first-error selection across out-of-order - failures and missing direct header includes. New/changed C++ sections were - formatted with clang-format-21. -- Instrumented dependency testing exposed GDAL 3.10 races in MEM driver - creation, lazy block-cache lock initialization, and the unlocked LRU-head - check. Destination creation is serialized and cache setup runs before workers. - The unlocked LRU-head comparison in `GDALRasterBlock::Touch()` is now accepted - through `misc/suppression/tsan.txt`, at the user's request. The former GDAL - source patch and CMake hook were removed to avoid maintaining a dependency - patch. TSan suppressions match stack frames, so this rule also excludes other - races passing through `Touch()`. - After rebuilding with unpatched GDAL, the RF suite reports this race without - the suppression and passes all 30 cases (51602 assertions) with it enabled. - A separate negative control still reports an unrelated data race. -- UBSan also found CGAL's trapezoidal locator reading an uninitialized Boolean - in `set_with_guarantees()`. The constructors ignore the returned old value. - At the user's request, the initialization patch and its CMake hook were removed; - `misc/suppression/ubsan.txt` excludes Boolean diagnostics only in that CGAL setter. - It also excludes the four arrangement type families affected by the known - downcast issue (CGAL #9140). ASan and leak detection remain enabled. Negative - controls confirm unrelated Boolean reads, invalid downcasts, address errors, - leaks and integer overflow still fail. These exclusions leave the CGAL reads - and downcasts unfixed. -- After removing the Boolean patch, CMake restored the original CGAL header; - the rebuilt ASan RF suite passes all 30 cases and 51603 assertions with the - updated scratch exclusions and leak detection enabled. -- CI's ASan test step loads the tracked UBSan list with leak detection and - halt-on-error enabled. The same list passes all 30 ASan RF cases locally and - a Clang 21 CGAL fixture; all five unrelated sanitizer negative controls still - fail as intended. CI uses Clang 23, which was unavailable for local validation. -- CI's existing two oneTBB TSan exclusions now live in - `misc/suppression/tsan.txt`. The unused root suppression file was removed; - its broader legacy rules were not carried over. -- Full-Vienna validation completed on 2026-09-11: ASan and TSan at 4 and 16 - workers all passed. Release runs at 1, 2, 4, 8 and 12 workers passed, executed - serially after all sanitizer runs finished. Every run published 62 tiles; - all 64 published files are byte-identical across all nine configurations, - verified by file sets, sizes, hashes and direct byte comparison. Elapsed time, - peak RSS, exact invocations, source/executable hashes and the scratch-only - harness are retained under `/data/scratch/codex/rf-parallel-20260910`. - Those full runs used the former CGAL Boolean and GDAL cache-head patches; - their saved results and suppression files describe that configuration. - The default remains one worker. - -### Online import verification log - -- Baseline: existing `build/Desktop_Debug/unittests/unittests_rfbuilder`, with - the documented PROJ/GDAL environment: 30 cases, 51459 assertions passed. -- After GDAL extraction: `cmake --build build/Desktop_Debug --target rf-builder - unittests_rfbuilder --parallel 6`, then the same RF suite/environment: - 30 cases, 51603 assertions passed. Assertion totals vary with cancellation - timing. Build/test transcripts: `/tmp/rf-online-baseline/build-gdal.log` and - `/tmp/rf-online-baseline/tests-gdal.log`. -- Provider GET probes on 2026-09-12 at 16.3738 E, 48.2082 N, zooms 0..22: - Basemap returned JPEG 256x256 at 1..20; Gataki at 4..20. Other probed - levels returned 404. This samples Vienna, not exhaustive coverage. - Exact URLs/statuses are in `/tmp/rf-online-baseline/provider-probes.json`. - -- Complete Debug RF suite: 47 cases, 53812 assertions passed, including 17 - online cases. Tests use a bounded loopback HTTP fixture, with no live provider - dependency. The out-of-order progress test exposed serial processing of - descendants from a single root; idle lanes now take pending sibling subtrees. - The corrected fixture verifies 25%, idle 75%, and final 100% progress. -- Shared-dependency regressions passed: reader/storage/image roundtrips - (33 cases, 480 assertions), tilebuilder (14 cases, 389 assertions), SF builder - (16 cases, 176102 assertions), and retained downloader (13 cases, 49 assertions). -- The configured OpenCV JPEG decoder uses fetched libjpeg-turbo 3.0.3-70. - CMake reports `BUILD_JPEG=ON`; JPEG decoding symbols are local to OpenCV's - image-codec library. System JPEG may still load indirectly through TIFF. -- New/changed C++ was formatted with clang-format-21. The Qt C++ skill's six - review missions found no confirmed remaining defects. Deterministic warnings - about chrono `count()` and mutation of a `std::vector` reference do not apply - to these APIs; existing GDAL aggregate/container warnings are unchanged. - -- Optimized validation exposed a temporary-lifetime error in online refinement: - the optional child array is now retained before range iteration. The CLI HTTP - fixture now publishes configuration through its handler mutex before the - subprocess starts. After these fixes, full Release and TSan RF suites pass - all 47 cases (53811 and 53812 assertions respectively), with no TSan reports. - TSan uses the existing tracked dependency suppressions; none were added. -- The shared mask regression filter in SF merger passes 6 cases / 85 assertions. - -- Basemap's configured minimum is 4, explicitly confirmed by the user; the - available lower levels 1..3 are deliberately excluded to keep RF's default - side 4096. Both shipped provider files pass CLI provider/ratio validation - before the expected rejection of an empty mask; this check issues no HTTP. -- Release measurement (`[online-benchmark] --benchmark-samples 20`): source - side 256, RF side 4096, two workers, synthetic whole-world coverage at source - zoom 4 with one available zoom-5 tile and one zoom-6 tile. The import produces - seven disjoint leaves (7x the dense pixels of the coarse single-root output), - 54494 stored bytes and 1671 requests in 6.203 seconds. Overall test process: - 8.146 seconds, 818180 KiB peak RSS measured with Linux `getrusage`. - The constant-colour JPEG fixture compresses unusually well; its stored byte - count is not representative of aerial photographs. Geographic completion - weight costs about 6.91 ns per lookup for one rectangle in the Catch2 - benchmark. These are one-run measurements; dependency compilation was - concurrent on this 24-logical-CPU host. -- RF builds and tests pass in both Release and TSan configurations with - `ALP_BUILD_TILE_DOWNLOADER=OFF`. The final Debug online subset passes - 17 cases / 2209 assertions after the optimized-build fixes. - -- Final ASan/LSan/UBSan suite: all 47 RF cases / 53811 assertions passed. - `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1`; `UBSAN_OPTIONS` enables - halt-on-error, stack traces and the existing `misc/suppression/ubsan.txt`. - No new suppressions were introduced. The final incremental build had no - additional work. TSan likewise uses only `misc/suppression/tsan.txt`. -- Whitespace and Git-attribute line-ending checks pass for all 43 changed/new - text files. Generated regression outputs were removed. The initial `main` - checkout was retained throughout implementation and verification. -- Final evidence is retained under `/data/scratch/codex/rf-online-20260912`: - build/test logs, provider probes, benchmark, lint/review report, exact build - roots/test environment in `verification.json`, and source/executable hashes. - Earlier `/tmp/rf-online-baseline` paths in this log are mirrored there. - All planned implementation stages are verified; no open product decisions - remain. Live provider evidence is limited to the regional JPEG probes; the - end-to-end correctness and performance checks use deterministic fixtures. diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index d0b0f630..12fa76fe 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -151,8 +151,8 @@ quadtree. It resolves: - chunk and source-map decoding; and - neighbouring data needed by the window. -The generator determines the requested halo. The planned raster-store halo -extractor applies replication only where physical coverage is missing, +The generator determines the requested halo. The raster-store halo +reader applies replication only where physical coverage is missing, including vertical world limits, using clamped views as described in [Tiles with halo](tiles-with-halo.md). Zero attribution within a physical source is not a boundary and does not trigger replacement of its data. @@ -173,7 +173,7 @@ Sampling placement alone does not determine a correct filter: | Source mask/NoData | importer validity and coverage rules before generic scaling | Source NoData handling remains an importer concern. Once a raster is supplied -to the [single-raster scaling facility](scaling.md), attribution does not mask +to the [scaling facility](#implemented-scaling-rules), attribution does not mask any numerical input. Missing attribution and missing physical coverage are distinct; neither should be inferred from a numeric sentinel by the scaler. @@ -212,7 +212,7 @@ The generator needs explicit rules for: - zero-attribution pixels inside physically covered chunks; and - filters whose support crosses a layer's coverage boundary. -The planned raster-store halo rules are specified in +The raster-store halo rules are specified in [Tiles with halo](tiles-with-halo.md): preserve physically supplied zero-attribution payloads and replicate only missing physical coverage through clamped views. Other generator-specific boundary policies remain separate @@ -231,7 +231,7 @@ Before production filtering is implemented, synthetic fixtures should prove: 5. Filtering is unchanged when a store window is split into different chunks. 6. A source boundary blends supplied payloads independently of attribution. Paired wrappers report representative attribution according to the - [scaling contract](scaling.md): repeated local 2x2 mode reduction, including + [scaling rules](#implemented-scaling-rules): repeated local 2x2 mode reduction, including zero in the vote, and nearest-neighbour attribution for upscaling. 7. Existing zero-attribution pixels contribute normally and retain their supplied or resampled payloads; missing physical coverage follows the @@ -240,14 +240,123 @@ Before production filtering is implemented, synthetic fixtures should prove: 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 [scaling contract](scaling.md) specifies coefficients and verification -requirements for its area-pixel operations. Other layer-specific and +The [scaling rules](#implemented-scaling-rules) specify coefficients for +the area-pixel operations. Other layer-specific and vertex-pixel filters remain separate design decisions; tests should fix their numeric tolerances only after representative evaluation. -The implemented [halo reader](tiles-with-halo.md) clamps bilinear ancestor +The [halo reader](tiles-with-halo.md) clamps ancestor interpolation support at the complete supplying physical tile's edge. This is its explicit fallback policy; it does not promise the seam-free vertex-generation invariant above. A future generator needing that invariant must assemble common support or generate metatiles. Halo attribution is representative and never masks numerical contributions; zero attribution triggers no resolution fallback. + +## Implemented scaling rules + +`raster::algorithm` scales and reduces single rasters; `raster_store::scaler` +pairs these operations for data and attribution. This section records their +numerical contract; signatures are in the code. + +### Scope + +Generic scaling operates on one raster without interpreting attribution or +treating any sample as NoData. Every supplied pixel participates according to +the selected operation. Callers must supply usable, finite values, including +where the attribution raster contains zero; there is no runtime validity scan. +Nearest-neighbour sampling and zero-level cropping copy stored pixels exactly, +including nonfinite values. + +Only power-of-two scale factors are supported, with area-pixel placement. +Vertex-pixel generation, world wrapping, physical-source selection and +ancestor fallback belong to callers such as the halo reader. + +### Methods + +| Selection | Upscaling | Downscaling | +|---|---|---| +| `NearestNeighbourAndBox` | Nearest-neighbour | Box | +| `BiliinearAndBox` | Bilinear | Box | +| `Lanczos2` | Lanczos-2 | Lanczos-2 | +| `Lanczos3` | Lanczos-3 | Lanczos-3 | +| `Lanczos4` | Lanczos-4 | Lanczos-4 | + +Custom reductions operate on complete 2x2 blocks. Supplied reducers are +component-wise `Min`, `Max` and `Median` (average of the two middle values; +integer halfway cases round away from zero), and scalar `Mode` (greatest +frequency, ties broken by first occurrence in row-major order; zero is an +ordinary value). + +### Sampling phase + +With interior pixel centres at integer positions, direct upscaling by factor +`F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input coordinates. +A reduction by two evaluates pixel `j` at `2j + 0.5`. Positions are applied +independently on each axis. Halo offsets affect addressing, not phase. + +Upscaling goes directly from the input to the final resolution. Downscaling +repeats reduction by two, encoding the result into the stored pixel type after +every step; the next step decodes it again. A reduction by four therefore +equals two reductions by two, but repeated median, mode, rounding and sRGB +encoding need not equal a single larger filter. + +### Kernels + +Lanczos upscaling with radius `a` uses normalized `sinc(x) * sinc(x/a)` +weights with `2a` taps per axis, where `sinc(x) = sin(pi*x) / (pi*x)`. +Reduction by two evaluates the same kernel at half the input distance: +weights proportional to `sinc(t/2) * sinc(t/(2a))` for `abs(t) < 2a`, with +`4a` taps, low-passing before decimation. Two-dimensional weights are products +of the axis weights, normalized over the complete support, independently of +payloads or attribution. Box reduction averages the complete 2x2 block; +bilinear interpolation uses all four neighbours. + +Separable passes decode once, filter horizontally and vertically in the +working type, and encode only after both passes. + +### Required halos + +Inputs carry a symmetric halo, supplied physically or through a clamped view. +The scaler never clamps implicitly, including at the edges of a processing +window. Minimum halo widths in input pixels: + +| Operation | Required halo | +|---|---| +| Zero levels (crop only) | 0 | +| Nearest-neighbour upscaling | 0 | +| Bilinear upscaling | 1 | +| Lanczos upscaling, radius `a` | `a` | +| Box or custom 2x2 reduction, including repeated steps | 0 | +| Lanczos reduction by total factor `F`, radius `a` | `(2a - 1) * (F - 1)` | + +For one Lanczos reduction step, the first output centre is `0.5` and input +indices range from `1 - 2a` through `2a`, giving a halo of `2a - 1`. Retaining +output halo `h` needs input halo `2h + (2a - 1)`; repeated application yields +the formula. Lanczos-3 therefore needs 5 input halo pixels for reduction by +two and 15 for reduction by four. + +For Lanczos upscaling by factor `F`, a symmetric output halo `h` needs input +halo `ceil(a - 0.5 + (h - 0.5) / F)`. Lanczos-3 at 2x can thus preserve five +halo pixels: a 266x266 source with a 256x256 interior produces a 522x522 +window at offset `{-5, -5}`. + +### Output windows + +A window of the conceptual full output can be requested by offset and size. +The result equals filtering a sufficiently large raster and cropping, while +computing only the necessary samples and preserving the global phase and +reduction-stage alignment. The full conceptual output is never allocated, so +distant ancestors can be upscaled into small windows. + +### Attribution and value mapping + +Paired operations handle attribution independently of data: nearest-neighbour +for upscaling and repeated 2x2 `Mode` for downscaling. Zero takes part in the +vote, so `[0, 0, 0, 7]` reduces to zero. Attribution never changes data +samples, weights or results; one representative ID is kept per pixel. + +The snapshot's `pixel::Mapping` selects the data conversion. `Linear` filters +stored values directly (integers through floating point, rounded and clamped +on encoding). `SRGBA` requires RGB8/RGBA8 for numerical resampling; it +decodes RGB through the sRGB transfer function to linear light, filters, and +encodes back. Alpha is linear, filtered independently and does not weight RGB. diff --git a/docs/raster-store/scaling.md b/docs/raster-store/scaling.md deleted file mode 100644 index 35d007fa..00000000 --- a/docs/raster-store/scaling.md +++ /dev/null @@ -1,678 +0,0 @@ -# Raster scaling - -Status: the single-raster, view-based refactor was implemented on 2026-09-22, -including paired raster-store wrappers, conversion tuples, rectangular strided -windows, and separable box/Lanczos filtering. Output-window scaling, halo -extraction, and snapshot halo metadata were implemented later the same day. - -## Lanczos upscaling and halo windows - -Implemented on 2026-09-25. One `Resampling` enum selects behavior in both -scaling directions: - -| Selection | Upscaling | Downscaling | -|---|---|---| -| `NearestNeighbourAndBox` | Nearest-neighbour | Box | -| `BiliinearAndBox` | Bilinear | Box | -| `Lanczos2` | Lanczos-2 | Lanczos-2 | -| `Lanczos3` | Lanczos-3 | Lanczos-3 | -| `Lanczos4` | Lanczos-4 | Lanczos-4 | - -Window offsets are `glm::ivec2`, relative to the scaled interior. They can -include the source halo in either scaling direction and at zero levels. -Every contributing source sample must be available within the supplied -logical source; otherwise validation fails before any destination writes. -Full-output overloads are convenience wrappers around the windowed operation. - -`required_source_window(input_size, halo_width, levels, method, output_offset, -output_size)` queries the same source-support calculation without reading pixels. -It returns a `SourceWindow` with `origin` and `size` in source pixels, measured -from the supplied raster's top-left corner, including its halo. Invalid geometry -or insufficient support returns an error. RF-builder uses this public query to -fetch only contributing source pixels before scaling. - -Tile dimensions are at most 2^30 and offset components lie in -[-2^30, 2^30), enforced as assertions. Ancestor fallback in RF-builder and the -halo reader supports gaps up to 30 levels; larger gaps return an error. -Products, source-footprint calculations, pixel -counts, and storage offsets use wider arithmetic. In particular, dimensions -such as 2^17 by 2^17 are supported subject to actual storage capacity. - -`rf-builder tiles` uses fixed Lanczos-3 for enlarged ancestor fallback, with -no command-line selector. Its existing fallback description records that -choice; there is no new cache field or migration mechanism. The standalone -`tile-downloader` is unchanged. - -## Scope and ownership - -Generic scaling and reduction operate on one raster, without interpreting -attribution or treating any sample as NoData. Every supplied pixel participates -according to the selected operation. Callers must supply usable payloads, -including where their separate attribution raster contains zero. For numerical -paths, callers must ensure finite decoded values, finite intermediate and -reducer results, and values accepted by their encoder. These are preconditions, -not runtime validity scans or per-pixel errors. Nearest-neighbour sampling and -zero-level cropping bypass conversion and preserve stored pixels exactly, -including nonfinite floating-point values. - -Only power-of-two scale factors are supported. Input and output use area-pixel -placement; vertex-pixel generation and area/vertex conversion are outside this -facility. World wrapping, physical-source selection, ancestor fallback, and -preparation of missing coverage belong to callers or the halo extractor. - -Implementations live under `src/terrainlib/raster/algorithm/*.h` and are exposed -through the `raster/algorithm.h` umbrella in namespace `raster::algorithm`. -Paired convenience wrappers live in `raster_store/scaler.h`, in namespace -`raster_store::scaler`. They compose generic operations for data and -attribution; they do not implement filtering, interpolation, or pixel loops. - -See [Raster views and view algorithms](../raster-view-algorithms.md) for the -view model and [Sampling and pyramid generation](sampling-and-generation.md) -for broader generator requirements. - -## Interface and geometry - -Accept a raster, ordinary view, or clamped view as a source. Normalize it with -`raster::make_view`, as the existing algorithms do, and expose read-only input -to the implementation. Public operations are synchronous and may accept a -temporary raster for the duration of the call. No borrowed view escapes. - -Each operation has an explicit-destination overload returning `Expected` -and an allocating overload returning `Expected>`. An explicit -destination may be a writable raster or ordinary view, must have exactly the -computed output dimensions, and must use the same stored pixel type as the -source. Read-only and clamped destinations are rejected at compile time. -Normalize source and destination independently; do not multiply overloads for -every raster/view combination. Both overloads share a view-based implementation. - -Inputs are rectangular, with a symmetric `halo_width` on all four sides. Both -axes scale by the same factor. Full-output calls contain only the scaled -interior; explicit output windows may also include halo pixels. Reject empty inputs and -empty interiors after removing the halo, even though the view model permits -empty views. Destination overloads avoid allocating the final output, but -separable passes and repeated reductions still allocate intermediate rasters. - -Express scaling amounts as `n_zoom_levels`, the exponent of two. Public -`scale` and `required_halo` use a signed count: positive means upscaling, -negative means downscaling, and zero means cropping only. For example, `+2` -upscales by four and `-2` downscales by four. Directional helpers and custom -`reduce` use unsigned exponent counts. Zero levels crop without invoking a -reducer. Keep `upscale` and `downscale` in `detail`. - -For reduction, both interior dimensions must be divisible by the factor. -Validate geometry, halo bounds, algorithm enum selections, zoom-level counts, -and dimension/allocation overflow. Check pixel and conversion-callable type -compatibility at compile time. Validate exponents before shifting, and obtain -a negative signed count's magnitude without signed overflow. - -With interior pixel centres indexed by integers, direct upscaling by factor -`F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input coordinates. -A reduction by two evaluates pixel `j` at `2j + 0.5`. Apply these positions -independently on each axis. Halo offsets affect addressing, not sampling phase. - -### Generic API - -The `Resampling` enum above selects the paired upscaling/downscaling method. -Generic `scale` and `reduce` accept a tuple `(value_decoder, value_encoder)`; the decoder's -return type determines the working pixel type. The encoder returns exactly -the original stored pixel type, so neither operation changes the raster's type. - -Expose the following call shapes in `raster::algorithm`. `conversion` is the -tuple. Convenience overloads omit it and supply the operation's default; all -forms delegate to the same processing implementation. Keep destinations last -and constrain conversion tuples and destinations so the forms are unambiguous. - -```cpp -scale(source, halo_width, n_zoom_levels, method); -scale(source, halo_width, n_zoom_levels, method, destination); -scale(source, halo_width, n_zoom_levels, method, conversion); -scale(source, halo_width, n_zoom_levels, method, conversion, - destination); - -reduce(source, halo_width, n_zoom_levels, reducer); -reduce(source, halo_width, n_zoom_levels, reducer, destination); -reduce(source, halo_width, n_zoom_levels, conversion, reducer); -reduce(source, halo_width, n_zoom_levels, conversion, reducer, destination); -``` - -Allocating forms return `Expected>`, where `T` is the source -pixel type. Explicit-destination forms return `Expected`. The default -tuple is the supplied linear numeric conversion for `scale` and identity for -`reduce`, as specified below. A custom reducer is invoked through `const Reducer&`. -The halo queries are independent of conversion: - -```cpp -namespace raster::algorithm { - -Expected required_halo( - int n_zoom_levels, Resampling method); - -} // namespace raster::algorithm -``` - -`scale` and `required_halo` receive one method selection. The halo query -covers a full interior output; explicit windows validate their actual source -footprint, including any additional support needed by output halo pixels. - -## Agreed windowed scaling extension - -Implemented on 2026-09-22 after the view-based scaler refactor. Overloads of generic and paired -`scale` select a window of their conceptual full output, in either -scaling direction. - -Use `output_offset` (`glm::ivec2`) and, for allocating overloads, -`output_size` (`glm::uvec2`) as direct parameters. The offset is measured in -pixels relative to the conceptual scaled interior; negative coordinates lie -before its top/left edges. Writing starts at destination-local `(0,0)`. Destination-taking overloads infer the -window size from the destination. Paired destinations must have matching -sizes and use the same offset. - -Calls without `output_offset` require destinations whose dimensions exactly -match the full scaled output, excluding the source halo. A smaller destination -does not request cropping; any size mismatch returns `InvalidInput` before -writing. This applies to both generic and paired overloads. Only overloads -with an explicit `output_offset` infer a crop size from the destination. - -Generic call shapes extend the existing convention: - -```cpp -scale(source, halo_width, n_zoom_levels, method, - output_offset, destination); -scale(source, halo_width, n_zoom_levels, method, - output_offset, output_size); -scale(source, halo_width, n_zoom_levels, method, - output_offset, conversion, destination); -scale(source, halo_width, n_zoom_levels, method, - output_offset, output_size, conversion); -``` - -The corresponding paired forms are: - -```cpp -scale(data, attribution, halo_width, n_zoom_levels, method, - output_offset, value_mapping, data_destination, attribution_destination); -scale(data, attribution, halo_width, n_zoom_levels, method, - output_offset, output_size, value_mapping); -``` - -The selected rectangle is `[output_offset, output_offset + output_size)`. -Results must equal filtering a sufficiently large surrounding raster and -cropping this rectangle, while computing only the necessary samples. Existing full-output overloads retain -their behavior and share the processing implementation with windowed forms. -Preserve existing return types, conversion defaults, signed zoom-level -convention and pixel semantics. Zero levels select an exact crop, including -supplied halo pixels when requested. - -For upscaling, evaluate only requested output pixels with the sampling phase -of their position in the conceptual full result. A source view alone cannot -express arbitrary output offsets: they need not align with source-pixel -boundaries. Use integer quotient/remainder calculations before converting -the local interpolation phase to floating point. - -For downscaling, work backwards from the requested output window to the -source and intermediate samples needed by each reduction step, including -filter support. Preserve the existing globally aligned reduction stages, -encoding/rounding after each stage, and independent attribution reduction. -Do not clamp at processing-window edges or replace repeated reductions with -a single larger filter. This extension does not add window overloads to -custom `reduce` as a separate public feature. - -Validate the selected rectangle's complete source footprint before writing; -it must fit inside the supplied logical source. Use signed 32-bit offsets, -unsigned dimensions/factors, and wider products with checked shifts and bounds. -The full conceptual image -need not fit raster dimensions or be allocated; only the requested output -and necessary intermediate storage must fit their allocation limits. -Preserve the existing overlap rules. An interior window may require less -source halo than a full-output operation; validate the actual requested taps. - -Verification must compare windows against full-scale-then-crop for zero, -positive, and negative levels, including non-aligned offsets, multi-step -rounding, Lanczos support, paired attribution, and strided destinations. -Verify that calls without `output_offset` reject both smaller and larger -destinations without modifying them. -Test bounded work for distant ancestors without allocating their conceptual -full output, including a 20-level ancestor gap. - -## Algorithms and reducers - -Lanczos upscaling uses normalized `sinc(x) * sinc(x/a)` weights with `2a` -taps per axis. Kernel evaluation and normalization are shared with downscaling; -2x downscaling evaluates that kernel at half the input distance and uses `4a` -taps. Horizontal upscaling coefficients are cached only for the requested -output width. Fractional phases use integer quotient/remainder calculations, -including floor division for negative output positions. - -Upscaling offers nearest-neighbour, bilinear, and Lanczos-2/3/4 interpolation. Upscale directly -from the input to the final resolution rather than repeating interpolation by -two. Nearest-neighbour sampling copies the selected stored pixel exactly, without -a decoder/encoder round trip; neither callable is invoked. Bilinear interpolation -uses all four neighbours; there is no attribution-based bypass or exclusion. - -Downscaling offers box filtering and Lanczos with lobe count/support radius -2, 3, or 4. Implement reduction by two and repeat it for larger reductions. -Box filtering averages the complete local 2x2 block. Lanczos support is widened -for the reduction ratio to low-pass before decimation. - -For Lanczos radius `a`, the one-dimensional kernel is proportional to -`sinc(t / 2) * sinc(t / (2a))` for `abs(t) < 2a` and zero otherwise, where -`sinc(x) = sin(pi*x) / (pi*x)` and `sinc(0) = 1`. Two-dimensional weights are -products of the axis weights. Normalize over the complete kernel support, -independently of payloads or attribution. Zero-weight samples do not contribute -payload arithmetic. The former masked-weight cancellation and empty-block -payload fallbacks do not apply: there is no validity mask changing the support. - -Custom reduction also operates on a complete 2x2 block at each step. Accept -lambdas and functors through templates, without type erasure or a required -function pointer. Let `W` be the value type returned by the decoder. The -reducer receives `std::span` containing exactly four decoded values -in row-major order and returns exactly one `W` by value. Encode that result -into the original stored pixel type after each step. With the default identity -tuple, `W` is the stored type, including integer types. No samples are omitted -and no blocks bypass the callable during a nonzero reduction. - -Invoke reducers through `const Reducer&`. Immutable captured configuration is -allowed, but callbacks must not change state between calls or depend on -invocation order. This matches the existing view-algorithm contract and -supersedes the scaler's earlier support for stateful reducers. Const invocation -alone does not prove the absence of external side effects. - -Provide these stateless functors with templated const call operators: - -- `Min` and `Max`: operate component-wise for scalar and GLM-vector pixels. -- `Median`: operates component-wise and averages the two middle values of the - four samples. For integer components, calculate without overflow and round - halfway cases away from zero; this rule belongs to the functor and also - applies with identity conversion. `Min`, `Max`, and `Mode` select input values. -- `Mode`: operates on scalar-like values. Select the sample with the greatest - frequency under ordinary equality, breaking ties by the first occurrence in - row-major order. Each sample is a candidate with one occurrence of its own; - equal other samples add to its frequency. Zero is an ordinary value. There - is no NaN special case: NaNs do not compare equal to other samples, including - other NaNs, and therefore do not form a repeated group. A first NaN can win - an all-distinct tie; a repeated ordinary value beats it. - -`Mode` is a categorical frequency operation, not a numeric median. Its ordinary -equality semantics above also describe direct functor calls with NaNs; they do -not waive the finite-value precondition for numerical `scale`/`reduce` paths. -The scalar-like value support belongs to the functor itself. Generic `reduce` -does not inspect which functor was supplied or add a `Mode`-specific vector -restriction; no explicit vector-exclusion checks or tests are required. - -## Paired raster-store wrappers - -The paired wrappers accept matching rectangular data and `uint16_t` attribution -sources, each independently supplied as a raster, ordinary view, or clamped -view. Both use the same logical dimensions, halo width, and zoom-level count. -The wrappers do not fill coverage gaps or repair payloads. A caller choosing -clamped inputs is responsible for their source coverage and boundary policy. - -Data follows the requested generic operation. Attribution follows: - -- Upscaling: generic nearest-neighbour scaling, bypassing conversion. -- Downscaling: generic custom reduction with scalar `Mode` and identity conversion, - repeated by two, independently of the data filter's larger support. -- Zero levels: crop both sources exactly. - -Every attribution value participates, including zero. For example, -`[0, 0, 0, 7]` reduces to zero. Attribution never changes the data samples, -weights, interpolation path, or result. Attribution reduction operates directly -on `uint16_t` IDs without floating-point conversion. Keep one representative ID; -complete provenance and original-source weight totals are not retained. - -Provide these call shapes under `raster_store::scaler`. Paired `scale` accepts -an explicit `raster_store::pixel::Mapping` for data interpretation. Paired -`reduce` accepts a decoder/encoder tuple of lambdas or functors, defaulting to -identity conversion, and has no mapping enum parameter: - -```cpp -scale(data, attribution, halo_width, n_zoom_levels, - method, value_mapping); -scale(data, attribution, halo_width, n_zoom_levels, - method, value_mapping, data_destination, attribution_destination); - -reduce(data, attribution, halo_width, n_zoom_levels, reducer); -reduce(data, attribution, halo_width, n_zoom_levels, reducer, - data_destination, attribution_destination); -reduce(data, attribution, halo_width, n_zoom_levels, conversion, reducer); -reduce(data, attribution, halo_width, n_zoom_levels, conversion, reducer, - data_destination, attribution_destination); -``` - -Allocating wrappers return -`Expected, radix::Raster>>`, with data -first. Explicit-destination wrappers return `Expected`. The `scale` wrapper maps -`pixel::Mapping` to a supplied data conversion tuple and invokes the generic -algorithm. Custom scaling tuples remain available through the generic -single-raster API. The `reduce` wrapper forwards its data conversion tuple and -reducer to generic `reduce`. Attribution reduction always uses `Mode` with -identity conversion, independently of the data tuple. -Reuse generic validation and processing helpers rather than duplicate kernels. - -`Mapping::Linear` selects the supplied linear conversion for `scale`; -`Mapping::SRGBA` selects sRGB conversion for `scale` and requires RGB/RGBA -unsigned 8-bit pixels for numerical resampling. Zero-level and nearest-neighbour -copies bypass conversion and do not impose that type restriction. Validate -mapping enumerators, and compatibility when needed, before writing either output. Callers may explicitly supply the sRGB conversion tuple -to paired `reduce`, just as they can supply custom lambdas. - -### Shared pixel description - -The former `raster_store/pixel_type.h` is now `raster_store/pixel.h`, with namespace -`raster_store::pixel`. `pixel::identifier()` is public; `Format` -and its specializations live in `pixel::detail::Format`. Existing identifier -strings and pixel-layout requirements are preserved. Storage and codec use -the new header and names. - -Define `pixel::Mapping { Linear, SRGBA }` in that same header. It replaces the -former scaler `ValueMapping` name while retaining runtime mapping selection at -the paired `scale` boundary. Snapshot metadata and creation options -use this same type for `value_mapping`; do not define a separate enum in the -manifest's versioned namespace. Generic raster algorithms accept conversion -tuples and do not depend on the raster-store pixel header or mapping enum. -The halo implementation added this field to version 1 metadata. - -## Conversion tuples and numeric behavior - -A conversion is a `std::tuple` of two lambdas or functors, in decoder/encoder -order. Invoke both through const references, without type erasure. Like the -reducers, they may capture immutable configuration but must not change state -or depend on invocation order. - -For source pixel type `T`, the decoder accepts `const T&` and returns a plain -working pixel value `W`. The encoder accepts `const W&` and returns exactly -`T` by value. Neither callable returns `Expected`, a reference, or a different -output pixel type. Infer `W` from the decoder; generic algorithms do not choose -it through a `WorkingPixel` mapping. The reducer also returns exactly `W`. - -`scale` supports floating-point working components, including floating-point -GLM vectors. Integer source and destination rasters remain supported through -their conversion tuples. `reduce` supports integer and floating-point working -components, including GLM vectors. Operations -work on the decoder's result; callers choose any layer-specific interpretation -or postprocessing through their callables. - -For numerical paths, the caller must ensure that decoded values, intermediate -arithmetic, and reducer results remain finite, and that values passed to the -encoder are within its supported domain. Finite decoder output alone is not -sufficient: arithmetic can overflow or a custom reducer can produce infinity. -No runtime validity scanning, per-pixel conversion errors, or special recovery -is promised for precondition violations. This supersedes the former promise -to preserve NaN/Inf through numerical filtering or report nonfinite-to-integer -conversion errors. Nearest-neighbour sampling and zero-level cropping instead -copy stored pixels exactly, never invoke decoder/encoder callables, and have -no finite-value requirement. - -### Supplied conversion tuples - -Provide reusable tuple factories alongside the algorithms: - -- `identity_conversion()`: decoder and encoder return their input value - unchanged. This is the default for `reduce`, preserving integer working - values exactly. It can also be used with `scale` when the resulting working - components are floating point. -- `linear_conversion()`: default for `scale`. The decoder casts stored - components to the former working precision shown below, without changing - the interpretation or scale of linear values. Its encoder converts back to - `T`; for integer components it rounds halfway cases away from zero and - clamps finite results to the representable range before conversion. For - floating-point storage it preserves representable finite overshoot. These - conversions belong to the supplied tuple, not generic scaling/reduction. -- `srgb_conversion()`: supports RGB/RGBA unsigned 8-bit stored pixels. Decode - RGB through the sRGB transfer function into linear-light float components - and encode back afterwards. This is sRGB, not an arbitrary gamma exponent. - Decode alpha, when present, linearly into `[0, 1]` and encode it independently; - alpha neither weights RGB nor excludes samples. Encoding rounds and clamps - to the stored channel range. Use this tuple with either operation explicitly. - -The default linear tuple selects its decoder component type as follows, -preserving GLM vector length: - -| Stored component type | Linear decoder component type | -|---|---| -| 32-bit `float` | `float` | -| Signed or unsigned integers up to 16 bits | `float` | -| `double` | `double` | -| Signed or unsigned 32-bit integers | `double` | -| Signed or unsigned 64-bit integers | `long double` | - -For example, `linear_conversion>()` decodes to four -`float` components, whereas identity conversion keeps four `uint16_t` -components for reduction. A caller may supply a different decoder precision. -Numerical results are subject to the chosen working type; exact weighted -64-bit integer arithmetic is not promised. Identity reduction avoids an -unnecessary conversion for integer min/max/mode, and integer median uses its -own overflow-safe rounding rule. - -Every nonzero reduction step encodes its result into the original stored -pixel type. Subsequent steps decode that stored result again, including any -rounding or sRGB conversion. Reduction by four therefore has the same stage -semantics as two explicit reductions by two with sufficient halos and the same -tuple. Repeated median and mode need not equal a direct reduction over the -original larger block. Repeated rounding and encoding also prevent a general -promise of agreement with a single larger numeric filter. - -The planned snapshot metadata uses `raster_store::pixel::Mapping` to describe -linear or sRGB interpretation. The halo extractor passes that value to -paired `scale`, which selects the corresponding tuple. Runtime mapping -selection stays at this store boundary; generic `scale` and `reduce` receive -only conversion callables. This refactor does not change the manifest schema. - -## Halo and clamped-view contract - -Require an explicit `halo_width` for every source representation. It identifies -the interior within the logical input rectangle, whether halo pixels occupy -physical storage or are supplied by a clamped view. Clamped views still have -finite logical bounds; they do not grant arbitrary out-of-bounds access. - -For example, bilinearly upscaling a 4x4 interior requires a logical 6x6 source -with a one-pixel halo. A caller choosing edge replication can construct: - -```cpp -auto padded = raster::make_clamped_view(source, {-1, -1}, {6, 6}); -// After checking padded, pass *padded with halo_width = 1. -// At n_zoom_levels = +1 the output is 8x8, with no output halo. -``` - -An ordinary 6x6 source view can instead supply physical neighbours. The scaler -does not construct clamped views implicitly. It rejects an insufficient logical -halo for either source kind. Clamping bounds are caller policy: the halo -extractor deliberately clamps interpolation support at the full supplying -ancestor's edges. A smaller processing cutout must preserve those bounds. - -Compute the required halo for the complete operation, including all reduction -steps. Retain enough intermediate halo for later steps and crop only the final -result. Minimum widths, in input pixels on each side including corners, are: - -| Operation | Required halo | -|---|---| -| Zero levels (crop only) | 0 | -| Nearest-neighbour upscaling | 0 | -| Bilinear upscaling, positive levels | 1 | -| Lanczos upscaling, radius `a`, positive levels | `a` | -| Box or custom 2x2 reduction, including repeated steps | 0 | -| Lanczos reduction by total factor `F`, radius `a` | `(2a - 1) * (F - 1)` | - -For one Lanczos step, the first output centre is `0.5` and input indices range -from `1 - 2a` through `2a`, giving a halo of `2a - 1`. Retaining output halo -`h` needs input halo `2h + (2a - 1)`; repeated application yields the formula. -Lanczos-3 therefore requires 5 input halo pixels for reduction by two and 15 -for reduction by four. Halo queries validate parameters and arithmetic overflow. - -For Lanczos upscaling by factor `F`, a symmetric output halo `h` needs input -halo `ceil(a - 0.5 + (h - 0.5) / F)`. Thus Lanczos-3 at 2x can preserve five -halo pixels: a 266x266 source with a 256x256 interior produces a 522x522 -window at offset `{-5, -5}`. Filtering uses the original interior's sampling -phase; it does not resize the whole padded rectangle onto the output extent. - -There is no `required_reduction_halo`: custom block reduction always needs zero -halo, though its supplied halo is still cropped and its geometry validated. -Paired wrappers use the data operation's requirement, which also suffices for -nearest-neighbour or mode attribution. - -## Overlap and failure behavior - -Reject input/output storage overlap for nonzero scaling or reduction, using the -existing view helpers and physical footprints for clamped views. Zero-level -cropping follows `copy`: permit an identical interior/destination mapping, -reject partial overlap. Copying into an overlapping displaced region is not -promised to behave like a snapshot. - -Paired wrappers validate both output geometries and input/output relationships -before writing either output. Check cross-overlap as well as each corresponding -pair, including when data is also `uint16_t`; reject overlapping output rasters. -Inputs may overlap each other when they do not violate these output constraints. - -Complete argument validation before writing output or invoking reducers or -conversion callables. Invalid arguments leave destinations unchanged. A later -failure, such as an allocation failure while producing paired outputs, has no -rollback guarantee; paired outputs are not transactional. An allocating overload -returns an error without exposing its partial raster. Callback exceptions -receive no special catching, translation, or rollback guarantee, matching the -view algorithms. Numerical precondition violations are not returned as errors. - -Use `InvalidInput` for invalid geometry, parameters, overlap, and insufficient -halos, and `ResourceExhausted` for unrepresentable allocation sizes or allocation -failure. Pixel, decoder, encoder, and reducer type incompatibilities are -compile-time errors. Follow the project's `Error::fail`/`Error::propagate` style. - -## Relationship to halo extraction - -The planned [halo extractor](tiles-with-halo.md) calls the paired store wrappers -with box downscaling; it has no downscaling-algorithm parameter. Upscaling -interpolation remains an extraction parameter. Algorithm details are owned here. - -The extractor resolves physical coverage, source windows, world wrapping, and -required halos. It preserves supplied and resampled zero-attribution payloads. -Only missing physical coverage, including vertical world limits, uses a clamped -view of the central interior to replicate data with synthesized attribution zero. -There is no attribution-based replacement after scaling. See the -[attribution ADR](../adr/0004-representative-attribution-for-halo-samples.md) for -the decision to retain one representative ID rather than complete provenance. - -## Implementation and verification plan - -1. Move the scaler into headers under `raster/algorithm/`, retaining the umbrella - includes. Reuse view normalization, read-only access, allocation, and overlap - helpers, and `copy` for zero-level cropping. Extend - `window_transform` to even kernels and a stride parameter to share - downscaling traversal, as agreed below. Direct upscaling retains its - coordinate mapping over the shared view interface. -2. Remove coupled validity/attribution paths, implement both generic overload - patterns, retain intermediate halos, and add const-callable four-sample - reduction and scalar `Mode`. Replace `ValueMapping` and the generic fixed - working-type policy with decoder/encoder tuples; provide linear numeric, - sRGB, and identity factories with the agreed operation defaults. -3. Add thin paired wrappers with `pixel::Mapping` selection for `scale`, - identity-default conversion tuples for `reduce`, and validation of - both destinations before writes. Consolidate the pixel identifier and - mapping type in `raster_store/pixel.h`. Update CMake registrations and - existing tests for the revised contracts. -4. Build and run focused and terrainlib regression tests. Verify GCC 16 Release - compilation with warnings as errors: the existing median reducer produced - an array-bounds diagnostic in `std::sort`. An explicit `std::min(size, 4)` - bound did not eliminate it. Report whether the refactor incidentally fixes - the warning or it still occurs. Do not take additional steps specifically - to fix or suppress it; any such fix requires the user's separate decision. - Apply Qt C++ lint and format new C++ with `clang-format-21`. - -### Agreed window-transform reuse - -The extension accepts `glm::uvec2` kernel sizes and strides, with positive -components and even or odd rectangular kernels. There are no scalar/square -shorthand overloads. Omitting the stride selects `{1, 1}`. Per-axis output size is -`1 + (input_size - kernel_size) / stride`, using integer division after checking -that the kernel fits. Windows start at `output_coordinate * stride`, -component-wise; incomplete trailing strides are not sampled. The scaler retains its stricter divisibility -checks before invoking this primitive. - -For a 2x2 kernel and stride two, even input dimensions produce output dimensions -exactly half as large. For example, 8x6 becomes 4x3. Larger kernels also consume -border pixels: an 8x8 input with a 4x4 kernel and stride two produces 3x3 output. -Both the allocated raster and explicit destination use these same dimensions; -the caller supplies a destination of that size, and the operation does not -resize its view. - -The extension expresses every factor-two reduction step: - -| Operation | Kernel size | Stride | -|---|---|---| -| Custom reduction, including attribution mode | `{2, 2}` | `{2, 2}` | -| Box, horizontal then vertical | `{2, 1}`, then `{1, 2}` | `{2, 1}`, then `{1, 2}` | -| Lanczos radius `a`, horizontal then vertical | `{4*a, 1}`, then `{1, 4*a}` | `{2, 1}`, then `{1, 2}` | - -Box and Lanczos use separable passes. The horizontal pass decodes stored pixels -and produces a raster of working pixels; the vertical pass filters those values -and encodes the final output. Encode only after both passes, never between them. -The next zoom level decodes that stored output again. Rectangular windows also -support caller-supplied separable filters such as Gaussian; no Gaussian scaler -mode is introduced. - -Custom reduction callbacks decode the 2x2 window, invoke the reducer, and encode -the output pixel. Separable filter callbacks split that work across two passes. The shared operation owns spatial traversal and destination assignment, -including ordinary/clamped input support. The scaler still owns repeated levels, -intermediate rasters, retained halos, and cropping the stage input to the exact -required window. Validate the public source/destination overlap before narrowing -that input; keep paired cross-overlap validation outside the primitive. - -Direct upscaling still requires separate coordinate mapping. The code -savings are modest: the reusable traversal replaces output loops and coordinate -setup, but not filter arithmetic or the level driver. Verification covers rectangular/even kernels, per-axis positive strides, -sample origins, dimensions, ordinary/clamped equivalence, and encoding only -after both filter passes. - -### Required coverage - -- Raster, ordinary subview with row stride, and clamped-view sources; allocating - and explicit-destination routes; read-only input and writable output constraints. -- Output equivalence across routes and view forms, unchanged pixels outside - destination subviews, empty-input/interior rejection, dimensions, divisibility, - enums, overflow, extreme zoom counts, and insufficient logical halos. -- Clamped logical halos versus equivalent materialized padding, no implicit edge - clamping, and tile/metatile equivalence with sufficient physical halos. -- Overlap rejection, clamped physical-footprint overlap, zero-level identical - copies, paired cross-overlap, and argument errors before any output writes. -- Independent numeric references, constant preservation, Lanczos impulse and - frequency response, direct multi-factor upscaling, and repeated-step equality. -- Exactly four decoded reducer samples in row-major order, const-callable lambdas - and configured functors, decoder-selected working types, and the distinct - scale/reduce defaults. Check integer identity reduction, including 64-bit - min/max/mode and overflow-safe, halfway-away-from-zero integer median. -- Supplied and custom tuples, same stored input/output type, and compile-time - rejection of incompatible types and `Expected`-returning converters. Verify - that NN/crop bypass both callables and preserve nonfinite payloads exactly. -- Scalar mode, first-occurrence ties and zeros. Direct functor tests may check - distinct NaNs and repeated ordinary values beating NaNs; numerical pipeline - tests respect the finite-value - precondition. -- Paired output matching two independent generic calls; NN attribution on every - upscaling path, mode at every downscaling step, and all-zero attribution having - no effect on data arithmetic, including when Lanczos uses outer support. -- Linear-light RGB, independent alpha, integer limits, finite rounding/clamping, - representable floating overshoot, and repeated sRGB stage semantics. - -Vertex shared edges, physical-source selection, world wrapping, and tile-scheme -normalization remain broader generator/extractor tests. - -### Refactor verification — 2026-09-22 - -- Focused scaler/view tests: 61 cases, 1,347 assertions passed. -- Terrainlib: 546 cases, 26,141 assertions; RF builder: 47 cases, 53,812 assertions; - DAG builder: 76 cases, 479 assertions. -- Eleven affected public headers compile independently with warnings as errors. -- GCC 16 Release compilation of the scaler test translation unit at `-O3` - with warnings as errors passed. The previous median `std::sort` array-bounds - diagnostic did not recur; no warning-specific fix or suppression was added. -- New and changed C++ is formatted with `clang-format-21`. Qt lint reports only - an existing warning on unchanged `storage.h` code about a default-constructed - `std::optional`. - -### Historical verification of the previous implementation - -The coupled scaler passed 34 cases/904 assertions and the terrainlib suite -passed 519 cases/25,698 assertions, excluding the existing mesh clipping -benchmark, on 2026-09-19. Its independent Lanczos fixtures, type conversions, -Qt review, formatting, and line-ending checks passed. CI for commit `2a75fb4` -subsequently passed GCC 14, Clang unity, ASan, and TSan; GCC 16 failed on the -median sort diagnostic. These results are a baseline, not verification of the -refactor or halo implementation specified above. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 6423efe1..c54b677b 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -2,8 +2,8 @@ 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, implementation status, attribution management, -and the snapshot lifecycle. [Terminology](terminology.md) defines the domain +owns system responsibilities, attribution management, and the snapshot +lifecycle. [Terminology](terminology.md) defines the domain language. ## Directory layout @@ -86,12 +86,11 @@ 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. Halo extraction asserts a nominal size of at least 64. -`raster_store/pixel.h` exposes `pixel::identifier()` and -`pixel::Mapping { Linear, 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. -Storage validates the enum, while numerical operations validate whether the -pixel type supports the requested mapping. Exact copies do not need conversion. +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 @@ -169,19 +168,8 @@ defines when these files are read and how incomplete snapshots are opened. 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: - -```cpp -namespace raster_store { - -template -struct Tile { - radix::Raster data; - radix::Raster source_attribution; -}; - -} // namespace raster_store -``` +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 and hash options, @@ -222,6 +210,5 @@ participates in generic scaling just like any other supplied data value. Storage 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. The single-raster -[scaling refactor](scaling.md) specifies this separation; the design is agreed, -while the existing coupled scaler still awaits replacement. +an importer concern, separate from stored attribution. See the +[scaling rules](sampling-and-generation.md#implemented-scaling-rules). diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 4b7ed0fe..92c965f4 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -1,6 +1,6 @@ # Terminology -The planned extraction and storage contract is in +The halo extraction and storage contract is in [Tiles with halo](tiles-with-halo.md). **Tile interior**: @@ -107,7 +107,7 @@ _Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) **Representative attribution** : The single attribution index retained for a resampled value under the - [shared scaling rules](scaling.md#paired-raster-store-wrappers). It is not + [shared scaling rules](sampling-and-generation.md#attribution-and-value-mapping). It is not a complete record of the sources contributing to that value. **Ancestor fallback** diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md index 1992a884..02c9745b 100644 --- a/docs/raster-store/tiles-with-halo.md +++ b/docs/raster-store/tiles-with-halo.md @@ -1,255 +1,76 @@ # Tiles with halo -Status: implemented on 2026-09-22. Decisions were recorded during -2026-09-13 through 2026-09-15, with scaling and physical-coverage integration -revised on 2026-09-22. Version 1 is revised directly; no existing snapshots -require compatibility. - -## Accepted contract +A tile with halo carries extra pixels around its interior so that filters can +be evaluated near tile edges. Halos are either persisted in a snapshot or +assembled on read from neighbouring tiles. The implementation is +`src/terrainlib/raster_store/read_tile_with_halo.h`. -Use halo and `halo_width`, replacing the proposed border terminology. -Return the existing `Tile`, containing data and attribution rasters. -Keep `radix::Raster` and `raster_store::Tile` unchanged. Do not introduce a -separate `TileWithHalo` type or add halo width to `Tile`: -the caller knows the requested width, and metadata supplies the stored width. -The extraction result is in memory; the storage format must also gain support -for persisted halos in TB snapshots. +## Geometry Let `N` be the interior side, `H` the stored halo width, and `h` the requested -halo width. The interior is square and has a power-of-two side of at least -64 pixels; assert the minimum-size precondition. Both stored -rasters have side `N + 2H`; both result rasters have side `N + 2h`. A halo -extends the sampling region beyond the tile ID's interior geographic extent. -The requested width satisfies `0 <= h <= N`, including the corners. Reject -larger requests and dimension overflow. - -RF snapshots always have `H = 0`. TB snapshots may have `H > 0`, with -`0 <= H <= N`. RF builders enforce zero stored halo width; generic raster -storage does not need an RF/TB discriminator for this restriction. - -### Snapshot metadata and creation - -The metadata here is `raster_store::io::manifest::Metadata`, persisted in -`raster_store.metadata`, not members of the raster or tile objects. -Replace metadata `width` and `height` with `stored_tile_size`, -`nominal_tile_size`, and `halo_width`, where -`stored_tile_size = nominal_tile_size + 2 * halo_width`. -Nominal tile size defaults to 4096 pixels; halo width defaults to zero. -Both rasters remain square. - -Revise the existing version-1 representation directly. No metadata upgrade -or legacy-snapshot compatibility path is required. - -Use `raster_store::pixel::Mapping`, with values `Linear` and `SRGBA`, as -`value_mapping` in metadata and `storage::CreateOptions`. Define it in -`raster_store/pixel.h`, alongside the public `pixel::identifier()` and -internal `pixel::detail::Format`; do not define a duplicate mapping enum in -the versioned manifest namespace. The paired wrappers in -`raster_store/scaler.h` use this same type. This is a -value mapping rather than a colour-space label: linear data also includes -elevation. `SRGBA` applies to RGB as well as RGBA: RGB channels use the sRGB -transfer function; alpha, when present, is linear. - -Default three- and four-component unsigned 8-bit pixel types to `SRGBA`; -default other pixel types to `Linear`. Allow an explicit mapping override, -which takes precedence over the type-based default. Persist the resolved -mapping in metadata so readers do not infer it again. This convention -supersedes file-format-based selection and mandatory declarations for -untagged TIFF/VRT imagery. - -Add an RF CLI override, `--value-mapping linear|srgba`. CLI help must explain -the type-based defaults, the explicit override, and that SRGBA leaves alpha -linear and also applies to three-channel RGB. Both RF commands implement this option. It declares the stored payload mapping; -it does not alter import filtering or perform source-profile conversion. - -See [GDAL value-mapping research](gdal-value-mapping-research.md) for the -metadata available from GDAL and the inspected Swissimage reference TIFF. - -Keep the `storage::CreateOptions` struct and replace its `tile_dimensions` -member with `unsigned nominal_tile_size = 4096`. Add `halo_width = 0` to -creation options as well. Creation consumes these options to derive -`stored_tile_size` and write metadata, checking for dimension overflow. -This supersedes the earlier decision to specify stored size in the options. - -### Extraction API - -Use `read_tile_with_halo(storage, metadata, tile_id, halo_width, -interpolation_algorithm)`, returning `Expected>`. -Downscaling always uses box filtering; there is no downscaling-algorithm -parameter. Upscaling interpolation is caller-supplied and its parameter type -follows the scaling API. Pass metadata explicitly as -`const io::manifest::Metadata&`, allowing callers to read it once and reuse -it across extractions. No metadata member is added to `Raster` or `Tile`. - -Perform resolution changes through the implemented paired wrappers in -`terrainlib/raster_store/scaler.h`, which delegate to the generic algorithms -exposed by `terrainlib/raster/algorithm.h`, using these selections. -The [scaling contract](scaling.md) owns algorithm behavior, attribution -selection, conversion-tuple contracts, and required source halos; -do not duplicate those rules here. The extractor -provides the source windows and required halos for the selected operation. - -Use `raster::make_view` and `raster::algorithm::copy` for central and -same-zoom copies and for stored-halo cropping. Use a `raster::ClampedView` -of the complete central interior with `copy` for missing-coverage replication; -do not make a narrow source strip its own clamping boundary. Use the scaler's -explicit-destination overloads with output subviews wherever the geometry -matches, keeping fetched source tiles alive until their views are consumed. - -Extend the existing `scale` operations with output-window overloads as -specified in [Windowed scaling](scaling.md#agreed-windowed-scaling-extension). -Use `output_offset` and infer window size from the destination; allocating -overloads take `output_size` explicitly. This permits whole source tiles to -feed destination subviews without full scaled intermediates or a separate -crop/copy step. The extension applies to upscaling and downscaling. The -four-level descent limit bounds downscaling from finer tiles; it does not -bound upscaling from ancestors. Ancestor fallback supports gaps up to 30 levels; -larger gaps return an error before calculating local scaling offsets. -Verify bounded work with a 20-level ancestor gap and the 30-level limit. - -Bilinear ancestor interpolation requires one source pixel of support; -Lanczos-2/3/4 requires two/three/four respectively. Select the method with -`Resampling`. Finer-source reduction remains Box. Source -support uses a clamped view of the supplying ancestor's complete interior. -Do not fetch neighbouring tiles to extend interpolation support. If processing -a smaller cutout, preserve the full ancestor's clamping bounds rather than -clamping at the cutout edges. Clamping at the supplying ancestor's physical -edge is intentional, even if another tile exists beyond it. This avoids -recursive support dependencies and preserves once-per-source reads. - -Reject unsupported mapping/type combinations only in the operation that -does not support them. Do not impose the paired scaler's RGB8/RGBA8 SRGBA -restriction on metadata creation/opening or exact-copy paths. Other metadata -and geometry validation remains applicable. - -Validate the requested tile ID and require a physical tile. Both `Leaf` and -`Inner` qualify; `Virtual` and missing tiles do not. Reading or decoding an -indexed payload can fail; propagate those errors rather than treating them -as missing coverage. - -### Stores without a stored halo - -For `H = 0`: +halo width. The interior is square with a power-of-two side of at least 64 +pixels. Stored rasters have side `N + 2H`; results have side `N + 2h`, with +`0 <= h <= N`, including the corners. A halo extends the sampling region +beyond the tile ID's geographic extent at the interior's pixel spacing. -1. Allocate the two result rasters with side `N + 2h`. -2. Copy the central tile's data and attribution into the interior exactly, - including payload values with attribution zero. Do not resample the centre. -3. Fill the halo from spatial neighbours, including diagonal neighbours. - Prefer a physical tile at the requested zoom. -4. If that tile is not physical, descend into intersecting child regions, - stopping at the first physical tile on each branch. Downscale finer data - to the requested pixel spacing through the scaling facility. Limit descent - to four levels. If further - descent would be required, use the nearest available physical ancestor. -5. For regions without a physical descendant, use the nearest physical - ancestor and upscale its corresponding region through the scaling facility. - Descendant coverage in one region does not suppress fallback in another. +RF snapshots always have `H = 0`. TB snapshots may have `0 <= H <= N`. The +[storage format](storage-format.md#index-and-metadata) records the nominal +size, stored size and halo width in snapshot metadata. -Fetch each supplying ancestor at most once per extraction. Ancestor samples -must not overwrite regions supplied by selected same-zoom or descendant -tiles. This protection includes selected pixels with attribution zero: -missing attribution does not trigger fallback to another level. - -Resolve the source regions before reading their payloads. Partition the -halo into disjoint regions assigned to source tile IDs using the accepted -selection and depth-limit rules. Group those regions by ID, load each source -once, and fill only its assigned regions. This avoids ancestor overwrites -and the need to retain all decoded ancestors simultaneously. Regions without -a selected physical source use central-tile replication as described below. - -With a minimum nominal side of 64 and at most four levels of descent, -descendant tile boundaries align with requested output-pixel boundaries: -even a tile four levels finer spans at least four output pixels per side. -Consequently an output cell's geometric footprint need not combine different -selected tile resolutions. The scaling facility determines any additional -source samples required by the selected operation. +The requested centre must be a physical tile (`Leaf` or `Inner`). Read and +decode errors of indexed payloads are propagated, not treated as missing +coverage. -Selection is based on physical coverage, not attribution. Pixels with -attribution zero in a selected physical tile are valid inputs to scaling and -do not trigger a search at other levels. Preserve their supplied or resampled -payloads. The caller is responsible for supplying usable data values; -attribution does not mask numerical contributions. Representative attribution -follows the scaling contract. Complete provenance is not required. - -Only regions without a selected physical source use central-tile replication. -Read their data through a `raster::ClampedView` of the central tile's interior, -with the uncovered region's coordinates relative to that interior. The view -replicates the closest central pixel without allocating a padded source. -Set attribution to zero for these synthesized pixels, independently of the -central pixel's attribution. Do not replace a supplied or resampled payload -merely because its attribution is zero. - -Wrap horizontally at the antimeridian. Do not wrap vertically. Beyond the -vertical world limits, and wherever no selected physical representation covers -a region, use the same clamped-view replication with attribution -zero. The central interior remains unchanged. - -Pass the metadata's `pixel::Mapping` to the paired scaling wrappers, which -select the supplied linear or sRGB decoder/encoder tuple. Do not pass a -value-mapping enum into the generic algorithms. Native and -nearest-neighbour copies bypass conversion and remain bit-preserving. -For numerical resampling, callers must supply finite decoded values and keep -intermediate results finite and encodable. Rescaling follows the facility's -numeric and attribution contract; no separate radix rescaling refactor is -required for extraction. - -### Stores with a stored halo +## Stores with a stored halo For `H > 0`, fail if `h > H`. Otherwise crop both stored rasters to the -requested halo width, removing `H-h` pixels on each side. Preserve the -retained payload and attribution exactly, including zero-attribution pixels -and stored halo values. Do not fetch neighbours or repair the stored halo. -A request for zero halo width returns the interior; requesting the full -stored width returns all stored pixels. +requested halo width, preserving payload and attribution exactly. Neighbours +are not fetched and the stored halo is not repaired. -## Relationship to existing documents +## Stores without a stored halo -The previously documented allowance for arbitrary positive square dimensions -was intended to accommodate halo-inclusive rasters. The revised model makes -the interior power-of-two and expresses halo width separately. Existing -version-1 snapshots do not need preserving. +For `H = 0`: -The `Mixed sources` section has been removed from -`sampling-and-generation.md` as requested. Halo samples use representative -attribution. Further alignment of the existing storage/sampling documents -with the final halo contract belongs to the implementation plan. +1. Copy the central tile's data and attribution into the interior exactly, + including payload values with attribution zero. +2. Fill the halo from spatial neighbours, including diagonal neighbours. + Prefer a physical tile at the requested zoom. +3. If that tile is not physical, descend into intersecting child regions, + stopping at the first physical tile on each branch, and downscale with a + box filter. Descent is limited to four levels. +4. For regions without a selected physical descendant, use the nearest + physical ancestor and upscale its region with the requested interpolation. + Descendant coverage in one region does not suppress fallback in another. + Ancestor gaps up to 30 levels are supported. +5. Regions without any physical source replicate the central interior's edge + through a clamped view and receive attribution zero, independently of the + central pixel's attribution. -## Implementation +Wrap horizontally at the antimeridian. Do not wrap vertically; beyond the +vertical world limits, use the same replication. -Conversion tuples remain in the scaling interface. The staged -[implementation plan](halo-implementation-plan.md) records the completed stages -and verification results. The public implementation is -`src/terrainlib/raster_store/read_tile_with_halo.h`. +Source regions are resolved from the index before reading payloads: the halo +is partitioned into disjoint regions assigned to source tiles, and each +source is read once and fills only its assigned regions. Ancestor samples +therefore never overwrite same-zoom or descendant data. -## Required verification - -- Invalid, missing, and virtual centre IDs; physical leaf and inner centres. -- Bit-exact central copies, including zero-attribution payloads, and requests - for zero halo width. -- Same-zoom edges/corners, first-physical descendant selection, four-level - descent limit with ancestor fallback, and partial coverage. -- At-most-once ancestor fetches and preservation of selected finer samples, - including zero-attribution regions, when ancestor fallback is also used. -- Bilinear and Lanczos support clamp at the full supplying ancestor's edges, not a - processing cutout's edges, without reading neighbouring support tiles. -- Numerical contributions from zero-attribution pixels, representative - attribution, and preservation of supplied or resampled zero-attribution - payloads even when all source attributions are zero. -- Clamped-view replication only for missing physical coverage, including - replication from zero-attribution central pixels; synthesized attribution - remains zero even when the replicated central pixel has nonzero attribution. -- Horizontal wrapping, vertical limits, and uncovered dataset regions. -- Using box downscaling, forwarding the shared `pixel::Mapping` and the - interpolation parameter to the paired scaling wrappers, - and exact native/nearest-neighbour copies without invoking conversion. -- Type-based mapping defaults, explicit overrides, persistence of the - resolved mapping, linear alpha, and CLI help documenting the policy. -- Stored-halo cropping at zero, partial, and full width; rejection of requests - exceeding the stored halo; preservation of stored zero-attribution samples. -- Power-of-two interiors, the minimum side of 64, halo-width bounds, and - dimension overflow. -- Revised version-1 metadata and payload round trips. -- Integration with the scaling facility, including sufficient source halos, - multi-level reductions, and the extractor's missing-coverage replication. -- Windowed upscaling/downscaling into assigned output views, preserving full - result sampling phase and reduction stages while limiting computed samples. +With a minimum interior side of 64 and at most four levels of descent, +descendant tile boundaries align with output-pixel boundaries, so an output +pixel never combines different source resolutions. + +Selection is based on physical coverage, not attribution. Pixels with +attribution zero in a selected physical tile are valid inputs to scaling and +do not trigger a search at other levels. Attribution follows the +[representative attribution](../adr/0004-representative-attribution-for-halo-samples.md) +rule; complete provenance is not retained. + +Ancestor interpolation support is clamped at the complete supplying +ancestor's edges, even if another tile exists beyond it. Neighbouring tiles +are not fetched to extend interpolation support. This avoids recursive +support dependencies and preserves once-per-source reads, but it does not +provide the seam-free invariant described in +[sampling and generation](sampling-and-generation.md#no-duplicated-height-borders-in-the-store). + +The snapshot's value mapping selects linear or sRGB conversion for +resampling. Native and nearest-neighbour copies bypass conversion and are +bit-preserving. diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index e0a12c58..e6a3e3b3 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -4,7 +4,7 @@ additional SF invariants and their required error reporting are defined. This is not part of the shared-store refactor. - Idea: store each tile's XXH3-64 content hash (see the - [content-hash plan](content-hash-plan.md)) in its raster-store index entry. + [hash decision](../adr/0005-xxh3-64-envelope-hashes.md)) in its raster-store index entry. TB dependency checks would become in-memory lookups instead of one envelope header read per source tile, which matters for millions of tiles and on network filesystems. Saving a tile knows the hash from the envelope write, diff --git a/docs/raster-view-algorithms.md b/docs/raster-view-algorithms.md deleted file mode 100644 index b26a31aa..00000000 --- a/docs/raster-view-algorithms.md +++ /dev/null @@ -1,316 +0,0 @@ -# Raster views and view algorithms - -Status: design agreed on 2026-09-21 and extended and implemented on 2026-09-22, -including overloads that allocate and return rasters. -The scaling refactor also implements rectangular kernels and per-axis strides. - -## Scope - -Add rectangular views of `radix::Raster` and operations over caller-supplied -input and output views or whole rasters: - -- `copy`: copy pixels from one view into another. -- `transform`: invoke a callable on each input pixel to produce an output pixel. -- `zip_transform`: invoke a callable with one pixel from each of two or more - input views to produce an output pixel. Input pixel types may differ. -- `window_transform`: invoke a callable with a rectangular, kernel-sized input - view to produce one output pixel. - -Inputs may also be read-only clamped views that replicate source-edge pixels. -Destinations must be writable ordinary views or rasters. - -Pointwise operations require matching view widths and heights. Source and -destination pixel types may differ for transforms. The windowed operation -uses positive `glm::uvec2` kernel sizes and strides with no border treatment. -Each output dimension is `1 + (input_size - kernel_size) / stride`, using -integer division independently on each axis. Omitted stride means `{1, 1}`. -There are no scalar kernel-size or scalar-stride overloads. - -## Agreed view model - -Ordinary views are statically typed as `raster::View` or -`raster::View`, defined in `terrainlib/raster/View.h`. Clamped views -are defined in `terrainlib/raster/ClampedView.h`. Operations belong to -`raster::algorithm`, with implementations in `terrainlib/raster/algorithm/copy.h`, -`transform.h`, `zip_transform.h`, and `window_transform.h`. The existing -`terrainlib/raster/algorithm.h` includes those headers. -There is no type erasure or runtime datatype field. A pixel type denotes the -complete stored pixel, including all components of a vector pixel. - -Support rectangular regions of `radix::Raster` and subviews of those -regions. Pixels within a row remain contiguous, and a subview inherits its -backing raster's row stride. Offset and row stride are measured in pixels. -External buffers, flipped rows, and every-nth-pixel sampling are outside scope. - -Views borrow storage without extending its lifetime. The caller must keep the -backing storage alive and stable; destroying or replacing it invalidates its -views. Algorithm inputs expose read-only pixels. An ordinary descriptor carries -the backing storage pointer, pixel offset, row stride, width, and height; its -template argument supplies the pixel type. Representation details remain an -implementation choice within this model. - -## Construction and dimensions - -Use the free factory `raster::make_view` for raster and ordinary view sources; -there is no `subview` member method. Whole-raster or whole-view construction -returns a view directly. Construction with an origin and size returns -`Expected>`, with pixel constness preserved. Coordinates are relative -to the supplied raster or view. A requested region must fit completely; -construction does not clip it. - -Mutable views can convert to read-only views, but not the reverse. -Both view factories reject temporary rasters. A temporary view is only a -borrowed descriptor, and its destruction does not destroy the backing raster. - -Zero-width or zero-height views are valid. Pointwise operations with matching -empty dimensions succeed without invoking the callable. Window transforms -require positive kernel dimensions that fit the corresponding input dimensions -and positive strides on both axes. A `{1, 1}` kernel is valid; an oversized -kernel is an error. - -## Clamped views - -Provide a separate, read-only `raster::ClampedView` type, constructed by -`make_clamped_view(source, origin, size)`. The source is a raster or ordinary -view. The signed origin and logical dimensions may describe a region extending -beyond the source. Reads clamp each source coordinate to the supplied source -region's edges, repeating edge and corner pixels without allocating padding. -An ordinary source view defines its own clamping bounds, rather than those of -its entire backing raster. A nonempty clamped view requires a nonempty source. - -Clamped views expose the same `pixel`, `width`, `height`, and `size` interface -as ordinary views, but pixel access is always read-only. Callers must still -address within the clamped view's logical dimensions. Clamping applies to the -mapping into the source; it does not permit arbitrary out-of-view coordinates. -Ordinary views retain their direct addressing without a runtime clamping mode. - -`make_view(clamped_view)` preserves the clamped view type and mapping. Creating -a region of a clamped view through `make_view` requires that region to fit its -logical dimensions and preserves the original clamping bounds. In particular, -a window-transform kernel must not clamp independently at its own edges. - -The window transform does not add border treatment. -For example, extending an input by three pixels on each side allows a 7x7 -window transform at stride `{1, 1}` to produce output with the original input's dimensions. - -### Agreed window-transform extension - -The [scaling refactor](raster-store/scaling.md#agreed-window-transform-reuse) -uses rectangular kernels for separable passes. Both `kernel_size` and `stride` -are `glm::uvec2`; explicit-stride forms place the stride after the kernel size: - -```cpp -window_transform(source, kernel_size, stride, function, destination); -auto filtered = window_transform(source, kernel_size, stride, function); -``` - -For each input dimension `n`, kernel dimension `k`, and stride `s`, the output -dimension is `1 + (n - k) / s`, using integer division. Validate that the kernel -fits and that the stride is positive before calculating dimensions. Windows -start at `(x * stride.x, y * stride.y)`; trailing pixels that cannot form a complete window -are omitted. Both overload forms use the same rule, and supplied destinations -must already have the resulting dimensions. No destination view is resized. -A 2x2 kernel with stride two maps an 8x6 input to a 4x3 output. Larger kernels -consume more border pixels; stride two alone does not guarantee half-size output. - -Box and Lanczos downscaling use horizontal and vertical passes with an intermediate -raster of working pixels. Encoding happens only after both passes. Overlap -and clamped-view semantics are unchanged. - -## Access and call syntax - -Expose `pixel({x, y})`, `width()`, `height()`, and `size()` in the style of -`radix::Raster`. Pixel coordinates are relative to the view. Individual pixel -access has an in-bounds precondition checked by debug assertions; it does not -return `Expected`. - -For ordinary views, `pixel(...) const` returns `T&`: constness of the descriptor -does not determine pixel writability. `View` exposes read-only pixels, whereas even a -const `View` descriptor can expose writable pixels. Internal algorithm view -parameters are const references; public wrappers accept rasters or views as -described below. The output pixel type must be non-const. The algorithms do -not change the supplied descriptors. - -Use these call shapes: - -```cpp -copy(source, destination); -transform(source, function, destination); -zip_transform(a, b, function, destination); -zip_transform(a, b, c, function, destination); -zip_transform(std::tuple{a, b, c, d}, function, destination); -window_transform(source, kernel_size, function, destination); -``` - -Each call also has an overload omitting the destination, returning -`Expected>`. Copy preserves the source pixel type; transforms -infer `T` from the callable's exact return type. Pointwise output dimensions -match the inputs; window output dimensions follow the kernel-and-stride rule. -Validate input dimensions and kernel parameters before allocating. Invalid -geometry returns an error; unrepresentable storage sizes or allocation failure -return `ResourceExhausted`. Callable exceptions retain their existing contract. - -```cpp -auto copied = copy(source); -auto transformed = transform(source, function); -auto combined = zip_transform(a, b, function); -auto filtered = window_transform(source, kernel_size, function); -``` - -Zip exposes direct overloads for exactly two and three inputs. Four or -more inputs use the tuple overload, accepting the tuple by const -reference. Input order is the callable's pixel-argument order. All overloads -share validation and processing logic internally. - -When the tuple contains views, constructing it copies only view descriptors, -not raster pixels. For raster inputs, use a tuple of references such as -`std::tie(a, b, c, d)`, or explicitly create views before constructing the -tuple. `std::tuple{...}` containing raster objects by value would copy their -pixel buffers at the call site. -A bare braced list cannot deduce the heterogeneous input types for this -function-template interface; `std::tuple{...}` provides that deduction. -This deduction behavior was verified with a standalone C++23 compiler probe -and follows the language's -[initializer-list argument deduction rules](https://eel.is/c++draft/temp.deduct.call). - -## Raster and view argument wrappers - -Each public operation accepts a raster, ordinary view, or clamped view -independently for each input. The destination accepts a writable raster or -ordinary view. Normalize these arguments through `make_view` -and delegate to one internal implementation operating on views. A raster -becomes a view covering the whole raster; an existing view becomes an -equivalent view. Normalization does not copy raster buffers. Overloads with -an explicit destination do not allocate output storage; destination-free -overloads allocate a raster and use the same processing implementation. - -Internal implementations template their input-view types and read through -`pixel`, so ordinary and clamped inputs use the same processing logic without -inheritance or runtime dispatch. Zip inputs may mix both view types. Both -types provide backing-storage information internally for overlap validation. - -Constrain the normalized destination to `View` with non-const `T`, rejecting -read-only and clamped destinations at compile time. For example: - -```cpp -template - requires (!std::is_const_v) -Expected copy_views(const SourceView& source, const View& destination); -``` - -For example, the public copy wrapper has this structure, with the supported -argument and pixel-type constraints applied in the implementation: - -```cpp -template -Expected copy(Source&& source, Destination&& destination) -{ - auto source_view = make_view(source); - auto destination_view = make_view(destination); - - return detail::copy_views(source_view, destination_view); -} -``` - -The single public template supports all four combinations: - -```cpp -copy(source_raster, destination_raster); -copy(source_raster, destination_view); -copy(source_view, destination_raster); -copy(source_view, destination_view); -``` - -`detail::copy_views` owns dimension and overlap validation and the copying -loop. Apply the same wrapper structure to `transform`, `window_transform`, -and the three `zip_transform` overloads. Normalize each zip input, including -tuple elements, independently, preserving pixel constness. Temporary raster -arguments are valid for these synchronous operations. - -There are twelve public function-template overloads: six taking a destination -and six returning a raster. Each group has one copy, one transform, one window -transform, and three zip transforms. Internal implementation helpers are -additional. Do not multiply public overloads for -raster/view combinations or introduce a converting constructor to make -algorithm calls work. The compiler can instantiate the templates for the -argument combinations actually used. - -## Types and errors - -Pixel-type and callable compatibility are checked at compile time. -Dimensions, region bounds, kernel parameters, and prohibited input/output -overlap are checked at runtime. Runtime validation errors follow terrainlib's -`Expected` / `Error` conventions; operations return `Expected`. - -All argument validation completes before writing any output pixels or invoking -the transform callable. Invalid arguments leave the output unchanged. - -Copy requires identical pixel types, ignoring source constness. Each transform -callable must return exactly the destination pixel type by value. There is -no implicit output conversion; callers express conversions inside the callable. - -## Callables - -The callable is a template parameter, with no type erasure or machinery to -inspect its state. Immutable configuration, including captured values, is -permitted. Callbacks must not change state between invocations or depend on -invocation order. This is a caller contract; const invocation does not prove -the absence of external side effects. - -Pointwise callbacks receive stored input pixels through const references. -Window callbacks receive a read-only input view with local coordinates -starting at `(0, 0)`. The kernel for output pixel `(x, y)` starts at input-view -coordinate `(x * stride.x, y * stride.y)` and covers `kernel_size` pixels. -The kernel preserves the source's ordinary or clamped view type; a callback -such as `[](const auto& window) { ... }` can handle either. - -Execution order is not part of the public contract. The implementation visits -pixels in row-major order internally. Exceptions have no specified API -guarantees or special treatment; no catching, translation, or rollback is -introduced for callback exceptions. - -## Overlap - -Input views may overlap each other. Reject partial overlap between any input -and the output. For copy and pointwise transforms, permit an identical -input/output region when pixel types match. Other inputs to a zip transform -must independently satisfy the same rule relative to the output. - -For clamped inputs, overlap checks account for the backing pixels read through -clamping. Identical input/output regions require corresponding logical pixels -to address the same stored pixels, not merely equal logical dimensions. - -Reject all input/output overlap for windowed transforms. These operations do -not promise snapshot behavior through temporary buffers. - -## Pixel interpretation - -These are raw pixel operations. They do not interpret attribution, NoData, -colour encoding, or numeric conversion policy. Callables receive stored -pixels unchanged and control interpretation and conversion. Copy preserves -pixels exactly. - -The [scaling refactor agreed on 2026-09-22](raster-store/scaling.md) adopts -the same raster/view normalization, clamped sources, destination and allocating -overloads, and header placement. Generic scaling has no attribution, validity -mask, or value-mapping enum. A supplied decoder/encoder tuple determines the -working type and conversion; linear numeric conversion is the default for -`scale`, and identity is the default for `reduce`. Reducers receive exactly -four decoded working pixels through a read-only span. Numerical paths require -finite, encodable values as a caller precondition; NN/crop bypass conversions -and preserve pixels exactly. Empty inputs and interiors remain errors for -scaling. Paired `raster_store::scaler::scale` accepts the shared -`raster_store::pixel::Mapping` and selects the data conversion tuple; paired -`reduce` accepts a custom conversion tuple with identity as its default. -Both compose independent data and attribution operations. - -## Verification - -The refactor's focused scaler/view suite passes 61 cases (1,347 assertions). -The terrainlib, RF builder, and DAG builder regression suites pass. Eleven -public headers compile independently with warnings as errors. GCC 16 Release -compilation of the scaler tests at `-O3 -Werror` passes, including median -instantiations that previously triggered an array-bounds diagnostic. -Changed C++ is formatted with `clang-format-21`; Qt lint reports only an -existing warning on unchanged `storage.h` code. See the -[scaler verification record](raster-store/scaling.md#refactor-verification--2026-09-22). 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. From d30576f9283f061ac3d06f8af234aa23d70e3c64 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:55:38 +0200 Subject: [PATCH 34/83] removed read with halo, the tools are better off with writing their own bespoke reader - merger doesn't need downscaling, - tb builder needs reading the already the generated level from below (tb) and the rf source (if reading ancestors/same level) --- ...esentative-attribution-for-halo-samples.md | 15 - docs/raster-store/README.md | 1 - docs/raster-store/sampling-and-generation.md | 30 +- docs/raster-store/storage-format.md | 3 +- docs/raster-store/terminology.md | 4 +- docs/raster-store/tiles-with-halo.md | 76 ---- src/terrainlib/CMakeLists.txt | 1 - src/terrainlib/raster/algorithm/scale.h | 29 +- .../raster/algorithm/scaling_detail.h | 30 +- src/terrainlib/raster_store/Tile.h | 4 +- src/terrainlib/raster_store/io/TileCodec.h | 6 +- .../raster_store/read_tile_with_halo.h | 240 ------------- src/terrainlib/raster_store/scaler.h | 42 +-- unittests/CMakeLists.txt | 1 - unittests/terrainlib/raster_algorithm.cpp | 59 ++-- unittests/terrainlib/raster_halo.cpp | 324 ------------------ unittests/terrainlib/raster_store.cpp | 14 +- 17 files changed, 103 insertions(+), 776 deletions(-) delete mode 100644 docs/adr/0004-representative-attribution-for-halo-samples.md delete mode 100644 docs/raster-store/tiles-with-halo.md delete mode 100644 src/terrainlib/raster_store/read_tile_with_halo.h delete mode 100644 unittests/terrainlib/raster_halo.cpp diff --git a/docs/adr/0004-representative-attribution-for-halo-samples.md b/docs/adr/0004-representative-attribution-for-halo-samples.md deleted file mode 100644 index eb97187a..00000000 --- a/docs/adr/0004-representative-attribution-for-halo-samples.md +++ /dev/null @@ -1,15 +0,0 @@ -# Retain one representative attribution for halo samples - -Halo extraction retains one representative attribution per resampled pixel, -following the [scaling rules](../raster-store/sampling-and-generation.md#implemented-scaling-rules). -Attribution indices are categorical and are never averaged. The refactor -agreed on 2026-09-22 delegates paired operations to `raster_store::scaler`, -whose thin wrappers call the generic single-raster algorithms independently -for data and attribution: nearest-neighbour upscaling and repeated scalar -mode reduction for attribution. Zero participates as an ordinary unattributed -category and never controls which data samples contribute to arithmetic. - -This preserves the existing single-index attribution raster, including -persisted TB tiles, at the cost of discarding other contributing source IDs. -The retained index is representative attribution, not complete provenance. -A contributing-source set was considered and rejected. diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index 20ecc00b..d0d44ecc 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -12,7 +12,6 @@ implemented; TB generation and the tile server remain future work. - [Storage format](storage-format.md) - [Sampling and pyramid generation](sampling-and-generation.md): sampling theory, TB generation requirements, and the implemented scaling rules -- [Tiles with halo](tiles-with-halo.md) - [TODO](todo.md) Decisions and their rationale are recorded in the [ADRs](../adr/). diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md index 12fa76fe..1319c9fd 100644 --- a/docs/raster-store/sampling-and-generation.md +++ b/docs/raster-store/sampling-and-generation.md @@ -143,7 +143,7 @@ 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 physical-tile halo reader resolves a tile-centred window over the +The generator's window reader resolves a tile-centred window over the quadtree. It resolves: - physical chunks selected for the requested accuracy; @@ -151,11 +151,8 @@ quadtree. It resolves: - chunk and source-map decoding; and - neighbouring data needed by the window. -The generator determines the requested halo. The raster-store halo -reader applies replication only where physical coverage is missing, -including vertical world limits, using clamped views as described in -[Tiles with halo](tiles-with-halo.md). Zero attribution within a physical -source is not a boundary and does not trigger replacement of its data. +The generator determines the requested halo. Zero attribution within a +physical source is not a boundary and does not trigger replacement of its data. Callers prepare usable values before invoking the generic scaler; the scaler neither assembles neighbours nor invents boundary conditions. @@ -212,11 +209,8 @@ The generator needs explicit rules for: - zero-attribution pixels inside physically covered chunks; and - filters whose support crosses a layer's coverage boundary. -The raster-store halo rules are specified in -[Tiles with halo](tiles-with-halo.md): preserve physically supplied -zero-attribution payloads and replicate only missing physical coverage through -clamped views. Other generator-specific boundary policies remain separate -design work. Tests must distinguish true coverage boundaries from ordinary +Physically supplied zero-attribution payloads are preserved. Other +generator-specific boundary policies remain separate design work. Tests must distinguish true coverage boundaries from ordinary internal chunk and delivery-tile boundaries. Source dataset NoData/mask boundaries are handled during import, not by generic scaling. @@ -245,16 +239,14 @@ the area-pixel operations. Other layer-specific and vertex-pixel filters remain separate design decisions; tests should fix their numeric tolerances only after representative evaluation. -The [halo reader](tiles-with-halo.md) clamps ancestor interpolation -support at the complete supplying physical tile's edge. This is its explicit -fallback policy; it does not promise the seam-free vertex-generation invariant -above. A future generator needing that invariant must assemble common support -or generate metatiles. Halo attribution is representative and never masks -numerical contributions; zero attribution triggers no resolution fallback. +A generator needing the seam-free vertex-generation invariant above must +assemble common support or generate metatiles. Halo attribution is +representative and never masks numerical contributions; zero attribution +triggers no resolution fallback. ## Implemented scaling rules -`raster::algorithm` scales and reduces single rasters; `raster_store::scaler` +`raster::algorithm` scales and reduces single rasters; `raster_store::scale` pairs these operations for data and attribution. This section records their numerical contract; signatures are in the code. @@ -269,7 +261,7 @@ including nonfinite values. Only power-of-two scale factors are supported, with area-pixel placement. Vertex-pixel generation, world wrapping, physical-source selection and -ancestor fallback belong to callers such as the halo reader. +ancestor fallback belong to callers such as the RF merger. ### Methods diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index c54b677b..dfe1c258 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -84,7 +84,8 @@ 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. Halo extraction asserts a nominal size of at least 64. +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. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 92c965f4..dc4e1541 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -1,8 +1,5 @@ # Terminology -The halo extraction and storage contract is in -[Tiles with halo](tiles-with-halo.md). - **Tile interior**: The square raster region corresponding to a tile ID's geographic extent, excluding its halo. @@ -34,6 +31,7 @@ _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. diff --git a/docs/raster-store/tiles-with-halo.md b/docs/raster-store/tiles-with-halo.md deleted file mode 100644 index 02c9745b..00000000 --- a/docs/raster-store/tiles-with-halo.md +++ /dev/null @@ -1,76 +0,0 @@ -# Tiles with halo - -A tile with halo carries extra pixels around its interior so that filters can -be evaluated near tile edges. Halos are either persisted in a snapshot or -assembled on read from neighbouring tiles. The implementation is -`src/terrainlib/raster_store/read_tile_with_halo.h`. - -## Geometry - -Let `N` be the interior side, `H` the stored halo width, and `h` the requested -halo width. The interior is square with a power-of-two side of at least 64 -pixels. Stored rasters have side `N + 2H`; results have side `N + 2h`, with -`0 <= h <= N`, including the corners. A halo extends the sampling region -beyond the tile ID's geographic extent at the interior's pixel spacing. - -RF snapshots always have `H = 0`. TB snapshots may have `0 <= H <= N`. The -[storage format](storage-format.md#index-and-metadata) records the nominal -size, stored size and halo width in snapshot metadata. - -The requested centre must be a physical tile (`Leaf` or `Inner`). Read and -decode errors of indexed payloads are propagated, not treated as missing -coverage. - -## Stores with a stored halo - -For `H > 0`, fail if `h > H`. Otherwise crop both stored rasters to the -requested halo width, preserving payload and attribution exactly. Neighbours -are not fetched and the stored halo is not repaired. - -## Stores without a stored halo - -For `H = 0`: - -1. Copy the central tile's data and attribution into the interior exactly, - including payload values with attribution zero. -2. Fill the halo from spatial neighbours, including diagonal neighbours. - Prefer a physical tile at the requested zoom. -3. If that tile is not physical, descend into intersecting child regions, - stopping at the first physical tile on each branch, and downscale with a - box filter. Descent is limited to four levels. -4. For regions without a selected physical descendant, use the nearest - physical ancestor and upscale its region with the requested interpolation. - Descendant coverage in one region does not suppress fallback in another. - Ancestor gaps up to 30 levels are supported. -5. Regions without any physical source replicate the central interior's edge - through a clamped view and receive attribution zero, independently of the - central pixel's attribution. - -Wrap horizontally at the antimeridian. Do not wrap vertically; beyond the -vertical world limits, use the same replication. - -Source regions are resolved from the index before reading payloads: the halo -is partitioned into disjoint regions assigned to source tiles, and each -source is read once and fills only its assigned regions. Ancestor samples -therefore never overwrite same-zoom or descendant data. - -With a minimum interior side of 64 and at most four levels of descent, -descendant tile boundaries align with output-pixel boundaries, so an output -pixel never combines different source resolutions. - -Selection is based on physical coverage, not attribution. Pixels with -attribution zero in a selected physical tile are valid inputs to scaling and -do not trigger a search at other levels. Attribution follows the -[representative attribution](../adr/0004-representative-attribution-for-halo-samples.md) -rule; complete provenance is not retained. - -Ancestor interpolation support is clamped at the complete supplying -ancestor's edges, even if another tile exists beyond it. Neighbouring tiles -are not fetched to extend interpolation support. This avoids recursive -support dependencies and preserves once-per-source reads, but it does not -provide the seam-free invariant described in -[sampling and generation](sampling-and-generation.md#no-duplicated-height-borders-in-the-store). - -The snapshot's value mapping selects linear or sRGB conversion for -resampling. Native and nearest-neighbour copies bypass conversion and are -bit-preserving. diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 4510aa66..01db50eb 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -204,7 +204,6 @@ add_library(terrainlib raster_store/path_layout.h raster_store/pixel.h raster_store/scaler.h - raster_store/read_tile_with_halo.h raster_store/storage.h sf/finalize_storage.h diff --git a/src/terrainlib/raster/algorithm/scale.h b/src/terrainlib/raster/algorithm/scale.h index 05a6bbd5..bb643128 100644 --- a/src/terrainlib/raster/algorithm/scale.h +++ b/src/terrainlib/raster/algorithm/scale.h @@ -48,7 +48,7 @@ namespace detail { } template - std::array upscaling_weights(int position, unsigned factor, unsigned radius) + 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); @@ -72,7 +72,7 @@ namespace detail { Resampling method, const Conversion& conversion, const View& destination, - glm::ivec2 output_offset) + glm::i64vec2 output_offset) { using W = DecodedPixel; using S = typename PixelTraits::Scalar; @@ -81,8 +81,7 @@ namespace detail { if (radius == 0) { for (unsigned y = 0; y < destination.height(); ++y) for (unsigned x = 0; x < destination.width(); ++x) { - const glm::uvec2 nearest( - int(halo_width) + source_cell(output_offset.x + int(x), factor), int(halo_width) + source_cell(output_offset.y + int(y), factor)); + 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 {}; @@ -92,13 +91,13 @@ namespace detail { 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 + int(x), factor, radius); + horizontal->pixel({ x, 0 }) = upscaling_weights(output_offset.x + x, factor, radius); for (unsigned y = 0; y < destination.height(); ++y) { - const int position_y = output_offset.y + int(y); + const std::int64_t position_y = output_offset.y + y; const auto weights_y = upscaling_weights(position_y, factor, radius); - const unsigned origin_y = int(halo_width) + upscaling_origin(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 unsigned origin_x = int(halo_width) + upscaling_origin(output_offset.x + int(x), factor, radius); + 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) @@ -159,7 +158,7 @@ namespace detail { unsigned halo_width, int levels, Resampling method, - glm::ivec2 offset, + glm::i64vec2 offset, const Conversion& conversion, const View& destination) { @@ -184,7 +183,7 @@ template > && detail::ScalingConversion> && std::same_as, detail::SourcePixel> [[nodiscard]] Expected scale( - Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, const Conversion& conversion, Destination&& destination) + 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)); @@ -193,7 +192,7 @@ requires detail::NumericPixel> && detail::ScalingCon template requires detail::NumericPixel> && detail::ScalingConversion> [[nodiscard]] Expected>> scale( - Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, glm::uvec2 output_size, const Conversion& conversion) + 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) @@ -204,7 +203,7 @@ requires detail::NumericPixel> && detail::ScalingCon template requires detail::NumericPixel> && std::same_as, detail::SourcePixel> -[[nodiscard]] Expected scale(Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, Destination&& destination) +[[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, @@ -218,7 +217,7 @@ requires detail::NumericPixel> && std::same_as requires detail::NumericPixel> [[nodiscard]] Expected>> scale( - Source&& source, unsigned halo_width, int levels, Resampling method, glm::ivec2 output_offset, glm::uvec2 output_size) + 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>()); } @@ -234,7 +233,7 @@ requires detail::NumericPixel> && detail::ScalingCon 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::ivec2(0), conversion, std::forward(destination)); + return scale(std::forward(source), halo_width, levels, method, glm::i64vec2(0), conversion, std::forward(destination)); } template @@ -245,7 +244,7 @@ requires detail::NumericPixel> && detail::ScalingCon 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::ivec2(0), geometry->output, conversion); + return scale(std::forward(source), halo_width, levels, method, glm::i64vec2(0), geometry->output, conversion); } template diff --git a/src/terrainlib/raster/algorithm/scaling_detail.h b/src/terrainlib/raster/algorithm/scaling_detail.h index 449cd361..a0703931 100644 --- a/src/terrainlib/raster/algorithm/scaling_detail.h +++ b/src/terrainlib/raster/algorithm/scaling_detail.h @@ -56,20 +56,14 @@ namespace detail { return Error::fail(Error::Code::InvalidInput, "invalid raster resampling method"); } - // Floor division, including negative output coordinates and factors above INT_MAX. - inline int source_cell(int position, unsigned factor) - { - return position >= 0 ? int(unsigned(position) / factor) : -1 - int(unsigned(-(position + 1)) / factor); - } + // 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(int position, unsigned factor) - { - return unsigned(std::int64_t(position) - std::int64_t(source_cell(position, factor)) * factor); - } + inline unsigned source_phase(std::int64_t position, unsigned factor) { return unsigned(position - source_cell(position, factor) * factor); } - inline int upscaling_origin(int position, unsigned factor, unsigned radius) + inline std::int64_t upscaling_origin(std::int64_t position, unsigned factor, unsigned radius) { - const int cell = source_cell(position, factor); + 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; @@ -195,22 +189,24 @@ namespace detail { } // 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::ivec2 offset, glm::uvec2 size) + 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)); - assert(offset.x >= -(1 << 30) && offset.x < (1 << 30) && offset.y >= -(1 << 30) && offset.y < (1 << 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 int last = offset[axis] + int(size[axis] - 1); + 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) { @@ -220,8 +216,8 @@ namespace detail { } 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) + std::int64_t(offset[axis]) * factor - *required; - last_source = std::int64_t(halo_width) + (std::int64_t(last) + 1) * factor + *required - 1; + 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; @@ -245,7 +241,7 @@ struct SourceWindow { /// 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::ivec2 output_offset, glm::uvec2 output_size) + 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) diff --git a/src/terrainlib/raster_store/Tile.h b/src/terrainlib/raster_store/Tile.h index 1ed7578e..dc7fea7c 100644 --- a/src/terrainlib/raster_store/Tile.h +++ b/src/terrainlib/raster_store/Tile.h @@ -40,12 +40,12 @@ template struct Tile { explicit Tile(const unsigned side = default_tile_side) : data(side) - , source_attribution(glm::uvec2(side), std::uint16_t { 0 }) + , attribution(glm::uvec2(side), std::uint16_t { 0 }) { } radix::Raster data; - radix::Raster source_attribution; + radix::Raster attribution; }; } // namespace raster_store diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index fd731a3f..61101dab 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -85,7 +85,7 @@ class TileCodec final : public store::Codec> { } Tile tile(encoded->width); std::memcpy(tile.data.bytes().data(), encoded->data.data(), encoded->data.size()); - std::memcpy(tile.source_attribution.bytes().data(), encoded->source_attribution.data(), encoded->source_attribution.size()); + std::memcpy(tile.attribution.bytes().data(), encoded->source_attribution.data(), encoded->source_attribution.size()); return tile; } @@ -94,11 +94,11 @@ class TileCodec final : public store::Codec> { if (auto valid = validate_dimensions(m_dimensions); !valid) { return valid; } - if (tile.data.size() != m_dimensions || tile.source_attribution.size() != m_dimensions) { + 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 data = tile.data.bytes(); - const auto source_attribution = tile.source_attribution.bytes(); + const auto source_attribution = tile.attribution.bytes(); const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; return ::io::envelope::write_to_path(encoded, paths(node_path).front(), true, m_compression_algorithm, m_hash_algorithm); diff --git a/src/terrainlib/raster_store/read_tile_with_halo.h b/src/terrainlib/raster_store/read_tile_with_halo.h deleted file mode 100644 index 1cdece1f..00000000 --- a/src/terrainlib/raster_store/read_tile_with_halo.h +++ /dev/null @@ -1,240 +0,0 @@ -/***************************************************************************** - * 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 "raster/ClampedView.h" -#include -#include - -#include "raster_store/scaler.h" -#include "raster_store/storage.h" - -namespace raster_store { -namespace halo_detail { - struct Rectangle { - glm::uvec2 origin; - glm::uvec2 size; - }; - struct Assignment { - radix::tile::Id neighbour; - Rectangle region; - glm::uvec2 destination; - glm::uvec2 source_origin; - }; - inline bool physical(const store::Index& index, const radix::tile::Id& id) - { - const auto status = index.get(id); - return status && *status && **status != store::NodeStatus::Virtual; - } - inline std::optional intersect(Rectangle left, Rectangle right) - { - const auto origin = glm::max(left.origin, right.origin); - const auto end = glm::min(left.origin + left.size, right.origin + right.size); - if (origin.x >= end.x || origin.y >= end.y) - return std::nullopt; - return Rectangle { origin, end - origin }; - } -} // namespace halo_detail - -/// Read a physical tile and its halo. Metadata must belong to the supplied storage. -template -Expected> read_tile_with_halo(const storage::IndexedStorage& storage, - const io::manifest::Metadata& metadata, - const radix::tile::Id& id, - unsigned halo_width, - raster::algorithm::Resampling method) -{ - namespace algorithm = raster::algorithm; - using namespace halo_detail; - if (auto valid = algorithm::required_halo(0, method); !valid) - return Error::propagate(std::move(valid)); - if (auto valid = io::manifest::validate(metadata); !valid) - return Error::propagate(std::move(valid)); - if (!StoreTraits::is_valid(id)) - return Error::fail(Error::Code::InvalidInput, "invalid halo centre tile ID"); - const unsigned side = metadata.nominal_tile_size; - DEBUG_ASSERT(side >= 64); - if (halo_width > side || halo_width > ((std::numeric_limits::max)() - side) / 2 || (metadata.halo_width != 0 && halo_width > metadata.halo_width)) - return Error::fail(Error::Code::InvalidInput, "requested halo exceeds available width"); - auto centre = storage.load(id); - if (!centre) - return Error::propagate(std::move(centre), "read halo centre"); - const auto validate_tile = [&](const Tile& tile) -> Expected { - if (tile.data.size() != glm::uvec2(metadata.stored_tile_size) || tile.source_attribution.size() != tile.data.size()) - return Error::fail(Error::Code::CorruptData, "tile dimensions disagree with halo metadata"); - return {}; - }; - if (auto valid = validate_tile(*centre); !valid) - return Error::propagate(std::move(valid)); - const unsigned output_side = side + 2 * halo_width; - auto pair = scaler::detail::produce_pair(glm::uvec2(output_side), [](auto&, auto&) -> Expected { return {}; }); - if (!pair) - return Error::propagate(std::move(pair)); - Tile output(0); - output.data = std::move(pair->first); - output.source_attribution = std::move(pair->second); - output.source_attribution.fill(0); - if (metadata.halo_width != 0 || halo_width == 0) { - const auto origin = glm::uvec2(metadata.halo_width - halo_width); - if (auto result = algorithm::copy(*raster::make_view(centre->data, origin, output.data.size()), output.data); !result) - return Error::propagate(std::move(result)); - if (auto result = algorithm::copy(*raster::make_view(centre->source_attribution, origin, output.data.size()), output.source_attribution); !result) - return Error::propagate(std::move(result)); - return output; - } - if (auto result = algorithm::copy(centre->data, *raster::make_view(output.data, glm::uvec2(halo_width), glm::uvec2(side))); !result) - return Error::propagate(std::move(result)); - if (auto result = algorithm::copy(centre->source_attribution, *raster::make_view(output.source_attribution, glm::uvec2(halo_width), glm::uvec2(side))); - !result) - return Error::propagate(std::move(result)); - - std::map> assignments; - const auto replicate = [&](Rectangle region, glm::uvec2 destination) -> Expected { - auto source = raster::make_clamped_view(centre->data, glm::ivec2(destination) - glm::ivec2(halo_width), region.size); - if (!source) - return Error::propagate(std::move(source)); - return algorithm::copy(*source, *raster::make_view(output.data, destination, region.size)); - }; - // Every neighbour covers at most one side/corner rectangle. Resolve index - // coverage first, then group disjoint assignments to guarantee one source read. - for (int dy = -1; dy <= 1; ++dy) { - for (int dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dy == 0) - continue; - const glm::uvec2 size(dx == 0 ? side : halo_width, dy == 0 ? side : halo_width); - const glm::uvec2 destination(dx < 0 ? 0 : dx == 0 ? halo_width : halo_width + side, dy < 0 ? 0 : dy == 0 ? halo_width : halo_width + side); - const Rectangle region { { dx < 0 ? side - halo_width : 0, dy < 0 ? side - halo_width : 0 }, size }; - const unsigned maximum = id.zoom_level == 32 ? (std::numeric_limits::max)() : (1u << id.zoom_level) - 1; - if ((dy < 0 && id.coords.y == 0) || (dy > 0 && id.coords.y == maximum)) { - if (auto result = replicate(region, destination); !result) - return Error::propagate(std::move(result)); - continue; - } - const radix::tile::Id neighbour { id.zoom_level, { (id.coords.x + static_cast(dx)) & maximum, id.coords.y + static_cast(dy) } }; - std::optional ancestor = std::nullopt; - for (auto candidate = StoreTraits::parent(neighbour); candidate; candidate = StoreTraits::parent(*candidate)) { - if (physical(storage.index(), *candidate)) { - ancestor = candidate; - break; - } - } - const auto visit = [&](const auto& self, const radix::tile::Id& node, glm::uvec2 origin, unsigned extent, unsigned depth) -> Expected { - const auto overlap = intersect(region, { origin, glm::uvec2(extent) }); - if (!overlap) - return {}; - const auto target = destination + overlap->origin - region.origin; - if (physical(storage.index(), node)) { - assignments[node].push_back({ neighbour, *overlap, target, origin }); - return {}; - } - const auto status = storage.index().get(node); - const auto children = StoreTraits::children(node); - if (depth < 4 && status && *status && children) { - for (const auto& child : *children) { - const auto child_origin = origin + (child.coords & glm::uvec2(1)) * (extent / 2); - if (auto result = self(self, child, child_origin, extent / 2, depth + 1); !result) - return result; - } - return {}; - } - if (ancestor) - assignments[*ancestor].push_back({ neighbour, *overlap, target, {} }); - else - return replicate(*overlap, target); - return {}; - }; - if (auto result = visit(visit, neighbour, {}, side, 0); !result) - return Error::propagate(std::move(result)); - } - } - for (const auto& [source_id, regions] : assignments) { - std::optional> loaded; - if (source_id != id) { - auto tile = storage.load(source_id); - if (!tile) - return Error::propagate(std::move(tile), "read halo source"); - if (auto valid = validate_tile(*tile); !valid) - return Error::propagate(std::move(valid)); - loaded.emplace(std::move(*tile)); - } - const auto& source = loaded ? *loaded : *centre; - for (const auto& assignment : regions) { - const auto data = *raster::make_view(output.data, assignment.destination, assignment.region.size); - const auto attribution = *raster::make_view(output.source_attribution, assignment.destination, assignment.region.size); - if (source_id.zoom_level == id.zoom_level) { - if (auto result = algorithm::copy(*raster::make_view(source.data, assignment.region.origin, data.size()), data); !result) - return Error::propagate(std::move(result)); - if (auto result = algorithm::copy(*raster::make_view(source.source_attribution, assignment.region.origin, data.size()), attribution); !result) - return Error::propagate(std::move(result)); - } else if (source_id.zoom_level > id.zoom_level) { - auto result = scaler::scale(source.data, - source.source_attribution, - 0, - -int(source_id.zoom_level - id.zoom_level), - algorithm::Resampling::NearestNeighbourAndBox, - assignment.region.origin - assignment.source_origin, - metadata.value_mapping, - data, - attribution); - if (!result) - return Error::propagate(std::move(result)); - } else { - const unsigned gap = id.zoom_level - source_id.zoom_level; - if (gap > 30) - return Error::fail(Error::Code::InvalidInput, "ancestor fallback zoom gap exceeds 30 levels"); - const unsigned factor = 1u << gap; - const auto tile_offset = assignment.neighbour.coords & glm::uvec2(factor - 1); - const unsigned side_bits = std::countr_zero(side); - glm::uvec2 source_origin; - glm::uvec2 phase; - if (gap <= side_bits) { - source_origin = tile_offset * (side >> gap) + assignment.region.origin / factor; - phase = assignment.region.origin % factor; - } else { - source_origin = tile_offset >> (gap - side_bits); - phase = (tile_offset & glm::uvec2((1u << (gap - side_bits)) - 1)) * side + assignment.region.origin; - } - const auto last = phase + assignment.region.size - glm::uvec2(1); - const auto interior = last / factor + glm::uvec2(1); - const unsigned support = *algorithm::required_halo(int(gap), method); - const glm::ivec2 offset(phase); - // Clamping bounds remain those of the complete supplying tile. - auto input = raster::make_clamped_view(source.data, glm::ivec2(source_origin) - glm::ivec2(support), interior + glm::uvec2(2 * support)); - auto input_attribution - = raster::make_clamped_view(source.source_attribution, glm::ivec2(source_origin) - glm::ivec2(support), interior + glm::uvec2(2 * support)); - if (!input) - return Error::propagate(std::move(input)); - if (!input_attribution) - return Error::propagate(std::move(input_attribution)); - auto result = scaler::scale(*input, *input_attribution, support, int(gap), method, offset, metadata.value_mapping, data, attribution); - if (!result) - return Error::propagate(std::move(result)); - } - } - } - return output; -} -} // namespace raster_store diff --git a/src/terrainlib/raster_store/scaler.h b/src/terrainlib/raster_store/scaler.h index c010373f..98366fac 100644 --- a/src/terrainlib/raster_store/scaler.h +++ b/src/terrainlib/raster_store/scaler.h @@ -22,8 +22,8 @@ #include "raster/algorithm/reduce.h" #include "raster/algorithm/scale.h" -namespace raster_store::scaler { -namespace detail { +namespace raster_store { +namespace scale_details { namespace algorithm = raster::algorithm; namespace raster_detail = algorithm::detail; @@ -96,24 +96,24 @@ namespace detail { } return std::pair { std::move(*data), std::move(*attribution) }; } -} // namespace detail +} // namespace scale_details /// Scale only the selected conceptual output window. template -requires detail::PairedSources && detail::PairedDestinations +requires scale_details::PairedSources && scale_details::PairedDestinations [[nodiscard]] Expected scale(Data&& data, Attribution&& attribution, unsigned halo_width, int levels, raster::algorithm::Resampling method, - glm::ivec2 output_offset, + glm::i64vec2 output_offset, pixel::Mapping mapping, Destination&& destination, AttributionDestination&& attribution_destination) { namespace algorithm = raster::algorithm; using T = algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && method != raster::algorithm::Resampling::NearestNeighbourAndBox)); + 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)); @@ -161,18 +161,18 @@ requires detail::PairedSources && detail::PairedDestinations< } template -requires detail::PairedSources +requires scale_details::PairedSources [[nodiscard]] Expected>, radix::Raster>> scale(Data&& data, Attribution&& attribution, unsigned halo_width, int levels, raster::algorithm::Resampling method, - glm::ivec2 output_offset, + glm::i64vec2 output_offset, glm::uvec2 output_size, pixel::Mapping mapping) { using T = raster::algorithm::detail::SourcePixel; - if (auto valid = detail::validate_mapping(mapping, levels < 0 || (levels > 0 && method != raster::algorithm::Resampling::NearestNeighbourAndBox)); + 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); @@ -180,14 +180,14 @@ requires detail::PairedSources return Error::propagate(std::move(geometry)); if (data.size() != attribution.size()) return Error::fail(Error::Code::InvalidInput, "data and attribution dimensions differ"); - return detail::produce_pair(output_size, [&](auto& output, auto& output_attribution) { + 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 detail::PairedSources && detail::PairedDestinations +requires scale_details::PairedSources && scale_details::PairedDestinations [[nodiscard]] Expected scale(Data&& data, Attribution&& attribution, unsigned halo_width, @@ -207,26 +207,26 @@ requires detail::PairedSources && detail::PairedDestinations< halo_width, levels, method, - glm::ivec2(0), + glm::i64vec2(0), mapping, std::forward(destination), std::forward(attribution_destination)); } template -requires detail::PairedSources +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::ivec2(0), geometry->output, mapping); + 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 detail::PairedSources && detail::PairedDestinations +requires scale_details::PairedSources && scale_details::PairedDestinations && raster::algorithm::detail::PixelReduction> [[nodiscard]] Expected reduce(Data&& data, Attribution&& attribution, @@ -246,7 +246,7 @@ requires detail::PairedSources && detail::PairedDestinations< if (!geometry) { return Error::propagate(std::move(geometry)); } - if (auto valid = detail::validate_destinations(input, input_attribution, halo_width, *geometry, output, output_attribution); !valid) { + 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) { @@ -256,7 +256,7 @@ requires detail::PairedSources && detail::PairedDestinations< } template -requires detail::PairedSources +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) @@ -268,13 +268,13 @@ requires detail::PairedSources if (!geometry) { return Error::propagate(std::move(geometry)); } - return detail::produce_pair>(geometry->output, [&](auto& output, auto& output_attribution) { + 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 detail::PairedSources && detail::PairedDestinations +requires scale_details::PairedSources && scale_details::PairedDestinations && raster::algorithm::detail::PixelReduction> [[nodiscard]] Expected reduce(Data&& data, Attribution&& attribution, @@ -295,7 +295,7 @@ requires detail::PairedSources && detail::PairedDestinations< } template -requires detail::PairedSources +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>> @@ -303,4 +303,4 @@ requires detail::PairedSources return reduce( std::forward(data), std::forward(attribution), halo_width, n_zoom_levels, raster::algorithm::identity_conversion(), reducer); } -} // namespace raster_store::scaler +} // namespace raster_store diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 60396075..e16c9621 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -91,7 +91,6 @@ add_executable(unittests_terrainlib terrainlib/raster_fold.cpp terrainlib/raster_view.cpp terrainlib/raster_store.cpp - terrainlib/raster_halo.cpp terrainlib/spatial_lookup.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp diff --git a/unittests/terrainlib/raster_algorithm.cpp b/unittests/terrainlib/raster_algorithm.cpp index 84b08f11..fc203fd1 100644 --- a/unittests/terrainlib/raster_algorithm.cpp +++ b/unittests/terrainlib/raster_algorithm.cpp @@ -42,7 +42,6 @@ namespace { namespace algorithm = raster::algorithm; using algorithm::Resampling; -namespace scaler = raster_store::scaler; using ValueMapping = raster_store::pixel::Mapping; using Catch::Matchers::WithinAbs; @@ -60,7 +59,7 @@ auto scale(const radix::Raster& data, Resampling method = Resampling::NearestNeighbourAndBox, ValueMapping mapping = ValueMapping::Linear) { - return scaler::scale(data, attribution, halo, levels, method, mapping); + return raster_store::scale(data, attribution, halo, levels, method, mapping); } template @@ -168,7 +167,7 @@ TEST_CASE("raster scaling rejects invalid geometry and dimensions before allocat 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(scaler::reduce(data, attribution, 0, (std::numeric_limits::max)(), algorithm::Max {})); + 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]") @@ -191,7 +190,7 @@ TEST_CASE("zero zoom levels crop both rasters preserving payload bits", "[raster ++calls; return 0.f; }; - auto reduced = scaler::reduce(data, attribution, 2, 0, algorithm::linear_conversion>(), reducer); + auto reduced = raster_store::reduce(data, attribution, 2, 0, algorithm::linear_conversion>(), reducer); REQUIRE(reduced); CHECK(calls == 0); same_raster(output->first, reduced->first); @@ -308,7 +307,7 @@ TEST_CASE("custom reducers receive four decoded row-major samples and immutable CHECK(values[3] == 8); return values[2] + bias; }; - const auto result = scaler::reduce(data, ids, 0, 1, conversion, reducer); + 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); @@ -342,7 +341,7 @@ TEMPLATE_TEST_CASE("raster scalar working types support integer endpoints", const auto result = scale(data, ids, -1); REQUIRE(result); CHECK(result->first.pixel({ 0, 0 }) == value); - const auto maximum = scaler::reduce(data, ids, 0, 1, algorithm::Max {}); + const auto maximum = raster_store::reduce(data, ids, 0, 1, algorithm::Max {}); REQUIRE(maximum); CHECK(maximum->first.pixel({ 0, 0 }) == value); } @@ -354,7 +353,7 @@ TEST_CASE("integer raster conversion rounds and clamps finite custom values", "[ radix::Raster ids(glm::uvec2(2), 1); const auto convert = [&](float value) { const auto reducer = [value](std::span) { return value; }; - return scaler::reduce(data, ids, 0, 1, algorithm::linear_conversion>(), reducer); + 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); @@ -362,7 +361,7 @@ TEST_CASE("integer raster conversion rounds and clamps finite custom values", "[ 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(scaler::reduce(large, ids, 0, 1, algorithm::linear_conversion(), upper)->first.pixel({ 0, 0 }) + CHECK(raster_store::reduce(large, ids, 0, 1, algorithm::linear_conversion(), upper)->first.pixel({ 0, 0 }) == (std::numeric_limits::max)()); } @@ -379,7 +378,7 @@ TEST_CASE("sRGB raster scaling and custom reduction operate in linear light with CHECK(values[2].w == 128.f / 255.f); return algorithm::Median {}(values); }; - const auto custom = scaler::reduce(data, ids, 0, 1, algorithm::srgb_conversion(), reducer); + 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); @@ -503,7 +502,7 @@ TEMPLATE_TEST_CASE("raster vector arithmetic instantiates every working precisio 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 = scaler::reduce(data, ids, 3, 1, algorithm::Median {}); + 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) { @@ -654,15 +653,15 @@ TEST_CASE("paired destination validation prevents partial writes on invalid attr radix::Raster ids(glm::uvec2(4), 0); radix::Raster output(glm::uvec2(2), 99); radix::Raster wrong(glm::uvec2(3), 77); - CHECK_FALSE(scaler::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, wrong)); - CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, wrong)); + 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(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, cross)); - CHECK_FALSE(scaler::scale(data, ids, 0, -1, Resampling::BiliinearAndBox, ValueMapping::Linear, output, cross)); - CHECK_FALSE(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, output)); + 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(scaler::reduce(data, ids, 0, 1, algorithm::Max {}, output, out_ids)); + 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; })); } @@ -755,11 +754,11 @@ TEST_CASE("scaling windows match full output crops across stages and kernels", " { 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 = scaler::scale(data, attribution, halo, levels, method, ValueMapping::Linear); + auto full = raster_store::scale(data, attribution, halo, levels, method, ValueMapping::Linear); REQUIRE(full); - const glm::ivec2 offset(3, 1); + const glm::i64vec2 offset(3, 1); const glm::uvec2 size(3, 4); - auto window = scaler::scale(data, attribution, halo, levels, method, offset, size, ValueMapping::Linear); + 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))); @@ -775,8 +774,8 @@ TEST_CASE("scaling windows retain conversion tuples and per-stage encoded roundi data.pixel({ x, y }) = { x * 7, y * 7, (x * y) % 256, (x + 3 * y) % 256 }; attribution.pixel({ x, y }) = (x + y) % 3; } - auto full = scaler::scale(data, attribution, 0, -2, Resampling::NearestNeighbourAndBox, ValueMapping::SRGBA); - auto window = scaler::scale(data, attribution, 0, -2, Resampling::NearestNeighbourAndBox, { 1, 2 }, { 4, 3 }, ValueMapping::SRGBA); + 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 }))); @@ -808,14 +807,14 @@ TEST_CASE("scaling validates full destinations and windows before writing", "[ra CHECK(destination.pixel({ 0, 0 }) == 99); radix::Raster attribution({ 4, 4 }); radix::Raster wrong({ 2, 2 }); - CHECK_FALSE(scaler::scale(source, attribution, 0, 1, Resampling::NearestNeighbourAndBox, { 0, 0 }, ValueMapping::Linear, destination, wrong)); + 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 = scaler::scale(data, attribution, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, { 5, 7 }, ValueMapping::Linear); + 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); @@ -823,7 +822,7 @@ TEST_CASE("windowed scaling writes strided destinations and rejects overlap befo 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(scaler::scale(data, attribution, 1, 1, Resampling::BiliinearAndBox, { 3, 5 }, ValueMapping::Linear, view, attribution_view)); + 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); @@ -863,8 +862,8 @@ TEST_CASE("Lanczos upscaling preserves halos and matches independent sinc recons 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::ivec2 offset(-int(halo)); - auto output = scaler::scale(data, ids, halo, levels, method, offset, size, ValueMapping::Linear); + 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) { @@ -923,8 +922,8 @@ TEST_CASE("halo windows match larger raster crops in both scaling directions", " 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 = scaler::scale(data, ids, halo, levels, method, { -2, -2 }, output_size, ValueMapping::Linear); - auto reference = scaler::scale(data, ids, support, levels, method, ValueMapping::Linear); + 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; @@ -943,10 +942,10 @@ TEST_CASE("negative halo windows validate support before touching paired strided 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(scaler::scale(data, ids, 5, 1, Resampling::Lanczos3, { -6, -5 }, ValueMapping::Linear, output, attribution)); + 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(scaler::scale(data, ids, 5, 1, Resampling::Lanczos3, { -5, -5 }, ValueMapping::Linear, output, attribution)); + 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); diff --git a/unittests/terrainlib/raster_halo.cpp b/unittests/terrainlib/raster_halo.cpp deleted file mode 100644 index 029057a7..00000000 --- a/unittests/terrainlib/raster_halo.cpp +++ /dev/null @@ -1,324 +0,0 @@ -/***************************************************************************** - * 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 "../temporary_directory.h" -#include "raster_store/read_tile_with_halo.h" - -namespace { -using Tile = raster_store::Tile; -using Id = radix::tile::Id; -using Resampling = raster::algorithm::Resampling; -struct MemoryCodec final : store::Codec { - mutable std::map tiles; - mutable std::map reads; - std::vector paths(const std::filesystem::path& path) const override { return { path }; } - Expected read(const std::filesystem::path& path) const override - { - ++reads[path]; - if (!tiles.contains(path)) - return Error::fail(Error::Code::Io, "injected missing payload"); - return tiles.at(path); - } - Expected write(const std::filesystem::path& path, const Tile& tile) const override - { - tiles.insert_or_assign(path, tile); - return {}; - } -}; -struct Fixture { - test::TemporaryDirectory directory; - MemoryCodec* codec; - std::unique_ptr> storage; - raster_store::io::manifest::Metadata metadata { "zoom/x/y_google", "float32", "memory", 64, 64, 0 }; - Fixture() - { - auto owned = std::make_unique(); - codec = owned.get(); - auto made = store::make_storage(directory.path(), - raster_store::io::manifest::index_format(), - raster_store::path_layout::zoom_xy_google::zoom_x_y_google(), - "float32", - "memory", - std::move(owned)); - REQUIRE(made); - storage = std::make_unique>(std::move(*made)); - } - void add(Id id, float value, unsigned attribution = 1) - { - Tile tile(metadata.stored_tile_size); - tile.data.fill(value); - tile.source_attribution.fill(attribution); - REQUIRE(storage->save(id, tile)); - } - auto read(Id id, unsigned halo = 2, Resampling interpolation = Resampling::NearestNeighbourAndBox) - { - return raster_store::read_tile_with_halo(*storage, metadata, id, halo, interpolation); - } - void once() const - { - for (const auto& [path, count] : codec->reads) { - INFO(path); - CHECK(count == 1); - } - } -}; -} // namespace - -TEST_CASE("halo centre and missing coverage preserve payload bits", "[raster-halo]") -{ - Fixture f; - const Id centre { 2, { 1, 1 } }; - f.add(centre, 42, 7); - auto tile = f.read(centre); - REQUIRE(tile); - CHECK(tile->data.size() == glm::uvec2(68)); - CHECK(tile->data.pixel({ 0, 0 }) == 42); - CHECK(tile->source_attribution.pixel({ 0, 0 }) == 0); - CHECK(tile->source_attribution.pixel({ 2, 2 }) == 7); - f.once(); - CHECK_FALSE(f.read({ 2, { 4, 0 } })); - CHECK_FALSE(f.read({ 2, { 0, 0 } })); - CHECK_FALSE(f.read({ 0, { 0, 0 } })); - CHECK_FALSE(f.read(centre, 65)); -} - -TEST_CASE("halo neighbours supply every edge and corner including zero attribution", "[raster-halo]") -{ - Fixture f; - for (unsigned y = 0; y < 3; ++y) - for (unsigned x = 0; x < 3; ++x) - f.add({ 2, { x, y } }, float(10 * y + x), 0); - auto tile = f.read({ 2, { 1, 1 } }, 64); - REQUIRE(tile); - for (unsigned y = 0; y < 192; ++y) - for (unsigned x = 0; x < 192; ++x) { - CHECK(tile->data.pixel({ x, y }) == float(10 * (y / 64) + x / 64)); - CHECK(tile->source_attribution.pixel({ x, y }) == 0); - } - f.once(); -} - -TEST_CASE("halo descendants stop at physical tiles and ancestors fill only uncovered regions", "[raster-halo]") -{ - Fixture f; - const Id root { 0, { 0, 0 } }, centre { 2, { 1, 1 } }; - f.add(root, 5, 5); - f.add(centre, 10, 10); - f.add({ 3, { 4, 2 } }, 20, 0); - f.add({ 4, { 8, 4 } }, 90, 9); // Physical inner at zoom 3 must win. - auto tile = f.read(centre, 64); - REQUIRE(tile); - CHECK(tile->data.pixel({ 128, 64 }) == 20); - CHECK(tile->source_attribution.pixel({ 128, 64 }) == 0); - CHECK(tile->data.pixel({ 160, 64 }) == 5); - CHECK(tile->data.pixel({ 128, 96 }) == 5); - CHECK(f.codec->reads.size() == 3); - f.once(); -} - -TEST_CASE("halo descent cutoff uses an ancestor without loading deep payloads", "[raster-halo]") -{ - Fixture f; - f.add({ 0, { 0, 0 } }, 7); - f.add({ 2, { 1, 1 } }, 11); - f.add({ 7, { 64, 32 } }, 99); - auto tile = f.read({ 2, { 1, 1 } }, 64); - REQUIRE(tile); - CHECK(tile->data.pixel({ 128, 64 }) == 7); - CHECK(f.codec->reads.size() == 2); - f.once(); -} - -TEST_CASE("halo root wraps horizontally and replicates beyond vertical limits", "[raster-halo]") -{ - Fixture f; - f.add({ 0, { 0, 0 } }, 12, 4); - auto tile = f.read({ 0, { 0, 0 } }); - REQUIRE(tile); - CHECK(tile->source_attribution.pixel({ 0, 2 }) == 4); - CHECK(tile->source_attribution.pixel({ 0, 0 }) == 0); - CHECK(tile->source_attribution.pixel({ 2, 67 }) == 0); - f.once(); -} - -TEST_CASE("stored halo crops exactly without fetching neighbours or validating numerical mapping", "[raster-halo]") -{ - Fixture f; - f.metadata.halo_width = 3; - f.metadata.stored_tile_size = 70; - f.metadata.value_mapping = raster_store::pixel::Mapping::SRGBA; - f.add({ 1, { 0, 0 } }, std::bit_cast(0x7fc01234u), 0); - f.add({ 1, { 1, 0 } }, 1); - for (unsigned halo : { 0u, 1u, 3u }) { - f.codec->reads.clear(); - auto tile = f.read({ 1, { 0, 0 } }, halo); - REQUIRE(tile); - CHECK(tile->data.size() == glm::uvec2(64 + 2 * halo)); - CHECK(std::bit_cast(tile->data.pixel({ 0, 0 })) == 0x7fc01234u); - CHECK(f.codec->reads.size() == 1); - f.once(); - } - CHECK_FALSE(f.read({ 1, { 0, 0 } }, 4)); -} - -TEST_CASE("halo propagates indexed payload errors", "[raster-halo]") -{ - Fixture f; - f.add({ 1, { 0, 0 } }, 1); - f.add({ 1, { 1, 0 } }, 2); - f.codec->tiles.erase(f.directory.path() / "1/1/0"); - auto result = f.read({ 1, { 0, 0 } }); - REQUIRE_FALSE(result); - CHECK(result.error().code() == Error::Code::Io); -} - -TEST_CASE("halo distant ancestor samples use bounded windows with every resampling method", "[raster-halo]") -{ - for (auto interpolation : - { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { - Fixture f; - f.add({ 0, { 0, 0 } }, 23, 3); - f.add({ 20, { 400000, 500000 } }, 42, 4); - auto result = f.read({ 20, { 400000, 500000 } }, 2, interpolation); - REQUIRE(result); - CHECK(result->data.pixel({ 0, 0 }) == Catch::Approx(23).margin(1e-4)); - CHECK(result->source_attribution.pixel({ 0, 0 }) == 3); - CHECK(result->data.pixel({ 2, 2 }) == 42); - f.once(); - } -} - -TEST_CASE("halo ancestor windows match complete-ancestor scaling at edges and cutouts", "[raster-halo]") -{ - for (auto interpolation : - { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos2, Resampling::Lanczos3, Resampling::Lanczos4 }) { - Fixture f; - Tile ancestor(64); - for (unsigned y = 0; y < 64; ++y) - for (unsigned x = 0; x < 64; ++x) { - ancestor.data.pixel({ x, y }) = float(x + 100 * y); - ancestor.source_attribution.pixel({ x, y }) = (x + 2 * y) % 7; - } - REQUIRE(f.storage->save({ 0, { 0, 0 } }, ancestor)); - const Id centre { 2, { 0, 1 } }; - f.add(centre, -1, 9); - auto tile = f.read(centre, 64, interpolation); - REQUIRE(tile); - const unsigned support = *raster::algorithm::required_halo(2, interpolation); - const auto padded = *raster::make_clamped_view(ancestor.data, glm::ivec2(-int(support)), glm::uvec2(64 + 2 * support)); - const auto padded_attribution = *raster::make_clamped_view(ancestor.source_attribution, glm::ivec2(-int(support)), glm::uvec2(64 + 2 * support)); - auto full = raster_store::scaler::scale(padded, padded_attribution, support, 2, interpolation, raster_store::pixel::Mapping::Linear); - REQUIRE(full); - for (unsigned y = 0; y < 192; ++y) - for (unsigned x = 0; x < 192; ++x) { - if (x >= 64 && x < 128 && y >= 64 && y < 128) - continue; - const glm::uvec2 position((x + 192) % 256, y); - CHECK(tile->data.pixel({ x, y }) == full->first.pixel(position)); - CHECK(tile->source_attribution.pixel({ x, y }) == full->second.pixel(position)); - } - f.once(); - } -} - -TEST_CASE("halo box reductions use physical zero-attribution samples", "[raster-halo]") -{ - Fixture f; - f.add({ 2, { 1, 1 } }, -1); - Tile finer(64); - for (unsigned y = 0; y < 64; ++y) - for (unsigned x = 0; x < 64; ++x) - finer.data.pixel({ x, y }) = float(x + 100 * y); - REQUIRE(f.storage->save({ 3, { 4, 2 } }, finer)); - auto tile = f.read({ 2, { 1, 1 } }, 64); - REQUIRE(tile); - CHECK(tile->data.pixel({ 128, 64 }) == 50.5f); - CHECK(tile->data.pixel({ 159, 95 }) == 6312.5f); - CHECK(tile->source_attribution.pixel({ 128, 64 }) == 0); - CHECK(tile->data.pixel({ 160, 64 }) == -1); - CHECK(tile->source_attribution.pixel({ 160, 64 }) == 0); - f.once(); -} - -TEST_CASE("halo exact centre and replication preserve nonfinite and signed-zero bits", "[raster-halo]") -{ - Fixture f; - Tile centre(64); - centre.data.fill(std::bit_cast(0x7fc01234u)); - centre.data.pixel({ 63, 63 }) = -0.f; - REQUIRE(f.storage->save({ 2, { 1, 1 } }, centre)); - auto tile = f.read({ 2, { 1, 1 } }, 2); - REQUIRE(tile); - CHECK(std::bit_cast(tile->data.pixel({ 0, 0 })) == 0x7fc01234u); - CHECK(std::bit_cast(tile->data.pixel({ 2, 2 })) == 0x7fc01234u); - CHECK(std::bit_cast(tile->data.pixel({ 67, 67 })) == 0x80000000u); - auto exact = f.read({ 2, { 1, 1 } }, 0); - REQUIRE(exact); - CHECK(std::ranges::equal(exact->data.bytes(), centre.data.bytes())); -} - -TEST_CASE("halo nonconstant distant ancestor preserves fractional phase", "[raster-halo]") -{ - Fixture f; - Tile ancestor(64); - for (unsigned y = 0; y < 64; ++y) - for (unsigned x = 0; x < 64; ++x) - ancestor.data.pixel({ x, y }) = float(x); - REQUIRE(f.storage->save({ 0, { 0, 0 } }, ancestor)); - const Id centre { 20, { 400000, 500000 } }; - f.add(centre, -1); - auto tile = f.read(centre, 2, Resampling::BiliinearAndBox); - REQUIRE(tile); - // The left halo begins two requested pixels before the centre's boundary. - const double expected = (400000.0 * 64 - 2 + 0.5) / double(1u << 20) - 0.5; - CHECK(std::abs(tile->data.pixel({ 0, 2 }) - expected) < 0.00001); - f.once(); -} - -TEST_CASE("halo ancestor fallback supports a zoom gap of 30 without rebasing", "[raster-halo]") -{ - for (auto method : { Resampling::NearestNeighbourAndBox, Resampling::BiliinearAndBox, Resampling::Lanczos3 }) { - Fixture f; - f.add({ 0, { 0, 0 } }, 23, 3); - const Id centre { 30, { (1u << 24) - 1, (1u << 24) - 1 } }; - f.add(centre, 42, 4); - auto result = f.read(centre, 2, method); - REQUIRE(result); - CHECK(result->data.pixel({ 0, 0 }) == Catch::Approx(23).margin(1e-4)); - CHECK(result->data.pixel({ 67, 32 }) == Catch::Approx(23).margin(1e-4)); - CHECK(result->source_attribution.pixel({ 67, 32 }) == 3); - f.once(); - } -} - -TEST_CASE("halo ancestor fallback rejects zoom gaps above 30", "[raster-halo]") -{ - for (unsigned gap : { 31u, 32u }) { - Fixture f; - f.add({ 0, { 0, 0 } }, 23, 3); - const Id centre { gap, { 1u << 24, 1u << 24 } }; - f.add(centre, 42, 4); - auto result = f.read(centre, 2, Resampling::Lanczos3); - REQUIRE_FALSE(result); - CHECK(result.error().code() == Error::Code::InvalidInput); - CHECK(result.error().to_string().find("ancestor fallback zoom gap") != std::string::npos); - } -} diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 26985eac..bdb03b63 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -142,20 +142,20 @@ TEMPLATE_TEST_CASE("AMORT preserves native scalar and packed vector bytes", "[ra { TemporaryDirectory directory; raster_store::Tile tile(3); - CHECK(std::ranges::all_of(tile.source_attribution, [](const auto value) { return value == 0; })); + 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.source_attribution.pixel({ 1, 1 }) = 65534; + 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->source_attribution, tile.source_attribution); + check_bytes(read->attribution, tile.attribution); TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::Xxh3_64); REQUIRE(uncompressed.write(path, tile)); @@ -169,7 +169,7 @@ TEST_CASE("AMORT preserves NaN bits and data beneath NoData pixels", "[raster-st TemporaryDirectory directory; raster_store::Tile tile(3); tile.data.pixel({ 0, 0 }) = std::bit_cast(std::uint32_t { 0x7fc01234 }); - tile.source_attribution.pixel({ 0, 0 }) = 1; + 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 }); @@ -177,7 +177,7 @@ TEST_CASE("AMORT preserves NaN bits and data beneath NoData pixels", "[raster-st auto read = amort.read(directory.path() / "tile"); REQUIRE(read); check_bytes(read->data, tile.data); - check_bytes(read->source_attribution, tile.source_attribution); + check_bytes(read->attribution, tile.attribution); } TEST_CASE("AMORT validates dimensions and byte counts before allocating typed rasters", "[raster-store][codec]") @@ -186,7 +186,7 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra const auto path = directory.path() / "tile"; TileCodec amort({ 3, 3 }); raster_store::Tile tile(3); - tile.source_attribution = radix::Raster(2); + 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); @@ -254,7 +254,7 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl raster_store::Tile tile(4); tile.data.fill(12.5f); // Reference checks belong to RF builder, not storage reads or writes. - tile.source_attribution.fill(65534); + tile.attribution.fill(65534); REQUIRE(created->save({ 0, { 0, 0 } }, tile)); REQUIRE(created->save_index()); CHECK(io::read_bytes_from_path(partial / manifest::metadata_file_name).value() == metadata_before); From b7ef1fdd81de4f19c68a1ea62b5147ffbbfac836 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:38:07 +0200 Subject: [PATCH 35/83] update docs --- docs/raster-store/architecture.md | 5 +++-- docs/raster-store/terminology.md | 13 ++++++++++++- docs/raster-store/todo.md | 4 ++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/raster-store/architecture.md b/docs/raster-store/architecture.md index e95c733c..c051b66c 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -28,7 +28,7 @@ Authoritative rf raster store │ │ 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 @@ -128,7 +128,8 @@ unusable and must be validated or rebuilt. ### tb_builder -Tile-base adds overviews to RF and retains the data. Every level is occupied +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. See [sampling and generation](sampling-and-generation.md) for filtering and diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index dc4e1541..6242258f 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -117,8 +117,19 @@ _Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) coordinates at a location. It expresses the local reduction in source detail without treating a pure rotation as a change in resolution. +**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 e6a3e3b3..873eb077 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -14,3 +14,7 @@ store keeps a separate hash table beside it. Header reads remain available for verifying or rebuilding an index. Decide with the TB builder design that defines the dependency records. +- 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 From 3ff910cd30d55203049537cd2c3335538d7d9e65 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:59:02 +0200 Subject: [PATCH 36/83] move non-template srs functions into srs.cpp --- src/terrainlib/CMakeLists.txt | 2 +- src/terrainlib/srs.cpp | 269 ++++++++++++++++++++++++++++++++ src/terrainlib/srs.h | 280 ++++++---------------------------- 3 files changed, 318 insertions(+), 233 deletions(-) create mode 100644 src/terrainlib/srs.cpp diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 01db50eb..ad10866d 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -42,7 +42,7 @@ add_library(terrainlib optional_utils.h pch.h range_utils.h - srs.h + srs.h srs.cpp string_utils.h tile_path.h type_utils.h diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp new file mode 100644 index 00000000..05549d6d --- /dev/null +++ b/src/terrainlib/srs.cpp @@ -0,0 +1,269 @@ +/***************************************************************************** + * 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 "srs.h" + +#include +#include +#include + +namespace 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 + +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; +} + +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; +} + +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]) } }; +} + +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); +} + +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] } }; +} + +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); +} + +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; +} + +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); +} + +radix::geometry::Aabb3d encompassing_bounds_transfer(OGRCoordinateTransformation* transform, + const radix::geometry::Aabb3d& source_bounds, + const uint32_t intermediate_points_edges, + const uint32_t intermediate_points_faces) +{ + 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; +} + +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, + const uint32_t intermediate_points_faces) +{ + 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); +} + +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 srs diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index c0f9699f..cbf10c8c 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -19,133 +19,66 @@ #pragma once +#include #include #include +#include +#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); +std::optional epsg_code(const OGRSpatialReference& srs); - if (authName == nullptr || authCode == nullptr) { - return std::nullopt; - } +std::string friendly_name(const OGRSpatialReference& srs); - if (std::string(authName) != "EPSG") { - return std::nullopt; - } +std::unique_ptr clone(const OGRSpatialReference& srs); - 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); - } +std::unique_ptr uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); - 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; -} +std::shared_ptr transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); template -inline glm::tvec2 transform_point(OGRCoordinateTransformation *transform, glm::tvec2 p) { +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) { +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) { +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){ +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) { +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); @@ -158,7 +91,7 @@ inline void transform_points_inplace(OGRCoordinateTransformation *transform, Con OutputContainer xs, ys; std::conditional_t zs; - + if constexpr (!is_array) { xs.resize(size); ys.resize(size); @@ -196,16 +129,17 @@ inline void transform_points_inplace(OGRCoordinateTransformation *transform, Con } template -inline Container transform_points(const OGRSpatialReference &source_srs, const OGRSpatialReference &target_srs, Container points) { +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) { +inline std::vector> transform_points_to_2d(OGRCoordinateTransformation* transform, const std::vector>& points) +{ std::vector xs; std::vector ys; std::vector zs; @@ -227,161 +161,43 @@ inline std::vector> transform_points_to_2d(OGRCoordinateTransforma std::vector> transformed; transformed.resize(points.size()); for (size_t i = 0; i < points.size(); ++i) { - transformed[i] = {xs[i], ys[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); -} +radix::tile::SrsBounds non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds); +radix::tile::SrsBounds non_exact_bounds_transform( + const radix::tile::SrsBounds& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); +radix::geometry::Aabb3d non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& bounds); +radix::geometry::Aabb3d non_exact_bounds_transform( + const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); /// 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); -} +radix::tile::SrsBounds encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& source_bounds); +radix::tile::SrsBounds encompassing_bounds_transfer( + const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, const radix::tile::SrsBounds& source_bounds); -inline radix::geometry::Aabb3d encompassing_bounds_transfer( - OGRCoordinateTransformation *transform, - const radix::geometry::Aabb3d &source_bounds, +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_faces = 5); +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 uint32_t intermediate_points_faces = 5); - const auto transform = srs::transformation(source_srs, target_srs); - return encompassing_bounds_transfer(transform.get(), source_bounds, intermediate_points_edges, intermediate_points_faces); -} +std::expected from_epsg(const uint32_t epsg); -inline std::expected from_epsg(const uint32_t epsg) { - OGRSpatialReference srs; - if (srs.importFromEPSG(epsg) != OGRERR_NONE) { - return 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); -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(); -} +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(); } -} +} // namespace srs From df181dce291e08f1fa828112ba0bda623bb84ac5 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:28:51 +0200 Subject: [PATCH 37/83] srs: return Expected from transformations instead of throwing --- src/sf_builder/main.cpp | 8 ++- src/sf_builder/mesh_builder.cpp | 77 ++++++++++++++++++----- src/sf_builder/mesh_builder.h | 3 +- src/sf_builder/terrainbuilder.cpp | 19 ++++-- src/sf_builder/texture_assembler.h | 7 ++- src/sf_merger/mask.h | 8 +-- src/terrainlib/Dataset.cpp | 6 +- src/terrainlib/srs.cpp | 72 +++++++++++++-------- src/terrainlib/srs.h | 64 +++++++++++-------- unittests/sf_builder/mesh.cpp | 12 ++-- unittests/terrainlib/dataset.cpp | 6 +- unittests/tilebuilder/dataset_reading.cpp | 10 +-- 12 files changed, 191 insertions(+), 101 deletions(-) diff --git a/src/sf_builder/main.cpp b/src/sf_builder/main.cpp index 4ef6bf2e..948470c1 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -163,8 +163,12 @@ void log_dataset_overview(const Dataset &dataset) { const auto datasetSrs = dataset.srs(); const auto bounds = dataset.bounds3d(true); - const auto boundsEcef = - srs::encompassing_bounds_transfer(datasetSrs, srs::ecef(), bounds); + const auto boundsEcefResult = srs::encompassing_bounds_transfer(datasetSrs, srs::ecef(), 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 = diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 13b8d280..42e33c48 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -53,6 +53,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; @@ -132,10 +135,16 @@ 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 = srs::transformation(source_srs, target_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + if (auto result = 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) { @@ -148,8 +157,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 = srs::transformation(mesh_srs, texture_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + auto transformed = 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) { @@ -165,6 +186,12 @@ radix::geometry::Aabb3d extend_bounds_to_3d(radix::geometry::Aabb2d bounds2d) { 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; +} } std::expected build_reference_mesh_tile( @@ -183,13 +210,17 @@ std::expected build_reference_mesh_patch( const OGRSpatialReference &source_srs = dataset.srs(); // 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; + Expected target_bounds_result; 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)); + target_bounds_result = 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); + target_bounds_result = srs::encompassing_bounds_transfer(clip_srs, source_srs, clip_bounds); } + if (!target_bounds_result) { + return std::unexpected(log_transformation_error(target_bounds_result.error())); + } + const radix::tile::SrsBounds target_bounds_in_source_srs = *target_bounds_result; // Read height data according to bounds directly from dataset (no interpolation). RawDatasetReader reader(dataset); @@ -226,14 +257,20 @@ std::expected build_reference_mesh_patch( // 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)) { + const auto approx_clip_bounds = srs::encompassing_bounds_transfer(source_srs, clip_srs, actual_source_bounds); + if (!approx_clip_bounds) { + return std::unexpected(log_transformation_error(approx_clip_bounds.error())); + } + if (!radix::geometry::intersect(*approx_clip_bounds, 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); @@ -241,11 +278,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 826b0a98..b7191931 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.h @@ -31,7 +31,8 @@ namespace terrainbuilder { enum class BuildMeshError { OutOfBounds, - EmptyRegion + EmptyRegion, + TransformationFailed }; std::ostream &operator<<(std::ostream &os, BuildMeshError error); diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 420846fd..c4b0cbe5 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -112,6 +112,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)); @@ -226,8 +228,11 @@ Expected build_all_patches( const auto dataset_bounds = dataset.bounds3d(true); const auto ecef_srs = srs::ecef(); - const auto ecef_bounds = srs::encompassing_bounds_transfer( - dataset_srs, ecef_srs, dataset_bounds); + auto ecef_bounds_result = srs::encompassing_bounds_transfer(dataset_srs, ecef_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), @@ -238,11 +243,14 @@ Expected build_all_patches( tbb::task_group tg; std::function process_node; + // Validate once, so that the per-thread transformations below cannot fail. + if (auto transform = srs::transformation(ecef_srs, dataset_srs); !transform) { + return Error::propagate(std::move(transform), "create ECEF to dataset transformation"); + } 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; + return Error::asserting_unwrap(srs::transformation(local_ecef_srs, local_dataset_srs)); }); process_node = [&](octree::Id node) { @@ -253,8 +261,7 @@ Expected build_all_patches( } // 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); + const auto node_bounds_dataset_srs = Error::asserting_unwrap(srs::encompassing_bounds_transfer(&*transform_ecef_dataset.local(), node_bounds, 7, 3)); if (!radix::geometry::intersect(node_bounds_dataset_srs, dataset_bounds)) { return; } diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 3396559d..e71d3079 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -485,7 +485,12 @@ inline std::optional assemble_texture_from_tiles( } // 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); + const auto encompassing_bounds_result = srs::encompassing_bounds_transfer(target_srs, grid.getSRS(), target_bounds); + if (!encompassing_bounds_result) { + LOG_ERROR("Failed to transform texture target bounds: {}", encompassing_bounds_result.error().to_string()); + return std::nullopt; + } + const radix::tile::SrsBounds encompassing_bounds = *encompassing_bounds_result; // Then we find the smallest tile (id) that encompasses these bounds. radix::tile::Id smallest_encompassing_tile = grid.findSmallestEncompassingTile(encompassing_bounds).value(); LOG_TRACE("Smallest encompassing tile for texture bounds is {}", radix::tile::to_string(smallest_encompassing_tile)); diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index edda05f7..3140988c 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -118,18 +118,18 @@ using vector_mask::load_referenced_from_dataset; namespace { 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()); + const auto srs_transform = Error::asserting_unwrap(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::dvec3 ecef_point = Error::asserting_unwrap(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::dvec3 ecef_point = Error::asserting_unwrap(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); } @@ -183,7 +183,7 @@ inline SpherePolygonMask project_onto_sphere(ReferencedPolygonMask ref_mask, con 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 radix::geometry::Aabb3d ecef_bounds = Error::asserting_unwrap(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); const SphereProjector projector(tangent_point); diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 36d77da5..02974065 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -199,7 +199,7 @@ radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) con // don't wanna miss out the max/max edge vertex addCoordinate(east, north); - const auto transformer = srs::transformation(srs(), targetSrs); + const auto transformer = Error::throwing_unwrap(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"); } @@ -263,11 +263,11 @@ double Dataset::gridResolution(const OGRSpatialReference &target_srs) const { double Dataset::pixelWidthIn(const OGRSpatialReference &target_srs) const { const auto b0 = bounds(); - const auto b1 = srs::non_exact_bounds_transform(b0, srs(), target_srs); + const auto b1 = Error::throwing_unwrap(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); + const auto b = Error::throwing_unwrap(srs::non_exact_bounds_transform(bounds(), srs(), target_srs)); return b.height() / heightInPixels(); } diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 05549d6d..4398d040 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -73,16 +73,16 @@ namespace { }; } // namespace -std::unique_ptr uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) +Expected> 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 Error::fail(Error::Code::InvalidInput, "create SRS transformation from " + friendly_name(source_srs) + " to " + friendly_name(target_srs)); } return transform; } -std::shared_ptr transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) +Expected> transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) { thread_local std::vector cache; @@ -98,7 +98,8 @@ std::shared_ptr transformation(const OGRSpatialRefe 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"); + 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; @@ -107,47 +108,56 @@ std::shared_ptr transformation(const OGRSpatialRefe // Not found auto transform = uncached_transformation(source_srs, target_srs); - std::shared_ptr shared_transform = std::move(transform); + 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; } -radix::tile::SrsBounds non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds) +Expected 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 Error::fail(Error::Code::InvalidInput, "transform bounds corners"); } - 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]) } }; + return radix::tile::SrsBounds { { std::min(xs[0], xs[1]), std::min(ys[0], ys[1]) }, { std::max(xs[0], xs[1]), std::max(ys[0], ys[1]) } }; } -radix::tile::SrsBounds non_exact_bounds_transform( +Expected 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); + auto transform = transformation(sourceSrs, targetSrs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + return non_exact_bounds_transform(transform->get(), bounds); } -radix::geometry::Aabb3d non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& bounds) +Expected 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 Error::fail(Error::Code::InvalidInput, "transform bounds corners"); } - return { { xs[0], ys[0], zs[0] }, { xs[1], ys[1], zs[1] } }; + return radix::geometry::Aabb3d { { xs[0], ys[0], zs[0] }, { xs[1], ys[1], zs[1] } }; } -radix::geometry::Aabb3d non_exact_bounds_transform( +Expected 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); + auto transform = transformation(sourceSrs, targetSrs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + return non_exact_bounds_transform(transform->get(), bounds); } -radix::tile::SrsBounds encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& source_bounds) +Expected 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, @@ -160,23 +170,26 @@ radix::tile::SrsBounds encompassing_bounds_transfer(OGRCoordinateTransformation* &target_bounds.max.y, 21); if (result != TRUE) { - throw std::runtime_error("srs::encompassing_bounding_box_transfer failed"); + return Error::fail(Error::Code::InvalidInput, "transform bounds"); } return target_bounds; } -radix::tile::SrsBounds encompassing_bounds_transfer( +Expected 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); + auto transformation = srs::transformation(source_srs, target_srs); + if (!transformation) { + return Error::propagate(std::move(transformation)); + } + return encompassing_bounds_transfer(transformation->get(), source_bounds); } -radix::geometry::Aabb3d encompassing_bounds_transfer(OGRCoordinateTransformation* transform, +Expected encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& source_bounds, const uint32_t intermediate_points_edges, const uint32_t intermediate_points_faces) @@ -219,7 +232,9 @@ radix::geometry::Aabb3d encompassing_bounds_transfer(OGRCoordinateTransformation } // Transform all collected points - transform_points_inplace(transform, points); + if (auto result = transform_points_inplace(transform, points); !result) { + return Error::propagate(std::move(result)); + } // Compute bounds from transformed points radix::geometry::Aabb3d target_bounds; @@ -232,7 +247,7 @@ radix::geometry::Aabb3d encompassing_bounds_transfer(OGRCoordinateTransformation return target_bounds; } -radix::geometry::Aabb3d encompassing_bounds_transfer(const OGRSpatialReference& source_srs, +Expected encompassing_bounds_transfer(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, const radix::geometry::Aabb3d& source_bounds, const uint32_t intermediate_points_edges, @@ -242,8 +257,11 @@ radix::geometry::Aabb3d encompassing_bounds_transfer(const OGRSpatialReference& 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); + auto transform = srs::transformation(source_srs, target_srs); + if (!transform) { + return Error::propagate(std::move(transform)); + } + return encompassing_bounds_transfer(transform->get(), source_bounds, intermediate_points_edges, intermediate_points_faces); } std::expected from_epsg(const uint32_t epsg) diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index cbf10c8c..90104068 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -36,6 +36,8 @@ #include #include +#include "Error.h" + namespace srs { std::optional epsg_code(const OGRSpatialReference& srs); @@ -44,40 +46,44 @@ std::string friendly_name(const OGRSpatialReference& srs); std::unique_ptr clone(const OGRSpatialReference& srs); -std::unique_ptr uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); +Expected> uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); -std::shared_ptr transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); +Expected> transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs); template -inline glm::tvec2 transform_point(OGRCoordinateTransformation* transform, glm::tvec2 p) +inline Expected> 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 Error::fail(Error::Code::InvalidInput, "transform 2d point"); return p; } template -inline glm::tvec3 transform_point(OGRCoordinateTransformation* transform, glm::tvec3 p) +inline Expected> 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 Error::fail(Error::Code::InvalidInput, "transform 3d point"); return p; } template -inline glm::tvec2 transform_point(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, glm::tvec2 p) +inline Expected> 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); + auto transform = transformation(source_srs, target_srs); + if (!transform) + return Error::propagate(std::move(transform)); + return transform_point(transform->get(), p); } template -inline glm::tvec3 transform_point(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, glm::tvec3 p) +inline Expected> 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); + auto transform = transformation(source_srs, target_srs); + if (!transform) + return Error::propagate(std::move(transform)); + return transform_point(transform->get(), p); } template -inline void transform_points_inplace(OGRCoordinateTransformation* transform, Container& points) +inline Expected transform_points_inplace(OGRCoordinateTransformation* transform, Container& points) { using PointType = typename Container::value_type; using T = typename PointType::value_type; @@ -116,7 +122,7 @@ inline void transform_points_inplace(OGRCoordinateTransformation* transform, Con } if (!success) { - throw std::runtime_error("srs::transform_points_inplace failed"); + return Error::fail(Error::Code::InvalidInput, "transform points"); } for (size_t i = 0; i < size; i++) { @@ -126,19 +132,23 @@ inline void transform_points_inplace(OGRCoordinateTransformation* transform, Con points[i] = PointType(xs[i], ys[i]); } } + return {}; } template -inline Container transform_points(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, Container points) +inline Expected 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); + 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 std::vector> transform_points_to_2d(OGRCoordinateTransformation* transform, const std::vector>& points) +inline Expected>> transform_points_to_2d(OGRCoordinateTransformation* transform, const std::vector>& points) { std::vector xs; std::vector ys; @@ -155,7 +165,7 @@ inline std::vector> transform_points_to_2d(OGRCoordinateTransforma } if (!transform->Transform(points.size(), xs.data(), ys.data(), zs.data())) { - throw std::runtime_error("srs::transform_points_to_2d(OGRCoordinateTransformation *, std::vector>) failed"); + return Error::fail(Error::Code::InvalidInput, "transform points to 2d"); } std::vector> transformed; @@ -167,25 +177,25 @@ inline std::vector> transform_points_to_2d(OGRCoordinateTransforma return transformed; } -radix::tile::SrsBounds non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds); -radix::tile::SrsBounds non_exact_bounds_transform( +Expected non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds); +Expected non_exact_bounds_transform( const radix::tile::SrsBounds& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); -radix::geometry::Aabb3d non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& bounds); -radix::geometry::Aabb3d non_exact_bounds_transform( +Expected non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& bounds); +Expected non_exact_bounds_transform( const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); /// 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. -radix::tile::SrsBounds encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& source_bounds); -radix::tile::SrsBounds encompassing_bounds_transfer( +Expected encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& source_bounds); +Expected encompassing_bounds_transfer( const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, const radix::tile::SrsBounds& source_bounds); -radix::geometry::Aabb3d encompassing_bounds_transfer(OGRCoordinateTransformation* transform, +Expected 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); -radix::geometry::Aabb3d encompassing_bounds_transfer(const OGRSpatialReference& source_srs, +Expected encompassing_bounds_transfer(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, const radix::geometry::Aabb3d& source_bounds, const uint32_t intermediate_points_edges = 21, diff --git a/unittests/sf_builder/mesh.cpp b/unittests/sf_builder/mesh.cpp index dda49d45..3b80a1a4 100644 --- a/unittests/sf_builder/mesh.cpp +++ b/unittests/sf_builder/mesh.cpp @@ -129,9 +129,9 @@ TEST_CASE("can build reference mesh patches for various datasets", "[terrainbuil const auto webmercator_srs = 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 = 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 = srs::transform_point(wgs84_srs, ecef_srs, steffl_wgs84).value(); const std::vector test_data{ {"/austria/pizbuin_1m_epsg4326.tif", @@ -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 = 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 = 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 = 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; @@ -246,7 +246,7 @@ TEST_CASE("neighbouring patches fit together", "[terrainbuilder]") { const auto webmercator_srs = 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 = 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(); diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index 17084efb..a0314699 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -136,8 +136,8 @@ TEST_CASE("bbox width pixels") { 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)); + CHECK(d_mgi.widthInPixels(srs::non_exact_bounds_transform(d_mgi.bounds(), d_mgi.srs(), webmercator).value(), webmercator) == Approx(620.0)); + CHECK(d_mgi.heightInPixels(srs::non_exact_bounds_transform(d_mgi.bounds(), d_mgi.srs(), webmercator).value(), webmercator) == Approx(350.0)); auto adjust_bounds = [](auto bounds) { const auto unadjusted_width = bounds.width(); @@ -150,7 +150,7 @@ TEST_CASE("bbox width pixels") { 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); + const auto webmercator_bounds = srs::non_exact_bounds_transform(d_wgs84.bounds(), d_wgs84.srs(), webmercator).value(); CHECK(d_wgs84.widthInPixels(webmercator_bounds, webmercator) == Approx(620.0)); CHECK(d_wgs84.heightInPixels(webmercator_bounds, webmercator) == Approx(350.0)); diff --git a/unittests/tilebuilder/dataset_reading.cpp b/unittests/tilebuilder/dataset_reading.cpp index e0d4d2ff..b420576c 100644 --- a/unittests/tilebuilder/dataset_reading.cpp +++ b/unittests/tilebuilder/dataset_reading.cpp @@ -39,7 +39,7 @@ void require_projection_available(const Dataset& dataset, const OGRSpatialRefere const auto source_bounds = dataset.bounds(); std::shared_ptr transform; - REQUIRE_NOTHROW(transform = srs::transformation(dataset_srs, target_srs)); + REQUIRE_NOTHROW(transform = srs::transformation(dataset_srs, target_srs).value()); REQUIRE(transform != nullptr); std::array xs = { (source_bounds.min.x + source_bounds.max.x) / 2.0 }; @@ -113,7 +113,7 @@ TEST_CASE("reading") srs.importFromEPSG(test_srs); srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - const auto srs_bounds = srs::non_exact_bounds_transform(geodetic_bounds, geodetic_srs, srs); + const auto srs_bounds = srs::non_exact_bounds_transform(geodetic_bounds, geodetic_srs, srs).value(); require_projection_available(*dataset, srs); const DatasetReader reader(dataset, srs, 1); @@ -227,7 +227,7 @@ TEST_CASE("reading") 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); + auto srs_bounds = srs::non_exact_bounds_transform(low_res_ds->bounds(), low_res_ds->srs(), srs).value(); 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 }; @@ -273,7 +273,7 @@ TEST_CASE("reading") 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 srs_bounds = srs::non_exact_bounds_transform(radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, geodetic_srs, srs).value(); const auto render_width = unsigned(low_res_ds->widthInPixels(srs_bounds, srs)); const auto render_height = unsigned(low_res_ds->heightInPixels(srs_bounds, srs)); @@ -315,7 +315,7 @@ TEST_CASE("reading") 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 srs_bounds = srs::non_exact_bounds_transform(radix::tile::SrsBounds{{9.5, 46.4}, {17.1, 49.0}}, geodetic_srs, srs).value(); 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; From 6a414ef85d67e8e1b26479906401a49401664229 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:46:11 +0200 Subject: [PATCH 38/83] Dataset: return Expected, add geodetic and mercator coverage - Dataset::bounds() and srs() return Expected; bounds() rejects missing or nonfinite geotransforms, rotation/shear, mirrored and south-up rasters. Dependent Dataset methods return Expected as well. - srs::geodetic_coverage and srs::mercator_coverage (with Dataset wrappers) return 1-2 geodetic or 0-2 Web Mercator bounds, split at the antimeridian and clamped at the polar limit. Not used yet. - Move the Web Mercator constants to srs; RasterTransform aliases them. - Remove tile_builder and the GDAL overview reading in DatasetReader. --- .github/workflows/ci.yml | 1 - CMakeLists.txt | 3 - src/CMakeLists.txt | 3 - src/sf_builder/main.cpp | 14 +- src/sf_builder/mesh_builder.cpp | 6 +- src/sf_builder/terrainbuilder.cpp | 19 +- src/terrainlib/CMakeLists.txt | 4 - src/terrainlib/Dataset.cpp | 142 +++++-- src/terrainlib/Dataset.h | 30 +- src/terrainlib/DatasetReader.cpp | 76 +--- src/terrainlib/DatasetReader.h | 4 +- src/terrainlib/RasterTransform.h | 5 +- src/terrainlib/srs.cpp | 94 +++++ src/terrainlib/srs.h | 13 + src/terrainlib/vector_mask.h | 7 +- src/tile_builder/CMakeLists.txt | 35 -- src/tile_builder/Exception.h | 35 -- src/tile_builder/ParallelTileGenerator.cpp | 113 ------ src/tile_builder/ParallelTileGenerator.h | 77 ---- src/tile_builder/ParallelTiler.cpp | 73 ---- src/tile_builder/ParallelTiler.h | 33 -- src/tile_builder/TileHeightsGenerator.cpp | 84 ----- src/tile_builder/TileHeightsGenerator.h | 38 -- src/tile_builder/Tiler.cpp | 66 ---- src/tile_builder/Tiler.h | 46 --- src/tile_builder/TopDownTiler.cpp | 38 -- src/tile_builder/TopDownTiler.h | 31 -- src/tile_builder/alpine_raster.cpp | 47 --- src/tile_builder/alpine_raster.h | 51 --- src/tile_builder/depth_first_tile_traverser.h | 42 --- src/tile_builder/image_writer.cpp | 41 -- src/tile_builder/image_writer.h | 53 --- src/tile_builder/main.cpp | 57 --- unittests/CMakeLists.txt | 18 +- unittests/data/capehorn/data_attribution | 2 - unittests/data/capehorn/small.tif | Bin 514877 -> 0 bytes unittests/sf_builder/mesh.cpp | 2 +- unittests/terrainlib/dataset.cpp | 138 +++++-- unittests/terrainlib/dataset_reading.cpp | 225 +++++++++++ .../tilebuilder/alpine_raster_format.cpp | 85 ----- unittests/tilebuilder/dataset_reading.cpp | 352 ------------------ .../depth_first_tile_traverser.cpp | 203 ---------- unittests/tilebuilder/image_writer.cpp | 88 ----- .../tilebuilder/parallel_tile_generator.cpp | 103 ----- unittests/tilebuilder/parallel_tiler.cpp | 324 ---------------- .../tilebuilder/tile_heights_generator.cpp | 72 ---- unittests/tilebuilder/top_down_tiler.cpp | 102 ----- 47 files changed, 619 insertions(+), 2476 deletions(-) delete mode 100644 src/tile_builder/CMakeLists.txt delete mode 100644 src/tile_builder/Exception.h delete mode 100644 src/tile_builder/ParallelTileGenerator.cpp delete mode 100644 src/tile_builder/ParallelTileGenerator.h delete mode 100644 src/tile_builder/ParallelTiler.cpp delete mode 100644 src/tile_builder/ParallelTiler.h delete mode 100644 src/tile_builder/TileHeightsGenerator.cpp delete mode 100644 src/tile_builder/TileHeightsGenerator.h delete mode 100644 src/tile_builder/Tiler.cpp delete mode 100644 src/tile_builder/Tiler.h delete mode 100644 src/tile_builder/TopDownTiler.cpp delete mode 100644 src/tile_builder/TopDownTiler.h delete mode 100644 src/tile_builder/alpine_raster.cpp delete mode 100644 src/tile_builder/alpine_raster.h delete mode 100644 src/tile_builder/depth_first_tile_traverser.h delete mode 100644 src/tile_builder/image_writer.cpp delete mode 100644 src/tile_builder/image_writer.h delete mode 100644 src/tile_builder/main.cpp delete mode 100644 unittests/data/capehorn/data_attribution delete mode 100644 unittests/data/capehorn/small.tif create mode 100644 unittests/terrainlib/dataset_reading.cpp delete mode 100644 unittests/tilebuilder/alpine_raster_format.cpp delete mode 100644 unittests/tilebuilder/dataset_reading.cpp delete mode 100644 unittests/tilebuilder/depth_first_tile_traverser.cpp delete mode 100644 unittests/tilebuilder/image_writer.cpp delete mode 100644 unittests/tilebuilder/parallel_tile_generator.cpp delete mode 100644 unittests/tilebuilder/parallel_tiler.cpp delete mode 100644 unittests/tilebuilder/tile_heights_generator.cpp delete mode 100644 unittests/tilebuilder/top_down_tiler.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b03c228..396ab23e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,6 @@ jobs: 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..128ee387 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,6 @@ cmake_minimum_required(VERSION 3.21) 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) @@ -17,7 +16,6 @@ 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) @@ -35,7 +33,6 @@ set(ALP_DEPENDENCIES_N_BUILD_JOBS "" CACHE STRING # 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") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d626a004..d24f1174 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -180,9 +180,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/sf_builder/main.cpp b/src/sf_builder/main.cpp index 948470c1..3d39d156 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -160,8 +160,18 @@ void log_dataset_overview(const Dataset &dataset) { 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 boundsEcefResult = srs::encompassing_bounds_transfer(datasetSrs, srs::ecef(), bounds); if (!boundsEcefResult) { diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 42e33c48..1c4fed9a 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -207,7 +207,11 @@ std::expected build_reference_mesh_patch( 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. Expected target_bounds_result; diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index c4b0cbe5..35523be2 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -97,7 +97,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" @@ -224,8 +229,16 @@ Expected build_all_patches( raw_storage.settings().allow_overwrite = overwrite_existing; store::ThreadSafeStorage storage(std::move(raw_storage)); - 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(true); + if (!dataset_bounds_result) { + return Error::propagate(std::move(dataset_bounds_result), "read dataset bounds"); + } + const auto dataset_bounds = *dataset_bounds_result; const auto ecef_srs = srs::ecef(); auto ecef_bounds_result = srs::encompassing_bounds_transfer(dataset_srs, ecef_srs, dataset_bounds); diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index ad10866d..efd53d69 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -287,7 +287,3 @@ target_link_libraries(terrainlib PUBLIC target_link_libraries(terrainlib PRIVATE cpptrace::cpptrace) # Only io/hash.cpp includes xxHash. target_link_libraries(terrainlib PRIVATE xxhash) - -if(ALP_ENABLE_OVERVIEW_READING) - target_compile_definitions(terrainlib PUBLIC ALP_ENABLE_OVERVIEW_READING) -endif() diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 02974065..0d4f5756 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -118,10 +119,14 @@ std::string Dataset::name() const { Dataset::~Dataset() = default; -radix::tile::SrsBounds Dataset::bounds() const { +Expected Dataset::bounds() const +{ 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 +136,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 +148,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,22 +164,33 @@ 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(); +Expected Dataset::bounds(const OGRSpatialReference& targetSrs) const +{ + const auto bounds_result = bounds(); + if (!bounds_result) { + return bounds_result; + } + const auto l_bounds = *bounds_result; 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)) + auto data_srs = srs(); + if (!data_srs) { + return Error::propagate(std::move(data_srs)); + } + if (targetSrs.IsSame(&*data_srs)) return l_bounds; // We need to transform the bounds to the target SRS @@ -184,14 +205,14 @@ radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) con const auto deltaX = l_bounds.width() / 2000.0; if (deltaX <= 0.0) - throw std::runtime_error("west coordinate > east coordinate. This is not supported."); + return Error::fail(Error::Code::Unsupported, "west coordinate > east coordinate"); for (double s = west; s < east; s += deltaX) { addCoordinate(s, south); addCoordinate(s, north); } const auto deltaY = (north - south) / 2000.0; if (deltaY <= 0.0) - throw std::runtime_error("south coordinate > north coordinate. This is not supported."); + return Error::fail(Error::Code::Unsupported, "south coordinate > north coordinate"); for (double s = south; s < north; s += deltaY) { addCoordinate(west, s); addCoordinate(east, s); @@ -199,9 +220,12 @@ radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) con // don't wanna miss out the max/max edge vertex addCoordinate(east, north); - const auto transformer = Error::throwing_unwrap(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"); + auto transformer = srs::transformation(*data_srs, targetSrs); + if (!transformer) { + return Error::propagate(std::move(transformer)); + } + if (!(*transformer)->Transform(int(x.size()), x.data(), y.data())) { + return Error::fail(Error::Code::InvalidInput, "transform dataset bounds to target SRS"); } DEBUG_ASSERT(!x.empty()); @@ -210,10 +234,37 @@ radix::tile::SrsBounds Dataset::bounds(const OGRSpatialReference &targetSrs) con 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}}; + return radix::tile::SrsBounds { { target_minX, target_minY }, { target_maxX, target_maxY } }; +} + +Expected> Dataset::geodetic_coverage() const +{ + auto reference = srs(); + if (!reference) { + return Error::propagate(std::move(reference)); + } + auto l_bounds = bounds(); + if (!l_bounds) { + return Error::propagate(std::move(l_bounds)); + } + return srs::geodetic_coverage(*reference, *l_bounds); } -OGRSpatialReference Dataset::srs() const { +Expected> Dataset::mercator_coverage() const +{ + auto reference = srs(); + if (!reference) { + return Error::propagate(std::move(reference)); + } + auto l_bounds = bounds(); + if (!l_bounds) { + return Error::propagate(std::move(l_bounds)); + } + return srs::mercator_coverage(*reference, *l_bounds); +} + +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 +272,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); @@ -236,12 +287,14 @@ 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); +Expected Dataset::widthInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const +{ + return pixelWidthIn(bounds_srs).transform([&](double pixel_width) { return bounds.width() / pixel_width; }); } -double Dataset::heightInPixels(const radix::tile::SrsBounds &bounds, const OGRSpatialReference &bounds_srs) const { - return bounds.height() / pixelHeightIn(bounds_srs); +Expected Dataset::heightInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const +{ + return pixelHeightIn(bounds_srs).transform([&](double pixel_height) { return bounds.height() / pixel_height; }); } unsigned Dataset::n_bands() const { @@ -257,17 +310,40 @@ 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)); +Expected Dataset::gridResolution(const OGRSpatialReference& target_srs) const +{ + auto width = pixelWidthIn(target_srs); + if (!width) { + return width; + } + auto height = pixelHeightIn(target_srs); + if (!height) { + return height; + } + return std::min(*width, *height); +} + +namespace { +Expected bounds_in(const Dataset& dataset, const OGRSpatialReference& target_srs) +{ + auto bounds = dataset.bounds(); + if (!bounds) { + return bounds; + } + auto reference = dataset.srs(); + if (!reference) { + return Error::propagate(std::move(reference)); + } + return srs::non_exact_bounds_transform(*bounds, *reference, target_srs); } +} // namespace -double Dataset::pixelWidthIn(const OGRSpatialReference &target_srs) const { - const auto b0 = bounds(); - const auto b1 = Error::throwing_unwrap(srs::non_exact_bounds_transform(b0, srs(), target_srs)); - return b1.width() / widthInPixels(); +Expected Dataset::pixelWidthIn(const OGRSpatialReference& target_srs) const +{ + return bounds_in(*this, target_srs).transform([&](const radix::tile::SrsBounds& bounds) { return bounds.width() / widthInPixels(); }); } -double Dataset::pixelHeightIn(const OGRSpatialReference &target_srs) const { - const auto b = Error::throwing_unwrap(srs::non_exact_bounds_transform(bounds(), srs(), target_srs)); - return b.height() / heightInPixels(); +Expected Dataset::pixelHeightIn(const OGRSpatialReference& target_srs) const +{ + return bounds_in(*this, target_srs).transform([&](const radix::tile::SrsBounds& bounds) { return bounds.height() / heightInPixels(); }); } diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index 391b799b..2b85502f 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/Dataset.h @@ -19,13 +19,15 @@ #pragma once +#include #include -#include #include -#include +#include +#include -#include +#include "Error.h" #include "log.h" +#include class GDALDataset; class OGRSpatialReference; @@ -52,21 +54,25 @@ 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; + [[nodiscard]] Expected bounds(const OGRSpatialReference& targetSrs) const; + // See srs::geodetic_coverage and srs::mercator_coverage. + [[nodiscard]] Expected> geodetic_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]] Expected widthInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const; + [[nodiscard]] Expected 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; + [[nodiscard]] Expected gridResolution(const OGRSpatialReference& target_srs) const; + [[nodiscard]] Expected pixelWidthIn(const OGRSpatialReference& target_srs) const; + [[nodiscard]] Expected pixelHeightIn(const OGRSpatialReference& target_srs) const; private: Dataset(const std::filesystem::path path, GDALDataset *dataset); diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 4be75888..fe41ab29 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -119,71 +119,13 @@ WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, co 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) +DatasetReader::DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band) : m_dataset(dataset) - , m_dataset_srs_wkt(toWkt(dataset->srs())) + , m_dataset_srs_wkt(toWkt(Error::throwing_unwrap(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_requires_reprojection(!Error::throwing_unwrap(dataset->srs()).IsSame(&targetSRS)) , m_band(band) { if (band > dataset->n_bands()) @@ -195,18 +137,6 @@ radix::Raster DatasetReader::read(const radix::tile::SrsBounds& bounds, u 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). diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index 53181fe8..e235028f 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -57,10 +57,9 @@ class DatasetReader { static Expected> read_colour( GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); - DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band, bool warn_on_missing_overviews = true); + DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band); 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; } @@ -75,7 +74,6 @@ class DatasetReader { 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; }; diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h index 7c62045c..a5c65145 100644 --- a/src/terrainlib/RasterTransform.h +++ b/src/terrainlib/RasterTransform.h @@ -25,12 +25,13 @@ #include #include #include "Error.h" +#include "srs.h" // Exact affine raster coordinates, independent of delivery-tile dimensions. class RasterTransform { public: - static constexpr double world_half_extent = 20037508.342789244; - static constexpr double latitude_limit = 85.0511287798066; + static constexpr double world_half_extent = srs::webmercator_half_extent; + static constexpr double latitude_limit = srs::webmercator_latitude_limit; using Bounds = radix::tile::SrsBounds; static Expected create(GDALDataset& dataset); diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 4398d040..4d19a6f7 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -19,6 +19,8 @@ #include "srs.h" +#include +#include #include #include #include @@ -284,4 +286,96 @@ std::expected from_user_input(const std::strin return srs; } +Expected> geodetic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) +{ + constexpr int boundary_samples = 257; + const auto geodetic = wgs84(); + radix::tile::SrsBounds longitude_latitude = bounds; + const bool reprojected = !reference.IsSame(&geodetic); + if (reprojected) { + auto transform = transformation(reference, geodetic); + if (!transform) { + return Error::propagate(std::move(transform)); + } + if (!(*transform) + ->TransformBounds(bounds.min.x, + bounds.min.y, + bounds.max.x, + bounds.max.y, + &longitude_latitude.min.x, + &longitude_latitude.min.y, + &longitude_latitude.max.x, + &longitude_latitude.max.y, + boundary_samples)) { + return Error::fail(Error::Code::InvalidInput, "transform bounds to geodetic coordinates"); + } + } + double west = longitude_latitude.min.x; + double east = longitude_latitude.max.x; + double south = longitude_latitude.min.y; + double north = longitude_latitude.max.y; + if (!std::isfinite(west) || !std::isfinite(east) || !std::isfinite(south) || !std::isfinite(north)) { + return Error::fail(Error::Code::InvalidInput, "nonfinite geodetic coverage bounds"); + } + // TransformBounds reports antimeridian-crossing bounds with east < west. + if (east < west) { + east += 360; + } + if (reprojected) { + // Densified transformation samples are not exact extrema of curved edges. + // Retain a full sampling interval as a guard band rather than pruning at + // the sampled extremum. + const double longitude_guard = std::max(1e-6, (east - west) / (boundary_samples + 1)); + const double latitude_guard = std::max(1e-6, (north - south) / (boundary_samples + 1)); + west -= longitude_guard; + east += longitude_guard; + south = std::max(-90.0, south - latitude_guard); + north = std::min(90.0, north + latitude_guard); + } + if (east - west >= 360) { + return std::vector { { { -180, south }, { 180, north } } }; + } + const double shift = 360 * std::floor((west + 180) / 360); + west -= shift; + east -= shift; + if (east > 180) { + return std::vector { { { west, south }, { 180, north } }, { { -180, south }, { east - 360, north } } }; + } + return std::vector { { { west, south }, { east, north } } }; +} + +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 geodetic 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 geodetic = geodetic_coverage(reference, bounds); + if (!geodetic) { + return geodetic; + } + std::vector result; + for (const auto& longitude_latitude : *geodetic) { + // 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; +} + } // namespace srs diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index 90104068..525a6a5e 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -210,4 +210,17 @@ inline OGRSpatialReference webmercator() { return from_epsg(3857).value(); } inline OGRSpatialReference wgs84() { return from_epsg(4326).value(); } inline OGRSpatialReference mgi() { return from_epsg(4312).value(); } +/// Half the side length of the square Web Mercator world in metres. +constexpr double webmercator_half_extent = 20037508.342789244; +/// Latitude in degrees at which Web Mercator y reaches webmercator_half_extent. +constexpr double webmercator_latitude_limit = 85.0511287798066; + +/// 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> geodetic_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); + } // namespace srs diff --git a/src/terrainlib/vector_mask.h b/src/terrainlib/vector_mask.h index 414fd295..f860555a 100644 --- a/src/terrainlib/vector_mask.h +++ b/src/terrainlib/vector_mask.h @@ -235,10 +235,9 @@ inline std::expected load_referenced_from_data GDALDataset *dataset = mask_dataset.gdalDataset(); OGRSpatialReference srs; - // TODO: remove this try catch - try { - srs = mask_dataset.srs(); - } catch (std::runtime_error &e) { + if (auto dataset_srs = mask_dataset.srs()) { + srs = std::move(*dataset_srs); + } else { LOG_WARN("Mask does not reference an srs, assuming WGS84"); srs = srs::wgs84(); // srs.SetAxisMappingStrategy(OAMS_AUTHORITY_COMPLIANT); diff --git a/src/tile_builder/CMakeLists.txt b/src/tile_builder/CMakeLists.txt deleted file mode 100644 index d7e76c5c..00000000 --- a/src/tile_builder/CMakeLists.txt +++ /dev/null @@ -1,35 +0,0 @@ -# ***************************************************************************** -# 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(tilebuilderlib - 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/Exception.h b/src/tile_builder/Exception.h deleted file mode 100644 index c1f18bfb..00000000 --- a/src/tile_builder/Exception.h +++ /dev/null @@ -1,35 +0,0 @@ -/***************************************************************************** - * 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 EXCEPTION_H -#define EXCEPTION_H - -#include - -/// This represents a CTB runtime error -class Exception : public std::runtime_error { -public: - Exception(const std::string& message) - : std::runtime_error(message) - { - while (false) { } - } -}; - -#endif // EXCEPTION_H diff --git a/src/tile_builder/ParallelTileGenerator.cpp b/src/tile_builder/ParallelTileGenerator.cpp deleted file mode 100644 index 774a4ea5..00000000 --- a/src/tile_builder/ParallelTileGenerator.cpp +++ /dev/null @@ -1,113 +0,0 @@ -/***************************************************************************** - * AlpineMaps.org - * Copyright (C) 2022 Martin Braunsperger - * 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 . - *****************************************************************************/ - -#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 74ea1586..00000000 --- a/src/tile_builder/ParallelTileGenerator.h +++ /dev/null @@ -1,77 +0,0 @@ -/***************************************************************************** - * AlpineMaps.org - * Copyright (C) 2022 Martin Braunsperger - * 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 . - *****************************************************************************/ - -#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 d89cf64d..00000000 --- a/src/tile_builder/ParallelTiler.cpp +++ /dev/null @@ -1,73 +0,0 @@ -/***************************************************************************** - * 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 "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/ParallelTiler.h b/src/tile_builder/ParallelTiler.h deleted file mode 100644 index 2c6f776c..00000000 --- a/src/tile_builder/ParallelTiler.h +++ /dev/null @@ -1,33 +0,0 @@ -/***************************************************************************** - * 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 "Tiler.h" - -class ParallelTiler : public Tiler { -public: - ParallelTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border); - - [[nodiscard]] std::vector generateTiles(unsigned zoom_level) const; - [[nodiscard]] std::vector generateTiles(const std::pair& zoom_range) const; - - [[nodiscard]] radix::tile::Id southWestTile(unsigned zoom_level) const; - [[nodiscard]] radix::tile::Id northEastTile(unsigned zoom_level) const; -}; diff --git a/src/tile_builder/TileHeightsGenerator.cpp b/src/tile_builder/TileHeightsGenerator.cpp deleted file mode 100644 index 6e8555e5..00000000 --- a/src/tile_builder/TileHeightsGenerator.cpp +++ /dev/null @@ -1,84 +0,0 @@ -/***************************************************************************** - * 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 "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/TileHeightsGenerator.h b/src/tile_builder/TileHeightsGenerator.h deleted file mode 100644 index cec68fd2..00000000 --- a/src/tile_builder/TileHeightsGenerator.h +++ /dev/null @@ -1,38 +0,0 @@ -/***************************************************************************** - * 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 "ctb/Grid.hpp" -#include - - -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; -}; diff --git a/src/tile_builder/Tiler.cpp b/src/tile_builder/Tiler.cpp deleted file mode 100644 index 56d07de1..00000000 --- a/src/tile_builder/Tiler.cpp +++ /dev/null @@ -1,66 +0,0 @@ -/***************************************************************************** - * 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 "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 ca32e5f6..00000000 --- a/src/tile_builder/Tiler.h +++ /dev/null @@ -1,46 +0,0 @@ -/***************************************************************************** - * 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 "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/TopDownTiler.cpp b/src/tile_builder/TopDownTiler.cpp deleted file mode 100644 index 68d34716..00000000 --- a/src/tile_builder/TopDownTiler.cpp +++ /dev/null @@ -1,38 +0,0 @@ -/***************************************************************************** - * 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 "TopDownTiler.h" - -TopDownTiler::TopDownTiler(const ctb::Grid& grid, const radix::tile::SrsBounds& bounds, radix::tile::Border border) - : Tiler(grid, bounds, border) -{ -} - -std::vector TopDownTiler::generateTiles(const radix::tile::Id& parent_id) const -{ - 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)); - } - - return tiles; -} diff --git a/src/tile_builder/TopDownTiler.h b/src/tile_builder/TopDownTiler.h deleted file mode 100644 index f20397b8..00000000 --- a/src/tile_builder/TopDownTiler.h +++ /dev/null @@ -1,31 +0,0 @@ -/***************************************************************************** - * 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 "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); - - [[nodiscard]] std::vector generateTiles(const radix::tile::Id& parent_id) const; -}; diff --git a/src/tile_builder/alpine_raster.cpp b/src/tile_builder/alpine_raster.cpp deleted file mode 100644 index 789f7f64..00000000 --- a/src/tile_builder/alpine_raster.cpp +++ /dev/null @@ -1,47 +0,0 @@ -/***************************************************************************** - * 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 "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 5d374807..00000000 --- a/src/tile_builder/alpine_raster.h +++ /dev/null @@ -1,51 +0,0 @@ -/***************************************************************************** - * 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 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 ab107102..00000000 --- a/src/tile_builder/depth_first_tile_traverser.h +++ /dev/null @@ -1,42 +0,0 @@ -/***************************************************************************** - * 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 "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 e122f6a1..00000000 --- a/src/tile_builder/image_writer.cpp +++ /dev/null @@ -1,41 +0,0 @@ -/***************************************************************************** - * AlpineMaps.org - * Copyright (C) 2022 Adam Celarek-Litofcenko - * 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/image.h" - -Expected image::save_image_as_png(const radix::Raster& input_image, const std::string& path) -{ - if (input_image.width() == 0 || input_image.height() == 0) { - return Error::fail(Error::Code::InvalidInput, "cannot write an empty tile builder image"); - } - try { - // The legacy tile builder stores its first row at the bottom. - auto flipped = input_image; - for (unsigned y = 0; y < input_image.height(); ++y) { - std::copy_n(input_image.data() + std::size_t(y) * input_image.width(), - input_image.width(), - flipped.data() + std::size_t(input_image.height() - 1 - y) * input_image.width()); - } - return io::image::write(flipped, path, { .overwrite = true, .make_dirs = false }); - } catch (const std::bad_alloc&) { - return Error::fail(Error::Code::ResourceExhausted, "flip tile builder image"); - } -} diff --git a/src/tile_builder/image_writer.h b/src/tile_builder/image_writer.h deleted file mode 100644 index dbceb4f1..00000000 --- a/src/tile_builder/image_writer.h +++ /dev/null @@ -1,53 +0,0 @@ -/***************************************************************************** - * AlpineMaps.org - * Copyright (C) 2022 Adam Celarek-Litofcenko - * 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 19511997..00000000 --- a/src/tile_builder/main.cpp +++ /dev/null @@ -1,57 +0,0 @@ -/***************************************************************************** - * 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 "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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index e16c9621..a0f2de42 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -25,7 +25,6 @@ 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_UNITTESTS_EXTENDED=$ ALP_UNITTESTS_DEBUG_IMAGES=$ ) @@ -39,6 +38,7 @@ add_executable(unittests_terrainlib terrainlib/convert.cpp terrainlib/cow.cpp terrainlib/dataset.cpp + terrainlib/dataset_reading.cpp terrainlib/enumerate.cpp terrainlib/envelope.cpp terrainlib/error.cpp @@ -108,22 +108,6 @@ target_compile_definitions(unittests_glm_serialization_aligned PRIVATE GLM_FORCE target_include_directories(unittests_glm_serialization_aligned PRIVATE ${CMAKE_SOURCE_DIR}/src/terrainlib) target_link_libraries(unittests_glm_serialization_aligned PRIVATE glm::glm zpp_bits Catch2::Catch2WithMain) -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() - if(TARGET sfbuilderlib) add_executable(unittests_sfbuilder catch2_helpers.h 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 cf11d7f2d1318bb0034fff9c6aa3bd0074cf88d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 514877 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

Hk6h?(WEe^QT>nXfU(tQA@XN*w2OV9~K^ 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

Tpd2|8=)`R1V8Fd!oB0bi2f9a6^=N6#~!P) zwD7gI4l+MdLxPhY+~Uk3?-!%yQ#sZ1hS-@jPMZtEOkXxiogc;-o2q1ehn%wm#pvl3 z;?Fr^Y%!mThc@*LzF*JFwM{h2?`DT-1^3s~@=Z-CH@T-VEPjCL^A!xvZv7v+L7Jqn zcoQlXYGSZ*h>L4qNB%}Bd$ewFQnWS>truhE$R(~?D&y}aALiUAml&Pa!`!GI{^OMF zA`cVAwuY^K^onegnim%`5Z$mz#uOT=%LBX2u7tEFj&{e&bHau^RjOu*bqDJO~bYr zL)4#>Q!53CZysa1O+S;h>iNJ&$`e+Guv?;!+jHHJ<*bhM6m{4vm|&69V@6h~qk5wb z4*LRc+6(dBp(@^fVT*ki^zi%0Ar^-8uqSZ=r01vM$PW{Ie>c#4=0`qJ>*qlmL%jEy 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

821i1-#N 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"mask", &expected_srs, wkbPolygon, nullptr) != nullptr); Dataset dataset(raw_dataset); - const OGRSpatialReference actual_srs = dataset.srs(); + const OGRSpatialReference actual_srs = dataset.srs().value(); CHECK(actual_srs.IsSame(&expected_srs)); } @@ -134,10 +134,12 @@ TEST_CASE("bbox width pixels") { 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).value(), webmercator) == Approx(620.0)); - CHECK(d_mgi.heightInPixels(srs::non_exact_bounds_transform(d_mgi.bounds(), d_mgi.srs(), webmercator).value(), webmercator) == Approx(350.0)); + CHECK(d_mgi.widthInPixels(d_mgi.bounds().value(), d_mgi.srs().value()).value() == Approx(620.0)); + CHECK(d_mgi.heightInPixels(d_mgi.bounds().value(), d_mgi.srs().value()).value() == Approx(350.0)); + CHECK(d_mgi.widthInPixels(srs::non_exact_bounds_transform(d_mgi.bounds().value(), d_mgi.srs().value(), webmercator).value(), webmercator).value() + == Approx(620.0)); + CHECK(d_mgi.heightInPixels(srs::non_exact_bounds_transform(d_mgi.bounds().value(), d_mgi.srs().value(), webmercator).value(), webmercator).value() + == Approx(350.0)); auto adjust_bounds = [](auto bounds) { const auto unadjusted_width = bounds.width(); @@ -147,14 +149,104 @@ TEST_CASE("bbox width pixels") { 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)); + CHECK(d_wgs84.widthInPixels(adjust_bounds(d_wgs84.bounds().value()), d_wgs84.srs().value()).value() == Approx(620.0 * 0.7)); + CHECK(d_wgs84.heightInPixels(adjust_bounds(d_wgs84.bounds().value()), d_wgs84.srs().value()).value() == Approx(350.0 * 0.5)); - const auto webmercator_bounds = srs::non_exact_bounds_transform(d_wgs84.bounds(), d_wgs84.srs(), webmercator).value(); - CHECK(d_wgs84.widthInPixels(webmercator_bounds, webmercator) == Approx(620.0)); - CHECK(d_wgs84.heightInPixels(webmercator_bounds, webmercator) == Approx(350.0)); + const auto webmercator_bounds = srs::non_exact_bounds_transform(d_wgs84.bounds().value(), d_wgs84.srs().value(), webmercator).value(); + CHECK(d_wgs84.widthInPixels(webmercator_bounds, webmercator).value() == Approx(620.0)); + CHECK(d_wgs84.heightInPixels(webmercator_bounds, webmercator).value() == 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)); + CHECK(d_wgs84.widthInPixels(webmercator_adjusted_bounds, webmercator).value() == Approx(620.0 * 0.7)); + CHECK(d_wgs84.heightInPixels(webmercator_adjusted_bounds, webmercator).value() == Approx(350.0 * 0.5)); +} + +namespace { +Dataset memory_raster(std::array geotransform, glm::uvec2 size, int epsg) +{ + initialize_gdal_once(); + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("MEM"); + REQUIRE(driver != nullptr); + 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 geodetic = dataset.geodetic_coverage(); + REQUIRE(geodetic); + REQUIRE(geodetic->size() == 2); + check_bounds_near((*geodetic)[0], { { 170, -5 }, { 180, 5 } }, 1e-9); + check_bounds_near((*geodetic)[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 geodetic = dataset.geodetic_coverage(); + REQUIRE(geodetic); + REQUIRE(geodetic->size() == 1); + check_bounds_near(geodetic->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 = srs::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 geodetic = dataset.geodetic_coverage(); + REQUIRE(geodetic); + REQUIRE(geodetic->size() == 1); + check_bounds_near(geodetic->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 = srs::webmercator_half_extent; + check_bounds_near(mercator_bounds->front(), { { -half, -half }, { half, half } }, 1e-3); } diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp new file mode 100644 index 00000000..f0a88bc8 --- /dev/null +++ b/unittests/terrainlib/dataset_reading.cpp @@ -0,0 +1,225 @@ +/***************************************************************************** + * 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 "Dataset.h" +#include "DatasetReader.h" +#include "ctb/types.hpp" +#include "io/image.h" +#include "srs.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 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 = 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())); +} +} + +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).value(); + + 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(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(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(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); + } + } + } +} diff --git a/unittests/tilebuilder/alpine_raster_format.cpp b/unittests/tilebuilder/alpine_raster_format.cpp deleted file mode 100644 index aaf0f4b4..00000000 --- a/unittests/tilebuilder/alpine_raster_format.cpp +++ /dev/null @@ -1,85 +0,0 @@ -/***************************************************************************** - * 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 "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 b420576c..00000000 --- a/unittests/tilebuilder/dataset_reading.cpp +++ /dev/null @@ -1,352 +0,0 @@ -/***************************************************************************** - * 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 "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).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())); -} -} - -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).value(); - - 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).value(); - 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).value(); - - 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).value(); - - 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 db437ef4..00000000 --- a/unittests/tilebuilder/depth_first_tile_traverser.cpp +++ /dev/null @@ -1,203 +0,0 @@ -/***************************************************************************** - * 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 "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 420c221a..00000000 --- a/unittests/tilebuilder/image_writer.cpp +++ /dev/null @@ -1,88 +0,0 @@ -/***************************************************************************** - * 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 . - *****************************************************************************/ - -#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 c0ff28c3..00000000 --- a/unittests/tilebuilder/parallel_tile_generator.cpp +++ /dev/null @@ -1,103 +0,0 @@ -/***************************************************************************** - * 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 "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 26fe400b..00000000 --- a/unittests/tilebuilder/parallel_tiler.cpp +++ /dev/null @@ -1,324 +0,0 @@ -/***************************************************************************** - * 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 "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 335c8391..00000000 --- a/unittests/tilebuilder/tile_heights_generator.cpp +++ /dev/null @@ -1,72 +0,0 @@ -/***************************************************************************** - * 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 "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 75394da2..00000000 --- a/unittests/tilebuilder/top_down_tiler.cpp +++ /dev/null @@ -1,102 +0,0 @@ -/***************************************************************************** - * 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 "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)); - } -} From 2a410efadf8faaef585f83f9d4ba6e3cc569c5de Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:29:53 +0200 Subject: [PATCH 39/83] remove bounds/switch to coverage, as bounds might break on the antimerridian (should return 2 boxes) --- src/terrainlib/Dataset.cpp | 63 -------------------------------- src/terrainlib/Dataset.h | 1 - unittests/terrainlib/dataset.cpp | 29 ++++++++------- 3 files changed, 15 insertions(+), 78 deletions(-) diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 0d4f5756..9876599c 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -174,69 +174,6 @@ Expected Dataset::bounds3d(bool approx_ok) cons return bounds3d; } -Expected Dataset::bounds(const OGRSpatialReference& targetSrs) const -{ - const auto bounds_result = bounds(); - if (!bounds_result) { - return bounds_result; - } - const auto l_bounds = *bounds_result; - 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; - - auto data_srs = srs(); - if (!data_srs) { - return Error::propagate(std::move(data_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) - return Error::fail(Error::Code::Unsupported, "west coordinate > east coordinate"); - for (double s = west; s < east; s += deltaX) { - addCoordinate(s, south); - addCoordinate(s, north); - } - const auto deltaY = (north - south) / 2000.0; - if (deltaY <= 0.0) - return Error::fail(Error::Code::Unsupported, "south coordinate > north coordinate"); - for (double s = south; s < north; s += deltaY) { - addCoordinate(west, s); - addCoordinate(east, s); - } - // don't wanna miss out the max/max edge vertex - addCoordinate(east, north); - - auto transformer = srs::transformation(*data_srs, targetSrs); - if (!transformer) { - return Error::propagate(std::move(transformer)); - } - if (!(*transformer)->Transform(int(x.size()), x.data(), y.data())) { - return Error::fail(Error::Code::InvalidInput, "transform dataset bounds to target SRS"); - } - - 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 radix::tile::SrsBounds { { target_minX, target_minY }, { target_maxX, target_maxY } }; -} - Expected> Dataset::geodetic_coverage() const { auto reference = srs(); diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index 2b85502f..a2726d4c 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/Dataset.h @@ -57,7 +57,6 @@ class Dataset { // 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; - [[nodiscard]] Expected bounds(const OGRSpatialReference& targetSrs) const; // See srs::geodetic_coverage and srs::mercator_coverage. [[nodiscard]] Expected> geodetic_coverage() const; [[nodiscard]] Expected> mercator_coverage() const; diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index a18ae1cd..f757466a 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -41,8 +41,9 @@ void checkBounds(const radix::tile::SrsBounds &a, const radix::tile::SrsBounds & 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); + // 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") { @@ -69,22 +70,22 @@ TEST_CASE("datasets are as expected") { REQUIRE_FALSE(d_mgi.srs().value().IsSame(&wgs84)); } - SECTION("bounds") { + SECTION("coverage") + { { - // fmt::print("webmercator: \n"); - checkBounds(d_mgi.bounds(webmercator).value(), d_wgs84.bounds(webmercator).value()); + 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()); } { - // fmt::print("d_wgs84: \n"); - checkBounds(d_mgi.bounds(d_wgs84.srs().value()).value(), d_wgs84.bounds(d_wgs84.srs().value()).value()); + const auto mgi_coverage = d_mgi.geodetic_coverage().value(); + const auto wgs84_coverage = d_wgs84.geodetic_coverage().value(); + REQUIRE(mgi_coverage.size() == 1); + REQUIRE(wgs84_coverage.size() == 1); + checkBounds(mgi_coverage.front(), wgs84_coverage.front()); } - // { - // // 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); From b83dd654bc7c7fc874fc3d5c223a34ead02acb17 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:45:20 +0200 Subject: [PATCH 40/83] remove more obsolete code --- src/terrainlib/Dataset.cpp | 48 ------------------------- src/terrainlib/Dataset.h | 6 ---- unittests/terrainlib/dataset.cpp | 61 -------------------------------- 3 files changed, 115 deletions(-) diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 9876599c..9bcc58b4 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -224,16 +224,6 @@ unsigned Dataset::heightInPixels() const { return ctb::i_pixel(m_gdal_dataset->GetRasterYSize()); } -Expected Dataset::widthInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const -{ - return pixelWidthIn(bounds_srs).transform([&](double pixel_width) { return bounds.width() / pixel_width; }); -} - -Expected Dataset::heightInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const -{ - return pixelHeightIn(bounds_srs).transform([&](double pixel_height) { return bounds.height() / pixel_height; }); -} - unsigned Dataset::n_bands() const { const auto n = m_gdal_dataset->GetRasterCount(); DEBUG_ASSERT(n >= 0); @@ -246,41 +236,3 @@ GDALDataset *Dataset::gdalDataset() { const GDALDataset *Dataset::gdalDataset() const { return m_gdal_dataset.get(); } - -Expected Dataset::gridResolution(const OGRSpatialReference& target_srs) const -{ - auto width = pixelWidthIn(target_srs); - if (!width) { - return width; - } - auto height = pixelHeightIn(target_srs); - if (!height) { - return height; - } - return std::min(*width, *height); -} - -namespace { -Expected bounds_in(const Dataset& dataset, const OGRSpatialReference& target_srs) -{ - auto bounds = dataset.bounds(); - if (!bounds) { - return bounds; - } - auto reference = dataset.srs(); - if (!reference) { - return Error::propagate(std::move(reference)); - } - return srs::non_exact_bounds_transform(*bounds, *reference, target_srs); -} -} // namespace - -Expected Dataset::pixelWidthIn(const OGRSpatialReference& target_srs) const -{ - return bounds_in(*this, target_srs).transform([&](const radix::tile::SrsBounds& bounds) { return bounds.width() / widthInPixels(); }); -} - -Expected Dataset::pixelHeightIn(const OGRSpatialReference& target_srs) const -{ - return bounds_in(*this, target_srs).transform([&](const radix::tile::SrsBounds& bounds) { return bounds.height() / heightInPixels(); }); -} diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index a2726d4c..eb5af209 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/Dataset.h @@ -63,16 +63,10 @@ class Dataset { [[nodiscard]] Expected srs() const; [[nodiscard]] unsigned int widthInPixels() const; [[nodiscard]] unsigned int heightInPixels() const; - [[nodiscard]] Expected widthInPixels(const radix::tile::SrsBounds& bounds, const OGRSpatialReference& bounds_srs) const; - [[nodiscard]] Expected 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]] Expected gridResolution(const OGRSpatialReference& target_srs) const; - [[nodiscard]] Expected pixelWidthIn(const OGRSpatialReference& target_srs) const; - [[nodiscard]] Expected pixelHeightIn(const OGRSpatialReference& target_srs) const; - private: Dataset(const std::filesystem::path path, GDALDataset *dataset); std::unique_ptr m_gdal_dataset; diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index f757466a..195a53a5 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -23,9 +23,6 @@ #include #include "Dataset.h" -#include "ctb/GlobalGeodetic.hpp" -#include "ctb/GlobalMercator.hpp" -#include "ctb/types.hpp" #include "init.h" #include "srs.h" @@ -50,14 +47,6 @@ 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"); @@ -91,21 +80,6 @@ TEST_CASE("datasets are as expected") { 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).value() == Approx((eastern - western) / 620)); - CHECK(d_mgi.pixelHeightIn(wgs84).value() == Approx((northern - southern) / 350)); - CHECK(d_mgi.gridResolution(wgs84).value() == Approx((northern - southern) / 350)); - - const auto wgs84_grid = ctb::GlobalGeodetic(256); - CHECK(wgs84_grid.zoomForResolution(d_mgi.gridResolution(wgs84).value()) == 7); - - const auto webmercator_grid = ctb::GlobalMercator(); - CHECK(webmercator_grid.zoomForResolution(d_mgi.gridResolution(webmercator).value()) == 7); } } @@ -127,41 +101,6 @@ TEST_CASE("vector datasets use the layer spatial reference") { 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().value(), d_mgi.srs().value()).value() == Approx(620.0)); - CHECK(d_mgi.heightInPixels(d_mgi.bounds().value(), d_mgi.srs().value()).value() == Approx(350.0)); - CHECK(d_mgi.widthInPixels(srs::non_exact_bounds_transform(d_mgi.bounds().value(), d_mgi.srs().value(), webmercator).value(), webmercator).value() - == Approx(620.0)); - CHECK(d_mgi.heightInPixels(srs::non_exact_bounds_transform(d_mgi.bounds().value(), d_mgi.srs().value(), webmercator).value(), webmercator).value() - == 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().value()), d_wgs84.srs().value()).value() == Approx(620.0 * 0.7)); - CHECK(d_wgs84.heightInPixels(adjust_bounds(d_wgs84.bounds().value()), d_wgs84.srs().value()).value() == Approx(350.0 * 0.5)); - - const auto webmercator_bounds = srs::non_exact_bounds_transform(d_wgs84.bounds().value(), d_wgs84.srs().value(), webmercator).value(); - CHECK(d_wgs84.widthInPixels(webmercator_bounds, webmercator).value() == Approx(620.0)); - CHECK(d_wgs84.heightInPixels(webmercator_bounds, webmercator).value() == Approx(350.0)); - - const auto webmercator_adjusted_bounds = adjust_bounds(webmercator_bounds); - CHECK(d_wgs84.widthInPixels(webmercator_adjusted_bounds, webmercator).value() == Approx(620.0 * 0.7)); - CHECK(d_wgs84.heightInPixels(webmercator_adjusted_bounds, webmercator).value() == Approx(350.0 * 0.5)); -} - namespace { Dataset memory_raster(std::array geotransform, glm::uvec2 size, int epsg) { From 91c51ae75601b59a3373d8ee83e5254249950e9d Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:50:46 +0200 Subject: [PATCH 41/83] Update Radix dependency --- src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d24f1174..9c02cf0c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -99,7 +99,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 From 779f315762cd687c4ba4905c379415be5456fd70 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:41:33 +0200 Subject: [PATCH 42/83] min_pixel_size for DatasetReader --- src/terrainlib/DatasetReader.cpp | 82 +++++++++++++++++++++- src/terrainlib/DatasetReader.h | 10 ++- unittests/terrainlib/dataset_reading.cpp | 87 ++++++++++++++++++++++-- 3 files changed, 169 insertions(+), 10 deletions(-) diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index fe41ab29..6fef1af2 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -18,12 +18,14 @@ #include "DatasetReader.h" #include "Dataset.h" +#include "srs.h" #include #include #include #include #include +#include #include #include #include @@ -48,6 +50,17 @@ std::string toWkt(const OGRSpatialReference& srs) return wkt_string; } +OGRSpatialReference target_srs(DatasetReader::Projection projection) +{ + switch (projection) { + case DatasetReader::Projection::WebMercator: + return srs::webmercator(); + case DatasetReader::Projection::Geodetic: + return srs::wgs84(); + } + PANIC("unsupported projection", static_cast(projection)); +} + std::array computeGeoTransform(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) { return { bounds.min.x, bounds.width() / width, 0, @@ -121,13 +134,16 @@ WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, co } -DatasetReader::DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band) +DatasetReader::DatasetReader(const std::shared_ptr& dataset, Projection projection, unsigned band) : m_dataset(dataset) + , m_projection(projection) , m_dataset_srs_wkt(toWkt(Error::throwing_unwrap(dataset->srs()))) - , m_target_srs_wkt(toWkt(targetSRS)) - , m_requires_reprojection(!Error::throwing_unwrap(dataset->srs()).IsSame(&targetSRS)) , m_band(band) { + const auto target = target_srs(projection); + m_target_srs_wkt = toWkt(target); + m_requires_reprojection = !Error::throwing_unwrap(dataset->srs()).IsSame(&target); + if (band > dataset->n_bands()) throw std::runtime_error(fmt::format("Dataset does not contain band number {} (there are {} bands).", band, dataset->n_bands())); } @@ -156,6 +172,66 @@ radix::Raster DatasetReader::readFrom(const std::shared_ptr& sou return heights_data; } +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 == Projection::WebMercator ? m_dataset->mercator_coverage() : m_dataset->geodetic_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; +} + namespace { struct WarpCoordinates { const RasterTransform* source; diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index e235028f..1c547243 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -29,10 +29,11 @@ #include "RasterTransform.h" class Dataset; -class OGRSpatialReference; class DatasetReader { public: + enum class Projection { WebMercator, Geodetic }; + template struct Samples { radix::Raster data; @@ -57,10 +58,14 @@ class DatasetReader { static Expected> read_colour( GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); - DatasetReader(const std::shared_ptr& dataset, const OGRSpatialReference& targetSRS, unsigned band); + DatasetReader(const std::shared_ptr& dataset, Projection projection, unsigned band); radix::Raster read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; + // 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; + Projection projection() const { return m_projection; } 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; } @@ -71,6 +76,7 @@ class DatasetReader { private: std::shared_ptr m_dataset; + Projection m_projection; std::string m_dataset_srs_wkt; std::string m_target_srs_wkt; bool m_requires_reprojection; diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index f0a88bc8..f206ad8c 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -19,14 +19,18 @@ #include #include +#include #include #include #include #include +#include #include #include +#include #include #include +#include #include "Dataset.h" #include "DatasetReader.h" #include "ctb/types.hpp" @@ -100,7 +104,10 @@ TEST_CASE("reading") SECTION("check for min and max heights") { - const std::array test_srses = { 4326, 3857 }; + const std::array test_projections = { + std::make_pair(DatasetReader::Projection::Geodetic, 4326), + std::make_pair(DatasetReader::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 @@ -127,7 +134,7 @@ TEST_CASE("reading") 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) { + for (const auto& [test_projection, test_srs] : test_projections) { OGRSpatialReference srs; srs.importFromEPSG(test_srs); srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); @@ -135,7 +142,8 @@ TEST_CASE("reading") const auto srs_bounds = srs::non_exact_bounds_transform(geodetic_bounds, geodetic_srs, srs).value(); require_projection_available(*dataset, srs); - const DatasetReader reader(dataset, srs, 1); + const DatasetReader reader(dataset, test_projection, 1); + REQUIRE(reader.projection() == test_projection); if (ALP_UNITTESTS_DEBUG_IMAGES) { const auto heights = reader.read(srs_bounds, 1000, 1000); const auto s = std::string("/austria/").length(); @@ -187,7 +195,7 @@ TEST_CASE("reading") 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_reader = DatasetReader(ref_dataset, DatasetReader::Projection::Geodetic, 1); const auto ref_heights = ref_reader.read(ref_bounds, render_width, render_height); if (ALP_UNITTESTS_DEBUG_IMAGES) { REQUIRE(debug_out(ref_heights, fmt::format("./heights_ref.png")).has_value()); @@ -196,7 +204,7 @@ TEST_CASE("reading") 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 reader = DatasetReader(dataset, DatasetReader::Projection::Geodetic, 1); const auto heights = reader.read(ref_bounds, render_width, render_height); const auto s = std::string("/austria/").length(); @@ -223,3 +231,72 @@ TEST_CASE("reading") } } } + +TEST_CASE("min pixel size") +{ + using Projection = DatasetReader::Projection; + const auto open = [](const char* name) { return Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(name)).value(); }; + 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 geodetic_bounds { { 12.0, 47.0 }, { 14.0, 48.0 } }; + const auto mercator_bounds = srs::non_exact_bounds_transform(geodetic_bounds, srs::wgs84(), srs::webmercator()).value(); + + SECTION("same srs as the dataset") + { + const auto mercator = open("/austria/at_100m_epsg3857.tif"); + const auto mercator_size = DatasetReader(mercator, Projection::WebMercator, 1).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 geodetic_size = DatasetReader(anisotropic, Projection::Geodetic, 1).min_pixel_size(geodetic_bounds); + REQUIRE(geodetic_size); + CHECK(geodetic_size->x == Catch::Approx(pixel_size(anisotropic).x)); + CHECK(geodetic_size->y == Catch::Approx(pixel_size(anisotropic).y)); + } + + SECTION("geodetic 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 = DatasetReader(dataset, Projection::WebMercator, 1).min_pixel_size(mercator_bounds); + REQUIRE(size); + const double south = geodetic_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(-srs::webmercator_half_extent), glm::dvec2(srs::webmercator_half_extent) }; + const auto size = DatasetReader(dataset, Projection::WebMercator, 1).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 = DatasetReader(dataset, Projection::WebMercator, 1).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(!DatasetReader(dataset, Projection::Geodetic, 1).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); + } +} From 47a90cfc24c1e7c69a37417588341c42d7adf8fd Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:08:15 +0200 Subject: [PATCH 43/83] fix 3d bounds lowest value --- src/terrainlib/srs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 4d19a6f7..5ba456c5 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -241,7 +241,7 @@ Expected encompassing_bounds_transfer(OGRCoordinateTran // 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()); + target_bounds.max = glm::dvec3(std::numeric_limits::lowest()); for (const auto& point : points) { target_bounds.expand_by(point); } From d0c5a7a6e802174d871badae99c15a345e97bc75 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:14:22 +0200 Subject: [PATCH 44/83] Rename geodetic to geographic --- src/terrainlib/Dataset.cpp | 4 +-- src/terrainlib/Dataset.h | 4 +-- src/terrainlib/DatasetReader.cpp | 4 +-- src/terrainlib/DatasetReader.h | 2 +- src/terrainlib/srs.cpp | 22 +++++++------- src/terrainlib/srs.h | 2 +- unittests/terrainlib/dataset.cpp | 30 +++++++++---------- unittests/terrainlib/dataset_reading.cpp | 38 ++++++++++++------------ 8 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 9bcc58b4..7b5136ab 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -174,7 +174,7 @@ Expected Dataset::bounds3d(bool approx_ok) cons return bounds3d; } -Expected> Dataset::geodetic_coverage() const +Expected> Dataset::geographic_coverage() const { auto reference = srs(); if (!reference) { @@ -184,7 +184,7 @@ Expected> Dataset::geodetic_coverage() const if (!l_bounds) { return Error::propagate(std::move(l_bounds)); } - return srs::geodetic_coverage(*reference, *l_bounds); + return srs::geographic_coverage(*reference, *l_bounds); } Expected> Dataset::mercator_coverage() const diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index eb5af209..23bac95c 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/Dataset.h @@ -57,8 +57,8 @@ class Dataset { // 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::geodetic_coverage and srs::mercator_coverage. - [[nodiscard]] Expected> geodetic_coverage() 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; diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 6fef1af2..54675e9c 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -55,7 +55,7 @@ OGRSpatialReference target_srs(DatasetReader::Projection projection) switch (projection) { case DatasetReader::Projection::WebMercator: return srs::webmercator(); - case DatasetReader::Projection::Geodetic: + case DatasetReader::Projection::Geographic: return srs::wgs84(); } PANIC("unsupported projection", static_cast(projection)); @@ -177,7 +177,7 @@ Expected DatasetReader::min_pixel_size(const radix::tile::SrsBounds& constexpr unsigned samples_per_axis = 5; constexpr double step_fraction = 1e-3; - auto coverage = m_projection == Projection::WebMercator ? m_dataset->mercator_coverage() : m_dataset->geodetic_coverage(); + auto coverage = m_projection == Projection::WebMercator ? m_dataset->mercator_coverage() : m_dataset->geographic_coverage(); if (!coverage) { return Error::propagate(std::move(coverage), "compute dataset coverage"); } diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index 1c547243..d456b914 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -32,7 +32,7 @@ class Dataset; class DatasetReader { public: - enum class Projection { WebMercator, Geodetic }; + enum class Projection { WebMercator, Geographic }; template struct Samples { diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 5ba456c5..9c20ebb4 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -286,14 +286,14 @@ std::expected from_user_input(const std::strin return srs; } -Expected> geodetic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) +Expected> geographic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) { constexpr int boundary_samples = 257; - const auto geodetic = wgs84(); + const auto geographic = wgs84(); radix::tile::SrsBounds longitude_latitude = bounds; - const bool reprojected = !reference.IsSame(&geodetic); + const bool reprojected = !reference.IsSame(&geographic); if (reprojected) { - auto transform = transformation(reference, geodetic); + auto transform = transformation(reference, geographic); if (!transform) { return Error::propagate(std::move(transform)); } @@ -307,7 +307,7 @@ Expected> geodetic_coverage(const OGRSpatial &longitude_latitude.max.x, &longitude_latitude.max.y, boundary_samples)) { - return Error::fail(Error::Code::InvalidInput, "transform bounds to geodetic coordinates"); + return Error::fail(Error::Code::InvalidInput, "transform bounds to geographic coordinates"); } } double west = longitude_latitude.min.x; @@ -315,7 +315,7 @@ Expected> geodetic_coverage(const OGRSpatial double south = longitude_latitude.min.y; double north = longitude_latitude.max.y; if (!std::isfinite(west) || !std::isfinite(east) || !std::isfinite(south) || !std::isfinite(north)) { - return Error::fail(Error::Code::InvalidInput, "nonfinite geodetic coverage bounds"); + return Error::fail(Error::Code::InvalidInput, "nonfinite geographic coverage bounds"); } // TransformBounds reports antimeridian-crossing bounds with east < west. if (east < west) { @@ -348,7 +348,7 @@ Expected> mercator_coverage(const OGRSpatial { const auto mercator = webmercator(); const glm::dvec2 half_extent(webmercator_half_extent); - // Avoid the geodetic round trip, it would widen exact Web Mercator bounds by a guard band. + // 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) { @@ -356,12 +356,12 @@ Expected> mercator_coverage(const OGRSpatial } return std::vector { clipped }; } - auto geodetic = geodetic_coverage(reference, bounds); - if (!geodetic) { - return geodetic; + auto geographic = geographic_coverage(reference, bounds); + if (!geographic) { + return geographic; } std::vector result; - for (const auto& longitude_latitude : *geodetic) { + 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); diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index 525a6a5e..ab4c7403 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -218,7 +218,7 @@ constexpr double webmercator_latitude_limit = 85.0511287798066; /// 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> geodetic_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds); +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); diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index 195a53a5..cf8785a9 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -69,8 +69,8 @@ TEST_CASE("datasets are as expected") { checkBounds(mgi_coverage.front(), wgs84_coverage.front()); } { - const auto mgi_coverage = d_mgi.geodetic_coverage().value(); - const auto wgs84_coverage = d_wgs84.geodetic_coverage().value(); + 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()); @@ -135,11 +135,11 @@ 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 geodetic = dataset.geodetic_coverage(); - REQUIRE(geodetic); - REQUIRE(geodetic->size() == 2); - check_bounds_near((*geodetic)[0], { { 170, -5 }, { 180, 5 } }, 1e-9); - check_bounds_near((*geodetic)[1], { { -180, -5 }, { -170, 5 } }, 1e-9); + 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); @@ -152,10 +152,10 @@ 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 geodetic = dataset.geodetic_coverage(); - REQUIRE(geodetic); - REQUIRE(geodetic->size() == 1); - check_bounds_near(geodetic->front(), { { 10, 88.36 }, { 13.2, 89 } }, 1e-9); + 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); @@ -179,10 +179,10 @@ 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 geodetic = dataset.geodetic_coverage(); - REQUIRE(geodetic); - REQUIRE(geodetic->size() == 1); - check_bounds_near(geodetic->front(), { { -180, -90 }, { 180, 90 } }, 1e-9); + 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); diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index f206ad8c..134c1c9d 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -98,14 +98,14 @@ TEST_CASE("reading") "/austria/pizbuin_1m_epsg4326.tif", }; - OGRSpatialReference geodetic_srs; - geodetic_srs.importFromEPSG(4326); - geodetic_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + 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(DatasetReader::Projection::Geodetic, 4326), + std::make_pair(DatasetReader::Projection::Geographic, 4326), std::make_pair(DatasetReader::Projection::WebMercator, 3857), }; const std::array test_locations = { @@ -130,7 +130,7 @@ TEST_CASE("reading") }; for (const auto& test : test_locations) { - const auto [test_name, test_datasets, geodetic_bounds, limits] = test; + const auto [test_name, test_datasets, geographic_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(); @@ -139,7 +139,7 @@ TEST_CASE("reading") srs.importFromEPSG(test_srs); srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - const auto srs_bounds = srs::non_exact_bounds_transform(geodetic_bounds, geodetic_srs, srs).value(); + const auto srs_bounds = srs::non_exact_bounds_transform(geographic_bounds, geographic_srs, srs).value(); require_projection_available(*dataset, srs); const DatasetReader reader(dataset, test_projection, 1); @@ -194,8 +194,8 @@ TEST_CASE("reading") 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, DatasetReader::Projection::Geodetic, 1); + require_projection_available(*ref_dataset, geographic_srs); + const auto ref_reader = DatasetReader(ref_dataset, DatasetReader::Projection::Geographic, 1); const auto ref_heights = ref_reader.read(ref_bounds, render_width, render_height); if (ALP_UNITTESTS_DEBUG_IMAGES) { REQUIRE(debug_out(ref_heights, fmt::format("./heights_ref.png")).has_value()); @@ -203,8 +203,8 @@ TEST_CASE("reading") 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, DatasetReader::Projection::Geodetic, 1); + require_projection_available(*dataset, geographic_srs); + const auto reader = DatasetReader(dataset, DatasetReader::Projection::Geographic, 1); const auto heights = reader.read(ref_bounds, render_width, render_height); const auto s = std::string("/austria/").length(); @@ -243,8 +243,8 @@ TEST_CASE("min pixel size") }; const double metres_per_degree = 6378137.0 * std::numbers::pi / 180; // Within all Austrian test datasets. - const radix::tile::SrsBounds geodetic_bounds { { 12.0, 47.0 }, { 14.0, 48.0 } }; - const auto mercator_bounds = srs::non_exact_bounds_transform(geodetic_bounds, srs::wgs84(), srs::webmercator()).value(); + const radix::tile::SrsBounds geographic_bounds { { 12.0, 47.0 }, { 14.0, 48.0 } }; + const auto mercator_bounds = srs::non_exact_bounds_transform(geographic_bounds, srs::wgs84(), srs::webmercator()).value(); SECTION("same srs as the dataset") { @@ -255,19 +255,19 @@ TEST_CASE("min pixel size") CHECK(mercator_size->y == Catch::Approx(pixel_size(mercator).y)); const auto anisotropic = open("/austria/at_x149m_y100m_epsg4326.tif"); - const auto geodetic_size = DatasetReader(anisotropic, Projection::Geodetic, 1).min_pixel_size(geodetic_bounds); - REQUIRE(geodetic_size); - CHECK(geodetic_size->x == Catch::Approx(pixel_size(anisotropic).x)); - CHECK(geodetic_size->y == Catch::Approx(pixel_size(anisotropic).y)); + const auto geographic_size = DatasetReader(anisotropic, Projection::Geographic, 1).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("geodetic dataset in web mercator") + 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 = DatasetReader(dataset, Projection::WebMercator, 1).min_pixel_size(mercator_bounds); REQUIRE(size); - const double south = geodetic_bounds.min.y * std::numbers::pi / 180; + 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)); } @@ -297,6 +297,6 @@ TEST_CASE("min pixel size") SECTION("bounds outside the coverage") { const auto dataset = open("/austria/at_100m_epsg4326.tif"); - CHECK(!DatasetReader(dataset, Projection::Geodetic, 1).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); + CHECK(!DatasetReader(dataset, Projection::Geographic, 1).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); } } From 8e22cb754af391ec42f8d6f92f8372ded50a4f4a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:49:47 +0200 Subject: [PATCH 45/83] Add ECEF-specific bounds coverage --- src/terrainlib/srs.cpp | 300 ++++++++++++++++++++++---- src/terrainlib/srs.h | 17 ++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/srs_coverage.cpp | 263 ++++++++++++++++++++++ 4 files changed, 537 insertions(+), 44 deletions(-) create mode 100644 unittests/terrainlib/srs_coverage.cpp diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 9c20ebb4..0c250450 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -286,62 +286,114 @@ std::expected from_user_input(const std::strin 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 (!transform->TransformBounds( + 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; + } + + // 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) { - constexpr int boundary_samples = 257; const auto geographic = wgs84(); radix::tile::SrsBounds longitude_latitude = bounds; - const bool reprojected = !reference.IsSame(&geographic); - if (reprojected) { + if (!reference.IsSame(&geographic)) { auto transform = transformation(reference, geographic); if (!transform) { return Error::propagate(std::move(transform)); } - if (!(*transform) - ->TransformBounds(bounds.min.x, - bounds.min.y, - bounds.max.x, - bounds.max.y, - &longitude_latitude.min.x, - &longitude_latitude.min.y, - &longitude_latitude.max.x, - &longitude_latitude.max.y, - boundary_samples)) { - return Error::fail(Error::Code::InvalidInput, "transform bounds to geographic coordinates"); + 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); } - double west = longitude_latitude.min.x; - double east = longitude_latitude.max.x; - double south = longitude_latitude.min.y; - double north = longitude_latitude.max.y; - if (!std::isfinite(west) || !std::isfinite(east) || !std::isfinite(south) || !std::isfinite(north)) { + 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"); } - // TransformBounds reports antimeridian-crossing bounds with east < west. - if (east < west) { - east += 360; - } - if (reprojected) { - // Densified transformation samples are not exact extrema of curved edges. - // Retain a full sampling interval as a guard band rather than pruning at - // the sampled extremum. - const double longitude_guard = std::max(1e-6, (east - west) / (boundary_samples + 1)); - const double latitude_guard = std::max(1e-6, (north - south) / (boundary_samples + 1)); - west -= longitude_guard; - east += longitude_guard; - south = std::max(-90.0, south - latitude_guard); - north = std::min(90.0, north + latitude_guard); - } - if (east - west >= 360) { - return std::vector { { { -180, south }, { 180, north } } }; - } - const double shift = 360 * std::floor((west + 180) / 360); - west -= shift; - east -= shift; - if (east > 180) { - return std::vector { { { west, south }, { 180, north } }, { { -180, south }, { east - 360, north } } }; - } - return std::vector { { { west, south }, { east, north } } }; + return split_at_antimeridian(longitude_latitude); } Expected> mercator_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) @@ -378,4 +430,164 @@ Expected> mercator_coverage(const OGRSpatial 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. + result.min -= glm::dvec3(1e-3); + result.max += glm::dvec3(1e-3); + 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)); + } + + // 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 { + auto transform = transformation(wgs84_srs, reference); + if (!transform) { + return Error::propagate(std::move(transform)); + } + for (const auto& box : geographic) { + auto transformed = guarded_bounds_transform(transform->get(), 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 srs diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index ab4c7403..d66bd5dd 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -222,5 +222,22 @@ Expected> geographic_coverage(const OGRSpati /// 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 exact; 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 within 43 km of 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 srs diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index a0f2de42..8750e5ed 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -92,6 +92,7 @@ add_executable(unittests_terrainlib terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp + terrainlib/srs_coverage.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp terrainlib/tiny_vector.cpp diff --git a/unittests/terrainlib/srs_coverage.cpp b/unittests/terrainlib/srs_coverage.cpp new file mode 100644 index 00000000..483abb59 --- /dev/null +++ b/unittests/terrainlib/srs_coverage.cpp @@ -0,0 +1,263 @@ +/***************************************************************************** + * 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 "srs.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 srs::transform_point(srs::wgs84(), 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", srs::wgs84(), { { 16.36, 48.20, 0 }, { 16.37, 48.21, 1000 } } }, + { "austria", srs::wgs84(), { { 9.5, 46.3, -100 }, { 17.2, 49.1, 4000 } } }, + { "equator", srs::wgs84(), { { -0.37, -0.28, 0 }, { 0.59, 0.44, 1000 } } }, + { "near_pole", srs::wgs84(), { { 10, 89.0, 0 }, { 20, 89.9, 500 } } }, + { "antimeridian", srs::wgs84(), { { 170, -5, 0 }, { 190, 5, 100 } } }, + { "mercator_10km", srs::webmercator(), { { 1810000, 6130000, 0 }, { 1820000, 6140000, 1000 } } }, + { "austria_lambert", srs::from_epsg(31287).value(), { { 400000, 400000, 0 }, { 500000, 480000, 3000 } } }, + { "utm_33n", srs::from_epsg(32633).value(), { { 500000, 5300000, 0 }, { 600000, 5400000, 3000 } } }, + }; + for (const auto& scenario : cases) { + CAPTURE(scenario.name); + const auto coverage = srs::ecef_coverage(scenario.srs, scenario.bounds); + REQUIRE(coverage); + const auto samples = srs::transform_points(scenario.srs, 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 = srs::ecef_coverage(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 = srs::ecef_coverage(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 = srs::ecef_coverage(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", srs::wgs84() }, + { "webmercator", srs::webmercator() }, + { "mgi", srs::mgi() }, + { "austria_lambert", srs::from_epsg(31287).value() }, + { "utm_33n", 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 = srs::ecef2srs_coverage(box, reference.srs); + REQUIRE(coverage); + REQUIRE(coverage->size() == 1); + const auto samples = srs::transform_points(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 = srs::ecef2srs_coverage(box, 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(srs::transform_points(srs::ecef(), 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 = srs::ecef2srs_coverage(cube({ 6378137, 0, 0 }, 50000), 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 : { srs::wgs84(), srs::webmercator() }) { + const auto coverage = srs::ecef2srs_coverage(box, reference); + REQUIRE(coverage); + REQUIRE(coverage->size() == 2); + const double half_width = reference.IsGeographic() ? 180 : srs::webmercator_half_extent; + for (const auto& bounds : *coverage) { + CHECK(bounds.min.x >= -half_width); + CHECK(bounds.max.x <= half_width); + } + const auto samples = srs::transform_points(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 = srs::ecef2srs_coverage(box, 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, srs::transform_points(srs::ecef(), srs::wgs84(), box_samples(box)).value()) == 0); + const auto mercator = srs::ecef2srs_coverage(box, srs::webmercator()); + REQUIRE(mercator); + CHECK(mercator->empty()); + } + SECTION("centre of the earth") + { + const auto coverage = srs::ecef2srs_coverage(cube(glm::dvec3(0), 1000), srs::wgs84()); + REQUIRE_FALSE(coverage); + CHECK(coverage.error().code() == Error::Code::Unsupported); + } +} + +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(srs::ecef_coverage(srs::ecef(), box).value() == box); + const auto unchanged = srs::ecef2srs_coverage(box, srs::ecef()).value(); + REQUIRE(unchanged.size() == 1); + CHECK(unchanged.front() == box); + + const auto compound = srs::from_user_input("EPSG:4326+3855").value(); + REQUIRE(compound.IsCompound()); + const auto forward = 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 = 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 = srs::ecef_coverage(srs::wgs84(), bad); + REQUIRE_FALSE(forward_result); + CHECK(forward_result.error().code() == Error::Code::InvalidInput); + const auto backward_result = srs::ecef2srs_coverage(bad, srs::wgs84()); + REQUIRE_FALSE(backward_result); + CHECK(backward_result.error().code() == Error::Code::InvalidInput); + } +} From df44e5d3161a32dbb22cd27e064605d83c2df4ac Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:14:16 +0200 Subject: [PATCH 46/83] Reject out-of-world Mercator coverage --- src/terrainlib/srs.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 0c250450..e7da765d 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -374,6 +374,9 @@ 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)) { From 7557d73aa323e0d8d608eb523d4c84f235feeb9c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:52:44 +0200 Subject: [PATCH 47/83] fix datum conversions (e.g. mgi uses a different ellipsoid -> normals diverge from wgs84 -> points at altitudes transform to different coordinates compared to points at sealevel -> our 2d bounds transform might return wrong bounds. add an additional margin to be on the safe side --- src/terrainlib/srs.cpp | 27 ++++++++++++++++++++++----- unittests/terrainlib/srs_coverage.cpp | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index e7da765d..2878845c 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -346,6 +346,12 @@ namespace { 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). @@ -484,9 +490,13 @@ Expected ecef_coverage(const OGRSpatialReference& refer for (const auto& point : *points) { result.expand_by(point); } - // Allow for rounding in the transformation. - result.min -= glm::dvec3(1e-3); - result.max += glm::dvec3(1e-3); + // 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; } @@ -566,12 +576,19 @@ Expected> ecef2srs_coverage(const radix::ge 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 : geographic) { - auto transformed = guarded_bounds_transform(transform->get(), box); + 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)); } diff --git a/unittests/terrainlib/srs_coverage.cpp b/unittests/terrainlib/srs_coverage.cpp index 483abb59..7d3aa35c 100644 --- a/unittests/terrainlib/srs_coverage.cpp +++ b/unittests/terrainlib/srs_coverage.cpp @@ -233,6 +233,24 @@ TEST_CASE("Coverage of ECEF boxes stays within the bounds of the SRS", "[srs][co } } +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 = 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 = srs::transform_point(srs::ecef(), lambert, ecef).value(); + const auto backward = srs::ecef2srs_coverage({ ecef, ecef }, lambert); + REQUIRE(backward); + CHECK(count_uncovered(*backward, { projected }) == 0); + const auto forward = srs::ecef_coverage(lambert, { projected, projected }); + REQUIRE(forward); + CHECK(forward->contains_inclusive(srs::transform_point(lambert, 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); From 2f968d6b5c54961061ea5047411fe2750f77e613 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:59:31 +0200 Subject: [PATCH 48/83] make sure axis mapping is correct, and degrees are used (no 0-100/400 grads) --- src/terrainlib/srs.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index 2878845c..d04c5bc8 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -77,7 +77,11 @@ namespace { Expected> uncached_transformation(const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) { - std::unique_ptr transform(OGRCreateCoordinateTransformation(&source_srs, &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)); } @@ -512,6 +516,9 @@ Expected> ecef2srs_coverage(const radix::ge 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 From e50a332acd3d95373a7a3a49876e58fa6b93a3b1 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:38:03 +0200 Subject: [PATCH 49/83] improve error handling --- src/sf_merger/mask.h | 8 ++++---- src/terrainlib/Dataset.cpp | 22 +++++++++++----------- src/terrainlib/Dataset.h | 6 +++--- unittests/terrainlib/dataset_reading.cpp | 8 ++++---- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 3140988c..08fa7017 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -352,12 +352,12 @@ inline std::expected load_referenced_from_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); + auto opened = Dataset::open_vector(path); + if (!opened) { + LOG_ERROR("Failed to load mask dataset: {}", opened.error().to_string()); return std::unexpected(LoadErrorKind::FileNotFound); } - Dataset dataset = std::move(ds_opt.value()); + Dataset dataset = std::move(*opened); return load_referenced_from_dataset(dataset); } diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 7b5136ab..25d33b15 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -49,26 +49,26 @@ static GDALDataset *open_gdal_dataset(const std::filesystem::path &path, unsigne 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) { diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/Dataset.h index 23bac95c..20646a00 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/Dataset.h @@ -41,9 +41,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; diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index 134c1c9d..63e3a603 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -133,7 +133,7 @@ TEST_CASE("reading") const auto [test_name, test_datasets, geographic_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(); + const auto dataset = Error::throwing_unwrap(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); @@ -193,7 +193,7 @@ TEST_CASE("reading") 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(); + const auto ref_dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(datasets.front()))); require_projection_available(*ref_dataset, geographic_srs); const auto ref_reader = DatasetReader(ref_dataset, DatasetReader::Projection::Geographic, 1); const auto ref_heights = ref_reader.read(ref_bounds, render_width, render_height); @@ -202,7 +202,7 @@ TEST_CASE("reading") } for (std::string dataset_name : datasets) { - const auto dataset = Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name)).value(); + const auto dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); require_projection_available(*dataset, geographic_srs); const auto reader = DatasetReader(dataset, DatasetReader::Projection::Geographic, 1); const auto heights = reader.read(ref_bounds, render_width, render_height); @@ -235,7 +235,7 @@ TEST_CASE("reading") TEST_CASE("min pixel size") { using Projection = DatasetReader::Projection; - const auto open = [](const char* name) { return Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(name)).value(); }; + const auto open = [](const char* name) { return Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(name))); }; const auto pixel_size = [](const std::shared_ptr& dataset) { std::array geo_transform {}; REQUIRE(dataset->gdalDataset()->GetGeoTransform(geo_transform.data()) == CE_None); From dbf4ed81013c317225c5e17b58e963835fc12125 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:37:06 +0200 Subject: [PATCH 50/83] Migrate SF coverage and cache projected bounds transformations --- src/sf_builder/main.cpp | 8 +- src/sf_builder/mesh_builder.cpp | 190 +++++++++++++++------- src/sf_builder/mesh_builder.h | 7 + src/sf_builder/terrainbuilder.cpp | 73 +++++++-- src/sf_builder/terrainbuilder.h | 3 + src/sf_builder/texture_assembler.h | 12 +- src/sf_merger/mask.h | 46 +++--- src/terrainlib/CMakeLists.txt | 1 + src/terrainlib/srs.cpp | 161 ++----------------- src/terrainlib/srs.h | 28 +--- src/terrainlib/srs/detail.cpp | 151 ++++++++++++++++++ src/terrainlib/srs/detail.h | 22 +++ unittests/CMakeLists.txt | 3 + unittests/sf_builder/coverage.cpp | 192 +++++++++++++++++++++++ unittests/sf_merger/mask.cpp | 23 +++ unittests/terrainlib/dataset_reading.cpp | 6 +- unittests/terrainlib/srs_bounds.cpp | 140 +++++++++++++++++ 17 files changed, 780 insertions(+), 286 deletions(-) create mode 100644 src/terrainlib/srs/detail.cpp create mode 100644 src/terrainlib/srs/detail.h create mode 100644 unittests/sf_builder/coverage.cpp create mode 100644 unittests/terrainlib/srs_bounds.cpp diff --git a/src/sf_builder/main.cpp b/src/sf_builder/main.cpp index 3d39d156..dd3e7913 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -128,6 +128,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); } @@ -173,7 +176,7 @@ void log_dataset_overview(const Dataset &dataset) { } const auto bounds = *boundsResult; - const auto boundsEcefResult = srs::encompassing_bounds_transfer(datasetSrs, srs::ecef(), bounds); + const auto boundsEcefResult = srs::ecef_coverage(datasetSrs, bounds); if (!boundsEcefResult) { LOG_ERROR("Failed to transform dataset bounds to ECEF: {}", boundsEcefResult.error().to_string()); return; @@ -286,7 +289,8 @@ 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.") ->required(); diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 1c4fed9a..6268fa75 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -18,6 +18,7 @@ *****************************************************************************/ #include +#include #include #include #include @@ -180,26 +181,64 @@ Expected> generate_uv_space(const std::vector::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 = 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( @@ -213,61 +252,94 @@ std::expected build_reference_mesh_patch( } const OGRSpatialReference& source_srs = *source_srs_result; - // Translate tile bounds from tile srs into the source srs, so we know what data to read. - Expected target_bounds_result; - if (std::isinf(clip_bounds.min.z) && std::isinf(clip_bounds.max.z)) { - // Make target bounds 2d if the unbounded by height. - target_bounds_result = srs::encompassing_bounds_transfer(clip_srs, source_srs, radix::tile::SrsBounds(clip_bounds)); - } else { - target_bounds_result = srs::encompassing_bounds_transfer(clip_srs, source_srs, clip_bounds); - } - if (!target_bounds_result) { - return std::unexpected(log_transformation_error(target_bounds_result.error())); + // 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 = 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 = 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; } - const radix::tile::SrsBounds target_bounds_in_source_srs = *target_bounds_result; - // 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; - }); - - 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_in_source_srs = meshify(source_points, valid_mask); - // Check if we even have any valid vertices. Can happen if all of the region is padding. - if (mesh_in_source_srs.vertex_count() == 0 || mesh_in_source_srs.face_count() == 0) { - return std::unexpected(BuildMeshError::EmptyRegion); + 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("Finding valid pixels"); + const radix::RasterMask valid_mask = radix::raster::transform(height_map, [=](const float height) { return height != no_data_value; }); + + 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)); + } } - - // Fast check if all vertices will be clipped - const radix::geometry::Aabb3d actual_source_bounds = calculate_bounds(mesh_in_source_srs); - const auto approx_clip_bounds = srs::encompassing_bounds_transfer(source_srs, clip_srs, actual_source_bounds); - if (!approx_clip_bounds) { - return std::unexpected(log_transformation_error(approx_clip_bounds.error())); + if (!any_read) { + return std::unexpected(BuildMeshError::OutOfBounds); } - if (!radix::geometry::intersect(*approx_clip_bounds, clip_bounds)) { + 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. 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 = 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 auto mesh_in_clip_srs = transform_mesh(std::move(mesh_in_source_srs), source_srs, clip_srs); diff --git a/src/sf_builder/mesh_builder.h b/src/sf_builder/mesh_builder.h index b7191931..7e26c713 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.h @@ -36,6 +36,13 @@ enum class BuildMeshError { }; 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, diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 35523be2..a1ca3157 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -209,6 +210,11 @@ Expected build_all_patches( const std::string &output_format, const bool overwrite_existing ) { + if (target_level < min_target_level) { + return Error::fail( + Error::Code::InvalidInput, fmt::format("target level {} is below the minimum of {}", unsigned(target_level), unsigned(min_target_level))); + } + 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); @@ -234,14 +240,19 @@ Expected build_all_patches( return Error::propagate(std::move(dataset_srs_result), "read dataset SRS"); } const auto dataset_srs = *dataset_srs_result; - auto dataset_bounds_result = dataset.bounds3d(true); + 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(); - auto ecef_bounds_result = srs::encompassing_bounds_transfer(dataset_srs, ecef_srs, dataset_bounds); + auto ecef_bounds_result = srs::ecef_coverage(dataset_srs, dataset_bounds); if (!ecef_bounds_result) { return Error::propagate(std::move(ecef_bounds_result), "transform dataset bounds to ECEF"); } @@ -251,20 +262,39 @@ Expected build_all_patches( 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; - // Validate once, so that the per-thread transformations below cannot fail. - if (auto transform = srs::transformation(ecef_srs, dataset_srs); !transform) { - return Error::propagate(std::move(transform), "create ECEF to dataset transformation"); - } - tbb::enumerable_thread_specific> transform_ecef_dataset([&]() { - auto local_ecef_srs = ecef_srs; - auto local_dataset_srs = dataset_srs; - return Error::asserting_unwrap(srs::transformation(local_ecef_srs, local_dataset_srs)); - }); + // SRS objects are not shared between threads. + tbb::enumerable_thread_specific local_wgs84_srs([]() { return 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 @@ -272,13 +302,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 = Error::asserting_unwrap(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 = 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) { @@ -286,6 +324,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); } }; diff --git a/src/sf_builder/terrainbuilder.h b/src/sf_builder/terrainbuilder.h index eef07f06..1d90eb58 100644 --- a/src/sf_builder/terrainbuilder.h +++ b/src/sf_builder/terrainbuilder.h @@ -33,6 +33,9 @@ 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, const OGRSpatialReference &target_bounds_srs, diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index e71d3079..4f8f7302 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -484,14 +484,10 @@ inline std::optional assemble_texture_from_tiles( return std::nullopt; } - // Start by transforming the input bounds into the srs the tiles are in. - const auto encompassing_bounds_result = srs::encompassing_bounds_transfer(target_srs, grid.getSRS(), target_bounds); - if (!encompassing_bounds_result) { - LOG_ERROR("Failed to transform texture target bounds: {}", encompassing_bounds_result.error().to_string()); - return std::nullopt; - } - const radix::tile::SrsBounds encompassing_bounds = *encompassing_bounds_result; - // Then we find the smallest tile (id) that encompasses these bounds. + // The texture is linear in the srs of the tiles, the uvs are linear in the target srs. + ASSERT(target_srs.IsSame(&grid.getSRS())); + const radix::tile::SrsBounds& encompassing_bounds = target_bounds; + // Find the smallest tile (id) that encompasses these bounds. radix::tile::Id smallest_encompassing_tile = grid.findSmallestEncompassingTile(encompassing_bounds).value(); LOG_TRACE("Smallest encompassing tile for texture bounds is {}", radix::tile::to_string(smallest_encompassing_tile)); diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 08fa7017..dd63cf19 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -117,25 +118,20 @@ using vector_mask::process_geometry; using vector_mask::load_referenced_from_dataset; namespace { -void convert_to_ecef_and_project_onto_sphere(MultipolygonWithHoles2 &polygons, const SphereProjector &projector, const OGRSpatialReference &srs) { - const auto srs_transform = Error::asserting_unwrap(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 = Error::asserting_unwrap(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 = Error::asserting_unwrap(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) { @@ -179,15 +175,17 @@ inline SpherePolygonMask project_onto_sphere(ReferencedPolygonMask ref_mask, con 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 = Error::asserting_unwrap(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(srs::transformation(srs, srs::ecef())); + Error::asserting_unwrap(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); } diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index efd53d69..af1b1b52 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -43,6 +43,7 @@ add_library(terrainlib pch.h range_utils.h srs.h srs.cpp + srs/detail.h srs/detail.cpp string_utils.h tile_path.h type_utils.h diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index d04c5bc8..e331d93f 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -18,6 +18,7 @@ *****************************************************************************/ #include "srs.h" +#include "srs/detail.h" #include #include @@ -122,154 +123,6 @@ Expected> transformation(const OGRS return shared_transform; } -Expected 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())) { - return Error::fail(Error::Code::InvalidInput, "transform bounds corners"); - } - return radix::tile::SrsBounds { { std::min(xs[0], xs[1]), std::min(ys[0], ys[1]) }, { std::max(xs[0], xs[1]), std::max(ys[0], ys[1]) } }; -} - -Expected non_exact_bounds_transform( - const radix::tile::SrsBounds& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs) -{ - auto transform = transformation(sourceSrs, targetSrs); - if (!transform) { - return Error::propagate(std::move(transform)); - } - return non_exact_bounds_transform(transform->get(), bounds); -} - -Expected 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())) { - return Error::fail(Error::Code::InvalidInput, "transform bounds corners"); - } - return radix::geometry::Aabb3d { { xs[0], ys[0], zs[0] }, { xs[1], ys[1], zs[1] } }; -} - -Expected non_exact_bounds_transform( - const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs) -{ - auto transform = transformation(sourceSrs, targetSrs); - if (!transform) { - return Error::propagate(std::move(transform)); - } - return non_exact_bounds_transform(transform->get(), bounds); -} - -Expected 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) { - return Error::fail(Error::Code::InvalidInput, "transform bounds"); - } - return target_bounds; -} - -Expected 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; - } - - auto transformation = srs::transformation(source_srs, target_srs); - if (!transformation) { - return Error::propagate(std::move(transformation)); - } - return encompassing_bounds_transfer(transformation->get(), source_bounds); -} - -Expected encompassing_bounds_transfer(OGRCoordinateTransformation* transform, - const radix::geometry::Aabb3d& source_bounds, - const uint32_t intermediate_points_edges, - const uint32_t intermediate_points_faces) -{ - 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 - if (auto result = transform_points_inplace(transform, points); !result) { - return Error::propagate(std::move(result)); - } - - // 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::lowest()); - for (const auto& point : points) { - target_bounds.expand_by(point); - } - - return target_bounds; -} - -Expected encompassing_bounds_transfer(const OGRSpatialReference& source_srs, - const OGRSpatialReference& target_srs, - const radix::geometry::Aabb3d& source_bounds, - const uint32_t intermediate_points_edges, - const uint32_t intermediate_points_faces) -{ - if (source_srs.IsSame(&target_srs)) { - return source_bounds; - } - - auto transform = srs::transformation(source_srs, target_srs); - if (!transform) { - return Error::propagate(std::move(transform)); - } - return encompassing_bounds_transfer(transform->get(), source_bounds, intermediate_points_edges, intermediate_points_faces); -} - std::expected from_epsg(const uint32_t epsg) { OGRSpatialReference srs; @@ -300,8 +153,16 @@ namespace { Expected guarded_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds) { radix::tile::SrsBounds result; - if (!transform->TransformBounds( - 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)) { + 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)) { diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index d66bd5dd..39632cc8 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -177,30 +177,6 @@ inline Expected>> transform_points_to_2d(OGRCoordinate return transformed; } -Expected non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& bounds); -Expected non_exact_bounds_transform( - const radix::tile::SrsBounds& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); -Expected non_exact_bounds_transform(OGRCoordinateTransformation* transform, const radix::geometry::Aabb3d& bounds); -Expected non_exact_bounds_transform( - const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& sourceSrs, const OGRSpatialReference& targetSrs); - -/// 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. -Expected encompassing_bounds_transfer(OGRCoordinateTransformation* transform, const radix::tile::SrsBounds& source_bounds); -Expected encompassing_bounds_transfer( - const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs, const radix::tile::SrsBounds& source_bounds); - -Expected 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); -Expected 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); - std::expected from_epsg(const uint32_t epsg); std::expected from_user_input(const std::string& user_input); @@ -232,11 +208,11 @@ Expected ecef_coverage(const OGRSpatialReference& refer /// 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 exact; other SRSes use GDAL's densified +/// 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 within 43 km of the earth's centre, where PROJ is +/// 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); diff --git a/src/terrainlib/srs/detail.cpp b/src/terrainlib/srs/detail.cpp new file mode 100644 index 00000000..b0940b1e --- /dev/null +++ b/src/terrainlib/srs/detail.cpp @@ -0,0 +1,151 @@ +/* + * 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 "detail.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "srs.h" + +namespace 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 srs::detail diff --git a/src/terrainlib/srs/detail.h b/src/terrainlib/srs/detail.h new file mode 100644 index 00000000..6b0a88af --- /dev/null +++ b/src/terrainlib/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 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 srs::detail diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 8750e5ed..3e1ffc7c 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -93,6 +93,7 @@ add_executable(unittests_terrainlib terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp terrainlib/srs_coverage.cpp + terrainlib/srs_bounds.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp terrainlib/tiny_vector.cpp @@ -112,6 +113,8 @@ target_link_libraries(unittests_glm_serialization_aligned PRIVATE glm::glm zpp_b 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 ) diff --git a/unittests/sf_builder/coverage.cpp b/unittests/sf_builder/coverage.cpp new file mode 100644 index 00000000..003402db --- /dev/null +++ b/unittests/sf_builder/coverage.cpp @@ -0,0 +1,192 @@ +/***************************************************************************** + * 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 "Dataset.h" +#include "mesh/io.h" +#include "mesh_builder.h" +#include "srs.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 srs::ecef_coverage(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)); } + +} // namespace + +TEST_CASE("native read windows follow the longitudes of the dataset", "[terrainbuilder][coverage]") +{ + const auto wgs84 = 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 = srs::from_epsg(31287).value(); + const radix::tile::SrsBounds native_bounds { { 100000, 250000 }, { 700000, 600000 } }; + const glm::dvec3 vienna = 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 = srs::wgs84(); + const auto ecef = srs::ecef(); + const auto webmercator = srs::webmercator(); + radix::tile::SrsBounds texture_bounds; + + SECTION("dataset stored at [180, 190]") + { + 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") + { + 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 = srs::wgs84(); + const auto ecef = srs::ecef(); + const auto webmercator = srs::webmercator(); + // Crosses the plane between two level-one nodes, so traversal starts at the root. + 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, 1, webmercator, nullptr, ecef, output, ".sfmesh", false).has_value()); + CHECK_FALSE(std::filesystem::exists(output)); + } + + SECTION("nodes on both sides are built") + { + REQUIRE(terrainbuilder::build_all_patches(dataset, 12, webmercator, nullptr, ecef, output, ".sfmesh", false).has_value()); + 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); + } +} diff --git a/unittests/sf_merger/mask.cpp b/unittests/sf_merger/mask.cpp index 6f4342ff..ce2c17b2 100644 --- a/unittests/sf_merger/mask.cpp +++ b/unittests/sf_merger/mask.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -180,6 +181,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), 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/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index 63e3a603..8e3dbe71 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -139,7 +139,8 @@ TEST_CASE("reading") srs.importFromEPSG(test_srs); srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - const auto srs_bounds = srs::non_exact_bounds_transform(geographic_bounds, geographic_srs, srs).value(); + const auto srs_corners = 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); const DatasetReader reader(dataset, test_projection, 1); @@ -244,7 +245,8 @@ TEST_CASE("min pixel size") 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_bounds = srs::non_exact_bounds_transform(geographic_bounds, srs::wgs84(), srs::webmercator()).value(); + const auto mercator_corners = srs::transform_points(srs::wgs84(), 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") { diff --git a/unittests/terrainlib/srs_bounds.cpp b/unittests/terrainlib/srs_bounds.cpp new file mode 100644 index 00000000..18d7c25e --- /dev/null +++ b/unittests/terrainlib/srs_bounds.cpp @@ -0,0 +1,140 @@ +// Copyright (C) 2026 Adam Celarek-Litofcenko +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "srs.h" +#include "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 = srs::from_user_input(scenario.source).value(); + auto target = 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 + = 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 = srs::wgs84(); + const auto target = srs::from_user_input("+proj=sinu +lon_0=10 +datum=WGS84 +units=m +type=crs").value(); + const auto transform = srs::transformation(source, target).value(); + std::array bounds {}; + REQUIRE(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 = 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 = srs::transformation(srs::wgs84(), srs::from_epsg(31287).value()).value(); + std::array bounds {}; + REQUIRE(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") + { + 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; + }; +} From fc824314757928857f5065670137853c5382c1eb Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:06:45 +0200 Subject: [PATCH 51/83] port rf-builder to the new coverage api --- docs/raster-store/terminology.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index 6242258f..c3b22592 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -113,9 +113,9 @@ _Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) absent. The supplying source ancestor need not be retained as an RF tile. **Local sampling ratio** -: The largest directional stretch from an output pixel spacing into source-pixel - coordinates at a location. It expresses the local reduction in source detail - without treating a pure rotation as a change in resolution. +: 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 From 19fd66ac90d07d28e60d7b465879a3c674aaacfa Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:49:09 +0200 Subject: [PATCH 52/83] move RasterTransform::tile_bounds to srs::webmercator_tile_bounds and fix some smaller issues in DatasetReader --- src/terrainlib/Dataset.cpp | 4 +++ src/terrainlib/DatasetReader.cpp | 16 +++++++++--- src/terrainlib/RasterTransform.cpp | 10 -------- src/terrainlib/RasterTransform.h | 1 - src/terrainlib/srs.cpp | 8 ++++++ src/terrainlib/srs.h | 2 ++ unittests/terrainlib/dataset.cpp | 16 ++++++++++++ unittests/terrainlib/dataset_reading.cpp | 31 ++++++++++++++++++++++++ 8 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 25d33b15..200bed00 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -121,6 +121,10 @@ Dataset::~Dataset() = default; 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) { return Error::fail(Error::Code::Unsupported, "dataset " + name() + " has no geotransform"); diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 54675e9c..02ab015e 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -96,7 +96,7 @@ WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, co auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); options->hSrcDS = dataset->gdalDataset(); options->nBandCount = 1; - options->eResampleAlg = GDALResampleAlg::GRA_Cubic; + options->eResampleAlg = GDALResampleAlg::GRA_Lanczos; options->panSrcBands = static_cast(CPLMalloc(sizeof(int) * 1)); options->panDstBands = static_cast(CPLMalloc(sizeof(int) * 1)); options->padfSrcNoDataReal = static_cast(CPLMalloc(sizeof(double) * 1)); @@ -119,7 +119,7 @@ WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, co } constexpr auto use_approximation = true; if (use_approximation) { - const auto error_threshold = 0.5; + const auto error_threshold = 0.125; 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; @@ -137,12 +137,20 @@ WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, co DatasetReader::DatasetReader(const std::shared_ptr& dataset, Projection projection, unsigned band) : m_dataset(dataset) , m_projection(projection) - , m_dataset_srs_wkt(toWkt(Error::throwing_unwrap(dataset->srs()))) , m_band(band) { + auto source = Error::throwing_unwrap(dataset->srs()); const auto target = target_srs(projection); m_target_srs_wkt = toWkt(target); - m_requires_reprojection = !Error::throwing_unwrap(dataset->srs()).IsSame(&target); + m_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()); + } + } + m_dataset_srs_wkt = toWkt(source); if (band > dataset->n_bands()) throw std::runtime_error(fmt::format("Dataset does not contain band number {} (there are {} bands).", band, dataset->n_bands())); diff --git a/src/terrainlib/RasterTransform.cpp b/src/terrainlib/RasterTransform.cpp index 3f1a7d04..67c879de 100644 --- a/src/terrainlib/RasterTransform.cpp +++ b/src/terrainlib/RasterTransform.cpp @@ -147,16 +147,6 @@ Expected RasterTransform::create(GDALDataset& dataset) return result; } -RasterTransform::Bounds RasterTransform::tile_bounds(const radix::tile::Id& key) -{ - // ldexp avoids both 1u << 32 and tile-coordinate * tile-dimension overflow. - const double side = std::ldexp(2 * world_half_extent, -int(key.zoom_level)); - return { { -world_half_extent + double(key.coords.x) * side, - world_half_extent - (double(key.coords.y) + 1) * side }, - { -world_half_extent + (double(key.coords.x) + 1) * side, - world_half_extent - double(key.coords.y) * side } }; -} - Expected RasterTransform::source_pixel(glm::dvec2 point, bool wrap_longitude) const { if (!m_to_source->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h index a5c65145..d0d61b7c 100644 --- a/src/terrainlib/RasterTransform.h +++ b/src/terrainlib/RasterTransform.h @@ -36,7 +36,6 @@ class RasterTransform { static Expected create(GDALDataset& dataset); static Expected> coverage(const OGRSpatialReference& reference, const Bounds& bounds); - static Bounds tile_bounds(const radix::tile::Id& key); // Sampling estimates can omit source-branch wrapping to keep derivatives // continuous at a global raster's longitude seam. diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/srs.cpp index e331d93f..27430f79 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/srs.cpp @@ -243,6 +243,14 @@ namespace { } } // namespace +radix::tile::SrsBounds webmercator_tile_bounds(const radix::tile::Id& key) +{ + // ldexp avoids both 1u << 32 and tile-coordinate * tile-dimension overflow. + const double side = std::ldexp(2 * webmercator_half_extent, -int(key.zoom_level)); + return { { -webmercator_half_extent + double(key.coords.x) * side, webmercator_half_extent - (double(key.coords.y) + 1) * side }, + { -webmercator_half_extent + (double(key.coords.x) + 1) * side, webmercator_half_extent - double(key.coords.y) * side } }; +} + 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)) { diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index 39632cc8..51aa3d7e 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -190,6 +190,8 @@ inline OGRSpatialReference mgi() { return from_epsg(4312).value(); } constexpr double webmercator_half_extent = 20037508.342789244; /// Latitude in degrees at which Web Mercator y reaches webmercator_half_extent. constexpr double webmercator_latitude_limit = 85.0511287798066; +/// Web Mercator bounds of an XYZ tile; valid up to zoom level 32. +radix::tile::SrsBounds webmercator_tile_bounds(const radix::tile::Id& key); /// WGS84 longitude/latitude bounds in degrees covering the given bounds. /// Longitudes lie in [-180, 180]; coverage crossing the antimeridian is split into two bounds. diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/dataset.cpp index cf8785a9..00b99608 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/dataset.cpp @@ -190,3 +190,19 @@ TEST_CASE("global dataset coverage is a single world rectangle") const double half = srs::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/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index 8e3dbe71..839de268 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -34,6 +34,7 @@ #include "Dataset.h" #include "DatasetReader.h" #include "ctb/types.hpp" +#include "init.h" #include "io/image.h" #include "srs.h" @@ -302,3 +303,33 @@ TEST_CASE("min pixel size") CHECK(!DatasetReader(dataset, Projection::Geographic, 1).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); } } + +TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") +{ + initialize_gdal_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 = 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); + + const DatasetReader reader(dataset, DatasetReader::Projection::WebMercator, 1); + const double degree = srs::webmercator_half_extent / 180; + for (const double longitude : { -90.0, 90.0 }) { + const auto values = reader.read({ { (longitude - 1) * degree, -degree }, { (longitude + 1) * degree, degree } }, 4, 4); + for (const float value : values.buffer()) { + CHECK(value == Catch::Approx(longitude < 0 ? longitude + 360 : longitude).margin(1.5)); + } + } +} From 059d98ab0103b567625f983aa4c8676cb60a24b5 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:54:06 +0200 Subject: [PATCH 53/83] remove remaining ctb --- NOTICE | 45 --- src/sf_builder/main.cpp | 17 +- src/sf_builder/terrainbuilder.cpp | 5 +- src/sf_builder/texture_assembler.h | 63 +++-- src/terrainlib/CMakeLists.txt | 7 - src/terrainlib/Dataset.cpp | 5 +- src/terrainlib/DatasetReader.cpp | 1 - src/terrainlib/ctb/CTBException.hpp | 42 --- src/terrainlib/ctb/GlobalGeodetic.cpp | 36 --- src/terrainlib/ctb/GlobalGeodetic.hpp | 59 ---- src/terrainlib/ctb/GlobalMercator.cpp | 43 --- src/terrainlib/ctb/GlobalMercator.hpp | 66 ----- src/terrainlib/ctb/Grid.hpp | 342 ----------------------- src/terrainlib/ctb/NOTICE | 24 -- src/terrainlib/ctb/TileCoordinate.hpp | 71 ----- src/terrainlib/ctb/types.hpp | 45 --- unittests/CMakeLists.txt | 1 - unittests/sf_builder/texture.cpp | 41 +-- unittests/terrainlib/dataset_reading.cpp | 1 - unittests/terrainlib/grid.cpp | 58 ---- 20 files changed, 61 insertions(+), 911 deletions(-) delete mode 100644 NOTICE delete mode 100644 src/terrainlib/ctb/CTBException.hpp delete mode 100644 src/terrainlib/ctb/GlobalGeodetic.cpp delete mode 100644 src/terrainlib/ctb/GlobalGeodetic.hpp delete mode 100644 src/terrainlib/ctb/GlobalMercator.cpp delete mode 100644 src/terrainlib/ctb/GlobalMercator.hpp delete mode 100644 src/terrainlib/ctb/Grid.hpp delete mode 100644 src/terrainlib/ctb/NOTICE delete mode 100644 src/terrainlib/ctb/TileCoordinate.hpp delete mode 100644 src/terrainlib/ctb/types.hpp delete mode 100644 unittests/terrainlib/grid.cpp 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/src/sf_builder/main.cpp b/src/sf_builder/main.cpp index dd3e7913..3815602d 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -38,10 +38,6 @@ #include "octree/Space.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); if (!result) { @@ -91,23 +87,16 @@ 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."); + 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(srs::webmercator_tile_bounds(target_tile)); } radix::geometry::Aabb3d parse_bounds_from_node(const std::vector &data, const OGRSpatialReference &srs) { diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index a1ca3157..a8a075d5 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -45,8 +45,6 @@ #include #include "Dataset.h" -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" #include "srs.h" #include "ProgressIndicator.h" @@ -84,7 +82,6 @@ std::optional build_patch( 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; @@ -128,7 +125,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? diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 4f8f7302..9a237d5d 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -19,10 +19,14 @@ #pragma once +#include +#include #include #include #include +#include #include +#include #include #include #include @@ -33,8 +37,6 @@ #include #include -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" #include "geometry/geometry.h" #include "numeric/glm_math.h" #include "numeric/int_math.h" @@ -57,14 +59,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 = srs::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. @@ -77,7 +108,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, srs::webmercator_tile_bounds(root_tile), target_bounds) + 1; } // A list of tiles determined to be relevant for the target bounds. @@ -90,7 +121,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 = srs::webmercator_tile_bounds(tile); if (!radix::geometry::intersect(target_bounds, tile_bounds)) { return true; } @@ -370,8 +401,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. @@ -386,7 +415,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 = srs::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); @@ -463,8 +492,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. @@ -485,10 +512,10 @@ inline std::optional assemble_texture_from_tiles( } // The texture is linear in the srs of the tiles, the uvs are linear in the target srs. - ASSERT(target_srs.IsSame(&grid.getSRS())); + ASSERT(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 = grid.findSmallestEncompassingTile(encompassing_bounds).value(); + 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()) { @@ -500,9 +527,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 + // srs::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. @@ -512,7 +539,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. @@ -522,6 +549,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/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index af1b1b52..a7bda4e6 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -65,13 +65,6 @@ 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 diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/Dataset.cpp index 200bed00..b2cafe41 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/Dataset.cpp @@ -30,7 +30,6 @@ #include #include -#include "ctb/Grid.hpp" #include "init.h" #include "log.h" #include "srs.h" @@ -221,11 +220,11 @@ Expected 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()); + return unsigned(m_gdal_dataset->GetRasterYSize()); } unsigned Dataset::n_bands() const { diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 02ab015e..69955a12 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -37,7 +37,6 @@ #include "Dataset.h" #include #include -#include "ctb/types.hpp" #include "log.h" namespace { diff --git a/src/terrainlib/ctb/CTBException.hpp b/src/terrainlib/ctb/CTBException.hpp deleted file mode 100644 index bcd7e7f1..00000000 --- a/src/terrainlib/ctb/CTBException.hpp +++ /dev/null @@ -1,42 +0,0 @@ -#ifndef CTBEXCEPTION_HPP -#define CTBEXCEPTION_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright (C) 2022 Adam Celarek-Litofcenko - * - * 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 0ca8010a..00000000 --- a/src/terrainlib/ctb/GlobalGeodetic.cpp +++ /dev/null @@ -1,36 +0,0 @@ -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright (C) 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. - *******************************************************************************/ - -/** - * @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 ef1d8180..00000000 --- a/src/terrainlib/ctb/GlobalGeodetic.hpp +++ /dev/null @@ -1,59 +0,0 @@ -#ifndef GLOBALGEODETIC_HPP -#define GLOBALGEODETIC_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright (C) 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. - *******************************************************************************/ - -/** - * @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 a6166cf3..00000000 --- a/src/terrainlib/ctb/GlobalMercator.cpp +++ /dev/null @@ -1,43 +0,0 @@ -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright (C) 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. - *******************************************************************************/ - -/** - * @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 219aacde..00000000 --- a/src/terrainlib/ctb/GlobalMercator.hpp +++ /dev/null @@ -1,66 +0,0 @@ -#ifndef GLOBALMERCATOR_HPP -#define GLOBALMERCATOR_HPP - -/******************************************************************************* - * Copyright 2014 GeoData - * Copyright (C) 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. - *******************************************************************************/ - -/** - * @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 0129f96a..00000000 --- a/src/terrainlib/ctb/Grid.hpp +++ /dev/null @@ -1,342 +0,0 @@ -#ifndef CTBGRID_HPP -#define CTBGRID_HPP - -/******************************************************************************* - * 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. - *******************************************************************************/ - -/** - * @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 7349b3e9..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-Litofcenko - * - * 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 8dad025a..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-Litofcenko - * - * 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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 3e1ffc7c..bbcea658 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -45,7 +45,6 @@ add_executable(unittests_terrainlib terrainlib/fixed_vector.cpp terrainlib/geometry_frames.cpp terrainlib/glm_serialization.cpp - terrainlib/grid.cpp terrainlib/hash_utils.cpp terrainlib/hybrid_index_pair_map.cpp terrainlib/hybrid_vector.cpp diff --git a/unittests/sf_builder/texture.cpp b/unittests/sf_builder/texture.cpp index b798ba54..246852fd 100644 --- a/unittests/sf_builder/texture.cpp +++ b/unittests/sf_builder/texture.cpp @@ -25,17 +25,14 @@ #include "../catch2_helpers.h" #include "Dataset.h" -#include "ctb/GlobalMercator.hpp" -#include "ctb/Grid.hpp" #include "srs.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 = srs::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), + srs::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), + srs::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), + srs::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), + srs::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), + srs::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), + srs::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 = srs::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/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index 839de268..3b19eabe 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -33,7 +33,6 @@ #include #include "Dataset.h" #include "DatasetReader.h" -#include "ctb/types.hpp" #include "init.h" #include "io/image.h" #include "srs.h" diff --git a/unittests/terrainlib/grid.cpp b/unittests/terrainlib/grid.cpp deleted file mode 100644 index 2ef0f16e..00000000 --- a/unittests/terrainlib/grid.cpp +++ /dev/null @@ -1,58 +0,0 @@ -/***************************************************************************** - * 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 "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)); - } -} From d13421f1c5be2d2c9db561f32274ff571b878e3e Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:24:15 +0200 Subject: [PATCH 54/83] refactor of dataset Reader part one --- src/terrainlib/DatasetReader.cpp | 339 +++++++++---- src/terrainlib/DatasetReader.h | 112 +++-- src/terrainlib/srs.h | 2 + unittests/terrainlib/dataset_reading.cpp | 589 ++++++++++++++++++++++- 4 files changed, 882 insertions(+), 160 deletions(-) diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index 69955a12..f3f8c3a6 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -20,9 +20,13 @@ #include "Dataset.h" #include "srs.h" +#include +#include #include +#include #include #include +#include #include #include #include @@ -31,11 +35,12 @@ #include #include #include +#include +#include #include +#include #include -#include "Dataset.h" -#include #include #include "log.h" @@ -49,142 +54,269 @@ std::string toWkt(const OGRSpatialReference& srs) return wkt_string; } -OGRSpatialReference target_srs(DatasetReader::Projection projection) +OGRSpatialReference target_srs(srs::Projection projection) { switch (projection) { - case DatasetReader::Projection::WebMercator: + case srs::Projection::WebMercator: return srs::webmercator(); - case DatasetReader::Projection::Geographic: + case srs::Projection::Geographic: return srs::wgs84(); } PANIC("unsupported projection", static_cast(projection)); } -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 GdalWarpOptionsPtr = std::unique_ptr; +using ApproxTransformer = std::unique_ptr; -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 std::runtime_error("GDALCreateGenImgProjTransformer2 failed."); - } +// Maximum approximation error in source pixels. +constexpr double approximation_error = 0.125; - const auto adfGeoTransform = computeGeoTransform(bounds, width, height); - GDALSetGenImgProjTransformerDstGeoTransform(args.get(), adfGeoTransform.data()); +template +constexpr GDALDataType source_type = std::is_same_v ? GDT_Float32 : GDT_Byte; - return args; -} +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); -using GdalWarpOptionsPtr = std::unique_ptr; -using WarpOptionData = std::pair; +// 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; +}; -WarpOptionData makeWarpOptions(const DatasetReader& reader, Dataset* dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) +Expected make_source(GDALDataset& dataset, std::span bands) { - auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); - options->hSrcDS = dataset->gdalDataset(); - options->nBandCount = 1; - options->eResampleAlg = GDALResampleAlg::GRA_Lanczos; - 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; + 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; } - constexpr auto use_approximation = true; - if (use_approximation) { - const auto error_threshold = 0.125; - 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) }; + // 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; +} - options->pTransformerArg = make_image_transform_args(reader, dataset, bounds, width, height).release(); - options->pfnTransformer = GDALGenImgProjTransform; +// 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())); + } - return { std::move(options), GdalImageTransformArgsPtr(nullptr, &GDALDestroyGenImgProjTransformer) }; + 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); } -DatasetReader::DatasetReader(const std::shared_ptr& dataset, Projection projection, unsigned band) - : m_dataset(dataset) +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_band(band) + , 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) { - auto source = Error::throwing_unwrap(dataset->srs()); + 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); - m_target_srs_wkt = toWkt(target); - m_requires_reprojection = !source.IsSame(&target); - if (source.IsGeographic() && std::abs(source.GetAngularUnits() - std::numbers::pi / 180) < 1e-12) { + 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()); + source->SetExtension("GEOGCS", "CENTER_LONG", fmt::format("{}", (bounds->min.x + bounds->max.x) / 2).c_str()); } } - m_dataset_srs_wkt = toWkt(source); - - if (band > dataset->n_bands()) - throw std::runtime_error(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); + 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)); } -radix::Raster DatasetReader::readFrom(const std::shared_ptr& source_dataset, const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const +template +Expected::Samples> DatasetReader::read(const radix::tile::SrsBounds& bounds, const glm::uvec2 size) { - // 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()))); + 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"); + } - 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 std::runtime_error("couldn't read data"); + 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()); + } - return heights_data; + // 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; } -Expected DatasetReader::min_pixel_size(const radix::tile::SrsBounds& bounds) const +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 == Projection::WebMercator ? m_dataset->mercator_coverage() : m_dataset->geographic_coverage(); + 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"); } @@ -296,7 +428,11 @@ int transform_import(void* argument, int destination_to_source, int count, } } -Expected> DatasetReader::read_scalar( +template class DatasetReader; +template class DatasetReader; + +namespace deprecated { +Expected::Samples> read_scalar( GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const unsigned band) { if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) @@ -412,7 +548,7 @@ Expected> DatasetReader::read_scalar( if (operation.Initialize(options.get()) != CE_None || operation.ChunkAndWarpImage(0, 0, int(size.x), int(size.y)) != CE_None || coordinates.failed) { return Error::fail(Error::Code::Io, "warp RF source band: " + std::string(CPLGetLastErrorMsg())); } - Samples result { radix::Raster(size), radix::Raster(size) }; + DatasetReader::Samples result { radix::Raster(size), radix::Raster(size) }; auto* output = destination.gdalDataset(); if (output->GetRasterBand(1)->RasterIO(GF_Read, 0, 0, int(size.x), int(size.y), result.data.buffer().data(), int(size.x), int(size.y), GDT_Float32, 0, 0) != CE_None) { @@ -431,14 +567,14 @@ Expected> DatasetReader::read_scalar( return result; } -Expected> DatasetReader::read_colour( +Expected::Samples> read_colour( GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const std::array& bands) { if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) || std::size_t(size.x) > std::vector().max_size() / size.y) { return Error::fail(Error::Code::InvalidInput, "invalid RF RGB read dimensions"); } - Samples result { radix::Raster(size), radix::Raster(size, 255) }; + DatasetReader::Samples result { radix::Raster(size), radix::Raster(size, 255) }; for (unsigned channel = 0; channel < 3; ++channel) { auto samples = read_scalar(dataset, transform, bounds, size, bands[channel]); if (!samples) { @@ -466,3 +602,4 @@ Expected> DatasetReader::read_colour( } return result; } +} // namespace deprecated diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index d456b914..45bece15 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -20,67 +20,103 @@ #ifndef DATASETREADER_H #define DATASETREADER_H +#include +#include #include -#include +#include #include #include #include +#include "Error.h" #include "RasterTransform.h" +#include "srs.h" class Dataset; +class GDALDataset; +// 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: - enum class Projection { WebMercator, Geographic }; + static constexpr unsigned channel_count = std::is_same_v ? 1 : 3; - template + // 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 data; radix::Raster valid; }; - // RF import: Lanczos at base resolution, exact coordinates, declared source - // validity. RGB requires a valid filtered result in every selected channel. - static Expected> read_scalar( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, unsigned band) - { - return read_scalar(dataset, transform, bounds, glm::uvec2(side), band); - } - static Expected> read_colour( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands) - { - return read_colour(dataset, transform, bounds, glm::uvec2(side), bands); - } - - static Expected> read_scalar( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, unsigned band); - static Expected> read_colour( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); - - DatasetReader(const std::shared_ptr& dataset, Projection projection, unsigned band); - - radix::Raster read(const radix::tile::SrsBounds& bounds, unsigned width, unsigned height) const; + // 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; - Projection projection() const { return m_projection; } - 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; + 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; - Projection m_projection; - std::string m_dataset_srs_wkt; - std::string m_target_srs_wkt; - bool m_requires_reprojection; - unsigned m_band; + 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; + +// The previous RF import reader, kept until the RF builder migrates to DatasetReader. +// Lanczos at base resolution, exact coordinates, declared source validity. +// RGB requires a valid filtered result in every selected channel. +namespace deprecated { +Expected::Samples> read_scalar( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, unsigned band); +Expected::Samples> read_colour( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); + +inline Expected::Samples> read_scalar( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, unsigned band) +{ + return read_scalar(dataset, transform, bounds, glm::uvec2(side), band); +} +inline Expected::Samples> read_colour( + GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands) +{ + return read_colour(dataset, transform, bounds, glm::uvec2(side), bands); +} +} // namespace deprecated + #endif // DATASETREADER_H diff --git a/src/terrainlib/srs.h b/src/terrainlib/srs.h index 51aa3d7e..c3874830 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/srs.h @@ -186,6 +186,8 @@ 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 }; + /// Half the side length of the square Web Mercator world in metres. constexpr double webmercator_half_extent = 20037508.342789244; /// Latitude in degrees at which Web Mercator y reaches webmercator_half_extent. diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/dataset_reading.cpp index 3b19eabe..10520d9c 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/dataset_reading.cpp @@ -19,18 +19,27 @@ #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 "Dataset.h" #include "DatasetReader.h" #include "init.h" @@ -69,6 +78,119 @@ void require_projection_available(const Dataset& dataset, const OGRSpatialRefere 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 +DatasetReader make_reader( + std::shared_ptr dataset, srs::Projection projection, std::array::channel_count> bands, Pixel default_pixel) +{ + auto reader = DatasetReader::make(std::move(dataset), projection, bands, default_pixel); + INFO((reader ? "" : reader.error().to_string())); + REQUIRE(reader); + return std::move(*reader); +} + +template +typename DatasetReader::Samples read_samples(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) +{ + initialize_gdal_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 = 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(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") @@ -105,8 +227,8 @@ TEST_CASE("reading") SECTION("check for min and max heights") { const std::array test_projections = { - std::make_pair(DatasetReader::Projection::Geographic, 4326), - std::make_pair(DatasetReader::Projection::WebMercator, 3857), + std::make_pair(srs::Projection::Geographic, 4326), + std::make_pair(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] @@ -143,17 +265,19 @@ TEST_CASE("reading") const radix::tile::SrsBounds srs_bounds { srs_corners[0], srs_corners[1] }; require_projection_available(*dataset, srs); - const DatasetReader reader(dataset, test_projection, 1); + auto reader = make_reader(dataset, test_projection, { 1 }, 0.0F); REQUIRE(reader.projection() == test_projection); if (ALP_UNITTESTS_DEBUG_IMAGES) { - const auto heights = reader.read(srs_bounds, 1000, 1000); + 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(heights, debug_file).has_value()); + REQUIRE(debug_out(debug_samples.data, debug_file).has_value()); } - const auto heights = reader.read(srs_bounds, 100, 111); + 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; @@ -180,8 +304,9 @@ TEST_CASE("reading") 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.01, 0.006), + 740U, 315U, 3.2, 0.006), #if defined(ALP_UNITTESTS_EXTENDED) && ALP_UNITTESTS_EXTENDED std::make_tuple( "pizbuin1m_highres", @@ -196,8 +321,10 @@ TEST_CASE("reading") const auto ref_dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(datasets.front()))); require_projection_available(*ref_dataset, geographic_srs); - const auto ref_reader = DatasetReader(ref_dataset, DatasetReader::Projection::Geographic, 1); - const auto ref_heights = ref_reader.read(ref_bounds, render_width, render_height); + auto ref_reader = make_reader(ref_dataset, 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()); } @@ -205,8 +332,10 @@ TEST_CASE("reading") for (std::string dataset_name : datasets) { const auto dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); require_projection_available(*dataset, geographic_srs); - const auto reader = DatasetReader(dataset, DatasetReader::Projection::Geographic, 1); - const auto heights = reader.read(ref_bounds, render_width, render_height); + auto reader = make_reader(dataset, 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; @@ -235,8 +364,9 @@ TEST_CASE("reading") TEST_CASE("min pixel size") { - using Projection = DatasetReader::Projection; + using Projection = srs::Projection; const auto open = [](const char* name) { return Error::throwing_unwrap(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); @@ -251,13 +381,13 @@ TEST_CASE("min pixel size") SECTION("same srs as the dataset") { const auto mercator = open("/austria/at_100m_epsg3857.tif"); - const auto mercator_size = DatasetReader(mercator, Projection::WebMercator, 1).min_pixel_size(mercator_bounds); + 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 = DatasetReader(anisotropic, Projection::Geographic, 1).min_pixel_size(geographic_bounds); + 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)); @@ -267,7 +397,7 @@ TEST_CASE("min pixel size") { // 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 = DatasetReader(dataset, Projection::WebMercator, 1).min_pixel_size(mercator_bounds); + 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)); @@ -278,7 +408,7 @@ TEST_CASE("min pixel size") { const auto dataset = open("/austria/at_100m_epsg4326.tif"); const radix::tile::SrsBounds world { glm::dvec2(-srs::webmercator_half_extent), glm::dvec2(srs::webmercator_half_extent) }; - const auto size = DatasetReader(dataset, Projection::WebMercator, 1).min_pixel_size(world); + 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)); @@ -288,7 +418,7 @@ TEST_CASE("min pixel size") { // 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 = DatasetReader(dataset, Projection::WebMercator, 1).min_pixel_size(mercator_bounds); + const auto size = reader(dataset, Projection::WebMercator).min_pixel_size(mercator_bounds); REQUIRE(size); CHECK(size->x > 130); CHECK(size->x < 155); @@ -299,7 +429,7 @@ TEST_CASE("min pixel size") SECTION("bounds outside the coverage") { const auto dataset = open("/austria/at_100m_epsg4326.tif"); - CHECK(!DatasetReader(dataset, Projection::Geographic, 1).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); + CHECK(!reader(dataset, Projection::Geographic).min_pixel_size({ { 0.0, 0.0 }, { 1.0, 1.0 } })); } } @@ -323,12 +453,429 @@ TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") } REQUIRE(dataset->gdalDataset()->GetRasterBand(1)->RasterIO(GF_Write, 0, 0, width, height, longitudes.data(), width, height, GDT_Float32, 0, 0) == CE_None); - const DatasetReader reader(dataset, DatasetReader::Projection::WebMercator, 1); + auto reader = make_reader(dataset, srs::Projection::WebMercator, { 1 }, 0.0F); const double degree = srs::webmercator_half_extent / 180; for (const double longitude : { -90.0, 90.0 }) { - const auto values = reader.read({ { (longitude - 1) * degree, -degree }, { (longitude + 1) * degree, degree } }, 4, 4); - for (const float value : values.buffer()) { + 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 = srs::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), 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), 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 = srs::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 = srs::Projection; + + // No source type conversion. + for (const auto type : { GDT_Byte, GDT_Int16, GDT_Float64 }) { + require_code(DatasetReader::make(raster(type), Projection::WebMercator, { 1 }, 0.0F), Error::Code::Unsupported); + } + for (const auto type : { GDT_Float32, GDT_UInt16 }) { + require_code(DatasetReader::make(raster(type), Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)), Error::Code::Unsupported); + } + for (const unsigned band : { 0u, 4u }) { + require_code(DatasetReader::make(raster(GDT_Float32), Projection::WebMercator, { band }, 0.0F), Error::Code::InvalidInput); + require_code(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(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(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]") +{ + initialize_gdal_once(); + auto reader = make_reader(std::make_shared(new FailingDataset()), 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 = srs::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, 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 = srs::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, 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 = srs::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, 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), 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, 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 = srs::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, 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, srs::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 = srs::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, 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 = srs::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, 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 = srs::transform_points(srs::wgs84(), 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, 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(Dataset::open_shared_raster(vrt_path)), 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, 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, srs::Projection::WebMercator, { 1 }, 0.0F); + const double width = 2 * srs::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 = srs::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))), 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, srs::Projection::WebMercator, { 1 }, 0.0F); + const auto samples = read_samples(reader, srs::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 }), 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)); +} From 9499a0907afdebf4c36f35e7f6a3cd6a54699aed Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:57:56 +0200 Subject: [PATCH 55/83] move rf builder to DatasetReader --- src/terrainlib/CMakeLists.txt | 1 - src/terrainlib/DatasetReader.cpp | 231 ----------------------------- src/terrainlib/DatasetReader.h | 23 --- src/terrainlib/RasterTransform.cpp | 173 --------------------- src/terrainlib/RasterTransform.h | 57 ------- 5 files changed, 485 deletions(-) delete mode 100644 src/terrainlib/RasterTransform.cpp delete mode 100644 src/terrainlib/RasterTransform.h diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index a7bda4e6..2385e41d 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -20,7 +20,6 @@ add_library(terrainlib Error.h Error.cpp DatasetReader.h DatasetReader.cpp - RasterTransform.h RasterTransform.cpp vector_mask.h Dataset.h Dataset.cpp ImageKey.h diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/DatasetReader.cpp index f3f8c3a6..3e23330b 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/DatasetReader.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -371,235 +370,5 @@ Expected DatasetReader::min_pixel_size(const radix::tile::Srs return result; } -namespace { -struct WarpCoordinates { - const RasterTransform* source; - radix::tile::SrsBounds bounds; - glm::uvec2 size; - bool failed = false; - unsigned column_padding = 0; - double source_columns_per_metre = 0; - double centre_source_column = 0; -}; - -int transform_import(void* argument, int destination_to_source, int count, - double* x, double* y, double*, int* success) -{ - auto& coordinates = *static_cast(argument); - const glm::dvec2 spacing { coordinates.bounds.width() / coordinates.size.x, coordinates.bounds.height() / coordinates.size.y }; - for (int i = 0; i < count; ++i) { - const glm::dvec2 destination { coordinates.bounds.min.x + x[i] * spacing.x, coordinates.bounds.max.y - y[i] * spacing.y }; - glm::dvec2 canonical = destination; - const double world_period = 2 * RasterTransform::world_half_extent; - canonical.x -= world_period * std::floor((canonical.x + RasterTransform::world_half_extent) / world_period); - const bool in_coverage = std::ranges::any_of(coordinates.source->bounds(), [&](const auto& bounds) { - return canonical.x >= bounds.min.x && canonical.x <= bounds.max.x - && canonical.y >= bounds.min.y && canonical.y <= bounds.max.y; - }); - const auto point = destination_to_source - ? coordinates.source->source_pixel(destination) - : coordinates.source->mercator({ x[i] - coordinates.column_padding, y[i] }); - success[i] = bool(point); - if (!point) { - // Successful out-of-coverage probes are needed for GDAL's source - // window estimation. Only failed excluded probes are nonfatal. - coordinates.failed = coordinates.failed || !destination_to_source || in_coverage; - x[i] = y[i] = HUGE_VAL; - } else if (destination_to_source) { - const double centre = (coordinates.bounds.min.x + coordinates.bounds.max.x) / 2; - x[i] = point->x; - if (coordinates.source_columns_per_metre != 0) { - const double expected = coordinates.centre_source_column + (destination.x - centre) * coordinates.source_columns_per_metre; - const double columns = std::abs(coordinates.source_columns_per_metre) * world_period; - x[i] += columns * std::round((expected - x[i]) / columns); - } - x[i] += coordinates.column_padding; - y[i] = point->y; - } else { - // Choose the equivalent Mercator branch nearest this output window. - const double period = 2 * RasterTransform::world_half_extent; - const double centre = (coordinates.bounds.min.x + coordinates.bounds.max.x) / 2; - const double world_x = point->x + period * std::round((centre - point->x) / period); - x[i] = (world_x - coordinates.bounds.min.x) / spacing.x; - y[i] = (coordinates.bounds.max.y - point->y) / spacing.y; - } - } - return TRUE; -} -} - template class DatasetReader; template class DatasetReader; - -namespace deprecated { -Expected::Samples> read_scalar( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const unsigned band) -{ - if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) - || std::size_t(size.x) > std::vector().max_size() / size.y || band == 0 || band > unsigned(dataset.GetRasterCount())) { - return Error::fail(Error::Code::InvalidInput, "invalid RF read dimensions or source band"); - } - auto* source_band = dataset.GetRasterBand(int(band)); - if (GDALDataTypeIsComplex(source_band->GetRasterDataType())) { - return Error::fail(Error::Code::Unsupported, "complex raster bands cannot be imported as scalar or RGB data"); - } - // A single-band VRT exposes that band's own mask as alpha, including - // per-band masks which the low-level warper does not automatically apply. - const auto& affine = transform.affine(); - const double period = transform.reference().IsGeographic() - ? 2 * std::numbers::pi / transform.reference().GetAngularUnits() - : 2 * RasterTransform::world_half_extent; - const bool periodic = affine[2] == 0 && affine[4] == 0 - && (transform.reference().IsGeographic() || (transform.reference().GetAuthorityCode(nullptr) && std::string_view(transform.reference().GetAuthorityCode(nullptr)) == "3857")) - && std::abs(std::abs(affine[1]) * dataset.GetRasterXSize() - period) < period * 1e-10; - double columns_per_metre = 0; - double centre_column = 0; - unsigned padding = 0; - if (periodic) { - // Keep global source addressing continuous across its longitude seam. - // The VRT repeats just the required edge strips, including filter support. - const double centre = (bounds.min.x + bounds.max.x) / 2; - auto pixel = transform.source_pixel({ centre, (bounds.min.y + bounds.max.y) / 2 }); - if (!pixel) { - return Error::propagate(std::move(pixel)); - } - centre_column = pixel->x; - columns_per_metre = std::copysign(double(dataset.GetRasterXSize()) / (2 * RasterTransform::world_half_extent), affine[1]); - const double half_columns = std::abs(bounds.width() * columns_per_metre) / 2; - const double filter_support = 3 * (std::max)(1., 2 * half_columns / size.x); - const double required = std::ceil( - (std::max)({ 8., half_columns - centre_column + filter_support, centre_column + half_columns - dataset.GetRasterXSize() + filter_support })); - if (!std::isfinite(required) || required > ((std::numeric_limits::max)() - dataset.GetRasterXSize()) / 2) { - return Error::fail(Error::Code::InvalidInput, "periodic RF source view exceeds GDAL dimensions"); - } - padding = unsigned(required); - } - VRTDataset source(dataset.GetRasterXSize() + int(2 * padding), dataset.GetRasterYSize()); - if (source.AddBand(source_band->GetRasterDataType(), nullptr) != CE_None - || source.AddBand(GDT_Byte, nullptr) != CE_None) { - return Error::fail(Error::Code::Io, "create RF source-band view"); - } - auto* values = static_cast(source.GetRasterBand(1)); - auto* validity = static_cast(source.GetRasterBand(2)); - const auto attach = [&](VRTSourcedRasterBand* destination_band, GDALRasterBand* input_band) { - const int width = dataset.GetRasterXSize(); - const int height = dataset.GetRasterYSize(); - if (destination_band->AddSimpleSource(input_band, 0, 0, width, height, padding, 0, width, height) != CE_None) { - return false; - } - for (const auto& [begin, end] : { std::pair { 0, int(padding) }, std::pair { width + int(padding), width + int(2 * padding) } }) { - for (int destination = begin; destination < end;) { - const int source_column = int(((std::int64_t(destination) - padding) % width + width) % width); - const int count = (std::min)(end - destination, width - source_column); - if (destination_band->AddSimpleSource(input_band, source_column, 0, count, height, destination, 0, count, height) != CE_None) { - return false; - } - destination += count; - } - } - return true; - }; - if (!attach(values, source_band) || !attach(validity, source_band->GetMaskBand())) { - return Error::fail(Error::Code::Io, "attach source values and validity to RF view"); - } - auto* driver = GetGDALDriverManager()->GetDriverByName("MEM"); - if (!driver) { - return Error::fail(Error::Code::Unsupported, "GDAL MEM driver is required"); - } - Dataset destination([&] { - // GDAL 3.10 rewrites the shared driver's create callback on every call. - // Only creation needs serialization; each resulting dataset is private. - static std::mutex creation_mutex; - const std::lock_guard lock(creation_mutex); - return driver->Create("", int(size.x), int(size.y), 2, GDT_Float32, nullptr); - }()); - if (!destination.gdalDataset()) { - return Error::fail(Error::Code::Io, "create RF warp destination"); - } - auto options = GdalWarpOptionsPtr(GDALCreateWarpOptions(), &GDALDestroyWarpOptions); - options->hSrcDS = &source; - options->hDstDS = destination.gdalDataset(); - options->nBandCount = 1; - options->panSrcBands = static_cast(CPLMalloc(sizeof(int))); - options->panDstBands = static_cast(CPLMalloc(sizeof(int))); - options->panSrcBands[0] = options->panDstBands[0] = 1; - options->nSrcAlphaBand = options->nDstAlphaBand = 2; - options->eResampleAlg = GRA_Lanczos; - options->eWorkingDataType = GDT_Float32; - options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "INIT_DEST", "0"); - // Our borrowed transformer context is synchronous and cannot be cloned by - // GDAL workers. Do not inherit GDAL_NUM_THREADS from the environment. - 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"); - // Sample the interior too: a rotated source may lie wholly inside a window. - options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "SAMPLE_GRID", "YES"); - options->papszWarpOptions = CSLSetNameValue(options->papszWarpOptions, "SAMPLE_STEPS", "21"); - int has_nodata = FALSE; - const double nodata = source_band->GetNoDataValue(&has_nodata); - if (has_nodata) { - options->padfSrcNoDataReal = static_cast(CPLMalloc(sizeof(double))); - options->padfSrcNoDataReal[0] = nodata; - } - WarpCoordinates coordinates { &transform, bounds, size, false, padding, columns_per_metre, centre_column }; - options->pfnTransformer = transform_import; - options->pTransformerArg = &coordinates; - GDALWarpOperation operation; - if (operation.Initialize(options.get()) != CE_None || operation.ChunkAndWarpImage(0, 0, int(size.x), int(size.y)) != CE_None || coordinates.failed) { - return Error::fail(Error::Code::Io, "warp RF source band: " + std::string(CPLGetLastErrorMsg())); - } - DatasetReader::Samples result { radix::Raster(size), radix::Raster(size) }; - auto* output = destination.gdalDataset(); - if (output->GetRasterBand(1)->RasterIO(GF_Read, 0, 0, int(size.x), int(size.y), result.data.buffer().data(), int(size.x), int(size.y), GDT_Float32, 0, 0) - != CE_None) { - return Error::fail(Error::Code::Io, "read RF warped values and validity"); - } - std::vector alpha(size.x); - for (unsigned row = 0; row < size.y; ++row) { - if (output->GetRasterBand(2)->RasterIO(GF_Read, 0, int(row), int(size.x), 1, alpha.data(), int(size.x), 1, GDT_Float32, 0, 0) != CE_None) { - return Error::fail(Error::Code::Io, "read RF destination validity"); - } - for (unsigned column = 0; column < size.x; ++column) { - result.valid.buffer()[std::size_t(row) * size.x + column] - = alpha[column] > 0 && std::isfinite(result.data.buffer()[std::size_t(row) * size.x + column]); - } - } - return result; -} - -Expected::Samples> read_colour( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, const glm::uvec2 size, const std::array& bands) -{ - if (size.x == 0 || size.y == 0 || size.x > unsigned((std::numeric_limits::max)()) || size.y > unsigned((std::numeric_limits::max)()) - || std::size_t(size.x) > std::vector().max_size() / size.y) { - return Error::fail(Error::Code::InvalidInput, "invalid RF RGB read dimensions"); - } - DatasetReader::Samples result { radix::Raster(size), radix::Raster(size, 255) }; - for (unsigned channel = 0; channel < 3; ++channel) { - auto samples = read_scalar(dataset, transform, bounds, size, bands[channel]); - if (!samples) { - return Error::propagate(std::move(samples), "read RGB channel " + std::to_string(channel)); - } - for (std::size_t i = 0; i < samples->data.buffer().size(); ++i) { - if (!samples->valid.buffer()[i]) { - samples->data.buffer()[i] = 0; - } - } - // Convert one row at a time to keep GDAL's signed word count in range. - for (unsigned row = 0; row < size.y; ++row) { - const std::size_t offset = std::size_t(row) * size.x; - GDALCopyWords(samples->data.buffer().data() + offset, - GDT_Float32, - sizeof(float), - &result.data.buffer()[offset][channel], - GDT_Byte, - sizeof(glm::u8vec3), - int(size.x)); - } - for (std::size_t pixel = 0; pixel < result.data.buffer().size(); ++pixel) { - result.valid.buffer()[pixel] = result.valid.buffer()[pixel] && samples->valid.buffer()[pixel]; - } - } - return result; -} -} // namespace deprecated diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/DatasetReader.h index 45bece15..3d444a61 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/DatasetReader.h @@ -29,11 +29,9 @@ #include #include #include "Error.h" -#include "RasterTransform.h" #include "srs.h" class Dataset; -class GDALDataset; // Reads selected dataset bands into a target projection, using Lanczos at base // resolution and GDAL's approximate transformer. Validity comes from the @@ -98,25 +96,4 @@ class DatasetReader { extern template class DatasetReader; extern template class DatasetReader; -// The previous RF import reader, kept until the RF builder migrates to DatasetReader. -// Lanczos at base resolution, exact coordinates, declared source validity. -// RGB requires a valid filtered result in every selected channel. -namespace deprecated { -Expected::Samples> read_scalar( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, unsigned band); -Expected::Samples> read_colour( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, glm::uvec2 size, const std::array& bands); - -inline Expected::Samples> read_scalar( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, unsigned band) -{ - return read_scalar(dataset, transform, bounds, glm::uvec2(side), band); -} -inline Expected::Samples> read_colour( - GDALDataset& dataset, const RasterTransform& transform, const radix::tile::SrsBounds& bounds, unsigned side, const std::array& bands) -{ - return read_colour(dataset, transform, bounds, glm::uvec2(side), bands); -} -} // namespace deprecated - #endif // DATASETREADER_H diff --git a/src/terrainlib/RasterTransform.cpp b/src/terrainlib/RasterTransform.cpp deleted file mode 100644 index 67c879de..00000000 --- a/src/terrainlib/RasterTransform.cpp +++ /dev/null @@ -1,173 +0,0 @@ -/***************************************************************************** - * 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 "RasterTransform.h" - -#include -#include -#include -#include "srs.h" - -namespace { -OGRSpatialReference mercator_reference() -{ - OGRSpatialReference result; - result.importFromEPSG(3857); - result.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - return result; -} -} - -Expected> RasterTransform::coverage( - const OGRSpatialReference& reference, const Bounds& bounds) -{ - const auto mercator = mercator_reference(); - if (reference.IsSame(&mercator) && bounds.min.x >= -world_half_extent && bounds.max.x <= world_half_extent) { - Bounds clipped { glm::max(bounds.min, glm::dvec2(-world_half_extent)), - glm::min(bounds.max, glm::dvec2(world_half_extent)) }; - if (clipped.min.x >= clipped.max.x || clipped.min.y >= clipped.max.y) { return std::vector {}; } - return std::vector { clipped }; - } - auto geographic = srs::wgs84(); - std::unique_ptr transform(OGRCreateCoordinateTransformation(&reference, &geographic)); - if (!transform) { - return Error::fail(Error::Code::InvalidInput, "create coverage transformation"); - } - Bounds longitude_latitude; - constexpr int boundary_samples = 257; - const bool reprojected = !reference.IsSame(&geographic); - if (reference.IsSame(&geographic)) { - longitude_latitude = bounds; - } else if (!transform->TransformBounds(bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y, - &longitude_latitude.min.x, &longitude_latitude.min.y, - &longitude_latitude.max.x, &longitude_latitude.max.y, boundary_samples)) { - return Error::fail(Error::Code::InvalidInput, "transform source coverage bounds"); - } - const double south = (std::max)(-latitude_limit, longitude_latitude.min.y); - const double north = (std::min)(latitude_limit, longitude_latitude.max.y); - if (south >= north) { - return std::vector {}; - } - double west = longitude_latitude.min.x; - double east = longitude_latitude.max.x; - if (!std::isfinite(west) || !std::isfinite(east) || !std::isfinite(south) || !std::isfinite(north)) { - return Error::fail(Error::Code::InvalidInput, "nonfinite source coverage bounds"); - } - if (east < west) { - east += 360; - } - // Densified transformation samples are not exact extrema of curved edges. - // Retain a full sampling interval as a guard band rather than pruning at - // the sampled extremum. Final pixel acceptance uses original-CRS geometry. - const double longitude_guard = reprojected ? (std::max)(1e-6, (east - west) / (boundary_samples + 1)) : 0; - const double latitude_guard = reprojected ? (std::max)(1e-6, - (longitude_latitude.max.y - longitude_latitude.min.y) / (boundary_samples + 1)) : 0; - west -= longitude_guard; - east += longitude_guard; - if (east - west >= 360) { - west = -180; - east = 180; - } else { - const double shift = 360 * std::floor((west + 180) / 360); - west -= shift; - east -= shift; - } - const auto latitude_y = [](double latitude) { - return std::clamp(world_half_extent / std::numbers::pi - * std::asinh(std::tan(latitude * std::numbers::pi / 180)), - -world_half_extent, world_half_extent); - }; - const auto rectangle = [&](double left, double right) -> Bounds { - return { { left * world_half_extent / 180, latitude_y((std::max)(-latitude_limit, south - latitude_guard)) }, - { right * world_half_extent / 180, latitude_y((std::min)(latitude_limit, north + latitude_guard)) } }; - }; - if (east > 180) { - return std::vector { rectangle(west, 180), rectangle(-180, east - 360) }; - } - return std::vector { rectangle(west, east) }; -} - -Expected RasterTransform::create(GDALDataset& dataset) -{ - RasterTransform result; - const auto* reference = dataset.GetSpatialRef(); - if (!reference || dataset.GetGeoTransform(result.m_affine.data()) != CE_None - || dataset.GetGCPCount() != 0 || CSLCount(dataset.GetMetadata("RPC")) != 0 - || CSLCount(dataset.GetMetadata("GEOLOCATION")) != 0) { - return Error::fail(Error::Code::Unsupported, "RF inputs require an explicit CRS and affine geotransform; GCP/RPC/geolocation inputs are unsupported"); - } - if (dataset.GetRasterXSize() <= 0 || dataset.GetRasterYSize() <= 0 - || !std::ranges::all_of(result.m_affine, [](double value) { return std::isfinite(value); }) - || !GDALInvGeoTransform(result.m_affine.data(), result.m_inverse.data())) { - return Error::fail(Error::Code::InvalidInput, "invalid raster dimensions or singular affine geotransform"); - } - result.m_reference = *reference; - result.m_reference.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); - const auto mercator = mercator_reference(); - result.m_to_source.reset(OGRCreateCoordinateTransformation(&mercator, &result.m_reference)); - result.m_to_mercator.reset(OGRCreateCoordinateTransformation(&result.m_reference, &mercator)); - if (!result.m_to_source || !result.m_to_mercator) { - return Error::fail(Error::Code::InvalidInput, "create affine raster CRS transformations"); - } - if (result.m_reference.IsGeographic()) { - result.m_period = 2 * std::numbers::pi / result.m_reference.GetAngularUnits(); - } else if (result.m_reference.IsSame(&mercator)) { - result.m_period = 2 * world_half_extent; - } - Bounds bounds { { INFINITY, INFINITY }, { -INFINITY, -INFINITY } }; - for (double column : { 0., double(dataset.GetRasterXSize()) }) { - for (double row : { 0., double(dataset.GetRasterYSize()) }) { - glm::dvec2 point; - GDALApplyGeoTransform(result.m_affine.data(), column, row, &point.x, &point.y); - bounds.min = glm::min(bounds.min, point); - bounds.max = glm::max(bounds.max, point); - } - } - result.m_centre_x = (bounds.min.x + bounds.max.x) / 2; - auto coverage_bounds = coverage(result.m_reference, bounds); - if (!coverage_bounds) { - return Error::propagate(std::move(coverage_bounds)); - } - result.m_bounds = std::move(*coverage_bounds); - return result; -} - -Expected RasterTransform::source_pixel(glm::dvec2 point, bool wrap_longitude) const -{ - if (!m_to_source->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { - return Error::fail(Error::Code::InvalidInput, "transform RF position into source CRS"); - } - // Keep a seam-crossing affine grid continuous in its own source coordinates. - // This is source addressing only; RF coordinates remain canonical. - if (wrap_longitude && m_period > 0) { - point.x = m_centre_x + std::remainder(point.x - m_centre_x, m_period); - } - glm::dvec2 pixel; - GDALApplyGeoTransform(const_cast(m_inverse.data()), point.x, point.y, &pixel.x, &pixel.y); - return pixel; -} - -Expected RasterTransform::mercator(glm::dvec2 pixel) const -{ - glm::dvec2 point; - GDALApplyGeoTransform(const_cast(m_affine.data()), pixel.x, pixel.y, &point.x, &point.y); - if (!m_to_mercator->Transform(1, &point.x, &point.y) || !std::isfinite(point.x) || !std::isfinite(point.y)) { - return Error::fail(Error::Code::InvalidInput, "transform source pixel into RF CRS"); - } - return point; -} diff --git a/src/terrainlib/RasterTransform.h b/src/terrainlib/RasterTransform.h deleted file mode 100644 index d0d61b7c..00000000 --- a/src/terrainlib/RasterTransform.h +++ /dev/null @@ -1,57 +0,0 @@ -/***************************************************************************** - * 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 "srs.h" - -// Exact affine raster coordinates, independent of delivery-tile dimensions. -class RasterTransform { -public: - static constexpr double world_half_extent = srs::webmercator_half_extent; - static constexpr double latitude_limit = srs::webmercator_latitude_limit; - using Bounds = radix::tile::SrsBounds; - - static Expected create(GDALDataset& dataset); - static Expected> coverage(const OGRSpatialReference& reference, const Bounds& bounds); - - // Sampling estimates can omit source-branch wrapping to keep derivatives - // continuous at a global raster's longitude seam. - Expected source_pixel(glm::dvec2 mercator, bool wrap_longitude = true) const; - Expected mercator(glm::dvec2 source_pixel) const; - const std::vector& bounds() const { return m_bounds; } - const OGRSpatialReference& reference() const { return m_reference; } - const std::array& affine() const { return m_affine; } - -private: - OGRSpatialReference m_reference; - std::array m_affine {}; - std::array m_inverse {}; - std::unique_ptr m_to_source; - std::unique_ptr m_to_mercator; - std::vector m_bounds; - double m_period = 0; - double m_centre_x = 0; -}; From 4f508d373d9ea913340e9846c7d976a403cc4e8d Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:49:45 +0200 Subject: [PATCH 56/83] move a bunch of stuff to gis:: (clean up) --- src/sf_builder/main.cpp | 23 +-- src/sf_builder/mesh_builder.cpp | 24 +-- src/sf_builder/mesh_builder.h | 6 +- src/sf_builder/raw_dataset_reader.h | 2 +- src/sf_builder/terrainbuilder.cpp | 26 +-- src/sf_builder/terrainbuilder.h | 8 +- src/sf_builder/texture_assembler.h | 15 +- src/sf_merger/mask.h | 48 +++--- src/terrainlib/CMakeLists.txt | 14 +- src/terrainlib/{ => gis}/Dataset.cpp | 12 +- src/terrainlib/{ => gis}/Dataset.h | 4 + src/terrainlib/{ => gis}/DatasetReader.cpp | 10 +- src/terrainlib/{ => gis}/DatasetReader.h | 6 +- src/terrainlib/{init.cpp => gis/gdal.cpp} | 12 +- src/terrainlib/{init.h => gis/gdal.h} | 6 +- src/terrainlib/{ => gis}/srs.cpp | 27 ++-- src/terrainlib/{ => gis}/srs.h | 11 +- src/terrainlib/{ => gis}/srs/detail.cpp | 11 +- src/terrainlib/{ => gis}/srs/detail.h | 4 +- src/terrainlib/{ => gis}/vector_mask.h | 8 +- src/terrainlib/gis/webmercator.cpp | 33 ++++ src/terrainlib/gis/webmercator.h | 32 ++++ unittests/CMakeLists.txt | 8 +- unittests/sf_builder/coverage.cpp | 30 ++-- unittests/sf_builder/mesh.cpp | 36 ++--- unittests/sf_builder/texture.cpp | 20 +-- unittests/sf_merger/mask.cpp | 2 +- .../{dataset.cpp => gis_dataset.cpp} | 24 +-- ...et_reading.cpp => gis_dataset_reading.cpp} | 149 +++++++++--------- .../{srs_bounds.cpp => gis_srs_bounds.cpp} | 26 +-- ...{srs_coverage.cpp => gis_srs_coverage.cpp} | 91 +++++------ 31 files changed, 407 insertions(+), 321 deletions(-) rename src/terrainlib/{ => gis}/Dataset.cpp (98%) rename src/terrainlib/{ => gis}/Dataset.h (98%) rename src/terrainlib/{ => gis}/DatasetReader.cpp (99%) rename src/terrainlib/{ => gis}/DatasetReader.h (98%) rename src/terrainlib/{init.cpp => gis/gdal.cpp} (94%) rename src/terrainlib/{init.h => gis/gdal.h} (92%) rename src/terrainlib/{ => gis}/srs.cpp (95%) rename src/terrainlib/{ => gis}/srs.h (95%) rename src/terrainlib/{ => gis}/srs/detail.cpp (97%) rename src/terrainlib/{ => gis}/srs/detail.h (88%) rename src/terrainlib/{ => gis}/vector_mask.h (98%) create mode 100644 src/terrainlib/gis/webmercator.cpp create mode 100644 src/terrainlib/gis/webmercator.h rename unittests/terrainlib/{dataset.cpp => gis_dataset.cpp} (92%) rename unittests/terrainlib/{dataset_reading.cpp => gis_dataset_reading.cpp} (84%) rename unittests/terrainlib/{srs_bounds.cpp => gis_srs_bounds.cpp} (82%) rename unittests/terrainlib/{srs_coverage.cpp => gis_srs_coverage.cpp} (68%) diff --git a/src/sf_builder/main.cpp b/src/sf_builder/main.cpp index 3815602d..9da7f7ba 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -31,15 +31,16 @@ #include #include -#include "Dataset.h" +#include "gis/Dataset.h" #include "log.h" -#include "srs.h" +#include "gis/srs.h" +#include "gis/webmercator.h" #include "terrainbuilder.h" #include "octree/Space.h" #include "tile_provider.h" 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); @@ -87,7 +88,7 @@ radix::geometry::Aabb3d parse_bounds_from_tile( const std::vector &data, const OGRSpatialReference &srs) { - const OGRSpatialReference &webmercator_srs = srs::webmercator(); + const OGRSpatialReference &webmercator_srs = gis::srs::webmercator(); if (!srs.IsSame(&webmercator_srs)) { LOG_ERROR_AND_EXIT("Only WebMercator (EPSG:3857) supported for --tile."); } @@ -96,12 +97,12 @@ radix::geometry::Aabb3d parse_bounds_from_tile( const glm::uvec2 tile_coords(data[1], data[2]); const radix::tile::Id target_tile(zoom_level, tile_coords); - return extend_bounds_to_3d(srs::webmercator_tile_bounds(target_tile)); + 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."); } @@ -145,7 +146,7 @@ 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(); @@ -165,7 +166,7 @@ void log_dataset_overview(const Dataset &dataset) { } const auto bounds = *boundsResult; - const auto boundsEcefResult = srs::ecef_coverage(datasetSrs, 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; @@ -182,7 +183,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); @@ -297,10 +298,10 @@ int run(std::span args) { 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()) { diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 6268fa75..76b04110 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -31,14 +31,14 @@ #include #include -#include "Dataset.h" +#include "gis/Dataset.h" #include "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" @@ -138,11 +138,11 @@ SimpleMesh meshify(const radix::Raster& source_points, const radix:: Expected transform_mesh(SimpleMesh&& source_mesh, const OGRSpatialReference& source_srs, const OGRSpatialReference& target_srs) { - auto transform = srs::transformation(source_srs, target_srs); + auto transform = gis::srs::transformation(source_srs, target_srs); if (!transform) { return Error::propagate(std::move(transform)); } - if (auto result = srs::transform_points_inplace(transform->get(), source_mesh.positions); !result) { + 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); @@ -150,7 +150,7 @@ Expected transform_mesh(SimpleMesh&& source_mesh, const OGRSpatialRe /* 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; @@ -163,11 +163,11 @@ Expected> generate_uv_space(const std::vectorget(), positions); + auto transformed = gis::srs::transform_points_to_2d(transform->get(), positions); if (!transformed) { return Error::propagate(std::move(transformed)); } @@ -221,7 +221,7 @@ SimpleMesh concatenate(std::vector&& meshes) Expected> native_read_windows( const OGRSpatialReference& dataset_srs, const radix::tile::SrsBounds& native_bounds, const radix::geometry::Aabb3d& ecef_bounds) { - auto coverage = srs::ecef2srs_coverage(ecef_bounds, dataset_srs); + auto coverage = gis::srs::ecef2srs_coverage(ecef_bounds, dataset_srs); if (!coverage) { return Error::propagate(std::move(coverage)); } @@ -242,7 +242,7 @@ Expected> native_read_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) { @@ -255,7 +255,7 @@ std::expected build_reference_mesh_patch( // 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 = srs::ecef(); !clip_srs.IsSame(&ecef_srs)) { + 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; @@ -265,7 +265,7 @@ std::expected build_reference_mesh_patch( if (!std::isfinite(finite_clip_bounds.max.z)) { finite_clip_bounds.max.z = finite_clip_bounds.min.z; } - const auto ecef_bounds = srs::ecef_coverage(clip_srs, finite_clip_bounds); + 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())); } @@ -332,7 +332,7 @@ std::expected build_reference_mesh_patch( // 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 = srs::ecef_coverage(source_srs, actual_source_bounds); + 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())); } diff --git a/src/sf_builder/mesh_builder.h b/src/sf_builder/mesh_builder.h index 7e26c713..71568c4a 100644 --- a/src/sf_builder/mesh_builder.h +++ b/src/sf_builder/mesh_builder.h @@ -21,8 +21,8 @@ #include -#include "Dataset.h" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/srs.h" #include "mesh/SimpleMesh.h" #include "border.h" @@ -45,7 +45,7 @@ Expected> native_read_windows( /// 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 c1424bbb..77a6659b 100644 --- a/src/sf_builder/raw_dataset_reader.h +++ b/src/sf_builder/raw_dataset_reader.h @@ -39,7 +39,7 @@ 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 a8a075d5..2df1656b 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -44,8 +44,8 @@ #include #include -#include "Dataset.h" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/srs.h" #include "ProgressIndicator.h" #include "log.h" @@ -76,7 +76,7 @@ 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, @@ -144,7 +144,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, @@ -198,7 +198,7 @@ T expect(const std::optional &opt, const std::string &msg) { } Expected build_all_patches( - Dataset &dataset, + gis::Dataset &dataset, const octree::Id::Level target_level, const OGRSpatialReference &texture_srs, const TileProvider *tile_provider, @@ -248,8 +248,8 @@ Expected build_all_patches( } const auto dataset_coverage = *dataset_coverage_result; - const auto ecef_srs = srs::ecef(); - auto ecef_bounds_result = srs::ecef_coverage(dataset_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"); } @@ -289,7 +289,7 @@ Expected build_all_patches( std::function process_node; // SRS objects are not shared between threads. - tbb::enumerable_thread_specific local_wgs84_srs([]() { return srs::wgs84(); }); + 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); @@ -305,7 +305,7 @@ Expected build_all_patches( // Check if node coverage intersects with dataset in geographic coordinates if (node.level() >= min_target_level) { - const auto node_coverage = srs::ecef2srs_coverage(node_bounds, local_wgs84_srs.local()); + 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()); @@ -340,17 +340,17 @@ Expected build_all_patches( 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 diff --git a/src/sf_builder/terrainbuilder.h b/src/sf_builder/terrainbuilder.h index 1d90eb58..9d300f2c 100644 --- a/src/sf_builder/terrainbuilder.h +++ b/src/sf_builder/terrainbuilder.h @@ -24,7 +24,7 @@ #include -#include "Dataset.h" +#include "gis/Dataset.h" #include "octree/Id.h" #include "mesh/SimpleMesh.h" #include "tile_provider.h" @@ -37,7 +37,7 @@ namespace terrainbuilder { 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, @@ -46,7 +46,7 @@ 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, @@ -54,7 +54,7 @@ std::optional build_patch( const OGRSpatialReference &mesh_srs); Expected build_all_patches( - Dataset &dataset, + gis::Dataset &dataset, const octree::Id::Level target_level, const OGRSpatialReference &texture_srs, const TileProvider *tile_provider, diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 9a237d5d..34f9ccea 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -40,7 +40,8 @@ #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" @@ -69,7 +70,7 @@ namespace terrainbuilder { 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 = srs::webmercator_tile_bounds(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); }); }; @@ -108,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, srs::webmercator_tile_bounds(root_tile), 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. @@ -121,7 +122,7 @@ namespace terrainbuilder { } // Check if we even require this tile to fill the target region. - const radix::tile::SrsBounds tile_bounds = srs::webmercator_tile_bounds(tile); + const radix::tile::SrsBounds tile_bounds = gis::webmercator::tile_bounds(tile); if (!radix::geometry::intersect(target_bounds, tile_bounds)) { return true; } @@ -415,7 +416,7 @@ inline AssembledTexture splatter_tiles_to_texture( return {}; } - const radix::tile::SrsBounds root_tile_bounds = srs::webmercator_tile_bounds(root_tile); + 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); @@ -512,7 +513,7 @@ inline std::optional assemble_texture_from_tiles( } // The texture is linear in the srs of the tiles, the uvs are linear in the target srs. - ASSERT(srs::webmercator().IsSame(&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(); @@ -528,7 +529,7 @@ inline std::optional assemble_texture_from_tiles( } // Very small target bounds can push the examined zoom level arbitrarily deep, but - // srs::webmercator_tile_bounds is only valid up to this zoom level; never recurse deeper than that. + // 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); diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index dd63cf19..a6bb0c17 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -29,15 +29,15 @@ #include #include -#include "Dataset.h" -#include "vector_mask.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 @@ -52,13 +52,13 @@ #include #include -using vector_mask::Kernel; -using vector_mask::Point2; -using vector_mask::Polygon2; -using vector_mask::PolygonWithHoles2; -using vector_mask::PolygonSet2; -using vector_mask::MultipolygonWithHoles2; -using vector_mask::ReferencedPolygonMask; +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; @@ -106,16 +106,16 @@ namespace { } } -using vector_mask::LoadErrorKind; -using vector_mask::LoadError; -using vector_mask::simplification_tolerance_metres; -using vector_mask::simplification_tolerance; -using vector_mask::point_count; -using vector_mask::simplify_geometry; -using vector_mask::convert_ring; -using vector_mask::convert_polygon; -using vector_mask::process_geometry; -using vector_mask::load_referenced_from_dataset; +using gis::vector_mask::LoadErrorKind; +using gis::vector_mask::LoadError; +using gis::vector_mask::simplification_tolerance_metres; +using gis::vector_mask::simplification_tolerance; +using gis::vector_mask::point_count; +using gis::vector_mask::simplify_geometry; +using gis::vector_mask::convert_ring; +using gis::vector_mask::convert_polygon; +using gis::vector_mask::process_geometry; +using gis::vector_mask::load_referenced_from_dataset; namespace { template @@ -177,8 +177,8 @@ inline SpherePolygonMask project_onto_sphere(ReferencedPolygonMask ref_mask, con // Translate to ecef and project onto sphere 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(srs::transformation(srs, srs::ecef())); - Error::asserting_unwrap(srs::transform_points_inplace(srs_transform.get(), ecef_points)); + 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)); @@ -350,12 +350,12 @@ inline std::expected load_referenced_from_path return std::unexpected(LoadErrorKind::FileNotFound); } - auto opened = Dataset::open_vector(path); + auto opened = gis::Dataset::open_vector(path); if (!opened) { LOG_ERROR("Failed to load mask dataset: {}", opened.error().to_string()); return std::unexpected(LoadErrorKind::FileNotFound); } - Dataset dataset = std::move(*opened); + gis::Dataset dataset = std::move(*opened); return load_referenced_from_dataset(dataset); } diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 2385e41d..06d078ce 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -19,9 +19,6 @@ add_library(terrainlib Error.h Error.cpp - DatasetReader.h DatasetReader.cpp - vector_mask.h - Dataset.h Dataset.cpp ImageKey.h ProgressIndicator.h ProgressIndicator.cpp Range.h @@ -34,15 +31,12 @@ 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 srs.cpp - srs/detail.h srs/detail.cpp string_utils.h tile_path.h type_utils.h @@ -69,6 +63,14 @@ add_library(terrainlib geometry/Rect.h geometry/geometry.h + 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/webmercator.h gis/webmercator.cpp + io/bytes.h io/bytes.cpp io/compression.h io/compression.cpp io/conversion.h diff --git a/src/terrainlib/Dataset.cpp b/src/terrainlib/gis/Dataset.cpp similarity index 98% rename from src/terrainlib/Dataset.cpp rename to src/terrainlib/gis/Dataset.cpp index b2cafe41..2f47c17d 100644 --- a/src/terrainlib/Dataset.cpp +++ b/src/terrainlib/gis/Dataset.cpp @@ -17,7 +17,7 @@ * along with this program. If not, see . *****************************************************************************/ -#include "Dataset.h" +#include "gis/Dataset.h" #include #include @@ -30,9 +30,11 @@ #include #include -#include "init.h" +#include "gis/gdal.h" +#include "gis/srs.h" #include "log.h" -#include "srs.h" + +namespace gis { void GDALDatasetDeleter::operator()(GDALDataset *dataset) const { if (dataset) { @@ -41,7 +43,7 @@ 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); @@ -239,3 +241,5 @@ GDALDataset *Dataset::gdalDataset() { const GDALDataset *Dataset::gdalDataset() const { return m_gdal_dataset.get(); } + +} // namespace gis diff --git a/src/terrainlib/Dataset.h b/src/terrainlib/gis/Dataset.h similarity index 98% rename from src/terrainlib/Dataset.h rename to src/terrainlib/gis/Dataset.h index 20646a00..c9f54c1a 100644 --- a/src/terrainlib/Dataset.h +++ b/src/terrainlib/gis/Dataset.h @@ -32,6 +32,8 @@ class GDALDataset; class OGRSpatialReference; +namespace gis { + struct GDALDatasetDeleter { void operator()(GDALDataset *dataset) const; }; @@ -72,3 +74,5 @@ class Dataset { std::unique_ptr m_gdal_dataset; std::optional m_path; }; + +} // namespace gis diff --git a/src/terrainlib/DatasetReader.cpp b/src/terrainlib/gis/DatasetReader.cpp similarity index 99% rename from src/terrainlib/DatasetReader.cpp rename to src/terrainlib/gis/DatasetReader.cpp index 3e23330b..dea3bf2b 100644 --- a/src/terrainlib/DatasetReader.cpp +++ b/src/terrainlib/gis/DatasetReader.cpp @@ -16,9 +16,9 @@ * under the License. *******************************************************************************/ -#include "DatasetReader.h" -#include "Dataset.h" -#include "srs.h" +#include "gis/DatasetReader.h" +#include "gis/Dataset.h" +#include "gis/srs.h" #include #include @@ -43,6 +43,8 @@ #include #include "log.h" +namespace gis { + namespace { std::string toWkt(const OGRSpatialReference& srs) { @@ -372,3 +374,5 @@ Expected DatasetReader::min_pixel_size(const radix::tile::Srs template class DatasetReader; template class DatasetReader; + +} // namespace gis diff --git a/src/terrainlib/DatasetReader.h b/src/terrainlib/gis/DatasetReader.h similarity index 98% rename from src/terrainlib/DatasetReader.h rename to src/terrainlib/gis/DatasetReader.h index 3d444a61..a99f2b3a 100644 --- a/src/terrainlib/DatasetReader.h +++ b/src/terrainlib/gis/DatasetReader.h @@ -29,7 +29,9 @@ #include #include #include "Error.h" -#include "srs.h" +#include "gis/srs.h" + +namespace gis { class Dataset; @@ -96,4 +98,6 @@ class DatasetReader { extern template class DatasetReader; extern template class DatasetReader; +} // namespace gis + #endif // DATASETREADER_H diff --git a/src/terrainlib/init.cpp b/src/terrainlib/gis/gdal.cpp similarity index 94% rename from src/terrainlib/init.cpp rename to src/terrainlib/gis/gdal.cpp index cbb89c06..65e0c591 100644 --- a/src/terrainlib/init.cpp +++ b/src/terrainlib/gis/gdal.cpp @@ -21,9 +21,13 @@ #include -#include "init.h" +#include "gis/gdal.h" #include "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) { @@ -52,7 +56,9 @@ inline void GdalErrorHandler(CPLErr eErrClass, int err_no, const char *msg) { Log::get_logger().get()->log(level, "GDAL({}): {}", err_no, msg); } -void initialize_gdal_once() { +} // namespace + +void initialize_once() { std::call_once(g_gdal_initialized_once_flag, []() { LOG_DEBUG("calling GDALAllRegister..."); CPLSetErrorHandler(GdalErrorHandler); @@ -61,3 +67,5 @@ void initialize_gdal_once() { GDALGetCacheMax64(); }); } + +} // namespace gis::gdal diff --git a/src/terrainlib/init.h b/src/terrainlib/gis/gdal.h similarity index 92% rename from src/terrainlib/init.h rename to src/terrainlib/gis/gdal.h index dfd56b23..ad9e8e0e 100644 --- a/src/terrainlib/init.h +++ b/src/terrainlib/gis/gdal.h @@ -19,4 +19,8 @@ #pragma once -void initialize_gdal_once(); +namespace gis::gdal { + +void initialize_once(); + +} // namespace gis::gdal diff --git a/src/terrainlib/srs.cpp b/src/terrainlib/gis/srs.cpp similarity index 95% rename from src/terrainlib/srs.cpp rename to src/terrainlib/gis/srs.cpp index 27430f79..cb156682 100644 --- a/src/terrainlib/srs.cpp +++ b/src/terrainlib/gis/srs.cpp @@ -17,8 +17,9 @@ * along with this program. If not, see . *****************************************************************************/ -#include "srs.h" -#include "srs/detail.h" +#include "gis/srs.h" +#include "gis/srs/detail.h" +#include "gis/webmercator.h" #include #include @@ -26,7 +27,7 @@ #include #include -namespace srs { +namespace gis::srs { std::optional epsg_code(const OGRSpatialReference& srs) { @@ -243,17 +244,9 @@ namespace { } } // namespace -radix::tile::SrsBounds webmercator_tile_bounds(const radix::tile::Id& key) -{ - // ldexp avoids both 1u << 32 and tile-coordinate * tile-dimension overflow. - const double side = std::ldexp(2 * webmercator_half_extent, -int(key.zoom_level)); - return { { -webmercator_half_extent + double(key.coords.x) * side, webmercator_half_extent - (double(key.coords.y) + 1) * side }, - { -webmercator_half_extent + (double(key.coords.x) + 1) * side, webmercator_half_extent - double(key.coords.y) * side } }; -} - 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)) { + 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(); @@ -281,9 +274,9 @@ Expected> geographic_coverage(const OGRSpati Expected> mercator_coverage(const OGRSpatialReference& reference, const radix::tile::SrsBounds& bounds) { const auto mercator = webmercator(); - const glm::dvec2 half_extent(webmercator_half_extent); + 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) { + 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 {}; @@ -297,8 +290,8 @@ Expected> mercator_coverage(const OGRSpatial 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); + 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; } @@ -486,4 +479,4 @@ Expected> ecef2srs_coverage(const radix::ge return result; } -} // namespace srs +} // namespace gis::srs diff --git a/src/terrainlib/srs.h b/src/terrainlib/gis/srs.h similarity index 95% rename from src/terrainlib/srs.h rename to src/terrainlib/gis/srs.h index c3874830..946e00fd 100644 --- a/src/terrainlib/srs.h +++ b/src/terrainlib/gis/srs.h @@ -38,7 +38,7 @@ #include "Error.h" -namespace srs { +namespace gis::srs { std::optional epsg_code(const OGRSpatialReference& srs); @@ -188,13 +188,6 @@ inline OGRSpatialReference mgi() { return from_epsg(4312).value(); } enum class Projection { WebMercator, Geographic }; -/// Half the side length of the square Web Mercator world in metres. -constexpr double webmercator_half_extent = 20037508.342789244; -/// Latitude in degrees at which Web Mercator y reaches webmercator_half_extent. -constexpr double webmercator_latitude_limit = 85.0511287798066; -/// Web Mercator bounds of an XYZ tile; valid up to zoom level 32. -radix::tile::SrsBounds webmercator_tile_bounds(const radix::tile::Id& key); - /// 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. @@ -220,4 +213,4 @@ Expected ecef_coverage(const OGRSpatialReference& refer /// erratic, and if the transformation fails. Expected> ecef2srs_coverage(const radix::geometry::Aabb3d& bounds, const OGRSpatialReference& reference); -} // namespace srs +} // namespace gis::srs diff --git a/src/terrainlib/srs/detail.cpp b/src/terrainlib/gis/srs/detail.cpp similarity index 97% rename from src/terrainlib/srs/detail.cpp rename to src/terrainlib/gis/srs/detail.cpp index b0940b1e..ee20229e 100644 --- a/src/terrainlib/srs/detail.cpp +++ b/src/terrainlib/gis/srs/detail.cpp @@ -25,7 +25,7 @@ * DEALINGS IN THE SOFTWARE. */ -#include "detail.h" +#include "gis/srs/detail.h" #include #include @@ -37,9 +37,10 @@ #include #include -#include "srs.h" +#include "gis/srs.h" +#include "gis/webmercator.h" -namespace srs::detail { +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. @@ -121,7 +122,7 @@ int ogr_transform_bounds(OGRCoordinateTransformation* transform, 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; + pole_latitude = webmercator::latitude_limit; } constexpr double epsilon = 1e-8; bool includes_pole = false; @@ -148,4 +149,4 @@ int ogr_transform_bounds(OGRCoordinateTransformation* transform, return *out_xmin != HUGE_VAL && *out_ymin != HUGE_VAL && *out_xmax != HUGE_VAL && *out_ymax != HUGE_VAL; } -} // namespace srs::detail +} // namespace gis::srs::detail diff --git a/src/terrainlib/srs/detail.h b/src/terrainlib/gis/srs/detail.h similarity index 88% rename from src/terrainlib/srs/detail.h rename to src/terrainlib/gis/srs/detail.h index 6b0a88af..48d46835 100644 --- a/src/terrainlib/srs/detail.h +++ b/src/terrainlib/gis/srs/detail.h @@ -5,7 +5,7 @@ class OGRCoordinateTransformation; -namespace srs::detail { +namespace gis::srs::detail { // Drop-in replacement for OGRCoordinateTransformation::TransformBounds. int ogr_transform_bounds(OGRCoordinateTransformation* transform, @@ -19,4 +19,4 @@ int ogr_transform_bounds(OGRCoordinateTransformation* transform, double* out_ymax, int densify_pts); -} // namespace srs::detail +} // namespace gis::srs::detail diff --git a/src/terrainlib/vector_mask.h b/src/terrainlib/gis/vector_mask.h similarity index 98% rename from src/terrainlib/vector_mask.h rename to src/terrainlib/gis/vector_mask.h index f860555a..7d4fecfa 100644 --- a/src/terrainlib/vector_mask.h +++ b/src/terrainlib/gis/vector_mask.h @@ -25,11 +25,11 @@ #include #include #include -#include "Dataset.h" +#include "gis/Dataset.h" +#include "gis/srs.h" #include "mesh/cgal.h" -#include "srs.h" -namespace vector_mask { +namespace gis::vector_mask { using Kernel = cgal::kernel::epeck::Kernel; using Point2 = Kernel::Point_2; @@ -284,4 +284,4 @@ inline std::expected load_referenced_from_data } -} // namespace vector_mask +} // namespace gis::vector_mask diff --git a/src/terrainlib/gis/webmercator.cpp b/src/terrainlib/gis/webmercator.cpp new file mode 100644 index 00000000..01664b83 --- /dev/null +++ b/src/terrainlib/gis/webmercator.cpp @@ -0,0 +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 . + *****************************************************************************/ + +#include "gis/webmercator.h" + +#include + +namespace gis::webmercator { + +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/terrainlib/gis/webmercator.h b/src/terrainlib/gis/webmercator.h new file mode 100644 index 00000000..f0326de3 --- /dev/null +++ b/src/terrainlib/gis/webmercator.h @@ -0,0 +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 . + *****************************************************************************/ + +#pragma once + +#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); + +} // namespace gis::webmercator diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index bbcea658..510d9ba8 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -37,13 +37,15 @@ add_executable(unittests_terrainlib terrainlib/convert.cpp terrainlib/cow.cpp - terrainlib/dataset.cpp - terrainlib/dataset_reading.cpp terrainlib/enumerate.cpp terrainlib/envelope.cpp terrainlib/error.cpp terrainlib/fixed_vector.cpp terrainlib/geometry_frames.cpp + terrainlib/gis_dataset.cpp + terrainlib/gis_dataset_reading.cpp + terrainlib/gis_srs_bounds.cpp + terrainlib/gis_srs_coverage.cpp terrainlib/glm_serialization.cpp terrainlib/hash_utils.cpp terrainlib/hybrid_index_pair_map.cpp @@ -91,8 +93,6 @@ add_executable(unittests_terrainlib terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp - terrainlib/srs_coverage.cpp - terrainlib/srs_bounds.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp terrainlib/tiny_vector.cpp diff --git a/unittests/sf_builder/coverage.cpp b/unittests/sf_builder/coverage.cpp index 003402db..66a71880 100644 --- a/unittests/sf_builder/coverage.cpp +++ b/unittests/sf_builder/coverage.cpp @@ -26,10 +26,10 @@ #include #include "../temporary_directory.h" -#include "Dataset.h" +#include "gis/Dataset.h" #include "mesh/io.h" #include "mesh_builder.h" -#include "srs.h" +#include "gis/srs.h" #include "terrainbuilder.h" using test::TemporaryDirectory; @@ -63,7 +63,7 @@ std::filesystem::path write_raster( // 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 srs::ecef_coverage(srs::wgs84(), { { longitudes.x, latitudes.x, 0 }, { longitudes.y, latitudes.y, 200 } }).value(); + 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)); } @@ -72,7 +72,7 @@ double longitude(const glm::dvec3& ecef_position) { return glm::degrees(std::ata TEST_CASE("native read windows follow the longitudes of the dataset", "[terrainbuilder][coverage]") { - const auto wgs84 = srs::wgs84(); + 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]") @@ -107,9 +107,9 @@ TEST_CASE("native read windows follow the longitudes of the dataset", "[terrainb SECTION("projected datasets are not shifted") { - const auto austria_lambert = srs::from_epsg(31287).value(); + const auto austria_lambert = gis::srs::from_epsg(31287).value(); const radix::tile::SrsBounds native_bounds { { 100000, 250000 }, { 700000, 600000 } }; - const glm::dvec3 vienna = srs::transform_point(wgs84, austria_lambert, glm::dvec3(16.37, 48.2, 0)).value(); + 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))); @@ -120,14 +120,14 @@ TEST_CASE("native read windows follow the longitudes of the dataset", "[terrainb TEST_CASE("reference meshes are built across longitude seams", "[terrainbuilder][coverage]") { TemporaryDirectory directory("sf-coverage"); - const auto wgs84 = srs::wgs84(); - const auto ecef = srs::ecef(); - const auto webmercator = srs::webmercator(); + 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]") { - Dataset dataset(write_raster(directory.path(), wgs84, { { 180, -5 }, { 190, 5 } }, { 1000, 1000 })); + 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()); @@ -140,7 +140,7 @@ TEST_CASE("reference meshes are built across longitude seams", "[terrainbuilder] SECTION("dataset across the antimeridian") { - Dataset dataset(write_raster(directory.path(), wgs84, { { -180, -5 }, { 180, 5 } }, { 3600, 100 })); + 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()); @@ -158,11 +158,11 @@ TEST_CASE("reference meshes are built across longitude seams", "[terrainbuilder] TEST_CASE("batch builds datasets crossing the prime meridian", "[terrainbuilder][coverage]") { TemporaryDirectory directory("sf-coverage-batch"); - const auto wgs84 = srs::wgs84(); - const auto ecef = srs::ecef(); - const auto webmercator = srs::webmercator(); + 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. - Dataset dataset(write_raster(directory.path(), wgs84, { { -0.02, 51.47 }, { 0.02, 51.49 } }, { 400, 200 })); + 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") diff --git a/unittests/sf_builder/mesh.cpp b/unittests/sf_builder/mesh.cpp index f34834fa..5904a95e 100644 --- a/unittests/sf_builder/mesh.cpp +++ b/unittests/sf_builder/mesh.cpp @@ -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).value(); + 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).value(); + 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,7 +157,7 @@ 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)); + 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; @@ -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).value(); + 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).value(); + 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).value(); + 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).value(); + 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/texture.cpp b/unittests/sf_builder/texture.cpp index 246852fd..163c365e 100644 --- a/unittests/sf_builder/texture.cpp +++ b/unittests/sf_builder/texture.cpp @@ -24,15 +24,15 @@ #include #include "../catch2_helpers.h" -#include "Dataset.h" -#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 radix::tile::Id tile(20, glm::uvec2(0, 1)); - const radix::tile::SrsBounds tile_bounds = srs::webmercator_tile_bounds(tile); + 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); @@ -74,7 +74,7 @@ TEST_CASE("texture assembler takes root tile if only available ", "[terrainbuild std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - srs::webmercator_tile_bounds(root_tile), + 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())); @@ -100,7 +100,7 @@ TEST_CASE("texture assembler ignores parent if all children are present", "[terr std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - srs::webmercator_tile_bounds(root_tile), + 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())); @@ -123,7 +123,7 @@ TEST_CASE("texture assembler considers max zoom level", "[terrainbuilder]") { std::vector actual_tiles_vec = terrainbuilder::find_relevant_tiles_to_splatter_in_bounds( root_tile, - srs::webmercator_tile_bounds(root_tile), + gis::webmercator::tile_bounds(root_tile), tile_provider, 3); std::set actual_tiles(std::make_move_iterator(actual_tiles_vec.begin()), @@ -206,7 +206,7 @@ TEST_CASE("texture assembler assembles single tile", "[terrainbuilder]") { const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - srs::webmercator_tile_bounds(root_tile), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -231,7 +231,7 @@ TEST_CASE("texture assembler assembles two tiles", "[terrainbuilder]") { const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - srs::webmercator_tile_bounds(root_tile), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -260,7 +260,7 @@ TEST_CASE("texture assembler correct order of texture writes", "[terrainbuilder] const radix::tile::Id root_tile(0, {0, 0}); cv::Mat assembled_texture = terrainbuilder::splatter_tiles_to_texture( root_tile, - srs::webmercator_tile_bounds(root_tile), + gis::webmercator::tile_bounds(root_tile), tile_provider, tiles_to_splatter, cv::INTER_NEAREST_EXACT).image; @@ -336,7 +336,7 @@ 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 radix::tile::SrsBounds root_tile_bounds = srs::webmercator_tile_bounds(root_tile); + 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( diff --git a/unittests/sf_merger/mask.cpp b/unittests/sf_merger/mask.cpp index ce2c17b2..2cf626e0 100644 --- a/unittests/sf_merger/mask.cpp +++ b/unittests/sf_merger/mask.cpp @@ -186,7 +186,7 @@ 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), srs::wgs84() }, earth::radius()); + 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()) { diff --git a/unittests/terrainlib/dataset.cpp b/unittests/terrainlib/gis_dataset.cpp similarity index 92% rename from unittests/terrainlib/dataset.cpp rename to unittests/terrainlib/gis_dataset.cpp index 00b99608..b303c7eb 100644 --- a/unittests/terrainlib/dataset.cpp +++ b/unittests/terrainlib/gis_dataset.cpp @@ -22,9 +22,9 @@ #include #include -#include "Dataset.h" -#include "init.h" -#include "srs.h" +#include "gis/Dataset.h" +#include "gis/gdal.h" +#include "gis/webmercator.h" using namespace radix; using Catch::Approx; @@ -44,8 +44,8 @@ void checkBounds(const radix::tile::SrsBounds &a, const radix::tile::SrsBounds & } 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"); + 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"); @@ -84,7 +84,7 @@ TEST_CASE("datasets are as expected") { } TEST_CASE("vector datasets use the layer spatial reference") { - initialize_gdal_once(); + gis::gdal::initialize_once(); GDALDriver *driver = GetGDALDriverManager()->GetDriverByName("Memory"); REQUIRE(driver != nullptr); @@ -96,18 +96,18 @@ TEST_CASE("vector datasets use the layer spatial reference") { expected_srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); REQUIRE(raw_dataset->CreateLayer("mask", &expected_srs, wkbPolygon, nullptr) != nullptr); - Dataset dataset(raw_dataset); + gis::Dataset dataset(raw_dataset); const OGRSpatialReference actual_srs = dataset.srs().value(); CHECK(actual_srs.IsSame(&expected_srs)); } namespace { -Dataset memory_raster(std::array geotransform, glm::uvec2 size, int epsg) +gis::Dataset memory_raster(std::array geotransform, glm::uvec2 size, int epsg) { - initialize_gdal_once(); + gis::gdal::initialize_once(); GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("MEM"); REQUIRE(driver != nullptr); - Dataset dataset(driver->Create("", int(size.x), int(size.y), 1, GDT_Float32, 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); @@ -165,7 +165,7 @@ TEST_CASE("dataset coverage beyond the polar limit is empty in web mercator") TEST_CASE("web mercator dataset coverage is exact") { // Tile (3, {4, 3}) in XYZ order. - const double side = srs::webmercator_half_extent / 4; + 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(); @@ -187,7 +187,7 @@ TEST_CASE("global dataset coverage is a single world rectangle") const auto mercator_bounds = dataset.mercator_coverage(); REQUIRE(mercator_bounds); REQUIRE(mercator_bounds->size() == 1); - const double half = srs::webmercator_half_extent; + const double half = gis::webmercator::half_extent; check_bounds_near(mercator_bounds->front(), { { -half, -half }, { half, half } }, 1e-3); } diff --git a/unittests/terrainlib/dataset_reading.cpp b/unittests/terrainlib/gis_dataset_reading.cpp similarity index 84% rename from unittests/terrainlib/dataset_reading.cpp rename to unittests/terrainlib/gis_dataset_reading.cpp index 10520d9c..9896d3ec 100644 --- a/unittests/terrainlib/dataset_reading.cpp +++ b/unittests/terrainlib/gis_dataset_reading.cpp @@ -40,11 +40,12 @@ #include #include #include "../temporary_directory.h" -#include "Dataset.h" -#include "DatasetReader.h" -#include "init.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" -#include "srs.h" using namespace radix; @@ -65,13 +66,13 @@ Expected debug_out(const radix::Raster& image, const std::filesystem::p return io::image::write(gray, path, { .overwrite = true }); } -void require_projection_available(const Dataset& dataset, const OGRSpatialReference& target_srs) +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 = srs::transformation(dataset_srs, target_srs).value()); + 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 }; @@ -80,17 +81,17 @@ void require_projection_available(const Dataset& dataset, const OGRSpatialRefere } template -DatasetReader make_reader( - std::shared_ptr dataset, srs::Projection projection, std::array::channel_count> bands, Pixel default_pixel) +gis::DatasetReader make_reader( + std::shared_ptr dataset, gis::srs::Projection projection, std::array::channel_count> bands, Pixel default_pixel) { - auto reader = DatasetReader::make(std::move(dataset), projection, bands, 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 DatasetReader::Samples read_samples(DatasetReader& reader, const radix::tile::SrsBounds& bounds, glm::uvec2 size) +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())); @@ -112,14 +113,14 @@ std::array geo_transform_for(const radix::tile::SrsBounds& bounds, gl // 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( +std::shared_ptr make_raster( const std::filesystem::path& path, glm::uvec2 size, unsigned bands, GDALDataType type, std::array geo_transform, int epsg = 3857) { - initialize_gdal_once(); + 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 = srs::from_epsg(epsg).value(); + 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) { @@ -174,7 +175,7 @@ class FailingBand final : public GDALRasterBand { class FailingDataset final : public GDALDataset { public: FailingDataset() - : m_reference(srs::webmercator()) + : m_reference(gis::srs::webmercator()) { nRasterXSize = 16; nRasterYSize = 16; @@ -227,8 +228,8 @@ TEST_CASE("reading") SECTION("check for min and max heights") { const std::array test_projections = { - std::make_pair(srs::Projection::Geographic, 4326), - std::make_pair(srs::Projection::WebMercator, 3857), + 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] @@ -255,13 +256,13 @@ TEST_CASE("reading") const auto [test_name, test_datasets, geographic_bounds, limits] = test; for (std::string dataset_name : test_datasets) { - const auto dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); + 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 = srs::transform_points(geographic_srs, srs, std::array { geographic_bounds.min, geographic_bounds.max }).value(); + 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); @@ -319,9 +320,9 @@ TEST_CASE("reading") 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(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(datasets.front()))); + 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, srs::Projection::Geographic, { 1 }, 0.0F); + 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; @@ -330,9 +331,9 @@ TEST_CASE("reading") } for (std::string dataset_name : datasets) { - const auto dataset = Error::throwing_unwrap(Dataset::open_shared_raster(ALP_TEST_DATA_DIR + std::string(dataset_name))); + 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, srs::Projection::Geographic, { 1 }, 0.0F); + 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; @@ -364,10 +365,10 @@ TEST_CASE("reading") TEST_CASE("min pixel size") { - using Projection = srs::Projection; - const auto open = [](const char* name) { return Error::throwing_unwrap(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) { + 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]); @@ -375,7 +376,7 @@ TEST_CASE("min pixel size") 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 = srs::transform_points(srs::wgs84(), srs::webmercator(), std::array { geographic_bounds.min, geographic_bounds.max }).value(); + 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") @@ -407,7 +408,7 @@ TEST_CASE("min pixel size") 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(-srs::webmercator_half_extent), glm::dvec2(srs::webmercator_half_extent) }; + 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; @@ -435,17 +436,17 @@ TEST_CASE("min pixel size") TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") { - initialize_gdal_once(); + 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)); + 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 = srs::wgs84(); + 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) { @@ -453,8 +454,8 @@ TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") } 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, srs::Projection::WebMercator, { 1 }, 0.0F); - const double degree = srs::webmercator_half_extent / 180; + 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)); @@ -466,11 +467,11 @@ TEST_CASE("geographic datasets wider than 360 degrees wrap around their centre") TEST_CASE("dataset reader reads the selected bands in order", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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), srs::Projection::WebMercator, { 2 }, 0.0F); + 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)); @@ -479,7 +480,7 @@ TEST_CASE("dataset reader reads the selected bands in order", "[dataset_reader]" } SECTION("RGB") { - auto reader = make_reader(make_raster({}, glm::uvec2(32), 4, GDT_Byte, geo_transform), srs::Projection::WebMercator, { 3, 1, 4 }, glm::u8vec3(0)); + 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)); @@ -490,32 +491,32 @@ TEST_CASE("dataset reader reads the selected bands in order", "[dataset_reader]" TEST_CASE("dataset reader rejects unsupported sources and invalid requests", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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 = srs::Projection; + using Projection = gis::srs::Projection; // No source type conversion. for (const auto type : { GDT_Byte, GDT_Int16, GDT_Float64 }) { - require_code(DatasetReader::make(raster(type), Projection::WebMercator, { 1 }, 0.0F), Error::Code::Unsupported); + 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(DatasetReader::make(raster(type), Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)), Error::Code::Unsupported); + 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(DatasetReader::make(raster(GDT_Float32), Projection::WebMercator, { band }, 0.0F), Error::Code::InvalidInput); - require_code(DatasetReader::make(raster(GDT_Byte), Projection::WebMercator, { 1, band, 3 }, glm::u8vec3(0)), Error::Code::InvalidInput); + 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(DatasetReader::make(raster(GDT_Float32), Projection::WebMercator, { 1 }, default_pixel), Error::Code::InvalidInput); + 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(DatasetReader::make(unreferenced, Projection::WebMercator, { 1 }, 0.0F), Error::Code::InvalidInput); + 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); @@ -543,8 +544,8 @@ TEST_CASE("dataset reader rejects unsupported sources and invalid requests", "[d TEST_CASE("dataset reader returns GDAL read failures", "[dataset_reader]") { - initialize_gdal_once(); - auto reader = make_reader(std::make_shared(new FailingDataset()), srs::Projection::WebMercator, { 1 }, 0.0F); + 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); @@ -552,7 +553,7 @@ TEST_CASE("dataset reader returns GDAL read failures", "[dataset_reader]") TEST_CASE("dataset reader combines the effective source masks", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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)); @@ -596,7 +597,7 @@ TEST_CASE("dataset reader combines the effective source masks", "[dataset_reader mask(3, 0, top); expected = [&](unsigned x, unsigned y) { return left(x, y) && top(x, y); }; } - auto reader = make_reader(dataset, srs::Projection::WebMercator, { 1, 2, 3 }, fallback); + 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) { @@ -609,13 +610,13 @@ TEST_CASE("dataset reader combines the effective source masks", "[dataset_reader TEST_CASE("dataset reader prefers an explicit mask over a conflicting NoData value", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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, srs::Projection::WebMercator, { 1 }, 0.0F); + 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); })); @@ -623,7 +624,7 @@ TEST_CASE("dataset reader prefers an explicit mask over a conflicting NoData val TEST_CASE("dataset reader keeps valid pixels equal to sentinels or the default", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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") { @@ -631,14 +632,14 @@ TEST_CASE("dataset reader keeps valid pixels equal to sentinels or the default", for (int band = 1; band <= 3; ++band) { REQUIRE(dataset->gdalDataset()->GetRasterBand(band)->Fill(0) == CE_None); } - auto reader = make_reader(dataset, srs::Projection::WebMercator, { 1, 2, 3 }, glm::u8vec3(0)); + 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), srs::Projection::WebMercator, { 1 }, 10.0F); + 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; })); @@ -647,7 +648,7 @@ TEST_CASE("dataset reader keeps valid pixels equal to sentinels or the default", { 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, srs::Projection::WebMercator, { 1 }, 0.0F); + 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; })); @@ -656,13 +657,13 @@ TEST_CASE("dataset reader keeps valid pixels equal to sentinels or the default", namespace { template -void check_coverage(GDALDataType type, std::array::channel_count> bands, Pixel inside, Pixel fallback) +void check_coverage(GDALDataType type, std::array::channel_count> bands, Pixel inside, Pixel fallback) { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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, srs::Projection::WebMercator, bands, fallback); + 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) { @@ -671,7 +672,7 @@ void check_coverage(GDALDataType type, std::array CHECK(partial.data.pixel({ x, y }) == (x < 16 ? inside : fallback)); } } - const auto absent = read_samples(reader, srs::webmercator_tile_bounds({ 5, { 2, 2 } }), size); + 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; })); } @@ -686,10 +687,10 @@ TEST_CASE("dataset reader returns defaults outside the source coverage", "[datas 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 = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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, srs::Projection::WebMercator, { 1 }, 7.5F); + auto reader = make_reader(dataset, gis::srs::Projection::WebMercator, { 1 }, 7.5F); SECTION("everywhere") { REQUIRE(band->Fill(nonfinite) == CE_None); @@ -718,7 +719,7 @@ TEST_CASE("dataset reader replaces nonfinite float results with the default", "[ TEST_CASE("dataset reader excludes a sample invalid in one channel from all channels", "[dataset_reader]") { - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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) { @@ -729,7 +730,7 @@ TEST_CASE("dataset reader excludes a sample invalid in one channel from all chan 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, srs::Projection::WebMercator, { 1, 2, 3 }, fallback); + 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. @@ -751,12 +752,12 @@ TEST_CASE("dataset reader reprojects within the approximation tolerance", "[data 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 = srs::transform_points(srs::wgs84(), srs::webmercator(), std::array { glm::dvec2(5, 5), glm::dvec2(15, 15) }).value(); + 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, srs::Projection::WebMercator, { band }, 0.0F); + 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) { @@ -793,12 +794,12 @@ TEST_CASE("dataset reader reads a VRT mosaic like a single raster at base resolu (directory.path() / "right.tif").string()); REQUIRE(vrt.good()); } - auto mosaic = make_reader(Error::throwing_unwrap(Dataset::open_shared_raster(vrt_path)), srs::Projection::WebMercator, { 1 }, 0.0F); + 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, srs::Projection::WebMercator, { 1 }, 0.0F); + 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) { @@ -818,8 +819,8 @@ TEST_CASE("dataset reader agrees across adjacent output windows", "[dataset_read // 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, srs::Projection::WebMercator, { 1 }, 0.0F); - const double width = 2 * srs::webmercator_half_extent / 180; + 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)); @@ -853,9 +854,9 @@ TEST_CASE("dataset reader ignores the GDAL threading environment", "[dataset_rea }; const Configuration threads("GDAL_NUM_THREADS", "2"); const Configuration chunks("WARP_THREAD_CHUNK_SIZE", "1"); - const auto bounds = srs::webmercator_tile_bounds({ 5, { 16, 15 } }); + 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))), srs::Projection::WebMercator, { 1 }, 0.0F); + = 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)); @@ -865,8 +866,8 @@ TEST_CASE("dataset reader finds a coarse projected source in a world tile", "[da { // 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, srs::Projection::WebMercator, { 1 }, 0.0F); - const auto samples = read_samples(reader, srs::webmercator_tile_bounds({ 0, { 0, 0 } }), glm::uvec2(16)); + 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)); @@ -874,7 +875,7 @@ TEST_CASE("dataset reader finds a coarse projected source in a world tile", "[da 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 }), srs::Projection::WebMercator, { 1 }, 0.0F); + 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/srs_bounds.cpp b/unittests/terrainlib/gis_srs_bounds.cpp similarity index 82% rename from unittests/terrainlib/srs_bounds.cpp rename to unittests/terrainlib/gis_srs_bounds.cpp index 18d7c25e..ec94b6b6 100644 --- a/unittests/terrainlib/srs_bounds.cpp +++ b/unittests/terrainlib/gis_srs_bounds.cpp @@ -1,8 +1,8 @@ // Copyright (C) 2026 Adam Celarek-Litofcenko // SPDX-License-Identifier: GPL-3.0-or-later -#include "srs.h" -#include "srs/detail.h" +#include "gis/srs.h" +#include "gis/srs/detail.h" #include #include @@ -28,8 +28,8 @@ struct BoundsCase { void compare_bounds(const BoundsCase& scenario, int samples) { CAPTURE(scenario.name, samples); - auto source = srs::from_user_input(scenario.source).value(); - auto target = srs::from_user_input(scenario.target).value(); + 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)); @@ -42,7 +42,7 @@ void compare_bounds(const BoundsCase& scenario, int samples) 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 - = srs::detail::ogr_transform_bounds(extracted.get(), b[0], b[1], b[2], b[3], &actual[0], &actual[1], &actual[2], &actual[3], samples); + = 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) { @@ -108,13 +108,13 @@ TEST_CASE("Cached bounds preserve GDAL failure handling", "[srs][bounds]") TEST_CASE("Cached bounds include interior points at a shifted projection seam", "[srs][bounds]") { - const auto source = srs::wgs84(); - const auto target = srs::from_user_input("+proj=sinu +lon_0=10 +datum=WGS84 +units=m +type=crs").value(); - const auto transform = srs::transformation(source, target).value(); + 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(srs::detail::ogr_transform_bounds(transform.get(), -175, -80, -165, 80, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257)); + 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 = srs::transform_point(transform.get(), glm::dvec2(longitude, 0)).value(); + 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]); @@ -124,9 +124,9 @@ TEST_CASE("Cached bounds include interior points at a shifted projection seam", TEST_CASE("Projected bounds transformation benchmark", "[srs][!benchmark][.]") { - const auto transform = srs::transformation(srs::wgs84(), srs::from_epsg(31287).value()).value(); + const auto transform = gis::srs::transformation(gis::srs::wgs84(), gis::srs::from_epsg(31287).value()).value(); std::array bounds {}; - REQUIRE(srs::detail::ogr_transform_bounds(transform.get(), 12.6, 47, 12.61, 47.01, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257)); + 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); @@ -134,7 +134,7 @@ TEST_CASE("Projected bounds transformation benchmark", "[srs][!benchmark][.]") }; BENCHMARK("cached projected bounds") { - srs::detail::ogr_transform_bounds(transform.get(), 12.6, 47, 12.61, 47.01, &bounds[0], &bounds[1], &bounds[2], &bounds[3], 257); + 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/srs_coverage.cpp b/unittests/terrainlib/gis_srs_coverage.cpp similarity index 68% rename from unittests/terrainlib/srs_coverage.cpp rename to unittests/terrainlib/gis_srs_coverage.cpp index 7d3aa35c..3e407c50 100644 --- a/unittests/terrainlib/srs_coverage.cpp +++ b/unittests/terrainlib/gis_srs_coverage.cpp @@ -16,7 +16,8 @@ * along with this program. If not, see . *****************************************************************************/ -#include "srs.h" +#include "gis/srs.h" +#include "gis/webmercator.h" #include #include @@ -63,7 +64,7 @@ size_t count_uncovered(const std::vector& coverage, const std::vector cases { - { "geographic_1km", srs::wgs84(), { { 16.36, 48.20, 0 }, { 16.37, 48.21, 1000 } } }, - { "austria", srs::wgs84(), { { 9.5, 46.3, -100 }, { 17.2, 49.1, 4000 } } }, - { "equator", srs::wgs84(), { { -0.37, -0.28, 0 }, { 0.59, 0.44, 1000 } } }, - { "near_pole", srs::wgs84(), { { 10, 89.0, 0 }, { 20, 89.9, 500 } } }, - { "antimeridian", srs::wgs84(), { { 170, -5, 0 }, { 190, 5, 100 } } }, - { "mercator_10km", srs::webmercator(), { { 1810000, 6130000, 0 }, { 1820000, 6140000, 1000 } } }, - { "austria_lambert", srs::from_epsg(31287).value(), { { 400000, 400000, 0 }, { 500000, 480000, 3000 } } }, - { "utm_33n", srs::from_epsg(32633).value(), { { 500000, 5300000, 0 }, { 600000, 5400000, 3000 } } }, + { "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 = srs::ecef_coverage(scenario.srs, scenario.bounds); + const auto coverage = gis::srs::ecef_coverage(scenario.srs, scenario.bounds); REQUIRE(coverage); - const auto samples = srs::transform_points(scenario.srs, srs::ecef(), box_samples(scenario.bounds)).value(); + 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. @@ -118,7 +119,7 @@ TEST_CASE("ECEF coverage of geographic boxes is exact", "[srs][coverage]") } } } - const auto coverage = srs::ecef_coverage(srs::wgs84(), austria); + 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)); @@ -127,13 +128,13 @@ TEST_CASE("ECEF coverage of geographic boxes is exact", "[srs][coverage]") } SECTION("extremum inside the top face, at the equator and the prime meridian") { - const auto coverage = srs::ecef_coverage(srs::wgs84(), { { -0.37, -0.28, 0 }, { 0.59, 0.44, 1000 } }); + 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 = srs::ecef_coverage(srs::wgs84(), { { -1, 10, 0 }, { 1, 11, 100 } }); + 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)); } @@ -143,20 +144,20 @@ 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", srs::wgs84() }, - { "webmercator", srs::webmercator() }, - { "mgi", srs::mgi() }, - { "austria_lambert", srs::from_epsg(31287).value() }, - { "utm_33n", srs::from_epsg(32633).value() }, + { "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 = srs::ecef2srs_coverage(box, reference.srs); + const auto coverage = gis::srs::ecef2srs_coverage(box, reference.srs); REQUIRE(coverage); REQUIRE(coverage->size() == 1); - const auto samples = srs::transform_points(srs::ecef(), reference.srs, box_samples(box)).value(); + 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. @@ -173,11 +174,11 @@ 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 = srs::ecef2srs_coverage(box, srs::wgs84()); + 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(srs::transform_points(srs::ecef(), srs::wgs84(), box_samples(box)).value()); + 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)); @@ -186,7 +187,7 @@ TEST_CASE("Coverage of ECEF boxes is exact in WGS84", "[srs][coverage]") } SECTION("height minimum inside a face, at the equator") { - const auto coverage = srs::ecef2srs_coverage(cube({ 6378137, 0, 0 }, 50000), srs::wgs84()); + 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)); @@ -198,36 +199,36 @@ TEST_CASE("Coverage of ECEF boxes stays within the bounds of the SRS", "[srs][co SECTION("antimeridian") { const auto box = cube({ -6378137, 0, 0 }, 50000); - for (const auto& reference : { srs::wgs84(), srs::webmercator() }) { - const auto coverage = srs::ecef2srs_coverage(box, reference); + 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 : srs::webmercator_half_extent; + 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 = srs::transform_points(srs::ecef(), reference, box_samples(box)).value(); + 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 = srs::ecef2srs_coverage(box, srs::wgs84()); + 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, srs::transform_points(srs::ecef(), srs::wgs84(), box_samples(box)).value()) == 0); - const auto mercator = srs::ecef2srs_coverage(box, srs::webmercator()); + 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 = srs::ecef2srs_coverage(cube(glm::dvec3(0), 1000), srs::wgs84()); + 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); } @@ -237,44 +238,44 @@ 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 = srs::from_epsg(31287).value(); + 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 = srs::transform_point(srs::ecef(), lambert, ecef).value(); - const auto backward = srs::ecef2srs_coverage({ ecef, ecef }, lambert); + 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 = srs::ecef_coverage(lambert, { projected, projected }); + const auto forward = gis::srs::ecef_coverage(lambert, { projected, projected }); REQUIRE(forward); - CHECK(forward->contains_inclusive(srs::transform_point(lambert, srs::ecef(), projected).value())); + 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(srs::ecef_coverage(srs::ecef(), box).value() == box); - const auto unchanged = srs::ecef2srs_coverage(box, srs::ecef()).value(); + 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 = srs::from_user_input("EPSG:4326+3855").value(); + const auto compound = gis::srs::from_user_input("EPSG:4326+3855").value(); REQUIRE(compound.IsCompound()); - const auto forward = srs::ecef_coverage(compound, { { 16.36, 48.20, 0 }, { 16.37, 48.21, 1000 } }); + 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 = srs::ecef2srs_coverage(box, compound); + 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 = srs::ecef_coverage(srs::wgs84(), bad); + 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 = srs::ecef2srs_coverage(bad, srs::wgs84()); + const auto backward_result = gis::srs::ecef2srs_coverage(bad, gis::srs::wgs84()); REQUIRE_FALSE(backward_result); CHECK(backward_result.error().code() == Error::Code::InvalidInput); } From a6c64365e1bff4eaabb650fbc020c19cd3cc1aba Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:48:00 +0200 Subject: [PATCH 57/83] rename / cleanup the io::byte api --- src/terrainlib/CMakeLists.txt | 2 +- src/terrainlib/io/{bytes.cpp => byte.cpp} | 14 ++++----- src/terrainlib/io/{bytes.h => byte.h} | 11 ++++--- src/terrainlib/io/compression.cpp | 12 ++++---- src/terrainlib/io/compression.h | 7 +++-- src/terrainlib/io/envelope.cpp | 2 +- src/terrainlib/io/envelope.h | 30 ++++++++++---------- src/terrainlib/io/hash.cpp | 4 +-- src/terrainlib/io/hash.h | 4 ++- src/terrainlib/io/image.cpp | 20 ++++++------- src/terrainlib/io/image.h | 9 +++--- src/terrainlib/raster_store/attribution.cpp | 4 +-- src/terrainlib/raster_store/io/TileCodec.h | 5 ++-- unittests/terrainlib/envelope.cpp | 25 ++++++++-------- unittests/terrainlib/glm_serialization.cpp | 9 +++--- unittests/terrainlib/io_hash.cpp | 9 +++--- unittests/terrainlib/io_image.cpp | 18 ++++++------ unittests/terrainlib/raster_store.cpp | 20 ++++++------- unittests/terrainlib/store_codec.cpp | 2 +- unittests/terrainlib/store_compatibility.cpp | 4 +-- unittests/terrainlib/store_storage.cpp | 8 +++--- 21 files changed, 115 insertions(+), 104 deletions(-) rename src/terrainlib/io/{bytes.cpp => byte.cpp} (85%) rename src/terrainlib/io/{bytes.h => byte.h} (72%) diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 06d078ce..a5298baa 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -71,7 +71,7 @@ add_library(terrainlib gis/vector_mask.h gis/webmercator.h gis/webmercator.cpp - io/bytes.h io/bytes.cpp + io/byte.h io/byte.cpp io/compression.h io/compression.cpp io/conversion.h io/image.h io/image.cpp diff --git a/src/terrainlib/io/bytes.cpp b/src/terrainlib/io/byte.cpp similarity index 85% rename from src/terrainlib/io/bytes.cpp rename to src/terrainlib/io/byte.cpp index f3935aa6..32b7745c 100644 --- a/src/terrainlib/io/bytes.cpp +++ b/src/terrainlib/io/byte.cpp @@ -21,18 +21,18 @@ #include #include -#include "io/bytes.h" +#include "io/byte.h" #include "io/utils.h" #include "log.h" -namespace io { +namespace io::byte { -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs) +Expected write(const span bytes, const std::filesystem::path& path, bool make_dirs) { - return write_bytes_to_path(bytes, path, WriteMode::Overwrite, make_dirs); + return write(bytes, path, WriteMode::Overwrite, make_dirs); } -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) +Expected write(const span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) { LOG_TRACE("Writing bytes to path {}", path); @@ -69,7 +69,7 @@ Expected write_bytes_to_path(const std::span bytes, const return {}; } -Expected> read_bytes_from_path(const std::filesystem::path& path, const std::size_t max_size) +Expected read(const std::filesystem::path& path, const std::size_t max_size) { LOG_TRACE("Reading bytes from path {}", path); @@ -91,7 +91,7 @@ Expected> read_bytes_from_path(const std::filesystem::pat } const auto read_size = static_cast(std::min(static_cast(size), max_size)); - std::vector buffer(static_cast(read_size)); + vector buffer(static_cast(read_size)); file.seekg(0); file.read(reinterpret_cast(buffer.data()), read_size); diff --git a/src/terrainlib/io/bytes.h b/src/terrainlib/io/byte.h similarity index 72% rename from src/terrainlib/io/bytes.h rename to src/terrainlib/io/byte.h index 388e4713..d02d003d 100644 --- a/src/terrainlib/io/bytes.h +++ b/src/terrainlib/io/byte.h @@ -29,13 +29,16 @@ #include "Error.h" -namespace io { +namespace io::byte { + +using vector = std::vector; +using span = std::span; enum class WriteMode { Overwrite, CreateNew }; -Expected write_bytes_to_path(const std::span bytes, const std::filesystem::path& path, bool make_dirs = true); -Expected write_bytes_to_path(std::span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs = true); +Expected write(const span bytes, const std::filesystem::path& path, bool make_dirs = true); +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_bytes_from_path(const std::filesystem::path& path, std::size_t max_size = std::numeric_limits::max()); +Expected read(const std::filesystem::path& path, std::size_t max_size = std::numeric_limits::max()); } diff --git a/src/terrainlib/io/compression.cpp b/src/terrainlib/io/compression.cpp index a3a72d14..8cd528d2 100644 --- a/src/terrainlib/io/compression.cpp +++ b/src/terrainlib/io/compression.cpp @@ -37,7 +37,7 @@ namespace { } // namespace -Expected> compress(const std::span uncompressed_data, const CompressionAlgorithm compression_algorithm) +Expected compress(const byte::span uncompressed_data, const CompressionAlgorithm compression_algorithm) { if (uncompressed_data.size() > default_max_decompressed_size) { return Error::fail(Error::Code::ResourceExhausted, "uncompressed data exceeds the compression size limit"); @@ -46,7 +46,7 @@ Expected> compress(const std::span uncom int compression_level = 0; switch (compression_algorithm) { case CompressionAlgorithm::None: - return std::vector(uncompressed_data.begin(), uncompressed_data.end()); + return byte::vector(uncompressed_data.begin(), uncompressed_data.end()); case CompressionAlgorithm::ZstdBestCompression: compression_level = ZSTD_maxCLevel(); break; @@ -66,7 +66,7 @@ Expected> compress(const std::span uncom } const std::size_t capacity = ZSTD_compressBound(uncompressed_data.size()); - std::vector 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)) { @@ -77,14 +77,14 @@ Expected> compress(const std::span uncom return compressed_data; } -Expected> decompress( - const std::span compressed_data, const CompressionAlgorithm compression_algorithm, 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) { 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); - std::vector 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"); diff --git a/src/terrainlib/io/compression.h b/src/terrainlib/io/compression.h index 08db75e3..40090d17 100644 --- a/src/terrainlib/io/compression.h +++ b/src/terrainlib/io/compression.h @@ -26,6 +26,7 @@ #include #include "Error.h" +#include "io/byte.h" namespace io::envelope { @@ -38,10 +39,10 @@ enum class CompressionAlgorithm : std::uint8_t { }; // The algorithm must be an enumerator. Zstandard frames are written without their frame checksum. -Expected> compress(std::span uncompressed_data, CompressionAlgorithm compression_algorithm); +Expected compress(byte::span uncompressed_data, CompressionAlgorithm compression_algorithm); // The algorithm must be an enumerator. Zstandard frames are accepted with or without their frame checksum. -Expected> decompress( - std::span compressed_data, CompressionAlgorithm compression_algorithm, std::size_t max_decompressed_size = default_max_decompressed_size); +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/envelope.cpp b/src/terrainlib/io/envelope.cpp index ba447df0..75ceb7b2 100644 --- a/src/terrainlib/io/envelope.cpp +++ b/src/terrainlib/io/envelope.cpp @@ -24,7 +24,7 @@ namespace io::envelope { Expected

read_header(const std::filesystem::path& path) { - auto file_bytes = ::io::read_bytes_from_path(path, max_header_size); + 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() + "\""); } diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index 6986516e..a3bb6c31 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -18,7 +18,7 @@ #pragma once -#include "io/bytes.h" +#include "io/byte.h" #include "io/compression.h" #include "io/glm_serialization.h" #include "io/hash.h" @@ -52,7 +52,7 @@ struct Header { std::uint32_t class_version; hash::Algorithm hash_algorithm; // hash::data of the uncompressed payload. - std::vector hash; + byte::vector hash; CompressionAlgorithm compression_algorithm; std::uint64_t uncompressed_size; }; @@ -60,7 +60,7 @@ struct Header { // The payload is the last field, so a file prefix of max_header_size bytes contains the header. struct Envelope { Header header; - std::vector compressed_data; + byte::vector compressed_data; }; // zpp_bits prefixes strings and vectors with their 32-bit size. @@ -155,17 +155,17 @@ struct PayloadSchema { }; template -Expected> serialize(const typename Schema::template payload_type& payload, +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, +Expected serialize(const typename Schema::latest_type& payload, CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, hash::Algorithm hash_algorithm = hash::Algorithm::Xxh3_64); template -Expected deserialize(std::span bytes, std::size_t max_decompressed_size = default_max_decompressed_size); +Expected deserialize(byte::span bytes, std::size_t max_decompressed_size = default_max_decompressed_size); // 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. @@ -177,9 +177,9 @@ namespace detail { Expected validate_header(const Header& header); template -Expected> serialize_to_bytes(const Value& value) +Expected serialize_to_bytes(const Value& value) { - std::vector bytes; + byte::vector bytes; zpp::bits::out output(bytes, zpp::bits::alloc_limit()); const zpp::bits::errc result = output(value); if (zpp::bits::failure(result)) { @@ -191,7 +191,7 @@ Expected> serialize_to_bytes(const Value& value) } template -Expected deserialize_from_bytes(const std::span bytes) +Expected deserialize_from_bytes(const byte::span bytes) { Value value{}; zpp::bits::in input(bytes, zpp::bits::alloc_limit()); @@ -217,7 +217,7 @@ typename Schema::latest_type convert_to_latest(Current current) template Expected deserialize_version( const std::uint32_t class_version, - const std::span payload_bytes) + const byte::span payload_bytes) { using CurrentVersion = typename Schema::template version_at; if (class_version == CurrentVersion::number) { @@ -239,7 +239,7 @@ Expected deserialize_version( } // namespace detail template -Expected> serialize( +Expected serialize( const typename Schema::template payload_type &payload, const CompressionAlgorithm compression_algorithm, const hash::Algorithm hash_algorithm) @@ -274,7 +274,7 @@ Expected> serialize( } template -Expected> serialize( +Expected serialize( const typename Schema::latest_type &payload, const CompressionAlgorithm compression_algorithm, const hash::Algorithm hash_algorithm) @@ -287,7 +287,7 @@ Expected> serialize( template Expected deserialize( - const std::span bytes, + const byte::span bytes, const std::size_t max_decompressed_size) { auto envelope = detail::deserialize_from_bytes(bytes); @@ -336,7 +336,7 @@ 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); + auto file_bytes = ::io::byte::read(path); if (!file_bytes) { return Error::propagate(std::move(file_bytes), "reading envelope file \"" + path.string() + "\""); } @@ -358,7 +358,7 @@ Expected write_to_path(const typename Schema::latest_type& payload, if (!serialized) { return Error::propagate(std::move(serialized), "encoding envelope file \"" + path.string() + "\""); } - auto result = ::io::write_bytes_to_path(*serialized, path, make_dirs); + auto result = ::io::byte::write(*serialized, path, make_dirs); if (!result) { return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); } diff --git a/src/terrainlib/io/hash.cpp b/src/terrainlib/io/hash.cpp index 33f5d3dd..b6672238 100644 --- a/src/terrainlib/io/hash.cpp +++ b/src/terrainlib/io/hash.cpp @@ -26,7 +26,7 @@ namespace io::hash { namespace { - std::vector xxh3_64(const std::span bytes) + byte::vector xxh3_64(const byte::span bytes) { XXH64_canonical_t canonical; XXH64_canonicalFromHash(&canonical, XXH3_64bits(bytes.data(), bytes.size())); @@ -36,7 +36,7 @@ namespace { } // namespace -std::vector data(const std::span bytes, const Algorithm algorithm) +byte::vector data(const byte::span bytes, const Algorithm algorithm) { switch (algorithm) { case Algorithm::None: diff --git a/src/terrainlib/io/hash.h b/src/terrainlib/io/hash.h index 0aa7c5bf..de516fd4 100644 --- a/src/terrainlib/io/hash.h +++ b/src/terrainlib/io/hash.h @@ -23,6 +23,8 @@ #include #include +#include "io/byte.h" + namespace io::hash { enum class Algorithm : std::uint8_t { @@ -32,6 +34,6 @@ enum class Algorithm : std::uint8_t { // 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. -std::vector data(std::span bytes, Algorithm algorithm); +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 index 2001ae61..2bcd22e8 100644 --- a/src/terrainlib/io/image.cpp +++ b/src/terrainlib/io/image.cpp @@ -26,7 +26,7 @@ #include #include -#include "bytes.h" +#include "byte.h" #include "conversion.h" #include "raster/algorithm/transform.h" @@ -55,7 +55,7 @@ namespace { } template - Expected> encode_raster(const radix::Raster& image, Format format, EncodeOptions options) + Expected encode_raster(const radix::Raster& image, Format format, EncodeOptions options) try { if (auto valid = validate_encoding(format, Pixel::length(), options); !valid) { return Error::propagate(std::move(valid)); @@ -76,7 +76,7 @@ namespace { } // OpenCV only encodes into a std::vector. const auto bytes = std::as_bytes(std::span(encoded)); - return std::vector(bytes.begin(), bytes.end()); + 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&) { @@ -84,7 +84,7 @@ namespace { } template - Expected> decode_raster(std::span bytes) + Expected> decode_raster(byte::span bytes) try { if (bytes.empty() || bytes.size() > static_cast((std::numeric_limits::max)())) { return Error::fail(Error::Code::CorruptData, "empty or oversized encoded image"); @@ -133,7 +133,7 @@ namespace { template Expected> read_raster(const std::filesystem::path& path) try { - auto bytes = read_bytes_from_path(path); + auto bytes = byte::read(path); if (!bytes) { return Error::propagate(std::move(bytes)); } @@ -179,15 +179,15 @@ namespace { if (!bytes) { return Error::propagate(std::move(bytes), "encode image for " + path.string()); } - return write_bytes_to_path(*bytes, path, options.overwrite ? WriteMode::Overwrite : WriteMode::CreateNew, options.make_dirs); + 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(std::span bytes) { return decode_raster(bytes); } -Expected decode_rgba8(std::span bytes) { return decode_raster(bytes); } +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) { return decode_raster(bytes); } +Expected decode_rgba8(byte::span bytes) { return decode_raster(bytes); } 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); } diff --git a/src/terrainlib/io/image.h b/src/terrainlib/io/image.h index 67c86005..0da04344 100644 --- a/src/terrainlib/io/image.h +++ b/src/terrainlib/io/image.h @@ -27,6 +27,7 @@ #include #include "Error.h" +#include "io/byte.h" namespace io::image { @@ -47,13 +48,13 @@ struct WriteOptions { }; /// RGB(A) channels, first row at the top. No flipping, scaling or premultiplication. -Expected> encode(const RGB8& image, Format format, EncodeOptions options = {}); +Expected encode(const RGB8& image, Format format, EncodeOptions options = {}); /// PNG preserves all four channels. JPEG rejects RGBA input. -Expected> encode(const RGBA8& image, Format format, EncodeOptions options = {}); +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. -Expected decode_rgb8(std::span bytes); -Expected decode_rgba8(std::span bytes); +Expected decode_rgb8(byte::span bytes); +Expected decode_rgba8(byte::span bytes); Expected read_rgb8(const std::filesystem::path& path); Expected read_rgba8(const std::filesystem::path& path); diff --git a/src/terrainlib/raster_store/attribution.cpp b/src/terrainlib/raster_store/attribution.cpp index 2919c32d..3e5e2282 100644 --- a/src/terrainlib/raster_store/attribution.cpp +++ b/src/terrainlib/raster_store/attribution.cpp @@ -24,7 +24,7 @@ #include -#include "io/bytes.h" +#include "io/byte.h" namespace raster_store::attribution { @@ -110,7 +110,7 @@ Expected

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

read_table(const std::filesystem::path& index_path) if (!path) { return Error::propagate(std::move(path)); } - auto bytes = ::io::read_bytes_from_path(*path); + auto bytes = ::io::byte::read(*path); if (!bytes) { return Error::propagate(std::move(bytes), "read source attribution table"); } diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index 61101dab..bb74a9cf 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -22,6 +22,7 @@ #include #include +#include "io/byte.h" #include "io/envelope.h" #include "raster_store/Tile.h" #include "raster_store/pixel.h" @@ -36,8 +37,8 @@ namespace tile_codec { struct RasterTile { std::uint32_t width; std::uint32_t height; - std::vector data; - std::vector source_attribution; + ::io::byte::vector data; + ::io::byte::vector source_attribution; }; } // namespace detail::v1 diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index ec59860e..671e3bff 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -19,6 +19,7 @@ #include "../catch2_helpers.h" #include "../temporary_directory.h" +#include "io/byte.h" #include "io/envelope.h" #include @@ -115,17 +116,17 @@ static_assert(std::same_as); static_assert(std::same_as, v1::Payload>); template -std::vector encode_value(const Value& value) +io::byte::vector encode_value(const Value& value) { - std::vector bytes; + io::byte::vector bytes; zpp::bits::out output(bytes); output(value).or_throw(); return bytes; } -std::vector 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 std::vector& bytes) +io::envelope::Envelope decode_envelope(const io::byte::vector& bytes) { io::envelope::Envelope envelope {}; zpp::bits::in input(bytes); @@ -134,7 +135,7 @@ io::envelope::Envelope decode_envelope(const std::vector& bytes) } // Writes a frame with a checksum, which the reader accepts but does not require. -std::vector compress_without_content_size(const std::vector& uncompressed_data) +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)) @@ -142,7 +143,7 @@ std::vector compress_without_content_size(const std::vector 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)) { @@ -159,7 +160,7 @@ void check_error(const Result& result, const Error::Code expected) CHECK(result.error().code() == expected); } -void write_file(const std::filesystem::path& path, const std::span bytes) { REQUIRE(io::write_bytes_to_path(bytes, path)); } +void write_file(const std::filesystem::path& path, const io::byte::span bytes) { REQUIRE(io::byte::write(bytes, path)); } } // namespace @@ -334,7 +335,7 @@ TEST_CASE("XXH3-64 detects corrupted envelope payloads") TEST_CASE("Compression round trips and enforces its size limit") { - const std::vector 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(original, compression_algorithm); @@ -362,7 +363,7 @@ TEST_CASE("Compression round trips and enforces its size limit") TEST_CASE("Decompression uses its maximum when the format omits the content size") { - const std::vector 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) { @@ -521,7 +522,7 @@ TEST_CASE("Envelope reports malformed serialized data") { SECTION("envelope") { - const std::vector 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); } @@ -589,8 +590,8 @@ TEST_CASE("Envelope headers are read without the payload") { 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 = std::vector(io::envelope::max_hash_size, std::byte { 0x5a }); - envelope.compressed_data = std::vector(1024, std::byte { 0x11 }); + 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); diff --git a/unittests/terrainlib/glm_serialization.cpp b/unittests/terrainlib/glm_serialization.cpp index c857050a..41d8365a 100644 --- a/unittests/terrainlib/glm_serialization.cpp +++ b/unittests/terrainlib/glm_serialization.cpp @@ -17,6 +17,7 @@ *****************************************************************************/ #include "io/glm_serialization.h" +#include "io/byte.h" #include #include @@ -32,7 +33,7 @@ namespace { template auto encode(const Value& value) { - std::vector bytes; + io::byte::vector bytes; zpp::bits::out output(bytes); REQUIRE(!zpp::bits::failure(output(value))); return bytes; @@ -52,7 +53,7 @@ void check_encoding(const Value& value, const std::array& componen CHECK(input.position() == bytes.size()); // Non-const values must also work with output archives. - std::vector mutable_bytes; + io::byte::vector mutable_bytes; zpp::bits::out output(mutable_bytes); REQUIRE(!zpp::bits::failure(output(decoded))); CHECK(mutable_bytes == bytes); @@ -140,10 +141,10 @@ TEMPLATE_TEST_CASE("GLM serialization propagates truncated buffer errors", "[glm for (std::size_t size = 0; size < bytes.size(); ++size) { CAPTURE(size); TestType decoded {}; - zpp::bits::in input(std::span(bytes.data(), size)); + zpp::bits::in input(io::byte::span(bytes.data(), size)); CHECK(zpp::bits::failure(input(decoded))); - std::vector buffer(size); + io::byte::vector buffer(size); zpp::bits::out output { std::span(buffer) }; CHECK(zpp::bits::failure(output(value))); } diff --git a/unittests/terrainlib/io_hash.cpp b/unittests/terrainlib/io_hash.cpp index 24e57af4..49378db9 100644 --- a/unittests/terrainlib/io_hash.cpp +++ b/unittests/terrainlib/io_hash.cpp @@ -16,6 +16,7 @@ * along with this program. If not, see . *****************************************************************************/ +#include "io/byte.h" #include "io/hash.h" #include @@ -28,9 +29,9 @@ namespace { // XSUM_fillTestBuffer from xxHash's cli/xsum_sanity_check.c, which generates the input of its test vectors. -std::vector sanity_buffer(const std::size_t size) +io::byte::vector sanity_buffer(const std::size_t size) { - std::vector buffer(size); + io::byte::vector buffer(size); std::uint64_t generator = 2654435761U; for (auto& byte : buffer) { byte = std::byte(generator >> 56); @@ -39,9 +40,9 @@ std::vector sanity_buffer(const std::size_t size) return buffer; } -std::vector big_endian(const std::uint64_t value) +io::byte::vector big_endian(const std::uint64_t value) { - std::vector bytes(8); + io::byte::vector bytes(8); for (std::size_t index = 0; index < bytes.size(); ++index) { bytes[index] = std::byte(value >> (56 - 8 * index)); } diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp index e17ed280..a4d2eed6 100644 --- a/unittests/terrainlib/io_image.cpp +++ b/unittests/terrainlib/io_image.cpp @@ -24,7 +24,7 @@ #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" #include "io/image.h" namespace { @@ -40,7 +40,7 @@ image::RGBA8 colours() return result; } -cv::Mat encoded_mat(const std::vector& bytes) { return { 1, int(bytes.size()), CV_8UC1, const_cast(bytes.data()) }; } +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]") @@ -134,10 +134,10 @@ TEST_CASE("disk image I/O preserves RGBA and honours overwrite and directory pol auto source = colours(); CHECK_FALSE(image::write(source, path, { .make_dirs = false })); REQUIRE(image::write(source, path)); - const auto original = io::read_bytes_from_path(path); + const auto original = io::byte::read(path); REQUIRE(original); CHECK(image::write(source, path).error().code() == Error::Code::AlreadyExists); - CHECK(*io::read_bytes_from_path(path) == *original); + 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); @@ -162,9 +162,9 @@ TEST_CASE("byte writer retains legacy overwrite and supports exclusive creation" 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::write_bytes_to_path(first, path, io::WriteMode::CreateNew)); - CHECK(io::write_bytes_to_path(second, path, io::WriteMode::CreateNew).error().code() == Error::Code::AlreadyExists); - CHECK(*io::read_bytes_from_path(path) == std::vector(first.begin(), first.end())); - REQUIRE(io::write_bytes_to_path(second, path)); - CHECK(*io::read_bytes_from_path(path) == std::vector(second.begin(), second.end())); + 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)); + CHECK(*io::byte::read(path) == std::vector(second.begin(), second.end())); } diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index bdb03b63..85fa765f 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -29,7 +29,7 @@ #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" #include "raster_store/storage.h" namespace { @@ -47,7 +47,7 @@ constexpr std::string_view entity_json = void write_text(const std::filesystem::path& path, const std::string_view text) { - REQUIRE(io::write_bytes_to_path(std::as_bytes(std::span(text)), path)); + REQUIRE(io::byte::write(std::as_bytes(std::span(text)), path)); } void write_table(const std::filesystem::path& directory) @@ -191,7 +191,7 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra TileCodec rectangular({ 3, 2 }); CHECK(rectangular.write(path, tile).error().code() == Error::Code::InvalidInput); - tile_codec::detail::v1::RasterTile encoded { 3, 3, std::vector(36), std::vector(18) }; + 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 }); } @@ -250,14 +250,14 @@ TEST_CASE("raster snapshots checkpoint metadata dimensions and publish explicitl CHECK_FALSE(storage::open(partial)); CHECK_FALSE(storage::open(partial / "")); CHECK(storage::open(partial, { .allow_incomplete = true })); - const auto metadata_before = io::read_bytes_from_path(partial / manifest::metadata_file_name).value(); + 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::read_bytes_from_path(partial / manifest::metadata_file_name).value() == metadata_before); + 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); @@ -394,8 +394,8 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" 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::read_bytes_from_path(rf_root / attribution::file_name).value() - == io::read_bytes_from_path(target->base_path() / attribution::file_name).value()); + 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); @@ -411,7 +411,7 @@ TEST_CASE("byte writes report buffered device failures", "[io][bytes][raster-sto { #ifdef __linux__ const std::array bytes { std::byte { 42 } }; - auto written = io::write_bytes_to_path(bytes, "/dev/full", false); + auto written = io::byte::write(bytes, "/dev/full", false); REQUIRE_FALSE(written); CHECK(written.error().code() == Error::Code::Io); #endif @@ -515,7 +515,7 @@ TEST_CASE("AMORT propagates envelope checksum and version errors", "[raster-stor const auto payload_path = directory.path() / "tile.amort"; TileCodec amort({ 3, 3 }); REQUIRE(amort.write(path, raster_store::Tile(3))); - const auto bytes = io::read_bytes_from_path(payload_path).value(); + 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 }; } @@ -525,7 +525,7 @@ TEST_CASE("AMORT propagates envelope checksum and version errors", "[raster-stor expected_error = Error::Code::Unsupported; } const auto modified = io::envelope::detail::serialize_to_bytes(envelope).value(); - REQUIRE(io::write_bytes_to_path(modified, payload_path)); + REQUIRE(io::byte::write(modified, payload_path)); CHECK(amort.read(path).error().code() == expected_error); } diff --git a/unittests/terrainlib/store_codec.cpp b/unittests/terrainlib/store_codec.cpp index bd2ff794..c27eac4b 100644 --- a/unittests/terrainlib/store_codec.cpp +++ b/unittests/terrainlib/store_codec.cpp @@ -25,7 +25,7 @@ #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" #include "mesh/codec/Gltf.h" #include "mesh/codec/SfMesh.h" #include "store/Codec.h" diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 0b3d132a..56b35950 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -26,7 +26,7 @@ #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" @@ -250,7 +250,7 @@ TEST_CASE("octree opening retains index failures without directory fallback", "[ CHECK(unknown_codec.error().code() == Error::Code::Unsupported); const std::array malformed_bytes { std::byte { 0xff }, std::byte { 0x00 }, std::byte { 0x01 } }; - REQUIRE(io::write_bytes_to_path(malformed_bytes, index_path).has_value()); + REQUIRE(io::byte::write(malformed_bytes, index_path).has_value()); const auto malformed = mesh::storage::open_folder_indexed(directory.path()); REQUIRE_FALSE(malformed.has_value()); CHECK(malformed.error().code() == Error::Code::CorruptData); diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index c07a962d..b21dddbb 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -27,7 +27,7 @@ #include #include "../temporary_directory.h" -#include "io/bytes.h" +#include "io/byte.h" #include "io/utils.h" #include "octree/StoreTraits.h" #include "octree/store_layout/Mappings.h" @@ -79,7 +79,7 @@ store::path_layout::Mapping test_mapping read_int(const std::filesystem::path& path) { - auto bytes = io::read_bytes_from_path(path); + auto bytes = io::byte::read(path); if (!bytes) { return Error::propagate(std::move(bytes), "read test integer"); } @@ -94,7 +94,7 @@ Expected read_int(const std::filesystem::path& path) Expected write_int(const int value, const std::filesystem::path& path) { const auto bytes = std::as_bytes(std::span(&value, 1)); - auto written = io::write_bytes_to_path(bytes, path); + auto written = io::byte::write(bytes, path); if (!written) { return Error::propagate(std::move(written), "write test integer"); } @@ -245,7 +245,7 @@ TEST_CASE("byte writes report directory creation errors", "[io][bytes]") 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"); REQUIRE_FALSE(result.has_value()); CHECK(result.error().code() == Error::Code::Io); } From 179102d0ffeab1b3e0c82192ac689427395e0f05 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:57:20 +0200 Subject: [PATCH 58/83] replace store copy_from with link_from link_from only hard-links: it fails if the target node exists or the codecs differ, instead of overwriting or re-encoding. sf-merger cut now keeps the input's layout and codec instead of converting to .glb. --- docs/raster-store/todo.md | 2 +- src/sf_merger/NodeWriter.h | 8 +- src/sf_merger/cut.h | 10 +- src/sf_merger/merge.h | 8 +- src/terrainlib/store/RawStorage.h | 35 +--- src/terrainlib/store/Storage.h | 23 +- src/terrainlib/store/ThreadSafeStorage.h | 7 + .../dag_builder/storage_compatibility.cpp | 2 +- unittests/sf_merger/storage_boundaries.cpp | 23 ++ unittests/terrainlib/raster_store.cpp | 2 +- unittests/terrainlib/store_compatibility.cpp | 20 +- unittests/terrainlib/store_storage.cpp | 197 ++++-------------- 12 files changed, 94 insertions(+), 243 deletions(-) diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index 873eb077..ec6bb30c 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -8,7 +8,7 @@ TB dependency checks would become in-memory lookups instead of one envelope header read per source tile, which matters for millions of tiles and on network filesystems. Saving a tile knows the hash from the envelope write, - and `copy_from` takes it from the source index. The cost is 8 bytes per + and `link_from` takes it from the source index. The cost is 8 bytes per entry, about 1.6 times the current index size. Either the generic `store::Index` carries per-node data beyond `NodeStatus`, or the raster store keeps a separate hash table beside it. Header reads remain available diff --git a/src/sf_merger/NodeWriter.h b/src/sf_merger/NodeWriter.h index e0952da1..079293d0 100644 --- a/src/sf_merger/NodeWriter.h +++ b/src/sf_merger/NodeWriter.h @@ -71,7 +71,7 @@ class NodeWriter { return {}; } - Expected copy_subtree_to_output( + Expected link_subtree_to_output( const octree::Id &id, const NodeLoader &loader) { std::optional error = std::nullopt; @@ -86,7 +86,7 @@ class NodeWriter { } DEBUG_ASSERT(status == store::NodeStatus::Leaf); - auto result = this->_storage.copy_from(child_id, loader.storage()); + auto result = this->_storage.link_from(child_id, loader.storage()); if (!result) { error = std::move(result).error(); } @@ -95,11 +95,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/cut.h b/src/sf_merger/cut.h index 29b6feed..4e4c6350 100644 --- a/src/sf_merger/cut.h +++ b/src/sf_merger/cut.h @@ -65,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; @@ -177,8 +177,8 @@ inline Expected cut_dataset( std::filesystem::create_directories(output_path); mesh::storage::OpenOptions options; - options.default_mapping = octree::store_layout::level_and_coordinate_directories(); - options.preferred_extension = ".glb"; + options.default_mapping = input_dataset.layout().mapping(); + options.preferred_extension = std::string(input_dataset.codec_selector().value_or(".sfmesh")); auto output_result = mesh::storage::open_folder_indexed(output_path, std::move(options)); if (!output_result) { return Error::propagate( diff --git a/src/sf_merger/merge.h b/src/sf_merger/merge.h index ef81c58a..3a1b9160 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/merge.h @@ -109,7 +109,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()) { @@ -127,11 +127,11 @@ 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; + if (!link_result) { + return link_result; } } return {}; diff --git a/src/terrainlib/store/RawStorage.h b/src/terrainlib/store/RawStorage.h index 7fcc400e..8b53e6be 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.h @@ -113,13 +113,9 @@ class RawStorage { 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. 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); @@ -134,19 +130,7 @@ class RawStorage { 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)); @@ -154,19 +138,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); diff --git a/src/terrainlib/store/Storage.h b/src/terrainlib/store/Storage.h index da1b27fb..beb5ca8d 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -141,39 +141,28 @@ class Storage { return {}; } - Expected copy_from(const Key& key, const Storage& source) + 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); + auto result = m_raw.link_from(key, source.m_raw); if (!result) { return result; } 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"); + return Error::propagate(std::move(added), "add linked node " + Traits::key_to_string(key) + " to storage index"); } m_dirty = m_dirty || added.value(); } diff --git a/src/terrainlib/store/ThreadSafeStorage.h b/src/terrainlib/store/ThreadSafeStorage.h index ed567d1b..12ec174b 100644 --- a/src/terrainlib/store/ThreadSafeStorage.h +++ b/src/terrainlib/store/ThreadSafeStorage.h @@ -68,6 +68,13 @@ class ThreadSafeStorage { return m_storage.save(key, value); } + template + auto link_from(const key_type& key, const Source& source) const + { + std::unique_lock lock(m_mutex); + return m_storage.link_from(key, source); + } + auto save_or_create_index() const { std::unique_lock lock(m_mutex); diff --git a/unittests/dag_builder/storage_compatibility.cpp b/unittests/dag_builder/storage_compatibility.cpp index e40c4364..70a3419f 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.cpp @@ -195,7 +195,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); diff --git a/unittests/sf_merger/storage_boundaries.cpp b/unittests/sf_merger/storage_boundaries.cpp index 13411e49..12635f64 100644 --- a/unittests/sf_merger/storage_boundaries.cpp +++ b/unittests/sf_merger/storage_boundaries.cpp @@ -24,6 +24,7 @@ #include "cut.h" #include "merge.h" #include "mesh/storage.h" +#include "octree/store_layout/Mappings.h" namespace { @@ -194,3 +195,25 @@ TEST_CASE("SF cut hard-links unchanged leaves and writes clipped leaves", "[sfme CHECK_FALSE(clipped->is_empty()); } } + +TEST_CASE("SF cut output keeps the input layout and codec", "[sfmerger][sf][link]") +{ + const octree::Id root = octree::Id::root(); + TemporaryDirectory input_directory("cut-format-input"); + TemporaryDirectory output_directory("cut-format-output"); + mesh::storage::OpenOptions options; + options.default_mapping = octree::store_layout::flat(); + options.preferred_extension = ".glb"; + auto input_result = mesh::storage::open_folder_indexed(input_directory.path(), std::move(options)); + REQUIRE(input_result.has_value()); + auto input = std::move(input_result.value()); + REQUIRE(input.save(root, triangle()).has_value()); + REQUIRE(input.save_index().has_value()); + + const auto output_path = output_directory.path() / "cut"; + REQUIRE(cut_dataset(input, MeshMask {}, output_path, false).has_value()); + const auto output = indexed_storage(output_path); + CHECK(output.codec_selector() == input.codec_selector()); + CHECK(output.layout().mapping().id == input.layout().mapping().id); + CHECK(std::filesystem::equivalent(input.paths(root)->front(), output.paths(root)->front())); +} diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 85fa765f..a7db4845 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -390,7 +390,7 @@ TEST_CASE("cross-root hard links and independent attribution survive RF removal" 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->copy_from(key, *source)); + 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)); diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 56b35950..0d47eef8 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -114,30 +114,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"); diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index b21dddbb..4a5dec1a 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -143,43 +143,6 @@ class MultiFileCodec final : public store::Codec { } }; -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; -}; - std::atomic_uint32_t index_writes = 0; Expected> unused_index_read(const std::filesystem::path& path) @@ -341,19 +304,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())); @@ -361,13 +324,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); @@ -375,137 +338,51 @@ 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") - { - TemporaryDirectory directory("copy-self"); - auto storage = make_indexed_storage(directory.path()); - REQUIRE(storage.save(root, 43).has_value()); - storage.settings().allow_overwrite = true; - - REQUIRE(storage.copy_from(root, storage).has_value()); - CHECK(storage.load(root).value() == 43); - } } -TEST_CASE("copy_from re-encodes differing path lists", "[store][storage][copy]") +TEST_CASE("link_from rejects existing targets, missing sources and different codecs", "[store][storage][link]") { 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()); - - 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())); -} -TEST_CASE("copy_from enforces overwrite settings", "[store][storage][copy]") -{ - const octree::Id root = octree::Id::root(); - TemporaryDirectory source_directory("copy-overwrite-source"); - TemporaryDirectory target_directory("copy-overwrite-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())); -} - -TEST_CASE("failed multi-file overwrite leaves the target node unindexed", "[store][storage][copy]") -{ - 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); - 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])); -} + 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()); -TEST_CASE("copy_from propagates source and codec failures", "[store][storage][copy]") -{ - const octree::Id root = octree::Id::root(); + 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 source_directory("copy-missing-source"); - TemporaryDirectory target_directory("copy-missing-target"); + 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(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); - } - 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); + 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("encode failure") + SECTION("different codecs") { - 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); + 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()); } } From 1c29b657898b65bcc11507fa16bd306f3e523885 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:56:44 +0200 Subject: [PATCH 59/83] move CLI helpers to cli_support log and ProgressIndicator move to terrainlib/cli_support in namespace cli_support. The SIGINT/SIGTERM handling duplicated in the rf-builder and rf-merger mains becomes cli_support::stop_signal. --- src/dag_builder/build.cpp | 6 +-- src/dag_builder/main.cpp | 4 +- src/dag_builder/merge/clusters.h | 2 +- src/dag_builder/merge/weld.h | 2 +- src/dag_builder/split.h | 2 +- src/dag_builder/texture_reuse.h | 2 +- src/dag_builder/texturing.h | 2 +- src/dag_convert_debug/main.cpp | 8 ++-- src/mesh_convert/main.cpp | 4 +- src/sf_browser/core/Application.cpp | 2 +- src/sf_browser/core/Buffer.cpp | 2 +- src/sf_browser/core/Camera.cpp | 2 +- src/sf_browser/core/shader/Shader.cpp | 2 +- src/sf_browser/core/shader/ShaderProgram.cpp | 2 +- src/sf_browser/core/window/Window.cpp | 2 +- src/sf_browser/main.cpp | 6 +-- src/sf_builder/main.cpp | 4 +- src/sf_builder/mesh_builder.cpp | 2 +- src/sf_builder/raw_dataset_reader.h | 2 +- src/sf_builder/terrainbuilder.cpp | 8 ++-- src/sf_builder/texture_assembler.h | 2 +- src/sf_index_browser/main.cpp | 4 +- src/sf_merger/cut.h | 2 +- src/sf_merger/main.cpp | 4 +- src/sf_merger/merge/NodeData.h | 2 +- src/terrainlib/CMakeLists.txt | 6 ++- .../{ => cli_support}/ProgressIndicator.cpp | 6 ++- .../{ => cli_support}/ProgressIndicator.h | 4 ++ src/terrainlib/{ => cli_support}/log.cpp | 6 ++- src/terrainlib/{ => cli_support}/log.h | 20 +++++--- src/terrainlib/cli_support/stop_signal.cpp | 46 +++++++++++++++++++ src/terrainlib/cli_support/stop_signal.h | 30 ++++++++++++ src/terrainlib/gis/Dataset.cpp | 2 +- src/terrainlib/gis/Dataset.h | 2 +- src/terrainlib/gis/DatasetReader.cpp | 2 +- src/terrainlib/gis/gdal.cpp | 4 +- src/terrainlib/io/byte.cpp | 2 +- src/terrainlib/mesh/clip.cpp | 2 +- src/terrainlib/mesh/encode.h | 2 +- src/terrainlib/mesh/holes.cpp | 2 +- src/terrainlib/mesh/io.cpp | 2 +- src/terrainlib/mesh/io/gltf.cpp | 2 +- src/terrainlib/mesh/merging/VertexMapping.h | 2 +- src/terrainlib/mesh/merging/mapping.cpp | 2 +- src/terrainlib/mesh/texture_trim.cpp | 2 +- src/terrainlib/mesh/validate.cpp | 2 +- src/terrainlib/mesh/validate_cgal.inl | 2 +- src/terrainlib/pch.h | 2 +- src/terrainlib/polygon/triangulate.cpp | 2 +- src/terrainlib/spatial_lookup/CellBased.h | 2 +- src/terrainlib/store/Index.h | 2 +- src/terrainlib/store/NodeStatus.h | 2 +- src/terrainlib/store/NodeStatusOrMissing.h | 2 +- src/terrainlib/store/Storage.h | 2 +- src/terrainlib/uv/atlas.cpp | 2 +- src/tile_downloader/TileLogger.h | 6 +-- src/tile_downloader/main.cpp | 4 +- unittests/CMakeLists.txt | 1 + unittests/sf_merger/sphere_projector.cpp | 2 +- unittests/terrainlib/mesh_holes.cpp | 2 +- .../terrainlib/progress_indicator_test.cpp | 16 +++---- unittests/terrainlib/stop_signal.cpp | 34 ++++++++++++++ 62 files changed, 222 insertions(+), 89 deletions(-) rename src/terrainlib/{ => cli_support}/ProgressIndicator.cpp (97%) rename src/terrainlib/{ => cli_support}/ProgressIndicator.h (96%) rename src/terrainlib/{ => cli_support}/log.cpp (93%) rename src/terrainlib/{ => cli_support}/log.h (81%) create mode 100644 src/terrainlib/cli_support/stop_signal.cpp create mode 100644 src/terrainlib/cli_support/stop_signal.h create mode 100644 unittests/terrainlib/stop_signal.cpp diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index 85b03815..1190b250 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -36,7 +36,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" @@ -46,7 +46,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" @@ -552,7 +552,7 @@ std::unordered_set build_level( tbb::concurrent_vector saved_ids; - ProgressIndicator progress(targets.size()); + cli_support::ProgressIndicator progress(targets.size()); auto progress_thread = progress.start_monitoring(); parallel_foreach(targets, [&](const octree::Id &target) { diff --git a/src/dag_builder/main.cpp b/src/dag_builder/main.cpp index 2ab78ef5..cbd8d421 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -23,7 +23,7 @@ #include "cli.h" #include "build.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/storage.h" #include "storage.h" #include "ContinuationMode.h" @@ -31,7 +31,7 @@ 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); diff --git a/src/dag_builder/merge/clusters.h b/src/dag_builder/merge/clusters.h index 2a46e62a..f5e08051 100644 --- a/src/dag_builder/merge/clusters.h +++ b/src/dag_builder/merge/clusters.h @@ -29,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 9e74f3d3..fd5a09db 100644 --- a/src/dag_builder/merge/weld.h +++ b/src/dag_builder/merge/weld.h @@ -32,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/split.h b/src/dag_builder/split.h index bde5128c..723964d8 100644 --- a/src/dag_builder/split.h +++ b/src/dag_builder/split.h @@ -28,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/texture_reuse.h b/src/dag_builder/texture_reuse.h index d974c579..3d82df9a 100644 --- a/src/dag_builder/texture_reuse.h +++ b/src/dag_builder/texture_reuse.h @@ -29,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/texturing.h b/src/dag_builder/texturing.h index 879f9d74..355ed5a9 100644 --- a/src/dag_builder/texturing.h +++ b/src/dag_builder/texturing.h @@ -33,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_convert_debug/main.cpp b/src/dag_convert_debug/main.cpp index 45e05e40..1029170d 100644 --- a/src/dag_convert_debug/main.cpp +++ b/src/dag_convert_debug/main.cpp @@ -23,11 +23,11 @@ #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 "utils.h" @@ -83,7 +83,7 @@ void export_storage(const cli::Args &args) { 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()) { @@ -135,7 +135,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/main.cpp b/src/mesh_convert/main.cpp index f7653f40..e6a45923 100644 --- a/src/mesh_convert/main.cpp +++ b/src/mesh_convert/main.cpp @@ -24,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) { @@ -57,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/core/Application.cpp b/src/sf_browser/core/Application.cpp index ad517dd2..efa9a4de 100644 --- a/src/sf_browser/core/Application.cpp +++ b/src/sf_browser/core/Application.cpp @@ -21,7 +21,7 @@ #include "Application.h" #include -#include +#include #include diff --git a/src/sf_browser/core/Buffer.cpp b/src/sf_browser/core/Buffer.cpp index 9a29b3de..81bcf9d3 100644 --- a/src/sf_browser/core/Buffer.cpp +++ b/src/sf_browser/core/Buffer.cpp @@ -18,7 +18,7 @@ *****************************************************************************/ #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/Camera.cpp b/src/sf_browser/core/Camera.cpp index 3241dff4..5cd23cfe 100644 --- a/src/sf_browser/core/Camera.cpp +++ b/src/sf_browser/core/Camera.cpp @@ -18,7 +18,7 @@ *****************************************************************************/ #include "Camera.h" -#include +#include Camera::Camera(CameraConfig config) : m_fov_deg(config.fov_deg), diff --git a/src/sf_browser/core/shader/Shader.cpp b/src/sf_browser/core/shader/Shader.cpp index 4ecb7807..a4aa2954 100644 --- a/src/sf_browser/core/shader/Shader.cpp +++ b/src/sf_browser/core/shader/Shader.cpp @@ -18,7 +18,7 @@ *****************************************************************************/ #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/ShaderProgram.cpp b/src/sf_browser/core/shader/ShaderProgram.cpp index 25f54b0c..c23dafc5 100644 --- a/src/sf_browser/core/shader/ShaderProgram.cpp +++ b/src/sf_browser/core/shader/ShaderProgram.cpp @@ -19,7 +19,7 @@ #include "ShaderProgram.h" #include -#include +#include #include ShaderProgram::ShaderProgram() { diff --git a/src/sf_browser/core/window/Window.cpp b/src/sf_browser/core/window/Window.cpp index 7826b66d..c9e0e97b 100644 --- a/src/sf_browser/core/window/Window.cpp +++ b/src/sf_browser/core/window/Window.cpp @@ -18,7 +18,7 @@ *****************************************************************************/ #include "Window.h" -#include +#include #include std::atomic Window::glfw_initialized(false); diff --git a/src/sf_browser/main.cpp b/src/sf_browser/main.cpp index fc56013b..650a4fb3 100644 --- a/src/sf_browser/main.cpp +++ b/src/sf_browser/main.cpp @@ -18,14 +18,14 @@ *****************************************************************************/ #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_builder/main.cpp b/src/sf_builder/main.cpp index 9da7f7ba..91fb31f2 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -32,7 +32,7 @@ #include #include "gis/Dataset.h" -#include "log.h" +#include "cli_support/log.h" #include "gis/srs.h" #include "gis/webmercator.h" #include "terrainbuilder.h" @@ -296,7 +296,7 @@ int run(std::span args) { CLI11_PARSE(app, argc, argv); - Log::init(log_level); + cli_support::Log::init(log_level); gis::Dataset dataset(dataset_path.string()); log_dataset_overview(dataset); diff --git a/src/sf_builder/mesh_builder.cpp b/src/sf_builder/mesh_builder.cpp index 76b04110..91de8226 100644 --- a/src/sf_builder/mesh_builder.cpp +++ b/src/sf_builder/mesh_builder.cpp @@ -32,7 +32,7 @@ #include #include "gis/Dataset.h" -#include "log.h" +#include "cli_support/log.h" #include "mesh/SimpleMesh.h" #include "mesh/cleanup.h" #include "mesh_builder.h" diff --git a/src/sf_builder/raw_dataset_reader.h b/src/sf_builder/raw_dataset_reader.h index 77a6659b..ec308415 100644 --- a/src/sf_builder/raw_dataset_reader.h +++ b/src/sf_builder/raw_dataset_reader.h @@ -32,7 +32,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" #include namespace terrainbuilder { diff --git a/src/sf_builder/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 2df1656b..1e2e992d 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -47,8 +47,8 @@ #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" @@ -336,7 +336,7 @@ 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 @@ -354,7 +354,7 @@ Expected build_all_patches( }); // 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 diff --git a/src/sf_builder/texture_assembler.h b/src/sf_builder/texture_assembler.h index 34f9ccea..f7e5a84a 100644 --- a/src/sf_builder/texture_assembler.h +++ b/src/sf_builder/texture_assembler.h @@ -44,7 +44,7 @@ #include "gis/webmercator.h" #include "tile_provider.h" -#include "log.h" +#include "cli_support/log.h" namespace terrainbuilder { diff --git a/src/sf_index_browser/main.cpp b/src/sf_index_browser/main.cpp index 03ff728d..8e1db413 100644 --- a/src/sf_index_browser/main.cpp +++ b/src/sf_index_browser/main.cpp @@ -30,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" @@ -322,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/cut.h b/src/sf_merger/cut.h index 4e4c6350..bf3f0799 100644 --- a/src/sf_merger/cut.h +++ b/src/sf_merger/cut.h @@ -24,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" diff --git a/src/sf_merger/main.cpp b/src/sf_merger/main.cpp index cb685c5a..56a0bbf5 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.cpp @@ -25,7 +25,7 @@ #include "cli.h" #include "cut.h" -#include "log.h" +#include "cli_support/log.h" #include "mask.h" #include "mesh/storage.h" #include "merge.h" @@ -127,7 +127,7 @@ void run(const cli::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) { diff --git a/src/sf_merger/merge/NodeData.h b/src/sf_merger/merge/NodeData.h index ee135e18..7108944f 100644 --- a/src/sf_merger/merge/NodeData.h +++ b/src/sf_merger/merge/NodeData.h @@ -22,7 +22,7 @@ #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/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index a5298baa..0bc03894 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -20,7 +20,6 @@ add_library(terrainlib Error.h Error.cpp ImageKey.h - ProgressIndicator.h ProgressIndicator.cpp Range.h SphereProjector.h VecHash.h @@ -31,7 +30,6 @@ add_library(terrainlib fmt_impls.h glm_utils.h hash_utils.h - log.h log.cpp macros.h opencv_utils.h optional_utils.h @@ -43,6 +41,10 @@ add_library(terrainlib 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 diff --git a/src/terrainlib/ProgressIndicator.cpp b/src/terrainlib/cli_support/ProgressIndicator.cpp similarity index 97% rename from src/terrainlib/ProgressIndicator.cpp rename to src/terrainlib/cli_support/ProgressIndicator.cpp index 85f7e1e1..cee9d26e 100644 --- a/src/terrainlib/ProgressIndicator.cpp +++ b/src/terrainlib/cli_support/ProgressIndicator.cpp @@ -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 96% rename from src/terrainlib/ProgressIndicator.h rename to src/terrainlib/cli_support/ProgressIndicator.h index 737a8237..fcb52ee5 100644 --- a/src/terrainlib/ProgressIndicator.h +++ b/src/terrainlib/cli_support/ProgressIndicator.h @@ -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/log.cpp b/src/terrainlib/cli_support/log.cpp similarity index 93% rename from src/terrainlib/log.cpp rename to src/terrainlib/cli_support/log.cpp index 99eb8e8e..1d1ec287 100644 --- a/src/terrainlib/log.cpp +++ b/src/terrainlib/cli_support/log.cpp @@ -19,7 +19,9 @@ #include #include -#include "log.h" +#include "cli_support/log.h" + +namespace cli_support { std::shared_ptr Log::logger; @@ -28,3 +30,5 @@ void Log::init(spdlog::level::level_enum log_level) { Log::logger = spdlog::stderr_color_mt("LOG"); Log::logger->set_level(log_level); } + +} // namespace cli_support diff --git a/src/terrainlib/log.h b/src/terrainlib/cli_support/log.h similarity index 81% rename from src/terrainlib/log.h rename to src/terrainlib/cli_support/log.h index 47f9ea22..2ca67ae6 100644 --- a/src/terrainlib/log.h +++ b/src/terrainlib/cli_support/log.h @@ -27,6 +27,8 @@ #include #include +namespace cli_support { + class Log { private: static std::shared_ptr logger; @@ -40,11 +42,13 @@ class Log { } }; -#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__) +} // 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__); \ @@ -61,6 +65,8 @@ class Log { } \ } while (false) +namespace cli_support { + // 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 { @@ -78,10 +84,12 @@ class LogBackoff { uint64_t _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 LogBackoff _log_warn_backoff; \ + static ::cli_support::LogBackoff _log_warn_backoff; \ if (_log_warn_backoff.should_log()) { \ LOG_WARN(fmt_str " (occurrence {})", ##__VA_ARGS__, _log_warn_backoff.count()); \ } \ diff --git a/src/terrainlib/cli_support/stop_signal.cpp b/src/terrainlib/cli_support/stop_signal.cpp new file mode 100644 index 00000000..3272186b --- /dev/null +++ b/src/terrainlib/cli_support/stop_signal.cpp @@ -0,0 +1,46 @@ +/***************************************************************************** + * 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 "cli_support/stop_signal.h" + +#include +#include + +namespace cli_support::stop_signal { + +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 + +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/terrainlib/cli_support/stop_signal.h b/src/terrainlib/cli_support/stop_signal.h new file mode 100644 index 00000000..92450bc5 --- /dev/null +++ b/src/terrainlib/cli_support/stop_signal.h @@ -0,0 +1,30 @@ +/***************************************************************************** + * 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 + +namespace cli_support::stop_signal { + +// 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(); + +} // namespace cli_support::stop_signal diff --git a/src/terrainlib/gis/Dataset.cpp b/src/terrainlib/gis/Dataset.cpp index 2f47c17d..65e142e6 100644 --- a/src/terrainlib/gis/Dataset.cpp +++ b/src/terrainlib/gis/Dataset.cpp @@ -32,7 +32,7 @@ #include "gis/gdal.h" #include "gis/srs.h" -#include "log.h" +#include "cli_support/log.h" namespace gis { diff --git a/src/terrainlib/gis/Dataset.h b/src/terrainlib/gis/Dataset.h index c9f54c1a..ad911e0d 100644 --- a/src/terrainlib/gis/Dataset.h +++ b/src/terrainlib/gis/Dataset.h @@ -26,7 +26,7 @@ #include #include "Error.h" -#include "log.h" +#include "cli_support/log.h" #include class GDALDataset; diff --git a/src/terrainlib/gis/DatasetReader.cpp b/src/terrainlib/gis/DatasetReader.cpp index dea3bf2b..32da97bf 100644 --- a/src/terrainlib/gis/DatasetReader.cpp +++ b/src/terrainlib/gis/DatasetReader.cpp @@ -41,7 +41,7 @@ #include #include -#include "log.h" +#include "cli_support/log.h" namespace gis { diff --git a/src/terrainlib/gis/gdal.cpp b/src/terrainlib/gis/gdal.cpp index 65e0c591..12b6f356 100644 --- a/src/terrainlib/gis/gdal.cpp +++ b/src/terrainlib/gis/gdal.cpp @@ -22,7 +22,7 @@ #include #include "gis/gdal.h" -#include "log.h" +#include "cli_support/log.h" namespace gis::gdal { @@ -53,7 +53,7 @@ inline void GdalErrorHandler(CPLErr eErrClass, int err_no, const char *msg) { break; } - Log::get_logger().get()->log(level, "GDAL({}): {}", err_no, msg); + cli_support::Log::get_logger().get()->log(level, "GDAL({}): {}", err_no, msg); } } // namespace diff --git a/src/terrainlib/io/byte.cpp b/src/terrainlib/io/byte.cpp index 32b7745c..5a5e31e4 100644 --- a/src/terrainlib/io/byte.cpp +++ b/src/terrainlib/io/byte.cpp @@ -23,7 +23,7 @@ #include "io/byte.h" #include "io/utils.h" -#include "log.h" +#include "cli_support/log.h" namespace io::byte { diff --git a/src/terrainlib/mesh/clip.cpp b/src/terrainlib/mesh/clip.cpp index 18210c8d..e5ef1e3f 100644 --- a/src/terrainlib/mesh/clip.cpp +++ b/src/terrainlib/mesh/clip.cpp @@ -38,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/encode.h b/src/terrainlib/mesh/encode.h index a7a511d5..8cd3fadc 100644 --- a/src/terrainlib/mesh/encode.h +++ b/src/terrainlib/mesh/encode.h @@ -25,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/holes.cpp b/src/terrainlib/mesh/holes.cpp index a3e8c4f8..ff98fbb5 100644 --- a/src/terrainlib/mesh/holes.cpp +++ b/src/terrainlib/mesh/holes.cpp @@ -31,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/io.cpp b/src/terrainlib/mesh/io.cpp index a1f32405..4a615f33 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -18,7 +18,7 @@ *****************************************************************************/ #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" diff --git a/src/terrainlib/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index 724d7097..60295c3b 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -35,7 +35,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" diff --git a/src/terrainlib/mesh/merging/VertexMapping.h b/src/terrainlib/mesh/merging/VertexMapping.h index 6727aa72..30b94b6a 100644 --- a/src/terrainlib/mesh/merging/VertexMapping.h +++ b/src/terrainlib/mesh/merging/VertexMapping.h @@ -32,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/mapping.cpp b/src/terrainlib/mesh/merging/mapping.cpp index c445b1ab..3b7e0a56 100644 --- a/src/terrainlib/mesh/merging/mapping.cpp +++ b/src/terrainlib/mesh/merging/mapping.cpp @@ -36,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/texture_trim.cpp b/src/terrainlib/mesh/texture_trim.cpp index 46a39e42..c8d90e6f 100644 --- a/src/terrainlib/mesh/texture_trim.cpp +++ b/src/terrainlib/mesh/texture_trim.cpp @@ -26,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/validate.cpp b/src/terrainlib/mesh/validate.cpp index 79e8960e..7a87d829 100644 --- a/src/terrainlib/mesh/validate.cpp +++ b/src/terrainlib/mesh/validate.cpp @@ -28,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_cgal.inl b/src/terrainlib/mesh/validate_cgal.inl index 59a9b256..c6d58cff 100644 --- a/src/terrainlib/mesh/validate_cgal.inl +++ b/src/terrainlib/mesh/validate_cgal.inl @@ -20,7 +20,7 @@ #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/pch.h b/src/terrainlib/pch.h index 63d27427..77dd0944 100644 --- a/src/terrainlib/pch.h +++ b/src/terrainlib/pch.h @@ -56,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/triangulate.cpp b/src/terrainlib/polygon/triangulate.cpp index e7154140..44087c90 100644 --- a/src/terrainlib/polygon/triangulate.cpp +++ b/src/terrainlib/polygon/triangulate.cpp @@ -31,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/spatial_lookup/CellBased.h b/src/terrainlib/spatial_lookup/CellBased.h index 6bee284a..da017421 100644 --- a/src/terrainlib/spatial_lookup/CellBased.h +++ b/src/terrainlib/spatial_lookup/CellBased.h @@ -23,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/store/Index.h b/src/terrainlib/store/Index.h index e2d15457..ca13074e 100644 --- a/src/terrainlib/store/Index.h +++ b/src/terrainlib/store/Index.h @@ -25,7 +25,7 @@ #include #include "Error.h" -#include "log.h" +#include "cli_support/log.h" #include "store/NodeStatus.h" #include "store/Traits.h" #include diff --git a/src/terrainlib/store/NodeStatus.h b/src/terrainlib/store/NodeStatus.h index 885a4151..c7dadba4 100644 --- a/src/terrainlib/store/NodeStatus.h +++ b/src/terrainlib/store/NodeStatus.h @@ -22,7 +22,7 @@ #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 fab68328..8dab27c3 100644 --- a/src/terrainlib/store/NodeStatusOrMissing.h +++ b/src/terrainlib/store/NodeStatusOrMissing.h @@ -29,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/Storage.h b/src/terrainlib/store/Storage.h index beb5ca8d..fabd5c11 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -26,7 +26,7 @@ #include -#include "log.h" +#include "cli_support/log.h" #include "store/IndexFormat.h" #include "store/RawStorage.h" diff --git a/src/terrainlib/uv/atlas.cpp b/src/terrainlib/uv/atlas.cpp index dcc1aace..957b5b6c 100644 --- a/src/terrainlib/uv/atlas.cpp +++ b/src/terrainlib/uv/atlas.cpp @@ -24,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/tile_downloader/TileLogger.h b/src/tile_downloader/TileLogger.h index 47281bba..d0b4e555 100644 --- a/src/tile_downloader/TileLogger.h +++ b/src/tile_downloader/TileLogger.h @@ -34,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 {}; @@ -88,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/main.cpp b/src/tile_downloader/main.cpp index 100211a0..19eae88a 100644 --- a/src/tile_downloader/main.cpp +++ b/src/tile_downloader/main.cpp @@ -21,11 +21,11 @@ #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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 510d9ba8..c7d3b034 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -93,6 +93,7 @@ add_executable(unittests_terrainlib terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp + terrainlib/stop_signal.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp terrainlib/tiny_vector.cpp diff --git a/unittests/sf_merger/sphere_projector.cpp b/unittests/sf_merger/sphere_projector.cpp index 542a23e2..c1ecd429 100644 --- a/unittests/sf_merger/sphere_projector.cpp +++ b/unittests/sf_merger/sphere_projector.cpp @@ -23,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/terrainlib/mesh_holes.cpp b/unittests/terrainlib/mesh_holes.cpp index 8357e7e2..de62be50 100644 --- a/unittests/terrainlib/mesh_holes.cpp +++ b/unittests/terrainlib/mesh_holes.cpp @@ -31,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/progress_indicator_test.cpp b/unittests/terrainlib/progress_indicator_test.cpp index 5826b686..f2c312ab 100644 --- a/unittests/terrainlib/progress_indicator_test.cpp +++ b/unittests/terrainlib/progress_indicator_test.cpp @@ -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/stop_signal.cpp b/unittests/terrainlib/stop_signal.cpp new file mode 100644 index 00000000..61b1b985 --- /dev/null +++ b/unittests/terrainlib/stop_signal.cpp @@ -0,0 +1,34 @@ +/***************************************************************************** + * 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 "cli_support/stop_signal.h" + +TEST_CASE("stop signals are recorded and mapped to the conventional exit code", "[cli_support]") +{ + cli_support::stop_signal::install(); + CHECK_FALSE(cli_support::stop_signal::requested()); + CHECK(cli_support::stop_signal::exit_code() == 1); + + REQUIRE(std::raise(SIGTERM) == 0); + CHECK(cli_support::stop_signal::requested()); + CHECK(cli_support::stop_signal::exit_code() == 128 + SIGTERM); +} From 19419ec5b064d5b28336d61db524c2e9e17c2cee Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:03 +0200 Subject: [PATCH 60/83] share snapshot, input record and cache helpers store::snapshot owns the .part lifecycle (create, publish, link filesystem check, checkpoint interval) and store::inputs the input record, fingerprints and cache validation. raster_store, rf-builder and rf-merger now use them instead of their own copies, and octree::storage::create/publish give octree stores the same lifecycle. --- src/terrainlib/CMakeLists.txt | 3 + src/terrainlib/octree/storage/create.h | 73 ++++++++++ src/terrainlib/raster_store/storage.h | 52 ++----- src/terrainlib/store/inputs.cpp | 58 ++++++++ src/terrainlib/store/inputs.h | 75 ++++++++++ src/terrainlib/store/snapshot.cpp | 130 +++++++++++++++++ src/terrainlib/store/snapshot.h | 45 ++++++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/raster_store.cpp | 1 + unittests/terrainlib/store_snapshot.cpp | 177 ++++++++++++++++++++++++ 10 files changed, 572 insertions(+), 43 deletions(-) create mode 100644 src/terrainlib/octree/storage/create.h create mode 100644 src/terrainlib/store/inputs.cpp create mode 100644 src/terrainlib/store/inputs.h create mode 100644 src/terrainlib/store/snapshot.cpp create mode 100644 src/terrainlib/store/snapshot.h create mode 100644 unittests/terrainlib/store_snapshot.cpp diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 0bc03894..936e83db 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -156,6 +156,7 @@ add_library(terrainlib octree/Space.h octree/Space.cpp octree/StoreTraits.h octree/storage/IndexFile.h + octree/storage/create.h octree/storage/open_runtime.h octree/store_layout/Flat.h octree/store_layout/LevelAndCoordinateDirectories.h @@ -220,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 @@ -231,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 diff --git a/src/terrainlib/octree/storage/create.h b/src/terrainlib/octree/storage/create.h new file mode 100644 index 00000000..c000aa69 --- /dev/null +++ b/src/terrainlib/octree/storage/create.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/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index 5826db0e..3f47ec03 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -26,12 +26,12 @@ #include #include -#include "io/utils.h" #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 { @@ -55,19 +55,6 @@ struct CreateOptions { namespace detail { - inline 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 {}; - } - // The owned handle is destroyed on return, before publication renames its directory. template Expected finish_output(std::unique_ptr> storage) @@ -141,28 +128,16 @@ Expected>, std::unique_ptr publish(std::unique_ptr> storage) return Error::fail(Error::Code::InvalidInput, "publication requires an output handle"); } const auto partial_path = storage->base_path().lexically_normal(); - if (partial_path.extension() != ".part") { + if (!store::snapshot::is_partial(partial_path)) { return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); } - auto destination = partial_path; - destination.replace_extension(); - if (auto absent = detail::require_absent(destination); !absent) { - return absent; - } if (auto finished = detail::finish_output(std::move(storage)); !finished) { return Error::propagate(std::move(finished), "finish raster snapshot before publication"); } - auto renamed = ::io::utils::rename_without_replacement(partial_path, destination); - if (!renamed) { - return Error::propagate(std::move(renamed), "publish raster snapshot"); - } - return {}; + return store::snapshot::publish(partial_path); } } // namespace raster_store::storage 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..4fce5d77 --- /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_to_path(record, snapshot / file_name); + 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_from_path(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/snapshot.cpp b/src/terrainlib/store/snapshot.cpp new file mode 100644 index 00000000..8f6b96a4 --- /dev/null +++ b/src/terrainlib/store/snapshot.cpp @@ -0,0 +1,130 @@ +/***************************************************************************** + * 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 "io/utils.h" +#include "store/inputs.h" + +namespace store::snapshot { + +std::filesystem::path partial_path(const std::filesystem::path& final_path) +{ + auto path = final_path.lexically_normal(); + path += ".part"; + return path; +} + +bool is_partial(const std::filesystem::path& path) +{ + auto normalized = path.lexically_normal(); + if (!normalized.has_filename()) { + normalized = normalized.parent_path(); + } + return normalized.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 = final_path.lexically_normal(); + 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 = partial_path.lexically_normal(); + 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; + } + const auto inputs_path = partial / inputs::file_name; + std::error_code error; + std::filesystem::remove(inputs_path, error); + if (error) { + return Error::fail(Error::Code::Io, "remove input record before publication", inputs_path, error); + } + auto renamed = ::io::utils::rename_without_replacement(partial, destination); + if (!renamed) { + return Error::propagate(std::move(renamed), "publish snapshot"); + } + 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..3f9a10fd --- /dev/null +++ b/src/terrainlib/store/snapshot.h @@ -0,0 +1,45 @@ +/***************************************************************************** + * 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); + +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); +// Removes the inputs record and renames .part to . 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/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index c7d3b034..fdf8e0e1 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -61,6 +61,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 diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index a7db4845..a31bb08c 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -30,6 +30,7 @@ #include "../temporary_directory.h" #include "io/byte.h" +#include "io/utils.h" #include "raster_store/storage.h" namespace { diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp new file mode 100644 index 00000000..a417c23f --- /dev/null +++ b/unittests/terrainlib/store_snapshot.cpp @@ -0,0 +1,177 @@ +/***************************************************************************** + * 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/create.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") + { + REQUIRE(std::filesystem::create_directory(final_path)); + const auto published = store::snapshot::publish(partial_path); + REQUIRE_FALSE(published.has_value()); + CHECK(published.error().code() == Error::Code::AlreadyExists); + CHECK(std::filesystem::is_directory(partial_path)); + } +} + +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); +} From 55d849f0562ad0e268c12fb896359b8aa6c6a06b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:12:57 +0200 Subject: [PATCH 61/83] sf-builder: build into .part with cache instead of overwrite batch writes .part and publishes it to a new ; it no longer resumes or overwrites an existing output. --cache reuses the nodes of an interrupted build whose inputs.tmp matches. The index is checkpointed every two minutes, and SIGINT/SIGTERM finish the active nodes and keep the .part. --- src/sf_builder/CMakeLists.txt | 1 + src/sf_builder/inputs.h | 49 +++++++++++ src/sf_builder/main.cpp | 41 +++++---- src/sf_builder/terrainbuilder.cpp | 133 ++++++++++++++++++++---------- src/sf_builder/terrainbuilder.h | 32 ++++--- src/terrainlib/mesh/storage.h | 13 +++ unittests/sf_builder/coverage.cpp | 84 ++++++++++++++++++- 7 files changed, 279 insertions(+), 74 deletions(-) create mode 100644 src/sf_builder/inputs.h diff --git a/src/sf_builder/CMakeLists.txt b/src/sf_builder/CMakeLists.txt index a55c3a65..a9eedef5 100644 --- a/src/sf_builder/CMakeLists.txt +++ b/src/sf_builder/CMakeLists.txt @@ -18,6 +18,7 @@ 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/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 91fb31f2..0cb98006 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -31,12 +31,13 @@ #include #include -#include "gis/Dataset.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 "terrainbuilder.h" #include "octree/Space.h" +#include "terrainbuilder.h" #include "tile_provider.h" OGRSpatialReference parse_srs(const std::string &user_input) { @@ -282,7 +283,7 @@ int run(std::span args) { ->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") @@ -291,8 +292,8 @@ 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); @@ -344,19 +345,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/terrainbuilder.cpp b/src/sf_builder/terrainbuilder.cpp index 1e2e992d..626507df 100644 --- a/src/sf_builder/terrainbuilder.cpp +++ b/src/sf_builder/terrainbuilder.cpp @@ -17,6 +17,7 @@ * along with this program. If not, see . *****************************************************************************/ +#include #include #include #include @@ -60,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 { @@ -197,40 +200,46 @@ T expect(const std::optional &opt, const std::string &msg) { } } -Expected build_all_patches( - gis::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 (target_level < min_target_level) { - return Error::fail( - Error::Code::InvalidInput, fmt::format("target level {} is below the minimum of {}", unsigned(target_level), unsigned(min_target_level))); +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); - 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); + 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 open_options; - open_options.preferred_extension = output_format; - auto storage_result = mesh::storage::open_folder( - output_base_path, - std::move(open_options)); + 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() + "\""); } - mesh::storage::Storage raw_storage = std::move(storage_result.value()); - raw_storage.settings().allow_overwrite = overwrite_existing; - store::ThreadSafeStorage storage(std::move(raw_storage)); + if (auto written = store::inputs::write(storage_result->base_path(), record); !written) { + return written; + } + store::ThreadSafeStorage storage(std::move(storage_result.value())); auto dataset_srs_result = dataset.srs(); if (!dataset_srs_result) { @@ -362,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(); @@ -409,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); @@ -420,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 9d300f2c..3842c604 100644 --- a/src/sf_builder/terrainbuilder.h +++ b/src/sf_builder/terrainbuilder.h @@ -19,17 +19,20 @@ #pragma once -#include #include +#include +#include +#include #include +#include "Error.h" #include "gis/Dataset.h" -#include "octree/Id.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 { @@ -53,13 +56,16 @@ std::optional build_patch( const TileProvider *tile_provider, const OGRSpatialReference &mesh_srs); -Expected build_all_patches( - gis::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/terrainlib/mesh/storage.h b/src/terrainlib/mesh/storage.h index 98220a38..07c2a545 100644 --- a/src/terrainlib/mesh/storage.h +++ b/src/terrainlib/mesh/storage.h @@ -25,6 +25,7 @@ #include "mesh/codec/from_extension.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" +#include "octree/storage/create.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" @@ -51,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/unittests/sf_builder/coverage.cpp b/unittests/sf_builder/coverage.cpp index 66a71880..b40a37d5 100644 --- a/unittests/sf_builder/coverage.cpp +++ b/unittests/sf_builder/coverage.cpp @@ -27,9 +27,11 @@ #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 "gis/srs.h" +#include "store/inputs.h" #include "terrainbuilder.h" using test::TemporaryDirectory; @@ -68,6 +70,16 @@ radix::geometry::Aabb3d ecef_bounds(const glm::dvec2& longitudes, const glm::dve 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]") @@ -167,13 +179,15 @@ TEST_CASE("batch builds datasets crossing the prime meridian", "[terrainbuilder] SECTION("target levels below the minimum are rejected") { - CHECK_FALSE(terrainbuilder::build_all_patches(dataset, 1, webmercator, nullptr, ecef, output, ".sfmesh", false).has_value()); + 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, 12, webmercator, nullptr, ecef, output, ".sfmesh", false).has_value()); + 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)) { @@ -190,3 +204,67 @@ TEST_CASE("batch builds datasets crossing the prime meridian", "[terrainbuilder] CHECK(west > 1000); } } + +TEST_CASE("batch builds write a new snapshot and reuse compatible caches", "[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("an existing output is rejected") + { + REQUIRE(std::filesystem::create_directory(output)); + const auto built = terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, record); + REQUIRE_FALSE(built.has_value()); + CHECK(built.error().code() == Error::Code::AlreadyExists); + } + + 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()); + + SECTION("with matching inputs") + { + 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); + } + + SECTION("with different inputs") + { + const auto built = terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, batch_record(13), cache); + REQUIRE_FALSE(built.has_value()); + CHECK(built.error().code() == Error::Code::InvalidInput); + CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); + } + } +} From 5d70e129b21f9323242625b10de7fbddca3aff21 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:24:45 +0200 Subject: [PATCH 62/83] sf-merger: merge and cut into .part, cache instead of resume merge and cut write .part and publish it to a new . merge no longer skips nodes the output already has; --cache reuses the nodes of an interrupted merge whose inputs.tmp matches, and --overwrite (which was never used) is gone. The merge checkpoints its index every two minutes and stops before the next node on SIGINT/SIGTERM, keeping the .part. NodeWriter no longer writes a leftover .png beside each node. --- src/sf_merger/CMakeLists.txt | 1 + src/sf_merger/NodeWriter.h | 34 +---- src/sf_merger/cli.cpp | 10 +- src/sf_merger/cli.h | 2 +- src/sf_merger/cut.h | 20 +-- src/sf_merger/inputs.h | 66 +++++++++ src/sf_merger/main.cpp | 55 ++++---- src/sf_merger/merge.h | 154 +++++++++++++++++---- unittests/sf_merger/storage_boundaries.cpp | 66 +++++++++ 9 files changed, 310 insertions(+), 98 deletions(-) create mode 100644 src/sf_merger/inputs.h diff --git a/src/sf_merger/CMakeLists.txt b/src/sf_merger/CMakeLists.txt index 5dfba5a7..9b82d696 100644 --- a/src/sf_merger/CMakeLists.txt +++ b/src/sf_merger/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(sfmergerlib INTERFACE NodeWriter.h cut.h earth.h + inputs.h mask.h merge.h utils.h diff --git a/src/sf_merger/NodeWriter.h b/src/sf_merger/NodeWriter.h index 079293d0..753f78c0 100644 --- a/src/sf_merger/NodeWriter.h +++ b/src/sf_merger/NodeWriter.h @@ -23,7 +23,6 @@ #include #include -#include #include "Error.h" #include "NodeLoader.h" @@ -37,40 +36,17 @@ 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 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(); } + Expected link_subtree_to_output( const octree::Id &id, const NodeLoader &loader) { diff --git a/src/sf_merger/cli.cpp b/src/sf_merger/cli.cpp index 0a4c352d..8458dfad 100644 --- a/src/sf_merger/cli.cpp +++ b/src/sf_merger/cli.cpp @@ -44,10 +44,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{ @@ -67,7 +70,8 @@ Args cli::parse(int argc, const char *const *argv) { cut.add_option("--mask", cut_args.mask_path, "Path to a mask denoting the regions to retain") ->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."); diff --git a/src/sf_merger/cli.h b/src/sf_merger/cli.h index 5f0bdb55..3cb438e5 100644 --- a/src/sf_merger/cli.h +++ b/src/sf_merger/cli.h @@ -43,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 bf3f0799..e6924a37 100644 --- a/src/sf_merger/cut.h +++ b/src/sf_merger/cut.h @@ -174,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 = input_dataset.layout().mapping(); options.preferred_extension = std::string(input_dataset.codec_selector().value_or(".sfmesh")); - auto output_result = mesh::storage::open_folder_indexed(output_path, std::move(options)); + 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/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 56a0bbf5..4ba65bbb 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.cpp @@ -24,13 +24,15 @@ #include #include "cli.h" -#include "cut.h" #include "cli_support/log.h" +#include "cli_support/stop_signal.h" +#include "cut.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)) { @@ -52,7 +54,8 @@ std::optional load_mask_from_path(const std::filesystem::path& path) { return std::nullopt; } -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) { @@ -73,33 +76,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)); - 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) { @@ -116,12 +114,15 @@ void run(const cli::CutArgs& args) { 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) { @@ -135,7 +136,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/merge.h b/src/sf_merger/merge.h index 3a1b9160..4cab3fb1 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/merge.h @@ -19,25 +19,31 @@ #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(); @@ -57,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() { @@ -83,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); @@ -150,6 +168,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; @@ -198,13 +229,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) { @@ -224,20 +263,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); @@ -247,3 +290,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/unittests/sf_merger/storage_boundaries.cpp b/unittests/sf_merger/storage_boundaries.cpp index 12635f64..f11ae09f 100644 --- a/unittests/sf_merger/storage_boundaries.cpp +++ b/unittests/sf_merger/storage_boundaries.cpp @@ -25,6 +25,7 @@ #include "merge.h" #include "mesh/storage.h" #include "octree/store_layout/Mappings.h" +#include "store/inputs.h" namespace { @@ -217,3 +218,68 @@ TEST_CASE("SF cut output keeps the input layout and codec", "[sfmerger][sf][link CHECK(output.layout().mapping().id == input.layout().mapping().id); CHECK(std::filesystem::equivalent(input.paths(root)->front(), output.paths(root)->front())); } + +TEST_CASE("SF merge writes a new snapshot and reuses compatible caches", "[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("an existing output is rejected") + { + REQUIRE(std::filesystem::create_directory(output)); + const auto merged = merge_datasets(left, right, output, std::nullopt, *record); + REQUIRE_FALSE(merged.has_value()); + CHECK(merged.error().code() == Error::Code::AlreadyExists); + } + + 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()); + + SECTION("with matching inputs") + { + 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())); + } + + SECTION("with different inputs") + { + auto different = *record; + different.mask = "mask.shp"; + const auto merged = merge_datasets(left, right, output, std::nullopt, different, cache); + REQUIRE_FALSE(merged.has_value()); + CHECK(merged.error().code() == Error::Code::InvalidInput); + CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); + } + } +} From 9c2cf9970ae1258e3f1436073a67be30038e27d9 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:37:49 +0200 Subject: [PATCH 63/83] dag-builder: build into .part with cache; remove allow_overwrite dag-builder writes .part and publishes it to a new . --resume, --overwrite and ContinuationMode are gone; --cache reuses the nodes of an interrupted build whose inputs.tmp matches, including the level range and root node, so each run stands alone. The index is also checkpointed every two minutes, SIGINT/SIGTERM keep the .part, and debug meshes go to a new -debug. dag-convert-debug fails on an existing output instead of overwriting it. With no users left, StorageSettings::allow_overwrite is removed and saving an existing node is always an error. --- src/dag_builder/CMakeLists.txt | 2 +- src/dag_builder/ContinuationMode.h | 25 ----- src/dag_builder/build.cpp | 86 +++++++++++------ src/dag_builder/build.h | 23 +++-- src/dag_builder/cli.cpp | 42 +-------- src/dag_builder/cli.h | 2 +- src/dag_builder/inputs.h | 94 +++++++++++++++++++ src/dag_builder/main.cpp | 79 +++++++++++----- src/dag_builder/storage.h | 10 ++ src/dag_convert_debug/main.cpp | 10 +- src/terrainlib/store/Storage.h | 12 +-- .../dag_builder/storage_compatibility.cpp | 41 ++++++++ unittests/terrainlib/store_compatibility.cpp | 16 ---- unittests/terrainlib/store_storage.cpp | 7 +- 14 files changed, 290 insertions(+), 159 deletions(-) delete mode 100644 src/dag_builder/ContinuationMode.h create mode 100644 src/dag_builder/inputs.h diff --git a/src/dag_builder/CMakeLists.txt b/src/dag_builder/CMakeLists.txt index 9b8af87f..f98259bc 100644 --- a/src/dag_builder/CMakeLists.txt +++ b/src/dag_builder/CMakeLists.txt @@ -18,7 +18,6 @@ # ***************************************************************************** add_library(dagbuilderlib - ContinuationMode.h Partitioning.h TextureSet.h VertexInCluster.h @@ -34,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 9f1fa4be..00000000 --- a/src/dag_builder/ContinuationMode.h +++ /dev/null @@ -1,25 +0,0 @@ -/***************************************************************************** - * 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 - -enum class ContinuationMode { - Error, - Resume, - Overwrite, -}; diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index 1190b250..b47465b2 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -18,7 +18,10 @@ *****************************************************************************/ #include +#include +#include #include +#include #include #include #include @@ -56,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 { @@ -174,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. @@ -506,6 +513,19 @@ std::optional find_max_input_level(const std::vector build_level( const uint32_t level, @@ -521,27 +541,12 @@ 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: {}", @@ -555,8 +560,32 @@ std::unordered_set build_level( 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()); + } + }; + 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()) { + const auto linked = ctx.output_storage.link_from(target, *ctx.cache); + if (!linked) { + LOG_ERROR_AND_EXIT("Failed to link cached node {}: {}", target, linked.error().to_string()); + } + linked_ids.push_back(target); progress.task_finished(); return; } @@ -586,12 +615,13 @@ std::unordered_set build_level( save_result.error().to_string()); } } + checkpoint(); progress.task_finished(); }, ctx.options.parallelize); progress_thread.join(); - std::unordered_set built = std::move(already_built); + std::unordered_set built(linked_ids.begin(), linked_ids.end()); for (const octree::Id &id : saved_ids) { built.insert(id); } @@ -606,7 +636,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; @@ -636,16 +668,8 @@ 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( @@ -661,6 +685,10 @@ Expected build_levels( level, result.error().to_string()); } + if (ctx.stopped) { + output_storage = std::move(ctx.output_storage).release(); + return Error::fail(Error::Code::Cancelled, "DAG build cancelled; incomplete snapshot retained"); + } } output_storage = std::move(ctx.output_storage).release(); diff --git a/src/dag_builder/build.h b/src/dag_builder/build.h index c8b2015c..b0ea0511 100644 --- a/src/dag_builder/build.h +++ b/src/dag_builder/build.h @@ -20,10 +20,11 @@ #pragma once #include -#include #include +#include +#include +#include -#include "ContinuationMode.h" #include "Range.h" #include "build_config.h" #include "texturing.h" @@ -48,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/cli.cpp b/src/dag_builder/cli.cpp index f491b664..76ef2467 100644 --- a/src/dag_builder/cli.cpp +++ b/src/dag_builder/cli.cpp @@ -106,36 +106,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) { @@ -160,14 +130,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; @@ -214,11 +184,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"); @@ -239,7 +206,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 57dce287..561afcc4 100644 --- a/src/dag_builder/cli.h +++ b/src/dag_builder/cli.h @@ -51,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/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 cbd8d421..05d9d79c 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -20,13 +20,17 @@ #include #include #include +#include -#include "cli.h" #include "build.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); @@ -42,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, @@ -69,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) { + if (build_result.error().code() == Error::Code::Cancelled) { + LOG_INFO("{}", build_result.error().to_string()); + return cli_support::stop_signal::exit_code(); + } LOG_ERROR("Invalid Structura Fundamentalis input: {}", 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/storage.h b/src/dag_builder/storage.h index 67963abe..c63f6aaf 100644 --- a/src/dag_builder/storage.h +++ b/src/dag_builder/storage.h @@ -25,6 +25,7 @@ #include "codec.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" +#include "octree/storage/create.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" @@ -69,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_convert_debug/main.cpp b/src/dag_convert_debug/main.cpp index 1029170d..73bb6a4d 100644 --- a/src/dag_convert_debug/main.cpp +++ b/src/dag_convert_debug/main.cpp @@ -29,6 +29,7 @@ #include "mesh/storage.h" #include "cli_support/ProgressIndicator.h" #include "storage.h" +#include "store/snapshot.h" #include "utils.h" namespace { @@ -49,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()); @@ -66,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( @@ -79,7 +88,6 @@ 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; diff --git a/src/terrainlib/store/Storage.h b/src/terrainlib/store/Storage.h index fabd5c11..36e87aeb 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -32,10 +32,6 @@ namespace store { -struct StorageSettings { - bool allow_overwrite = false; -}; - template class Storage { public: @@ -73,7 +69,6 @@ class Storage { : m_raw(std::move(other.m_raw)) , m_index(std::move(other.m_index)) , m_persistence(std::move(other.m_persistence)) - , m_settings(other.m_settings) , m_dirty(std::exchange(other.m_dirty, false)) { other.m_index.reset(); @@ -89,7 +84,6 @@ class Storage { m_raw = std::move(other.m_raw); m_index = std::move(other.m_index); 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_persistence.reset(); @@ -122,7 +116,7 @@ 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()); } @@ -268,9 +262,6 @@ class Storage { return save_index(); } - const StorageSettings& settings() const { return m_settings; } - StorageSettings& settings() { return m_settings; } - protected: Index& index_mut() { return m_index.value(); } const Index& index_ref() const { return m_index.value(); } @@ -292,7 +283,6 @@ class Storage { RawStorage m_raw; std::optional> m_index; std::optional m_persistence = std::nullopt; - StorageSettings m_settings; mutable bool m_dirty = false; }; diff --git a/unittests/dag_builder/storage_compatibility.cpp b/unittests/dag_builder/storage_compatibility.cpp index 70a3419f..80e31d62 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.cpp @@ -235,3 +235,44 @@ 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())); + } + } + } +} diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 0d47eef8..72df25ca 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -120,22 +120,6 @@ TEST_CASE("storage hard-links matching payload formats") 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"); diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index 4a5dec1a..9703735e 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -241,7 +241,7 @@ TEST_CASE("shared storage rejects invalid 2D keys", "[store][storage]") 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()); @@ -252,10 +252,6 @@ 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]") @@ -294,7 +290,6 @@ TEST_CASE("storage moves transfer and finalize dirty index state", "[store][stor { auto destination = make_indexed_storage(first_directory.path()); - destination.settings().allow_overwrite = true; REQUIRE(destination.save(octree::Id::root(), 2).has_value()); auto source = make_indexed_storage(second_directory.path()); REQUIRE(source.save(octree::Id::root(), 3).has_value()); From 4cd594425a605685f3c46e00dc0f647f12d86486 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:39:32 +0200 Subject: [PATCH 64/83] golden-e2e: resume through .part caches The tools now refuse an existing output, so the script no longer creates output directories or passes --resume/--overwrite. An interrupted run's .part is moved to -cache.part and passed as --cache. --- docs/raster-store/golden-e2e.sh | 54 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 17 deletions(-) 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}" } From c490e4e10481dba4467181e831a2870d4cbedb8c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:28:22 +0200 Subject: [PATCH 65/83] share --verbosity names, log file and assertion logging cli_support::log_level_names replaces the seven copies of the --verbosity map, and Log::add_file and log_assertion_failures replace the log-file and assertion-handler code duplicated in the rf-builder and rf-merger mains. --- src/dag_builder/cli.cpp | 14 +++--------- src/dag_convert_debug/cli.cpp | 18 +++------------ src/mesh_convert/cli.cpp | 11 ++------- src/sf_builder/main.cpp | 11 +-------- src/sf_index_browser/cli.cpp | 13 +++-------- src/sf_merger/cli.cpp | 12 +++------- src/terrainlib/cli_support/log.cpp | 36 ++++++++++++++++++++++++++++++ src/terrainlib/cli_support/log.h | 13 +++++++++++ src/tile_downloader/cli.cpp | 12 +++------- 9 files changed, 67 insertions(+), 73 deletions(-) diff --git a/src/dag_builder/cli.cpp b/src/dag_builder/cli.cpp index 76ef2467..21f2ff0c 100644 --- a/src/dag_builder/cli.cpp +++ b/src/dag_builder/cli.cpp @@ -23,6 +23,8 @@ #include #include +#include "cli_support/log.h" + #include #include #include @@ -36,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}, @@ -196,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 { diff --git a/src/dag_convert_debug/cli.cpp b/src/dag_convert_debug/cli.cpp index 4096d246..b925a0c5 100644 --- a/src/dag_convert_debug/cli.cpp +++ b/src/dag_convert_debug/cli.cpp @@ -21,25 +21,13 @@ #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"}; @@ -57,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/mesh_convert/cli.cpp b/src/mesh_convert/cli.cpp index 48fe4254..fa462842 100644 --- a/src/mesh_convert/cli.cpp +++ b/src/mesh_convert/cli.cpp @@ -19,6 +19,7 @@ #include #include "cli.h" +#include "cli_support/log.h" using namespace cli; @@ -41,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/sf_builder/main.cpp b/src/sf_builder/main.cpp index 0cb98006..11d2ac18 100644 --- a/src/sf_builder/main.cpp +++ b/src/sf_builder/main.cpp @@ -217,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); @@ -238,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") diff --git a/src/sf_index_browser/cli.cpp b/src/sf_index_browser/cli.cpp index ad602f49..077d628e 100644 --- a/src/sf_index_browser/cli.cpp +++ b/src/sf_index_browser/cli.cpp @@ -23,6 +23,8 @@ #include #include +#include "cli_support/log.h" + using namespace cli; Args cli::parse(int argc, const char * const * argv) { @@ -40,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_merger/cli.cpp b/src/sf_merger/cli.cpp index 8458dfad..74040656 100644 --- a/src/sf_merger/cli.cpp +++ b/src/sf_merger/cli.cpp @@ -22,6 +22,8 @@ #include #include +#include "cli_support/log.h" + using namespace cli; Args cli::parse(int argc, const char *const *argv) { @@ -78,16 +80,8 @@ Args cli::parse(int argc, const char *const *argv) { 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/terrainlib/cli_support/log.cpp b/src/terrainlib/cli_support/log.cpp index 1d1ec287..8a7c73be 100644 --- a/src/terrainlib/cli_support/log.cpp +++ b/src/terrainlib/cli_support/log.cpp @@ -17,8 +17,13 @@ *****************************************************************************/ #include +#include #include +#include + +#include + #include "cli_support/log.h" namespace cli_support { @@ -31,4 +36,35 @@ void Log::init(spdlog::level::level_enum log_level) { Log::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 index 2ca67ae6..e32662b3 100644 --- a/src/terrainlib/cli_support/log.h +++ b/src/terrainlib/cli_support/log.h @@ -24,6 +24,10 @@ #endif #include "fmt_impls.h" #include "numeric/int_math.h" +#include +#include +#include + #include #include @@ -34,6 +38,8 @@ class Log { static std::shared_ptr logger; 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); inline static std::shared_ptr& get_logger() { if (Log::logger == nullptr) { Log::init(spdlog::level::level_enum::info); @@ -42,6 +48,13 @@ class Log { } }; +// 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__) diff --git a/src/tile_downloader/cli.cpp b/src/tile_downloader/cli.cpp index df63bc1f..9523c277 100644 --- a/src/tile_downloader/cli.cpp +++ b/src/tile_downloader/cli.cpp @@ -24,6 +24,8 @@ #include +#include "cli_support/log.h" + namespace cli { Args parse(int argc, const char *const *argv) { @@ -60,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"); From 04aaba6f7f2f221f7951e78ec49bf5d6dddb2c72 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:23:20 +0200 Subject: [PATCH 66/83] trim duplicated snapshot and cache tests The generic .part, publication, cache record and fingerprint behaviour is tested once in store_snapshot.cpp, which takes over the fingerprint and destination variants. The raster, rf and sf suites keep only what their own code does: record contents, stopping and cache linking. Also drops the cut format and stop signal tests, and renames octree/storage/create.h to snapshot.h. --- src/dag_builder/storage.h | 2 +- src/terrainlib/CMakeLists.txt | 2 +- src/terrainlib/mesh/storage.h | 2 +- .../octree/storage/{create.h => snapshot.h} | 0 unittests/CMakeLists.txt | 1 - unittests/sf_builder/coverage.cpp | 41 ++++---------- unittests/sf_merger/storage_boundaries.cpp | 54 ++----------------- unittests/terrainlib/raster_store.cpp | 18 ------- unittests/terrainlib/stop_signal.cpp | 34 ------------ unittests/terrainlib/store_snapshot.cpp | 25 ++++++++- 10 files changed, 42 insertions(+), 137 deletions(-) rename src/terrainlib/octree/storage/{create.h => snapshot.h} (100%) delete mode 100644 unittests/terrainlib/stop_signal.cpp diff --git a/src/dag_builder/storage.h b/src/dag_builder/storage.h index c63f6aaf..16a27e2f 100644 --- a/src/dag_builder/storage.h +++ b/src/dag_builder/storage.h @@ -25,7 +25,7 @@ #include "codec.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" -#include "octree/storage/create.h" +#include "octree/storage/snapshot.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 936e83db..aa0bb612 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -156,8 +156,8 @@ add_library(terrainlib octree/Space.h octree/Space.cpp octree/StoreTraits.h octree/storage/IndexFile.h - octree/storage/create.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 diff --git a/src/terrainlib/mesh/storage.h b/src/terrainlib/mesh/storage.h index 07c2a545..84339565 100644 --- a/src/terrainlib/mesh/storage.h +++ b/src/terrainlib/mesh/storage.h @@ -25,7 +25,7 @@ #include "mesh/codec/from_extension.h" #include "octree/StoreTraits.h" #include "octree/storage/IndexFile.h" -#include "octree/storage/create.h" +#include "octree/storage/snapshot.h" #include "octree/storage/open_runtime.h" #include "store/IndexedStorage.h" diff --git a/src/terrainlib/octree/storage/create.h b/src/terrainlib/octree/storage/snapshot.h similarity index 100% rename from src/terrainlib/octree/storage/create.h rename to src/terrainlib/octree/storage/snapshot.h diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index fdf8e0e1..2b7c1703 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -94,7 +94,6 @@ add_executable(unittests_terrainlib terrainlib/raster_view.cpp terrainlib/raster_store.cpp terrainlib/spatial_lookup.cpp - terrainlib/stop_signal.cpp terrainlib/string_utils.cpp terrainlib/texture_dilate_colors.cpp terrainlib/tiny_vector.cpp diff --git a/unittests/sf_builder/coverage.cpp b/unittests/sf_builder/coverage.cpp index b40a37d5..52138791 100644 --- a/unittests/sf_builder/coverage.cpp +++ b/unittests/sf_builder/coverage.cpp @@ -205,7 +205,7 @@ TEST_CASE("batch builds datasets crossing the prime meridian", "[terrainbuilder] } } -TEST_CASE("batch builds write a new snapshot and reuse compatible caches", "[terrainbuilder][snapshot]") +TEST_CASE("batch builds stop on request and reuse cached nodes", "[terrainbuilder][snapshot]") { TemporaryDirectory directory("sf-batch-snapshot"); const auto wgs84 = gis::srs::wgs84(); @@ -215,14 +215,6 @@ TEST_CASE("batch builds write a new snapshot and reuse compatible caches", "[ter const auto record = batch_record(12); const auto output = directory.path() / "output"; - SECTION("an existing output is rejected") - { - REQUIRE(std::filesystem::create_directory(output)); - const auto built = terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, record); - REQUIRE_FALSE(built.has_value()); - CHECK(built.error().code() == Error::Code::AlreadyExists); - } - 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; }); @@ -243,28 +235,17 @@ TEST_CASE("batch builds write a new snapshot and reuse compatible caches", "[ter std::filesystem::rename(first, cache); REQUIRE(store::inputs::write(cache, record).has_value()); - SECTION("with matching inputs") - { - 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; - } + 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); - } - - SECTION("with different inputs") - { - const auto built = terrainbuilder::build_all_patches(dataset, webmercator, nullptr, ecef, output, batch_record(13), cache); - REQUIRE_FALSE(built.has_value()); - CHECK(built.error().code() == Error::Code::InvalidInput); - CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); } + CHECK(linked > 0); } } diff --git a/unittests/sf_merger/storage_boundaries.cpp b/unittests/sf_merger/storage_boundaries.cpp index f11ae09f..3247e832 100644 --- a/unittests/sf_merger/storage_boundaries.cpp +++ b/unittests/sf_merger/storage_boundaries.cpp @@ -24,7 +24,6 @@ #include "cut.h" #include "merge.h" #include "mesh/storage.h" -#include "octree/store_layout/Mappings.h" #include "store/inputs.h" namespace { @@ -197,29 +196,7 @@ TEST_CASE("SF cut hard-links unchanged leaves and writes clipped leaves", "[sfme } } -TEST_CASE("SF cut output keeps the input layout and codec", "[sfmerger][sf][link]") -{ - const octree::Id root = octree::Id::root(); - TemporaryDirectory input_directory("cut-format-input"); - TemporaryDirectory output_directory("cut-format-output"); - mesh::storage::OpenOptions options; - options.default_mapping = octree::store_layout::flat(); - options.preferred_extension = ".glb"; - auto input_result = mesh::storage::open_folder_indexed(input_directory.path(), std::move(options)); - REQUIRE(input_result.has_value()); - auto input = std::move(input_result.value()); - REQUIRE(input.save(root, triangle()).has_value()); - REQUIRE(input.save_index().has_value()); - - const auto output_path = output_directory.path() / "cut"; - REQUIRE(cut_dataset(input, MeshMask {}, output_path, false).has_value()); - const auto output = indexed_storage(output_path); - CHECK(output.codec_selector() == input.codec_selector()); - CHECK(output.layout().mapping().id == input.layout().mapping().id); - CHECK(std::filesystem::equivalent(input.paths(root)->front(), output.paths(root)->front())); -} - -TEST_CASE("SF merge writes a new snapshot and reuses compatible caches", "[sfmerger][sf][snapshot]") +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"); @@ -235,14 +212,6 @@ TEST_CASE("SF merge writes a new snapshot and reuses compatible caches", "[sfmer REQUIRE(record.has_value()); const auto output = directory.path() / "output"; - SECTION("an existing output is rejected") - { - REQUIRE(std::filesystem::create_directory(output)); - const auto merged = merge_datasets(left, right, output, std::nullopt, *record); - REQUIRE_FALSE(merged.has_value()); - CHECK(merged.error().code() == Error::Code::AlreadyExists); - } - SECTION("a stop request retains the incomplete snapshot") { const auto merged = merge_datasets(left, right, output, std::nullopt, *record, std::nullopt, [] { return true; }); @@ -264,22 +233,9 @@ TEST_CASE("SF merge writes a new snapshot and reuses compatible caches", "[sfmer std::filesystem::rename(first, cache); REQUIRE(store::inputs::write(cache, *record).has_value()); - SECTION("with matching inputs") - { - 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())); - } - - SECTION("with different inputs") - { - auto different = *record; - different.mask = "mask.shp"; - const auto merged = merge_datasets(left, right, output, std::nullopt, different, cache); - REQUIRE_FALSE(merged.has_value()); - CHECK(merged.error().code() == Error::Code::InvalidInput); - CHECK_FALSE(std::filesystem::exists(directory.path() / "output.part")); - } + 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/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index a31bb08c..22e6433a 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -311,24 +311,6 @@ TEST_CASE("failed checkpoints retain the previous index and ignore unindexed fil CHECK(reopened->index().size() == 1); } -TEST_CASE("raster creation and publication reject destination collisions", "[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); - CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); - SECTION("empty destination") { std::filesystem::create_directory(final); } - SECTION("nonempty destination") { write_text(final / "keep", "keep"); } - SECTION("dangling symlink") { std::filesystem::create_symlink(directory.path() / "missing", final); } - CHECK(storage::publish(std::move(created)).error().code() == Error::Code::AlreadyExists); - CHECK_FALSE(created); - CHECK(std::filesystem::exists(directory.path() / "snapshot.part")); - CHECK(storage::create(final, small_options()).error().code() == Error::Code::AlreadyExists); -} - TEST_CASE("raster publication does not scan payloads or reopen output", "[raster-store][storage]") { TemporaryDirectory directory; diff --git a/unittests/terrainlib/stop_signal.cpp b/unittests/terrainlib/stop_signal.cpp deleted file mode 100644 index 61b1b985..00000000 --- a/unittests/terrainlib/stop_signal.cpp +++ /dev/null @@ -1,34 +0,0 @@ -/***************************************************************************** - * 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 "cli_support/stop_signal.h" - -TEST_CASE("stop signals are recorded and mapped to the conventional exit code", "[cli_support]") -{ - cli_support::stop_signal::install(); - CHECK_FALSE(cli_support::stop_signal::requested()); - CHECK(cli_support::stop_signal::exit_code() == 1); - - REQUIRE(std::raise(SIGTERM) == 0); - CHECK(cli_support::stop_signal::requested()); - CHECK(cli_support::stop_signal::exit_code() == 128 + SIGTERM); -} diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp index a417c23f..82b1314b 100644 --- a/unittests/terrainlib/store_snapshot.cpp +++ b/unittests/terrainlib/store_snapshot.cpp @@ -25,7 +25,7 @@ #include "io/byte.h" #include "io/envelope.h" #include "mesh/storage.h" -#include "octree/storage/create.h" +#include "octree/storage/snapshot.h" #include "octree/store_layout/Mappings.h" #include "store/inputs.h" #include "store/snapshot.h" @@ -97,7 +97,9 @@ TEST_CASE("snapshots are created as .part and published by renaming", "[store][s SECTION("publication does not replace an existing destination") { - REQUIRE(std::filesystem::create_directory(final_path)); + SECTION("empty directory") { REQUIRE(std::filesystem::create_directory(final_path)); } + SECTION("nonempty directory") { REQUIRE(io::byte::write(io::byte::span {}, final_path / "keep")); } + SECTION("dangling symlink") { std::filesystem::create_symlink(directory.path() / "missing", final_path); } const auto published = store::snapshot::publish(partial_path); REQUIRE_FALSE(published.has_value()); CHECK(published.error().code() == Error::Code::AlreadyExists); @@ -174,4 +176,23 @@ TEST_CASE("octree snapshots are created empty, published, and fingerprinted", "[ 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_from_path(metadata_path); + REQUIRE(metadata.has_value()); + SECTION("recompression keeps the fingerprint") + { + REQUIRE(io::envelope::write_to_path(*metadata, metadata_path, 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_to_path( + *metadata, metadata_path, 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); + } } From f4c61f076ee47e08ec3328c76a544124ed1bffe8 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:27:45 +0200 Subject: [PATCH 67/83] keep the input record until publication succeeded publish removed inputs.tmp before renaming, so a .part whose publication failed could no longer serve as a cache. The record is now removed from the published snapshot after the rename; failing to remove it only logs a warning, as a published snapshot is never a cache. --- src/terrainlib/store/snapshot.cpp | 15 +++++++++------ src/terrainlib/store/snapshot.h | 2 +- unittests/terrainlib/store_snapshot.cpp | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/terrainlib/store/snapshot.cpp b/src/terrainlib/store/snapshot.cpp index 8f6b96a4..eb22d9ca 100644 --- a/src/terrainlib/store/snapshot.cpp +++ b/src/terrainlib/store/snapshot.cpp @@ -23,6 +23,7 @@ #include +#include "cli_support/log.h" #include "io/utils.h" #include "store/inputs.h" @@ -94,16 +95,18 @@ Expected publish(const std::filesystem::path& partial_path) if (auto absent = require_absent(destination); !absent) { return absent; } - const auto inputs_path = partial / inputs::file_name; - std::error_code error; - std::filesystem::remove(inputs_path, error); - if (error) { - return Error::fail(Error::Code::Io, "remove input record before publication", inputs_path, error); - } + // 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 {}; } diff --git a/src/terrainlib/store/snapshot.h b/src/terrainlib/store/snapshot.h index 3f9a10fd..b448a972 100644 --- a/src/terrainlib/store/snapshot.h +++ b/src/terrainlib/store/snapshot.h @@ -36,7 +36,7 @@ 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); -// Removes the inputs record and renames .part to . The storage in it +// 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. diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp index 82b1314b..e0bddb22 100644 --- a/unittests/terrainlib/store_snapshot.cpp +++ b/unittests/terrainlib/store_snapshot.cpp @@ -100,10 +100,12 @@ TEST_CASE("snapshots are created as .part and published by renaming", "[store][s SECTION("empty directory") { REQUIRE(std::filesystem::create_directory(final_path)); } SECTION("nonempty directory") { REQUIRE(io::byte::write(io::byte::span {}, final_path / "keep")); } 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); - CHECK(std::filesystem::is_directory(partial_path)); + // The retained .part stays usable as a cache. + CHECK(store::inputs::validate_cache(partial_path, Record { "input", 1 }).has_value()); } } From 0096a6f27e7e1a94e9f9c33b01862fd35ff413d3 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:27:45 +0200 Subject: [PATCH 68/83] sf-merger: poll while linking unchanged subtrees Linking an unchanged subtree did not check for stop requests or checkpoint the index, so a stop during a large subtree could be ignored until publication. The merger's poll now runs before each linked leaf. --- src/sf_merger/NodeWriter.h | 9 ++++++++- src/sf_merger/merge.h | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sf_merger/NodeWriter.h b/src/sf_merger/NodeWriter.h index 753f78c0..701dd1eb 100644 --- a/src/sf_merger/NodeWriter.h +++ b/src/sf_merger/NodeWriter.h @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include @@ -47,9 +48,11 @@ class NodeWriter { 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(), @@ -62,6 +65,10 @@ class NodeWriter { } DEBUG_ASSERT(status == store::NodeStatus::Leaf); + 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(); diff --git a/src/sf_merger/merge.h b/src/sf_merger/merge.h index 4cab3fb1..cffe98d6 100644 --- a/src/sf_merger/merge.h +++ b/src/sf_merger/merge.h @@ -147,7 +147,8 @@ class Merger { } else { auto link_result = this->_output.link_subtree_to_output( id, - result.source == merge::Source::Left ? this->_left : this->_right); + result.source == merge::Source::Left ? this->_left : this->_right, + [this] { return this->poll(); }); if (!link_result) { return link_result; } From a862c9e3ce6b86d2de44dda03d1e29b9f8cfa7dd Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:38:47 +0200 Subject: [PATCH 69/83] accept snapshot paths with a trailing separator --output out/ was rejected, because the normalized path has an empty filename, and partial_path made out/.part of it. store::snapshot:: normalized strips the separator for every snapshot path, and raster open/create, the rf log paths and the dag debug path use it too. --- src/dag_builder/build.cpp | 5 +---- src/terrainlib/raster_store/storage.h | 9 +++------ src/terrainlib/store/snapshot.cpp | 24 +++++++++++++----------- src/terrainlib/store/snapshot.h | 2 ++ 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index b47465b2..e7b6f587 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -515,10 +515,7 @@ std::optional find_max_input_level(const std::vector Expected>, std::unique_ptr>> open( const std::filesystem::path& base_path, const OpenOptions options = {}) { - auto normalized_path = base_path.lexically_normal(); - if (!normalized_path.has_filename()) { - normalized_path = normalized_path.parent_path(); - } + 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); } @@ -106,7 +103,7 @@ template Expected>, std::unique_ptr>> create( const std::filesystem::path& final_path, const CreateOptions options = {}) { - const auto destination = final_path.lexically_normal(); + 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); } @@ -172,7 +169,7 @@ Expected publish(std::unique_ptr> storage) if (!storage) { return Error::fail(Error::Code::InvalidInput, "publication requires an output handle"); } - const auto partial_path = storage->base_path().lexically_normal(); + 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); } diff --git a/src/terrainlib/store/snapshot.cpp b/src/terrainlib/store/snapshot.cpp index eb22d9ca..d1f41b66 100644 --- a/src/terrainlib/store/snapshot.cpp +++ b/src/terrainlib/store/snapshot.cpp @@ -29,21 +29,23 @@ 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 = final_path.lexically_normal(); + auto path = normalized(final_path); path += ".part"; return path; } -bool is_partial(const std::filesystem::path& path) -{ - auto normalized = path.lexically_normal(); - if (!normalized.has_filename()) { - normalized = normalized.parent_path(); - } - return normalized.extension() == ".part"; -} +bool is_partial(const std::filesystem::path& path) { return normalized(path).extension() == ".part"; } Expected require_absent(const std::filesystem::path& path) { @@ -60,7 +62,7 @@ Expected require_absent(const std::filesystem::path& path) Expected create_partial(const std::filesystem::path& final_path) { - const auto destination = final_path.lexically_normal(); + 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); } @@ -86,7 +88,7 @@ Expected create_partial(const std::filesystem::path& fina Expected publish(const std::filesystem::path& partial_path) { - const auto partial = partial_path.lexically_normal(); + const auto partial = normalized(partial_path); if (!is_partial(partial)) { return Error::fail(Error::Code::InvalidInput, "publication requires an incomplete .part snapshot", partial_path); } diff --git a/src/terrainlib/store/snapshot.h b/src/terrainlib/store/snapshot.h index b448a972..20f12bd7 100644 --- a/src/terrainlib/store/snapshot.h +++ b/src/terrainlib/store/snapshot.h @@ -31,6 +31,8 @@ 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); From 00aba6528b3051207cf75abdf665295266c02063 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:53:31 +0200 Subject: [PATCH 70/83] envelope: rename read_from_path and write_to_path to read and write The names now match io::byte::read and io::byte::write. No behaviour change. --- src/dag_builder/codec.cpp | 8 ++++---- src/terrainlib/io/envelope.h | 4 ++-- src/terrainlib/mesh/codec/SfMesh.h | 4 ++-- src/terrainlib/octree/storage/IndexFile.h | 8 ++++---- src/terrainlib/raster_store/io/TileCodec.h | 4 ++-- src/terrainlib/raster_store/io/manifest.cpp | 8 ++++---- src/terrainlib/store/inputs.h | 4 ++-- unittests/terrainlib/envelope.cpp | 4 ++-- unittests/terrainlib/raster_store.cpp | 10 +++++----- unittests/terrainlib/store_snapshot.cpp | 6 +++--- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index 26ecf07f..4484ce1b 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -380,7 +380,7 @@ namespace { Expected read_metadata(const std::filesystem::path& path) { - auto payload = io::envelope::read_from_path(path); + auto payload = io::envelope::read(path); if (!payload) { return Error::propagate(std::move(payload), "read DAG metadata"); } @@ -404,7 +404,7 @@ std::vector ClusterBatch::paths(const std::filesystem::pa Expected ClusterBatch::read(const std::filesystem::path& node_path) const { const auto node_paths = paths(node_path); - auto clustering_payload = io::envelope::read_from_path(node_paths[0]); + auto clustering_payload = io::envelope::read(node_paths[0]); if (!clustering_payload) { return Error::propagate(std::move(clustering_payload), "read DAG clustering"); } @@ -443,11 +443,11 @@ 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]); + auto data_written = io::envelope::write(*clustering, node_paths[0]); if (!data_written) { return Error::propagate(std::move(data_written), "write DAG clustering"); } - auto metadata_written = io::envelope::write_to_path(metadata, node_paths[1]); + auto metadata_written = io::envelope::write(metadata, node_paths[1]); if (!metadata_written) { return Error::propagate(std::move(metadata_written), "write DAG metadata"); } diff --git a/src/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index a3bb6c31..6c259950 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -333,7 +333,7 @@ Expected deserialize( } template -Expected read_from_path( +Expected read( const std::filesystem::path& path, const std::size_t max_decompressed_size = default_max_decompressed_size) { auto file_bytes = ::io::byte::read(path); @@ -348,7 +348,7 @@ Expected read_from_path( } template -Expected write_to_path(const typename Schema::latest_type& payload, +Expected write(const typename Schema::latest_type& payload, const std::filesystem::path& path, const bool make_dirs = true, const CompressionAlgorithm compression_algorithm = CompressionAlgorithm::ZstdDefaultCompression, diff --git a/src/terrainlib/mesh/codec/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index 40969602..894020ab 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -35,7 +35,7 @@ class SfMesh final : public store::Codec { Expected read(const std::filesystem::path& node_path) 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); if (!payload) { return Error::propagate(std::move(payload), "read SF mesh"); } @@ -61,7 +61,7 @@ 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); + auto result = ::io::envelope::write(*payload, path); if (!result) { return Error::propagate(std::move(result), "write SF mesh"); } diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index d445391c..3c76af97 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -119,7 +119,7 @@ inline Expected> decode_index(const StoreIndex& encode 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; } @@ -136,7 +136,7 @@ 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() + "\""); } @@ -163,12 +163,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); !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) { + if (auto written = io::envelope::write(encoded_index, path); !written) { return Error::propagate(std::move(written), "write octree store index " + path.string()); } return {}; diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index bb74a9cf..74f56597 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -71,7 +71,7 @@ class TileCodec final : public store::Codec> { if (auto valid = validate_dimensions(m_dimensions); !valid) { return Error::propagate(std::move(valid)); } - auto encoded = ::io::envelope::read_from_path(paths(node_path).front()); + auto encoded = ::io::envelope::read(paths(node_path).front()); if (!encoded) { return Error::propagate(std::move(encoded), "read AMORT tile"); } @@ -102,7 +102,7 @@ class TileCodec final : public store::Codec> { const auto source_attribution = tile.attribution.bytes(); const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; - return ::io::envelope::write_to_path(encoded, paths(node_path).front(), true, m_compression_algorithm, m_hash_algorithm); + return ::io::envelope::write(encoded, paths(node_path).front(), true, m_compression_algorithm, m_hash_algorithm); } private: diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index c2960636..b7498369 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -46,7 +46,7 @@ Expected validate(const Metadata& metadata) Expected read_metadata(const std::filesystem::path& base_path) { - auto metadata = ::io::envelope::read_from_path(base_path / metadata_file_name); + auto metadata = ::io::envelope::read(base_path / metadata_file_name); if (!metadata) { return metadata; } @@ -58,7 +58,7 @@ Expected read_metadata(const std::filesystem::path& base_path) Expected write_metadata(const Metadata& metadata, const std::filesystem::path& base_path) { - return ::io::envelope::write_to_path(metadata, base_path / metadata_file_name); + return ::io::envelope::write(metadata, base_path / metadata_file_name); } detail::v1::Hierarchy encode_index(const store::Index& index) @@ -99,7 +99,7 @@ Expected> decode_index(const detail::v1::Hierarchy& en Expected> read_hierarchy(const std::filesystem::path& index_path) { - auto encoded = ::io::envelope::read_from_path(index_path); + auto encoded = ::io::envelope::read(index_path); if (!encoded) { return Error::propagate(std::move(encoded), "read raster hierarchy"); } @@ -123,7 +123,7 @@ Expected write_index_file(const std::filesystem::path& index_path, const s { auto temporary_path = index_path; temporary_path += ".tmp"; - auto written = ::io::envelope::write_to_path(encode_index(metadata.index), temporary_path); + auto written = ::io::envelope::write(encode_index(metadata.index), temporary_path); if (!written) { return Error::propagate(std::move(written), "checkpoint raster index"); } diff --git a/src/terrainlib/store/inputs.h b/src/terrainlib/store/inputs.h index 4fce5d77..0b98701b 100644 --- a/src/terrainlib/store/inputs.h +++ b/src/terrainlib/store/inputs.h @@ -49,7 +49,7 @@ Expected fingerprint(const std::filesystem::path& snapshot, std::st template Expected write(const std::filesystem::path& snapshot, const typename Schema::latest_type& record) { - auto written = ::io::envelope::write_to_path(record, snapshot / file_name); + auto written = ::io::envelope::write(record, snapshot / file_name); if (!written) { return Error::propagate(std::move(written), "write input record"); } @@ -62,7 +62,7 @@ Expected validate_cache(const std::filesystem::path& cache, const typename 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_from_path(cache / file_name); + 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)); } diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 671e3bff..5af2a077 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -561,7 +561,7 @@ TEST_CASE("Envelope headers are read without the payload") 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_to_path(payload, path)); + REQUIRE(io::envelope::write(payload, path)); const auto header = io::envelope::read_header(path); REQUIRE(header.has_value()); CHECK(header->magic == io::envelope::magic); @@ -570,7 +570,7 @@ TEST_CASE("Envelope headers are read without the payload") CHECK(header->hash_algorithm == io::hash::Algorithm::Xxh3_64); CHECK(header->compression_algorithm == io::envelope::CompressionAlgorithm::ZstdDefaultCompression); - const auto full = io::envelope::read_from_path(path); + 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)); diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 22e6433a..ac377363 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -196,7 +196,7 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra 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_to_path(encoded, path.string() + ".amort")); + REQUIRE(io::envelope::write(encoded, path.string() + ".amort")); CHECK(amort.read(path).error().code() == Error::Code::CorruptData); } @@ -340,10 +340,10 @@ TEST_CASE("raster reader selection follows metadata instead of file endings", "[ REQUIRE(storage::publish(std::move(created))); auto metadata = manifest::read_metadata(final).value(); metadata.codec_selector = ".amort"; - REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name)); CHECK(storage::open(final).error().code() == Error::Code::Unsupported); metadata.codec_selector = "amort"; - REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name)); REQUIRE(storage::open(final)); } @@ -473,14 +473,14 @@ TEST_CASE("raster opening retains metadata and index errors without creating a d { auto metadata = manifest::read_metadata(final).value(); metadata.layout_id = "unknown"; - REQUIRE(io::envelope::write_to_path(metadata, final / manifest::metadata_file_name)); + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name)); 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_to_path(metadata, final / manifest::metadata_file_name)); + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name)); CHECK(storage::open(final).error().code() == Error::Code::CorruptData); } SECTION("wrong envelope class") diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp index e0bddb22..ab054dd6 100644 --- a/unittests/terrainlib/store_snapshot.cpp +++ b/unittests/terrainlib/store_snapshot.cpp @@ -180,18 +180,18 @@ TEST_CASE("octree snapshots are created empty, published, and fingerprinted", "[ 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_from_path(metadata_path); + const auto metadata = io::envelope::read(metadata_path); REQUIRE(metadata.has_value()); SECTION("recompression keeps the fingerprint") { - REQUIRE(io::envelope::write_to_path(*metadata, metadata_path, true, io::envelope::CompressionAlgorithm::None)); + REQUIRE(io::envelope::write(*metadata, metadata_path, 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_to_path( + REQUIRE(io::envelope::write( *metadata, metadata_path, 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()); From a5654f50e771fb619c1fcdc1daa09f8438349071 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:07 +0200 Subject: [PATCH 71/83] io: make the write mode explicit io::byte::write and io::envelope::write no longer have overloads that silently overwrite; every caller passes a WriteMode. All callers keep overwriting, so there is no behaviour change. --- src/dag_builder/codec.cpp | 4 ++-- src/terrainlib/io/byte.cpp | 5 ----- src/terrainlib/io/byte.h | 1 - src/terrainlib/io/envelope.h | 3 ++- src/terrainlib/mesh/codec/SfMesh.h | 2 +- src/terrainlib/octree/storage/IndexFile.h | 4 ++-- src/terrainlib/raster_store/io/TileCodec.h | 3 ++- src/terrainlib/raster_store/io/manifest.cpp | 4 ++-- src/terrainlib/store/inputs.h | 2 +- unittests/terrainlib/envelope.cpp | 4 ++-- unittests/terrainlib/io_image.cpp | 2 +- unittests/terrainlib/raster_store.cpp | 16 ++++++++-------- unittests/terrainlib/store_compatibility.cpp | 2 +- unittests/terrainlib/store_snapshot.cpp | 13 +++++++++---- unittests/terrainlib/store_storage.cpp | 4 ++-- 15 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index 4484ce1b..5bedadcd 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -443,11 +443,11 @@ 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(*clustering, node_paths[0]); + auto data_written = io::envelope::write(*clustering, node_paths[0], io::byte::WriteMode::Overwrite); if (!data_written) { return Error::propagate(std::move(data_written), "write DAG clustering"); } - auto metadata_written = io::envelope::write(metadata, node_paths[1]); + auto metadata_written = io::envelope::write(metadata, node_paths[1], io::byte::WriteMode::Overwrite); if (!metadata_written) { return Error::propagate(std::move(metadata_written), "write DAG metadata"); } diff --git a/src/terrainlib/io/byte.cpp b/src/terrainlib/io/byte.cpp index 5a5e31e4..19595be0 100644 --- a/src/terrainlib/io/byte.cpp +++ b/src/terrainlib/io/byte.cpp @@ -27,11 +27,6 @@ namespace io::byte { -Expected write(const span bytes, const std::filesystem::path& path, bool make_dirs) -{ - return write(bytes, path, WriteMode::Overwrite, make_dirs); -} - Expected write(const span bytes, const std::filesystem::path& path, WriteMode mode, bool make_dirs) { LOG_TRACE("Writing bytes to path {}", path); diff --git a/src/terrainlib/io/byte.h b/src/terrainlib/io/byte.h index d02d003d..5e822f76 100644 --- a/src/terrainlib/io/byte.h +++ b/src/terrainlib/io/byte.h @@ -36,7 +36,6 @@ using span = std::span; enum class WriteMode { Overwrite, CreateNew }; -Expected write(const span bytes, const std::filesystem::path& path, bool make_dirs = true); 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/envelope.h b/src/terrainlib/io/envelope.h index 6c259950..78143d03 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -350,6 +350,7 @@ Expected read( 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) @@ -358,7 +359,7 @@ Expected write(const typename Schema::latest_type& payload, if (!serialized) { return Error::propagate(std::move(serialized), "encoding envelope file \"" + path.string() + "\""); } - auto result = ::io::byte::write(*serialized, path, make_dirs); + auto result = ::io::byte::write(*serialized, path, mode, make_dirs); if (!result) { return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); } diff --git a/src/terrainlib/mesh/codec/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index 894020ab..a6614043 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -61,7 +61,7 @@ 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(*payload, path); + auto result = ::io::envelope::write(*payload, path, ::io::byte::WriteMode::Overwrite); if (!result) { return Error::propagate(std::move(result), "write SF mesh"); } diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index 3c76af97..f35581c3 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -163,12 +163,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(store_metadata, metadata_path); !written) { + if (auto written = io::envelope::write(store_metadata, metadata_path, io::byte::WriteMode::Overwrite); !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(encoded_index, path); !written) { + if (auto written = io::envelope::write(encoded_index, path, io::byte::WriteMode::Overwrite); !written) { return Error::propagate(std::move(written), "write octree store index " + path.string()); } return {}; diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index 74f56597..ed3ff8c0 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -102,7 +102,8 @@ class TileCodec final : public store::Codec> { const auto source_attribution = tile.attribution.bytes(); const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; - return ::io::envelope::write(encoded, paths(node_path).front(), true, m_compression_algorithm, m_hash_algorithm); + return ::io::envelope::write( + encoded, paths(node_path).front(), ::io::byte::WriteMode::Overwrite, true, m_compression_algorithm, m_hash_algorithm); } private: diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index b7498369..1ea54d20 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -58,7 +58,7 @@ Expected read_metadata(const std::filesystem::path& base_path) Expected write_metadata(const Metadata& metadata, const std::filesystem::path& base_path) { - return ::io::envelope::write(metadata, base_path / metadata_file_name); + return ::io::envelope::write(metadata, base_path / metadata_file_name, ::io::byte::WriteMode::Overwrite); } detail::v1::Hierarchy encode_index(const store::Index& index) @@ -123,7 +123,7 @@ Expected write_index_file(const std::filesystem::path& index_path, const s { auto temporary_path = index_path; temporary_path += ".tmp"; - auto written = ::io::envelope::write(encode_index(metadata.index), temporary_path); + auto written = ::io::envelope::write(encode_index(metadata.index), temporary_path, ::io::byte::WriteMode::Overwrite); if (!written) { return Error::propagate(std::move(written), "checkpoint raster index"); } diff --git a/src/terrainlib/store/inputs.h b/src/terrainlib/store/inputs.h index 0b98701b..65807de7 100644 --- a/src/terrainlib/store/inputs.h +++ b/src/terrainlib/store/inputs.h @@ -49,7 +49,7 @@ Expected fingerprint(const std::filesystem::path& snapshot, std::st template Expected write(const std::filesystem::path& snapshot, const typename Schema::latest_type& record) { - auto written = ::io::envelope::write(record, snapshot / file_name); + auto written = ::io::envelope::write(record, snapshot / file_name, ::io::byte::WriteMode::Overwrite); if (!written) { return Error::propagate(std::move(written), "write input record"); } diff --git a/unittests/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 5af2a077..3af44e48 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -160,7 +160,7 @@ void check_error(const Result& result, const Error::Code expected) CHECK(result.error().code() == expected); } -void write_file(const std::filesystem::path& path, const io::byte::span bytes) { REQUIRE(io::byte::write(bytes, path)); } +void write_file(const std::filesystem::path& path, const io::byte::span bytes) { REQUIRE(io::byte::write(bytes, path, io::byte::WriteMode::Overwrite)); } } // namespace @@ -561,7 +561,7 @@ TEST_CASE("Envelope headers are read without the payload") 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)); + 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); diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp index a4d2eed6..9a9a0f03 100644 --- a/unittests/terrainlib/io_image.cpp +++ b/unittests/terrainlib/io_image.cpp @@ -165,6 +165,6 @@ TEST_CASE("byte writer retains legacy overwrite and supports exclusive creation" 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)); + 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/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index ac377363..10b749bb 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -48,7 +48,7 @@ constexpr std::string_view entity_json = void write_text(const std::filesystem::path& path, const std::string_view text) { - REQUIRE(io::byte::write(std::as_bytes(std::span(text)), path)); + REQUIRE(io::byte::write(std::as_bytes(std::span(text)), path, io::byte::WriteMode::Overwrite)); } void write_table(const std::filesystem::path& directory) @@ -196,7 +196,7 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra 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")); + REQUIRE(io::envelope::write(encoded, path.string() + ".amort", io::byte::WriteMode::Overwrite)); CHECK(amort.read(path).error().code() == Error::Code::CorruptData); } @@ -340,10 +340,10 @@ TEST_CASE("raster reader selection follows metadata instead of file endings", "[ 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)); + 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)); + REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name, io::byte::WriteMode::Overwrite)); REQUIRE(storage::open(final)); } @@ -394,7 +394,7 @@ TEST_CASE("byte writes report buffered device failures", "[io][bytes][raster-sto { #ifdef __linux__ const std::array bytes { std::byte { 42 } }; - auto written = io::byte::write(bytes, "/dev/full", false); + auto written = io::byte::write(bytes, "/dev/full", io::byte::WriteMode::Overwrite, false); REQUIRE_FALSE(written); CHECK(written.error().code() == Error::Code::Io); #endif @@ -473,14 +473,14 @@ TEST_CASE("raster opening retains metadata and index errors without creating a d { auto metadata = manifest::read_metadata(final).value(); metadata.layout_id = "unknown"; - REQUIRE(io::envelope::write(metadata, final / manifest::metadata_file_name)); + 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)); + 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") @@ -508,7 +508,7 @@ TEST_CASE("AMORT propagates envelope checksum and version errors", "[raster-stor expected_error = Error::Code::Unsupported; } const auto modified = io::envelope::detail::serialize_to_bytes(envelope).value(); - REQUIRE(io::byte::write(modified, payload_path)); + REQUIRE(io::byte::write(modified, payload_path, io::byte::WriteMode::Overwrite)); CHECK(amort.read(path).error().code() == expected_error); } diff --git a/unittests/terrainlib/store_compatibility.cpp b/unittests/terrainlib/store_compatibility.cpp index 72df25ca..bf307109 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -216,7 +216,7 @@ TEST_CASE("octree opening retains index failures without directory fallback", "[ CHECK(unknown_codec.error().code() == Error::Code::Unsupported); const std::array malformed_bytes { std::byte { 0xff }, std::byte { 0x00 }, std::byte { 0x01 } }; - REQUIRE(io::byte::write(malformed_bytes, index_path).has_value()); + 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); diff --git a/unittests/terrainlib/store_snapshot.cpp b/unittests/terrainlib/store_snapshot.cpp index ab054dd6..b246b120 100644 --- a/unittests/terrainlib/store_snapshot.cpp +++ b/unittests/terrainlib/store_snapshot.cpp @@ -98,7 +98,7 @@ TEST_CASE("snapshots are created as .part and published by renaming", "[store][s 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")); } + 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); @@ -184,15 +184,20 @@ TEST_CASE("octree snapshots are created empty, published, and fingerprinted", "[ REQUIRE(metadata.has_value()); SECTION("recompression keeps the fingerprint") { - REQUIRE(io::envelope::write(*metadata, metadata_path, true, io::envelope::CompressionAlgorithm::None)); + 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, true, io::envelope::CompressionAlgorithm::ZstdDefaultCompression, io::hash::Algorithm::None)); + 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 9703735e..4069d2e3 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -94,7 +94,7 @@ Expected read_int(const std::filesystem::path& path) Expected write_int(const int value, const std::filesystem::path& path) { const auto bytes = std::as_bytes(std::span(&value, 1)); - auto written = io::byte::write(bytes, path); + auto written = io::byte::write(bytes, path, io::byte::WriteMode::Overwrite); if (!written) { return Error::propagate(std::move(written), "write test integer"); } @@ -208,7 +208,7 @@ TEST_CASE("byte writes report directory creation errors", "[io][bytes]") const std::filesystem::path blocker = directory.path() / "blocker"; REQUIRE(write_int(1, blocker).has_value()); - const auto result = io::byte::write(io::byte::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); } From e0ba0fdbad4c5319b47a0e8f9e0b52be0327c642 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:09:17 +0200 Subject: [PATCH 72/83] write payloads on the worker threads Encoding a node (copies, serialization, zstd, hashing) ran one node at a time: on the coordinator in rf_builder and rf_merger, and under the exclusive lock of ThreadSafeStorage in sf_builder and dag_builder. With zstd-best the merger was effectively single-threaded. Storage::save is now split into write_payload, which only reads the layout and codec and may run concurrently, and commit, which records the node in the index. Workers write the payloads; the coordinator, or the exclusive lock, only commits. Reads see a node only after commit. Codecs create payload files exclusively, so a second write of a key, concurrent or later, fails with AlreadyExists and never truncates a file hard-linked into another snapshot. Storage is always indexed now; the unindexed mode and the unused remove operations are gone. Failed saves stop dag_builder. The RF merger cancellation test now stops after the first written tile instead of after a fixed number of polls, which failed when the worker was slow. --- src/dag_builder/build.cpp | 2 +- src/dag_builder/codec.cpp | 4 +- src/terrainlib/mesh/codec/Gltf.h | 2 +- src/terrainlib/mesh/codec/SfMesh.h | 10 +- src/terrainlib/mesh/io.cpp | 2 +- src/terrainlib/mesh/io/gltf.cpp | 12 +- src/terrainlib/mesh/io/options.h | 2 + src/terrainlib/raster_store/io/TileCodec.h | 2 +- src/terrainlib/store/Codec.h | 6 + src/terrainlib/store/IndexedStorage.h | 1 - src/terrainlib/store/RawStorage.h | 17 -- src/terrainlib/store/Storage.h | 97 ++++------ src/terrainlib/store/ThreadSafeStorage.h | 22 ++- .../dag_builder/storage_compatibility.cpp | 22 +++ unittests/terrainlib/raster_store.cpp | 23 ++- unittests/terrainlib/store_codec.cpp | 29 ++- unittests/terrainlib/store_storage.cpp | 170 +++++++++++++----- 17 files changed, 282 insertions(+), 141 deletions(-) diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index e7b6f587..b34466e7 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -606,7 +606,7 @@ std::unordered_set build_level( } saved_ids.push_back(target); } else { - LOG_ERROR( + LOG_ERROR_AND_EXIT( "Failed to save node {}: {}", target, save_result.error().to_string()); diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index 5bedadcd..6ee340e2 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -443,11 +443,11 @@ 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(*clustering, node_paths[0], io::byte::WriteMode::Overwrite); + auto data_written = io::envelope::write(*clustering, node_paths[0], io::byte::WriteMode::CreateNew); if (!data_written) { return Error::propagate(std::move(data_written), "write DAG clustering"); } - auto metadata_written = io::envelope::write(metadata, node_paths[1], io::byte::WriteMode::Overwrite); + auto metadata_written = io::envelope::write(metadata, node_paths[1], io::byte::WriteMode::CreateNew); if (!metadata_written) { return Error::propagate(std::move(metadata_written), "write DAG metadata"); } diff --git a/src/terrainlib/mesh/codec/Gltf.h b/src/terrainlib/mesh/codec/Gltf.h index fa6b4272..3bf191e8 100644 --- a/src/terrainlib/mesh/codec/Gltf.h +++ b/src/terrainlib/mesh/codec/Gltf.h @@ -62,7 +62,7 @@ class Gltf final : public store::Codec { { 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() + "\""); } diff --git a/src/terrainlib/mesh/codec/SfMesh.h b/src/terrainlib/mesh/codec/SfMesh.h index a6614043..365a6702 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -49,9 +49,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); @@ -61,7 +65,7 @@ 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(*payload, path, ::io::byte::WriteMode::Overwrite); + auto result = ::io::envelope::write(*payload, path, mode); if (!result) { return Error::propagate(std::move(result), "write SF mesh"); } diff --git a/src/terrainlib/mesh/io.cpp b/src/terrainlib/mesh/io.cpp index 4a615f33..dae0b462 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -56,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); } else { return Error::fail(Error::Code::Unsupported, "unsupported mesh output format: " + extension.string()); } diff --git a/src/terrainlib/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index 60295c3b..a445a07b 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #define CGLTF_IMPLEMENTATION #define CGLTF_WRITE_IMPLEMENTATION @@ -294,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; @@ -337,8 +339,12 @@ try { 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); } @@ -894,7 +900,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/options.h b/src/terrainlib/mesh/io/options.h index 7806e6af..55216cbf 100644 --- a/src/terrainlib/mesh/io/options.h +++ b/src/terrainlib/mesh/io/options.h @@ -22,6 +22,7 @@ #include #include +#include "io/byte.h" namespace mesh::io { @@ -32,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/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index ed3ff8c0..69ee3c09 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -103,7 +103,7 @@ class TileCodec final : public store::Codec> { const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; return ::io::envelope::write( - encoded, paths(node_path).front(), ::io::byte::WriteMode::Overwrite, true, m_compression_algorithm, m_hash_algorithm); + encoded, paths(node_path).front(), ::io::byte::WriteMode::CreateNew, true, m_compression_algorithm, m_hash_algorithm); } private: diff --git a/src/terrainlib/store/Codec.h b/src/terrainlib/store/Codec.h index 0de7cfa2..cc8ffb6e 100644 --- a/src/terrainlib/store/Codec.h +++ b/src/terrainlib/store/Codec.h @@ -28,6 +28,12 @@ namespace store { +// 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: diff --git a/src/terrainlib/store/IndexedStorage.h b/src/terrainlib/store/IndexedStorage.h index 248c0d32..c478b71f 100644 --- a/src/terrainlib/store/IndexedStorage.h +++ b/src/terrainlib/store/IndexedStorage.h @@ -33,7 +33,6 @@ class IndexedStorage : public Storage { explicit IndexedStorage(Storage storage) : Base(std::move(storage)) { - this->ensure_indexed(); } IndexedStorage(RawStorage raw, Index index, Persistence persistence) diff --git a/src/terrainlib/store/RawStorage.h b/src/terrainlib/store/RawStorage.h index 8b53e6be..708165e7 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.h @@ -96,23 +96,6 @@ 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; - } - // Hard-links the node's files from source. Both storages must use the same codec, // and the target files must not exist. Expected link_from(const Key& key, const RawStorage& source) const diff --git a/src/terrainlib/store/Storage.h b/src/terrainlib/store/Storage.h index 36e87aeb..d448ea0e 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -47,11 +47,6 @@ 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) : m_raw(std::move(raw)) , m_index(std::move(index)) @@ -95,14 +90,12 @@ class Storage { 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); } @@ -121,17 +114,37 @@ class Storage { 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); + auto result = write_payload(key, data); if (!result) { return result; } - 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); + } + + // Writes the node's payload files without recording the node in the index; + // commit records it. 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 in the index after write_payload has succeeded for that key. + // Not thread safe. Fails with AlreadyExists if the node is already physically + // present in the index. + Expected commit(const Key& key) + { + 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"); } + m_dirty = true; return {}; } @@ -153,30 +166,7 @@ class Storage { if (!result) { return result; } - if (m_index.has_value()) { - auto added = m_index->add(key); - if (!added) { - return Error::propagate(std::move(added), "add linked node " + Traits::key_to_string(key) + " to storage index"); - } - m_dirty = m_dirty || added.value(); - } - return {}; - } - - Expected remove(const Key& key) - { - auto removed = m_raw.remove(key); - if (!removed) { - return removed; - } - 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(); - } - return removed.value(); + return commit(key); } Expected has(const Key& key) const @@ -184,9 +174,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"); @@ -216,13 +203,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 { @@ -253,14 +233,7 @@ class Storage { return result; } - Expected save_or_create_index() - { - if (!m_index.has_value()) { - m_index.emplace(); - m_dirty = true; - } - return save_index(); - } + Expected save_or_create_index() { return save_index(); } protected: Index& index_mut() { return m_index.value(); } diff --git a/src/terrainlib/store/ThreadSafeStorage.h b/src/terrainlib/store/ThreadSafeStorage.h index 12ec174b..88d6ec6e 100644 --- a/src/terrainlib/store/ThreadSafeStorage.h +++ b/src/terrainlib/store/ThreadSafeStorage.h @@ -26,6 +26,8 @@ #include +#include "Error.h" + namespace store { template @@ -62,10 +64,26 @@ class ThreadSafeStorage { 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))); + } + } + if (auto written = m_storage.write_payload(key, value); !written) { + return written; + } std::unique_lock lock(m_mutex); - return m_storage.save(key, value); + return m_storage.commit(key); } template diff --git a/unittests/dag_builder/storage_compatibility.cpp b/unittests/dag_builder/storage_compatibility.cpp index 80e31d62..af25f756 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.cpp @@ -25,6 +25,7 @@ #include "build.h" #include "codec.h" #include "dag_node.h" +#include "io/byte.h" #include "mesh/SimpleMesh.h" #include "mesh/storage.h" #include "storage.h" @@ -133,6 +134,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"); diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 10b749bb..329fac26 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -159,12 +159,31 @@ TEMPLATE_TEST_CASE("AMORT preserves native scalar and packed vector bytes", "[ra check_bytes(read->attribution, tile.attribution); TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::Xxh3_64); - REQUIRE(uncompressed.write(path, tile)); - read = writer.read(path); + 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; diff --git a/unittests/terrainlib/store_codec.cpp b/unittests/terrainlib/store_codec.cpp index c27eac4b..ef722d97 100644 --- a/unittests/terrainlib/store_codec.cpp +++ b/unittests/terrainlib/store_codec.cpp @@ -83,6 +83,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]") @@ -123,14 +137,23 @@ 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); } diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index 4069d2e3..c19bb416 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -19,9 +19,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include @@ -33,6 +36,7 @@ #include "octree/store_layout/Mappings.h" #include "raster_store/StoreTraits.h" #include "store/IndexedStorage.h" +#include "store/ThreadSafeStorage.h" #include "string_utils.h" namespace { @@ -94,7 +98,7 @@ Expected read_int(const std::filesystem::path& path) Expected write_int(const int value, const std::filesystem::path& path) { const auto bytes = std::as_bytes(std::span(&value, 1)); - auto written = io::byte::write(bytes, path, io::byte::WriteMode::Overwrite); + auto written = io::byte::write(bytes, path, io::byte::WriteMode::CreateNew); if (!written) { return Error::propagate(std::move(written), "write test integer"); } @@ -110,13 +114,6 @@ class IntCodec final : public store::Codec { Expected write(const std::filesystem::path& node_path, const int& value) const override { return write_int(value, paths(node_path).front()); } }; -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())); -} - class MultiFileCodec final : public store::Codec { public: std::vector paths(const std::filesystem::path& node_path) const override @@ -145,51 +142,61 @@ class MultiFileCodec final : public store::Codec { 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; - return store::IndexedStorage( - store::RawStorage(store::path_layout::Resolver(path, octree::store_layout::flat()), std::move(codec)), - store::Index {}, - Storage::Persistence { - counting_index_format(), - path / "octree.storeindex", - "flat", + const auto mapping = test_mapping(); + return store::IndexedStorage( + store::RawStorage(store::path_layout::Resolver(path, mapping), std::move(codec)), + store::Index {}, + 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() }; +} + } // namespace TEST_CASE("parent directory creation reports success for new and existing directories", "[io][bytes]") @@ -213,11 +220,11 @@ TEST_CASE("byte writes report directory creation errors", "[io][bytes]") 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) +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()); @@ -225,26 +232,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 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()); @@ -254,7 +258,7 @@ TEST_CASE("shared storage rejects saving existing nodes", "[store][storage]") CHECK(storage.load(root).value() == 1); } -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( @@ -267,9 +271,90 @@ 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(); + + REQUIRE(storage.write_payload(root, 7).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).has_value()); + CHECK(storage.has(root).value()); + CHECK(storage.load(root).value() == 7); + + const auto recommitted = storage.commit(root); + 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]") @@ -277,6 +362,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()); @@ -289,9 +375,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()); + 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); From efcc45de2d82741877f3dc8fee8b38bd249bfacd Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:12:02 +0200 Subject: [PATCH 73/83] gis: new vector_mask API with load, transform and simplify vector_mask now offers three functions on Expected, so they chain: load only reads the polygons and rejects broken input, simplify removes vertices with CGAL's topology-preserving polyline simplification, and transform reprojects while subdividing edges until the result is within the tolerance of the curved edges. Tolerances are in metres, converted to SRS units on a coarse grid. The previous API moves unchanged to vector_mask_deprecated.h until sf-merger and rf-builder migrate. --- src/sf_merger/mask.h | 22 +- src/terrainlib/CMakeLists.txt | 3 +- src/terrainlib/gis/vector_mask.cpp | 510 ++++++++++++++++++++ src/terrainlib/gis/vector_mask.h | 270 +---------- src/terrainlib/gis/vector_mask_deprecated.h | 274 +++++++++++ unittests/CMakeLists.txt | 1 + unittests/terrainlib/gis_vector_mask.cpp | 337 +++++++++++++ 7 files changed, 1158 insertions(+), 259 deletions(-) create mode 100644 src/terrainlib/gis/vector_mask.cpp create mode 100644 src/terrainlib/gis/vector_mask_deprecated.h create mode 100644 unittests/terrainlib/gis_vector_mask.cpp diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index a6bb0c17..1ef7b9b2 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -30,7 +30,7 @@ #include #include "gis/Dataset.h" -#include "gis/vector_mask.h" +#include "gis/vector_mask_deprecated.h" #include "mesh/SimpleMesh.h" #include "mesh/cgal.h" #include "mesh/convert.h" @@ -106,16 +106,16 @@ namespace { } } -using gis::vector_mask::LoadErrorKind; -using gis::vector_mask::LoadError; -using gis::vector_mask::simplification_tolerance_metres; -using gis::vector_mask::simplification_tolerance; -using gis::vector_mask::point_count; -using gis::vector_mask::simplify_geometry; -using gis::vector_mask::convert_ring; -using gis::vector_mask::convert_polygon; -using gis::vector_mask::process_geometry; -using gis::vector_mask::load_referenced_from_dataset; +using gis::vector_mask::deprecated::LoadErrorKind; +using gis::vector_mask::deprecated::LoadError; +using gis::vector_mask::deprecated::simplification_tolerance_metres; +using gis::vector_mask::deprecated::simplification_tolerance; +using gis::vector_mask::deprecated::point_count; +using gis::vector_mask::deprecated::simplify_geometry; +using gis::vector_mask::deprecated::convert_ring; +using gis::vector_mask::deprecated::convert_polygon; +using gis::vector_mask::deprecated::process_geometry; +using gis::vector_mask::deprecated::load_referenced_from_dataset; namespace { template diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index aa0bb612..1aec6966 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -70,7 +70,8 @@ add_library(terrainlib 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.h gis/vector_mask.cpp + gis/vector_mask_deprecated.h gis/webmercator.h gis/webmercator.cpp io/byte.h io/byte.cpp 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 index 7d4fecfa..d67dbf99 100644 --- a/src/terrainlib/gis/vector_mask.h +++ b/src/terrainlib/gis/vector_mask.h @@ -18,17 +18,17 @@ #pragma once -#include -#include -#include -#include -#include #include #include -#include "gis/Dataset.h" -#include "gis/srs.h" +#include + +#include "Error.h" #include "mesh/cgal.h" +namespace gis { +class Dataset; +} + namespace gis::vector_mask { using Kernel = cgal::kernel::epeck::Kernel; @@ -43,245 +43,21 @@ struct ReferencedPolygonMask { OGRSpatialReference srs; }; -enum class LoadErrorKind { - UnsupportedFormat, - FileNotFound, - EmptySource, - InvalidGeometry, - UnsupportedSpatialReference, - ReadFailure, -}; - -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::ReadFailure: - return "feature read failure"; - 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_metres = 0.1; - -inline 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; -} - -inline 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; -} - -inline std::unique_ptr simplify_geometry( - const OGRGeometry &geometry, - const double tolerance -) { - return std::unique_ptr(geometry.SimplifyPreserveTopology(tolerance)); -} - -inline 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; -} - -inline 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); - - 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); - polygon.add_hole(std::move(hole)); - } - - return polygon; -} - -inline 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; - } -} - -inline std::expected load_referenced_from_dataset(Dataset& mask_dataset) { - GDALDataset *dataset = mask_dataset.gdalDataset(); - - OGRSpatialReference srs; - if (auto dataset_srs = mask_dataset.srs()) { - srs = std::move(*dataset_srs); - } else { - 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; - CPLErrorReset(); - 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 (CPLGetLastErrorType() >= CE_Failure) { - return std::unexpected(LoadErrorKind::ReadFailure); - } - - 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)}; -} - +/// 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/terrainlib/gis/vector_mask_deprecated.h b/src/terrainlib/gis/vector_mask_deprecated.h new file mode 100644 index 00000000..32f16617 --- /dev/null +++ b/src/terrainlib/gis/vector_mask_deprecated.h @@ -0,0 +1,274 @@ +/***************************************************************************** + * 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 "gis/Dataset.h" +#include "gis/srs.h" +#include "gis/vector_mask.h" + +// The previous mask API; superseded by gis/vector_mask.h, which provides the shared types. +namespace gis::vector_mask::deprecated { + +enum class LoadErrorKind { + UnsupportedFormat, + FileNotFound, + EmptySource, + InvalidGeometry, + UnsupportedSpatialReference, + ReadFailure, +}; + +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::ReadFailure: + return "feature read failure"; + 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_metres = 0.1; + +inline 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; +} + +inline 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; +} + +inline std::unique_ptr simplify_geometry( + const OGRGeometry &geometry, + const double tolerance +) { + return std::unique_ptr(geometry.SimplifyPreserveTopology(tolerance)); +} + +inline 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; +} + +inline 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); + + 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); + polygon.add_hole(std::move(hole)); + } + + return polygon; +} + +inline 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; + } +} + +inline std::expected load_referenced_from_dataset(Dataset& mask_dataset) { + GDALDataset *dataset = mask_dataset.gdalDataset(); + + OGRSpatialReference srs; + if (auto dataset_srs = mask_dataset.srs()) { + srs = std::move(*dataset_srs); + } else { + 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; + CPLErrorReset(); + 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 (CPLGetLastErrorType() >= CE_Failure) { + return std::unexpected(LoadErrorKind::ReadFailure); + } + + 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)}; +} + + +} // namespace gis::vector_mask::deprecated diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 2b7c1703..729d48f2 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(unittests_terrainlib terrainlib/gis_dataset_reading.cpp terrainlib/gis_srs_bounds.cpp terrainlib/gis_srs_coverage.cpp + terrainlib/gis_vector_mask.cpp terrainlib/glm_serialization.cpp terrainlib/hash_utils.cpp terrainlib/hybrid_index_pair_map.cpp 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); +} From b40383ca457c683774116ada7b14a26122ae5b0a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:35:45 +0200 Subject: [PATCH 74/83] port sf_merger to new vector mask --- src/sf_merger/cli.cpp | 1 + src/sf_merger/main.cpp | 30 +-- src/sf_merger/mask.h | 30 +-- src/terrainlib/CMakeLists.txt | 1 - src/terrainlib/gis/vector_mask_deprecated.h | 274 -------------------- unittests/sf_merger/mask.cpp | 52 ---- 6 files changed, 19 insertions(+), 369 deletions(-) delete mode 100644 src/terrainlib/gis/vector_mask_deprecated.h diff --git a/src/sf_merger/cli.cpp b/src/sf_merger/cli.cpp index 74040656..70ed0d7e 100644 --- a/src/sf_merger/cli.cpp +++ b/src/sf_merger/cli.cpp @@ -70,6 +70,7 @@ 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, "New path for the cut dataset; it is written to .part, which is renamed to when complete") diff --git a/src/sf_merger/main.cpp b/src/sf_merger/main.cpp index 4ba65bbb..6ac0dfcf 100644 --- a/src/sf_merger/main.cpp +++ b/src/sf_merger/main.cpp @@ -34,24 +34,16 @@ #include "mesh/storage.h" #include "optional_utils.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); } int run(const cli::MergeArgs& args) @@ -81,7 +73,7 @@ int run(const cli::MergeArgs& args) LOG_ERROR_AND_EXIT("Failed to record the merge inputs: {}", record.error().to_string()); } - std::optional mask = flatten(map(args.mask_path, load_mask_from_path)); + std::optional mask = map(args.mask_path, load_mask_from_path); 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); @@ -107,7 +99,7 @@ int 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, diff --git a/src/sf_merger/mask.h b/src/sf_merger/mask.h index 1ef7b9b2..0afc8d2c 100644 --- a/src/sf_merger/mask.h +++ b/src/sf_merger/mask.h @@ -30,7 +30,7 @@ #include #include "gis/Dataset.h" -#include "gis/vector_mask_deprecated.h" +#include "gis/vector_mask.h" #include "mesh/SimpleMesh.h" #include "mesh/cgal.h" #include "mesh/convert.h" @@ -106,16 +106,7 @@ namespace { } } -using gis::vector_mask::deprecated::LoadErrorKind; -using gis::vector_mask::deprecated::LoadError; -using gis::vector_mask::deprecated::simplification_tolerance_metres; -using gis::vector_mask::deprecated::simplification_tolerance; -using gis::vector_mask::deprecated::point_count; -using gis::vector_mask::deprecated::simplify_geometry; -using gis::vector_mask::deprecated::convert_ring; -using gis::vector_mask::deprecated::convert_polygon; -using gis::vector_mask::deprecated::process_geometry; -using gis::vector_mask::deprecated::load_referenced_from_dataset; +inline constexpr double simplification_tolerance_m = 0.1; namespace { template @@ -344,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); - } - +inline Expected load_referenced_from_path(const std::filesystem::path &path) { auto opened = gis::Dataset::open_vector(path); if (!opened) { - LOG_ERROR("Failed to load mask dataset: {}", opened.error().to_string()); - return std::unexpected(LoadErrorKind::FileNotFound); + return Error::propagate(std::move(opened)); } - gis::Dataset dataset = std::move(*opened); - 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/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 1aec6966..797729c1 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -71,7 +71,6 @@ add_library(terrainlib gis/srs.h gis/srs.cpp gis/srs/detail.h gis/srs/detail.cpp gis/vector_mask.h gis/vector_mask.cpp - gis/vector_mask_deprecated.h gis/webmercator.h gis/webmercator.cpp io/byte.h io/byte.cpp diff --git a/src/terrainlib/gis/vector_mask_deprecated.h b/src/terrainlib/gis/vector_mask_deprecated.h deleted file mode 100644 index 32f16617..00000000 --- a/src/terrainlib/gis/vector_mask_deprecated.h +++ /dev/null @@ -1,274 +0,0 @@ -/***************************************************************************** - * 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 "gis/Dataset.h" -#include "gis/srs.h" -#include "gis/vector_mask.h" - -// The previous mask API; superseded by gis/vector_mask.h, which provides the shared types. -namespace gis::vector_mask::deprecated { - -enum class LoadErrorKind { - UnsupportedFormat, - FileNotFound, - EmptySource, - InvalidGeometry, - UnsupportedSpatialReference, - ReadFailure, -}; - -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::ReadFailure: - return "feature read failure"; - 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_metres = 0.1; - -inline 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; -} - -inline 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; -} - -inline std::unique_ptr simplify_geometry( - const OGRGeometry &geometry, - const double tolerance -) { - return std::unique_ptr(geometry.SimplifyPreserveTopology(tolerance)); -} - -inline 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; -} - -inline 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); - - 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); - polygon.add_hole(std::move(hole)); - } - - return polygon; -} - -inline 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; - } -} - -inline std::expected load_referenced_from_dataset(Dataset& mask_dataset) { - GDALDataset *dataset = mask_dataset.gdalDataset(); - - OGRSpatialReference srs; - if (auto dataset_srs = mask_dataset.srs()) { - srs = std::move(*dataset_srs); - } else { - 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; - CPLErrorReset(); - 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 (CPLGetLastErrorType() >= CE_Failure) { - return std::unexpected(LoadErrorKind::ReadFailure); - } - - 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)}; -} - - -} // namespace gis::vector_mask::deprecated diff --git a/unittests/sf_merger/mask.cpp b/unittests/sf_merger/mask.cpp index 2cf626e0..ca1d60f0 100644 --- a/unittests/sf_merger/mask.cpp +++ b/unittests/sf_merger/mask.cpp @@ -38,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) { From 486dfc68deb664501fb3930be75451208eb29690 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:21:04 +0200 Subject: [PATCH 75/83] log: create the logger thread-safely get_logger created the logger lazily on the first call without synchronisation. Since payloads are written on the worker threads, the first log calls can come from several threads at once; each saw no logger, and all but one failed with "logger with name 'LOG' already exists". The tools hid this by calling Log::init in main, before starting threads. The logger is now a function-local static, created once on first use. Log::init only sets the level, so it can also be called after something has logged. --- src/terrainlib/cli_support/log.cpp | 17 +++++++++++------ src/terrainlib/cli_support/log.h | 10 ++-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/terrainlib/cli_support/log.cpp b/src/terrainlib/cli_support/log.cpp index 8a7c73be..976a0000 100644 --- a/src/terrainlib/cli_support/log.cpp +++ b/src/terrainlib/cli_support/log.cpp @@ -28,14 +28,19 @@ namespace cli_support { -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); +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); diff --git a/src/terrainlib/cli_support/log.h b/src/terrainlib/cli_support/log.h index e32662b3..7249cb63 100644 --- a/src/terrainlib/cli_support/log.h +++ b/src/terrainlib/cli_support/log.h @@ -34,18 +34,12 @@ namespace cli_support { class Log { -private: - static std::shared_ptr logger; 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); - inline static std::shared_ptr& get_logger() { - if (Log::logger == nullptr) { - Log::init(spdlog::level::level_enum::info); - } - return Log::logger; - } + // 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. From 8be0c92a757d1fa151bcbd1addd4654e9e7c3339 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:21:48 +0200 Subject: [PATCH 76/83] log: make LOG_WARN_ONCE thread-safe The macro read and set a plain static bool, a data race when several threads reach the call site at once, which could log the warning more than once. The flag is now an atomic, claimed with exchange before logging. --- src/terrainlib/cli_support/log.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/terrainlib/cli_support/log.h b/src/terrainlib/cli_support/log.h index 7249cb63..8c4dfd9f 100644 --- a/src/terrainlib/cli_support/log.h +++ b/src/terrainlib/cli_support/log.h @@ -24,6 +24,7 @@ #endif #include "fmt_impls.h" #include "numeric/int_math.h" +#include #include #include #include @@ -62,14 +63,13 @@ void log_assertion_failures(); 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; \ - } \ +// 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 { From 031fede294a80cb8660ccf5fd57952a6d83f938b Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:31:02 +0200 Subject: [PATCH 77/83] log: make LOG_WARN_BACKOFF thread-safe LogBackoff incremented a plain counter, a data race when several threads reach the call site at once, and the macro read the count again for the message, so another thread's increment could show up there. The counter is now atomic, and count_occurrence counts and returns the occurrence number to log in one step, so each power-of-two occurrence is logged exactly once and with its own number. --- src/terrainlib/cli_support/log.h | 35 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/terrainlib/cli_support/log.h b/src/terrainlib/cli_support/log.h index 8c4dfd9f..b176eba8 100644 --- a/src/terrainlib/cli_support/log.h +++ b/src/terrainlib/cli_support/log.h @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -74,30 +75,32 @@ void log_assertion_failures(); namespace cli_support { -// Tracks call count per call site and reports whether the current call falls -// on a power-of-two occurrence (1, 2, 4, 8, 16, ...). +// 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: - bool should_log() { - this->_count++; - return is_power_of_two(this->_count); - } - - uint64_t count() const { - return this->_count; + // 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: - uint64_t _count = 0; + 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 (_log_warn_backoff.should_log()) { \ - LOG_WARN(fmt_str " (occurrence {})", ##__VA_ARGS__, _log_warn_backoff.count()); \ - } \ +#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) From 9ab53a06aafd376fb7fcfcff3fad9acdc79a6138 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:42:00 +0200 Subject: [PATCH 78/83] dag-builder: stop on write failures instead of exiting A failed save, cache link or debug mesh write called exit(1) on a worker thread. That skipped the other workers and the final index save, and could interrupt another worker's checkpoint, which rewrites the index in place. A failure now stops the level like a stop request: active tasks finish their nodes, queued ones are skipped, and build_level returns the first error. build_levels saves the index before returning it, so the nodes committed before the failure are kept in the incomplete snapshot. --- src/dag_builder/build.cpp | 67 +++++++++++++++++++++------------------ src/dag_builder/main.cpp | 2 +- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/dag_builder/build.cpp b/src/dag_builder/build.cpp index b34466e7..103057c9 100644 --- a/src/dag_builder/build.cpp +++ b/src/dag_builder/build.cpp @@ -523,8 +523,8 @@ std::filesystem::path debug_mesh_path(const std::filesystem::path& output_path) return path; } -// Build a single level of the DAG, returning the set of nodes that were built. -std::unordered_set build_level( +// 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, @@ -545,9 +545,7 @@ std::unordered_set build_level( options.preferred_extension = ".glb"; 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())); } @@ -570,6 +568,13 @@ std::unordered_set build_level( 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 (ctx.stopped || (ctx.stop_requested && ctx.stop_requested())) { @@ -578,9 +583,10 @@ std::unordered_set build_level( return; } if (ctx.cache != nullptr && DEBUG_ASSERT_VAL(ctx.cache->has(target)).value()) { - const auto linked = ctx.output_storage.link_from(target, *ctx.cache); + auto linked = ctx.output_storage.link_from(target, *ctx.cache); if (!linked) { - LOG_ERROR_AND_EXIT("Failed to link cached node {}: {}", target, linked.error().to_string()); + fail(Error::propagate(std::move(linked), fmt::format("link cached node {}", target))); + return; } linked_ids.push_back(target); progress.task_finished(); @@ -592,25 +598,20 @@ 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_AND_EXIT( - "Failed to save node {}: {}", - target, - save_result.error().to_string()); } + saved_ids.push_back(target); } checkpoint(); progress.task_finished(); @@ -618,6 +619,10 @@ std::unordered_set build_level( progress_thread.join(); + 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); @@ -669,23 +674,25 @@ Expected build_levels( 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/main.cpp b/src/dag_builder/main.cpp index 05d9d79c..7292402e 100644 --- a/src/dag_builder/main.cpp +++ b/src/dag_builder/main.cpp @@ -118,7 +118,7 @@ int main(int argc, char **argv) { LOG_INFO("{}", build_result.error().to_string()); return cli_support::stop_signal::exit_code(); } - LOG_ERROR("Invalid Structura Fundamentalis input: {}", build_result.error().to_string()); + LOG_ERROR("DAG build failed: {}", build_result.error().to_string()); return EXIT_FAILURE; } const auto published = dag::storage::publish(std::move(output_storage)); From fd0d78b56aa4ee0804995cc376df1b925a96973c Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:59 +0200 Subject: [PATCH 79/83] io: replace index files atomically and durably The octree store wrote its index and metadata in place, truncating them first, so a crash during a checkpoint could leave an empty or partial index in the incomplete snapshot. The raster index and the RF merger statistics already wrote a temporary file and renamed it, each with its own copy of the code, but without syncing it. WriteMode::Replace writes and fsyncs .tmp, then renames it over path, so path holds the old or the new bytes even after a crash or power loss, and hard links to the old file keep the old bytes. All three now use it. The glTF writer, which opens its file itself, rejects Replace as unsupported. --- src/terrainlib/io/byte.cpp | 17 ++++++++++++++++ src/terrainlib/io/byte.h | 5 ++++- src/terrainlib/io/utils.cpp | 21 ++++++++++++++++++++ src/terrainlib/io/utils.h | 2 ++ src/terrainlib/mesh/io/gltf.cpp | 3 +++ src/terrainlib/octree/storage/IndexFile.h | 4 ++-- src/terrainlib/raster_store/io/manifest.cpp | 9 +-------- unittests/terrainlib/store_storage.cpp | 22 +++++++++++++++++++++ 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/terrainlib/io/byte.cpp b/src/terrainlib/io/byte.cpp index 19595be0..e64bf354 100644 --- a/src/terrainlib/io/byte.cpp +++ b/src/terrainlib/io/byte.cpp @@ -31,6 +31,23 @@ Expected write(const span bytes, const std::filesystem::path& path, WriteM { 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) { diff --git a/src/terrainlib/io/byte.h b/src/terrainlib/io/byte.h index 5e822f76..e3065488 100644 --- a/src/terrainlib/io/byte.h +++ b/src/terrainlib/io/byte.h @@ -34,7 +34,10 @@ namespace io::byte { using vector = std::vector; using span = std::span; -enum class WriteMode { Overwrite, CreateNew }; +// 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. diff --git a/src/terrainlib/io/utils.cpp b/src/terrainlib/io/utils.cpp index 46164fda..67674bdd 100644 --- a/src/terrainlib/io/utils.cpp +++ b/src/terrainlib/io/utils.cpp @@ -24,6 +24,7 @@ #ifdef __linux__ #include +#include #endif namespace io::utils { @@ -61,4 +62,24 @@ Expected rename_without_replacement(const std::filesystem::path& source, c #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 4dd3d144..05cfdf01 100644 --- a/src/terrainlib/io/utils.h +++ b/src/terrainlib/io/utils.h @@ -28,5 +28,7 @@ namespace io::utils { [[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/mesh/io/gltf.cpp b/src/terrainlib/mesh/io/gltf.cpp index a445a07b..a8dc2972 100644 --- a/src/terrainlib/mesh/io/gltf.cpp +++ b/src/terrainlib/mesh/io/gltf.cpp @@ -334,6 +334,9 @@ try { } } + 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"); diff --git a/src/terrainlib/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index f35581c3..7c557ea8 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -163,12 +163,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(store_metadata, metadata_path, io::byte::WriteMode::Overwrite); !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(encoded_index, path, io::byte::WriteMode::Overwrite); !written) { + 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/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index 1ea54d20..a58422ee 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -121,17 +121,10 @@ Expected> read_index_file(const std::filesyste Expected write_index_file(const std::filesystem::path& index_path, const store::IndexMetadata& metadata) { - auto temporary_path = index_path; - temporary_path += ".tmp"; - auto written = ::io::envelope::write(encode_index(metadata.index), temporary_path, ::io::byte::WriteMode::Overwrite); + auto written = ::io::envelope::write(encode_index(metadata.index), index_path, ::io::byte::WriteMode::Replace); if (!written) { return Error::propagate(std::move(written), "checkpoint raster index"); } - std::error_code error; - std::filesystem::rename(temporary_path, index_path, error); - if (error) { - return Error::fail(Error::Code::Io, "replace raster index checkpoint", temporary_path, index_path, error); - } return {}; } diff --git a/unittests/terrainlib/store_storage.cpp b/unittests/terrainlib/store_storage.cpp index c19bb416..a1cb010e 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -220,6 +220,28 @@ TEST_CASE("byte writes report directory creation errors", "[io][bytes]") CHECK(result.error().code() == Error::Code::Io); } +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; From 1fc49f29454cdeac7b195732ea90e88d02c93e94 Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:59:10 +0200 Subject: [PATCH 80/83] introduce node hashing and put them into the index --- docs/raster-store/storage-format.md | 39 ++-- docs/raster-store/todo.md | 11 - src/dag_builder/codec.cpp | 67 ++++-- src/dag_builder/codec.h | 14 +- src/terrainlib/io/envelope.h | 60 +++-- src/terrainlib/mesh/codec/Gltf.h | 9 +- src/terrainlib/mesh/codec/SfMesh.h | 20 +- src/terrainlib/mesh/io.cpp | 2 +- src/terrainlib/octree/storage/IndexFile.h | 50 +++- src/terrainlib/raster_store/io/TileCodec.h | 27 +-- src/terrainlib/raster_store/io/manifest.cpp | 38 ++- src/terrainlib/raster_store/io/manifest.h | 12 +- src/terrainlib/raster_store/storage.h | 13 +- src/terrainlib/store/Codec.h | 22 +- src/terrainlib/store/IndexFormat.h | 46 ++++ src/terrainlib/store/IndexedStorage.h | 4 +- src/terrainlib/store/RawStorage.h | 20 +- src/terrainlib/store/Storage.h | 84 +++++-- src/terrainlib/store/ThreadSafeStorage.h | 13 +- src/terrainlib/store/open.h | 6 +- .../dag_builder/storage_compatibility.cpp | 57 +++++ unittests/terrainlib/envelope.cpp | 31 ++- unittests/terrainlib/raster_store.cpp | 78 ++++++- unittests/terrainlib/store_codec.cpp | 25 +- unittests/terrainlib/store_compatibility.cpp | 59 ++++- unittests/terrainlib/store_storage.cpp | 217 +++++++++++++++++- 26 files changed, 851 insertions(+), 173 deletions(-) diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index dfe1c258..a0df8141 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -77,7 +77,9 @@ 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. Metadata +`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 @@ -102,19 +104,29 @@ for the first codec. Existing mesh stores retain extension-based selectors. The metadata codec selector is `amort`; payload filenames end in `.amort`. The versioned payloads use the existing `io::envelope` serialization. Version 1 -was revised directly for halo metadata; no compatibility with earlier V1 -metadata is provided. Size fields are 32-bit unsigned on supported platforms; -mapping enum values are 0 for Linear and 1 for SRGBA: +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 | +| `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 | -Index entries 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`. +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 @@ -144,8 +156,8 @@ 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, for example for TB dependency records, -but that read does not verify it. See the +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 @@ -173,11 +185,12 @@ 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 and hash options, -defaulting to standard Zstandard compression and an XXH3-64 hash. Readers +`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. Writing is lossless to preserve data through repeated merging and +their source, which is copied from the source index without reading the +tile. Writing is lossless to preserve data through repeated merging and overview generation. 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. diff --git a/docs/raster-store/todo.md b/docs/raster-store/todo.md index ec6bb30c..f36808ed 100644 --- a/docs/raster-store/todo.md +++ b/docs/raster-store/todo.md @@ -3,17 +3,6 @@ - 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. -- Idea: store each tile's XXH3-64 content hash (see the - [hash decision](../adr/0005-xxh3-64-envelope-hashes.md)) in its raster-store index entry. - TB dependency checks would become in-memory lookups instead of one envelope - header read per source tile, which matters for millions of tiles and on - network filesystems. Saving a tile knows the hash from the envelope write, - and `link_from` takes it from the source index. The cost is 8 bytes per - entry, about 1.6 times the current index size. Either the generic - `store::Index` carries per-node data beyond `NodeStatus`, or the raster - store keeps a separate hash table beside it. Header reads remain available - for verifying or rebuilding an index. Decide with the TB builder design that - defines the dependency records. - 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 diff --git a/src/dag_builder/codec.cpp b/src/dag_builder/codec.cpp index 6ee340e2..ae1ddfbd 100644 --- a/src/dag_builder/codec.cpp +++ b/src/dag_builder/codec.cpp @@ -17,13 +17,16 @@ *****************************************************************************/ #include +#include #include #include +#include #include #include #include #include +#include #include #include @@ -378,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) { - auto payload = io::envelope::read(path); + 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(path, expected_hash); if (!payload) { return Error::propagate(std::move(payload), "read DAG metadata"); } @@ -401,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(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)); } @@ -426,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) { @@ -443,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(*clustering, node_paths[0], io::byte::WriteMode::CreateNew); - 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(metadata, node_paths[1], io::byte::WriteMode::CreateNew); - 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 2fc792e4..ec753028 100644 --- a/src/dag_builder/codec.h +++ b/src/dag_builder/codec.h @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -28,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/terrainlib/io/envelope.h b/src/terrainlib/io/envelope.h index 78143d03..b9cdd801 100644 --- a/src/terrainlib/io/envelope.h +++ b/src/terrainlib/io/envelope.h @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -164,8 +165,10 @@ 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(byte::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); // 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. @@ -236,18 +239,14 @@ Expected deserialize_version( } } -} // namespace detail - template -Expected serialize( - const typename Schema::template payload_type &payload, +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"); + static_assert(Schema::template supports_version, "the requested payload version is not part of the schema"); - auto payload_bytes = detail::serialize_to_bytes(payload); + auto payload_bytes = serialize_to_bytes(payload); if (!payload_bytes) { return Error::propagate(std::move(payload_bytes), "serializing envelope payload"); } @@ -257,10 +256,10 @@ Expected serialize( return Error::propagate(std::move(compressed), "compressing envelope payload"); } - const Envelope envelope{ + Envelope envelope { .header = { .magic = magic, - .class_name = std::string{Schema::class_name}, + .class_name = std::string { Schema::class_name }, .class_version = VersionNumber, .hash_algorithm = hash_algorithm, .hash = hash::data(*payload_bytes, hash_algorithm), @@ -270,7 +269,21 @@ Expected serialize( .compressed_data = std::move(*compressed), }; ASSERT(envelope.header.hash.size() <= max_hash_size); - return detail::serialize_to_bytes(envelope); + 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 @@ -287,8 +300,7 @@ Expected serialize( template Expected deserialize( - const byte::span bytes, - const std::size_t max_decompressed_size) + 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) { @@ -328,34 +340,44 @@ Expected deserialize( 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::size_t max_decompressed_size = default_max_decompressed_size) +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, max_decompressed_size); + 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, +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 serialized = serialize(payload, compression_algorithm, hash_algorithm); + 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() + "\""); } @@ -363,7 +385,7 @@ Expected write(const typename Schema::latest_type& payload, if (!result) { return Error::propagate(std::move(result), "writing envelope file \"" + path.string() + "\""); } - return {}; + return std::move(envelope->header.hash); } } // namespace io::envelope diff --git a/src/terrainlib/mesh/codec/Gltf.h b/src/terrainlib/mesh/codec/Gltf.h index 3bf191e8..ea510721 100644 --- a/src/terrainlib/mesh/codec/Gltf.h +++ b/src/terrainlib/mesh/codec/Gltf.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -48,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); @@ -58,7 +61,7 @@ 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 { @@ -66,7 +69,7 @@ class Gltf final : public store::Codec { 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 365a6702..440dbb26 100644 --- a/src/terrainlib/mesh/codec/SfMesh.h +++ b/src/terrainlib/mesh/codec/SfMesh.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include "io/envelope.h" @@ -32,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(path); + auto payload = ::io::envelope::read(path, expected_hash); if (!payload) { return Error::propagate(std::move(payload), "read SF mesh"); } @@ -49,12 +51,12 @@ class SfMesh final : public store::Codec { return std::move(*decoded); } - 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 { return write(node_path, mesh, {}, ::io::byte::WriteMode::CreateNew); } - Expected write( + 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(); @@ -65,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(*payload, path, mode); - 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/io.cpp b/src/terrainlib/mesh/io.cpp index dae0b462..cde918cc 100644 --- a/src/terrainlib/mesh/io.cpp +++ b/src/terrainlib/mesh/io.cpp @@ -56,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}, options.write_mode); + 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/octree/storage/IndexFile.h b/src/terrainlib/octree/storage/IndexFile.h index 7c557ea8..f993414b 100644 --- a/src/terrainlib/octree/storage/IndexFile.h +++ b/src/terrainlib/octree/storage/IndexFile.h @@ -18,12 +18,15 @@ #pragma once +#include #include #include #include +#include #include #include +#include "io/byte.h" #include "io/envelope.h" #include "octree/StoreTraits.h" #include "octree/store_layout/Mappings.h" @@ -49,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 @@ -75,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) { @@ -86,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)); @@ -113,7 +135,16 @@ inline Expected> decode_index(const StoreIndex& encode if (auto valid = result.validate(); !valid) { return Error::propagate(std::move(valid), "validate octree index topology"); } - return result; + 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 (!decoded.hashes.emplace(*id, entry.hash).second) { + return Error::fail(Error::Code::CorruptData, "index contains a duplicate octree node hash"); + } + } + return decoded; } inline Expected read_store_metadata(const std::filesystem::path& base_path) @@ -140,12 +171,13 @@ inline Expected> read_index_file(const std::fi 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), @@ -167,7 +199,7 @@ inline Expected write_index_file(const std::filesystem::path& path, const return Error::propagate(std::move(written), "write octree store metadata " + metadata_path.string()); } - const StoreIndex encoded_index = encode_index(metadata.index); + 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()); } diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index 69ee3c09..b445f219 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -20,6 +20,7 @@ #include #include +#include #include #include "io/byte.h" @@ -53,11 +54,9 @@ class TileCodec final : public store::Codec> { public: explicit TileCodec(const glm::uvec2 dimensions = glm::uvec2(default_tile_side), - const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression, - const ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64) + const ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression) : m_dimensions(dimensions) , m_compression_algorithm(compression_algorithm) - , m_hash_algorithm(hash_algorithm) { } @@ -66,12 +65,13 @@ class TileCodec final : public store::Codec> { return { this->add_extension(node_path, ".amort") }; } - 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 { if (auto valid = validate_dimensions(m_dimensions); !valid) { return Error::propagate(std::move(valid)); } - auto encoded = ::io::envelope::read(paths(node_path).front()); + auto encoded = ::io::envelope::read(paths(node_path).front(), expected_hash); if (!encoded) { return Error::propagate(std::move(encoded), "read AMORT tile"); } @@ -90,10 +90,10 @@ class TileCodec final : public store::Codec> { return tile; } - Expected write(const std::filesystem::path& node_path, const Tile& tile) const override + Expected write(const std::filesystem::path& node_path, const Tile& tile) const override { if (auto valid = validate_dimensions(m_dimensions); !valid) { - return 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"); @@ -103,24 +103,25 @@ class TileCodec final : public store::Codec> { const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; return ::io::envelope::write( - encoded, paths(node_path).front(), ::io::byte::WriteMode::CreateNew, true, m_compression_algorithm, m_hash_algorithm); + 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; - ::io::hash::Algorithm m_hash_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, - const ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64) + 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, hash_algorithm); + return std::make_unique>(dimensions, compression_algorithm); } return Error::fail(Error::Code::Unsupported, "unsupported raster codec selector: " + std::string(name)); } diff --git a/src/terrainlib/raster_store/io/manifest.cpp b/src/terrainlib/raster_store/io/manifest.cpp index a58422ee..72b54681 100644 --- a/src/terrainlib/raster_store/io/manifest.cpp +++ b/src/terrainlib/raster_store/io/manifest.cpp @@ -58,10 +58,10 @@ Expected read_metadata(const std::filesystem::path& base_path) 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); + 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) +detail::v1::Hierarchy encode_index(const store::Index& index, const store::Key2Hash_Map& hashes) { detail::v1::Hierarchy encoded; encoded.entries.reserve(index.size()); @@ -69,12 +69,18 @@ detail::v1::Hierarchy encode_index(const store::Index& 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) +Expected> decode_index(const detail::v1::Hierarchy& encoded) { - store::Index index; + store::IndexContents decoded; + store::Index& index = decoded.index; std::unordered_set seen; for (const auto& entry : encoded.entries) { if (!StoreTraits::is_valid(entry.id)) { @@ -94,10 +100,18 @@ Expected> decode_index(const detail::v1::Hierarchy& en if (auto valid = index.validate(); !valid) { return Error::propagate(std::move(valid), "validate raster index topology"); } - return index; + 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) +Expected> read_hierarchy(const std::filesystem::path& index_path) { auto encoded = ::io::envelope::read(index_path); if (!encoded) { @@ -112,16 +126,18 @@ Expected> read_index_file(const std::filesyste if (!metadata) { return Error::propagate(std::move(metadata)); } - auto index = read_hierarchy(index_path); - if (!index) { - return Error::propagate(std::move(index)); + auto contents = read_hierarchy(index_path); + if (!contents) { + return Error::propagate(std::move(contents)); } - return store::IndexMetadata { std::move(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }; + 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), index_path, ::io::byte::WriteMode::Replace); + 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"); } diff --git a/src/terrainlib/raster_store/io/manifest.h b/src/terrainlib/raster_store/io/manifest.h index dd8e3849..2ca1f5b6 100644 --- a/src/terrainlib/raster_store/io/manifest.h +++ b/src/terrainlib/raster_store/io/manifest.h @@ -53,8 +53,14 @@ namespace detail::v1 { store::NodeStatus::Value status; }; + struct HashEntry { + radix::tile::Id id; + store::NodeDataHash hash; + }; + struct Hierarchy { std::vector entries; + std::vector hashes; }; } // namespace detail::v1 @@ -66,9 +72,9 @@ 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); +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(); diff --git a/src/terrainlib/raster_store/storage.h b/src/terrainlib/raster_store/storage.h index 0fc40247..9d0e118f 100644 --- a/src/terrainlib/raster_store/storage.h +++ b/src/terrainlib/raster_store/storage.h @@ -48,8 +48,6 @@ struct CreateOptions { std::optional value_mapping = std::nullopt; std::string codec_selector = "amort"; ::io::envelope::CompressionAlgorithm compression_algorithm = ::io::envelope::CompressionAlgorithm::ZstdDefaultCompression; - // Tile hashes serve as content identities, for example for TB dependency records; with None, tiles have none. - ::io::hash::Algorithm hash_algorithm = ::io::hash::Algorithm::Xxh3_64; std::optional copy_attribution_from_index; }; @@ -77,9 +75,9 @@ Expected>, std::unique return Error::propagate(std::move(metadata), "open raster snapshot"); } const auto index_path = normalized_path / io::manifest::index_file_name; - auto index = io::manifest::read_hierarchy(index_path); - if (!index) { - return Error::propagate(std::move(index), "open raster snapshot"); + 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) { @@ -89,7 +87,7 @@ Expected>, std::unique = [&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(*index), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, + { std::move(contents->index), std::move(contents->hashes), metadata->layout_id, metadata->payload_type, metadata->codec_selector }, pixel::identifier(), resolve_codec); if (!storage) { @@ -120,8 +118,7 @@ Expected>, std::unique_ptr( - options.codec_selector, glm::uvec2(metadata->stored_tile_size), options.compression_algorithm, options.hash_algorithm); + 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)); } diff --git a/src/terrainlib/store/Codec.h b/src/terrainlib/store/Codec.h index cc8ffb6e..2de7e911 100644 --- a/src/terrainlib/store/Codec.h +++ b/src/terrainlib/store/Codec.h @@ -18,16 +18,25 @@ #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 @@ -41,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/IndexFormat.h b/src/terrainlib/store/IndexFormat.h index e4464de7..fd82e5cc 100644 --- a/src/terrainlib/store/IndexFormat.h +++ b/src/terrainlib/store/IndexFormat.h @@ -18,27 +18,73 @@ #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 c478b71f..e5067e28 100644 --- a/src/terrainlib/store/IndexedStorage.h +++ b/src/terrainlib/store/IndexedStorage.h @@ -35,8 +35,8 @@ class IndexedStorage : public Storage { { } - 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/RawStorage.h b/src/terrainlib/store/RawStorage.h index 708165e7..39f36d75 100644 --- a/src/terrainlib/store/RawStorage.h +++ b/src/terrainlib/store/RawStorage.h @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -50,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); @@ -66,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)) { @@ -96,20 +99,13 @@ class RawStorage { return true; } - // Hard-links the node's files from source. Both storages must use the same codec, - // and the target files must not exist. + // 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)) { diff --git a/src/terrainlib/store/Storage.h b/src/terrainlib/store/Storage.h index d448ea0e..d87515fe 100644 --- a/src/terrainlib/store/Storage.h +++ b/src/terrainlib/store/Storage.h @@ -26,6 +26,8 @@ #include +#include + #include "cli_support/log.h" #include "store/IndexFormat.h" #include "store/RawStorage.h" @@ -47,9 +49,10 @@ class Storage { std::string codec_selector; }; - 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) { @@ -63,10 +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_dirty(std::exchange(other.m_dirty, false)) { other.m_index.reset(); + other.m_hashes.clear(); other.m_persistence.reset(); } @@ -78,13 +83,16 @@ 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_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)) { @@ -97,7 +105,11 @@ class Storage { 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) @@ -114,15 +126,16 @@ class Storage { return Error::fail(Error::Code::AlreadyExists, "save node to", node_paths.empty() ? m_raw.layout().node_path(key) : node_paths.front()); } - auto result = write_payload(key, data); - if (!result) { - return result; + auto hash = write_payload(key, data); + if (!hash) { + return Error::propagate(std::move(hash)); } - return commit(key); + return commit(key, std::move(*hash)); } - // Writes the node's payload files without recording the node in the index; - // commit records it. save is the existence check, write_payload and commit. + // 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 @@ -130,12 +143,13 @@ class Storage { // 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); } + Expected write_payload(const Key& key, const NodeData& data) const { return m_raw.save(key, data); } - // Records a node in the index after write_payload has succeeded for that key. - // Not thread safe. Fails with AlreadyExists if the node is already physically - // present in the index. - Expected commit(const Key& key) + // 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) { @@ -144,10 +158,16 @@ class Storage { 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 {}; } + // 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)) { @@ -161,12 +181,23 @@ class Storage { const auto node_paths = m_raw.paths(key).value(); return Error::fail(Error::Code::AlreadyExists, "link node to", node_paths.empty() ? m_raw.layout().node_path(key) : node_paths.front()); } + 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 (!source_exists.value()) { + return Error::fail(Error::Code::NotFound, "source node " + Traits::key_to_string(key) + " is missing"); + } + NodeDataHash source_hash; + if (const NodeDataHash* hash = source.recorded_hash(key)) { + source_hash = *hash; + } auto result = m_raw.link_from(key, source.m_raw); if (!result) { return result; } - return commit(key); + return commit(key, std::move(source_hash)); } Expected has(const Key& key) const @@ -192,6 +223,20 @@ 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(); } std::optional codec_selector() const @@ -222,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, @@ -242,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()) { @@ -255,6 +309,8 @@ 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; mutable bool m_dirty = false; }; diff --git a/src/terrainlib/store/ThreadSafeStorage.h b/src/terrainlib/store/ThreadSafeStorage.h index 88d6ec6e..83af5d55 100644 --- a/src/terrainlib/store/ThreadSafeStorage.h +++ b/src/terrainlib/store/ThreadSafeStorage.h @@ -62,6 +62,12 @@ 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(); } // Saves of different keys encode and write their payloads in parallel. The @@ -79,11 +85,12 @@ class ThreadSafeStorage { return Error::fail(Error::Code::AlreadyExists, "save node to", Error::asserting_unwrap(m_storage.path_for(key))); } } - if (auto written = m_storage.write_payload(key, value); !written) { - return written; + 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); + return m_storage.commit(key, std::move(*hash)); } template diff --git a/src/terrainlib/store/open.h b/src/terrainlib/store/open.h index cf2feacd..ac626b2d 100644 --- a/src/terrainlib/store/open.h +++ b/src/terrainlib/store/open.h @@ -53,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, @@ -103,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/unittests/dag_builder/storage_compatibility.cpp b/unittests/dag_builder/storage_compatibility.cpp index af25f756..25b102a6 100644 --- a/unittests/dag_builder/storage_compatibility.cpp +++ b/unittests/dag_builder/storage_compatibility.cpp @@ -16,8 +16,10 @@ * along with this program. If not, see . *****************************************************************************/ +#include #include #include +#include #include @@ -26,6 +28,7 @@ #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" @@ -298,3 +301,57 @@ TEST_CASE("DAG builds stop on request and reuse cached nodes", "[dag][snapshot]" } } } + +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/terrainlib/envelope.cpp b/unittests/terrainlib/envelope.cpp index 3af44e48..28a0827e 100644 --- a/unittests/terrainlib/envelope.cpp +++ b/unittests/terrainlib/envelope.cpp @@ -492,7 +492,7 @@ TEST_CASE("Envelope rejects incompatible metadata") SECTION("uncompressed size exceeds a caller limit") { auto envelope = decode_envelope(*serialized); - check_error(io::envelope::deserialize(encode_envelope(envelope), static_cast(envelope.header.uncompressed_size - 1)), + check_error(io::envelope::deserialize(encode_envelope(envelope), std::nullopt, static_cast(envelope.header.uncompressed_size - 1)), Error::Code::ResourceExhausted); } } @@ -660,3 +660,32 @@ TEST_CASE("Envelope headers are read without the payload") 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/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 329fac26..65c1b0a9 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -158,7 +159,7 @@ TEMPLATE_TEST_CASE("AMORT preserves native scalar and packed vector bytes", "[ra check_bytes(read->data, tile.data); check_bytes(read->attribution, tile.attribution); - TileCodec uncompressed({ 3, 3 }, io::envelope::CompressionAlgorithm::None, io::hash::Algorithm::Xxh3_64); + 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); @@ -238,12 +239,12 @@ TEST_CASE("raster index adapters preserve mixed hierarchy and reject corrupt top const auto root = raster_store::StoreTraits::root(); REQUIRE(index.add(root)); REQUIRE(index.add({ 2, { 1, 1 } })); - auto encoded = manifest::encode_index(index); + auto encoded = manifest::encode_index(index, {}); auto decoded = manifest::decode_index(encoded); REQUIRE(decoded); - CHECK(decoded->is(store::NodeStatus::Inner, root).value()); - CHECK(decoded->is(store::NodeStatus::Virtual, { 1, { 0, 0 } }).value()); - CHECK(decoded->is(store::NodeStatus::Leaf, { 2, { 1, 1 } }).value()); + 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 } }); @@ -592,3 +593,70 @@ TEST_CASE("snapshot geometry and mapping validation precede creation", "[raster- 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/store_codec.cpp b/unittests/terrainlib/store_codec.cpp index ef722d97..726de9c3 100644 --- a/unittests/terrainlib/store_codec.cpp +++ b/unittests/terrainlib/store_codec.cpp @@ -26,6 +26,7 @@ #include "../temporary_directory.h" #include "io/byte.h" +#include "io/envelope.h" #include "mesh/codec/Gltf.h" #include "mesh/codec/SfMesh.h" #include "store/Codec.h" @@ -45,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; @@ -157,3 +158,23 @@ TEST_CASE("runtime glTF codec reports writer IO errors", "[store][codec]") 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 bf307109..91db8e19 100644 --- a/unittests/terrainlib/store_compatibility.cpp +++ b/unittests/terrainlib/store_compatibility.cpp @@ -16,11 +16,15 @@ * along with this program. If not, see . *****************************************************************************/ +#include #include +#include +#include #include #include #include #include +#include #include #include @@ -51,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", @@ -83,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); @@ -179,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", @@ -197,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", @@ -225,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_storage.cpp b/unittests/terrainlib/store_storage.cpp index a1cb010e..124b7d76 100644 --- a/unittests/terrainlib/store_storage.cpp +++ b/unittests/terrainlib/store_storage.cpp @@ -16,12 +16,15 @@ * along with this program. If not, see . *****************************************************************************/ +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -33,10 +36,12 @@ #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 { @@ -81,7 +86,19 @@ 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)) +{ + 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) { @@ -92,6 +109,9 @@ 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; } @@ -109,9 +129,20 @@ 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); + } + + bool supports_hashing() const override { return true; } }; class MultiFileCodec final : public store::Codec { @@ -121,23 +152,25 @@ 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); } + + bool supports_hashing() const override { return true; } }; std::atomic_uint32_t index_writes = 0; @@ -182,6 +215,7 @@ store::IndexedStorage make_indexed_storage( 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", @@ -197,6 +231,30 @@ std::vector root_children() 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::make_unique()), + store::Index {}, + store::Key2Hash_Map {}, + store::Storage::Persistence { + octree::storage::index_format(), + path / "octree.storeindex", + "flat", + "test.Int", + ".data", + }); +} + +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]") @@ -301,17 +359,18 @@ TEST_CASE("written payloads become available after exactly one commit", "[store] auto storage = make_indexed_storage(directory.path()); const octree::Id root = octree::Id::root(); - REQUIRE(storage.write_payload(root, 7).has_value()); + 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).has_value()); + REQUIRE(storage.commit(root, *hash).has_value()); CHECK(storage.has(root).value()); CHECK(storage.load(root).value() == 7); - const auto recommitted = storage.commit(root); + const auto recommitted = storage.commit(root, *hash); REQUIRE_FALSE(recommitted.has_value()); CHECK(recommitted.error().code() == Error::Code::AlreadyExists); } @@ -489,3 +548,141 @@ TEST_CASE("link_from rejects existing targets, missing sources and different cod CHECK_FALSE(target.has(root).value()); } } + +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(); + 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()); + } + + 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("hash lookups report missing and virtual nodes", "[store][storage][hash]") +{ + TemporaryDirectory directory("hash-lookup"); + const octree::Id root = octree::Id::root(); + 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, 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("indexed sources are authoritative for links", "[store][storage][link][hash]") +{ + const octree::Id root = octree::Id::root(); + 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()); +} + +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); +} + +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"); + + { + 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)); + + 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); +} From fb9a6d9781f23f13030011ebc6a7ac63574b112a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:21:52 +0200 Subject: [PATCH 81/83] add jpeg xl encoding (amoit codec), selected automatically for rgb 8 tiles --- cmake/SetupJpegXL.cmake | 58 +++++++ docs/raster-store/storage-format.md | 22 ++- src/CMakeLists.txt | 3 + src/terrainlib/CMakeLists.txt | 2 + src/terrainlib/io/image.cpp | 168 ++++++++++++++++++++- src/terrainlib/io/image.h | 18 ++- src/terrainlib/raster_store/io/TileCodec.h | 63 ++++++-- unittests/terrainlib/io_image.cpp | 63 ++++++++ unittests/terrainlib/raster_store.cpp | 85 +++++++++++ 9 files changed, 447 insertions(+), 35 deletions(-) create mode 100644 cmake/SetupJpegXL.cmake diff --git a/cmake/SetupJpegXL.cmake b/cmake/SetupJpegXL.cmake new file mode 100644 index 00000000..e9c1dcd4 --- /dev/null +++ b/cmake/SetupJpegXL.cmake @@ -0,0 +1,58 @@ +############################################################################# +# 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_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/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index a0df8141..6a644f99 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -36,8 +36,8 @@ tile-base-root/ ``` The default layout ID is `zoom/x/y_google`, mapping tiles to `//` -with the codec adding `.amort`. Other layouts may map the same tile IDs -differently. [Dataset organization](architecture.md#dataset-organization) +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 @@ -102,7 +102,8 @@ for the first codec. Existing mesh stores retain extension-based selectors. ### Version 1 representation -The metadata codec selector is `amort`; payload filenames end in `.amort`. +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 @@ -113,6 +114,7 @@ supported platforms; mapping enum values are 0 for Linear and 1 for SRGBA: | `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. @@ -190,10 +192,14 @@ 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. Writing is lossless to preserve data through repeated merging and -overview generation. 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. +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 @@ -221,7 +227,7 @@ 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. -Storage preserves all payloads losslessly, including NaNs and values with +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. See the diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9c02cf0c..50c4d072 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -52,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}) diff --git a/src/terrainlib/CMakeLists.txt b/src/terrainlib/CMakeLists.txt index 797729c1..aecea71a 100644 --- a/src/terrainlib/CMakeLists.txt +++ b/src/terrainlib/CMakeLists.txt @@ -287,3 +287,5 @@ target_link_libraries(terrainlib PUBLIC 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/io/image.cpp b/src/terrainlib/io/image.cpp index 2bcd22e8..b6fa6c9d 100644 --- a/src/terrainlib/io/image.cpp +++ b/src/terrainlib/io/image.cpp @@ -20,9 +20,15 @@ #include #include +#include #include #include +#include +#include +#include +#include +#include #include #include @@ -38,7 +44,10 @@ namespace { 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 (format != Format::Jpeg && format != Format::Png) { + 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) { @@ -55,11 +64,8 @@ namespace { } template - Expected encode_raster(const radix::Raster& image, Format format, EncodeOptions options) + Expected encode_opencv(const radix::Raster& image, Format format, EncodeOptions options) try { - if (auto valid = validate_encoding(format, Pixel::length(), options); !valid) { - return Error::propagate(std::move(valid)); - } auto bgr = raster::algorithm::transform(image, swap_red_blue); if (!bgr) { return Error::propagate(std::move(bgr)); @@ -84,7 +90,82 @@ namespace { } template - Expected> decode_raster(byte::span bytes) + 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"); @@ -95,6 +176,9 @@ namespace { 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"); } @@ -130,6 +214,71 @@ namespace { 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 { @@ -152,6 +301,9 @@ namespace { if (extension == ".png") { return Format::Png; } + if (extension == ".jxl") { + return Format::JpegXl; + } return Error::fail(Error::Code::Unsupported, "unsupported image extension", path); } @@ -186,8 +338,8 @@ 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) { return decode_raster(bytes); } -Expected decode_rgba8(byte::span bytes) { return decode_raster(bytes); } +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); } diff --git a/src/terrainlib/io/image.h b/src/terrainlib/io/image.h index 0da04344..c537fd37 100644 --- a/src/terrainlib/io/image.h +++ b/src/terrainlib/io/image.h @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -34,11 +35,15 @@ namespace io::image { using RGB8 = radix::Raster; using RGBA8 = radix::Raster; -enum class Format { Jpeg, Png }; +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 { @@ -49,16 +54,17 @@ struct WriteOptions { /// RGB(A) channels, first row at the top. No flipping, scaling or premultiplication. Expected encode(const RGB8& image, Format format, EncodeOptions options = {}); -/// PNG preserves all four channels. JPEG rejects RGBA input. +/// 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. -Expected decode_rgb8(byte::span bytes); -Expected decode_rgba8(byte::span bytes); +/// 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 from the extension. Existing symlinks and nonregular files are rejected. +/// 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 = {}); diff --git a/src/terrainlib/raster_store/io/TileCodec.h b/src/terrainlib/raster_store/io/TileCodec.h index b445f219..40deb747 100644 --- a/src/terrainlib/raster_store/io/TileCodec.h +++ b/src/terrainlib/raster_store/io/TileCodec.h @@ -22,9 +22,12 @@ #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" @@ -38,6 +41,7 @@ namespace tile_codec { 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; }; @@ -45,12 +49,23 @@ namespace tile_codec { } // 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 +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), @@ -62,7 +77,7 @@ class TileCodec final : public store::Codec> { std::vector paths(const std::filesystem::path& node_path) const override { - return { this->add_extension(node_path, ".amort") }; + return { this->add_extension(node_path, is_image ? ".amoit" : ".amort") }; } Expected> read( @@ -71,21 +86,29 @@ class TileCodec final : public store::Codec> { 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); + auto encoded = ::io::envelope::read(paths(node_path).front(), expected_hash); if (!encoded) { - return Error::propagate(std::move(encoded), "read AMORT tile"); + 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, "AMORT tile dimensions do not match snapshot metadata"); + 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 (encoded->data.size() % sizeof(PixelType) != 0 || encoded->data.size() / sizeof(PixelType) != count + 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, "AMORT raster buffer byte counts do not match dimensions and payload type"); + return Error::fail(Error::Code::CorruptData, std::string(name) + " raster buffer byte counts do not match dimensions and payload type"); } Tile tile(encoded->width); - std::memcpy(tile.data.bytes().data(), encoded->data.data(), encoded->data.size()); + 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; } @@ -98,12 +121,19 @@ class TileCodec final : public store::Codec> { 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 data = tile.data.bytes(); const auto source_attribution = tile.attribution.bytes(); - const tile_codec::detail::v1::RasterTile encoded { m_dimensions.x, m_dimensions.y, - { data.begin(), data.end() }, { source_attribution.begin(), source_attribution.end() } }; - return ::io::envelope::write( - encoded, paths(node_path).front(), ::io::byte::WriteMode::CreateNew, true, m_compression_algorithm); + 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; } @@ -123,6 +153,13 @@ namespace tile_codec { 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)); } diff --git a/unittests/terrainlib/io_image.cpp b/unittests/terrainlib/io_image.cpp index 9a9a0f03..129fb460 100644 --- a/unittests/terrainlib/io_image.cpp +++ b/unittests/terrainlib/io_image.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include "../temporary_directory.h" @@ -113,6 +114,60 @@ TEST_CASE("PNG compression and JPEG quality options reach encoders", "[io-image] 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(); @@ -121,10 +176,18 @@ TEST_CASE("image I/O rejects invalid inputs without silent alpha loss", "[io-ima 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]") diff --git a/unittests/terrainlib/raster_store.cpp b/unittests/terrainlib/raster_store.cpp index 65c1b0a9..dfc92fd0 100644 --- a/unittests/terrainlib/raster_store.cpp +++ b/unittests/terrainlib/raster_store.cpp @@ -27,10 +27,12 @@ #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" @@ -220,6 +222,89 @@ TEST_CASE("AMORT validates dimensions and byte counts before allocating typed ra 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(); From 30e53cf385fdb810597d6f1e8940736ced175e0a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:34 +0200 Subject: [PATCH 82/83] Support embedding terrainlib in tile-foundry --- CMakeLists.txt | 91 ++---------- README.md | 7 + cmake/ConfigureBuild.cmake | 70 +++++++++ docs/raster-store/README.md | 3 +- unittests/CMakeLists.txt | 8 +- .../gis_vector_mask_read_errors.cpp | 79 ++++++++++ unittests/terrainlib/raster_tile_pool.cpp | 135 ++++++++++++++++++ 7 files changed, 310 insertions(+), 83 deletions(-) create mode 100644 cmake/ConfigureBuild.cmake create mode 100644 unittests/terrainlib/gis_vector_mask_read_errors.cpp create mode 100644 unittests/terrainlib/raster_tile_pool.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 128ee387..6aedbaa5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,86 +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_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_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() +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/README.md b/README.md index 0b4d9fdc..4a24c305 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ 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 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/docs/raster-store/README.md b/docs/raster-store/README.md index d0d44ecc..cdf13d39 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -2,7 +2,8 @@ This directory describes authoritative raster tile storage and the planned generation of delivery tile pyramids from it. RF import and merging are -implemented; TB generation and the tile server remain future work. +implemented in [tile-foundry](https://github.com/AlpineMapsOrg/tile-foundry); +TB generation and the tile server remain future work. ## Documents diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 729d48f2..72fb5b80 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -24,7 +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_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data/" ALP_UNITTESTS_EXTENDED=$ ALP_UNITTESTS_DEBUG_IMAGES=$ ) @@ -47,6 +47,7 @@ add_executable(unittests_terrainlib 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 @@ -92,6 +93,7 @@ add_executable(unittests_terrainlib 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 @@ -108,7 +110,7 @@ atb_configure_test(unittests_terrainlib) 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 ${CMAKE_SOURCE_DIR}/src/terrainlib) +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) @@ -142,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() 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/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); +} From 95a2e50cf9dd589896dfe3f402d7c8c37956dd3a Mon Sep 17 00:00:00 2001 From: adam-ce <5292991+adam-ce@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:13:07 +0200 Subject: [PATCH 83/83] Disable libjxl fuzzers and remove obsolete sampling documentation --- cmake/SetupJpegXL.cmake | 1 + docs/raster-store/README.md | 2 - docs/raster-store/architecture.md | 5 +- docs/raster-store/sampling-and-generation.md | 354 ------------------- docs/raster-store/storage-format.md | 3 +- docs/raster-store/terminology.md | 2 +- 6 files changed, 4 insertions(+), 363 deletions(-) delete mode 100644 docs/raster-store/sampling-and-generation.md diff --git a/cmake/SetupJpegXL.cmake b/cmake/SetupJpegXL.cmake index e9c1dcd4..e03a6af8 100644 --- a/cmake/SetupJpegXL.cmake +++ b/cmake/SetupJpegXL.cmake @@ -29,6 +29,7 @@ function(alp_setup_jpegxl version) -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 diff --git a/docs/raster-store/README.md b/docs/raster-store/README.md index cdf13d39..a3fb601c 100644 --- a/docs/raster-store/README.md +++ b/docs/raster-store/README.md @@ -11,8 +11,6 @@ TB generation and the tile server remain future work. - [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): sampling - theory, TB generation requirements, and the implemented scaling rules - [TODO](todo.md) 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 c051b66c..38ea1bc4 100644 --- a/docs/raster-store/architecture.md +++ b/docs/raster-store/architecture.md @@ -8,8 +8,7 @@ part of the `terrainlib` target. [Storage format](storage-format.md) defines the directory layout, serialized data, and format invariants. [Terminology](terminology.md) defines the domain -language, and [sampling and generation](sampling-and-generation.md) covers -sampling and filtering policy. +language. ## System boundary @@ -132,8 +131,6 @@ 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. -See [sampling and generation](sampling-and-generation.md) for filtering and -source-selection policy. Planned location: `src/tb-builder`. diff --git a/docs/raster-store/sampling-and-generation.md b/docs/raster-store/sampling-and-generation.md deleted file mode 100644 index 1319c9fd..00000000 --- a/docs/raster-store/sampling-and-generation.md +++ /dev/null @@ -1,354 +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 - -RF snapshots store non-overlapping interiors. TB snapshots may persist filter -halos, described by metadata, without changing interior geographic extents. -These halos are distinct from overlapping vertex-pixel 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 generator's window reader resolves a tile-centred 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. Zero attribution within a -physical source is not a boundary and does not trigger replacement of its data. -Callers prepare usable values before invoking the generic scaler; the scaler -neither assembles neighbours nor invents boundary conditions. - -## 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; no alpha support needed | -| Categorical | mode, coverage, or another categorical policy | -| Probability/coverage | conservative area averaging may be appropriate | -| Vector/normal | component filtering followed by normalization where needed | -| Source mask/NoData | importer validity and coverage rules before generic scaling | - -Source NoData handling remains an importer concern. Once a raster is supplied -to the [scaling facility](#implemented-scaling-rules), attribution does not mask -any numerical input. Missing attribution and missing physical coverage are -distinct; neither should be inferred from a numeric sentinel by the scaler. - -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; -- zero-attribution pixels inside physically covered chunks; and -- filters whose support crosses a layer's coverage boundary. - -Physically supplied zero-attribution payloads are preserved. Other -generator-specific boundary policies remain separate design work. Tests must distinguish true coverage boundaries from ordinary -internal chunk and delivery-tile boundaries. Source dataset NoData/mask -boundaries are handled during import, not by generic scaling. - -## 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 supplied payloads independently of attribution. - Paired wrappers report representative attribution according to the - [scaling rules](#implemented-scaling-rules): repeated local 2x2 mode reduction, including - zero in the vote, and nearest-neighbour attribution for upscaling. -7. Existing zero-attribution pixels contribute normally and retain their - supplied or resampled payloads; missing physical coverage follows the - caller's explicit clamped-view policy. Import fixtures separately verify - source NoData and mask handling. -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 [scaling rules](#implemented-scaling-rules) specify coefficients for -the area-pixel operations. Other layer-specific and -vertex-pixel filters remain separate design decisions; tests should fix their -numeric tolerances only after representative evaluation. - -A generator needing the seam-free vertex-generation invariant above must -assemble common support or generate metatiles. Halo attribution is -representative and never masks numerical contributions; zero attribution -triggers no resolution fallback. - -## Implemented scaling rules - -`raster::algorithm` scales and reduces single rasters; `raster_store::scale` -pairs these operations for data and attribution. This section records their -numerical contract; signatures are in the code. - -### Scope - -Generic scaling operates on one raster without interpreting attribution or -treating any sample as NoData. Every supplied pixel participates according to -the selected operation. Callers must supply usable, finite values, including -where the attribution raster contains zero; there is no runtime validity scan. -Nearest-neighbour sampling and zero-level cropping copy stored pixels exactly, -including nonfinite values. - -Only power-of-two scale factors are supported, with area-pixel placement. -Vertex-pixel generation, world wrapping, physical-source selection and -ancestor fallback belong to callers such as the RF merger. - -### Methods - -| Selection | Upscaling | Downscaling | -|---|---|---| -| `NearestNeighbourAndBox` | Nearest-neighbour | Box | -| `BiliinearAndBox` | Bilinear | Box | -| `Lanczos2` | Lanczos-2 | Lanczos-2 | -| `Lanczos3` | Lanczos-3 | Lanczos-3 | -| `Lanczos4` | Lanczos-4 | Lanczos-4 | - -Custom reductions operate on complete 2x2 blocks. Supplied reducers are -component-wise `Min`, `Max` and `Median` (average of the two middle values; -integer halfway cases round away from zero), and scalar `Mode` (greatest -frequency, ties broken by first occurrence in row-major order; zero is an -ordinary value). - -### Sampling phase - -With interior pixel centres at integer positions, direct upscaling by factor -`F` evaluates output pixel `j` at `(j + 0.5) / F - 0.5` in input coordinates. -A reduction by two evaluates pixel `j` at `2j + 0.5`. Positions are applied -independently on each axis. Halo offsets affect addressing, not phase. - -Upscaling goes directly from the input to the final resolution. Downscaling -repeats reduction by two, encoding the result into the stored pixel type after -every step; the next step decodes it again. A reduction by four therefore -equals two reductions by two, but repeated median, mode, rounding and sRGB -encoding need not equal a single larger filter. - -### Kernels - -Lanczos upscaling with radius `a` uses normalized `sinc(x) * sinc(x/a)` -weights with `2a` taps per axis, where `sinc(x) = sin(pi*x) / (pi*x)`. -Reduction by two evaluates the same kernel at half the input distance: -weights proportional to `sinc(t/2) * sinc(t/(2a))` for `abs(t) < 2a`, with -`4a` taps, low-passing before decimation. Two-dimensional weights are products -of the axis weights, normalized over the complete support, independently of -payloads or attribution. Box reduction averages the complete 2x2 block; -bilinear interpolation uses all four neighbours. - -Separable passes decode once, filter horizontally and vertically in the -working type, and encode only after both passes. - -### Required halos - -Inputs carry a symmetric halo, supplied physically or through a clamped view. -The scaler never clamps implicitly, including at the edges of a processing -window. Minimum halo widths in input pixels: - -| Operation | Required halo | -|---|---| -| Zero levels (crop only) | 0 | -| Nearest-neighbour upscaling | 0 | -| Bilinear upscaling | 1 | -| Lanczos upscaling, radius `a` | `a` | -| Box or custom 2x2 reduction, including repeated steps | 0 | -| Lanczos reduction by total factor `F`, radius `a` | `(2a - 1) * (F - 1)` | - -For one Lanczos reduction step, the first output centre is `0.5` and input -indices range from `1 - 2a` through `2a`, giving a halo of `2a - 1`. Retaining -output halo `h` needs input halo `2h + (2a - 1)`; repeated application yields -the formula. Lanczos-3 therefore needs 5 input halo pixels for reduction by -two and 15 for reduction by four. - -For Lanczos upscaling by factor `F`, a symmetric output halo `h` needs input -halo `ceil(a - 0.5 + (h - 0.5) / F)`. Lanczos-3 at 2x can thus preserve five -halo pixels: a 266x266 source with a 256x256 interior produces a 522x522 -window at offset `{-5, -5}`. - -### Output windows - -A window of the conceptual full output can be requested by offset and size. -The result equals filtering a sufficiently large raster and cropping, while -computing only the necessary samples and preserving the global phase and -reduction-stage alignment. The full conceptual output is never allocated, so -distant ancestors can be upscaled into small windows. - -### Attribution and value mapping - -Paired operations handle attribution independently of data: nearest-neighbour -for upscaling and repeated 2x2 `Mode` for downscaling. Zero takes part in the -vote, so `[0, 0, 0, 7]` reduces to zero. Attribution never changes data -samples, weights or results; one representative ID is kept per pixel. - -The snapshot's `pixel::Mapping` selects the data conversion. `Linear` filters -stored values directly (integers through floating point, rounded and clamped -on encoding). `SRGBA` requires RGB8/RGBA8 for numerical resampling; it -decodes RGB through the sRGB transfer function to linear light, filters, and -encodes back. Alpha is linear, filtered independently and does not weight RGB. diff --git a/docs/raster-store/storage-format.md b/docs/raster-store/storage-format.md index 6a644f99..bb9d2bd9 100644 --- a/docs/raster-store/storage-format.md +++ b/docs/raster-store/storage-format.md @@ -230,5 +230,4 @@ 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. See the -[scaling rules](sampling-and-generation.md#implemented-scaling-rules). +an importer concern, separate from stored attribution. diff --git a/docs/raster-store/terminology.md b/docs/raster-store/terminology.md index c3b22592..76cd36fb 100644 --- a/docs/raster-store/terminology.md +++ b/docs/raster-store/terminology.md @@ -105,7 +105,7 @@ _Avoid_: NoData pixel, invalid pixel (for a stored zero-attribution pixel) **Representative attribution** : The single attribution index retained for a resampled value under the - [shared scaling rules](sampling-and-generation.md#attribution-and-value-mapping). It is not + shared scaling rules. It is not a complete record of the sources contributing to that value. **Ancestor fallback**