Source code for simvx.core.shapecast

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