Source code for simvx.core.nodes_3d.spring_arm

"""SpringArm3D -- camera collision avoidance arm for 3rd-person games."""

from __future__ import annotations

from ..descriptors import Property
from ..math.types import Vec3
from .node3d import Node3D


[docs] class SpringArm3D(Node3D): """Camera collision avoidance arm for 3rd-person games. Casts a ray from its position along its forward (-Z) axis. If geometry is hit, shortens the arm so children (typically a Camera3D) avoid clipping through walls. Usage:: pivot = Node3D(name="Pivot") arm = SpringArm3D(spring_length=5.0) camera = Camera3D() pivot.add_child(arm) arm.add_child(camera) # Camera auto-positions at arm tip, shortened if blocked """ spring_length = Property(5.0, range=(0.1, 100.0), hint="Maximum arm length") margin = Property(0.01, range=(0.0, 1.0), hint="Collision margin (pulls camera forward)") collision_mask = Property(0xFFFFFFFF, hint="Physics layer mask for collision check") shape_radius = Property(0.0, range=(0.0, 2.0), hint="Sphere cast radius (0 = ray)") gizmo_colour = Property((0.8, 0.8, 0.8, 0.5), hint="Editor gizmo colour") def __init__(self, **kwargs): super().__init__(**kwargs) self._hit_length: float = float(self.spring_length) self._excluded: set[int] = set()
[docs] def get_hit_length(self) -> float: """Return the current computed arm length after collision check.""" return self._hit_length
[docs] def add_excluded_object(self, obj) -> None: """Exclude a specific body from the springarm raycast.""" self._excluded.add(id(obj))
[docs] def remove_excluded_object(self, obj) -> None: """Remove an exclusion so the body is considered again.""" self._excluded.discard(id(obj))
[docs] def physics_process(self, dt: float) -> None: origin = self.world_position direction = self.forward # -Z in global space hit_length = self._cast_ray(origin, direction) self._hit_length = hit_length # Reposition children at the arm tip in local space tip = Vec3(0, 0, -hit_length) for child in self.children: if isinstance(child, Node3D): child.position = tip
def _cast_ray(self, origin: Vec3, direction: Vec3) -> float: """Cast ray/sphere and return the effective arm length.""" try: from ..physics.engine import PhysicsServer server = PhysicsServer.get() except Exception: return float(self.spring_length) # No bodies registered means no collision avoidance if not server._bodies: return float(self.spring_length) hits = server.raycast( origin, direction, max_dist=float(self.spring_length), layer_mask=int(self.collision_mask), ) # Filter excluded objects for hit in hits: if id(hit.body) not in self._excluded: length = max(0.0, hit.distance - float(self.margin)) return length return float(self.spring_length)
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return a line from pivot to arm tip, plus a cross at the tip.""" origin = self.world_position fwd = self.forward tip = origin + fwd * self._hit_length full_tip = origin + fwd * float(self.spring_length) lines: list[tuple[Vec3, Vec3]] = [] # Main arm line (pivot to current tip) lines.append((origin, tip)) # Extension to full length when shortened if self._hit_length < float(self.spring_length) - 0.01: lines.append((tip, full_tip)) # Cross at arm tip up = self.up right = self.right cross_size = 0.15 lines.append((tip - right * cross_size, tip + right * cross_size)) lines.append((tip - up * cross_size, tip + up * cross_size)) return lines