Source code for ladybug_geometry.geometry2d.polygon

# coding=utf-8
"""2D Polygon"""
from __future__ import division
import math
import time
from collections import deque

try:  # Python3
    from queue import PriorityQueue
except ImportError:  # Python2
    from Queue import PriorityQueue

from .pointvector import Point2D, Vector2D
from .line import LineSegment2D
from .ray import Ray2D
from .polyline import Polyline2D
from ..triangulation import _linked_list, _eliminate_holes
from ..intersection2d import intersect_line2d, intersect_line2d_infinite, \
    does_intersection_exist_line2d, closest_point2d_on_line2d, \
    closest_end_point2d_between_line2d, closest_point2d_on_line2d_infinite
from ._2d import Base2DIn2D
import ladybug_geometry.boolean as pb

inf = float("inf")


[docs] class Polygon2D(Base2DIn2D): """2D polygon object. Args: vertices: A list of Point2D objects representing the vertices of the polygon. Properties: * vertices * segments * inside_angles * outside_angles * min * max * center * perimeter * area * is_clockwise * is_convex * is_self_intersecting * self_intersection_points * is_valid """ __slots__ = ('_segments', '_inside_angles', '_outside_angles', '_perimeter', '_area', '_is_clockwise', '_is_convex', '_is_self_intersecting') def __init__(self, vertices): """Initialize Polygon2D.""" Base2DIn2D.__init__(self, vertices) self._segments = None self._perimeter = None self._inside_angles = None self._outside_angles = None self._area = None self._is_clockwise = None self._is_convex = None self._is_self_intersecting = None
[docs] @classmethod def from_dict(cls, data): """Create a Polygon2D from a dictionary. Args: data: A python dictionary in the following format .. code-block:: python { "type": "Polygon2D", "vertices": [(0, 0), (10, 0), (0, 10)] } """ return cls(tuple(Point2D.from_array(pt) for pt in data['vertices']))
[docs] @classmethod def from_array(cls, point_array): """Create a Polygon2D from a nested array of vertex coordinates. Args: point_array: nested array of point arrays. """ return Polygon2D(Point2D(*point) for point in point_array)
[docs] @classmethod def from_rectangle(cls, base_point, height_vector, base, height): """Initialize Polygon2D from rectangle parameters. Initializing a polygon this way has the added benefit of having its properties quickly computed. Args: base_point: A Point2D for the lower left vertex of the polygon. height_vector: A vector denoting the direction of the rectangle height. base: A number indicating the length of the base of the rectangle. height: A number indicating the length of the height of the rectangle. """ assert isinstance(base_point, Point2D), \ 'base_point must be Point2D. Got {}.'.format(type(base_point)) assert isinstance(height_vector, Vector2D), \ 'height_vector must be Vector2D. Got {}.'.format(type(height_vector)) assert isinstance(base, (float, int)), 'base must be a number.' assert isinstance(height, (float, int)), 'height must be a number.' _hv_norm = height_vector.normalize() _bv = Vector2D(_hv_norm.y, -_hv_norm.x) * base _hv = _hv_norm * height _verts = (base_point, base_point + _bv, base_point + _hv + _bv, base_point + _hv) polygon = cls(_verts) polygon._perimeter = base * 2 + height * 2 polygon._area = base * height polygon._is_clockwise = False polygon._is_convex = True polygon._is_self_intersecting = False return polygon
[docs] @classmethod def from_regular_polygon(cls, number_of_sides, radius=1, base_point=Point2D()): """Initialize Polygon2D from regular polygon parameters. Args: number_of_sides: An integer for the number of sides on the regular polygon. This number must be greater than 2. radius: A number indicating the distance from the polygon's center where the vertices of the polygon will lie. The default is set to 1. base_point: A Point2D for the center of the regular polygon. The default is the Origin at (0, 0). """ assert isinstance(number_of_sides, int), 'number_of_sides must be an ' \ 'integer. Got {}.'.format(type(number_of_sides)) assert number_of_sides > 2, 'number_of_sides must be greater than 2. ' \ 'Got {}.'.format(number_of_sides) assert isinstance(base_point, Point2D), \ 'base_point must be Point2D. Got {}.'.format(type(base_point)) assert isinstance(radius, (float, int)), 'height must be a number.' # calculate angle at which each vertex is rotated from the previous one angle = (math.pi * 2) / number_of_sides cos_a = math.cos(angle) sin_a = math.sin(angle) # pick a starting vertex that makes sense for the number of sides if number_of_sides % 2 == 0: start_vert = Point2D(base_point.x - radius, base_point.y) start_vert = start_vert.rotate(angle / 2, base_point) else: start_vert = Point2D(base_point.x, base_point.y + radius) _vertices = [start_vert] # generate the vertices for i in range(number_of_sides - 1): last_pt = _vertices[-1] qx = cos_a * (last_pt.x - base_point.x) - sin_a * (last_pt.y - base_point.y) qy = sin_a * (last_pt.x - base_point.x) + cos_a * (last_pt.y - base_point.y) _vertices.append(Point2D(qx + base_point.x, qy + base_point.y)) # build the new polygon and set the properties that we know. _new_poly = cls(_vertices) _new_poly._is_clockwise = False _new_poly._is_convex = True _new_poly._is_self_intersecting = False return _new_poly
[docs] @classmethod def from_shape_with_hole(cls, boundary, hole): """Initialize a Polygon2D from a boundary shape with a hole inside of it. This method will convert the shape into a single concave polygon by drawing a line from the hole to the outer boundary. Args: boundary: A list of Point2D objects for the outer boundary of the polygon inside of which the hole is contained. hole: A list of Point2D objects for the hole. """ # check that the inputs are in the correct format assert isinstance(boundary, list), \ 'boundary should be a list. Got {}'.format(type(boundary)) assert isinstance(hole, list), \ 'hole should be a list. Got {}'.format(type(hole)) # check that the direction of vertices for the hole is opposite the boundary bound_direction = Polygon2D._are_clockwise(boundary) if cls._are_clockwise(hole) is bound_direction: hole.reverse() # join the hole with the boundary at the closest point dist_dict = {} for i, b_pt in enumerate(boundary): for j, h_pt in enumerate(hole): dist_dict[b_pt.distance_to_point(h_pt)] = (i, j) boundary = cls._merge_boundary_and_hole(boundary, hole, dist_dict) # return the polygon with some properties set based on what we know _new_poly = cls(boundary) _new_poly._is_clockwise = bound_direction _new_poly._is_convex = False return _new_poly
[docs] @classmethod def from_shape_with_holes(cls, boundary, holes): """Initialize a Polygon2D from a boundary shape with holes inside of it. This method will convert the shape into a single concave polygon by drawing lines from the holes to the outer boundary. Args: boundary: A list of Point2D objects for the outer boundary of the polygon inside of which all of the holes are contained. holes: A list of lists with one list for each hole in the shape. Each hole should be a list of at least 3 Point2D objects. """ # check that the inputs are in the correct format. assert isinstance(boundary, list), \ 'boundary should be a list. Got {}'.format(type(boundary)) assert isinstance(holes, list), \ 'holes should be a list. Got {}'.format(type(holes)) for hole in holes: assert isinstance(hole, list), \ 'hole should be a list. Got {}'.format(type(hole)) assert len(hole) >= 3, \ 'hole should have at least 3 vertices. Got {}'.format(len(hole)) # check that the direction of vertices for the hole is opposite the boundary bound_direction = cls._are_clockwise(boundary) for hole in holes: if cls._are_clockwise(hole) is bound_direction: hole.reverse() # recursively add the nearest hole to the boundary until there are none left. boundary = cls._merge_boundary_and_holes(boundary, holes) # return the polygon with some properties set based on what we know _new_poly = cls(boundary) _new_poly._is_clockwise = bound_direction _new_poly._is_convex = False return _new_poly
[docs] @classmethod def from_shape_with_holes_fast(cls, boundary, holes): """Initialize a Polygon2D from a boundary shape with holes using a fast method. This method is similar in principle to the from_shape_with_holes method but it uses David Eberly's algorithm for finding a bridge between the holes and outer polygon. This is extremely fast in comparison to the methods used by from_shape_with_holes but is not always the prettiest or the shortest pathway through the holes. Granted, it is very practical for shapes with lots of holes (eg. 100 holes) and will run in a fraction of the time for this case. Args: boundary: A list of Point2D objects for the outer boundary of the polygon inside of which all of the holes are contained. holes: A list of lists with one list for each hole in the shape. Each hole should be a list of at least 3 Point2D objects. """ # check the initial direction of the boundary vertices bound_direction = cls._are_clockwise(boundary) # format the coordinates for input to the earcut methods vert_coords, hole_indices = [], None for pt in boundary: vert_coords.append(pt.x) vert_coords.append(pt.y) hole_indices = [] for hole in holes: hole_indices.append(int(len(vert_coords) / 2)) for pt in hole: vert_coords.append(pt.x) vert_coords.append(pt.y) # eliminate the holes within the list outer_len = hole_indices[0] * 2 outer_node = _linked_list(vert_coords, 0, outer_len, 2, True) outer_node = _eliminate_holes(vert_coords, hole_indices, outer_node, 2) # loop through the chain of nodes and translate them to Point2D start_i = outer_node.i vertices = [Point2D(outer_node.x, outer_node.y)] node = outer_node.next node_counter, orig_start_i = 0, 0 while node.i != start_i: vertices.append(Point2D(node.x, node.y)) node_counter += 1 if node.i == 0: orig_start_i = node_counter node = node.next # ensure that the starting vertex is the same as the input boundary vertices = vertices[orig_start_i:] + vertices[:orig_start_i] vertices[0] = boundary[0] # this avoids issues of floating point tolerance # return the polygon with some properties set based on what we know _new_poly = cls(vertices) _new_poly._is_clockwise = bound_direction _new_poly._is_convex = False _new_poly._is_self_intersecting = False return _new_poly
@property def vertices(self): """Tuple of all vertices in this geometry.""" return self._vertices @property def segments(self): """Tuple of all line segments in the polygon.""" if self._segments is None: _segs = self._segments_from_vertices(self.vertices) self._segments = tuple(_segs) return self._segments @property def inside_angles(self): """Tuple of angles in radians for the interior angles of the polygon. These are aligned with the vertices such that the first angle corresponds to the inside angle at the first vertex, the second at the second vertex, and so on. """ if self._inside_angles is None: angles, is_clock = [], self.is_clockwise for i, pt in enumerate(self.vertices): v1 = self.vertices[i - 2] - self.vertices[i - 1] v2 = pt - self.vertices[i - 1] v_angle = v1.angle_counterclockwise(v2) if is_clock \ else v1.angle_clockwise(v2) angles.append(v_angle) angles.append(angles.pop(0)) # move the start angle to the end self._inside_angles = tuple(angles) return self._inside_angles @property def outside_angles(self): """Tuple of angles in radians for the exterior angles of the polygon. These are aligned with the vertices such that the first angle corresponds to the inside angle at the first vertex, the second at the second vertex, and so on. """ if self._outside_angles is None: pi2 = math.pi * 2 self._outside_angles = tuple(pi2 - a for a in self.inside_angles) return self._outside_angles @property def perimeter(self): """The perimeter of the polygon.""" if self._perimeter is None: self._perimeter = sum([seg.length for seg in self.segments]) return self._perimeter @property def area(self): """The area of the polygon.""" if self._area is None: _a = 0 for i, pt in enumerate(self.vertices): _a += self.vertices[i - 1].x * pt.y - self.vertices[i - 1].y * pt.x self._area = _a / 2 return abs(self._area) @property def is_clockwise(self): """Boolean for whether the polygon vertices are in clockwise order.""" if self._is_clockwise is None: if self._area is None: self.area self._is_clockwise = self._area < 0 return self._is_clockwise @property def is_convex(self): """Boolean noting whether the polygon is convex (True) or non-convex (False).""" if self._is_convex is None: self._is_convex = True if len(self.vertices) == 3: pass else: _segs = self.segments if self.is_clockwise: for i, _s in enumerate(_segs): if _segs[i - 1].v.determinant(_s.v) > 0: # counterclockwise turn self._is_convex = False break else: for i, _s in enumerate(_segs): if _segs[i - 1].v.determinant(_s.v) < 0: # clockwise turn self._is_convex = False break return self._is_convex @property def is_self_intersecting(self): """Boolean noting whether the polygon has self-intersecting edges. Note that this property is relatively computationally intense to obtain compared to properties like area and is_convex. Also, most CAD programs forbid geometry with self-intersecting edges. So it is recommended that this property only be used in quality control scripts where the origin of the geometry is unknown. """ if self._is_self_intersecting is None: self._is_self_intersecting = False _segs = self.segments for i, _s in enumerate(_segs[1: len(_segs) - 1]): _skip = (i, i + 1, i + 2) _other_segs = [x for j, x in enumerate(_segs) if j not in _skip] for _oth_s in _other_segs: if _s.intersect_line_ray(_oth_s) is not None: # intersection! self._is_self_intersecting = True break if self._is_self_intersecting is True: break return self._is_self_intersecting @property def self_intersection_points(self): """A tuple of Point2Ds for the locations where the polygon intersects itself. This will be an empty tuple if the polygon is not self-intersecting and it is generally recommended that the Polygon2D.is_self_intersecting property be checked before using this property. """ if self.is_self_intersecting: int_pts = [] _segs = self.segments for i, _s in enumerate(_segs[1: len(_segs) - 1]): _skip = (i, i + 1, i + 2) _other_segs = [x for j, x in enumerate(_segs) if j not in _skip] 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) return tuple(int_pts) return () @property def is_valid(self): """Boolean noting whether the polygon is valid (having a non-zero area). Note that polygons are still considered valid if they have self-intersecting edges, or duplicate/colinear vertices. The s_self_intersecting property identifies self-intersecting edges, and the remove_colinear_vertices method will remove duplicate/colinear vertices. """ return not self.area == 0
[docs] def is_equivalent(self, other, tolerance): """Boolean for equivalence between this polygon and another (within tolerance). The order of the polygon vertices do not have to start from the same vertex for equivalence to be true, but must be in the same counterclockwise or clockwise order. Args: other: Polygon2D for comparison. tolerance: float representing point equivalence. Returns: True if equivalent else False """ # Check number of points if len(self.vertices) != len(other.vertices): return False vertices = self.vertices # Check order if not vertices[0].is_equivalent(other.vertices[0], tolerance): self_idx = None other_pt = other.vertices[0] for i, pt in enumerate(self.vertices): if pt.is_equivalent(other_pt, tolerance): self_idx = i break if self_idx is None: return False # Re-order polygon vertices to match other vertices = vertices[self_idx:] + vertices[:self_idx] is_equivalent = True for pt, other_pt in zip(vertices[1:], other.vertices[1:]): is_equivalent = is_equivalent and pt.is_equivalent(other_pt, tolerance) return is_equivalent
[docs] def is_rectangle(self, angle_tolerance): """Test whether this Polygon2D is a rectangle given an angle tolerance. Note that this method will return False if the Polygon2D does not have four vertices, even if they are duplicated or colinear. Args: angle_tolerance: The max angle in radians that the corners of the rectangle can differ from a right angle before it is not considered a rectangle. """ if len(self.vertices) != 4: return False min_ang = (math.pi / 2) - angle_tolerance max_ang = (math.pi / 2) + angle_tolerance for i, pt in enumerate(self.vertices): v1 = self.vertices[i - 2] - self.vertices[i - 1] v2 = pt - self.vertices[i - 1] v_angle = v1.angle(v2) if v_angle < min_ang or v_angle > max_ang: return False return True
[docs] def rectangular_approximation(self): """Get a rectangular Polygon2D with the same area and aspect ratio as this one. This is useful when an interface requires a rectangular input but the user-defined geometry can be any shape. The resulting rectangle will share the same center as this one. """ b_rect_len = self.max.x - self.min.x b_rect_hgt = self.max.y - self.min.y aspect_ratio = b_rect_len / b_rect_hgt final_hgt = math.sqrt(self.area / aspect_ratio) final_len = self.area / final_hgt b_pt = Point2D(self.center.x - (final_len / 2), self.center.y - (final_hgt / 2)) return Polygon2D.from_rectangle(b_pt, Vector2D(0, 1), final_len, final_hgt)
[docs] def pole_of_inaccessibility(self, tolerance): """Get the pole of inaccessibility for the polygon. The pole of inaccessibility is the most distant internal point from the polygon outline. It is not to be confused with the centroid, which represents the "center of mass" of the polygon and may be outside of the polygon if the shape is concave. The poly of inaccessibility is useful for optimal placement of a text label on a polygon. The algorithm here is a port of the polylabel library from MapBox assembled by Michal Hatak. (https://github.com/Twista/python-polylabel). Args: tolerance: The precision to which the pole of inaccessibility will be computed. """ # compute the cell size from the bounding rectangle min_x, min_y = self.min.x, self.min.y max_x, max_y = self.max.x, self.max.y width = max_x - min_x height = max_y - min_y cell_size = min(width, height) h = cell_size / 2.0 max_dim = max(width, height) if cell_size == 0 or self.area < max_dim * tolerance: # degenerate polygon; just return the center return self.center # get an array representation of the polygon and set up the priority queue _polygon = tuple(pt.to_array() for pt in self.vertices) cell_queue = PriorityQueue() # cover polygon with initial cells x = min_x while x < max_x: y = min_y while y < max_y: c = _Cell(x + h, y + h, h, _polygon) y += cell_size cell_queue.put((-c.max, time.time(), c)) x += cell_size best_cell = self._get_centroid_cell(_polygon) bbox_cell = _Cell(min_x + width / 2, min_y + height / 2, 0, _polygon) if bbox_cell.d > best_cell.d: best_cell = bbox_cell # recursively iterate until we find the pole num_of_probes = cell_queue.qsize() while not cell_queue.empty(): _, __, cell = cell_queue.get() if cell.d > best_cell.d: best_cell = cell if cell.max - best_cell.d <= tolerance: continue h = cell.h / 2 c = _Cell(cell.x - h, cell.y - h, h, _polygon) cell_queue.put((-c.max, time.time(), c)) c = _Cell(cell.x + h, cell.y - h, h, _polygon) cell_queue.put((-c.max, time.time(), c)) c = _Cell(cell.x - h, cell.y + h, h, _polygon) cell_queue.put((-c.max, time.time(), c)) c = _Cell(cell.x + h, cell.y + h, h, _polygon) cell_queue.put((-c.max, time.time(), c)) num_of_probes += 4 return Point2D(best_cell.x, best_cell.y)
[docs] def remove_duplicate_vertices(self, tolerance): """Get a version of this polygon without duplicate vertices. Args: tolerance: The minimum distance between a two vertices at which they are considered co-located or duplicated. """ new_vertices = tuple( pt for i, pt in enumerate(self._vertices) if not pt.is_equivalent(self._vertices[i - 1], tolerance)) return Polygon2D(new_vertices)
[docs] def remove_colinear_vertices(self, tolerance): """Get a version of this polygon without colinear or duplicate vertices. Args: tolerance: The minimum distance that a vertex can be from a line before it is considered colinear. """ new_vertices = [] # list to hold the new vertices skip = 0 # track the number of vertices being skipped/removed first_skip, is_first, = 0, True # track the number skipped from first vertex # loop through vertices and remove all cases of colinear verts for i, _v in enumerate(self.vertices): _v2, _v1 = self[i - 2 - skip], self[i - 1] _a = _v2.determinant(_v1) + _v1.determinant(_v) + _v.determinant(_v2) b_dist = _v.distance_to_point(_v2) b_dist = tolerance if b_dist < tolerance else b_dist tri_tol = (b_dist * tolerance) / 2 # area of triangle with tolerance height if abs(_a) >= tri_tol: # triangle area > tolerance; not colinear new_vertices.append(self[i - 1]) skip = 0 if is_first: is_first = False first_skip = i - 1 else: # colinear point to be removed skip += 1 # catch case of last few vertices being equal but distinct from first point if skip != 0 and first_skip != -1: assert abs(-2 - skip) <= len(self), \ 'There must be at least 3 vertices for a Polygon2D.' _v2, _v1, _v = self[-2 - skip], self[-1], self[first_skip] _a = _v2.determinant(_v1) + _v1.determinant(_v) + _v.determinant(_v2) b_dist = _v.distance_to_point(_v2) b_dist = tolerance if b_dist < tolerance else b_dist tri_tol = (b_dist * tolerance) / 2 # area of triangle with tolerance height if abs(_a) >= tri_tol: # triangle area > area tolerance; not colinear new_vertices.append(_v1) return Polygon2D(new_vertices)
[docs] 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
[docs] 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))) self._transfer_properties(_new_poly) if self._is_clockwise is not None: _new_poly._is_clockwise = not self._is_clockwise return _new_poly
[docs] def move(self, moving_vec): """Get a polygon that has been moved along a vector. Args: moving_vec: A Vector2D with the direction and distance to move the polygon. """ _new_poly = Polygon2D(tuple(pt.move(moving_vec) for pt in self.vertices)) self._transfer_properties(_new_poly) return _new_poly
[docs] def rotate(self, angle, origin): """Get a polygon that is rotated counterclockwise by a certain angle. Args: angle: An angle for rotation in radians. origin: A Point2D for the origin around which the point will be rotated. """ _new_poly = Polygon2D(tuple(pt.rotate(angle, origin) for pt in self.vertices)) self._transfer_properties(_new_poly) return _new_poly
[docs] def reflect(self, normal, origin): """Get a polygon reflected across a plane with the input normal and origin. Args: normal: A Vector2D representing the normal vector for the plane across which the polygon will be reflected. THIS VECTOR MUST BE NORMALIZED. origin: A Point2D representing the origin from which to reflect. """ _new_poly = Polygon2D(tuple(pt.reflect(normal, origin) for pt in self.vertices)) self._transfer_properties(_new_poly) if self._is_clockwise is not None: _new_poly._is_clockwise = not self._is_clockwise return _new_poly
[docs] def scale(self, factor, origin=None): """Scale a polygon by a factor from an origin point. Args: factor: A number representing how much the polygon should be scaled. origin: A Point2D representing the origin from which to scale. If None, it will be scaled from the World origin (0, 0). """ if origin is None: return Polygon2D(tuple( Point2D(pt.x * factor, pt.y * factor) for pt in self.vertices)) else: return Polygon2D(tuple(pt.scale(factor, origin) for pt in self.vertices))
[docs] def offset(self, distance, check_intersection=False): """Offset the polygon by a given distance inwards or outwards. Note that the resulting shape may be self-intersecting if the distance is large enough and the is_self_intersecting property may be used to identify these shapes. Args: distance: The distance inwards that the polygon will be offset. Positive values will always be offset inwards while negative ones will be offset outwards. check_intersection: A boolean to note whether the resulting operation should be checked for self intersection and, if so, None will be returned instead of the self-intersecting polygon. """ # make sure the offset is not zero if distance == 0: return self # loop through the vertices and get the new offset vectors init_verts = self._vertices if not self.is_clockwise \ else list(reversed(self._vertices)) init_verts = [pt for i, pt in enumerate(init_verts) if pt != init_verts[i - 1]] move_vecs, max_i = [], len(init_verts) - 1 for i, pt in enumerate(init_verts): v1 = init_verts[i - 1] - pt end_i = i + 1 if i != max_i else 0 v2 = init_verts[end_i] - pt if not self.is_clockwise: ang = v1.angle_clockwise(v2) / 2 if ang == 0: ang = math.pi / 2 m_vec = v1.rotate(-ang).normalize() m_dist = distance / math.sin(ang) else: ang = v1.angle_counterclockwise(v2) / 2 if ang == 0: ang = math.pi / 2 m_vec = v1.rotate(ang).normalize() m_dist = -distance / math.sin(ang) m_vec = m_vec * m_dist move_vecs.append(m_vec) # move the vertices by the offset to create the new Polygon2D new_pts = tuple(pt.move(m_vec) for pt, m_vec in zip(init_verts, move_vecs)) if self.is_clockwise: new_pts = tuple(reversed(new_pts)) new_poly = Polygon2D(new_pts) # check for self intersection between the moving vectors if requested if check_intersection: poly_segs = new_poly.segments if not self.is_clockwise else \ Polygon2D(tuple(reversed(new_pts))).segments _segs = [LineSegment2D(p, v) for p, v in zip(init_verts, move_vecs)] _skip = (0, len(_segs) - 1) _other_segs = [x for j, x in enumerate(poly_segs) if j not in _skip] for _oth_s in _other_segs: if _segs[0].intersect_line_ray(_oth_s) is not None: # intersection! return None for i, _s in enumerate(_segs[1:]): _skip = (i, i + 1) _other_segs = [x for j, x in enumerate(poly_segs) if j not in _skip] for _oth_s in _other_segs: if _s.intersect_line_ray(_oth_s) is not None: # intersection! return None return new_poly
[docs] def intersect_line_ray(self, line_ray): """Get the intersections between this polygon and a Ray2D or LineSegment2D. Args: line_ray: A LineSegment2D or Ray2D or to intersect. Returns: A list with Point2D objects for the intersections. List will be empty if no intersection exists. """ intersections = [] for _s in self.segments: inters = intersect_line2d(_s, line_ray) if inters is not None: intersections.append(inters) return intersections
[docs] def intersect_line_infinite(self, ray): """Get the intersections between this polygon and a Ray2D extended infinitely. Args: ray: A Ray2D or to intersect. This will be extended in both directions infinitely for the intersection. Returns: A list with Point2D objects for the intersections. List will be empty if no intersection exists. """ intersections = [] for _s in self.segments: inters = intersect_line2d_infinite(_s, ray) if inters is not None: intersections.append(inters) return intersections
[docs] def point_relationship(self, point, tolerance): """Test whether a Point2D lies inside, outside or on the boundary of the polygon. Compared to other methods like is_point_inside this method is slow. However, it covers all edge cases, including the literal edge of the polygon. Args: point: A Point2D for which the relationship to the polygon will be tested. tolerance: The minimum distance from the edge at which a point is considered to lie on the edge. Returns: An integer denoting the relationship of the point. This will be one of the following: * -1 = Outside polygon * 0 = On the edge of the polygon * +1 = Inside polygon """ if self.is_point_on_edge(point, tolerance): return 0 if self.is_point_inside_bound_rect(point): return 1 return -1
[docs] def is_point_on_edge(self, point, tolerance): """Test whether a Point2D lies on the boundary edges of the polygon. Args: point: A Point2D for which the edge relationship will be tested. tolerance: The minimum distance from the edge at which a point is considered to lie on the edge. Returns: A boolean denoting whether the point lies on the polygon edges (True) or not on the edges (False). """ for _s in self.segments: close_pt = closest_point2d_on_line2d(point, _s) if point.distance_to_point(close_pt) <= tolerance: return True return False
[docs] def is_point_inside_check(self, point): """Test whether a Point2D lies inside the polygon with checks for fringe cases. This method uses the same calculation as the the `is_point_inside` method but it includes additional checks for the fringe cases noted in the `is_point_inside` description. Using this method means that it will always yield the right result for all convex polygons and concave polygons with one concave turn (provided that they do not have colinear vertices). This is suitable for nearly all practical purposes and the only cases that could yield an incorrect result are when a point is co-linear with two or more polygon edges along the X vector like so: .. code-block:: shell _____ _____ _____ | . |___| |___| | |_________________________| While this method covers most fringe cases, it will not test for whether a point lies perfectly on the edge of the polygon so it assesses whether a point lies inside the polygon up to Python floating point tolerance (16 digits). If distinguishing edge conditions from inside/ outside is important, the `point_relationship` method should be used. Args: point: A Point2D for which the inside/ outside relationship will be tested. Returns: A boolean denoting whether the point lies inside (True) or outside (False). """ def non_duplicate_intersect(test_ray): inters = [] n_int = 0 for _s in self.segments: inter = intersect_line2d(_s, test_ray) if inter is not None: try: if inter != inters[-1]: # ensure intersection is not duplicated n_int += 1 inters.append(inter) except IndexError: n_int += 1 inters.append(inter) return n_int, inters n_int, inters = non_duplicate_intersect(Ray2D(point, Vector2D(1, 0))) # check that intersections do not form a polygon segment co-linear with test_ray if self.is_convex is False and n_int == 2: for _s in self.segments: if _s.p1 == inters[0] and _s.p2 == inters[1]: return self.is_point_inside(point, Vector2D(0, 1)) if n_int % 2 == 0: return False n_int, inters = non_duplicate_intersect(Ray2D(point, Vector2D(0, 1))) # check that intersections do not form a polygon segment co-linear with test_ray if self.is_convex is False and n_int == 2: for _s in self.segments: if _s.p1 == inters[0] and _s.p2 == inters[1]: return self.is_point_inside(point, Vector2D(1, 0)) if n_int % 2 == 0: return False return True
[docs] def is_point_inside(self, point, test_vector=Vector2D(1, 0.00001)): """Test whether a Point2D lies inside or outside the polygon. This method is the fastest way to tell if a point is inside a polygon when the given point lies inside the boundary rectangle of the polygon. However, while this method gives the correct result in 99.9% of cases, there are a few fringe cases where it will not give the correct result. Specifically these are: .. code-block:: shell 1 - When the test_ray intersects perfectly with a polygon vertex. For example, this case with an X-unit (1, 0) test_vector: _____________ | . | | / |___________/ 2 - When there are two polygon vertices that are colinear with the point along the test_ray. For example, this case with an X-unit test_vector: _____ | . |____ |__________| Use the `is_point_inside_check` method if a result that covers these fringe cases is needed. Oftentimes, it is more practical to use a test_vector with a low probability of encountering the fringe cases than to use the (much longer) `is_point_inside_check` method. Args: point: A Point2D for which the inside/outside relationship will be tested. test_vector: Optional vector to set the direction in which intersections with the polygon edges will be evaluated to determine if the point is inside. Default is a slight variation of the X-unit vector with a low probability of encountering the unsupported fringe cases. Returns: A boolean denoting whether the point lies inside (True) or outside (False). """ test_ray = Ray2D(point, test_vector) n_int = 0 for _s in self.segments: if does_intersection_exist_line2d(_s, test_ray): n_int += 1 if n_int % 2 == 0: return False return True
[docs] def is_point_inside_bound_rect(self, point, test_vector=Vector2D(1, 0.00001)): """Test whether a Point2D lies roughly inside or outside the polygon. This function is virtually identical to the `is_point_inside` method but will first do a check to see if the point lies inside the polygon bounding rectangle. As such, it is a faster approach when one expects many of tested points to lie far away from the polygon. Args: point: A Point2D for which the inside/ outside relationship will be tested. test_vector: Optional vector to set the direction in which intersections with the polygon edges will be evaluated to determine if the point is inside. Default is the X unit vector. Returns: A boolean denoting whether the point lies inside (True) or outside (False). """ min = self.min max = self.max if point.x < min.x or point.y < min.y or point.x > max.x or point.y > max.y: return False return self.is_point_inside(point, test_vector)
[docs] def is_polygon_inside(self, polygon): """Test whether another Polygon2D lies completely inside this polygon. Args: polygon: A Polygon2D to test whether it is completely inside this one. Returns: A boolean denoting whether the polygon lies inside (True) or not (False). """ # if the first polygon vertex lies outside, we know it is not inside. if not self.is_point_inside_bound_rect(polygon[0]): return False # if one of the edges intersects, we know it has crossed outside. for seg in self.segments: for _s in polygon.segments: if does_intersection_exist_line2d(seg, _s): return False return True
[docs] def is_polygon_outside(self, polygon): """Test whether another Polygon2D lies completely outside this polygon. Args: polygon: A Polygon2D to test whether it is completely outside this one. Returns: A boolean denoting whether the polygon lies outside (True) or not (False). """ # if the first polygon vertex lies inside, we know it is not outside. if self.is_point_inside_bound_rect(polygon[0]): return False # if one of the edges intersects, we know it has crossed inside. for seg in self.segments: for _s in polygon.segments: if does_intersection_exist_line2d(seg, _s): return False return True
[docs] def does_polygon_touch(self, polygon, tolerance): """Test whether another Polygon2D touches, overlaps or is inside this polygon. Args: polygon: A Polygon2D to test whether it touches this polygon. tolerance: The minimum distance from an edge at which a point is considered to touch the edge. Returns: A boolean denoting whether the polygon touches (True) or not (False). """ # perform a bounding rectangle check to see if the polygons cannot overlap if not Polygon2D.overlapping_bounding_rect(self, polygon, tolerance): return False # first evaluate the point relationships pt_rels1 = [self.point_relationship(pt, tolerance) for pt in polygon] if 0 in pt_rels1 or 1 in pt_rels1: return True # definitely touching polygons pt_rels2 = [polygon.point_relationship(pt, tolerance) for pt in self] if 0 in pt_rels2 or 1 in pt_rels2: return True # definitely touching polygons # if any of the segments intersect the other polygon, there is overlap for seg in self.segments: for _s in polygon.segments: if does_intersection_exist_line2d(seg, _s): return True # we can reliably say that the polygons do not touch return False
[docs] def polygon_relationship(self, polygon, tolerance): """Test whether another Polygon2D lies inside, outside or overlaps this one. This method is not usually the fastest for understanding the relationship between polygons but it accurately accounts for tolerance such that the case of the two polygons sharing edges will not determine the outcome. Only when the polygon has vertices that are truly outside this polygon within the tolerance will the relationship become outside (or intersecting if one of the vertices is already inside within the tolerance). In the case of the input polygon being identical to the current polygon, the relationship will be Inside. Args: polygon: A Polygon2D for which the relationship to the current polygon will be tested. tolerance: The minimum distance from the edge at which a point is considered to lie on the edge. Returns: An integer denoting the relationship of the polygon. This will be one of the following: * -1 = Outside this polygon * 0 = Overlaps (intersects) this polygon * +1 = Inside this polygon """ # perform a bounding rectangle check to see if the polygons cannot overlap if not Polygon2D.overlapping_bounding_rect(self, polygon, tolerance): return -1 # first evaluate the point relationships to rule out the inside case pt_rels1 = [self.point_relationship(pt, tolerance) for pt in polygon] pt_rels2 = [polygon.point_relationship(pt, tolerance) for pt in self] if all(r1 >= 0 for r1 in pt_rels1) and all(r2 <= 0 for r2 in pt_rels2): poi = polygon._point_in_polygon(tolerance) if self.is_point_inside(poi) == 1: return 1 # definitely inside the polygon if 1 in pt_rels1 or 1 in pt_rels2: return 0 # definitely overlap in the polygons if all(r2 == 0 for r2 in pt_rels2): poi = self._point_in_polygon(tolerance) if polygon.is_point_inside(poi) == 1: return 0 # offset one of the polygons inward by the tolerance off_poly = polygon.offset(tolerance) # if any of the offset segments intersect the other polygon, there is overlap for seg in self.segments: for _s in off_poly.segments: if does_intersection_exist_line2d(seg, _s): return 0 # we can reliably say that the polygons have nothing to do with one another return -1
[docs] def distance_to_point(self, point): """Get the minimum distance between this shape and a point. Points that are inside the Polygon2D will return a distance of zero. If the distance of an interior point to an edge is needed, the distance_from_edge_to_point method should be used. Args: point: A Point2D object to which the minimum distance will be computed. Returns: The distance to the input point. Will be zero if the point is inside the Polygon2D. """ if self.is_point_inside_bound_rect(point): return 0 return min(seg.distance_to_point(point) for seg in self.segments)
[docs] def distance_from_edge_to_point(self, point): """Get the minimum distance between the edge of this shape and the input point. Args: point: A Point2D object to which the minimum distance will be computed. Returns: The distance to the input point from the nearest edge. """ return min(seg.distance_to_point(point) for seg in self.segments)
[docs] def snap_to_polygon(self, polygon, tolerance): """Snap another Polygon2D to this one for differences smaller than the tolerance. This is useful to run before performing operations where small tolerance differences are likely to cause issues, such as in boolean operations. Args: polygon: A Polygon2D which will be snapped to the current polygon. tolerance: The minimum distance at which points will be snapped. Returns: A version of the polygon that is snapped to this Polygon2D. """ new_verts = [] for pt in polygon.vertices: # first check if the point can be snapped to a vertex for s_pt in self.vertices: if pt.is_equivalent(s_pt, tolerance): new_verts.append(s_pt) break else: # check if the point can be snapped to a segment for seg in self.segments: s_pt = seg.closest_point(pt) if s_pt.distance_to_point(pt) <= tolerance: new_verts.append(s_pt) break else: # point could not be snapped new_verts.append(pt) return Polygon2D(new_verts)
[docs] def snap_to_grid(self, grid_increment): """Snap this polygon's vertices to the nearest grid node defined by an increment. Args: grid_increment: A positive number for dimension of each grid cell. This typically should be equal to the tolerance or larger but should not be larger than the smallest detail of the polygon that you wish to resolve. Returns: A version of this polygon that is snapped to the grid. """ new_verts = [] for pt in self.vertices: new_x = grid_increment * round(pt.x / grid_increment) new_y = grid_increment * round(pt.y / grid_increment) new_verts.append(Point2D(new_x, new_y)) return Polygon2D(new_verts)
[docs] def to_dict(self): """Get Polygon2D as a dictionary.""" return {'type': 'Polygon2D', 'vertices': [pt.to_array() for pt in self.vertices]}
[docs] def to_array(self): """Get a tuple of tuples where each sub-tuple represents a Point2D vertex.""" return tuple(pt.to_array() for pt in self.vertices)
def _to_bool_poly(self): """Translate the Polygon2D to a BooleanPolygon.""" b_pts = (pb.BooleanPoint(pt.x, pt.y) for pt in self.vertices) return pb.BooleanPolygon([b_pts]) def _to_snapped_bool_poly(self, snap_ref_polygon, tolerance): """Snap a Polygon2D to this one and translate it to a BooleanPolygon. This is necessary to ensure that boolean operations will succeed between two polygons. """ new_poly = snap_ref_polygon.snap_to_polygon(self, tolerance) return new_poly._to_bool_poly() @staticmethod def _from_bool_poly(bool_polygon, tolerance=None): """Get a list of Polygon2D from a BooleanPolygon object. Args: bool_polygon: A BooleanPolygon to be interpreted to a list of Polygon2D. tolerance: An optional tolerance value to be used to remove degenerate objects from the result. If None, the result may contain degenerate objects. """ polys = [] for new_poly in bool_polygon.regions: if len(new_poly) > 2: poly = Polygon2D(tuple(Point2D(pt.x, pt.y) for pt in new_poly)) if tolerance is not None: try: poly = poly.remove_duplicate_vertices(tolerance) polys.append(poly) except AssertionError: pass # degenerate polygon to be removed else: polys.append(poly) return polys
[docs] def boolean_union(self, polygon, tolerance): """Get a list of Polygon2D for the union of this Polygon and another. Note that the result will not differentiate hole polygons from boundary polygons and so it may be desirable to use the Polygon2D.is_polygon_inside method to distinguish whether a given polygon in the result represents a hole in another polygon in the result. Also note that this method will return the original polygons when there is no overlap in the two. Args: polygon: A Polygon2D for which the union with the current polygon will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the union of the two polygons. """ result = pb.union( self._to_bool_poly(), polygon._to_snapped_bool_poly(self, tolerance), tolerance / 1000 ) return Polygon2D._from_bool_poly(result, tolerance)
[docs] def boolean_intersect(self, polygon, tolerance): """Get a list of Polygon2D for the intersection of this Polygon and another. Args: polygon: A Polygon2D for which the intersection with the current polygon will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the intersection of the two polygons. Will be an empty list if no overlap exists between the polygons. """ result = pb.intersect( self._to_bool_poly(), polygon._to_snapped_bool_poly(self, tolerance), tolerance / 1000 ) return Polygon2D._from_bool_poly(result, tolerance)
[docs] def boolean_difference(self, polygon, tolerance): """Get a list of Polygon2D for the subtraction of another polygon from this one. Args: polygon: A Polygon2D for which the difference with the current polygon will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the difference of the two polygons. Will be an empty list if subtracting the polygons results in the complete elimination of this polygon. Will be the original polygon when there is no overlap between the polygons. """ result = pb.difference( self._to_bool_poly(), polygon._to_snapped_bool_poly(self, tolerance), tolerance / 1000 ) return Polygon2D._from_bool_poly(result, tolerance)
[docs] def boolean_xor(self, polygon, tolerance): """Get Polygon2D list for the exclusive disjunction of this polygon and another. Note that this method is prone to merging holes that may exist in the result into the boundary to create a single list of joined vertices, which may not always be desirable. In this case, it may be desirable to do two separate boolean_difference calculations instead. Also note that, when the result includes separate polygons for holes, it will not differentiate hole polygons from boundary polygons and so it may be desirable to use the Polygon2D.is_polygon_inside method to distinguish whether a given polygon in the result represents a hole within another polygon in the result. Args: polygon: A Polygon2D for which the exclusive disjunction with the current polygon will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the exclusive disjunction of the two polygons. Will be the original polygons when there is no overlap in the two. """ result = pb.xor( self._to_bool_poly(), polygon._to_snapped_bool_poly(self, tolerance), tolerance / 1000 ) return Polygon2D._from_bool_poly(result, tolerance)
[docs] @staticmethod def snap_polygons(polygons, tolerance): """Snap several Polygon2D to each other if they differ less than the tolerance. This is useful to run before performing operations where small tolerance differences are likely to cause issues, such as in boolean operations. Args: polygons: A list of Polygon2D, which will be snapped to each other. tolerance: The minimum distance at which points will be snapped. Returns: A list of the input polygon2D that have been snapped to one another. """ new_polygons = list(polygons) for i, poly_1 in enumerate(new_polygons): try: for j, poly_2 in enumerate(new_polygons[i + 1:]): new_polygons[i + j + 1] = poly_1.snap_to_polygon(poly_2, tolerance) except IndexError: pass # we have reached the end of the list of polygons return new_polygons
[docs] @staticmethod def boolean_union_all(polygons, tolerance): """Get a list of Polygon2D for the union of several Polygon2D. Using this method is more computationally efficient than calling the Polygon2D.boolean_union() method multiple times as this method will only compute the intersection of the segments once. Note that the result will not differentiate hole polygons from boundary polygons and so it may be desirable to use the Polygon2D.is_polygon_inside method to distinguish whether a given polygon in the result represents a hole in another polygon in the result. Also note that this method will return the original polygons when there is no overlap in the two. Args: polygons: An array of Polygon2D for which the union will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the union of all the polygons. """ polygons = Polygon2D.snap_polygons(polygons, tolerance) bool_polys = [poly._to_bool_poly() for poly in polygons] result = pb.union_all(bool_polys, tolerance / 1000) return Polygon2D._from_bool_poly(result, tolerance)
[docs] @staticmethod def boolean_intersect_all(polygons, tolerance): """Get a list of Polygon2D for the intersection of several Polygon2D. Using this method is more computationally efficient than calling the Polygon2D.boolean_intersect() method multiple times as this method will only compute the intersection of the segments once. Note that the result will not differentiate hole polygons from boundary polygons and so it may be desirable to use the Polygon2D.is_polygon_inside method to distinguish whether a given polygon in the result represents a hole in another polygon in the result. Also note that this method will return the original polygons when there is no overlap in the two. Args: polygons: An array of Polygon2D for which the intersection will be computed. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A list of Polygon2D representing the intersection of all the polygons. Will be an empty list if no overlap exists between the polygons. """ polygons = Polygon2D.snap_polygons(polygons, tolerance) bool_polys = [poly._to_bool_poly() for poly in polygons] result = pb.intersect_all(bool_polys, tolerance / 1000) return Polygon2D._from_bool_poly(result, tolerance)
[docs] @staticmethod def boolean_split(polygon1, polygon2, tolerance): """Split two Polygon2D with one another to get the intersection and difference. Using this method is more computationally efficient than calling the Polygon2D.intersect() and Polygon2D.difference() methods individually as this method will only compute the intersection of the segments once. Note that the result will not differentiate hole polygons from boundary polygons and so it may be desirable to use the Polygon2D.is_polygon_inside method to distinguish whether a given polygon in the result represents a hole in another polygon in the result. Args: polygon1: A Polygon2D for the first polygon that will be split with the second polygon. polygon2: A Polygon2D for the second polygon that will be split with the first polygon. tolerance: The minimum distance between points before they are considered distinct from one another. Returns: A tuple with three elements - intersection: A list of Polygon2D for the intersection of the two input polygons. - poly1_difference: A list of Polygon2D for the portion of polygon1 that does not overlap with polygon2. When combined with the intersection, this makes a split version of polygon1. - poly2_difference: A list of Polygon2D for the portion of polygon2 that does not overlap with polygon1. When combined with the intersection, this makes a split version of polygon2. """ int_result, poly1_result, poly2_result = pb.split( polygon1._to_bool_poly(), polygon2._to_snapped_bool_poly(polygon1, tolerance), tolerance / 1000 ) intersection = Polygon2D._from_bool_poly(int_result, tolerance) poly1_difference = Polygon2D._from_bool_poly(poly1_result, tolerance) poly2_difference = Polygon2D._from_bool_poly(poly2_result, tolerance) return intersection, poly1_difference, poly2_difference
[docs] @staticmethod def perimeter_core_by_offset(polygon, distance, holes=None): """Compute perimeter and core sub-polygons using a simple offset method. 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 simply return None. Args: polygon: A Polygon2D to split into perimeter and core sub-polygons. distance: Distance in model units to offset perimeter sub-polygon. holes: A list of Polygon2D objects representing holes in the polygon. (Default: None). Returns: A tuple with two items. * perimeter_sub_polys -- A list of perimeter sub-polygons as Polygon2D objects. Will be None if the offset distance is too deep. * core_sub_polys -- A list of core sub-polygons as Polygon2D objects. In the event of a core sub-polygon with a hole, a list with be returned with the first item being a boundary and successive items as hole polygons. Will be None if the offset distance is too deep. """ # extract the core polygon and make sure it doesn't intersect itself core_sub_poly = polygon.offset(distance, check_intersection=True) if core_sub_poly is None: return None, None # generate the perimeter polygons if holes is None: perimeter_sub_polys = [] for out_seg, in_seg in zip(polygon.segments, core_sub_poly.segments): pts = (out_seg.p1, out_seg.p2, in_seg.p2, in_seg.p1) perimeter_sub_polys.append(Polygon2D(pts)) return perimeter_sub_polys, [core_sub_poly] else: # offset all of the holes into the shape core_sub_polys = [core_sub_poly] for hole in holes: hole_sub_poly = hole.offset(-distance, check_intersection=True) if hole_sub_poly is None: return None, None core_sub_polys.append(hole_sub_poly) # check that None of the holes intersect one another for i, c_pgon in enumerate(core_sub_polys): for other_pgon in core_sub_polys[i + 1:]: if Polygon2D._do_polygons_intersect(c_pgon, other_pgon): return None, None # if nothing intersects, we can build the perimeter polygons out_polys = [polygon] + list(holes) perimeter_sub_polys = [] for p_count, (out_poly, in_poly) in enumerate(zip(out_polys, core_sub_polys)): for out_seg, in_seg in zip(out_poly.segments, in_poly.segments): if p_count == 0: pts = (out_seg.p1, out_seg.p2, in_seg.p2, in_seg.p1) else: if not out_poly.is_clockwise: pts = (out_seg.p1, in_seg.p1, in_seg.p2, out_seg.p2) else: (out_seg.p1, out_seg.p2, in_seg.p2, in_seg.p1) perimeter_sub_polys.append(Polygon2D(pts)) return perimeter_sub_polys, core_sub_polys
@staticmethod def _do_polygons_intersect(polygon_1, polygon_2): """Test to see if two polygons intersect one another.""" for seg in polygon_1.segments: for _s in polygon_2.segments: if does_intersection_exist_line2d(seg, _s): return True return False
[docs] @staticmethod def intersect_polygon_segments(polygon_list, tolerance): """Intersect the line segments of a Polygon2D array to ensure matching segments. Specifically, this method checks a list of polygons in a pairwise manner to see if one contains a vertex along an edge segment of the other within the given tolerance. If so, the method creates a co-located vertex at that point, partitioning the edge segment into two edge segments. Point ordering is reserved within each Polygon2D and the order of Polygon2Ds within the input polygon_list is also preserved. Args: polygon_list: List of Polygon2Ds which will have their segments intersected with one another. tolerance: Distance within which two points are considered to be co-located. Returns: The input list of Polygon2D objects with extra vertices inserted where necessary. """ for i in range(len(polygon_list) - 1): # No need for j to start at 0 since two polygons are passed # and they are compared against one other within intersect_segments. for j in range(i + 1, len(polygon_list)): polygon_list[i], polygon_list[j] = \ Polygon2D.intersect_segments(polygon_list[i], polygon_list[j], tolerance) return polygon_list
[docs] @staticmethod def intersect_segments(polygon1, polygon2, tolerance): """Intersect the line segments of two Polygon2Ds to ensure matching segments. Specifically, this method checks two adjacent polygons to see if one contains a vertex along an edge segment of the other within the given tolerance. If so, it creates a co-located vertex at that point, partitioning the edge segment into two edge segments. Point ordering is preserved. Args: polygon1: First polygon to check. polygon2: Second polygon to check. tolerance: Distance within which two points are considered to be co-located. Returns: Two polygon objects with extra vertices inserted if necessary. """ polygon1_updates = [] polygon2_updates = [] # bounding rectangle check if not Polygon2D.overlapping_bounding_rect(polygon1, polygon2, tolerance): return polygon1, polygon2 # no overlap # test if each point of polygon2 is within the tolerance distance of any segment # of polygon1. If so, add the closest point on the segment to the polygon1 # update list. And vice versa (testing polygon2 against polygon1). for i1, seg1 in enumerate(polygon1.segments): for i2, seg2 in enumerate(polygon2.segments): # Test polygon1 against polygon2 x = closest_point2d_on_line2d(seg2.p1, seg1) if all(p.distance_to_point(x) > tolerance for p in polygon1.vertices) \ and x.distance_to_point(seg2.p1) <= tolerance: polygon1_updates.append((i1, x)) # Test polygon2 against polygon1 y = closest_point2d_on_line2d(seg1.p1, seg2) if all(p.distance_to_point(y) > tolerance for p in polygon2.vertices) \ and y.distance_to_point(seg1.p1) <= tolerance: polygon2_updates.append((i2, y)) # apply any updates to polygon1 polygon1 = Polygon2D._insert_updates_in_order(polygon1, polygon1_updates) # Apply any updates to polygon2 polygon2 = Polygon2D._insert_updates_in_order(polygon2, polygon2_updates) return polygon1, polygon2
[docs] @staticmethod def overlapping_bounding_rect(polygon1, polygon2, tolerance): """Check if the bounding rectangles of two polygons overlap within a tolerance. This is particularly useful as a check before performing computationally intense processes between two polygons like intersection or boolean operations. Checking the overlap of the bounding boxes is extremely quick with this method's use of the the Separating Axis Theorem. Args: polygon1: The first polygon to check. polygon2: The second polygon to check. tolerance: Distance within which two points are considered to be co-located. """ # Bounding rectangle check using the Separating Axis Theorem polygon1_width = polygon1.max.x - polygon1.min.x polygon2_width = polygon2.max.x - polygon2.min.x dist_btwn_x = abs(polygon1.center.x - polygon2.center.x) x_gap_btwn_rect = dist_btwn_x - (0.5 * polygon1_width) - (0.5 * polygon2_width) polygon1_height = polygon1.max.y - polygon1.min.y polygon2_height = polygon2.max.y - polygon2.min.y dist_btwn_y = abs(polygon1.center.y - polygon2.center.y) y_gap_btwn_rect = dist_btwn_y - (0.5 * polygon1_height) - (0.5 * polygon2_height) if x_gap_btwn_rect > tolerance or y_gap_btwn_rect > tolerance: return False # no overlap return True # overlap exists
[docs] @staticmethod def group_by_overlap(polygons, tolerance): """Group Polygon2Ds that overlap one another within the tolerance. This is useful as a pre-step before running Polygon2D.boolean_union_all() in order to assess whether unionizing is necessary and to ensure that it is only performed among the necessary groups of polygons. This method will return the minimal number of overlapping polygon groups thanks to a recursive check of whether groups can be merged. Args: polygons: A list of Polygon2D to be grouped by their overlapping. tolerance: The minimum distance from the edge of a neighboring polygon at which a point is considered to overlap with that polygon. Returns: A list of lists where each sub-list represents a group of polygons that all overlap with one another. """ # sort the polygons by area to help larger ones grab smaller ones polygons = list(sorted(polygons, key=lambda x: x.area, reverse=True)) # loop through the polygons and check to see if it overlaps with the others grouped_polys = [[polygons[0]]] for poly in polygons[1:]: group_found = False for poly_group in grouped_polys: for oth_poly in poly_group: if poly.polygon_relationship(oth_poly, tolerance) >= 0: poly_group.append(poly) group_found = True break if group_found: break if not group_found: # the polygon does not overlap with any of the others grouped_polys.append([poly]) # make a new group for the polygon # if some groups were found, recursively merge groups together old_group_len = len(polygons) while len(grouped_polys) != old_group_len: new_groups, g_to_remove = grouped_polys[:], [] for i, group_1 in enumerate(grouped_polys): try: for j, group_2 in enumerate(grouped_polys[i + 1:]): if Polygon2D._groups_overlap(group_1, group_2, tolerance): new_groups[i] = new_groups[i] + group_2 g_to_remove.append(i + j + 1) except IndexError: pass # we have reached the end of the list of polygons if len(g_to_remove) != 0: g_to_remove = list(set(g_to_remove)) g_to_remove.sort() for ri in reversed(g_to_remove): new_groups.pop(ri) old_group_len = len(grouped_polys) grouped_polys = new_groups return grouped_polys
@staticmethod def _groups_overlap(group_1, group_2, tolerance): """Evaluate whether two groups of Polygons overlap with one another.""" for poly_1 in group_1: for poly_2 in group_2: if poly_1.polygon_relationship(poly_2, tolerance) >= 0: return True return False
[docs] @staticmethod def group_by_touching(polygons, tolerance): """Group Polygon2Ds that touch or overlap one another within the tolerance. This is useful to group geometries together before extracting a bounding rectangle or convex hull around multiple polygons. This method will return the minimal number of polygon groups thanks to a recursive check of whether groups can be merged. Args: polygons: A list of Polygon2D to be grouped by their touching. tolerance: The minimum distance from the edge of a neighboring polygon at which a point is considered to touch that polygon. Returns: A list of lists where each sub-list represents a group of polygons that all touch or overlap with one another. """ # sort the polygons by area to help larger ones grab smaller ones polygons = list(sorted(polygons, key=lambda x: x.area, reverse=True)) # loop through the polygons and check to see if it overlaps with the others grouped_polys = [[polygons[0]]] for poly in polygons[1:]: group_found = False for poly_group in grouped_polys: for oth_poly in poly_group: if poly.does_polygon_touch(oth_poly, tolerance): poly_group.append(poly) group_found = True break if group_found: break if not group_found: # the polygon does not touch any of the others grouped_polys.append([poly]) # make a new group for the polygon # if some groups were found, recursively merge groups together old_group_len = len(polygons) while len(grouped_polys) != old_group_len: new_groups, g_to_remove = grouped_polys[:], [] for i, group_1 in enumerate(grouped_polys): try: for j, group_2 in enumerate(grouped_polys[i + 1:]): if Polygon2D._groups_touch(group_1, group_2, tolerance): new_groups[i] = new_groups[i] + group_2 g_to_remove.append(i + j + 1) except IndexError: pass # we have reached the end of the list of polygons if len(g_to_remove) != 0: g_to_remove = list(set(g_to_remove)) g_to_remove.sort() for ri in reversed(g_to_remove): new_groups.pop(ri) old_group_len = len(grouped_polys) grouped_polys = new_groups return grouped_polys
@staticmethod def _groups_touch(group_1, group_2, tolerance): """Evaluate whether two groups of Polygons touch with one another.""" for poly_1 in group_1: for poly_2 in group_2: if poly_1.does_polygon_touch(poly_2, tolerance): return True return False
[docs] @staticmethod def group_boundaries_and_holes(polygons, tolerance): """Group polygons by whether they are contained within another. Args: polygons: A list of Polygon2Ds to be grouped according to boundary and holes within those boundaries. tolerance: The minimum distance between points at which they are considered distinct. Returns: A list of lists where each sub-list contains Polygon2Ds and represents one core geometry. Each sub-list will have has at least one Polygon2D and, in the event that a core geometry has holes, there will be multiple Polygon2Ds in the sub-list. The first item in the list will be the outer boundary of the geometry and successive items represent hole polygons contained within it. """ # first check to be sure that there isn't just one polygon if len(polygons) == 1: return [polygons] # sort the polygons by area and separate base polygon from the remaining polygons = sorted(polygons, key=lambda x: x.area, reverse=True) base_poly = polygons[0] remain_polys = list(polygons[1:]) # merge the smaller polygons into the larger polygons merged_polys = [] while len(remain_polys) > 0: grp_poly = Polygon2D._match_holes_to_poly(base_poly, remain_polys, tolerance) merged_polys.append(grp_poly) if len(remain_polys) > 1: base_poly = remain_polys[0] del remain_polys[0] elif len(remain_polys) == 1: # lone last Polygon2D merged_polys.append([remain_polys[0]]) del remain_polys[0] return merged_polys
@staticmethod def _match_holes_to_poly(base_poly, other_polys, tolerance): """Attempt to merge other polygons into a base polygon as holes. Args: base_poly: A Polygon2D to serve as the base. other_polys: A list of other Polygon2D objects to attempt to merge into the base_poly as a hole. This method will remove any Polygon2D that are successfully merged into the output from this list. tolerance: The minimum distance between points at which they are considered distinct. Returns: A list of Polygon2D where the first item is the base_poly and successive items represent holes in this geometry. """ holes = [] more_to_check = True while more_to_check: for i, r_poly in enumerate(other_polys): if base_poly.polygon_relationship(r_poly, tolerance) == 1: holes.append(r_poly) del other_polys[i] break else: more_to_check = False return [base_poly] + holes
[docs] @staticmethod def joined_intersected_boundary(polygons, tolerance): """Get the boundary around several Polygon2D that are touching one another. This method is faster and more reliable than the gap_crossing_boundary but requires that the Polygon2D be touching one another within the tolerance. Args: polygons: The polygons to be joined into a boundary. These polygons should have colinear vertices removed and they should not contain degenerate polygons at the tolerance. The remove_colinear_vertices method can be used to pre-process the input polygons to ensure they meet these criteria. tolerance: The tolerance at which the polygons are to be intersected and then joined to give a resulting boundary. Returns: A list of Polygon2D that represent the boundary around the input polygons. Note that some of these Polygon2D may represent 'holes' within others and it may be necessary to assess this when interpreting the result. """ # intersect the polygons with one another int_poly = Polygon2D.intersect_polygon_segments(polygons, tolerance) # get indices of all unique vertices across the polygons vertices = [] # collection of vertices as point objects poly_indices = [] # collection of polygon indices for loop in int_poly: ind = [] for v in loop: found = False for i, vert in enumerate(vertices): if v.is_equivalent(vert, tolerance): found = True ind.append(i) break if not found: # add new point vertices.append(v) ind.append(len(vertices) - 1) poly_indices.append(ind) # use the unique vertices to extract naked edges edge_i = [] edge_t = [] for poly_i in poly_indices: for i, vi in enumerate(poly_i): try: # this can get slow for large number of vertices ind = edge_i.index((vi, poly_i[i - 1])) edge_t[ind] += 1 except ValueError: # make sure reversed edge isn't there try: ind = edge_i.index((poly_i[i - 1], vi)) edge_t[ind] += 1 except ValueError: # add a new edge if poly_i[i - 1] != vi: # avoid cases of same start and end edge_i.append((poly_i[i - 1], vi)) edge_t.append(0) ext_edges = [] for i, et in enumerate(edge_t): if et == 0: edg_ind = edge_i[i] pts_2d = (vertices[edg_ind[0]], vertices[edg_ind[1]]) ext_edges.append(LineSegment2D.from_end_points(*pts_2d)) # join the naked edges into closed polygons outlines = Polyline2D.join_segments(ext_edges, tolerance) closed_polys = [] for bnd in outlines: if isinstance(bnd, Polyline2D) and bnd.is_closed(tolerance): closed_polys.append(bnd.to_polygon(tolerance)) return closed_polys
[docs] @staticmethod def gap_crossing_boundary(polygons, min_separation, tolerance): """Get the boundary around several Polygon2D, crossing gaps of min_separation. This method is less reliable than the joined_intersected_boundary because higher values of min_separation that are greater than the lengths of polygon segments can cause important details of the polygons to disappear. However, when used appropriately, it can provide a boundary that jumps across gaps to give resulting polygons that effectively bound all of the input polygons. Args: polygons: The polygons to be joined into a boundary. These polygons should have colinear vertices removed and they should not contain degenerate polygons at the tolerance. The remove_colinear_vertices method can be used to pre-process the input polygons to ensure they meet these criteria. min_separation: A number for the minimum distance between Polygon2D that is considered a meaningful separation. In other words, this is the maximum distance of the gap across tolerance: The maximum difference between coordinate values of two vertices at which they can be considered equivalent. Returns: A list of Polygon2D that represent the boundary around the input polygons. Note that some of these Polygon2D may represent 'holes' within others and it may be necessary to assess this when interpreting the result. """ # ensure that all of the input polygons are counterclockwise cclock_poly = [] for poly in polygons: if poly.is_clockwise: cclock_poly.append(poly.reverse()) else: cclock_poly.append(poly) # determine which Polygon2D segments are 'exterior' using the min_separation right_ang = -math.pi / 2 ext_segs = [] for i, poly in enumerate(cclock_poly): # remove any short segments rel_segs = [s for s in poly.segments if s.length > min_separation] # create min_separation line segments to be used to test intersection test_segs = [] for _s in rel_segs: d_vec = _s.v.rotate(right_ang).normalize() seg_pts = _s.subdivide(min_separation) if len(seg_pts) <= 3: seg_pts = [_s.midpoint] else: seg_pts = seg_pts[1:-1] spec_test_segs = [] for spt in seg_pts: m_pt = spt.move(d_vec * -tolerance) spec_test_segs.append(LineSegment2D(m_pt, d_vec * min_separation)) test_segs.append(spec_test_segs) # intersect the test line segments to asses which parts are exterior non_int_segs = [] other_poly = [p for j, p in enumerate(cclock_poly) if j != i] for j, (_s, int_lins) in enumerate(zip(rel_segs, test_segs)): int_vals = [0] * len(int_lins) for m, int_lin in enumerate(int_lins): for _oth_p in other_poly: if _oth_p.intersect_line_ray(int_lin): # intersection! int_vals[m] = 1 break if sum(int_vals) == len(int_lins): # fully internal line continue else: # if the polygon is concave, also check for self intersection if not poly.is_convex: _other_segs = [x for k, x in enumerate(rel_segs) if k != j] for m, int_lin in enumerate(int_lins): for _oth_s in _other_segs: if int_lin.intersect_line_ray(_oth_s) is not None: int_vals[m] = 1 break # determine the exterior segments using the intersections check_sum = sum(int_vals) if check_sum == 0: # fully external line non_int_segs.append(_s) elif len(int_vals) == 1 or check_sum == len(int_vals): continue # fully internal line else: # line that extends from inside to outside # first see if the exterior part is meaningful count_in_a_rows, repeat_count = [], 0 for v in int_vals: if v == 0: repeat_count += 1 count_in_a_rows.append(repeat_count) else: repeat_count = 0 max_repeat = max(count_in_a_rows) # if the exterior part is meaningful, split it if max_repeat != 1: last_pt = _s.p1 if int_vals[0] == 0 else None for v, ts in zip(int_vals, int_lins): if v == 0 and last_pt is None: last_pt = ts.p1 elif v == 1 and last_pt is not None: lin_seg = LineSegment2D.from_end_points(last_pt, ts.p1) last_pt = None non_int_segs.append(lin_seg) if last_pt is not None: lin_seg = LineSegment2D.from_end_points(last_pt, _s.p2) non_int_segs.append(lin_seg) ext_segs.extend(non_int_segs) # loop through exterior segments and add segments across the min_separation joining_segs = [] for i, e_seg in enumerate(ext_segs): try: for o_seg in ext_segs[i + 1:]: dist, pts = closest_end_point2d_between_line2d(e_seg, o_seg) if tolerance < dist <= min_separation: joining_segs.append(LineSegment2D.from_end_points(*pts)) except IndexError: pass # we have reached the end of the list # join all of the segments together into polylines all_segs = ext_segs + joining_segs ext_bounds = Polyline2D.join_segments(all_segs, tolerance) # separate valid closed boundaries from open ones closed_polys, open_bounds = [], [] for bnd in ext_bounds: if isinstance(bnd, Polyline2D) and bnd.is_closed(tolerance): try: closed_polys.append(bnd.to_polygon(tolerance)) except AssertionError: # not a valid polygon pass else: open_bounds.append(bnd) # if the resulting polylines are not closed, join the nearest end points if len(closed_polys) != len(ext_bounds): extra_segs = [] for i, s_bnd in enumerate(open_bounds): self_seg = LineSegment2D.from_end_points(s_bnd.p1, s_bnd.p2) poss_segs = [self_seg] try: for o_bnd in open_bounds[i + 1:]: pts = [ (s_bnd.p1, o_bnd.p1), (s_bnd.p1, o_bnd.p2), (s_bnd.p2, o_bnd.p1), (s_bnd.p2, o_bnd.p2)] for comb in pts: poss_segs.append(LineSegment2D.from_end_points(*comb)) except IndexError: continue # we have reached the end of the list # sort the possible segments by their length poss_segs.sort(key=lambda x: x.length, reverse=False) if poss_segs[0] is self_seg: extra_segs.append(poss_segs[0]) else: # two possible connecting segments extra_segs.append(poss_segs[0]) extra_segs.append(poss_segs[1]) # remove any duplicates from the extra segment list non_dup_segs = [] for e_seg in extra_segs: for f_seg in non_dup_segs: if e_seg.is_equivalent(f_seg, tolerance): break else: non_dup_segs.append(e_seg) extra_segs = non_dup_segs # take the best available segments that fit the criteria extra_segs.sort(key=lambda x: x.length, reverse=False) extra_segs = extra_segs[:len(open_bounds)] # join all segments, hopefully into a final closed polyline all_segs = ext_segs + joining_segs + extra_segs ext_bounds = Polyline2D.join_segments(all_segs, tolerance) closed_polys = [] for bnd in ext_bounds: if isinstance(bnd, Polyline2D) and bnd.is_closed(tolerance): try: closed_polys.append(bnd.to_polygon(tolerance)) except AssertionError: # not a valid polygon pass return closed_polys
[docs] @staticmethod def common_axes(polygons, direction, min_distance, merge_distance, angle_tolerance): """Get LineSegment2Ds for the most common axes across a set of Polygon2Ds. This is often useful as a step before aligning a set of polygons to these common axes. Args: polygons: A list or tuple of Polygon2D objects for which common axes will be evaluated. direction: A Vector2D object to represent the direction in which the common axes will be evaluated and generated min_distance: The minimum distance at which common axes will be evaluated. This value should typically be a little larger than the model tolerance (eg. 5 to 20 times the tolerance) in order to ensure that possible common axes across the input polygons are not missed. merge_distance: The distance at which common axes next to one another will be merged into a single axis. This should typically be 2-3 times the min_distance in order to avoid generating several axes that are immediately adjacent to one another. When using this method to generate axes for alignment, this merge_distance should be in the range of the alignment distance. angle_tolerance: The max angle difference in radians that the polygon segments direction can differ from the input direction before the segments are not factored into this calculation of common axes. Returns: A tuple with two elements. - common_axes: A list of LineSegment2D objects for the common axes across the input polygons. - axis_values: A list of integers that aligns with the common_axes and denotes how many segments of the input polygons each axis relates to. Higher numbers indicate that that the axis is more common among all of the possible axes. """ # gather the relevant segments of the input polygons min_ang, max_ang = angle_tolerance, math.pi - angle_tolerance rel_segs = [] for p_gon in polygons: for seg in p_gon.segments: try: s_ang = direction.angle(seg.v) if s_ang < min_ang or s_ang > max_ang: rel_segs.append(seg) except ZeroDivisionError: # zero length segment to ignore continue if len(rel_segs) == 0: return [], [] # none of the polygon segments are relevant in the direction # determine the extents around the polygons and the input direction gen_vec = direction.rotate(math.pi / 2) axis_angle = Vector2D(0, 1).angle_counterclockwise(gen_vec) orient_poly = polygons if axis_angle != 0: # rotate geometry to the bounding box cpt = polygons[0].vertices[0] orient_poly = [pl.rotate(-axis_angle, cpt) for pl in polygons] xx = Polygon2D._bounding_domain_x(orient_poly) yy = Polygon2D._bounding_domain_y(orient_poly) min_pt = Point2D(xx[0], yy[0]) max_pt = Point2D(xx[1], yy[1]) if axis_angle != 0: # rotate the points back min_pt = min_pt.rotate(axis_angle, cpt) max_pt = max_pt.rotate(axis_angle, cpt) # generate all possible axes from the extents and min_distance axis_vec = direction.normalize() * (xx[1] - xx[0]) incr_vec = gen_vec.normalize() * (min_distance) current_pt = min_pt current_dist, max_dist = 0, yy[1] - yy[0] all_axes = [] while current_dist < max_dist: axis = LineSegment2D(current_pt, axis_vec) all_axes.append(axis) current_pt = current_pt.move(incr_vec) current_dist += min_distance # evaluate the axes based on how many relevant segments they are next to mid_pts = [seg.midpoint for seg in rel_segs] rel_axes, axes_value = [], [] for axis in all_axes: axis_val = 0 for pt in mid_pts: close_pt = closest_point2d_on_line2d_infinite(pt, axis) if close_pt.distance_to_point(pt) <= min_distance: axis_val += 1 if axis_val != 0: rel_axes.append(axis) axes_value.append(axis_val) if len(rel_axes) == 0: return [], [] # none of the generated axes are relevant # group the axes by proximity last_ax = rel_axes[0] axes_groups = [[last_ax]] group_values = [[axes_value[0]]] for axis, val in zip(rel_axes[1:], axes_value[1:]): if axis.p.distance_to_point(last_ax.p) <= merge_distance: axes_groups[-1].append(axis) group_values[-1].append(val) else: # start a new group axes_groups.append([axis]) group_values.append([val]) last_ax = axis # average the line segments that are within the merge_distance of one another axis_values = [max(val) for val in group_values] common_axes = [] for ax_group, grp_vals in zip(axes_groups, group_values): if len(ax_group) == 1: common_axes.append(ax_group[0]) else: index_max = max(range(len(grp_vals)), key=grp_vals.__getitem__) common_axes.append(ax_group[index_max]) return common_axes, axis_values
@staticmethod def _bounding_domain_x(geometries): """Get minimum and maximum X coordinates of multiple polygons.""" min_x, max_x = geometries[0].min.x, geometries[0].max.x for geom in geometries[1:]: if geom.min.x < min_x: min_x = geom.min.x if geom.max.x > max_x: max_x = geom.max.x return min_x, max_x @staticmethod def _bounding_domain_y(geometries): """Get minimum and maximum Y coordinates of multiple polygons.""" min_y, max_y = geometries[0].min.y, geometries[0].max.y for geom in geometries[1:]: if geom.min.y < min_y: min_y = geom.min.y if geom.max.y > max_y: max_y = geom.max.y return min_y, max_y def _point_in_polygon(self, tolerance): """Get a Point2D that is always reliably inside this Polygon2D. The point will be close to the edge of the Polygon but it will always be inside it for all concave geometries. Furthermore, it is relatively fast compared with computing the pole_of_inaccessibility. """ try: poly = self.remove_colinear_vertices(tolerance) move_vec, v_angle = self._inward_pointing_vec(poly) except (AssertionError, ZeroDivisionError): # zero area Polygon2D; use center return self.center move_vec = move_vec * ((tolerance / math.sin(v_angle / 2)) + 0.00001) point_in_poly = poly.vertices[0] + move_vec if not self.is_point_inside(point_in_poly): point_in_poly = poly.vertices[0] - move_vec return point_in_poly @staticmethod def _inward_pointing_vec(polygon): """Get a unit vector pointing inward/outward from the first vertex of the Polygon """ v1 = polygon.vertices[-1] - polygon.vertices[0] v2 = polygon.vertices[1] - polygon.vertices[0] v_angle = v1.angle(v2) if v_angle == math.pi: # colinear vertices; prevent averaging to zero rgt_ang = math.pi / 2 return v1.rotate(rgt_ang).normalize(), rgt_ang else: # average the two edge vectors together avg_coords = ((v1.x + v2.x) / 2), ((v1.y + v2.y) / 2) return Vector2D(*avg_coords).normalize(), v_angle def _transfer_properties(self, new_polygon): """Transfer properties from this polygon to a new polygon. This is used by the transform methods that don't alter the relationship of face vertices to one another (move, rotate, reflect). """ new_polygon._perimeter = self._perimeter new_polygon._area = self._area new_polygon._is_convex = self._is_convex new_polygon._is_self_intersecting = self._is_self_intersecting new_polygon._is_clockwise = self._is_clockwise @staticmethod def _get_centroid_cell(polygon): """Get a Cell object at the centroid of the Polygon2D.""" area = 0 x = 0 y = 0 b = polygon[-1] # prev for a in polygon: f = a[0] * b[1] - b[0] * a[1] x += (a[0] + b[0]) * f y += (a[1] + b[1]) * f area += f * 3 b = a if area == 0: return _Cell(polygon[0][0], polygon[0][1], 0, polygon) return _Cell(x / area, y / area, 0, polygon) @staticmethod def _insert_updates_in_order(polygon, polygon_updates): """Insert updates from the intersect_segments method into a polygon. This method ensures that multiple updates to a single segment are inserted in the correct order over the polygon. Args: polygon: A Polygon2D to be updated with intersection points polygon_updates: A list of tuples where each tuple has two values. The first is the index of the segment to be updated and the second is the point to insert. """ polygon_updates.sort(key=lambda x: x[0]) # sort updates by order of insertion poly_points = list(polygon.vertices) # convert tuple to mutable list last_i = -1 colinear_count = 0 for update in polygon_updates[::-1]: # traverse backwards to preserve order new_i = update[0] + 1 if new_i == last_i: # check order of new intersections on the same segment colinear_count += 1 p1 = poly_points[update[0]] for i, pt in enumerate(poly_points[new_i:new_i + colinear_count]): if p1.distance_to_point(pt) > p1.distance_to_point(update[1]): poly_points.insert(new_i + i, update[1]) break else: poly_points.insert(new_i + colinear_count, update[1]) else: colinear_count = 0 poly_points.insert(new_i, update[1]) last_i = new_i return Polygon2D(poly_points) @staticmethod def _segments_from_vertices(vertices): _segs = [] for i, vert in enumerate(vertices): _seg = LineSegment2D.from_end_points(vertices[i - 1], vert) _segs.append(_seg) _segs.append(_segs.pop(0)) # segments will start from the first point return _segs @staticmethod def _merge_boundary_and_holes(boundary, holes, split=False): """Return a list of points for a boundary merged with all holes. The holes are merged one-by-one using the shortest distance from all of the holes to the boundary to ensure one hole's seam does not cross another. The run time of this method scales linearly with the total number of vertices, which makes it significantly better for shapes with many holes compared to recursively calling the Polygon2D._merge_boundary_and_closest_hole method. However, it is not nearly as efficient as the method used in Polygon2D.from_shape_with_holes_fast, which is typically not as beautiful of a result as this method. Args: boundary: A list of Point2D objects for the outer boundary inside of which the hole is contained. hole: A list of lists where each sub-list represents a hole and contains several Point2D objects that represent the hole. split: A boolean to note whether the last hole should be merged into the boundary, effectively splitting the shape into two lists of vertices instead of a single list. This is useful when trying to translate a shape with holes to a platform that does not support holes or struggles with single lists of vertices that wind inward to cut out the holes since this option returns two "normal" concave polygons. However, it is possible that the shape cannot be reliably split this way and, in this case, this method will return None. (Default: False). Returns: A single list of vertices with the holes merged into the boundary. When split is True and the splitting is successful, this will be two lists of vertices for the split shapes. If splitting was not successful, this method will return None and, if a hole-less split shape is still required, it is recommended that triangulation be used to get the hole-less shapes. """ # compute the initial distances between the holes and the boundary original_boundary = boundary[:] hole_dicts, min_dists = [], [] for hole in holes: dist_dict = {} for i, b_pt in enumerate(boundary): for j, h_pt in enumerate(hole): dist_dict[b_pt.distance_to_point(h_pt)] = [i, j] hole_dicts.append(dist_dict) min_dists.append(min(dist_dict.keys())) # merge each hole into the boundary, moving progressively by minimum distance final_hole_count = 0 if not split else 1 while len(holes) > final_hole_count: # merge the hole into the boundary hole_index = min_dists.index(min(min_dists)) boundary, old_hole, b_ind = Polygon2D._merge_boundary_and_hole_detailed( boundary, holes[hole_index], hole_dicts[hole_index]) # remove the hole from the older lists hole_dicts.pop(hole_index) holes.pop(hole_index) # update the hole_dicts based on the new boundary add_ind = len(old_hole) for hd in hole_dicts: for ind_list in hd.values(): if ind_list[0] > b_ind: ind_list[0] += add_ind # add the distances from the old hole to the remaining holes to hole_dicts old_hole_ind = [b_ind + i for i in range(add_ind)] for hole, dist_dict in zip(holes, hole_dicts): for bi, b_pt in zip(old_hole_ind, old_hole): for j, h_pt in enumerate(hole): dist_dict[b_pt.distance_to_point(h_pt)] = [bi, j] # generated an updated min_dists list min_dists = [min(dist_dict.keys()) for dist_dict in hole_dicts] if not split: return boundary # sort the distances to find the closest point last_hd = hole_dicts[0] sort_dist = sorted(last_hd.keys()) # find the closest connection between the hole and the original boundary polygon p1_indices = dist_dict[sort_dist[0]] p2_index = 1 p2_indices = dist_dict[sort_dist[p2_index]] p2_bound_pt = boundary[p2_indices[0]] while p2_bound_pt not in original_boundary: p2_index += 1 p2_indices = dist_dict[sort_dist[p2_index]] p2_bound_pt = boundary[p2_indices[0]] p2_hole_pt = hole[p2_indices[1]] # merge the hole into the boundary hole_deque = deque(hole) hole_deque.rotate(-p1_indices[1]) hole_insert = [boundary[p1_indices[0]]] + list(hole_deque) + \ [hole[p1_indices[1]]] boundary[p1_indices[0]:p1_indices[0]] = hole_insert # use the second most distant points to split the shape p2_bound_i = boundary.index(p2_bound_pt) p2_hole_i = boundary.index(p2_hole_pt) if p2_hole_i < p2_bound_i: boundary_1 = boundary[p2_hole_i:p2_bound_i + 1] boundary_2 = boundary[:p2_hole_i + 1] + boundary[p2_bound_i:] else: boundary_1 = boundary[p2_bound_i:p2_hole_i + 1] boundary_2 = boundary[:p2_bound_i + 1] + boundary[p2_hole_i:] poly_1, poly_2 = Polygon2D(boundary_1), Polygon2D(boundary_2) # if the split polygons are self-intersecting, try to find a solution p2_index = 0 while poly_1.is_self_intersecting or poly_2.is_self_intersecting: p2_index += 1 try: p2_indices = dist_dict[sort_dist[p2_index]] except IndexError: # no solution was found; just return None return None p2_bound_pt = boundary[p2_indices[0]] p2_hole_pt = hole[p2_indices[1]] p2_bound_i = boundary.index(p2_bound_pt) p2_hole_i = boundary.index(p2_hole_pt) if p2_hole_i < p2_bound_i: boundary_1 = boundary[p2_hole_i:p2_bound_i + 1] boundary_2 = boundary[:p2_hole_i + 1] + boundary[p2_bound_i:] else: boundary_1 = boundary[p2_bound_i:p2_hole_i + 1] boundary_2 = boundary[:p2_bound_i + 1] + boundary[p2_hole_i:] try: poly_1, poly_2 = Polygon2D(boundary_1), Polygon2D(boundary_2) except AssertionError: pass # the polygons are not valid; keep searching return boundary_1, boundary_2 @staticmethod def _merge_boundary_and_hole_detailed(boundary, hole, dist_dict): """Create a single list of points with a hole and boundary. This method will also return the newly-added vertices of the hole as well as the index of where the hole was inserted in the larger boundary. Args: boundary: A list of Point2D objects for the outer boundary inside of which the hole is contained. hole: A list of Point2D objects for the hole. dist_dict: A dictionary with keys of distances between each of the points in the boundary and hole lists and values as tuples with two values: (the index of the boundary point, the index of the hole point) Returns: A tuple with three values - boundary: A single list of vertices with the input hole merged into the boundary. - hole_insert: A list of vertices representing the hole that has been inserted into the boundary. - insert_index: An integer for where the hole was inserted in the boundary. """ min_dist = min(dist_dict.keys()) min_indexes = dist_dict[min_dist] hole_deque = deque(hole) hole_deque.rotate(-min_indexes[1]) hole_insert = [boundary[min_indexes[0]]] + list(hole_deque) + \ [hole[min_indexes[1]]] boundary[min_indexes[0]:min_indexes[0]] = hole_insert insert_index = min_indexes[0] return boundary, hole_insert, insert_index @staticmethod def _merge_boundary_and_closest_hole(boundary, holes): """Return a list of points for a boundary merged with the closest hole.""" hole_dicts = [] min_dists = [] for hole in holes: dist_dict = {} for i, b_pt in enumerate(boundary): for j, h_pt in enumerate(hole): dist_dict[b_pt.distance_to_point(h_pt)] = (i, j) hole_dicts.append(dist_dict) min_dists.append(min(dist_dict.keys())) hole_index = min_dists.index(min(min_dists)) new_boundary = Polygon2D._merge_boundary_and_hole( boundary, holes[hole_index], hole_dicts[hole_index]) holes.pop(hole_index) return new_boundary, holes @staticmethod def _merge_boundary_and_hole(boundary, hole, dist_dict): """Create a single list of points describing a boundary shape with a hole. Args: boundary: A list of Point2D objects for the outer boundary inside of which the hole is contained. hole: A list of Point2D objects for the hole. dist_dict: A dictionary with keys of distances between each of the points in the boundary and hole lists and values as tuples with two values: (the index of the boundary point, the index of the hole point) """ min_dist = min(dist_dict.keys()) min_indexes = dist_dict[min_dist] hole_deque = deque(hole) hole_deque.rotate(-min_indexes[1]) hole_insert = [boundary[min_indexes[0]]] + list(hole_deque) + \ [hole[min_indexes[1]]] boundary[min_indexes[0]:min_indexes[0]] = hole_insert return boundary @staticmethod def _are_clockwise(vertices): """Check if a list of vertices are clockwise. This is a quicker calculation when all you need is the direction and not area. """ _a = 0 for i, pt in enumerate(vertices): _a += vertices[i - 1].x * pt.y - vertices[i - 1].y * pt.x return _a < 0 def __copy__(self): _new_poly = Polygon2D(self._vertices) _new_poly._segments = self._segments _new_poly._perimeter = self._perimeter _new_poly._area = self._area _new_poly._is_clockwise = self._is_clockwise _new_poly._is_convex = self._is_convex _new_poly._is_self_intersecting = self._is_self_intersecting return _new_poly def __key(self): """A tuple based on the object properties, useful for hashing.""" return tuple(hash(pt) for pt in self._vertices) def __hash__(self): return hash(self.__key()) def __eq__(self, other): return isinstance(other, Polygon2D) and self.__key() == other.__key() def __repr__(self): return 'Polygon2D ({} vertices)'.format(len(self))
class _Cell(object): """2D cell object used in certain Polygon computations (eg. pole_of_inaccessibility). Args: x: The X coordinate of the cell origin. y: The Y coordinate of the cell origin. h: The dimension of the cell. polygon: An array representation of a Polygon2D. Properties: * x * y * h * d * max """ __slots__ = ('x', 'y', 'h', 'd', 'max') def __init__(self, x, y, h, polygon): self.h = h self.y = y self.x = x self.d = self._point_to_polygon_distance(x, y, polygon) self.max = self.d + self.h * math.sqrt(2) def _point_to_polygon_distance(self, x, y, polygon): """Get the distance from an X,Y point to the edge of a Polygon.""" inside = False min_dist_sq = inf b = polygon[-1] for a in polygon: if (a[1] > y) != (b[1] > y) and \ (x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]): inside = not inside min_dist_sq = min(min_dist_sq, self._get_seg_dist_sq(x, y, a, b)) b = a result = math.sqrt(min_dist_sq) if not inside: return -result return result @staticmethod def _get_seg_dist_sq(px, py, a, b): """Get the squared distance from a point to a segment.""" x = a[0] y = a[1] dx = b[0] - x dy = b[1] - y if dx != 0 or dy != 0: t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy) if t > 1: x = b[0] y = b[1] elif t > 0: x += dx * t y += dy * t dx = px - x dy = py - y return dx * dx + dy * dy def __lt__(self, other): return self.max < other.max def __lte__(self, other): return self.max <= other.max def __gt__(self, other): return self.max > other.max def __gte__(self, other): return self.max >= other.max def __eq__(self, other): return self.max == other.max