diff --git a/src/compas/geometry/polygon.py b/src/compas/geometry/polygon.py index 9feffed84f0..63b951ab26b 100644 --- a/src/compas/geometry/polygon.py +++ b/src/compas/geometry/polygon.py @@ -16,7 +16,7 @@ from compas.geometry import earclip_polygon from compas.geometry import is_coplanar from compas.geometry import is_polygon_convex -from compas.geometry import normal_triangle +from compas.geometry import normal_polygon from compas.geometry import transform_points from compas.itertools import pairwise from compas.tolerance import TOL @@ -181,14 +181,11 @@ def normal(self): @property def plane(self): # by just taking the bestfit plane, - # the normal might not be aligned with the winding direciton of the polygon - # this can be solved by comparing the plane normal with the normal of one of the triangles of the polygon - # for convex polygons this is always correct - # in the case of concave polygons it may not be - # to be entirely correct, the check should be done with one of the polygon ears after earclipping - # however, this is costly - # and even then it is only correct if we assume th polygon is plat enough to have a consistent direction - normal = normal_triangle([self.centroid] + self.points[:2]) + # the normal might not be aligned with the winding direction of the polygon + # this can be solved by comparing the plane normal with the normal of the polygon + # using normal_polygon is more robust than normal_triangle for concave polygons + # as it considers all vertices instead of just a triangle formed by the centroid and first two points + normal = normal_polygon(self.points) plane = Plane.from_points(self.points) if plane.normal.dot(normal) < 0: plane.normal.flip() diff --git a/tests/compas/geometry/test_polygon.py b/tests/compas/geometry/test_polygon.py index 322a5912583..caa0b90c78a 100644 --- a/tests/compas/geometry/test_polygon.py +++ b/tests/compas/geometry/test_polygon.py @@ -110,3 +110,37 @@ def test_polygon_normal_direction(): def test_polygon_duplicate_removal(points): polygon = Polygon(points) assert len(polygon.points) == 4 + + +def test_polygon_normal_concave(): + """Test that polygon normal works correctly for concave polygons.""" + # L-shape concave polygon + points = [ + [0, 0, 0], + [2, 0, 0], + [2, 1, 0], + [1, 1, 0], + [1, 2, 0], + [0, 2, 0] + ] + polygon = Polygon(points) + # Normal should point in positive Z direction for CCW winding + assert polygon.normal.dot([0, 0, 1]) > 0.99 + + # Arrow/chevron shape concave polygon + points = [ + [0, 1, 0], + [0, 0, 0], + [2, 0, 0], + [3, 1, 0], + [2, 2, 0], + [0, 2, 0], + ] + polygon = Polygon(points) + # Normal should point in positive Z direction for CCW winding + assert polygon.normal.dot([0, 0, 1]) > 0.99 + + # Reverse winding should give opposite normal + points_reversed = list(reversed(points)) + polygon_reversed = Polygon(points_reversed) + assert polygon_reversed.normal.dot([0, 0, -1]) > 0.99 diff --git a/tests/compas/geometry/test_triangulation_earclip.py b/tests/compas/geometry/test_triangulation_earclip.py index 6eb6e1b3dd3..7f84b6df5c0 100644 --- a/tests/compas/geometry/test_triangulation_earclip.py +++ b/tests/compas/geometry/test_triangulation_earclip.py @@ -64,34 +64,41 @@ def test_earclip_polygon_wrong_winding(): faces = earclip_polygon(polygon) + # Expected faces updated after changing Polygon.normal to use normal_polygon + # instead of normal_triangle for more robust concave polygon handling. + # Previous behavior: Used normal_triangle with centroid and first two points, + # which could give incorrect normals for concave polygons. + # New behavior: Uses normal_polygon which considers all vertices, correctly + # detecting the winding direction for this complex concave polygon. + # Result: Different but equally valid triangulation orientation. assert faces == [ - [0, 28, 27], - [26, 25, 24], - [23, 22, 21], - [21, 20, 19], - [19, 18, 17], - [17, 16, 15], - [15, 14, 13], - [10, 9, 8], - [8, 7, 6], - [4, 3, 2], - [27, 26, 24], - [24, 23, 21], - [17, 15, 13], - [11, 10, 8], - [4, 2, 1], - [27, 24, 21], - [19, 17, 13], - [5, 4, 1], - [27, 21, 19], - [19, 13, 12], - [6, 5, 1], - [27, 19, 12], - [6, 1, 0], - [27, 12, 11], - [8, 6, 0], - [0, 27, 11], - [11, 8, 0], + [2, 3, 4], + [5, 6, 7], + [7, 8, 9], + [12, 13, 14], + [14, 15, 16], + [17, 18, 19], + [19, 20, 21], + [21, 22, 23], + [23, 24, 25], + [27, 28, 0], + [1, 2, 4], + [7, 9, 10], + [14, 16, 17], + [23, 25, 26], + [0, 1, 4], + [12, 14, 17], + [21, 23, 26], + [0, 4, 5], + [12, 17, 19], + [21, 26, 27], + [0, 5, 7], + [11, 12, 19], + [19, 21, 27], + [0, 7, 10], + [11, 19, 27], + [0, 10, 11], + [11, 27, 0], ]