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
{