diff --git a/README.md b/README.md index 7a631b1..627542a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ and GeoPackage. The ActiveX layer supports COM activation, vector loading, polygon rendering, and asynchronous generic GDAL algorithms with progress and cancellation events. +Vector loads add layers rather than replace them. Call `ClearLayers` to +release the loaded datasets before switching or refreshing them. + ## Build and test ### Prerequisites diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11237e5..58c215c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -166,14 +166,47 @@ take snapshots. resolution that shows it on both axes, so the other axis expands. Padding is kept free on every side. Extent edges map to the centers of the outermost pixels inside the padding, so pixel rounding never pushes - boundary geometry outside the viewport. A zero-span axis does not constrain - the resolution; a single point keeps the current resolution. Invalid + boundary geometry outside the viewport. If padding does not fit, the extent + spans half a pixel around the center instead. A zero-span axis does not constrain + the resolution. A single point keeps the current resolution, or uses + `kDefaultPointResolution` when no view exists. Invalid extents and zero-sized viewports return `false` without changing the view. - **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 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. + This includes `std::exception` errors except `std::bad_alloc`, which propagates. + Exceptions outside that hierarchy also propagate. 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 +223,35 @@ 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. For windowless hosts, `SetObjectRects` supplies the live size. +`OnDraw` uses a viewport copy sized to the drawing target. Printing, metafile +output, and scaled draws do not change the live viewport or its pending fit. +If no view exists yet, drawing fits only that copy to the visible map extent. +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,16 +263,17 @@ 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 backend first and fall back to GDI when the scene, hardware, allocation, or drawing operation is unsupported. The control reports the active backend and the reason for a fallback through `RenderingBackend` and `RenderingStatus`. -These rendering properties are on `IMapWinGISControl2`, the coclass default -interface, which derives from `IMapWinGISControl`; the original interface and -its IID are left unchanged so clients built against it keep working. +These rendering properties are on `IMapWinGISControl2`, which derives from +`IMapWinGISControl`. The coclass default interface is now `IMapWinGISControl3`, +which derives from `IMapWinGISControl2` and adds `ClearLayers` at DISPID 8. +The earlier interfaces and their IIDs remain unchanged. OpenGL capability detection checks framebuffer-object entry points, the maximum renderbuffer size, and the maximum viewport dimensions at runtime; targets exceeding either limit use GDI. CPU staging for OpenGL readback is @@ -270,15 +316,30 @@ 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. +This includes repeated loads of the same dataset. Call `ClearLayers` before +switching or refreshing datasets to avoid duplicate layers. It releases all +loaded datasets and clears the feature total, but keeps the view. +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. The control reads +and validates the candidate layer before adding it, so a rejected load cannot +change the live viewport or schedule a fit. +`LayerLoaded` reports whether the map has any layer, and `FeatureCount` is +the cached total over all loaded layers. The control updates the total after +a successful load and resets it on `ClearLayers`. Property reads do not scan +datasets or rebuild the scene. 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 +349,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/PORTING.md b/docs/PORTING.md index 50aab01..a0aa150 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -52,13 +52,18 @@ The initial `MapWinGIS.ocx` exposes a deliberately small v6 automation contract. `IMapWinGISControl` reports Core initialization, loads a vector dataset, and reports whether a layer is loaded and its feature count. `IMapWinGISControl2` adds renderer preference, active backend, and status. +`IMapWinGISControl3` adds `ClearLayers` and is the default automation interface. +Loads add layers, including duplicate loads of one dataset. Call `ClearLayers` +before switching or refreshing datasets. It releases all layers and resets +the cached feature total while keeping the viewport. New members belong on a new derived interface; do not change a published interface IID or silently add vtable members to it. These are new interfaces, not the MapWinGIS 5 type-library interfaces, and the control does not claim binary or API compatibility with existing v5 clients. -Core scene creation selects only the first vector layer and currently prepares -Polygon/MultiPolygon scene data; the control requests that scene from Core. It +Each control load selects the first dataset layer. Core prepares an ordered +scene for all visible map layers, including Polygon, MultiPolygon, +CurvePolygon, and MultiSurface geometry. It does not expose the broad v5 map, layer, shape, event, or editing API. It draws in source coordinates without reprojection. OpenGL only handles convex single-ring polygons; GDI handles the supported polygon scene as the CPU diff --git a/docs/TESTING.md b/docs/TESTING.md index bcc9824..9906686 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -189,8 +189,24 @@ 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. +- Native `ActiveXMapTest` cases activate the real OCX through its class factory + without registration. They cover stacked loads, rejected loads, cached + feature totals, dataset release through `ClearLayers`, `WM_SIZE`, and draws + into alternate-sized targets that must not change the live fitting size. +- Renderer regressions compare edge-adjacent pixels at quarter- and + three-quarter-pixel rectangle coordinates for GDI and OpenGL. Overlapping + layers make drawing order visible: the top fill covers the bottom outline. + Map view tests fit point and zero-span extents, and tiny targets with default + padding. Map scene tests isolate standard geometry-read exceptions, reuse + the resulting scene, and let allocation failures propagate. - 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/MapWinGIS.ActiveX.idl b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl index b185472..4493e4e 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl @@ -57,6 +57,19 @@ library MapWinGISActiveXLib HRESULT RenderingBackendPreference([in] BSTR preference); }; + [ + object, + dual, + oleautomation, + uuid(994F4526-284B-463F-8ED5-1551B601981B), + pointer_default(unique) + ] + interface IMapWinGISControl3 : IMapWinGISControl2 + { + [id(8)] + HRESULT ClearLayers(); + }; + [ object, dual, @@ -111,7 +124,7 @@ library MapWinGISActiveXLib ] coclass MapWinGISControl { - [default] interface IMapWinGISControl2; + [default] interface IMapWinGISControl3; interface IMapWinGISControlGdal; [default, source] dispinterface IMapWinGISControlEvents; }; diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index f1ef8cb..6e3a5ad 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,18 +314,53 @@ 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"); + } + + // Validate before AddLayer can auto-fit or schedule a pending fit. + mwg::Map candidate; + candidate.AddLayer(dataset); + const mwg::rendering::MapRenderScene scene = + mwg::rendering::CreateMapRenderScene(candidate); + for (const mwg::rendering::SkippedLayer& skipped : scene.skippedLayers) + { + if (skipped.reason == mwg::rendering::LayerSkipReason::Failed) + { + throw mwg::CoreError(skipped.message); + } + } + const LONGLONG count = scene.layers.empty() + ? static_cast(dataset->Layer(0).FeatureCount()) + : static_cast(scene.layers.front().scene.sourceFeatureCount); + if (count < 0 || count > std::numeric_limits::max() - _featureCount) + { + throw mwg::CoreError("Map feature count is out of range"); + } + + const mwg::LayerId id = _view.AddLayer(std::move(dataset)); + try + { + // 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; + } + + _featureCount += count; FireViewChange(); return S_OK; } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } catch (const std::exception& exception) { const CA2W message(exception.what(), CP_UTF8); @@ -330,6 +372,16 @@ STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) } } +STDMETHODIMP CMapWinGISControl::ClearLayers() +{ + if (_view.ClearLayers()) + { + _featureCount = 0; + FireViewChange(); + } + return S_OK; +} + STDMETHODIMP CMapWinGISControl::get_LayerLoaded(VARIANT_BOOL* loaded) { if (loaded == nullptr) @@ -337,7 +389,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; } @@ -407,14 +459,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; } @@ -464,6 +534,28 @@ STDMETHODIMP CMapWinGISControl::Close(const DWORD saveOption) return ATL::IOleObjectImpl::Close(saveOption); } +STDMETHODIMP CMapWinGISControl::SetObjectRects(LPCRECT position, LPCRECT clip) +{ + if (position == nullptr || clip == nullptr) + { + return E_POINTER; + } + const HRESULT result = + ATL::IOleInPlaceObjectWindowlessImpl::SetObjectRects(position, clip); + if (SUCCEEDED(result) && m_bWndLess) + { + const LONGLONG width = static_cast(position->right) - position->left; + const LONGLONG height = static_cast(position->bottom) - position->top; + if (ResizeView( + static_cast(std::clamp(width, 0, mwg::Viewport::kMaxDimension)), + static_cast(std::clamp(height, 0, mwg::Viewport::kMaxDimension)))) + { + FireViewChange(); + } + } + return result; +} + HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) { if (drawInfo.hdcDraw == nullptr || drawInfo.prcBounds == nullptr) @@ -485,9 +577,38 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) return E_FAIL; } - return _scene.has_value() - ? _renderer.Draw(drawInfo.hdcDraw, bounds, *_scene) - : S_OK; + 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; + } + try + { + const mwg::rendering::MapRenderScene& scene = _view.Scene(); + // Printing and scaled draws use a snapshot, never the live viewport. + mwg::Viewport viewport = _view.GetViewport(); + viewport.SetSize(static_cast(width), static_cast(height)); + if (!viewport.HasView()) + { + (void)viewport.FitExtent(_view.GetMap().GetExtent(), mwg::MapView::kDefaultPadding); + } + 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..0b75bba 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" @@ -39,8 +38,8 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, public ATL::IDispatchImpl< - IMapWinGISControl2, - &IID_IMapWinGISControl2, + IMapWinGISControl3, + &IID_IMapWinGISControl3, &LIBID_MapWinGISActiveXLib, 1, 0>, @@ -59,11 +58,12 @@ class ATL_NO_VTABLE CMapWinGISControl : DECLARE_PROTECT_FINAL_CONSTRUCT() BEGIN_COM_MAP(CMapWinGISControl) + COM_INTERFACE_ENTRY(IMapWinGISControl3) COM_INTERFACE_ENTRY(IMapWinGISControl2) COM_INTERFACE_ENTRY(IMapWinGISControl) COM_INTERFACE_ENTRY(IMapWinGISControlGdal) COM_INTERFACE_ENTRY(IConnectionPointContainer) - COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl2) + COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl3) COM_INTERFACE_ENTRY(IViewObjectEx) COM_INTERFACE_ENTRY(IViewObject2) COM_INTERFACE_ENTRY(IViewObject) @@ -94,6 +94,7 @@ class ATL_NO_VTABLE CMapWinGISControl : STDMETHOD(get_CoreInitialized)(VARIANT_BOOL* initialized) override; STDMETHOD(LoadVectorDataset)(BSTR path) override; + STDMETHOD(ClearLayers)() override; STDMETHOD(get_LayerLoaded)(VARIANT_BOOL* loaded) override; STDMETHOD(get_FeatureCount)(LONGLONG* count) override; STDMETHOD(get_RenderingBackend)(BSTR* backend) override; @@ -116,6 +117,7 @@ class ATL_NO_VTABLE CMapWinGISControl : BSTR* diagnostics, SAFEARRAY** warnings) override; STDMETHOD(Close)(DWORD saveOption) override; + STDMETHOD(SetObjectRects)(LPCRECT position, LPCRECT clip) override; LRESULT OnSize(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnGdalDispatch(UINT, WPARAM, LPARAM, BOOL& handled); @@ -125,11 +127,14 @@ 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; + LONGLONG _featureCount = 0; 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..ddb2dbe --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h @@ -0,0 +1,78 @@ +// 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. Other std::exception + /// errors from individual layers are reported in skippedLayers. + /// Exceptions not derived from std::exception propagate. + [[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..593a563 --- /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; + } + + // Valid extents, including points and lines, fit any positive size. + // Padding that cannot fit is reduced by Viewport::FitExtent. + 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..7080f9c --- /dev/null +++ b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp @@ -0,0 +1,53 @@ +// 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 +#include + +#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 std::bad_alloc&) + { + throw; + } + catch (const std::exception& 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/ActiveXActivationTests.cpp b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp index a850714..4c1153b 100644 --- a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include #include @@ -30,6 +32,7 @@ #include "TestData.h" #include "mapwingis/gdal/GdalAlgorithmRunner.h" #include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/map/Viewport.h" namespace { @@ -747,6 +750,153 @@ namespace HMODULE _handle; }; + class ActiveXMapTest : public testing::Test + { + protected: + void SetUp() override + { + ASSERT_TRUE(SUCCEEDED(_apartment.Result())); + wchar_t executablePath[MAX_PATH]{}; + const DWORD length = GetModuleFileNameW(nullptr, executablePath, MAX_PATH); + ASSERT_GT(length, 0U); + ASSERT_LT(length, static_cast(MAX_PATH)); + _module = std::make_unique(LoadLibraryW( + (std::filesystem::path(executablePath).parent_path() / L"MapWinGIS.ocx").c_str())); + ASSERT_NE(_module->Get(), nullptr); + using GetClassObject = HRESULT(STDAPICALLTYPE*)(REFCLSID, REFIID, LPVOID*); + const auto getClassObject = reinterpret_cast( + GetProcAddress(_module->Get(), "DllGetClassObject")); + ASSERT_NE(getClassObject, nullptr); + ComPtr factory; + ASSERT_EQ(getClassObject(__uuidof(MapWinGISControl), IID_PPV_ARGS(&factory)), S_OK); + ASSERT_EQ(factory->CreateInstance(nullptr, IID_PPV_ARGS(&_control)), S_OK); + ASSERT_EQ(_control.As(&_window), S_OK); + ASSERT_EQ(_control.As(&_viewObject), S_OK); + ASSERT_EQ(_control->put_RenderingBackendPreference(ATL::CComBSTR(L"GDI")), S_OK); + } + + HRESULT LoadRectangles(const std::vector& rectangles) + { + const auto path = mwg::test::MakeRectanglesDataset( + _directory.Path(), "layer-" + std::to_string(_datasetCount++) + ".gpkg", rectangles); + return _control->LoadVectorDataset(ATL::CComBSTR(path.c_str())); + } + + HRESULT LoadLine() + { + const auto path = _directory.Path() / "line.geojson"; + std::ofstream output(path); + output << R"({"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"LineString","coordinates":[[1000,1000],[1010,1010]]}}]})"; + output.close(); + if (!output) + { + return E_FAIL; + } + return _control->LoadVectorDataset(ATL::CComBSTR(path.c_str())); + } + + HRESULT Resize(const int width, const int height) + { + LRESULT result = 0; + return _window->OnWindowMessage(WM_SIZE, 0, MAKELPARAM(width, height), &result); + } + + HRESULT Draw(const HDC target, const int width, const int height) + { + const RECTL bounds{0, 0, width, height}; + return _viewObject->Draw( + DVASPECT_CONTENT, -1, nullptr, nullptr, nullptr, target, + &bounds, nullptr, nullptr, 0); + } + + ComApartment _apartment; + std::unique_ptr _module; + mwg::test::TempDirectory _directory; + ComPtr _control; + ComPtr _window; + ComPtr _viewObject; + int _datasetCount = 0; + }; + +} + +TEST_F(ActiveXMapTest, LoadsStackAndClearReleasesTheDatasets) +{ + ASSERT_EQ(LoadRectangles({{0, 0, 2, 2}, {3, 0, 5, 2}}), S_OK); + ASSERT_EQ(LoadRectangles({{10, 0, 12, 2}}), S_OK); + LONGLONG count = -1; + for (int read = 0; read < 3; ++read) + { + ASSERT_EQ(_control->get_FeatureCount(&count), S_OK); + EXPECT_EQ(count, 3); + } + EXPECT_EQ(_control->get_FeatureCount(nullptr), E_POINTER); + ASSERT_EQ(_control->ClearLayers(), S_OK); + ASSERT_EQ(_control->ClearLayers(), S_OK); + VARIANT_BOOL loaded = VARIANT_TRUE; + ASSERT_EQ(_control->get_LayerLoaded(&loaded), S_OK); + EXPECT_EQ(loaded, VARIANT_FALSE); + ASSERT_EQ(_control->get_FeatureCount(&count), S_OK); + EXPECT_EQ(count, 0); + // Windows refuses an exclusive file open while GDAL still holds it. + const auto path = _directory.Path() / "layer-0.gpkg"; + const HANDLE file = CreateFileW( + path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + ASSERT_NE(file, INVALID_HANDLE_VALUE); + CloseHandle(file); + ASSERT_EQ(LoadRectangles({{0, 0, 2, 2}}), S_OK); + ASSERT_EQ(_control->get_FeatureCount(&count), S_OK); + EXPECT_EQ(count, 1); +} + +TEST_F(ActiveXMapTest, RejectedLoadsLeaveTheMapAndRenderingUnchanged) +{ + EXPECT_EQ(LoadLine(), E_FAIL); + VARIANT_BOOL loaded = VARIANT_TRUE; + ASSERT_EQ(_control->get_LayerLoaded(&loaded), S_OK); + EXPECT_EQ(loaded, VARIANT_FALSE); + ASSERT_EQ(Resize(120, 100), S_OK); + ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); + BitmapSurface before(120, 100); + BitmapSurface after(120, 100); + ASSERT_TRUE(before.IsValid()); + ASSERT_TRUE(after.IsValid()); + ASSERT_EQ(Draw(before.Get(), 120, 100), S_OK); + EXPECT_EQ(LoadLine(), E_FAIL); + LONGLONG count = -1; + ASSERT_EQ(_control->get_FeatureCount(&count), S_OK); + EXPECT_EQ(count, 1); + ASSERT_EQ(Draw(after.Get(), 120, 100), S_OK); + for (int y = 0; y < 100; ++y) + { + for (int x = 0; x < 120; ++x) + { + ASSERT_EQ(GetPixel(before.Get(), x, y), GetPixel(after.Get(), x, y)) << x << ", " << y; + } + } +} + +TEST_F(ActiveXMapTest, AlternateDrawBoundsDoNotChangeTheLiveSizeUsedForFitting) +{ + ASSERT_EQ(Resize(120, 100), S_OK); + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + ASSERT_EQ(Draw(surface.Get(), 24, 18), S_OK); + ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); + ASSERT_EQ(Draw(surface.Get(), 120, 100), S_OK); + mwg::Viewport expected(120, 100); + ASSERT_TRUE(expected.FitExtent({0, 0, 10, 5}, 12.0)); + const auto sample = mwg::ToPixel(*expected.MapToScreen({1, 2.5})); + ASSERT_TRUE(sample.has_value()); + EXPECT_EQ(GetPixel(surface.Get(), sample->x, sample->y), RGB(61, 122, 169)); + ASSERT_EQ(Resize(200, 120), S_OK); + BitmapSurface resized(200, 120); + ASSERT_TRUE(resized.IsValid()); + ASSERT_EQ(Draw(resized.Get(), 200, 120), S_OK); + expected.SetSize(200, 120); + const auto moved = mwg::ToPixel(*expected.MapToScreen({1, 2.5})); + ASSERT_TRUE(moved.has_value()); + EXPECT_EQ(GetPixel(resized.Get(), moved->x, moved->y), RGB(61, 122, 169)); } TEST(ActiveXActivation, CreatesControlThroughClassFactory) diff --git a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp new file mode 100644 index 0000000..d7d1c7d --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp @@ -0,0 +1,308 @@ +// 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 +#include + +#include "TestData.h" +#include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/map/Map.h" +#include "mapwingis/map/MapView.h" +#include "mapwingis/rendering/MapRenderScene.h" +#include "mapwingis/vector/VectorDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + class ThrowingLayer final : public OGRLayer + { + public: + explicit ThrowingLayer(const bool allocationFailure) + : _allocationFailure(allocationFailure), + _definition(OGRFeatureDefn::CreateFeatureDefn("throwing")) + { + _definition->Reference(); + _definition->SetGeomType(wkbPolygon); + } + + ~ThrowingLayer() override { _definition->Release(); } + void ResetReading() override {} + const OGRFeatureDefn* GetLayerDefn() const override { return _definition; } + int TestCapability(const char*) const override { return FALSE; } + GIntBig GetFeatureCount(int) override { return 1; } + + OGRFeature* GetNextFeature() override + { + if (_allocationFailure) + { + throw std::bad_alloc(); + } + throw std::runtime_error("Simulated geometry read exception"); + } + + protected: + OGRErr IGetExtent(int, OGREnvelope* extent, bool) override + { + extent->MinX = 0.0; + extent->MinY = 0.0; + extent->MaxX = 1.0; + extent->MaxY = 1.0; + return OGRERR_NONE; + } + + private: + bool _allocationFailure; + OGRFeatureDefn* _definition; + }; + + class ThrowingDataset final : public GDALDataset + { + public: + explicit ThrowingDataset(const bool allocationFailure) + : _layer(allocationFailure) + { + } + int GetLayerCount() const override { return 1; } + const OGRLayer* GetLayer(const int index) const override + { + return index == 0 ? &_layer : nullptr; + } + + private: + ThrowingLayer _layer; + }; + + /// 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, IsolatesStandardExceptionsButPropagatesAllocationFailures) +{ + GdalEnvironment::Initialize(); + const auto deregister = [](GDALDriver* driver) + { + GetGDALDriverManager()->DeregisterDriver(driver); + delete driver; + }; + const std::unique_ptr driver{ + std::make_unique().release(), deregister}; + driver->SetDescription("MWGThrowingGeometry"); + driver->SetMetadataItem(GDAL_DCAP_VECTOR, "YES"); + driver->SetMetadataItem(GDAL_DCAP_OPEN, "YES"); + driver->pfnIdentify = [](GDALOpenInfo* info) + { + return std::string_view(info->pszFilename).starts_with("MWG_THROWING:") ? TRUE : FALSE; + }; + driver->pfnOpen = [](GDALOpenInfo* info) -> GDALDataset* + { + const std::string_view name(info->pszFilename); + return name.starts_with("MWG_THROWING:") + ? std::make_unique(name.ends_with("allocation")).release() + : nullptr; + }; + GetGDALDriverManager()->RegisterDriver(driver.get()); + + MapView view; + const LayerId below = view.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId failed = view.AddLayer(VectorDataset::Open("MWG_THROWING:runtime")); + const LayerId above = view.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + const auto& scene = view.Scene(); + 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, failed); + EXPECT_EQ(scene.skippedLayers[0].reason, rendering::LayerSkipReason::Failed); + EXPECT_EQ(scene.skippedLayers[0].message, "Simulated geometry read exception"); + const auto* polygons = scene.layers[0].scene.polygons.data(); + EXPECT_EQ(view.Scene().layers[0].scene.polygons.data(), polygons); + + view.AddLayer(VectorDataset::Open("MWG_THROWING:allocation")); + EXPECT_THROW((void)view.Scene(), std::bad_alloc); +} + +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..0348139 --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -0,0 +1,279 @@ +// 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()) << 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, DegenerateExtentsCompleteTheInitialFit) +{ + const Extent extents[] = { + {4.0, 2.0, 4.0, 8.0}, {2.0, 4.0, 8.0, 4.0}, {4.0, 4.0, 4.0, 4.0}}; + for (const Extent& extent : extents) + { + MapView view; + view.AddLayer(OpenRectangles({extent})); + EXPECT_FALSE(view.GetViewport().HasView()); + ASSERT_TRUE(view.Resize(200, 100)); + ASSERT_TRUE(view.GetViewport().IsValid()); + EXPECT_TRUE(IsVisible(view.GetViewport(), extent)); + const double resolution = *view.GetViewport().Resolution(); + EXPECT_FALSE(view.Resize(200, 100)) << "the initial fit must not remain pending"; + EXPECT_DOUBLE_EQ(*view.GetViewport().Resolution(), resolution); + if (extent.Width() == 0.0 && extent.Height() == 0.0) + { + EXPECT_DOUBLE_EQ(resolution, Viewport::kDefaultPointResolution); + ASSERT_TRUE(view.Resize(400, 200)); + ASSERT_TRUE(view.FitToMap()); + EXPECT_DOUBLE_EQ(*view.GetViewport().Resolution(), resolution); + } + } +} + +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..a87495e 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, 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), S_FALSE); + 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,221 @@ 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, GdiAndOpenGlDrawAtTheSharedTransformCoordinates) +TEST(VectorLayerRenderer, SharedTransformRequiresAValidViewport) { - BitmapSurface gdiSurface(120, 100); - BitmapSurface openGlSurface(120, 100); + 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, DrawsAnEmptySceneAsBackgroundOnly) +{ + 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); +} + +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)); +} - const POINT samplePoints[] = { - {60, 50}, - {85, 50}, - {25, 50}, - {95, 50}}; - for (const POINT point : samplePoints) +TEST(VectorLayerRenderer, GdiAndOpenGlAgreeAtFractionalRectangleEdges) +{ + BitmapSurface gdiSurface(120, 100); + BitmapSurface openGlSurface(120, 100); + ASSERT_TRUE(gdiSurface.IsValid()); + ASSERT_TRUE(openGlSurface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + mwg::Viewport viewport(120, 100); + viewport.SetView({60.0, 50.0}, 1.0); + mwg::activex::GdiVectorLayerRenderer gdi; + mwg::activex::OpenGlVectorLayerRenderer openGl; + for (const double fraction : {0.25, 0.75}) { - EXPECT_EQ( - GetPixel(gdiSurface.Get(), point.x, point.y), - GetPixel(openGlSurface.Get(), point.x, point.y)) - << "at (" << point.x << ", " << point.y << ")"; + const auto scene = MakeMapScene({ + MakeRectangleLayer({20.0 + fraction, 20.0 + fraction, + 90.0 + fraction, 80.0 + fraction})}); + ASSERT_NE(FillRect(gdiSurface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + ASSERT_EQ(gdi.Draw(gdiSurface.Get(), bounds, scene, viewport), S_OK); + const HRESULT result = openGl.Draw(openGlSurface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + for (int offset = -2; offset <= 2; ++offset) + { + for (const mwg::PixelPoint pixel : { + mwg::PixelPoint{20 + offset, 50}, {90 + offset, 50}, + {60, 19 + offset}, {60, 79 + offset}}) + { + EXPECT_EQ(GetPixel(gdiSurface.Get(), pixel.x, pixel.y), + GetPixel(openGlSurface.Get(), pixel.x, pixel.y)) + << pixel.x << ", " << pixel.y << ", fraction " << fraction; + } + } + } +} + +TEST(VectorLayerRenderer, OverlappingLayersDrawBottomFirst) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + mwg::Viewport viewport(120, 100); + viewport.SetView({60.0, 50.0}, 1.0); + auto scene = MakeMapScene({ + MakeRectangleLayer({40.25, 30.25, 80.25, 70.25}), + MakeRectangleLayer({20.25, 10.25, 100.25, 90.25})}); + mwg::activex::GdiVectorLayerRenderer gdi; + ASSERT_NE(FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); + // The top layer's fill must cover the bottom layer's outline. + EXPECT_EQ(GetPixel(surface.Get(), 40, 50), kFill); + std::swap(scene.layers[0], scene.layers[1]); + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_EQ(GetPixel(surface.Get(), 40, 50), RGB(24, 58, 82)); + + mwg::activex::OpenGlVectorLayerRenderer openGl; + const HRESULT result = openGl.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; } + ASSERT_EQ(result, S_OK); + EXPECT_EQ(GetPixel(surface.Get(), 40, 50), RGB(24, 58, 82)); + std::swap(scene.layers[0], scene.layers[1]); + ASSERT_EQ(openGl.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_EQ(GetPixel(surface.Get(), 40, 50), kFill); } -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; + } }