Source code for simvx.core.physics.nodes

"""Body + shape-carrier nodes for 3D physics.

The user-facing 3D body/shape node taxonomy:

- :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:`PhysicsObject3D` -- the abstract base of every node that owns a body
  in the physics world: it holds the backend body handle, the layer/mask pair,
  the shape resolution, the collision Signals and the teardown. Annotate with
  this wherever the meaning is "a node with a body in the world".
- :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 native model (Jolt
  ``EMotionType``, pymunk ``body_type``, Box2D ``b2BodyType``). On enter-tree it
  resolves its world (:func:`resolve_world`), builds its shape, creates a
  backend body, and (for non-static bodies) registers with the tree's
  handle->node sync registry. On exit-tree it unregisters, destroys the backend
  body, and clears its state.
- :class:`CharacterBody3D` -- a ``KINEMATIC`` body with a swept movement helper
  (:meth:`CharacterBody3D.move_and_slide`), which is the only thing that
  distinguishes it from any other kinematic body.

The 2D siblings live in ``nodes2d.py``. All of these are exported from
``simvx.core``.
"""

from __future__ import annotations

import contextlib
import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar, overload

from ..descriptors import Notification, Property, restore_property
from ..math import Quat, Vec3
from ..nodes_3d.node3d import Node3D
from ..properties import Bitmask
from ..signals import Signal
from . import slide
from ._pose_reconcile import _PhysicsPoseReconcile
from .capability import Capability
from .material import PhysicsMaterial
from .root import resolve_world
from .shapes import Shape, SphereShape3D
from .world import (
    DEFAULT_ANGULAR_DAMPING,
    DEFAULT_GRAVITY_SCALE,
    DEFAULT_LINEAR_DAMPING,
    BodyMode,
    SweepHit,
)

if TYPE_CHECKING:
    from .world import BodyHandle, JointHandle, PhysicsWorld, ShapeHandle

log = logging.getLogger(__name__)


@contextlib.contextmanager
def _refusal_named(path: str):
    """Re-raise a ``ValueError`` from the seam with ``path`` in front of it."""
    try:
        yield
    except ValueError as exc:
        raise ValueError(f"{path}: {exc}") from exc


#: Binds ``Area3D.get_overlapping_bodies``'s ``type=`` filter to its return type, so
#: ``get_overlapping_bodies(type=PhysicsBody3D)`` is a ``list[PhysicsBody3D]``.
_Obj3 = TypeVar("_Obj3", bound="PhysicsObject3D")

