"""Persistent raycast query nodes: RayCast2D and RayCast3D.
Private leaf module: import via the ``simvx.core.physics`` facade.
"""
import math
import numpy as np
from ..collision import RayHit
from ..descriptors import Property
from ..math.types import Vec2, Vec3
from ..nodes_2d.node2d import Node2D
from ..nodes_3d.node3d import Node3D
from ..properties import Bitmask
from ._server import PhysicsServer
__all__ = [
"RayCast2D",
"RayCast3D",
]
# ============================================================================
# RayCast3D: Persistent ray query node
# ============================================================================
[docs]
class RayCast3D(Node3D):
"""Persistent ray query node for 3D physics.
Casts a ray from the node's global position towards ``target_position``
(in local space) each physics frame and caches the result. Useful for
ground detection, line-of-sight checks, lasers, etc.
Example:
ray = RayCast3D(target_position=Vec3(0, -2, 0))
player.add_child(ray)
# Later, in physics_process:
if ray.is_colliding():
print("Hit:", ray.collider, "at", ray.collision_point)
"""
target_position = Property(Vec3(0, -1, 0), hint="Local-space ray endpoint")
collision_mask = Bitmask(0xFFFFFFFF, group="Collision")
enabled = Property(True, hint="Enable/disable raycasting")
gizmo_colour = Property((1.0, 0.6, 0.0, 0.6), hint="Editor gizmo colour")
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return ray line with arrowhead from position to target in world space."""
p = self.world_position
target = Vec3(self.target_position)
direction = self.world_rotation * target
end = p + direction
lines: list[tuple[Vec3, Vec3]] = [(p, end)]
ln = direction.length()
if ln > 1e-6:
nd = direction * (1.0 / ln)
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.15, ln * 0.2)
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, target_position=None, collision_mask: int = 0xFFFFFFFF, enabled: bool = True, **kwargs):
super().__init__(**kwargs)
if target_position is not None:
self.target_position = Vec3(target_position)
self.collision_mask = collision_mask
self.enabled = enabled
self._hits: list[RayHit] = []
[docs]
def on_physics_process(self, dt: float) -> None:
self._hits = []
if not self.enabled:
return
server = PhysicsServer.get()
origin = self.world_position
# target_position is in local space: convert to world direction
target = Vec3(self.target_position)
direction = self.world_rotation * target
max_dist = float(np.linalg.norm(np.array([direction.x, direction.y, direction.z], dtype=np.float32)))
if max_dist < 1e-10:
return
self._hits = server.raycast(origin, direction, max_dist=max_dist, layer_mask=self.collision_mask)
[docs]
def is_colliding(self) -> bool:
"""True if the ray hit something on the last physics frame."""
return len(self._hits) > 0
[docs]
@property
def collider(self) -> Node3D | None:
"""The first body hit, or None."""
return self._hits[0].body if self._hits else None
[docs]
@property
def collision_point(self) -> Vec3:
"""World-space collision point of the first hit."""
return Vec3(self._hits[0].point) if self._hits else Vec3()
[docs]
@property
def collision_normal(self) -> Vec3:
"""Outward surface normal at the first hit (zero vector if no hit)."""
return Vec3(self._hits[0].normal) if self._hits else Vec3()
[docs]
@property
def all_hits(self) -> list[RayHit]:
"""All hits from the last cast, sorted by distance."""
return list(self._hits)
# ============================================================================
# RayCast2D: Persistent ray query node
# ============================================================================
[docs]
class RayCast2D(Node2D):
"""Persistent ray query node for 2D physics.
Casts a ray from the node's global position towards ``target_position``
(in local space) each physics frame and caches the result.
Example:
ray = RayCast2D(target_position=Vec2(0, 100))
player.add_child(ray)
if ray.is_colliding():
print("Hit:", ray.collider)
"""
target_position = Property(Vec2(0, 1), hint="Local-space ray endpoint")
collision_mask = Bitmask(0xFFFFFFFF, group="Collision")
enabled = Property(True, hint="Enable/disable raycasting")
gizmo_colour = Property((1.0, 0.6, 0.0, 0.6), hint="Editor gizmo colour")
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return ray line with arrowhead from position to target in world space."""
p = self.world_position
target = Vec2(self.target_position)
angle = self.world_rotation
cos_a, sin_a = math.cos(angle), math.sin(angle)
direction = Vec2(target.x * cos_a - target.y * sin_a, target.x * sin_a + target.y * cos_a)
end = Vec2(p.x + direction.x, p.y + direction.y)
lines: list[tuple[Vec2, Vec2]] = [(p, end)]
ln = direction.length()
if ln > 1e-6:
ndx, ndy = direction.x / ln, direction.y / ln
px, py = -ndy, ndx
head = min(8.0, ln * 0.2)
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, target_position=None, collision_mask: int = 0xFFFFFFFF, enabled: bool = True, **kwargs):
super().__init__(**kwargs)
if target_position is not None:
self.target_position = Vec2(target_position)
self.collision_mask = collision_mask
self.enabled = enabled
self._hits: list[RayHit] = []
[docs]
def on_physics_process(self, dt: float) -> None:
self._hits = []
if not self.enabled:
return
server = PhysicsServer.get()
origin = self.world_position
# Rotate target_position by node's global rotation
target = Vec2(self.target_position)
angle = self.world_rotation
cos_a, sin_a = math.cos(angle), math.sin(angle)
direction = Vec2(target.x * cos_a - target.y * sin_a, target.x * sin_a + target.y * cos_a)
max_dist = float(direction.length())
if max_dist < 1e-10:
return
self._hits = server.raycast(origin, direction, max_dist=max_dist, layer_mask=self.collision_mask)
[docs]
def is_colliding(self) -> bool:
"""True if the ray hit something on the last physics frame."""
return len(self._hits) > 0
[docs]
@property
def collider(self) -> Node2D | None:
"""The first body hit, or None."""
return self._hits[0].body if self._hits else None
[docs]
@property
def collision_point(self) -> Vec2:
"""World-space collision point of the first hit."""
if self._hits:
p = self._hits[0].point
return Vec2(float(p[0]), float(p[1]))
return Vec2()
[docs]
@property
def collision_normal(self) -> Vec2:
"""Outward surface normal at the first hit (zero vector if no hit)."""
if self._hits:
n = self._hits[0].normal
return Vec2(float(n[0]), float(n[1]))
return Vec2()
[docs]
@property
def all_hits(self) -> list[RayHit]:
"""All hits from the last cast, sorted by distance."""
return list(self._hits)