Skip to content

Commit

Permalink
fix(polygon): Add a method to split a self-intersecting polygon
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey authored and Chris Mackey committed Jun 21, 2024
1 parent 1c3fc58 commit b129f43
Show file tree
Hide file tree
Showing 2 changed files with 164 additions and 3 deletions.
82 changes: 80 additions & 2 deletions ladybug_geometry/geometry2d/polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,85 @@ def remove_colinear_vertices(self, tolerance):
new_vertices.append(pts_2d[-1])
return Polygon2D(new_vertices)

def split_through_self_intersection(self, tolerance):
"""Get a list of non-intersecting Polygon2D if this polygon intersects itself.
If the Polygon2D does not intersect itself, then a list with the current
Polygon2D will be returned.
Args:
tolerance: The minimum difference between vertices before they are
considered co-located.
"""
# loop over the segments and group the vertices by intersection points
intersect_groups = [[]]
_segs = self.segments
seg_count = len(_segs)
for i, _s in enumerate(_segs):
# loop over the other segments and find any intersection points
if i == 0:
_skip = (len(_segs) - 1, i, i + 1)
elif i == seg_count - 1:
_skip = (i - 1, i, 0)
else:
_skip = (i - 1, i, i + 1)
_other_segs = [x for j, x in enumerate(_segs) if j not in _skip]
int_pts = []
for _oth_s in _other_segs:
int_pt = _s.intersect_line_ray(_oth_s)
if int_pt is not None: # intersection!
int_pts.append(int_pt)
# if intersection points were found, adjust the groups accordingly
if len(int_pts) == 0: # no self intersection on this segment
intersect_groups[-1].append(_s.p2)
elif len(int_pts) == 1: # typical self-intersection case we should split
intersect_groups[-1].append(int_pts[0])
intersect_groups.append([_s.p2])
else: # rare case of multiple intersections on the same segment
# sort the intersection points along the segment
dists = [_s.p1.distance_to_point(ipt) for ipt in int_pts]
sort_pts = [pt for _, pt in sorted(zip(dists, int_pts),
key=lambda pair: pair[0])]
intersect_groups[-1].append(sort_pts[0])
for s_pt in sort_pts[1:]:
intersect_groups.append([s_pt])
intersect_groups.append([_s.p2])

# process the intersect groups into polygon objects
if len(intersect_groups) == 1:
return [self] # not a self-intersecting shape
split_polygons = []
poly_count = int(len(intersect_groups) / 2)
if len(intersect_groups[poly_count]) == 1: # rare case of start at intersect
for i in range(poly_count):
vert_group = [intersect_groups[i], intersect_groups[-i - 1]]
for verts_list in vert_group:
if len(verts_list) > 2:
try:
clean_poly = Polygon2D(verts_list)
clean_poly = clean_poly.remove_duplicate_vertices(tolerance)
split_polygons.append(clean_poly)
except AssertionError: # degenerate polygon that should not be added
pass
else: # typical case of intersection in the middle
for i in range(poly_count):
verts_list = intersect_groups[i] + intersect_groups[-i - 1]
if len(verts_list) > 2:
try:
clean_poly = Polygon2D(verts_list)
clean_poly = clean_poly.remove_duplicate_vertices(tolerance)
split_polygons.append(clean_poly)
except AssertionError: # degenerate polygon that should not be added
pass
final_verts = intersect_groups[i + 1]
try:
clean_poly = Polygon2D(final_verts)
clean_poly = clean_poly.remove_duplicate_vertices(tolerance)
split_polygons.append(clean_poly)
except AssertionError: # degenerate polygon that should not be added
pass
return split_polygons

