diff --git a/.ai/memory/build/cmake.md b/.ai/memory/build/cmake.md index 927b109..9c8c5b9 100644 --- a/.ai/memory/build/cmake.md +++ b/.ai/memory/build/cmake.md @@ -14,6 +14,15 @@ Generated Visual Studio projects set `WindowsTargetPlatformVersion` before importing `Directory.Build.props`; its legacy SDK default must not override that selection, including in CMake's `VCTargetsPath` probe. +The `cmake-parity` job fails (`-FailOnLostFlags`) when a translation unit is +compiled by MSBuild but missing from the CMake target — including test +projects. Adding a source file to a `.vcxproj` therefore requires the same +addition to the corresponding `CMakeLists.txt` source list (verified on +PR #71, where `PolygonTriangulationTests.cpp` was listed in +`MapWinGIS.Core.Tests.vcxproj` but not in its CMake target). The parity +report (`out/parity/parity-Debug-x64.json` on the runner) names the lost +TU; the job log only shows the lost count. + ## References - [CMake presets](../../../CMakePresets.json) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a6e760d..09cd455 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -258,7 +258,7 @@ responsibilities, not a requirement to split them into more DLLs. | `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 | -| 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 | +| OpenGL backend (in the OCX) | Fill polygons from their Core triangulated meshes, outline the original rings, and copy pixels to the host device context through a reusable WGL 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 @@ -296,8 +296,8 @@ and has no mesh. `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 +The coordinator tries the OpenGL backend when every polygon of every layer +has a triangulated fill mesh. 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. @@ -307,10 +307,13 @@ fixed-function calls into a framebuffer object (OpenGL 3.0 or 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, +Interior rings and concave boundaries draw identically in both backends +because both consume the same scene; a polygon without a fill mesh (for +example a self-intersecting ring that GEOS cannot triangulate), missing +framebuffer-object support, initialization failures, and drawing failures use +the GDI backend, which draws the original rings. +The GDI backend draws the original rings of the whole supported polygon scene, +including its interior rings and concave boundaries. Both backends draw source coordinates without reprojection, and scene preparation currently supports Polygon, MultiPolygon, CurvePolygon, and MultiSurface features. @@ -407,10 +410,14 @@ changes and explicit invalidation rebuild the scene for all visible layers. There is no tiling, paging, or clipping to the visible extent. GDI skips a layer whose device coordinates exceed its range. It clips drawing to the requested target rectangle and the host's existing clip region, then restores -the device context. The opt-in +the device context. Triangulation runs once per polygon when the scene is +prepared; view changes (resize, zoom, pan) and redraws reuse the retained +meshes. 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 +tracks scene preparation, mesh storage, and backend frame costs on both a +convex grid and a complex mix of concave, holed multipart, and many-vertex +polygons. OpenGL fills every polygon from its retained triangle mesh 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. diff --git a/docs/PORTING.md b/docs/PORTING.md index d5dde6d..9440f66 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -97,9 +97,10 @@ 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. +in source coordinates without reprojection. OpenGL fills the triangulated +meshes Core prepares for convex, concave, holed, and multipart polygons; GDI +draws the original rings as the CPU fallback for scenes and systems OpenGL +cannot handle. 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 661c823..2e84d5b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -185,9 +185,15 @@ For build and manual-use instructions, see the GeoPackage grid of convex 32-vertex polygons and separately reports Core dataset open, render-scene preparation, process private-memory growth, the median GDI frame, the first OpenGL frame (context creation and drawing), and -the median steady OpenGL frame, for a 1280x720 off-screen target. It records -the values as GoogleTest properties and fails only when a stage fails. Use a -Release build for meaningful numbers: +the median steady OpenGL frame, for a 1280x720 off-screen target. +`RenderingBaseline.MeasuresComplexPolygonCosts` generates a second dataset — +concave L-shaped polygons, holed rectangles where every feature is a +two-component multipolygon, and concave 32-vertex star polygons across three +layers — and additionally reports the number of triangulated polygons and the +mesh storage (vertices plus indices). Scene preparation in both tests +includes the one-time constrained Delaunay triangulation of every polygon. +Results are recorded as GoogleTest properties; the tests fail only when a +stage fails. Use a Release build for meaningful numbers: ```powershell $env:MWG_RUN_RENDERING_BASELINE = '1' @@ -195,8 +201,32 @@ $env:MWG_RENDERING_BASELINE_FEATURES = '100000' # optional; default 50000 bin\Release\x64\MapWinGIS.ActiveX.Tests.exe --gtest_filter=RenderingBaseline.* ``` -Measurements on one developer workstation (Release, hardware OpenGL); the -previous implementation measurements are retained for comparison: +Measurements on one developer workstation (Release, hardware OpenGL), with +triangulation included in scene preparation: + +| Measurement | Convex grid, 50k polygons | Complex mix, 162.5k meshes | +|---|---:|---:| +| Core open dataset | 1.8 ms | 2.1 ms | +| Core prepare render scene | 2,505.6 ms | 2,758.7 ms | +| Mesh storage (vertices+indices) | — | 32.1 MiB | +| Private memory increase for scene | 87.6 MiB | 86.4 MiB | +| GDI frame (median) | 84.0 ms | 163.9 ms | +| OpenGL first frame | 291.4 ms | 49.3 ms | +| OpenGL frame (median) | 28.4 ms | 38.1 ms | +| Private memory increase total | 203.7 MiB | 94.4 MiB | + +The complex mix is 50k concave L-shapes, 50k holed multipart features, and +12.5k star polygons (162,500 triangulated meshes in total); it ran after the +convex grid in the same process, so its total memory does not repeat the +one-time OpenGL driver allocation. Triangulation runs once per polygon during +scene preparation and the meshes are reused on redraw, so the triangulation +cost shows up in scene preparation, not in per-frame times. Before +triangulation existed, the same convex grid prepared its scene in 91.5 ms per +100k features; triangulation now dominates scene preparation at roughly +15 µs per polygon on this workstation. OpenGL frames re-submit all geometry +because there is no unchanged-geometry cache. + +The pre-triangulation measurements are retained for comparison: | Measurement | x64, 10k (previous) | x64, 100k (previous) | x64, 100k (sequential) | |---|---:|---:|---:| @@ -208,11 +238,11 @@ previous implementation measurements are retained for comparison: | OpenGL frame (median) | 10.0 ms | 82.3 ms | 80.5 ms | | Private memory, total | 91.7 MiB | 167.4 MiB | 167.2 MiB | -Scene preparation now reads geometries sequentially through OGR and copies +Scene preparation reads geometries sequentially through OGR and copies their coordinates directly, avoiding indexed lookups, WKT serialization, and reparsing. The 100k-feature measurement dropped from 15,225 ms to 91.5 ms on -the recorded x64 run. OpenGL frames re-submit all geometry because there is no -unchanged-geometry cache. Total memory includes the OpenGL driver and context. +the recorded x64 run before triangulation was added. Total memory includes +the OpenGL driver and context. ## Conventions diff --git a/tests/MapWinGIS.ActiveX.Tests/RenderingBaselineTests.cpp b/tests/MapWinGIS.ActiveX.Tests/RenderingBaselineTests.cpp index 0a99809..4e444e1 100644 --- a/tests/MapWinGIS.ActiveX.Tests/RenderingBaselineTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/RenderingBaselineTests.cpp @@ -6,8 +6,11 @@ * * Generates a GeoPackage polygon grid and separately times Core dataset access, * render-scene preparation, GDI frames, and OpenGL first and steady frames. - * Results are printed and recorded as GoogleTest properties; the test only - * fails when a stage fails, never on a timing threshold. + * A second scenario generates concave, holed multipart, and many-vertex star + * polygons so the triangulation and mesh-storage costs of complex geometry + * are measured too. Results are printed and recorded as GoogleTest + * properties; the tests only fail when a stage fails, never on a timing + * threshold. */ #include #include @@ -17,14 +20,17 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -147,6 +153,159 @@ namespace return path; } + /// Adds a closed ring to a polygon from (x, y) coordinate pairs. + void AddRing(OGRPolygon& polygon, std::initializer_list> points) + { + OGRLinearRing ring; + for (const auto& [x, y] : points) + { + ring.addPoint(x, y); + } + ring.closeRings(); + polygon.addRing(&ring); + } + + /// Writes three polygon layers that exercise triangulation with complex + /// geometry: a grid of small concave L-shaped polygons, a grid of + /// holed rectangles where every feature is a two-component multipolygon, + /// and a sparser grid of concave 32-vertex star polygons. + std::filesystem::path MakeComplexPolygonDataset( + const std::filesystem::path& directory, + const int count) + { + GdalEnvironment::Initialize(); + const std::filesystem::path path = directory / "complex.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 dataset(driver->Create( + reinterpret_cast(utf8.c_str()), 0, 0, 0, GDT_Unknown, nullptr)); + if (dataset == nullptr) + { + throw std::runtime_error("Cannot create the complex baseline dataset"); + } + + const auto makeLayer = [&](const char* name, const OGRwkbGeometryType type) { + OGRLayer* layer = dataset->CreateLayer(name, nullptr, type, nullptr); + if (layer == nullptr || dataset->StartTransaction() != OGRERR_NONE) + { + throw std::runtime_error("Cannot create a complex baseline layer"); + } + return layer; + }; + + // Many small concave polygons. + OGRLayer* concave = makeLayer("concave", wkbPolygon); + const int columns = static_cast(std::ceil(std::sqrt(static_cast(count)))); + for (int index = 0; index < count; ++index) + { + const double cx = static_cast(index % columns); + const double cy = static_cast(index / columns); + OGRPolygon polygon; + AddRing(polygon, + {{cx - 0.4, cy - 0.4}, + {cx + 0.4, cy - 0.4}, + {cx + 0.4, cy}, + {cx, cy}, + {cx, cy + 0.4}, + {cx - 0.4, cy + 0.4}}); + OGRFeature feature(concave->GetLayerDefn()); + if (feature.SetGeometry(&polygon) != OGRERR_NONE || + concave->CreateFeature(&feature) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write a concave baseline feature"); + } + } + if (dataset->CommitTransaction() != OGRERR_NONE) + { + throw std::runtime_error("Cannot commit the concave baseline layer"); + } + + // Holed rectangles, every one a two-component multipart geometry. + OGRLayer* holed = makeLayer("holed", wkbMultiPolygon); + for (int index = 0; index < count; ++index) + { + const double cx = static_cast(index % columns); + const double cy = static_cast(index / columns); + OGRPolygon body; + AddRing(body, + {{cx - 0.4, cy - 0.4}, + {cx + 0.4, cy - 0.4}, + {cx + 0.4, cy + 0.4}, + {cx - 0.4, cy + 0.4}, + {cx - 0.4, cy - 0.4}}); + AddRing(body, + {{cx - 0.15, cy - 0.15}, + {cx + 0.15, cy - 0.15}, + {cx + 0.15, cy + 0.15}, + {cx - 0.15, cy + 0.15}, + {cx - 0.15, cy - 0.15}}); + // Keep the satellite inside the gap between adjacent bodies + // (bodies extend to +-0.4 on a 1.0 grid) so the multipolygon + // components stay disjoint and the geometry remains valid. + OGRPolygon satellite; + AddRing(satellite, + {{cx + 0.41, cy + 0.41}, + {cx + 0.59, cy + 0.41}, + {cx + 0.59, cy + 0.59}, + {cx + 0.41, cy + 0.59}, + {cx + 0.41, cy + 0.41}}); + OGRMultiPolygon multipart; + multipart.addGeometry(&body); + multipart.addGeometry(&satellite); + if (!multipart.IsValid()) + { + throw std::runtime_error("Cannot build a valid holed baseline multipart"); + } + OGRFeature feature(holed->GetLayerDefn()); + if (feature.SetGeometry(&multipart) != OGRERR_NONE || + holed->CreateFeature(&feature) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write a holed baseline feature"); + } + } + if (dataset->CommitTransaction() != OGRERR_NONE) + { + throw std::runtime_error("Cannot commit the holed baseline layer"); + } + + // Larger concave polygons with many vertices. + const int starCount = std::max(1, count / 4); + const int starColumns = + static_cast(std::ceil(std::sqrt(static_cast(starCount)))); + OGRLayer* stars = makeLayer("stars", wkbPolygon); + for (int index = 0; index < starCount; ++index) + { + const double cx = static_cast(index % starColumns) * 4.0; + const double cy = static_cast(index / starColumns) * 4.0; + OGRLinearRing ring; + for (int vertex = 0; vertex < kVerticesPerPolygon; ++vertex) + { + const double angle = 2.0 * std::numbers::pi * vertex / kVerticesPerPolygon; + const double radius = vertex % 2 == 0 ? 1.6 : 0.7; + ring.addPoint(cx + radius * std::cos(angle), cy + radius * std::sin(angle)); + } + ring.closeRings(); + OGRPolygon polygon; + polygon.addRing(&ring); + OGRFeature feature(stars->GetLayerDefn()); + if (feature.SetGeometry(&polygon) != OGRERR_NONE || + stars->CreateFeature(&feature) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write a star baseline feature"); + } + } + if (dataset->CommitTransaction() != OGRERR_NONE) + { + throw std::runtime_error("Cannot commit the star baseline layer"); + } + return path; + } + /// A 32-bit DIB section target that does not depend on the screen DC. class DibSurface final { @@ -291,3 +450,110 @@ TEST_F(RenderingBaseline, MeasuresCoreSceneAndBackendCosts) ASSERT_TRUE(memoryAfterOpenGl.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; Report("Private memory increase total", *memoryAfterOpenGl - *memoryBefore, "MiB"); } + +TEST_F(RenderingBaseline, MeasuresComplexPolygonCosts) +{ + const char* enabled = std::getenv("MWG_RUN_RENDERING_BASELINE"); + if (enabled == nullptr || std::string(enabled) != "1") + { + GTEST_SKIP() << "Set MWG_RUN_RENDERING_BASELINE=1 to measure the rendering baseline."; + } + + const int featureCount = FeatureCountFromEnvironment(); + TempDirectory directory; + const std::filesystem::path path = + MakeComplexPolygonDataset(directory.Path(), featureCount); + + DWORD memoryError = ERROR_SUCCESS; + const std::optional memoryBefore = PrivateMiB(memoryError); + ASSERT_TRUE(memoryBefore.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; + Clock::time_point start = Clock::now(); + Map map; + const std::shared_ptr dataset = VectorDataset::Open(path); + map.AddLayer(dataset, 0); + map.AddLayer(dataset, 1); + map.AddLayer(dataset, 2); + const double openMs = ElapsedMilliseconds(start); + + start = Clock::now(); + const rendering::MapRenderScene scene = rendering::CreateMapRenderScene(map); + const double sceneMs = ElapsedMilliseconds(start); + const std::optional memoryAfterScene = PrivateMiB(memoryError); + ASSERT_TRUE(memoryAfterScene.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; + ASSERT_EQ(scene.layers.size(), 3U); + ASSERT_TRUE(scene.skippedLayers.empty()); + + std::size_t polygonCount = 0; + std::size_t meshedCount = 0; + std::size_t meshVertexCount = 0; + std::size_t meshIndexCount = 0; + for (const rendering::LayerRenderScene& layer : scene.layers) + { + ASSERT_EQ(layer.scene.fillMeshes.size(), layer.scene.polygons.size()); + polygonCount += layer.scene.polygons.size(); + for (const std::optional& mesh : layer.scene.fillMeshes) + { + ASSERT_TRUE(mesh.has_value()) + << "Every complex baseline polygon must triangulate"; + ++meshedCount; + meshVertexCount += mesh->vertices.size(); + meshIndexCount += mesh->indices.size(); + } + } + const double meshMiB = + (meshVertexCount * sizeof(rendering::Point) + + meshIndexCount * sizeof(std::uint32_t)) / + (1024.0 * 1024.0); + ASSERT_EQ(meshedCount, polygonCount); + + DibSurface surface(kTargetWidth, kTargetHeight); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, kTargetWidth, kTargetHeight}; + Viewport viewport(kTargetWidth, kTargetHeight); + ASSERT_TRUE(viewport.FitExtent(map.GetExtent(), 12.0)); + + activex::GdiVectorLayerRenderer gdi; + std::vector gdiFrames; + for (int frame = 0; frame < kSteadyFrames; ++frame) + { + start = Clock::now(); + ASSERT_EQ(gdi.Draw(surface.Get(), bounds, scene, viewport), S_OK); + gdiFrames.push_back(ElapsedMilliseconds(start)); + } + + std::cout << "Complex polygon baseline: " << featureCount << " concave + " + << featureCount << " holed multipart + " << std::max(1, featureCount / 4) + << " star polygons (" << meshedCount << " triangulated), " << kTargetWidth + << 'x' << kTargetHeight << " target\n"; + Report("Polygons with fill mesh", static_cast(meshedCount), ""); + Report("Core open dataset", openMs, "ms"); + Report("Core prepare render scene", sceneMs, "ms"); + Report("Mesh storage (vertices+indices)", meshMiB, "MiB"); + Report("Private memory increase for scene", *memoryAfterScene - *memoryBefore, "MiB"); + Report("GDI frame (median)", Median(gdiFrames), "ms"); + + activex::OpenGlVectorLayerRenderer openGl; + start = Clock::now(); + const HRESULT firstFrame = openGl.Draw(surface.Get(), bounds, scene, viewport); + const double firstOpenGlMs = ElapsedMilliseconds(start); + if (firstFrame == S_FALSE) + { + std::wcout << L" OpenGL unavailable: " << openGl.GetLastFailureReason() << L'\n'; + return; + } + ASSERT_EQ(firstFrame, S_OK); + + std::vector openGlFrames; + for (int frame = 0; frame < kSteadyFrames; ++frame) + { + start = Clock::now(); + ASSERT_EQ(openGl.Draw(surface.Get(), bounds, scene, viewport), S_OK); + openGlFrames.push_back(ElapsedMilliseconds(start)); + } + + Report("OpenGL first frame (context+draw)", firstOpenGlMs, "ms"); + Report("OpenGL frame (median)", Median(openGlFrames), "ms"); + const std::optional memoryAfterOpenGl = PrivateMiB(memoryError); + ASSERT_TRUE(memoryAfterOpenGl.has_value()) << "GetProcessMemoryInfo failed: " << memoryError; + Report("Private memory increase total", *memoryAfterOpenGl - *memoryBefore, "MiB"); +} diff --git a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp index 00fc3fc..b7c0b4e 100644 --- a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp @@ -441,6 +441,56 @@ TEST(VectorLayerRenderer, FallsBackToGdiWhenAPolygonHasNoFillMesh) L"every polygon; this frame was rendered with GDI."); } +TEST(VectorLayerRenderer, GdiFallbackRendersConcaveHoledPolygonFromRings) +{ + 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); + + // A scene whose only polygon has no triangulated mesh: the coordinator + // must fall back to GDI, which fills the concave shape from the original + // rings and leaves the concave indentation and the interior ring empty. + mwg::rendering::VectorRenderScene layer; + layer.extent = {0.0, 0.0, 10.0, 10.0}; + layer.sourceFeatureCount = 1; + layer.polygons = {{ + {{0.0, 0.0}, {10.0, 0.0}, {10.0, 4.0}, {4.0, 4.0}, {4.0, 10.0}, {0.0, 10.0}, {0.0, 0.0}}, + {{1.0, 1.0}, {1.0, 3.0}, {3.0, 3.0}, {3.0, 1.0}, {1.0, 1.0}}, + }}; + layer.fillMeshes.push_back(std::nullopt); + mwg::rendering::MapRenderScene scene; + scene.layers.push_back({mwg::LayerId{1}, std::move(layer)}); + + const mwg::Viewport viewport = FitViewport(bounds); + mwg::activex::VectorLayerRenderer renderer; + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, viewport), S_OK); + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); + + EXPECT_EQ(MapPixel(surface.Get(), viewport, 7.0, 2.0), kFill) << "lower arm"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 2.0, 7.0), kFill) << "left arm"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 7.0, 7.0), GetSysColor(COLOR_WINDOW)) + << "concave indentation stays unfilled"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 2.0, 2.0), GetSysColor(COLOR_WINDOW)) + << "interior ring stays unfilled"; + + // A later mesh-complete frame must select OpenGL again when the backend + // is available, so a coordinator that permanently stops trying OpenGL + // after one missing-mesh frame cannot pass. Probe availability + // independently of the coordinator, as + // UsesOpenGlForConvexPolygonWhenAvailable does. + const mwg::rendering::MapRenderScene meshScene = MakeConvexPolygonScene(); + mwg::activex::OpenGlVectorLayerRenderer openGlProbe; + const HRESULT probeResult = openGlProbe.Draw(surface.Get(), bounds, meshScene, viewport); + if (probeResult == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(probeResult, S_OK); + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, meshScene, viewport), S_OK); + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::OpenGL); +} + TEST(VectorLayerRenderer, OpenGlSupportRequiresAMeshForEveryPolygon) { const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index 8618987..7270c1f 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -14,12 +14,16 @@ #include #include +#include #include #include #include +#include + #include "TestData.h" #include "mapwingis/core/Error.h" +#include "mapwingis/gdal/GdalEnvironment.h" #include "mapwingis/map/MapView.h" #include "mapwingis/vector/VectorDataset.h" @@ -48,6 +52,56 @@ namespace MakeRectanglesDataset(_directory.Path(), fileName, rectangles)); } + /// Opens a single-feature multipart layer whose first component is a + /// concave L-shaped polygon and whose second is a rectangle with a + /// square hole, so scene-lifetime checks cover triangulated complex + /// geometry rather than rectangles only. + std::shared_ptr OpenComplexPolygons() + { + GdalEnvironment::Initialize(); + const std::string fileName = + "complex-" + std::to_string(_datasetCount++) + ".gpkg"; + const std::filesystem::path path = _directory.Path() / fileName; + + GDALDriver* driver = GetGDALDriverManager()->GetDriverByName("GPKG"); + if (driver == nullptr) + { + throw std::runtime_error("GPKG driver is not available"); + } + const std::u8string utf8 = path.u8string(); + GDALDatasetUniquePtr dataset(driver->Create( + reinterpret_cast(utf8.c_str()), 0, 0, 0, GDT_Unknown, nullptr)); + if (dataset == nullptr) + { + throw std::runtime_error("Cannot create the complex polygon dataset"); + } + OGRLayer* layer = dataset->CreateLayer("complex", nullptr, wkbMultiPolygon, nullptr); + if (layer == nullptr) + { + throw std::runtime_error("Cannot create the complex polygon layer"); + } + + const char* wkt = + "MULTIPOLYGON (((0 0, 10 0, 10 4, 4 4, 4 10, 0 10, 0 0)), " + "((20 0, 30 0, 30 10, 20 10, 20 0), (23 3, 23 7, 27 7, 27 3, 23 3)))"; + OGRGeometry* geometry = nullptr; + if (OGRGeometryFactory::createFromWkt(&wkt, nullptr, &geometry) != OGRERR_NONE || + geometry == nullptr) + { + throw std::runtime_error("Cannot parse the complex polygon WKT"); + } + OGRFeatureUniquePtr feature(OGRFeature::CreateFeature(layer->GetLayerDefn())); + const OGRErr geometryResult = feature->SetGeometry(geometry); + OGRGeometryFactory::destroyGeometry(geometry); + if (geometryResult != OGRERR_NONE || layer->CreateFeature(feature.get()) != OGRERR_NONE) + { + throw std::runtime_error("Cannot write the complex polygon feature"); + } + + dataset.reset(); + return VectorDataset::Open(path); + } + TempDirectory _directory; MapView _view; int _datasetCount = 0; @@ -288,6 +342,59 @@ TEST_F(MapViewTest, PreparedLayersRetainTheirGeometryUntilInvalidated) EXPECT_EQ(driver.readCalls, 16); } +TEST_F(MapViewTest, ComplexSceneMeshesSurviveViewportChanges) +{ + _view.AddLayer(OpenComplexPolygons()); + ASSERT_TRUE(_view.Resize(300, 200)); + + const rendering::MapRenderScene& scene = _view.Scene(); + ASSERT_EQ(scene.layers.size(), 1U); + const rendering::VectorRenderScene& layerScene = scene.layers.front().scene; + ASSERT_EQ(layerScene.polygons.size(), 2U); + ASSERT_EQ(layerScene.fillMeshes.size(), 2U); + // Both the concave component and the holed component are triangulated. + ASSERT_TRUE(layerScene.fillMeshes[0].has_value()); + ASSERT_TRUE(layerScene.fillMeshes[1].has_value()); + const void* meshStorage = layerScene.fillMeshes.data(); + const void* holedMeshVertices = layerScene.fillMeshes[1]->vertices.data(); + const std::size_t holedMeshIndices = layerScene.fillMeshes[1]->indices.size(); + const std::size_t holedPolygonRings = layerScene.polygons[1].size(); + ASSERT_EQ(holedPolygonRings, 2U); + + // Resizing, zooming, and panning change the viewport only: the scene and + // its triangulated meshes stay alive and are not rebuilt. + ASSERT_TRUE(_view.Resize(640, 480)); + EXPECT_EQ(&_view.Scene(), &scene); + EXPECT_EQ(_view.Scene().layers.front().scene.fillMeshes.data(), meshStorage); + EXPECT_EQ(_view.Scene().layers.front().scene.fillMeshes[1]->vertices.data(), + holedMeshVertices); + + ASSERT_TRUE(_view.ZoomIn()); + ASSERT_TRUE(_view.ZoomOut()); + ASSERT_TRUE(_view.ZoomAt(ScreenPoint{150.0, 100.0}, 2.0)); + ASSERT_TRUE(_view.Pan(1.0, -0.5)); + EXPECT_EQ(&_view.Scene(), &scene); + const rendering::VectorRenderScene& navigated = _view.Scene().layers.front().scene; + EXPECT_EQ(navigated.fillMeshes[1]->vertices.data(), holedMeshVertices); + EXPECT_EQ(navigated.fillMeshes[1]->indices.size(), holedMeshIndices); + EXPECT_EQ(navigated.polygons[1].size(), holedPolygonRings); + + // Map changes rebuild the scene; the rebuilt scene triangulates the + // complex polygon again and keeps its original rings for outlines and GDI. + const LayerId second = _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 10.0}})); + ASSERT_EQ(_view.Scene().layers.size(), 2U); + const rendering::VectorRenderScene& rebuilt = _view.Scene().layers.front().scene; + ASSERT_EQ(rebuilt.polygons.size(), 2U); + ASSERT_EQ(rebuilt.fillMeshes.size(), 2U); + EXPECT_TRUE(rebuilt.fillMeshes[0].has_value()); + EXPECT_TRUE(rebuilt.fillMeshes[1].has_value()); + EXPECT_EQ(rebuilt.polygons[1].size(), holedPolygonRings); + + EXPECT_TRUE(_view.RemoveLayer(second)); + ASSERT_EQ(_view.Scene().layers.size(), 1U); + EXPECT_TRUE(_view.Scene().layers.front().scene.fillMeshes[1].has_value()); +} + TEST_F(MapViewTest, PreparedEmptyLayerPreservesExistingAndStaleScenes) { const LayerId first = _view.AddLayer(OpenRectangles({{0, 0, 1, 1}}));