diff --git a/apps/scripts/quadtree_benchmark.py b/apps/scripts/quadtree_benchmark.py new file mode 100644 index 0000000..674466f --- /dev/null +++ b/apps/scripts/quadtree_benchmark.py @@ -0,0 +1,96 @@ +""" +FastQuadTree Depth Benchmarking Script + +This tool evaluates how varying the depth of the FastQuadTree affects build and +query performance for spatial collision detection on 2D rectangular tiles. + +Features: +- Randomly generates a set of rectangular tiles within a defined bounding box. +- Builds a FastQuadTree structure using a configurable depth parameter. +- Runs multiple spatial queries to check which tiles intersect a test rectangle. +- Measures and prints build time and total query time across a range of depths. + +Usage: +Run the script with optional CLI arguments: + --min-depth Minimum quadtree depth to test (default: 2) + --max-depth Maximum quadtree depth to test (default: 6) + --items Number of rectangles to generate (default: 5000) + --queries Number of queries to perform per depth level (default: 1000) + +This is useful for profiling and tuning FastQuadTree performance across different +configurations. +""" + +import random +import timeit +from argparse import ArgumentParser + +from pygame.rect import Rect + +from pyscroll.quadtree import FastQuadTree + + +def generate_tile_rects(n, bounds=(0, 0, 800, 600), tile_size=(32, 32)) -> list[Rect]: + x0, y0, w, h = bounds + tw, th = tile_size + return [ + Rect(random.randint(x0, x0 + w - tw), random.randint(y0, y0 + h - th), tw, th) + for _ in range(n) + ] + + +def run_benchmark( + depth: int, item_count: int, query_count: int, tile_size=(32, 32) +) -> dict: + items = generate_tile_rects(item_count, tile_size=tile_size) + + # Measure build time + start_build = timeit.default_timer() + tree = FastQuadTree(items, depth=depth) + build_time = timeit.default_timer() - start_build + + # Measure query time + test_rect = Rect(400, 300, tile_size[0] * 2, tile_size[1] * 2) + start_query = timeit.default_timer() + for _ in range(query_count): + tree.hit(test_rect) + query_time = timeit.default_timer() - start_query + + return { + "depth": depth, + "items": item_count, + "queries": query_count, + "build_time": build_time, + "query_time": query_time, + } + + +def main(): + parser = ArgumentParser(description="FastQuadTree depth benchmark") + parser.add_argument("--min-depth", type=int, default=2) + parser.add_argument("--max-depth", type=int, default=6) + parser.add_argument("--items", type=int, default=5000) + parser.add_argument("--queries", type=int, default=1000) + args = parser.parse_args() + + results = [] + for depth in range(args.min_depth, args.max_depth + 1): + result = run_benchmark(depth, args.items, args.queries) + results.append(result) + + print("\nBenchmark Configuration:") + print(f" Items : {args.items}") + print(f" Queries : {args.queries}") + print(f" Min Depth : {args.min_depth}") + print(f" Max Depth : {args.max_depth}") + + print("\nFastQuadTree Benchmark Results") + print(f"{'Depth':>5} | {'Build (s)':>10} | {'Query (s)':>10}") + print("-" * 32) + for r in results: + print(f"{r['depth']:>5} | {r['build_time']:.6f} | {r['query_time']:.6f}") + print() + + +if __name__ == "__main__": + main() diff --git a/apps/scripts/quadtree_hitmap.py b/apps/scripts/quadtree_hitmap.py new file mode 100644 index 0000000..f4f242c --- /dev/null +++ b/apps/scripts/quadtree_hitmap.py @@ -0,0 +1,97 @@ +""" +FastQuadTree Hit Pattern Visualizer + +This standalone script provides a visual, interactive demonstration of how +FastQuadTree spatial queries work. It generates a random set of rectangles +across a 2D screen, builds a FastQuadTree index, and displays: +- The fixed query rectangle (blue) +- Tile rectangles (gray) +- Hit results from the query (red outlines) + +Users can toggle hit visibility using the [H] key and observe spatial +distribution and query precision. + +Features: +- Adjustable depth, tile size, and rectangle count via configuration variables +- Visual feedback for hit detection +- Overlay stats to track hit count live +""" + +import random + +import pygame +from pygame.rect import Rect + +from pyscroll.quadtree import FastQuadTree + +# Configuration +SCREEN_SIZE = (800, 600) +TILE_SIZE = (32, 32) +RECT_COUNT = 1000 +DEPTH = 3 +QUERY_RECT = Rect(400, 300, 64, 64) +FPS = 30 + + +def generate_tile_rects(n, bounds=(0, 0, 800, 600), tile_size=(32, 32)) -> list[Rect]: + x0, y0, w, h = bounds + tw, th = tile_size + return [ + Rect(random.randint(x0, x0 + w - tw), random.randint(y0, y0 + h - th), tw, th) + for _ in range(n) + ] + + +def main(): + pygame.init() + screen = pygame.display.set_mode(SCREEN_SIZE) + pygame.display.set_caption("FastQuadTree Hit Pattern Visualizer") + clock = pygame.time.Clock() + font = pygame.font.SysFont(None, 24) + + # Generate tiles and build quadtree + tile_rects = generate_tile_rects( + RECT_COUNT, bounds=(0, 0, *SCREEN_SIZE), tile_size=TILE_SIZE + ) + tree = FastQuadTree(tile_rects, depth=DEPTH) + + running = True + show_hits = True + + while running: + screen.fill((30, 30, 30)) # Dark background + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_h: # Toggle hit visibility + show_hits = not show_hits + + # Draw all rectangles (gray) + for rect in tile_rects: + pygame.draw.rect(screen, (120, 120, 120), rect, 1) + + # Draw query rect (blue) + pygame.draw.rect(screen, (0, 128, 255), QUERY_RECT, 2) + + # Draw hits (red) + hits = tree.hit(QUERY_RECT) + if show_hits: + for hit_rect in hits: + pygame.draw.rect(screen, (255, 0, 0), hit_rect, 2) + + # Info overlay + text = font.render( + f"Hits: {len(hits)} | Press [H] to toggle hits", True, (240, 240, 240) + ) + screen.blit(text, (10, 10)) + + pygame.display.flip() + clock.tick(FPS) + + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/apps/scripts/quadtree_vs_bruteforce.py b/apps/scripts/quadtree_vs_bruteforce.py new file mode 100644 index 0000000..b8912d5 --- /dev/null +++ b/apps/scripts/quadtree_vs_bruteforce.py @@ -0,0 +1,69 @@ +""" +Performance Benchmark: FastQuadTree vs Brute-force Collision Detection + +This script compares the speed and efficiency of two collision detection +methods in a 2D space: +1. FastQuadTree - a spatial partitioning structure optimized for querying + rectangular areas. +2. Brute-force - a simple approach that checks every rectangle for intersection. + +Features: +- Generates a random set of rectangular objects within a defined area. +- Performs repeated collision queries using both methods. +- Measures and prints build/setup time and query time for each method. +""" + +import random +import timeit + +from pygame.rect import Rect + +from pyscroll.quadtree import FastQuadTree + + +def generate_rects(n, bounds=(0, 0, 800, 600), tile_size=(32, 32)) -> list[Rect]: + x0, y0, w, h = bounds + tw, th = tile_size + return [ + Rect(random.randint(x0, x0 + w - tw), random.randint(y0, y0 + h - th), tw, th) + for _ in range(n) + ] + + +def brute_force_hit(items: list[Rect], target: Rect) -> list[Rect]: + return [r for r in items if r.colliderect(target)] + + +def benchmark(item_count=5000, query_count=1000, depth=4): + print(f"\nBenchmark: {item_count} rects, {query_count} queries, depth={depth}") + + # Generate test data + items = generate_rects(item_count) + test_rect = Rect(400, 300, 64, 64) + + # Benchmark Quadtree + start = timeit.default_timer() + tree = FastQuadTree(items, depth=depth) + build_time = timeit.default_timer() - start + + start = timeit.default_timer() + for _ in range(query_count): + tree.hit(test_rect) + quadtree_query_time = timeit.default_timer() - start + + # Benchmark Brute-force + start = timeit.default_timer() + for _ in range(query_count): + brute_force_hit(items, test_rect) + brute_query_time = timeit.default_timer() - start + + print( + f"FastQuadTree:\n Build Time: {build_time:.6f}s\n Query Time: {quadtree_query_time:.6f}s" + ) + print( + f"Brute-force:\n Setup Time: negligible\n Query Time: {brute_query_time:.6f}s\n" + ) + + +if __name__ == "__main__": + benchmark() diff --git a/pyscroll/quadtree.py b/pyscroll/quadtree.py index 3209993..94becaf 100644 --- a/pyscroll/quadtree.py +++ b/pyscroll/quadtree.py @@ -3,18 +3,24 @@ A quadtree is used with pyscroll to detect overlapping tiles. """ + from __future__ import annotations import itertools from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional -from pygame import Rect +from pygame.rect import Rect if TYPE_CHECKING: from .common import RectLike +def get_rect(item) -> Rect: + """Helper to consistently get a pygame.Rect from item.""" + return item.rect if hasattr(item, "rect") else item + + class FastQuadTree: """ An implementation of a quad-tree. @@ -31,82 +37,82 @@ class FastQuadTree: original code from https://pygame.org/wiki/QuadTree """ - __slots__ = ["items", "cx", "cy", "nw", "sw", "ne", "se"] + __slots__ = ["items", "cx", "cy", "nw", "sw", "ne", "se", "boundary"] - def __init__(self, items: Sequence, depth: int = 4, boundary=None) -> None: + def __init__( + self, items: Sequence[Rect], depth: int = 4, boundary: Optional[RectLike] = None + ) -> None: """Creates a quad-tree. Args: items: Sequence of items to check depth: The maximum recursion depth boundary: The bounding rectangle of all of the items in the quad-tree - """ + if not items: + raise ValueError("Items must not be empty") - # The sub-quadrants are empty to start with. - self.nw = self.ne = self.se = self.sw = None - - # Find this quadrant's centre. - if boundary: - boundary = Rect(boundary) - else: - # If there isn't a bounding rect, then calculate it from the items. - boundary = Rect(items[0]).unionall(items[1:]) + # Compute boundary if not provided + rects = [get_rect(item) for item in items] + boundary = Rect(boundary) if boundary else rects[0].unionall(rects[1:]) - cx = self.cx = boundary.centerx - cy = self.cy = boundary.centery + self.cx, self.cy = boundary.centerx, boundary.centery + self.boundary = boundary + self.items: list[Rect] = [] + self.nw = self.ne = self.sw = self.se = None - # If we've reached the maximum depth then insert all items into this - # quadrant. - depth -= 1 - if depth == 0 or not items: - self.items = items + # Base case: store all in this node + if depth <= 0: + self.items = rects return - self.items = [] - nw_items = [] - ne_items = [] - se_items = [] - sw_items = [] - - for item in items: - # Which of the sub-quadrants does the item overlap? - in_nw = item.left <= cx and item.top <= cy - in_sw = item.left <= cx and item.bottom >= cy - in_ne = item.right >= cx and item.top <= cy - in_se = item.right >= cx and item.bottom >= cy - - # If it overlaps all 4 quadrants then insert it at the current - # depth, otherwise append it to a list to be inserted under every - # quadrant that it overlaps. + # Partition items into sub-quadrants + nw_items, ne_items, se_items, sw_items = [], [], [], [] + + for rect in rects: + in_nw = rect.left <= self.cx and rect.top <= self.cy + in_sw = rect.left <= self.cx and rect.bottom >= self.cy + in_ne = rect.right >= self.cx and rect.top <= self.cy + in_se = rect.right >= self.cx and rect.bottom >= self.cy + if in_nw and in_ne and in_se and in_sw: - self.items.append(item) + self.items.append(rect) else: - in_nw and nw_items.append(item) - in_ne and ne_items.append(item) - in_se and se_items.append(item) - in_sw and sw_items.append(item) - - # Create the sub-quadrants, recursively. + if in_nw: + nw_items.append(rect) + if in_ne: + ne_items.append(rect) + if in_se: + se_items.append(rect) + if in_sw: + sw_items.append(rect) + + # Recursive sub-quadrant initialization if nw_items: self.nw = FastQuadTree( - nw_items, depth, (boundary.left, boundary.top, cx, cy) + nw_items, depth - 1, (boundary.left, boundary.top, self.cx, self.cy) ) if ne_items: self.ne = FastQuadTree( - ne_items, depth, (cx, boundary.top, boundary.right, cy) + ne_items, depth - 1, (self.cx, boundary.top, boundary.right, self.cy) ) if se_items: self.se = FastQuadTree( - se_items, depth, (cx, cy, boundary.right, boundary.bottom) + se_items, depth - 1, (self.cx, self.cy, boundary.right, boundary.bottom) ) if sw_items: self.sw = FastQuadTree( - sw_items, depth, (boundary.left, cy, cx, boundary.bottom) + sw_items, depth - 1, (boundary.left, self.cy, self.cx, boundary.bottom) ) def __iter__(self): - return itertools.chain(self.items, self.nw, self.ne, self.se, self.sw) + return itertools.chain( + self.items, + self.nw or [], + self.ne or [], + self.se or [], + self.sw or [], + ) def hit(self, rect: RectLike) -> set[tuple[int, int, int, int]]: """ @@ -117,26 +123,27 @@ def hit(self, rect: RectLike) -> set[tuple[int, int, int, int]]: Args: rect: The bounding rectangle being tested - """ - # Find the hits at the current level. - hits = {tuple(self.items[i]) for i in rect.collidelistall(self.items)} - - # Recursively check the lower quadrants. - left = rect.left <= self.cx - right = rect.right >= self.cx - top = rect.top <= self.cy - bottom = rect.bottom >= self.cy - - if left: - if top and self.nw: - hits |= self.nw.hit(rect) - if bottom and self.sw: - hits |= self.sw.hit(rect) - if right: - if top and self.ne: - hits |= self.ne.hit(rect) - if bottom and self.se: - hits |= self.se.hit(rect) + if not isinstance(rect, Rect): + rect = get_rect(rect) + + if not self.boundary.colliderect(rect): + return set() + + # Check for collisions in this node + hits = {tuple(item) for item in self.items if item.colliderect(rect)} + + # Check lower quadrants + if rect.left <= self.cx: + if rect.top <= self.cy and self.nw is not None: + hits.update(self.nw.hit(rect)) + if rect.bottom >= self.cy and self.sw is not None: + hits.update(self.sw.hit(rect)) + + if rect.right >= self.cx: + if rect.top <= self.cy and self.ne is not None: + hits.update(self.ne.hit(rect)) + if rect.bottom >= self.cy and self.se is not None: + hits.update(self.se.hit(rect)) return hits diff --git a/tests/pyscroll/test_quadtree.py b/tests/pyscroll/test_quadtree.py new file mode 100644 index 0000000..e654d4c --- /dev/null +++ b/tests/pyscroll/test_quadtree.py @@ -0,0 +1,64 @@ +import unittest + +from pygame.rect import Rect + +from pyscroll.quadtree import FastQuadTree + + +class TestFastQuadTree(unittest.TestCase): + + def test_init(self): + rectangles = [Rect(0, 0, 10, 10), Rect(5, 5, 10, 10), Rect(10, 10, 10, 10)] + quadtree = FastQuadTree(rectangles) + self.assertIsNotNone(quadtree) + + def test_hit(self): + rectangles = [Rect(0, 0, 10, 10), Rect(5, 5, 10, 10), Rect(10, 10, 10, 10)] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(Rect(2, 2, 12, 12)) + self.assertGreater(len(collisions), 0) + + def test_hit_no_collisions(self): + rectangles = [Rect(0, 0, 10, 10), Rect(20, 20, 10, 10), Rect(30, 30, 10, 10)] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(Rect(5, 5, 5, 5)) + self.assertEqual(len(collisions), 1) + + def test_hit_empty(self): + rectangles = [Rect(0, 0, 10, 10)] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(Rect(0, 0, 10, 10)) + self.assertEqual(len(collisions), 1) + + def test_hit_empty_tree(self): + rectangles = [] + with self.assertRaises(ValueError): + FastQuadTree(rectangles) + + def test_overlap_multiple_quadrants(self): + rectangles = [ + Rect(0, 0, 50, 50), # overlaps all quadrants + Rect(60, 0, 10, 10), # NE + Rect(0, 60, 10, 10), # SW + ] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(Rect(25, 25, 10, 10)) + self.assertIn((0, 0, 50, 50), collisions) + + def test_deep_tree_structure(self): + rectangles = [Rect(i * 5, i * 5, 4, 4) for i in range(50)] + quadtree = FastQuadTree(rectangles, depth=6) + self.assertEqual(len(list(quadtree)), 50) + + def test_exact_match(self): + target = Rect(10, 10, 10, 10) + rectangles = [Rect(0, 0, 10, 10), target, Rect(20, 20, 10, 10)] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(target) + self.assertIn(tuple(target), collisions) + + def test_hit_outside_bounds(self): + rectangles = [Rect(0, 0, 10, 10), Rect(20, 20, 10, 10)] + quadtree = FastQuadTree(rectangles) + collisions = quadtree.hit(Rect(100, 100, 10, 10)) + self.assertEqual(len(collisions), 0)