diff --git a/geom/demos/sat.py b/geom/demos/sat.py index 95434b3..20f6d28 100644 --- a/geom/demos/sat.py +++ b/geom/demos/sat.py @@ -6,6 +6,40 @@ from ..math import * from ..draw import * +def compute_separating_axes(p1, p2): + """Compute the projections upon the separating axes of polygons p1 and p2. + + For efficient collision detection, this could early-exit as soon as one + axis is found where the projections of p1 and p2 do not overlap. For + visualization purposes, we compute the projections for all axes and + return the results.""" + + # The list of axes to project upon + axes = [] + + # Iterate the normals of both polygons + for normal in p1.get_normals() + p2.get_normals(): + # we can trim out normals that are in the same or opposite direction + # as axes we already have, since they will give us the same overlap + # information + if not any(is_colinear(*normal, *axis) for axis in axes): + axes.append(normal) + + # helper function to find the min/max projections of vertices onto + # the given axis. + + def get_min_max(axis, vertices): + projections = [dot(*axis, *vertex) for vertex in vertices] + return min(projections), max(projections) + + return [ + (axis, + get_min_max(axis, p1.get_translated_vertices()), + get_min_max(axis, p2.get_translated_vertices())) + for axis in axes + ] + + class SeparatingAxisTheorem: title = "Separating Axis Theorem" @@ -19,6 +53,7 @@ class SeparatingAxisTheorem: self.generate_shape2() def generate_shape2(self): + """Generates the shape we can move around - a regular polygon.""" rotation = 45 if self.shape2 is None else self.shape2.rotation self.shape2 = generate_polygon(*get_cursor_pos(), rotation, @@ -27,9 +62,11 @@ class SeparatingAxisTheorem: def handle_key_down(self, key): if key == pygame.K_a: + # remove a side from the polygon self.shape2_sides = max(self.shape2_sides - 1, 3) self.generate_shape2() if key == pygame.K_d: + # add a side to the polygon self.shape2_sides = min(self.shape2_sides + 1, 31) self.generate_shape2() @@ -38,77 +75,65 @@ class SeparatingAxisTheorem: inputs = get_inputs() if inputs[pygame.K_q]: + # rotate counter-clockwise by 90 degrees every second self.shape2.rotate((1/60) * -90) if inputs[pygame.K_e]: + # same as above, but clockwise self.shape2.rotate((1/60) * 90) - def draw_axis(self, surface, normal, overlap, min1, max1, min2, max2): - offset = scale(*rnormal(*normal), 300) - - colliding = overlap < 0 - - if not colliding: - # separating line - dist = scale(*normal, - (min1 if min1 > min2 else min2) - overlap * 0.5) - line(surface, CYAN, - add(*dist, *offset), - add(*dist, *scale(*lnormal(*normal), 1000))) - - # axis - line(surface, WHITE, - add(*scale(*reverse(*normal), 1000), *offset), - add(*scale(*normal, 1000), *offset)) - - # shape 1 - line(surface, GREEN if not colliding else RED, - add(*scale(*normal, min1), *offset), - add(*scale(*normal, max1), *offset), 8) - # shape 2 - line(surface, GREEN if not colliding else RED, - add(*scale(*normal, min2), *offset), - add(*scale(*normal, max2), *offset), 8) - def render(self, surface): self.handle_input() - normals = [] + gaps = [] - def add_if_not_exists(normal): - for existing in normals: - if is_colinear(*existing, *normal): - return - normals.append(normal) + for result in compute_separating_axes(self.shape1, self.shape2): - for normal in self.shape1.get_normals() + self.shape2.get_normals(): - add_if_not_exists(normal) + axis, (min1, max1), (min2, max2) = result - def get_min_max(normal, vertices): - _min = float('inf') - _max = float('-inf') - for vertex in vertices: - proj = dot(*normal, *vertex) - _min = min(_min, proj) - _max = max(_max, proj) - return _min, _max + # length of the gap between the two projections + gap = max(min2 - max1, min1 - max2) + gaps.append(gap) - overlaps = [] - for normal in normals: - min1, max1 = get_min_max(normal, - self.shape1.get_translated_vertices()) - min2, max2 = get_min_max(normal, - self.shape2.get_translated_vertices()) + self.draw_axis(surface, axis, gap, min1, max1, min2, max2) - overlap = max(min2 - max1, min1 - max2) - overlaps.append(overlap) - - self.draw_axis(surface, normal, overlap, - min1, max1, min2, max2) - - colliding = all(overlap < 0 for overlap in overlaps) + colliding = all(gap < 0 for gap in gaps) if colliding: text_screen(surface, WHITE, (10, HEIGHT - 20), - f"Min Distance to Resolve: {max(overlaps)}") + f"Min Distance to Resolve: {max(gaps)}") self.shape1.draw(surface, YELLOW, 1) self.shape2.draw(surface, RED if colliding else WHITE, 1) + + def draw_axis(self, surface, normal, gap, min1, max1, min2, max2): + """Draws a separating axis with shape projections, and separating line. + Uses colours to clearly indicate whether the projections overlap.""" + + # since the normal's origin is 0,0 (center of the screen), calcuate + # an offset to move all drawing off to one side - in this case 300px. + offset = scale(*rnormal(*normal), 300) + + overlapping = gap < 0 + + if not overlapping: + # how far from the center of the gap is from the origin + dist = scale(*normal, (min1 if min1 > min2 else min2) - 0.5 * gap) + + # the separating line + line(surface, CYAN, + add(*dist, *offset), + add(*dist, *scale(*lnormal(*normal), 1000))) + + # axis, extending off the screen in both directions + line(surface, WHITE, + add(*offset, *scale(*reverse(*normal), 1000)), + add(*offset, *scale(*normal, 1000))) + + # projection 1 + line(surface, RED if overlapping else GREEN, + add(*offset, *scale(*normal, min1)), + add(*offset, *scale(*normal, max1)), 8) + + # projection 2 + line(surface, RED if overlapping else GREEN, + add(*offset, *scale(*normal, min2)), + add(*offset, *scale(*normal, max2)), 8)