"""Physics body nodes: rigid, static, and kinematic bodies.
Private leaf module: import via the ``simvx.core.physics`` facade.
"""
import numpy as np
from ..collision import CollisionShape
from ..descriptors import Property
from ..signals import Signal
from ..math.types import Vec2, Vec3
from ..nodes_2d.node2d import Node2D
from ..nodes_3d.node3d import Node3D
from ..properties import Bitmask
from ._material import BodyMode, Contact, PhysicsMaterial
from ._server import PhysicsServer
__all__ = [
"RigidBody2D",
"RigidBody3D",
"StaticBody2D",
"StaticBody3D",
"KinematicBody2D",
"KinematicBody3D",
]
# ============================================================================
# _PhysicsBodyMixin: Shared logic for RigidBody2D/3D
# ============================================================================
class _PhysicsBodyMixin:
"""Mixin providing physics body behavior for Node2D/Node3D subclasses.
Not intended for direct use. Provides force accumulation, collision shape
management, and PhysicsServer registration.
"""
def _physics_body_init(self):
"""Initialize physics body state. Called from __init__."""
self._accumulated_force = np.zeros(3, dtype=np.float32)
self._accumulated_torque = np.zeros(3, dtype=np.float32)
self._physics_shape: CollisionShape | None = None
self.body_entered = Signal()
self.body_exited = Signal()
def apply_force(self, force, position=None) -> None:
"""Apply a continuous force (integrated over the next physics step).
Args:
force: Force vector (Vec2/Vec3/tuple).
position: Optional application point for computing torque.
If None, force is applied at the center of mass.
"""
if isinstance(force, Vec3):
f = np.array([force.x, force.y, force.z], dtype=np.float32)
elif isinstance(force, Vec2):
f = np.array([force.x, force.y, 0.0], dtype=np.float32)
else:
f = np.asarray(force, dtype=np.float32)
if f.shape[0] == 2:
f = np.array([f[0], f[1], 0.0], dtype=np.float32)
self._accumulated_force += f
if position is not None:
# Compute torque from off-center force application
if isinstance(position, Vec3):
r = np.array([position.x, position.y, position.z], dtype=np.float32)
elif isinstance(position, Vec2):
r = np.array([position.x, position.y, 0.0], dtype=np.float32)
else:
r = np.asarray(position, dtype=np.float32)
if r.shape[0] == 2:
r = np.array([r[0], r[1], 0.0], dtype=np.float32)
self._accumulated_torque += np.cross(r, f)
def apply_impulse(self, impulse, position=None) -> None:
"""Apply an instantaneous impulse (immediately changes velocity).
Args:
impulse: Impulse vector (Vec2/Vec3/tuple).
position: Optional application point for computing angular impulse.
"""
if isinstance(impulse, Vec3):
imp = np.array([impulse.x, impulse.y, impulse.z], dtype=np.float32)
elif isinstance(impulse, Vec2):
imp = np.array([impulse.x, impulse.y, 0.0], dtype=np.float32)
else:
imp = np.asarray(impulse, dtype=np.float32)
if imp.shape[0] == 2:
imp = np.array([imp[0], imp[1], 0.0], dtype=np.float32)
if self.mass > 0:
vel = imp / self.mass
if isinstance(self, Node3D):
lv = self.linear_velocity
self.linear_velocity = Vec3(lv.x + vel[0], lv.y + vel[1], lv.z + vel[2])
elif isinstance(self, Node2D):
lv = self.linear_velocity
self.linear_velocity = Vec2(lv.x + vel[0], lv.y + vel[1])
if position is not None and self.mass > 0:
if isinstance(position, Vec3):
r = np.array([position.x, position.y, position.z], dtype=np.float32)
elif isinstance(position, Vec2):
r = np.array([position.x, position.y, 0.0], dtype=np.float32)
else:
r = np.asarray(position, dtype=np.float32)
if r.shape[0] == 2:
r = np.array([r[0], r[1], 0.0], dtype=np.float32)
inertia = self.mass * 0.4
angular_impulse = np.cross(r, imp)
if inertia > 0:
dav = angular_impulse / inertia
if isinstance(self, Node3D):
av = self.angular_velocity
self.angular_velocity = Vec3(av.x + dav[0], av.y + dav[1], av.z + dav[2])
elif isinstance(self, Node2D):
self.angular_velocity += float(dav[2])
def apply_torque(self, torque) -> None:
"""Apply a continuous torque (integrated over the next physics step).
Args:
torque: Torque vector (Vec3/float for 2D).
"""
if isinstance(torque, int | float):
self._accumulated_torque[2] += torque
elif isinstance(torque, Vec3):
self._accumulated_torque += np.array([torque.x, torque.y, torque.z], dtype=np.float32)
elif isinstance(torque, Vec2):
self._accumulated_torque[2] += torque.x
else:
self._accumulated_torque += np.asarray(torque, dtype=np.float32)
@property
def collision_shape(self) -> CollisionShape | None:
"""The collision shape used by the physics engine.
Assign a :class:`CollisionShape` (SphereShape, BoxShape, ConvexShape).
"""
return self._physics_shape
@collision_shape.setter
def collision_shape(self, shape: CollisionShape) -> None:
self._physics_shape = shape
def _register_with_server(self) -> None:
"""Register this body with the PhysicsServer."""
PhysicsServer.get().add_body(self)
def _unregister_from_server(self) -> None:
"""Unregister this body from the PhysicsServer."""
PhysicsServer.get().remove_body(self)
# ============================================================================
# RigidBody3D
# ============================================================================
[docs]
class RigidBody3D(_PhysicsBodyMixin, Node3D):
"""3D rigid body with full physics simulation.
Supports dynamic, kinematic, and static modes. Collision shapes are set
via the ``collision_shape`` property or auto-detected from child nodes.
Signals:
body_entered: Emitted when another body starts overlapping.
body_exited: Emitted when a previously overlapping body separates.
Example:
class Ball(RigidBody3D):
mass = Property(2.0)
physics_material = PhysicsMaterial(restitution=0.8)
def on_ready(self):
self.collision_shape = SphereShape(radius=0.5)
"""
mass = Property(1.0, range=(0.001, 100000), hint="Body mass in kg", group="Physics")
gravity_scale = Property(1.0, range=(-10, 10), hint="Gravity multiplier", group="Physics")
linear_damp = Property(0.0, range=(0, 100), hint="Linear velocity damping", group="Physics")
angular_damp = Property(0.0, range=(0, 100), hint="Angular velocity damping", group="Physics")
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
# Extract physics-specific kwargs before super().__init__
mode = kwargs.pop("mode", BodyMode.DYNAMIC)
physics_material = kwargs.pop("physics_material", None)
linear_velocity = kwargs.pop("linear_velocity", None)
angular_velocity = kwargs.pop("angular_velocity", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = mode
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec3 = Vec3(linear_velocity) if linear_velocity else Vec3()
self.angular_velocity: Vec3 = Vec3(angular_velocity) if angular_velocity else Vec3()
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
[docs]
def on_physics_process(self, dt: float) -> None:
"""Called each physics tick. Override for custom behavior.
The PhysicsServer handles integration; this hook runs after forces
are accumulated but before the server step.
"""
pass
# ============================================================================
# RigidBody2D
# ============================================================================
[docs]
class RigidBody2D(_PhysicsBodyMixin, Node2D):
"""2D rigid body with full physics simulation.
Same API as RigidBody3D but works in 2D space (x, y).
Angular velocity is a scalar (rotation around the z-axis).
Example:
class Ball2D(RigidBody2D):
mass = Property(1.0)
def on_ready(self):
self.collision_shape = SphereShape(radius=10)
"""
mass = Property(1.0, range=(0.001, 100000), hint="Body mass in kg", group="Physics")
gravity_scale = Property(1.0, range=(-10, 10), hint="Gravity multiplier", group="Physics")
linear_damp = Property(0.0, range=(0, 100), hint="Linear velocity damping", group="Physics")
angular_damp = Property(0.0, range=(0, 100), hint="Angular velocity damping", group="Physics")
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
mode = kwargs.pop("mode", BodyMode.DYNAMIC)
physics_material = kwargs.pop("physics_material", None)
linear_velocity = kwargs.pop("linear_velocity", None)
angular_velocity = kwargs.pop("angular_velocity", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = mode
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec2 = Vec2(linear_velocity) if linear_velocity else Vec2()
self.angular_velocity: float = float(angular_velocity) if angular_velocity else 0.0
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
[docs]
def on_physics_process(self, dt: float) -> None:
pass
# ============================================================================
# StaticBody3D / StaticBody2D: Immovable bodies
# ============================================================================
[docs]
class StaticBody3D(_PhysicsBodyMixin, Node3D):
"""Immovable 3D physics body. Used for floors, walls, and obstacles.
Has infinite effective mass: never moved by forces or collisions,
but other bodies collide against it normally.
Example:
ground = StaticBody3D(position=Vec3(0, -1, 0))
ground.collision_shape = BoxShape(half_extents=(50, 1, 50))
"""
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
physics_material = kwargs.pop("physics_material", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = BodyMode.STATIC
self.mass: float = 0.0 # Infinite mass (inverse = 0)
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec3 = Vec3()
self.angular_velocity: Vec3 = Vec3()
self.gravity_scale: float = 0.0
self.linear_damp: float = 0.0
self.angular_damp: float = 0.0
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
[docs]
class StaticBody2D(_PhysicsBodyMixin, Node2D):
"""Immovable 2D physics body. Used for platforms and walls.
Same behavior as StaticBody3D but in 2D space.
"""
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
physics_material = kwargs.pop("physics_material", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = BodyMode.STATIC
self.mass: float = 0.0
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec2 = Vec2()
self.angular_velocity: float = 0.0
self.gravity_scale: float = 0.0
self.linear_damp: float = 0.0
self.angular_damp: float = 0.0
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
# ============================================================================
# KinematicBody3D / KinematicBody2D: Code-driven bodies
# ============================================================================
[docs]
class KinematicBody3D(_PhysicsBodyMixin, Node3D):
"""3D body moved by code that participates in collisions.
Not affected by gravity or forces, but pushes dynamic bodies.
Use ``move_and_collide()`` for movement with collision detection.
Example:
class Platform(KinematicBody3D):
def on_physics_process(self, dt):
self.linear_velocity = Vec3(0, math.sin(time) * 2, 0)
"""
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
physics_material = kwargs.pop("physics_material", None)
linear_velocity = kwargs.pop("linear_velocity", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = BodyMode.KINEMATIC
self.mass: float = 1.0 # Mass for push calculations
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec3 = Vec3(linear_velocity) if linear_velocity else Vec3()
self.angular_velocity: Vec3 = Vec3()
self.gravity_scale: float = 0.0
self.linear_damp: float = 0.0
self.angular_damp: float = 0.0
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
[docs]
def move_and_collide(self, velocity, dt: float = 1.0) -> Contact | None:
"""Move by velocity*dt and return the first contact, or None."""
if isinstance(velocity, Vec3):
v = np.array([velocity.x, velocity.y, velocity.z], dtype=np.float32)
else:
v = np.asarray(velocity, dtype=np.float32)
self.position = self.position + Vec3(*(v * dt))
return None # Collision handled by PhysicsServer.step()
[docs]
def on_physics_process(self, dt: float) -> None:
pass
[docs]
class KinematicBody2D(_PhysicsBodyMixin, Node2D):
"""2D body moved by code that participates in collisions.
Same behavior as KinematicBody3D but in 2D space.
"""
collision_layer = Bitmask(1, group="Collision")
collision_mask = Bitmask(1, group="Collision")
def __init__(self, **kwargs):
physics_material = kwargs.pop("physics_material", None)
linear_velocity = kwargs.pop("linear_velocity", None)
super().__init__(**kwargs)
self._physics_body_init()
self.mode: BodyMode = BodyMode.KINEMATIC
self.mass: float = 1.0
self.physics_material: PhysicsMaterial = physics_material or PhysicsMaterial()
self.linear_velocity: Vec2 = Vec2(linear_velocity) if linear_velocity else Vec2()
self.angular_velocity: float = 0.0
self.gravity_scale: float = 0.0
self.linear_damp: float = 0.0
self.angular_damp: float = 0.0
[docs]
def on_enter_tree(self) -> None:
self._register_with_server()
[docs]
def on_exit_tree(self) -> None:
self._unregister_from_server()
[docs]
def move_and_collide(self, velocity, dt: float = 1.0) -> Contact | None:
"""Move by velocity*dt and return the first contact, or None."""
if isinstance(velocity, Vec2):
v = np.array([velocity.x, velocity.y], dtype=np.float32)
else:
v = np.asarray(velocity, dtype=np.float32)
self.position = self.position + Vec2(*(v * dt))
return None
[docs]
def on_physics_process(self, dt: float) -> None:
pass