"""Body + shape-carrier nodes for 2D physics.
The 2D sibling of :mod:`~simvx.core.physics.nodes`: the user-facing 2D
body/shape/area/joint node taxonomy backed by
:class:`~simvx.core.physics.world2d.Physics2DWorld`. Every node extends
:class:`~simvx.core.nodes_2d.node2d.Node2D` (scalar ``world_rotation`` in
radians), resolves its 2D world via :func:`resolve_world_2d`, and rides the same
SceneTree fixed-step plumbing as the 3D nodes.
These nodes reuse the dimension-agnostic pieces by import (:class:`BodyMode`,
:class:`PhysicsMaterial`, :class:`Bitmask`, :class:`Property`, :class:`Signal`)
and carry 2D-only state (``one_way`` / ``one_way_normal``, scalar ``spin``). All
of them are exported from ``simvx.core``.
"""
from __future__ import annotations
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 Vec2
from ..nodes_2d.node2d import Node2D
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_2d
from .shapes2d import RectangleShape2D, Shape2D
from .world2d import (
DEFAULT_ANGULAR_DAMPING,
DEFAULT_GRAVITY_SCALE,
DEFAULT_LINEAR_DAMPING,
BodyMode,
SweepHit2D,
)
if TYPE_CHECKING:
from .world2d import BodyHandle, JointHandle, Physics2DWorld, ShapeHandle
log = logging.getLogger(__name__)
#: Binds ``Area2D.get_overlapping_bodies``'s ``type=`` filter to its return type.
_Obj2 = TypeVar("_Obj2", bound="PhysicsObject2D")
__all__ = [
"BodyMode",
"Contact2D",
"CollisionShape2D",
"PhysicsObject2D",
"PhysicsBody2D",
"CharacterBody2D",
"Area2D",
"GravityArea2D",
"Joint2D",
"FixedJoint2D",
"PinJoint2D",
"HingeJoint2D",
"SpringJoint2D",
"GrooveJoint2D",
]
def _resolve_shape_2d(node: Node2D) -> Shape2D | None:
"""Resolve a 2D 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:`CollisionShape2D`'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:`PhysicsObject2D` so the order is stated once for every node family
that carries a collider.
"""
shape: Shape2D | None = node.shape
if shape is not None:
return shape
shape_node = node.find(CollisionShape2D, direct=True)
return None if shape_node is None else shape_node.shape
[docs]
class CollisionShape2D(Node2D):
"""A ``Node2D`` that carries a :class:`Shape2D` collision-geometry resource.
Geometry lives in a single :class:`Shape2D` resource (``CircleShape2D`` /
``RectangleShape2D`` / ...), so the node stays open for new shape kinds. A
node that only carries data: collision detection itself belongs to the physics
world. It exists so a body can discover its geometry as a child node.
"""
shape: Shape2D = Property(
default_factory=RectangleShape2D,
hint="Collision shape resource",
group="Collision",
on_change="_on_shape_changed",
)
def __init__(self, **kwargs: object) -> None:
# The last accepted ``shape``, set before super().__init__ because the
# base flushes deferred Property hooks at the end of its own init, and
# re-read after so a node built with the Property's default tracks it.
self._live_shape: Shape2D | None = None
super().__init__(**kwargs)
self._live_shape = self.shape
[docs]
def build_shape(self, world: Physics2DWorld) -> ShapeHandle:
"""Build this node's shape into an opaque backend shape handle."""
return self.shape.build(world)
def _on_shape_changed(self) -> None:
"""Swap the owning body's collider when this resource is replaced.
The 2D sibling of
:meth:`~simvx.core.physics.nodes.CollisionShape3D._on_shape_changed`:
routed only when this node is the collider the parent actually uses, and
put back to the last accepted value when the world refuses the swap.
"""
held, self._live_shape = self._live_shape, self.shape
owner = self.parent
if not isinstance(owner, PhysicsObject2D) or owner.shape is not None:
return
if owner.find(CollisionShape2D, 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.
The 2D sibling of
:meth:`~simvx.core.physics.nodes.CollisionShape3D._notification`: adding,
removing or re-ordering a collider child changes which shape a body
resolves to just as assigning a new resource does, and a resolution the
world refuses raises with the body still on the collider it had, nothing
put back, and this child left claiming another.
"""
super()._notification(what)
if what is not Notification.PARENTED and what is not Notification.UNPARENTED:
return
owner = self.parent
if isinstance(owner, PhysicsObject2D) and owner.shape is None:
owner._push_shape_to_world()
[docs]
class PhysicsObject2D(_PhysicsPoseReconcile, Node2D):
"""Abstract base of every 2D node that owns a body in the physics world.
The 2D sibling of :class:`~simvx.core.physics.nodes.PhysicsObject3D`; see it
for the rationale. 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, the :attr:`collided` / :attr:`separated` Signals
and the exit-tree teardown. Subclasses own body CREATION only.
"""
#: Layer membership + collision mask (plain 32-bit ints). LIVE: a write reaches
#: the simulation immediately (see :meth:`_push_filter_to_world`). The
#: body-body rule is AND (a pair collides iff BOTH bodies opt in to the other's
#: layer). See ``PhysicsBody3D`` for the ``IntFlag`` named-bits pattern.
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
#: ``CollisionShape2D`` child (order: ``shape`` Property if set, else first
#: ``CollisionShape2D`` child, else inert). LIVE: assigning a new resource swaps
#: the body's collider in place (see :meth:`_push_shape_to_world`). Mutating a
#: shape resource IN PLACE is not a Property write and does not reach the
#: simulation: assign a new shape instead.
shape: Shape2D | None = Property(
None,
hint="Single-shape convenience collider (else add CollisionShape2D children)",
group="Collision",
on_change="_on_shape_changed",
)
#: Fires once when this object BEGINS touching another (contact ENTER). Payload
#: is a node-level :class:`Contact2D` (``other`` is the peer,
#: ``normal`` / ``velocity`` oriented toward THIS object). A sensor body is
#: excluded from collision resolution, so an :class:`Area2D` never emits these.
#: Latches for the two one-per-node reports in :meth:`_push_shape_to_world`.
#: Class attributes, so a node that never hits either case carries nothing.
_bodyless_reported = False
_shapeless_reported = False
collided = Signal(Contact2D)
#: Fires once when this object STOPS touching another (contact EXIT). Payload
#: is a degenerate :class:`Contact2D` (only ``other`` is meaningful). A peer
#: DESTROYED mid-touch fires it too, one step later, with ``other`` set to the
#: destroyed node detached (``tree`` / ``handle`` / ``world`` all ``None``);
#: see :attr:`~simvx.core.physics.nodes.PhysicsObject3D.separated`.
separated = Signal(Contact2D)
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 must exist for the not-in-tree no-op
# guards to read.
self._world: Physics2DWorld | None = None
self._handle: BodyHandle | None = None
self._built_shape: Shape2D | None = None
# The last ``shape`` value that was accepted, tracked rather than inferred
# from the built collider; see the 3D twin.
self._live_shape: Shape2D | 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) -> Physics2DWorld | None:
"""The :class:`Physics2DWorld` this object's body was created in, or ``None``."""
return self._world
# -- helpers -----------------------------------------------------------
def _find_shape_handle(self, world: Physics2DWorld) -> ShapeHandle | None:
"""Build this object's collider into ``world`` (Property-first), or ``None``.
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_2d(self)
self._built_shape = shape
return None if shape is None else shape.build(world)
def _build_transform(self) -> tuple[Vec2, float]:
"""Return this object's initial world pose as ``(position, rotation)`` (scalar)."""
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 -----------------------------------------------
#
# The 2D siblings of
# :meth:`~simvx.core.physics.nodes.PhysicsObject3D._push_filter_to_world` and
# :meth:`~simvx.core.physics.nodes.PhysicsObject3D._push_shape_to_world`: every
# Property the body was created from is pushed into the body that already
# exists, so the node and the simulation never disagree and no body is ever
# destroyed and rebuilt for a value change.
def _push_filter_to_world(self) -> None:
"""Push the layer/mask pair to the live body (no-op when inert)."""
if self._world is None or self._handle is None:
return
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.
Keeps the body's handle, pose, velocity, mode, mass, filter and joints; the
swap is a teleport of geometry, so an overlap it introduces is pushed apart
by the ordinary contact solve over the following steps. The 2D sibling of
:meth:`~simvx.core.physics.nodes.PhysicsObject3D._push_shape_to_world`,
including its two once-per-node reports: a node in the tree with no body
(it entered without a collider), and a live body whose last collider was
cleared, which cannot become shapeless.
"""
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_2d(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 CollisionShape2D 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:
return # already the body's geometry: do not build a second copy
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; the same holds for a write to a collider
child's ``shape``, and not for adding or removing the child itself (see
:meth:`CollisionShape2D._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.
The 2D twin of
:meth:`~simvx.core.physics.nodes.PhysicsObject3D.on_exit_tree`: retired
rather than dropped, so a peer's ``separated`` / ``body_exited`` can still
name this body on the step after it goes.
"""
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)
self._world.destroy_body(self._handle)
self._handle = None
self._world = None
self._built_shape = None
[docs]
class PhysicsBody2D(PhysicsObject2D):
"""A 2D physics body whose motion mode is a Property.
The 2D sibling of :class:`~simvx.core.physics.nodes.PhysicsBody3D`: one
concrete body node with a runtime-mutable :class:`BodyMode` ``mode`` knob
(``STATIC | KINEMATIC | DYNAMIC``). :class:`PhysicsObject2D` owns the body
lifecycle (resolve 2D world, build shape, unregister + destroy on exit); this
class adds the mode-typed create, the mass/material knobs and the 2D-only
``one_way`` / ``one_way_normal`` Properties. ``velocity`` is a ``Vec2``
accessor; ``spin`` is a scalar (2D angular is 1-DOF).
**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 (runtime-mutable while in-tree). 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 (``PhysicsBody2D(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.world2d.Physics2DWorld` call with a non-positive
#: mass raises ``ValueError``. LIVE: a write reaches the simulation immediately,
#: recomputing the body's moment 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), shareable across bodies. 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. One bounded backend difference: pymunk integrates
#: position before damping the velocity, so a coasting body there travels one
#: step's worth of the speed it has shed further than the formula says (a fixed
#: ``damping * dt`` fraction of the coast: 0.083% at the default rate, 0.83% at
#: ``0.5``). 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, and independent of
#: :attr:`linear_damping`.
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 (a floating pickup), and a negative value falls upward
#: (a balloon). LIVE, and the write wakes the body.
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, the body's centre displacement
#: is swept against STATIC geometry each step and clamped to the TOI, so a fast
#: small body cannot tunnel a thin static collider. LIVE: a write reaches the
#: simulation immediately. On the built-in backend this is a CENTRE sweep
#: against STATIC bodies only. The pymunk backend does not advertise
#: :attr:`~simvx.core.physics.capability.Capability.CONTINUOUS` (Chipmunk2D 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. LIVE: a
#: write reaches the simulation immediately, and forbidding sleep wakes the
#: body at once rather than waiting for the next settle. It reaches further
#: than this body: a pile settles as a unit, so a body that may never sleep
#: keeps everything it is touching awake as well. Honoured only where
#: the backend advertises
#: :attr:`~simvx.core.physics.capability.Capability.SLEEP`; the pymunk backend
#: does not (Chipmunk2D sleeps nothing unless its space is given a finite
#: sleep-time threshold), so there 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
#: One-way platform (2D-only). When True, the body only collides with bodies
#: approaching from the ``+one_way_normal`` side (landing on it); bodies
#: passing through along ``+one_way_normal`` are not blocked. Unlike the other
#: body knobs this one is LIVE: the 2D world exposes ``set_one_way``, so a write
#: reaches the simulation immediately whether the body is in the world or not.
#: The filter is velocity-gated, so a fast body can still pop through.
one_way: bool = Property(False, hint="One-way platform (2D-only)", group="Physics", on_change="_on_one_way_changed")
#: The "solid side" normal of the one-way platform (``Vec2``, unit); the side
#: a lander must approach from. Defaults to ``+Y`` (a floor). Live like
#: :attr:`one_way`, and only meaningful while that is True.
one_way_normal: Vec2 = Property(
default_factory=lambda: Vec2(0.0, 1.0),
hint="One-way solid-side normal (unit)",
group="Physics",
on_change="_on_one_way_changed",
)
# -- read-only accessors (tests) ---------------------------------------
[docs]
@property
def is_sleeping(self) -> bool:
"""True if the simulated body is asleep. False when inert."""
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. 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. 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) -> Vec2:
"""Live linear velocity (``Vec2``), read/written straight to the physics world.
Runtime sim state, NOT a serialized :class:`Property`. Returns ``Vec2()``
when inert (not in tree / no body). The setter preserves the current
angular velocity (:attr:`spin`).
"""
if self._world is None or self._handle is None:
return Vec2()
linear, _angular = self._world.body_velocity(self._handle)
return linear
[docs]
@velocity.setter
def velocity(self, value: Vec2 | 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, Vec2(*value), angular)
@property
def spin(self) -> float:
"""Live scalar angular velocity (radians/s, CCW positive), written to the physics world.
Companion to :attr:`velocity`; same live-state, never-serialized rules.
Returns ``0.0`` when inert. The setter preserves linear velocity.
"""
if self._world is None or self._handle is None:
return 0.0
_linear, angular = self._world.body_velocity(self._handle)
return float(angular)
[docs]
@spin.setter
def spin(self, value: 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, float(value))
# -- forces (DYNAMIC only; inert no-op otherwise) ----------------------
[docs]
def push(self, impulse: Vec2 | Sequence[float], *, at: Vec2 | Sequence[float] | None = None) -> None:
"""Apply an instantaneous linear impulse NOW (DYNAMIC-only, inert otherwise).
``at`` is a world-space point; its offset ``r = at - centre`` adds a scalar
angular impulse via the 2D cross product.
"""
if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC:
return
self._world.apply_impulse(self._handle, Vec2(*impulse), at=None if at is None else Vec2(*at))
[docs]
def spin_up(self, angular_impulse: float) -> None:
"""Apply an instantaneous SCALAR angular impulse (DYNAMIC-only, inert otherwise).
Applied via the body's real inverse moment of inertia (the 2D backend has
a per-shape scalar moment, unlike the 3D ``inverse_mass`` stand-in).
"""
if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC:
return
self._world.apply_impulse(self._handle, Vec2(0.0, 0.0), angular=float(angular_impulse))
[docs]
def add_force(self, force: Vec2 | Sequence[float], *, at: Vec2 | Sequence[float] | None = None) -> None:
"""Accumulate a continuous force, applied during the NEXT fixed step.
Auto-cleared each step (re-call per ``on_fixed_update`` to sustain).
DYNAMIC-only, inert otherwise. ``at`` adds a scalar torque ``cross(r, force)``.
"""
if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC:
return
self._world.apply_force(self._handle, Vec2(*force), at=None if at is None else Vec2(*at))
[docs]
def add_torque(self, torque: float) -> None:
"""Accumulate a continuous SCALAR torque for the NEXT fixed step.
Auto-cleared each step like :meth:`add_force`. DYNAMIC-only, inert
otherwise. Applied via the real inverse moment of inertia.
"""
if self._world is None or self._handle is None or self.mode is not BodyMode.DYNAMIC:
return
self._world.apply_torque(self._handle, float(torque))
# -- lifecycle ---------------------------------------------------------
[docs]
def on_enter_tree(self) -> None:
world = resolve_world_2d(self)
shape_handle = self._find_shape_handle(world)
if shape_handle is None:
# No collider: stay inert (no half-registered body, no crash). Warned,
# not debug-logged: a body 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 CollisionShape2D child: "
"no body was created, so it does not take part in the simulation.",
type(self).__name__,
)
return
transform = self._build_transform()
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,
)
# One-way is a post-create per-body flag on the 2D world.
if self.one_way:
world.set_one_way(self._handle, True, Vec2(*self.one_way_normal))
self._world = world
self._live_mode = self.mode
tree = self.tree
# Mode-independent handle->node map for collision-event dispatch (a static
# floor must still fire ``collided``).
if tree is not None:
tree.register_physics_node(world, self._handle, self)
# Only integrated bodies need transform read-back; STATIC never moves.
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. While in-tree it flips the backend motion type AND
keeps the scatter registry consistent (STATIC leaves the dynamic
bulk-sync; non-STATIC must be in it).
A flip the world refuses -- a segment-soup 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.
Gated on the capability rather than on the backend's name, and reported only
when CCD is turned ON and only once per node. See
:meth:`~simvx.core.physics.nodes.PhysicsBody3D._on_continuous_changed`.
"""
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)
def _on_one_way_changed(self) -> None:
"""Push ``one_way`` / ``one_way_normal`` to the live body.
The 2D world exposes ``set_one_way`` as a per-body flag settable at any
time, so unlike the other body knobs this pair is genuinely live: both
Properties route here and re-state the whole flag+normal pair, which is
also how one-way filtering is turned back off.
No-op when the body is inert (not in tree / no collider); the values are
applied by the next :meth:`on_enter_tree`.
"""
if self._world is None or self._handle is None:
return
self._world.set_one_way(self._handle, bool(self.one_way), Vec2(*self.one_way_normal))
[docs]
def move_and_collide(self, velocity: Vec2 | Sequence[float], dt: float = 1.0) -> SweepHit2D | None:
"""Move by ``velocity * dt``, stop at the first contact, sync, return it.
Meaningful for ``mode == KINEMATIC``. Sweeps the body's shape and
reports the first blocker as a
:class:`~simvx.core.physics.world2d.SweepHit2D`. After the sweep the
node's transform is synced SYNCHRONOUSLY from the simulated body. Returns
``None`` on no contact or when inert.
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: there is no solver lambda to report.
"""
if self._world is None or self._handle is None:
return None
motion = Vec2(*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 CharacterBody2D(PhysicsObject2D):
"""A ``KINEMATIC`` 2D physics body with a swept movement helper.
The 2D sibling of :class:`~simvx.core.physics.nodes.CharacterBody3D`: an
ordinary kinematic body in the same body table, visible to raycasts, shape
queries, areas and the contact stream, collided with and rested on by dynamic
bodies, and blocking another character when their layers and masks mutually
opt in. :meth:`move_and_slide` is the only thing that distinguishes it. 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.
It is deliberately NOT registered for the dynamic auto bulk-sync;
:meth:`move_and_slide` syncs the node transform synchronously.
:attr:`velocity` is a plain instance attribute, deliberately NOT the
world-backed :attr:`PhysicsBody2D.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.
"""
#: Read PER MOVE, not frozen at enter-tree, so they can be tuned live, 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 the same unit as :attr:`PhysicsBody2D.mass`.
#: Read only by the push arithmetic below; nothing integrates forces on a
#: kinematic body. A 2D game usually works in pixels and light masses, so this
#: is the knob that decides whether the character shoves crates around or
#: barely disturbs them: 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 (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. It is dimensionless and defaults to ``1.0``, the arrest impulse of a
#: perfectly inelastic collision, so what the character walks into leaves at no
#: more than its own approach speed. ``0.0`` is the opt-out. A contact
#: classified as FLOOR is exempt: standing on a dynamic body imparts nothing to
#: it. See :class:`~simvx.core.physics.nodes.CharacterBody3D`.
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: Vec2 = Vec2() # per-frame, not serialized geometry
self.up_direction: Vec2 = Vec2(0.0, 1.0) # Y-up convention
self.floor_normal: Vec2 = Vec2(0.0, 1.0) # last move's floor normal
#: Blocking contacts the last :meth:`move_and_slide` resolved, in slide
#: order. Reported whatever :attr:`push_factor` is, so game code can apply
#: physics of its own, e.g. ``self.world.apply_impulse(c.body, imp,
#: at=c.point)`` for each entry.
self.collisions: tuple[SweepHit2D, ...] = ()
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. No-op if inert (no collider).
"""
if self._world is None or self._handle is None:
return
result = slide.move_and_slide_2d(
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_2d(self)
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 CollisionShape2D child: "
"no body was created, so it does not take part in the simulation.",
type(self).__name__,
)
return
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.
if tree is not None:
tree.register_physics_node(world, self._handle, self)
[docs]
class Area2D(PhysicsObject2D):
"""A 2D pure sensor zone (trigger): broadphase-driven overlap detection.
The 2D sibling of :class:`~simvx.core.physics.nodes.Area3D`. Owns a SENSOR
body (a flag, not a separate class): it participates in the broadphase but is
excluded from collision resolution. Detection is ONE-DIRECTIONAL (the area sees
another body iff ``area.collision_mask & other.collision_layer``). Overlap edges
arrive as a buffered, deferred event stream drained by the tree; the live
overlap sets are maintained from those edges (never a per-frame tree scan).
Geometry follows the standard order (``shape`` Property if set, else first
``CollisionShape2D`` child, else inert). The sensor body is STATIC.
As in 3D, a STATIC body is reported only where
:attr:`~simvx.core.physics.capability.Capability.SENSOR_DETECTS_STATIC` is
advertised, which is both 2D backends: pymunk forms no static-vs-static
pair, so the adapter holds every sensor KINEMATIC instead, which also lets
an ``Area2D`` there report another ``Area2D``.
"""
#: When False the area is INERT: no sensor body, detects nothing, fires nothing.
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: Shape2D | None = Property(
None,
hint="Single-shape convenience zone (else add CollisionShape2D children)",
group="Collision",
on_change="_on_shape_changed",
)
#: Fires once when a :class:`PhysicsObject2D` BEGINS overlapping, so a
#: :class:`PhysicsBody2D` or a :class:`CharacterBody2D` (an overlapping area
#: routes to :attr:`area_entered` instead).
body_entered = Signal(PhysicsObject2D)
#: Fires once when a :class:`PhysicsObject2D` STOPS overlapping, including
#: when it is destroyed mid-overlap (detached payload, one step later).
body_exited = Signal(PhysicsObject2D)
#: Fires once when another :class:`Area2D` BEGINS overlapping (one-directional;
#: bare ``Signal()`` as the class cannot reference itself at class-body time).
area_entered = Signal()
#: Fires once when another :class:`Area2D` STOPS overlapping, including when
#: that area is destroyed mid-overlap (detached payload, one step later).
area_exited = Signal()
def __init__(self, **kwargs: object) -> None:
# Live overlap sets, set BEFORE super().__init__ for the same reason the
# base sets its world/handle state there.
self._overlapping_bodies: set[PhysicsObject2D] = set()
self._overlapping_areas: set[Area2D] = set()
super().__init__(**kwargs)
# -- polling (live, from the maintained sets; NOT a tree scan) ----------
@overload
def get_overlapping_bodies(self, *, group: str | None = ...) -> list[PhysicsObject2D]: ...
@overload
def get_overlapping_bodies(self, *, group: str | None = ..., type: type[_Obj2]) -> list[_Obj2]: ...
[docs]
def get_overlapping_bodies(self, *, group=None, type=None):
"""Bodies currently overlapping this area (live, as of the last step).
A peer destroyed mid-overlap is dropped from the maintained set by the
overlap ``EXIT`` the seam reports for it on the step after it goes.
Detached peers (``handle is None``) are also filtered here, covering the
one step before that event lands.
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:`PhysicsObject2D` 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=PhysicsBody2D`` 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[Area2D]:
"""Other areas currently overlapping this area (live, as of the last step)."""
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_2d(self)
if not self.monitoring:
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 CollisionShape2D child: "
"no sensor body was created, so it detects nothing.",
type(self).__name__,
)
return
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: overlap dispatch reads it to
# map both sides. Do NOT register_physics_body (no bulk scatter).
if tree is not None:
tree.register_physics_node(world, self._handle, self)
[docs]
def on_exit_tree(self) -> None:
super().on_exit_tree()
self._overlapping_bodies.clear()
self._overlapping_areas.clear()
[docs]
class GravityArea2D(Area2D):
"""A 2D force-field zone: an ADDITIVE gravity effector over the bodies it overlaps.
The 2D sibling of :class:`~simvx.core.physics.nodes.GravityArea3D`. Inherits
the entire sensor mechanism unchanged and adds an :meth:`on_fixed_update`
handler that, each fixed step, applies a field (additive on top of world
gravity) to every DYNAMIC overlapping body. Two independent SUMMING components:
- **Directional** (:attr:`gravity`): a uniform acceleration (``Vec2``) applied
regardless of body mass, exactly like world gravity.
- **Point** (:attr:`point_gravity` / :attr:`point_strength`): a CONSTANT
acceleration of magnitude :attr:`point_strength` toward the area centre
(:attr:`world_position`).
The acceleration becomes a force via ``add_force(mass * accel)`` (the
integrator divides by mass, so the net effect is mass-INDEPENDENT, like
gravity). ``add_force`` is auto-cleared each step, so the field is freshly
re-applied every fixed step and consumed once by the following ``world.step``.
"""
#: Uniform acceleration added to bodies in the area (m/s^2, world space).
gravity: Vec2 = Property(
default_factory=lambda: Vec2(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.
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 point gravity is on.
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 gets no point term.
_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 not used to scale
(``add_force`` is a continuous force, not an impulse).
A :class:`CharacterBody2D` overlapping the area IS in the overlap set (it
is an ordinary kinematic body) but is never force-driven: it is not a
:class:`PhysicsBody2D` and so has no :meth:`~PhysicsBody2D.add_force`, and
the :attr:`~CharacterBody2D.mass` it does carry is read only by its own
push arithmetic, never by an integrator. It is position-driven by
:meth:`CharacterBody2D.move_and_slide` instead. The ``type=`` filter is
what excludes it.
"""
bodies = self.get_overlapping_bodies(type=PhysicsBody2D)
if not bodies:
return
directional = Vec2(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:
continue
accel = Vec2(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 Joint2D(Node2D):
"""Base class for the 2D constraint nodes (node-agnostic carriers).
The 2D sibling of :class:`~simvx.core.physics.nodes.Joint3D`. Constrains two
:class:`PhysicsBody2D` instances in the 2D world; a thin carrier that owns the
constraint lifecycle (create on enter-tree, remove on exit-tree). Body
references (:attr:`body_a` / :attr:`body_b`) are PLAIN instance attributes, set
programmatically or via the constructor.
Same resolution rules as the 3D base: a missing body is a soft-inert no-op; a
cross-world (or missing-world) pair after handles resolve is a ``ValueError``.
ALWAYS add a joint AFTER both of its bodies (or re-enter it).
"""
def __init__(
self,
*,
body_a: PhysicsBody2D | None = None,
body_b: PhysicsBody2D | None = None,
**kwargs: object,
) -> None:
# World/handle state BEFORE super().__init__ (same pattern as PhysicsBody2D).
self._world: Physics2DWorld | None = None
self._joint: JointHandle | None = None
#: The two bodies this joint constrains (plain attributes, not Properties).
self.body_a: PhysicsBody2D | None = body_a
self.body_b: PhysicsBody2D | 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) -> Physics2DWorld | None:
"""The :class:`Physics2DWorld` this joint was created in, or ``None``."""
return self._world
# -- subclass hook -----------------------------------------------------
def _create(self, world: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
"""Create the backend constraint for this joint kind, returning its handle."""
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:
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:
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:
raise ValueError(f"{type(self).__name__}: body_a and body_b must be in the same Physics2DWorld")
if resolve_world_2d(self) is not wa:
log.debug(
"%s sits under a different PhysicsRoot2D 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:
self._world.remove_joint(self._joint)
self._joint = None
self._world = None
[docs]
class FixedJoint2D(Joint2D):
"""Weld two 2D bodies: lock their full relative transform (position + rotation).
Captures the current relative pose at enter-tree, in ``body_a``'s frame, and
holds it there: the two bodies move as one rigid assembly, and the assembly
swings round when ``body_a`` turns. No anchor Properties.
"""
def _create(self, world: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
return world.create_fixed_joint(a, b)
[docs]
class PinJoint2D(Joint2D):
"""Pin two 2D bodies at a single world-space point, rotation free.
The two bodies cannot separate at :attr:`anchor` but rotate freely about it,
so a chain of pins is a rope and a pin to a STATIC body is a pendulum. The
anchor is captured into each body's own frame at enter-tree, so a pin on a
spinning body orbits with it.
Built-in backend caveat: convergence is a few sequential-impulse iterations, so
a loaded chain sags and a hard yank stretches a link before the solver pulls it
straight; the pymunk backend hangs the same chain on its rest length. See
``docs/core/physics_backends.md`` for the measured sag.
"""
#: World-space pivot point. Defaults to ``Vec2(0, 0)``; if left at that
#: default, the node falls back to its own ``world_position`` at enter-tree.
#: Read at enter-tree.
anchor: Vec2 = Property(default_factory=lambda: Vec2(0.0, 0.0), group="Joint", hint="World-space pivot point")
def _create(self, world: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
anchor = Vec2(*self.anchor)
if float(anchor.x) == 0.0 and float(anchor.y) == 0.0:
anchor = self.world_position # default sentinel: use the node's own pivot
return world.create_pin_joint(a, b, anchor)
[docs]
class HingeJoint2D(Joint2D):
"""Hinge two 2D bodies at :attr:`anchor` (no axis: 2D rotation is 1-DOF).
2D rotation is 1-DOF, so a 2D hinge has no ``axis`` argument: this tier it is a
pin at ``anchor`` (motors / angular limits are a follow-on).
"""
#: World-space hinge pivot (same default-to-``world_position`` behaviour as
#: :class:`PinJoint2D`). Read at enter-tree.
anchor: Vec2 = Property(default_factory=lambda: Vec2(0.0, 0.0), group="Joint", hint="World-space pivot point")
def _create(self, world: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
anchor = Vec2(*self.anchor)
if float(anchor.x) == 0.0 and float(anchor.y) == 0.0:
anchor = self.world_position
return world.create_hinge_joint(a, b, anchor)
[docs]
class SpringJoint2D(Joint2D):
"""Soft distance-spring between the two 2D 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.
"""
#: 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. 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: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
rest = self.rest_length
if rest < 0.0:
# Auto-capture: _create is only called once both bodies resolved.
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)
[docs]
class GrooveJoint2D(Joint2D):
"""Slide ``body_b``'s anchor along a groove (line segment) fixed on ``body_a``.
The 2D-only pymunk-native slider-on-a-line constraint (no 3D equivalent).
:attr:`groove_a` / :attr:`groove_b` are body-local points in ``body_a``'s frame
defining the groove segment; :attr:`anchor_b` is a body-local point in
``body_b``'s frame. ``body_b``'s anchor is held on that segment: it slides freely
ALONG the groove and is pinned PERPENDICULAR to it, clamped between the endpoints.
The groove turns with ``body_a`` and the anchor with ``body_b``: both are held
in the frames they are declared in.
There is no slide motor on any backend: drive the slider with ``apply_force``
on ``body_b``. The endpoint stop differs in firmness: pymunk keeps the anchor
on the groove under loads that push the built-in solver's slider past the end
and leave it there, the built-in stop being the same soft positional push its
other joints use. ``docs/core/physics_backends.md`` has the measurement.
"""
#: Groove start, body-local in ``body_a``'s frame. Read at enter-tree.
groove_a: Vec2 = Property(
default_factory=lambda: Vec2(-1.0, 0.0), group="Joint", hint="Groove start (body_a local)"
)
#: Groove end, body-local in ``body_a``'s frame. Read at enter-tree.
groove_b: Vec2 = Property(default_factory=lambda: Vec2(1.0, 0.0), group="Joint", hint="Groove end (body_a local)")
#: ``body_b``'s anchor, body-local in ``body_b``'s frame. Read at enter-tree.
anchor_b: Vec2 = Property(default_factory=lambda: Vec2(0.0, 0.0), group="Joint", hint="Anchor (body_b local)")
def _create(self, world: Physics2DWorld, a: BodyHandle, b: BodyHandle) -> JointHandle:
return world.create_groove_joint(a, b, Vec2(*self.groove_a), Vec2(*self.groove_b), Vec2(*self.anchor_b))