"""
Pygame renderer for 2D physics visualization
Renders rigid bodies, colliders, and debug information.
"""
import pygame
import math
from .physics_engine_core import Vector2, ColliderType
from .camera import Camera
[docs]
class Renderer:
"""Pygame-based renderer for physics simulation
Handles rendering of rigid bodies, colliders, forces, and debug info.
Supports camera transformations and multiple rendering modes.
"""
# Color scheme
COLORS = {
'background': (20, 20, 30),
'grid': (40, 40, 50),
'axis': (80, 80, 100),
'static_body': (100, 100, 120),
'dynamic_body': (100, 150, 255),
'collider': (150, 200, 255),
'velocity': (255, 100, 100),
'force': (255, 200, 100),
'text': (200, 200, 200),
'highlight': (255, 255, 100),
}
def __init__(self, width=1280, height=720, title="2D Physics Simulator"):
"""Initialize renderer
Args:
width: Screen width in pixels
height: Screen height in pixels
title: Window title
"""
pygame.init()
self.width = width
self.height = height
# Create display
self.screen = pygame.display.set_mode((width, height))
pygame.display.set_caption(title)
# Create camera
self.camera = Camera(width, height, pixels_per_meter=30.0)
# Fonts
self.font_small = pygame.font.Font(None, 24)
self.font_large = pygame.font.Font(None, 36)
# Rendering options
self.show_grid = True
self.show_axes = True
self.show_velocity = True
self.show_forces = False
self.show_colliders = True
self.show_stats = True
# Clock for FPS
self.clock = pygame.time.Clock()
[docs]
def clear(self):
"""Clear screen with background color"""
self.screen.fill(self.COLORS['background'])
[docs]
def draw_grid(self, spacing=1.0):
"""Draw grid lines
Args:
spacing: Grid spacing in meters
"""
if not self.show_grid:
return
min_x, max_x, min_y, max_y = self.camera.get_view_bounds()
# Vertical lines
start_x = math.floor(min_x / spacing) * spacing
x = start_x
while x <= max_x:
screen_pos = self.camera.world_to_screen(Vector2(x, 0))
color = self.COLORS['axis'] if x == 0 else self.COLORS['grid']
width = 2 if x == 0 else 1
pygame.draw.line(self.screen, color,
(screen_pos[0], 0),
(screen_pos[0], self.height),
width)
x += spacing
# Horizontal lines
start_y = math.floor(min_y / spacing) * spacing
y = start_y
while y <= max_y:
screen_pos = self.camera.world_to_screen(Vector2(0, y))
color = self.COLORS['axis'] if y == 0 else self.COLORS['grid']
width = 2 if y == 0 else 1
pygame.draw.line(self.screen, color,
(0, screen_pos[1]),
(self.width, screen_pos[1]),
width)
y += spacing
[docs]
def draw_circle_collider(self, body):
"""Draw a circle collider
Args:
body: RigidBody with CircleCollider
"""
collider = body.get_collider()
radius = collider.get_radius()
# Get world position
world_pos = body.get_position()
screen_pos = self.camera.world_to_screen(world_pos)
screen_radius = self.camera.world_to_screen_scalar(radius)
# Skip if too small or off screen
if screen_radius < 2:
return
# Choose color based on static/dynamic
if body.is_static():
color = self.COLORS['static_body']
else:
color = self.COLORS['dynamic_body']
# Draw filled circle
pygame.draw.circle(self.screen, color, screen_pos, screen_radius)
# Draw outline
if self.show_colliders:
pygame.draw.circle(self.screen, self.COLORS['collider'],
screen_pos, screen_radius, 2)
# Draw rotation indicator (line from center to edge)
angle = body.get_rotation()
end_x = world_pos.x + radius * math.cos(angle)
end_y = world_pos.y + radius * math.sin(angle)
end_screen = self.camera.world_to_screen(Vector2(end_x, end_y))
pygame.draw.line(self.screen, (255, 255, 255),
screen_pos, end_screen, 2)
[docs]
def draw_box_collider(self, body):
"""Draw a box collider
Args:
body: RigidBody with BoxCollider
"""
collider = body.get_collider()
width = collider.get_width()
height = collider.get_height()
# Get world position and rotation
world_pos = body.get_position()
rotation = body.get_rotation()
# Calculate the four corners in local space
half_w = width / 2.0
half_h = height / 2.0
local_corners = [
Vector2(-half_w, -half_h), # Bottom-left
Vector2(half_w, -half_h), # Bottom-right
Vector2(half_w, half_h), # Top-right
Vector2(-half_w, half_h), # Top-left
]
# Transform corners to world space and then to screen space
screen_corners = []
cos_r = math.cos(rotation)
sin_r = math.sin(rotation)
for local_corner in local_corners:
# Rotate
rotated_x = local_corner.x * cos_r - local_corner.y * sin_r
rotated_y = local_corner.x * sin_r + local_corner.y * cos_r
# Translate
world_corner = Vector2(
world_pos.x + rotated_x,
world_pos.y + rotated_y
)
# Convert to screen space
screen_corner = self.camera.world_to_screen(world_corner)
screen_corners.append(screen_corner)
# Skip if too small
if len(screen_corners) < 3:
return
# Choose color based on static/dynamic
if body.is_static():
color = self.COLORS['static_body']
else:
color = self.COLORS['dynamic_body']
# Draw filled polygon
pygame.draw.polygon(self.screen, color, screen_corners)
# Draw outline
if self.show_colliders:
pygame.draw.polygon(self.screen, self.COLORS['collider'],
screen_corners, 2)
# Draw rotation indicator (line from center to right edge midpoint)
center_screen = self.camera.world_to_screen(world_pos)
indicator_end = Vector2(
world_pos.x + half_w * cos_r,
world_pos.y + half_w * sin_r
)
indicator_screen = self.camera.world_to_screen(indicator_end)
pygame.draw.line(self.screen, (255, 255, 255),
center_screen, indicator_screen, 2)
[docs]
def draw_body(self, body):
"""Draw a rigid body
Args:
body: RigidBody to draw
"""
if not body.has_collider():
return
collider = body.get_collider()
collider_type = collider.get_type()
if collider_type == ColliderType.Circle:
self.draw_circle_collider(body)
elif collider_type == ColliderType.Box:
self.draw_box_collider(body)
[docs]
def draw_velocity_vector(self, body, scale=0.5):
"""Draw velocity vector
Args:
body: RigidBody
scale: Vector scale factor
"""
if not self.show_velocity:
return
vel = body.get_velocity()
if vel.length() < 0.1: # Skip if too small
return
pos = body.get_position()
screen_start = self.camera.world_to_screen(pos)
# Scale velocity for visibility
end_pos = Vector2(pos.x + vel.x * scale, pos.y + vel.y * scale)
screen_end = self.camera.world_to_screen(end_pos)
# Draw arrow
pygame.draw.line(self.screen, self.COLORS['velocity'],
screen_start, screen_end, 2)
# Draw arrowhead
self._draw_arrow_head(screen_start, screen_end, self.COLORS['velocity'])
def _draw_arrow_head(self, start, end, color, size=10):
"""Draw arrow head at end of line
Args:
start: Start position (x, y)
end: End position (x, y)
color: Arrow color
size: Arrowhead size in pixels
"""
# Calculate direction
dx = end[0] - start[0]
dy = end[1] - start[1]
length = math.sqrt(dx*dx + dy*dy)
if length < 0.1:
return
# Normalize
dx /= length
dy /= length
# Calculate arrowhead points
angle = math.pi / 6 # 30 degrees
cos_a = math.cos(angle)
sin_a = math.sin(angle)
# Left point
left_x = end[0] - size * (dx * cos_a - dy * sin_a)
left_y = end[1] - size * (dy * cos_a + dx * sin_a)
# Right point
right_x = end[0] - size * (dx * cos_a + dy * sin_a)
right_y = end[1] - size * (dy * cos_a - dx * sin_a)
# Draw triangle
pygame.draw.polygon(self.screen, color,
[end, (int(left_x), int(left_y)), (int(right_x), int(right_y))])
[docs]
def draw_world(self, world):
"""Draw entire physics world
Args:
world: PhysicsWorld to render
"""
# Draw grid and axes
self.draw_grid()
# Draw all bodies
bodies = world.get_bodies()
for body in bodies:
self.draw_body(body)
self.draw_velocity_vector(body)
# Draw stats
if self.show_stats:
self.draw_stats(world)
[docs]
def draw_stats(self, world):
"""Draw statistics overlay
Args:
world: PhysicsWorld
"""
stats = [
f"Bodies: {world.get_body_count()}",
f"Zoom: {self.camera.pixels_per_meter:.1f} px/m",
f"Camera: ({self.camera.position.x:.1f}, {self.camera.position.y:.1f})",
f"FPS: {self.clock.get_fps():.0f}",
]
y = 10
for stat in stats:
text = self.font_small.render(stat, True, self.COLORS['text'])
self.screen.blit(text, (10, y))
y += 25
[docs]
def draw_text(self, text, position, font='small', color='text'):
"""Draw text at position
Args:
text: Text string
position: Screen position (x, y)
font: 'small' or 'large'
color: Color name from COLORS dict
"""
font_obj = self.font_small if font == 'small' else self.font_large
color_val = self.COLORS.get(color, self.COLORS['text'])
surface = font_obj.render(text, True, color_val)
self.screen.blit(surface, position)
[docs]
def draw_help(self):
"""Draw help overlay"""
help_text = [
"Controls:",
" Mouse Drag: Pan camera",
" Mouse Wheel: Zoom",
" Space: Pause/Resume",
" R: Reset simulation",
" G: Toggle grid",
" V: Toggle velocity vectors",
" H: Toggle this help",
" ESC: Quit",
]
# Semi-transparent background
overlay = pygame.Surface((300, 250))
overlay.set_alpha(200)
overlay.fill((20, 20, 30))
self.screen.blit(overlay, (self.width - 320, 10))
# Draw text
y = 20
for line in help_text:
self.draw_text(line, (self.width - 310, y), font='small', color='text')
y += 25
[docs]
def flip(self):
"""Update display"""
pygame.display.flip()
[docs]
def tick(self, fps=60):
"""Limit frame rate
Args:
fps: Target frames per second
Returns:
float: Delta time in seconds
"""
return self.clock.tick(fps) / 1000.0
[docs]
def quit(self):
"""Cleanup and quit"""
pygame.quit()