├── dist └── collision-1.2.2.tar.gz ├── .gitignore ├── collision ├── __init__.py ├── circle.py ├── response.py ├── tripy.py ├── util.py ├── poly.py └── tests.py ├── setup.py ├── LICENSE ├── examples └── concaves.py ├── logo.svg └── README.md /dist/collision-1.2.2.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertyquerty/collision/HEAD/dist/collision-1.2.2.tar.gz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test1.py 2 | test2.py 3 | test3.py 4 | test4.py 5 | test5.py 6 | /.idea/ 7 | /__pycache__/ 8 | /collision/__pycache__/ 9 | collision.egg-info 10 | build -------------------------------------------------------------------------------- /collision/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | An implementation of SAT in Python 3 | ------------------------------------ 4 | By: qwertyquerty (and Neko... a bit) 5 | """ 6 | 7 | 8 | from .circle import Circle 9 | from .poly import Poly, Concave_Poly 10 | from .response import Response 11 | from .tests import * 12 | from .tripy import * 13 | from .util import Vector 14 | 15 | 16 | __title__ = 'collision' 17 | __author__ = 'qwertyquerty' 18 | __copyright__ = 'Copyright 2018 qwertyquerty' 19 | __license__ = 'MIT' 20 | __version__ = '1.0.0' 21 | -------------------------------------------------------------------------------- /collision/circle.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from . import poly 4 | from .util import Vector 5 | 6 | CIRCLE_RECALC_ATTRS = ["r"] 7 | 8 | 9 | class Circle: 10 | def __init__(self,pos,r): 11 | self.pos = pos 12 | self.radius = r 13 | 14 | def __setattr__(self,key,val): 15 | self.__dict__[key] = val 16 | 17 | @property 18 | def aabb(self): 19 | r = self.radius 20 | pos = self.pos 21 | return ((pos.x-r, self.pos.y-r), (pos.x+r,self.pos.y-r), (pos.x-r, self.pos.y+r), (pos.x+r, pos.y+r)) 22 | 23 | def __str__(self): 24 | r = "Circle [\n\tradius = {}\n\tpos = {}\n]".format(self.radius, self.pos) 25 | return r 26 | 27 | def __repr__(self): 28 | return self.__str__() 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Use README for the PyPI page 4 | with open('README.md') as f: 5 | long_description = f.read() 6 | 7 | # https://setuptools.readthedocs.io/en/latest/setuptools.html 8 | setup(name='collision', 9 | author='qwertyquerty', 10 | url='https://github.com/QwekoDev/collision/', 11 | version='1.2.2', 12 | packages=['collision'], 13 | python_requires='>=3.5', 14 | platforms=['Windows', 'Linux', 'OSX'], 15 | zip_safe=True, 16 | license='MIT', 17 | description='Collision is a python library meant for collision detection between convex and concave polygons, circles, and points.', 18 | long_description=long_description, 19 | 20 | long_description_content_type='text/markdown', 21 | keywords='python collision detection polygon concave convex circle physics game', 22 | 23 | ) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 qwertyquerty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /collision/response.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .util import Vector 4 | 5 | 6 | class Response: 7 | def __init__(self): 8 | self.reset() 9 | 10 | def reset(self): 11 | self.a = None 12 | self.b = None 13 | self.overlap_n = Vector(0,0) 14 | self.overlap_v = Vector(0,0) 15 | self.a_in_b = True 16 | self.b_in_a = True 17 | self.overlap = math.inf 18 | return self 19 | 20 | def __str__(self): 21 | r = "Response [" \ 22 | "\n\ta = {}" \ 23 | "\n\tb = {}" \ 24 | "\n\toverlap = {}" \ 25 | "\n\toverlap_n = {}" \ 26 | "\n\toverlap_v = {}" \ 27 | "\n\ta_in_b = {}" \ 28 | "\n\tb_in_a = {}\n]" \ 29 | "".format("\n\t".join(str(self.a).split("\n"))+"\n", 30 | "\n\t".join(str(self.b).split("\n"))+"\n", 31 | self.overlap, 32 | self.overlap_n, 33 | self.overlap_v, 34 | self.a_in_b, 35 | self.b_in_a) 36 | return r 37 | 38 | def __repr__(self): 39 | return self.__str__() 40 | -------------------------------------------------------------------------------- /examples/concaves.py: -------------------------------------------------------------------------------- 1 | import pygame as pg 2 | import sys 3 | from collision import * 4 | 5 | SCREENSIZE = (500,500) 6 | 7 | screen = pg.display.set_mode(SCREENSIZE, pg.DOUBLEBUF|pg.HWACCEL) 8 | 9 | v = Vector 10 | 11 | p0 = Concave_Poly(v(0,0), [v(-80,0), v(-20,20), v(0,80), v(20,20), v(80,0), v(20,-20), v(0,-80), v(-20,-20)]) 12 | p1 = Concave_Poly(v(500,500), [v(-80,0), v(-20,20), v(0,80), v(20,20), v(80,0), v(20,-20), v(0,-80), v(-20,-20)]) 13 | 14 | clock = pg.time.Clock() 15 | 16 | while 1: 17 | for event in pg.event.get(): 18 | if event.type == pg.QUIT: 19 | sys.exit() 20 | 21 | screen.fill((0,0,0)) 22 | 23 | p0.pos.x += 1 24 | p0.pos.y += 0.75 25 | p0.angle += 0.005 26 | p1.pos.x -= 0.6 27 | p1.pos.y -= 0.5 28 | 29 | p0c, p1c, p2c = (0,255,255),(0,255,255),(0,255,255) 30 | p0bc = (255,255,255) 31 | p1bc = (255,255,255) 32 | 33 | if collide(p0,p1): p1c = (255,0,0); p0c = (255,0,0); 34 | if test_aabb(p0.aabb,p1.aabb): p1bc = (255,0,0); p0bc = (255,0,0); 35 | 36 | pg.draw.polygon(screen, p0c, p0.points, 3) 37 | pg.draw.polygon(screen, p1c, p1.points, 3) 38 | 39 | pg.draw.polygon(screen, p0bc, (p0.aabb[0],p0.aabb[1],p0.aabb[3],p0.aabb[2]), 3) 40 | pg.draw.polygon(screen, p1bc, (p1.aabb[0],p1.aabb[1],p1.aabb[3],p1.aabb[2]), 3) 41 | 42 | pg.display.flip() 43 | 44 | 45 | clock.tick(100) 46 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /collision/tripy.py: -------------------------------------------------------------------------------- 1 | from .util import Vector 2 | 3 | # This is my edit of the tripy lib. Changed to support my vectors, and some other additions. - qwerty 4 | 5 | import math 6 | TWO_PI = 2 * math.pi 7 | PI = math.pi 8 | 9 | 10 | def earclip(polygon): 11 | ear_vertex = [] 12 | triangles = [] 13 | polygon = [point.copy() for point in polygon] 14 | 15 | point_count = len(polygon) 16 | for i in range(point_count): 17 | prev_index = i - 1 18 | prev_point = polygon[prev_index] 19 | point = polygon[i] 20 | next_index = (i + 1) % point_count 21 | next_point = polygon[next_index] 22 | 23 | if is_ear(prev_point, point, next_point, polygon): 24 | ear_vertex.append(point) 25 | 26 | while ear_vertex and point_count >= 3: 27 | ear = ear_vertex.pop(0) 28 | i = polygon.index(ear) 29 | prev_index = i - 1 30 | prev_point = polygon[prev_index] 31 | next_index = (i + 1) % point_count 32 | next_point = polygon[next_index] 33 | 34 | polygon.remove(ear) 35 | point_count -= 1 36 | triangles.append([Vector(prev_point.x, prev_point.y), Vector(ear.x, ear.y), Vector(next_point.x, next_point.y)]) 37 | if point_count > 3: 38 | prev_prev_point = polygon[prev_index - 1] 39 | next_next_index = (i + 1) % point_count 40 | next_next_point = polygon[next_next_index] 41 | 42 | groups = [ 43 | (prev_prev_point, prev_point, next_point, polygon), 44 | (prev_point, next_point, next_next_point, polygon) 45 | ] 46 | for group in groups: 47 | p = group[1] 48 | if is_ear(*group): 49 | if p not in ear_vertex: 50 | ear_vertex.append(p) 51 | elif p in ear_vertex: 52 | ear_vertex.remove(p) 53 | return triangles 54 | 55 | 56 | def is_clockwise(polygon): 57 | s = 0 58 | polygon_count = len(polygon) 59 | for i in range(polygon_count): 60 | point = polygon[i] 61 | point2 = polygon[(i + 1) % polygon_count] 62 | s += (point2.x - point.x) * (point2.y + point.y) 63 | return s > 0 64 | 65 | 66 | def is_convex(prev, point, next): 67 | return triangle_sum(prev.x, prev.y, point.x, point.y, next.x, next.y) < 0 68 | 69 | 70 | def is_ear(p1, p2, p3, polygon): 71 | return contains_no_points(p1, p2, p3, polygon) and is_convex(p1, p2, p3) and triangle_area(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y) > 0 72 | 73 | 74 | def contains_no_points(p1, p2, p3, polygon): 75 | for pn in polygon: 76 | if pn in (p1, p2, p3): 77 | continue 78 | elif is_point_inside(pn, p1, p2, p3): 79 | return False 80 | return True 81 | 82 | 83 | def is_point_inside(p, a, b, c): 84 | return triangle_area(a.x, a.y, b.x, b.y, c.x, c.y) == sum([triangle_area(p.x, p.y, b.x, b.y, c.x, c.y), triangle_area(p.x, p.y, a.x, a.y, c.x, c.y), triangle_area(p.x, p.y, a.x, a.y, b.x, b.y)]) 85 | 86 | 87 | def triangle_area(x1, y1, x2, y2, x3, y3): 88 | return abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0) 89 | 90 | 91 | def triangle_sum(x1, y1, x2, y2, x3, y3): 92 | return x1 * (y3 - y2) + x2 * (y1 - y3) + x3 * (y2 - y1) 93 | 94 | 95 | def is_convex_polygon(polygon): 96 | try: 97 | if len(polygon) < 3: 98 | return False 99 | 100 | old_x, old_y = polygon[-2] 101 | new_x, new_y = polygon[-1] 102 | new_direction = math.atan2(new_y - old_y, new_x - old_x) 103 | angle_sum = 0.0 104 | 105 | for ndx, newpoint in enumerate(polygon): 106 | old_x, old_y, old_direction = new_x, new_y, new_direction 107 | new_x, new_y = newpoint 108 | new_direction = math.atan2(new_y - old_y, new_x - old_x) 109 | if old_x == new_x and old_y == new_y: 110 | return False 111 | angle = new_direction - old_direction 112 | if angle <= -PI: 113 | angle += TWO_PI 114 | elif angle > PI: 115 | angle -= TWO_PI 116 | orientation = 1.0 if angle > 0.0 else -1.0 117 | if ndx == 0: 118 | if angle == 0.0: 119 | return False 120 | else: 121 | if orientation * angle <= 0.0: 122 | return False 123 | 124 | angle_sum += angle 125 | 126 | return abs(round(angle_sum / TWO_PI)) == 1 127 | except (ArithmeticError, TypeError, ValueError): 128 | return False 129 | -------------------------------------------------------------------------------- /collision/util.py: -------------------------------------------------------------------------------- 1 | import math 2 | from numbers import Real 3 | from typing import Any, Union 4 | 5 | 6 | LEFT_VORONOI_REGION = -1 7 | MIDDLE_VORONOI_REGION = 0 8 | RIGHT_VORONOI_REGION = 1 9 | ALLOWED_NUM_TYPES = (int, float) 10 | 11 | 12 | class Vector: 13 | __slots__ = ['x', 'y'] 14 | 15 | def __init__(self, x, y): 16 | self.x = x 17 | self.y = y 18 | 19 | def __add__(self, other: Any): 20 | if isinstance(other, ALLOWED_NUM_TYPES): 21 | return Vector(self.x+other, self.y+other) 22 | 23 | return Vector(self.x+other.x, self.y+other.y) 24 | 25 | def __mul__(self, other: Any): 26 | if isinstance(other, ALLOWED_NUM_TYPES): 27 | return Vector(self.x*other, self.y*other) 28 | 29 | return Vector(self.x*other.x, self.y*other.y) 30 | 31 | def __sub__(self, other: Any): 32 | if isinstance(other, ALLOWED_NUM_TYPES): 33 | return Vector(self.x-other, self.y-other) 34 | 35 | return Vector(self.x-other.x, self.y-other.y) 36 | 37 | def __neg__(self): 38 | return Vector(-self.x, -self.y) 39 | 40 | def __truediv__(self, other: Any): 41 | if isinstance(other, ALLOWED_NUM_TYPES): 42 | return Vector(self.x/other, self.y/other) 43 | 44 | return Vector(self.x/other.x, self.y/other.y) 45 | 46 | def __floordiv__(self, other: Any): 47 | if isinstance(other, ALLOWED_NUM_TYPES): 48 | return Vector(self.x//other, self.y//other) 49 | 50 | return Vector(self.x//other.x, self.y//other.y) 51 | 52 | def __mod__(self, other: Any): 53 | if isinstance(other, ALLOWED_NUM_TYPES): 54 | return Vector(self.x % other, self.y % other) 55 | 56 | return Vector(self.x % other.x, self.y % other.y) 57 | 58 | def __eq__(self, other: Any): 59 | if not isinstance(other, Vector): 60 | return False 61 | return self.x == other.x and self.y == other.y 62 | 63 | def __ne__(self, other: Any): 64 | if not isinstance(other, Vector): 65 | return True 66 | 67 | return self.x != other.x or self.y != other.y 68 | 69 | def __getitem__(self, index: int): 70 | return [self.x, self.y][index] 71 | 72 | def __contains__(self, value): 73 | return value == self.x or value == self.y 74 | 75 | def __len__(self): 76 | return 2 77 | 78 | def __repr__(self): 79 | return self.__str__() 80 | 81 | def __str__(self): 82 | return "Vector [{x}, {y}]".format(x=self.x, y=self.y) 83 | 84 | def copy(self): 85 | return Vector(self.x, self.y) 86 | 87 | def set(self, other): 88 | self.x = other.x 89 | self.y = other.y 90 | 91 | def perp(self): 92 | return Vector(self.y, -self.x) 93 | 94 | def rotate(self, angle: Union[int, float, Real]): 95 | return Vector(self.x * math.cos(angle) - self.y * math.sin(angle), self.x * math.sin(angle) + self.y * math.cos(angle)) 96 | 97 | def reverse(self): 98 | return Vector(-self.x, -self.y) 99 | 100 | def int(self): 101 | return Vector(int(self.x), int(self.y)) 102 | 103 | def normalize(self): 104 | dot = self.ln() 105 | return self / dot 106 | 107 | def project(self, other): 108 | amt = self.dot(other) / other.ln2() 109 | 110 | return Vector(amt * other.x, amt * other.y) 111 | 112 | def project_n(self, other): 113 | amt = self.dot(other) 114 | 115 | return Vector(amt * other.x, amt * other.y) 116 | 117 | def reflect(self, axis): 118 | v = Vector(self.x, self.y) 119 | v = v.project(axis) * 2 120 | v = -v 121 | 122 | return v 123 | 124 | def reflect_n(self, axis): 125 | v = Vector(self.x, self.y) 126 | v = v.project_n(axis) * 2 127 | v = -v 128 | 129 | return v 130 | 131 | def dot(self, other): 132 | return self.x * other.x + self.y * other.y 133 | 134 | def ln2(self): 135 | return self.dot(self) 136 | 137 | def ln(self): 138 | return math.sqrt(self.ln2()) 139 | 140 | 141 | def flatten_points_on(points, normal, result): 142 | minpoint = math.inf 143 | maxpoint = -math.inf 144 | 145 | for i in range(len(points)): 146 | dot = points[i].dot(normal) 147 | if dot < minpoint: 148 | minpoint = dot 149 | if dot > maxpoint: 150 | maxpoint = dot 151 | 152 | result[0] = minpoint 153 | result[1] = maxpoint 154 | 155 | 156 | def is_separating_axis(a_pos, b_pos, a_points, b_points, axis, response=None): 157 | range_a = [0, 0] 158 | range_b = [0, 0] 159 | 160 | offset_v = b_pos-a_pos 161 | 162 | projected_offset = offset_v.dot(axis) 163 | 164 | flatten_points_on(a_points, axis, range_a) 165 | flatten_points_on(b_points, axis, range_b) 166 | 167 | range_b[0] += projected_offset 168 | range_b[1] += projected_offset 169 | 170 | if range_a[0] > range_b[1] or range_b[0] > range_a[1]: 171 | return True 172 | 173 | if response: 174 | 175 | overlap = 0 176 | 177 | if range_a[0] < range_b[0]: 178 | response.a_in_b = False 179 | 180 | if range_a[1] < range_b[1]: 181 | overlap = range_a[1] - range_b[0] 182 | response.b_in_a = False 183 | 184 | else: 185 | option_1 = range_a[1] - range_b[0] 186 | option_2 = range_b[1] - range_a[0] 187 | overlap = option_1 if option_1 < option_2 else -option_2 188 | 189 | else: 190 | response.b_in_a = False 191 | 192 | if range_a[1] > range_b[1]: 193 | overlap = range_a[0] - range_b[1] 194 | response.a_in_b = False 195 | 196 | else: 197 | option_1 = range_a[1] - range_b[0] 198 | option_2 = range_b[1] - range_a[0] 199 | 200 | overlap = option_1 if option_1 < option_2 else -option_2 201 | 202 | abs_overlap = abs(overlap) 203 | if abs_overlap < response.overlap: 204 | response.overlap = abs_overlap 205 | response.overlap_n.set(axis) 206 | if overlap < 0: 207 | response.overlap_n = response.overlap_n.reverse() 208 | 209 | return False 210 | 211 | 212 | def voronoi_region(line, point): 213 | dp = point.dot(line) 214 | 215 | if dp < 0: 216 | return LEFT_VORONOI_REGION 217 | elif dp > line.ln2(): 218 | return RIGHT_VORONOI_REGION 219 | return MIDDLE_VORONOI_REGION 220 | -------------------------------------------------------------------------------- /collision/poly.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .util import Vector 4 | from . import tripy 5 | 6 | POLY_RECALC_ATTRS = ["angle"] 7 | 8 | 9 | class Poly: 10 | def __init__(self, pos, points, angle=0): 11 | self.pos = pos 12 | self.__dict__["angle"] = angle 13 | self.set_points(points) 14 | 15 | @classmethod 16 | def from_box(cls, center, width, height): 17 | hw = width / 2 18 | hh = height / 2 19 | c = cls(center, [Vector(-hw, -hh), Vector(hw, -hh), Vector(hw, hh), Vector(-hw, hh)]) 20 | return c 21 | 22 | def __setattr__(self, key, val): 23 | self.__dict__[key] = val 24 | if key in POLY_RECALC_ATTRS: 25 | self._recalc() 26 | 27 | def set_points(self, points): 28 | if tripy.is_clockwise(points): 29 | points = points[::-1] 30 | 31 | length_changed = len(self.base_points) != len(points) if hasattr(self, "base_points") else True 32 | if length_changed: 33 | self.rel_points = [] 34 | self.edges = [] 35 | self.normals = [] 36 | 37 | for i in range(len(points)): 38 | self.rel_points.append(Vector(0, 0)) 39 | self.edges.append(Vector(0, 0)) 40 | self.normals.append(Vector(0, 0)) 41 | 42 | self.base_points = points 43 | self._recalc() 44 | 45 | def _recalc(self): 46 | l = range(len(self.base_points)) 47 | 48 | for i in l: 49 | self.rel_points[i].set(self.base_points[i]) 50 | if self.angle != 0: 51 | self.rel_points[i] = self.rel_points[i].rotate(self.angle) 52 | 53 | for i in l: 54 | p1 = self.rel_points[i] 55 | p2 = self.rel_points[i+1] if i < len(self.rel_points) - 1 else self.rel_points[0] 56 | 57 | e = self.edges[i] = p2-p1 58 | 59 | self.normals[i] = e.perp().normalize() 60 | 61 | 62 | @property 63 | def points(self): 64 | pos = self.pos 65 | return [pos+point for point in self.rel_points] 66 | 67 | @property 68 | def aabb(self): 69 | points = self.points 70 | x_min = points[0].x 71 | y_min = points[0].y 72 | x_max = points[0].x 73 | y_max = points[0].y 74 | 75 | for point in points: 76 | if point.x < x_min: x_min = point.x 77 | elif point.x > x_max: x_max = point.x 78 | if point.y < y_min: y_min = point.y 79 | elif point.y > y_max: y_max = point.y 80 | 81 | return ((x_min,y_min), (x_max,y_min), (x_min,y_max), (x_max,y_max)) 82 | 83 | 84 | def get_centroid(self): 85 | cx = 0 86 | cy = 0 87 | ar = 0 88 | for i in range(len(self.rel_points)): 89 | p1 = self.rel_points[i] 90 | p2 = self.rel_points[0] if i == len(self.rel_points) - 1 else self.rel_points[i+1] 91 | a = p1.x * p2.y - p2.x * p1.y 92 | cx += (p1.x + p2.x) * a 93 | cy += (p1.x + p2.y) * a 94 | ar += a 95 | 96 | ar = ar * 3 97 | cx = cx / ar 98 | cy = cy / ar 99 | 100 | return Vector(cx, cy) 101 | 102 | def __str__(self): 103 | r = "Poly [\n\tpoints = [\n" 104 | for p in self.points: 105 | r += "\t\t{}\n".format(str(p)) 106 | r += "\t]\n" 107 | r += "\tpos = {}\n\tangle = {}\n".format(self.pos, self.angle) 108 | r += "]" 109 | return r 110 | 111 | def __repr__(self): 112 | return self.__str__() 113 | 114 | 115 | class Concave_Poly(): 116 | def __init__(self, pos, points, angle=0): 117 | self.pos = pos 118 | self.__dict__["angle"] = angle 119 | self.set_points(points) 120 | 121 | def __setattr__(self, key, val): 122 | self.__dict__[key] = val 123 | if key in POLY_RECALC_ATTRS: 124 | self._recalc() 125 | 126 | def set_points(self, points): 127 | if tripy.is_clockwise(points): 128 | points = points[::-1] 129 | 130 | length_changed = len(self.base_points) != len(points) if hasattr(self,"base_points") else True 131 | if length_changed: 132 | self.rel_points = [] 133 | self.tris = [] 134 | self.edges = [] 135 | self.normals = [] 136 | 137 | for i in range(len(points)): 138 | self.rel_points.append(Vector(0,0)) 139 | self.edges.append(Vector(0,0)) 140 | self.normals.append(Vector(0,0)) 141 | 142 | self.base_points = points 143 | self._calculate_tris() 144 | self._recalc() 145 | 146 | def _recalc(self): 147 | l = range(len(self.base_points)) 148 | 149 | for i in l: 150 | self.rel_points[i].set(self.base_points[i]) 151 | if self.angle != 0: 152 | self.rel_points[i] = self.rel_points[i].rotate(self.angle) 153 | 154 | for i in l: 155 | p1 = self.rel_points[i] 156 | p2 = self.rel_points[i+1] if i < len(self.rel_points) - 1 else self.rel_points[0] 157 | 158 | e = self.edges[i] = p2-p1 159 | 160 | self.normals[i] = e.perp().normalize() 161 | 162 | self._update_tris() 163 | 164 | def _calculate_tris(self): 165 | self.tris = [Poly(self.pos, points, self.angle) for points in tripy.earclip(self.base_points)] 166 | 167 | def _update_tris(self): 168 | for tri in self.tris: 169 | tri.angle = self.angle 170 | tri.pos = self.pos 171 | 172 | @property 173 | def points(self): 174 | templist = [] 175 | for p in self.rel_points: 176 | templist.append(p+self.pos) 177 | return templist 178 | 179 | @property 180 | def aabb(self): 181 | points = self.points 182 | x_min = points[0].x 183 | y_min = points[0].y 184 | x_max = points[0].x 185 | y_max = points[0].y 186 | 187 | for point in points: 188 | if point.x < x_min: x_min = point.x 189 | elif point.x > x_max: x_max = point.x 190 | if point.y < y_min: y_min = point.y 191 | elif point.y > y_max: y_max = point.y 192 | 193 | return ((x_min,y_min), (x_max,y_min), (x_min,y_max), (x_max,y_max)) 194 | 195 | 196 | 197 | def get_centroid(self): 198 | cx = 0 199 | cy = 0 200 | ar = 0 201 | for i in range(len(self.rel_points)): 202 | p1 = self.rel_points[i] 203 | p2 = self.rel_points[0] if i == len(self.rel_points) - 1 else self.rel_points[i+1] 204 | a = p1.x * p2.y - p2.x * p1.y 205 | cx += (p1.x + p2.x) * a 206 | cy += (p1.x + p2.y) * a 207 | ar += a 208 | 209 | ar = ar * 3 210 | cx = cx / ar 211 | cy = cy / ar 212 | 213 | return Vector(cx, cy) 214 | 215 | def __str__(self): 216 | r = "Concave_Poly [\n\tpoints = [\n" 217 | for p in self.points: 218 | r+= "\t\t{}\n".format(str(p)) 219 | r += "\t]\n" 220 | r += "\tpos = {}\n\tangle = {}\n".format(self.pos, self.angle) 221 | r += "]" 222 | return r 223 | 224 | def __repr__(self): 225 | return self.__str__() 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Info 2 | 3 | Collision is a python library that is meant for collision detection between convex and concave polygons, circles, and points. 4 | 5 | ### Installation 6 | 7 | To get the latest stable version: 8 | 9 | `pip install collision` 10 | 11 | To get the latest development version run: 12 | 13 | `pip install https://github.com/qwertyquerty/collision/archive/master.zip` 14 | 15 | ## Classes 16 | 17 | ### ***class*** `collision.Vector(x, y)` 18 | 19 | A 2D vector/point class 20 | 21 | **Properties:** 22 | 23 | - `x` *(int) or (float)* - The x-coordinate 24 | - `y` *(int) or (float)* - The y-coordinate 25 | 26 | **Methods:** 27 | 28 | ##### *func* `copy()` → `collision.vec` 29 | 30 | Return a copy of the vector 31 | 32 | ##### *func* `set(v)` 33 | 34 | Copy another vector's values onto the target vector 35 | 36 | - `v` *(collision.vec)* - The vector to copy from 37 | 38 | ##### *func* `perp()` → `collision.vec` 39 | 40 | Return the vector rotated perpendicularly 41 | 42 | ##### *func* `rotate(angle)` → `collision.vec` 43 | 44 | Return the vector rotated to the angle 45 | 46 | - `angle` *(int) or (float)* - Radians to rotate the point 47 | 48 | ##### *func* `reverse()` → `collision.vec` 49 | 50 | Return a reversed version of the vector 51 | 52 | ##### *func* `normalize()` → `collision.vec` 53 | 54 | Return a normalized version of the vector 55 | 56 | ##### *func* `project(v)` → `collision.vec` 57 | 58 | Return the vector projected onto the passed vector 59 | 60 | - `v` *(collision.vec)* - The vector to project upon 61 | 62 | ##### *func* `project_n(v)` → `collision.vec` 63 | 64 | Return the vector projected onto a unit vector 65 | 66 | - `v` *(collision.vec)* - The vector to project upon 67 | 68 | ##### *func* `reflect(axis)` → `collision.vec` 69 | 70 | Return the vector reflected upon the passed axis vector 71 | 72 | - `axis` *(collision.vec)* - The axis to reflect upon 73 | 74 | ##### *func* `reflect_n(axis)` → `collision.vec` 75 | 76 | Return the vector reflected upon the passed axis unit vector 77 | 78 | - `axis` *(collision.vec)* - The axis to reflect upon 79 | 80 | ##### *func* `dot(v)` → `int or float` 81 | 82 | Returns the dot product of the vector and another 83 | 84 | - `v` *(collision.vec)* - The other vector for the dot product 85 | 86 | ##### *func* `ln()` → `int or float` 87 | 88 | Returns the length of the vector 89 | 90 | ##### *func* `ln2()` → `int or float` 91 | 92 | Returns the squared length of the vector 93 | 94 | ------ 95 | 96 | ### ***class*** `collision.Circle(pos, radius)` 97 | 98 | A simple circle with a position and radius 99 | 100 | **Properties:** 101 | 102 | - `pos` *(collision.vec)* - The center coordinate of the circle 103 | - `radius` *(int) or (float)* - The radius of the circle 104 | - `aabb` *(tuple(tuple(int or float))* - The axis alligned bounding box of the circle 105 | 106 | ### ***class*** `collision.Poly(pos, points, angle = 0)` 107 | 108 | A **convex** polygon with a position, a list of points relative to that position, and an angle 109 | 110 | **Properties:** 111 | 112 | - `pos` *(collision.vec)* - The center coordinate of the circle 113 | - `points` *(list[collision.vec])* - A list of absolute points (each relative point + the position of the polygon.) Can not be directly edited. 114 | - `rel_points` *(list[collision.vec])* - A list of points relative to the position. This property should not be directly changed. 115 | - `angle` *(int) or (float)* - The angle which the polygon is rotated. Changing this will cause the polygon to be recalculated. 116 | - `aabb` *(tuple(tuple(int or float))* - The axis-aligned bounding box of the Poly 117 | 118 | **Class Methods:** 119 | 120 | ##### *func* `Poly.from_box(pos, width, height)` → `collision.Poly` 121 | 122 | Creates a polygon from 123 | 124 | - `pos` *(collision.vec)* - The center coordinate of the polygon/box 125 | - `width` *(int) or (float)* - The width of the box 126 | - `height` *(int) or (float)* - The height of the box 127 | 128 | **Methods:** 129 | 130 | ##### *func* `set_points(points)` 131 | 132 | Change the base points relative to the position. After this is done, the polygon will be recalculated again. The angle will be preserved. Use this instead of editing the `points` property. 133 | 134 | ##### *func* `get_centroid()` → `collision.vec` 135 | 136 | Get the centroid of the polygon. Arithmetic means all of the points. 137 | 138 | 139 | ------ 140 | 141 | ### ***class*** `collision.Concave_Poly(pos, points, angle = 0)` 142 | 143 | A **concave** polygon with a position, a list of points relative to that position, and an angle. This takes longer to collide than a regular `Poly` does, so only use this, if your shape must be concave. 144 | 145 | **Properties:** 146 | 147 | - `pos` *(collision.vec)* - The center coordinate of the circle 148 | - `points` *(list[collision.vec])* - A list of absolute points (each relative point + the position of the polygon.) Can not be directly edited. 149 | - `rel_points` *(list[collision.vec])* - A list of points relative to the position. This property should not be directly changed. 150 | - `tris` *(list[collision.Poly])* - A list of triangles relative to the position on the poly that makes up the concave polygon is used for concave collisions. 151 | - `angle` *(int) or (float)* - The angle which the polygon is rotated. Changing this will cause the polygon to be recalculated. 152 | - `aabb` *(tuple(tuple(int or float))* - The axis alligned bounding box of the Poly 153 | 154 | **Methods:** 155 | 156 | ##### *func* `set_points(points)` 157 | 158 | Change the base points relative to the position. After this is done, the polygon will be recalculated again. The angle will be preserved. Use this instead of editing the `points` property. 159 | 160 | ##### *func* `get_centroid()` → `collision.vec` 161 | 162 | Get the centroid of the polygon. Arithmetic means all of the points. 163 | 164 | 165 | ------ 166 | 167 | ### ***class*** `collision.Response()` 168 | 169 | The result of a collision between two objects. It may optionally be passed to collision tests to retrieve additional information. At its cleared state, it may seem to have odd values. Ignore these, they are just there to make generating the response more efficient. The response should be ignored unless there is a successful collision. 170 | 171 | **Properties:** 172 | 173 | - `a` *(collision shape)* - The first object in the collision test 174 | - `b` *(collision shape)* - The second object in the collision test 175 | - `overlap` *(int) or (float)* - Magnitude of the overlap on the shortest colliding axis 176 | - `overlap_n` *(collision.vec)* - The shortest colliding axis (unit vector) 177 | - `overlap_v` *(collision.vec)* - The overlap vector. If this is subtracted from the position of `a`, `a` and `b` will no longer be colliding. 178 | - `a_in_b` *(bool)* - Whether `a` is fully inside of `b` 179 | - `b_in_a` *(bool)* - Whether `b` is fully inside of `a` 180 | 181 | **Methods:** 182 | 183 | ##### *func* `reset()` → `collision.Response` 184 | 185 | Reset the Response for re-use, and returns itself 186 | 187 | ## Collisions 188 | 189 | ### *func* `collision.collide(a, b, response = None)` → `bool` 190 | 191 | Test two shapes against each other. If a response is passed, and there is a collision, that response will be updated to the response values. **The response will not be generated if there is no collision and it will be at its default values. Concave polys currently do not support responses.** 192 | 193 | - `a` *(collision shape)* - The first shape to test 194 | - `b` *(collision shape)* - The second shape to test 195 | - `response` *(collision.Response)* - Optional response that will be updated if there's a collision. 196 | 197 | ### *func* `collision.test_aabb(a, b)` → `bool` 198 | 199 | Test two axis-aligned bounding boxes against each other. This is already done in `collision.collide` so there's no need for you to do it for optimization. 200 | 201 | - `a` *(tuple(tuple(int or float)))* The first AABB 202 | - `b` *(tuple(tuple(int or float)))* The second AABB 203 | -------------------------------------------------------------------------------- /collision/tests.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .circle import Circle 4 | from .poly import Poly, Concave_Poly 5 | from .response import Response 6 | from .util import Vector, flatten_points_on, voronoi_region, is_separating_axis 7 | 8 | 9 | LEFT_VORONOI_REGION = -1 10 | MIDDLE_VORONOI_REGION = 0 11 | RIGHT_VORONOI_REGION = 1 12 | RESPONSE = Response() 13 | TEST_POINT = Poly(Vector(0, 0), [Vector(0, 0), Vector(0.0000001, 0.0000001)]) 14 | 15 | 16 | def test_aabb(b1,b2): 17 | return b1[0][0] <= b2[1][0] and b2[0][0] <= b1[1][0] and b1[0][1] <= b2[2][1] and b2[0][1] <= b1[2][1] 18 | 19 | def point_in_circle(p, c): 20 | difference_v = p - c.pos 21 | 22 | radius_sq = c.radius * c.radius 23 | 24 | distance_sq = difference_v.ln2() 25 | 26 | return distance_sq <= radius_sq 27 | 28 | 29 | def point_in_poly(p, poly): 30 | TEST_POINT.pos.set(p) 31 | RESPONSE.reset() 32 | 33 | result = test_poly_poly(TEST_POINT, poly, RESPONSE) 34 | 35 | if result: 36 | return RESPONSE.a_in_b 37 | 38 | return result 39 | 40 | 41 | def test_circle_circle(a, b, response = None): 42 | 43 | difference_v = b.pos - a.pos 44 | total_radius = a.radius + b.radius 45 | total_radius_sq = total_radius * total_radius 46 | distance_sq = difference_v.ln2() 47 | 48 | if distance_sq > total_radius_sq: 49 | return False 50 | 51 | if response: 52 | dist = math.sqrt(distance_sq) 53 | response.a = a 54 | response.b = b 55 | response.overlap = total_radius - dist 56 | if difference_v.ln2() != 0: 57 | response.overlap_n = difference_v.normalize() 58 | response.overlap_v = response.overlap_n * response.overlap 59 | else: 60 | response.overlap_n = Vector(0, 1) 61 | response.overlap_v = Vector(0, response.overlap) 62 | response.a_in_b = a.radius <= b.radius and dist <= b.radius - a.radius 63 | response.b_in_a = b.radius <= a.radius and dist <= a.radius - b.radius 64 | 65 | return True 66 | 67 | 68 | def test_poly_circle(polygon, circle, response = None): 69 | circle_pos = circle.pos - polygon.pos 70 | radius = circle.radius 71 | radius2 = radius * radius 72 | points = polygon.rel_points 73 | ln = len(points) 74 | 75 | for i in range(ln): 76 | nextn = 0 if i == ln - 1 else i + 1 77 | prevn = ln - 1 if i == 0 else i - 1 78 | 79 | overlap = 0 80 | overlap_n = None 81 | 82 | edge = polygon.edges[i].copy() 83 | point = circle_pos - points[i] 84 | 85 | if response and point.ln2() > radius2: 86 | response.a_in_b = False 87 | 88 | region = voronoi_region(edge,point) 89 | 90 | if region == LEFT_VORONOI_REGION: 91 | edge.set(polygon.edges[prevn]) 92 | 93 | point2 = circle_pos - points[prevn] 94 | 95 | region = voronoi_region(edge, point2) 96 | 97 | if region == RIGHT_VORONOI_REGION: 98 | 99 | dist = point.ln() 100 | 101 | if dist > radius: 102 | return False 103 | 104 | elif response: 105 | response.b_in_a = False 106 | overlap_n = point.normalize() 107 | overlap = radius - dist 108 | 109 | elif region == RIGHT_VORONOI_REGION: 110 | edge.set(polygon.edges[nextn]) 111 | point = circle_pos - points[nextn] 112 | region = voronoi_region(edge,point) 113 | 114 | if region == LEFT_VORONOI_REGION: 115 | dist = point.ln() 116 | 117 | if dist > radius: 118 | return False 119 | 120 | elif response: 121 | response.b_in_a = False 122 | overlap_n = point.normalize() 123 | overlap = radius - dist 124 | 125 | else: 126 | 127 | normal = edge.perp().normalize() 128 | 129 | dist = point.dot(normal) 130 | 131 | dist_abs = abs(dist) 132 | 133 | if dist > 0 and dist_abs > radius: 134 | return False 135 | 136 | elif response: 137 | overlap_n = normal 138 | overlap = radius - dist 139 | 140 | if dist >= 0 or overlap < 2 * radius: 141 | response.b_in_a = False 142 | 143 | if overlap_n and response and abs(overlap) < abs(response.overlap): 144 | response.overlap = overlap 145 | response.overlap_n.set(overlap_n) 146 | 147 | if response: 148 | response.a = polygon 149 | response.b = circle 150 | response.overlap_v = response.overlap_n * response.overlap 151 | 152 | return True 153 | 154 | 155 | def test_circle_poly(circle,polygon,response=None): 156 | result = test_poly_circle(polygon, circle, response) 157 | 158 | if result and response: 159 | a = response.a 160 | a_in_b = response.a_in_b 161 | response.overlap_n = response.overlap_n.reverse() 162 | response.overlap_v = response.overlap_v.reverse() 163 | response.a = response.b 164 | response.b = a 165 | response.a_in_b = response.b_in_a 166 | response.b_in_a = a_in_b 167 | else: 168 | response = None 169 | 170 | return result 171 | 172 | 173 | def test_poly_poly(a, b, response=None): 174 | a_points = a.rel_points 175 | b_points = b.rel_points 176 | a_pos = a.pos 177 | b_pos = b.pos 178 | 179 | for n in a.normals: 180 | if is_separating_axis(a_pos, b_pos, a_points, b_points, n, response): 181 | return False 182 | 183 | for n in b.normals: 184 | if is_separating_axis(a_pos, b_pos, a_points, b_points, n, response): 185 | return False 186 | 187 | if response: 188 | response.a = a 189 | response.b = b 190 | response.overlap_v = response.overlap_n * response.overlap 191 | 192 | return True 193 | 194 | 195 | def point_in_concave_poly(p, poly): 196 | TEST_POINT.pos.set(p) 197 | 198 | for tri in poly.tris: 199 | result = test_poly_poly(TEST_POINT, tri) 200 | if result: 201 | return result 202 | 203 | return result 204 | 205 | 206 | def test_concave_poly_concave_poly(a, b): 207 | a_pos = a.pos 208 | b_pos = b.pos 209 | 210 | for a_tri in a.tris: 211 | for b_tri in b.tris: 212 | test = True 213 | for n in a_tri.normals: 214 | if is_separating_axis(a_pos, b_pos, a_tri.rel_points, b_tri.rel_points, n): 215 | test = False 216 | 217 | for n in b_tri.normals: 218 | if is_separating_axis(a_pos, b_pos, a_tri.rel_points, b_tri.rel_points, n): 219 | #print("YIKES 2") 220 | test = False 221 | 222 | if test: 223 | return True 224 | 225 | return False 226 | 227 | 228 | def test_concave_poly_poly(a, b): 229 | b_points = b.rel_points 230 | a_pos = a.pos 231 | b_pos = b.pos 232 | 233 | for a_tri in a.tris: 234 | test = True 235 | for n in a_tri.normals: 236 | if is_separating_axis(a_pos, b_pos, a_tri.rel_points, b_points, n): 237 | test = False 238 | 239 | for n in b.normals: 240 | if is_separating_axis(a_pos, b_pos, a_tri.rel_points, b_points, n): 241 | test = False 242 | 243 | if test: 244 | return True 245 | 246 | return False 247 | 248 | 249 | def test_concave_poly_circle(concave_poly, circle): 250 | for polygon in concave_poly.tris: 251 | test = True 252 | circle_pos = circle.pos - polygon.pos 253 | radius = circle.radius 254 | radius2 = radius * radius 255 | points = polygon.rel_points 256 | ln = len(points) 257 | 258 | for i in range(ln): 259 | next = 0 if i == ln - 1 else i + 1 260 | prev = ln - 1 if i == 0 else i - 1 261 | overlap = 0 262 | overlap_n = None 263 | edge = polygon.edges[i].copy() 264 | point = circle_pos - points[i] 265 | region = voronoi_region(edge,point) 266 | 267 | if region == LEFT_VORONOI_REGION: 268 | edge.set(polygon.edges[prev]) 269 | point2 = circle_pos - points[prev] 270 | region = voronoi_region(edge, point2) 271 | 272 | if region == RIGHT_VORONOI_REGION: 273 | dist = point.ln() 274 | 275 | if dist > radius: 276 | test = False 277 | 278 | elif region == RIGHT_VORONOI_REGION: 279 | edge.set(polygon.edges[next]) 280 | point = circle_pos - points[next] 281 | region = voronoi_region(edge,point) 282 | 283 | if region == LEFT_VORONOI_REGION: 284 | dist = point.ln() 285 | if dist > radius: 286 | test = False 287 | 288 | else: 289 | normal = edge.perp().normalize() 290 | dist = point.dot(normal) 291 | dist_abs = abs(dist) 292 | 293 | if dist > 0 and dist_abs > radius: 294 | test = False 295 | 296 | if test: 297 | return True 298 | 299 | return False 300 | 301 | 302 | def collide(a, b, response=None): 303 | if isinstance(a, Circle) and isinstance(b, Circle): 304 | return test_circle_circle(a, b, response) 305 | 306 | if not test_aabb(a.aabb, b.aabb): 307 | return False 308 | 309 | 310 | if isinstance(a, Poly) and isinstance(b, Poly): 311 | return test_poly_poly(a, b, response) 312 | elif isinstance(a, Poly) and isinstance(b, Circle): 313 | return test_poly_circle(a, b, response) 314 | elif isinstance(a, Circle) and isinstance(b, Poly): 315 | return test_circle_poly(a, b, response) 316 | elif isinstance(a, Concave_Poly) and isinstance(b, Concave_Poly): 317 | return test_concave_poly_concave_poly(a, b) 318 | elif isinstance(a, Concave_Poly) and isinstance(b, Poly): 319 | return test_concave_poly_poly(a, b) 320 | elif isinstance(a, Poly) and isinstance(b, Concave_Poly): 321 | return test_concave_poly_poly(b, a) 322 | elif isinstance(a, Concave_Poly) and isinstance(b, Circle): 323 | return test_concave_poly_circle(a, b) 324 | elif isinstance(a, Circle) and isinstance(b, Concave_Poly): 325 | return test_concave_poly_circle(b, a) 326 | else: 327 | raise TypeError("Invalid types for collide {}() and {}()".format(a.__class__.__name__,b.__class__.__name__)) 328 | --------------------------------------------------------------------------------