"""ShapeCast2D and ShapeCast3D — Swept shape query nodes."""
from __future__ import annotations
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
# ============================================================================
# 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 = Property(0xFFFFFFFF, hint="Layer mask for filtering", 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 = Property((1.0, 0.6, 0.0, 0.6), hint="Editor gizmo colour")
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 = Property(0xFFFFFFFF, hint="Layer mask for filtering", 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 = Property((1.0, 0.6, 0.0, 0.6), hint="Editor gizmo colour")
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()