From 0b0c8ff3b69b1182aba19602d3623c9f0fb9ea29 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 10:11:12 +0100 Subject: [PATCH 1/3] Render multiple map layers through a shared Core viewport Add mwg::MapView as the single owner of the map, its viewport, and its render scene, and CreateMapRenderScene to build an owned multi-layer scene with per-layer skip reasons. GDI and OpenGL backends now draw every layer through the same Viewport; GDI skips only layers it cannot draw. The ActiveX control delegates loading, resizing, and redraw decisions to MapView and no longer keeps its own extent or transform. LoadVectorDataset now adds a layer on top of the map. Co-authored-by: Junie --- docs/ARCHITECTURE.md | 93 +++-- docs/TESTING.md | 10 +- .../GdiVectorLayerRenderer.cpp | 138 ++++--- .../GdiVectorLayerRenderer.h | 19 +- src/MapWinGIS.ActiveX/MapWinGISControl.cpp | 128 +++++- src/MapWinGIS.ActiveX/MapWinGISControl.h | 17 +- .../OpenGlVectorLayerRenderer.cpp | 132 +++--- .../OpenGlVectorLayerRenderer.h | 17 +- src/MapWinGIS.ActiveX/VectorLayerRenderer.cpp | 12 +- src/MapWinGIS.ActiveX/VectorLayerRenderer.h | 24 +- src/MapWinGIS.ActiveX/VectorRenderBackend.h | 22 +- src/MapWinGIS.ActiveX/ViewTransform.cpp | 51 +-- src/MapWinGIS.ActiveX/ViewTransform.h | 32 +- src/MapWinGIS.Core/MapWinGIS.Core.vcxproj | 5 + .../include/mapwingis/map/MapView.h | 103 +++++ .../mapwingis/rendering/MapRenderScene.h | 77 ++++ .../src/internal/VectorRenderSceneBuilder.h | 22 + src/MapWinGIS.Core/src/map/MapView.cpp | 148 +++++++ .../src/rendering/MapRenderScene.cpp | 49 +++ .../src/rendering/VectorRenderScene.cpp | 10 +- .../MapRenderSceneTests.cpp | 202 +++++++++ tests/MapWinGIS.Core.Tests/MapViewTests.cpp | 254 ++++++++++++ .../MapWinGIS.Core.Tests.vcxproj | 2 + .../RenderingBaselineTests.cpp | 21 +- .../VectorLayerRendererTests.cpp | 384 ++++++++++++++---- 25 files changed, 1629 insertions(+), 343 deletions(-) create mode 100644 src/MapWinGIS.Core/include/mapwingis/map/MapView.h create mode 100644 src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h create mode 100644 src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h create mode 100644 src/MapWinGIS.Core/src/map/MapView.cpp create mode 100644 src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp create mode 100644 tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp create mode 100644 tests/MapWinGIS.Core.Tests/MapViewTests.cpp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11237e5..58149ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -174,6 +174,35 @@ take snapshots. kMaxResolution]`, where the minimum is relative to the center's magnitude so that pixels stay many representable doubles wide. +## Map view and multi-layer rendering + +`mwg::MapView` (`mapwingis/map/MapView.h`) owns one `Map`, the one `Viewport` +all its layers are drawn through, and the render scene prepared from the map. +It is the only map and view state: the ActiveX control owns a `MapView` and +has no extent or transform of its own. + +- **Render scene.** `rendering::CreateMapRenderScene` + (`mapwingis/rendering/MapRenderScene.h`) copies the polygon geometry of + every visible layer into an owned `MapRenderScene`, bottom layer first. Each + layer is converted on its own: hidden layers, layers without polygon + geometry, and layers that fail (for example, non-polygon layers) are listed + in `skippedLayers` with a reason and do not stop the other layers. The scene + holds no view state and no references to the map or its datasets. +- **Shared viewport.** Renderers draw the scene through the `Viewport` passed + with it, so every layer uses the same transformation. A view change, such as + a resize, does not rebuild the scene. +- **Redraws.** Map changes go through `MapView` (`AddLayer`, `RemoveLayer`, + `ClearLayers`, `SetLayerVisible`), which marks the scene stale; `Scene()` + rebuilds it on the next call. Every mutator returns whether the frame may + have changed, and the host requests a redraw when it does. Edits to source + geometry are not observed; call `InvalidateScene` after them. +- **Initial view and resizing.** While the viewport has no view, adding a + layer fits the view to the visible map extent. `Resize` keeps the center + and resolution, so resizing neither moves nor rescales the map. A fit + requested while the viewport has no size, for example before the host + window exists or while it is minimized, is done by the first `Resize` that + gives it a size. + ## ActiveX and rendering architecture The first ActiveX and rendering vertical slice is implemented across the @@ -190,23 +219,33 @@ responsibilities, not a requirement to split them into more DLLs. | Future compute work | Host optional compute acceleration only for workloads justified by profiling. | OpenGL being installed or selected | The OCX is the composition root for control, render-coordinator, and -host-specific graphics-context lifetimes. The control owns the opened -`VectorDataset` and its prepared `VectorRenderScene`; each `VectorDataset` -owns its `VectorLayer` objects. Core creates backend-neutral render input; the -coordinator binds it to the control target and backend. Backends must not -retain references to dataset-owned objects after a draw. Core dataset and -layer objects are not thread-safe, so the control must serialize their use. +host-specific graphics-context lifetimes. The control owns a `MapView`, which +owns the map, its layers' datasets (shared), the viewport, and the prepared +`MapRenderScene`; each `VectorDataset` owns its `VectorLayer` objects. Core +creates backend-neutral render input; the coordinator binds it to the control +target and backend. Backends must not retain references to dataset-owned +objects after a draw, and they keep no view state. Core dataset and layer +objects are not thread-safe, so the control must serialize their use. The OpenGL backend owns its WGL context and reusable pixel buffers; those -resources are released with the control. Resizing updates the OpenGL viewport -and staging buffers without changing ownership of map data. +resources are released with the control. `WM_SIZE` resizes the `MapView` +viewport, and `OnDraw` also sets it to the drawn bounds because windowless +hosts never send `WM_SIZE`; a redraw is requested only when the view changed. +Resizing updates the OpenGL viewport and staging buffers without changing +ownership of map data. Rendering consumes backend-neutral input prepared from core data; neither the -coordinator nor a backend loads datasets or implements GIS rules. Core selects -the first dataset layer and sequentially reads Polygon, MultiPolygon, -CurvePolygon, and MultiSurface features into an owned vector scene, linearizing -curved geometries. The shared `ViewTransform` supplies the same centered, -aspect-preserving scale and 12-pixel margin to both renderers. The coordinator -tries the OpenGL backend for simple convex polygons with one ring. +coordinator nor a backend loads datasets or implements GIS rules. For each +visible map layer, Core sequentially reads Polygon, MultiPolygon, +CurvePolygon, and MultiSurface features into an owned vector scene, +linearizing curved geometries. `ViewTransform` captures the `Viewport` once +per frame so both backends transform every vertex of every layer with the +same arithmetic as `Viewport::MapToScreen`; GDI draws a screen position in +the pixel that contains it (`ToPixel`). One backend draws the whole frame. +The coordinator tries the OpenGL backend when every layer has only simple +convex polygons with one ring. GDI checks each layer before drawing it; a +layer with non-finite or out-of-range device coordinates is skipped and +reported (`GetFailedLayers`, `RenderingStatus`) while the other layers are +still drawn. OpenGL uses a context created on a hidden, never-presented window, draws with fixed-function calls into a framebuffer object (OpenGL 3.0 or `GL_EXT_framebuffer_object`), and reads pixels back into a reusable CPU buffer @@ -218,7 +257,7 @@ support, initialization failures, and drawing failures use the GDI backend. The GDI backend draws the currently supported polygon scene, including its interior rings. Both backends draw source coordinates without reprojection, and scene preparation currently supports Polygon, MultiPolygon, CurvePolygon, -and MultiSurface features from the first vector layer only. +and MultiSurface features. The `RenderingBackendPreference` automation property is configurable as `Automatic`, `OpenGL`, or `GDI`. `Automatic` and `OpenGL` try the OpenGL @@ -270,15 +309,23 @@ pauses notification delivery without cancelling operations; a subsequent COM reference cancels outstanding work and drops queued notifications. Workers never retain COM references or wait for event handlers. -Only the first vector layer in a dataset is considered, and it must be a -polygon-compatible layer; a dataset whose first layer has another geometry -type fails to load even if a later layer is polygonal. Raster layers and other -geometry types are not rendered. There is no map navigation or -reprojection, so coordinates remain in the source coordinate system. +`LoadVectorDataset` adds the first vector layer of a dataset on top of the +map; earlier layers stay loaded, so calling it again shows several layers. +The layer must be polygon-compatible: a dataset whose first layer has another +geometry type fails to load and is not added, even if a later layer is +polygonal. An empty polygon layer loads but draws nothing. +`LayerLoaded` reports whether the map has any layer, and `FeatureCount` is +the total over all layers. Until the control exposes navigation, every load +fits the view to the whole map. Raster layers and other geometry types are +not rendered. There is no reprojection, so coordinates remain in the source +coordinate system and layers are assumed to share one. Core prepares and retains the complete supported polygon scene in memory, reading feature geometries sequentially without WKT serialization and -reparsing. There is no tiling, paging, or unchanged-geometry cache. The opt-in +reparsing. Any map change rebuilds the scene for all visible layers, not only +the changed one. There is no tiling, paging, clipping to the visible extent, +or unchanged-geometry cache, so a layer whose device coordinates exceed the +GDI range is skipped by GDI. The opt-in rendering baseline (see [TESTING.md](TESTING.md#rendering-performance-baseline)) tracks scene preparation and backend frame costs. OpenGL only supports convex single-ring polygons and copies each frame through CPU memory. Its reusable CPU @@ -288,7 +335,7 @@ Windows CPU fallback; Core has no independent platform-neutral renderer. The C# host's pixel assertions, activation, and native tests pass for Debug and Release on Win32 and x64 in an interactive desktop session. -The next useful milestone is a tested map-view transform for viewport and -coordinate-system behavior. Measure scene preparation with representative +The next milestone is navigation on the shared `MapView`: zoom to map and +layer, zoom in and out, and pan. Measure scene preparation with representative datasets before adding caching, paging, or broader OpenGL geometry support. OpenCL and full MapWinGIS 5 API parity remain deferred. diff --git a/docs/TESTING.md b/docs/TESTING.md index bcc9824..dd35833 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -189,8 +189,14 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. - Append `<< value` to expectations on strings when the failure output matters. - Test files carry the same SPDX and Doxygen header as production files. -- `ViewTransform` tests cover the shared scale and offset used by both GDI and - OpenGL renderers, including degenerate extents and zero-area targets. +- `ViewTransform` tests check that the renderers' transform gives the same + screen positions as `Viewport::MapToScreen` and requires a valid viewport. +- Multi-layer rendering is tested in three places, none of which needs a + window or ActiveX: `MapRenderSceneTests.cpp` covers scene order, hidden and + empty layers, and per-layer failures; `MapViewTests.cpp` covers fitting, + resizing, and scene invalidation; and `VectorLayerRendererTests.cpp` draws + layers with different extents into memory bitmaps and samples the pixels at + `Viewport::MapToScreen` positions, for GDI and, when available, OpenGL. - Cover for each feature: happy path, empty/missing input, out-of-range indexes, null values, non-ASCII paths, write-then-reopen round-trips, and error cases. diff --git a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp index 750f224..c0f4ecc 100644 --- a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp @@ -4,8 +4,8 @@ * @file GdiVectorLayerRenderer.cpp * @brief Draws polygon render scenes into a Windows GDI device context. * - * Fits source coordinates into the control bounds and paints polygon fills - * and outlines without retaining scene or host resources. + * Transforms every layer through the same Core viewport and paints polygon + * fills and outlines without retaining scene or host resources. */ #include "GdiVectorLayerRenderer.h" @@ -122,8 +122,8 @@ namespace mwg::activex value <= kMaximumGdiCoordinate; } - /// Checks every vertex before drawing so invalid geometry fails the - /// whole scene instead of being partially rasterized. + /// Checks every vertex of a layer before drawing so invalid geometry + /// skips the whole layer instead of being partially rasterized. [[nodiscard]] bool HasDrawableCoordinates( const rendering::VectorRenderScene& scene, const ViewTransform& transform, @@ -148,6 +148,9 @@ namespace mwg::activex return true; } + /// GDI addresses pixels by integer coordinates, so a continuous + /// screen position is drawn in the pixel that contains it, as ToPixel + /// defines. [[nodiscard]] POINT ToPoint( const rendering::Point& point, const ViewTransform& transform, @@ -155,48 +158,75 @@ namespace mwg::activex const double originY) noexcept { return { - static_cast(std::lround(ToDeviceX(point, transform, originX))), - static_cast(std::lround(ToDeviceY(point, transform, originY)))}; + static_cast(std::floor(ToDeviceX(point, transform, originX))), + static_cast(std::floor(ToDeviceY(point, transform, originY)))}; + } + + [[nodiscard]] HRESULT DrawLayer( + const HDC deviceContext, + const rendering::VectorRenderScene& scene, + const ViewTransform& transform, + const double originX, + const double originY) + { + for (const rendering::Polygon& polygon : scene.polygons) + { + std::vector points; + std::vector ringCounts; + for (const rendering::Ring& ring : polygon) + { + if (ring.size() < 3) + { + continue; + } + + const std::size_t maximumPoints = + static_cast(std::numeric_limits::max()); + if (ring.size() > maximumPoints || + points.size() > maximumPoints - ring.size()) + { + return E_INVALIDARG; + } + + ringCounts.push_back(static_cast(ring.size())); + for (const rendering::Point& point : ring) + { + points.push_back(ToPoint(point, transform, originX, originY)); + } + } + + if (!points.empty() && + PolyPolygon( + deviceContext, + points.data(), + ringCounts.data(), + static_cast(ringCounts.size())) == FALSE) + { + return E_FAIL; + } + } + return S_OK; } } HRESULT GdiVectorLayerRenderer::Draw( const HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept { - if (deviceContext == nullptr || scene.extent.IsEmpty() || - scene.extent.Width() <= 0.0 || scene.extent.Height() <= 0.0) + _failedLayers.clear(); + if (deviceContext == nullptr || !MatchesViewport(bounds, viewport)) { return E_INVALIDARG; } - const double targetWidth = - static_cast(bounds.right) - static_cast(bounds.left); - const double targetHeight = - static_cast(bounds.bottom) - static_cast(bounds.top); - if (targetWidth <= 2.0 * ViewTransform::kMargin || - targetHeight <= 2.0 * ViewTransform::kMargin) - { - return S_OK; - } - - const auto transform = ViewTransform::Create( - scene.extent, - targetWidth, - targetHeight); + const auto transform = ViewTransform::Create(viewport); if (!transform.has_value()) { return E_INVALIDARG; } - const double originX = static_cast(bounds.left); - const double originY = static_cast(bounds.top); - if (!HasDrawableCoordinates(scene, *transform, originX, originY)) - { - return E_INVALIDARG; - } - const ScopedDcState savedState(deviceContext); if (!savedState.IsValid()) { @@ -218,43 +248,31 @@ namespace mwg::activex return E_FAIL; } + const double originX = static_cast(bounds.left); + const double originY = static_cast(bounds.top); + std::size_t drawnLayers = 0; try { - for (const rendering::Polygon& polygon : scene.polygons) + for (const rendering::LayerRenderScene& layer : scene.layers) { - std::vector points; - std::vector ringCounts; - for (const rendering::Ring& ring : polygon) + if (!HasDrawableCoordinates(layer.scene, *transform, originX, originY)) { - if (ring.size() < 3) - { - continue; - } - - const std::size_t maximumPoints = - static_cast(std::numeric_limits::max()); - if (ring.size() > maximumPoints || - points.size() > maximumPoints - ring.size()) - { - return E_INVALIDARG; - } - - ringCounts.push_back(static_cast(ring.size())); - for (const rendering::Point& point : ring) - { - points.push_back(ToPoint(point, *transform, originX, originY)); - } + _failedLayers.push_back(layer.layerId); + continue; } - if (!points.empty() && - PolyPolygon( - deviceContext, - points.data(), - ringCounts.data(), - static_cast(ringCounts.size())) == FALSE) + const HRESULT layerResult = + DrawLayer(deviceContext, layer.scene, *transform, originX, originY); + if (layerResult == E_INVALIDARG) { - return E_FAIL; + _failedLayers.push_back(layer.layerId); + continue; + } + if (FAILED(layerResult)) + { + return layerResult; } + ++drawnLayers; } } catch (const std::bad_alloc&) @@ -266,6 +284,6 @@ namespace mwg::activex return E_OUTOFMEMORY; } - return S_OK; + return drawnLayers == 0 && !_failedLayers.empty() ? E_INVALIDARG : S_OK; } } diff --git a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h index 8e5238b..7a0363d 100644 --- a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h @@ -9,6 +9,8 @@ */ #pragma once +#include + #include "VectorRenderBackend.h" namespace mwg::activex @@ -16,9 +18,24 @@ namespace mwg::activex class GdiVectorLayerRenderer final : public IVectorRenderBackend { public: + /// Draws the layers bottom first. A layer with a vertex outside the + /// GDI coordinate range, or a non-finite vertex, is skipped as a + /// whole and listed by GetFailedLayers; the other layers are still + /// drawn. Returns E_INVALIDARG when every layer was skipped, and S_OK + /// when the scene has no layers. [[nodiscard]] HRESULT Draw( HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept override; + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept override; + + /// Layers skipped by the last Draw call, in drawing order. + [[nodiscard]] const std::vector& GetFailedLayers() const noexcept + { + return _failedLayers; + } + + private: + std::vector _failedLayers; }; } diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index f1ef8cb..1c73c31 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -3,11 +3,17 @@ /** * @file MapWinGISControl.cpp * @brief Implements vector loading, ActiveX state reporting, and host drawing. + * + * Redraws: every map or view change made through the control goes through + * its MapView; the control calls FireViewChange when the MapView reports + * that the frame changed. OnDraw draws the MapView's render scene through + * its viewport, so every layer uses the same transformation. */ #include "MapWinGISControl.h" #include +#include #include #include #include @@ -20,6 +26,7 @@ #include "ComConversion.h" #include "mapwingis/core/Error.h" #include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/vector/VectorDataset.h" namespace { @@ -307,15 +314,37 @@ STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) } const std::filesystem::path datasetPath(pathText); - std::unique_ptr dataset = mwg::VectorDataset::Open(datasetPath); - mwg::rendering::VectorRenderScene scene = - mwg::rendering::CreateVectorRenderScene(*dataset); - const LONGLONG featureCount = - static_cast(scene.sourceFeatureCount); - - _dataset = std::move(dataset); - _scene = std::move(scene); - _featureCount = featureCount; + std::shared_ptr dataset = mwg::VectorDataset::Open(datasetPath); + if (dataset->LayerCount() == 0) + { + throw mwg::CoreError("Vector dataset contains no layers"); + } + + // The first dataset layer goes on top of the map. A layer that + // cannot be rendered is rejected so the host gets the error now + // instead of a layer that never draws. + const mwg::LayerId id = _view.AddLayer(std::move(dataset)); + try + { + for (const mwg::rendering::SkippedLayer& skipped : _view.Scene().skippedLayers) + { + if (skipped.layerId == id && + skipped.reason == mwg::rendering::LayerSkipReason::Failed) + { + throw mwg::CoreError(skipped.message); + } + } + + // Until the control exposes navigation, every load shows the + // whole map so that each loaded layer is visible. + (void)_view.FitToMap(); + } + catch (...) + { + (void)_view.RemoveLayer(id); + throw; + } + FireViewChange(); return S_OK; } @@ -337,7 +366,7 @@ STDMETHODIMP CMapWinGISControl::get_LayerLoaded(VARIANT_BOOL* loaded) return E_POINTER; } - *loaded = _scene.has_value() ? VARIANT_TRUE : VARIANT_FALSE; + *loaded = _view.GetMap().LayerCount() > 0 ? VARIANT_TRUE : VARIANT_FALSE; return S_OK; } @@ -348,8 +377,27 @@ STDMETHODIMP CMapWinGISControl::get_FeatureCount(LONGLONG* count) return E_POINTER; } - *count = _featureCount; - return S_OK; + *count = 0; + try + { + const mwg::Map& map = _view.GetMap(); + LONGLONG total = 0; + for (int position = 0; position < map.LayerCount(); ++position) + { + total += static_cast(map.Layer(position).Source().FeatureCount()); + } + *count = total; + return S_OK; + } + catch (const std::exception& exception) + { + const CA2W message(exception.what(), CP_UTF8); + return AtlReportError( + CLSID_MapWinGISControl, + message, + IID_IMapWinGISControl, + E_FAIL); + } } STDMETHODIMP CMapWinGISControl::get_RenderingBackend(BSTR* backend) @@ -407,14 +455,32 @@ STDMETHODIMP CMapWinGISControl::put_RenderingBackendPreference(BSTR preference) return S_OK; } +bool CMapWinGISControl::ResizeView(const LONG width, const LONG height) noexcept +{ + try + { + return _view.Resize( + static_cast(std::clamp(width, 0, mwg::Viewport::kMaxDimension)), + static_cast(std::clamp(height, 0, mwg::Viewport::kMaxDimension))); + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + return false; + } +} + LRESULT CMapWinGISControl::OnSize( UINT, WPARAM, - LPARAM, + const LPARAM lParam, BOOL& handled) { _gdalEvents.OnSize(); - FireViewChange(); + if (ResizeView(LOWORD(lParam), HIWORD(lParam))) + { + FireViewChange(); + } handled = TRUE; return 0; } @@ -485,9 +551,37 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) return E_FAIL; } - return _scene.has_value() - ? _renderer.Draw(drawInfo.hdcDraw, bounds, *_scene) - : S_OK; + // Windowless hosts never send WM_SIZE, and a host may draw before it + // does, so the drawn bounds also define the viewport size. Bounds + // outside the supported size range are not drawn. + const LONGLONG width = static_cast(bounds.right) - bounds.left; + const LONGLONG height = static_cast(bounds.bottom) - bounds.top; + if (width <= 0 || height <= 0 || + width > mwg::Viewport::kMaxDimension || height > mwg::Viewport::kMaxDimension) + { + return S_OK; + } + (void)ResizeView(static_cast(width), static_cast(height)); + + try + { + const mwg::rendering::MapRenderScene& scene = _view.Scene(); + const mwg::Viewport& viewport = _view.GetViewport(); + if (scene.layers.empty() || !viewport.IsValid()) + { + return S_OK; + } + return _renderer.Draw(drawInfo.hdcDraw, bounds, scene, viewport); + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + return E_FAIL; + } } CMapWinGISControl::~CMapWinGISControl() diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index a3f941d..d299835 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -5,21 +5,20 @@ * @brief Declares the COM-creatable MapWinGIS ActiveX control. * * Provides automation for vector loading, GDAL operations, rendering - * preferences, and painting the initial polygon layer. + * preferences, and painting the map's polygon layers. The control owns one + * Core MapView, the only map and view state; it forwards host events to it + * and draws its render scene through its viewport. */ #pragma once #include "ActiveXModule.h" #include "resource.h" -#include -#include #include #include -#include "mapwingis/rendering/VectorRenderScene.h" -#include "mapwingis/vector/VectorDataset.h" +#include "mapwingis/map/MapView.h" #include "GdalEventSource.h" #include "VectorLayerRenderer.h" @@ -125,11 +124,13 @@ class ATL_NO_VTABLE CMapWinGISControl : ~CMapWinGISControl(); private: - std::unique_ptr _dataset; - std::optional _scene; + /// Resizes the view to a host size, clamped to the supported range, and + /// returns true when the frame must be redrawn. + bool ResizeView(LONG width, LONG height) noexcept; + + mwg::MapView _view; mwg::activex::VectorLayerRenderer _renderer; mwg::activex::GdalEventSource _gdalEvents; - LONGLONG _featureCount = 0; }; OBJECT_ENTRY_AUTO(CLSID_MapWinGISControl, CMapWinGISControl) diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp index 581bf86..eb1f278 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp @@ -219,27 +219,67 @@ namespace mwg::activex return orientation != 0.0 && std::abs(totalTurn) < 3.0 * kPi; } - [[nodiscard]] bool IsSupported(const rendering::VectorRenderScene& scene) noexcept + [[nodiscard]] bool IsSupported(const rendering::MapRenderScene& scene) noexcept { - for (const rendering::Polygon& polygon : scene.polygons) + for (const rendering::LayerRenderScene& layer : scene.layers) { - if (polygon.size() != 1 || !IsConvex(polygon.front())) - { - return false; - } - - for (const rendering::Point& point : polygon.front()) + for (const rendering::Polygon& polygon : layer.scene.polygons) { - if (!std::isfinite(point.x) || !std::isfinite(point.y)) + if (polygon.size() != 1 || !IsConvex(polygon.front())) { return false; } + + for (const rendering::Point& point : polygon.front()) + { + if (!std::isfinite(point.x) || !std::isfinite(point.y)) + { + return false; + } + } } } return true; } + /// Draws the convex single-ring polygons of one layer; IsSupported + /// must have accepted the scene. + void DrawLayer( + const rendering::VectorRenderScene& scene, + const ViewTransform& transform) noexcept + { + for (const rendering::Polygon& polygon : scene.polygons) + { + const rendering::Ring& ring = polygon.front(); + std::size_t count = ring.size(); + if (count > 1 && + ring.front().x == ring.back().x && + ring.front().y == ring.back().y) + { + --count; + } + + glColor3ub(61, 122, 169); + glBegin(GL_POLYGON); + for (std::size_t index = 0; index < count; ++index) + { + const rendering::Point& point = ring[index]; + glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + } + glEnd(); + + glColor3ub(24, 58, 82); + glBegin(GL_LINE_LOOP); + for (std::size_t index = 0; index < count; ++index) + { + const rendering::Point& point = ring[index]; + glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + } + glEnd(); + } + } + [[nodiscard]] std::int64_t GetWidth(const RECT& bounds) noexcept { return static_cast(bounds.right) - bounds.left; @@ -505,7 +545,8 @@ namespace mwg::activex OpenGlVectorLayerRenderer::ValidateTarget( const HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept { if (deviceContext == nullptr) { @@ -513,30 +554,23 @@ namespace mwg::activex return {E_INVALIDARG, std::nullopt}; } - if (!IsSupported(scene)) + if (!MatchesViewport(bounds, viewport)) { _lastFailureReason = - L"The scene uses geometry that the OpenGL backend does not support."; - return {S_FALSE, std::nullopt}; + L"The viewport is not valid or does not match the OpenGL target size."; + return {E_INVALIDARG, std::nullopt}; } - if (scene.extent.IsEmpty() || scene.extent.Width() <= 0.0 || - scene.extent.Height() <= 0.0) + if (!IsSupported(scene)) { - _lastFailureReason = L"The scene extent is invalid for OpenGL rendering."; - return {E_INVALIDARG, std::nullopt}; + _lastFailureReason = + L"The scene uses geometry that the OpenGL backend does not support."; + return {S_FALSE, std::nullopt}; } + // MatchesViewport limits both dimensions to Viewport::kMaxDimension. const std::int64_t width = GetWidth(bounds); const std::int64_t height = GetHeight(bounds); - if (width <= 2 * static_cast(ViewTransform::kMargin) || - height <= 2 * static_cast(ViewTransform::kMargin) || - width > std::numeric_limits::max() || - height > std::numeric_limits::max()) - { - _lastFailureReason = L"The OpenGL target has invalid or too-small dimensions."; - return {S_FALSE, std::nullopt}; - } const auto rejectionReason = GetTargetRejectionReason( static_cast(width), @@ -586,17 +620,14 @@ namespace mwg::activex } HRESULT OpenGlVectorLayerRenderer::RenderScene( - const rendering::VectorRenderScene& scene, + const rendering::MapRenderScene& scene, + const Viewport& viewport, const Target& target) noexcept { - const auto transform = ViewTransform::Create( - scene.extent, - static_cast(target.width), - static_cast(target.height)); + const auto transform = ViewTransform::Create(viewport); if (!transform.has_value()) { - _lastFailureReason = - L"The scene extent cannot be represented in the OpenGL viewport."; + _lastFailureReason = L"The viewport is not valid for OpenGL rendering."; return E_INVALIDARG; } @@ -646,34 +677,9 @@ namespace mwg::activex 1.0F); glClear(GL_COLOR_BUFFER_BIT); - for (const rendering::Polygon& polygon : scene.polygons) + for (const rendering::LayerRenderScene& layer : scene.layers) { - const rendering::Ring& ring = polygon.front(); - std::size_t count = ring.size(); - if (count > 1 && - ring.front().x == ring.back().x && - ring.front().y == ring.back().y) - { - --count; - } - - glColor3ub(61, 122, 169); - glBegin(GL_POLYGON); - for (std::size_t index = 0; index < count; ++index) - { - const rendering::Point& point = ring[index]; - glVertex2d(transform->ToDeviceX(point), transform->ToDeviceY(point)); - } - glEnd(); - - glColor3ub(24, 58, 82); - glBegin(GL_LINE_LOOP); - for (std::size_t index = 0; index < count; ++index) - { - const rendering::Point& point = ring[index]; - glVertex2d(transform->ToDeviceX(point), transform->ToDeviceY(point)); - } - glEnd(); + DrawLayer(layer.scene, *transform); } glFlush(); @@ -778,10 +784,12 @@ namespace mwg::activex HRESULT OpenGlVectorLayerRenderer::Draw( const HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept { _lastFailureReason = L"OpenGL did not render this frame."; - const TargetValidation validation = ValidateTarget(deviceContext, bounds, scene); + const TargetValidation validation = + ValidateTarget(deviceContext, bounds, scene, viewport); if (!validation.target.has_value()) { return validation.result; @@ -821,7 +829,7 @@ namespace mwg::activex return S_FALSE; } - const HRESULT renderResult = RenderScene(scene, target); + const HRESULT renderResult = RenderScene(scene, viewport, target); if (FAILED(renderResult) || renderResult == S_FALSE) { return renderResult; diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h index db56312..7296b70 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h @@ -48,14 +48,17 @@ namespace mwg::activex /** * @brief Draws a scene of simple convex single-ring polygons. * - * Returns S_FALSE without drawing when the scene contains unsupported - * geometry or when no OpenGL context with framebuffer-object support - * is available, so callers can fall back to another backend. + * Draws all layers into one frame, bottom first. Returns S_FALSE + * without drawing when any layer contains unsupported geometry or + * when no OpenGL context with framebuffer-object support is + * available, so callers can fall back to another backend for the + * whole frame. */ [[nodiscard]] HRESULT Draw( HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept override; + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept override; [[nodiscard]] std::wstring_view GetLastFailureReason() const noexcept { @@ -97,10 +100,12 @@ namespace mwg::activex [[nodiscard]] TargetValidation ValidateTarget( HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept; + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept; [[nodiscard]] bool EnsureStaging(std::size_t stagingBytes) noexcept; [[nodiscard]] HRESULT RenderScene( - const rendering::VectorRenderScene& scene, + const rendering::MapRenderScene& scene, + const Viewport& viewport, const Target& target) noexcept; [[nodiscard]] HRESULT CopyToDeviceContext( HDC deviceContext, diff --git a/src/MapWinGIS.ActiveX/VectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/VectorLayerRenderer.cpp index d133605..1bf7d27 100644 --- a/src/MapWinGIS.ActiveX/VectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/VectorLayerRenderer.cpp @@ -14,13 +14,15 @@ namespace mwg::activex HRESULT VectorLayerRenderer::Draw( const HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept { _activeBackend.reset(); if (_preference != VectorRenderBackendPreference::Gdi) { - const HRESULT openGlResult = _openGlRenderer.Draw(deviceContext, bounds, scene); + const HRESULT openGlResult = + _openGlRenderer.Draw(deviceContext, bounds, scene, viewport); if (openGlResult == S_OK) { _activeBackend = VectorRenderBackendKind::OpenGL; @@ -40,10 +42,14 @@ namespace mwg::activex _diagnostic = L"GDI was selected by the rendering preference."; } - const HRESULT gdiResult = _gdiRenderer.Draw(deviceContext, bounds, scene); + const HRESULT gdiResult = _gdiRenderer.Draw(deviceContext, bounds, scene, viewport); if (SUCCEEDED(gdiResult)) { _activeBackend = VectorRenderBackendKind::Gdi; + if (!_gdiRenderer.GetFailedLayers().empty()) + { + _diagnostic = L"GDI could not draw one or more layers; the other layers were drawn."; + } } else { diff --git a/src/MapWinGIS.ActiveX/VectorLayerRenderer.h b/src/MapWinGIS.ActiveX/VectorLayerRenderer.h index ac95cea..05211cb 100644 --- a/src/MapWinGIS.ActiveX/VectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/VectorLayerRenderer.h @@ -5,19 +5,22 @@ * @brief Declares the backend coordinator for vector scene rendering. * * Tries OpenGL rendering first and uses the GDI backend for unsupported - * geometry or when OpenGL cannot be initialized. + * geometry or when OpenGL cannot be initialized. One backend draws the whole + * frame, so all layers share its viewport transform and pixel conventions. */ #pragma once #include #include +#include #include #include "GdiVectorLayerRenderer.h" #include "OpenGlVectorLayerRenderer.h" #include "VectorRenderBackend.h" -#include "mapwingis/rendering/VectorRenderScene.h" +#include "mapwingis/map/Viewport.h" +#include "mapwingis/rendering/MapRenderScene.h" namespace mwg::activex { @@ -34,10 +37,25 @@ namespace mwg::activex return _preference; } + /// Draws the scene through viewport into bounds; see + /// IVectorRenderBackend::Draw. Layers that the selected backend + /// cannot draw are skipped and listed by GetFailedLayers. [[nodiscard]] HRESULT Draw( HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept; + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept; + + /// Layers skipped by the last Draw call; empty when every layer was + /// drawn. + [[nodiscard]] std::span GetFailedLayers() const noexcept + { + if (_activeBackend == VectorRenderBackendKind::OpenGL) + { + return {}; + } + return _gdiRenderer.GetFailedLayers(); + } [[nodiscard]] std::optional GetActiveBackend() const noexcept { diff --git a/src/MapWinGIS.ActiveX/VectorRenderBackend.h b/src/MapWinGIS.ActiveX/VectorRenderBackend.h index 80d1a98..3fef485 100644 --- a/src/MapWinGIS.ActiveX/VectorRenderBackend.h +++ b/src/MapWinGIS.ActiveX/VectorRenderBackend.h @@ -4,14 +4,16 @@ * @file VectorRenderBackend.h * @brief Defines the internal backend contract for vector scene drawing. * - * Backends consume owned Core render scenes and draw them into a Windows - * device context without accessing datasets or COM interfaces. + * Backends consume owned Core map render scenes and draw them through a Core + * viewport into a Windows device context, without accessing maps, datasets, + * or COM interfaces and without keeping view state of their own. */ #pragma once #include -#include "mapwingis/rendering/VectorRenderScene.h" +#include "mapwingis/map/Viewport.h" +#include "mapwingis/rendering/MapRenderScene.h" namespace mwg::activex { @@ -33,10 +35,22 @@ namespace mwg::activex public: virtual ~IVectorRenderBackend() = default; + /// Draws all scene layers, bottom first, through the same viewport. + /// bounds is the device rectangle of the viewport; its size must + /// equal the viewport size, and the viewport must be valid. /// Returns S_FALSE when the backend cannot render this scene or target. [[nodiscard]] virtual HRESULT Draw( HDC deviceContext, const RECT& bounds, - const rendering::VectorRenderScene& scene) noexcept = 0; + const rendering::MapRenderScene& scene, + const Viewport& viewport) noexcept = 0; }; + + /// True when bounds has the viewport's size and the viewport is valid. + [[nodiscard]] inline bool MatchesViewport(const RECT& bounds, const Viewport& viewport) noexcept + { + return viewport.IsValid() && + static_cast(bounds.right) - bounds.left == viewport.Width() && + static_cast(bounds.bottom) - bounds.top == viewport.Height(); + } } diff --git a/src/MapWinGIS.ActiveX/ViewTransform.cpp b/src/MapWinGIS.ActiveX/ViewTransform.cpp index 4a1635d..c66e368 100644 --- a/src/MapWinGIS.ActiveX/ViewTransform.cpp +++ b/src/MapWinGIS.ActiveX/ViewTransform.cpp @@ -2,56 +2,27 @@ /** * @file ViewTransform.cpp - * @brief Builds the shared source-to-device transform for polygon renderers. + * @brief Captures the Core viewport as the renderers' map-to-device transform. */ #include "ViewTransform.h" -#include -#include - namespace mwg::activex { - std::optional ViewTransform::Create( - const Extent& extent, - const double targetWidth, - const double targetHeight) noexcept + std::optional ViewTransform::Create(const Viewport& viewport) noexcept { - const double extentWidth = extent.Width(); - const double extentHeight = extent.Height(); - const double availableWidth = targetWidth - 2.0 * kMargin; - const double availableHeight = targetHeight - 2.0 * kMargin; - if (extent.IsEmpty() || !std::isfinite(extentWidth) || - !std::isfinite(extentHeight) || extentWidth <= 0.0 || - extentHeight <= 0.0 || !std::isfinite(availableWidth) || - !std::isfinite(availableHeight) || availableWidth <= 0.0 || - availableHeight <= 0.0) - { - return std::nullopt; - } - - const double scale = std::min( - availableWidth / extentWidth, - availableHeight / extentHeight); - if (!std::isfinite(scale) || scale <= 0.0) - { - return std::nullopt; - } - - const double drawingWidth = extentWidth * scale; - const double drawingHeight = extentHeight * scale; - const double offsetX = (targetWidth - drawingWidth) / 2.0; - const double offsetY = (targetHeight - drawingHeight) / 2.0; - if (!std::isfinite(drawingWidth) || !std::isfinite(drawingHeight) || - !std::isfinite(offsetX) || !std::isfinite(offsetY)) + const std::optional center = viewport.Center(); + const std::optional resolution = viewport.Resolution(); + if (!viewport.IsValid() || !center.has_value() || !resolution.has_value()) { return std::nullopt; } + // Same terms as Viewport::MapToScreen, so both give identical results. return ViewTransform{ - scale, - offsetX, - offsetY, - extent.xMin, - extent.yMax}; + static_cast(viewport.Width()) / 2.0, + static_cast(viewport.Height()) / 2.0, + center->x, + center->y, + *resolution}; } } diff --git a/src/MapWinGIS.ActiveX/ViewTransform.h b/src/MapWinGIS.ActiveX/ViewTransform.h index 0e7d197..4a1046a 100644 --- a/src/MapWinGIS.ActiveX/ViewTransform.h +++ b/src/MapWinGIS.ActiveX/ViewTransform.h @@ -2,43 +2,43 @@ /** * @file ViewTransform.h - * @brief Declares the shared source-to-device transform for polygon renderers. + * @brief Declares the map-to-device transform shared by the polygon renderers. * - * Provides the same aspect-preserving scale and centered margin to the GDI - * and OpenGL rendering backends. + * Captures the Core viewport once per frame so the GDI and OpenGL backends + * transform every vertex of every layer with the same values and the same + * arithmetic as Viewport::MapToScreen, without per-vertex optional results. */ #pragma once #include -#include "mapwingis/core/Extent.h" +#include "mapwingis/map/Viewport.h" #include "mapwingis/rendering/VectorRenderScene.h" namespace mwg::activex { struct ViewTransform final { - static constexpr double kMargin = 12.0; - + /// Returns std::nullopt when the viewport is not valid. [[nodiscard]] static std::optional Create( - const Extent& extent, - double targetWidth, - double targetHeight) noexcept; + const Viewport& viewport) noexcept; + /// Screen X of a map point, relative to the viewport's left edge. [[nodiscard]] double ToDeviceX(const rendering::Point& point) const noexcept { - return offsetX + (point.x - xMin) * scale; + return halfWidth + (point.x - centerX) / resolution; } + /// Screen Y of a map point, relative to the viewport's top edge. [[nodiscard]] double ToDeviceY(const rendering::Point& point) const noexcept { - return offsetY + (yMax - point.y) * scale; + return halfHeight - (point.y - centerY) / resolution; } - double scale = 0.0; - double offsetX = 0.0; - double offsetY = 0.0; - double xMin = 0.0; - double yMax = 0.0; + double halfWidth = 0.0; + double halfHeight = 0.0; + double centerX = 0.0; + double centerY = 0.0; + double resolution = 1.0; }; } diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj index 6ff648d..4e7f52c 100644 --- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj +++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj @@ -61,14 +61,17 @@ + + + @@ -80,8 +83,10 @@ + + diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h new file mode 100644 index 0000000..0213745 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapView.h + * @brief Declares the map view: a map, its viewport, and its render scene. + * + * MapView is the single owner of the view state that hosts display: one Map, + * one Viewport shared by all layers, and the render scene prepared from the + * map. Hosts such as the ActiveX control change the map and the view only + * through MapView, so the scene is rebuilt when the map changes and every + * layer is drawn through the same viewport. MapView holds no windowing, COM, + * or rendering-backend state. + */ +#pragma once + +#include + +#include "mapwingis/core/Api.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/map/Viewport.h" +#include "mapwingis/rendering/MapRenderScene.h" + +namespace mwg +{ + class VectorDataset; + + /// A map, the viewport it is shown in, and its render scene. + /// + /// Redraws: every mutator returns whether the displayed frame may have + /// changed. Hosts request a redraw when it returns true and draw Scene() + /// through GetViewport(). Changes to the layer collection or visibility + /// mark the scene stale; Scene() rebuilds it on the next call. Edits to + /// source geometry are not observed; call InvalidateScene after them. + /// + /// Initial view: while the viewport has no view, adding a layer fits the + /// viewport to the map. A fit that cannot happen because the viewport has + /// no size yet is kept pending and done by the first Resize that gives + /// the viewport a size, so data loaded before a host window has a size is + /// fitted once the window appears. + /// + /// Not thread-safe; see Map. + class MWG_CORE_API MapView + { + public: + /// Padding, in pixels, used by FitToMap when none is given. + static constexpr double kDefaultPadding = 12.0; + + MapView(); + ~MapView(); + + MapView(const MapView&) = delete; + MapView& operator=(const MapView&) = delete; + + [[nodiscard]] const Map& GetMap() const noexcept; + [[nodiscard]] const Viewport& GetViewport() const noexcept; + + /// Adds a layer on top of the map; see Map::AddLayer. Fits the view + /// to the map when the viewport has no view yet. + /// \throws InvalidArgument as Map::AddLayer does. + LayerId AddLayer(std::shared_ptr dataset, int layerIndex = 0); + + /// Removes a layer; see Map::RemoveLayer. The view is kept. + bool RemoveLayer(LayerId id) noexcept; + + /// Removes all layers and returns true when there were any. The view + /// is kept. + bool ClearLayers() noexcept; + + /// Shows or hides a layer and returns true when its visibility + /// changed. Returns false when the map has no such layer. + bool SetLayerVisible(LayerId id, bool visible) noexcept; + + /// Changes the viewport size, keeping the center and resolution so + /// the map neither moves nor changes scale; see Viewport::SetSize. + /// Completes a pending fit once both dimensions are positive. + /// Returns true when the size or view changed. + /// \throws InvalidArgument as Viewport::SetSize does. + bool Resize(int width, int height); + + /// Fits the viewport to the visible map extent with paddingPixels + /// free on every side; see Viewport::FitExtent. Returns true when the + /// view was fitted. When the viewport has no size yet, the fit is + /// kept pending for Resize and false is returned. A map without a + /// valid visible extent returns false and leaves the view unchanged. + /// \throws InvalidArgument when paddingPixels is negative or not finite. + bool FitToMap(double paddingPixels = kDefaultPadding); + + /// The render scene of the current map, rebuilt first when the map + /// changed since the last call; see rendering::CreateMapRenderScene. + /// The reference stays valid for the lifetime of the MapView; a later + /// call that rebuilds the scene replaces its contents. + /// \throws std::bad_alloc when memory runs out. + [[nodiscard]] const rendering::MapRenderScene& Scene(); + + /// Marks the render scene stale, for example after source geometry + /// was edited. Returns true: the frame must be redrawn. + bool InvalidateScene() noexcept; + + private: + class Impl; + std::unique_ptr _impl; + }; +} diff --git a/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h new file mode 100644 index 0000000..cd548c6 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapRenderScene.h + * @brief Declares backend-neutral render data for all layers of a map. + * + * A map render scene is an owned snapshot of the drawable geometry of a Map, + * in drawing order. It holds no references to the map, its layers, or their + * datasets, and it holds no view state: renderers draw it through a separate + * Viewport, so a view change does not need a new scene. + */ +#pragma once + +#include +#include + +#include "mapwingis/core/Api.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/rendering/VectorRenderScene.h" + +namespace mwg::rendering +{ + /// Owned geometry of one map layer. + struct LayerRenderScene + { + /// Identity of the map layer the geometry was copied from. + LayerId layerId; + /// Polygon geometry in map coordinates; never empty in a MapRenderScene. + VectorRenderScene scene; + }; + + /// Why CreateMapRenderScene left a map layer out of the scene. + enum class LayerSkipReason : int + { + /// The layer is hidden. + Hidden, + /// The layer has no polygon geometry to draw, for example an empty layer. + NoGeometry, + /// The layer cannot be rendered, for example because it is not a + /// polygon layer or its geometry cannot be read. See the message. + Failed, + }; + + /// A map layer that is not part of a MapRenderScene. + struct SkippedLayer + { + LayerId layerId; + LayerSkipReason reason = LayerSkipReason::Failed; + /// Error message for LayerSkipReason::Failed; empty otherwise. + std::string message; + }; + + /// Owned render data for the layers of a map. + struct MWG_CORE_API MapRenderScene + { + /// Drawable layers in drawing order: the bottom map layer first. + std::vector layers; + /// Layers left out of the scene, in map order. + std::vector skippedLayers; + }; + + /// Copies the drawable geometry of every visible map layer into owned + /// scene data, in map order (see Map). + /// + /// Each layer is converted on its own, so a layer that cannot be + /// rendered is listed in skippedLayers and does not prevent the other + /// layers from being drawn. Hidden layers and layers without polygon + /// geometry are listed in skippedLayers too. Coordinates stay in the + /// layers' source coordinate system; the scene does not reproject. + /// + /// The scene is a snapshot: later changes to the map or its sources are + /// not reflected, so create a new scene after the map changes. Reads + /// every visible layer completely. Not thread-safe; see Map. + /// \throws std::bad_alloc when memory runs out. Errors of single layers + /// are reported in skippedLayers instead of thrown. + [[nodiscard]] MWG_CORE_API MapRenderScene CreateMapRenderScene(const Map& map); +} diff --git a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h new file mode 100644 index 0000000..893579e --- /dev/null +++ b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file VectorRenderSceneBuilder.h + * @brief Declares private scene building shared by layer and map scenes. + * + * Lets map scene creation tell layers without polygon geometry apart from + * layers that cannot be rendered, without parsing exception messages. + */ +#pragma once + +#include "mapwingis/rendering/VectorRenderScene.h" + +namespace mwg::rendering::detail +{ + /// Copies polygon geometry from a layer into scene data. Unlike + /// CreateVectorRenderScene, a layer without polygon geometry gives a + /// scene with no polygons instead of an error. + /// \throws CoreError when the layer is not polygonal or its geometry + /// cannot be converted. + [[nodiscard]] VectorRenderScene BuildVectorRenderScene(const VectorLayer& layer); +} diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp new file mode 100644 index 0000000..f6ac29b --- /dev/null +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapView.cpp + * @brief Implements the map view and its render-scene invalidation. + */ +#include "mapwingis/map/MapView.h" + +#include +#include + +#include "mapwingis/vector/VectorDataset.h" + +namespace mwg +{ + class MapView::Impl + { + public: + Map _map; + Viewport _viewport; + rendering::MapRenderScene _scene; + bool _sceneStale = false; + /// Padding of a fit that waits for the viewport to get a size. + std::optional _pendingFitPadding; + }; + + MapView::MapView() + : _impl(std::make_unique()) + { + } + + MapView::~MapView() = default; + + const Map& MapView::GetMap() const noexcept + { + return _impl->_map; + } + + const Viewport& MapView::GetViewport() const noexcept + { + return _impl->_viewport; + } + + LayerId MapView::AddLayer(std::shared_ptr dataset, const int layerIndex) + { + const LayerId id = _impl->_map.AddLayer(std::move(dataset), layerIndex); + _impl->_sceneStale = true; + if (!_impl->_viewport.HasView()) + { + try + { + (void)FitToMap(); + } + catch (...) + { + // Reading the extent failed; leave the map as it was. + (void)_impl->_map.RemoveLayer(id); + throw; + } + } + return id; + } + + bool MapView::RemoveLayer(const LayerId id) noexcept + { + if (!_impl->_map.RemoveLayer(id)) + { + return false; + } + _impl->_sceneStale = true; + return true; + } + + bool MapView::ClearLayers() noexcept + { + if (_impl->_map.LayerCount() == 0) + { + return false; + } + _impl->_map.Clear(); + _impl->_sceneStale = true; + return true; + } + + bool MapView::SetLayerVisible(const LayerId id, const bool visible) noexcept + { + const MapLayer* layer = _impl->_map.FindLayer(id); + if (layer == nullptr || layer->IsVisible() == visible) + { + return false; + } + (void)_impl->_map.SetLayerVisible(id, visible); + _impl->_sceneStale = true; + return true; + } + + bool MapView::Resize(const int width, const int height) + { + Viewport& viewport = _impl->_viewport; + const bool sizeChanged = viewport.Width() != width || viewport.Height() != height; + viewport.SetSize(width, height); + + bool fitted = false; + if (_impl->_pendingFitPadding.has_value() && width > 0 && height > 0) + { + fitted = FitToMap(*_impl->_pendingFitPadding); + } + return sizeChanged || fitted; + } + + bool MapView::FitToMap(const double paddingPixels) + { + const Extent extent = _impl->_map.GetExtent(); + if (_impl->_viewport.FitExtent(extent, paddingPixels)) + { + _impl->_pendingFitPadding.reset(); + return true; + } + + // FitExtent fails for an invalid extent or a zero-sized viewport; + // only the second can succeed later without another request. + if (extent.IsValid()) + { + _impl->_pendingFitPadding = paddingPixels; + } + else + { + _impl->_pendingFitPadding.reset(); + } + return false; + } + + const rendering::MapRenderScene& MapView::Scene() + { + if (_impl->_sceneStale) + { + _impl->_scene = rendering::CreateMapRenderScene(_impl->_map); + _impl->_sceneStale = false; + } + return _impl->_scene; + } + + bool MapView::InvalidateScene() noexcept + { + _impl->_sceneStale = true; + return true; + } +} diff --git a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp new file mode 100644 index 0000000..272da5d --- /dev/null +++ b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapRenderScene.cpp + * @brief Builds owned render data for all visible layers of a map. + */ +#include "mapwingis/rendering/MapRenderScene.h" + +#include + +#include "mapwingis/core/Error.h" + +#include "internal/VectorRenderSceneBuilder.h" + +namespace mwg::rendering +{ + MapRenderScene CreateMapRenderScene(const Map& map) + { + MapRenderScene result; + for (int position = 0; position < map.LayerCount(); ++position) + { + const MapLayer& layer = map.Layer(position); + if (!layer.IsVisible()) + { + result.skippedLayers.push_back({layer.Id(), LayerSkipReason::Hidden, {}}); + continue; + } + + VectorRenderScene scene; + try + { + scene = detail::BuildVectorRenderScene(layer.Source()); + } + catch (const CoreError& error) + { + result.skippedLayers.push_back({layer.Id(), LayerSkipReason::Failed, error.what()}); + continue; + } + + if (scene.polygons.empty()) + { + result.skippedLayers.push_back({layer.Id(), LayerSkipReason::NoGeometry, {}}); + continue; + } + result.layers.push_back({layer.Id(), std::move(scene)}); + } + return result; + } +} diff --git a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp index c106ef1..8d5ba09 100644 --- a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp @@ -19,6 +19,7 @@ #include "mapwingis/vector/VectorDataset.h" #include "internal/VectorLayerAccess.h" +#include "internal/VectorRenderSceneBuilder.h" namespace mwg::rendering { @@ -96,7 +97,7 @@ namespace mwg::rendering } } - VectorRenderScene CreateVectorRenderScene(const VectorLayer& layer) + VectorRenderScene detail::BuildVectorRenderScene(const VectorLayer& layer) { if (BaseShapeType(layer.Type()) != ShapeType::Polygon) { @@ -106,13 +107,18 @@ namespace mwg::rendering VectorRenderScene scene; scene.extent = layer.GetExtent(); scene.sourceFeatureCount = layer.FeatureCount(); - detail::VectorLayerAccess::ForEachGeometry( + mwg::detail::VectorLayerAccess::ForEachGeometry( layer, [&scene](const OGRGeometry& geometry) { AppendGeometry(geometry, scene); }); + return scene; + } + VectorRenderScene CreateVectorRenderScene(const VectorLayer& layer) + { + VectorRenderScene scene = detail::BuildVectorRenderScene(layer); if (scene.polygons.empty()) { throw CoreError("Vector layer contains no polygon geometry to render"); diff --git a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp new file mode 100644 index 0000000..fcb7f11 --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapRenderSceneTests.cpp + * @brief Tests render-scene creation for all layers of a map. + * + * Verifies drawing order, visibility, skipped layers, per-layer failure + * isolation, and that the scene is an owned snapshot of the map. + */ +#include + +#include +#include +#include +#include + +#include +#include + +#include "TestData.h" +#include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/rendering/MapRenderScene.h" +#include "mapwingis/vector/VectorDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + /// Writes a GeoPackage whose only layer holds one line string. + std::filesystem::path MakeLineDataset(const std::filesystem::path& directory) + { + GdalEnvironment::Initialize(); + const std::filesystem::path path = directory / "line.gpkg"; + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("GPKG"); + if (driver == nullptr) + { + throw std::runtime_error("GPKG driver is not available"); + } + + const std::u8string utf8 = path.u8string(); + GDALDatasetUniquePtr output(driver->Create( + reinterpret_cast(utf8.c_str()), 0, 0, 0, GDT_Unknown, nullptr)); + if (output == nullptr) + { + throw std::runtime_error("Cannot create the line dataset"); + } + + OGRLayer* layer = output->CreateLayer("lines", nullptr, wkbLineString, nullptr); + if (layer == nullptr) + { + throw std::runtime_error("Cannot create the line layer"); + } + + OGRLineString line; + line.addPoint(0.0, 0.0); + line.addPoint(5.0, 5.0); + OGRFeatureUniquePtr feature(OGRFeature::CreateFeature(layer->GetLayerDefn())); + if (feature->SetGeometry(&line) != OGRERR_NONE || + layer->CreateFeature(feature.get()) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write the line feature"); + } + return path; + } + + class MapRenderSceneTest : public testing::Test + { + protected: + std::shared_ptr OpenRectangles(const std::vector& rectangles) + { + const std::string fileName = "layer-" + std::to_string(_datasetCount++) + ".gpkg"; + return VectorDataset::Open( + MakeRectanglesDataset(_directory.Path(), fileName, rectangles)); + } + + TempDirectory _directory; + Map _map; + int _datasetCount = 0; + }; +} + +TEST_F(MapRenderSceneTest, EmptyMapGivesAnEmptyScene) +{ + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + EXPECT_TRUE(scene.layers.empty()); + EXPECT_TRUE(scene.skippedLayers.empty()); +} + +TEST_F(MapRenderSceneTest, ContainsEveryVisibleLayerInDrawingOrder) +{ + const LayerId bottom = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const LayerId middle = _map.AddLayer(OpenRectangles({{10.0, 10.0, 12.0, 12.0}, + {20.0, 20.0, 21.0, 21.0}})); + const LayerId top = _map.AddLayer(OpenRectangles({{-5.0, -5.0, -4.0, -4.0}})); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 3U); + EXPECT_TRUE(scene.skippedLayers.empty()); + EXPECT_EQ(scene.layers[0].layerId, bottom); + EXPECT_EQ(scene.layers[1].layerId, middle); + EXPECT_EQ(scene.layers[2].layerId, top); + EXPECT_EQ(scene.layers[0].scene.polygons.size(), 1U); + EXPECT_EQ(scene.layers[1].scene.polygons.size(), 2U); + EXPECT_EQ(scene.layers[1].scene.sourceFeatureCount, 2); +} + +TEST_F(MapRenderSceneTest, KeepsEachLayersOwnExtentAndCoordinates) +{ + _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + _map.AddLayer(OpenRectangles({{100.0, 50.0, 140.0, 60.0}})); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 2U); + EXPECT_DOUBLE_EQ(scene.layers[0].scene.extent.xMax, 1.0); + EXPECT_DOUBLE_EQ(scene.layers[1].scene.extent.xMin, 100.0); + EXPECT_DOUBLE_EQ(scene.layers[1].scene.extent.yMax, 60.0); + for (const rendering::Point& point : scene.layers[1].scene.polygons.front().front()) + { + EXPECT_GE(point.x, 100.0); + EXPECT_LE(point.x, 140.0); + EXPECT_GE(point.y, 50.0); + EXPECT_LE(point.y, 60.0); + } +} + +TEST_F(MapRenderSceneTest, SkipsHiddenLayers) +{ + const LayerId hidden = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const LayerId visible = _map.AddLayer(OpenRectangles({{2.0, 2.0, 3.0, 3.0}})); + ASSERT_TRUE(_map.SetLayerVisible(hidden, false)); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 1U); + EXPECT_EQ(scene.layers[0].layerId, visible); + ASSERT_EQ(scene.skippedLayers.size(), 1U); + EXPECT_EQ(scene.skippedLayers[0].layerId, hidden); + EXPECT_EQ(scene.skippedLayers[0].reason, rendering::LayerSkipReason::Hidden); + EXPECT_TRUE(scene.skippedLayers[0].message.empty()); +} + +TEST_F(MapRenderSceneTest, SkipsLayersWithoutGeometry) +{ + const LayerId empty = _map.AddLayer(OpenRectangles({})); + const LayerId filled = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 1U); + EXPECT_EQ(scene.layers[0].layerId, filled); + ASSERT_EQ(scene.skippedLayers.size(), 1U); + EXPECT_EQ(scene.skippedLayers[0].layerId, empty); + EXPECT_EQ(scene.skippedLayers[0].reason, rendering::LayerSkipReason::NoGeometry); +} + +TEST_F(MapRenderSceneTest, LayerThatCannotBeRenderedDoesNotStopTheOthers) +{ + const LayerId below = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const LayerId lines = _map.AddLayer(VectorDataset::Open(MakeLineDataset(_directory.Path()))); + const LayerId above = _map.AddLayer(OpenRectangles({{2.0, 2.0, 3.0, 3.0}})); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 2U); + EXPECT_EQ(scene.layers[0].layerId, below); + EXPECT_EQ(scene.layers[1].layerId, above); + ASSERT_EQ(scene.skippedLayers.size(), 1U); + EXPECT_EQ(scene.skippedLayers[0].layerId, lines); + EXPECT_EQ(scene.skippedLayers[0].reason, rendering::LayerSkipReason::Failed); + EXPECT_EQ(scene.skippedLayers[0].message, "Vector rendering supports polygon layers only"); +} + +TEST_F(MapRenderSceneTest, DuplicateLayersAreSeparateSceneLayers) +{ + const std::shared_ptr dataset = OpenRectangles({{0.0, 0.0, 1.0, 1.0}}); + const LayerId first = _map.AddLayer(dataset); + const LayerId second = _map.AddLayer(dataset); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 2U); + EXPECT_EQ(scene.layers[0].layerId, first); + EXPECT_EQ(scene.layers[1].layerId, second); +} + +TEST_F(MapRenderSceneTest, SceneIsASnapshotThatOutlivesTheMapLayers) +{ + const LayerId id = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_TRUE(_map.SetLayerVisible(id, false)); + _map.Clear(); + + ASSERT_EQ(scene.layers.size(), 1U); + EXPECT_EQ(scene.layers[0].layerId, id); + EXPECT_EQ(scene.layers[0].scene.polygons.front().front().size(), 5U); +} diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp new file mode 100644 index 0000000..e0b04c9 --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file MapViewTests.cpp + * @brief Tests the map view: shared viewport, scene invalidation, and resizing. + * + * Verifies that layers with different extents are shown through one + * viewport, that map changes rebuild the render scene and report a redraw, + * and that resizing keeps the view stable without a window or ActiveX. + */ +#include + +#include +#include +#include + +#include "TestData.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/map/MapView.h" +#include "mapwingis/vector/VectorDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + constexpr double kTolerance = 1e-9; + + /// True when the extent lies within the viewport's visible extent. + bool IsVisible(const Viewport& viewport, const Extent& extent) + { + const Extent visible = viewport.VisibleExtent(); + return visible.xMin <= extent.xMin && visible.yMin <= extent.yMin && + visible.xMax >= extent.xMax && visible.yMax >= extent.yMax; + } + + class MapViewTest : public testing::Test + { + protected: + std::shared_ptr OpenRectangles(const std::vector& rectangles) + { + const std::string fileName = "layer-" + std::to_string(_datasetCount++) + ".gpkg"; + return VectorDataset::Open( + MakeRectanglesDataset(_directory.Path(), fileName, rectangles)); + } + + TempDirectory _directory; + MapView _view; + int _datasetCount = 0; + }; +} + +TEST_F(MapViewTest, NewViewHasNoLayersSizeOrView) +{ + EXPECT_EQ(_view.GetMap().LayerCount(), 0); + EXPECT_FALSE(_view.GetViewport().HasView()); + EXPECT_EQ(_view.GetViewport().Width(), 0); + EXPECT_TRUE(_view.Scene().layers.empty()); + EXPECT_FALSE(_view.FitToMap()); +} + +TEST_F(MapViewTest, FirstLayerFitsTheViewWhenTheViewportHasASize) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + + const Viewport& viewport = _view.GetViewport(); + ASSERT_TRUE(viewport.IsValid()); + EXPECT_NEAR(viewport.Center()->x, 5.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 2.5, kTolerance); + EXPECT_TRUE(IsVisible(viewport, {0.0, 0.0, 10.0, 5.0})); +} + +TEST_F(MapViewTest, LaterLayersKeepTheViewUntilFitToMap) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const MapPoint center = *_view.GetViewport().Center(); + const double resolution = *_view.GetViewport().Resolution(); + + _view.AddLayer(OpenRectangles({{90.0, 40.0, 100.0, 50.0}})); + + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->y, center.y); + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); + + ASSERT_TRUE(_view.FitToMap()); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 100.0, 50.0})); +} + +TEST_F(MapViewTest, FitToMapShowsLayersWithDifferentExtentsInOneViewport) +{ + ASSERT_TRUE(_view.Resize(400, 300)); + const Extent small{-1.0, -1.0, 1.0, 1.0}; + const Extent large{500.0, 200.0, 1500.0, 900.0}; + _view.AddLayer(OpenRectangles({small})); + _view.AddLayer(OpenRectangles({large})); + + ASSERT_TRUE(_view.FitToMap(MapView::kDefaultPadding)); + + const Viewport& viewport = _view.GetViewport(); + EXPECT_TRUE(IsVisible(viewport, small)); + EXPECT_TRUE(IsVisible(viewport, large)); + // Both layers map through the same transformation: the screen distance + // between them equals their map distance divided by the resolution. + const auto first = viewport.MapToScreen({small.xMin, small.yMin}); + const auto second = viewport.MapToScreen({large.xMax, large.yMax}); + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(second.has_value()); + EXPECT_NEAR(second->x - first->x, (large.xMax - small.xMin) / *viewport.Resolution(), 1e-6); + EXPECT_NEAR(first->y - second->y, (large.yMax - small.yMin) / *viewport.Resolution(), 1e-6); +} + +TEST_F(MapViewTest, FitToMapIgnoresHiddenLayers) +{ + ASSERT_TRUE(_view.Resize(200, 200)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId distant = _view.AddLayer(OpenRectangles({{1000.0, 1000.0, 1010.0, 1010.0}})); + ASSERT_TRUE(_view.SetLayerVisible(distant, false)); + + ASSERT_TRUE(_view.FitToMap()); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 5.0, kTolerance); + EXPECT_FALSE(IsVisible(_view.GetViewport(), {1000.0, 1000.0, 1010.0, 1010.0})); +} + +TEST_F(MapViewTest, FitToMapRejectsInvalidPadding) +{ + EXPECT_THROW((void)_view.FitToMap(-1.0), InvalidArgument); +} + +TEST_F(MapViewTest, LayerAddedBeforeTheViewportHasASizeIsFittedOnFirstResize) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + EXPECT_FALSE(_view.GetViewport().HasView()); + + ASSERT_TRUE(_view.Resize(200, 100)); + + ASSERT_TRUE(_view.GetViewport().IsValid()); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 10.0, 5.0})); +} + +TEST_F(MapViewTest, FitRequestedWhileMinimizedHappensOnRestore) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + + EXPECT_FALSE(_view.FitToMap()); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); +} + +TEST_F(MapViewTest, ResizeKeepsTheCenterAndScale) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const MapPoint center = *_view.GetViewport().Center(); + const double resolution = *_view.GetViewport().Resolution(); + + ASSERT_TRUE(_view.Resize(640, 80)); + + EXPECT_EQ(_view.GetViewport().Width(), 640); + EXPECT_EQ(_view.GetViewport().Height(), 80); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->y, center.y); + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); + EXPECT_FALSE(_view.Resize(640, 80)); +} + +TEST_F(MapViewTest, ResizeHandlesTinyAndElongatedSizes) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const int sizes[][2] = {{1, 1}, {Viewport::kMaxDimension, 1}, {1, Viewport::kMaxDimension}, {3, 2}}; + for (const auto& size : sizes) + { + ASSERT_NO_THROW((void)_view.Resize(size[0], size[1])); + ASSERT_TRUE(_view.FitToMap(0.0)) << size[0] << " x " << size[1]; + const Viewport& viewport = _view.GetViewport(); + EXPECT_TRUE(viewport.IsValid()); + // The resolution is the same on both axes, so the map is not distorted. + const Extent visible = viewport.VisibleExtent(); + EXPECT_NEAR(visible.Width() / visible.Height(), + static_cast(size[0]) / static_cast(size[1]), + 1e-9 * static_cast(Viewport::kMaxDimension)); + EXPECT_TRUE(IsVisible(viewport, {0.0, 0.0, 10.0, 5.0})); + } + EXPECT_THROW((void)_view.Resize(Viewport::kMaxDimension + 1, 10), InvalidArgument); +} + +TEST_F(MapViewTest, MapChangesRebuildTheSceneAndReportARedraw) +{ + const LayerId first = _view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + ASSERT_EQ(_view.Scene().layers.size(), 1U); + + const LayerId second = _view.AddLayer(OpenRectangles({{2.0, 2.0, 3.0, 3.0}})); + ASSERT_EQ(_view.Scene().layers.size(), 2U); + EXPECT_EQ(_view.Scene().layers[1].layerId, second); + + EXPECT_TRUE(_view.SetLayerVisible(first, false)); + EXPECT_FALSE(_view.SetLayerVisible(first, false)); + ASSERT_EQ(_view.Scene().layers.size(), 1U); + EXPECT_EQ(_view.Scene().layers[0].layerId, second); + + EXPECT_TRUE(_view.SetLayerVisible(first, true)); + ASSERT_EQ(_view.Scene().layers.size(), 2U); + EXPECT_EQ(_view.Scene().layers[0].layerId, first); + + EXPECT_TRUE(_view.RemoveLayer(first)); + EXPECT_FALSE(_view.RemoveLayer(first)); + ASSERT_EQ(_view.Scene().layers.size(), 1U); + + EXPECT_TRUE(_view.ClearLayers()); + EXPECT_FALSE(_view.ClearLayers()); + EXPECT_TRUE(_view.Scene().layers.empty()); + EXPECT_FALSE(_view.SetLayerVisible(second, true)); +} + +TEST_F(MapViewTest, SceneIsReusedUntilTheMapChanges) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const rendering::MapRenderScene& scene = _view.Scene(); + const rendering::Polygon* polygons = scene.layers.front().scene.polygons.data(); + + ASSERT_TRUE(_view.Resize(300, 200)); + EXPECT_EQ(_view.Scene().layers.front().scene.polygons.data(), polygons); + + EXPECT_TRUE(_view.InvalidateScene()); + EXPECT_EQ(&_view.Scene(), &scene); + EXPECT_EQ(scene.layers.size(), 1U); +} + +TEST_F(MapViewTest, ClearingLayersKeepsTheView) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const MapPoint center = *_view.GetViewport().Center(); + + ASSERT_TRUE(_view.ClearLayers()); + + ASSERT_TRUE(_view.GetViewport().HasView()); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_FALSE(_view.FitToMap()); +} + +TEST_F(MapViewTest, InvalidLayerIndexLeavesTheMapUnchanged) +{ + EXPECT_THROW((void)_view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}}), 1), InvalidArgument); + EXPECT_THROW((void)_view.AddLayer(nullptr), InvalidArgument); + EXPECT_EQ(_view.GetMap().LayerCount(), 0); + EXPECT_TRUE(_view.Scene().layers.empty()); +} diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj index 5a561c6..715a8a3 100644 --- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj +++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj @@ -64,7 +64,9 @@ + + diff --git a/tests/MapWinGIS.Core.Tests/RenderingBaselineTests.cpp b/tests/MapWinGIS.Core.Tests/RenderingBaselineTests.cpp index 6bf7b94..0a99809 100644 --- a/tests/MapWinGIS.Core.Tests/RenderingBaselineTests.cpp +++ b/tests/MapWinGIS.Core.Tests/RenderingBaselineTests.cpp @@ -34,7 +34,9 @@ #include "GdiVectorLayerRenderer.h" #include "TestData.h" #include "mapwingis/gdal/GdalEnvironment.h" -#include "mapwingis/rendering/VectorRenderScene.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/map/Viewport.h" +#include "mapwingis/rendering/MapRenderScene.h" #include "mapwingis/vector/VectorDataset.h" using namespace mwg; @@ -229,27 +231,30 @@ TEST_F(RenderingBaseline, MeasuresCoreSceneAndBackendCosts) const std::optional memoryBefore = PrivateMiB(memoryError); ASSERT_TRUE(memoryBefore.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; Clock::time_point start = Clock::now(); - std::unique_ptr dataset = VectorDataset::Open(path); - VectorLayer& layer = dataset->Layer(0); + Map map; + map.AddLayer(VectorDataset::Open(path)); const double openMs = ElapsedMilliseconds(start); start = Clock::now(); - const rendering::VectorRenderScene scene = rendering::CreateVectorRenderScene(layer); + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(map); const double sceneMs = ElapsedMilliseconds(start); const std::optional memoryAfterScene = PrivateMiB(memoryError); ASSERT_TRUE(memoryAfterScene.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; - ASSERT_EQ(scene.polygons.size(), static_cast(featureCount)); + ASSERT_EQ(scene.layers.size(), 1U); + ASSERT_EQ(scene.layers.front().scene.polygons.size(), static_cast(featureCount)); DibSurface surface(kTargetWidth, kTargetHeight); ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, kTargetWidth, kTargetHeight}; + Viewport viewport(kTargetWidth, kTargetHeight); + ASSERT_TRUE(viewport.FitExtent(map.GetExtent(), 12.0)); activex::GdiVectorLayerRenderer gdi; std::vector gdiFrames; for (int frame = 0; frame < kSteadyFrames; ++frame) { start = Clock::now(); - ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); gdiFrames.push_back(ElapsedMilliseconds(start)); } @@ -263,7 +268,7 @@ TEST_F(RenderingBaseline, MeasuresCoreSceneAndBackendCosts) activex::OpenGlVectorLayerRenderer openGl; start = Clock::now(); - const HRESULT firstFrame = openGl.Draw(surface.Get(), bounds, scene); + const HRESULT firstFrame = openGl.Draw(surface.Get(), bounds, scene, viewport); const double firstOpenGlMs = ElapsedMilliseconds(start); if (firstFrame == S_FALSE) { @@ -276,7 +281,7 @@ TEST_F(RenderingBaseline, MeasuresCoreSceneAndBackendCosts) for (int frame = 0; frame < kSteadyFrames; ++frame) { start = Clock::now(); - ASSERT_EQ(openGl.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(openGl.Draw(surface.Get(), bounds, scene, viewport), S_OK); openGlFrames.push_back(ElapsedMilliseconds(start)); } diff --git a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp index b271b58..403acfb 100644 --- a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp @@ -3,13 +3,20 @@ /** * @file VectorLayerRendererTests.cpp * @brief Tests OpenGL rendering and the GDI fallback for vector scenes. + * + * Also verifies that multi-layer scenes are drawn through one shared + * viewport and that a layer that cannot be drawn does not stop the others. */ #include #include +#include #include +#include #include +#include +#include #include "OpenGlVectorLayerRenderer.h" #include "VectorLayerRenderer.h" @@ -83,12 +90,91 @@ namespace HGDIOBJ _previous{}; }; - [[nodiscard]] mwg::rendering::VectorRenderScene MakeConvexPolygonScene() + constexpr double kPadding = 12.0; + constexpr COLORREF kFill = RGB(61, 122, 169); + + /// One polygon layer: a closed axis-aligned rectangle. + [[nodiscard]] mwg::rendering::VectorRenderScene MakeRectangleLayer(const mwg::Extent& rectangle) { return { - mwg::Extent{0.0, 0.0, 10.0, 10.0}, - 0, - {{{{1.0, 1.0}, {9.0, 1.0}, {9.0, 9.0}, {1.0, 9.0}, {1.0, 1.0}}}}}; + rectangle, + 1, + {{{{rectangle.xMin, rectangle.yMin}, + {rectangle.xMax, rectangle.yMin}, + {rectangle.xMax, rectangle.yMax}, + {rectangle.xMin, rectangle.yMax}, + {rectangle.xMin, rectangle.yMin}}}}}; + } + + /// A map scene with these layers, bottom first, with identities 1, 2, ... + [[nodiscard]] mwg::rendering::MapRenderScene MakeMapScene( + std::vector layers) + { + mwg::rendering::MapRenderScene scene; + std::uint64_t id = 0; + for (mwg::rendering::VectorRenderScene& layer : layers) + { + scene.layers.push_back({mwg::LayerId{++id}, std::move(layer)}); + } + return scene; + } + + [[nodiscard]] mwg::rendering::MapRenderScene MakeConvexPolygonScene() + { + return MakeMapScene({MakeRectangleLayer({1.0, 1.0, 9.0, 9.0})}); + } + + /// The first polygon of the first layer, for tests that change geometry. + [[nodiscard]] mwg::rendering::Polygon& FirstPolygon(mwg::rendering::MapRenderScene& scene) + { + return scene.layers.front().scene.polygons.front(); + } + + /// A viewport of the bounds' size fitted to extent, as the control does. + [[nodiscard]] mwg::Viewport FitViewport(const RECT& bounds, const mwg::Extent& extent) + { + mwg::Viewport viewport(bounds.right - bounds.left, bounds.bottom - bounds.top); + EXPECT_TRUE(viewport.FitExtent(extent, kPadding)); + return viewport; + } + + [[nodiscard]] mwg::Viewport FitViewport(const RECT& bounds) + { + return FitViewport(bounds, mwg::Extent{0.0, 0.0, 10.0, 10.0}); + } + + /// Color of the pixel that contains a map point. + [[nodiscard]] COLORREF MapPixel( + const HDC surface, + const mwg::Viewport& viewport, + const double x, + const double y) + { + const auto screen = viewport.MapToScreen({x, y}); + const auto pixel = screen.has_value() ? mwg::ToPixel(*screen) : std::nullopt; + EXPECT_TRUE(pixel.has_value()) << "map point (" << x << ", " << y << ")"; + return pixel.has_value() ? GetPixel(surface, pixel->x, pixel->y) : CLR_INVALID; + } + + /// Two layers with different extents and a gap between them. + [[nodiscard]] mwg::rendering::MapRenderScene MakeTwoLayerScene() + { + return MakeMapScene({ + MakeRectangleLayer({0.0, 0.0, 2.0, 2.0}), + MakeRectangleLayer({8.0, 3.0, 10.0, 5.0})}); + } + + constexpr mwg::Extent kTwoLayerExtent{0.0, 0.0, 10.0, 5.0}; + + /// Checks the pixels of MakeTwoLayerScene drawn through viewport. + void ExpectTwoLayerPixels(const HDC surface, const mwg::Viewport& viewport) + { + const COLORREF background = GetSysColor(COLOR_WINDOW); + EXPECT_EQ(MapPixel(surface, viewport, 1.0, 1.0), kFill) << "first layer"; + EXPECT_EQ(MapPixel(surface, viewport, 9.0, 4.0), kFill) << "second layer"; + EXPECT_EQ(MapPixel(surface, viewport, 5.0, 2.5), background) << "gap"; + EXPECT_EQ(MapPixel(surface, viewport, 1.0, 4.0), background) << "above first layer"; + EXPECT_EQ(MapPixel(surface, viewport, 9.0, 1.0), background) << "below second layer"; } } @@ -99,28 +185,30 @@ TEST(VectorLayerRenderer, UsesOpenGlForConvexPolygonWhenAvailable) ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); const RECT bounds{0, 0, 120, 100}; - const mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + const mwg::Viewport viewport = FitViewport(bounds); // Only an unavailable OpenGL target may skip; drawing, readback or // presentation failures in the backend must fail the test. mwg::activex::OpenGlVectorLayerRenderer openGlRenderer; - const HRESULT openGlResult = openGlRenderer.Draw(surface.Get(), bounds, scene); + const HRESULT openGlResult = openGlRenderer.Draw(surface.Get(), bounds, scene, viewport); if (openGlResult == S_FALSE) { GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; } ASSERT_EQ(openGlResult, S_OK); - EXPECT_EQ(GetPixel(surface.Get(), 60, 50), RGB(61, 122, 169)); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 5, 5), GetSysColor(COLOR_WINDOW)); ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); mwg::activex::VectorLayerRenderer renderer; - ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), S_OK); EXPECT_EQ( renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::OpenGL); EXPECT_EQ(renderer.GetDiagnostic(), L"OpenGL rendered the frame successfully."); - EXPECT_EQ(GetPixel(surface.Get(), 60, 50), RGB(61, 122, 169)); + EXPECT_TRUE(renderer.GetFailedLayers().empty()); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 5, 5), GetSysColor(COLOR_WINDOW)); } @@ -129,10 +217,11 @@ TEST(VectorLayerRenderer, OpenGlRecreatesLostContextOnLaterDraw) BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 120, 100}; - const mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + const mwg::Viewport viewport = FitViewport(bounds); mwg::activex::OpenGlVectorLayerRenderer renderer; - const HRESULT firstResult = renderer.Draw(surface.Get(), bounds, scene); + const HRESULT firstResult = renderer.Draw(surface.Get(), bounds, scene, viewport); if (firstResult == S_FALSE) { GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; @@ -140,14 +229,14 @@ TEST(VectorLayerRenderer, OpenGlRecreatesLostContextOnLaterDraw) ASSERT_EQ(firstResult, S_OK); renderer.SimulateContextLossForTesting(); - EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene), E_FAIL); + EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), E_FAIL); EXPECT_EQ( renderer.GetLastFailureReason(), L"OpenGL could not activate its rendering context."); ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); - ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_OK); - EXPECT_EQ(GetPixel(surface.Get(), 60, 50), RGB(61, 122, 169)); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 5, 5), GetSysColor(COLOR_WINDOW)); } @@ -157,12 +246,12 @@ TEST(VectorLayerRenderer, OpenGlRejectsConcaveCornerHiddenByDuplicateVertex) ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 120, 100}; - mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); - scene.polygons.front().front() = { + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + FirstPolygon(scene).front() = { {0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {5.0, 5.0}, {5.0, 5.0}, {0.0, 10.0}, {0.0, 0.0}}; mwg::activex::OpenGlVectorLayerRenderer renderer; - EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_FALSE); + EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_FALSE); } TEST(VectorLayerRenderer, OpenGlRejectsSelfIntersectingRing) @@ -171,13 +260,34 @@ TEST(VectorLayerRenderer, OpenGlRejectsSelfIntersectingRing) ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 120, 100}; - mwg::rendering::VectorRenderScene scene{ + const mwg::rendering::MapRenderScene scene = MakeMapScene({{ mwg::Extent{-3.0, -3.0, 3.0, 3.0}, 0, - {{{{0.0, 3.0}, {2.0, -3.0}, {-3.0, 1.0}, {3.0, 1.0}, {-2.0, -3.0}, {0.0, 3.0}}}}}; + {{{{0.0, 3.0}, {2.0, -3.0}, {-3.0, 1.0}, {3.0, 1.0}, {-2.0, -3.0}, {0.0, 3.0}}}}}}); mwg::activex::OpenGlVectorLayerRenderer renderer; - EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_FALSE); + EXPECT_EQ( + renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds, {-3.0, -3.0, 3.0, 3.0})), + S_FALSE); +} + +TEST(VectorLayerRenderer, OpenGlRejectsTheFrameWhenAnyLayerIsUnsupported) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); + scene.layers.back().scene.polygons.front().push_back( + {{8.5, 3.5}, {8.5, 4.5}, {9.5, 4.5}, {9.5, 3.5}, {8.5, 3.5}}); + + mwg::activex::OpenGlVectorLayerRenderer renderer; + EXPECT_EQ( + renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds, kTwoLayerExtent)), + S_FALSE); + EXPECT_EQ( + renderer.GetLastFailureReason(), + L"The scene uses geometry that the OpenGL backend does not support."); } TEST(VectorLayerRenderer, FailsWithoutDrawingNonFiniteCoordinates) @@ -187,16 +297,43 @@ TEST(VectorLayerRenderer, FailsWithoutDrawingNonFiniteCoordinates) ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); const RECT bounds{0, 0, 120, 100}; - mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); - scene.polygons.push_back( + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + scene.layers.front().scene.polygons.push_back( {{{1.0, 1.0}, {std::numeric_limits::quiet_NaN(), 5.0}, {9.0, 9.0}, {1.0, 1.0}}}); mwg::activex::VectorLayerRenderer renderer; - EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene), E_INVALIDARG); + EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), E_INVALIDARG); EXPECT_FALSE(renderer.GetActiveBackend().has_value()); + ASSERT_EQ(renderer.GetFailedLayers().size(), 1U); + EXPECT_EQ(renderer.GetFailedLayers().front(), mwg::LayerId{1}); EXPECT_EQ(GetPixel(surface.Get(), 60, 50), RGB(0, 0, 0)); } +TEST(VectorLayerRenderer, SkipsOnlyTheLayerThatCannotBeDrawn) +{ + BitmapSurface surface(200, 120); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 200, 120}; + ASSERT_NE(FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + + mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); + scene.layers.front().scene.polygons.push_back( + {{{0.0, 0.0}, {std::numeric_limits::infinity(), 1.0}, {1.0, 1.0}, {0.0, 0.0}}}); + const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); + + mwg::activex::VectorLayerRenderer renderer; + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), S_OK); + + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); + EXPECT_EQ( + renderer.GetDiagnostic(), + L"GDI could not draw one or more layers; the other layers were drawn."); + ASSERT_EQ(renderer.GetFailedLayers().size(), 1U); + EXPECT_EQ(renderer.GetFailedLayers().front(), mwg::LayerId{1}); + EXPECT_EQ(MapPixel(surface.Get(), viewport, 1.0, 1.0), GetSysColor(COLOR_WINDOW)); + EXPECT_EQ(MapPixel(surface.Get(), viewport, 9.0, 4.0), kFill); +} + TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) { BitmapSurface surface(120, 100); @@ -207,13 +344,13 @@ TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); - mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); - scene.polygons.front().front() = { + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + FirstPolygon(scene).front() = { {0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {0.0, 10.0}, {0.0, 0.0}}; - scene.polygons.front().push_back( + FirstPolygon(scene).push_back( {{3.0, 3.0}, {3.0, 7.0}, {7.0, 7.0}, {7.0, 3.0}, {3.0, 3.0}}); mwg::activex::VectorLayerRenderer renderer; - ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); EXPECT_EQ( renderer.GetActiveBackend(), @@ -221,7 +358,7 @@ TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) EXPECT_EQ( renderer.GetDiagnostic(), L"The scene uses geometry that the OpenGL backend does not support."); - EXPECT_EQ(GetPixel(surface.Get(), 30, 50), RGB(61, 122, 169)); + EXPECT_EQ(GetPixel(surface.Get(), 30, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); } @@ -230,11 +367,11 @@ TEST(VectorLayerRenderer, HonorsCpuPreferenceAndReportsTheSelectedBackend) BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 120, 100}; - const mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); mwg::activex::VectorLayerRenderer renderer; renderer.SetPreference(mwg::activex::VectorRenderBackendPreference::Gdi); - ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); EXPECT_EQ(renderer.GetPreference(), mwg::activex::VectorRenderBackendPreference::Gdi); EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); @@ -246,10 +383,10 @@ TEST(VectorLayerRenderer, FallsBackToCpuWhenOpenGlStagingBudgetIsExceeded) BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 5000, 5000}; - const mwg::rendering::VectorRenderScene scene = MakeConvexPolygonScene(); + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); mwg::activex::VectorLayerRenderer renderer; - ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene), S_OK); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); EXPECT_EQ( @@ -284,80 +421,151 @@ TEST(VectorLayerRenderer, AppliesDetectedFramebufferLimitWithoutRequiringOpenGl) 800).has_value()); } -TEST(VectorLayerRenderer, ComputesExpectedSharedTransform) +TEST(VectorLayerRenderer, SharedTransformMatchesTheCoreViewport) { - const mwg::Extent extent{0.0, 0.0, 10.0, 10.0}; - const auto transform = - mwg::activex::ViewTransform::Create(extent, 120.0, 100.0); - + mwg::Viewport viewport(120, 100); + viewport.SetView({5.0, 5.0}, 0.125); + const auto transform = mwg::activex::ViewTransform::Create(viewport); ASSERT_TRUE(transform.has_value()); - EXPECT_DOUBLE_EQ(transform->scale, 7.6); - EXPECT_DOUBLE_EQ(transform->offsetX, 22.0); - EXPECT_DOUBLE_EQ(transform->offsetY, 12.0); - EXPECT_DOUBLE_EQ(transform->ToDeviceX({0.0, 10.0}), 22.0); - EXPECT_DOUBLE_EQ(transform->ToDeviceY({0.0, 10.0}), 12.0); - EXPECT_DOUBLE_EQ(transform->ToDeviceX({10.0, 0.0}), 98.0); - EXPECT_DOUBLE_EQ(transform->ToDeviceY({10.0, 0.0}), 88.0); + + const mwg::rendering::Point points[] = { + {5.0, 5.0}, {0.0, 10.0}, {10.0, 0.0}, {-3.25, 7.5}, {1.0e6, -1.0e6}}; + for (const mwg::rendering::Point& point : points) + { + const auto expected = viewport.MapToScreen({point.x, point.y}); + ASSERT_TRUE(expected.has_value()); + EXPECT_DOUBLE_EQ(transform->ToDeviceX(point), expected->x); + EXPECT_DOUBLE_EQ(transform->ToDeviceY(point), expected->y); + } + EXPECT_DOUBLE_EQ(transform->ToDeviceX({5.0, 5.0}), 60.0); + EXPECT_DOUBLE_EQ(transform->ToDeviceY({5.0, 5.0}), 50.0); + EXPECT_DOUBLE_EQ(transform->ToDeviceX({0.0, 10.0}), 20.0); + EXPECT_DOUBLE_EQ(transform->ToDeviceY({0.0, 10.0}), 10.0); +} + +TEST(VectorLayerRenderer, SharedTransformRequiresAValidViewport) +{ + EXPECT_FALSE(mwg::activex::ViewTransform::Create(mwg::Viewport{}).has_value()); + EXPECT_FALSE(mwg::activex::ViewTransform::Create(mwg::Viewport(120, 100)).has_value()); + + mwg::Viewport zeroWidth(0, 100); + zeroWidth.SetView({0.0, 0.0}, 1.0); + EXPECT_FALSE(mwg::activex::ViewTransform::Create(zeroWidth).has_value()); +} + +TEST(VectorLayerRenderer, BackendsRejectAViewportThatDoesNotMatchTheBounds) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + const mwg::Viewport otherSize = FitViewport({0, 0, 100, 100}); + + mwg::activex::GdiVectorLayerRenderer gdi; + EXPECT_EQ(gdi.Draw(surface.Get(), bounds, scene, otherSize), E_INVALIDARG); + EXPECT_EQ(gdi.Draw(surface.Get(), bounds, scene, mwg::Viewport(120, 100)), E_INVALIDARG); + + mwg::activex::OpenGlVectorLayerRenderer openGl; + EXPECT_EQ(openGl.Draw(surface.Get(), bounds, scene, otherSize), E_INVALIDARG); + EXPECT_EQ( + openGl.GetLastFailureReason(), + L"The viewport is not valid or does not match the OpenGL target size."); } -TEST(VectorLayerRenderer, GdiAndOpenGlDrawAtTheSharedTransformCoordinates) +TEST(VectorLayerRenderer, DrawsAnEmptySceneAsBackgroundOnly) { - BitmapSurface gdiSurface(120, 100); - BitmapSurface openGlSurface(120, 100); + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + ASSERT_NE(FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + + mwg::activex::GdiVectorLayerRenderer gdi; + EXPECT_EQ(gdi.Draw(surface.Get(), bounds, {}, FitViewport(bounds)), S_OK); + EXPECT_TRUE(gdi.GetFailedLayers().empty()); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); +} + +TEST(VectorLayerRenderer, GdiDrawsLayersWithDifferentExtentsThroughOneViewport) +{ + BitmapSurface surface(200, 120); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 200, 120}; + ASSERT_NE(FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + const mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); + const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); + + mwg::activex::GdiVectorLayerRenderer gdi; + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_TRUE(gdi.GetFailedLayers().empty()); + ExpectTwoLayerPixels(surface.Get(), viewport); + + // Moving the shared view moves both layers by the same amount. + mwg::Viewport moved = viewport; + moved.SetView({viewport.Center()->x + 1.5, viewport.Center()->y - 0.5}, *viewport.Resolution()); + ASSERT_NE(FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, moved), S_OK); + ExpectTwoLayerPixels(surface.Get(), moved); +} + +TEST(VectorLayerRenderer, GdiAndOpenGlDrawMultipleLayersAtTheSameViewportPositions) +{ + BitmapSurface gdiSurface(200, 120); + BitmapSurface openGlSurface(200, 120); ASSERT_TRUE(gdiSurface.IsValid()); ASSERT_TRUE(openGlSurface.IsValid()); - const RECT bounds{0, 0, 120, 100}; - const auto scene = MakeConvexPolygonScene(); - ASSERT_NE( - FillRect(gdiSurface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), - 0); + const RECT bounds{0, 0, 200, 120}; + ASSERT_NE(FillRect(gdiSurface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + const mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); + const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); mwg::activex::GdiVectorLayerRenderer gdiRenderer; - ASSERT_EQ(gdiRenderer.Draw(gdiSurface.Get(), bounds, scene), S_OK); - EXPECT_EQ(GetPixel(gdiSurface.Get(), 60, 50), RGB(61, 122, 169)); - EXPECT_EQ(GetPixel(gdiSurface.Get(), 95, 50), GetSysColor(COLOR_WINDOW)); + ASSERT_EQ(gdiRenderer.Draw(gdiSurface.Get(), bounds, scene, viewport), S_OK); mwg::activex::OpenGlVectorLayerRenderer openGlRenderer; - const HRESULT openGlResult = openGlRenderer.Draw( - openGlSurface.Get(), - bounds, - scene); + const HRESULT openGlResult = + openGlRenderer.Draw(openGlSurface.Get(), bounds, scene, viewport); if (openGlResult == S_FALSE) { GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; } ASSERT_EQ(openGlResult, S_OK); + ExpectTwoLayerPixels(openGlSurface.Get(), viewport); + ExpectTwoLayerPixels(gdiSurface.Get(), viewport); +} - const POINT samplePoints[] = { - {60, 50}, - {85, 50}, - {25, 50}, - {95, 50}}; - for (const POINT point : samplePoints) - { - EXPECT_EQ( - GetPixel(gdiSurface.Get(), point.x, point.y), - GetPixel(openGlSurface.Get(), point.x, point.y)) - << "at (" << point.x << ", " << point.y << ")"; - } +TEST(VectorLayerRenderer, DrawsIntoBoundsWithAnOffsetOrigin) +{ + BitmapSurface surface(220, 140); + ASSERT_TRUE(surface.IsValid()); + const RECT surfaceBounds{0, 0, 220, 140}; + ASSERT_NE(FillRect(surface.Get(), &surfaceBounds, GetSysColorBrush(COLOR_WINDOW)), 0); + const RECT bounds{20, 20, 220, 140}; + const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); + + mwg::activex::GdiVectorLayerRenderer gdi; + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, MakeTwoLayerScene(), viewport), S_OK); + + const auto first = mwg::ToPixel(*viewport.MapToScreen({1.0, 1.0})); + ASSERT_TRUE(first.has_value()); + EXPECT_EQ(GetPixel(surface.Get(), first->x + 20, first->y + 20), kFill); + EXPECT_EQ(GetPixel(surface.Get(), 10, 10), GetSysColor(COLOR_WINDOW)); } -TEST(VectorLayerRenderer, RejectsDegenerateExtentsAndZeroAreaTargets) +TEST(VectorLayerRenderer, DrawsVerySmallAndVeryElongatedTargets) { - EXPECT_FALSE(mwg::activex::ViewTransform::Create( - mwg::Extent{1.0, 0.0, 1.0, 10.0}, - 120.0, - 100.0).has_value()); - EXPECT_FALSE(mwg::activex::ViewTransform::Create( - mwg::Extent{0.0, 1.0, 10.0, 1.0}, - 120.0, - 100.0).has_value()); - EXPECT_FALSE(mwg::activex::ViewTransform::Create( - mwg::Extent{0.0, 0.0, 10.0, 10.0}, - 0.0, - 100.0).has_value()); - EXPECT_FALSE(mwg::activex::ViewTransform::Create( - mwg::Extent{0.0, 0.0, 10.0, 10.0}, - 120.0, - 0.0).has_value()); + BitmapSurface surface(2000, 400); + ASSERT_TRUE(surface.IsValid()); + const mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); + const RECT targets[] = {{0, 0, 1, 1}, {0, 0, 2000, 3}, {0, 0, 3, 400}}; + for (const RECT& bounds : targets) + { + const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); + mwg::activex::GdiVectorLayerRenderer gdi; + EXPECT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK) + << bounds.right << " x " << bounds.bottom; + + mwg::activex::VectorLayerRenderer renderer; + EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), S_OK) + << bounds.right << " x " << bounds.bottom; + } } From 57186bf38e77256cd18cfcb1a41a3776843a51ef Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 10:28:07 +0100 Subject: [PATCH 2/3] Add programmatic map navigation to the Core map view Add Pan, Zoom, and anchored ZoomAt to Viewport, and ZoomToLayer, ZoomIn, ZoomOut, ZoomAt, and Pan to MapView on top of the existing FitToMap. Every operation returns whether the view changed, clamps to the viewport limits, and handles empty maps, missing layers, and invalid extents without changing the view. A pending fit can target the map or one layer, and navigation that changes the view cancels it. Co-authored-by: Junie --- docs/ARCHITECTURE.md | 27 +- docs/TESTING.md | 4 + .../include/mapwingis/map/MapView.h | 45 +++ .../include/mapwingis/map/Viewport.h | 32 +++ src/MapWinGIS.Core/src/map/MapView.cpp | 109 +++++-- src/MapWinGIS.Core/src/map/Viewport.cpp | 80 ++++++ tests/MapWinGIS.Core.Tests/MapViewTests.cpp | 265 +++++++++++++++++- tests/MapWinGIS.Core.Tests/ViewportTests.cpp | 166 ++++++++++- 8 files changed, 704 insertions(+), 24 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58149ba..b262242 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -169,6 +169,15 @@ take snapshots. boundary geometry outside the viewport. A zero-span axis does not constrain the resolution; a single point keeps the current resolution. Invalid extents and zero-sized viewports return `false` without changing the view. +- **Pan and zoom.** `Pan(dx, dy)` shifts the visible extent by a map-unit + delta, so content moves the opposite way on screen. `Zoom(factor)` divides + the resolution by `factor` around the center (above 1 zooms in). + `ZoomAt(anchor, factor)` does the same around a screen position and keeps + the map coordinate under it in place, which is what cursor zoom needs. All + three take plain numbers, not input events, return whether the view + changed, and clamp to the limits below; at a limit they return `false`. + Non-finite deltas, anchors, and non-positive factors throw + `InvalidArgument`. - **Limits.** Sizes are limited to `kMaxDimension` pixels. Centers are clamped to `±kMaxCoordinate` and resolutions to `[MinResolution(center), kMaxResolution]`, where the minimum is relative to the center's magnitude so @@ -202,6 +211,15 @@ has no extent or transform of its own. requested while the viewport has no size, for example before the host window exists or while it is minimized, is done by the first `Resize` that gives it a size. +- **Navigation.** `FitToMap` (zoom to map: visible layers only, with + padding), `ZoomToLayer` (by `LayerId`, also for hidden layers), `ZoomIn` + and `ZoomOut` (center zoom by a factor above 1, default + `kDefaultZoomFactor`), `ZoomAt`, and `Pan` change the shared viewport and + return whether a redraw is needed. Empty maps, missing layers, and layers + without a valid extent return `false` and keep the view. The latest request + wins: a new fit replaces a pending one, and a navigation step that changes + the view cancels it. None of this depends on COM, mouse messages, or a + renderer; Phase 5 maps input onto these calls. ## ActiveX and rendering architecture @@ -335,7 +353,10 @@ Windows CPU fallback; Core has no independent platform-neutral renderer. The C# host's pixel assertions, activation, and native tests pass for Debug and Release on Win32 and x64 in an interactive desktop session. -The next milestone is navigation on the shared `MapView`: zoom to map and -layer, zoom in and out, and pan. Measure scene preparation with representative -datasets before adding caching, paging, or broader OpenGL geometry support. +Core navigation on the shared `MapView` (zoom to map and layer, zoom in and +out, anchored zoom, and pan) is implemented but not yet exposed through COM or +mouse input; that is the next milestone. Deep zoom can push a layer past the +GDI coordinate range until clipping exists. Measure scene preparation with +representative datasets before adding caching, paging, or broader OpenGL +geometry support. OpenCL and full MapWinGIS 5 API parity remain deferred. diff --git a/docs/TESTING.md b/docs/TESTING.md index dd35833..7376568 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -197,6 +197,10 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. resizing, and scene invalidation; and `VectorLayerRendererTests.cpp` draws layers with different extents into memory bitmaps and samples the pixels at `Viewport::MapToScreen` positions, for GDI and, when available, OpenGL. +- Navigation is tested without input events: `ViewportTests.cpp` covers the + pan, center-zoom, and anchored-zoom calculations and their limits, and + `MapViewTests.cpp` covers zoom to map and layer, zoom in and out, empty and + missing-layer cases, and pending-fit handling on `MapView`. - Cover for each feature: happy path, empty/missing input, out-of-range indexes, null values, non-ASCII paths, write-then-reopen round-trips, and error cases. diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h index 0213745..8e44078 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -38,6 +38,11 @@ namespace mwg /// the viewport a size, so data loaded before a host window has a size is /// fitted once the window appears. /// + /// Navigation: FitToMap, ZoomToLayer, ZoomIn, ZoomOut, ZoomAt, and Pan + /// change the viewport; they depend on no window, input, or renderer. + /// A later fit replaces a pending fit, and a navigation operation that + /// changes the view cancels it, so the latest request wins. + /// /// Not thread-safe; see Map. class MWG_CORE_API MapView { @@ -45,6 +50,9 @@ namespace mwg /// Padding, in pixels, used by FitToMap when none is given. static constexpr double kDefaultPadding = 12.0; + /// Zoom factor used by ZoomIn and ZoomOut when none is given. + static constexpr double kDefaultZoomFactor = 2.0; + MapView(); ~MapView(); @@ -85,6 +93,40 @@ namespace mwg /// \throws InvalidArgument when paddingPixels is negative or not finite. bool FitToMap(double paddingPixels = kDefaultPadding); + /// Fits the viewport to the extent of one layer with paddingPixels + /// free on every side; see Viewport::FitExtent. The layer is fitted + /// even when it is hidden, because the caller named it explicitly. + /// Returns true when the view was fitted. When the viewport has no + /// size yet, the fit is kept pending for Resize and false is + /// returned. Returns false and leaves the view unchanged when the map + /// has no such layer or the layer has no valid extent. + /// \throws InvalidArgument when paddingPixels is negative or not finite. + /// \throws CoreError when the layer extent cannot be read. + bool ZoomToLayer(LayerId id, double paddingPixels = kDefaultPadding); + + /// Zooms in around the viewport center: the resolution is divided by + /// factor; see Viewport::Zoom. Returns true when the view changed; + /// false without a view or at the resolution limit. + /// \throws InvalidArgument when factor is not finite or not above 1. + bool ZoomIn(double factor = kDefaultZoomFactor); + + /// Zooms out around the viewport center: the resolution is + /// multiplied by factor; see Viewport::Zoom. Returns true when the + /// view changed; false without a view or at the resolution limit. + /// \throws InvalidArgument when factor is not finite or not above 1. + bool ZoomOut(double factor = kDefaultZoomFactor); + + /// Zooms around a screen position, keeping the map coordinate under + /// it in place; see Viewport::ZoomAt. A factor above 1 zooms in and + /// below 1 zooms out. Returns true when the view changed. + /// \throws InvalidArgument as Viewport::ZoomAt does. + bool ZoomAt(const ScreenPoint& anchor, double factor); + + /// Moves the view by a map-coordinate delta; see Viewport::Pan. + /// Returns true when the view changed. + /// \throws InvalidArgument as Viewport::Pan does. + bool Pan(double deltaX, double deltaY); + /// The render scene of the current map, rebuilt first when the map /// changed since the last call; see rendering::CreateMapRenderScene. /// The reference stays valid for the lifetime of the MapView; a later @@ -97,6 +139,9 @@ namespace mwg bool InvalidateScene() noexcept; private: + /// Clears a pending fit when changed is true and returns changed. + bool ViewChanged(bool changed) noexcept; + class Impl; std::unique_ptr _impl; }; diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h index b6bbc42..767e9bc 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h @@ -164,6 +164,34 @@ namespace mwg /// \throws InvalidArgument when paddingPixels is negative or not finite. bool FitExtent(const Extent& extent, double paddingPixels = 0.0); + /// Moves the view by a map-coordinate delta: the visible extent + /// shifts by (deltaX, deltaY), so positive deltaX shows content + /// further right (east) and positive deltaY content further up + /// (north). The center is clamped to the limits. Returns true when + /// the view changed; false without a view or for a zero delta. + /// The size is not needed, so a zero-sized viewport can be panned. + /// \throws InvalidArgument when a delta is not finite. + bool Pan(double deltaX, double deltaY); + + /// Zooms around the viewport center: the resolution is divided by + /// factor, so a factor above 1 zooms in and below 1 zooms out. The + /// center is kept and the resolution is clamped to the limits. + /// Returns true when the view changed; false without a view or when + /// the resolution is already at the limit in that direction. + /// \throws InvalidArgument when factor is not a finite positive number. + bool Zoom(double factor); + + /// Zooms around a screen position: the resolution is divided by + /// factor as in Zoom, and the map coordinate under anchor stays + /// under anchor. The anchor may lie outside the viewport. When the + /// limits clamp the result, the resolution or center is clamped and + /// the anchored coordinate may move. Returns true when the view + /// changed; false when the viewport is not valid or the view is + /// already at the limit. + /// \throws InvalidArgument when factor is not a finite positive number + /// or an anchor coordinate is not finite. + bool ZoomAt(const ScreenPoint& anchor, double factor); + /// Map extent covered by the whole viewport, or an empty Extent when /// the viewport is not valid. [[nodiscard]] Extent VisibleExtent() const noexcept; @@ -179,6 +207,10 @@ namespace mwg [[nodiscard]] std::optional ScreenToMap(const ScreenPoint& point) const noexcept; private: + /// Applies a clamped view and returns true when it differs from the + /// current one. + bool ApplyView(const MapPoint& center, double resolution) noexcept; + int _width = 0; int _height = 0; bool _hasView = false; diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp index f6ac29b..daa19ea 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -6,22 +6,65 @@ */ #include "mapwingis/map/MapView.h" +#include #include #include +#include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" namespace mwg { + namespace + { + void ValidateStepFactor(const double factor) + { + if (!std::isfinite(factor) || factor <= 1.0) + { + throw InvalidArgument("Zoom factor must be a finite number above 1"); + } + } + } + class MapView::Impl { public: + /// A fit that waits for the viewport to get a size. + struct PendingFit + { + /// The layer to fit, or std::nullopt to fit the whole map. + std::optional layer; + double padding = 0.0; + }; + + /// Fits extent and returns true, or keeps the fit pending when only + /// the viewport size prevents it. + bool Fit(const Extent& extent, const PendingFit& request) + { + if (_viewport.FitExtent(extent, request.padding)) + { + _pendingFit.reset(); + return true; + } + + // FitExtent fails for an invalid extent or a zero-sized viewport; + // only the second can succeed later without another request. + if (extent.IsValid()) + { + _pendingFit = request; + } + else + { + _pendingFit.reset(); + } + return false; + } + Map _map; Viewport _viewport; rendering::MapRenderScene _scene; bool _sceneStale = false; - /// Padding of a fit that waits for the viewport to get a size. - std::optional _pendingFitPadding; + std::optional _pendingFit; }; MapView::MapView() @@ -101,33 +144,67 @@ namespace mwg viewport.SetSize(width, height); bool fitted = false; - if (_impl->_pendingFitPadding.has_value() && width > 0 && height > 0) + if (_impl->_pendingFit.has_value() && width > 0 && height > 0) { - fitted = FitToMap(*_impl->_pendingFitPadding); + // Cleared first, so a layer removed in the meantime does not + // leave the fit pending forever. + const Impl::PendingFit pending = *_impl->_pendingFit; + _impl->_pendingFit.reset(); + fitted = pending.layer.has_value() + ? ZoomToLayer(*pending.layer, pending.padding) + : FitToMap(pending.padding); } return sizeChanged || fitted; } bool MapView::FitToMap(const double paddingPixels) { - const Extent extent = _impl->_map.GetExtent(); - if (_impl->_viewport.FitExtent(extent, paddingPixels)) + return _impl->Fit(_impl->_map.GetExtent(), {std::nullopt, paddingPixels}); + } + + bool MapView::ZoomToLayer(const LayerId id, const double paddingPixels) + { + if (!std::isfinite(paddingPixels) || paddingPixels < 0.0) { - _impl->_pendingFitPadding.reset(); - return true; + throw InvalidArgument("Viewport padding must be a finite non-negative number"); } - - // FitExtent fails for an invalid extent or a zero-sized viewport; - // only the second can succeed later without another request. - if (extent.IsValid()) + const MapLayer* layer = _impl->_map.FindLayer(id); + if (layer == nullptr) { - _impl->_pendingFitPadding = paddingPixels; + return false; } - else + return _impl->Fit(layer->GetExtent(), {id, paddingPixels}); + } + + bool MapView::ZoomIn(const double factor) + { + ValidateStepFactor(factor); + return ViewChanged(_impl->_viewport.Zoom(factor)); + } + + bool MapView::ZoomOut(const double factor) + { + ValidateStepFactor(factor); + return ViewChanged(_impl->_viewport.Zoom(1.0 / factor)); + } + + bool MapView::ZoomAt(const ScreenPoint& anchor, const double factor) + { + return ViewChanged(_impl->_viewport.ZoomAt(anchor, factor)); + } + + bool MapView::Pan(const double deltaX, const double deltaY) + { + return ViewChanged(_impl->_viewport.Pan(deltaX, deltaY)); + } + + bool MapView::ViewChanged(const bool changed) noexcept + { + if (changed) { - _impl->_pendingFitPadding.reset(); + _impl->_pendingFit.reset(); } - return false; + return changed; } const rendering::MapRenderScene& MapView::Scene() diff --git a/src/MapWinGIS.Core/src/map/Viewport.cpp b/src/MapWinGIS.Core/src/map/Viewport.cpp index 8d2fc07..b174fc5 100644 --- a/src/MapWinGIS.Core/src/map/Viewport.cpp +++ b/src/MapWinGIS.Core/src/map/Viewport.cpp @@ -40,6 +40,14 @@ namespace mwg return std::clamp(resolution, Viewport::MinResolution(center), Viewport::kMaxResolution); } + void ValidateZoomFactor(const double factor) + { + if (!std::isfinite(factor) || factor <= 0.0) + { + throw InvalidArgument("Viewport zoom factor must be a finite positive number"); + } + } + // Pixels available for the extent on one axis. The extent edges map // to the centers of the outermost pixels inside the padding, which // removes one pixel from the span. At least half a pixel remains, so @@ -170,6 +178,78 @@ namespace mwg return true; } + bool Viewport::Pan(const double deltaX, const double deltaY) + { + if (!IsFinite(deltaX, deltaY)) + { + throw InvalidArgument("Viewport pan delta must be finite"); + } + if (!_hasView) + { + return false; + } + // The center is at most kMaxCoordinate, so a finite delta can only + // overflow to infinity, which the clamp maps back to the limit. + return ApplyView({_center.x + deltaX, _center.y + deltaY}, _resolution); + } + + bool Viewport::Zoom(const double factor) + { + ValidateZoomFactor(factor); + if (!_hasView) + { + return false; + } + return ApplyView(_center, _resolution / factor); + } + + bool Viewport::ZoomAt(const ScreenPoint& anchor, const double factor) + { + ValidateZoomFactor(factor); + if (!IsFinite(anchor.x, anchor.y)) + { + throw InvalidArgument("Viewport zoom anchor must be finite"); + } + const std::optional anchored = ScreenToMap(anchor); + if (!anchored.has_value()) + { + return false; + } + + // Resolve the resolution first, then place the center so that the + // anchored map point lands on the anchor at the new resolution. + const double resolution = ClampResolution(_resolution / factor, _center); + if (resolution == _resolution) + { + // At the limit: recomputing the center would only add rounding. + return false; + } + const double offsetX = anchor.x - static_cast(_width) / 2.0; + const double offsetY = anchor.y - static_cast(_height) / 2.0; + const MapPoint center{ + anchored->x - offsetX * resolution, + anchored->y + offsetY * resolution}; + if (!IsFinite(center.x, center.y)) + { + return false; + } + return ApplyView(center, resolution); + } + + bool Viewport::ApplyView(const MapPoint& center, const double resolution) noexcept + { + const MapPoint clampedCenter = ClampCenter(center); + const double clampedResolution = ClampResolution(resolution, clampedCenter); + if (clampedCenter.x == _center.x && clampedCenter.y == _center.y && + clampedResolution == _resolution) + { + return false; + } + _center = clampedCenter; + _resolution = clampedResolution; + return true; + } + Extent Viewport::VisibleExtent() const noexcept { if (!IsValid()) diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index e0b04c9..7703b5c 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -2,14 +2,17 @@ /** * @file MapViewTests.cpp - * @brief Tests the map view: shared viewport, scene invalidation, and resizing. + * @brief Tests the map view: shared viewport, scene invalidation, resizing, + * and programmatic navigation. * * Verifies that layers with different extents are shown through one * viewport, that map changes rebuild the render scene and report a redraw, - * and that resizing keeps the view stable without a window or ActiveX. + * that resizing keeps the view stable, and that zoom-to-map, zoom-to-layer, + * zoom in and out, anchored zoom, and pan work without a window or ActiveX. */ #include +#include #include #include #include @@ -245,6 +248,264 @@ TEST_F(MapViewTest, ClearingLayersKeepsTheView) EXPECT_FALSE(_view.FitToMap()); } +TEST_F(MapViewTest, ZoomToMapWithOneLayerCentersAndFitsIt) +{ + ASSERT_TRUE(_view.Resize(201, 101)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.ZoomIn(8.0)); + ASSERT_TRUE(_view.Pan(100.0, 100.0)); + + ASSERT_TRUE(_view.FitToMap(0.0)); + + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 5.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 2.5, kTolerance); + // 10 x 5 map units over 200 x 100 pixel-center spans: both axes fit exactly. + EXPECT_NEAR(*viewport.Resolution(), 0.05, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMapWithMultipleLayersShowsAllOfThem) +{ + ASSERT_TRUE(_view.Resize(300, 300)); + const Extent first{0.0, 0.0, 10.0, 10.0}; + const Extent second{-50.0, 20.0, -40.0, 30.0}; + const Extent third{200.0, -100.0, 210.0, -90.0}; + _view.AddLayer(OpenRectangles({first})); + _view.AddLayer(OpenRectangles({second})); + _view.AddLayer(OpenRectangles({third})); + + ASSERT_TRUE(_view.FitToMap()); + + const Viewport& viewport = _view.GetViewport(); + for (const Extent& extent : {first, second, third}) + { + EXPECT_TRUE(IsVisible(viewport, extent)); + } + EXPECT_NEAR(viewport.Center()->x, 80.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, -35.0, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMapOnAnEmptyMapKeepsTheView) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.GetViewport().HasView()); + + const LayerId id = _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.SetLayerVisible(id, false)); + const MapPoint center = *_view.GetViewport().Center(); + + // Only hidden layers: no visible extent, so the view stays. + EXPECT_FALSE(_view.FitToMap()); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); +} + +TEST_F(MapViewTest, ZoomToLayerFitsLargeAndSmallLayers) +{ + ASSERT_TRUE(_view.Resize(400, 200)); + const Extent large{-1000.0, -500.0, 1000.0, 500.0}; + const Extent small{10.0, 10.0, 10.5, 10.25}; + const LayerId largeId = _view.AddLayer(OpenRectangles({large})); + const LayerId smallId = _view.AddLayer(OpenRectangles({small})); + + ASSERT_TRUE(_view.ZoomToLayer(smallId, 0.0)); + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 10.25, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 10.125, kTolerance); + EXPECT_TRUE(IsVisible(viewport, small)); + EXPECT_FALSE(IsVisible(viewport, large)); + const double smallResolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomToLayer(largeId, 0.0)); + EXPECT_NEAR(viewport.Center()->x, 0.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 0.0, kTolerance); + EXPECT_TRUE(IsVisible(viewport, large)); + EXPECT_NEAR(*viewport.Resolution() / smallResolution, 4000.0, 1e-6); +} + +TEST_F(MapViewTest, ZoomToLayerAppliesPaddingAndFitsHiddenLayers) +{ + ASSERT_TRUE(_view.Resize(201, 201)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId hidden = _view.AddLayer(OpenRectangles({{100.0, 100.0, 120.0, 120.0}})); + ASSERT_TRUE(_view.SetLayerVisible(hidden, false)); + + ASSERT_TRUE(_view.ZoomToLayer(hidden, 50.0)); + + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 110.0, kTolerance); + // 20 map units over 200 - 2 * 50 pixels between the outer pixel centers. + EXPECT_NEAR(*viewport.Resolution(), 0.2, kTolerance); + EXPECT_THROW((void)_view.ZoomToLayer(hidden, -1.0), InvalidArgument); +} + +TEST_F(MapViewTest, ZoomToMissingOrEmptyLayerKeepsTheView) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + const LayerId id = _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const LayerId empty = _view.AddLayer(OpenRectangles({})); + const MapPoint center = *_view.GetViewport().Center(); + const double resolution = *_view.GetViewport().Resolution(); + + EXPECT_FALSE(_view.ZoomToLayer(empty)); + ASSERT_TRUE(_view.RemoveLayer(id)); + EXPECT_FALSE(_view.ZoomToLayer(id)); + EXPECT_FALSE(_view.ZoomToLayer(LayerId{})); + + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->y, center.y); + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); +} + +TEST_F(MapViewTest, ZoomToLayerBeforeTheViewportHasASizeHappensOnFirstResize) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 100.0, 100.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + + EXPECT_FALSE(_view.ZoomToLayer(target, 0.0)); + ASSERT_TRUE(_view.Resize(101, 101)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 50.5, kTolerance); + EXPECT_NEAR(*_view.GetViewport().Resolution(), 0.01, kTolerance); +} + +TEST_F(MapViewTest, PendingZoomToRemovedLayerIsDropped) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + EXPECT_FALSE(_view.ZoomToLayer(target)); + ASSERT_TRUE(_view.RemoveLayer(target)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + // The removed layer cannot be fitted, and no fit stays pending. + EXPECT_FALSE(_view.GetViewport().HasView()); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.GetViewport().HasView()); +} + +TEST_F(MapViewTest, ZoomInAndOutUseTheFactorAndKeepTheCenter) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const MapPoint center = *viewport.Center(); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomIn()); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / MapView::kDefaultZoomFactor); + ASSERT_TRUE(_view.ZoomIn(4.0)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0); + ASSERT_TRUE(_view.ZoomOut(1.5)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0 * 1.5); + ASSERT_TRUE(_view.ZoomOut()); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0 * 1.5 * 2.0); + + EXPECT_DOUBLE_EQ(viewport.Center()->x, center.x); + EXPECT_DOUBLE_EQ(viewport.Center()->y, center.y); +} + +TEST_F(MapViewTest, ZoomInAndOutRejectFactorsThatDoNotZoom) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const double resolution = *_view.GetViewport().Resolution(); + + for (const double factor : {1.0, 0.5, 0.0, -2.0, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) + { + EXPECT_THROW((void)_view.ZoomIn(factor), InvalidArgument) << factor; + EXPECT_THROW((void)_view.ZoomOut(factor), InvalidArgument) << factor; + } + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); +} + +TEST_F(MapViewTest, ZoomInStopsAtTheLimitAndReportsNoRedraw) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + + int steps = 0; + while (_view.ZoomIn(1.0e6)) + { + ASSERT_LT(++steps, 100); + } + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), + Viewport::MinResolution(*_view.GetViewport().Center())); + EXPECT_FALSE(_view.ZoomIn()); + EXPECT_TRUE(_view.ZoomOut()); +} + +TEST_F(MapViewTest, NavigationWithoutAViewReportsNoChange) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.ZoomIn()); + EXPECT_FALSE(_view.ZoomOut()); + EXPECT_FALSE(_view.ZoomAt({10.0, 10.0}, 2.0)); + EXPECT_FALSE(_view.Pan(1.0, 1.0)); + EXPECT_FALSE(_view.GetViewport().HasView()); +} + +TEST_F(MapViewTest, ZoomAtKeepsTheMapCoordinateUnderTheCursor) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const ScreenPoint cursor{37.5, 81.25}; + const MapPoint before = *viewport.ScreenToMap(cursor); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomAt(cursor, 2.0)); + EXPECT_NEAR(*viewport.Resolution(), resolution / 2.0, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->x, before.x, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->y, before.y, kTolerance); + + ASSERT_TRUE(_view.ZoomAt(cursor, 0.25)); + EXPECT_NEAR(*viewport.Resolution(), resolution * 2.0, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->x, before.x, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->y, before.y, kTolerance); +} + +TEST_F(MapViewTest, PanMovesTheViewByTheMapDelta) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const Extent before = viewport.VisibleExtent(); + const ScreenPoint origin = *viewport.MapToScreen({0.0, 0.0}); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.Pan(-3.0, 2.0)); + + const Extent after = viewport.VisibleExtent(); + EXPECT_NEAR(after.xMin, before.xMin - 3.0, kTolerance); + EXPECT_NEAR(after.yMax, before.yMax + 2.0, kTolerance); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution); + // Panning west moves geometry right; panning north moves it down. + const ScreenPoint moved = *viewport.MapToScreen({0.0, 0.0}); + EXPECT_NEAR(moved.x - origin.x, 3.0 / resolution, 1e-6); + EXPECT_NEAR(moved.y - origin.y, 2.0 / resolution, 1e-6); + EXPECT_FALSE(_view.Pan(0.0, 0.0)); + EXPECT_THROW((void)_view.Pan(std::numeric_limits::quiet_NaN(), 0.0), InvalidArgument); +} + +TEST_F(MapViewTest, NavigationCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.Pan(1.0, 0.0)); + const MapPoint center = *_view.GetViewport().Center(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_FALSE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); +} + TEST_F(MapViewTest, InvalidLayerIndexLeavesTheMapUnchanged) { EXPECT_THROW((void)_view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}}), 1), InvalidArgument); diff --git a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp index 0eb24ef..e2d2d2d 100644 --- a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp @@ -2,11 +2,12 @@ /** * @file ViewportTests.cpp - * @brief Tests viewport coordinate transformations and extent fitting. + * @brief Tests viewport coordinate transformations, extent fitting, and + * pan and zoom calculations. * * Verifies map-to-screen and screen-to-map conversions, aspect-preserving - * extent fitting with padding, limits, and the handling of zero-sized - * viewports, degenerate extents, and non-finite inputs. + * extent fitting with padding, pan, center and anchored zoom, limits, and the + * handling of zero-sized viewports, degenerate extents, and non-finite inputs. */ #include @@ -530,6 +531,165 @@ TEST(ToPixel, RoundsDownToTheContainingPixel) EXPECT_EQ(negative->y, -1); } +TEST(Viewport, PanShiftsTheVisibleExtentByTheMapDelta) +{ + Viewport viewport = MakeFittedViewport(); + const Extent before = viewport.VisibleExtent(); + + ASSERT_TRUE(viewport.Pan(10.0, -5.0)); + + ExpectMap(viewport.Center(), 60, 20); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectExtent(viewport.VisibleExtent(), + {before.xMin + 10.0, before.yMin - 5.0, before.xMax + 10.0, before.yMax - 5.0}); + // Content moves the opposite way on screen: a pan east moves geometry + // left, a pan south moves geometry up. + ExpectScreen(viewport.MapToScreen({50, 25}), 40.5, 20.5); +} + +TEST(Viewport, PanWithoutViewOrWithZeroDeltaChangesNothing) +{ + Viewport noView(100, 100); + EXPECT_FALSE(noView.Pan(1.0, 1.0)); + EXPECT_FALSE(noView.HasView()); + + Viewport viewport = MakeFittedViewport(); + EXPECT_FALSE(viewport.Pan(0.0, 0.0)); + ExpectMap(viewport.Center(), 50, 25); +} + +TEST(Viewport, PanWorksOnAZeroSizedViewport) +{ + Viewport viewport = MakeFittedViewport(); + viewport.SetSize(0, 0); + + ASSERT_TRUE(viewport.Pan(-50.0, 0.0)); + + ExpectMap(viewport.Center(), 0, 25); +} + +TEST(Viewport, PanRejectsNonFiniteDeltasAndClampsTheCenter) +{ + Viewport viewport = MakeFittedViewport(); + EXPECT_THROW((void)viewport.Pan(kNaN, 0.0), InvalidArgument); + EXPECT_THROW((void)viewport.Pan(0.0, kInfinity), InvalidArgument); + ExpectMap(viewport.Center(), 50, 25); + + ASSERT_TRUE(viewport.Pan(std::numeric_limits::max(), -std::numeric_limits::max())); + EXPECT_DOUBLE_EQ(viewport.Center()->x, Viewport::kMaxCoordinate); + EXPECT_DOUBLE_EQ(viewport.Center()->y, -Viewport::kMaxCoordinate); + EXPECT_FALSE(viewport.Pan(1.0e6, -1.0e6)); +} + +TEST(Viewport, ZoomDividesTheResolutionAndKeepsTheCenter) +{ + Viewport viewport = MakeFittedViewport(); + + ASSERT_TRUE(viewport.Zoom(2.0)); + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 0.5); + EXPECT_NEAR(viewport.VisibleExtent().Width(), 50.5, kTolerance); + + ASSERT_TRUE(viewport.Zoom(0.25)); + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 2.0); +} + +TEST(Viewport, ZoomInThenOutRestoresTheView) +{ + Viewport viewport = MakeFittedViewport(); + const Extent before = viewport.VisibleExtent(); + + ASSERT_TRUE(viewport.Zoom(4.0)); + ASSERT_TRUE(viewport.Zoom(0.25)); + + ExpectExtent(viewport.VisibleExtent(), before); +} + +TEST(Viewport, ZoomRejectsInvalidFactors) +{ + Viewport viewport = MakeFittedViewport(); + for (const double factor : {0.0, -2.0, kNaN, kInfinity}) + { + EXPECT_THROW((void)viewport.Zoom(factor), InvalidArgument) << factor; + EXPECT_THROW((void)viewport.ZoomAt({0, 0}, factor), InvalidArgument) << factor; + } + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); +} + +TEST(Viewport, ZoomStopsAtTheResolutionLimits) +{ + Viewport viewport = MakeFittedViewport(); + const MapPoint center = *viewport.Center(); + + ASSERT_TRUE(viewport.Zoom(1.0e300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(center)); + EXPECT_FALSE(viewport.Zoom(2.0)); + + ASSERT_TRUE(viewport.Zoom(1.0e-300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution); + EXPECT_FALSE(viewport.Zoom(0.5)); + ExpectMap(viewport.Center(), center.x, center.y); +} + +TEST(Viewport, ZoomWithoutViewChangesNothing) +{ + Viewport viewport(100, 100); + EXPECT_FALSE(viewport.Zoom(2.0)); + EXPECT_FALSE(viewport.ZoomAt({10, 10}, 2.0)); + EXPECT_FALSE(viewport.HasView()); +} + +TEST(Viewport, ZoomAtKeepsTheMapCoordinateUnderTheAnchor) +{ + const ScreenPoint anchors[] = {{0.0, 0.0}, {100.25, 3.75}, {50.5, 25.5}, {-30.0, 200.0}}; + for (const ScreenPoint& anchor : anchors) + { + for (const double factor : {2.0, 0.5, 3.3}) + { + Viewport viewport = MakeFittedViewport(); + const std::optional before = viewport.ScreenToMap(anchor); + ASSERT_TRUE(before.has_value()); + + ASSERT_TRUE(viewport.ZoomAt(anchor, factor)); + + EXPECT_NEAR(*viewport.Resolution(), 1.0 / factor, kTolerance); + ExpectMap(viewport.ScreenToMap(anchor), before->x, before->y); + ExpectScreen(viewport.MapToScreen(*before), anchor.x, anchor.y); + } + } +} + +TEST(Viewport, ZoomAtTheCenterMatchesCenterZoom) +{ + Viewport anchored = MakeFittedViewport(); + Viewport centered = MakeFittedViewport(); + + ASSERT_TRUE(anchored.ZoomAt({50.5, 25.5}, 2.0)); + ASSERT_TRUE(centered.Zoom(2.0)); + + ExpectExtent(anchored.VisibleExtent(), centered.VisibleExtent()); +} + +TEST(Viewport, ZoomAtNeedsAValidViewportAndAFiniteAnchor) +{ + Viewport viewport = MakeFittedViewport(); + EXPECT_THROW((void)viewport.ZoomAt({kNaN, 0}, 2.0), InvalidArgument); + EXPECT_THROW((void)viewport.ZoomAt({0, -kInfinity}, 2.0), InvalidArgument); + + viewport.SetSize(0, 10); + EXPECT_FALSE(viewport.ZoomAt({0, 0}, 2.0)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); +} + +TEST(Viewport, ZoomAtStopsAtTheResolutionLimit) +{ + Viewport viewport = MakeFittedViewport(); + ASSERT_TRUE(viewport.ZoomAt({0, 0}, 1.0e300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + EXPECT_FALSE(viewport.ZoomAt({0, 0}, 2.0)); +} + TEST(ToPixel, RejectsNonFiniteAndOutOfRangeCoordinates) { EXPECT_FALSE(ToPixel({kNaN, 0}).has_value()); From fe1bb1b4e068c76806b43ee3038bedfb317e17f5 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 17:39:04 +0100 Subject: [PATCH 3/3] Fix navigation review findings and expand pending-fit tests Cancel deferred fits for missing layers, document resize error state and anchored zoom limits, and multiply zoom-out resolution directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 + docs/ARCHITECTURE.md | 14 +- docs/TESTING.md | 8 +- .../include/mapwingis/map/MapView.h | 9 +- .../include/mapwingis/map/Viewport.h | 16 ++- src/MapWinGIS.Core/src/map/MapView.cpp | 3 +- src/MapWinGIS.Core/src/map/Viewport.cpp | 10 ++ tests/MapWinGIS.Core.Tests/MapViewTests.cpp | 124 ++++++++++++++++++ tests/MapWinGIS.Core.Tests/ViewportTests.cpp | 40 ++++++ 9 files changed, 221 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 627542a..c195cf0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ and GeoPackage. The ActiveX layer supports COM activation, vector loading, polygon rendering, and asynchronous generic GDAL algorithms with progress and cancellation events. +The core also supports a shared map viewport with pan, center zoom, anchored +zoom, and fitting to the visible map or a named layer. These navigation calls +do not depend on a window. ActiveX mouse navigation remains future work. + Vector loads add layers rather than replace them. Call `ClearLayers` to release the loaded datasets before switching or refreshing them. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7366ee7..7052ea7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -175,6 +175,12 @@ take snapshots. to `±kMaxCoordinate` and resolutions to `[MinResolution(center), kMaxResolution]`, where the minimum is relative to the center's magnitude so that pixels stay many representable doubles wide. +- **Navigation.** `Pan` moves the center in map units. `Zoom` divides the + resolution by its factor, and `ZoomOut` multiplies it directly. `ZoomAt` + keeps the map coordinate under a screen anchor unless the limits intervene. + Moving the center can raise `MinResolution` and change the resolution + again, so the anchored coordinate can move even without center clamping. + `ZoomAt` needs a valid viewport. Center zoom and pan only need a view. ## Map view and multi-layer rendering @@ -206,6 +212,9 @@ has no extent or transform of its own. requested while the viewport has no size, for example before the host window exists or while it is minimized, is done by the first `Resize` that gives it a size. + If reading the pending extent throws, `Resize` propagates the error after + applying the size and discarding the pending fit. The center and resolution + stay unchanged. The host must request a redraw after this error. - **Navigation.** `FitToMap` (zoom to map: visible layers only, with padding), `ZoomToLayer` (by `LayerId`, also for hidden layers), `ZoomIn` and `ZoomOut` (center zoom by a factor above 1, default @@ -213,7 +222,10 @@ has no extent or transform of its own. return whether a redraw is needed. Empty maps, missing layers, and layers without a valid extent return `false` and keep the view. The latest request wins: a new fit replaces a pending one, and a navigation step that changes - the view cancels it. None of this depends on COM, mouse messages, or a + the view cancels it. A missing layer or an invalid extent also cancels a + pending fit. Navigation that returns `false` keeps it, including `ZoomAt` + on a zero-sized viewport and center zoom at a resolution limit. + None of this depends on COM, mouse messages, or a renderer; Phase 5 maps input onto these calls. ## ActiveX and rendering architecture diff --git a/docs/TESTING.md b/docs/TESTING.md index 09ef533..5349ccf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -210,7 +210,13 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. - Navigation is tested without input events: `ViewportTests.cpp` covers the pan, center-zoom, and anchored-zoom calculations and their limits, and `MapViewTests.cpp` covers zoom to map and layer, zoom in and out, empty and - missing-layer cases, and pending-fit handling on `MapView`. + missing-layer cases, and pending-fit handling on `MapView`. Pending-fit + tests cover replacement in both directions between map and layer fits, + cancellation by pan and center zoom, and cancellation by missing or empty + layers. They also cover unchanged navigation at a resolution limit and + `ZoomAt` on a zero-sized viewport, which keep the pending fit. + Viewport tests cover anchor movement when the new center raises the + minimum resolution, and direct zoom-out multiplication without a reciprocal. - Cover for each feature: happy path, empty/missing input, out-of-range indexes, null values, non-ASCII paths, write-then-reopen round-trips, and error cases. diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h index 8e44078..edf4e62 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -82,7 +82,11 @@ namespace mwg /// the map neither moves nor changes scale; see Viewport::SetSize. /// Completes a pending fit once both dimensions are positive. /// Returns true when the size or view changed. + /// If reading the pending fit extent throws, the new size stays + /// applied, the center and resolution stay unchanged, and the + /// pending fit is discarded. The host must redraw after this error. /// \throws InvalidArgument as Viewport::SetSize does. + /// \throws CoreError when the pending fit extent cannot be read. bool Resize(int width, int height); /// Fits the viewport to the visible map extent with paddingPixels @@ -90,7 +94,9 @@ namespace mwg /// view was fitted. When the viewport has no size yet, the fit is /// kept pending for Resize and false is returned. A map without a /// valid visible extent returns false and leaves the view unchanged. + /// A map without a valid visible extent also cancels a pending fit. /// \throws InvalidArgument when paddingPixels is negative or not finite. + /// \throws CoreError when a visible layer extent cannot be read. bool FitToMap(double paddingPixels = kDefaultPadding); /// Fits the viewport to the extent of one layer with paddingPixels @@ -100,6 +106,7 @@ namespace mwg /// size yet, the fit is kept pending for Resize and false is /// returned. Returns false and leaves the view unchanged when the map /// has no such layer or the layer has no valid extent. + /// These cases also cancel a pending fit. /// \throws InvalidArgument when paddingPixels is negative or not finite. /// \throws CoreError when the layer extent cannot be read. bool ZoomToLayer(LayerId id, double paddingPixels = kDefaultPadding); @@ -111,7 +118,7 @@ namespace mwg bool ZoomIn(double factor = kDefaultZoomFactor); /// Zooms out around the viewport center: the resolution is - /// multiplied by factor; see Viewport::Zoom. Returns true when the + /// multiplied by factor; see Viewport::ZoomOut. Returns true when the /// view changed; false without a view or at the resolution limit. /// \throws InvalidArgument when factor is not finite or not above 1. bool ZoomOut(double factor = kDefaultZoomFactor); diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h index 767e9bc..0299636 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h @@ -181,13 +181,23 @@ namespace mwg /// \throws InvalidArgument when factor is not a finite positive number. bool Zoom(double factor); + /// Zooms around the viewport center by multiplying the resolution + /// by factor. A factor above 1 zooms out and below 1 zooms in. + /// Keeps the center and clamps the resolution to the limits. + /// Returns true when the view changed; false without a view or at + /// the resolution limit in that direction. + /// \throws InvalidArgument when factor is not a finite positive number. + bool ZoomOut(double factor); + /// Zooms around a screen position: the resolution is divided by /// factor as in Zoom, and the map coordinate under anchor stays /// under anchor. The anchor may lie outside the viewport. When the /// limits clamp the result, the resolution or center is clamped and - /// the anchored coordinate may move. Returns true when the view - /// changed; false when the viewport is not valid or the view is - /// already at the limit. + /// the anchored coordinate may move. Moving the center can increase + /// MinResolution, which clamps the resolution again and can also + /// move the anchored coordinate. Returns true when the view changed; + /// false when the viewport is not valid or the view is already at + /// the limit. /// \throws InvalidArgument when factor is not a finite positive number /// or an anchor coordinate is not finite. bool ZoomAt(const ScreenPoint& anchor, double factor); diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp index daa19ea..f36b8d6 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -171,6 +171,7 @@ namespace mwg const MapLayer* layer = _impl->_map.FindLayer(id); if (layer == nullptr) { + _impl->_pendingFit.reset(); return false; } return _impl->Fit(layer->GetExtent(), {id, paddingPixels}); @@ -185,7 +186,7 @@ namespace mwg bool MapView::ZoomOut(const double factor) { ValidateStepFactor(factor); - return ViewChanged(_impl->_viewport.Zoom(1.0 / factor)); + return ViewChanged(_impl->_viewport.ZoomOut(factor)); } bool MapView::ZoomAt(const ScreenPoint& anchor, const double factor) diff --git a/src/MapWinGIS.Core/src/map/Viewport.cpp b/src/MapWinGIS.Core/src/map/Viewport.cpp index b174fc5..50e7594 100644 --- a/src/MapWinGIS.Core/src/map/Viewport.cpp +++ b/src/MapWinGIS.Core/src/map/Viewport.cpp @@ -203,6 +203,16 @@ namespace mwg return ApplyView(_center, _resolution / factor); } + bool Viewport::ZoomOut(const double factor) + { + ValidateZoomFactor(factor); + if (!_hasView) + { + return false; + } + return ApplyView(_center, _resolution * factor); + } + bool Viewport::ZoomAt(const ScreenPoint& anchor, const double factor) { ValidateZoomFactor(factor); diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index 8347239..71647e4 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -387,6 +387,7 @@ TEST_F(MapViewTest, ZoomToLayerBeforeTheViewportHasASizeHappensOnFirstResize) _view.AddLayer(OpenRectangles({{0.0, 0.0, 100.0, 100.0}})); const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + EXPECT_FALSE(_view.FitToMap(20.0)); EXPECT_FALSE(_view.ZoomToLayer(target, 0.0)); ASSERT_TRUE(_view.Resize(101, 101)); @@ -394,6 +395,46 @@ TEST_F(MapViewTest, ZoomToLayerBeforeTheViewportHasASizeHappensOnFirstResize) EXPECT_NEAR(*_view.GetViewport().Resolution(), 0.01, kTolerance); } +TEST_F(MapViewTest, FitToMapReplacesAPendingZoomToLayer) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 100.0, 100.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + EXPECT_FALSE(_view.ZoomToLayer(target, 20.0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + + ASSERT_TRUE(_view.Resize(101, 101)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 50.0, kTolerance); + EXPECT_NEAR(_view.GetViewport().Center()->y, 50.0, kTolerance); + EXPECT_NEAR(*_view.GetViewport().Resolution(), 1.0, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMissingLayerCancelsAPendingMapFit) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId removed = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + ASSERT_TRUE(_view.RemoveLayer(removed)); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.ZoomToLayer(removed)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_FALSE(_view.GetViewport().HasView()); + EXPECT_FALSE(_view.Resize(200, 100)); +} + +TEST_F(MapViewTest, ZoomToEmptyLayerCancelsAPendingMapFit) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId empty = _view.AddLayer(OpenRectangles({})); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.ZoomToLayer(empty)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_FALSE(_view.GetViewport().HasView()); +} + TEST_F(MapViewTest, PendingZoomToRemovedLayerIsDropped) { _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); @@ -446,6 +487,18 @@ TEST_F(MapViewTest, ZoomInAndOutRejectFactorsThatDoNotZoom) EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); } +TEST_F(MapViewTest, ZoomOutMultipliesWithoutTakingAReciprocal) +{ + ASSERT_TRUE(_view.Resize(101, 101)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 30.0, 30.0}})); + ASSERT_TRUE(_view.FitToMap(0.0)); + const double resolution = *_view.GetViewport().Resolution(); + + ASSERT_TRUE(_view.ZoomOut(3.0)); + + EXPECT_EQ(*_view.GetViewport().Resolution(), resolution * 3.0); +} + TEST_F(MapViewTest, ZoomInStopsAtTheLimitAndReportsNoRedraw) { ASSERT_TRUE(_view.Resize(200, 100)); @@ -531,6 +584,77 @@ TEST_F(MapViewTest, NavigationCancelsAPendingFit) EXPECT_FALSE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); } +TEST_F(MapViewTest, ZoomInCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.ZoomIn()); + const Viewport before = _view.GetViewport(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); +} + +TEST_F(MapViewTest, ZoomOutCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.ZoomOut()); + const Viewport before = _view.GetViewport(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); +} + +TEST_F(MapViewTest, ZoomAtOnAZeroSizedViewportKeepsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + const Viewport before = _view.GetViewport(); + + EXPECT_FALSE(_view.ZoomAt({37.5, 81.25}, 2.0)); + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 55.0, kTolerance); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); +} + +TEST_F(MapViewTest, CenterZoomAtAResolutionLimitKeepsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.ZoomOut(1.0e300)); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + + EXPECT_FALSE(_view.ZoomOut()); + EXPECT_EQ(*_view.GetViewport().Resolution(), Viewport::kMaxResolution); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 55.0, kTolerance); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); + EXPECT_LT(*_view.GetViewport().Resolution(), Viewport::kMaxResolution); +} + TEST_F(MapViewTest, InvalidLayerIndexLeavesTheMapUnchanged) { EXPECT_THROW((void)_view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}}), 1), InvalidArgument); diff --git a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp index e2d2d2d..361e31e 100644 --- a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp @@ -612,6 +612,7 @@ TEST(Viewport, ZoomRejectsInvalidFactors) for (const double factor : {0.0, -2.0, kNaN, kInfinity}) { EXPECT_THROW((void)viewport.Zoom(factor), InvalidArgument) << factor; + EXPECT_THROW((void)viewport.ZoomOut(factor), InvalidArgument) << factor; EXPECT_THROW((void)viewport.ZoomAt({0, 0}, factor), InvalidArgument) << factor; } EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); @@ -636,10 +637,31 @@ TEST(Viewport, ZoomWithoutViewChangesNothing) { Viewport viewport(100, 100); EXPECT_FALSE(viewport.Zoom(2.0)); + EXPECT_FALSE(viewport.ZoomOut(2.0)); EXPECT_FALSE(viewport.ZoomAt({10, 10}, 2.0)); EXPECT_FALSE(viewport.HasView()); } +TEST(Viewport, ZoomOutMultipliesTheResolutionAndClampsOverflow) +{ + Viewport viewport(100, 100); + viewport.SetView({5.0, 10.0}, 0.3); + + ASSERT_TRUE(viewport.ZoomOut(3.0)); + EXPECT_EQ(*viewport.Resolution(), 0.3 * 3.0); + ExpectMap(viewport.Center(), 5.0, 10.0); + + ASSERT_TRUE(viewport.ZoomOut(0.5)); + EXPECT_EQ(*viewport.Resolution(), 0.3 * 3.0 * 0.5); + ASSERT_TRUE(viewport.ZoomOut(std::numeric_limits::max())); + EXPECT_EQ(*viewport.Resolution(), Viewport::kMaxResolution); + EXPECT_FALSE(viewport.ZoomOut(2.0)); + + ASSERT_TRUE(viewport.ZoomOut(1.0e-300)); + EXPECT_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + EXPECT_FALSE(viewport.ZoomOut(0.5)); +} + TEST(Viewport, ZoomAtKeepsTheMapCoordinateUnderTheAnchor) { const ScreenPoint anchors[] = {{0.0, 0.0}, {100.25, 3.75}, {50.5, 25.5}, {-30.0, 200.0}}; @@ -690,6 +712,24 @@ TEST(Viewport, ZoomAtStopsAtTheResolutionLimit) EXPECT_FALSE(viewport.ZoomAt({0, 0}, 2.0)); } +TEST(Viewport, ZoomAtCanMoveTheAnchorWhenTheNewCenterRaisesTheMinimumResolution) +{ + Viewport viewport(Viewport::kMaxDimension, 100); + viewport.SetView({Viewport::kMaxCoordinate - 1.0e8, 0.0}, 200.0); + const ScreenPoint anchor{static_cast(Viewport::kMaxDimension), 50.0}; + const MapPoint before = *viewport.ScreenToMap(anchor); + const double oldMinimum = Viewport::MinResolution(*viewport.Center()); + + ASSERT_TRUE(viewport.ZoomAt(anchor, 3.0)); + + EXPECT_LT(viewport.Center()->x, Viewport::kMaxCoordinate); + EXPECT_GT(*viewport.Resolution(), oldMinimum); + EXPECT_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + const MapPoint after = *viewport.ScreenToMap(anchor); + EXPECT_GT(after.x - before.x, 1.0); + EXPECT_EQ(after.y, before.y); +} + TEST(ToPixel, RejectsNonFiniteAndOutOfRangeCoordinates) { EXPECT_FALSE(ToPixel({kNaN, 0}).has_value());