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