From 1e15f35a5b33004534cee899525225ed5618ed65 Mon Sep 17 00:00:00 2001 From: Liu Jiayi <58357938+JayLeo04@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:45:32 +0800 Subject: [PATCH] Fix Polygon2D convex hull winding direction --- src/Spatial.Tests/Euclidean/Polygon2DTests.cs | 64 +++++++++++++------ src/Spatial/Euclidean/Polygon2D.cs | 7 +- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/Spatial.Tests/Euclidean/Polygon2DTests.cs b/src/Spatial.Tests/Euclidean/Polygon2DTests.cs index c3585ea..731ca1d 100644 --- a/src/Spatial.Tests/Euclidean/Polygon2DTests.cs +++ b/src/Spatial.Tests/Euclidean/Polygon2DTests.cs @@ -95,7 +95,7 @@ public void IsPointInPolygonTest2(double x, double y, bool outcome) Assert.AreEqual(outcome, testPoly.EnclosesPoint(testPoint)); } - // These test cases were generated using scipy.spatial's ConvexHull method + // These counter-clockwise test cases were generated using scipy.spatial's ConvexHull method [TestCase("0.27,0.41;0.87,0.67;0.7,0.33;0.5,0.61;0.04,0.23;0.73,0.14;0.84,0.02;0.25,0.23;0.12,0.2;0.37,0.78", "0.87,0.67;0.37,0.78;0.04,0.23;0.12,0.2;0.84,0.02")] [TestCase("0.81,0.25;0.77,0.15;0.17,0.48;0.4,0.58;0.29,0.92;0.37,0.26;0.7,0.91;0.04,0.1;0.39,0.73;0.7,0.12", "0.7,0.91;0.29,0.92;0.04,0.1;0.7,0.12;0.77,0.15;0.81,0.25")] [TestCase("0.87,0.39;0.83,0.42;0.75,0.62;0.91,0.49;0.18,0.63;0.17,0.95;0.22,0.5;0.93,0.41;0.66,0.79;0.32,0.42", "0.66,0.79;0.17,0.95;0.18,0.63;0.22,0.5;0.32,0.42;0.87,0.39;0.93,0.41;0.91,0.49")] @@ -107,27 +107,19 @@ public void ConvexHullTest(string points, string expected) var testPoints = (from x in points.Split(';') select Point2D.Parse(x)).ToList(); var expectedPoints = (from x in expected.Split(';') select Point2D.Parse(x)).ToList(); - var hullClockwise = Polygon2D.GetConvexHullFromPoints(testPoints, true); + var hullCounterClockwise = Polygon2D.GetConvexHullFromPoints(testPoints, false); + var counterClockwiseVertices = hullCounterClockwise.Vertices; + CollectionAssert.AreEqual(expectedPoints, counterClockwiseVertices); + Assert.That(SignedArea(hullCounterClockwise), Is.GreaterThan(0)); + var hullClockwise = Polygon2D.GetConvexHullFromPoints(testPoints, true); var clockwiseVertices = hullClockwise.Vertices; + expectedPoints.Reverse(); CollectionAssert.AreEqual(expectedPoints, clockwiseVertices); - /* - for (var i = 0; i < hullClockwise.VertexCount; i++) - { - Assert.That(ClockwiseVerticies[i], Is.EqualTo(expectedPoints[i])); - } - */ + Assert.That(SignedArea(hullClockwise), Is.LessThan(0)); - var hullCounterClockwise = Polygon2D.GetConvexHullFromPoints(testPoints, false); - var counterClockwiseVertices = hullCounterClockwise.Vertices; - expectedPoints.Reverse(); - CollectionAssert.AreEqual(expectedPoints, counterClockwiseVertices); - /* - for (var i = 0; i < hullCounterClockwise.VertexCount; i++) - { - Assert.That(counterClockwiseVerticies[i], Is.EqualTo(expectedPoints[hullCounterClockwise.VertexCount - 1 - i])); - } - */ + var defaultHull = Polygon2D.GetConvexHullFromPoints(testPoints); + CollectionAssert.AreEqual(clockwiseVertices, defaultHull.Vertices); var pointsNotOnConvexHull = testPoints.Except(hullCounterClockwise.Vertices); foreach (var pointNotOnConvexHull in pointsNotOnConvexHull) @@ -148,6 +140,28 @@ public void ConvexHullTest(string points, string expected) } } + [Test] + public void ConvexHullHonorsDirectionForTriangle() + { + var points = new[] + { + new Point2D(0, 0), + new Point2D(1, 0), + new Point2D(0, 1) + }; + + var defaultHull = Polygon2D.GetConvexHullFromPoints(points); + var clockwiseHull = Polygon2D.GetConvexHullFromPoints(points, true); + var counterClockwiseHull = Polygon2D.GetConvexHullFromPoints(points, false); + + CollectionAssert.AreEquivalent(points, defaultHull.Vertices); + CollectionAssert.AreEquivalent(points, clockwiseHull.Vertices); + CollectionAssert.AreEquivalent(points, counterClockwiseHull.Vertices); + Assert.That(SignedArea(defaultHull), Is.LessThan(0)); + Assert.That(SignedArea(clockwiseHull), Is.LessThan(0)); + Assert.That(SignedArea(counterClockwiseHull), Is.GreaterThan(0)); + } + [TestCase("0,0;0.4,0;0.5,0;0.6,0;1,0;1,.25;1,.75;1,1;0,1;0,0.5", "1,0;1,1;0,1;0,0")] public void ReduceComplexity(string points, string reduced) { @@ -166,6 +180,20 @@ private static Polygon2D TestPolygon1() return new Polygon2D(points); } + private static double SignedArea(Polygon2D polygon) + { + var vertices = polygon.Vertices.ToList(); + double twiceArea = 0; + for (var i = 0; i < vertices.Count; i++) + { + var current = vertices[i]; + var next = vertices[(i + 1) % vertices.Count]; + twiceArea += (current.X * next.Y) - (next.X * current.Y); + } + + return twiceArea / 2; + } + private static Polygon2D TestPolygon2() { var points = from x in new[] { "0,0", "0.25,0.5", "1,1", "-1,1", "0.5,-0.5", "0,0" } select Point2D.Parse(x); diff --git a/src/Spatial/Euclidean/Polygon2D.cs b/src/Spatial/Euclidean/Polygon2D.cs index a255a18..bdacc24 100644 --- a/src/Spatial/Euclidean/Polygon2D.cs +++ b/src/Spatial/Euclidean/Polygon2D.cs @@ -163,11 +163,6 @@ public static Polygon2D GetConvexHullFromPoints(IEnumerable pointList, throw new ArgumentException("Must have at least 3 points in the polygon to compute the convex hull"); } - if (count <= 3) - { - return new Polygon2D(pointList); - } - var chull = new ConvexHull(pointList, false); chull.CalcConvexHull(); var hullPoints = chull.GetResultsAsArrayOfPoint(); @@ -175,7 +170,7 @@ public static Polygon2D GetConvexHullFromPoints(IEnumerable pointList, // Order the hull points by angle to the centroid var centroid = Point2D.Centroid(hullPoints); var xAxis = new Vector2D(1, 0); - var results = (from x in hullPoints select new Tuple(centroid.VectorTo(x).SignedAngleTo(xAxis, clockWise), x)).ToList(); + var results = (from x in hullPoints select new Tuple(centroid.VectorTo(x).SignedAngleTo(xAxis, !clockWise), x)).ToList(); results.Sort((a, b) => a.Item1.CompareTo(b.Item1)); return new Polygon2D(from x in results select x.Item2);