From 136161f5c9efbadf703a4185900a0044ff08422a Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Thu, 12 Nov 2026 17:44:28 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(core):=20add=20GEOS-backed?= =?UTF-8?q?=20polygon=20triangulation=20to=20render=20scenes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rendering::TriangulatePolygon() building an indexed TriangleMesh from polygon rings via OGR ConstrainedDelaunayTriangulation() and GEOS. - Extend VectorRenderScene with one optional fill mesh per polygon, prepared at scene creation so renderers never triangulate per frame; triangulation failures keep the original rings for GDI and outlines. - Reject invalid input explicitly (no silent repair); deduplicate mesh vertices and enforce consistent counter-clockwise winding. - Add PolygonTriangulationTests covering convex, concave, holed, and multipart meshes plus area, containment, overlap, and validation checks. - Record GDAL 3.12 snake_case geometry accessors and GeoPackage invalid-geometry fixture behavior in project memory. --- .ai/memory/build/dependencies.md | 7 + .ai/memory/testing/testing.md | 7 + docs/ARCHITECTURE.md | 6 +- src/MapWinGIS.Core/MapWinGIS.Core.vcxproj | 2 + .../rendering/PolygonTriangulation.h | 40 ++ .../mapwingis/rendering/VectorRenderScene.h | 22 + .../src/rendering/PolygonTriangulation.cpp | 218 ++++++++ .../src/rendering/VectorRenderScene.cpp | 19 + .../MapWinGIS.Core.Tests.vcxproj | 1 + .../PolygonTriangulationTests.cpp | 526 ++++++++++++++++++ 10 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 src/MapWinGIS.Core/include/mapwingis/rendering/PolygonTriangulation.h create mode 100644 src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp create mode 100644 tests/MapWinGIS.Core.Tests/PolygonTriangulationTests.cpp diff --git a/.ai/memory/build/dependencies.md b/.ai/memory/build/dependencies.md index 25d5bcd..31f8504 100644 --- a/.ai/memory/build/dependencies.md +++ b/.ai/memory/build/dependencies.md @@ -5,6 +5,13 @@ dependencies, and GoogleTest is supplied by the `tests` feature. The optional `full-drivers` feature adds a larger set of GDAL drivers. The manifest baseline and GDAL minimum version must be considered together when updating GDAL. +GDAL 3.12 renamed the OGR geometry accessors to snake_case (`get_Area()`, +`get_Length()`); the old camelCase names such as `getArea()` are removed. +`get_Area()` is not on the `OGRGeometry` base at all (only on `OGRCurve` and +`OGRSurface`), and a disjoint polygon `Intersection()` returns `POLYGON EMPTY`, +which still reports dimension 2 — check `IsEmpty()` before using `getDimension()` +or a surface cast on intersection results. + MSBuild resolves vcpkg from the `MwgVcpkgRoot` property, `MWG_VCPKG_ROOT`, the sibling `..\vcpkg` directory, then `VCPKG_ROOT`. It installs to a platform-specific tree because vcpkg can remove another triplet from a shared diff --git a/.ai/memory/testing/testing.md b/.ai/memory/testing/testing.md index b9b7978..232afeb 100644 --- a/.ai/memory/testing/testing.md +++ b/.ai/memory/testing/testing.md @@ -24,6 +24,13 @@ The C# host calls ActiveX methods late-bound. An invalid layer ID surfaces as The host assertion accepts this mapping as well as `COMException`; see `samples/CSharp/ActiveXTestHost/MainForm.cs`. +GeoPackage fixture layers need an explicit geometry type: a layer created with +`wkbUnknown` maps to `ShapeType::NullShape` and is rejected as non-polygonal by +scene creation, while `wkbMultiPolygon` layers map to `ShapeType::Polygon` and +accept multipart WKT. The GPKG driver also writes self-intersecting polygon WKT +without validation, so invalid-geometry behavior is testable through a generated +GeoPackage. + ## References - [Testing guide](../../../docs/TESTING.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed1a4e8..a6e760d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,7 +288,11 @@ 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 +linearizing curved geometries. Scene creation also triangulates every polygon +into an indexed, backend-neutral triangle mesh with the constrained Delaunay +triangulation of GDAL/OGR and GEOS, retained per polygon so a backend never +triangulates per frame; a polygon that cannot be triangulated keeps its rings +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. diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj index 23a6914..1d73ffb 100644 --- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj +++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj @@ -67,6 +67,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/src/MapWinGIS.Core/include/mapwingis/rendering/PolygonTriangulation.h b/src/MapWinGIS.Core/include/mapwingis/rendering/PolygonTriangulation.h new file mode 100644 index 0000000..6c9ee4c --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/PolygonTriangulation.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file PolygonTriangulation.h + * @brief Declares reusable polygon triangulation for rendering. + * + * Converts a backend-neutral polygon with optional interior rings into an + * indexed triangle mesh using the constrained Delaunay triangulation provided + * by GDAL/OGR and GEOS. The mesh stays in the source coordinate system and + * contains no graphics-API or platform types. + */ +#pragma once + +#include "mapwingis/core/Api.h" +#include "mapwingis/rendering/VectorRenderScene.h" + +namespace mwg::rendering +{ + /// Triangulates a polygon with zero or more interior rings into a 2D + /// fill mesh. + /// + /// The first ring of \p polygon is the exterior boundary; any further + /// rings are interior holes. Rings do not need to repeat their first + /// point at the end. Interior rings with fewer than three points are + /// ignored. Duplicate consecutive vertices are tolerated. + /// + /// The triangulation is the constrained Delaunay triangulation from + /// OGRGeometry::ConstrainedDelaunayTriangulation(), which preserves the + /// polygon boundaries. Vertices are shared through the index buffer and + /// all triangles have counter-clockwise winding. The result is the 2D + /// rendering mesh; no Z or M values are computed. + /// + /// Invalid input is never repaired: self-intersecting or otherwise + /// invalid polygons are rejected instead of being reinterpreted. + /// \throws InvalidArgument when the polygon has no exterior ring of at + /// least three points or contains non-finite coordinates. + /// \throws CoreError when the polygon is invalid or the triangulation + /// fails or produces no triangles. + [[nodiscard]] MWG_CORE_API TriangleMesh TriangulatePolygon(const Polygon& polygon); +} diff --git a/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h b/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h index 0ced468..6a63d73 100644 --- a/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h +++ b/src/MapWinGIS.Core/include/mapwingis/rendering/VectorRenderScene.h @@ -10,6 +10,7 @@ #pragma once #include +#include #include #include "mapwingis/core/Api.h" @@ -35,6 +36,20 @@ namespace mwg /// A polygon and its rings. The first ring is the exterior boundary. using Polygon = std::vector; + /// A backend-neutral triangulated 2D fill mesh for one polygon. + /// + /// Vertices stay in the source layer's coordinate system. Indices + /// occur in groups of three that each describe one triangle; every + /// index references a valid vertex. Triangles produced by + /// TriangulatePolygon share vertices through the index buffer and + /// have consistent counter-clockwise winding. The representation + /// contains no graphics-API, Windows, COM, ATL, or MFC types. + struct MWG_CORE_API TriangleMesh + { + std::vector vertices; + std::vector indices; + }; + /// Owned polygon geometry and its source-coordinate bounds. struct MWG_CORE_API VectorRenderScene { @@ -42,6 +57,13 @@ namespace mwg /// Feature count of the layer used to create this scene. std::int64_t sourceFeatureCount = 0; std::vector polygons; + /// Triangulated fill meshes prepared once when the scene is + /// created, aligned with polygons: fillMeshes[i] belongs to + /// polygons[i]. An entry is std::nullopt when that polygon could + /// not be triangulated; its rings in polygons remain available + /// for outline drawing and for backends that draw rings directly, + /// such as GDI. + std::vector> fillMeshes; }; /// Copies polygon geometry from a layer into backend-neutral scene data. diff --git a/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp b/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp new file mode 100644 index 0000000..8862fd7 --- /dev/null +++ b/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file PolygonTriangulation.cpp + * @brief Implements polygon triangulation through OGR and GEOS. + * + * Builds a temporary OGR polygon from the backend-neutral rings, validates + * it, and converts the constrained Delaunay triangulation into an indexed + * triangle mesh. All OGR geometry is owned through RAII handles. + */ +#include "mapwingis/rendering/PolygonTriangulation.h" + +#include +#include +#include +#include +#include + +#include + +#include "mapwingis/core/Error.h" +#include "mapwingis/gdal/GdalDiagnostics.h" +#include "mapwingis/gdal/GdalEnvironment.h" + +namespace mwg::rendering +{ + namespace + { + std::unique_ptr MakeRing(const Ring& ring) + { + auto result = std::make_unique(); + for (const Point& point : ring) + { + result->addPoint(point.x, point.y); + } + if (result->getNumPoints() > 1) + { + result->closeRings(); + } + return result; + } + + void ValidateRing(const Ring& ring, const char* role) + { + if (ring.size() < 3) + { + throw InvalidArgument(std::string("Polygon ") + role + + " ring has fewer than three points"); + } + for (const Point& point : ring) + { + if (!std::isfinite(point.x) || !std::isfinite(point.y)) + { + throw InvalidArgument(std::string("Polygon ") + role + + " ring contains a non-finite coordinate"); + } + } + } + + std::unique_ptr MakeOgrPolygon(const Polygon& polygon) + { + if (polygon.empty()) + { + throw InvalidArgument("Polygon has no rings"); + } + + ValidateRing(polygon.front(), "exterior"); + auto result = std::make_unique(); + result->addRingDirectly(MakeRing(polygon.front()).release()); + for (std::size_t index = 1; index < polygon.size(); ++index) + { + const Ring& hole = polygon[index]; + if (hole.size() < 3) + { + continue; + } + ValidateRing(hole, "interior"); + result->addRingDirectly(MakeRing(hole).release()); + } + return result; + } + + double TwiceSignedArea(const Point& a, const Point& b, const Point& c) + { + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); + } + + /// Deduplicates vertices and assembles consistently wound triangles. + class MeshBuilder + { + public: + void AddTriangle(Point a, Point b, Point c) + { + if (TwiceSignedArea(a, b, c) < 0.0) + { + std::swap(b, c); + } + _mesh.indices.push_back(AddVertex(a)); + _mesh.indices.push_back(AddVertex(b)); + _mesh.indices.push_back(AddVertex(c)); + } + + [[nodiscard]] TriangleMesh TakeResult() + { + TriangleMesh result; + result.vertices = std::move(_mesh.vertices); + result.indices = std::move(_mesh.indices); + return result; + } + + private: + [[nodiscard]] std::uint32_t AddVertex(const Point& point) + { + const auto key = std::make_pair(point.x, point.y); + const auto found = _vertexLookup.find(key); + if (found != _vertexLookup.end()) + { + return found->second; + } + const auto index = static_cast(_mesh.vertices.size()); + _mesh.vertices.push_back(point); + _vertexLookup.emplace(key, index); + return index; + } + + TriangleMesh _mesh; + std::map, std::uint32_t> _vertexLookup; + }; + + void AppendTriangle(const OGRPolygon& triangle, MeshBuilder& builder) + { + const OGRLinearRing* ring = triangle.getExteriorRing(); + if (ring == nullptr || ring->getNumPoints() < 3) + { + return; + } + const Point a{ring->getX(0), ring->getY(0)}; + const Point b{ring->getX(1), ring->getY(1)}; + const Point c{ring->getX(2), ring->getY(2)}; + if (TwiceSignedArea(a, b, c) == 0.0) + { + return; + } + builder.AddTriangle(a, b, c); + } + + void AppendTriangles(const OGRGeometry& geometry, MeshBuilder& builder) + { + switch (wkbFlatten(geometry.getGeometryType())) + { + case wkbPolygon: + AppendTriangle(static_cast(geometry), builder); + return; + + case wkbMultiPolygon: + 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) + { + AppendTriangles(*child, builder); + } + } + return; + } + + default: + throw CoreError("Triangulation returned an unexpected geometry type"); + } + } + } + + TriangleMesh TriangulatePolygon(const Polygon& polygon) + { + GdalEnvironment::Initialize(); + const std::unique_ptr source = MakeOgrPolygon(polygon); + + GdalErrorScope scope; + if (!source->IsValid()) + { + throw CoreError( + "Polygon triangulation requires a valid polygon; the input is " + "self-intersecting or otherwise invalid"); + } + + std::unique_ptr triangulation; + try + { + triangulation.reset(source->ConstrainedDelaunayTriangulation()); + } + catch (const std::exception& error) + { + throw CoreError(std::string("Polygon triangulation failed: ") + error.what()); + } + if (triangulation == nullptr) + { + std::string message = "Polygon triangulation failed"; + const std::string detail = scope.DetailedErrorMessage(); + if (!detail.empty()) + { + message += ": " + detail; + } + throw CoreError(message); + } + + MeshBuilder builder; + AppendTriangles(*triangulation, builder); + TriangleMesh mesh = builder.TakeResult(); + if (mesh.indices.empty()) + { + throw CoreError("Polygon triangulation produced no triangles"); + } + return mesh; + } +} diff --git a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp index 64213aa..bd2d52c 100644 --- a/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp +++ b/src/MapWinGIS.Core/src/rendering/VectorRenderScene.cpp @@ -20,6 +20,7 @@ #include "internal/VectorLayerAccess.h" #include "internal/VectorRenderSceneBuilder.h" +#include "mapwingis/rendering/PolygonTriangulation.h" namespace mwg::rendering { @@ -112,6 +113,24 @@ namespace mwg::rendering { AppendGeometry(geometry, scene); }); + + // Prepare one fill mesh per polygon while the scene is created, so + // renderers never triangulate per frame. A polygon that cannot be + // triangulated keeps a null mesh; its rings above remain available + // for outlines and ring-based backends such as GDI. + scene.fillMeshes.reserve(scene.polygons.size()); + for (const Polygon& polygon : scene.polygons) + { + std::optional mesh; + try + { + mesh = TriangulatePolygon(polygon); + } + catch (const CoreError&) + { + } + scene.fillMeshes.push_back(std::move(mesh)); + } return scene; } diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj index 24d9cb7..2c0f845 100644 --- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj +++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj @@ -69,6 +69,7 @@ + diff --git a/tests/MapWinGIS.Core.Tests/PolygonTriangulationTests.cpp b/tests/MapWinGIS.Core.Tests/PolygonTriangulationTests.cpp new file mode 100644 index 0000000..4436678 --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/PolygonTriangulationTests.cpp @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file PolygonTriangulationTests.cpp + * @brief Tests GEOS-backed polygon triangulation and scene mesh integration. + * + * Verifies that convex, concave, holed, and multipart polygons become valid + * indexed triangle meshes whose triangles cover exactly the polygon area, + * respect holes, do not overlap, and stay aligned with the scene polygons. + */ +#include + +#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/rendering/PolygonTriangulation.h" +#include "mapwingis/rendering/VectorRenderScene.h" +#include "mapwingis/vector/VectorDataset.h" + +using namespace mwg; +using namespace mwg::test; + +namespace +{ + using Coordinate = std::pair; + using HoleList = std::initializer_list>; + + rendering::Ring MakeRing(std::initializer_list points) + { + rendering::Ring ring; + ring.reserve(points.size()); + for (const auto& [x, y] : points) + { + ring.push_back({x, y}); + } + return ring; + } + + rendering::Polygon MakePolygon(std::initializer_list exterior) + { + return {MakeRing(exterior)}; + } + + rendering::Polygon MakePolygon(std::initializer_list exterior, + const HoleList& holes) + { + rendering::Polygon polygon{MakeRing(exterior)}; + for (const auto& hole : holes) + { + polygon.push_back(MakeRing(hole)); + } + return polygon; + } + + double TwiceSignedArea(const rendering::Point& a, + const rendering::Point& b, + const rendering::Point& c) + { + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); + } + + double RingArea(const rendering::Ring& ring) + { + double twiceArea = 0.0; + for (std::size_t index = 0; index < ring.size(); ++index) + { + const rendering::Point& current = ring[index]; + const rendering::Point& next = ring[(index + 1) % ring.size()]; + twiceArea += current.x * next.y - next.x * current.y; + } + return std::abs(twiceArea) * 0.5; + } + + double PolygonArea(const rendering::Polygon& polygon) + { + double area = RingArea(polygon.front()); + for (std::size_t index = 1; index < polygon.size(); ++index) + { + area -= RingArea(polygon[index]); + } + return area; + } + + double MeshArea(const rendering::TriangleMesh& mesh) + { + double area = 0.0; + for (std::size_t index = 0; index < mesh.indices.size(); index += 3) + { + const rendering::Point& a = mesh.vertices[mesh.indices[index]]; + const rendering::Point& b = mesh.vertices[mesh.indices[index + 1]]; + const rendering::Point& c = mesh.vertices[mesh.indices[index + 2]]; + area += std::abs(TwiceSignedArea(a, b, c)) * 0.5; + } + return area; + } + + std::unique_ptr MakeOgrTriangle(const rendering::Point& a, + const rendering::Point& b, + const rendering::Point& c) + { + auto triangle = std::make_unique(); + auto ring = std::make_unique(); + ring->addPoint(a.x, a.y); + ring->addPoint(b.x, b.y); + ring->addPoint(c.x, c.y); + ring->addPoint(a.x, a.y); + triangle->addRingDirectly(ring.release()); + return triangle; + } + + std::unique_ptr MakeOgrPolygon(const rendering::Polygon& polygon) + { + auto result = std::make_unique(); + for (const rendering::Ring& ring : polygon) + { + auto ogrRing = std::make_unique(); + for (const rendering::Point& point : ring) + { + ogrRing->addPoint(point.x, point.y); + } + ogrRing->closeRings(); + result->addRingDirectly(ogrRing.release()); + } + return result; + } + + /// Asserts the mesh-level invariants: grouped indices, valid references, + /// unique vertices, non-degenerate triangles, and consistent winding. + void ExpectValidMesh(const rendering::TriangleMesh& mesh) + { + ASSERT_FALSE(mesh.indices.empty()); + EXPECT_EQ(mesh.indices.size() % 3, 0u); + for (const std::uint32_t index : mesh.indices) + { + EXPECT_LT(index, mesh.vertices.size()); + } + + std::map, int> vertexCounts; + for (const rendering::Point& vertex : mesh.vertices) + { + ++vertexCounts[{vertex.x, vertex.y}]; + } + for (const auto& [coordinate, count] : vertexCounts) + { + EXPECT_EQ(count, 1) << "Duplicate vertex at " << coordinate.first + << ", " << coordinate.second; + } + + for (std::size_t index = 0; index < mesh.indices.size(); index += 3) + { + const rendering::Point& a = mesh.vertices[mesh.indices[index]]; + const rendering::Point& b = mesh.vertices[mesh.indices[index + 1]]; + const rendering::Point& c = mesh.vertices[mesh.indices[index + 2]]; + EXPECT_GT(TwiceSignedArea(a, b, c), 0.0) + << "Triangle is degenerate or not counter-clockwise"; + } + } + + /// Asserts that the mesh covers exactly the polygon: areas match, every + /// triangle stays inside, triangles do not overlap, and no triangle + /// covers an interior hole. + void ExpectMeshCoversPolygon(const rendering::TriangleMesh& mesh, + const rendering::Polygon& polygon) + { + ExpectValidMesh(mesh); + const std::unique_ptr source = MakeOgrPolygon(polygon); + + EXPECT_NEAR(MeshArea(mesh), PolygonArea(polygon), 1e-9); + + std::vector> triangles; + triangles.reserve(mesh.indices.size() / 3); + for (std::size_t index = 0; index < mesh.indices.size(); index += 3) + { + triangles.push_back(MakeOgrTriangle(mesh.vertices[mesh.indices[index]], + mesh.vertices[mesh.indices[index + 1]], + mesh.vertices[mesh.indices[index + 2]])); + } + + for (const auto& triangle : triangles) + { + EXPECT_TRUE(triangle->Within(source.get())) + << "Triangle extends outside the polygon"; + } + + for (std::size_t first = 0; first < triangles.size(); ++first) + { + for (std::size_t second = first + 1; second < triangles.size(); ++second) + { + const std::unique_ptr overlap( + triangles[first]->Intersection(triangles[second].get())); + ASSERT_NE(overlap, nullptr); + // Disjoint triangles give an empty result. Adjacent triangles + // may share an edge or a point (lower dimension), but never + // a two-dimensional region. + EXPECT_TRUE(overlap->IsEmpty() || overlap->getDimension() < 2) + << "Triangles " << first << " and " << second << " overlap"; + } + } + + for (std::size_t hole = 1; hole < polygon.size(); ++hole) + { + rendering::Polygon holeOnly{polygon[hole]}; + const std::unique_ptr holePolygon = MakeOgrPolygon(holeOnly); + for (const auto& triangle : triangles) + { + const std::unique_ptr intersection( + triangle->Intersection(holePolygon.get())); + ASSERT_NE(intersection, nullptr); + EXPECT_TRUE(intersection->IsEmpty() || intersection->getDimension() < 2) + << "Triangles cover interior hole " << hole; + } + } + } + + /// Asserts that two meshes have identical vertices and indices. + void ExpectSameMesh(const rendering::TriangleMesh& actual, + const rendering::TriangleMesh& expected) + { + ASSERT_EQ(actual.vertices.size(), expected.vertices.size()); + for (std::size_t index = 0; index < actual.vertices.size(); ++index) + { + EXPECT_DOUBLE_EQ(actual.vertices[index].x, expected.vertices[index].x); + EXPECT_DOUBLE_EQ(actual.vertices[index].y, expected.vertices[index].y); + } + EXPECT_EQ(actual.indices, expected.indices); + } + + std::filesystem::path MakeWktDataset(const std::filesystem::path& directory, + const char* fileName, + const OGRwkbGeometryType layerType, + const char* geometryWkt) + { + GdalEnvironment::Initialize(); + const std::filesystem::path path = directory / 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 output(driver->Create( + reinterpret_cast(utf8.data()), 0, 0, 0, GDT_Unknown, nullptr)); + if (output == nullptr) + { + throw std::runtime_error("Cannot create polygon dataset"); + } + + OGRLayer* layer = output->CreateLayer("polygons", nullptr, layerType, nullptr); + if (layer == nullptr) + { + throw std::runtime_error("Cannot create polygon layer"); + } + + const char* text = geometryWkt; + OGRGeometry* geometry = nullptr; + if (OGRGeometryFactory::createFromWkt(&text, nullptr, &geometry) != OGRERR_NONE || + geometry == nullptr) + { + throw std::runtime_error("Cannot parse polygon geometry"); + } + + 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 polygon feature"); + } + + output.reset(); + return path; + } +} + +// --- Basic polygons --- + +TEST(PolygonTriangulation, TriangulatesTriangle) +{ + const rendering::TriangleMesh mesh = + rendering::TriangulatePolygon(MakePolygon({{0, 0}, {2, 0}, {0, 2}})); + + ExpectValidMesh(mesh); + EXPECT_EQ(mesh.vertices.size(), 3u); + EXPECT_EQ(mesh.indices.size(), 3u); + EXPECT_NEAR(MeshArea(mesh), 2.0, 1e-12); +} + +TEST(PolygonTriangulation, TriangulatesConvexRectangleWithSharedVertices) +{ + const rendering::Polygon polygon = MakePolygon({{0, 0}, {2, 0}, {2, 1}, {0, 1}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_EQ(mesh.vertices.size(), 4u) << "Both triangles should share the diagonal"; + EXPECT_EQ(mesh.indices.size(), 6u); +} + +TEST(PolygonTriangulation, TriangulatesConvexPolygonWithSeveralVertices) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {3, 0}, {4, 1.5}, {2.5, 3}, {1, 2.5}, {-0.5, 1}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_GE(mesh.indices.size(), 12u); +} + +// --- Concave polygons --- + +TEST(PolygonTriangulation, TriangulatesSimpleConcavePolygon) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {4, 0}, {4, 4}, {2, 2}, {0, 4}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_NEAR(MeshArea(mesh), 12.0, 1e-9); +} + +TEST(PolygonTriangulation, TriangulatesLShapedPolygon) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {3, 0}, {3, 1}, {1, 1}, {1, 3}, {0, 3}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_NEAR(MeshArea(mesh), 5.0, 1e-9); +} + +TEST(PolygonTriangulation, TriangulatesComplexConcavePolygon) +{ + const rendering::Polygon polygon = MakePolygon( + {{1, 0}, {2, 0}, {2, 1}, {3, 1}, {3, 2}, {2, 2}, {2, 3}, {1, 3}, {1, 2}, {0, 2}, {0, 1}, {1, 1}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_NEAR(MeshArea(mesh), 5.0, 1e-9); +} + +// --- Interior rings --- + +TEST(PolygonTriangulation, TriangulatesPolygonWithOneHole) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {3, 0}, {3, 3}, {0, 3}}, {{{1, 1}, {2, 1}, {2, 2}, {1, 2}}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_NEAR(MeshArea(mesh), 8.0, 1e-9); +} + +TEST(PolygonTriangulation, TriangulatesPolygonWithMultipleHoles) +{ + const rendering::Polygon polygon = MakePolygon( + {{0, 0}, {5, 0}, {5, 4}, {0, 4}}, + {{{1, 1}, {2, 1}, {2, 2}, {1, 2}}, {{3, 2}, {4, 2}, {4, 3}, {3, 3}}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); + EXPECT_NEAR(MeshArea(mesh), 18.0, 1e-9); +} + +TEST(PolygonTriangulation, TriangulatesConcavePolygonWithHole) +{ + const rendering::Polygon polygon = MakePolygon( + {{0, 0}, {4, 0}, {4, 2}, {2, 2}, {2, 4}, {0, 4}}, + {{{1, 1}, {1.5, 1}, {1.5, 1.5}, {1, 1.5}}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(mesh, polygon); +} + +// --- Input validation --- + +TEST(PolygonTriangulation, RejectsPolygonWithoutRings) +{ + EXPECT_THROW((void)rendering::TriangulatePolygon({}), InvalidArgument); +} + +TEST(PolygonTriangulation, RejectsDegenerateExteriorRing) +{ + EXPECT_THROW( + (void)rendering::TriangulatePolygon(MakePolygon({{0, 0}, {1, 0}})), + InvalidArgument); +} + +TEST(PolygonTriangulation, RejectsCollinearExteriorRing) +{ + EXPECT_THROW( + (void)rendering::TriangulatePolygon(MakePolygon({{0, 0}, {1, 0}, {2, 0}})), + CoreError); +} + +TEST(PolygonTriangulation, RejectsSelfIntersectingPolygon) +{ + EXPECT_THROW( + (void)rendering::TriangulatePolygon(MakePolygon({{0, 0}, {2, 2}, {2, 0}, {0, 2}})), + CoreError); +} + +TEST(PolygonTriangulation, RejectsNonFiniteCoordinates) +{ + EXPECT_THROW( + (void)rendering::TriangulatePolygon( + MakePolygon({{0, 0}, {1, 0}, {0, std::numeric_limits::quiet_NaN()}})), + InvalidArgument); +} + +TEST(PolygonTriangulation, AcceptsUnclosedAndDuplicateVertices) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {2, 0}, {2, 0}, {2, 2}, {0, 2}, {0, 0}, {0, 0}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectValidMesh(mesh); + EXPECT_NEAR(MeshArea(mesh), 4.0, 1e-9); +} + +TEST(PolygonTriangulation, IgnoresInteriorRingWithFewerThanThreePoints) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {2, 0}, {2, 2}, {0, 2}}, {{{1, 1}, {1.5, 1.5}}}); + const rendering::TriangleMesh mesh = rendering::TriangulatePolygon(polygon); + + ExpectValidMesh(mesh); + EXPECT_NEAR(MeshArea(mesh), 4.0, 1e-9); +} + +TEST(PolygonTriangulation, RepeatedCallsProduceIdenticalMeshes) +{ + const rendering::Polygon polygon = + MakePolygon({{0, 0}, {3, 0}, {3, 2}, {1, 2}, {1, 4}, {0, 4}}, + {{{2, 0.5}, {2.5, 0.5}, {2.5, 1}, {2, 1}}}); + + const rendering::TriangleMesh first = rendering::TriangulatePolygon(polygon); + const rendering::TriangleMesh second = rendering::TriangulatePolygon(polygon); + + ExpectMeshCoversPolygon(first, polygon); + ExpectMeshCoversPolygon(second, polygon); + ExpectSameMesh(second, first); +} + +// --- Render scene integration --- + +TEST(PolygonTriangulation, SceneMeshesAlignWithPolygons) +{ + TempDirectory directory; + const std::vector rectangles = { + {0.0, 0.0, 2.0, 1.0}, {5.0, 0.0, 7.0, 3.0}, {10.0, 0.0, 11.0, 1.0}}; + const auto dataset = + VectorDataset::Open(MakeRectanglesDataset(directory.Path(), "rects.gpkg", rectangles)); + + const rendering::VectorRenderScene scene = rendering::CreateVectorRenderScene(dataset->Layer(0)); + + ASSERT_EQ(scene.polygons.size(), rectangles.size()); + ASSERT_EQ(scene.fillMeshes.size(), scene.polygons.size()); + for (std::size_t index = 0; index < scene.polygons.size(); ++index) + { + ASSERT_TRUE(scene.fillMeshes[index].has_value()) << "Polygon " << index; + const rendering::TriangleMesh& mesh = *scene.fillMeshes[index]; + ExpectValidMesh(mesh); + const double width = rectangles[index].xMax - rectangles[index].xMin; + const double height = rectangles[index].yMax - rectangles[index].yMin; + EXPECT_NEAR(MeshArea(mesh), width * height, 1e-9); + // Original rings stay available for outlines and GDI. + ASSERT_EQ(scene.polygons[index].size(), 1u); + EXPECT_EQ(scene.polygons[index].front().size(), 5u); + } +} + +TEST(PolygonTriangulation, SceneTriangulatesMultipartGeometryWithHole) +{ + TempDirectory directory; + const auto dataset = VectorDataset::Open(MakeWktDataset( + directory.Path(), + "multipart.gpkg", + wkbMultiPolygon, + "MULTIPOLYGON (((0 0, 4 0, 4 4, 0 4, 0 0)), " + "((10 10, 14 10, 14 14, 10 14, 10 10), (11 11, 12 11, 12 12, 11 12, 11 11)))")); + + const rendering::VectorRenderScene scene = rendering::CreateVectorRenderScene(dataset->Layer(0)); + + ASSERT_EQ(scene.polygons.size(), 2u); + ASSERT_EQ(scene.fillMeshes.size(), 2u); + for (std::size_t index = 0; index < scene.polygons.size(); ++index) + { + ASSERT_TRUE(scene.fillMeshes[index].has_value()) << "Polygon " << index; + ExpectMeshCoversPolygon(*scene.fillMeshes[index], scene.polygons[index]); + } + EXPECT_NEAR(MeshArea(*scene.fillMeshes[0]), 16.0, 1e-9); + EXPECT_NEAR(MeshArea(*scene.fillMeshes[1]), 15.0, 1e-9); +} + +TEST(PolygonTriangulation, FailedTriangulationKeepsOriginalRings) +{ + TempDirectory directory; + const auto dataset = VectorDataset::Open(MakeWktDataset( + directory.Path(), + "invalid.gpkg", + wkbPolygon, + "POLYGON ((0 0, 2 2, 2 0, 0 2, 0 0))")); + + const rendering::VectorRenderScene scene = rendering::CreateVectorRenderScene(dataset->Layer(0)); + + ASSERT_EQ(scene.polygons.size(), 1u); + ASSERT_EQ(scene.fillMeshes.size(), 1u); + EXPECT_FALSE(scene.fillMeshes.front().has_value()) + << "The self-intersecting polygon must not receive a mesh"; + // GDI can still draw the original rings. + ASSERT_EQ(scene.polygons.front().size(), 1u); + EXPECT_EQ(scene.polygons.front().front().size(), 5u); +} From 890f9dc5adbb72f9eeb9b682db258d6931b3ea8c Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Thu, 12 Nov 2026 18:12:11 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20harden=20polygo?= =?UTF-8?q?n=20triangulation=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard the mesh vertex count before narrowing the index to uint32_t - Rethrow std::bad_alloc from triangulation instead of converting it to CoreError - Rethrow captured GdalErrorScope exceptions after validation and triangulation --- .../src/rendering/PolygonTriangulation.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp b/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp index 8862fd7..12a6a38 100644 --- a/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp +++ b/src/MapWinGIS.Core/src/rendering/PolygonTriangulation.cpp @@ -11,8 +11,11 @@ #include "mapwingis/rendering/PolygonTriangulation.h" #include +#include +#include #include #include +#include #include #include @@ -117,6 +120,13 @@ namespace mwg::rendering { return found->second; } + if (_mesh.vertices.size() >= + static_cast(std::numeric_limits::max())) + { + throw CoreError( + "Polygon triangulation produced more distinct vertices than the " + "mesh index type supports"); + } const auto index = static_cast(_mesh.vertices.size()); _mesh.vertices.push_back(point); _vertexLookup.emplace(key, index); @@ -179,7 +189,9 @@ namespace mwg::rendering const std::unique_ptr source = MakeOgrPolygon(polygon); GdalErrorScope scope; - if (!source->IsValid()) + const bool valid = source->IsValid(); + scope.RethrowCaptureException(); + if (!valid) { throw CoreError( "Polygon triangulation requires a valid polygon; the input is " @@ -191,10 +203,15 @@ namespace mwg::rendering { triangulation.reset(source->ConstrainedDelaunayTriangulation()); } + catch (const std::bad_alloc&) + { + throw; + } catch (const std::exception& error) { throw CoreError(std::string("Polygon triangulation failed: ") + error.what()); } + scope.RethrowCaptureException(); if (triangulation == nullptr) { std::string message = "Polygon triangulation failed"; From 44411fcb314158045363f34d985f723b5325a747 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Fri, 13 Nov 2026 19:48:05 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20register=20pol?= =?UTF-8?q?ygon=20triangulation=20sources=20in=20CMake=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Core CMakeLists sources and headers lagged behind MapWinGIS.Core.vcxproj, failing the cmake-parity configure check; the new test also never compiled in CMake builds. Add PolygonTriangulation.cpp/.h to MapWinGIS.Core and PolygonTriangulationTests.cpp to MapWinGIS.Core.Tests. --- src/MapWinGIS.Core/CMakeLists.txt | 2 ++ tests/MapWinGIS.Core.Tests/CMakeLists.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/MapWinGIS.Core/CMakeLists.txt b/src/MapWinGIS.Core/CMakeLists.txt index c5e1a3c..6644b34 100644 --- a/src/MapWinGIS.Core/CMakeLists.txt +++ b/src/MapWinGIS.Core/CMakeLists.txt @@ -23,6 +23,7 @@ set(MWG_CORE_PUBLIC_HEADERS include/mapwingis/map/MapView.h include/mapwingis/map/Viewport.h include/mapwingis/rendering/MapRenderScene.h + include/mapwingis/rendering/PolygonTriangulation.h include/mapwingis/rendering/VectorRenderScene.h include/mapwingis/vector/FieldInfo.h include/mapwingis/vector/VectorDataset.h @@ -48,6 +49,7 @@ set(MWG_CORE_SOURCES src/map/MapView.cpp src/map/Viewport.cpp src/rendering/MapRenderScene.cpp + src/rendering/PolygonTriangulation.cpp src/rendering/VectorRenderScene.cpp src/vector/VectorDataset.cpp ) diff --git a/tests/MapWinGIS.Core.Tests/CMakeLists.txt b/tests/MapWinGIS.Core.Tests/CMakeLists.txt index ab92355..160cf20 100644 --- a/tests/MapWinGIS.Core.Tests/CMakeLists.txt +++ b/tests/MapWinGIS.Core.Tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(MWG_CORE_TEST_SOURCES MapRenderSceneTests.cpp MapTests.cpp MapViewTests.cpp + PolygonTriangulationTests.cpp TestData.cpp VectorDatasetTests.cpp VectorLayerTests.cpp