Source code for simvx.core.physics._bodies

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