def reverse(self):
"""Get a copy of this polygon where the vertices are reversed."""
_new_poly = Polygon2D(tuple(pt for pt in reversed(self.vertices)))
Expand Down Expand Up @@ -1441,8 +1520,7 @@ def perimeter_core_by_offset(polygon, distance, holes=None):
This method will only return polygons when the distance is shallow enough
that the perimeter offset does not intersect itself or turn inward on itself.
Otherwise, the method will simple return None. This means that there will
only ever be one core polygon.
Otherwise, the method will simply return None.
Args:
polygon: A Polygon2D to split into perimeter and core sub-polygons.
Expand Down
85 changes: 84 additions & 1 deletion tests/polygon2d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,89 @@ def test_remove_colinear_vertices():
assert len(polygon_2.remove_colinear_vertices(0.0001).vertices) == 4


def test_split_through_self_intersection():
"""Test the split_through_self_intersection method."""
pts_1 = (Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), Point2D(0, 2))
polygon_1 = Polygon2D(pts_1)
assert not polygon_1.is_self_intersecting
split_polys = polygon_1.split_through_self_intersection(0.01)
assert len(split_polys) == 1

pts_2 = (Point2D(0, 0), Point2D(0, 2), Point2D(2, 0), Point2D(2, 2))
polygon_2 = Polygon2D(pts_2)
assert polygon_2.is_self_intersecting
split_polys = polygon_2.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_3 = (Point2D(0, 0), Point2D(0, 2), Point2D(1, 1),
Point2D(2, 0), Point2D(2, 2), Point2D(1, 1))
polygon_3 = Polygon2D(pts_3)
assert polygon_3.is_self_intersecting
split_polys = polygon_3.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_4 = (Point2D(0, 0), Point2D(0, 2), Point2D(1, 1),
Point2D(2, 0), Point2D(2, 2))
polygon_4 = Polygon2D(pts_4)
assert polygon_4.is_self_intersecting
split_polys = polygon_4.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_5 = (Point2D(1, 1), Point2D(2, 0), Point2D(2, 2), Point2D(0, 0), Point2D(0, 2))
polygon_5 = Polygon2D(pts_5)
assert polygon_5.is_self_intersecting
split_polys = polygon_5.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_6 = (Point2D(2, 0), Point2D(2, 2), Point2D(0, 0), Point2D(0, 2), Point2D(1, 1))
polygon_6 = Polygon2D(pts_6)
assert polygon_6.is_self_intersecting
split_polys = polygon_6.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_7 = (Point2D(1, 1), Point2D(2, 0), Point2D(2, 2), Point2D(0, 0),
Point2D(0, 2), Point2D(1, 1))
polygon_7 = Polygon2D(pts_7)
assert polygon_7.is_self_intersecting
split_polys = polygon_7.split_through_self_intersection(0.01)
assert len(split_polys) == 2
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(1.0, rel=1e-3)

pts_8 = (Point2D(0, 1), Point2D(0, 2), Point2D(1, 0), Point2D(2, 2), Point2D(2, 1))
polygon_8 = Polygon2D(pts_8)
assert polygon_8.is_self_intersecting
split_polys = polygon_8.split_through_self_intersection(0.01)
assert len(split_polys) == 3
for poly in split_polys:
assert not poly.is_self_intersecting
assert len(poly) == 3
assert poly.area == pytest.approx(0.25, rel=1e-3) or \
poly.area == pytest.approx(0.5, rel=1e-3)


def test_polygon2d_duplicate():
"""Test the duplicate method of Polygon2D."""
pts = (Point2D(0, 0), Point2D(2, 0), Point2D(2, 2), Point2D(0, 2))
Expand Down Expand Up @@ -1114,7 +1197,7 @@ def test_common_axes():
polygons = [Polygon2D.from_dict(p) for p in geo_dict]

axes, values = Polygon2D.common_axes(
polygons, Vector2D(1, 0),min_distance=0.1, merge_distance=0.3,
polygons, Vector2D(1, 0), min_distance=0.1, merge_distance=0.3,
angle_tolerance=math.pi / 180)

assert len(axes) == 31
Expand Down

0 comments on commit b129f43

Please sign in to comment.