__all__ = [
    "BodyMode",
    "Contact",
    "CollisionShape3D",
    "PhysicsObject3D",
    "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 world-level :class:`~simvx.core.physics.world.SweepHit` (a query/sweep result keyed by handle): 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 physics object 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 the solver applied this step. ``0.0`` on ``separated`` and on a ``collided`` the solver did not push apart. ``None`` iff the resolved backend does not advertise :attr:`~simvx.core.physics.capability.Capability.CONTACT_IMPULSE` (both Jolt backends): it cannot read its solver's applied lambda, so it reports no number rather than a guess. Check the capability once at setup rather than testing for ``None`` mid-collision. impulse_estimate: The portable stand-in for ``impulse``, one formula on every backend: the impulse it would take to arrest the approach, from the pair's masses and the difference of the two bodies' LINEAR velocities. Always a number, and ``0.0`` on ``separated`` and for a pair that is not approaching. This is what a game reads where the backend cannot measure. It is deliberately not computed from ``velocity`` below, whose at-point meaning would put each backend's choice of contact point into the number. See :attr:`~simvx.core.physics.world.ContactEvent.impulse_estimate`. velocity: Relative velocity of ``other`` w.r.t. the receiver AT THE CONTACT POINT, pre-solve. Spin counts: a wheel skidding on the ground reports the speed of its tread, which its centre is not moving at. Degenerate (``Vec3(0)``) on ``separated``. For the receiver's own linear velocity, read it off the body. """ other: PhysicsObject3D point: Vec3 normal: Vec3 impulse: float | None impulse_estimate: float velocity: Vec3
def _resolve_shape(node: Node3D) -> Shape | None: """Resolve a physics object's collision-geometry resource, 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. Lives here rather than on :class:`PhysicsObject3D` so the Property-first order is stated once for every node family that carries a collider. """ shape: Shape | None = node.shape if shape is not None: return shape shape_node = node.find(CollisionShape3D, direct=True) return None if shape_node is None else shape_node.shape
[docs] class CollisionShape3D(Node3D): """A ``Node3D`` that carries a :class:`Shape` collision-geometry resource. Geometry lives in a single :class:`Shape` resource (``SphereShape3D`` / ``BoxShape3D``) rather than in loose ``kind`` / ``radius`` / ``extents`` fields, which keeps the node open for new shape kinds. The node is a pure data carrier: it runs no overlap or penetration tests of its own (narrowphase belongs to the physics world). 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", on_change="_on_shape_changed", ) #: 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") def __init__(self, **kwargs: object) -> None: # The last ``shape`` value that was accepted, so a refused swap knows what # to put back. Set BEFORE super().__init__ because the base flushes # deferred Property on_change hooks at the end of its own init and the # hook reads it; re-read AFTER, so a node built with the Property's # default -- which fires no hook -- tracks that default. self._live_shape: Shape | None = None super().__init__(**kwargs) self._live_shape = self.shape
[docs] def build_shape(self, world: PhysicsWorld) -> ShapeHandle: """Build this node's shape into an opaque backend shape handle.""" return self.shape.build(world)
[docs] @property def collider_scale(self) -> Vec3: """The world scale the SIMULATION applies to this collider's geometry. Scale lives on the BODY: a body has one scale and the seam gives a body one, so a collider child's own local scale does not resize the collider it carries. This resolves the scale that actually reaches the physics world -- the owning body's when this node has one, and this node's own when it does not, because a free collider (a pick target, an editor gizmo) has no body to take a scale from. Read this, not :attr:`world_scale`, anywhere a collider's SIMULATED size matters. The two differ exactly when a collider child carries a local scale, which is the case a user provokes by trying to resize a collider by scaling the collider node. """ owner = self.parent return owner.world_scale if isinstance(owner, PhysicsObject3D) else self.world_scale
[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 largest axis of :attr:`collider_scale`, so a uniformly or non-uniformly scaled collider still has a conservative pick sphere and one that matches the collider the simulation actually holds. """ s = self.collider_scale return float(self.shape.bounding_radius) * max(abs(float(s.x)), abs(float(s.y)), abs(float(s.z)))
def _on_shape_changed(self) -> None: """Swap the owning body's collider when this resource is replaced. Routed only when this node is the collider the parent actually uses: a parent whose own ``shape`` Property is set ignores its children entirely, and only the first direct-child collider is read. See :meth:`PhysicsObject3D._push_shape_to_world` for what a live swap keeps. A swap the world refuses (a triangle mesh onto a body that moves) puts this Property back to the last value it accepted, so a refused write never leaves this node reporting geometry the body does not have. """ held, self._live_shape = self._live_shape, self.shape owner = self.parent if not isinstance(owner, PhysicsObject3D) or owner.shape is not None: return if owner.find(CollisionShape3D, direct=True) is not self: return try: owner._push_shape_to_world() except Exception: self._live_shape = held restore_property(self, "shape", held) raise def _notification(self, what: Notification) -> None: """Re-resolve the owning body's collider when this node joins or leaves it. Adding, removing or re-ordering a collider child changes which shape a body resolves to just as assigning a new resource does, so it takes the same route. Parented / unparented rather than enter- / exit-tree, because those two fire for a whole subtree entering or leaving with the body, where the body is being built or destroyed anyway; these fire only when the parent-child link itself changes, and by then the node is already in (or already out of) the parent's children, so the resolution reads the shape the body should now have. A resolution the world refuses -- a triangle mesh arriving under a body that moves -- raises out of ``add_child`` with the body still on the collider it had, and, unlike the two Property routes, nothing is put back: the refused geometry is a NODE that is already parented, and un-parenting it would be a second structural change the caller did not ask for. So the body keeps its collider while this child claims another, and the raised error is the only thing that says so. """ super()._notification(what) if what is not Notification.PARENTED and what is not Notification.UNPARENTED: return owner = self.parent if isinstance(owner, PhysicsObject3D) and owner.shape is None: owner._push_shape_to_world()
[docs] class PhysicsObject3D(_PhysicsPoseReconcile, Node3D): """Abstract base of every 3D node that owns a body in the physics world. Not instantiable in practice (it creates nothing on enter-tree): it exists so that "a node with a body in the physics world" has ONE name. Every physics node family is one of these -- :class:`PhysicsBody3D` (which adds ``mode`` / ``mass`` / ``material`` and the live velocity), :class:`CharacterBody3D` (which adds the collide-and-slide movement helper) and :class:`Area3D` (which adds the sensor flag and the overlap sets) -- so a payload or an annotation that means "the peer body node" names this class rather than a union or an ``isinstance`` tuple. What it owns: the backend ``handle`` and its ``world``, the :attr:`collision_layer` / :attr:`collision_mask` pair, the :attr:`shape` convenience collider and its resolution, the initial-pose build, the node-pose-to-body teleport of :class:`_PhysicsPoseReconcile`, the :attr:`collided` / :attr:`separated` Signals, and the exit-tree teardown. Subclasses own body CREATION only, because what they create differs (a typed-mode body, a kinematic body, a static sensor). """ #: Layer membership + collision mask (plain 32-bit ints). LIVE: a write reaches #: the simulation immediately (see :meth:`_push_filter_to_world`) and is honoured #: from the next step, so a pair that stops matching separates through the normal #: contact diff rather than vanishing. The canonical #: body-body rule is AND: 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", on_change="_on_collision_layer_changed") collision_mask: int = Bitmask(1, group="Collision", on_change="_on_collision_mask_changed") #: Single-shape convenience collider. When set, it WINS over any #: ``CollisionShape3D`` child (resolution order: ``shape`` Property if set, else #: first ``CollisionShape3D`` child, else inert). A node with both a ``shape`` #: and a child silently ignores the child, so reserve children for compound #: colliders only. LIVE: assigning a new resource swaps the body's collider in #: place (see :meth:`_push_shape_to_world`), keeping the body's handle, pose, #: velocity, mode, mass and joints. Mutating a shape resource IN PLACE #: (``body.shape.radius = 2``) is not a Property write and does not reach the #: simulation: assign a new shape instead. shape: Shape | None = Property( None, hint="Single-shape convenience collider (else add CollisionShape3D children)", group="Collision", on_change="_on_shape_changed", ) #: Fires once when this object BEGINS touching another (contact ENTER). The #: payload is a node-level :class:`Contact` (``other`` is the peer, #: ``normal``/``velocity`` oriented toward THIS object). Connect with #: ``body.collided.connect(self._on_hit)``. A sensor body is excluded from #: collision resolution, so an :class:`Area3D` never emits these. #: Latches for the two one-per-node reports in :meth:`_push_shape_to_world`. #: Class attributes rather than instance state, so a node that never hits #: either case carries nothing for them. _bodyless_reported = False _shapeless_reported = False collided = Signal(Contact) #: Fires once when this object STOPS touching another (contact EXIT). Payload #: is a degenerate :class:`Contact` (``other`` is the peer; ``point`` / #: ``normal`` / ``velocity`` are zero: no live manifold on exit). ``impulse`` #: is ``0.0``, or ``None`` on a backend that does not advertise #: ``Capability.CONTACT_IMPULSE``. Connect with #: ``body.separated.connect(self._on_left)``. #: #: A peer DESTROYED mid-touch has also stopped touching, so it fires for that #: too, one step later, with ``other`` set to the destroyed node itself. That #: node is detached: ``other.tree``, ``other.handle`` and ``other.world`` are #: all ``None``, which is how a handler tells "it was destroyed" from "it #: moved away", and every physics call on it is a no-op. Keeping a reference #: to it keeps a dead node alive, so read what is needed and let it go. separated = Signal(Contact) def __init__(self, **kwargs: object) -> None: # Set the world/handle 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__, and a ``position=`` kwarg fires the pose-reconcile # hook during base init, so both attributes must already exist for the # not-in-tree no-op guards to read them. self._world: PhysicsWorld | None = None self._handle: BodyHandle | None = None self._built_shape: Shape | None = None # The last ``shape`` value that was accepted, tracked rather than inferred # from the built collider: the two differ whenever the geometry the body # holds came from somewhere this Property was not set to. self._live_shape: Shape | None = None super().__init__(**kwargs) # -- read-only accessors -----------------------------------------------
[docs] @property def handle(self) -> BodyHandle | None: """This object's opaque backend body handle, or ``None`` if none was created.""" return self._handle
[docs] @property def world(self) -> PhysicsWorld | None: """The :class:`PhysicsWorld` this object's body was created in, or ``None``.""" return self._world
# -- helpers ----------------------------------------------------------- def _naming_this_node(self): """Re-raise a backend refusal with this node's path in front of it. The backend owns the rules it enforces, because they are predicates over the world's body table and the layer/mask filter, and a node has neither. What a node HAS is its identity, so it supplies that: a user editing a scene is told which node is wrong rather than reading a bare backend traceback and going looking for it. """ return _refusal_named(self.path) def _find_shape_handle(self, world: PhysicsWorld) -> ShapeHandle | None: """Build this object's collider into ``world``, 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. The resource it built from is remembered so a later push that resolves to the same one does not build a second copy of the same geometry. """ shape = _resolve_shape(self) self._built_shape = shape return None if shape is None else shape.build(world) def _build_transform(self) -> tuple[Vec3, Quat]: """Return this object's initial world pose as ``(position, orientation)``.""" return (self.world_position, self.world_rotation) def _push_node_pose_to_body(self) -> None: """Teleport the simulated body to the node's world pose AND scale. The single choke point every transform write reaches, so it is also where a scale change reaches the collider: the body is rescaled with the pose, and a collider therefore matches what is drawn rather than staying the size the resource was authored at. No-op when inert. The scale is THIS node's, so a collider child's own local scale does not resize the collider: a body has one scale, and the seam gives a body one. A PARENT's scale change is the documented gap: the reconcile hook skips the push when the change arrived from a parent, deliberately, so a character parented under a moving node is not dragged through its handle. Such a child's collider follows on its next own transform write. """ if self._world is not None and self._handle is not None: self._world.set_body_transform( self._handle, (self.world_position, self.world_rotation), scale=self.world_scale ) # -- live Property edits ----------------------------------------------- # # Every Property the body was created from is editable afterwards: the hook # pushes the new value into the body that already exists, so the node and the # simulation never disagree. The body is never destroyed and rebuilt for one, # which is what keeps its handle, its joints, its pose, its velocity and its # place in the tree's handle->node map. Each push is a no-op while the node # owns no body (out of tree, or in-tree with no collider): the values are # read again by the next :meth:`on_enter_tree`. def _push_filter_to_world(self) -> None: """Push the layer/mask pair to the live body (no-op when inert). Both Properties route here and re-state the pair, because the pair-acceptance rule reads both sides of both bodies. """ if self._world is None or self._handle is None: return with self._naming_this_node(): self._world.set_body_filter(self._handle, self.collision_layer, self.collision_mask) def _push_shape_to_world(self) -> None: """Swap the live body's collider to this node's currently resolved shape. Resolution is the standard order (:attr:`shape` if set, else the first direct-child :class:`CollisionShape3D`), so this is also how a body follows a collider child being added, removed or given a new resource. The body keeps its handle, pose, velocity, mode, mass, filter and joints; only the geometry changes, and it changes as a teleport, so an overlap the new shape introduces is pushed apart by the ordinary contact solve over the following steps. Two cases cannot be applied and are reported rather than passed over in silence, because both look exactly like a shape that did not take effect: - the node is in the tree with NO body, because it entered without a collider and a body is only ever created on enter-tree; - the last collider was cleared off a live body, and a body cannot become shapeless, so it keeps the geometry it has. Both are reported once per node: a per-frame assignment must not turn into a per-frame log. """ if self._world is None or self._handle is None: if self.tree is not None and self._wants_body() and not self._bodyless_reported: self._bodyless_reported = True log.warning( "%s is in the tree with no physics body, so its shape cannot be applied: it entered " "without a collider, and a body is only created on enter-tree. Remove and re-add the " "node to build one.", type(self).__name__, ) return shape = _resolve_shape(self) if shape is None: if not self._shapeless_reported: self._shapeless_reported = True log.warning( "%s has a live physics body but no shape and no CollisionShape3D child: a body cannot " "become shapeless, so it keeps the collider it has until the node is removed and re-added.", type(self).__name__, ) return if shape is self._built_shape: # Already the body's geometry. Assigning the same resource again, or a # collider child being re-parented under a body that reads a different # one, must not build a second copy of the same shape in the backend. return with self._naming_this_node(): self._world.set_body_shape(self._handle, shape.build(self._world)) self._built_shape = shape def _wants_body(self) -> bool: """True if this node owns a body whenever it is in the tree with a collider.""" return True def _on_collision_layer_changed(self) -> None: """Push ``collision_layer`` to the live body.""" self._push_filter_to_world() def _on_collision_mask_changed(self) -> None: """Push ``collision_mask`` to the live body.""" self._push_filter_to_world() def _on_shape_changed(self) -> None: """Swap the live body's collider to the new ``shape`` resource. A swap the world refuses puts this Property back to the last value it accepted, so writing ``shape`` never leaves the node reporting geometry the simulation does not have. Assigning to a collider CHILD's ``shape`` is undone the same way. Adding or removing the child ITSELF reaches this swap by a third route that has nothing to put back -- see :meth:`CollisionShape3D._notification`. """ held, self._live_shape = self._live_shape, self.shape try: self._push_shape_to_world() except Exception: self._live_shape = held restore_property(self, "shape", held) raise # -- lifecycle ---------------------------------------------------------
[docs] def on_exit_tree(self) -> None: """Retire from the tree's handle->node map and destroy the backend body. Retired, not dropped: destroying the body ends every contact and sensor overlap it was in, and the seam reports those closing ``EXIT``s on the first drain after the next step. The tree holds this node's handle->node entry until that dispatch has run, so a peer's ``separated`` / ``body_exited`` can still name the body that went away. By then this node is detached (``tree``, ``handle`` and ``world`` are all ``None``), which is exactly what a handler needs in order to tell "it left" from "it moved away". """ if self._world is not None and self._handle is not None: tree = self.tree if tree is not None: tree.retire_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 body is always freed. self._world.destroy_body(self._handle) self._handle = None self._world = None self._built_shape = None
[docs] class PhysicsBody3D(PhysicsObject3D): """A 3D physics body whose motion mode is a Property. One concrete body node with a runtime-mutable :class:`BodyMode` ``mode`` knob (``STATIC | KINEMATIC | DYNAMIC``) rather than a Static/Rigid/Kinematic class split. This mirrors every backend's native model (Jolt ``EMotionType``, pymunk ``body_type``, Box2D ``b2BodyType``): one body + a mode, runtime-flippable (sleep->static, ragdoll toggle). Per mode: - ``STATIC``: immovable collider (floors, walls). Simulated as infinite-mass and never integrated, so it is intentionally NOT registered for scatter read-back; :attr:`mass` is retained for a later flip to ``DYNAMIC``. - ``DYNAMIC``: force-simulated; responds to gravity, impulses, contacts. Uses :attr:`mass`. - ``KINEMATIC``: code-moved via :meth:`move_and_collide`; immune to gravity/forces. Simulated as infinite-mass, with :attr:`mass` retained for a later flip to ``DYNAMIC``. :class:`PhysicsObject3D` owns the rest of the body lifecycle: resolve world, build shape, 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 backend body, re-resolved against whatever ``PhysicsRoot`` it now sits under. **Parenting, and which mode follows a moving ancestor.** A STATIC body follows: move any ancestor and the collider goes with it, because nothing simulates a STATIC body and its pose is authored data. That is the mode to parent under a node whose motion is authoring rather than gameplay -- an editor drag, a group positioned once at setup, a level chunk assembled under a common origin. A DYNAMIC or KINEMATIC body does NOT follow: the simulation owns its pose, and an ancestor's move would be erased by the next write-back anyway. So for a platform that must carry or push bodies at RUNTIME, do not parent them to it. Make the platform a KINEMATIC body and move that body itself, because a STATIC pose write carries no derived velocity and would slide riders off rather than transport them. This is a deliberate difference from engines that push the composed transform into every body type regardless of mode. """ #: Motion mode of this body. The one user-facing, inspectable, serialized #: knob: runtime-mutable while in-tree (see :meth:`_on_mode_changed`). #: The value stored is always a :class:`BodyMode` member, so downstream #: identity comparisons hold. The mode *name* is accepted wherever values #: arrive untyped -- constructor keywords (``PhysicsBody3D(mode="static")``) #: and scene files, which is the form scenes are saved in. Typed code #: assigns the member: ``body.mode = BodyMode.STATIC``. mode: BodyMode = Property( BodyMode.DYNAMIC, coerce=BodyMode, enum=list(BodyMode), hint="Motion mode", group="Physics", on_change="_on_mode_changed", ) #: Body mass in kg. Must be > 0: this Property CLAMPS into #: ``(0.001, 100000)`` rather than raising, while a direct #: :class:`~simvx.core.physics.world.PhysicsWorld` call with a non-positive mass #: raises ``ValueError``. LIVE: a write reaches the simulation immediately, #: recomputing the body's inertia from its current shape and preserving its #: velocity (not its momentum). Retained by the body #: across a ``mode`` flip, so a body created STATIC or KINEMATIC carries this #: mass into a later DYNAMIC flip. mass: float = Property( 1.0, range=(0.001, 100000), hint="Body mass in kg", group="Physics", on_change="_on_mass_changed" ) #: 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). LIVE: assigning a material whose #: VALUES differ pushes all four to the body, honoured from the next step. The #: resource is frozen, so there is no in-place edit to get wrong; assigning an #: equal-valued material is a no-op, because the surface did not change. To #: change one coefficient, assign a derived material: #: ``body.material = replace(body.material, friction=0.9)``. material: PhysicsMaterial = Property( default_factory=PhysicsMaterial, hint="Surface material (friction, restitution, combine modes)", group="Physics", on_change="_on_material_changed", ) #: Per-second rate at which this body sheds LINEAR speed with nothing touching #: it: the drag of its own shape and material, which is why it belongs to the #: body and not to the world. Applied once per step as #: ``v = v * max(0, 1 - linear_damping * dt) + a * dt``, so the same value #: costs the same fraction of the speed per step whichever backend is running. #: ``0`` coasts forever. Two bounded native differences: Jolt damps AFTER #: adding the step's acceleration rather than before, so a body under sustained #: acceleration runs a relative ``linear_damping * dt`` slower there, while #: free coasting matches the formula exactly; pymunk integrates position before #: damping, so a coasting body travels one step's worth of the speed it has shed #: further (a fixed ``damping * dt`` fraction, 0.083% at the default rate). See #: ``docs/core/physics_backends.md``. LIVE: a write reaches the simulation #: immediately and slows the body from there rather than retroactively. linear_damping: float = Property( DEFAULT_LINEAR_DAMPING, range=(0.0, 100.0), clamp=False, hint="Rate at which the body sheds linear speed (per second)", group="Physics", on_change="_on_damping_changed", ) #: The same rate for SPIN, applied to the angular velocity. Independent of #: :attr:`linear_damping`: a wheel that must keep rolling while it stops sliding #: wants the two different. angular_damping: float = Property( DEFAULT_ANGULAR_DAMPING, range=(0.0, 100.0), clamp=False, hint="Rate at which the body sheds spin (per second)", group="Physics", on_change="_on_damping_changed", ) #: Multiplier on the world's gravity for this body alone. ``1`` falls normally, #: ``0`` ignores gravity entirely (a floating pickup, a hovering drone), and a #: negative value falls upward (a balloon). It multiplies the world's gravity, so #: a world with none has none whatever this says. LIVE: a write reaches the #: simulation immediately, and wakes the body, so one parked in mid-air at ``0`` #: really does start falling when this is turned back up. gravity_scale: float = Property( DEFAULT_GRAVITY_SCALE, range=(-10.0, 10.0), clamp=False, hint="This body's multiplier on world gravity", group="Physics", on_change="_on_gravity_scale_changed", ) #: Continuous collision detection. 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). LIVE: a write reaches the simulation immediately. On the #: built-in backend this is a CENTRE sweep vs STATIC bodies only (no rotational #: / dynamic-vs-dynamic CCD); the Jolt backend honours the flag faithfully via #: ``EMotionQuality::LinearCast``. A backend that does not advertise #: :attr:`~simvx.core.physics.capability.Capability.CONTINUOUS` has no CCD at #: all and integrates discretely whatever this says, which is reported once per #: body when it is turned on. continuous: bool = Property( False, hint="Continuous collision (anti-tunnelling for fast bodies)", group="Physics", on_change="_on_continuous_changed", ) #: Whether this body is ALLOWED to fall asleep once it settles. Defaults True. #: A settled body is skipped by integration and the contact solve until #: something disturbs it, which is what keeps a large resting pile cheap; set #: this False for a body that must stay simulated whatever it is doing, such #: as one a script polls the velocity of every frame. LIVE: a write reaches #: the simulation immediately, and forbidding sleep wakes the body at once #: rather than waiting for the next settle. A body that may not sleep is #: integrated and solved every step for as long as it exists, and so is #: everything it is touching, since a pile settles as a unit; that is the #: point of it, and also its cost. Honoured only where the backend advertises #: :attr:`~simvx.core.physics.capability.Capability.SLEEP`; where it does not, #: nothing ever sleeps and every body already behaves as though this were #: False. can_sleep: bool = Property( True, hint="Allow this body to fall asleep when it settles", group="Physics", on_change="_on_can_sleep_changed", ) #: Latch for the one-per-node "this backend has no CCD" report. A class #: attribute rather than instance state so a body that never touches #: ``continuous`` costs nothing for it. _ccd_gap_reported = False #: The mode the live body actually has, set when the body is built and after #: every accepted flip, so a refused one knows what to go back to. The seam #: has no mode getter to ask instead: motion mode is written, never read back. #: Class-level default so it reads safely before the body exists. _live_mode: BodyMode = BodyMode.DYNAMIC # -- read-only accessors (tests) ---------------------------------------
[docs] @property def is_sleeping(self) -> bool: """True if the simulated body is asleep. 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 body). """ if self._world is None or self._handle is None: return False return self._world.sleeping(self._handle)
[docs] def wake(self) -> None: """Wake this body now, whatever its sleep timer had reached. Rarely needed, because every write that changes what the solver reads already wakes the body. Reach for it when a game knows something the solver cannot see: a scripted force is coming next frame, or a script is about to read a velocity that must be current. A no-op when the body is awake, when it is STATIC or KINEMATIC (never asleep), and when inert (not in tree / no body). """ if self._world is None or self._handle is None: return self._world.wake(self._handle)
[docs] def sleep(self) -> None: """Put this body to sleep now, without waiting for it to settle. Freezes it where it is, still a full collider, until something wakes it. Use it to park a pile a game knows is finished rather than paying for it to come to rest first. A no-op when the body is STATIC or KINEMATIC, when :attr:`can_sleep` is False, when inert, and on a backend that does not advertise :attr:`~simvx.core.physics.capability.Capability.SLEEP`. """ if self._world is None or self._handle is None: return self._world.sleep(self._handle)
# -- velocity / spin (LIVE sim state, NOT serialized) ------------------ @property def velocity(self) -> Vec3: """Live linear velocity (``Vec3``), read/written straight to the physics world. This is runtime sim state, NOT a serialized :class:`Property`: each access delegates to the physics world, so reads are current and writes take effect immediately. Returns ``Vec3()`` (zero) when inert (not in tree / no 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 physics world. 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 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. The built-in backend has no inertia tensor and scales by ``inverse_mass`` as a stand-in for the inverse inertia, so spin results there are physically approximate. 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``. 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. On the built-in backend this 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))
# -- 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. Warned, not debug-logged: a body that is # absent from the simulation is almost always a mistake, and the symptom # (nothing collides, nothing falls) gives no clue on its own. self._handle = None self._world = None log.warning( "%s entered the tree with no shape and no CollisionShape3D child: " "no body was created, so it does not take part in the simulation.", type(self).__name__, ) return transform = self._build_transform() with self._naming_this_node(): self._handle = world.create_body( shape_handle, self.mode, transform, mass=self.mass, scale=self.world_scale, can_sleep=self.can_sleep, linear_damping=self.linear_damping, angular_damping=self.angular_damping, gravity_scale=self.gravity_scale, collision_layer=self.collision_layer, collision_mask=self.collision_mask, material=self.material, continuous=self.continuous, ) self._world = world self._live_mode = self.mode 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 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 backend 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). A flip the world refuses -- a triangle-mesh body leaving STATIC is the one case -- puts the Property back to the mode the body still has, so the node never reports a mode the simulation does not have. """ if self._world is None or self._handle is None: return try: self._world.set_body_mode(self._handle, self.mode) except Exception: restore_property(self, "mode", self._live_mode) raise self._live_mode = 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) def _body_follows_parent_transform(self) -> bool: """A STATIC body follows a moving ancestor; a simulated one does not.""" return self.mode is BodyMode.STATIC def _on_mass_changed(self) -> None: """Push ``mass`` to the live body (no-op when inert).""" if self._world is None or self._handle is None: return self._world.set_body_mass(self._handle, self.mass) def _on_can_sleep_changed(self) -> None: """Push ``can_sleep`` to the live body (no-op when inert).""" if self._world is None or self._handle is None: return self._world.set_body_can_sleep(self._handle, self.can_sleep) def _on_material_changed(self) -> None: """Push the whole surface material to the live body (no-op when inert).""" if self._world is None or self._handle is None: return self._world.set_body_material(self._handle, self.material) def _on_damping_changed(self) -> None: """Push both damping rates to the live body (no-op when inert). One hook for the pair because the seam takes them together: they are one description of how the body sheds motion. """ if self._world is None or self._handle is None: return self._world.set_body_damping(self._handle, self.linear_damping, self.angular_damping) def _on_gravity_scale_changed(self) -> None: """Push ``gravity_scale`` to the live body (no-op when inert).""" if self._world is None or self._handle is None: return self._world.set_body_gravity_scale(self._handle, self.gravity_scale) def _on_continuous_changed(self) -> None: """Push ``continuous`` to the live body, reporting a backend that has no CCD. The report is gated on the capability rather than on the backend's name, and fires only when CCD is turned ON (turning it off on a backend that never had it is not a surprise) and only once per node, since the value that matters is the state, not each write. """ if self._world is None or self._handle is None: return if self.continuous and not self._ccd_gap_reported and Capability.CONTINUOUS not in self._world.capabilities(): self._ccd_gap_reported = True log.warning( "%s.continuous was turned on, but %s has no continuous collision detection: this body " "keeps integrating discretely and a fast enough one can still tunnel through a thin collider.", type(self).__name__, type(self._world).__name__, ) self._world.set_body_continuous(self._handle, self.continuous)
[docs] def move_and_collide(self, velocity: Vec3 | Sequence[float], dt: float = 1.0) -> SweepHit | None: """Move by ``velocity * dt``, stop at the first contact, sync, return it. Meaningful for ``mode == KINEMATIC`` (code-driven movement); it sweeps the 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 physics world. After the sweep the node's transform is synced SYNCHRONOUSLY from the simulated body, so the pose is correct the instant this call returns. Returns a :class:`~simvx.core.physics.world.SweepHit` if the sweep stopped early, else ``None``. Also ``None`` if the body is inert (no ``CollisionShape3D`` child). A sweep is a geometric query, not a solver pass, so the result carries the blocker's handle, contact point, separating normal and guaranteed-clear distance, and no impulse. """ if self._world is None or self._handle is None: return None motion = Vec3(*velocity) * float(dt) hit = self._world.move_and_collide(self._handle, motion) pos, _rot = self._world.body_transform(self._handle) self._write_simulated_pose(pos) return hit
[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) super().on_exit_tree()
[docs] class CharacterBody3D(PhysicsObject3D): """A ``KINEMATIC`` physics body with a swept movement helper. Raycasts, shape queries and areas see it, dynamic bodies collide with it and rest on it, it produces contact events against dynamic bodies, and two characters block each other when their layers and masks mutually opt in. :meth:`move_and_slide` is the only thing that distinguishes it from any other kinematic body. It never carries a body resting on it, but it does shove one: at the default :attr:`push_factor` of ``1.0`` a blocking dynamic body takes the arrest impulse of the pair, so it leaves at no more than the speed the character was walking, scaled by the mass ratio. Set the factor to ``0.0`` and a dynamic body stops the sweep exactly as a static one does. The contacts it resolved are handed back in :attr:`collisions` either way, so game code can apply its own impulses. Geometry follows the standard order (:attr:`shape` Property if set, else the first direct-child :class:`CollisionShape3D`, else inert). It is deliberately NOT registered for the dynamic auto bulk-sync; :meth:`move_and_slide` syncs the node transform synchronously. :attr:`velocity` is set by game logic each frame and written back (deflected) after each move. :attr:`velocity` is a plain instance attribute, deliberately NOT the world-backed :attr:`PhysicsBody3D.velocity` property: the simulated body's velocity must stay zero, or the world's integrator would re-apply the motion :meth:`move_and_slide` has already performed. """ #: The movement knobs are read PER MOVE, not frozen at enter-tree, so they can #: be tuned live (a crouch lowering ``step_height``, a sprint raising #: ``max_slides``), as are ``collision_layer`` / ``collision_mask`` / ``shape``, #: which push straight to the live body like every other physics node's. 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="Contact clearance requested from the sweep (also scales the ground probe)", group="Character", ) #: The character's own mass in kg. Read only by the push arithmetic below: a #: character is kinematic, so nothing integrates forces on it and no solver #: reads this. The default is an adult human; a crate-pushing robot or a mouse #: sets its own, and what it walks into moves in proportion to #: ``mass / (mass + that body's mass)``. mass: float = Property( 70.0, range=(0.001, 100000), hint="Character mass in kg (scales how hard it shoves what it walks into)", group="Character", ) #: Impulse per blocking contact is the pair's reduced mass times the approach #: speed, times this factor: ``push_factor * (mass * m / (mass + m)) * #: approach_speed`` along the contact normal, ``m`` being the struck body's #: mass. The factor is dimensionless and defaults to ``1.0``, the arrest #: impulse of a perfectly inelastic collision, which is why it is safe to ship #: on: whatever it hits leaves at no more than the character's own approach #: speed. ``0.0`` is the opt-out and imparts nothing at all. A contact #: classified as FLOOR is exempt, so standing on a dynamic body imparts nothing #: to it; walls, ceilings and unwalkable slopes are pushed. See #: :mod:`simvx.core.physics.slide` for what the number means. push_factor: float = Property( 1.0, range=(0, 1000), hint="Dimensionless multiplier on the shove the character gives what it walks into", group="Character", ) def __init__(self, **kwargs: object) -> None: super().__init__(**kwargs) 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 #: Blocking contacts the last :meth:`move_and_slide` resolved, in slide #: order (empty before the first move and after an unobstructed one). #: Reported whatever :attr:`push_factor` is, so game code can apply physics #: of its own, e.g. for each ``c`` in here #: ``self.world.apply_impulse(c.body, impulse, at=c.point)``. self.collisions: tuple[SweepHit, ...] = () self._on_floor = False self._on_wall = False self._on_ceiling = False # -- read-only accessors -----------------------------------------------
[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. Runs the shared policy in :mod:`simvx.core.physics.slide` against the simulated body. Writes the deflected post-slide velocity back to :attr:`velocity`, caches floor/wall/ceiling state + :attr:`floor_normal` + :attr:`collisions`, shoves each non-floor blocking contact if :attr:`push_factor` is non-zero, and syncs the node's transform SYNCHRONOUSLY from the move (characters are not part of the auto bulk-sync). No-op if the body is inert (no collider). """ if self._world is None or self._handle is None: return result = slide.move_and_slide( self._world, self._handle, self.velocity, float(dt), up=self.up_direction, slope_limit=math.radians(float(self.slope_limit)), step_height=float(self.step_height), skin_width=float(self.skin_width), max_slides=int(self.max_slides), push_factor=float(self.push_factor), mass=float(self.mass), ) 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 self.collisions = result.collisions self._write_simulated_pose(result.position)
# -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: world = resolve_world(self) shape_handle = self._find_shape_handle(world) if shape_handle is None: # Inert (no collider): no body created, like an inert PhysicsBody3D. self._handle = None self._world = None log.warning( "%s entered the tree with no shape and no CollisionShape3D child: " "no body was created, so it does not take part in the simulation.", type(self).__name__, ) return with self._naming_this_node(): self._handle = world.create_body( shape_handle, BodyMode.KINEMATIC, self._build_transform(), scale=self.world_scale, collision_layer=self.collision_layer, collision_mask=self.collision_mask, ) self._world = world tree = self.tree # The mode-independent handle->node map: a character appears in the contact # and overlap streams like any other body. Deliberately NOT # register_physics_body: move_and_slide syncs the node pose synchronously, # so the bulk scatter would fight it. if tree is not None: tree.register_physics_node(world, self._handle, self)
[docs] class Area3D(PhysicsObject3D): """A pure sensor zone (trigger): broadphase-driven overlap detection. Owns a SENSOR body in the physics world (a flag on the body, not a separate class). 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: 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. Detection 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 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. What it detects is the same everywhere apart from one exception. An awake DYNAMIC or KINEMATIC body, a SLEEPING one and a STATIC one are all reported on every backend, the last of them where :attr:`~simvx.core.physics.capability.Capability.SENSOR_DETECTS_STATIC` is advertised, which today is every backend. The exception is an area whose geometry is a :class:`~simvx.core.physics.shapes.ConcaveMeshShape3D` on Jolt: Jolt requires a mesh collider to sit on a static body, and a static body never drives the pair search, so such an area sees only what is awake and movable. Trigger volumes are boxes and spheres in practice; give the area convex geometry -- at creation or by swapping it in later -- and the exception is gone. ``shape`` / ``collision_layer`` / ``collision_mask`` are live on an area exactly as they are on a body. :attr:`monitoring` is the one create-time knob: it decides whether a sensor body exists at all, so switching it after enter needs a re-enter (remove and re-add the node). """ #: 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). #: Read at enter-tree; toggling at runtime requires a re-enter. monitoring: bool = Property(True, hint="Detect overlaps (else inert)", group="Physics") def _wants_body(self) -> bool: """An area with ``monitoring=False`` is inert on purpose, not by accident.""" return bool(self.monitoring) #: Single-shape convenience ZONE. Same resolution order as every other physics #: node's ``shape``; redeclared only for the zone-flavoured inspector hint. shape: Shape | None = Property( None, hint="Single-shape convenience zone (else add CollisionShape3D children)", group="Collision", on_change="_on_shape_changed", ) #: Fires once when a :class:`PhysicsObject3D` BEGINS overlapping this area #: (overlap ENTER). Payload is the peer node, so a :class:`PhysicsBody3D` or a #: :class:`CharacterBody3D`; an overlapping area routes to #: :attr:`area_entered` instead. body_entered = Signal(PhysicsObject3D) #: Fires once when a :class:`PhysicsObject3D` STOPS overlapping this area #: (overlap EXIT). Payload is the peer node. A peer DESTROYED mid-overlap #: fires it too, one step later, carrying the destroyed node detached #: (``handle is None``); see :attr:`PhysicsObject3D.separated`. body_exited = Signal(PhysicsObject3D) #: 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. area_entered = Signal() #: Fires once when another :class:`Area3D` STOPS overlapping this area, #: including when that area is destroyed mid-overlap (detached payload, as #: for :attr:`body_exited`). area_exited = Signal() def __init__(self, **kwargs: object) -> None: # Live overlap sets, maintained by the tree from the edge-diffed event # stream (ENTER add / EXIT discard), NOT a tree scan. Set BEFORE # super().__init__ for the same reason the base sets its world/handle state there. self._overlapping_bodies: set[PhysicsObject3D] = set() self._overlapping_areas: set[Area3D] = set() super().__init__(**kwargs) # -- polling (live, from the maintained sets; NOT a tree scan) ---------- @overload def get_overlapping_bodies(self, *, group: str | None = ...) -> list[PhysicsObject3D]: ... @overload def get_overlapping_bodies(self, *, group: str | None = ..., type: type[_Obj3]) -> list[_Obj3]: ...
[docs] def get_overlapping_bodies(self, *, group=None, type=None): """Bodies currently overlapping this area (live, as of the last step). A peer destroyed / removed from the tree mid-overlap is dropped from the maintained set by the overlap ``EXIT`` the seam reports for it, on the step after it goes (:attr:`body_exited` fires with it at the same moment). Detached peers (``handle is None``) are also filtered here, which covers the one step before that event lands and a body carried out with a world that this tree will not step again. The optional filters narrow the result without a tree scan: they test the already-maintained overlap set. 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:`PhysicsObject3D` subclass (``isinstance(body, type)``), and the result is typed as a list of THAT class. A character is an ordinary kinematic body, so it appears here like any other; pass ``type=PhysicsBody3D`` when the caller needs the force/mass API a character does not have. Returns: The overlapping bodies (live, detached 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). Detached 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 = self._find_shape_handle(world) if shape_handle is None: self._handle = None self._world = None log.warning( "%s entered the tree with no shape and no CollisionShape3D child: " "no sensor body was created, so it detects nothing.", type(self).__name__, ) return with self._naming_this_node(): self._handle = world.create_body( shape_handle, BodyMode.STATIC, self._build_transform(), scale=self.world_scale, 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: super().on_exit_tree() # 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. Following 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 a replacement for it: 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`). There is no inverse-distance / inverse-square falloff, no per-area damping override and no gravity-replacement mode. 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 (shared with :class:`Area3D`): the sensor body pose is read at enter-tree, so moving a ``GravityArea3D`` at runtime does not move the zone it detects and pulls in. """ #: 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. A :class:`CharacterBody3D` overlapping the area IS in the overlap set (it is an ordinary kinematic body), but it is never force-driven: it is not a :class:`PhysicsBody3D` and so has no :meth:`~PhysicsBody3D.add_force`, and the :attr:`~CharacterBody3D.mass` it does carry is read only by its own push arithmetic, never by an integrator. It is position-driven by :meth:`CharacterBody3D.move_and_slide` instead, and a game that wants a gravity zone to move one applies the field to its ``velocity`` itself. The ``type=`` filter is what excludes it, so a character in a gravity zone is simply ignored rather than an error. """ bodies = self.get_overlapping_bodies(type=PhysicsBody3D) 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 backend 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 constraint nodes (node-agnostic carriers). A ``Joint3D`` constrains two :class:`PhysicsBody3D` instances in the physics world. It is a thin carrier: it holds the two body references plus its per-joint Properties and owns the constraint lifecycle (create on enter-tree, remove on exit-tree), exactly mirroring how :class:`PhysicsBody3D` owns its body. Body references (:attr:`body_a` / :attr:`body_b`) are PLAIN instance attributes, NOT :class:`Property` descriptors: constraints are keyed by body handle rather than by node, and cross-scene references are plain Python imports. 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 constraint is created), 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 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; body 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 (same as body shape / mass / layers): a joint reads its Properties and resolves its bodies ONCE at enter-tree. Changing a parameter or a body reference after enter requires a re-enter. 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). """ def __init__( self, *, body_a: PhysicsBody3D | None = None, body_b: PhysicsBody3D | None = None, **kwargs: object, ) -> None: # World/handle 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 backend 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 backend 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 constraint created).", 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 physics 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; body 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 world 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 in that frame, so the two bodies move as one rigid assembly and the assembly swings round when ``body_a`` turns. No anchor / axis Properties: the constraint captures the current relative pose at create. Built-in backend caveat: it 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), and convergence is a few iterations (a long weld chain sags slightly). Use near-uniform masses, or the Jolt backend for precise articulated mechanisms. """ 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. The anchor is captured into each body's own frame, so a pin on a spinning body orbits with it. Built-in backend caveat: angular cross-coupling uses the ``inverse_mass`` inverse-inertia scalar (there is no inertia tensor), and the solver runs only a few iterations, so a long chain sags slightly. """ #: 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``. The axis is captured into each body's own frame, so a door hung on a post that is itself turning keeps swinging about the post. There are no motors and no angular limits. Built-in backend caveat: the same ``inverse_mass`` inverse-inertia-scalar stand-in as :class:`PinJoint3D`, and only a few solver iterations. """ #: 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. Acts between the two centres of mass rather than between anchors as Pin and Hinge do. On the built-in backend 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)