guitarHeroButBetter/py/engine/raycasts.py
James Whiteman 3f7b409302 Python code
2026-01-30 21:16:42 -08:00

190 lines
6.3 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from Box2D import (b2AABB, b2Body, b2CircleShape, b2PolygonShape, b2QueryCallback,
b2RayCastCallback, b2Transform, b2Vec2, b2TestOverlap)
@dataclass
class RayHit:
"""Raycast hit data.
Attributes:
hit: True if the ray hit something.
body: The body hit, if any.
fraction: Fraction along the ray where the hit occurred.
distance: Distance along the ray in world units.
point: World-space hit point.
normal: World-space hit normal.
"""
hit: bool = False
body: Optional[b2Body] = None
fraction: float = 1.0
distance: float = 0.0
point: b2Vec2 = b2Vec2(0.0, 0.0)
normal: b2Vec2 = b2Vec2(0.0, 0.0)
class _RayCastClosest(b2RayCastCallback):
def __init__(self, ignore_body: Optional[b2Body], translation: b2Vec2, result: RayHit) -> None:
super().__init__()
self.ignore_body = ignore_body
self.translation = translation
self.result = result
def ReportFixture(self, fixture, point, normal, fraction): # noqa: N802
if self.ignore_body is not None and fixture.body == self.ignore_body:
return 1.0
if fraction < self.result.fraction:
self.result.hit = True
self.result.fraction = fraction
self.result.distance = self.translation.length * fraction
self.result.point = point
self.result.normal = normal
self.result.body = fixture.body
return fraction
def raycast_closest(world, ignore_body: Optional[b2Body], origin: b2Vec2, translation: b2Vec2) -> RayHit:
"""Cast a ray and return the closest hit.
Args:
world: Box2D world to query.
ignore_body: Optional body to ignore.
origin: Ray start in world units.
translation: Ray delta in world units.
Returns:
RayHit data for the closest hit (or empty hit if none).
"""
result = RayHit()
callback = _RayCastClosest(ignore_body, translation, result)
world.RayCast(callback, origin, origin + translation)
return result
def raycast_all(world, ignore_body: Optional[b2Body], origin: b2Vec2, translation: b2Vec2) -> List[RayHit]:
"""Cast a ray and return all hits.
Args:
world: Box2D world to query.
ignore_body: Optional body to ignore.
origin: Ray start in world units.
translation: Ray delta in world units.
Returns:
List of RayHit results.
"""
hits: List[RayHit] = []
class _RayCastAll(b2RayCastCallback):
def __init__(self, ignore: Optional[b2Body], translation_vec: b2Vec2) -> None:
super().__init__()
self.ignore = ignore
self.translation_vec = translation_vec
def ReportFixture(self, fixture, point, normal, fraction): # noqa: N802
if self.ignore is not None and fixture.body == self.ignore:
return 1.0
hit = RayHit(True, fixture.body, fraction, self.translation_vec.length * fraction, point, normal)
hits.append(hit)
return fraction
world.RayCast(_RayCastAll(ignore_body, translation), origin, origin + translation)
return hits
def _aabb_for_circle(center: b2Vec2, radius: float) -> b2AABB:
lower = b2Vec2(center.x - radius, center.y - radius)
upper = b2Vec2(center.x + radius, center.y + radius)
return b2AABB(lowerBound=lower, upperBound=upper)
def _aabb_for_box(center: b2Vec2, half_w: float, half_h: float, angle: float) -> b2AABB:
# Conservative AABB for rotated box
import math
cos_a = abs(math.cos(angle))
sin_a = abs(math.sin(angle))
extent_x = half_w * cos_a + half_h * sin_a
extent_y = half_w * sin_a + half_h * cos_a
lower = b2Vec2(center.x - extent_x, center.y - extent_y)
upper = b2Vec2(center.x + extent_x, center.y + extent_y)
return b2AABB(lowerBound=lower, upperBound=upper)
def shape_hit(world, ignore_body: Optional[b2Body], shape, transform: b2Transform) -> List[b2Body]:
"""Query overlaps for a shape and return hit bodies.
Args:
world: Box2D world to query.
ignore_body: Optional body to ignore.
shape: Box2D shape to test.
transform: Transform for the shape.
Returns:
List of bodies overlapping the shape.
"""
aabb = shape.getAABB(transform, 0)
hits: List[b2Body] = []
class _QueryCallback(b2QueryCallback):
def __init__(self, ignore: Optional[b2Body]) -> None:
super().__init__()
self.ignore = ignore
def ReportFixture(self, fixture): # noqa: N802
body = fixture.body
if self.ignore is not None and body == self.ignore:
return True
if b2TestOverlap(shape, 0, fixture.shape, 0, transform, fixture.body.transform):
if body not in hits:
hits.append(body)
return True
world.QueryAABB(_QueryCallback(ignore_body), aabb)
return hits
def circle_hit(world, ignore_body: Optional[b2Body], center: b2Vec2, radius: float) -> List[b2Body]:
"""Check for circle overlaps in the world.
Args:
world: Box2D world to query.
ignore_body: Optional body to ignore.
center: Circle center in world units.
radius: Circle radius in world units.
Returns:
List of bodies overlapping the circle.
"""
shape = b2CircleShape(radius=radius, pos=b2Vec2(0.0, 0.0))
transform = b2Transform()
transform.position = center
transform.angle = 0.0
return shape_hit(world, ignore_body, shape, transform)
def rectangle_hit(world, ignore_body: Optional[b2Body], center: b2Vec2, size: b2Vec2, rotation: float = 0.0) -> List[b2Body]:
"""Check for rectangle overlaps in the world.
Args:
world: Box2D world to query.
ignore_body: Optional body to ignore.
center: Rectangle center in world units.
size: Rectangle size in world units.
rotation: Rotation in radians.
Returns:
List of bodies overlapping the rectangle.
"""
half_w = size.x / 2.0
half_h = size.y / 2.0
shape = b2PolygonShape(box=(half_w, half_h))
transform = b2Transform()
transform.position = center
transform.angle = rotation
return shape_hit(world, ignore_body, shape, transform)