Source code for simvx.core.physics.nodes

"""Body + shape-carrier nodes for the new physics seam (3D only).

Role
----
This is the user-facing 3D body/shape node taxonomy on the new transport seam:

- :class:`CollisionShape3D` -- a ``Node3D`` that *carries* a :class:`Shape`
  resource. Bodies discover their geometry by scanning their direct children
  for the first ``CollisionShape3D`` and building its shape.
- :class:`PhysicsBody3D` -- the one concrete body node. Its motion mode is a
  user-facing, inspectable, serialized :class:`BodyMode` Property
  (``STATIC | KINEMATIC | DYNAMIC``), not fixed per class: one body node with a
  runtime-mutable ``mode`` knob, mirroring every backend's seam (Jolt
  ``EMotionType``, pymunk ``body_type``, Box2D ``b2BodyType``). On enter-tree it
  resolves its world (:func:`resolve_world`), builds its shape, creates a seam
  body, and (for non-static bodies) registers with the tree's handle->node sync
  registry. On exit-tree it unregisters, destroys the seam body, and clears its
  state.
- :class:`CharacterBody3D` -- a swept-collision controller (NOT a body),
  distinct from the :class:`PhysicsBody3D` family.

These nodes deliberately reuse the canonical names ``CollisionShape3D`` /
``PhysicsBody3D`` / ``CharacterBody3D`` but live ONLY here. They are NOT wired
into any facade (``simvx.core`` / ``simvx.core.physics`` still export the OLD
body system); tests import them via this module path. The facade flip + removal
of the old nodes is a later stage; 2D is later still.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING

from ..descriptors import Property
from ..math import Quat, Vec3
from ..nodes_3d.node3d import Node3D
from ..properties import Bitmask
from ..signals import Signal
from .material import PhysicsMaterial
from .root import resolve_world
from .shapes import Shape, SphereShape3D
from .world import BodyMode

if TYPE_CHECKING:
    from .world import BodyHandle, CharacterHandle, JointHandle, PhysicsWorld, ShapeHandle
    from .world import Contact as SeamContact  # noqa: F401  (query/sweep result, distinct from node Contact)

log = logging.getLogger(__name__)

__all__ = [
    "BodyMode",
    "Contact",
    "CollisionShape3D",
    "PhysicsBody3D",
    "CharacterBody3D",
    "Area3D",
    "GravityArea3D",
    "Joint3D",
    "FixedJoint3D",
    "PinJoint3D",
    "HingeJoint3D",
    "SpringJoint3D",
]


[docs] @dataclass(slots=True, frozen=True) class Contact: """Node-level collision-event payload for ``collided`` / ``separated``. Distinct from the seam-level ``world.Contact`` (a query/sweep TOI result): this is node-typed and built by the tree's dispatch from a node-agnostic ``ContactEvent``. The tree fills ``other`` with the peer node and reorients ``normal`` / ``velocity`` so they always point toward the RECEIVING body. Attributes: other: The OTHER body involved in the collision. point: World-space contact point. normal: Unit normal oriented TOWARD the receiving body (the separating direction). Degenerate (``Vec3(0)``) on ``separated`` (no live manifold on exit). impulse: Normal impulse magnitude applied this step. ``0`` on ``separated`` and on an ``enter`` the solver did not push apart. velocity: Relative velocity of ``other`` w.r.t. the receiver at the contact, pre-solve. Degenerate (``Vec3(0)``) on ``separated``. """ other: PhysicsBody3D point: Vec3 normal: Vec3 impulse: float velocity: Vec3
def _resolve_shape_handle(node: Node3D, world: PhysicsWorld) -> ShapeHandle | None: """Resolve a body/character collider per Design S3, or ``None`` if inert. Order: the node's ``shape`` convenience Property if set (it WINS over a child), else the first direct-child :class:`CollisionShape3D`'s shape, else ``None`` (inert). Direct-children only (``direct=True``) so a body never steals a nested sub-body's collider. Shared by :class:`PhysicsBody3D` and :class:`CharacterBody3D` so the Property-first order lives in one place. """ shape: Shape | None = node.shape if shape is not None: return shape.build(world) shape_node = node.find(CollisionShape3D, direct=True) if shape_node is None: return None return shape_node.build_shape(world)
[docs] class CollisionShape3D(Node3D): """A ``Node3D`` that carries a :class:`Shape` collision-geometry resource. Unlike the old ``physics_nodes.CollisionShape3D`` (which stored ``kind``/``radius``/``extents`` fields), geometry here lives in a single :class:`Shape` resource (``SphereShape3D`` / ``BoxShape3D``), which keeps the node open for new shape kinds. This stage it is a pure data carrier: it has no overlap/penetration tests (the seam does narrowphase). It exists so a body can discover its geometry as a child node. """ shape: Shape = Property(default_factory=SphereShape3D, hint="Collision shape resource", group="Collision") #: Opt-in to CPU mouse picking (``SceneTree.input_cast`` -> ``on_picked``). #: When True this collider is ray-tested against screen clicks using its #: shape's :attr:`~simvx.core.physics.shapes.Shape.bounding_radius` as a #: cheap bounding-sphere proxy. Defaults False (most colliders are not pick #: targets), so picking is strictly opt-in and zero-cost for the rest. pickable: bool = Property(False, hint="Receive CPU mouse-picking (on_picked)", group="Collision")
[docs] def build_shape(self, world: PhysicsWorld) -> ShapeHandle: """Build this node's shape into an opaque seam shape handle.""" return self.shape.build(world)
[docs] @property def pick_radius(self) -> float: """Bounding-sphere radius used for CPU picking, in WORLD units. The shape's local :attr:`~simvx.core.physics.shapes.Shape.bounding_radius` scaled by the node's largest world-scale axis, so a uniformly or non-uniformly scaled collider still has a conservative pick sphere. """ s = self.world_scale return float(self.shape.bounding_radius) * max(abs(float(s.x)), abs(float(s.y)), abs(float(s.z)))
[docs] class PhysicsBody3D(Node3D): """A 3D physics body on the new seam; its motion mode is a Property. One concrete body node with a runtime-mutable :class:`BodyMode` ``mode`` knob (``STATIC | KINEMATIC | DYNAMIC``), replacing the old Static/Rigid/Kinematic class split. This mirrors every backend's seam (Jolt ``EMotionType``, pymunk ``body_type``, Box2D ``b2BodyType``): one body + a mode, runtime-flippable (sleep->static, ragdoll toggle). Per mode: - ``STATIC``: immovable collider (floors, walls). Infinite mass; never integrated, so it is intentionally NOT registered for scatter read-back. - ``DYNAMIC``: force-simulated; responds to gravity, impulses, contacts. Uses :attr:`mass`. - ``KINEMATIC``: code-moved via :meth:`move_and_collide`; immune to gravity/forces, pushes dynamic bodies. ``mass`` is ignored (infinite). The base owns the seam lifecycle: resolve world, build shape, create body on enter; unregister + destroy on exit. ``_handle`` / ``_world`` are cleared on exit and rebuilt on enter, so a body that re-enters the tree (re-parenting / change_scene) gets a fresh seam body, re-resolved against whatever ``PhysicsRoot`` it now sits under. """ #: Motion mode of this body. The one user-facing, inspectable, serialized #: knob: runtime-mutable while in-tree (see :meth:`_on_mode_changed`). mode: BodyMode = Property(BodyMode.DYNAMIC, hint="Motion mode", group="Physics", on_change="_on_mode_changed") #: Body mass in kg. Used only when ``mode == DYNAMIC``; ignored (treated as #: infinite) by the seam for STATIC / KINEMATIC. mass: float = Property(1.0, range=(0.001, 100000), hint="Body mass in kg (DYNAMIC only)", group="Physics") #: Layer membership + collision mask (plain 32-bit ints). Forwarded to the seam #: at create time, so changing them after enter_tree requires a re-enter (same #: lifecycle limitation as shape this stage). The canonical body-body rule is #: AND (Design S6): a pair collides iff BOTH bodies opt in to the other's layer. #: For readable named bits, define your own #: ``class Layer(IntFlag, boundary=KEEP): WORLD = auto(); ...`` and assign #: ``Layer.X | Layer.Y`` (an ``IntFlag`` IS an ``int``, so it stores/serialises #: and bit-tests unchanged; ``boundary=KEEP`` keeps ``~`` / undefined bits #: well-defined). collision_layer: int = Bitmask(1, group="Collision") collision_mask: int = Bitmask(1, group="Collision") #: Single-shape convenience collider (Design S3). When set, it WINS over any #: ``CollisionShape3D`` child (resolution order: ``shape`` Property if set, else #: first ``CollisionShape3D`` child, else inert). A body with both a ``shape`` #: and a child silently ignores the child, so reserve children for compound #: colliders only. Read at create time (on enter-tree); changing it after enter #: needs a re-enter, same as ``collision_layer`` / ``collision_mask``. shape: Shape | None = Property( None, hint="Single-shape convenience collider (else add CollisionShape3D children)", group="Collision" ) #: Surface material (friction, restitution, and an independent combine mode for #: each). One grouped resource rather than four loose Properties: it serialises #: as one nested value and can be SHARED across bodies (assign the same #: ``PhysicsMaterial`` for a common surface). ``default_factory`` so each body #: owns its own instance (the dataclass is mutable; a shared mutable default #: would alias inspector edits across bodies). Read at create time (on #: enter-tree), same lifecycle limitation as ``mass`` / ``shape`` / #: ``collision_layer`` this stage: changing it after enter needs a re-enter (an #: ``on_change`` re-create hook is an explicit follow-on, not T1d). material: PhysicsMaterial = Property( default_factory=PhysicsMaterial, hint="Surface material (friction, restitution, combine modes)", group="Physics", ) #: Continuous collision detection (Stage T1e). When True, this body's centre #: displacement is swept against STATIC geometry each step and clamped to the #: time-of-impact, so a fast small body cannot tunnel through a thin static #: collider. Defaults False (discrete). Read at create time (enter-tree), the #: same lifecycle limitation as ``mass`` / ``shape`` / ``material``: changing it #: after enter needs a re-enter. Basic-tier honesty: a CENTRE sweep vs STATIC #: bodies only (no rotational / dynamic-vs-dynamic CCD); the Jolt backend #: honours the flag faithfully via ``EMotionQuality::LinearCast``. continuous: bool = Property( False, hint="Continuous collision (anti-tunnelling for fast bodies)", group="Physics" ) #: Fires once when this body BEGINS touching another (contact ENTER). The #: payload is a node-level :class:`Contact` (``other`` is the peer body, #: ``normal``/``velocity`` oriented toward THIS body). Connect with #: ``body.collided.connect(self._on_hit)``. collided = Signal(Contact) #: Fires once when this body STOPS touching another (contact EXIT). Payload #: is a degenerate :class:`Contact` (``other`` is the peer; ``point`` / #: ``normal`` / ``impulse`` / ``velocity`` are zero: no live manifold on #: exit). Connect with ``body.separated.connect(self._on_left)``. separated = Signal(Contact) def __init__(self, **kwargs: object) -> None: # Set seam state BEFORE super().__init__: the base flushes deferred # Property on_change hooks (e.g. ``mode``'s ``_on_mode_changed``) at the # end of its __init__, so these attributes must already exist for the # not-in-tree no-op guard to read them. self._world: PhysicsWorld | None = None self._handle: BodyHandle | None = None super().__init__(**kwargs) # -- read-only accessors (tests) ---------------------------------------
[docs] @property def handle(self) -> BodyHandle | None: """This body's opaque seam handle, or ``None`` if no body was created.""" return self._handle
[docs] @property def world(self) -> PhysicsWorld | None: """The :class:`PhysicsWorld` this body was created in, or ``None``.""" return self._world
[docs] @property def is_sleeping(self) -> bool: """True if the seam body is asleep (basic-tier sleeping). False when inert. A DYNAMIC body whose speed stays sub-threshold settles to sleep (skipped by integrate + the contact velocity solve) until a disturbance wakes it. STATIC / KINEMATIC bodies are never 'asleep' (they were never awake) and return False, as does an inert node (not in tree / no seam body). """ if self._world is None or self._handle is None: return False return self._world.sleeping(self._handle)
# -- velocity / spin (LIVE sim state, NOT serialized) ------------------ @property def velocity(self) -> Vec3: """Live linear velocity (``Vec3``), read/written straight to the seam. This is runtime sim state, NOT a serialized :class:`Property`: each access delegates to the seam, so reads are current and writes take effect immediately. Returns ``Vec3()`` (zero) when inert (not in tree / no seam body). A mass-free instant change is just ``self.velocity += dv`` (the getter returns a fresh ``Vec3``, ``+=`` writes it back through the setter). The setter preserves the current angular velocity (``spin``). """ if self._world is None or self._handle is None: return Vec3() linear, _angular = self._world.body_velocity(self._handle) return linear
[docs] @velocity.setter def velocity(self, value: Vec3 | Sequence[float]) -> None: if self._world is None or self._handle is None: return _linear, angular = self._world.body_velocity(self._handle) # preserve spin self._world.set_body_velocity(self._handle, Vec3(*value), angular)
@property def spin(self) -> Vec3: """Live angular velocity (``Vec3``, radians/s), read/written to the seam. Companion to :attr:`velocity`; same live-state, never-serialized rules. Returns ``Vec3()`` when inert. The setter preserves linear velocity. """ if self._world is None or self._handle is None: return Vec3() _linear, angular = self._world.body_velocity(self._handle) return angular
[docs] @spin.setter def spin(self, value: Vec3 | Sequence[float]) -> None: if self._world is None or self._handle is None: return linear, _angular = self._world.body_velocity(self._handle) # preserve velocity self._world.set_body_velocity(self._handle, linear, Vec3(*value))
# -- forces (DYNAMIC only; inert no-op otherwise) ----------------------
[docs] def push(self, impulse: Vec3 | Sequence[float], *, at: Vec3 | Sequence[float] | None = None) -> None: """Apply an instantaneous linear impulse (``v += impulse * inv_mass``) NOW. Meaningful only for ``mode == DYNAMIC`` (forces on STATIC / KINEMATIC are physically inert: ``inverse_mass == 0``); a no-op otherwise, and a no-op when inert (not in tree / no seam body). ``at`` is a world-space point; the offset ``r = at - centre`` adds an angular impulse contribution (``cross(r, impulse)``), replacing a separate central-vs-offset split. Args: impulse: Linear impulse (``Vec3`` / sequence), N*s. at: Optional world-space application point. ``None`` applies the impulse through the centre of mass (no spin). """ if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC: return self._world.apply_impulse( self._handle, Vec3(*impulse), at=None if at is None else Vec3(*at) )
[docs] def spin_up(self, angular_impulse: Vec3 | Sequence[float]) -> None: """Apply an instantaneous angular impulse (``omega += angular * inv_mass``). DYNAMIC-only, inert otherwise. Basic tier scales by ``inverse_mass`` as a stand-in for the inverse inertia tensor (the basic backend has no inertia tensor), so results are physically approximate: real inertia is a Tier-2 / Jolt concern. Args: angular_impulse: Angular impulse (``Vec3`` / sequence). """ if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC: return self._world.apply_impulse(self._handle, Vec3(0.0, 0.0, 0.0), angular=Vec3(*angular_impulse))
[docs] def add_force(self, force: Vec3 | Sequence[float], *, at: Vec3 | Sequence[float] | None = None) -> None: """Accumulate a continuous force, applied during the NEXT fixed step. Auto-cleared each step, so to sustain a force re-call this every ``on_fixed_update`` (design S7). DYNAMIC-only, inert otherwise. ``at`` is a world-space point; its offset adds a torque (``cross(r, force)``). Args: force: Linear force (``Vec3`` / sequence), N. at: Optional world-space application point. ``None`` applies the force through the centre of mass (no torque). """ if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC: return self._world.apply_force(self._handle, Vec3(*force), at=None if at is None else Vec3(*at))
[docs] def add_torque(self, torque: Vec3 | Sequence[float]) -> None: """Accumulate a continuous torque, applied during the NEXT fixed step. Auto-cleared each step like :meth:`add_force`; re-call per ``on_fixed_update`` to sustain. DYNAMIC-only, inert otherwise. Basic tier uses the ``inverse_mass`` inverse-inertia stand-in. Args: torque: Torque (``Vec3`` / sequence), N*m. """ if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC: return self._world.apply_torque(self._handle, Vec3(*torque))
# -- helpers ----------------------------------------------------------- def _find_shape_handle(self, world: PhysicsWorld) -> ShapeHandle | None: """Resolve this body's collider per Design S3, or ``None`` if inert. Order: the :attr:`shape` convenience Property if set (it WINS over a child), else the first direct-child :class:`CollisionShape3D`'s shape, else ``None`` (inert). Direct-children only (``direct=True``) so a body never steals a nested sub-body's collider. """ return _resolve_shape_handle(self, world) def _build_transform(self) -> tuple[Vec3, Quat]: """Return this body's initial world pose as ``(position, orientation)``.""" return (self.world_position, self.world_rotation) # -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: world = resolve_world(self) shape_handle = self._find_shape_handle(world) if shape_handle is None: # No CollisionShape3D child / no shape: stay inert (no half-registered # body, no crash). The body becomes live once a shape child is present # and it re-enters the tree. self._handle = None self._world = None log.debug("%s entered tree with no CollisionShape3D child: no seam body created.", type(self).__name__) return transform = self._build_transform() self._handle = world.create_body( shape_handle, self.mode, transform, mass=self.mass, collision_layer=self.collision_layer, collision_mask=self.collision_mask, friction=self.material.friction, restitution=self.material.restitution, friction_combine=self.material.friction_combine, restitution_combine=self.material.restitution_combine, continuous=self.continuous, ) self._world = world tree = self.tree # Collision-event dispatch needs a handle->node map for EVERY body, # static included (a floor must fire ``collided``). This map is # mode-INDEPENDENT, distinct from the scatter registry below. if tree is not None: tree.register_physics_node(world, self._handle, self) # Only bodies that are integrated need transform read-back. Static bodies # never move, so they are intentionally NOT registered for scatter. if self.mode is not BodyMode.STATIC and tree is not None: tree.register_physics_body(world, self._handle, self)
def _on_mode_changed(self) -> None: """Flip the live seam body's motion mode in place when ``mode`` changes. No-op when not in-tree (``_handle`` is None): the new value is read at the next :meth:`on_enter_tree`, which passes ``self.mode`` to ``create_body``. The Property deferral means constructing ``PhysicsBody3D(mode=...)`` runs this hook once after ``__init__`` with ``_handle`` still None, so it is a clean no-op. While in-tree it flips the seam motion type AND keeps the scatter registry consistent: STATIC must leave the dynamic bulk-sync, non-STATIC must be in it (else a STATIC->DYNAMIC body would never have its transform scattered back to the node). """ if self._world is None or self._handle is None: return self._world.set_body_mode(self._handle, self.mode) tree = self.tree if tree is None: return if self.mode is BodyMode.STATIC: tree.unregister_physics_body(self._world, self._handle) else: tree.register_physics_body(self._world, self._handle, self)
[docs] def move_and_collide(self, velocity: Vec3 | Sequence[float], dt: float = 1.0) -> Contact | None: """Move by ``velocity * dt``, stop at the first contact, sync, return it. Meaningful for ``mode == KINEMATIC`` (code-driven movement); it sweeps the seam body's shape and reports the first blocker. ``velocity`` is a world-space velocity (units/s); the node multiplies by ``dt`` to form the displacement handed to the seam. After the sweep the node's transform is synced SYNCHRONOUSLY from the seam, so the pose is correct the instant this call returns. Returns the :class:`Contact` if the sweep stopped early, else ``None``. Returns ``None`` if the body is inert (no ``CollisionShape3D`` child). """ if self._world is None or self._handle is None: return None motion = Vec3(*velocity) * float(dt) contact = self._world.move_and_collide(self._handle, motion) pos, _rot = self._world.body_transform(self._handle) self.world_position = pos return contact
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._handle is not None: tree = self.tree if tree is not None: tree.unregister_physics_body(self._world, self._handle) tree.unregister_physics_node(self._world, self._handle) # destroy_body runs against the cached world even if the tree # reference is already gone during teardown, so the seam body frees. self._world.destroy_body(self._handle) self._handle = None self._world = None
[docs] class CharacterBody3D(Node3D): """A character controller (CharacterVirtual-style), distinct from a rigid body. Unlike a :class:`PhysicsBody3D` it holds a ``CharacterHandle`` (not a ``BodyHandle``), mirroring that a character collides against the world but is never force-simulated. It manages its own seam lifecycle and is deliberately NOT registered for the dynamic auto bulk-sync; :meth:`move_and_slide` syncs the node transform synchronously. Geometry is supplied by a direct-child :class:`CollisionShape3D`, matching the Stage 3a body pattern. ``velocity`` is set by game logic each frame and is written back (deflected) after each :meth:`move_and_slide`. """ slope_limit: float = Property(45.0, range=(0, 90), hint="Max walkable slope (degrees)", group="Character") step_height: float = Property(0.0, range=(0, 10), hint="Max step-up height (world units)", group="Character") max_slides: int = Property(4, range=(1, 16), hint="Collide-and-slide iterations", group="Character") skin_width: float = Property(0.001, range=(0, 1), hint="Collision safe margin", group="Character") #: Layer/mask (plain 32-bit ints). Forwarded to the seam at create time; #: changing after enter_tree requires a re-enter (same limitation as shape this #: stage). For readable named bits define your own #: ``class Layer(IntFlag, boundary=KEEP): ...`` and assign ``Layer.X | Layer.Y`` #: (an ``IntFlag`` IS an ``int``; ``boundary=KEEP`` keeps ``~`` well-defined). collision_layer: int = Bitmask(1, group="Collision") collision_mask: int = Bitmask(1, group="Collision") #: Single-shape convenience collider (Design S3). When set, it WINS over any #: ``CollisionShape3D`` child (resolution order: ``shape`` Property if set, else #: first ``CollisionShape3D`` child, else inert). Read at create time. shape: Shape | None = Property( None, hint="Single-shape convenience collider (else add CollisionShape3D children)", group="Collision" ) def __init__(self, **kwargs: object) -> None: super().__init__(**kwargs) self._world: PhysicsWorld | None = None self._char: CharacterHandle | None = None self.velocity: Vec3 = Vec3() # per-frame, not serialized geometry # World up for floor/wall/ceiling classification. NOT named ``up``: # ``Node3D.up`` is a read-only orientation-derived direction property, so # an instance attribute of that name would shadow it and fail to set. self.up_direction: Vec3 = Vec3(0.0, 1.0, 0.0) # Y-up convention self.floor_normal: Vec3 = Vec3(0.0, 1.0, 0.0) # last move's floor normal self._on_floor = False self._on_wall = False self._on_ceiling = False # -- read-only accessors -----------------------------------------------
[docs] @property def character(self) -> CharacterHandle | None: """This node's opaque character handle, or ``None`` if inert.""" return self._char
[docs] @property def world(self) -> PhysicsWorld | None: """The :class:`PhysicsWorld` this character was created in, or ``None``.""" return self._world
[docs] def is_on_floor(self) -> bool: """True if the last :meth:`move_and_slide` ended on a walkable floor.""" return self._on_floor
[docs] def is_on_wall(self) -> bool: """True if the last :meth:`move_and_slide` hit a wall.""" return self._on_wall
[docs] def is_on_ceiling(self) -> bool: """True if the last :meth:`move_and_slide` hit a ceiling.""" return self._on_ceiling
# -- movement -----------------------------------------------------------
[docs] def move_and_slide(self, dt: float) -> None: """Collide-and-slide by ``self.velocity * dt``, then sync the transform. Writes the deflected post-slide velocity back to :attr:`velocity`, caches floor/wall/ceiling state + :attr:`floor_normal`, and syncs the node's transform SYNCHRONOUSLY from the seam (characters are not part of the auto bulk-sync). No-op if the body is inert (no ``CollisionShape3D`` child). """ if self._world is None or self._char is None: return result = self._world.character_move_and_slide( self._char, self.velocity, float(dt), up=self.up_direction, max_slides=int(self.max_slides) ) self.velocity = result.velocity self._on_floor, self._on_wall, self._on_ceiling = result.on_floor, result.on_wall, result.on_ceiling self.floor_normal = result.floor_normal pos, _rot = self._world.character_transform(self._char) self.world_position = pos
# -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: world = resolve_world(self) shape = _resolve_shape_handle(self, world) if shape is None: # Inert (no collider): no character created, like an inert body. self._char = None self._world = None log.debug("%s entered tree with no shape / CollisionShape3D child: inert.", type(self).__name__) return self._char = world.create_character( shape, (self.world_position, self.world_rotation), up=self.up_direction, slope_limit=math.radians(float(self.slope_limit)), step_height=float(self.step_height), skin_width=float(self.skin_width), collision_layer=self.collision_layer, collision_mask=self.collision_mask, ) self._world = world
# NOT registered with tree.register_physics_body: a character is not a # dynamic body and is never part of the auto bulk-sync.
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._char is not None: self._world.destroy_character(self._char) self._char = None self._world = None
[docs] class Area3D(Node3D): """A pure sensor zone (trigger): broadphase-driven overlap detection. Owns a SENSOR body on the new seam (a flag on the body, not a separate class: Design S3/S4). A sensor participates in the broadphase but is excluded from collision resolution, so an Area3D detects bodies/areas passing through it without pushing them. Detection is ONE-DIRECTIONAL (Design S6): the area sees another body iff ``area.collision_mask & other.collision_layer`` (the observer decides; the other body's mask is irrelevant), checked per detector independently for sensor-vs-sensor. Unlike the old ``Area3D`` this is NEVER a per-frame ``find_all`` tree scan: overlap edges arrive as a buffered, deferred event stream drained by the tree after each step, and the live overlap sets are maintained from those edges, so :meth:`get_overlapping_bodies` / :meth:`get_overlapping_areas` and the Signals are always consistent with the last dispatched step. Geometry follows the Design S3 resolution order: the :attr:`shape` convenience Property if set (it WINS over a child), else the first direct child :class:`CollisionShape3D`, else inert. The sensor body is STATIC (a zone is typically fixed); a sensor is excluded from response regardless of mode. Lifecycle limitations (this stage, parity with :class:`PhysicsBody3D`): ``monitoring`` / ``shape`` / ``collision_layer`` / ``collision_mask`` are read at enter-tree, so changing them after enter requires a re-enter. """ #: 32-bit layer membership of this area's sensor body. With the #: one-directional sensor rule the area's own layer matters only when ANOTHER #: sensor observes it (sensor-vs-sensor); a normal body never observes an #: area. See :class:`PhysicsBody3D` for the ``IntFlag`` named-bits pattern. collision_layer: int = Bitmask(1, group="Collision") #: The observer's detection mask: this area detects a body ``O`` iff #: ``collision_mask & O.collision_layer``. collision_mask: int = Bitmask(1, group="Collision") #: When False the area is INERT: no sensor body is created, so it detects #: nothing and fires no Signals (matching the no-shape inert pattern). #: Toggling at runtime requires a re-enter this stage. monitoring: bool = Property(True, hint="Detect overlaps (else inert)", group="Physics") #: Single-shape convenience collider (Design S3). When set, it WINS over any #: ``CollisionShape3D`` child (resolution order: ``shape`` Property if set, #: else first ``CollisionShape3D`` child, else inert). Read at enter-tree. shape: Shape | None = Property( None, hint="Single-shape convenience zone (else add CollisionShape3D children)", group="Collision" ) #: Fires once when a :class:`PhysicsBody3D` BEGINS overlapping this area #: (overlap ENTER). Payload is the peer body node. body_entered = Signal(PhysicsBody3D) #: Fires once when a :class:`PhysicsBody3D` STOPS overlapping this area #: (overlap EXIT). Payload is the peer body node. body_exited = Signal(PhysicsBody3D) #: Fires once when another :class:`Area3D` BEGINS overlapping this area, as #: observed by THIS area's mask (one-directional). Payload is the peer area. #: Bare ``Signal()`` (the class cannot reference itself at class-body time; #: parity with the old Area3D API). area_entered = Signal() #: Fires once when another :class:`Area3D` STOPS overlapping this area. area_exited = Signal() def __init__(self, **kwargs: object) -> None: # Set seam state BEFORE super().__init__ (same pattern as PhysicsBody3D): # the base may flush deferred Property hooks at the end of its __init__. self._world: PhysicsWorld | None = None self._handle: BodyHandle | None = None # Live overlap sets, maintained by the tree from the edge-diffed event # stream (ENTER add / EXIT discard), NOT a tree scan. self._overlapping_bodies: set[PhysicsBody3D] = set() self._overlapping_areas: set[Area3D] = set() super().__init__(**kwargs) # -- read-only accessors -----------------------------------------------
[docs] @property def handle(self) -> BodyHandle | None: """This area's opaque sensor-body handle, or ``None`` if inert.""" return self._handle
[docs] @property def world(self) -> PhysicsWorld | None: """The :class:`PhysicsWorld` this area's sensor was created in, or ``None``.""" return self._world
# -- polling (live, from the maintained sets; NOT a tree scan) ----------
[docs] def get_overlapping_bodies( self, *, group: str | None = None, type: type[PhysicsBody3D] | None = None ) -> list[PhysicsBody3D]: """Bodies currently overlapping this area (live, as of the last step). A peer destroyed / removed from the tree mid-overlap is purged silently by the backend (no dangling EXIT, parity with the contact stream), so the tree never discards it from the maintained set. Filter such off-seam peers (``handle is None``) here so polling never reports a body that has left the simulation, matching the backend's own purge condition. The optional filters narrow the result without a tree scan (they test the already-maintained overlap set): this is the durable, broadphase-backed replacement for the old arcade ``CharacterBody.get_overlapping(group=, body_type=)`` poll. Both are ANDed when given: Args: group: When set, keep only bodies that belong to this SceneTree group (``body.is_in_group(group)``). The canonical "what of kind X am I touching?" query (e.g. ``area.get_overlapping_bodies(group="mobs")``). type: When set, keep only bodies that are instances of this :class:`PhysicsBody3D` subclass (``isinstance(body, type)``). Returns: The overlapping bodies (live, off-seam peers filtered) matching every supplied filter, in arbitrary order. """ return [ b for b in self._overlapping_bodies if b.handle is not None and (group is None or b.is_in_group(group)) and (type is None or isinstance(b, type)) ]
[docs] def get_overlapping_areas(self) -> list[Area3D]: """Other areas currently overlapping this area (live, as of the last step). Off-seam peers (a destroyed / removed area, ``handle is None``) are filtered here for the same reason as :meth:`get_overlapping_bodies`. """ return [a for a in self._overlapping_areas if a.handle is not None]
# -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: world = resolve_world(self) if not self.monitoring: # Monitoring off: inert, no sensor body, fires nothing. self._handle = None self._world = None log.debug("%s entered tree with monitoring=False: inert.", type(self).__name__) return shape_handle = _resolve_shape_handle(self, world) if shape_handle is None: self._handle = None self._world = None log.debug("%s entered tree with no shape / CollisionShape3D child: inert.", type(self).__name__) return self._handle = world.create_body( shape_handle, BodyMode.STATIC, (self.world_position, self.world_rotation), collision_layer=self.collision_layer, collision_mask=self.collision_mask, is_sensor=True, ) self._world = world tree = self.tree # Reuse the mode-independent handle->node map: tree overlap dispatch reads # it to map both sides. Do NOT register_physics_body (no bulk scatter: the # node drives the sensor body, not the reverse). if tree is not None: tree.register_physics_node(world, self._handle, self)
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._handle is not None: tree = self.tree if tree is not None: tree.unregister_physics_node(self._world, self._handle) self._world.destroy_body(self._handle) self._handle = None self._world = None # Clear so a re-entered area starts with empty overlap state. self._overlapping_bodies.clear() self._overlapping_areas.clear()
[docs] class GravityArea3D(Area3D): """A force-field zone: an ADDITIVE gravity effector over the bodies it overlaps. Per Design S4 (the Unreal ``PhysicsVolume`` split), gravity is NOT baked into :class:`Area3D`: a plain ``Area3D`` stays a zero-cost pure sensor, and this separate effector node *consumes* its overlap set. ``GravityArea3D`` inherits the entire sensor mechanism unchanged (the STATIC sensor body, the broadphase-driven buffered overlap stream, the maintained overlap sets, :meth:`get_overlapping_bodies` / :meth:`get_overlapping_areas`, the ``body_entered`` / ``body_exited`` / ``area_entered`` / ``area_exited`` Signals, and the ``collision_layer`` / ``collision_mask`` / ``monitoring`` / ``shape`` Properties). It does NOT override the lifecycle: it is still a sensor that detects without colliding. What it adds is an :meth:`on_fixed_update` handler that, each fixed step, reads :meth:`get_overlapping_bodies` and applies a field to every DYNAMIC body inside it. The field is **additive on top of world gravity** (never an override this stage): the world still applies its own gravity inside ``step``, and this re-applied force composes with it. Two independent components that SUM when both are configured: - **Directional** (:attr:`gravity`): a uniform acceleration vector applied regardless of body mass, exactly like world gravity (e.g. ``Vec3(0, 9.81, 0)`` for an anti-gravity lift, or a sideways wind-as-accel). - **Point** (:attr:`point_gravity` / :attr:`point_strength`): a CONSTANT (distance-independent) acceleration of magnitude :attr:`point_strength` toward the area centre (:attr:`world_position`). Inverse-distance / inverse-square falloff, independently-overridable damping, and a full gravity-OVERRIDE (replace) mode are explicit later refinements. The acceleration is converted to a force via ``add_force(mass * accel)``: the integrator divides by mass, so the net effect is mass-INDEPENDENT, exactly like gravity. ``add_force`` is auto-cleared each step, so the field is freshly re-applied every fixed step and consumed exactly once by the immediately following ``world.step`` (no carry-over, no double-apply). When the area is empty the loop is a no-op (zero cost); ``monitoring=False`` makes the area inert (empty overlap set) so the field naturally turns off. Moving-emitter caveat (parity with :class:`Area3D`, not a regression): the sensor body pose is read at enter-tree, so a ``GravityArea3D`` moved at runtime is out of scope this stage. """ #: Uniform acceleration added to bodies in the area (m/s^2, world space), #: applied regardless of body mass (a directional field, like world gravity). gravity: Vec3 = Property( default_factory=lambda: Vec3(0.0, 0.0, 0.0), group="Gravity", hint="Directional acceleration added to bodies in the area (m/s^2, world space)", ) #: Enable the radial (point) component pulling bodies toward the area centre, #: in addition to (summed with) :attr:`gravity`. point_gravity: bool = Property( False, group="Gravity", hint="Pull bodies toward the area centre instead of/in addition to a direction", ) #: Acceleration magnitude toward the area centre (m/s^2) when #: :attr:`point_gravity` is on. CONSTANT (distance-independent) falloff. point_strength: float = Property( 9.81, group="Gravity", hint="Acceleration magnitude toward the area centre (m/s^2)", ) #: Centre-singularity guard: a body within this distance of the centre gets #: no point term (avoids dividing by a near-zero direction length / NaN). _POINT_EPSILON = 1e-6
[docs] def on_fixed_update(self, dt: float) -> None: """Apply the additive gravity field to every DYNAMIC overlapping body. Runs BEFORE ``world.step`` (the tree drives node ``on_fixed_update`` first, then steps the worlds), accumulating a fresh force consumed by that step. ``dt`` is accepted to match the hook signature but is NOT used to scale: ``add_force`` is a continuous force the integrator consumes over the step, not an impulse. """ bodies = self.get_overlapping_bodies() if not bodies: return directional = Vec3(self.gravity) point_on = self.point_gravity strength = self.point_strength centre = self.world_position for body in bodies: if body.mode is not BodyMode.DYNAMIC: # STATIC / KINEMATIC have inverse_mass == 0 (add_force is already a # no-op for them); skip to avoid wasted seam calls. continue accel = Vec3(directional) if point_on: to_centre = centre - body.world_position dist = to_centre.length() if dist > self._POINT_EPSILON: accel = accel + (to_centre / dist) * strength if accel.length_squared() > 0.0: body.add_force(body.mass * accel)
[docs] class Joint3D(Node3D): """Base class for the four T1c constraint nodes (node-agnostic carriers). A ``Joint3D`` constrains two :class:`PhysicsBody3D` instances via the physics seam. It is a thin carrier: it holds the two body references plus its per-joint Properties and owns the seam-constraint lifecycle (create on enter-tree, remove on exit-tree), exactly mirroring how :class:`PhysicsBody3D` owns its seam body. Body references (:attr:`body_a` / :attr:`body_b`) are PLAIN instance attributes, NOT :class:`Property` descriptors: a serialised scene-graph node reference is out of scope for this seam stage (the seam is handle-keyed, not node-keyed; scene-to-scene refs are plain Python imports per the codebase rule). Set them programmatically, e.g. ``joint.body_a = self.crate; joint.body_b = self.anchor``, or via the constructor: ``PinJoint3D(body_a=..., body_b=..., anchor=...)``. Resolution at :meth:`on_enter_tree`: 1. If either body reference is ``None`` the joint stays INERT (no seam joint), logs at debug, and returns: a one-body joint is a no-op, not a crash (mirrors the body's no-shape inert path). 2. Each body's seam handle (``body.handle``) is resolved. If EITHER is ``None`` (the body is not yet in the tree, or is inert with no collider) the joint stays inert: it never half-creates. 3. SAME-WORLD requirement: ``body_a.world`` and ``body_b.world`` MUST be the identical :class:`PhysicsWorld` (a joint is a within-world constraint; the seam handles are world-local). A mismatch (or either ``None`` after handles resolved) raises :class:`ValueError`: this is a genuine programming error, unlike a missing body (a transient tree-order condition), which is the soft-inert case above. LIFECYCLE LIMITATION (parity with body shape / mass / layers): a joint reads its Properties and resolves its bodies ONCE at enter-tree. Changing a param or a body reference after enter requires a re-enter (an ``on_change`` re-create hook is a follow-on, not T1c). A joint declared BEFORE its bodies resolves to inert and silently never constrains, so ALWAYS add a joint AFTER both of its bodies (or re-enter it). A robust deferred-resolve is a follow-on. """ def __init__( self, *, body_a: PhysicsBody3D | None = None, body_b: PhysicsBody3D | None = None, **kwargs: object, ) -> None: # Seam state BEFORE super().__init__ (same pattern as PhysicsBody3D / # Area3D): the base may flush deferred Property on_change hooks at the end # of its __init__, which could read these attributes. self._world: PhysicsWorld | None = None self._joint: JointHandle | None = None #: The two bodies this joint constrains (plain attributes, not Properties). self.body_a: PhysicsBody3D | None = body_a self.body_b: PhysicsBody3D | None = body_b super().__init__(**kwargs) # -- read-only accessors (tests) ---------------------------------------
[docs] @property def joint(self) -> JointHandle | None: """This joint's opaque seam handle, or ``None`` if inert / not created.""" return self._joint
[docs] @property def world(self) -> PhysicsWorld | None: """The :class:`PhysicsWorld` this joint was created in, or ``None``.""" return self._world
# -- subclass hook ----------------------------------------------------- def _create(self, world: PhysicsWorld, a: BodyHandle, b: BodyHandle) -> JointHandle: """Create the seam constraint for this joint kind, returning its handle. Overridden per subclass to call the matching ``world.create_*_joint``, reading the subclass's Properties. Never called when either body is inert; ``a`` / ``b`` are guaranteed live handles in the SAME world. """ raise NotImplementedError(f"{type(self).__name__} must override _create") # -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: a_node, b_node = self.body_a, self.body_b if a_node is None or b_node is None: # A joint needs two bodies; one (or zero) is an inert no-op, not a # crash (parity with a body that has no CollisionShape3D child). self._joint = None self._world = None log.debug("%s entered tree without two bodies: inert (no seam joint).", type(self).__name__) return ha, hb = a_node.handle, b_node.handle if ha is None or hb is None: # A body is not in the tree yet, or is inert (no collider): stay inert # rather than half-create. Add the joint AFTER both bodies (or # re-enter it) to constrain. See the class docstring. self._joint = None self._world = None log.debug( "%s: a body has no seam handle yet (declare the joint after its bodies): inert.", type(self).__name__, ) return wa, wb = a_node.world, b_node.world if wa is None or wb is None or wa is not wb: # Cross-world (or missing-world) joint: a genuine programming error # (a constraint is within a single world; the seam handles are # world-local), unlike a missing body (a transient tree-order case). raise ValueError(f"{type(self).__name__}: body_a and body_b must be in the same PhysicsWorld") # Sanity debug-log only: the bodies' world is authoritative (the joint may # sit anywhere in the tree), so resolve_world(self) is NOT used to pick it. if resolve_world(self) is not wa: log.debug( "%s sits under a different PhysicsRoot than its bodies; using the bodies' world.", type(self).__name__, ) self._world = wa self._joint = self._create(wa, ha, hb)
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._joint is not None: # remove_joint is a no-op on an already-purged handle (the seam drops # joints when a body is destroyed), so this is safe in either teardown # order. Runs against the cached world even if the tree ref is gone. self._world.remove_joint(self._joint) self._joint = None self._world = None
[docs] class FixedJoint3D(Joint3D): """Weld two bodies: lock their full relative transform (position + orientation). Captures the current relative pose of ``body_b`` in ``body_a``'s frame at enter-tree and holds it, so the two bodies move as one rigid assembly. No anchor / axis Properties: the constraint captures the current relative pose at create. Basic-tier honesty: the backend has NO inertia tensor, so the angular lock uses ``inverse_mass`` as the inverse-inertia scalar (a long thin or off-centre weld rotates too easily versus a real solver), and convergence is a few iterations (a long weld chain sags slightly). Use near-uniform masses; precise articulated mechanisms are a Jolt concern. """ def _create(self, world: PhysicsWorld, a: BodyHandle, b: BodyHandle) -> JointHandle: return world.create_fixed_joint(a, b)
[docs] class PinJoint3D(Joint3D): """Pin two bodies at a single point (ball / point-to-point), rotation free. The two bodies cannot separate at :attr:`anchor` but rotate freely about it. Basic-tier honesty: the anchor offset does NOT rotate with the bodies (re- derived each step in world axes), so a pin on a spinning body drifts; angular cross-coupling uses the ``inverse_mass`` inverse-inertia scalar. A few iterations of convergence. """ #: World-space pivot point. Defaults to ``Vec3(0, 0, 0)``; if left at that #: default, the node falls back to its own ``world_position`` at enter-tree (a #: Pin placed in the tree at the pivot needs no explicit anchor). Read at #: enter-tree; changing it after needs a re-enter. anchor: Vec3 = Property(default_factory=lambda: Vec3(0.0, 0.0, 0.0), group="Joint", hint="World-space pivot point") def _create(self, world: PhysicsWorld, a: BodyHandle, b: BodyHandle) -> JointHandle: anchor = self.anchor if anchor == Vec3(0.0, 0.0, 0.0): anchor = self.world_position # default sentinel: use the node's own pivot return world.create_pin_joint(a, b, anchor)
[docs] class HingeJoint3D(Joint3D): """Hinge two bodies: pin at :attr:`anchor` + one free rotational DOF about :attr:`axis`. A point constraint at the anchor PLUS an angular lock removing the two off-axis rotational DOF, leaving free rotation only about ``axis``. Motors and angular limits are explicitly OUT of scope this stage (a deliberate follow-on). Basic-tier honesty: same anchor-does-not-rotate and ``inverse_mass`` inverse-inertia-scalar caveats as :class:`PinJoint3D`; a few iterations of convergence. """ #: World-space hinge pivot (same default-to-``world_position`` behaviour as #: :class:`PinJoint3D`). Read at enter-tree. anchor: Vec3 = Property(default_factory=lambda: Vec3(0.0, 0.0, 0.0), group="Joint", hint="World-space pivot point") #: World-space hinge axis (normalised at create). Defaults to ``Vec3(0, 1, 0)`` #: (Y-up). Read at enter-tree. axis: Vec3 = Property( default_factory=lambda: Vec3(0.0, 1.0, 0.0), group="Joint", hint="World-space hinge axis (unit)" ) def _create(self, world: PhysicsWorld, a: BodyHandle, b: BodyHandle) -> JointHandle: anchor = self.anchor if anchor == Vec3(0.0, 0.0, 0.0): anchor = self.world_position return world.create_hinge_joint(a, b, anchor, self.axis)
[docs] class SpringJoint3D(Joint3D): """Soft distance-spring between the two body centres (compliant, not rigid). Pulls the two body centres toward :attr:`rest_length` apart with :attr:`stiffness` (N/m) and :attr:`damping` (N*s/m). Intentionally compliant: no position correction. Basic-tier honesty: uses the two centres of mass (Pin / Hinge use anchors, Spring uses COMs for simplicity). The explicit soft-impulse integration can oscillate / overshoot when ``stiffness`` is large relative to the fixed ``dt``; nothing is silently clamped. A stiff spring needs a smaller ``dt`` or the Jolt backend. """ #: Target centre-to-centre separation (world units). The default ``-1.0`` is #: an AUTO-CAPTURE sentinel: if left negative the node captures the current #: ``|pos_b - pos_a|`` at enter-tree as the natural rest length (and exposes #: it positive afterwards). Read at enter-tree. rest_length: float = Property( -1.0, range=(-1.0, 100000), group="Joint", hint="Target separation (-1 = auto-capture)" ) #: Spring constant k (N/m). stiffness: float = Property(100.0, range=(0.0, 1e6), group="Joint", hint="Spring constant k (N/m)") #: Damping coefficient c (N*s/m). damping: float = Property(1.0, range=(0.0, 1e4), group="Joint", hint="Damping coefficient c (N*s/m)") def _create(self, world: PhysicsWorld, a: BodyHandle, b: BodyHandle) -> JointHandle: rest = self.rest_length if rest < 0.0: # Auto-capture: the current centre-to-centre distance is the natural # rest length. Expose it positive so a later read reflects the capture. # _create is only ever called once both bodies resolved (the base # guards body_a / body_b being None), so the refs are non-None here. assert self.body_a is not None and self.body_b is not None rest = float((self.body_b.world_position - self.body_a.world_position).length()) self.rest_length = rest return world.create_spring_joint(a, b, rest, self.stiffness, self.damping)