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