diff --git a/README.md b/README.md index 222d53c..b8ca130 100644 --- a/README.md +++ b/README.md @@ -17,28 +17,36 @@ 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, 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 +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 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 +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. +in the visible extent, `MoveLayer` to change drawing order, 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..4460106 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -117,12 +117,12 @@ 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. 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 +134,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 @@ -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. @@ -225,8 +232,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 +248,16 @@ 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) | 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 | 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 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 @@ -262,29 +273,29 @@ 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. `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 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 windows 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 +303,10 @@ 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` 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 @@ -324,13 +336,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, 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 @@ -344,44 +357,46 @@ 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 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`. -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, -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`; 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. 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 0036d71..731852d 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -49,32 +49,38 @@ 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, +`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. 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 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. ## Map and viewport behavior @@ -83,9 +89,17 @@ 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. `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 d7dc40b..906ad02 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -237,3 +237,22 @@ 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 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. + +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/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..de172b6 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: @@ -122,44 +147,233 @@ 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 { + const auto finite = [](const rendering::Point& point) + { + 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) + { + if (!std::all_of(line.begin(), line.end(), finite)) + { + 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]] 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 RECT& clip) + { + std::vector points; + points.reserve(ring.size()); + for (const rendering::Point& point : ring) + { + const DevicePoint device = + ToDevicePoint(point, transform, originX, originY); + if (!std::isfinite(device.x) || !std::isfinite(device.y)) + { + return {}; + } + 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) + { + 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; + } + 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)) + { + continue; + } + const POINT points[] = {ToPoint(start), ToPoint(end)}; + if (Polyline(deviceContext, points, 2) == FALSE) + { + return E_FAIL; + } + } + return S_OK; } [[nodiscard]] HRESULT DrawLayer( @@ -167,8 +381,19 @@ namespace mwg::activex const rendering::VectorRenderScene& scene, const ViewTransform& transform, const double originX, - const double originY) + 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) { std::vector points; @@ -180,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)); } } @@ -205,8 +436,118 @@ namespace mwg::activex return E_FAIL; } } + + for (const rendering::Point& point : scene.points) + { + 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, + 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( @@ -250,19 +591,46 @@ 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 { for (const rendering::LayerRenderScene& layer : scene.layers) { - if (!HasDrawableCoordinates(layer.scene, *transform, originX, originY)) + 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 (!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/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..acb1e23 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl @@ -107,6 +107,99 @@ 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, + 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, @@ -161,7 +254,10 @@ library MapWinGISActiveXLib ] coclass MapWinGISControl { - [default] interface IMapWinGISControl4; + [default] interface IMapWinGISControl7; + 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..528e283 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); @@ -641,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) @@ -710,6 +749,371 @@ 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::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, + 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 +1336,10 @@ STDMETHODIMP CMapWinGISControl::Drop( else { loadResult = LoadVectorDataset(pathBstr); + if (FAILED(loadResult)) + { + loadResult = LoadRasterDataset(pathBstr); + } ::SysFreeString(pathBstr); } } @@ -1079,12 +1487,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 +1609,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 +1734,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 +1758,7 @@ LRESULT CMapWinGISControl::OnLButtonDown( _isPanning = true; _lastPanPosition = position; + (void)_view.BeginNavigationGesture(); handled = TRUE; return 0; } @@ -1173,6 +1769,11 @@ LRESULT CMapWinGISControl::OnMouseMove( const LPARAM lParam, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } if (!_isPanning) { handled = FALSE; @@ -1215,6 +1816,11 @@ LRESULT CMapWinGISControl::OnLButtonUp( LPARAM, BOOL& handled) { + if (_isTouchNavigating) + { + handled = TRUE; + return 0; + } if (_isPanning) { EndPan(true); @@ -1245,6 +1851,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 +2007,9 @@ LRESULT CMapWinGISControl::OnDestroy( BOOL& handled) { EndPan(true); + EndTouchNavigation(); + (void)_view.CancelTransition(); + (void)_gdalEvents.SetAnimationTimer(false); _gdalEvents.OnDestroy(); handled = FALSE; return 0; @@ -1277,6 +2022,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 +2055,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..9570487 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -4,16 +4,19 @@ * @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 - * 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 #include "ActiveXModule.h" #include "resource.h" +#include +#include #include #include @@ -39,8 +42,8 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, public ATL::IDispatchImpl< - IMapWinGISControl4, - &IID_IMapWinGISControl4, + IMapWinGISControl7, + &IID_IMapWinGISControl7, &LIBID_MapWinGISActiveXLib, 1, 0>, @@ -59,6 +62,9 @@ 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) COM_INTERFACE_ENTRY(IMapWinGISControl3) COM_INTERFACE_ENTRY(IMapWinGISControl2) @@ -66,7 +72,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, IMapWinGISControl7) COM_INTERFACE_ENTRY(IViewObjectEx) COM_INTERFACE_ENTRY(IViewObject2) COM_INTERFACE_ENTRY(IViewObject) @@ -89,12 +95,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 +125,31 @@ 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(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; @@ -153,12 +189,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 +219,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..48bd5a6 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; @@ -154,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; @@ -164,6 +186,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..78e8a3c 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,15 +24,17 @@ namespace mwg { + class RasterDataset; class VectorDataset; /// A map, the viewport it is shown in, and its render scene. /// /// Redraws: every mutator returns whether the displayed frame may have /// changed. Hosts request a redraw when it returns true and draw Scene() - /// through GetViewport(). Changes to the layer collection or visibility - /// mark the scene stale; Scene() rebuilds it on the next call. Edits to - /// source geometry are not observed; call InvalidateScene after them. + /// 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 @@ -40,6 +44,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 +72,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; @@ -78,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. @@ -134,20 +149,76 @@ namespace mwg /// \throws InvalidArgument as Viewport::Pan does. bool Pan(double deltaX, double deltaY); - /// The render scene of the current map, rebuilt first when the map - /// changed since the last call; see rendering::CreateMapRenderScene. + /// 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; + + /// 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: + 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..19c1938 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/raster/RasterDataset.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file RasterDataset.h + * @brief Declares the read-only raster dataset and display-image API. + * + * Raster datasets expose georeferenced bounds, viewport-sized RGBA reads, and + * bounded previews. 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. 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: + ~RasterDataset(); + RasterDataset(const RasterDataset&) = delete; + RasterDataset& operator=(const RasterDataset&) = delete; + + [[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; + [[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; + + /// 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); + 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..90de61d 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; + /// Raster window, or bounded preview for a map-only scene build. + 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,20 +63,30 @@ 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 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 - /// 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 - /// 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/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..611d971 100644 --- a/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h +++ b/src/MapWinGIS.Core/src/internal/VectorRenderSceneBuilder.h @@ -4,19 +4,37 @@ * @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/MapRenderScene.h" #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 + 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. - /// \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 = {}); + + [[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 33a2b8c..2cb2954 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,13 +107,62 @@ 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 { 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 { @@ -92,6 +179,7 @@ namespace mwg // vector reorders or reallocates. std::vector> _layers; std::uint64_t _lastId = 0; + std::string _coordinateSystemWkt; }; Map::Map() @@ -116,10 +204,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, 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), layerIndex))); + 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 +261,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 @@ -171,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); @@ -196,4 +361,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..f2c51ff 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -6,10 +6,14 @@ */ #include "mapwingis/map/MapView.h" +#include #include #include +#include #include +#include +#include "internal/VectorRenderSceneBuilder.h" #include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" @@ -17,6 +21,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 +41,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 +51,142 @@ namespace mwg double padding = 0.0; }; + struct Transition + { + NavigationState start; + NavigationState target; + std::chrono::milliseconds duration; + 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); + _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 +215,12 @@ namespace mwg rendering::MapRenderScene _scene; bool _sceneStale = false; std::optional _pendingFit; + std::vector _backHistory; + std::vector _forwardHistory; + std::optional _transition; + std::optional _navigationGestureStart; + std::optional _rasterView; + std::unordered_map _layerSceneCache; }; MapView::MapView() @@ -87,6 +243,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 +262,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 +301,9 @@ namespace mwg { return false; } + (void)EndNavigationGesture(); _impl->_map.Clear(); + _impl->_transition.reset(); _impl->_sceneStale = true; return true; } @@ -132,7 +315,21 @@ namespace mwg { return false; } + (void)EndNavigationGesture(); (void)_impl->_map.SetLayerVisible(id, visible); + _impl->_transition.reset(); + _impl->_sceneStale = true; + 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; } @@ -159,7 +356,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,49 +375,347 @@ 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() { + 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; } @@ -221,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 new file mode 100644 index 0000000..e081d11 --- /dev/null +++ b/src/MapWinGIS.Core/src/raster/RasterDataset.cpp @@ -0,0 +1,812 @@ +// 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 +#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 xOffset, + const int yOffset, + const int sourceWidth, + const int sourceHeight, + 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, + xOffset, + yOffset, + sourceWidth, + sourceHeight, + 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( + 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, + 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::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; + } + + 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(); + 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; + + 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))); + if (static_cast(width) * static_cast(height) > + 16'000'000ULL) + { + throw InvalidArgument("Raster preview exceeds the 16 megapixel limit"); + } + + return ReadRasterWindow(*source, DatasetExtent(*source), width, height); + } + + 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) + { + throw InvalidArgument("Raster window extent must have positive width and height"); + } + if (width <= 0 || height <= 0 || + static_cast(width) * static_cast(height) > + 16'000'000ULL) + { + throw InvalidArgument("Raster window dimensions must be positive and at most 16 megapixels"); + } + + 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 7080f9c..4cf75df 100644 --- a/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/MapRenderScene.cpp @@ -11,43 +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 - { - scene = detail::BuildVectorRenderScene(layer.Source()); - } - catch (const std::bad_alloc&) + try + { + if (layer.IsRaster()) { - throw; + RasterImage raster; + if (visibleExtent != nullptr) + { + raster = layer.RasterSource()->ReadWindow( + *visibleExtent, + map.CoordinateSystemWkt(), + width, + height); + } + else + { + raster = layer.RasterSource()->ReadImage(map.CoordinateSystemWkt()); + raster.extent = layer.GetExtent(); + } + return { + LayerRenderScene{layer.Id(), {}, std::move(raster)}, + {}}; } - 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.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)}); + 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/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..f013da1 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,156 @@ 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, 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); + 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 +1291,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 +1302,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..b55a52e 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,85 @@ 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, 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) 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 71647e4..2451ebe 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -12,6 +12,7 @@ */ #include +#include #include #include #include @@ -246,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) @@ -568,6 +612,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..c2555dd --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/RasterDatasetTests.cpp @@ -0,0 +1,462 @@ +// 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 + +#include "TestData.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/gdal/GdalEnvironment.h" +#include "mapwingis/raster/RasterDataset.h" + +#pragma comment(lib, "Ws2_32.lib") + +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; + } + + 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) +{ + 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); +} + +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 a87495e..c7d6e1f 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); @@ -507,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); 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);