Source code for pylygon

# Based on work by Chandler Armstrong (omni dot armstrong at gmail dot com)


"""
polygon object
"""


from __future__ import division
from operator import mul
try:
    from functools import reduce
except ImportError:
    pass # Ignore in python2
  
try:
  xrange
except Exception:
  xrange = range

from numpy import array, cos, dot, fabs, lexsort, pi, sin, sqrt, vstack
##from pygame import Rect

##from convexhull import convexhull
# convex hull related functions 
TURN_LEFT, TURN_RIGHT, TURN_NONE = (1, -1, 0)

def _turn(i, j, k):
    global P
    _P = P
    (p_x, p_y), (q_x, q_y), (r_x, r_y) = _P[i], _P[j], _P[k]
    trn = (q_x - p_x) * (r_y - p_y) - (r_x - p_x) * (q_y - p_y)

    if trn > 0:
        return 1
    elif trn < 0:
        return -1
    return 0


def _keep_left(hull, r):
    while len(hull) > 1 and _turn(hull[-2], hull[-1], r) != TURN_LEFT: hull.pop()
    if not len(hull) or not (hull[-1] == r).all(): hull.append(r)
    return hull


[docs]def convexhull(_P): """ Returns an array of the points in convex hull of P in CCW order. arguments: P -- a Polygon object or an numpy.array object of points """ global P P = _P I = lexsort((_P[:,1],_P[:,0])) l = reduce(_keep_left, I, []) u = reduce(_keep_left, reversed(I), []) l.extend(u[i] for i in xrange(1, len(u) - 1)) return array(l) # error tolerances
_MACHEPS = pow(2, -24) _E = _MACHEPS * 10 # utility functions _clamp = lambda a, v, b: max(a, min(b, v)) # clamp v between a and b _perp = lambda x_y: array([-x_y[1], x_y[0]]) # perpendicular _prod = lambda X: reduce(mul, X) # product _mag = lambda x_y: sqrt(x_y[0] * x_y[0] + x_y[1] * x_y[1]) # magnitude, or length _normalize = lambda V: array([i / _mag(V) for i in V]) # normalize a vector _intersect = lambda A, B: (A[1] > B[0] and B[1] > A[0]) # intersection test _unzip = lambda zipped: zip(*zipped) # unzip a list of tuples def _isbetween(o, p, q): # returns true if point p between points o and q o_x, o_y = o p_x, p_y = p q_x, q_y = q m = (q_y - o_y) / (q_x - o_x) b = o_y - (m * o_x) if fabs(p_y - ((m * p_x) + b)) < _MACHEPS: return True def _line_intersect(p1, q1, p2, q2): """ gets an intersection point of two lines """ x1, y1 = p1 x2, y2 = q1 x3, y3 = p2 x4, y4 = q2 x12 = x1 - x2 x34 = x3 - x4 y12 = y1 - y2 y34 = y3 - y4 c = x12 * y34 - y12 * x34 if abs(c) > 0.001: # Intersection a = x1 * y2 - y1 * x2 b = x3 * y4 - y3 * x4 x = (a * x34 - b * x12) / c y = (a * y34 - b * y12) / c # check boundaries if (x - min(x1, x2) > -1e-8 and x - min(x3, x4) > -1e-8 and max(x1, x2) - x > -1e-8 and max(x3, x4) - x > -1e-8 and y - min(y1, y2) > -1e-8 and y - min(y3, y4) > -1e-8 and max(y1, y2) - y > -1e-8 and max(y3, y4) - y > -1e-8): return (x, y) return None class _Support(object): # the support mapping of P - Q; s_P-Q # s_P-Q is the generic support mapping for polygons def __init__(self, P, Q): s = self._s self._s_P = s(P) self._s_Q = s(Q) self.M = [] def __repr__(self): return array([m for m in self]) def __len__(self): return len(self.M) def __iter__(self): return iter(p - q for p, q in self.M) def _s(self, C): # returns a function that returns the support mapping of C # the support mapping is the p in C such that # dot(r, p) == dot(r, _s(C)(r)) # ie, the support mapping is the p in C most in the direction of r return lambda r: max(dict((dot(r, p), p) for p in C).items())[1] def add(self, r): # add the value of s_P-Q(r) to self and return that value # NOTE: return value is always a pair of vertices from P and Q s_P, s_Q = self._s_P, self._s_Q p, q = s_P(r), s_Q(-r) self.M.append((p, q)) return p - q def get(self, r): # return value of s_P-Q(r) # NOTE: return value is always a pair of vertices from P and Q s_P, s_Q = self._s_P, self._s_Q return s_P(r) - s_Q(-r) def v(self, q=array([0, 0]), i=0): # find the point on the convex hull of C closest to q by iteratively # searching around voronoi regions # i is the index of the initial test edge # returns the point closest to q and sets self.M to be the minimum set # of points in C such that q in conv(points) A = array(list(self)) if len(A) > 1: I = convexhull(A) A = A[I] C = Polygon(A, conv=False).move(*-q) edges, n, P = C.edges, C.n, C.P if n == 1: return P[0] checked, inside = set(), set() while 1: checked.add(i) edge = edges[i] p = P[i] len2 = dot(edge, edge) # len(edge)**2 vprj = dot(p, edge) # p projected onto edge if vprj < 0: # q lies CW of edge i = (i - 1) % n if i in checked: if not i in inside: self.M = [self.M[I[i]]] return p - q i = (i - 1) % n continue if vprj > len2: # q lies CCW of edge i = (i + 1) % n if i in checked: if not i in inside: p = P[i] self.M = [self.M[I[i]]] return p - q i = (i + 1) % n continue nprj = dot(p, _perp(edge)) # p projected onto edge normal # perp of CCW edges will always point "outside" if nprj > 0: # q is "inside" the edge inside.add(i) if len(checked) == n: return q # q in C i = (i + 1) % n continue # q is closest to edge self.M = [self.M[I[i]], self.M[I[(i + 1) % n]]] edge_n = _normalize(edge) # move from p to q projected on to edge qprj = p - ((dot(p, edge_n)) * edge_n) return qprj
[docs]class Polygon(object): """polygon object""" def __init__(self, P, conv=True): """ arguments: P -- iterable or 2d numpy.array of (x, y) points. the constructor will find the convex hull of the points in CCW order; see the conv keyword argument for details. keyword arguments: conv -- boolean indicating if the convex hull of P should be found. conv is True by default. Polygon is intended for convex polygons only and P must be in CCW order. conv will ensure that P is both convex and in CCW. even if P is already convex, it is recommended to leave conv True, unless client code can be sure that P is also in CCW order. CCW order is requried for certain operations. NOTE: the order must be with respect to a bottom left orgin; graphics applications typically use a topleft origin. if your points are CCW with respect to a topleft origin they will be CW in a bottomleft origin """ P = array(list(P)) if conv: P = P[convexhull(P)] self.P = P n = len(P) # number of points self.n = n self.a = self._A() # area of polygon edges = [] # an edge is the vector from p to q for i, p in enumerate(P): q = P[(i + 1) % n] # x, y of next point in series edges.append(p - q) self.edges = array(edges) C = self.C # longest distance from C for all p in P self.rmax = sqrt(max(dot(C - p, C - p) for p in P)) def __len__(self): return self.n def __getitem__(self, i): return self.P[i] def __iter__(self): return iter(self.P) def __repr__(self): return str(self.P) def __add__(self, other): """ returns the minkowski sum of self and other arguments: other is a Polygon object returns an array of points for the results of minkowski addition NOTE: use the unary negation operator on other to find the so-called minkowski difference. eg A + (-B) """ P, Q = self.P, other.P return array([p + q for p in P for q in Q]) def __neg__(self): return Polygon(-self.P)
[docs] def get_rect(self): """return the AABB, as a pygame rect, of the polygon""" X, Y = _unzip(self.P) x, y = min(X), min(Y) w, h = max(X) - x, max(Y) - y #return Rect(x, y, w, h) return (x, y, w, h)
[docs] def move(self, x, y): """return a new polygon moved by x, y""" return Polygon([(x + p_x, y + p_y) for (p_x, p_y) in self.P])
[docs] def move_ip(self, x, y): """move the polygon by x, y""" self.P = array([(x + p_x, y + p_y) for (p_x, p_y) in self.P])
[docs] def collidepoint(self, x_y): """ test if point x_y = (x, y) is outside, on the boundary, or inside polygon uses raytracing algorithm returns 0 if outside returns -1 if on boundary returns 1 if inside """ x,y = x_y n, P = self.n, self.P # test if (x, y) on a vertex for p_x, p_y in P: if (x == p_x) and (y == p_y): return -1 intersections = 0 for i, p in enumerate(self.P): p_x, p_y = p q_x, q_y = P[(i + 1) % n] x_min, x_max = min(p_x, q_x), max(p_x, q_x) y_min, y_max = min(p_y, q_y), max(p_y, q_y) # test if (x, y) on horizontal boundary if (p_y == q_y) and (p_y == y) and (x > x_min) and (x < x_max): return -1 if (y > y_min) and (y <= y_max) and (x <= x_max) and (p_y != q_y): x_inters = (((y - p_y) * (q_x - p_x)) / (q_y - p_y)) + p_x # test if (x, y) on non-horizontal polygon boundary if x_inters == x: return -1 # test if line from (x, y) intersects boundary if p_x == q_x or x <= x_inters: intersections += 1 return intersections % 2
[docs] def collidepoly(self, other): """ test if other polygon collides with self using seperating axis theorem if collision, return projections arguments: other -- a polygon object returns: an array of projections """ # a projection is a vector representing the span of a polygon projected # onto an axis projections = [] for edge in vstack((self.edges, other.edges)): edge = _normalize(edge) # the separating axis is the line perpendicular to the edge axis = _perp(edge) self_projection = self.project(axis) other_projection = other.project(axis) # if self and other do not intersect on any axis, they do not # intersect in space if not _intersect(self_projection, other_projection): return False # find the overlapping portion of the projections projection = self_projection[1] - other_projection[0] projections.append((axis[0] * projection, axis[1] * projection)) return array(projections)
[docs] def distance(self, other, r=array([0, 0])): """ return distance between self and other uses GJK algorithm. for details see: Bergen, Gino Van Den. (1999). A fast and robust GJK implementation for collision detection of convex objects. Journal of Graphics Tools 4(2). arguments: other -- a Polygon object keyword arguments r -- initial search direction; setting r to the movement vector of self - other may speed convergence """ P, Q = self.P, other.P support = _Support(P, Q) # support mapping function s_P-Q(r) v = support.get(r) # initial support point w = support.add(-v) while dot(v, v) - dot(w, v) > _MACHEPS: # while w is closer to origin v = support.v() # closest point to origin in support points if len(support) == 3: return v # the origin is inside W; intersection w = support.add(-v) return v
[docs] def raycast(self, other, r, s=array([0, 0]), self_theta=0, other_theta=0): """ return the hit scalar, hit vector, and hit normal from self to other in direction r uses GJK-based raycast[1] modified to accomodate constant angular rotation[2][3] without needing to recompute the Minkowski Difference after each iteration[4]. [1] Bergen, Gino Van Den. (2004). Ray casting against general convex objects with application to continuous collision detection. GDC 2005. retrieved from http://www.bulletphysics.com/ftp/pub/test/physics/papers/ jgt04raycast.pdf on 6 July 2011. [2] Coumans, Erwin. (2005). Continuous collision detection and physics. retrieved from http://www.continuousphysics.com/ BulletContinuousCollisionDetection.pdf on 18 January 2012 [3] Mirtich, Brian Vincent. (1996). Impulse-based dynamic simulation of rigid body systems. PhD Thesis. University of California at Berkely. retrieved from http://www.kuffner.org/james/software/dynamics/mirtich/ mirtichThesis.pdf on 18 January 2012 [4] Behar, Evan and Jyh-Ming Lien. (2011). Dynamic Minkowski Sum of convex shapes. In proceedings of IEEE ICRA 2011. retrieved from http://masc.cs.gmu.edu/wiki/uploads/GeneralizedMsum/ icra11-dynsum-convex.pdf on 18 January 2012. arguments: other -- Polygon object r -- direction vector NOTE: GJK searches IN THE DIRECTION of r, thus r needs to point towards the origin with respect to the direction vector of self; in other words, if r represents the movement of self then client code should call raycast with -r. keyword arguments: s -- initial position along r, (0, 0) by default theta -- angular velocity in radians returns: if r does not intersect other, returns False else, returns the hit scalar, hit vector, and hit normal hit scalar -- the scalar where r intersects other hit vector -- the vector where self intersects other hit normal -- the edge normal at the intersection """ self_rmax, other_rmax = self.rmax, other.rmax # max arc length of rotation # maximum radians for arc length is pi L = ((self_rmax * abs(_clamp(-pi, self_theta, pi))) + (other_rmax * abs(_clamp(-pi, other_theta, pi)))) # polygons for support function; copied because they will be rotated A, B = Polygon(self), Polygon(other) support = _Support(A, B) # support mapping function s_P-Q(r) lambda_ = 0 # scalar of r to hit spot q = s # current point along r n = array([0, 0]) # hit normal at q v = support.get(r) - q # vector from q to s_P-Q p = support.add(-v) # support returns a v opposite of r w = p - q while dot(v, v) > _E * max(dot(p - q, p - q) for p in support): if dot(v, w) > 0: if (dot(v, r) <= 0) and (dot(v, v) > (L * L)): return False n = -v # update lambda # translation distance lower bound := dot(v, w) / dot(v, r) # angular rotation distance lower bound := L * (1 - lambda) lambda_change = dot(v, w) / (dot(v, r) + (L * (1 - lambda_))) lambda_ = lambda_ + lambda_change if lambda_ > 1: return False # interpolate lambda q = s + (lambda_ * r) # translation A.rotate_ip(lambda_change * self_theta) # rotation B.rotate_ip(lambda_change * other_theta) v = support.v(q) # closest point to q in support points p = support.add(-v) w = p - q return lambda_, q, n
def _A(self): # the area of polygon n = self.n P = self.P X, Y = P[:, 0], P[:, 1] return 0.5 * sum(X[i] * Y[(i + 1) % n] - X[(i + 1) % n] * Y[i] for i in xrange(n)) @property def C(self): """returns the centroid of the polygon""" a, n = self.a, self.n P = self.P X, Y = _unzip(P) if n == 1: return P[0] if n == 2: return array([X[0] + X[1] / 2, Y[0] + Y[1] / 2]) c_x, c_y = 0, 0 for i in xrange(n): a_i = X[i] * Y[(i + 1) % n] - X[(i + 1) % n] * Y[i] c_x += (X[i] + X[(i + 1) % n]) * a_i c_y += (Y[i] + Y[(i + 1) % n]) * a_i b = 1 / (6 * a) c_x *= b c_y *= b return array([c_x, c_y]) @C.setter
[docs] def C(self, x_y): x,y = x_y c_x, c_y = self.C x, y = x - c_x, y - c_y self.P = array([(p_x + x, p_y + y) for (p_x, p_y) in self.P])
def _rotate(self, x0, theta, origin=None): if not origin: origin = self.C origin = origin.reshape(2, 1) x0 = x0.reshape(2, 1) x0 = x0 - origin # assingment operator (-=) would modify original x0 A = array([[cos(theta), -sin(theta)], # rotation matrix [sin(theta), cos(theta)]]) return (dot(A, x0) + origin).ravel()
[docs] def rotopoints(self, theta): """ returns an array of points rotated theta radians around the centroid """ P = self.P rotate = self._rotate return array([rotate(p, theta) for p in P])
[docs] def rotoedges(self, theta): """return an array of vectors of edges rotated theta radians""" edges = self.edges rotate = self._rotate # edges, essentially angles, are always rotated around (0, 0) return array([rotate(edge, theta, origin=array([0, 0])) for edge in edges])
def rotate(self, theta): return Polygon(self.rotopoints(theta)) def rotate_ip(self, theta): other = Polygon(self.rotopoints(theta)) self.P[:] = other.P self.edges[:] = other.edges
[docs] def project(self, axis): """project self onto axis""" P = self.P projected_points = [dot(p, axis) for p in P] # return the span of the projection return min(projected_points), max(projected_points)
[docs] def intersection_points(self, other): """ Determine contact (intersection) points between self and other. returns Empty list if no collisions found returns List of intersection points """ points = [] for i, p in enumerate(self.P): q = self.P[(i + 1) % len(self.P)] for j, r in enumerate(other.P): s = other.P[(j + 1) % len(other.P)] ipt = _line_intersect(p, q, r, s) if ipt: points.append(ipt) return points