Source code for simvx.core.physics.nodes2d

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


[docs] @dataclass(slots=True, frozen=True) class Contact2D: """Node-level 2D collision-event payload for ``collided`` / ``separated``. The 2D sibling of :class:`~simvx.core.physics.nodes.Contact`. Node-typed and built by the tree's dispatch from a node-agnostic ``ContactEvent2D``. The tree fills ``other`` with the peer node and reorients ``normal`` / ``velocity`` so they always point toward the RECEIVING body. Distinct from the world-level :class:`~simvx.core.physics.world2d.SweepHit2D` (a query/sweep result keyed by handle): this carries the resolved peer node. Attributes: other: The OTHER physics object involved in the collision. point: World-space contact point (``Vec2``). normal: Unit normal oriented TOWARD the receiving body (the separating direction). Degenerate (``Vec2(0)``) on ``separated``. impulse: Normal impulse magnitude the solver applied this step. ``0.0`` on ``separated`` and on a ``collided`` the solver did not push apart. ``None`` iff the resolved backend cannot measure an applied impulse (it does not advertise :attr:`~simvx.core.physics.capability.Capability.CONTACT_IMPULSE`). impulse_estimate: The portable stand-in for ``impulse``, one formula on every backend: the impulse it would take to arrest the approach, from the pair's masses and the difference of the two bodies' LINEAR velocities. Always a number, and ``0.0`` on ``separated`` and for a pair that is not approaching. It is deliberately not computed from ``velocity`` below, whose at-point meaning would put each backend's choice of manifold point into the number. See :attr:`~simvx.core.physics.world.ContactEvent.impulse_estimate`. velocity: Relative velocity of ``other`` w.r.t. the receiver AT THE CONTACT POINT, pre-solve (``Vec2``). Spin counts: a wheel skidding on the ground reports the speed of its tread, which its centre is not moving at. Degenerate (``Vec2(0)``) on ``separated``. """ other: PhysicsObject2D point: Vec2 normal: Vec2 impulse: float | None impulse_estimate: float velocity: Vec2
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))