"""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",
]
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)