Source code for simvx.core.physics._raycasting

"""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)