From c536c0efe118ef05c393420252d3c51181a731ea Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Thu, 12 Nov 2026 19:15:29 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(activex):=20render=20trian?= =?UTF-8?q?gulated=20polygon=20meshes=20in=20OpenGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fill polygons with indexed triangle meshes via glVertexPointer and glDrawElements - Draw outlines from the original rings instead of triangle edges - Accept concave, holed and multipart scenes; drop the convexity checks - Reject scenes whose polygons lack a usable fill mesh so the coordinator falls back to GDI - Extend renderer tests with concave, holed, multipart and GDI-equivalence coverage - Update the ActiveX architecture memory to the mesh-based rendering model --- .ai/memory/architecture/activex.md | 4 +- .../OpenGlVectorLayerRenderer.cpp | 234 ++++++------ .../OpenGlVectorLayerRenderer.h | 19 +- .../OpenGlVectorLayerRendererTestAccess.h | 7 + .../VectorLayerRendererTests.cpp | 338 +++++++++++++++++- 5 files changed, 470 insertions(+), 132 deletions(-) diff --git a/.ai/memory/architecture/activex.md b/.ai/memory/architecture/activex.md index b637630..505d4ad 100644 --- a/.ai/memory/architecture/activex.md +++ b/.ai/memory/architecture/activex.md @@ -40,7 +40,9 @@ requiring COM activation. The control exposes a deliberately small v6 automation API. It does not expose the broad MapWinGIS 5 map, layer, shape, event, or editing surface. The current render scene is built in source coordinates, without reprojection; backend -support differs, and OpenGL is restricted to convex single-ring polygons. +support differs: GDI draws the original rings with an alternate fill rule, +while OpenGL fills the Core-prepared triangulated meshes and declines scenes +whose polygons lack a usable mesh so the coordinator falls back to GDI. Consult the current implementation and architecture documentation before assuming a capability or compatibility guarantee. diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp index 1ae91f1..4d22ef3 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp @@ -2,12 +2,14 @@ /** * @file OpenGlVectorLayerRenderer.cpp - * @brief Renders convex polygon scenes with WGL and copies pixels to GDI. + * @brief Renders triangulated polygon scenes with WGL and copies pixels to GDI. * * Owns one reusable OpenGL context and draws into a framebuffer object so - * pixel readback is not subject to window pixel-ownership rules. Scenes that - * need unsupported polygon features, or systems without framebuffer objects, - * return S_FALSE so the coordinator can use its GDI backend. + * pixel readback is not subject to window pixel-ownership rules. Polygon + * fills use the indexed triangle meshes prepared by the Core render scene; + * outlines follow the original rings. Scenes with polygons that lack a + * usable mesh, or systems without framebuffer objects, return S_FALSE so + * the coordinator can use its GDI backend. */ #include "OpenGlVectorLayerRenderer.h" @@ -131,110 +133,72 @@ namespace mwg::activex bool _valid; }; - [[nodiscard]] bool IsSamePoint( - const rendering::Point& first, - const rendering::Point& second) noexcept + /// Returns true when the mesh can feed glVertexPointer and + /// glDrawElements safely: complete triangles, in-range indices and + /// finite vertex coordinates. + [[nodiscard]] bool IsMeshUsable(const rendering::TriangleMesh& mesh) noexcept { - return first.x == second.x && first.y == second.y; - } - - /// Returns true when the ring is a simple, strictly convex polygon. - /// Consecutive duplicate vertices are ignored so they cannot hide a - /// concave corner, and the total turning angle must be one full turn - /// so self-intersecting rings with consistent turns are rejected. - [[nodiscard]] bool IsConvex(const rendering::Ring& ring) noexcept - { - std::vector vertices; - try - { - vertices.reserve(ring.size()); - for (const rendering::Point& point : ring) - { - if (vertices.empty() || !IsSamePoint(vertices.back(), point)) - { - vertices.push_back(point); - } - } - } - catch (const std::bad_alloc&) - { - return false; - } - - while (vertices.size() > 1 && IsSamePoint(vertices.front(), vertices.back())) - { - vertices.pop_back(); - } - - const std::size_t count = vertices.size(); - if (count < 3) + if (mesh.vertices.empty() || + mesh.indices.empty() || + mesh.indices.size() % 3U != 0U || + mesh.indices.size() > + static_cast(std::numeric_limits::max())) { return false; } - constexpr double kPi = 3.14159265358979323846; - double orientation = 0.0; - double totalTurn = 0.0; - for (std::size_t index = 0; index < count; ++index) + for (const std::uint32_t index : mesh.indices) { - const rendering::Point& first = vertices[index]; - const rendering::Point& second = vertices[(index + 1) % count]; - const rendering::Point& third = vertices[(index + 2) % count]; - const double cross = - (second.x - first.x) * (third.y - second.y) - - (second.y - first.y) * (third.x - second.x); - const double dot = - (second.x - first.x) * (third.x - second.x) + - (second.y - first.y) * (third.y - second.y); - if (!std::isfinite(cross) || !std::isfinite(dot)) + if (index >= mesh.vertices.size()) { return false; } + } - if (cross == 0.0) - { - // Collinear vertices are fine, but a reversal is a spike - // whose overlapping edges GL_POLYGON cannot represent. - if (dot < 0.0) - { - return false; - } - continue; - } - - if (orientation == 0.0) - { - orientation = cross; - } - else if ((orientation > 0.0) != (cross > 0.0)) + for (const rendering::Point& point : mesh.vertices) + { + if (!std::isfinite(point.x) || !std::isfinite(point.y)) { return false; } - - totalTurn += std::atan2(cross, dot); } - // With consistent turn signs the total is a multiple of 2*pi; - // only a single full turn describes a simple ring. - return orientation != 0.0 && std::abs(totalTurn) < 3.0 * kPi; + return true; } + /// Returns true when every polygon of the scene has a usable + /// triangulated fill mesh and finite coordinates. Meshes are + /// prepared once when the Core scene is created; a polygon without + /// one cannot be filled correctly, so the whole frame is reported + /// as unsupported and the coordinator uses GDI. [[nodiscard]] bool IsSupported(const rendering::MapRenderScene& scene) noexcept { for (const rendering::LayerRenderScene& layer : scene.layers) { - for (const rendering::Polygon& polygon : layer.scene.polygons) + const rendering::VectorRenderScene& vectorScene = layer.scene; + if (vectorScene.fillMeshes.size() != vectorScene.polygons.size()) + { + return false; + } + + for (std::size_t index = 0; index < vectorScene.polygons.size(); ++index) { - if (polygon.size() != 1 || !IsConvex(polygon.front())) + const rendering::Polygon& polygon = vectorScene.polygons[index]; + const std::optional& mesh = + vectorScene.fillMeshes[index]; + if (!mesh.has_value() || !IsMeshUsable(*mesh)) { return false; } - for (const rendering::Point& point : polygon.front()) + for (const rendering::Ring& ring : polygon) { - if (!std::isfinite(point.x) || !std::isfinite(point.y)) + for (const rendering::Point& point : ring) { - return false; + if (!std::isfinite(point.x) || !std::isfinite(point.y)) + { + return false; + } } } } @@ -243,40 +207,86 @@ namespace mwg::activex return true; } - /// Draws the convex single-ring polygons of one layer; IsSupported - /// must have accepted the scene. - void DrawLayer( + /// Draws the triangulated fills of one layer with indexed triangles + /// through the client-side vertex-array API; IsSupported must have + /// accepted the scene. + [[nodiscard]] HRESULT DrawLayerFills( const rendering::VectorRenderScene& scene, const ViewTransform& transform) noexcept { - for (const rendering::Polygon& polygon : scene.polygons) + glColor3ub(61, 122, 169); + glEnableClientState(GL_VERTEX_ARRAY); + for (std::size_t index = 0; index < scene.polygons.size(); ++index) { - const rendering::Ring& ring = polygon.front(); - std::size_t count = ring.size(); - if (count > 1 && - ring.front().x == ring.back().x && - ring.front().y == ring.back().y) + const rendering::TriangleMesh& mesh = *scene.fillMeshes[index]; + + // Client-side vertex arrays must stay alive until + // glDrawElements has consumed them; the transformed copy is + // local to this polygon's draw call. + std::vector deviceVertices; + try { - --count; + deviceVertices.reserve(mesh.vertices.size() * 2U); + for (const rendering::Point& point : mesh.vertices) + { + deviceVertices.push_back(transform.ToDeviceX(point)); + deviceVertices.push_back(transform.ToDeviceY(point)); + } } - - glColor3ub(61, 122, 169); - glBegin(GL_POLYGON); - for (std::size_t index = 0; index < count; ++index) + catch (const std::bad_alloc&) { - const rendering::Point& point = ring[index]; - glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + glDisableClientState(GL_VERTEX_ARRAY); + return E_OUTOFMEMORY; } - glEnd(); + catch (const std::length_error&) + { + glDisableClientState(GL_VERTEX_ARRAY); + return E_OUTOFMEMORY; + } + + glVertexPointer(2, GL_DOUBLE, 0, deviceVertices.data()); + glDrawElements( + GL_TRIANGLES, + static_cast(mesh.indices.size()), + GL_UNSIGNED_INT, + mesh.indices.data()); + } + glDisableClientState(GL_VERTEX_ARRAY); + return S_OK; + } - glColor3ub(24, 58, 82); - glBegin(GL_LINE_LOOP); - for (std::size_t index = 0; index < count; ++index) + /// Draws the original ring boundaries of one layer as outlines. + /// Triangle edges are not polygon boundaries and are never drawn. + void DrawLayerOutlines( + const rendering::VectorRenderScene& scene, + const ViewTransform& transform) noexcept + { + glColor3ub(24, 58, 82); + for (const rendering::Polygon& polygon : scene.polygons) + { + for (const rendering::Ring& ring : polygon) { - const rendering::Point& point = ring[index]; - glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + std::size_t count = ring.size(); + if (count > 1 && + ring.front().x == ring.back().x && + ring.front().y == ring.back().y) + { + --count; + } + + if (count < 3) + { + continue; + } + + glBegin(GL_LINE_LOOP); + for (std::size_t vertex = 0; vertex < count; ++vertex) + { + const rendering::Point& point = ring[vertex]; + glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + } + glEnd(); } - glEnd(); } } @@ -293,6 +303,12 @@ namespace mwg::activex OpenGlVectorLayerRenderer::OpenGlVectorLayerRenderer() noexcept = default; + bool OpenGlVectorLayerRenderer::IsSceneSupported( + const rendering::MapRenderScene& scene) noexcept + { + return IsSupported(scene); + } + OpenGlVectorLayerRenderer::~OpenGlVectorLayerRenderer() { Release(); @@ -564,8 +580,8 @@ namespace mwg::activex if (!IsSupported(scene)) { _lastFailureReason = - L"The OpenGL preview backend supports only convex, hole-free " - L"polygons; this frame was rendered with GDI."; + L"The OpenGL backend requires a triangulated fill mesh for " + L"every polygon; this frame was rendered with GDI."; return {S_FALSE, std::nullopt}; } @@ -680,7 +696,13 @@ namespace mwg::activex for (const rendering::LayerRenderScene& layer : scene.layers) { - DrawLayer(layer.scene, *transform); + const HRESULT fillsResult = DrawLayerFills(layer.scene, *transform); + if (FAILED(fillsResult)) + { + _lastFailureReason = L"OpenGL could not allocate its vertex buffers."; + return fillsResult; + } + DrawLayerOutlines(layer.scene, *transform); } glFlush(); diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h index d7c96c3..c7f3c13 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h @@ -51,13 +51,14 @@ namespace mwg::activex int maximumHeight) noexcept; /** - * @brief Draws a scene of simple convex single-ring polygons. + * @brief Draws a scene of triangulated polygons. * - * Draws all layers into one frame, bottom first. Returns S_FALSE - * without drawing when any layer contains unsupported geometry or - * when no OpenGL context with framebuffer-object support is - * available, so callers can fall back to another backend for the - * whole frame. + * Fills every polygon from the Core-prepared triangle mesh and draws + * its outlines from the original rings. Draws all layers into one + * frame, bottom first. Returns S_FALSE without drawing when any + * polygon lacks a usable fill mesh or when no OpenGL context with + * framebuffer-object support is available, so callers can fall back + * to another backend for the whole frame. */ [[nodiscard]] HRESULT Draw( HDC deviceContext, @@ -73,6 +74,12 @@ namespace mwg::activex private: friend class testing::OpenGlVectorLayerRendererTestAccess; + /// True when every polygon of the scene has a usable triangulated + /// fill mesh and finite coordinates. Exposed for tests so geometry + /// support decisions can be checked without an OpenGL context. + [[nodiscard]] static bool IsSceneSupported( + const rendering::MapRenderScene& scene) noexcept; + void SimulateContextLoss() noexcept; struct FramebufferFunctions; diff --git a/tests/MapWinGIS.ActiveX.Tests/OpenGlVectorLayerRendererTestAccess.h b/tests/MapWinGIS.ActiveX.Tests/OpenGlVectorLayerRendererTestAccess.h index 887b96d..11600db 100644 --- a/tests/MapWinGIS.ActiveX.Tests/OpenGlVectorLayerRendererTestAccess.h +++ b/tests/MapWinGIS.ActiveX.Tests/OpenGlVectorLayerRendererTestAccess.h @@ -17,5 +17,12 @@ namespace mwg::activex::testing { renderer.SimulateContextLoss(); } + + /// Checks scene support without activating an OpenGL context. + [[nodiscard]] static bool IsSceneSupported( + const rendering::MapRenderScene& scene) noexcept + { + return OpenGlVectorLayerRenderer::IsSceneSupported(scene); + } }; } diff --git a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp index 2e2f740..061fc21 100644 --- a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp @@ -23,6 +23,9 @@ #include "VectorLayerRenderer.h" #include "ViewTransform.h" +#include "mapwingis/core/Error.h" +#include "mapwingis/rendering/PolygonTriangulation.h" + namespace { class BitmapSurface final @@ -107,7 +110,29 @@ namespace {rectangle.xMin, rectangle.yMin}}}}}; } + /// Triangulates every polygon of a layer scene, mirroring how Core + /// prepares meshes when the scene is created. Polygons that cannot be + /// triangulated keep a null mesh so fallback behavior stays testable. + void TriangulateScene(mwg::rendering::VectorRenderScene& scene) + { + scene.fillMeshes.clear(); + scene.fillMeshes.reserve(scene.polygons.size()); + for (const mwg::rendering::Polygon& polygon : scene.polygons) + { + std::optional mesh; + try + { + mesh = mwg::rendering::TriangulatePolygon(polygon); + } + catch (const mwg::CoreError&) + { + } + scene.fillMeshes.push_back(std::move(mesh)); + } + } + /// A map scene with these layers, bottom first, with identities 1, 2, ... + /// Every polygon gets its Core triangulation mesh. [[nodiscard]] mwg::rendering::MapRenderScene MakeMapScene( std::vector layers) { @@ -115,6 +140,7 @@ namespace std::uint64_t id = 0; for (mwg::rendering::VectorRenderScene& layer : layers) { + TriangulateScene(layer); scene.layers.push_back({mwg::LayerId{++id}, std::move(layer)}); } return scene; @@ -242,18 +268,51 @@ TEST(VectorLayerRenderer, OpenGlRecreatesLostContextOnLaterDraw) EXPECT_EQ(GetPixel(surface.Get(), 5, 5), GetSysColor(COLOR_WINDOW)); } -TEST(VectorLayerRenderer, OpenGlRejectsConcaveCornerHiddenByDuplicateVertex) +TEST(VectorLayerRenderer, OpenGlRendersConcavePolygonWithDuplicateVertex) { BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); + ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); const RECT bounds{0, 0, 120, 100}; + // Concave corner with a consecutive duplicate vertex; Core + // triangulation tolerates the duplicate and produces the fill mesh. mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); FirstPolygon(scene).front() = { {0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {5.0, 5.0}, {5.0, 5.0}, {0.0, 10.0}, {0.0, 0.0}}; + TriangulateScene(scene.layers.front().scene); + + const mwg::Viewport viewport = FitViewport(bounds); + mwg::activex::OpenGlVectorLayerRenderer renderer; + const HRESULT result = renderer.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + EXPECT_EQ(MapPixel(surface.Get(), viewport, 3.0, 3.0), kFill) << "inside the body"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 3.0, 9.0), GetSysColor(COLOR_WINDOW)) + << "concave notch stays unfilled"; +} + +TEST(VectorLayerRenderer, OpenGlRejectsPolygonWithoutFillMesh) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + + // A triangulation failure leaves the mesh empty while the original + // rings remain; OpenGL must decline the frame instead of approximating + // the fill so the coordinator falls back to GDI. + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + scene.layers.front().scene.fillMeshes.front().reset(); mwg::activex::OpenGlVectorLayerRenderer renderer; EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_FALSE); + EXPECT_EQ( + renderer.GetLastFailureReason(), + L"The OpenGL backend requires a triangulated fill mesh for " + L"every polygon; this frame was rendered with GDI."); } TEST(VectorLayerRenderer, OpenGlRejectsSelfIntersectingRing) @@ -271,17 +330,20 @@ TEST(VectorLayerRenderer, OpenGlRejectsSelfIntersectingRing) EXPECT_EQ( renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds, {-3.0, -3.0, 3.0, 3.0})), S_FALSE); + EXPECT_EQ( + renderer.GetLastFailureReason(), + L"The OpenGL backend requires a triangulated fill mesh for " + L"every polygon; this frame was rendered with GDI."); } -TEST(VectorLayerRenderer, OpenGlRejectsTheFrameWhenAnyLayerIsUnsupported) +TEST(VectorLayerRenderer, OpenGlRejectsTheFrameWhenAnyLayerHasNoFillMesh) { BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); const RECT bounds{0, 0, 120, 100}; mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); - scene.layers.back().scene.polygons.front().push_back( - {{8.5, 3.5}, {8.5, 4.5}, {9.5, 4.5}, {9.5, 3.5}, {8.5, 3.5}}); + scene.layers.back().scene.fillMeshes.front().reset(); mwg::activex::OpenGlVectorLayerRenderer renderer; EXPECT_EQ( @@ -289,8 +351,8 @@ TEST(VectorLayerRenderer, OpenGlRejectsTheFrameWhenAnyLayerIsUnsupported) S_FALSE); EXPECT_EQ( renderer.GetLastFailureReason(), - L"The OpenGL preview backend supports only convex, hole-free " - L"polygons; this frame was rendered with GDI."); + L"The OpenGL backend requires a triangulated fill mesh for " + L"every polygon; this frame was rendered with GDI."); } TEST(VectorLayerRenderer, FailsWithoutDrawingNonFiniteCoordinates) @@ -303,6 +365,7 @@ TEST(VectorLayerRenderer, FailsWithoutDrawingNonFiniteCoordinates) mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); scene.layers.front().scene.polygons.push_back( {{{1.0, 1.0}, {std::numeric_limits::quiet_NaN(), 5.0}, {9.0, 9.0}, {1.0, 1.0}}}); + TriangulateScene(scene.layers.front().scene); mwg::activex::VectorLayerRenderer renderer; EXPECT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), E_INVALIDARG); @@ -322,6 +385,7 @@ TEST(VectorLayerRenderer, SkipsOnlyTheLayerThatCannotBeDrawn) mwg::rendering::MapRenderScene scene = MakeTwoLayerScene(); scene.layers.front().scene.polygons.push_back( {{{0.0, 0.0}, {std::numeric_limits::infinity(), 1.0}, {1.0, 1.0}, {0.0, 0.0}}}); + TriangulateScene(scene.layers.front().scene); const mwg::Viewport viewport = FitViewport(bounds, kTwoLayerExtent); mwg::activex::VectorLayerRenderer renderer; @@ -337,33 +401,269 @@ TEST(VectorLayerRenderer, SkipsOnlyTheLayerThatCannotBeDrawn) EXPECT_EQ(MapPixel(surface.Get(), viewport, 9.0, 4.0), kFill); } -TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) +TEST(VectorLayerRenderer, OpenGlRendersPolygonWithInteriorRing) { BitmapSurface surface(120, 100); ASSERT_TRUE(surface.IsValid()); ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); const RECT bounds{0, 0, 120, 100}; - ASSERT_NE( - FillRect(surface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), - 0); mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); - FirstPolygon(scene).front() = { - {0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {0.0, 10.0}, {0.0, 0.0}}; FirstPolygon(scene).push_back( {{3.0, 3.0}, {3.0, 7.0}, {7.0, 7.0}, {7.0, 3.0}, {3.0, 3.0}}); + TriangulateScene(scene.layers.front().scene); + mwg::activex::VectorLayerRenderer renderer; ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); + EXPECT_EQ(GetPixel(surface.Get(), 30, 50), kFill); + EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); +} - EXPECT_EQ( - renderer.GetActiveBackend(), - mwg::activex::VectorRenderBackendKind::Gdi); +TEST(VectorLayerRenderer, FallsBackToGdiWhenAPolygonHasNoFillMesh) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + scene.layers.front().scene.fillMeshes.front().reset(); + + mwg::activex::VectorLayerRenderer renderer; + ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); + EXPECT_EQ(renderer.GetActiveBackend(), mwg::activex::VectorRenderBackendKind::Gdi); EXPECT_EQ( renderer.GetDiagnostic(), - L"The OpenGL preview backend supports only convex, hole-free " - L"polygons; this frame was rendered with GDI."); - EXPECT_EQ(GetPixel(surface.Get(), 30, 50), kFill); - EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); + L"The OpenGL backend requires a triangulated fill mesh for " + L"every polygon; this frame was rendered with GDI."); +} + +TEST(VectorLayerRenderer, OpenGlSupportRequiresAMeshForEveryPolygon) +{ + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + EXPECT_TRUE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported(scene)); + + // No meshes at all. + mwg::rendering::MapRenderScene withoutMeshes = MakeConvexPolygonScene(); + withoutMeshes.layers.front().scene.fillMeshes.clear(); + EXPECT_FALSE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported( + withoutMeshes)); + + // A triangulation failure for one polygon rejects the whole frame. + mwg::rendering::MapRenderScene nullMesh = MakeConvexPolygonScene(); + nullMesh.layers.front().scene.fillMeshes.front().reset(); + EXPECT_FALSE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported(nullMesh)); + + // Non-finite coordinates stay unsupported regardless of the mesh. + mwg::rendering::MapRenderScene nonFinite = MakeConvexPolygonScene(); + FirstPolygon(nonFinite).front().front().x = std::numeric_limits::infinity(); + EXPECT_FALSE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported(nonFinite)); +} + +TEST(VectorLayerRenderer, OpenGlSupportsConcaveMultipartAndHoledScenes) +{ + mwg::rendering::VectorRenderScene multipart; + multipart.extent = {0.0, 0.0, 10.0, 10.0}; + multipart.sourceFeatureCount = 2; + multipart.polygons = { + // L-shaped concave polygon. + {{{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}}}, + // Rectangle with two interior rings. + {{{0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {0.0, 10.0}, {0.0, 0.0}}, + {{2.0, 2.0}, {2.0, 4.0}, {4.0, 4.0}, {4.0, 2.0}, {2.0, 2.0}}, + {{6.0, 6.0}, {6.0, 8.0}, {8.0, 8.0}, {8.0, 6.0}, {6.0, 6.0}}}}; + TriangulateScene(multipart); + + const mwg::rendering::MapRenderScene scene = + MakeMapScene({multipart, MakeRectangleLayer({20.0, 20.0, 30.0, 30.0})}); + EXPECT_TRUE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported(scene)); + + // A malformed mesh reference also rejects the scene. + mwg::rendering::MapRenderScene broken = scene; + broken.layers.front().scene.fillMeshes.front()->indices.resize(4); + EXPECT_FALSE( + mwg::activex::testing::OpenGlVectorLayerRendererTestAccess::IsSceneSupported(broken)); +} + +TEST(VectorLayerRenderer, OpenGlRendersLShapedPolygon) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + FirstPolygon(scene).front() = { + {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}}; + TriangulateScene(scene.layers.front().scene); + + const mwg::Viewport viewport = FitViewport(bounds); + mwg::activex::OpenGlVectorLayerRenderer renderer; + const HRESULT result = renderer.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + EXPECT_EQ(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)) + << "indentation stays unfilled"; +} + +TEST(VectorLayerRenderer, OpenGlRendersPolygonWithMultipleInteriorRings) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + mwg::rendering::Polygon& polygon = FirstPolygon(scene); + polygon.push_back({{2.0, 2.0}, {2.0, 4.0}, {4.0, 4.0}, {4.0, 2.0}, {2.0, 2.0}}); + polygon.push_back({{6.0, 6.0}, {6.0, 8.0}, {8.0, 8.0}, {8.0, 6.0}, {6.0, 6.0}}); + TriangulateScene(scene.layers.front().scene); + + const mwg::Viewport viewport = FitViewport(bounds); + mwg::activex::OpenGlVectorLayerRenderer renderer; + const HRESULT result = renderer.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + EXPECT_EQ(MapPixel(surface.Get(), viewport, 3.0, 3.0), GetSysColor(COLOR_WINDOW)) + << "first hole stays unfilled"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 7.0, 7.0), GetSysColor(COLOR_WINDOW)) + << "second hole stays unfilled"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 5.0, 5.0), kFill) << "between the holes"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 1.0, 5.0), kFill) << "beside the first hole"; +} + +TEST(VectorLayerRenderer, OpenGlRendersMultipartPolygonAndOutlinesFollowRings) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + ASSERT_TRUE(PatBlt(surface.Get(), 0, 0, 120, 100, BLACKNESS)); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::VectorRenderScene multipart; + multipart.extent = {0.0, 0.0, 10.0, 10.0}; + multipart.sourceFeatureCount = 2; + multipart.polygons = { + {{{0.0, 0.0}, {6.0, 0.0}, {6.0, 6.0}, {0.0, 6.0}, {0.0, 0.0}}}, + {{{4.0, 4.0}, {10.0, 4.0}, {10.0, 10.0}, {4.0, 10.0}, {4.0, 4.0}}, + {{6.0, 6.0}, {6.0, 8.0}, {8.0, 8.0}, {8.0, 6.0}, {6.0, 6.0}}}}; + const mwg::rendering::MapRenderScene scene = MakeMapScene({multipart}); + + const mwg::Viewport viewport = FitViewport(bounds); + mwg::activex::OpenGlVectorLayerRenderer renderer; + const HRESULT result = renderer.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + EXPECT_EQ(MapPixel(surface.Get(), viewport, 2.0, 2.0), kFill) << "first component"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 9.0, 5.0), kFill) << "second component body"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 7.0, 7.0), GetSysColor(COLOR_WINDOW)) + << "hole of the second component"; + EXPECT_EQ(MapPixel(surface.Get(), viewport, 8.0, 2.0), GetSysColor(COLOR_WINDOW)) + << "gap between the components"; +} + +TEST(VectorLayerRenderer, OpenGlDoesNotDrawInternalTriangleEdges) +{ + BitmapSurface surface(120, 100); + ASSERT_TRUE(surface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + const mwg::rendering::MapRenderScene scene = MakeConvexPolygonScene(); + const mwg::Viewport viewport = FitViewport(bounds); + + mwg::activex::OpenGlVectorLayerRenderer renderer; + const HRESULT result = renderer.Draw(surface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + + // A convex quad triangulates into two triangles whose diagonal crosses + // the polygon interior. Only the original ring may be outlined, so no + // interior pixel may have the outline color. + constexpr COLORREF kOutline = RGB(24, 58, 82); + for (double t = 0.15; t < 0.9; t += 0.1) + { + for (const mwg::rendering::Point diagonal : + {mwg::rendering::Point{1.0 + 8.0 * t, 1.0 + 8.0 * t}, + mwg::rendering::Point{1.0 + 8.0 * t, 9.0 - 8.0 * t}}) + { + const auto screen = viewport.MapToScreen({diagonal.x, diagonal.y}); + ASSERT_TRUE(screen.has_value()); + const auto pixel = mwg::ToPixel(*screen); + ASSERT_TRUE(pixel.has_value()); + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + EXPECT_NE(GetPixel(surface.Get(), pixel->x + dx, pixel->y + dy), kOutline) + << "diagonal point (" << diagonal.x << ", " << diagonal.y << ")"; + } + } + } + } +} + +TEST(VectorLayerRenderer, GdiAndOpenGlAgreeOnConcaveHoledScene) +{ + BitmapSurface gdiSurface(120, 100); + BitmapSurface openGlSurface(120, 100); + ASSERT_TRUE(gdiSurface.IsValid()); + ASSERT_TRUE(openGlSurface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + + mwg::rendering::VectorRenderScene layer; + layer.extent = {0.0, 0.0, 10.0, 10.0}; + layer.sourceFeatureCount = 2; + 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}}}, + {{{0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {0.0, 10.0}, {0.0, 0.0}}, + {{3.0, 3.0}, {3.0, 7.0}, {7.0, 7.0}, {7.0, 3.0}, {3.0, 3.0}}}}; + const mwg::rendering::MapRenderScene scene = MakeMapScene({layer}); + const mwg::Viewport viewport = FitViewport(bounds); + + mwg::activex::GdiVectorLayerRenderer gdi; + ASSERT_NE(FillRect(gdiSurface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + ASSERT_EQ(gdi.Draw(gdiSurface.Get(), bounds, scene, viewport), S_OK); + + mwg::activex::OpenGlVectorLayerRenderer openGl; + const HRESULT result = openGl.Draw(openGlSurface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + + // Sample points on a coarse grid that stays at least one map unit from + // every polygon and hole boundary; both backends must classify them + // identically. + for (double y = 2.0; y <= 8.0; y += 3.0) + { + for (double x = 2.0; x <= 8.0; x += 3.0) + { + const auto screen = viewport.MapToScreen({x, y}); + ASSERT_TRUE(screen.has_value()); + const auto pixel = mwg::ToPixel(*screen); + ASSERT_TRUE(pixel.has_value()); + EXPECT_EQ(GetPixel(gdiSurface.Get(), pixel->x, pixel->y), + GetPixel(openGlSurface.Get(), pixel->x, pixel->y)) + << "map point (" << x << ", " << y << ")"; + } + } } TEST(VectorLayerRenderer, HonorsCpuPreferenceAndReportsTheSelectedBackend) From cd3883ebce69dce63e3ec37ea1840a5dc573067d Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 13 Nov 2026 08:33:27 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20fix(activex-tests):=20skip?= =?UTF-8?q?=20holed-scene=20pixel=20assertions=20when=20OpenGL=20is=20unav?= =?UTF-8?q?ailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenGlRendersPolygonWithInteriorRing is the only OpenGL pixel test that draws through the auto-selecting VectorLayerRenderer. On runners without an OpenGL context it silently fell back to GDI, which leaves the host background untouched and strokes the ring outline, so the OpenGL-specific pixel assertions failed (PR #68 CI). Skip when the coordinator did not select OpenGL, matching how the other OpenGL rendering tests behave. --- tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp index 061fc21..d3b0d26 100644 --- a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp @@ -415,6 +415,10 @@ TEST(VectorLayerRenderer, OpenGlRendersPolygonWithInteriorRing) mwg::activex::VectorLayerRenderer renderer; ASSERT_EQ(renderer.Draw(surface.Get(), bounds, scene, FitViewport(bounds)), S_OK); + if (renderer.GetActiveBackend() != mwg::activex::VectorRenderBackendKind::OpenGL) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } EXPECT_EQ(GetPixel(surface.Get(), 30, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); } From f6f9721228fda9f01723c77e4cf696bb9cf607df Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 13 Nov 2026 08:34:03 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=9D=20docs(memory):=20note=20v145?= =?UTF-8?q?=20MSBuild=20location=20on=20the=20dev=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/memory/build/windows.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ai/memory/build/windows.md b/.ai/memory/build/windows.md index 0717782..e3ea206 100644 --- a/.ai/memory/build/windows.md +++ b/.ai/memory/build/windows.md @@ -9,6 +9,12 @@ The ActiveX project statically links ATL. MFC is not required. The C# ActiveX host requires the .NET 10 SDK and an interactive Windows desktop for its automated UI and pixel checks. +On the primary development machine the v145 toolset is only installed with +Visual Studio 18 Community at `D:\Program Files\Microsoft Visual Studio\18\ +Community`; its MSBuild (`MSBuild\Current\Bin\MSBuild.exe`) is the one that +can build this repository. The VS 2022 BuildTools under +`C:\Program Files (x86)` only have v170 and fail with MSB8020. + ## CI scope `.github/workflows/build.yml` runs both CMake and MSBuild jobs. Pull requests From 0ccd7e6ede0f50eccf44b039e509e97d510fc820 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 13 Nov 2026 09:20:56 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix(activex):=20preserve=20p?= =?UTF-8?q?er-polygon=20painter=20order=20in=20OpenGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fill and outline each polygon before advancing to the next one so overlapping polygons in one layer match the GDI backend's painter order - Add a GDI/OpenGL parity test for overlapping polygons in one layer --- .../OpenGlVectorLayerRenderer.cpp | 146 ++++++++++-------- .../OpenGlVectorLayerRenderer.h | 11 +- .../VectorLayerRendererTests.cpp | 62 ++++++++ 3 files changed, 148 insertions(+), 71 deletions(-) diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp index 4d22ef3..4bb1626 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp @@ -7,7 +7,9 @@ * Owns one reusable OpenGL context and draws into a framebuffer object so * pixel readback is not subject to window pixel-ownership rules. Polygon * fills use the indexed triangle meshes prepared by the Core render scene; - * outlines follow the original rings. Scenes with polygons that lack a + * outlines follow the original rings. Each polygon is filled and outlined + * before the next one is drawn, matching the GDI backend's painter order. + * Scenes with polygons that lack a * usable mesh, or systems without framebuffer objects, return S_FALSE so * the coordinator can use its GDI backend. */ @@ -207,87 +209,100 @@ namespace mwg::activex return true; } - /// Draws the triangulated fills of one layer with indexed triangles - /// through the client-side vertex-array API; IsSupported must have - /// accepted the scene. - [[nodiscard]] HRESULT DrawLayerFills( - const rendering::VectorRenderScene& scene, + /// Draws one polygon's triangulated fill as indexed triangles through + /// the client-side vertex-array API. The caller must have enabled + /// GL_VERTEX_ARRAY, and IsSupported must have accepted the scene. + [[nodiscard]] HRESULT DrawPolygonFill( + const rendering::TriangleMesh& mesh, const ViewTransform& transform) noexcept { - glColor3ub(61, 122, 169); - glEnableClientState(GL_VERTEX_ARRAY); - for (std::size_t index = 0; index < scene.polygons.size(); ++index) + // Client-side vertex arrays must stay alive until + // glDrawElements has consumed them; the transformed copy is + // local to this polygon's draw call. + std::vector deviceVertices; + try { - const rendering::TriangleMesh& mesh = *scene.fillMeshes[index]; - - // Client-side vertex arrays must stay alive until - // glDrawElements has consumed them; the transformed copy is - // local to this polygon's draw call. - std::vector deviceVertices; - try + deviceVertices.reserve(mesh.vertices.size() * 2U); + for (const rendering::Point& point : mesh.vertices) { - deviceVertices.reserve(mesh.vertices.size() * 2U); - for (const rendering::Point& point : mesh.vertices) - { - deviceVertices.push_back(transform.ToDeviceX(point)); - deviceVertices.push_back(transform.ToDeviceY(point)); - } + deviceVertices.push_back(transform.ToDeviceX(point)); + deviceVertices.push_back(transform.ToDeviceY(point)); } - catch (const std::bad_alloc&) + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::length_error&) + { + return E_OUTOFMEMORY; + } + + glVertexPointer(2, GL_DOUBLE, 0, deviceVertices.data()); + glDrawElements( + GL_TRIANGLES, + static_cast(mesh.indices.size()), + GL_UNSIGNED_INT, + mesh.indices.data()); + return S_OK; + } + + /// Draws one polygon's original ring boundaries as outlines. + /// Triangle edges are not polygon boundaries and are never drawn. + void DrawPolygonOutlines( + const rendering::Polygon& polygon, + const ViewTransform& transform) noexcept + { + for (const rendering::Ring& ring : polygon) + { + std::size_t count = ring.size(); + if (count > 1 && + ring.front().x == ring.back().x && + ring.front().y == ring.back().y) { - glDisableClientState(GL_VERTEX_ARRAY); - return E_OUTOFMEMORY; + --count; } - catch (const std::length_error&) + + if (count < 3) { - glDisableClientState(GL_VERTEX_ARRAY); - return E_OUTOFMEMORY; + continue; } - glVertexPointer(2, GL_DOUBLE, 0, deviceVertices.data()); - glDrawElements( - GL_TRIANGLES, - static_cast(mesh.indices.size()), - GL_UNSIGNED_INT, - mesh.indices.data()); + glBegin(GL_LINE_LOOP); + for (std::size_t vertex = 0; vertex < count; ++vertex) + { + const rendering::Point& point = ring[vertex]; + glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); + } + glEnd(); } - glDisableClientState(GL_VERTEX_ARRAY); - return S_OK; } - /// Draws the original ring boundaries of one layer as outlines. - /// Triangle edges are not polygon boundaries and are never drawn. - void DrawLayerOutlines( + /// Draws one layer polygon by polygon, filling each polygon from its + /// triangulated mesh and outlining its rings before advancing to the + /// next one, so overlapping polygons keep the same painter order as + /// the GDI backend. + [[nodiscard]] HRESULT DrawLayer( const rendering::VectorRenderScene& scene, const ViewTransform& transform) noexcept { - glColor3ub(24, 58, 82); - for (const rendering::Polygon& polygon : scene.polygons) + glEnableClientState(GL_VERTEX_ARRAY); + for (std::size_t index = 0; index < scene.polygons.size(); ++index) { - for (const rendering::Ring& ring : polygon) + glColor3ub(61, 122, 169); + const HRESULT fillResult = + DrawPolygonFill(*scene.fillMeshes[index], transform); + if (FAILED(fillResult)) { - std::size_t count = ring.size(); - if (count > 1 && - ring.front().x == ring.back().x && - ring.front().y == ring.back().y) - { - --count; - } - - if (count < 3) - { - continue; - } - - glBegin(GL_LINE_LOOP); - for (std::size_t vertex = 0; vertex < count; ++vertex) - { - const rendering::Point& point = ring[vertex]; - glVertex2d(transform.ToDeviceX(point), transform.ToDeviceY(point)); - } - glEnd(); + glDisableClientState(GL_VERTEX_ARRAY); + return fillResult; } + + glColor3ub(24, 58, 82); + DrawPolygonOutlines(scene.polygons[index], transform); } + glDisableClientState(GL_VERTEX_ARRAY); + return S_OK; } [[nodiscard]] std::int64_t GetWidth(const RECT& bounds) noexcept @@ -696,13 +711,12 @@ namespace mwg::activex for (const rendering::LayerRenderScene& layer : scene.layers) { - const HRESULT fillsResult = DrawLayerFills(layer.scene, *transform); - if (FAILED(fillsResult)) + const HRESULT layerResult = DrawLayer(layer.scene, *transform); + if (FAILED(layerResult)) { _lastFailureReason = L"OpenGL could not allocate its vertex buffers."; - return fillsResult; + return layerResult; } - DrawLayerOutlines(layer.scene, *transform); } glFlush(); diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h index c7f3c13..a61169f 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.h @@ -54,11 +54,12 @@ namespace mwg::activex * @brief Draws a scene of triangulated polygons. * * Fills every polygon from the Core-prepared triangle mesh and draws - * its outlines from the original rings. Draws all layers into one - * frame, bottom first. Returns S_FALSE without drawing when any - * polygon lacks a usable fill mesh or when no OpenGL context with - * framebuffer-object support is available, so callers can fall back - * to another backend for the whole frame. + * its outlines from the original rings before advancing to the next + * polygon, matching the GDI backend's painter order within a layer. + * Draws all layers into one frame, bottom first. Returns S_FALSE + * without drawing when any polygon lacks a usable fill mesh or when + * no OpenGL context with framebuffer-object support is available, so + * callers can fall back to another backend for the whole frame. */ [[nodiscard]] HRESULT Draw( HDC deviceContext, diff --git a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp index d3b0d26..00fc3fc 100644 --- a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp @@ -991,6 +991,68 @@ TEST(VectorLayerRenderer, OverlappingLayersDrawBottomFirst) EXPECT_EQ(GetPixel(surface.Get(), 40, 50), kFill); } +TEST(VectorLayerRenderer, GdiAndOpenGlAgreeOnOverlappingPolygonsInOneLayer) +{ + BitmapSurface gdiSurface(120, 100); + BitmapSurface openGlSurface(120, 100); + ASSERT_TRUE(gdiSurface.IsValid()); + ASSERT_TRUE(openGlSurface.IsValid()); + const RECT bounds{0, 0, 120, 100}; + mwg::Viewport viewport(120, 100); + viewport.SetView({60.0, 50.0}, 1.0); + + // One layer with two overlapping polygons. The second polygon is drawn + // after the first, so its fill must cover the first polygon's outlines + // inside the overlap, exactly as the GDI backend paints them. + mwg::rendering::VectorRenderScene layer; + layer.extent = {20.25, 10.25, 90.25, 70.25}; + layer.sourceFeatureCount = 2; + layer.polygons = { + {{{20.25, 10.25}, {60.25, 10.25}, {60.25, 50.25}, {20.25, 50.25}, {20.25, 10.25}}}, + {{{40.25, 30.25}, {90.25, 30.25}, {90.25, 70.25}, {40.25, 70.25}, {40.25, 30.25}}}}; + const mwg::rendering::MapRenderScene scene = MakeMapScene({layer}); + + mwg::activex::GdiVectorLayerRenderer gdi; + ASSERT_NE(FillRect(gdiSurface.Get(), &bounds, GetSysColorBrush(COLOR_WINDOW)), 0); + ASSERT_EQ(gdi.Draw(gdiSurface.Get(), bounds, scene, viewport), S_OK); + + mwg::activex::OpenGlVectorLayerRenderer openGl; + const HRESULT result = openGl.Draw(openGlSurface.Get(), bounds, scene, viewport); + if (result == S_FALSE) + { + GTEST_SKIP() << "No OpenGL context with framebuffer objects is available."; + } + ASSERT_EQ(result, S_OK); + + // The first polygon's right edge (pixel column 60) and top edge (pixel + // row 49) stay visible only outside the second polygon; inside the + // overlap the second polygon's fill covers them in both backends. + constexpr COLORREF kOutline = RGB(24, 58, 82); + for (const mwg::PixelPoint pixel : {mwg::PixelPoint{60, 60}, {50, 49}}) + { + EXPECT_EQ(GetPixel(gdiSurface.Get(), pixel.x, pixel.y), kFill) + << "GDI at " << pixel.x << ", " << pixel.y; + EXPECT_EQ(GetPixel(openGlSurface.Get(), pixel.x, pixel.y), kFill) + << "OpenGL at " << pixel.x << ", " << pixel.y; + } + for (const mwg::PixelPoint pixel : {mwg::PixelPoint{60, 80}, {40, 60}}) + { + EXPECT_EQ(GetPixel(gdiSurface.Get(), pixel.x, pixel.y), kOutline) + << "GDI at " << pixel.x << ", " << pixel.y; + EXPECT_EQ(GetPixel(openGlSurface.Get(), pixel.x, pixel.y), kOutline) + << "OpenGL at " << pixel.x << ", " << pixel.y; + } + + // Away from the boundaries both backends must agree as well. + for (const mwg::PixelPoint pixel : + {mwg::PixelPoint{30, 70}, {70, 40}, {10, 10}}) + { + EXPECT_EQ(GetPixel(gdiSurface.Get(), pixel.x, pixel.y), + GetPixel(openGlSurface.Get(), pixel.x, pixel.y)) + << pixel.x << ", " << pixel.y; + } +} + TEST(VectorLayerRenderer, DrawsVerySmallAndVeryElongatedTargets) { BitmapSurface surface(2000, 400); From 03f05826adb8041b2cd514553f323f6aba77b08d Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 13 Nov 2026 10:20:44 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20docs(memory):=20note=20devic?= =?UTF-8?q?e-pixel=20y-flip=20in=20renderer=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record that Viewport::MapToScreen flips the y axis, so renderer pixel tests must convert map coordinates to device pixels before sampling --- .ai/memory/testing/testing.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ai/memory/testing/testing.md b/.ai/memory/testing/testing.md index 232afeb..f9fbe16 100644 --- a/.ai/memory/testing/testing.md +++ b/.ai/memory/testing/testing.md @@ -31,6 +31,12 @@ accept multipart WKT. The GPKG driver also writes self-intersecting polygon WKT without validation, so invalid-geometry behavior is testable through a generated GeoPackage. +Renderer pixel tests sample device pixels, not map coordinates: +`Viewport::MapToScreen` flips the y axis (`height/2 - (y - center.y) / +resolution`), and both backends draw through the same transform. Sampling raw +map coordinates checks the wrong location; convert through `MapPixel`/`ToPixel` +or compute device coordinates explicitly (cost a test iteration in PR #68). + ## References - [Testing guide](../../../docs/TESTING.md)