diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e66bce..75c2e16 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,6 +106,38 @@ pattern is: one dataset object per thread, or serialized access. `GdalEnvironment` initialization is the exception — safe to call from anywhere. +## Map model + +`mwg::Map` (`mapwingis/map/Map.h`) is the authoritative owner of the layer +collection and map-level layer state. It has no windowing, COM, or rendering +state; hosts and renderers read from it. + +- **Identity.** `Map::AddLayer` returns a `LayerId`. Identities start at 1, + increase, and are never reused by the same map, even after `RemoveLayer` or + `Clear`. `LayerId{}` (0) identifies no layer. Neither display names nor + positions identify a layer. +- **Ordering.** Position 0 is the bottom layer and is drawn first; new layers + are appended on top. Positions shift on removal; identities do not. +- **Ownership.** The map owns its `MapLayer` objects. A `MapLayer` reference + stays valid until that layer is removed, the map is cleared, or the map is + destroyed; changes to other layers do not invalidate it. Each `MapLayer` + shares ownership of its `VectorDataset` (`std::shared_ptr`), so the dataset + lives as long as any map layer or other owner uses it. +- **Duplicates.** The same source layer may be added more than once; each + addition is an independent map layer with its own identity and visibility. +- **Visibility.** Layers are visible when added. `Map::SetLayerVisible` + changes visibility. Hidden layers are excluded from + `GetExtent(ExtentScope::VisibleLayers)`, the default and the extent used for + zoom-to-map, and included in `GetExtent(ExtentScope::AllLayers)`. +- **Extents.** Map extents are the union of valid layer extents + (`Extent::IsValid`: non-empty and finite). They are computed on each call + rather than cached, so visibility changes, added or removed layers, and + source edits are reflected without invalidation. A map with no valid layer + extent reports an empty `Extent`. +- **Coordinates.** Layers are assumed to share compatible source coordinates. + The map does not reproject; layers in different coordinate reference + systems do not align until coordinate transformations are implemented. + ## ActiveX and rendering architecture The first ActiveX and rendering vertical slice is implemented across the diff --git a/docs/TESTING.md b/docs/TESTING.md index fdc2a42..bcc9824 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,8 +4,10 @@ default through `MwgVcpkgFeatures`). - Test project: `tests/MapWinGIS.Core.Tests`, built with the solution. - Shared fixtures and helpers live in `TestData.h/.cpp` (`TempDirectory`, - `MakeSampleShapefile`, `MakeProvincesDataset`, and layer constants). Extend - these instead of writing per-file fixture code. + `MakeSampleShapefile`, `MakeProvincesDataset`, `MakeRectanglesDataset`, and + layer constants). Extend these instead of writing per-file fixture code. + `MakeRectanglesDataset` writes one rectangle per given extent, so map tests + can place layers at chosen coordinates or create an empty layer. ## Running diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj index 5652700..2f859bd 100644 --- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj +++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj @@ -60,6 +60,7 @@ + @@ -77,6 +78,7 @@ + diff --git a/src/MapWinGIS.Core/include/mapwingis/core/Extent.h b/src/MapWinGIS.Core/include/mapwingis/core/Extent.h index 82a4e18..16c76c1 100644 --- a/src/MapWinGIS.Core/include/mapwingis/core/Extent.h +++ b/src/MapWinGIS.Core/include/mapwingis/core/Extent.h @@ -10,6 +10,7 @@ #pragma once #include +#include #include #include "mapwingis/core/Api.h" @@ -37,6 +38,14 @@ namespace mwg return !(xMin <= xMax && yMin <= yMax); } + /// True when the extent is not empty and every bound is finite. + /// Map-level calculations ignore extents that are not valid. + [[nodiscard]] bool IsValid() const noexcept + { + return !IsEmpty() && std::isfinite(xMin) && std::isfinite(yMin) && + std::isfinite(xMax) && std::isfinite(yMax); + } + [[nodiscard]] double Width() const noexcept { return IsEmpty() ? 0.0 : xMax - xMin; } [[nodiscard]] double Height() const noexcept { return IsEmpty() ? 0.0 : yMax - yMin; } [[nodiscard]] double CenterX() const noexcept { return (xMin + xMax) / 2.0; } diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Map.h b/src/MapWinGIS.Core/include/mapwingis/map/Map.h new file mode 100644 index 0000000..3f39034 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/map/Map.h @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file Map.h + * @brief Declares the map model: an ordered collection of map layers. + * + * The map owns its layer collection and map-level layer state, such as + * visibility, and calculates the combined extent of its layers. It holds no + * windowing, COM, or rendering-backend state. + * + * Layer coordinates are used as stored in their source. The map assumes all + * layers use compatible coordinates; it does not reproject, so layers in + * different coordinate reference systems do not align. + */ +#pragma once + +#include +#include +#include +#include + +#include "mapwingis/core/Api.h" +#include "mapwingis/core/Extent.h" + +namespace mwg +{ + class Map; + class VectorDataset; + class VectorLayer; + + /// Stable identity of a layer within one Map. + /// + /// Assigned by Map::AddLayer, starting at 1 and increasing. An identity is + /// never reused by the same map, not even after the layer is removed or + /// the map is cleared, so a stale identity can never select another layer. + /// The default value (0) identifies no layer. + struct LayerId + { + std::uint64_t value = 0; + + [[nodiscard]] constexpr bool IsValid() const noexcept { return value != 0; } + + friend constexpr auto operator<=>(const LayerId&, const LayerId&) noexcept = default; + }; + + /// Which layers contribute to a map-level extent. + enum class ExtentScope : int + { + /// Only visible layers. This is the extent used for zoom-to-map. + VisibleLayers, + /// All layers, including hidden ones. + AllLayers, + }; + + /// One layer of a Map: a vector layer of a dataset plus map-level state. + /// + /// Owned by its Map. A MapLayer reference stays valid until that layer is + /// removed, the map is cleared, or the map is destroyed; adding, removing, + /// or clearing other layers does not invalidate it. The MapLayer shares + /// ownership of its dataset, so the source layer stays valid for the + /// MapLayer's lifetime even when the caller releases the dataset. + /// + /// Not thread-safe, consistent with VectorLayer. + class MWG_CORE_API MapLayer + { + public: + ~MapLayer(); + + MapLayer(const MapLayer&) = delete; + MapLayer& operator=(const MapLayer&) = delete; + + [[nodiscard]] LayerId Id() const noexcept; + + /// The vector layer displayed by this map layer. + [[nodiscard]] VectorLayer& Source() const noexcept; + + /// The dataset that owns Source(). + [[nodiscard]] VectorDataset& Dataset() const noexcept; + + /// 0-based index of Source() within Dataset(). + [[nodiscard]] int SourceLayerIndex() const noexcept; + + /// Layers are visible when added. Change visibility through + /// Map::SetLayerVisible. + [[nodiscard]] bool IsVisible() const noexcept; + + /// Extent of the source layer, read from the layer on every call so + /// that edits to the source are reflected. Empty when the layer has no + /// geometry or its extent is not valid (see Extent::IsValid). + /// May read the whole layer for formats that don't store an extent; + /// the source layer caches the result. + [[nodiscard]] Extent GetExtent() const; + + private: + friend class Map; + + class Impl; + explicit MapLayer(std::unique_ptr impl); + std::unique_ptr _impl; + }; + + /// The map model: an ordered collection of layers and map-level state. + /// + /// Ordering: position 0 is the bottom layer, drawn first; the last + /// position is the top layer, drawn last. AddLayer appends a layer on top. + /// Positions shift when layers are removed, so use LayerId to refer to a + /// layer over time. + /// + /// Duplicates: the same source layer may be added more than once. Each + /// addition is a separate map layer with its own identity and state. + /// + /// Extents: map-level extents are computed from the layers on every call, + /// so they reflect visibility changes, added and removed layers, and edits + /// to source geometry without explicit invalidation. Layers whose extent + /// is empty or not valid are ignored. A map without any valid layer extent + /// reports an empty Extent. + /// + /// Not thread-safe; see VectorLayer. + class MWG_CORE_API Map + { + public: + Map(); + ~Map(); + + Map(const Map&) = delete; + Map& operator=(const Map&) = delete; + + /// Adds layer layerIndex of dataset on top of the map and returns its + /// new identity. The map shares ownership of the dataset; the dataset + /// is released when its last map layer is removed and no other owner + /// remains. + /// \throws InvalidArgument when dataset is null or layerIndex is out of + /// range. + /// \throws CoreError when the map has exhausted its layer identity space. + LayerId AddLayer(std::shared_ptr dataset, int layerIndex = 0); + + /// Removes the layer with this identity. Returns false, and changes + /// nothing, when the map has no such layer. + bool RemoveLayer(LayerId id) noexcept; + + /// Removes all layers. Identities of removed layers are not reused. + void Clear() noexcept; + + [[nodiscard]] int LayerCount() const noexcept; + + /// Layer at a 0-based position; see the ordering rules above. + /// \throws InvalidArgument when position is out of range. + [[nodiscard]] const MapLayer& Layer(int position) const; + + /// nullptr when the map has no layer with this identity. + [[nodiscard]] const MapLayer* FindLayer(LayerId id) const noexcept; + + /// Current 0-based position of a layer, or std::nullopt when the map + /// has no layer with this identity. + [[nodiscard]] std::optional LayerPosition(LayerId id) const noexcept; + + /// Shows or hides a layer. Returns false when the map has no layer + /// with this identity. + bool SetLayerVisible(LayerId id, bool visible) noexcept; + + /// Union of the valid extents of the layers in scope, or an empty + /// Extent when none of them has a valid extent. Deterministic for a + /// given set of layer extents. Reads each layer's extent; see + /// MapLayer::GetExtent for the cost. + [[nodiscard]] Extent GetExtent(ExtentScope scope = ExtentScope::VisibleLayers) const; + + private: + class Impl; + std::unique_ptr _impl; + }; +} diff --git a/src/MapWinGIS.Core/src/map/Map.cpp b/src/MapWinGIS.Core/src/map/Map.cpp new file mode 100644 index 0000000..33a2b8c --- /dev/null +++ b/src/MapWinGIS.Core/src/map/Map.cpp @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file Map.cpp + * @brief Implements the map model, its layer collection, and extent aggregation. + */ +#include "mapwingis/map/Map.h" + +#include +#include +#include +#include +#include + +#include "mapwingis/core/Error.h" +#include "mapwingis/vector/VectorDataset.h" + +namespace mwg +{ + class MapLayer::Impl + { + public: + Impl(const LayerId id, std::shared_ptr dataset, const int sourceLayerIndex) + : _id(id), + _dataset(std::move(dataset)), + _source(&_dataset->Layer(sourceLayerIndex)), + _sourceLayerIndex(sourceLayerIndex) + { + } + + LayerId _id; + std::shared_ptr _dataset; + VectorLayer* _source; + int _sourceLayerIndex; + bool _visible = true; + }; + + MapLayer::MapLayer(std::unique_ptr impl) + : _impl(std::move(impl)) + { + } + + MapLayer::~MapLayer() = default; + + LayerId MapLayer::Id() const noexcept + { + return _impl->_id; + } + + VectorLayer& MapLayer::Source() const noexcept + { + return *_impl->_source; + } + + VectorDataset& MapLayer::Dataset() const noexcept + { + return *_impl->_dataset; + } + + int MapLayer::SourceLayerIndex() const noexcept + { + return _impl->_sourceLayerIndex; + } + + bool MapLayer::IsVisible() const noexcept + { + return _impl->_visible; + } + + Extent MapLayer::GetExtent() const + { + const Extent extent = _impl->_source->GetExtent(); + return extent.IsValid() ? extent : Extent{}; + } + + class Map::Impl + { + public: + [[nodiscard]] std::vector>::const_iterator Find( + const LayerId id) const noexcept + { + return std::find_if( + _layers.begin(), + _layers.end(), + [id](const std::unique_ptr& layer) + { + return layer->Id() == id; + }); + } + + // unique_ptr elements keep MapLayer addresses stable while the + // vector reorders or reallocates. + std::vector> _layers; + std::uint64_t _lastId = 0; + }; + + Map::Map() + : _impl(std::make_unique()) + { + } + + Map::~Map() = default; + + LayerId Map::AddLayer(std::shared_ptr dataset, const int layerIndex) + { + if (dataset == nullptr) + { + throw InvalidArgument("Cannot add a layer without a dataset"); + } + if (layerIndex < 0 || layerIndex >= dataset->LayerCount()) + { + throw InvalidArgument("Dataset layer index out of range: " + std::to_string(layerIndex)); + } + if (_impl->_lastId == std::numeric_limits::max()) + { + throw CoreError("Map layer identity space is exhausted"); + } + + const LayerId id{_impl->_lastId + 1}; + auto layer = std::unique_ptr( + new MapLayer(std::make_unique(id, std::move(dataset), layerIndex))); + _impl->_layers.push_back(std::move(layer)); + _impl->_lastId = id.value; + return id; + } + + bool Map::RemoveLayer(const LayerId id) noexcept + { + const auto layer = _impl->Find(id); + if (layer == _impl->_layers.end()) + { + return false; + } + + _impl->_layers.erase(layer); + return true; + } + + void Map::Clear() noexcept + { + _impl->_layers.clear(); + } + + int Map::LayerCount() const noexcept + { + return static_cast(_impl->_layers.size()); + } + + const MapLayer& Map::Layer(const int position) const + { + if (position < 0 || position >= LayerCount()) + { + throw InvalidArgument("Map layer position out of range: " + std::to_string(position)); + } + return *_impl->_layers[static_cast(position)]; + } + + const MapLayer* Map::FindLayer(const LayerId id) const noexcept + { + const auto layer = _impl->Find(id); + return layer != _impl->_layers.end() ? layer->get() : nullptr; + } + + std::optional Map::LayerPosition(const LayerId id) const noexcept + { + const auto layer = _impl->Find(id); + if (layer == _impl->_layers.end()) + { + return std::nullopt; + } + return static_cast(layer - _impl->_layers.begin()); + } + + bool Map::SetLayerVisible(const LayerId id, const bool visible) noexcept + { + const auto layer = _impl->Find(id); + if (layer == _impl->_layers.end()) + { + return false; + } + + (*layer)->_impl->_visible = visible; + return true; + } + + Extent Map::GetExtent(const ExtentScope scope) const + { + Extent combined; + for (const std::unique_ptr& layer : _impl->_layers) + { + if (scope == ExtentScope::VisibleLayers && !layer->IsVisible()) + { + continue; + } + combined = combined.Union(layer->GetExtent()); + } + return combined; + } +} diff --git a/tests/MapWinGIS.Core.Tests/MapTests.cpp b/tests/MapWinGIS.Core.Tests/MapTests.cpp new file mode 100644 index 0000000..4579fd6 --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/MapTests.cpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapTests.cpp + * @brief Tests the map model, its layer collection, and extent aggregation. + * + * Verifies stable layer identities, ordering, visibility, dataset ownership, + * and combined extents without creating a window or using ActiveX. + */ +#include + +#include +#include +#include +#include + +#include "TestData.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/vector/VectorDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + void ExpectExtent(const Extent& actual, const Extent& expected) + { + EXPECT_DOUBLE_EQ(actual.xMin, expected.xMin); + EXPECT_DOUBLE_EQ(actual.yMin, expected.yMin); + EXPECT_DOUBLE_EQ(actual.xMax, expected.xMax); + EXPECT_DOUBLE_EQ(actual.yMax, expected.yMax); + } + + class MapTest : public testing::Test + { + protected: + /// Opens a generated dataset whose only layer holds these rectangles. + std::shared_ptr OpenRectangles(const std::vector& rectangles, + const OpenMode mode = OpenMode::ReadOnly) + { + const std::string fileName = "layer-" + std::to_string(_datasetCount++) + ".gpkg"; + return VectorDataset::Open( + MakeRectanglesDataset(_directory.Path(), fileName, rectangles), mode); + } + + TempDirectory _directory; + Map _map; + int _datasetCount = 0; + }; +} + +TEST_F(MapTest, NewMapIsEmpty) +{ + EXPECT_EQ(_map.LayerCount(), 0); + EXPECT_TRUE(_map.GetExtent().IsEmpty()); + EXPECT_TRUE(_map.GetExtent(ExtentScope::AllLayers).IsEmpty()); + EXPECT_EQ(_map.FindLayer(LayerId{1}), nullptr); + EXPECT_FALSE(_map.LayerPosition(LayerId{1}).has_value()); + EXPECT_THROW((void)_map.Layer(0), InvalidArgument); +} + +TEST_F(MapTest, AddsLayersWithDistinctValidIdentities) +{ + const LayerId first = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId second = _map.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + + EXPECT_TRUE(first.IsValid()); + EXPECT_TRUE(second.IsValid()); + EXPECT_NE(first, second); + EXPECT_FALSE(LayerId{}.IsValid()); + ASSERT_EQ(_map.LayerCount(), 2); + EXPECT_EQ(_map.Layer(0).Id(), first); + EXPECT_EQ(_map.Layer(1).Id(), second); + EXPECT_EQ(_map.Layer(0).Source().Name(), "rectangles"); + EXPECT_EQ(_map.Layer(0).SourceLayerIndex(), 0); + EXPECT_TRUE(_map.Layer(0).IsVisible()); +} + +TEST_F(MapTest, AppendsNewLayersOnTop) +{ + const LayerId bottom = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId middle = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId top = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + + EXPECT_EQ(_map.LayerPosition(bottom), 0); + EXPECT_EQ(_map.LayerPosition(middle), 1); + EXPECT_EQ(_map.LayerPosition(top), 2); +} + +TEST_F(MapTest, RejectsMissingDatasetAndOutOfRangeLayerIndex) +{ + const std::shared_ptr dataset = OpenRectangles({{0, 0, 1, 1}}); + + EXPECT_THROW((void)_map.AddLayer(nullptr), InvalidArgument); + EXPECT_THROW((void)_map.AddLayer(dataset, -1), InvalidArgument); + EXPECT_THROW((void)_map.AddLayer(dataset, 1), InvalidArgument); + EXPECT_EQ(_map.LayerCount(), 0); +} + +TEST_F(MapTest, AddsRequestedNonZeroSourceLayer) +{ + const std::shared_ptr dataset = VectorDataset::Open( + MakeTwoLayerDataset(_directory.Path(), "two-layers.gpkg")); + ASSERT_EQ(dataset->LayerCount(), 2); + + const LayerId id = _map.AddLayer(dataset, 1); + const MapLayer* layer = _map.FindLayer(id); + ASSERT_NE(layer, nullptr); + EXPECT_EQ(layer->SourceLayerIndex(), 1); + EXPECT_EQ(layer->Source().Name(), "second"); +} + +TEST_F(MapTest, RejectsOutOfRangePositions) +{ + (void)_map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + + EXPECT_THROW((void)_map.Layer(-1), InvalidArgument); + EXPECT_THROW((void)_map.Layer(1), InvalidArgument); +} + +TEST_F(MapTest, RemovesLayerByIdentity) +{ + const LayerId first = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId second = _map.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + + EXPECT_TRUE(_map.RemoveLayer(first)); + ASSERT_EQ(_map.LayerCount(), 1); + EXPECT_EQ(_map.Layer(0).Id(), second); + EXPECT_EQ(_map.FindLayer(first), nullptr); + EXPECT_FALSE(_map.RemoveLayer(first)); + EXPECT_FALSE(_map.RemoveLayer(LayerId{})); + EXPECT_EQ(_map.LayerCount(), 1); +} + +TEST_F(MapTest, KeepsIdentitiesAndReferencesStableWhenOtherLayersAreRemoved) +{ + const LayerId first = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId second = _map.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + const LayerId third = _map.AddLayer(OpenRectangles({{4, 4, 5, 5}})); + const MapLayer* thirdLayer = _map.FindLayer(third); + ASSERT_NE(thirdLayer, nullptr); + + ASSERT_TRUE(_map.RemoveLayer(second)); + + EXPECT_EQ(_map.FindLayer(third), thirdLayer); + EXPECT_EQ(thirdLayer->Id(), third); + ExpectExtent(thirdLayer->GetExtent(), {4, 4, 5, 5}); + EXPECT_EQ(_map.LayerPosition(first), 0); + EXPECT_EQ(_map.LayerPosition(third), 1); + EXPECT_FALSE(_map.LayerPosition(second).has_value()); +} + +TEST_F(MapTest, NeverReusesIdentities) +{ + const LayerId first = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + ASSERT_TRUE(_map.RemoveLayer(first)); + const LayerId second = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + _map.Clear(); + const LayerId third = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + + EXPECT_NE(second, first); + EXPECT_NE(third, first); + EXPECT_NE(third, second); + EXPECT_EQ(_map.FindLayer(first), nullptr); + EXPECT_EQ(_map.FindLayer(second), nullptr); +} + +TEST_F(MapTest, ClearRemovesAllLayers) +{ + const LayerId first = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + (void)_map.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + + _map.Clear(); + + EXPECT_EQ(_map.LayerCount(), 0); + EXPECT_EQ(_map.FindLayer(first), nullptr); + EXPECT_TRUE(_map.GetExtent().IsEmpty()); +} + +TEST_F(MapTest, AddsTheSameSourceLayerAsSeparateLayers) +{ + const std::shared_ptr dataset = OpenRectangles({{0, 0, 1, 1}}); + const LayerId first = _map.AddLayer(dataset); + const LayerId second = _map.AddLayer(dataset); + + ASSERT_EQ(_map.LayerCount(), 2); + EXPECT_NE(first, second); + EXPECT_EQ(&_map.Layer(0).Source(), &_map.Layer(1).Source()); + + ASSERT_TRUE(_map.SetLayerVisible(first, false)); + EXPECT_FALSE(_map.FindLayer(first)->IsVisible()); + EXPECT_TRUE(_map.FindLayer(second)->IsVisible()); +} + +TEST_F(MapTest, SharesDatasetOwnershipWithItsLayers) +{ + std::shared_ptr dataset = OpenRectangles({{0, 0, 1, 1}}); + const std::weak_ptr observer = dataset; + const LayerId first = _map.AddLayer(dataset); + const LayerId second = _map.AddLayer(dataset); + dataset.reset(); + + ASSERT_FALSE(observer.expired()); + ExpectExtent(_map.FindLayer(first)->GetExtent(), {0, 0, 1, 1}); + EXPECT_EQ(&_map.FindLayer(first)->Dataset(), observer.lock().get()); + + ASSERT_TRUE(_map.RemoveLayer(first)); + EXPECT_FALSE(observer.expired()); + ASSERT_TRUE(_map.RemoveLayer(second)); + EXPECT_TRUE(observer.expired()); +} + +TEST_F(MapTest, CombinesOverlappingLayerExtents) +{ + (void)_map.AddLayer(OpenRectangles({{0, 0, 10, 10}})); + (void)_map.AddLayer(OpenRectangles({{5, -5, 15, 5}})); + + ExpectExtent(_map.GetExtent(), {0, -5, 15, 10}); +} + +TEST_F(MapTest, CombinesSeparatedLayerExtents) +{ + (void)_map.AddLayer(OpenRectangles({{-20, -20, -10, -10}})); + (void)_map.AddLayer(OpenRectangles({{100, 200, 110, 210}, {120, 220, 130, 230}})); + + ExpectExtent(_map.GetExtent(), {-20, -20, 130, 230}); +} + +TEST_F(MapTest, CombinedExtentDoesNotDependOnLayerOrder) +{ + Map reversed; + const std::vector extents = {{0, 0, 1, 1}, {5, 6, 7, 8}, {-3, 2, -1, 4}}; + for (const Extent& extent : extents) + { + (void)_map.AddLayer(OpenRectangles({extent})); + } + for (auto extent = extents.rbegin(); extent != extents.rend(); ++extent) + { + (void)reversed.AddLayer(OpenRectangles({*extent})); + } + + ExpectExtent(_map.GetExtent(), reversed.GetExtent()); + ExpectExtent(_map.GetExtent(), {-3, 0, 7, 8}); +} + +TEST_F(MapTest, IgnoresLayersWithoutAValidExtent) +{ + const LayerId empty = _map.AddLayer(OpenRectangles({})); + EXPECT_TRUE(_map.FindLayer(empty)->GetExtent().IsEmpty()); + EXPECT_TRUE(_map.GetExtent().IsEmpty()); + + (void)_map.AddLayer(OpenRectangles({{1, 2, 3, 4}})); + + ExpectExtent(_map.GetExtent(), {1, 2, 3, 4}); +} + +TEST_F(MapTest, HiddenLayersAreExcludedFromTheVisibleExtent) +{ + (void)_map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId hidden = _map.AddLayer(OpenRectangles({{10, 10, 20, 20}})); + + ASSERT_TRUE(_map.SetLayerVisible(hidden, false)); + + EXPECT_FALSE(_map.FindLayer(hidden)->IsVisible()); + ExpectExtent(_map.GetExtent(), {0, 0, 1, 1}); + ExpectExtent(_map.GetExtent(ExtentScope::AllLayers), {0, 0, 20, 20}); + + ASSERT_TRUE(_map.SetLayerVisible(hidden, true)); + ExpectExtent(_map.GetExtent(), {0, 0, 20, 20}); +} + +TEST_F(MapTest, MapWithOnlyHiddenLayersHasNoVisibleExtent) +{ + const LayerId layer = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + ASSERT_TRUE(_map.SetLayerVisible(layer, false)); + + EXPECT_TRUE(_map.GetExtent().IsEmpty()); + ExpectExtent(_map.GetExtent(ExtentScope::AllLayers), {0, 0, 1, 1}); +} + +TEST_F(MapTest, SetLayerVisibleRejectsUnknownIdentity) +{ + EXPECT_FALSE(_map.SetLayerVisible(LayerId{42}, false)); +} + +TEST_F(MapTest, ExtentReflectsSourceGeometryEdits) +{ + const LayerId layer = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}}, OpenMode::Update)); + ExpectExtent(_map.GetExtent(), {0, 0, 1, 1}); + + _map.FindLayer(layer)->Source().SetGeometryWkt(0, "POLYGON ((0 0, 4 0, 4 3, 0 3, 0 0))"); + + ExpectExtent(_map.GetExtent(), {0, 0, 4, 3}); +} + +TEST(Extent, IsValidRequiresANonEmptyFiniteExtent) +{ + constexpr double kInfinity = std::numeric_limits::infinity(); + constexpr double kNaN = std::numeric_limits::quiet_NaN(); + + EXPECT_TRUE((Extent{0, 0, 1, 1}.IsValid())); + EXPECT_TRUE((Extent{1, 1, 1, 1}.IsValid())); + EXPECT_FALSE(Extent{}.IsValid()); + EXPECT_FALSE((Extent{1, 0, 0, 1}.IsValid())); + EXPECT_FALSE((Extent{0, 0, kInfinity, 1}.IsValid())); + EXPECT_FALSE((Extent{-kInfinity, 0, 1, 1}.IsValid())); + EXPECT_FALSE((Extent{0, kNaN, 1, 1}.IsValid())); +} diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj index 1368127..8135b50 100644 --- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj +++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj @@ -64,6 +64,7 @@ + diff --git a/tests/MapWinGIS.Core.Tests/TestData.cpp b/tests/MapWinGIS.Core.Tests/TestData.cpp index dadc3ee..c5ac9ec 100644 --- a/tests/MapWinGIS.Core.Tests/TestData.cpp +++ b/tests/MapWinGIS.Core.Tests/TestData.cpp @@ -170,6 +170,69 @@ namespace mwg::test return path; } + std::filesystem::path MakeRectanglesDataset(const std::filesystem::path& directory, + const std::string& fileName, + const std::span rectangles) + { + GdalEnvironment::Initialize(); + + const std::filesystem::path path = directory / fileName; + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("GPKG"); + if (driver == nullptr) throw std::runtime_error("GPKG driver is not available"); + + GDALDatasetUniquePtr dataset( + driver->Create(ToUtf8(path).c_str(), 0, 0, 0, GDT_Unknown, nullptr)); + if (!dataset) throw std::runtime_error("Cannot create " + ToUtf8(path)); + + OGRLayer* layer = dataset->CreateLayer("rectangles", nullptr, wkbPolygon, nullptr); + if (layer == nullptr) throw std::runtime_error("Cannot create layer"); + + for (const Extent& rectangle : rectangles) + { + OGRLinearRing ring; + ring.addPoint(rectangle.xMin, rectangle.yMin); + ring.addPoint(rectangle.xMax, rectangle.yMin); + ring.addPoint(rectangle.xMax, rectangle.yMax); + ring.addPoint(rectangle.xMin, rectangle.yMax); + ring.closeRings(); + + OGRPolygon polygon; + polygon.addRing(&ring); + OGRFeatureUniquePtr feature(OGRFeature::CreateFeature(layer->GetLayerDefn())); + if (feature->SetGeometry(&polygon) != OGRERR_NONE || + layer->CreateFeature(feature.get()) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write rectangle feature"); + } + } + + dataset.reset(); + return path; + } + + std::filesystem::path MakeTwoLayerDataset(const std::filesystem::path& directory, + const std::string& fileName) + { + GdalEnvironment::Initialize(); + + const std::filesystem::path path = directory / fileName; + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("GPKG"); + if (driver == nullptr) throw std::runtime_error("GPKG driver is not available"); + + GDALDatasetUniquePtr dataset( + driver->Create(ToUtf8(path).c_str(), 0, 0, 0, GDT_Unknown, nullptr)); + if (!dataset) throw std::runtime_error("Cannot create " + ToUtf8(path)); + + if (dataset->CreateLayer("first", nullptr, wkbPolygon, nullptr) == nullptr || + dataset->CreateLayer("second", nullptr, wkbPolygon, nullptr) == nullptr) + { + throw std::runtime_error("Cannot create the two test layers"); + } + + dataset.reset(); + return path; + } + std::filesystem::path MakeSampleShapefile(const std::filesystem::path& directory, const std::string& baseName) { diff --git a/tests/MapWinGIS.Core.Tests/TestData.h b/tests/MapWinGIS.Core.Tests/TestData.h index 2ffab7e..b505564 100644 --- a/tests/MapWinGIS.Core.Tests/TestData.h +++ b/tests/MapWinGIS.Core.Tests/TestData.h @@ -11,8 +11,11 @@ #include #include +#include #include +#include "mapwingis/core/Extent.h" + namespace mwg::test { /// Writes a 12-by-8, single-band Byte GeoTIFF in EPSG:4326. @@ -69,6 +72,18 @@ namespace mwg::test static constexpr double kMaxY = 1.0; }; + /// Writes a GeoPackage with one polygon layer named "rectangles" that has + /// one axis-aligned rectangle feature per extent, in the given order, and + /// no projection. With no extents the layer is empty and has no extent. + /// Used for map and multi-layer tests that need layers at chosen places. + std::filesystem::path MakeRectanglesDataset(const std::filesystem::path& directory, + const std::string& fileName, + std::span rectangles); + + /// Writes a GeoPackage with two empty polygon layers named "first" and "second". + std::filesystem::path MakeTwoLayerDataset(const std::filesystem::path& directory, + const std::string& fileName); + /// Creates a directory that is deleted when the object goes out of scope. class TempDirectory {