From a8a490aeab242cf4c531bd7e2c616f9f09187bbf Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 18:55:28 +0100 Subject: [PATCH 1/3] Record final map navigation verification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/TESTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index d7dc40b..95d5964 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -237,3 +237,11 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. - Wrap expected GDAL noise in `GdalEnvironment::QuietScope`. - Tests use local generated data and do not require network access by default. Set `MWG_RUN_NETWORK_GDAL_TESTS=1` to enable the public `/vsicurl/` COG test. + +### Phase 7 final map-model and navigation verification + +The Debug x64 solution build succeeded. The native test executable ran 306 +tests: 303 passed and 3 were skipped (the opt-in network test, separately +exercised ActiveX hosting, and the opt-in rendering baseline). The automated +C# ActiveX host passed all 16 checks, including programmatic navigation, +mouse-wheel zoom, drag-to-pan, resize, repaint, and control recreation. From 5ef953da2d5f2708217451d5b741cff56664f5a0 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 20:23:31 +0100 Subject: [PATCH 2/3] Complete reprojection, raster, rendering, and navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 34 +- docs/ARCHITECTURE.md | 114 +-- docs/PORTING.md | 48 +- docs/TESTING.md | 15 +- src/MapWinGIS.ActiveX/GdalDispatchWindow.h | 38 +- src/MapWinGIS.ActiveX/GdalEventSource.cpp | 7 + src/MapWinGIS.ActiveX/GdalEventSource.h | 1 + .../GdiVectorLayerRenderer.cpp | 195 ++++- .../GdiVectorLayerRenderer.h | 4 +- src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl | 75 +- src/MapWinGIS.ActiveX/MapWinGISControl.cpp | 690 +++++++++++++++++- src/MapWinGIS.ActiveX/MapWinGISControl.h | 56 +- .../OpenGlVectorLayerRenderer.cpp | 5 + src/MapWinGIS.Core/MapWinGIS.Core.vcxproj | 3 + .../include/mapwingis/map/Map.h | 39 +- .../include/mapwingis/map/MapView.h | 65 +- .../include/mapwingis/raster/RasterDataset.h | 67 ++ .../mapwingis/rendering/MapRenderScene.h | 18 +- .../mapwingis/rendering/VectorRenderScene.h | 22 +- .../src/internal/CoordinateTransformation.h | 89 +++ .../src/internal/VectorRenderSceneBuilder.h | 14 +- src/MapWinGIS.Core/src/map/Map.cpp | 146 +++- src/MapWinGIS.Core/src/map/MapView.cpp | 347 ++++++++- .../src/raster/RasterDataset.cpp | 455 ++++++++++++ .../src/rendering/MapRenderScene.cpp | 18 +- .../src/rendering/VectorRenderScene.cpp | 105 ++- .../ActiveXActivationTests.cpp | 127 +++- .../MapRenderSceneTests.cpp | 110 ++- tests/MapWinGIS.Core.Tests/MapViewTests.cpp | 114 +++ .../MapWinGIS.Core.Tests.vcxproj | 1 + .../RasterDatasetTests.cpp | 130 ++++ .../VectorLayerRendererTests.cpp | 64 ++ .../VectorRenderSceneTests.cpp | 8 +- 33 files changed, 3032 insertions(+), 192 deletions(-) create mode 100644 src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h create mode 100644 src/MapWinGIS.Core/src/internal/CoordinateTransformation.h create mode 100644 src/MapWinGIS.Core/src/raster/RasterDataset.cpp create mode 100644 tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp diff --git a/README.md b/README.md index 222d53c..af5eb2f 100644 --- a/README.md +++ b/README.md @@ -17,28 +17,34 @@ selectively and must not prevent structural improvements. ## Status -Early. The core currently includes GDAL setup and OGR-backed vector dataset -and layer access, including feature and field reading and updates. Available -formats depend on the GDAL drivers enabled in the build; tests cover shapefiles -and GeoPackage. The ActiveX layer supports COM activation, vector loading, -polygon rendering, basic map navigation, and asynchronous generic GDAL +Early. The core includes GDAL setup, OGR-backed vector dataset and layer +access, raster display previews, coordinate reprojection, and map navigation. +Available formats depend on the GDAL drivers enabled in the build; tests cover +shapefiles and GeoPackage. The ActiveX layer supports COM activation, vector +and raster loading, polygon/line/point rendering, keyboard, touch and mouse +navigation, navigation history, animated fits, and asynchronous generic GDAL algorithms with progress and cancellation events. -The core also supports a shared map viewport with pan, center zoom, anchored -zoom, and fitting to the visible map or a named layer. These navigation calls -do not depend on a window. ActiveX exposes map/layer zoom operations and -supports cursor-anchored mouse-wheel zoom and left-button drag-to-pan. -The ActiveX control also accepts supported vector datasets through file -drag-and-drop. +The map uses the first layer's declared coordinate reference system (CRS). +Layers in other declared CRSs are reprojected into it; layers with and without +a CRS cannot be mixed. Raster display uses a bounded preview image. OpenGL +renders supported simple polygons; GDI draws polygons, lines, points, and +raster previews. + +The core supports pan, center and cursor-anchored zoom, map/layer fitting, +back/forward navigation history, and timed animated fits. ActiveX exposes +these operations, configurable zoom factors, mouse-wheel zoom, drag-to-pan, +arrow-key navigation, and touch pan/pinch gestures. ActiveX animation runs on +the control's owner-thread timer. Vector loads add layers rather than replace them. The first layer fits the initial view; later loads preserve it. Call `ZoomToMap` to include new layers in the visible extent, or `ClearLayers` to release loaded datasets before switching or refreshing them. -Dropped files append as layers and the control fits the combined visible -extent after processing the batch. Unsupported or invalid files are skipped; -valid files in the same drop are still loaded. +Dropped vector and raster files append as layers and the control fits the +combined visible extent after processing the batch. Unsupported or invalid +files are skipped; valid files in the same drop are still loaded. ## Build and test diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07a5ae2..50a69b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,9 +120,8 @@ state; hosts and renderers read from it. are appended on top. Positions shift on removal; identities do not. - **Ownership.** The map owns its `MapLayer` objects. A `MapLayer` reference stays valid until that layer is removed, the map is cleared, or the map is - destroyed; changes to other layers do not invalidate it. Each `MapLayer` - shares ownership of its `VectorDataset` (`std::shared_ptr`), so the dataset - lives as long as any map layer or other owner uses it. + destroyed; changes to other layers do not invalidate it. A vector layer + shares its `VectorDataset`; a raster layer shares its `RasterDataset`. - **Duplicates.** The same source layer may be added more than once; each addition is an independent map layer with its own identity and visibility. - **Visibility.** Layers are visible when added. `Map::SetLayerVisible` @@ -134,9 +133,10 @@ state; hosts and renderers read from it. rather than cached, so visibility changes, added or removed layers, and source edits are reflected without invalidation. A map with no valid layer extent reports an empty `Extent`. -- **Coordinates.** Layers are assumed to share compatible source coordinates. - The map does not reproject; layers in different coordinate reference - systems do not align until coordinate transformations are implemented. +- **Coordinates.** The first layer's declared coordinate reference system + (CRS) is the map CRS. Other declared CRSs are transformed into it for + extents and rendering. Layers with and without a CRS cannot be mixed; + transformations use traditional GIS axis ordering. ## Viewport @@ -225,8 +225,10 @@ has no extent or transform of its own. the view cancels it. A missing layer or an invalid extent also cancels a pending fit. Navigation that returns `false` keeps it, including `ZoomAt` on a zero-sized viewport and center zoom at a resolution limit. - None of this depends on COM, mouse messages, or a - renderer; Phase 5 maps input onto these calls. + None of this depends on COM, mouse messages, or a renderer. Navigation + history retains up to 100 views. Eased map/layer fit transitions are + advanced by a host timer and create one history entry when completed or + canceled. ## ActiveX and rendering architecture @@ -239,14 +241,15 @@ responsibilities, not a requirement to split them into more DLLs. | `MapWinGIS.Core` | Own GIS data, reusable map/layer state, and backend-neutral render data. Keep its public API independent of windowing, COM, and graphics APIs. | COM, ATL, MFC, windowing APIs, OpenGL, OpenCL | | `MapWinGIS.ocx` | Own COM activation, host-facing control lifecycle, and translation between the external API and core operations. Integrate core rendering with the host surface and selected backend. | GIS rules, core data ownership, rendering-specific types in its public COM contract | | Rendering coordinator (in the OCX) | Try OpenGL for supported scenes and use GDI when OpenGL is unavailable or the scene is unsupported. Keep backend selection independent of COM activation. | GIS rules, COM interface layout, backend-specific resource types outside the backend | -| GDI backend (in the OCX) | Paint supported polygon scenes, including interior rings, into a device context; provide the Windows CPU fallback. | Core dataset ownership, COM interface layout, GPU availability | +| GDI backend (in the OCX) | Paint polygon, line, point, and raster scenes into a device context; provide the Windows CPU fallback. | Core dataset ownership, COM interface layout, GPU availability | | OpenGL backend (in the OCX) | Render convex single-ring polygons through a reusable WGL context, then copy pixels to the host device context. | Core GIS data ownership and public COM interfaces | | 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 a `MapView`, which owns the map, its layers' datasets (shared), the viewport, and the prepared -`MapRenderScene`; each `VectorDataset` owns its `VectorLayer` objects. Core +`MapRenderScene`; each `VectorDataset` owns its `VectorLayer` objects. +`RasterDataset` owns the GDAL raster and prepares bounded display previews. 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 @@ -262,29 +265,28 @@ 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. 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. +visible map layer, Core sequentially reads supported point, line, and polygon +features into an owned vector scene, linearizing curved geometries. Raster +layers carry a bounded RGBA display preview and a georeferenced extent. +Vector geometry and raster previews from other declared CRSs are transformed +into the map CRS. `ViewTransform` captures the `Viewport` once per frame so +every backend uses the same map-to-screen arithmetic; GDI draws a screen +position in the pixel that contains it (`ToPixel`). The coordinator tries +OpenGL only when every layer is a simple convex polygon with one ring. GDI +handles polygons, lines, points, and raster previews. A layer with non-finite +or out-of-range device coordinates is skipped and reported +(`GetFailedLayers`, `RenderingStatus`) while the other layers continue. 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 before copying them to the host device context; this is a working backend boundary, not an end-to-end accelerated presentation path. Rendering into a framebuffer object avoids undefined pixels from window pixel-ownership rules. -Interior rings, concave or self-intersecting rings, missing framebuffer-object -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. +Interior rings, concave or self-intersecting rings, non-polygon geometry, +raster layers, missing framebuffer-object support, initialization failures, +and drawing failures use the GDI backend. Raster previews use nearest-neighbor +sampling, with grayscale or RGB bands, palette colors, nodata transparency, +alpha bands, and reprojection through GDAL's warped VRT support. The `RenderingBackendPreference` automation property is configurable as `Automatic`, `OpenGL`, or `GDI`. `Automatic` and `OpenGL` try the OpenGL @@ -292,9 +294,9 @@ 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`, 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. +`IMapWinGISControl`. `IMapWinGISControl3` adds `ClearLayers` at DISPID 8; +`IMapWinGISControl6` is the current coclass default and adds navigation and +animation operations. 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 @@ -324,13 +326,14 @@ intentional compatibility gaps and the rationale when interfaces are introduced. ## Current limits and next work -The control exposes a small v6 COM surface: Core initialization, vector -dataset loading, layer-loaded state, feature count, layer count and stable IDs, -layer removal, clear and visibility, map/layer zoom, and center zoom in/out. -Mouse-wheel zoom and drag-to-pan use the same `MapView`. The control also -supports renderer preferences and asynchronous generic GDAL operations with -progress, cancellation, and result reporting. The GDAL API has a new interface -and does not claim MapWinGIS 5 `IGdalUtils` binary compatibility. +The control exposes a small v6 COM surface: Core initialization, vector and +raster dataset loading, layer state, layer removal and visibility, map/layer +zoom, direct pan, anchored zoom, configurable zoom factors, back/forward +history, and animated fits. Mouse, keyboard, and touch navigation use the same +`MapView`. The control also supports renderer preferences and asynchronous +generic GDAL operations with progress, cancellation, and result reporting. +The GDAL API has a new interface and does not claim MapWinGIS 5 `IGdalUtils` +binary compatibility. At most sixteen operations may be active or awaiting result cleanup. Call `EndGdalOperation` after completion to release an operation slot. The control delivers events on its owning apartment thread. Destroying the in-place window @@ -344,11 +347,8 @@ 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. +Supported point, line, and polygon layers are validated before adding them, +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 or removal and resets it on `ClearLayers`; property reads do @@ -357,14 +357,16 @@ later loads preserve the current view, and callers can use `ZoomToMap` to include new layers in the visible extent. `IMapWinGISControl4` exposes layer count and stable layer IDs, removal, visibility, `ZoomToMap`, `ZoomToLayer`, `ZoomIn`, and `ZoomOut`; it inherits `ClearLayers` from `IMapWinGISControl3`. -Layer reordering is not exposed because Core has no reorder operation. -Mouse-wheel zoom is anchored at the cursor. Left-button dragging pans the -view, with windowless mouse capture released on button-up, capture loss, -cancellation, or control destruction. 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, +`IMapWinGISControl5` adds raster loading. `IMapWinGISControl6` adds direct +pan, anchored zoom, configurable zoom factors, back/forward history, and +animated map/layer fits. Layer reordering is not exposed because Core has no +reorder operation. Mouse-wheel zoom is anchored at the cursor; left-button +dragging pans, keyboard arrows pan, and touch pointers support one-finger pan +and two-finger pinch zoom. Animated fits use a 16 ms owner-thread timer and +are canceled when the control closes or its host window is destroyed. + +Core prepares and retains the supported vector scene and bounded raster +previews in memory, reading feature geometries sequentially without WKT serialization and 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, @@ -379,9 +381,11 @@ 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. -Core and ActiveX navigation use the shared `MapView`; the COM API exposes -programmatic zoom operations and the control handles mouse-wheel zoom and -drag-to-pan. Deep zoom can push a layer past the GDI coordinate range until -clipping exists. Measure scene preparation with representative datasets -before adding caching, paging, or broader OpenGL geometry support. +Core and ActiveX navigation use the shared `MapView`; its history is bounded +to 100 views, and each drag or touch gesture creates one history entry. +Core exposes eased transitions that hosts can advance, while ActiveX schedules +them on its message-only dispatch window. Deep zoom can push a layer past the +GDI coordinate range until clipping exists. Measure +scene preparation with representative datasets before adding caching, paging, +or broader OpenGL geometry support. OpenCL and full MapWinGIS 5 API parity remain deferred. diff --git a/docs/PORTING.md b/docs/PORTING.md index 0036d71..3b9b724 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -49,32 +49,35 @@ choose deliberately whether it belongs in v6 and document important departures. ## Current ActiveX compatibility boundary 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. +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. +`IMapWinGISControl3` adds `ClearLayers`. `IMapWinGISControl4` adds layer +management and basic zoom methods, `IMapWinGISControl5` adds raster loading, +and `IMapWinGISControl6` is the current 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. The windowless control accepts Explorer file drops through its OLE drop-target -hook. Dropped supported vector datasets append as layers, then the control -fits the combined visible extent once. Unsupported, missing, or invalid paths -are skipped without discarding other files that loaded successfully. A partial -drop returns `S_FALSE` with a copy effect; a drop with no successful files -returns a failing HRESULT and no effect. Failed paths and HRESULTs are written -to the debugger output. +hook. Dropped supported vector and raster datasets append as layers, then the +control fits the combined visible extent once. Unsupported, missing, or +invalid paths are skipped without discarding other files that loaded +successfully. A partial drop returns `S_FALSE` with a copy effect; a drop with +no successful files returns a failing HRESULT and no effect. Failed paths and +HRESULTs are written to the debugger output. 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. -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 -fallback. +Each vector load selects the first dataset layer. Core prepares an ordered +scene for all visible map layers, including point, line, and polygon +geometries; raster layers use bounded display previews. The map uses the first +layer's declared CRS, transforms other declared CRSs into it, and rejects +mixing referenced and unreferenced layers. The control does not expose the +broad v5 map, layer, shape, event, or editing API. OpenGL only handles convex +single-ring polygons; GDI handles the supported vector and raster scene as the +CPU fallback. ## Map and viewport behavior @@ -83,9 +86,16 @@ independent of its display name and current list index. `MapView` owns the viewport and the visible extent shared by every rendered layer. Map X increases right and map Y increases up; screen X increases right and screen Y increases down. Resizing keeps the view center and scale where possible. Loading through -`LoadVectorDataset` appends layers and preserves an existing view; a file-drop -batch appends layers and then fits the combined visible extent. Source -coordinates are not reprojected, so layers must use compatible coordinates. +`LoadVectorDataset` or `LoadRasterDataset` appends layers and preserves an +existing view; a file-drop batch appends layers and then fits the combined +visible extent. Layers with declared CRSs are transformed into the first +layer's CRS. + +`IMapWinGISControl6` adds pan, anchored zoom, configurable zoom factors, +back/forward view history, animated fits, and host input for mouse, keyboard, +and touch pan/pinch gestures. It is a new derived interface; earlier IIDs +remain unchanged. Animated map/layer fits run on the control's owner-thread +timer and can be canceled explicitly. Keep these gaps visible when extending the COM surface. Reuse Core behavior, add only the automation members required by a concrete use case, and retain a diff --git a/docs/TESTING.md b/docs/TESTING.md index 95d5964..10e47b0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -240,8 +240,13 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. ### Phase 7 final map-model and navigation verification -The Debug x64 solution build succeeded. The native test executable ran 306 -tests: 303 passed and 3 were skipped (the opt-in network test, separately -exercised ActiveX hosting, and the opt-in rendering baseline). The automated -C# ActiveX host passed all 16 checks, including programmatic navigation, -mouse-wheel zoom, drag-to-pan, resize, repaint, and control recreation. +The Debug x64 solution build succeeded. The native test executable ran 323 +tests: 320 passed and 3 were skipped (the opt-in network test, separately +exercised ActiveX hosting, and the opt-in rendering baseline). Coverage +includes vector reprojection and point/line/polygon scenes, raster preview and +reprojection, navigation history and animated transitions, the new ActiveX +navigation interface, its owner-thread animation timer, keyboard panning, and +one-entry-per-gesture navigation history. +The automated C# ActiveX host passed all 16 checks, including programmatic +navigation, mouse-wheel zoom, drag-to-pan, resize, repaint, and control +recreation. Touch gesture hardware input remains a manual validation item. diff --git a/src/MapWinGIS.ActiveX/GdalDispatchWindow.h b/src/MapWinGIS.ActiveX/GdalDispatchWindow.h index 6148aa2..a1c65e7 100644 --- a/src/MapWinGIS.ActiveX/GdalDispatchWindow.h +++ b/src/MapWinGIS.ActiveX/GdalDispatchWindow.h @@ -2,10 +2,10 @@ /** * @file GdalDispatchWindow.h - * @brief Declares the message-only window used to dispatch GDAL notifications. + * @brief Declares the owner-thread window for GDAL notifications and navigation timers. * - * The window is created on the control's owning COM thread and accepts only - * posted wake-up messages from native workers. + * The window is created on the control's owning COM thread. It accepts + * worker wake-up messages and timer messages for navigation animation. */ #pragma once @@ -17,7 +17,9 @@ namespace mwg::activex { inline constexpr UINT kGdalDispatchMessage = WM_APP + 0x316; inline constexpr UINT_PTR kGdalDispatchRetryTimerId = 1; + inline constexpr UINT_PTR kNavigationAnimationTimerId = 2; inline constexpr UINT kGdalDispatchRetryIntervalMilliseconds = 100; + inline constexpr UINT kNavigationAnimationIntervalMilliseconds = 16; class GdalDispatchWindow final : public ATL::CWindowImpl, @@ -68,9 +70,35 @@ namespace mwg::activex _deferredLifetime = shared_from_this(); } ::KillTimer(m_hWnd, kGdalDispatchRetryTimerId); + ::KillTimer(m_hWnd, kNavigationAnimationTimerId); DestroyWindow(); } + [[nodiscard]] HRESULT SetAnimationTimer(const bool enabled) noexcept + { + if (m_hWnd == nullptr) + { + return E_UNEXPECTED; + } + if (!enabled) + { + ::KillTimer(m_hWnd, kNavigationAnimationTimerId); + return S_OK; + } + if (::SetTimer( + m_hWnd, + kNavigationAnimationTimerId, + kNavigationAnimationIntervalMilliseconds, + nullptr) == 0) + { + const DWORD error = ::GetLastError(); + return error == ERROR_SUCCESS + ? E_FAIL + : HRESULT_FROM_WIN32(error); + } + return S_OK; + } + BEGIN_MSG_MAP(GdalDispatchWindow) MESSAGE_HANDLER(kGdalDispatchMessage, OnDispatch) MESSAGE_HANDLER(WM_TIMER, OnRetryTimer) @@ -117,7 +145,9 @@ namespace mwg::activex const std::shared_ptr lifetime = shared_from_this(); handled = TRUE; MessageGuard messageGuard(*this); - if (timerId == kGdalDispatchRetryTimerId && _callback != nullptr) + if ((timerId == kGdalDispatchRetryTimerId || + timerId == kNavigationAnimationTimerId) && + _callback != nullptr) { _callback(_context); } diff --git a/src/MapWinGIS.ActiveX/GdalEventSource.cpp b/src/MapWinGIS.ActiveX/GdalEventSource.cpp index c435a74..c5c14a3 100644 --- a/src/MapWinGIS.ActiveX/GdalEventSource.cpp +++ b/src/MapWinGIS.ActiveX/GdalEventSource.cpp @@ -90,6 +90,13 @@ namespace mwg::activex return S_OK; } + HRESULT GdalEventSource::SetAnimationTimer(const bool enabled) noexcept + { + return _dispatchWindow == nullptr + ? E_UNEXPECTED + : _dispatchWindow->SetAnimationTimer(enabled); + } + std::uint64_t GdalEventSource::Begin( std::string algorithmPath, std::vector arguments) diff --git a/src/MapWinGIS.ActiveX/GdalEventSource.h b/src/MapWinGIS.ActiveX/GdalEventSource.h index 2702e9c..2df36bc 100644 --- a/src/MapWinGIS.ActiveX/GdalEventSource.h +++ b/src/MapWinGIS.ActiveX/GdalEventSource.h @@ -35,6 +35,7 @@ namespace mwg::activex GdalEventSource& operator=(const GdalEventSource&) = delete; [[nodiscard]] HRESULT EnsureDispatchWindow() noexcept; + [[nodiscard]] HRESULT SetAnimationTimer(bool enabled) noexcept; [[nodiscard]] std::uint64_t Begin( std::string algorithmPath, std::vector arguments); diff --git a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp index c0f4ecc..2b5c1c3 100644 --- a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp @@ -2,15 +2,16 @@ /** * @file GdiVectorLayerRenderer.cpp - * @brief Draws polygon render scenes into a Windows GDI device context. + * @brief Draws vector geometry into a Windows GDI device context. * - * Transforms every layer through the same Core viewport and paints polygon - * fills and outlines without retaining scene or host resources. + * Transforms every layer through the same Core viewport and paints points, + * lines, and polygons without retaining scene or host resources. */ #include "GdiVectorLayerRenderer.h" #include #include +#include #include #include #include @@ -18,6 +19,8 @@ #include "ViewTransform.h" +#pragma comment(lib, "Msimg32.lib") + namespace mwg::activex { namespace @@ -70,6 +73,28 @@ namespace mwg::activex HGDIOBJ _object; }; + class ScopedMemoryDc + { + public: + explicit ScopedMemoryDc(HDC deviceContext) noexcept + : _deviceContext(CreateCompatibleDC(deviceContext)) + { + } + + ~ScopedMemoryDc() + { + if (_deviceContext != nullptr) + { + DeleteDC(_deviceContext); + } + } + + [[nodiscard]] HDC Get() const noexcept { return _deviceContext; } + + private: + HDC _deviceContext; + }; + class ScopedSelection { public: @@ -130,6 +155,25 @@ namespace mwg::activex const double originX, const double originY) noexcept { + for (const rendering::Point& point : scene.points) + { + if (!IsDrawableCoordinate(ToDeviceX(point, transform, originX)) || + !IsDrawableCoordinate(ToDeviceY(point, transform, originY))) + { + return false; + } + } + for (const rendering::Ring& line : scene.lines) + { + for (const rendering::Point& point : line) + { + if (!IsDrawableCoordinate(ToDeviceX(point, transform, originX)) || + !IsDrawableCoordinate(ToDeviceY(point, transform, originY))) + { + return false; + } + } + } for (const rendering::Polygon& polygon : scene.polygons) { for (const rendering::Ring& ring : polygon) @@ -169,6 +213,28 @@ namespace mwg::activex const double originX, const double originY) { + for (const rendering::Ring& line : scene.lines) + { + if (line.size() < 2) + { + continue; + } + if (line.size() > static_cast(std::numeric_limits::max())) + { + return E_INVALIDARG; + } + std::vector points; + points.reserve(line.size()); + for (const rendering::Point& point : line) + { + points.push_back(ToPoint(point, transform, originX, originY)); + } + if (Polyline(deviceContext, points.data(), static_cast(points.size())) == FALSE) + { + return E_FAIL; + } + } + for (const rendering::Polygon& polygon : scene.polygons) { std::vector points; @@ -205,8 +271,110 @@ namespace mwg::activex return E_FAIL; } } + + for (const rendering::Point& point : scene.points) + { + const POINT pixel = ToPoint(point, transform, originX, originY); + if (Ellipse( + deviceContext, + pixel.x - 3, + pixel.y - 3, + pixel.x + 4, + pixel.y + 4) == FALSE) + { + return E_FAIL; + } + } return S_OK; } + + [[nodiscard]] HRESULT DrawRasterLayer( + const HDC deviceContext, + const RasterImage& image, + const ViewTransform& transform, + const double originX, + const double originY) + { + if (image.width <= 0 || image.height <= 0 || !image.extent.IsValid() || + image.pixels.size() != + static_cast(image.width) * static_cast(image.height)) + { + return E_INVALIDARG; + } + + const rendering::Point topLeft{image.extent.xMin, image.extent.yMax}; + const rendering::Point bottomRight{image.extent.xMax, image.extent.yMin}; + const double leftValue = ToDeviceX(topLeft, transform, originX); + const double topValue = ToDeviceY(topLeft, transform, originY); + const double rightValue = ToDeviceX(bottomRight, transform, originX); + const double bottomValue = ToDeviceY(bottomRight, transform, originY); + if (!IsDrawableCoordinate(leftValue) || !IsDrawableCoordinate(topValue) || + !IsDrawableCoordinate(rightValue) || !IsDrawableCoordinate(bottomValue)) + { + return E_INVALIDARG; + } + + const LONG left = static_cast(std::floor(leftValue)); + const LONG top = static_cast(std::floor(topValue)); + const LONG right = static_cast(std::ceil(rightValue)); + const LONG bottom = static_cast(std::ceil(bottomValue)); + if (right <= left || bottom <= top) + { + return S_OK; + } + + ScopedMemoryDc memoryDc(deviceContext); + if (memoryDc.Get() == nullptr) + { + return E_FAIL; + } + + BITMAPINFO bitmapInfo{}; + bitmapInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmapInfo.bmiHeader.biWidth = image.width; + bitmapInfo.bmiHeader.biHeight = -image.height; + bitmapInfo.bmiHeader.biPlanes = 1; + bitmapInfo.bmiHeader.biBitCount = 32; + bitmapInfo.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; + const HBITMAP bitmap = CreateDIBSection( + memoryDc.Get(), + &bitmapInfo, + DIB_RGB_COLORS, + &pixels, + nullptr, + 0); + const ScopedGdiObject ownedBitmap(bitmap); + if (bitmap == nullptr || pixels == nullptr) + { + return E_OUTOFMEMORY; + } + std::memcpy( + pixels, + image.pixels.data(), + image.pixels.size() * sizeof(std::uint32_t)); + + const ScopedSelection selectedBitmap(memoryDc.Get(), bitmap); + if (!selectedBitmap.IsValid()) + { + return E_FAIL; + } + const BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA}; + return AlphaBlend( + deviceContext, + left, + top, + right - left, + bottom - top, + memoryDc.Get(), + 0, + 0, + image.width, + image.height, + blend) != FALSE + ? S_OK + : E_FAIL; + } } HRESULT GdiVectorLayerRenderer::Draw( @@ -255,6 +423,27 @@ namespace mwg::activex { for (const rendering::LayerRenderScene& layer : scene.layers) { + if (layer.raster.has_value()) + { + const HRESULT result = DrawRasterLayer( + deviceContext, + *layer.raster, + *transform, + originX, + originY); + if (result == E_INVALIDARG) + { + _failedLayers.push_back(layer.layerId); + continue; + } + if (FAILED(result)) + { + return result; + } + ++drawnLayers; + continue; + } + if (!HasDrawableCoordinates(layer.scene, *transform, originX, originY)) { _failedLayers.push_back(layer.layerId); diff --git a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h index 7a0363d..6f753ab 100644 --- a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.h @@ -2,7 +2,7 @@ /** * @file GdiVectorLayerRenderer.h - * @brief Declares the GDI backend for polygon render scenes. + * @brief Declares the GDI backend for vector render scenes. * * The backend paints supported vector scene geometry into the supplied * device context and does not retain host or scene resources. @@ -18,7 +18,7 @@ namespace mwg::activex class GdiVectorLayerRenderer final : public IVectorRenderBackend { public: - /// Draws the layers bottom first. A layer with a vertex outside the + /// Draws points, lines, and polygons 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 diff --git a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl index dc5038f..1e1eec6 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl @@ -107,6 +107,77 @@ library MapWinGISActiveXLib [out, retval] VARIANT_BOOL* changed); }; + [ + object, + dual, + oleautomation, + uuid(51A7F506-5FAB-45F3-A56B-1BE72738721B), + pointer_default(unique) + ] + interface IMapWinGISControl5 : IMapWinGISControl4 + { + [id(17)] + HRESULT LoadRasterDataset([in] BSTR path); + }; + + [ + object, + dual, + oleautomation, + uuid(C4A9D339-5B0A-44B3-A700-5C4E4C31E2D6), + pointer_default(unique) + ] + interface IMapWinGISControl6 : IMapWinGISControl5 + { + // Pan by map-coordinate units; zoom anchors use viewport pixels. + [id(18)] + HRESULT Pan( + [in] double deltaX, + [in] double deltaY, + [out, retval] VARIANT_BOOL* changed); + + [id(19)] + HRESULT ZoomAt( + [in] double screenX, + [in] double screenY, + [in] double factor, + [out, retval] VARIANT_BOOL* changed); + + [id(20)] + HRESULT ZoomInBy([in] double factor, [out, retval] VARIANT_BOOL* changed); + + [id(21)] + HRESULT ZoomOutBy([in] double factor, [out, retval] VARIANT_BOOL* changed); + + // History keeps at most 100 prior views. + [propget, id(22)] + HRESULT CanGoBack([out, retval] VARIANT_BOOL* canGoBack); + + [propget, id(23)] + HRESULT CanGoForward([out, retval] VARIANT_BOOL* canGoForward); + + [id(24)] + HRESULT GoBack([out, retval] VARIANT_BOOL* changed); + + [id(25)] + HRESULT GoForward([out, retval] VARIANT_BOOL* changed); + + // Animations run on the control's owner-thread timer. + [id(26)] + HRESULT AnimateZoomToMap([in] LONG durationMilliseconds); + + [id(27)] + HRESULT AnimateZoomToLayer( + [in] hyper layerId, + [in] LONG durationMilliseconds); + + [propget, id(28)] + HRESULT IsAnimating([out, retval] VARIANT_BOOL* isAnimating); + + [id(29)] + HRESULT StopAnimation(); + }; + [ object, dual, @@ -161,7 +232,9 @@ library MapWinGISActiveXLib ] coclass MapWinGISControl { - [default] interface IMapWinGISControl4; + [default] interface IMapWinGISControl6; + interface IMapWinGISControl5; + interface IMapWinGISControl4; interface IMapWinGISControl3; interface IMapWinGISControl2; interface IMapWinGISControl; diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 05d8a78..0699b11 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -2,7 +2,7 @@ /** * @file MapWinGISControl.cpp - * @brief Implements vector loading, file drop, ActiveX state, and host drawing. + * @brief Implements dataset loading, navigation, file drop, 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 @@ -29,6 +29,7 @@ #include "ComConversion.h" #include "mapwingis/core/Error.h" #include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/raster/RasterDataset.h" #include "mapwingis/vector/VectorDataset.h" namespace @@ -592,8 +593,10 @@ STDMETHODIMP CMapWinGISControl::RemoveLayer( return S_OK; } - const LONGLONG featureCount = static_cast( - layer->Dataset().Layer(layer->SourceLayerIndex()).FeatureCount()); + const LONGLONG featureCount = layer->IsRaster() + ? 0 + : static_cast( + layer->Dataset().Layer(layer->SourceLayerIndex()).FeatureCount()); if (featureCount < 0 || featureCount > _featureCount) { return SetNavigationErrorInfo("Layer feature count is out of range", E_FAIL); @@ -710,6 +713,325 @@ STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) } } +STDMETHODIMP CMapWinGISControl::LoadRasterDataset(BSTR path) +{ + if (path == nullptr) + { + return E_INVALIDARG; + } + + const UINT pathLength = ::SysStringLen(path); + if (pathLength == 0) + { + return E_INVALIDARG; + } + + try + { + const std::wstring pathText(path, static_cast(pathLength)); + if (pathText.find(L'\0') != std::wstring::npos) + { + return E_INVALIDARG; + } + + auto dataset = mwg::RasterDataset::Open(std::filesystem::path(pathText)); + mwg::Map candidate; + candidate.AddRasterLayer(dataset); + const mwg::rendering::MapRenderScene scene = + mwg::rendering::CreateMapRenderScene(candidate); + if (scene.layers.empty() || !scene.layers.front().raster.has_value()) + { + throw mwg::CoreError("Raster dataset has no renderable pixels"); + } + + (void)_view.AddRasterLayer(std::move(dataset)); + FireViewChange(); + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + const CA2W message(exception.what(), CP_UTF8); + return AtlReportError( + CLSID_MapWinGISControl, + message, + IID_IMapWinGISControl5, + E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::Pan( + const double deltaX, + const double deltaY, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + try + { + if (_view.Pan(deltaX, deltaY)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomAt( + const double screenX, + const double screenY, + const double factor, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + try + { + if (_view.ZoomAt({screenX, screenY}, factor)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomInBy( + const double factor, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + try + { + if (_view.ZoomIn(factor)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomOutBy( + const double factor, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + try + { + if (_view.ZoomOut(factor)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::get_CanGoBack(VARIANT_BOOL* canGoBack) +{ + if (canGoBack == nullptr) + { + return E_POINTER; + } + *canGoBack = _view.CanGoBack() ? VARIANT_TRUE : VARIANT_FALSE; + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::get_CanGoForward(VARIANT_BOOL* canGoForward) +{ + if (canGoForward == nullptr) + { + return E_POINTER; + } + *canGoForward = _view.CanGoForward() ? VARIANT_TRUE : VARIANT_FALSE; + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::GoBack(VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (_view.GoBack()) + { + (void)_gdalEvents.SetAnimationTimer(false); + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::GoForward(VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (_view.GoForward()) + { + (void)_gdalEvents.SetAnimationTimer(false); + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::AnimateZoomToMap( + const LONG durationMilliseconds) +{ + if (durationMilliseconds <= 0) + { + return E_INVALIDARG; + } + try + { + if (!_view.BeginTransitionToMap( + std::chrono::milliseconds(durationMilliseconds))) + { + return S_FALSE; + } + const HRESULT dispatchResult = _gdalEvents.EnsureDispatchWindow(); + if (FAILED(dispatchResult)) + { + (void)_view.CancelTransition(); + return dispatchResult; + } + _lastAnimationTick = std::chrono::steady_clock::now(); + const HRESULT timerResult = _gdalEvents.SetAnimationTimer(true); + if (FAILED(timerResult)) + { + (void)_view.CancelTransition(); + return timerResult; + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::AnimateZoomToLayer( + const LONGLONG layerId, + const LONG durationMilliseconds) +{ + if (layerId <= 0 || durationMilliseconds <= 0) + { + return E_INVALIDARG; + } + try + { + if (!_view.BeginTransitionToLayer( + mwg::LayerId{static_cast(layerId)}, + std::chrono::milliseconds(durationMilliseconds))) + { + return S_FALSE; + } + const HRESULT dispatchResult = _gdalEvents.EnsureDispatchWindow(); + if (FAILED(dispatchResult)) + { + (void)_view.CancelTransition(); + return dispatchResult; + } + _lastAnimationTick = std::chrono::steady_clock::now(); + const HRESULT timerResult = _gdalEvents.SetAnimationTimer(true); + if (FAILED(timerResult)) + { + (void)_view.CancelTransition(); + return timerResult; + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_INVALIDARG); + } +} + +STDMETHODIMP CMapWinGISControl::get_IsAnimating(VARIANT_BOOL* isAnimating) +{ + if (isAnimating == nullptr) + { + return E_POINTER; + } + *isAnimating = _view.IsTransitioning() ? VARIANT_TRUE : VARIANT_FALSE; + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::StopAnimation() +{ + const bool stopped = _view.CancelTransition(); + (void)_gdalEvents.SetAnimationTimer(false); + if (stopped) + { + FireViewChange(); + return S_OK; + } + return S_FALSE; +} + STDMETHODIMP CMapWinGISControl::ClearLayers() { if (_view.ClearLayers()) @@ -932,6 +1254,10 @@ STDMETHODIMP CMapWinGISControl::Drop( else { loadResult = LoadVectorDataset(pathBstr); + if (FAILED(loadResult)) + { + loadResult = LoadRasterDataset(pathBstr); + } ::SysFreeString(pathBstr); } } @@ -1079,12 +1405,113 @@ void CMapWinGISControl::EndPan(const bool releaseCapture) noexcept return; } _isPanning = false; + (void)_view.EndNavigationGesture(); if (releaseCapture && FAILED(SetPointerCapture(false))) { ::OutputDebugStringA("MapWinGIS could not release pointer capture after pan.\n"); } } +void CMapWinGISControl::EndTouchNavigation() noexcept +{ + if (!_isTouchNavigating) + { + return; + } + for (TouchPointer& pointer : _touchPointers) + { + pointer.active = false; + } + _lastTouchDistance = 0.0; + _isTouchNavigating = false; + (void)_view.EndNavigationGesture(); +} + +bool CMapWinGISControl::UpdateTouchPointer( + const UINT32 pointerId, + const LPARAM position) noexcept +{ + for (TouchPointer& pointer : _touchPointers) + { + if (pointer.active && pointer.id == pointerId) + { + return GetPointerPosition(position, true, pointer.position); + } + } + return false; +} + +void CMapWinGISControl::ApplyTouchGesture() noexcept +{ + const TouchPointer* first = nullptr; + const TouchPointer* second = nullptr; + for (const TouchPointer& pointer : _touchPointers) + { + if (!pointer.active) + { + continue; + } + if (first == nullptr) + { + first = &pointer; + } + else + { + second = &pointer; + } + } + + try + { + const std::optional resolution = _view.GetViewport().Resolution(); + if (resolution.has_value() && first != nullptr && second == nullptr) + { + const double deltaX = (_lastPanPosition.x - first->position.x) * *resolution; + const double deltaY = (first->position.y - _lastPanPosition.y) * *resolution; + if (_view.Pan(deltaX, deltaY)) + { + FireViewChange(); + } + _lastPanPosition = first->position; + } + else if (resolution.has_value() && first != nullptr && second != nullptr) + { + const mwg::ScreenPoint center{ + (first->position.x + second->position.x) / 2.0, + (first->position.y + second->position.y) / 2.0}; + const double distance = std::hypot( + first->position.x - second->position.x, + first->position.y - second->position.y); + if (_lastTouchDistance > 0.0 && distance > 0.0) + { + const double factor = distance / _lastTouchDistance; + bool changed = false; + if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) + { + changed = _view.ZoomAt(center, factor); + } + else if (center.x != _lastTouchCenter.x || + center.y != _lastTouchCenter.y) + { + changed = _view.Pan( + (_lastTouchCenter.x - center.x) * *resolution, + (center.y - _lastTouchCenter.y) * *resolution); + } + if (changed) + { + FireViewChange(); + } + } + _lastTouchCenter = center; + _lastTouchDistance = distance; + } + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + } +} + LRESULT CMapWinGISControl::OnSize( UINT, WPARAM, @@ -1100,12 +1527,93 @@ LRESULT CMapWinGISControl::OnSize( return 0; } +LRESULT CMapWinGISControl::OnKeyDown( + UINT, + const WPARAM key, + LPARAM, + BOOL& handled) +{ + const bool alt = (::GetKeyState(VK_MENU) & 0x8000) != 0; + const bool control = (::GetKeyState(VK_CONTROL) & 0x8000) != 0; + bool changed = false; + try + { + if (alt && key == VK_LEFT) + { + changed = _view.GoBack(); + } + else if (alt && key == VK_RIGHT) + { + changed = _view.GoForward(); + } + else if (control && (key == 'Z' || key == 'Y')) + { + changed = key == 'Z' ? _view.GoBack() : _view.GoForward(); + } + else if (_view.GetViewport().IsValid()) + { + const double step = *_view.GetViewport().Resolution() * 32.0; + switch (key) + { + case VK_LEFT: + changed = _view.Pan(-step, 0.0); + break; + case VK_RIGHT: + changed = _view.Pan(step, 0.0); + break; + case VK_UP: + changed = _view.Pan(0.0, step); + break; + case VK_DOWN: + changed = _view.Pan(0.0, -step); + break; + case VK_ADD: + case VK_OEM_PLUS: + changed = _view.ZoomIn(); + break; + case VK_SUBTRACT: + case VK_OEM_MINUS: + changed = _view.ZoomOut(); + break; + default: + handled = FALSE; + return 0; + } + } + else + { + handled = FALSE; + return 0; + } + if (changed) + { + if (alt || control) + { + (void)_gdalEvents.SetAnimationTimer(false); + } + FireViewChange(); + } + handled = TRUE; + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + handled = TRUE; + } + return 0; +} + LRESULT CMapWinGISControl::OnMouseWheel( UINT, const WPARAM wParam, const LPARAM lParam, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } if (_view.GetMap().LayerCount() == 0 || !_view.GetViewport().IsValid()) { handled = FALSE; @@ -1144,6 +1652,11 @@ LRESULT CMapWinGISControl::OnLButtonDown( const LPARAM lParam, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } mwg::ScreenPoint position; if (_view.GetMap().LayerCount() == 0 || !_view.GetViewport().IsValid() || @@ -1163,6 +1676,7 @@ LRESULT CMapWinGISControl::OnLButtonDown( _isPanning = true; _lastPanPosition = position; + (void)_view.BeginNavigationGesture(); handled = TRUE; return 0; } @@ -1173,6 +1687,11 @@ LRESULT CMapWinGISControl::OnMouseMove( const LPARAM lParam, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } if (!_isPanning) { handled = FALSE; @@ -1215,6 +1734,11 @@ LRESULT CMapWinGISControl::OnLButtonUp( LPARAM, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } if (_isPanning) { EndPan(true); @@ -1245,6 +1769,142 @@ LRESULT CMapWinGISControl::OnCancelMode( BOOL& handled) { EndPan(true); + EndTouchNavigation(); + handled = FALSE; + return 0; +} + +LRESULT CMapWinGISControl::OnPointerDown( + UINT, + const WPARAM wParam, + const LPARAM lParam, + BOOL& handled) +{ + const UINT32 pointerId = GET_POINTERID_WPARAM(wParam); + POINTER_INPUT_TYPE pointerType{}; + mwg::ScreenPoint position; + if (!::GetPointerType(pointerId, &pointerType) || + pointerType != PT_TOUCH || + _view.GetMap().LayerCount() == 0 || + !_view.GetViewport().IsValid() || + !GetPointerPosition(lParam, true, position)) + { + handled = FALSE; + return 0; + } + + TouchPointer* available = nullptr; + for (TouchPointer& pointer : _touchPointers) + { + if (pointer.active && pointer.id == pointerId) + { + handled = TRUE; + return 0; + } + if (!pointer.active && available == nullptr) + { + available = &pointer; + } + } + if (available == nullptr) + { + handled = FALSE; + return 0; + } + + *available = {pointerId, position, true}; + if (!_isTouchNavigating) + { + (void)_view.BeginNavigationGesture(); + } + _isTouchNavigating = true; + const std::size_t activeCount = static_cast( + _touchPointers[0].active) + static_cast(_touchPointers[1].active); + if (activeCount == 1) + { + _lastPanPosition = position; + } + else + { + _lastTouchCenter = { + (_touchPointers[0].position.x + _touchPointers[1].position.x) / 2.0, + (_touchPointers[0].position.y + _touchPointers[1].position.y) / 2.0}; + _lastTouchDistance = std::hypot( + _touchPointers[0].position.x - _touchPointers[1].position.x, + _touchPointers[0].position.y - _touchPointers[1].position.y); + } + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnPointerUpdate( + UINT, + const WPARAM wParam, + const LPARAM lParam, + BOOL& handled) +{ + if (!UpdateTouchPointer(GET_POINTERID_WPARAM(wParam), lParam)) + { + handled = FALSE; + return 0; + } + ApplyTouchGesture(); + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnPointerUp( + UINT, + const WPARAM wParam, + const LPARAM lParam, + BOOL& handled) +{ + const UINT32 pointerId = GET_POINTERID_WPARAM(wParam); + if (!UpdateTouchPointer(pointerId, lParam)) + { + handled = FALSE; + return 0; + } + ApplyTouchGesture(); + for (TouchPointer& pointer : _touchPointers) + { + if (pointer.active && pointer.id == pointerId) + { + pointer.active = false; + break; + } + } + if (!_touchPointers[0].active && !_touchPointers[1].active) + { + EndTouchNavigation(); + } + else + { + const TouchPointer& remaining = + _touchPointers[0].active ? _touchPointers[0] : _touchPointers[1]; + _lastPanPosition = remaining.position; + _lastTouchDistance = 0.0; + } + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnPointerCaptureChanged( + UINT, + const WPARAM wParam, + LPARAM, + BOOL& handled) +{ + const UINT32 pointerId = GET_POINTERID_WPARAM(wParam); + for (TouchPointer& pointer : _touchPointers) + { + if (pointer.active && pointer.id == pointerId) + { + EndTouchNavigation(); + handled = TRUE; + return 0; + } + } handled = FALSE; return 0; } @@ -1265,6 +1925,9 @@ LRESULT CMapWinGISControl::OnDestroy( BOOL& handled) { EndPan(true); + EndTouchNavigation(); + (void)_view.CancelTransition(); + (void)_gdalEvents.SetAnimationTimer(false); _gdalEvents.OnDestroy(); handled = FALSE; return 0; @@ -1277,6 +1940,25 @@ void CMapWinGISControl::DispatchGdalNotifications(void* const control) noexcept BOOL handled = FALSE; try { + if (instance->_view.IsTransitioning()) + { + const auto now = std::chrono::steady_clock::now(); + const auto elapsed = + std::chrono::duration_cast( + now - instance->_lastAnimationTick); + if (elapsed.count() > 0) + { + instance->_lastAnimationTick = now; + if (instance->_view.AdvanceTransition(elapsed)) + { + instance->FireViewChange(); + } + } + if (!instance->_view.IsTransitioning()) + { + (void)instance->_gdalEvents.SetAnimationTimer(false); + } + } instance->OnGdalDispatch( mwg::activex::kGdalDispatchMessage, 0, @@ -1291,6 +1973,8 @@ void CMapWinGISControl::DispatchGdalNotifications(void* const control) noexcept STDMETHODIMP CMapWinGISControl::Close(const DWORD saveOption) { + (void)_view.CancelTransition(); + (void)_gdalEvents.SetAnimationTimer(false); _gdalEvents.Close(); return ATL::IOleObjectImpl::Close(saveOption); } diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index 268419a..183a940 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -4,8 +4,8 @@ * @file MapWinGISControl.h * @brief Declares the COM-creatable MapWinGIS ActiveX control. * - * Provides automation for vector loading and map navigation, file drop, GDAL - * operations, rendering preferences, and painting polygon layers. The control + * Provides automation for vector and raster loading, map navigation, file + * drop, GDAL operations, rendering preferences, and map-layer painting. 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. */ @@ -14,6 +14,8 @@ #include "ActiveXModule.h" #include "resource.h" +#include +#include #include #include @@ -39,8 +41,8 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, public ATL::IDispatchImpl< - IMapWinGISControl4, - &IID_IMapWinGISControl4, + IMapWinGISControl6, + &IID_IMapWinGISControl6, &LIBID_MapWinGISActiveXLib, 1, 0>, @@ -59,6 +61,8 @@ class ATL_NO_VTABLE CMapWinGISControl : DECLARE_PROTECT_FINAL_CONSTRUCT() BEGIN_COM_MAP(CMapWinGISControl) + COM_INTERFACE_ENTRY(IMapWinGISControl6) + COM_INTERFACE_ENTRY(IMapWinGISControl5) COM_INTERFACE_ENTRY(IMapWinGISControl4) COM_INTERFACE_ENTRY(IMapWinGISControl3) COM_INTERFACE_ENTRY(IMapWinGISControl2) @@ -66,7 +70,7 @@ class ATL_NO_VTABLE CMapWinGISControl : COM_INTERFACE_ENTRY(IMapWinGISControlGdal) COM_INTERFACE_ENTRY(IDropTarget) COM_INTERFACE_ENTRY(IConnectionPointContainer) - COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl4) + COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl6) COM_INTERFACE_ENTRY(IViewObjectEx) COM_INTERFACE_ENTRY(IViewObject2) COM_INTERFACE_ENTRY(IViewObject) @@ -89,12 +93,17 @@ class ATL_NO_VTABLE CMapWinGISControl : BEGIN_MSG_MAP(CMapWinGISControl) MESSAGE_HANDLER(WM_SIZE, OnSize) + MESSAGE_HANDLER(WM_KEYDOWN, OnKeyDown) MESSAGE_HANDLER(WM_MOUSEWHEEL, OnMouseWheel) MESSAGE_HANDLER(WM_LBUTTONDOWN, OnLButtonDown) MESSAGE_HANDLER(WM_MOUSEMOVE, OnMouseMove) MESSAGE_HANDLER(WM_LBUTTONUP, OnLButtonUp) MESSAGE_HANDLER(WM_CAPTURECHANGED, OnCaptureChanged) MESSAGE_HANDLER(WM_CANCELMODE, OnCancelMode) + MESSAGE_HANDLER(WM_POINTERDOWN, OnPointerDown) + MESSAGE_HANDLER(WM_POINTERUPDATE, OnPointerUpdate) + MESSAGE_HANDLER(WM_POINTERUP, OnPointerUp) + MESSAGE_HANDLER(WM_POINTERCAPTURECHANGED, OnPointerCaptureChanged) MESSAGE_HANDLER(WM_DESTROY, OnDestroy) CHAIN_MSG_MAP(ATL::CComControl) END_MSG_MAP() @@ -114,6 +123,23 @@ class ATL_NO_VTABLE CMapWinGISControl : VARIANT_BOOL* changed) override; STDMETHOD(get_CoreInitialized)(VARIANT_BOOL* initialized) override; STDMETHOD(LoadVectorDataset)(BSTR path) override; + STDMETHOD(LoadRasterDataset)(BSTR path) override; + STDMETHOD(Pan)(double deltaX, double deltaY, VARIANT_BOOL* changed) override; + STDMETHOD(ZoomAt)( + double screenX, + double screenY, + double factor, + VARIANT_BOOL* changed) override; + STDMETHOD(ZoomInBy)(double factor, VARIANT_BOOL* changed) override; + STDMETHOD(ZoomOutBy)(double factor, VARIANT_BOOL* changed) override; + STDMETHOD(get_CanGoBack)(VARIANT_BOOL* canGoBack) override; + STDMETHOD(get_CanGoForward)(VARIANT_BOOL* canGoForward) override; + STDMETHOD(GoBack)(VARIANT_BOOL* changed) override; + STDMETHOD(GoForward)(VARIANT_BOOL* changed) override; + STDMETHOD(AnimateZoomToMap)(LONG durationMilliseconds) override; + STDMETHOD(AnimateZoomToLayer)(LONGLONG layerId, LONG durationMilliseconds) override; + STDMETHOD(get_IsAnimating)(VARIANT_BOOL* isAnimating) override; + STDMETHOD(StopAnimation)() override; STDMETHOD(ClearLayers)() override; STDMETHOD(get_LayerLoaded)(VARIANT_BOOL* loaded) override; STDMETHOD(get_FeatureCount)(LONGLONG* count) override; @@ -153,12 +179,17 @@ class ATL_NO_VTABLE CMapWinGISControl : STDMETHOD(SetObjectRects)(LPCRECT position, LPCRECT clip) override; LRESULT OnSize(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnKeyDown(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnMouseWheel(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnLButtonDown(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnMouseMove(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnLButtonUp(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnCaptureChanged(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnCancelMode(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnPointerDown(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnPointerUpdate(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnPointerUp(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnPointerCaptureChanged(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnGdalDispatch(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnDestroy(UINT, WPARAM, LPARAM, BOOL& handled); static void DispatchGdalNotifications(void* control) noexcept; @@ -178,13 +209,28 @@ class ATL_NO_VTABLE CMapWinGISControl : HRESULT SetPointerCapture(bool capture) noexcept; /// Ends a drag and optionally releases capture still owned by this control. void EndPan(bool releaseCapture) noexcept; + void EndTouchNavigation() noexcept; + bool UpdateTouchPointer(UINT32 pointerId, LPARAM position) noexcept; + void ApplyTouchGesture() noexcept; + + struct TouchPointer + { + UINT32 id = 0; + mwg::ScreenPoint position{}; + bool active = false; + }; mwg::MapView _view; LONGLONG _featureCount = 0; mwg::activex::VectorLayerRenderer _renderer; mwg::activex::GdalEventSource _gdalEvents; mwg::ScreenPoint _lastPanPosition{}; + std::array _touchPointers{}; + mwg::ScreenPoint _lastTouchCenter{}; + double _lastTouchDistance = 0.0; + std::chrono::steady_clock::time_point _lastAnimationTick{}; bool _isPanning = false; + bool _isTouchNavigating = false; bool _dropAcceptsFiles = false; }; diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp index eb1f278..3d90bfd 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp @@ -223,6 +223,11 @@ namespace mwg::activex { for (const rendering::LayerRenderScene& layer : scene.layers) { + if (layer.raster.has_value() || + !layer.scene.points.empty() || !layer.scene.lines.empty()) + { + return false; + } for (const rendering::Polygon& polygon : layer.scene.polygons) { if (polygon.size() != 1 || !IsConvex(polygon.front())) diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj index 4e7f52c..d3477fb 100644 --- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj +++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj @@ -65,10 +65,12 @@ + + @@ -86,6 +88,7 @@ + diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Map.h b/src/MapWinGIS.Core/include/mapwingis/map/Map.h index 3f39034..2514a49 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/Map.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/Map.h @@ -8,9 +8,10 @@ * visibility, and calculates the combined extent of its layers. It holds no * windowing, COM, or rendering-backend state. * - * Layer coordinates are used as stored in their source. The map assumes all - * layers use compatible coordinates; it does not reproject, so layers in - * different coordinate reference systems do not align. + * The first layer with a declared coordinate reference system sets the map + * coordinate system. Layers in another declared system are reprojected into + * it; layers with a missing coordinate system can only be combined with + * similarly unreferenced layers. */ #pragma once @@ -18,6 +19,7 @@ #include #include #include +#include #include "mapwingis/core/Api.h" #include "mapwingis/core/Extent.h" @@ -25,6 +27,7 @@ namespace mwg { class Map; + class RasterDataset; class VectorDataset; class VectorLayer; @@ -52,7 +55,7 @@ namespace mwg AllLayers, }; - /// One layer of a Map: a vector layer of a dataset plus map-level state. + /// One vector or raster layer of a Map plus map-level state. /// /// Owned by its Map. A MapLayer reference stays valid until that layer is /// removed, the map is cleared, or the map is destroyed; adding, removing, @@ -72,10 +75,17 @@ namespace mwg [[nodiscard]] LayerId Id() const noexcept; /// The vector layer displayed by this map layer. - [[nodiscard]] VectorLayer& Source() const noexcept; + /// Vector source. Throws CoreError when this is a raster layer. + [[nodiscard]] VectorLayer& Source() const; /// The dataset that owns Source(). - [[nodiscard]] VectorDataset& Dataset() const noexcept; + /// Dataset owning Source(). Throws CoreError for raster layers. + [[nodiscard]] VectorDataset& Dataset() const; + + /// Raster source, or nullptr for a vector layer. + [[nodiscard]] RasterDataset* RasterSource() const noexcept; + + [[nodiscard]] bool IsRaster() const noexcept; /// 0-based index of Source() within Dataset(). [[nodiscard]] int SourceLayerIndex() const noexcept; @@ -84,8 +94,8 @@ namespace mwg /// Map::SetLayerVisible. [[nodiscard]] bool IsVisible() const noexcept; - /// Extent of the source layer, read from the layer on every call so - /// that edits to the source are reflected. Empty when the layer has no + /// Extent in the map coordinate system, read from the source layer + /// on every call so edits are reflected. Empty when the layer has no /// geometry or its extent is not valid (see Extent::IsValid). /// May read the whole layer for formats that don't store an extent; /// the source layer caches the result. @@ -128,12 +138,19 @@ namespace mwg /// Adds layer layerIndex of dataset on top of the map and returns its /// new identity. The map shares ownership of the dataset; the dataset /// is released when its last map layer is removed and no other owner - /// remains. + /// remains. Raster source ownership follows the same rule. /// \throws InvalidArgument when dataset is null or layerIndex is out of /// range. /// \throws CoreError when the map has exhausted its layer identity space. LayerId AddLayer(std::shared_ptr dataset, int layerIndex = 0); + /// Adds a raster source on top of the map and returns its identity. + /// Its data is shared with the map until the layer is removed. + /// \throws InvalidArgument when dataset is null or its coordinate + /// system cannot be combined with the map's. + /// \throws CoreError when the map has exhausted its identity space. + LayerId AddRasterLayer(std::shared_ptr dataset); + /// Removes the layer with this identity. Returns false, and changes /// nothing, when the map has no such layer. bool RemoveLayer(LayerId id) noexcept; @@ -164,6 +181,10 @@ namespace mwg /// MapLayer::GetExtent for the cost. [[nodiscard]] Extent GetExtent(ExtentScope scope = ExtentScope::VisibleLayers) const; + /// Coordinate reference system of the first added layer, as WKT. + /// Empty when the map is empty or that layer has no CRS. + [[nodiscard]] std::string CoordinateSystemWkt() const; + private: class Impl; std::unique_ptr _impl; diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h index edf4e62..260f7bc 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -13,7 +13,9 @@ */ #pragma once +#include #include +#include #include "mapwingis/core/Api.h" #include "mapwingis/map/Map.h" @@ -22,6 +24,7 @@ namespace mwg { + class RasterDataset; class VectorDataset; /// A map, the viewport it is shown in, and its render scene. @@ -40,6 +43,7 @@ namespace mwg /// /// Navigation: FitToMap, ZoomToLayer, ZoomIn, ZoomOut, ZoomAt, and Pan /// change the viewport; they depend on no window, input, or renderer. + /// Changed views are retained in a bounded back/forward history. /// A later fit replaces a pending fit, and a navigation operation that /// changes the view cancels it, so the latest request wins. /// @@ -67,6 +71,11 @@ namespace mwg /// \throws InvalidArgument as Map::AddLayer does. LayerId AddLayer(std::shared_ptr dataset, int layerIndex = 0); + /// Adds a raster layer on top of the map; fits the view to the map + /// when the viewport has no view yet. + /// \throws InvalidArgument as Map::AddRasterLayer does. + LayerId AddRasterLayer(std::shared_ptr dataset); + /// Removes a layer; see Map::RemoveLayer. The view is kept. bool RemoveLayer(LayerId id) noexcept; @@ -134,6 +143,52 @@ namespace mwg /// \throws InvalidArgument as Viewport::Pan does. bool Pan(double deltaX, double deltaY); + /// Groups subsequent view changes into one navigation-history entry. + /// Returns false when a group is active or no view exists. + bool BeginNavigationGesture() noexcept; + + /// Ends an active navigation group and records its aggregate change. + /// Returns true when the group changed the view. + bool EndNavigationGesture() noexcept; + + /// True when a prior view can be restored. + [[nodiscard]] bool CanGoBack() const noexcept; + + /// True when a forward view can be restored. + [[nodiscard]] bool CanGoForward() const noexcept; + + /// Restores the previous view, preserving the current viewport size. + /// Returns false when no prior view exists. + bool GoBack() noexcept; + + /// Restores the next view, preserving the current viewport size. + /// Returns false when no forward view exists. + bool GoForward() noexcept; + + /// Starts an eased transition to the full visible map extent. + /// The host advances it by calling AdvanceTransition from its timer. + /// Returns false when no transition is needed or the viewport/map + /// cannot provide a target view. + /// \throws InvalidArgument when duration is not positive. + bool BeginTransitionToMap(std::chrono::milliseconds duration); + + /// Starts an eased transition to one layer's extent. + /// \throws InvalidArgument when duration is not positive. + bool BeginTransitionToLayer( + LayerId id, + std::chrono::milliseconds duration); + + /// Advances an active transition by elapsed time and returns true + /// when the viewport changed. + /// \throws InvalidArgument when elapsed time is negative. + bool AdvanceTransition(std::chrono::milliseconds elapsed); + + /// Cancels an active transition, retaining its current view. + /// The partial transition is recorded as one history entry. + bool CancelTransition() noexcept; + + [[nodiscard]] bool IsTransitioning() const noexcept; + /// 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 @@ -146,8 +201,16 @@ namespace mwg bool InvalidateScene() noexcept; private: + struct NavigationState + { + MapPoint center; + double resolution = 0.0; + }; + /// Clears a pending fit when changed is true and returns changed. - bool ViewChanged(bool changed) noexcept; + bool ViewChanged( + bool changed, + const std::optional& previousState) noexcept; class Impl; std::unique_ptr _impl; diff --git a/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h b/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h new file mode 100644 index 0000000..c8689b4 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file RasterDataset.h + * @brief Declares the read-only raster dataset and display-image API. + * + * Raster datasets expose their georeferenced bounds and a bounded RGBA + * preview for map rendering. GDAL and raster-driver types stay private. + */ +#pragma once + +#include +#include +#include +#include +#include + +#include "mapwingis/core/Api.h" +#include "mapwingis/core/Extent.h" + +namespace mwg +{ + /// Top-down RGBA pixels and their map-coordinate bounds. + struct MWG_CORE_API RasterImage + { + int width = 0; + int height = 0; + Extent extent; + /// Premultiplied-alpha pixels are 0xAARRGGBB; row zero is the top row. + std::vector pixels; + }; + + /// An open read-only GDAL raster data source. + /// + /// ReadImage limits its longest output edge to maxDimension and uses + /// nearest-neighbour resampling. Single-band data is displayed as + /// grayscale, paletted data uses its color table, and multiband data uses + /// RGB color interpretations or the first three bands. Nodata and alpha + /// bands are transparent. A non-empty target WKT reprojects the preview + /// before sampling. + class MWG_CORE_API RasterDataset + { + public: + ~RasterDataset(); + RasterDataset(const RasterDataset&) = delete; + RasterDataset& operator=(const RasterDataset&) = delete; + + [[nodiscard]] static std::shared_ptr Open( + const std::filesystem::path& path); + + [[nodiscard]] std::filesystem::path Path() const; + [[nodiscard]] int Width() const noexcept; + [[nodiscard]] int Height() const noexcept; + [[nodiscard]] int BandCount() const noexcept; + [[nodiscard]] Extent GetExtent() const; + [[nodiscard]] std::string ProjectionWkt() const; + + [[nodiscard]] RasterImage ReadImage( + const std::string& targetProjectionWkt = {}, + int maxDimension = 2048) const; + + private: + class Impl; + explicit RasterDataset(std::unique_ptr 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 index ddb2dbe..522498b 100644 --- a/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h @@ -12,10 +12,12 @@ #pragma once #include +#include #include #include "mapwingis/core/Api.h" #include "mapwingis/map/Map.h" +#include "mapwingis/raster/RasterDataset.h" #include "mapwingis/rendering/VectorRenderScene.h" namespace mwg::rendering @@ -25,8 +27,10 @@ namespace mwg::rendering { /// Identity of the map layer the geometry was copied from. LayerId layerId; - /// Polygon geometry in map coordinates; never empty in a MapRenderScene. + /// Point, line, and polygon geometry in map coordinates. VectorRenderScene scene; + /// Bounded raster preview when the layer is raster-backed. + std::optional raster; }; /// Why CreateMapRenderScene left a map layer out of the scene. @@ -34,7 +38,7 @@ namespace mwg::rendering { /// The layer is hidden. Hidden, - /// The layer has no polygon geometry to draw, for example an empty layer. + /// The layer has no drawable vector geometry, 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. @@ -59,14 +63,14 @@ namespace mwg::rendering std::vector skippedLayers; }; - /// Copies the drawable geometry of every visible map layer into owned - /// scene data, in map order (see Map). + /// Copies drawable vector geometry of every visible map layer into + /// owned scene data in map order and the map coordinate system. /// /// 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. + /// layers from being drawn. Hidden layers and layers without drawable + /// geometry are listed in skippedLayers too. Layers with differing, + /// declared coordinate systems are reprojected to that of the first layer. /// /// 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 diff --git a/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h b/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h index 0ced468..766547f 100644 --- a/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h @@ -2,7 +2,7 @@ /** * @file VectorRenderScene.h - * @brief Declares backend-neutral polygon scene data for vector rendering. + * @brief Declares backend-neutral vector geometry for rendering. * * Converts polygon features from a vector layer into owned coordinate data * that renderers can consume without retaining dataset-owned objects. @@ -35,32 +35,34 @@ namespace mwg /// A polygon and its rings. The first ring is the exterior boundary. using Polygon = std::vector; - /// Owned polygon geometry and its source-coordinate bounds. + /// Owned point, line, and polygon geometry in map coordinates. struct MWG_CORE_API VectorRenderScene { Extent extent; /// Feature count of the layer used to create this scene. std::int64_t sourceFeatureCount = 0; std::vector polygons; + std::vector points; + std::vector lines; }; /// Copies polygon geometry from a layer into backend-neutral scene data. /// - /// Supports Polygon and MultiPolygon layers. Empty geometries are - /// skipped. The returned coordinates remain in the layer's coordinate - /// system; this function does not transform projections. Not - /// thread-safe, consistent with the input layer's contract. - /// \throws CoreError when the layer is not polygonal or contains no - /// polygon geometry to render. + /// Supports Point, MultiPoint, LineString, MultiLineString, Polygon, + /// MultiPolygon, and their curved OGR equivalents. Empty geometries + /// are skipped. Coordinates remain in the layer's coordinate system. + /// Not thread-safe, consistent with the input layer's contract. + /// \throws CoreError when the layer uses an unsupported geometry type + /// or contains no geometry to render. [[nodiscard]] MWG_CORE_API VectorRenderScene CreateVectorRenderScene( const VectorLayer& layer); - /// Copies polygon geometry from the first layer into owned scene data. + /// Copies vector geometry from the first layer into owned scene data. /// /// A dataset with no layers is rejected. Only the first layer is /// considered. The scene's sourceFeatureCount matches that layer. /// \throws CoreError when the dataset has no layers or its first layer - /// is not polygonal or contains no polygon geometry to render. + /// has an unsupported geometry type or contains no geometry to render. [[nodiscard]] MWG_CORE_API VectorRenderScene CreateVectorRenderScene( const VectorDataset& dataset); } diff --git a/src/MapWinGIS.Core/src/internal/CoordinateTransformation.h b/src/MapWinGIS.Core/src/internal/CoordinateTransformation.h new file mode 100644 index 0000000..5bb7ada --- /dev/null +++ b/src/MapWinGIS.Core/src/internal/CoordinateTransformation.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file CoordinateTransformation.h + * @brief Provides private OGR coordinate transformations for map data. + */ +#pragma once + +#include +#include + +#include + +#include "mapwingis/core/Error.h" + +namespace mwg::detail +{ + class CoordinateTransformation final + { + public: + CoordinateTransformation(const std::string& sourceWkt, const std::string& targetWkt) + { + if (sourceWkt.empty() || targetWkt.empty()) + { + if (sourceWkt != targetWkt) + { + throw InvalidArgument( + "Layers with and without a coordinate reference system cannot be combined"); + } + return; + } + + _source = std::make_unique(); + _target = std::make_unique(); + _source->SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + _target->SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + if (_source->SetFromUserInput(sourceWkt.c_str()) != OGRERR_NONE || + _target->SetFromUserInput(targetWkt.c_str()) != OGRERR_NONE) + { + throw InvalidArgument("Layer coordinate reference system is invalid"); + } + if (_source->IsSame(_target.get())) + { + return; + } + + _transform.reset(OGRCreateCoordinateTransformation(_source.get(), _target.get())); + if (_transform == nullptr) + { + throw CoreError("Cannot transform between the layer coordinate reference systems"); + } + } + + [[nodiscard]] bool IsNeeded() const noexcept + { + return _transform != nullptr; + } + + [[nodiscard]] bool Apply(double& x, double& y) const noexcept + { + if (_transform == nullptr) + { + return true; + } + return _transform->Transform(1, &x, &y) != FALSE; + } + + [[nodiscard]] OGRCoordinateTransformation* Get() const noexcept + { + return _transform.get(); + } + + private: + struct DestroyTransform + { + void operator()(OGRCoordinateTransformation* value) const noexcept + { + if (value != nullptr) + { + OCTDestroyCoordinateTransformation(value); + } + } + }; + + std::unique_ptr _source; + std::unique_ptr _target; + std::unique_ptr _transform; + }; +} diff --git a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h index 893579e..ca71e51 100644 --- a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h +++ b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h @@ -4,19 +4,23 @@ * @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 + * Lets map scene creation tell layers without drawable geometry apart from * layers that cannot be rendered, without parsing exception messages. */ #pragma once +#include + #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 + /// Copies point, line, and polygon geometry from a layer into scene data. Unlike + /// CreateVectorRenderScene, a layer without drawable geometry gives a /// scene with no polygons instead of an error. - /// \throws CoreError when the layer is not polygonal or its geometry + /// \throws CoreError when the layer has an unsupported geometry type or its geometry /// cannot be converted. - [[nodiscard]] VectorRenderScene BuildVectorRenderScene(const VectorLayer& layer); + [[nodiscard]] VectorRenderScene BuildVectorRenderScene( + const VectorLayer& layer, + const std::string& mapProjectionWkt = {}); } diff --git a/src/MapWinGIS.Core/src/map/Map.cpp b/src/MapWinGIS.Core/src/map/Map.cpp index 33a2b8c..52cb7ce 100644 --- a/src/MapWinGIS.Core/src/map/Map.cpp +++ b/src/MapWinGIS.Core/src/map/Map.cpp @@ -7,12 +7,15 @@ #include "mapwingis/map/Map.h" #include +#include #include #include #include #include +#include "internal/CoordinateTransformation.h" #include "mapwingis/core/Error.h" +#include "mapwingis/raster/RasterDataset.h" #include "mapwingis/vector/VectorDataset.h" namespace mwg @@ -20,18 +23,35 @@ namespace mwg class MapLayer::Impl { public: - Impl(const LayerId id, std::shared_ptr dataset, const int sourceLayerIndex) + Impl( + const LayerId id, + std::shared_ptr dataset, + const int sourceLayerIndex, + std::string mapProjectionWkt) : _id(id), _dataset(std::move(dataset)), _source(&_dataset->Layer(sourceLayerIndex)), - _sourceLayerIndex(sourceLayerIndex) + _sourceLayerIndex(sourceLayerIndex), + _mapProjectionWkt(std::move(mapProjectionWkt)) + { + } + + Impl( + const LayerId id, + std::shared_ptr raster, + std::string mapProjectionWkt) + : _id(id), + _raster(std::move(raster)), + _mapProjectionWkt(std::move(mapProjectionWkt)) { } LayerId _id; std::shared_ptr _dataset; - VectorLayer* _source; - int _sourceLayerIndex; + std::shared_ptr _raster; + VectorLayer* _source = nullptr; + int _sourceLayerIndex = 0; + std::string _mapProjectionWkt; bool _visible = true; }; @@ -47,16 +67,34 @@ namespace mwg return _impl->_id; } - VectorLayer& MapLayer::Source() const noexcept + VectorLayer& MapLayer::Source() const { + if (_impl->_source == nullptr) + { + throw CoreError("Raster map layers do not have a vector source"); + } return *_impl->_source; } - VectorDataset& MapLayer::Dataset() const noexcept + VectorDataset& MapLayer::Dataset() const { + if (_impl->_dataset == nullptr) + { + throw CoreError("Raster map layers do not have a vector dataset"); + } return *_impl->_dataset; } + RasterDataset* MapLayer::RasterSource() const noexcept + { + return _impl->_raster.get(); + } + + bool MapLayer::IsRaster() const noexcept + { + return _impl->_raster != nullptr; + } + int MapLayer::SourceLayerIndex() const noexcept { return _impl->_sourceLayerIndex; @@ -69,8 +107,45 @@ namespace mwg Extent MapLayer::GetExtent() const { - const Extent extent = _impl->_source->GetExtent(); - return extent.IsValid() ? extent : Extent{}; + const Extent extent = IsRaster() + ? _impl->_raster->GetExtent() + : _impl->_source->GetExtent(); + if (!extent.IsValid()) + { + return {}; + } + + const std::string sourceWkt = IsRaster() + ? _impl->_raster->ProjectionWkt() + : _impl->_source->ProjectionWkt(); + detail::CoordinateTransformation transform(sourceWkt, _impl->_mapProjectionWkt); + if (!transform.IsNeeded()) + { + return extent; + } + + Extent result; + constexpr int subdivisions = 32; + for (int index = 0; index <= subdivisions; ++index) + { + const double fraction = static_cast(index) / subdivisions; + const double x = extent.xMin + (extent.xMax - extent.xMin) * fraction; + const double y = extent.yMin + (extent.yMax - extent.yMin) * fraction; + const double edgeX[] = {x, extent.xMax, x, extent.xMin}; + const double edgeY[] = {extent.yMin, y, extent.yMax, y}; + for (int edge = 0; edge < 4; ++edge) + { + double transformedX = edgeX[edge]; + double transformedY = edgeY[edge]; + if (!transform.Apply(transformedX, transformedY) || + !std::isfinite(transformedX) || !std::isfinite(transformedY)) + { + throw CoreError("Cannot transform the layer extent into the map coordinate system"); + } + result = result.Union({transformedX, transformedY, transformedX, transformedY}); + } + } + return result; } class Map::Impl @@ -92,6 +167,7 @@ namespace mwg // vector reorders or reallocates. std::vector> _layers; std::uint64_t _lastId = 0; + std::string _coordinateSystemWkt; }; Map::Map() @@ -116,10 +192,50 @@ namespace mwg throw CoreError("Map layer identity space is exhausted"); } + const std::string sourceWkt = dataset->Layer(layerIndex).ProjectionWkt(); + const std::string& mapWkt = _impl->_layers.empty() + ? sourceWkt + : _impl->_coordinateSystemWkt; + detail::CoordinateTransformation validateTransform(sourceWkt, mapWkt); + const LayerId id{_impl->_lastId + 1}; auto layer = std::unique_ptr( - new MapLayer(std::make_unique(id, std::move(dataset), layerIndex))); + new MapLayer(std::make_unique( + id, std::move(dataset), layerIndex, mapWkt))); _impl->_layers.push_back(std::move(layer)); + if (_impl->_layers.size() == 1) + { + _impl->_coordinateSystemWkt = sourceWkt; + } + _impl->_lastId = id.value; + return id; + } + + LayerId Map::AddRasterLayer(std::shared_ptr dataset) + { + if (dataset == nullptr) + { + throw InvalidArgument("Cannot add a layer without a raster dataset"); + } + if (_impl->_lastId == std::numeric_limits::max()) + { + throw CoreError("Map layer identity space is exhausted"); + } + + const std::string sourceWkt = dataset->ProjectionWkt(); + const std::string& mapWkt = _impl->_layers.empty() + ? sourceWkt + : _impl->_coordinateSystemWkt; + detail::CoordinateTransformation validateTransform(sourceWkt, mapWkt); + + const LayerId id{_impl->_lastId + 1}; + auto layer = std::unique_ptr( + new MapLayer(std::make_unique(id, std::move(dataset), mapWkt))); + _impl->_layers.push_back(std::move(layer)); + if (_impl->_layers.size() == 1) + { + _impl->_coordinateSystemWkt = sourceWkt; + } _impl->_lastId = id.value; return id; } @@ -133,12 +249,17 @@ namespace mwg } _impl->_layers.erase(layer); + if (_impl->_layers.empty()) + { + _impl->_coordinateSystemWkt.clear(); + } return true; } void Map::Clear() noexcept { _impl->_layers.clear(); + _impl->_coordinateSystemWkt.clear(); } int Map::LayerCount() const noexcept @@ -196,4 +317,9 @@ namespace mwg } return combined; } -} + + std::string Map::CoordinateSystemWkt() const + { + return _impl->_coordinateSystemWkt; + } + } diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp index f36b8d6..79bed26 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -6,9 +6,11 @@ */ #include "mapwingis/map/MapView.h" +#include #include #include #include +#include #include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" @@ -17,6 +19,14 @@ namespace mwg { namespace { + void ValidateTransitionDuration(const std::chrono::milliseconds duration) + { + if (duration.count() <= 0) + { + throw InvalidArgument("Transition duration must be positive"); + } + } + void ValidateStepFactor(const double factor) { if (!std::isfinite(factor) || factor <= 1.0) @@ -29,6 +39,8 @@ namespace mwg class MapView::Impl { public: + static constexpr std::size_t kMaxHistoryEntries = 100; + /// A fit that waits for the viewport to get a size. struct PendingFit { @@ -37,6 +49,129 @@ namespace mwg double padding = 0.0; }; + struct Transition + { + NavigationState start; + NavigationState target; + std::chrono::milliseconds duration; + std::chrono::milliseconds elapsed{0}; + }; + + Impl() + { + _backHistory.reserve(kMaxHistoryEntries); + _forwardHistory.reserve(kMaxHistoryEntries); + } + + [[nodiscard]] std::optional CaptureView() const noexcept + { + const std::optional center = _viewport.Center(); + const std::optional resolution = _viewport.Resolution(); + if (!center.has_value() || !resolution.has_value()) + { + return std::nullopt; + } + return NavigationState{*center, *resolution}; + } + + static void PushHistory( + std::vector& history, + const NavigationState& state) noexcept + { + if (history.size() == kMaxHistoryEntries) + { + history.erase(history.begin()); + } + history.push_back(state); + } + + bool RecordViewChange(const std::optional& previous) noexcept + { + if (_navigationGestureStart.has_value()) + { + return false; + } + const std::optional current = CaptureView(); + if (!previous.has_value() || !current.has_value() || + (previous->center.x == current->center.x && + previous->center.y == current->center.y && + previous->resolution == current->resolution)) + { + return false; + } + + PushHistory(_backHistory, *previous); + _forwardHistory.clear(); + return true; + } + + bool EndNavigationGesture() noexcept + { + if (!_navigationGestureStart.has_value()) + { + return false; + } + const NavigationState start = *_navigationGestureStart; + _navigationGestureStart.reset(); + return RecordViewChange(start); + } + + bool BeginTransition( + const Viewport& targetViewport, + const std::chrono::milliseconds duration) + { + ValidateTransitionDuration(duration); + (void)EndNavigationGesture(); + + const std::optional start = CaptureView(); + const std::optional targetCenter = targetViewport.Center(); + const std::optional targetResolution = targetViewport.Resolution(); + if (!start.has_value() || !targetCenter.has_value() || + !targetResolution.has_value()) + { + _transition.reset(); + return false; + } + + const NavigationState target{*targetCenter, *targetResolution}; + if (start->center.x == target.center.x && + start->center.y == target.center.y && + start->resolution == target.resolution) + { + _transition.reset(); + return false; + } + + _pendingFit.reset(); + _transition = Transition{*start, target, duration}; + return true; + } + + bool RestoreHistory( + std::vector& source, + std::vector& destination) noexcept + { + (void)EndNavigationGesture(); + if (source.empty()) + { + return false; + } + + const std::optional current = CaptureView(); + if (!current.has_value()) + { + return false; + } + + _transition.reset(); + const NavigationState target = source.back(); + source.pop_back(); + PushHistory(destination, *current); + _viewport.SetView(target.center, target.resolution); + _pendingFit.reset(); + return true; + } + /// Fits extent and returns true, or keeps the fit pending when only /// the viewport size prevents it. bool Fit(const Extent& extent, const PendingFit& request) @@ -65,6 +200,10 @@ namespace mwg rendering::MapRenderScene _scene; bool _sceneStale = false; std::optional _pendingFit; + std::vector _backHistory; + std::vector _forwardHistory; + std::optional _transition; + std::optional _navigationGestureStart; }; MapView::MapView() @@ -87,6 +226,8 @@ namespace mwg LayerId MapView::AddLayer(std::shared_ptr dataset, const int layerIndex) { const LayerId id = _impl->_map.AddLayer(std::move(dataset), layerIndex); + (void)EndNavigationGesture(); + _impl->_transition.reset(); _impl->_sceneStale = true; if (!_impl->_viewport.HasView()) { @@ -104,12 +245,35 @@ namespace mwg return id; } + LayerId MapView::AddRasterLayer(std::shared_ptr dataset) + { + const LayerId id = _impl->_map.AddRasterLayer(std::move(dataset)); + (void)EndNavigationGesture(); + _impl->_transition.reset(); + _impl->_sceneStale = true; + if (!_impl->_viewport.HasView()) + { + try + { + (void)FitToMap(); + } + catch (...) + { + (void)_impl->_map.RemoveLayer(id); + throw; + } + } + return id; + } + bool MapView::RemoveLayer(const LayerId id) noexcept { if (!_impl->_map.RemoveLayer(id)) { return false; } + (void)EndNavigationGesture(); + _impl->_transition.reset(); _impl->_sceneStale = true; return true; } @@ -120,7 +284,9 @@ namespace mwg { return false; } + (void)EndNavigationGesture(); _impl->_map.Clear(); + _impl->_transition.reset(); _impl->_sceneStale = true; return true; } @@ -132,7 +298,9 @@ namespace mwg { return false; } + (void)EndNavigationGesture(); (void)_impl->_map.SetLayerVisible(id, visible); + _impl->_transition.reset(); _impl->_sceneStale = true; return true; } @@ -159,7 +327,14 @@ namespace mwg bool MapView::FitToMap(const double paddingPixels) { - return _impl->Fit(_impl->_map.GetExtent(), {std::nullopt, paddingPixels}); + (void)EndNavigationGesture(); + _impl->_transition.reset(); + const std::optional previous = _impl->CaptureView(); + const bool fitted = _impl->Fit( + _impl->_map.GetExtent(), + {std::nullopt, paddingPixels}); + _impl->RecordViewChange(previous); + return fitted; } bool MapView::ZoomToLayer(const LayerId id, const double paddingPixels) @@ -171,43 +346,197 @@ namespace mwg const MapLayer* layer = _impl->_map.FindLayer(id); if (layer == nullptr) { + (void)EndNavigationGesture(); _impl->_pendingFit.reset(); + _impl->_transition.reset(); return false; } - return _impl->Fit(layer->GetExtent(), {id, paddingPixels}); + (void)EndNavigationGesture(); + _impl->_transition.reset(); + const std::optional previous = _impl->CaptureView(); + const bool fitted = _impl->Fit(layer->GetExtent(), {id, paddingPixels}); + _impl->RecordViewChange(previous); + return fitted; } bool MapView::ZoomIn(const double factor) { ValidateStepFactor(factor); - return ViewChanged(_impl->_viewport.Zoom(factor)); + const std::optional previous = _impl->CaptureView(); + return ViewChanged(_impl->_viewport.Zoom(factor), previous); } bool MapView::ZoomOut(const double factor) { ValidateStepFactor(factor); - return ViewChanged(_impl->_viewport.ZoomOut(factor)); + const std::optional previous = _impl->CaptureView(); + return ViewChanged(_impl->_viewport.ZoomOut(factor), previous); } bool MapView::ZoomAt(const ScreenPoint& anchor, const double factor) { - return ViewChanged(_impl->_viewport.ZoomAt(anchor, factor)); + const std::optional previous = _impl->CaptureView(); + return ViewChanged(_impl->_viewport.ZoomAt(anchor, factor), previous); } bool MapView::Pan(const double deltaX, const double deltaY) { - return ViewChanged(_impl->_viewport.Pan(deltaX, deltaY)); + const std::optional previous = _impl->CaptureView(); + return ViewChanged(_impl->_viewport.Pan(deltaX, deltaY), previous); } - bool MapView::ViewChanged(const bool changed) noexcept + bool MapView::BeginNavigationGesture() noexcept { - if (changed) + if (_impl->_navigationGestureStart.has_value()) { - _impl->_pendingFit.reset(); + return false; + } + const std::optional current = _impl->CaptureView(); + if (!current.has_value()) + { + return false; + } + _impl->_navigationGestureStart = *current; + return true; + } + + bool MapView::EndNavigationGesture() noexcept + { + return _impl->EndNavigationGesture(); + } + + bool MapView::CanGoBack() const noexcept + { + return !_impl->_backHistory.empty(); + } + + bool MapView::CanGoForward() const noexcept + { + return !_impl->_forwardHistory.empty(); + } + + bool MapView::GoBack() noexcept + { + return _impl->RestoreHistory(_impl->_backHistory, _impl->_forwardHistory); + } + + bool MapView::GoForward() noexcept + { + return _impl->RestoreHistory(_impl->_forwardHistory, _impl->_backHistory); + } + + bool MapView::BeginTransitionToMap(const std::chrono::milliseconds duration) + { + ValidateTransitionDuration(duration); + Viewport target = _impl->_viewport; + if (!target.FitExtent(_impl->_map.GetExtent(), kDefaultPadding)) + { + _impl->_transition.reset(); + return false; + } + return _impl->BeginTransition(target, duration); + } + + bool MapView::BeginTransitionToLayer( + const LayerId id, + const std::chrono::milliseconds duration) + { + ValidateTransitionDuration(duration); + const MapLayer* layer = _impl->_map.FindLayer(id); + if (layer == nullptr) + { + _impl->_transition.reset(); + return false; + } + + Viewport target = _impl->_viewport; + if (!target.FitExtent(layer->GetExtent(), kDefaultPadding)) + { + _impl->_transition.reset(); + return false; + } + return _impl->BeginTransition(target, duration); + } + + bool MapView::AdvanceTransition(const std::chrono::milliseconds elapsed) + { + if (elapsed.count() < 0) + { + throw InvalidArgument("Transition elapsed time cannot be negative"); + } + if (!_impl->_transition.has_value() || elapsed.count() == 0) + { + return false; + } + + Impl::Transition& transition = *_impl->_transition; + const std::chrono::milliseconds remaining = + transition.duration - transition.elapsed; + transition.elapsed += std::min(elapsed, remaining); + const double progress = std::clamp( + static_cast(transition.elapsed.count()) / + static_cast(transition.duration.count()), + 0.0, + 1.0); + const double eased = progress * progress * (3.0 - 2.0 * progress); + const auto interpolate = [eased](const double start, const double target) + { + return start + (target - start) * eased; + }; + const double resolution = std::exp( + std::log(transition.start.resolution) + + (std::log(transition.target.resolution) - + std::log(transition.start.resolution)) * eased); + const NavigationState start = transition.start; + _impl->_viewport.SetView( + { + interpolate(start.center.x, transition.target.center.x), + interpolate(start.center.y, transition.target.center.y)}, + resolution); + const bool changed = + _impl->_viewport.Center()->x != start.center.x || + _impl->_viewport.Center()->y != start.center.y || + *_impl->_viewport.Resolution() != start.resolution; + + if (transition.elapsed == transition.duration) + { + _impl->RecordViewChange(start); + _impl->_transition.reset(); } return changed; } + bool MapView::CancelTransition() noexcept + { + if (!_impl->_transition.has_value()) + { + return false; + } + (void)_impl->RecordViewChange(_impl->_transition->start); + _impl->_transition.reset(); + return true; + } + + bool MapView::IsTransitioning() const noexcept + { + return _impl->_transition.has_value(); + } + + bool MapView::ViewChanged( + const bool changed, + const std::optional& previousState) noexcept + { + if (!changed) + { + return false; + } + + _impl->_pendingFit.reset(); + _impl->_transition.reset(); + _impl->RecordViewChange(previousState); + return true; + } + const rendering::MapRenderScene& MapView::Scene() { if (_impl->_sceneStale) diff --git a/src/MapWinGIS.Core/src/raster/RasterDataset.cpp b/src/MapWinGIS.Core/src/raster/RasterDataset.cpp new file mode 100644 index 0000000..a881ae3 --- /dev/null +++ b/src/MapWinGIS.Core/src/raster/RasterDataset.cpp @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file RasterDataset.cpp + * @brief Implements read-only raster access and bounded display-image reads. + */ +#include "mapwingis/raster/RasterDataset.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "internal/CoordinateTransformation.h" +#include "internal/Utf8.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/gdal/GdalEnvironment.h" + +namespace mwg +{ + namespace + { + struct CloseDataset + { + void operator()(GDALDataset* dataset) const noexcept + { + if (dataset != nullptr) + { + GDALClose(dataset); + } + } + }; + + using DatasetPtr = std::unique_ptr; + + [[nodiscard]] std::string PathUtf8(const std::filesystem::path& path) + { + const std::u8string utf8 = path.u8string(); + return {reinterpret_cast(utf8.data()), utf8.size()}; + } + + [[nodiscard]] Extent DatasetExtent(const GDALDataset& dataset) + { + double transform[6]{}; + if (dataset.GetGeoTransform(transform) != CE_None) + { + transform[0] = 0.0; + transform[1] = 1.0; + transform[2] = 0.0; + transform[3] = static_cast(dataset.GetRasterYSize()); + transform[4] = 0.0; + transform[5] = -1.0; + } + + const double width = static_cast(dataset.GetRasterXSize()); + const double height = static_cast(dataset.GetRasterYSize()); + const double x[] = { + transform[0], + transform[0] + width * transform[1], + transform[0] + width * transform[1] + height * transform[2], + transform[0] + height * transform[2]}; + const double y[] = { + transform[3], + transform[3] + width * transform[4], + transform[3] + width * transform[4] + height * transform[5], + transform[3] + height * transform[5]}; + + Extent extent; + for (int index = 0; index < 4; ++index) + { + if (!std::isfinite(x[index]) || !std::isfinite(y[index])) + { + throw CoreError("Raster geotransform produced non-finite bounds"); + } + extent = extent.Union({x[index], y[index], x[index], y[index]}); + } + return extent; + } + + [[nodiscard]] double SampleMinimum(GDALRasterBand& band, const GDALDataType type) + { + if (type == GDT_Byte) + { + return 0.0; + } + int success = FALSE; + const double minimum = band.GetMinimum(&success); + if (success != FALSE && std::isfinite(minimum)) + { + return minimum; + } + double limits[2]{}; + if (band.ComputeRasterMinMax(TRUE, limits) != CE_None || + !std::isfinite(limits[0])) + { + throw CoreError("Cannot determine raster band display range"); + } + return limits[0]; + } + + [[nodiscard]] double SampleMaximum(GDALRasterBand& band, const GDALDataType type) + { + if (type == GDT_Byte) + { + return 255.0; + } + int success = FALSE; + const double maximum = band.GetMaximum(&success); + if (success != FALSE && std::isfinite(maximum)) + { + return maximum; + } + double limits[2]{}; + if (band.ComputeRasterMinMax(TRUE, limits) != CE_None || + !std::isfinite(limits[1])) + { + throw CoreError("Cannot determine raster band display range"); + } + return limits[1]; + } + + [[nodiscard]] std::vector ReadBand( + GDALRasterBand& band, + const int width, + const int height) + { + const std::size_t count = static_cast(width) * + static_cast(height); + std::vector samples(count); + if (band.RasterIO( + GF_Read, + 0, + 0, + band.GetXSize(), + band.GetYSize(), + samples.data(), + width, + height, + GDT_Float32, + 0, + 0) != CE_None) + { + throw CoreError("Cannot read raster band for display"); + } + return samples; + } + + [[nodiscard]] std::uint8_t ScaleSample( + const float sample, + const double minimum, + const double maximum) noexcept + { + if (!std::isfinite(sample)) + { + return 0; + } + if (maximum <= minimum) + { + return static_cast( + std::clamp(std::round(static_cast(sample)), 0.0, 255.0)); + } + const double normalized = (static_cast(sample) - minimum) / + (maximum - minimum); + return static_cast( + std::clamp(std::round(normalized * 255.0), 0.0, 255.0)); + } + + [[nodiscard]] std::uint8_t ScaleAlphaSample( + const float sample, + const double minimum, + const double maximum) noexcept + { + if (maximum <= minimum && minimum >= 0.0 && maximum <= 1.0) + { + return static_cast( + std::clamp(std::round(static_cast(sample) * 255.0), 0.0, 255.0)); + } + return ScaleSample(sample, minimum, maximum); + } + } + + class RasterDataset::Impl + { + public: + Impl(std::filesystem::path path, DatasetPtr dataset) + : _path(std::move(path)), _dataset(std::move(dataset)) + { + } + + std::filesystem::path _path; + DatasetPtr _dataset; + }; + + RasterDataset::RasterDataset(std::unique_ptr impl) + : _impl(std::move(impl)) + { + } + + RasterDataset::~RasterDataset() = default; + + std::shared_ptr RasterDataset::Open(const std::filesystem::path& path) + { + GdalEnvironment::Initialize(); + DatasetPtr dataset(static_cast(GDALOpenEx( + PathUtf8(path).c_str(), + GDAL_OF_RASTER | GDAL_OF_READONLY, + nullptr, + nullptr, + nullptr))); + if (dataset == nullptr) + { + throw OpenFailed("Cannot open raster dataset: " + PathUtf8(path)); + } + if (dataset->GetRasterXSize() <= 0 || dataset->GetRasterYSize() <= 0 || + dataset->GetRasterCount() <= 0) + { + throw OpenFailed("Raster dataset has no readable pixels: " + PathUtf8(path)); + } + return std::shared_ptr( + new RasterDataset(std::make_unique(path, std::move(dataset)))); + } + + std::filesystem::path RasterDataset::Path() const + { + return _impl->_path; + } + + int RasterDataset::Width() const noexcept + { + return _impl->_dataset->GetRasterXSize(); + } + + int RasterDataset::Height() const noexcept + { + return _impl->_dataset->GetRasterYSize(); + } + + int RasterDataset::BandCount() const noexcept + { + return _impl->_dataset->GetRasterCount(); + } + + Extent RasterDataset::GetExtent() const + { + return DatasetExtent(*_impl->_dataset); + } + + std::string RasterDataset::ProjectionWkt() const + { + const char* projection = _impl->_dataset->GetProjectionRef(); + return projection != nullptr ? projection : ""; + } + + RasterImage RasterDataset::ReadImage( + const std::string& targetProjectionWkt, + const int maxDimension) const + { + if (maxDimension <= 0) + { + throw InvalidArgument("Raster image maximum dimension must be positive"); + } + + GDALDataset* source = _impl->_dataset.get(); + const std::string sourceWkt = ProjectionWkt(); + std::unique_ptr warped; + const detail::CoordinateTransformation transform( + sourceWkt, + targetProjectionWkt.empty() ? sourceWkt : targetProjectionWkt); + double geotransform[6]{}; + const bool hasGeoTransform = source->GetGeoTransform(geotransform) == CE_None; + const bool isRotated = hasGeoTransform && + (geotransform[2] != 0.0 || geotransform[4] != 0.0); + if (transform.IsNeeded() || isRotated) + { + const std::string& destinationWkt = + targetProjectionWkt.empty() ? sourceWkt : targetProjectionWkt; + const char* sourceProjection = sourceWkt.empty() ? nullptr : sourceWkt.c_str(); + const char* destinationProjection = + destinationWkt.empty() ? nullptr : destinationWkt.c_str(); + warped.reset(static_cast(GDALAutoCreateWarpedVRT( + source, + sourceProjection, + destinationProjection, + GRA_NearestNeighbour, + 0.0, + nullptr))); + if (warped == nullptr) + { + throw CoreError("Cannot warp raster into the map coordinate system"); + } + source = warped.get(); + } + + const int sourceWidth = source->GetRasterXSize(); + const int sourceHeight = source->GetRasterYSize(); + const double scale = std::min( + 1.0, + static_cast(maxDimension) / + static_cast(std::max(sourceWidth, sourceHeight))); + const int width = std::max(1, static_cast(std::floor(sourceWidth * scale))); + const int height = std::max(1, static_cast(std::floor(sourceHeight * scale))); + + RasterImage image; + image.width = width; + image.height = height; + image.extent = DatasetExtent(*source); + const std::size_t pixelCount = static_cast(width) * + static_cast(height); + image.pixels.resize(pixelCount); + + GDALRasterBand* first = source->GetRasterBand(1); + if (first == nullptr) + { + throw CoreError("Raster dataset has no first band"); + } + + GDALColorTable* colorTable = first->GetColorTable(); + const int bandCount = source->GetRasterCount(); + const bool palette = colorTable != nullptr; + int redIndex = 1; + int greenIndex = bandCount < 3 ? 1 : 2; + int blueIndex = bandCount < 3 ? 1 : 3; + int alphaIndex = 0; + int interpretedRed = 0; + int interpretedGreen = 0; + int interpretedBlue = 0; + for (int index = 1; index <= bandCount; ++index) + { + GDALRasterBand* band = source->GetRasterBand(index); + if (band == nullptr) + { + continue; + } + switch (band->GetColorInterpretation()) + { + case GCI_RedBand: + interpretedRed = index; + break; + case GCI_GreenBand: + interpretedGreen = index; + break; + case GCI_BlueBand: + interpretedBlue = index; + break; + case GCI_AlphaBand: + alphaIndex = index; + break; + default: + break; + } + } + if (!palette && interpretedRed != 0 && interpretedGreen != 0 && + interpretedBlue != 0) + { + redIndex = interpretedRed; + greenIndex = interpretedGreen; + blueIndex = interpretedBlue; + } + GDALRasterBand* redBand = source->GetRasterBand(redIndex); + GDALRasterBand* greenBand = source->GetRasterBand(greenIndex); + GDALRasterBand* blueBand = source->GetRasterBand(blueIndex); + GDALRasterBand* alphaBand = + alphaIndex != 0 ? source->GetRasterBand(alphaIndex) : nullptr; + if (redBand == nullptr || greenBand == nullptr || blueBand == nullptr) + { + throw CoreError("Raster dataset is missing a display band"); + } + + const GDALDataType redType = redBand->GetRasterDataType(); + const GDALDataType greenType = greenBand->GetRasterDataType(); + const GDALDataType blueType = blueBand->GetRasterDataType(); + const double redMin = SampleMinimum(*redBand, redType); + const double redMax = SampleMaximum(*redBand, redType); + const double greenMin = SampleMinimum(*greenBand, greenType); + const double greenMax = SampleMaximum(*greenBand, greenType); + const double blueMin = SampleMinimum(*blueBand, blueType); + const double blueMax = SampleMaximum(*blueBand, blueType); + std::vector alpha; + double alphaMin = 0.0; + double alphaMax = 255.0; + if (alphaBand != nullptr) + { + const GDALDataType alphaType = alphaBand->GetRasterDataType(); + alphaMin = SampleMinimum(*alphaBand, alphaType); + alphaMax = SampleMaximum(*alphaBand, alphaType); + alpha = ReadBand(*alphaBand, width, height); + } + const std::vector red = ReadBand(*redBand, width, height); + const std::vector green = greenIndex == redIndex + ? red + : ReadBand(*greenBand, width, height); + const std::vector blue = blueIndex == redIndex + ? red + : blueIndex == greenIndex ? green : ReadBand(*blueBand, width, height); + + int hasNoData = FALSE; + const double noData = first->GetNoDataValue(&hasNoData); + for (std::size_t index = 0; index < pixelCount; ++index) + { + std::uint8_t r{}; + std::uint8_t g{}; + std::uint8_t b{}; + std::uint8_t a = 255; + if (palette) + { + const int colorIndex = static_cast(std::round(red[index])); + const GDALColorEntry* entry = colorTable->GetColorEntry(colorIndex); + if (entry == nullptr) + { + a = 0; + } + else + { + r = static_cast(std::clamp(entry->c1, 0, 255)); + g = static_cast(std::clamp(entry->c2, 0, 255)); + b = static_cast(std::clamp(entry->c3, 0, 255)); + a = static_cast(std::clamp(entry->c4, 0, 255)); + } + } + else + { + r = ScaleSample(red[index], redMin, redMax); + g = ScaleSample(green[index], greenMin, greenMax); + b = ScaleSample(blue[index], blueMin, blueMax); + } + if (hasNoData != FALSE && red[index] == static_cast(noData)) + { + a = 0; + } + if (!alpha.empty()) + { + const std::uint8_t bandAlpha = + ScaleAlphaSample(alpha[index], alphaMin, alphaMax); + a = static_cast( + (static_cast(a) * bandAlpha + 127U) / 255U); + } + r = static_cast((static_cast(r) * a + 127U) / 255U); + g = static_cast((static_cast(g) * a + 127U) / 255U); + b = static_cast((static_cast(b) * a + 127U) / 255U); + image.pixels[index] = + (static_cast(a) << 24) | + (static_cast(r) << 16) | + (static_cast(g) << 8) | + static_cast(b); + } + return image; + } +} diff --git a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp index 7080f9c..71a9eb0 100644 --- a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp @@ -29,7 +29,19 @@ namespace mwg::rendering VectorRenderScene scene; try { - scene = detail::BuildVectorRenderScene(layer.Source()); + if (layer.IsRaster()) + { + RasterImage raster = + layer.RasterSource()->ReadImage(map.CoordinateSystemWkt()); + raster.extent = layer.GetExtent(); + result.layers.push_back({layer.Id(), {}, std::move(raster)}); + continue; + } + + scene = detail::BuildVectorRenderScene( + layer.Source(), + map.CoordinateSystemWkt()); + scene.extent = layer.GetExtent(); } catch (const std::bad_alloc&) { @@ -41,12 +53,12 @@ namespace mwg::rendering continue; } - if (scene.polygons.empty()) + if (scene.points.empty() && scene.lines.empty() && scene.polygons.empty()) { result.skippedLayers.push_back({layer.Id(), LayerSkipReason::NoGeometry, {}}); continue; } - result.layers.push_back({layer.Id(), std::move(scene)}); + result.layers.push_back({layer.Id(), std::move(scene), std::nullopt}); } return result; } diff --git a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp index 8d5ba09..64e7cec 100644 --- a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp @@ -2,9 +2,9 @@ /** * @file VectorRenderScene.cpp - * @brief Builds owned polygon scene data from vector layers. + * @brief Builds owned point, line, and polygon scene data from vector layers. * - * Reads vector geometries sequentially and copies supported polygon + * Reads vector geometries sequentially and copies supported geometry * coordinates into owned scene data before releasing each feature. */ #include "mapwingis/rendering/VectorRenderScene.h" @@ -18,6 +18,7 @@ #include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" +#include "internal/CoordinateTransformation.h" #include "internal/VectorLayerAccess.h" #include "internal/VectorRenderSceneBuilder.h" @@ -61,6 +62,56 @@ namespace mwg::rendering { switch (wkbFlatten(geometry.getGeometryType())) { + case wkbPoint: + { + const auto& point = static_cast(geometry); + if (!point.IsEmpty()) + { + scene.points.push_back({point.getX(), point.getY()}); + } + return; + } + + case wkbMultiPoint: + case wkbMultiLineString: + case wkbMultiCurve: + case wkbGeometryCollection: + { + const auto& collection = static_cast(geometry); + for (int index = 0; index < collection.getNumGeometries(); ++index) + { + const OGRGeometry* child = collection.getGeometryRef(index); + if (child != nullptr) + { + AppendGeometry(*child, scene); + } + } + return; + } + + case wkbLineString: + case wkbCircularString: + case wkbCompoundCurve: + { + const std::unique_ptr linear(geometry.getLinearGeometry()); + if (linear == nullptr) + { + throw CoreError("Cannot linearize curved vector geometry"); + } + const auto& line = static_cast(*linear); + if (line.getNumPoints() >= 2) + { + Ring points; + points.reserve(static_cast(line.getNumPoints())); + for (int index = 0; index < line.getNumPoints(); ++index) + { + points.push_back({line.getX(index), line.getY(index)}); + } + scene.lines.push_back(std::move(points)); + } + return; + } + case wkbPolygon: AppendPolygon(static_cast(geometry), scene); return; @@ -80,7 +131,6 @@ namespace mwg::rendering } case wkbCurvePolygon: - case wkbMultiSurface: { const std::unique_ptr linear(geometry.getLinearGeometry()); if (linear == nullptr) @@ -91,27 +141,60 @@ namespace mwg::rendering return; } + case wkbMultiSurface: + { + const auto& surfaces = static_cast(geometry); + for (int index = 0; index < surfaces.getNumGeometries(); ++index) + { + const OGRGeometry* surface = surfaces.getGeometryRef(index); + if (surface != nullptr) + { + AppendGeometry(*surface, scene); + } + } + return; + } + default: - throw CoreError("Vector rendering supports Polygon and MultiPolygon geometries only"); + throw CoreError("Vector rendering does not support this geometry type"); } } } - VectorRenderScene detail::BuildVectorRenderScene(const VectorLayer& layer) + VectorRenderScene detail::BuildVectorRenderScene( + const VectorLayer& layer, + const std::string& mapProjectionWkt) { - if (BaseShapeType(layer.Type()) != ShapeType::Polygon) + const ShapeType shapeType = BaseShapeType(layer.Type()); + if (shapeType != ShapeType::Point && shapeType != ShapeType::MultiPoint && + shapeType != ShapeType::PolyLine && shapeType != ShapeType::Polygon) { - throw CoreError("Vector rendering supports polygon layers only"); + throw CoreError("Vector rendering does not support this layer geometry type"); } VectorRenderScene scene; scene.extent = layer.GetExtent(); scene.sourceFeatureCount = layer.FeatureCount(); + const std::string sourceWkt = layer.ProjectionWkt(); + const std::string& targetWkt = + mapProjectionWkt.empty() ? sourceWkt : mapProjectionWkt; + const mwg::detail::CoordinateTransformation transform(sourceWkt, targetWkt); mwg::detail::VectorLayerAccess::ForEachGeometry( layer, - [&scene](const OGRGeometry& geometry) + [&scene, &transform](const OGRGeometry& geometry) { - AppendGeometry(geometry, scene); + if (!transform.IsNeeded()) + { + AppendGeometry(geometry, scene); + return; + } + std::unique_ptr transformed(geometry.clone()); + if (transformed == nullptr || + transformed->transform(transform.Get()) != OGRERR_NONE) + { + throw CoreError("Cannot transform vector geometry into the map coordinate system"); + } + AppendGeometry(*transformed, scene); }); return scene; } @@ -119,9 +202,9 @@ namespace mwg::rendering VectorRenderScene CreateVectorRenderScene(const VectorLayer& layer) { VectorRenderScene scene = detail::BuildVectorRenderScene(layer); - if (scene.polygons.empty()) + if (scene.points.empty() && scene.lines.empty() && scene.polygons.empty()) { - throw CoreError("Vector layer contains no polygon geometry to render"); + throw CoreError("Vector layer contains no geometry to render"); } return scene; } diff --git a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp index 99a15f0..59a0e4f 100644 --- a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp @@ -969,11 +969,11 @@ namespace return _control->LoadVectorDataset(ATL::CComBSTR(path.c_str())); } - HRESULT LoadLine() + HRESULT LoadUnsupportedGeometry() { - const auto path = _directory.Path() / "line.geojson"; + const auto path = _directory.Path() / "unsupported.geojson"; std::ofstream output(path); - output << R"({"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"LineString","coordinates":[[1000,1000],[1010,1010]]}}]})"; + output << R"({"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[1000,1000],[1010,1010]]}]}}]})"; output.close(); if (!output) { @@ -1064,6 +1064,123 @@ TEST_F(ActiveXMapTest, LoadsStackAndClearReleasesTheDatasets) EXPECT_EQ(count, 1); } +TEST_F(ActiveXMapTest, Control6ExposesNavigationHistoryAndAnimatedFit) +{ + ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); + ASSERT_EQ(Resize(200, 100), S_OK); + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + + VARIANT_BOOL changed = VARIANT_FALSE; + ASSERT_EQ(automation->Pan(5.0, 2.0, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + VARIANT_BOOL canGoBack = VARIANT_FALSE; + ASSERT_EQ(automation->get_CanGoBack(&canGoBack), S_OK); + EXPECT_EQ(canGoBack, VARIANT_TRUE); + ASSERT_EQ(automation->GoBack(&changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + ASSERT_EQ(automation->ZoomAt(50.0, 50.0, 2.0, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + ASSERT_EQ(automation->ZoomInBy(3.0, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + ASSERT_EQ(automation->ZoomOutBy(3.0, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + EXPECT_EQ(automation->ZoomInBy(0.5, &changed), E_INVALIDARG); + EXPECT_EQ(changed, VARIANT_FALSE); + + ASSERT_EQ(automation->Pan(100.0, 50.0, &changed), S_OK); + ASSERT_EQ(automation->AnimateZoomToMap(100), S_OK); + VARIANT_BOOL isAnimating = VARIANT_FALSE; + ASSERT_EQ(automation->get_IsAnimating(&isAnimating), S_OK); + EXPECT_EQ(isAnimating, VARIANT_TRUE); + + const ULONGLONG deadline = ::GetTickCount64() + 2000; + while (::GetTickCount64() < deadline) + { + MSG message{}; + while (::PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) + { + ::TranslateMessage(&message); + ::DispatchMessageW(&message); + } + ASSERT_EQ(automation->get_IsAnimating(&isAnimating), S_OK); + if (isAnimating == VARIANT_FALSE) + { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + EXPECT_EQ(isAnimating, VARIANT_FALSE); + ASSERT_EQ(automation->get_CanGoBack(&canGoBack), S_OK); + EXPECT_EQ(canGoBack, VARIANT_TRUE); + + LONGLONG layerId = 0; + ASSERT_EQ(automation->GetLayerId(0, &layerId), S_OK); + ASSERT_EQ(automation->Pan(100.0, 50.0, &changed), S_OK); + ASSERT_EQ(automation->AnimateZoomToLayer(layerId, 100), S_OK); + ASSERT_EQ(automation->get_IsAnimating(&isAnimating), S_OK); + EXPECT_EQ(isAnimating, VARIANT_TRUE); + ASSERT_EQ(automation->StopAnimation(), S_OK); + ASSERT_EQ(automation->get_IsAnimating(&isAnimating), S_OK); + EXPECT_EQ(isAnimating, VARIANT_FALSE); +} + +TEST_F(ActiveXMapTest, KeyboardArrowKeysPanTheMap) +{ + ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); + ASSERT_EQ(Resize(200, 100), S_OK); + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + + LRESULT messageResult = 0; + ASSERT_EQ( + _window->OnWindowMessage(WM_KEYDOWN, VK_RIGHT, 0, &messageResult), + S_OK); + VARIANT_BOOL canGoBack = VARIANT_FALSE; + ASSERT_EQ(automation->get_CanGoBack(&canGoBack), S_OK); + EXPECT_EQ(canGoBack, VARIANT_TRUE); + VARIANT_BOOL changed = VARIANT_FALSE; + ASSERT_EQ(automation->GoBack(&changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); +} + +TEST_F(ActiveXMapTest, LoadsAndRendersRasterThroughTheAutomationInterface) +{ + const auto path = mwg::test::MakeRaster(_directory.Path()); + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + ASSERT_EQ( + automation->LoadRasterDataset(ATL::CComBSTR(path.c_str())), + S_OK); + + LONG layerCount = 0; + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + EXPECT_EQ(layerCount, 1); + LONGLONG featureCount = -1; + ASSERT_EQ(automation->get_FeatureCount(&featureCount), S_OK); + EXPECT_EQ(featureCount, 0); + + ASSERT_EQ(Resize(120, 80), S_OK); + BitmapSurface surface(120, 80); + ASSERT_TRUE(surface.IsValid()); + ASSERT_EQ(Draw(surface.Get(), 120, 80), S_OK); + EXPECT_NE(GetPixel(surface.Get(), 60, 40), GetSysColor(COLOR_WINDOW)); +} + +TEST_F(ActiveXMapTest, FileDropLoadsRasterDatasets) +{ + const auto path = mwg::test::MakeRaster(_directory.Path()); + DWORD effect = DROPEFFECT_NONE; + EXPECT_EQ(DropFiles({path}, effect), S_OK); + EXPECT_EQ(effect, static_cast(DROPEFFECT_COPY)); + + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + LONG layerCount = 0; + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + EXPECT_EQ(layerCount, 1); +} + TEST_F(ActiveXMapTest, FileDropAddsMultipleLayersAndFitsTheirCombinedExtent) { ASSERT_EQ(Resize(200, 120), S_OK); @@ -1141,7 +1258,7 @@ TEST_F(ActiveXMapTest, FileDropKeepsSuccessfulFilesWhenOthersAreUnsupported) TEST_F(ActiveXMapTest, RejectedLoadsLeaveTheMapAndRenderingUnchanged) { - EXPECT_EQ(LoadLine(), E_FAIL); + EXPECT_EQ(LoadUnsupportedGeometry(), E_FAIL); VARIANT_BOOL loaded = VARIANT_TRUE; ASSERT_EQ(_control->get_LayerLoaded(&loaded), S_OK); EXPECT_EQ(loaded, VARIANT_FALSE); @@ -1152,7 +1269,7 @@ TEST_F(ActiveXMapTest, RejectedLoadsLeaveTheMapAndRenderingUnchanged) ASSERT_TRUE(before.IsValid()); ASSERT_TRUE(after.IsValid()); ASSERT_EQ(Draw(before.Get(), 120, 100), S_OK); - EXPECT_EQ(LoadLine(), E_FAIL); + EXPECT_EQ(LoadUnsupportedGeometry(), E_FAIL); LONGLONG count = -1; ASSERT_EQ(_control->get_FeatureCount(&count), S_OK); EXPECT_EQ(count, 1); diff --git a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp index d7d1c7d..d93f9cb 100644 --- a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp @@ -126,6 +126,47 @@ namespace return path; } + std::filesystem::path MakeGeographicPointDataset( + const std::filesystem::path& directory) + { + GdalEnvironment::Initialize(); + const std::filesystem::path path = directory / "geographic-point.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 geographic point dataset"); + } + + OGRSpatialReference srs; + srs.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + if (srs.importFromEPSG(4326) != OGRERR_NONE) + { + throw std::runtime_error("Cannot create the point projection"); + } + OGRLayer* layer = output->CreateLayer("points", &srs, wkbPoint, nullptr); + if (layer == nullptr) + { + throw std::runtime_error("Cannot create the point layer"); + } + + OGRPoint point(5.3872, 52.1552); + OGRFeatureUniquePtr feature(OGRFeature::CreateFeature(layer->GetLayerDefn())); + if (feature->SetGeometry(&point) != OGRERR_NONE || + layer->CreateFeature(feature.get()) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write the point feature"); + } + return path; + } + class MapRenderSceneTest : public testing::Test { protected: @@ -219,7 +260,7 @@ TEST_F(MapRenderSceneTest, SkipsLayersWithoutGeometry) EXPECT_EQ(scene.skippedLayers[0].reason, rendering::LayerSkipReason::NoGeometry); } -TEST_F(MapRenderSceneTest, LayerThatCannotBeRenderedDoesNotStopTheOthers) +TEST_F(MapRenderSceneTest, IncludesLineLayersInDrawingOrder) { const LayerId below = _map.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); const LayerId lines = _map.AddLayer(VectorDataset::Open(MakeLineDataset(_directory.Path()))); @@ -227,13 +268,68 @@ TEST_F(MapRenderSceneTest, LayerThatCannotBeRenderedDoesNotStopTheOthers) const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); - ASSERT_EQ(scene.layers.size(), 2U); + ASSERT_EQ(scene.layers.size(), 3U); 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"); + EXPECT_EQ(scene.layers[1].layerId, lines); + EXPECT_EQ(scene.layers[2].layerId, above); + ASSERT_EQ(scene.layers[1].scene.lines.size(), 1U); + EXPECT_EQ(scene.layers[1].scene.lines.front().size(), 2U); + EXPECT_TRUE(scene.skippedLayers.empty()); +} + +TEST_F(MapRenderSceneTest, ReprojectsPointGeometryAndExtentIntoTheFirstLayersCoordinateSystem) +{ + const std::shared_ptr projected = + VectorDataset::Open(MakeProvincesDataset(_directory.Path(), "projected.gpkg")); + const std::shared_ptr geographic = + VectorDataset::Open(MakeGeographicPointDataset(_directory.Path())); + _map.AddLayer(projected); + const LayerId pointId = _map.AddLayer(geographic); + + OGRSpatialReference source; + OGRSpatialReference target; + source.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + target.SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER); + ASSERT_EQ(source.importFromEPSG(4326), OGRERR_NONE); + ASSERT_EQ(target.importFromEPSG(Provinces::kEpsg), OGRERR_NONE); + std::unique_ptr + transformation(OGRCreateCoordinateTransformation(&source, &target), + OCTDestroyCoordinateTransformation); + ASSERT_NE(transformation, nullptr); + double expectedX = 5.3872; + double expectedY = 52.1552; + ASSERT_NE(transformation->Transform(1, &expectedX, &expectedY), FALSE); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + ASSERT_EQ(scene.layers.size(), 2U); + EXPECT_EQ(scene.layers[1].layerId, pointId); + ASSERT_EQ(scene.layers[1].scene.points.size(), 1U); + EXPECT_NEAR(scene.layers[1].scene.points.front().x, expectedX, 1e-6); + EXPECT_NEAR(scene.layers[1].scene.points.front().y, expectedY, 1e-6); + const Extent extent = _map.FindLayer(pointId)->GetExtent(); + EXPECT_NEAR(extent.xMin, expectedX, 1e-6); + EXPECT_NEAR(extent.xMax, expectedX, 1e-6); + EXPECT_NEAR(extent.yMin, expectedY, 1e-6); + EXPECT_NEAR(extent.yMax, expectedY, 1e-6); +} + +TEST_F(MapRenderSceneTest, IncludesRasterPreviewAndExtentInMapScene) +{ + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(_directory.Path())); + const LayerId rasterId = _map.AddRasterLayer(raster); + + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(_map); + + ASSERT_EQ(scene.layers.size(), 1U); + EXPECT_EQ(scene.layers.front().layerId, rasterId); + ASSERT_TRUE(scene.layers.front().raster.has_value()); + EXPECT_EQ(scene.layers.front().raster->width, 12); + EXPECT_EQ(scene.layers.front().raster->height, 8); + EXPECT_EQ(scene.layers.front().raster->pixels.front(), 0xFF2A2A2AU); + EXPECT_DOUBLE_EQ(scene.layers.front().raster->extent.xMax, 12.0); + EXPECT_DOUBLE_EQ(scene.layers.front().raster->extent.yMax, 8.0); + EXPECT_TRUE(scene.layers.front().scene.polygons.empty()); } TEST_F(MapRenderSceneTest, DuplicateLayersAreSeparateSceneLayers) diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index 71647e4..b9e332d 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -12,6 +12,7 @@ */ #include +#include #include #include #include @@ -568,6 +569,119 @@ TEST_F(MapViewTest, PanMovesTheViewByTheMapDelta) EXPECT_THROW((void)_view.Pan(std::numeric_limits::quiet_NaN(), 0.0), InvalidArgument); } +TEST_F(MapViewTest, NavigationHistoryRestoresViewsAndDiscardsForwardHistoryOnNewNavigation) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const MapPoint initialCenter = *_view.GetViewport().Center(); + const double initialResolution = *_view.GetViewport().Resolution(); + EXPECT_FALSE(_view.CanGoBack()); + EXPECT_FALSE(_view.CanGoForward()); + + ASSERT_TRUE(_view.ZoomIn(2.0)); + const MapPoint zoomedCenter = *_view.GetViewport().Center(); + const double zoomedResolution = *_view.GetViewport().Resolution(); + ASSERT_TRUE(_view.Pan(3.0, -2.0)); + + ASSERT_TRUE(_view.CanGoBack()); + EXPECT_FALSE(_view.CanGoForward()); + ASSERT_TRUE(_view.GoBack()); + EXPECT_EQ(_view.GetViewport().Center()->x, zoomedCenter.x); + EXPECT_EQ(_view.GetViewport().Center()->y, zoomedCenter.y); + EXPECT_EQ(*_view.GetViewport().Resolution(), zoomedResolution); + ASSERT_TRUE(_view.GoBack()); + EXPECT_EQ(_view.GetViewport().Center()->x, initialCenter.x); + EXPECT_EQ(_view.GetViewport().Center()->y, initialCenter.y); + EXPECT_EQ(*_view.GetViewport().Resolution(), initialResolution); + EXPECT_FALSE(_view.GoBack()); + + ASSERT_TRUE(_view.CanGoForward()); + ASSERT_TRUE(_view.GoForward()); + EXPECT_EQ(_view.GetViewport().Center()->x, zoomedCenter.x); + EXPECT_EQ(*_view.GetViewport().Resolution(), zoomedResolution); + ASSERT_TRUE(_view.Pan(1.0, 0.0)); + EXPECT_FALSE(_view.CanGoForward()); +} + +TEST_F(MapViewTest, NavigationGestureAddsOnlyOneHistoryEntry) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const MapPoint startCenter = *_view.GetViewport().Center(); + + ASSERT_TRUE(_view.BeginNavigationGesture()); + EXPECT_FALSE(_view.BeginNavigationGesture()); + ASSERT_TRUE(_view.Pan(2.0, 1.0)); + ASSERT_TRUE(_view.Pan(3.0, 2.0)); + EXPECT_FALSE(_view.CanGoBack()); + ASSERT_TRUE(_view.EndNavigationGesture()); + EXPECT_TRUE(_view.CanGoBack()); + EXPECT_FALSE(_view.EndNavigationGesture()); + ASSERT_TRUE(_view.GoBack()); + EXPECT_EQ(_view.GetViewport().Center()->x, startCenter.x); + EXPECT_EQ(_view.GetViewport().Center()->y, startCenter.y); + EXPECT_FALSE(_view.CanGoBack()); +} + +TEST_F(MapViewTest, AnimatedFitAdvancesSmoothlyAndAddsOneHistoryEntry) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.Pan(100.0, 50.0)); + ASSERT_TRUE(_view.ZoomIn(2.0)); + const MapPoint startCenter = *_view.GetViewport().Center(); + const double startResolution = *_view.GetViewport().Resolution(); + + ASSERT_TRUE(_view.BeginTransitionToMap(std::chrono::milliseconds(100))); + EXPECT_TRUE(_view.IsTransitioning()); + EXPECT_TRUE(_view.CanGoBack()); + EXPECT_FALSE(_view.AdvanceTransition(std::chrono::milliseconds(0))); + ASSERT_TRUE(_view.AdvanceTransition(std::chrono::milliseconds(50))); + EXPECT_TRUE(_view.IsTransitioning()); + EXPECT_NE(_view.GetViewport().Center()->x, startCenter.x); + EXPECT_NE(*_view.GetViewport().Resolution(), startResolution); + ASSERT_TRUE(_view.AdvanceTransition(std::chrono::milliseconds(50))); + EXPECT_FALSE(_view.IsTransitioning()); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 10.0, 5.0})); + + ASSERT_TRUE(_view.CanGoBack()); + ASSERT_TRUE(_view.GoBack()); + EXPECT_EQ(_view.GetViewport().Center()->x, startCenter.x); + EXPECT_EQ(_view.GetViewport().Center()->y, startCenter.y); + EXPECT_EQ(*_view.GetViewport().Resolution(), startResolution); +} + +TEST_F(MapViewTest, CancellingAnAnimatedFitKeepsItsPartialViewInHistory) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.Pan(100.0, 50.0)); + const MapPoint startCenter = *_view.GetViewport().Center(); + + ASSERT_TRUE(_view.BeginTransitionToMap(std::chrono::milliseconds(100))); + ASSERT_TRUE(_view.AdvanceTransition(std::chrono::milliseconds(40))); + const MapPoint partialCenter = *_view.GetViewport().Center(); + ASSERT_TRUE(_view.CancelTransition()); + EXPECT_FALSE(_view.IsTransitioning()); + ASSERT_TRUE(_view.GoBack()); + EXPECT_EQ(_view.GetViewport().Center()->x, startCenter.x); + EXPECT_EQ(_view.GetViewport().Center()->y, startCenter.y); + EXPECT_NE(partialCenter.x, startCenter.x); +} + +TEST_F(MapViewTest, AnimatedFitRejectsNonPositiveDurations) +{ + EXPECT_THROW( + (void)_view.BeginTransitionToMap(std::chrono::milliseconds(0)), + InvalidArgument); + EXPECT_THROW( + (void)_view.BeginTransitionToLayer(LayerId{}, std::chrono::milliseconds(-1)), + InvalidArgument); + EXPECT_THROW( + (void)_view.AdvanceTransition(std::chrono::milliseconds(-1)), + InvalidArgument); +} + TEST_F(MapViewTest, NavigationCancelsAPendingFit) { ASSERT_TRUE(_view.Resize(200, 100)); diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj index 715a8a3..ced3a9b 100644 --- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj +++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj @@ -67,6 +67,7 @@ + diff --git a/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp b/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp new file mode 100644 index 0000000..ce54d9e --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file RasterDatasetTests.cpp + * @brief Tests read-only raster access and display-image generation. + */ +#include + +#include +#include +#include + +#include + +#include "TestData.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/raster/RasterDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + struct CloseGdalDataset + { + void operator()(GDALDataset* dataset) const noexcept + { + if (dataset != nullptr) + { + GDALClose(dataset); + } + } + }; + + std::filesystem::path MakeRgbaRaster(const std::filesystem::path& directory) + { + GdalEnvironment::Initialize(); + const std::filesystem::path path = directory / "rgba.tif"; + GDALDriver* const driver = GetGDALDriverManager()->GetDriverByName("GTiff"); + if (driver == nullptr) + { + throw std::runtime_error("GTiff driver is not available"); + } + std::unique_ptr dataset( + driver->Create(path.string().c_str(), 2, 2, 4, GDT_Byte, nullptr)); + if (dataset == nullptr) + { + throw std::runtime_error("Could not create RGBA test raster"); + } + + const std::array interpretations{ + GCI_RedBand, GCI_GreenBand, GCI_BlueBand, GCI_AlphaBand}; + const std::array values{200, 100, 50, 128}; + for (int index = 0; index < 4; ++index) + { + GDALRasterBand* const band = dataset->GetRasterBand(index + 1); + band->SetColorInterpretation(interpretations[static_cast(index)]); + std::array pixels{}; + pixels.fill(values[static_cast(index)]); + if (band->RasterIO( + GF_Write, 0, 0, 2, 2, pixels.data(), 2, 2, GDT_Byte, 0, 0) != CE_None) + { + throw std::runtime_error("Could not write RGBA test pixels"); + } + } + if (dataset->FlushCache() != CE_None) + { + throw std::runtime_error("Could not flush RGBA test raster"); + } + dataset.reset(); + return path; + } +} + +TEST(RasterDatasetTest, ReadsGeoreferencedSingleBandPixelsAsGrayscale) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + + EXPECT_EQ(raster->Width(), 12); + EXPECT_EQ(raster->Height(), 8); + EXPECT_EQ(raster->BandCount(), 1); + EXPECT_TRUE(raster->GetExtent().IsValid()); + EXPECT_DOUBLE_EQ(raster->GetExtent().xMin, 0.0); + EXPECT_DOUBLE_EQ(raster->GetExtent().yMin, 0.0); + EXPECT_DOUBLE_EQ(raster->GetExtent().xMax, 12.0); + EXPECT_DOUBLE_EQ(raster->GetExtent().yMax, 8.0); + + const RasterImage image = raster->ReadImage(); + ASSERT_EQ(image.width, 12); + ASSERT_EQ(image.height, 8); + ASSERT_EQ(image.pixels.size(), 96U); + EXPECT_EQ(image.pixels.front(), 0xFF2A2A2AU); +} + +TEST(RasterDatasetTest, ReadsColorInterpretationAndPremultipliesAlpha) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRgbaRaster(directory.Path())); + + const RasterImage image = raster->ReadImage(); + ASSERT_EQ(image.pixels.size(), 4U); + EXPECT_EQ(image.pixels.front(), 0x80643219U); +} + +TEST(RasterDatasetTest, ReprojectsDisplayImageToTheRequestedCoordinateSystem) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + const RasterImage image = raster->ReadImage("EPSG:3857"); + + ASSERT_TRUE(image.extent.IsValid()); + EXPECT_GT(image.extent.xMax, image.extent.xMin); + EXPECT_GT(image.extent.yMax, image.extent.yMin); + EXPECT_NEAR(image.extent.xMin, 0.0, 1e-4); + EXPECT_GT(image.extent.yMax, 800000.0); +} + +TEST(RasterDatasetTest, RejectsInvalidPreviewDimensions) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + + EXPECT_THROW((void)raster->ReadImage({}, 0), InvalidArgument); +} diff --git a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp index a87495e..a8253d9 100644 --- a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp @@ -362,6 +362,70 @@ TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); } +TEST(VectorLayerRenderer, DrawsPointAndLineGeometryWithGdi) +{ + 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::rendering::MapRenderScene scene; + mwg::rendering::VectorRenderScene layer; + layer.extent = {0.0, 0.0, 10.0, 10.0}; + layer.lines.push_back({{1.0, 5.0}, {9.0, 5.0}}); + layer.points.push_back({5.0, 2.0}); + scene.layers.push_back({mwg::LayerId{1}, std::move(layer)}); + + mwg::activex::VectorLayerRenderer renderer; + renderer.SetPreference(mwg::activex::VectorRenderBackendPreference::Gdi); + ASSERT_EQ( + renderer.Draw( + surface.Get(), + bounds, + scene, + FitViewport(bounds, {0.0, 0.0, 10.0, 10.0})), + S_OK); + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); + EXPECT_TRUE(renderer.GetFailedLayers().empty()); + EXPECT_EQ(MapPixel(surface.Get(), FitViewport(bounds, {0.0, 0.0, 10.0, 10.0}), 5.0, 5.0), + RGB(24, 58, 82)); + EXPECT_EQ(MapPixel(surface.Get(), FitViewport(bounds, {0.0, 0.0, 10.0, 10.0}), 5.0, 2.0), + kFill); +} + +TEST(VectorLayerRenderer, DrawsRasterLayersWithAlphaBlending) +{ + 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::rendering::MapRenderScene scene; + mwg::RasterImage image; + image.width = 1; + image.height = 1; + image.extent = {0.0, 0.0, 10.0, 10.0}; + image.pixels = {0xFF2A2A2AU}; + scene.layers.push_back({mwg::LayerId{1}, {}, std::move(image)}); + + mwg::activex::VectorLayerRenderer renderer; + renderer.SetPreference(mwg::activex::VectorRenderBackendPreference::Gdi); + ASSERT_EQ(renderer.Draw( + surface.Get(), + bounds, + scene, + FitViewport(bounds, {0.0, 0.0, 10.0, 10.0})), + S_OK); + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); + EXPECT_EQ( + MapPixel(surface.Get(), FitViewport(bounds, {0.0, 0.0, 10.0, 10.0}), 5.0, 5.0), + RGB(42, 42, 42)); +} + TEST(VectorLayerRenderer, HonorsCpuPreferenceAndReportsTheSelectedBackend) { BitmapSurface surface(120, 100); diff --git a/tests/MapWinGIS.Core.Tests/VectorRenderSceneTests.cpp b/tests/MapWinGIS.Core.Tests/VectorRenderSceneTests.cpp index 64efc1c..42dd615 100644 --- a/tests/MapWinGIS.Core.Tests/VectorRenderSceneTests.cpp +++ b/tests/MapWinGIS.Core.Tests/VectorRenderSceneTests.cpp @@ -433,14 +433,14 @@ TEST(VectorRenderScene, DatasetOverloadUsesFirstPolygonLayer) ExpectSameScene(scene, firstLayerScene); } -TEST(VectorRenderScene, DatasetOverloadDoesNotSearchPastNonPolygonFirstLayer) +TEST(VectorRenderScene, DatasetOverloadDoesNotSearchPastUnsupportedFirstLayer) { TempDirectory directory; const auto path = MakeTwoLayerDataset( directory.Path(), - "point-first.gpkg", - wkbPoint, - "POINT (0 0)", + "collection-first.gpkg", + wkbGeometryCollection, + "GEOMETRYCOLLECTION (POINT (0 0))", wkbPolygon, "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"); const auto dataset = VectorDataset::Open(path); From 2c44344b19725067dbeab9e50a06986845dc0dae Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 21:56:29 +0100 Subject: [PATCH 3/3] Implemented viewport-sized raster reads, XYZ tile layers, GDI clipping, per-layer scene caching, and layer reordering, including ActiveX APIs and tests. Updated the plan and documentation; remaining limits include unsupported WMTS/WMS, synchronous tile requests, and no OpenGL clipping --- README.md | 14 +- docs/ARCHITECTURE.md | 115 +-- docs/PORTING.md | 18 +- docs/TESTING.md | 6 + .../GdiVectorLayerRenderer.cpp | 289 ++++++-- src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl | 25 +- src/MapWinGIS.ActiveX/MapWinGISControl.cpp | 82 +++ src/MapWinGIS.ActiveX/MapWinGISControl.h | 24 +- .../include/mapwingis/map/Map.h | 5 + .../include/mapwingis/map/MapView.h | 22 +- .../include/mapwingis/raster/RasterDataset.h | 33 +- .../mapwingis/rendering/MapRenderScene.h | 20 +- .../src/internal/VectorRenderSceneBuilder.h | 14 + src/MapWinGIS.Core/src/map/Map.cpp | 44 ++ src/MapWinGIS.Core/src/map/MapView.cpp | 178 ++++- .../src/raster/RasterDataset.cpp | 687 +++++++++++++----- .../src/rendering/MapRenderScene.cpp | 134 +++- .../ActiveXActivationTests.cpp | 33 + .../MapRenderSceneTests.cpp | 17 + tests/MapWinGIS.Core.Tests/MapTests.cpp | 22 + tests/MapWinGIS.Core.Tests/MapViewTests.cpp | 47 +- .../RasterDatasetTests.cpp | 332 +++++++++ .../VectorLayerRendererTests.cpp | 19 + 23 files changed, 1831 insertions(+), 349 deletions(-) diff --git a/README.md b/README.md index af5eb2f..b8ca130 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ selectively and must not prevent structural improvements. ## Status Early. The core includes GDAL setup, OGR-backed vector dataset and layer -access, raster display previews, coordinate reprojection, and map navigation. +access, viewport-windowed raster display, XYZ tile layers, coordinate +reprojection, and map navigation. Available formats depend on the GDAL drivers enabled in the build; tests cover shapefiles and GeoPackage. The ActiveX layer supports COM activation, vector and raster loading, polygon/line/point rendering, keyboard, touch and mouse @@ -27,9 +28,10 @@ algorithms with progress and cancellation events. The map uses the first layer's declared coordinate reference system (CRS). Layers in other declared CRSs are reprojected into it; layers with and without -a CRS cannot be mixed. Raster display uses a bounded preview image. OpenGL -renders supported simple polygons; GDI draws polygons, lines, points, and -raster previews. +a CRS cannot be mixed. Raster layers are read for the visible map window at +viewport resolution. XYZ URL templates are supported as Web Mercator tile +layers, with GDAL-managed in-memory tile caching. OpenGL renders supported +simple polygons; GDI clips and draws polygons, lines, points, and rasters. The core supports pan, center and cursor-anchored zoom, map/layer fitting, back/forward navigation history, and timed animated fits. ActiveX exposes @@ -39,8 +41,8 @@ the control's owner-thread timer. Vector loads add layers rather than replace them. The first layer fits the initial view; later loads preserve it. Call `ZoomToMap` to include new layers -in the visible extent, or `ClearLayers` to release loaded datasets before -switching or refreshing them. +in the visible extent, `MoveLayer` to change drawing order, or `ClearLayers` +to release loaded datasets before switching or refreshing them. Dropped vector and raster files append as layers and the control fits the combined visible extent after processing the batch. Unsupported or invalid diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50a69b0..4460106 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -117,7 +117,8 @@ state; hosts and renderers read from it. `Clear`. `LayerId{}` (0) identifies no layer. Neither display names nor positions identify a layer. - **Ordering.** Position 0 is the bottom layer and is drawn first; new layers - are appended on top. Positions shift on removal; identities do not. + are appended on top. `MoveLayer` moves a stable identity to a 0-based + position. Positions shift on removal and reordering; identities do not. - **Ownership.** The map owns its `MapLayer` objects. A `MapLayer` reference stays valid until that layer is removed, the map is cleared, or the map is destroyed; changes to other layers do not invalidate it. A vector layer @@ -190,19 +191,25 @@ 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 + (`mapwingis/rendering/MapRenderScene.h`) copies the drawable geometry of every visible layer into an owned `MapRenderScene`, bottom layer first. Each - layer is converted on its own: hidden layers, layers without polygon + layer is converted on its own: hidden layers, layers without drawable 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. +- **Raster windows.** `MapView::Scene()` reads only the visible raster window + at viewport pixel dimensions. Pan, zoom, and resize refresh raster pixels; + offscreen source pixels are not loaded into the render scene. The direct + `CreateMapRenderScene(map)` overload remains available for bounded previews. - **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. + with it, so every layer uses the same transformation. Unchanged vector + geometry is moved through the per-layer scene cache when layer visibility, + order, or membership changes. `InvalidateScene` discards that cache after + source geometry edits. - **Redraws.** Map changes go through `MapView` (`AddLayer`, `RemoveLayer`, - `ClearLayers`, `SetLayerVisible`), which marks the scene stale; `Scene()` + `MoveLayer`, `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. @@ -241,7 +248,7 @@ responsibilities, not a requirement to split them into more DLLs. | `MapWinGIS.Core` | Own GIS data, reusable map/layer state, and backend-neutral render data. Keep its public API independent of windowing, COM, and graphics APIs. | COM, ATL, MFC, windowing APIs, OpenGL, OpenCL | | `MapWinGIS.ocx` | Own COM activation, host-facing control lifecycle, and translation between the external API and core operations. Integrate core rendering with the host surface and selected backend. | GIS rules, core data ownership, rendering-specific types in its public COM contract | | Rendering coordinator (in the OCX) | Try OpenGL for supported scenes and use GDI when OpenGL is unavailable or the scene is unsupported. Keep backend selection independent of COM activation. | GIS rules, COM interface layout, backend-specific resource types outside the backend | -| GDI backend (in the OCX) | Paint polygon, line, point, and raster scenes into a device context; provide the Windows CPU fallback. | Core dataset ownership, COM interface layout, GPU availability | +| GDI backend (in the OCX) | Clip vector geometry to the control bounds, then paint polygon, line, point, and raster scenes into a device context; provide the Windows CPU fallback. | Core dataset ownership, COM interface layout, GPU availability | | OpenGL backend (in the OCX) | Render convex single-ring polygons through a reusable WGL context, then copy pixels to the host device context. | Core GIS data ownership and public COM interfaces | | Future compute work | Host optional compute acceleration only for workloads justified by profiling. | OpenGL being installed or selected | @@ -249,7 +256,8 @@ The OCX is the composition root for control, render-coordinator, and 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. -`RasterDataset` owns the GDAL raster and prepares bounded display previews. Core +`RasterDataset` owns the GDAL raster and reads viewport-sized windows. XYZ +sources use GDAL's WMS TMS minidriver and its bounded in-memory tile cache. 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 @@ -266,16 +274,17 @@ 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. For each visible map layer, Core sequentially reads supported point, line, and polygon -features into an owned vector scene, linearizing curved geometries. Raster -layers carry a bounded RGBA display preview and a georeferenced extent. -Vector geometry and raster previews from other declared CRSs are transformed -into the map CRS. `ViewTransform` captures the `Viewport` once per frame so -every backend uses the same map-to-screen arithmetic; GDI draws a screen -position in the pixel that contains it (`ToPixel`). The coordinator tries -OpenGL only when every layer is a simple convex polygon with one ring. GDI -handles polygons, lines, points, and raster previews. A layer with non-finite -or out-of-range device coordinates is skipped and reported -(`GetFailedLayers`, `RenderingStatus`) while the other layers continue. +features into an owned vector scene, linearizing curved geometries. `MapView` +requests RGBA raster windows for the current viewport; the direct scene-builder +overload retains its bounded-preview behavior. Vector geometry and raster +windows from other declared CRSs are transformed into the map CRS. +`ViewTransform` captures the `Viewport` once per frame so every backend uses +the same map-to-screen arithmetic; GDI draws a screen position in the pixel +that contains it (`ToPixel`). The coordinator tries OpenGL only when every +layer is a simple convex polygon with one ring. GDI handles polygons, lines, +points, and raster windows, clipping finite vector geometry to the control +bounds. Non-finite geometry is skipped and reported (`GetFailedLayers`, +`RenderingStatus`) while the other layers continue. 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 @@ -284,7 +293,7 @@ boundary, not an end-to-end accelerated presentation path. Rendering into a framebuffer object avoids undefined pixels from window pixel-ownership rules. Interior rings, concave or self-intersecting rings, non-polygon geometry, raster layers, missing framebuffer-object support, initialization failures, -and drawing failures use the GDI backend. Raster previews use nearest-neighbor +and drawing failures use the GDI backend. Raster windows use nearest-neighbor sampling, with grayscale or RGB bands, palette colors, nodata transparency, alpha bands, and reprojection through GDAL's warped VRT support. @@ -295,8 +304,9 @@ 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`, which derives from `IMapWinGISControl`. `IMapWinGISControl3` adds `ClearLayers` at DISPID 8; -`IMapWinGISControl6` is the current coclass default and adds navigation and -animation operations. Earlier interfaces and their IIDs remain unchanged. +`IMapWinGISControl6` adds navigation and animation, and `IMapWinGISControl7` +is the current coclass default. 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 @@ -327,13 +337,13 @@ intentional compatibility gaps and the rationale when interfaces are introduced. ## Current limits and next work The control exposes a small v6 COM surface: Core initialization, vector and -raster dataset loading, layer state, layer removal and visibility, map/layer -zoom, direct pan, anchored zoom, configurable zoom factors, back/forward -history, and animated fits. Mouse, keyboard, and touch navigation use the same -`MapView`. The control also supports renderer preferences and asynchronous -generic GDAL operations with progress, cancellation, and result reporting. -The GDAL API has a new interface and does not claim MapWinGIS 5 `IGdalUtils` -binary compatibility. +raster dataset loading, layer state, layer removal, visibility and reordering, +XYZ tile loading, map/layer zoom, direct pan, anchored zoom, configurable zoom +factors, back/forward history, and animated fits. Mouse, keyboard, and touch +navigation use the same `MapView`. The control also supports renderer +preferences and asynchronous generic GDAL operations with progress, +cancellation, and result reporting. The GDAL API has a new interface and does +not claim MapWinGIS 5 `IGdalUtils` binary compatibility. At most sixteen operations may be active or awaiting result cleanup. Call `EndGdalOperation` after completion to release an operation slot. The control delivers events on its owning apartment thread. Destroying the in-place window @@ -354,38 +364,39 @@ the cached total over all loaded layers. The control updates the total after a successful load or removal and resets it on `ClearLayers`; property reads do not scan datasets or rebuild the scene. The first layer fits the initial view; later loads preserve the current view, and callers can use `ZoomToMap` to -include new layers in the visible extent. `IMapWinGISControl4` exposes layer -count and stable layer IDs, removal, visibility, `ZoomToMap`, `ZoomToLayer`, -`ZoomIn`, and `ZoomOut`; it inherits `ClearLayers` from `IMapWinGISControl3`. -`IMapWinGISControl5` adds raster loading. `IMapWinGISControl6` adds direct -pan, anchored zoom, configurable zoom factors, back/forward history, and -animated map/layer fits. Layer reordering is not exposed because Core has no -reorder operation. Mouse-wheel zoom is anchored at the cursor; left-button -dragging pans, keyboard arrows pan, and touch pointers support one-finger pan -and two-finger pinch zoom. Animated fits use a 16 ms owner-thread timer and -are canceled when the control closes or its host window is destroyed. - -Core prepares and retains the supported vector scene and bounded raster -previews in memory, -reading feature geometries sequentially without WKT serialization and -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 +include new layers in the visible extent. `IMapWinGISControl4` exposes layer count and stable layer IDs, removal, +visibility, `ZoomToMap`, `ZoomToLayer`, `ZoomIn`, and `ZoomOut`; it inherits +`ClearLayers` from `IMapWinGISControl3`. `IMapWinGISControl5` adds raster +loading. `IMapWinGISControl6` adds direct pan, anchored zoom, configurable +zoom factors, back/forward history, and animated map/layer fits. +`IMapWinGISControl7` adds `MoveLayer` and XYZ tile loading. Mouse-wheel zoom +is anchored at the cursor; left-button dragging pans, keyboard arrows pan, and +touch pointers support one-finger pan and two-finger pinch zoom. Animated fits +use a 16 ms owner-thread timer and are canceled when the control closes or its +host window is destroyed. + +Core retains the visible scene in memory. Per-layer vector scenes are reused +across layer membership, visibility, and ordering changes; source geometry +edits require explicit `MapView::InvalidateScene` calls. `MapView` reads only +the visible raster window at viewport resolution, with an output limit of +16 million pixels. XYZ tile URL templates use Web Mercator addressing and +GDAL's bounded in-memory cache; network tile requests are synchronous. Other +tile protocols, OpenGL clipping, and paging remain unsupported. 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 staging buffer is limited to 64 MiB per frame; target dimensions are also -limited by the detected framebuffer and viewport capabilities. GDI is the supported -Windows CPU fallback; Core has no independent platform-neutral renderer. +limited by the detected framebuffer and viewport capabilities. GDI clips +finite vector geometry to the control bounds and is the supported 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. Core and ActiveX navigation use the shared `MapView`; its history is bounded to 100 views, and each drag or touch gesture creates one history entry. Core exposes eased transitions that hosts can advance, while ActiveX schedules -them on its message-only dispatch window. Deep zoom can push a layer past the -GDI coordinate range until clipping exists. Measure -scene preparation with representative datasets before adding caching, paging, -or broader OpenGL geometry support. +them on its message-only dispatch window. GDI clips finite vectors at deep +zoom; OpenGL still supports only convex single-ring polygons. Measure scene +preparation with representative datasets before adding 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 3b9b724..731852d 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -54,7 +54,9 @@ reports whether a layer is loaded and its feature count. `IMapWinGISControl2` adds renderer preference, active backend, and status. `IMapWinGISControl3` adds `ClearLayers`. `IMapWinGISControl4` adds layer management and basic zoom methods, `IMapWinGISControl5` adds raster loading, -and `IMapWinGISControl6` is the current default automation interface. +`IMapWinGISControl6` adds navigation history and animated fits, and +`IMapWinGISControl7` is the current default automation interface. Version 7 +adds layer reordering and XYZ tile loading. 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. @@ -72,9 +74,10 @@ 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. Each vector load selects the first dataset layer. Core prepares an ordered scene for all visible map layers, including point, line, and polygon -geometries; raster layers use bounded display previews. The map uses the first -layer's declared CRS, transforms other declared CRSs into it, and rejects -mixing referenced and unreferenced layers. The control does not expose the +geometries; raster layers are read by viewport-sized windows. XYZ tile URL +templates use the Web Mercator tile matrix and GDAL's in-memory cache. The map +uses the first layer's declared CRS, transforms other declared CRSs into it, +and rejects mixing referenced and unreferenced layers. The control does not expose the broad v5 map, layer, shape, event, or editing API. OpenGL only handles convex single-ring polygons; GDI handles the supported vector and raster scene as the CPU fallback. @@ -93,9 +96,10 @@ layer's CRS. `IMapWinGISControl6` adds pan, anchored zoom, configurable zoom factors, back/forward view history, animated fits, and host input for mouse, keyboard, -and touch pan/pinch gestures. It is a new derived interface; earlier IIDs -remain unchanged. Animated map/layer fits run on the control's owner-thread -timer and can be canceled explicitly. +and touch pan/pinch gestures. `IMapWinGISControl7` adds `MoveLayer` and +`LoadXyzTileLayer`. These are new derived interfaces; earlier IIDs remain +unchanged. Animated map/layer fits run on the control's owner-thread timer and +can be canceled explicitly. Keep these gaps visible when extending the COM surface. Reuse Core behavior, add only the automation members required by a concrete use case, and retain a diff --git a/docs/TESTING.md b/docs/TESTING.md index 10e47b0..906ad02 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -250,3 +250,9 @@ one-entry-per-gesture navigation history. The automated C# ActiveX host passed all 16 checks, including programmatic navigation, mouse-wheel zoom, drag-to-pan, resize, repaint, and control recreation. Touch gesture hardware input remains a manual validation item. + +The viewport-raster and XYZ-tile follow-up passed the Debug x64 build and all +337 native tests (334 passed, 3 skipped). This includes an XYZ integration +test that serves a generated PNG tile from a loopback HTTP listener and checks +the requested tile path and rendered pixel; it does not require public network +access. The automated C# ActiveX host also passed all 16 checks. diff --git a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp index 2b5c1c3..de172b6 100644 --- a/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/GdiVectorLayerRenderer.cpp @@ -147,93 +147,252 @@ namespace mwg::activex value <= kMaximumGdiCoordinate; } - /// 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, - const double originX, - const double originY) noexcept + [[nodiscard]] bool HasFiniteCoordinates( + const rendering::VectorRenderScene& scene) noexcept { - for (const rendering::Point& point : scene.points) + const auto finite = [](const rendering::Point& point) { - if (!IsDrawableCoordinate(ToDeviceX(point, transform, originX)) || - !IsDrawableCoordinate(ToDeviceY(point, transform, originY))) - { - return false; - } + return std::isfinite(point.x) && std::isfinite(point.y); + }; + if (!std::all_of(scene.points.begin(), scene.points.end(), finite)) + { + return false; } for (const rendering::Ring& line : scene.lines) { - for (const rendering::Point& point : line) + if (!std::all_of(line.begin(), line.end(), finite)) { - if (!IsDrawableCoordinate(ToDeviceX(point, transform, originX)) || - !IsDrawableCoordinate(ToDeviceY(point, transform, originY))) - { - return false; - } + return false; } } for (const rendering::Polygon& polygon : scene.polygons) { for (const rendering::Ring& ring : polygon) { - for (const rendering::Point& point : ring) + if (!std::all_of(ring.begin(), ring.end(), finite)) { - if (!IsDrawableCoordinate(ToDeviceX(point, transform, originX)) || - !IsDrawableCoordinate(ToDeviceY(point, transform, originY))) - { - return false; - } + return false; } } } - 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( + struct DevicePoint + { + double x = 0.0; + double y = 0.0; + }; + + [[nodiscard]] DevicePoint ToDevicePoint( const rendering::Point& point, const ViewTransform& transform, const double originX, const double originY) noexcept { return { - static_cast(std::floor(ToDeviceX(point, transform, originX))), - static_cast(std::floor(ToDeviceY(point, transform, originY)))}; + ToDeviceX(point, transform, originX), + ToDeviceY(point, transform, originY)}; } - [[nodiscard]] HRESULT DrawLayer( - const HDC deviceContext, - const rendering::VectorRenderScene& scene, + [[nodiscard]] POINT ToPoint(const DevicePoint& point) noexcept + { + return { + static_cast(std::floor(point.x)), + static_cast(std::floor(point.y))}; + } + + [[nodiscard]] bool ClipSegment( + DevicePoint& start, + DevicePoint& end, + const RECT& clip) noexcept + { + if (!std::isfinite(start.x) || !std::isfinite(start.y) || + !std::isfinite(end.x) || !std::isfinite(end.y)) + { + return false; + } + + const double deltaX = end.x - start.x; + const double deltaY = end.y - start.y; + if (!std::isfinite(deltaX) || !std::isfinite(deltaY)) + { + return false; + } + + double minimum = 0.0; + double maximum = 1.0; + const double p[] = {-deltaX, deltaX, -deltaY, deltaY}; + const double q[] = { + start.x - clip.left, + clip.right - start.x, + start.y - clip.top, + clip.bottom - start.y}; + for (int edge = 0; edge < 4; ++edge) + { + if (p[edge] == 0.0) + { + if (q[edge] < 0.0) + { + return false; + } + continue; + } + + const double ratio = q[edge] / p[edge]; + if (p[edge] < 0.0) + { + minimum = std::max(minimum, ratio); + } + else + { + maximum = std::min(maximum, ratio); + } + if (minimum > maximum) + { + return false; + } + } + + const DevicePoint originalStart = start; + start = { + originalStart.x + minimum * deltaX, + originalStart.y + minimum * deltaY}; + end = { + originalStart.x + maximum * deltaX, + originalStart.y + maximum * deltaY}; + return true; + } + + [[nodiscard]] bool IsInside( + const DevicePoint& point, + const int edge, + const RECT& clip) noexcept + { + switch (edge) + { + case 0: + return point.x >= clip.left; + case 1: + return point.x <= clip.right; + case 2: + return point.y >= clip.top; + default: + return point.y <= clip.bottom; + } + } + + [[nodiscard]] DevicePoint IntersectEdge( + const DevicePoint& start, + const DevicePoint& end, + const int edge, + const RECT& clip) noexcept + { + if (edge < 2) + { + const double x = edge == 0 ? clip.left : clip.right; + const double ratio = (x - start.x) / (end.x - start.x); + return {x, start.y + ratio * (end.y - start.y)}; + } + + const double y = edge == 2 ? clip.top : clip.bottom; + const double ratio = (y - start.y) / (end.y - start.y); + return {start.x + ratio * (end.x - start.x), y}; + } + + [[nodiscard]] std::vector ClipRing( + const rendering::Ring& ring, const ViewTransform& transform, const double originX, - const double originY) + const double originY, + const RECT& clip) { - for (const rendering::Ring& line : scene.lines) + std::vector points; + points.reserve(ring.size()); + for (const rendering::Point& point : ring) { - if (line.size() < 2) + const DevicePoint device = + ToDevicePoint(point, transform, originX, originY); + if (!std::isfinite(device.x) || !std::isfinite(device.y)) { - continue; + return {}; } - if (line.size() > static_cast(std::numeric_limits::max())) + points.push_back(device); + } + + for (int edge = 0; edge < 4 && !points.empty(); ++edge) + { + std::vector clipped; + clipped.reserve(points.size() + 4U); + DevicePoint previous = points.back(); + bool previousInside = IsInside(previous, edge, clip); + for (const DevicePoint& current : points) { - return E_INVALIDARG; + const bool currentInside = IsInside(current, edge, clip); + if (currentInside != previousInside) + { + clipped.push_back(IntersectEdge(previous, current, edge, clip)); + } + if (currentInside) + { + clipped.push_back(current); + } + previous = current; + previousInside = currentInside; } - std::vector points; - points.reserve(line.size()); - for (const rendering::Point& point : line) + points = std::move(clipped); + } + return points; + } + + [[nodiscard]] HRESULT DrawClippedLine( + const HDC deviceContext, + const rendering::Ring& line, + const ViewTransform& transform, + const double originX, + const double originY, + const RECT& clip) + { + if (line.size() < 2) + { + return S_OK; + } + for (std::size_t index = 1; index < line.size(); ++index) + { + DevicePoint start = + ToDevicePoint(line[index - 1], transform, originX, originY); + DevicePoint end = + ToDevicePoint(line[index], transform, originX, originY); + if (!ClipSegment(start, end, clip)) { - points.push_back(ToPoint(point, transform, originX, originY)); + continue; } - if (Polyline(deviceContext, points.data(), static_cast(points.size())) == FALSE) + const POINT points[] = {ToPoint(start), ToPoint(end)}; + if (Polyline(deviceContext, points, 2) == FALSE) { return E_FAIL; } } + return S_OK; + } + + [[nodiscard]] HRESULT DrawLayer( + const HDC deviceContext, + const rendering::VectorRenderScene& scene, + const ViewTransform& transform, + const double originX, + const double originY, + const RECT& clip) + { + for (const rendering::Ring& line : scene.lines) + { + const HRESULT lineResult = DrawClippedLine( + deviceContext, line, transform, originX, originY, clip); + if (FAILED(lineResult)) + { + return lineResult; + } + } for (const rendering::Polygon& polygon : scene.polygons) { @@ -246,18 +405,24 @@ namespace mwg::activex continue; } + std::vector clipped = ClipRing( + ring, transform, originX, originY, clip); + if (clipped.size() < 3) + { + continue; + } const std::size_t maximumPoints = static_cast(std::numeric_limits::max()); - if (ring.size() > maximumPoints || - points.size() > maximumPoints - ring.size()) + if (clipped.size() > maximumPoints || + points.size() > maximumPoints - clipped.size()) { return E_INVALIDARG; } - ringCounts.push_back(static_cast(ring.size())); - for (const rendering::Point& point : ring) + ringCounts.push_back(static_cast(clipped.size())); + for (const DevicePoint& point : clipped) { - points.push_back(ToPoint(point, transform, originX, originY)); + points.push_back(ToPoint(point)); } } @@ -274,7 +439,15 @@ namespace mwg::activex for (const rendering::Point& point : scene.points) { - const POINT pixel = ToPoint(point, transform, originX, originY); + const DevicePoint device = ToDevicePoint( + point, transform, originX, originY); + if (!std::isfinite(device.x) || !std::isfinite(device.y) || + device.x < clip.left - 3.0 || device.x > clip.right + 3.0 || + device.y < clip.top - 3.0 || device.y > clip.bottom + 3.0) + { + continue; + } + const POINT pixel = ToPoint(device); if (Ellipse( deviceContext, pixel.x - 3, @@ -418,6 +591,7 @@ namespace mwg::activex const double originX = static_cast(bounds.left); const double originY = static_cast(bounds.top); + const RECT clip = bounds; std::size_t drawnLayers = 0; try { @@ -444,14 +618,19 @@ namespace mwg::activex continue; } - if (!HasDrawableCoordinates(layer.scene, *transform, originX, originY)) + if (!HasFiniteCoordinates(layer.scene)) { _failedLayers.push_back(layer.layerId); continue; } - const HRESULT layerResult = - DrawLayer(deviceContext, layer.scene, *transform, originX, originY); + const HRESULT layerResult = DrawLayer( + deviceContext, + layer.scene, + *transform, + originX, + originY, + clip); if (layerResult == E_INVALIDARG) { _failedLayers.push_back(layer.layerId); diff --git a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl index 1e1eec6..acb1e23 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl @@ -178,6 +178,28 @@ library MapWinGISActiveXLib HRESULT StopAnimation(); }; + [ + object, + dual, + oleautomation, + uuid(7D989B8F-2B70-4A5E-9AA4-81D0A16BF651), + pointer_default(unique) + ] + interface IMapWinGISControl7 : IMapWinGISControl6 + { + [id(30)] + HRESULT MoveLayer( + [in] hyper layerId, + [in] LONG newPosition, + [out, retval] VARIANT_BOOL* changed); + + [id(31)] + HRESULT LoadXyzTileLayer( + [in] BSTR urlTemplate, + [in] LONG minimumZoom, + [in] LONG maximumZoom); + }; + [ object, dual, @@ -232,7 +254,8 @@ library MapWinGISActiveXLib ] coclass MapWinGISControl { - [default] interface IMapWinGISControl6; + [default] interface IMapWinGISControl7; + interface IMapWinGISControl6; interface IMapWinGISControl5; interface IMapWinGISControl4; interface IMapWinGISControl3; diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 0699b11..528e283 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -644,6 +644,42 @@ STDMETHODIMP CMapWinGISControl::SetLayerVisible( return S_OK; } +STDMETHODIMP CMapWinGISControl::MoveLayer( + const LONGLONG layerId, + const LONG newPosition, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (layerId <= 0) + { + return E_INVALIDARG; + } + + try + { + if (_view.MoveLayer( + mwg::LayerId{static_cast(layerId)}, + newPosition)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) { if (path == nullptr) @@ -763,6 +799,52 @@ STDMETHODIMP CMapWinGISControl::LoadRasterDataset(BSTR path) } } +STDMETHODIMP CMapWinGISControl::LoadXyzTileLayer( + BSTR urlTemplate, + const LONG minimumZoom, + const LONG maximumZoom) +{ + if (urlTemplate == nullptr) + { + return E_INVALIDARG; + } + const UINT length = ::SysStringLen(urlTemplate); + if (length == 0) + { + return E_INVALIDARG; + } + + try + { + const std::wstring urlText(urlTemplate, static_cast(length)); + if (urlText.find(L'\0') != std::wstring::npos) + { + return E_INVALIDARG; + } + std::string url; + const HRESULT conversionResult = BstrToUtf8(urlTemplate, url); + if (FAILED(conversionResult)) + { + return conversionResult; + } + auto dataset = mwg::RasterDataset::OpenXyzTiles( + url, + minimumZoom, + maximumZoom); + (void)_view.AddRasterLayer(std::move(dataset)); + FireViewChange(); + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + STDMETHODIMP CMapWinGISControl::Pan( const double deltaX, const double deltaY, diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index 183a940..9570487 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -4,10 +4,11 @@ * @file MapWinGISControl.h * @brief Declares the COM-creatable MapWinGIS ActiveX control. * - * Provides automation for vector and raster loading, map navigation, file - * drop, GDAL operations, rendering preferences, and map-layer painting. 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. + * Provides automation for vector, raster, and XYZ tile loading, map + * navigation, layer ordering, file drop, GDAL operations, and rendering + * preferences. 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 @@ -41,8 +42,8 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, public ATL::IDispatchImpl< - IMapWinGISControl6, - &IID_IMapWinGISControl6, + IMapWinGISControl7, + &IID_IMapWinGISControl7, &LIBID_MapWinGISActiveXLib, 1, 0>, @@ -61,6 +62,7 @@ class ATL_NO_VTABLE CMapWinGISControl : DECLARE_PROTECT_FINAL_CONSTRUCT() BEGIN_COM_MAP(CMapWinGISControl) + COM_INTERFACE_ENTRY(IMapWinGISControl7) COM_INTERFACE_ENTRY(IMapWinGISControl6) COM_INTERFACE_ENTRY(IMapWinGISControl5) COM_INTERFACE_ENTRY(IMapWinGISControl4) @@ -70,7 +72,7 @@ class ATL_NO_VTABLE CMapWinGISControl : COM_INTERFACE_ENTRY(IMapWinGISControlGdal) COM_INTERFACE_ENTRY(IDropTarget) COM_INTERFACE_ENTRY(IConnectionPointContainer) - COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl6) + COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl7) COM_INTERFACE_ENTRY(IViewObjectEx) COM_INTERFACE_ENTRY(IViewObject2) COM_INTERFACE_ENTRY(IViewObject) @@ -140,6 +142,14 @@ class ATL_NO_VTABLE CMapWinGISControl : STDMETHOD(AnimateZoomToLayer)(LONGLONG layerId, LONG durationMilliseconds) override; STDMETHOD(get_IsAnimating)(VARIANT_BOOL* isAnimating) override; STDMETHOD(StopAnimation)() override; + STDMETHOD(MoveLayer)( + LONGLONG layerId, + LONG newPosition, + VARIANT_BOOL* changed) override; + STDMETHOD(LoadXyzTileLayer)( + BSTR urlTemplate, + LONG minimumZoom, + LONG maximumZoom) override; STDMETHOD(ClearLayers)() override; STDMETHOD(get_LayerLoaded)(VARIANT_BOOL* loaded) override; STDMETHOD(get_FeatureCount)(LONGLONG* count) override; diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Map.h b/src/MapWinGIS.Core/include/mapwingis/map/Map.h index 2514a49..48bd5a6 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/Map.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/Map.h @@ -171,6 +171,11 @@ namespace mwg /// has no layer with this identity. [[nodiscard]] std::optional LayerPosition(LayerId id) const noexcept; + /// Moves a layer to a new 0-based position. Returns false when the + /// layer is unknown or already at that position. + /// \throws InvalidArgument when position is outside the collection. + bool MoveLayer(LayerId id, int position); + /// Shows or hides a layer. Returns false when the map has no layer /// with this identity. bool SetLayerVisible(LayerId id, bool visible) noexcept; diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h index 260f7bc..78e8a3c 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -31,9 +31,10 @@ namespace mwg /// /// 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. + /// through GetViewport(). Changes to the layer collection, visibility, or + /// order mark the scene stale; unchanged vector geometry is reused. + /// Raster layers are read for the current viewport window. 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 @@ -87,6 +88,11 @@ namespace mwg /// changed. Returns false when the map has no such layer. bool SetLayerVisible(LayerId id, bool visible) noexcept; + /// Moves a layer to a new 0-based drawing position. Returns false + /// when its position did not change or the identity is unknown. + /// \throws InvalidArgument when position is outside the collection. + bool MoveLayer(LayerId id, int position); + /// 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. @@ -189,15 +195,17 @@ namespace mwg [[nodiscard]] bool IsTransitioning() const noexcept; - /// The render scene of the current map, rebuilt first when the map - /// changed since the last call; see rendering::CreateMapRenderScene. + /// Render scene of the current map and viewport. Raster layers contain + /// viewport-sized windows; unchanged vector geometry is cached by + /// layer identity across map changes. /// 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. + /// Marks the render scene and per-layer geometry cache stale, for + /// example after source geometry was edited. Returns true: the frame + /// must be redrawn. bool InvalidateScene() noexcept; private: diff --git a/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h b/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h index c8689b4..19c1938 100644 --- a/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h +++ b/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h @@ -4,8 +4,8 @@ * @file RasterDataset.h * @brief Declares the read-only raster dataset and display-image API. * - * Raster datasets expose their georeferenced bounds and a bounded RGBA - * preview for map rendering. GDAL and raster-driver types stay private. + * Raster datasets expose georeferenced bounds, viewport-sized RGBA reads, and + * bounded previews. GDAL and raster-driver types stay private. */ #pragma once @@ -33,11 +33,12 @@ namespace mwg /// An open read-only GDAL raster data source. /// /// ReadImage limits its longest output edge to maxDimension and uses - /// nearest-neighbour resampling. Single-band data is displayed as - /// grayscale, paletted data uses its color table, and multiband data uses - /// RGB color interpretations or the first three bands. Nodata and alpha - /// bands are transparent. A non-empty target WKT reprojects the preview - /// before sampling. + /// nearest-neighbour resampling. ReadWindow reads only the visible source + /// window and samples it to the requested output dimensions. Single-band + /// data is displayed as grayscale, paletted data uses its color table, + /// and multiband data uses RGB color interpretations or the first three + /// bands. Nodata and alpha bands are transparent. A non-empty target WKT + /// reprojects the source before sampling. class MWG_CORE_API RasterDataset { public: @@ -48,6 +49,15 @@ namespace mwg [[nodiscard]] static std::shared_ptr Open( const std::filesystem::path& path); + /// Opens an XYZ tile service using a URL containing {z}, {x}, and {y}. + /// Tiles use the Web Mercator matrix and are cached in memory by GDAL. + /// \throws InvalidArgument when the URL template or zoom range is invalid. + /// \throws OpenFailed when the GDAL WMS driver cannot open the service. + [[nodiscard]] static std::shared_ptr OpenXyzTiles( + const std::string& urlTemplate, + int minimumZoom = 0, + int maximumZoom = 19); + [[nodiscard]] std::filesystem::path Path() const; [[nodiscard]] int Width() const noexcept; [[nodiscard]] int Height() const noexcept; @@ -59,6 +69,15 @@ namespace mwg const std::string& targetProjectionWkt = {}, int maxDimension = 2048) const; + /// Reads an output image for a map-coordinate window. Pixels outside + /// the raster remain transparent; output dimensions are limited to + /// 16 million pixels. + [[nodiscard]] RasterImage ReadWindow( + const Extent& targetExtent, + const std::string& targetProjectionWkt, + int width, + int height) const; + private: class Impl; explicit RasterDataset(std::unique_ptr impl); diff --git a/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h index 522498b..90de61d 100644 --- a/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/MapRenderScene.h @@ -29,7 +29,7 @@ namespace mwg::rendering LayerId layerId; /// Point, line, and polygon geometry in map coordinates. VectorRenderScene scene; - /// Bounded raster preview when the layer is raster-backed. + /// Raster window, or bounded preview for a map-only scene build. std::optional raster; }; @@ -63,8 +63,9 @@ namespace mwg::rendering std::vector skippedLayers; }; - /// Copies drawable vector geometry of every visible map layer into - /// owned scene data in map order and the map coordinate system. + /// Copies drawable geometry of every visible map layer into owned scene + /// data in map order and the map coordinate system. This overload reads + /// bounded raster previews; use the viewport overload for raster windows. /// /// Each layer is converted on its own, so a layer that cannot be /// rendered is listed in skippedLayers and does not prevent the other @@ -73,10 +74,19 @@ namespace mwg::rendering /// declared coordinate systems are reprojected to that of the first layer. /// /// 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. + /// not reflected, so create a new scene after the map changes. Vector + /// layers are read 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); + + /// Builds a render scene whose raster layers are read only for the + /// requested visible map window and output size. Vector geometry remains + /// a complete scene and is clipped by the rendering backend. + [[nodiscard]] MWG_CORE_API MapRenderScene CreateMapRenderScene( + const Map& map, + const Extent& visibleExtent, + int width, + int height); } diff --git a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h index ca71e51..611d971 100644 --- a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h +++ b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h @@ -11,10 +11,17 @@ #include +#include "mapwingis/rendering/MapRenderScene.h" #include "mapwingis/rendering/VectorRenderScene.h" namespace mwg::rendering::detail { + struct LayerRenderSceneResult + { + std::optional layer; + std::optional skipped; + }; + /// Copies point, line, and polygon geometry from a layer into scene data. Unlike /// CreateVectorRenderScene, a layer without drawable geometry gives a /// scene with no polygons instead of an error. @@ -23,4 +30,11 @@ namespace mwg::rendering::detail [[nodiscard]] VectorRenderScene BuildVectorRenderScene( const VectorLayer& layer, const std::string& mapProjectionWkt = {}); + + [[nodiscard]] LayerRenderSceneResult BuildLayerRenderScene( + const Map& map, + const MapLayer& layer, + const Extent* visibleExtent = nullptr, + int width = 0, + int height = 0); } diff --git a/src/MapWinGIS.Core/src/map/Map.cpp b/src/MapWinGIS.Core/src/map/Map.cpp index 52cb7ce..2cb2954 100644 --- a/src/MapWinGIS.Core/src/map/Map.cpp +++ b/src/MapWinGIS.Core/src/map/Map.cpp @@ -151,6 +151,18 @@ namespace mwg class Map::Impl { public: + [[nodiscard]] std::vector>::iterator Find( + const LayerId id) noexcept + { + return std::find_if( + _layers.begin(), + _layers.end(), + [id](const std::unique_ptr& layer) + { + return layer->Id() == id; + }); + } + [[nodiscard]] std::vector>::const_iterator Find( const LayerId id) const noexcept { @@ -292,6 +304,38 @@ namespace mwg return static_cast(layer - _impl->_layers.begin()); } + bool Map::MoveLayer(const LayerId id, const int position) + { + if (position < 0 || position >= LayerCount()) + { + throw InvalidArgument("Map layer position out of range: " + std::to_string(position)); + } + + const auto layer = _impl->Find(id); + if (layer == _impl->_layers.end()) + { + return false; + } + + const auto currentPosition = + static_cast(layer - _impl->_layers.begin()); + if (currentPosition == position) + { + return false; + } + + auto target = _impl->_layers.begin() + position; + if (currentPosition < position) + { + std::rotate(layer, layer + 1, target + 1); + } + else + { + std::rotate(target, layer, layer + 1); + } + return true; + } + bool Map::SetLayerVisible(const LayerId id, const bool visible) noexcept { const auto layer = _impl->Find(id); diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp index 79bed26..f2c51ff 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -9,9 +9,11 @@ #include #include #include +#include #include #include +#include "internal/VectorRenderSceneBuilder.h" #include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" @@ -57,6 +59,19 @@ namespace mwg std::chrono::milliseconds elapsed{0}; }; + struct RasterView + { + Extent extent; + int width = 0; + int height = 0; + }; + + struct CachedLayer + { + std::optional layer; + std::optional skipped; + }; + Impl() { _backHistory.reserve(kMaxHistoryEntries); @@ -204,6 +219,8 @@ namespace mwg std::vector _forwardHistory; std::optional _transition; std::optional _navigationGestureStart; + std::optional _rasterView; + std::unordered_map _layerSceneCache; }; MapView::MapView() @@ -305,6 +322,18 @@ namespace mwg return true; } + bool MapView::MoveLayer(const LayerId id, const int position) + { + if (!_impl->_map.MoveLayer(id, position)) + { + return false; + } + (void)EndNavigationGesture(); + _impl->_transition.reset(); + _impl->_sceneStale = true; + return true; + } + bool MapView::Resize(const int width, const int height) { Viewport& viewport = _impl->_viewport; @@ -539,10 +568,154 @@ namespace mwg const rendering::MapRenderScene& MapView::Scene() { + std::optional rasterView; + const Viewport& viewport = _impl->_viewport; + if (viewport.IsValid()) + { + rasterView = Impl::RasterView{ + viewport.VisibleExtent(), viewport.Width(), viewport.Height()}; + } + if (_impl->_sceneStale) { - _impl->_scene = rendering::CreateMapRenderScene(_impl->_map); + for (rendering::LayerRenderScene& layer : _impl->_scene.layers) + { + Impl::CachedLayer& cached = _impl->_layerSceneCache[layer.layerId.value]; + cached.layer = std::move(layer); + cached.skipped.reset(); + } + for (rendering::SkippedLayer& skipped : _impl->_scene.skippedLayers) + { + if (skipped.reason != rendering::LayerSkipReason::Hidden) + { + Impl::CachedLayer& cached = + _impl->_layerSceneCache[skipped.layerId.value]; + cached.layer.reset(); + cached.skipped = std::move(skipped); + } + } + + for (auto cached = _impl->_layerSceneCache.begin(); + cached != _impl->_layerSceneCache.end();) + { + if (_impl->_map.FindLayer(LayerId{cached->first}) == nullptr) + { + cached = _impl->_layerSceneCache.erase(cached); + } + else + { + ++cached; + } + } + + rendering::MapRenderScene updated; + for (int position = 0; position < _impl->_map.LayerCount(); ++position) + { + const MapLayer& layer = _impl->_map.Layer(position); + if (!layer.IsVisible()) + { + updated.skippedLayers.push_back( + {layer.Id(), rendering::LayerSkipReason::Hidden, {}}); + continue; + } + + auto cached = _impl->_layerSceneCache.find(layer.Id().value); + if (cached != _impl->_layerSceneCache.end() && + cached->second.layer.has_value()) + { + Impl::CachedLayer& entry = cached->second; + if (layer.IsRaster()) + { + try + { + if (rasterView.has_value()) + { + entry.layer->raster = layer.RasterSource()->ReadWindow( + rasterView->extent, + _impl->_map.CoordinateSystemWkt(), + rasterView->width, + rasterView->height); + } + else + { + entry.layer->raster = + layer.RasterSource()->ReadImage( + _impl->_map.CoordinateSystemWkt()); + entry.layer->raster->extent = layer.GetExtent(); + } + } + catch (const std::bad_alloc&) + { + throw; + } + catch (const std::exception& error) + { + updated.skippedLayers.push_back( + {layer.Id(), rendering::LayerSkipReason::Failed, error.what()}); + continue; + } + } + updated.layers.push_back(std::move(*entry.layer)); + _impl->_layerSceneCache.erase(cached); + continue; + } + if (cached != _impl->_layerSceneCache.end() && + cached->second.skipped.has_value()) + { + updated.skippedLayers.push_back(std::move(*cached->second.skipped)); + _impl->_layerSceneCache.erase(cached); + continue; + } + + rendering::detail::LayerRenderSceneResult built = + rendering::detail::BuildLayerRenderScene( + _impl->_map, + layer, + rasterView.has_value() ? &rasterView->extent : nullptr, + rasterView.has_value() ? rasterView->width : 0, + rasterView.has_value() ? rasterView->height : 0); + if (built.layer.has_value()) + { + updated.layers.push_back(std::move(*built.layer)); + } + if (built.skipped.has_value()) + { + updated.skippedLayers.push_back(std::move(*built.skipped)); + } + } + _impl->_scene = std::move(updated); _impl->_sceneStale = false; + _impl->_rasterView = rasterView; + } + else if (rasterView.has_value() && + (!_impl->_rasterView.has_value() || + _impl->_rasterView->width != rasterView->width || + _impl->_rasterView->height != rasterView->height || + _impl->_rasterView->extent.xMin != rasterView->extent.xMin || + _impl->_rasterView->extent.yMin != rasterView->extent.yMin || + _impl->_rasterView->extent.xMax != rasterView->extent.xMax || + _impl->_rasterView->extent.yMax != rasterView->extent.yMax)) + { + std::vector> replacements; + for (rendering::LayerRenderScene& renderLayer : _impl->_scene.layers) + { + const MapLayer* layer = _impl->_map.FindLayer(renderLayer.layerId); + if (layer != nullptr && layer->IsRaster() && layer->IsVisible()) + { + replacements.emplace_back( + &renderLayer, + layer->RasterSource()->ReadWindow( + rasterView->extent, + _impl->_map.CoordinateSystemWkt(), + rasterView->width, + rasterView->height)); + } + } + for (auto& [layer, image] : replacements) + { + layer->raster = std::move(image); + } + _impl->_rasterView = rasterView; } return _impl->_scene; } @@ -550,6 +723,9 @@ namespace mwg bool MapView::InvalidateScene() noexcept { _impl->_sceneStale = true; + _impl->_rasterView.reset(); + _impl->_layerSceneCache.clear(); + _impl->_scene = {}; return true; } } diff --git a/src/MapWinGIS.Core/src/raster/RasterDataset.cpp b/src/MapWinGIS.Core/src/raster/RasterDataset.cpp index a881ae3..e081d11 100644 --- a/src/MapWinGIS.Core/src/raster/RasterDataset.cpp +++ b/src/MapWinGIS.Core/src/raster/RasterDataset.cpp @@ -7,13 +7,18 @@ #include "mapwingis/raster/RasterDataset.h" #include +#include #include +#include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -128,6 +133,10 @@ namespace mwg [[nodiscard]] std::vector ReadBand( GDALRasterBand& band, + const int xOffset, + const int yOffset, + const int sourceWidth, + const int sourceHeight, const int width, const int height) { @@ -136,10 +145,10 @@ namespace mwg std::vector samples(count); if (band.RasterIO( GF_Read, - 0, - 0, - band.GetXSize(), - band.GetYSize(), + xOffset, + yOffset, + sourceWidth, + sourceHeight, samples.data(), width, height, @@ -152,6 +161,429 @@ namespace mwg return samples; } + [[nodiscard]] std::uint8_t ScaleSample( + float sample, + double minimum, + double maximum) noexcept; + [[nodiscard]] std::uint8_t ScaleAlphaSample( + float sample, + double minimum, + double maximum) noexcept; + + [[nodiscard]] DatasetPtr CreateWarpedDataset( + GDALDataset* source, + const std::string& sourceWkt, + const std::string& targetWkt, + const bool rotated) + { + const detail::CoordinateTransformation transform( + sourceWkt, + targetWkt.empty() ? sourceWkt : targetWkt); + if (!transform.IsNeeded() && !rotated) + { + return {}; + } + + const std::string& destinationWkt = + targetWkt.empty() ? sourceWkt : targetWkt; + const char* sourceProjection = sourceWkt.empty() ? nullptr : sourceWkt.c_str(); + const char* destinationProjection = + destinationWkt.empty() ? nullptr : destinationWkt.c_str(); + DatasetPtr warped(static_cast(GDALAutoCreateWarpedVRT( + source, + sourceProjection, + destinationProjection, + GRA_NearestNeighbour, + 0.0, + nullptr))); + if (warped == nullptr) + { + throw CoreError("Cannot warp raster into the map coordinate system"); + } + return warped; + } + + [[nodiscard]] std::string EscapeXml(const std::string& value) + { + std::string escaped; + escaped.reserve(value.size()); + for (const char character : value) + { + switch (character) + { + case '&': + escaped += "&"; + break; + case '<': + escaped += "<"; + break; + case '>': + escaped += ">"; + break; + case '"': + escaped += """; + break; + case '\'': + escaped += "'"; + break; + default: + escaped += character; + break; + } + } + return escaped; + } + + void ReplaceAll( + std::string& value, + const std::string_view from, + const std::string_view to) + { + std::size_t position = 0; + while ((position = value.find(from, position)) != std::string::npos) + { + value.replace(position, from.size(), to); + position += to.size(); + } + } + + [[nodiscard]] bool HasPlaceholder( + const std::string& value, + const std::string_view placeholder) + { + return value.find(placeholder) != std::string::npos; + } + + [[nodiscard]] std::string MakeXyzServiceXml( + const std::string& urlTemplate, + const int minimumZoom, + const int maximumZoom) + { + if ((urlTemplate.starts_with("https://") == false && + urlTemplate.starts_with("http://") == false) || + !HasPlaceholder(urlTemplate, "{x}") || + !HasPlaceholder(urlTemplate, "{y}") || + !HasPlaceholder(urlTemplate, "{z}")) + { + throw InvalidArgument( + "XYZ tile URL must use HTTP or HTTPS and contain {z}, {x}, and {y}"); + } + + std::string serverUrl = urlTemplate; + ReplaceAll(serverUrl, "{x}", "${x}"); + ReplaceAll(serverUrl, "{y}", "${y}"); + ReplaceAll(serverUrl, "{z}", "${z}"); + const bool png = serverUrl.find(".png") != std::string::npos; + const bool jpeg = serverUrl.find(".jpg") != std::string::npos || + serverUrl.find(".jpeg") != std::string::npos; + + const int tileSize = 256; + const int tileCount = 1 << minimumZoom; + const int dimension = tileSize * tileCount; + const int overviewCount = maximumZoom - minimumZoom + 1; + const std::string format = jpeg ? "jpg" : "png"; + return + "" + "" + EscapeXml(serverUrl) + + "" + format + + "" + "" + "-20037508.342789244" + "20037508.342789244" + "20037508.342789244" + "-20037508.342789244" + "" + std::to_string(dimension) + "" + "" + std::to_string(dimension) + "" + "" + std::to_string(minimumZoom) + "" + "11" + "top" + "EPSG:3857" + "256256" + "" + std::to_string(overviewCount) + "" + "" + std::string(png ? "4" : "3") + "" + "Byte" + "/vsimem/mapwingis6-xyz-cache" + "67108864" + "154" + "MapWinGIS6" + ""; + } + + [[nodiscard]] RasterImage ReadRasterWindow( + GDALDataset& source, + const Extent& requestedExtent, + const int width, + const int height) + { + RasterImage image; + image.width = width; + image.height = height; + image.extent = requestedExtent; + const std::size_t pixelCount = static_cast(width) * + static_cast(height); + image.pixels.assign(pixelCount, 0U); + + const Extent sourceExtent = DatasetExtent(source); + const Extent overlap{ + std::max(requestedExtent.xMin, sourceExtent.xMin), + std::max(requestedExtent.yMin, sourceExtent.yMin), + std::min(requestedExtent.xMax, sourceExtent.xMax), + std::min(requestedExtent.yMax, sourceExtent.yMax)}; + if (!overlap.IsValid()) + { + return image; + } + + double geotransform[6]{}; + if (source.GetGeoTransform(geotransform) != CE_None) + { + geotransform[0] = 0.0; + geotransform[1] = 1.0; + geotransform[2] = 0.0; + geotransform[3] = static_cast(source.GetRasterYSize()); + geotransform[4] = 0.0; + geotransform[5] = -1.0; + } + double inverse[6]{}; + if (GDALInvGeoTransform(geotransform, inverse) == FALSE) + { + throw CoreError("Raster window has no invertible geotransform"); + } + + double pixelX[4]{}; + double pixelY[4]{}; + const double cornerX[] = { + overlap.xMin, overlap.xMax, overlap.xMax, overlap.xMin}; + const double cornerY[] = { + overlap.yMax, overlap.yMax, overlap.yMin, overlap.yMin}; + for (int corner = 0; corner < 4; ++corner) + { + GDALApplyGeoTransform( + inverse, + cornerX[corner], + cornerY[corner], + &pixelX[corner], + &pixelY[corner]); + } + const auto [pixelXMin, pixelXMax] = + std::minmax_element(std::begin(pixelX), std::end(pixelX)); + const auto [pixelYMin, pixelYMax] = + std::minmax_element(std::begin(pixelY), std::end(pixelY)); + const int xOffset = std::clamp( + static_cast(std::floor(*pixelXMin)), + 0, + source.GetRasterXSize()); + const int yOffset = std::clamp( + static_cast(std::floor(*pixelYMin)), + 0, + source.GetRasterYSize()); + const int xEnd = std::clamp( + static_cast(std::ceil(*pixelXMax)), + 0, + source.GetRasterXSize()); + const int yEnd = std::clamp( + static_cast(std::ceil(*pixelYMax)), + 0, + source.GetRasterYSize()); + const int sourceWidth = xEnd - xOffset; + const int sourceHeight = yEnd - yOffset; + if (sourceWidth <= 0 || sourceHeight <= 0) + { + return image; + } + + const int outputLeft = std::clamp( + static_cast(std::floor( + (overlap.xMin - requestedExtent.xMin) / + requestedExtent.Width() * width)), + 0, + width); + const int outputRight = std::clamp( + static_cast(std::ceil( + (overlap.xMax - requestedExtent.xMin) / + requestedExtent.Width() * width)), + 0, + width); + const int outputTop = std::clamp( + static_cast(std::floor( + (requestedExtent.yMax - overlap.yMax) / + requestedExtent.Height() * height)), + 0, + height); + const int outputBottom = std::clamp( + static_cast(std::ceil( + (requestedExtent.yMax - overlap.yMin) / + requestedExtent.Height() * height)), + 0, + height); + const int outputWidth = outputRight - outputLeft; + const int outputHeight = outputBottom - outputTop; + if (outputWidth <= 0 || outputHeight <= 0) + { + return image; + } + + GDALRasterBand* first = source.GetRasterBand(1); + if (first == nullptr) + { + throw CoreError("Raster dataset has no first band"); + } + GDALColorTable* colorTable = first->GetColorTable(); + const int bandCount = source.GetRasterCount(); + const bool palette = colorTable != nullptr; + int redIndex = 1; + int greenIndex = bandCount < 3 ? 1 : 2; + int blueIndex = bandCount < 3 ? 1 : 3; + int alphaIndex = 0; + int interpretedRed = 0; + int interpretedGreen = 0; + int interpretedBlue = 0; + for (int index = 1; index <= bandCount; ++index) + { + GDALRasterBand* band = source.GetRasterBand(index); + if (band == nullptr) + { + continue; + } + switch (band->GetColorInterpretation()) + { + case GCI_RedBand: + interpretedRed = index; + break; + case GCI_GreenBand: + interpretedGreen = index; + break; + case GCI_BlueBand: + interpretedBlue = index; + break; + case GCI_AlphaBand: + alphaIndex = index; + break; + default: + break; + } + } + if (!palette && interpretedRed != 0 && interpretedGreen != 0 && + interpretedBlue != 0) + { + redIndex = interpretedRed; + greenIndex = interpretedGreen; + blueIndex = interpretedBlue; + } + GDALRasterBand* redBand = source.GetRasterBand(redIndex); + GDALRasterBand* greenBand = source.GetRasterBand(greenIndex); + GDALRasterBand* blueBand = source.GetRasterBand(blueIndex); + GDALRasterBand* alphaBand = + alphaIndex != 0 ? source.GetRasterBand(alphaIndex) : nullptr; + if (redBand == nullptr || greenBand == nullptr || blueBand == nullptr) + { + throw CoreError("Raster dataset is missing a display band"); + } + + const GDALDataType redType = redBand->GetRasterDataType(); + const GDALDataType greenType = greenBand->GetRasterDataType(); + const GDALDataType blueType = blueBand->GetRasterDataType(); + const double redMin = SampleMinimum(*redBand, redType); + const double redMax = SampleMaximum(*redBand, redType); + const double greenMin = SampleMinimum(*greenBand, greenType); + const double greenMax = SampleMaximum(*greenBand, greenType); + const double blueMin = SampleMinimum(*blueBand, blueType); + const double blueMax = SampleMaximum(*blueBand, blueType); + std::vector alpha; + double alphaMin = 0.0; + double alphaMax = 255.0; + if (alphaBand != nullptr) + { + const GDALDataType alphaType = alphaBand->GetRasterDataType(); + alphaMin = SampleMinimum(*alphaBand, alphaType); + alphaMax = SampleMaximum(*alphaBand, alphaType); + alpha = ReadBand( + *alphaBand, xOffset, yOffset, sourceWidth, sourceHeight, + outputWidth, outputHeight); + } + const std::vector red = ReadBand( + *redBand, xOffset, yOffset, sourceWidth, sourceHeight, + outputWidth, outputHeight); + const std::vector green = greenIndex == redIndex + ? red + : ReadBand( + *greenBand, xOffset, yOffset, sourceWidth, sourceHeight, + outputWidth, outputHeight); + const std::vector blue = blueIndex == redIndex + ? red + : blueIndex == greenIndex + ? green + : ReadBand( + *blueBand, xOffset, yOffset, sourceWidth, sourceHeight, + outputWidth, outputHeight); + + int hasNoData = FALSE; + const double noData = first->GetNoDataValue(&hasNoData); + for (int row = 0; row < outputHeight; ++row) + { + for (int column = 0; column < outputWidth; ++column) + { + const std::size_t sampleIndex = + static_cast(row) * outputWidth + column; + std::uint8_t r{}; + std::uint8_t g{}; + std::uint8_t b{}; + std::uint8_t a = 255; + if (palette) + { + const int colorIndex = + static_cast(std::round(red[sampleIndex])); + const GDALColorEntry* entry = colorTable->GetColorEntry(colorIndex); + if (entry == nullptr) + { + a = 0; + } + else + { + r = static_cast(std::clamp(entry->c1, 0, 255)); + g = static_cast(std::clamp(entry->c2, 0, 255)); + b = static_cast(std::clamp(entry->c3, 0, 255)); + a = static_cast(std::clamp(entry->c4, 0, 255)); + } + } + else + { + r = ScaleSample(red[sampleIndex], redMin, redMax); + g = ScaleSample(green[sampleIndex], greenMin, greenMax); + b = ScaleSample(blue[sampleIndex], blueMin, blueMax); + } + if (hasNoData != FALSE && + red[sampleIndex] == static_cast(noData)) + { + a = 0; + } + if (!alpha.empty()) + { + const std::uint8_t bandAlpha = + ScaleAlphaSample(alpha[sampleIndex], alphaMin, alphaMax); + a = static_cast( + (static_cast(a) * bandAlpha + 127U) / 255U); + } + r = static_cast( + (static_cast(r) * a + 127U) / 255U); + g = static_cast( + (static_cast(g) * a + 127U) / 255U); + b = static_cast( + (static_cast(b) * a + 127U) / 255U); + const std::uint32_t pixel = + (static_cast(a) << 24) | + (static_cast(r) << 16) | + (static_cast(g) << 8) | + static_cast(b); + const std::size_t outputIndex = + static_cast(outputTop + row) * width + + outputLeft + column; + image.pixels[outputIndex] = pixel; + } + } + return image; + } + [[nodiscard]] std::uint8_t ScaleSample( const float sample, const double minimum, @@ -227,6 +659,60 @@ namespace mwg new RasterDataset(std::make_unique(path, std::move(dataset)))); } + std::shared_ptr RasterDataset::OpenXyzTiles( + const std::string& urlTemplate, + const int minimumZoom, + const int maximumZoom) + { + if (minimumZoom < 0 || maximumZoom < minimumZoom || maximumZoom > 22) + { + throw InvalidArgument("XYZ tile zoom range must be within [0, 22]"); + } + + GdalEnvironment::Initialize(); + const std::string xml = MakeXyzServiceXml(urlTemplate, minimumZoom, maximumZoom); + static std::atomic_uint64_t nextConfigurationId{0}; + const std::string configurationPath = + "/vsimem/mapwingis6_xyz_" + + std::to_string(nextConfigurationId.fetch_add(1, std::memory_order_relaxed)) + + ".xml"; + void* xmlBuffer = CPLMalloc(xml.size() + 1U); + if (xmlBuffer == nullptr) + { + throw std::bad_alloc(); + } + std::memcpy(xmlBuffer, xml.c_str(), xml.size() + 1U); + if (VSIFileFromMemBuffer( + configurationPath.c_str(), + static_cast(xmlBuffer), + static_cast(xml.size()), + TRUE) == nullptr) + { + CPLFree(xmlBuffer); + throw OpenFailed("Cannot create the XYZ tile service configuration"); + } + + static constexpr const char* allowedDrivers[]{"WMS", nullptr}; + DatasetPtr dataset(static_cast(GDALOpenEx( + configurationPath.c_str(), + GDAL_OF_RASTER | GDAL_OF_READONLY, + allowedDrivers, + nullptr, + nullptr))); + VSIUnlink(configurationPath.c_str()); + if (dataset == nullptr) + { + throw OpenFailed("Cannot open XYZ tile service: " + urlTemplate); + } + if (dataset->GetRasterXSize() <= 0 || dataset->GetRasterYSize() <= 0 || + dataset->GetRasterCount() <= 0) + { + throw OpenFailed("XYZ tile service has no readable pixels: " + urlTemplate); + } + return std::shared_ptr( + new RasterDataset(std::make_unique(std::filesystem::path{}, std::move(dataset)))); + } + std::filesystem::path RasterDataset::Path() const { return _impl->_path; @@ -269,34 +755,13 @@ namespace mwg GDALDataset* source = _impl->_dataset.get(); const std::string sourceWkt = ProjectionWkt(); - std::unique_ptr warped; - const detail::CoordinateTransformation transform( - sourceWkt, - targetProjectionWkt.empty() ? sourceWkt : targetProjectionWkt); double geotransform[6]{}; const bool hasGeoTransform = source->GetGeoTransform(geotransform) == CE_None; const bool isRotated = hasGeoTransform && (geotransform[2] != 0.0 || geotransform[4] != 0.0); - if (transform.IsNeeded() || isRotated) - { - const std::string& destinationWkt = - targetProjectionWkt.empty() ? sourceWkt : targetProjectionWkt; - const char* sourceProjection = sourceWkt.empty() ? nullptr : sourceWkt.c_str(); - const char* destinationProjection = - destinationWkt.empty() ? nullptr : destinationWkt.c_str(); - warped.reset(static_cast(GDALAutoCreateWarpedVRT( - source, - sourceProjection, - destinationProjection, - GRA_NearestNeighbour, - 0.0, - nullptr))); - if (warped == nullptr) - { - throw CoreError("Cannot warp raster into the map coordinate system"); - } - source = warped.get(); - } + DatasetPtr warped = CreateWarpedDataset( + source, sourceWkt, targetProjectionWkt, isRotated); + source = warped != nullptr ? warped.get() : source; const int sourceWidth = source->GetRasterXSize(); const int sourceHeight = source->GetRasterYSize(); @@ -306,150 +771,42 @@ namespace mwg static_cast(std::max(sourceWidth, sourceHeight))); const int width = std::max(1, static_cast(std::floor(sourceWidth * scale))); const int height = std::max(1, static_cast(std::floor(sourceHeight * scale))); - - RasterImage image; - image.width = width; - image.height = height; - image.extent = DatasetExtent(*source); - const std::size_t pixelCount = static_cast(width) * - static_cast(height); - image.pixels.resize(pixelCount); - - GDALRasterBand* first = source->GetRasterBand(1); - if (first == nullptr) + if (static_cast(width) * static_cast(height) > + 16'000'000ULL) { - throw CoreError("Raster dataset has no first band"); + throw InvalidArgument("Raster preview exceeds the 16 megapixel limit"); } - GDALColorTable* colorTable = first->GetColorTable(); - const int bandCount = source->GetRasterCount(); - const bool palette = colorTable != nullptr; - int redIndex = 1; - int greenIndex = bandCount < 3 ? 1 : 2; - int blueIndex = bandCount < 3 ? 1 : 3; - int alphaIndex = 0; - int interpretedRed = 0; - int interpretedGreen = 0; - int interpretedBlue = 0; - for (int index = 1; index <= bandCount; ++index) - { - GDALRasterBand* band = source->GetRasterBand(index); - if (band == nullptr) - { - continue; - } - switch (band->GetColorInterpretation()) - { - case GCI_RedBand: - interpretedRed = index; - break; - case GCI_GreenBand: - interpretedGreen = index; - break; - case GCI_BlueBand: - interpretedBlue = index; - break; - case GCI_AlphaBand: - alphaIndex = index; - break; - default: - break; - } - } - if (!palette && interpretedRed != 0 && interpretedGreen != 0 && - interpretedBlue != 0) - { - redIndex = interpretedRed; - greenIndex = interpretedGreen; - blueIndex = interpretedBlue; - } - GDALRasterBand* redBand = source->GetRasterBand(redIndex); - GDALRasterBand* greenBand = source->GetRasterBand(greenIndex); - GDALRasterBand* blueBand = source->GetRasterBand(blueIndex); - GDALRasterBand* alphaBand = - alphaIndex != 0 ? source->GetRasterBand(alphaIndex) : nullptr; - if (redBand == nullptr || greenBand == nullptr || blueBand == nullptr) - { - throw CoreError("Raster dataset is missing a display band"); - } + return ReadRasterWindow(*source, DatasetExtent(*source), width, height); + } - const GDALDataType redType = redBand->GetRasterDataType(); - const GDALDataType greenType = greenBand->GetRasterDataType(); - const GDALDataType blueType = blueBand->GetRasterDataType(); - const double redMin = SampleMinimum(*redBand, redType); - const double redMax = SampleMaximum(*redBand, redType); - const double greenMin = SampleMinimum(*greenBand, greenType); - const double greenMax = SampleMaximum(*greenBand, greenType); - const double blueMin = SampleMinimum(*blueBand, blueType); - const double blueMax = SampleMaximum(*blueBand, blueType); - std::vector alpha; - double alphaMin = 0.0; - double alphaMax = 255.0; - if (alphaBand != nullptr) + RasterImage RasterDataset::ReadWindow( + const Extent& targetExtent, + const std::string& targetProjectionWkt, + const int width, + const int height) const + { + if (!targetExtent.IsValid() || targetExtent.Width() <= 0.0 || + targetExtent.Height() <= 0.0) { - const GDALDataType alphaType = alphaBand->GetRasterDataType(); - alphaMin = SampleMinimum(*alphaBand, alphaType); - alphaMax = SampleMaximum(*alphaBand, alphaType); - alpha = ReadBand(*alphaBand, width, height); + throw InvalidArgument("Raster window extent must have positive width and height"); } - const std::vector red = ReadBand(*redBand, width, height); - const std::vector green = greenIndex == redIndex - ? red - : ReadBand(*greenBand, width, height); - const std::vector blue = blueIndex == redIndex - ? red - : blueIndex == greenIndex ? green : ReadBand(*blueBand, width, height); - - int hasNoData = FALSE; - const double noData = first->GetNoDataValue(&hasNoData); - for (std::size_t index = 0; index < pixelCount; ++index) + if (width <= 0 || height <= 0 || + static_cast(width) * static_cast(height) > + 16'000'000ULL) { - std::uint8_t r{}; - std::uint8_t g{}; - std::uint8_t b{}; - std::uint8_t a = 255; - if (palette) - { - const int colorIndex = static_cast(std::round(red[index])); - const GDALColorEntry* entry = colorTable->GetColorEntry(colorIndex); - if (entry == nullptr) - { - a = 0; - } - else - { - r = static_cast(std::clamp(entry->c1, 0, 255)); - g = static_cast(std::clamp(entry->c2, 0, 255)); - b = static_cast(std::clamp(entry->c3, 0, 255)); - a = static_cast(std::clamp(entry->c4, 0, 255)); - } - } - else - { - r = ScaleSample(red[index], redMin, redMax); - g = ScaleSample(green[index], greenMin, greenMax); - b = ScaleSample(blue[index], blueMin, blueMax); - } - if (hasNoData != FALSE && red[index] == static_cast(noData)) - { - a = 0; - } - if (!alpha.empty()) - { - const std::uint8_t bandAlpha = - ScaleAlphaSample(alpha[index], alphaMin, alphaMax); - a = static_cast( - (static_cast(a) * bandAlpha + 127U) / 255U); - } - r = static_cast((static_cast(r) * a + 127U) / 255U); - g = static_cast((static_cast(g) * a + 127U) / 255U); - b = static_cast((static_cast(b) * a + 127U) / 255U); - image.pixels[index] = - (static_cast(a) << 24) | - (static_cast(r) << 16) | - (static_cast(g) << 8) | - static_cast(b); + throw InvalidArgument("Raster window dimensions must be positive and at most 16 megapixels"); } - return image; + + GDALDataset* source = _impl->_dataset.get(); + const std::string sourceWkt = ProjectionWkt(); + double geotransform[6]{}; + const bool hasGeoTransform = source->GetGeoTransform(geotransform) == CE_None; + const bool isRotated = hasGeoTransform && + (geotransform[2] != 0.0 || geotransform[4] != 0.0); + DatasetPtr warped = CreateWarpedDataset( + source, sourceWkt, targetProjectionWkt, isRotated); + source = warped != nullptr ? warped.get() : source; + return ReadRasterWindow(*source, targetExtent, width, height); } } diff --git a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp index 71a9eb0..4cf75df 100644 --- a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp @@ -11,55 +11,121 @@ #include #include "internal/VectorRenderSceneBuilder.h" +#include "mapwingis/core/Error.h" -namespace mwg::rendering +namespace mwg::rendering::detail { - MapRenderScene CreateMapRenderScene(const Map& map) + LayerRenderSceneResult BuildLayerRenderScene( + const Map& map, + const MapLayer& layer, + const Extent* visibleExtent, + const int width, + const int height) { - MapRenderScene result; - for (int position = 0; position < map.LayerCount(); ++position) + if (!layer.IsVisible()) { - const MapLayer& layer = map.Layer(position); - if (!layer.IsVisible()) - { - result.skippedLayers.push_back({layer.Id(), LayerSkipReason::Hidden, {}}); - continue; - } + return {{}, SkippedLayer{layer.Id(), LayerSkipReason::Hidden, {}}}; + } - VectorRenderScene scene; - try + try + { + if (layer.IsRaster()) { - if (layer.IsRaster()) + RasterImage raster; + if (visibleExtent != nullptr) + { + raster = layer.RasterSource()->ReadWindow( + *visibleExtent, + map.CoordinateSystemWkt(), + width, + height); + } + else { - RasterImage raster = - layer.RasterSource()->ReadImage(map.CoordinateSystemWkt()); + raster = layer.RasterSource()->ReadImage(map.CoordinateSystemWkt()); raster.extent = layer.GetExtent(); - result.layers.push_back({layer.Id(), {}, std::move(raster)}); - continue; } - - scene = detail::BuildVectorRenderScene( - layer.Source(), - map.CoordinateSystemWkt()); - scene.extent = layer.GetExtent(); + return { + LayerRenderScene{layer.Id(), {}, std::move(raster)}, + {}}; } - catch (const std::bad_alloc&) - { - throw; - } - catch (const std::exception& error) + + VectorRenderScene scene = BuildVectorRenderScene( + layer.Source(), + map.CoordinateSystemWkt()); + scene.extent = layer.GetExtent(); + if (scene.points.empty() && scene.lines.empty() && scene.polygons.empty()) { - result.skippedLayers.push_back({layer.Id(), LayerSkipReason::Failed, error.what()}); - continue; + return { + {}, + SkippedLayer{layer.Id(), LayerSkipReason::NoGeometry, {}}}; } + return { + LayerRenderScene{layer.Id(), std::move(scene), std::nullopt}, + {}}; + } + catch (const std::bad_alloc&) + { + throw; + } + catch (const std::exception& error) + { + return { + {}, + SkippedLayer{layer.Id(), LayerSkipReason::Failed, error.what()}}; + } + } +} - if (scene.points.empty() && scene.lines.empty() && scene.polygons.empty()) +namespace mwg::rendering +{ + namespace + { + MapRenderScene BuildMapRenderScene( + const Map& map, + const Extent* visibleExtent, + const int width, + const int height) + { + MapRenderScene result; + for (int position = 0; position < map.LayerCount(); ++position) { - result.skippedLayers.push_back({layer.Id(), LayerSkipReason::NoGeometry, {}}); - continue; + detail::LayerRenderSceneResult layer = + detail::BuildLayerRenderScene( + map, + map.Layer(position), + visibleExtent, + width, + height); + if (layer.layer.has_value()) + { + result.layers.push_back(std::move(*layer.layer)); + } + if (layer.skipped.has_value()) + { + result.skippedLayers.push_back(std::move(*layer.skipped)); + } } - result.layers.push_back({layer.Id(), std::move(scene), std::nullopt}); + return result; + } + } + + MapRenderScene CreateMapRenderScene(const Map& map) + { + return BuildMapRenderScene(map, nullptr, 0, 0); + } + + MapRenderScene CreateMapRenderScene( + const Map& map, + const Extent& visibleExtent, + const int width, + const int height) + { + if (!visibleExtent.IsValid() || visibleExtent.Width() <= 0.0 || + visibleExtent.Height() <= 0.0 || width <= 0 || height <= 0) + { + throw InvalidArgument("Raster scene window must have a valid extent and dimensions"); } - return result; + return BuildMapRenderScene(map, &visibleExtent, width, height); } } diff --git a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp index 59a0e4f..f013da1 100644 --- a/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ActiveXActivationTests.cpp @@ -1125,6 +1125,39 @@ TEST_F(ActiveXMapTest, Control6ExposesNavigationHistoryAndAnimatedFit) EXPECT_EQ(isAnimating, VARIANT_FALSE); } +TEST_F(ActiveXMapTest, Control7ExposesLayerReordering) +{ + ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); + ASSERT_EQ(LoadRectangles({{20, 0, 30, 5}}), S_OK); + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + + LONGLONG bottomId = 0; + LONGLONG topId = 0; + ASSERT_EQ(automation->GetLayerId(0, &bottomId), S_OK); + ASSERT_EQ(automation->GetLayerId(1, &topId), S_OK); + VARIANT_BOOL changed = VARIANT_FALSE; + ASSERT_EQ(automation->MoveLayer(bottomId, 1, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_TRUE); + ASSERT_EQ(automation->GetLayerId(0, &bottomId), S_OK); + EXPECT_EQ(bottomId, topId); + ASSERT_EQ(automation->MoveLayer(topId, 0, &changed), S_OK); + EXPECT_EQ(changed, VARIANT_FALSE); +} + +TEST_F(ActiveXMapTest, Control7AddsXyzLayerWithoutFetchingUntilDraw) +{ + ComPtr automation; + ASSERT_EQ(_control.As(&automation), S_OK); + ATL::CComBSTR url(L"https://tiles.example.test/{z}/{x}/{y}.png"); + + ASSERT_EQ(automation->LoadXyzTileLayer(url, 0, 18), S_OK); + + LONG layerCount = 0; + ASSERT_EQ(automation->get_LayerCount(&layerCount), S_OK); + EXPECT_EQ(layerCount, 1); +} + TEST_F(ActiveXMapTest, KeyboardArrowKeysPanTheMap) { ASSERT_EQ(LoadRectangles({{0, 0, 10, 5}}), S_OK); diff --git a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp index d93f9cb..b55a52e 100644 --- a/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapRenderSceneTests.cpp @@ -332,6 +332,23 @@ TEST_F(MapRenderSceneTest, IncludesRasterPreviewAndExtentInMapScene) EXPECT_TRUE(scene.layers.front().scene.polygons.empty()); } +TEST_F(MapRenderSceneTest, ReadsRasterOnlyForTheRequestedViewAtItsPixelSize) +{ + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(_directory.Path())); + _map.AddRasterLayer(raster); + + const rendering::MapRenderScene scene = + rendering::CreateMapRenderScene(_map, {2.0, 1.0, 10.0, 7.0}, 80, 60); + + ASSERT_EQ(scene.layers.size(), 1U); + ASSERT_TRUE(scene.layers.front().raster.has_value()); + EXPECT_EQ(scene.layers.front().raster->width, 80); + EXPECT_EQ(scene.layers.front().raster->height, 60); + EXPECT_DOUBLE_EQ(scene.layers.front().raster->extent.xMin, 2.0); + EXPECT_DOUBLE_EQ(scene.layers.front().raster->extent.yMax, 7.0); +} + TEST_F(MapRenderSceneTest, DuplicateLayersAreSeparateSceneLayers) { const std::shared_ptr dataset = OpenRectangles({{0.0, 0.0, 1.0, 1.0}}); diff --git a/tests/MapWinGIS.Core.Tests/MapTests.cpp b/tests/MapWinGIS.Core.Tests/MapTests.cpp index 4579fd6..6943aa7 100644 --- a/tests/MapWinGIS.Core.Tests/MapTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapTests.cpp @@ -88,6 +88,28 @@ TEST_F(MapTest, AppendsNewLayersOnTop) EXPECT_EQ(_map.LayerPosition(top), 2); } +TEST_F(MapTest, MovesLayersWithoutChangingIdentityOrLayerReferences) +{ + const LayerId bottom = _map.AddLayer(OpenRectangles({{0, 0, 1, 1}})); + const LayerId middle = _map.AddLayer(OpenRectangles({{2, 2, 3, 3}})); + const LayerId top = _map.AddLayer(OpenRectangles({{4, 4, 5, 5}})); + const MapLayer* const middleLayer = _map.FindLayer(middle); + + ASSERT_TRUE(_map.MoveLayer(bottom, 2)); + + EXPECT_EQ(_map.Layer(0).Id(), middle); + EXPECT_EQ(_map.Layer(1).Id(), top); + EXPECT_EQ(_map.Layer(2).Id(), bottom); + EXPECT_EQ(_map.FindLayer(middle), middleLayer); + EXPECT_EQ(_map.LayerPosition(bottom), 2); + EXPECT_EQ(_map.LayerPosition(middle), 0); + EXPECT_EQ(_map.LayerPosition(top), 1); + EXPECT_FALSE(_map.MoveLayer(middle, 0)); + EXPECT_FALSE(_map.MoveLayer(LayerId{99}, 0)); + EXPECT_THROW((void)_map.MoveLayer(middle, -1), InvalidArgument); + EXPECT_THROW((void)_map.MoveLayer(middle, 3), InvalidArgument); +} + TEST_F(MapTest, RejectsMissingDatasetAndOutOfRangeLayerIndex) { const std::shared_ptr dataset = OpenRectangles({{0, 0, 1, 1}}); diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index b9e332d..2451ebe 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -247,18 +247,61 @@ TEST_F(MapViewTest, MapChangesRebuildTheSceneAndReportARedraw) EXPECT_FALSE(_view.SetLayerVisible(second, true)); } +TEST_F(MapViewTest, MovingLayerChangesSceneOrderAndRequestsRedraw) +{ + const LayerId bottom = _view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const LayerId top = _view.AddLayer(OpenRectangles({{2.0, 2.0, 3.0, 3.0}})); + ASSERT_EQ(_view.Scene().layers.size(), 2U); + + ASSERT_TRUE(_view.MoveLayer(bottom, 1)); + const auto& scene = _view.Scene(); + + ASSERT_EQ(scene.layers.size(), 2U); + EXPECT_EQ(scene.layers[0].layerId, top); + EXPECT_EQ(scene.layers[1].layerId, bottom); + EXPECT_FALSE(_view.MoveLayer(bottom, 1)); + EXPECT_FALSE(_view.MoveLayer(LayerId{99}, 0)); + EXPECT_THROW((void)_view.MoveLayer(bottom, 2), InvalidArgument); +} + TEST_F(MapViewTest, SceneIsReusedUntilTheMapChanges) { - _view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}})); + const LayerId first = _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); + _view.AddLayer(OpenRectangles({{2.0, 2.0, 3.0, 3.0}})); + EXPECT_EQ(_view.Scene().layers.front().scene.polygons.data(), polygons); + + ASSERT_TRUE(_view.SetLayerVisible(first, false)); + ASSERT_TRUE(_view.SetLayerVisible(first, true)); + 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); + EXPECT_EQ(scene.layers.size(), 2U); +} + +TEST_F(MapViewTest, RasterSceneTracksTheViewAndUsesViewportPixelDimensions) +{ + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(_directory.Path())); + ASSERT_TRUE(_view.Resize(60, 40)); + _view.AddRasterLayer(raster); + + const RasterImage first = *_view.Scene().layers.front().raster; + ASSERT_TRUE(_view.ZoomIn()); + const RasterImage zoomed = *_view.Scene().layers.front().raster; + + EXPECT_EQ(first.width, 60); + EXPECT_EQ(first.height, 40); + EXPECT_EQ(zoomed.width, 60); + EXPECT_EQ(zoomed.height, 40); + EXPECT_LT(zoomed.extent.Width(), first.extent.Width()); + EXPECT_LT(zoomed.extent.Height(), first.extent.Height()); } TEST_F(MapViewTest, ClearingLayersKeepsTheView) diff --git a/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp b/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp index ce54d9e..c2555dd 100644 --- a/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp +++ b/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp @@ -1,14 +1,22 @@ // SPDX-License-Identifier: MPL-2.0 +#include + /** * @file RasterDatasetTests.cpp * @brief Tests read-only raster access and display-image generation. */ #include +#include #include +#include #include +#include #include +#include +#include +#include #include @@ -17,6 +25,8 @@ #include "mapwingis/gdal/GdalEnvironment.h" #include "mapwingis/raster/RasterDataset.h" +#pragma comment(lib, "Ws2_32.lib") + using namespace mwg; using namespace mwg::test; @@ -71,6 +81,221 @@ namespace dataset.reset(); return path; } + + std::vector MakePngTile() + { + GdalEnvironment::Initialize(); + GDALDriver* const memoryDriver = GetGDALDriverManager()->GetDriverByName("MEM"); + GDALDriver* const pngDriver = GetGDALDriverManager()->GetDriverByName("PNG"); + if (memoryDriver == nullptr || pngDriver == nullptr) + { + throw std::runtime_error("Memory or PNG GDAL driver is not available"); + } + + std::unique_ptr image( + memoryDriver->Create("", 256, 256, 4, GDT_Byte, nullptr)); + if (image == nullptr) + { + throw std::runtime_error("Could not create an XYZ tile fixture"); + } + const std::array interpretations{ + GCI_RedBand, GCI_GreenBand, GCI_BlueBand, GCI_AlphaBand}; + const std::array colors{220, 80, 40, 255}; + for (int bandIndex = 0; bandIndex < 4; ++bandIndex) + { + GDALRasterBand* const band = image->GetRasterBand(bandIndex + 1); + band->SetColorInterpretation( + interpretations[static_cast(bandIndex)]); + std::vector pixels( + 256U * 256U, colors[static_cast(bandIndex)]); + if (band->RasterIO( + GF_Write, 0, 0, 256, 256, pixels.data(), 256, 256, + GDT_Byte, 0, 0) != CE_None) + { + throw std::runtime_error("Could not write XYZ tile fixture pixels"); + } + } + + constexpr const char* path = "/vsimem/mapwingis6-test-tile.png"; + std::unique_ptr encoded( + pngDriver->CreateCopy(path, image.get(), FALSE, nullptr, nullptr, nullptr)); + if (encoded == nullptr) + { + throw std::runtime_error("Could not encode the XYZ tile fixture"); + } + vsi_l_offset size = 0; + const GByte* const data = VSIGetMemFileBuffer(path, &size, FALSE); + if (data == nullptr || size == 0) + { + throw std::runtime_error("Could not read the XYZ tile fixture"); + } + std::vector result(data, data + size); + encoded.reset(); + VSIUnlink(path); + return result; + } + + class LocalTileServer final + { + public: + explicit LocalTileServer(std::vector tile) + { + WSADATA data{}; + if (WSAStartup(MAKEWORD(2, 2), &data) != 0) + { + throw std::runtime_error("Could not initialize Winsock"); + } + _winsockStarted = true; + try + { + _listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (_listener == INVALID_SOCKET) + { + throw std::runtime_error("Could not create a local tile listener"); + } + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (bind( + _listener, + reinterpret_cast(&address), + sizeof(address)) == SOCKET_ERROR || + listen(_listener, SOMAXCONN) == SOCKET_ERROR) + { + throw std::runtime_error("Could not bind a local tile listener"); + } + int addressLength = sizeof(address); + if (getsockname( + _listener, + reinterpret_cast(&address), + &addressLength) == SOCKET_ERROR) + { + throw std::runtime_error("Could not query the local tile listener"); + } + _port = ntohs(address.sin_port); + _thread = std::thread( + [this, tile = std::move(tile)]() mutable + { + while (!_stopping.load()) + { + fd_set readable{}; + FD_ZERO(&readable); + FD_SET(_listener, &readable); + timeval timeout{0, 100000}; + const int ready = select(0, &readable, nullptr, nullptr, &timeout); + if (ready == 0) + { + continue; + } + if (ready == SOCKET_ERROR || _stopping.load()) + { + break; + } + + SOCKET client = accept(_listener, nullptr, nullptr); + if (client == INVALID_SOCKET) + { + break; + } + char request[2048]{}; + const int received = recv(client, request, sizeof(request) - 1, 0); + if (received > 0) + { + { + std::lock_guard lock(_requestMutex); + _lastRequest.assign( + request, static_cast(received)); + } + const std::string header = + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\n" + "Content-Length: " + std::to_string(tile.size()) + + "\r\nConnection: close\r\n\r\n"; + auto sendAll = [client]( + const char* buffer, + std::size_t length) + { + std::size_t sent = 0; + while (sent < length) + { + const int count = send( + client, buffer + sent, + static_cast(length - sent), 0); + if (count <= 0) + { + return false; + } + sent += static_cast(count); + } + return true; + }; + const bool headerSent = + sendAll(header.data(), header.size()); + if (headerSent) + { + (void)sendAll( + reinterpret_cast(tile.data()), + tile.size()); + } + } + closesocket(client); + } + }); + } + catch (...) + { + if (_listener != INVALID_SOCKET) + { + closesocket(_listener); + _listener = INVALID_SOCKET; + } + WSACleanup(); + _winsockStarted = false; + throw; + } + } + + ~LocalTileServer() + { + _stopping.store(true); + if (_listener != INVALID_SOCKET) + { + closesocket(_listener); + } + if (_thread.joinable()) + { + _thread.join(); + } + if (_winsockStarted) + { + WSACleanup(); + } + } + + LocalTileServer(const LocalTileServer&) = delete; + LocalTileServer& operator=(const LocalTileServer&) = delete; + + [[nodiscard]] std::string UrlTemplate() const + { + return "http://127.0.0.1:" + std::to_string(_port) + "/{z}/{x}/{y}.png"; + } + + [[nodiscard]] std::string LastRequest() const + { + std::lock_guard lock(_requestMutex); + return _lastRequest; + } + + private: + SOCKET _listener = INVALID_SOCKET; + unsigned short _port = 0; + std::atomic_bool _stopping{false}; + std::thread _thread; + mutable std::mutex _requestMutex; + std::string _lastRequest; + bool _winsockStarted = false; + }; } TEST(RasterDatasetTest, ReadsGeoreferencedSingleBandPixelsAsGrayscale) @@ -128,3 +353,110 @@ TEST(RasterDatasetTest, RejectsInvalidPreviewDimensions) EXPECT_THROW((void)raster->ReadImage({}, 0), InvalidArgument); } + +TEST(RasterDatasetTest, ReadsOnlyTheRequestedRasterWindowAtViewportResolution) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + + const RasterImage image = raster->ReadWindow({3.0, 2.0, 9.0, 6.0}, {}, 60, 40); + + ASSERT_EQ(image.width, 60); + ASSERT_EQ(image.height, 40); + EXPECT_DOUBLE_EQ(image.extent.xMin, 3.0); + EXPECT_DOUBLE_EQ(image.extent.yMax, 6.0); + EXPECT_NE(image.pixels.front(), 0x00000000U); + EXPECT_NE(image.pixels[10U * 60U + 10U], 0x00000000U); +} + +TEST(RasterDatasetTest, ReprojectsOnlyTheRequestedWindow) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + const Extent projectedExtent = raster->ReadImage("EPSG:3857").extent; + + const RasterImage image = + raster->ReadWindow(projectedExtent, "EPSG:3857", 120, 80); + + EXPECT_EQ(image.width, 120); + EXPECT_EQ(image.height, 80); + EXPECT_NE( + std::find_if( + image.pixels.begin(), + image.pixels.end(), + [](const std::uint32_t pixel) { return pixel != 0U; }), + image.pixels.end()); +} + +TEST(RasterDatasetTest, WindowedReadLeavesPixelsOutsideRasterTransparent) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + + const RasterImage image = raster->ReadWindow({-6.0, -4.0, 6.0, 4.0}, {}, 120, 80); + + ASSERT_EQ(image.width, 120); + ASSERT_EQ(image.height, 80); + EXPECT_EQ(image.pixels[20U * 120U + 10U], 0x00000000U); + EXPECT_NE(image.pixels[20U * 120U + 80U], 0x00000000U); +} + +TEST(RasterDatasetTest, OpensXyzTileSourcesAsWebMercatorRasters) +{ + const std::shared_ptr raster = RasterDataset::OpenXyzTiles( + "https://tiles.example.test/{z}/{x}/{y}.png", + 1, + 18); + + EXPECT_GT(raster->Width(), 0); + EXPECT_GT(raster->Height(), 0); + EXPECT_NE(raster->ProjectionWkt().find("3857"), std::string::npos); + EXPECT_TRUE(raster->GetExtent().IsValid()); +} + +TEST(RasterDatasetTest, FetchesAndReadsTheRequestedXyzTile) +{ + LocalTileServer server(MakePngTile()); + const std::shared_ptr raster = + RasterDataset::OpenXyzTiles(server.UrlTemplate(), 0, 0); + constexpr double halfWorld = 20037508.342789244; + + const RasterImage image = raster->ReadWindow( + {-halfWorld, -halfWorld, halfWorld, halfWorld}, + "EPSG:3857", + 256, + 256); + + ASSERT_EQ(image.width, 256); + ASSERT_EQ(image.height, 256); + EXPECT_EQ(image.pixels[128U * 256U + 128U], 0xFFDC5028U); + EXPECT_NE(server.LastRequest().find("/0/0/0.png"), std::string::npos); +} + +TEST(RasterDatasetTest, RejectsInvalidXyzTileSourceDescriptions) +{ + EXPECT_THROW( + (void)RasterDataset::OpenXyzTiles("file:///tiles/{z}/{x}/{y}.png"), + InvalidArgument); + EXPECT_THROW( + (void)RasterDataset::OpenXyzTiles("https://tiles.example.test/{z}/{x}.png"), + InvalidArgument); + EXPECT_THROW( + (void)RasterDataset::OpenXyzTiles( + "https://tiles.example.test/{z}/{x}/{y}.png", 5, 4), + InvalidArgument); +} + +TEST(RasterDatasetTest, RejectsRasterWindowsThatExceedThePixelBudget) +{ + TempDirectory directory; + const std::shared_ptr raster = + RasterDataset::Open(MakeRaster(directory.Path())); + + EXPECT_THROW( + (void)raster->ReadWindow({0.0, 0.0, 1.0, 1.0}, {}, 16384, 16384), + InvalidArgument); +} diff --git a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp index a8253d9..c7d6e1f 100644 --- a/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.Core.Tests/VectorLayerRendererTests.cpp @@ -571,6 +571,25 @@ TEST(VectorLayerRenderer, GdiDrawsLayersWithDifferentExtentsThroughOneViewport) ExpectTwoLayerPixels(surface.Get(), moved); } +TEST(VectorLayerRenderer, ClipsOffscreenGeometryBeforeConvertingToGdiCoordinates) +{ + 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); + auto scene = MakeMapScene({ + MakeRectangleLayer({-1.0e12, -1.0e12, 1.0e12, 1.0e12})}); + mwg::rendering::VectorRenderScene lineLayer; + lineLayer.lines.push_back({{-1.0e12, 5.0}, {1.0e12, 5.0}}); + scene.layers.push_back({mwg::LayerId{2}, std::move(lineLayer)}); + const mwg::Viewport viewport = FitViewport(bounds); + + mwg::activex::GdiVectorLayerRenderer gdi; + EXPECT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_TRUE(gdi.GetFailedLayers().empty()); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), RGB(24, 58, 82)); +} + TEST(VectorLayerRenderer, GdiAndOpenGlDrawMultipleLayersAtTheSameViewportPositions) { BitmapSurface gdiSurface(200, 120);