Source code for simvx.core.shapecast

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