"""ShapeCast2D and ShapeCast3D — Swept shape query nodes."""
import math
from typing import Any
import numpy as np
from .collision import CollisionShape, SphereShape
from .descriptors import Property, Signal
from .math.types import Vec2, Vec3
from .nodes_2d.node2d import Node2D
from .nodes_3d.node3d import Node3D
from .properties import Bitmask, Colour
# ============================================================================
# ShapeCast2D — Swept shape query (2D)
# ============================================================================
[docs]
class ShapeCast2D(Node2D):
"""Swept shape query node for 2D. Like a thick raycast.
Sweeps a collision shape from this node's global position to
world_position + target_position, reporting all bodies intersected.
"""
target_position = Property(Vec2(0, 50), hint="End point relative to node position", group="Collision")
collision_mask = Bitmask(0xFFFFFFFF, group="Collision")
max_results = Property(8, range=(1, 64), hint="Max collisions to report", group="Collision")
enabled = Property(True, hint="Enable shape cast query", group="Collision")
exclude_parent = Property(True, hint="Skip parent body from results", group="Collision")
margin = Property(0.0, range=(0, 100), hint="Extra margin around shape", group="Collision")
gizmo_colour = Colour((1.0, 0.6, 0.0, 0.6))
collision_detected = Signal()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return arrow line from position to target_position in world space."""
p = self.world_position
tp = self.target_position
end = Vec2(p.x + tp.x, p.y + tp.y)
lines: list[tuple[Vec2, Vec2]] = [(p, end)]
# Arrowhead
dx, dy = end.x - p.x, end.y - p.y
ln = math.sqrt(dx * dx + dy * dy)
if ln > 1e-6:
ndx, ndy = dx / ln, dy / ln
px, py = -ndy, ndx
head = min(10.0, ln * 0.3)
lines.append((end, Vec2(end.x - ndx * head + px * head * 0.4, end.y - ndy * head + py * head * 0.4)))
lines.append((end, Vec2(end.x - ndx * head - px * head * 0.4, end.y - ndy * head - py * head * 0.4)))
return lines
def __init__(self, shape: CollisionShape | None = None, **kwargs):
super().__init__(**kwargs)
self._shape: CollisionShape = shape or SphereShape(0.5)
self._world = None
self._results: list = []
@property
def shape(self) -> CollisionShape:
return self._shape
@shape.setter
def shape(self, value: CollisionShape):
self._shape = value
@property
def is_colliding(self) -> bool:
return len(self._results) > 0
@property
def collision_count(self) -> int:
return len(self._results)
[docs]
def get_collider(self, index: int = 0) -> Any:
return self._results[index].body if index < len(self._results) else None
[docs]
def get_collision_point(self, index: int = 0) -> Vec2:
if index < len(self._results):
p = self._results[index].point
return Vec2(p[0], p[1])
return Vec2()
[docs]
def get_collision_normal(self, index: int = 0) -> Vec2:
if index < len(self._results):
n = self._results[index].normal
return Vec2(n[0], n[1])
return Vec2()
[docs]
def get_collision_fraction(self, index: int = 0) -> float:
return self._results[index].fraction if index < len(self._results) else 0.0
[docs]
def force_update(self):
self._update_query()
[docs]
def enter_tree(self):
if self._tree and hasattr(self._tree, 'collision_world'):
self._world = self._tree.collision_world
[docs]
def exit_tree(self):
self._world = None
self._results.clear()
[docs]
def physics_process(self, dt: float):
if self.enabled:
self._update_query()
def _update_query(self):
if not self._world or not self._shape:
self._results.clear()
return
gp = self.world_position
from_pos = np.array([gp.x, gp.y, 0], dtype=np.float32)
tp = self.target_position
to_pos = np.array([gp.x + tp.x, gp.y + tp.y, 0], dtype=np.float32)
exclude = {self.parent} if self.exclude_parent and self.parent else set()
was_colliding = self.is_colliding
self._results = self._world.shape_cast(
self._shape, from_pos, to_pos,
layer_mask=self.collision_mask, exclude=exclude or None, max_results=self.max_results,
)
if not was_colliding and self.is_colliding:
self.collision_detected()
# ============================================================================
# ShapeCast3D — Swept shape query (3D)
# ============================================================================
[docs]
class ShapeCast3D(Node3D):
"""Swept shape query node for 3D. Like a thick raycast.
Sweeps a collision shape from this node's global position to
world_position + target_position, reporting all bodies intersected.
"""
target_position = Property(Vec3(0, -1, 0), hint="End point relative to node position", group="Collision")
collision_mask = Bitmask(0xFFFFFFFF, group="Collision")
max_results = Property(8, range=(1, 64), hint="Max collisions to report", group="Collision")
enabled = Property(True, hint="Enable shape cast query", group="Collision")
exclude_parent = Property(True, hint="Skip parent body from results", group="Collision")
margin = Property(0.0, range=(0, 100), hint="Extra margin around shape", group="Collision")
gizmo_colour = Colour((1.0, 0.6, 0.0, 0.6))
collision_detected = Signal()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return arrow line from position to target_position in world space."""
p = self.world_position
tp = self.target_position
end = Vec3(p.x + tp.x, p.y + tp.y, p.z + tp.z)
lines: list[tuple[Vec3, Vec3]] = [(p, end)]
# Arrowhead via perpendicular offset
d = end - p
ln = d.length()
if ln > 1e-6:
nd = d * (1.0 / ln)
# Pick a perpendicular axis
up = Vec3(0, 1, 0) if abs(nd.y) < 0.9 else Vec3(1, 0, 0)
right = Vec3(*np.cross(nd, up))
rn = right.length()
if rn > 1e-6:
right = right * (1.0 / rn)
head = min(0.3, ln * 0.3)
lines.append((end, end - nd * head + right * head * 0.4))
lines.append((end, end - nd * head - right * head * 0.4))
return lines
def __init__(self, shape: CollisionShape | None = None, **kwargs):
super().__init__(**kwargs)
self._shape: CollisionShape = shape or SphereShape(0.5)
self._world = None
self._results: list = []
@property
def shape(self) -> CollisionShape:
return self._shape
@shape.setter
def shape(self, value: CollisionShape):
self._shape = value
@property
def is_colliding(self) -> bool:
return len(self._results) > 0
@property
def collision_count(self) -> int:
return len(self._results)
[docs]
def get_collider(self, index: int = 0) -> Any:
return self._results[index].body if index < len(self._results) else None
[docs]
def get_collision_point(self, index: int = 0) -> Vec3:
if index < len(self._results):
return Vec3(self._results[index].point)
return Vec3()
[docs]
def get_collision_normal(self, index: int = 0) -> Vec3:
if index < len(self._results):
return Vec3(self._results[index].normal)
return Vec3()
[docs]
def get_collision_fraction(self, index: int = 0) -> float:
return self._results[index].fraction if index < len(self._results) else 0.0
[docs]
def force_update(self):
self._update_query()
[docs]
def enter_tree(self):
if self._tree and hasattr(self._tree, 'collision_world'):
self._world = self._tree.collision_world
[docs]
def exit_tree(self):
self._world = None
self._results.clear()
[docs]
def physics_process(self, dt: float):
if self.enabled:
self._update_query()
def _update_query(self):
if not self._world or not self._shape:
self._results.clear()
return
gp = self.world_position
from_pos = np.array([gp.x, gp.y, gp.z], dtype=np.float32)
tp = self.target_position
to_pos = np.array([gp.x + tp.x, gp.y + tp.y, gp.z + tp.z], dtype=np.float32)
exclude = {self.parent} if self.exclude_parent and self.parent else set()
was_colliding = self.is_colliding
self._results = self._world.shape_cast(
self._shape, from_pos, to_pos,
layer_mask=self.collision_mask, exclude=exclude or None, max_results=self.max_results,
)
if not was_colliding and self.is_colliding:
self.collision_detected()