Source code for simvx.core.physics.nodes2d

"""Body + shape-carrier nodes for the 2D physics seam (Stage T2f).

Role
----
The 2D sibling of :mod:`~simvx.core.physics.nodes`: the user-facing 2D
body/shape/area/joint node taxonomy on the 2D transport seam
(: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 (width-aware ``_BodySync``).

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``). They
are NOT wired into any facade this stage; tests import them via this module path.
The facade flip + removal of the old 2D nodes is a later stage.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING

from ..descriptors import Property
from ..math import Vec2
from ..nodes_2d.node2d import Node2D
from ..properties import Bitmask
from ..signals import Signal
from .material import PhysicsMaterial
from .root import resolve_world_2d
from .shapes2d import RectangleShape2D, Shape2D
from .world2d import BodyMode

if TYPE_CHECKING:
    from .world2d import BodyHandle, CharacterHandle, JointHandle, Physics2DWorld, ShapeHandle

log = logging.getLogger(__name__)

__all__ = [
    "BodyMode",
    "Contact2D",
    "CollisionShape2D",
    "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 seam-level :class:`~simvx.core.physics.world2d.Contact2D` (a query/sweep TOI result keyed by handle): this carries the resolved peer node. Attributes: other: The OTHER body 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 applied this step. ``0`` on ``separated`` and on an ``enter`` the solver did not push apart. velocity: Relative velocity of ``other`` w.r.t. the receiver at the contact, pre-solve (``Vec2``). Degenerate (``Vec2(0)``) on ``separated``. """ other: PhysicsBody2D point: Vec2 normal: Vec2 impulse: float velocity: Vec2
def _resolve_shape_handle_2d(node: Node2D, world: Physics2DWorld) -> ShapeHandle | None: """Resolve a 2D body/character collider, 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. Shared by :class:`PhysicsBody2D` / :class:`CharacterBody2D` / :class:`Area2D` so the order lives in one place. """ shape: Shape2D | None = node.shape if shape is not None: return shape.build(world) shape_node = node.find(CollisionShape2D, direct=True) if shape_node is None: return None return shape_node.build_shape(world)
[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 pure data carrier this stage (the seam does narrowphase); 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")
[docs] def build_shape(self, world: Physics2DWorld) -> ShapeHandle: """Build this node's shape into an opaque seam shape handle.""" return self.shape.build(world)
[docs] class PhysicsBody2D(Node2D): """A 2D physics body on the 2D seam; its 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``). It owns the seam lifecycle (resolve 2D world, build shape, create body on enter; unregister + destroy on exit) and carries the 2D-only ``one_way`` / ``one_way_normal`` Properties. ``velocity`` is a ``Vec2`` accessor; ``spin`` is a scalar (2D angular is 1-DOF). """ #: Motion mode of this body (runtime-mutable while in-tree). mode: BodyMode = Property(BodyMode.DYNAMIC, hint="Motion mode", group="Physics", on_change="_on_mode_changed") #: Body mass in kg. Used only when ``mode == DYNAMIC``; ignored for STATIC / #: KINEMATIC (treated as infinite). mass: float = Property(1.0, range=(0.001, 100000), hint="Body mass in kg (DYNAMIC only)", group="Physics") #: Layer membership + collision mask (plain 32-bit ints). Forwarded to the #: seam at create time; 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") collision_mask: int = Bitmask(1, group="Collision") #: Single-shape convenience collider. When set, it WINS over any #: ``CollisionShape2D`` child (order: ``shape`` Property if set, else first #: ``CollisionShape2D`` child, else inert). Read at create time (enter-tree). shape: Shape2D | None = Property( None, hint="Single-shape convenience collider (else add CollisionShape2D children)", group="Collision" ) #: Surface material (friction, restitution, and an independent combine mode #: for each). ``default_factory`` so each body owns its own instance. Read at #: create time (enter-tree). material: PhysicsMaterial = Property( default_factory=PhysicsMaterial, hint="Surface material (friction, restitution, combine modes)", group="Physics", ) #: 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. Read at create time. #: Basic-tier honesty: a CENTRE sweep vs STATIC only (full CCD deferred to #: pymunk). continuous: bool = Property( False, hint="Continuous collision (anti-tunnelling for fast bodies)", group="Physics" ) #: 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. Read at create #: time; applied via ``world.set_one_way`` after the body is built. Basic-tier #: honesty: velocity-gated (a fast body can pop through). one_way: bool = Property(False, hint="One-way platform (2D-only)", group="Physics") #: The "solid side" normal of the one-way platform (``Vec2``, unit); the side #: a lander must approach from. Defaults to ``+Y`` (a floor). Read at create #: time. Only meaningful when :attr:`one_way` is True. one_way_normal: Vec2 = Property( default_factory=lambda: Vec2(0.0, 1.0), hint="One-way solid-side normal (unit)", group="Physics" ) #: Fires once when this body BEGINS touching another (contact ENTER). Payload #: is a node-level :class:`Contact2D` (``other`` is the peer body, #: ``normal`` / ``velocity`` oriented toward THIS body). collided = Signal(Contact2D) #: Fires once when this body STOPS touching another (contact EXIT). Payload is #: a degenerate :class:`Contact2D` (only ``other`` is meaningful). separated = Signal(Contact2D) def __init__(self, **kwargs: object) -> None: # Seam state BEFORE super().__init__: the base flushes deferred Property # on_change hooks (e.g. ``mode``'s ``_on_mode_changed``) at the end of its # __init__, so these must exist for the not-in-tree no-op guard to read. self._world: Physics2DWorld | None = None self._handle: BodyHandle | None = None super().__init__(**kwargs) # -- read-only accessors (tests) ---------------------------------------
[docs] @property def handle(self) -> BodyHandle | None: """This body's opaque seam handle, or ``None`` if no body was created.""" return self._handle
[docs] @property def world(self) -> Physics2DWorld | None: """The :class:`Physics2DWorld` this body was created in, or ``None``.""" return self._world
[docs] @property def is_sleeping(self) -> bool: """True if the seam body is asleep (basic-tier sleeping). False when inert.""" if self._world is None or self._handle is None: return False return self._world.sleeping(self._handle)
# -- velocity / spin (LIVE sim state, NOT serialized) ------------------ @property def velocity(self) -> Vec2: """Live linear velocity (``Vec2``), read/written straight to the seam. Runtime sim state, NOT a serialized :class:`Property`. Returns ``Vec2()`` when inert (not in tree / no seam 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), read/written to the seam. 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))
# -- helpers ----------------------------------------------------------- def _find_shape_handle(self, world: Physics2DWorld) -> ShapeHandle | None: """Resolve this body's collider (Property-first), or ``None`` if inert.""" return _resolve_shape_handle_2d(self, world) def _build_transform(self) -> tuple[Vec2, float]: """Return this body's initial world pose as ``(position, rotation)`` (scalar).""" return (self.world_position, self.world_rotation) # -- 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). self._handle = None self._world = None log.debug("%s entered tree with no CollisionShape2D child: no seam body created.", type(self).__name__) return transform = self._build_transform() self._handle = world.create_body( shape_handle, self.mode, transform, mass=self.mass, collision_layer=self.collision_layer, collision_mask=self.collision_mask, friction=self.material.friction, restitution=self.material.restitution, friction_combine=self.material.friction_combine, restitution_combine=self.material.restitution_combine, continuous=self.continuous, ) # One-way is a post-create per-body flag on the 2D seam. if self.one_way: world.set_one_way(self._handle, True, Vec2(*self.one_way_normal)) self._world = world 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 seam body's motion mode in place when ``mode`` changes. No-op when not in-tree. While in-tree it flips the seam motion type AND keeps the scatter registry consistent (STATIC leaves the dynamic bulk-sync; non-STATIC must be in it). """ if self._world is None or self._handle is None: return self._world.set_body_mode(self._handle, self.mode) tree = self.tree if tree is None: return if self.mode is BodyMode.STATIC: tree.unregister_physics_body(self._world, self._handle) else: tree.register_physics_body(self._world, self._handle, self)
[docs] def move_and_collide(self, velocity: Vec2 | Sequence[float], dt: float = 1.0) -> Contact2D | None: """Move by ``velocity * dt``, stop at the first contact, sync, return it. Meaningful for ``mode == KINEMATIC``. Sweeps the seam body's shape and reports the first blocker as a node-level :class:`Contact2D`. After the sweep the node's transform is synced SYNCHRONOUSLY from the seam. Returns ``None`` on no contact or when inert. """ if self._world is None or self._handle is None: return None motion = Vec2(*velocity) * float(dt) contact = self._world.move_and_collide(self._handle, motion) pos, _rot = self._world.body_transform(self._handle) self.world_position = pos if contact is None: return None # The seam Contact2D is keyed by the OTHER body's handle; map it to the # peer node so the node-level result is node-typed (parity with the 3D # move_and_collide). An unmapped peer yields a None ``other``. other = None tree = self.tree if tree is not None: node_map = tree._physics_nodes.get(self._world) if node_map is not None: other = node_map.get(contact.body) return Contact2D(other=other, point=contact.point, normal=contact.normal, impulse=0.0, velocity=Vec2())
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._handle is not None: tree = self.tree if tree is not None: tree.unregister_physics_body(self._world, self._handle) tree.unregister_physics_node(self._world, self._handle) self._world.destroy_body(self._handle) self._handle = None self._world = None
[docs] class CharacterBody2D(Node2D): """A 2D character controller (collide-and-slide), distinct from a rigid body. The 2D sibling of :class:`~simvx.core.physics.nodes.CharacterBody3D`: it holds a ``CharacterHandle`` (not a ``BodyHandle``), manages its own seam lifecycle, and is deliberately NOT registered for the dynamic auto bulk-sync; :meth:`move_and_slide` syncs the node transform synchronously. ``velocity`` is a ``Vec2`` set by game logic each frame and written back (deflected) after each move. """ slope_limit: float = Property(45.0, range=(0, 90), hint="Max walkable slope (degrees)", group="Character") step_height: float = Property(0.0, range=(0, 10), hint="Max step-up height (world units)", group="Character") max_slides: int = Property(4, range=(1, 16), hint="Collide-and-slide iterations", group="Character") skin_width: float = Property(0.001, range=(0, 1), hint="Collision safe margin", group="Character") collision_layer: int = Bitmask(1, group="Collision") collision_mask: int = Bitmask(1, group="Collision") #: Single-shape convenience collider. When set, it WINS over any #: ``CollisionShape2D`` child. Read at create time. shape: Shape2D | None = Property( None, hint="Single-shape convenience collider (else add CollisionShape2D children)", group="Collision" ) def __init__(self, **kwargs: object) -> None: super().__init__(**kwargs) self._world: Physics2DWorld | None = None self._char: CharacterHandle | None = None 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 self._on_floor = False self._on_wall = False self._on_ceiling = False # -- read-only accessors -----------------------------------------------
[docs] @property def character(self) -> CharacterHandle | None: """This node's opaque character handle, or ``None`` if inert.""" return self._char
[docs] @property def world(self) -> Physics2DWorld | None: """The :class:`Physics2DWorld` this character was created in, or ``None``.""" return self._world
[docs] def is_on_floor(self) -> bool: """True if the last :meth:`move_and_slide` ended on a walkable floor.""" return self._on_floor
[docs] def is_on_wall(self) -> bool: """True if the last :meth:`move_and_slide` hit a wall.""" return self._on_wall
[docs] def is_on_ceiling(self) -> bool: """True if the last :meth:`move_and_slide` hit a ceiling.""" return self._on_ceiling
# -- movement -----------------------------------------------------------
[docs] def move_and_slide(self, dt: float) -> None: """Collide-and-slide by ``self.velocity * dt``, then sync the transform. Writes the deflected post-slide velocity back to :attr:`velocity`, caches floor/wall/ceiling state + :attr:`floor_normal`, and syncs the node's transform SYNCHRONOUSLY from the seam. No-op if inert (no collider). """ if self._world is None or self._char is None: return result = self._world.character_move_and_slide( self._char, self.velocity, float(dt), up=self.up_direction, max_slides=int(self.max_slides) ) self.velocity = result.velocity self._on_floor, self._on_wall, self._on_ceiling = result.on_floor, result.on_wall, result.on_ceiling self.floor_normal = result.floor_normal pos, _rot = self._world.character_transform(self._char) self.world_position = pos
# -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: world = resolve_world_2d(self) shape = _resolve_shape_handle_2d(self, world) if shape is None: self._char = None self._world = None log.debug("%s entered tree with no shape / CollisionShape2D child: inert.", type(self).__name__) return self._char = world.create_character( shape, (self.world_position, self.world_rotation), up=self.up_direction, slope_limit=math.radians(float(self.slope_limit)), step_height=float(self.step_height), skin_width=float(self.skin_width), collision_layer=self.collision_layer, collision_mask=self.collision_mask, ) self._world = world
# NOT registered for the dynamic bulk-sync: a character is not a body.
[docs] def on_exit_tree(self) -> None: if self._world is not None and self._char is not None: self._world.destroy_character(self._char) self._char = None self._world = None
[docs] class Area2D(Node2D): """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. """ collision_layer: int = Bitmask(1, group="Collision") collision_mask: int = Bitmask(1, group="Collision") #: When False the area is INERT: no sensor body, detects nothing, fires nothing. monitoring: bool = Property(True, hint="Detect overlaps (else inert)", group="Physics") #: Single-shape convenience zone. When set, it WINS over any ``CollisionShape2D`` #: child. Read at enter-tree. shape: Shape2D | None = Property( None, hint="Single-shape convenience zone (else add CollisionShape2D children)", group="Collision" ) #: Fires once when a :class:`PhysicsBody2D` BEGINS overlapping (payload: the peer body). body_entered = Signal(PhysicsBody2D) #: Fires once when a :class:`PhysicsBody2D` STOPS overlapping (payload: the peer body). body_exited = Signal(PhysicsBody2D) #: 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. area_exited = Signal() def __init__(self, **kwargs: object) -> None: # Seam state BEFORE super().__init__ (same pattern as PhysicsBody2D). self._world: Physics2DWorld | None = None self._handle: BodyHandle | None = None self._overlapping_bodies: set[PhysicsBody2D] = set() self._overlapping_areas: set[Area2D] = set() super().__init__(**kwargs) # -- read-only accessors -----------------------------------------------
[docs] @property def handle(self) -> BodyHandle | None: """This area's opaque sensor-body handle, or ``None`` if inert.""" return self._handle
[docs] @property def world(self) -> Physics2DWorld | None: """The :class:`Physics2DWorld` this area's sensor was created in, or ``None``.""" return self._world
# -- polling (live, from the maintained sets; NOT a tree scan) ----------
[docs] def get_overlapping_bodies( self, *, group: str | None = None, type: type[PhysicsBody2D] | None = None ) -> list[PhysicsBody2D]: """Bodies currently overlapping this area (live, as of the last step). Off-seam peers (``handle is None``: destroyed / removed mid-overlap) are filtered here so polling never reports a body that has left the simulation. The optional filters narrow the result without a tree scan (they test the already-maintained overlap set): this is the durable, broadphase-backed replacement for the old arcade ``CharacterBody.get_overlapping(group=, body_type=)`` poll. Both are ANDed when given: Args: group: When set, keep only bodies that belong to this SceneTree group (``body.is_in_group(group)``). The canonical "what of kind X am I touching?" query (e.g. ``area.get_overlapping_bodies(group="mobs")``). type: When set, keep only bodies that are instances of this :class:`PhysicsBody2D` subclass (``isinstance(body, type)``). Returns: The overlapping bodies (live, off-seam peers filtered) matching every supplied filter, in arbitrary order. """ return [ b for b in self._overlapping_bodies if b.handle is not None and (group is None or b.is_in_group(group)) and (type is None or isinstance(b, type)) ]
[docs] def get_overlapping_areas(self) -> list[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 = _resolve_shape_handle_2d(self, world) if shape_handle is None: self._handle = None self._world = None log.debug("%s entered tree with no shape / CollisionShape2D child: inert.", type(self).__name__) return self._handle = world.create_body( shape_handle, BodyMode.STATIC, (self.world_position, self.world_rotation), collision_layer=self.collision_layer, collision_mask=self.collision_mask, is_sensor=True, ) self._world = world tree = self.tree # Reuse the mode-independent handle->node map: 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: if self._world is not None and self._handle is not None: tree = self.tree if tree is not None: tree.unregister_physics_node(self._world, self._handle) self._world.destroy_body(self._handle) self._handle = None self._world = None 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). """ bodies = self.get_overlapping_bodies() 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 via the 2D seam; a thin carrier that owns the seam-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: # Seam 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 seam 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 seam 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 seam joint).", 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 seam handle yet (declare the joint after its bodies): inert.", type(self).__name__ ) return wa, wb = a_node.world, b_node.world if wa is None or wb is None or wa is not wb: 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 and holds it, so the two bodies move as one rigid assembly. 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. """ #: 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. Basic-tier honesty: a hard 1-DOF lock with no endpoint-clamp softness or slide motor, and the groove direction is fixed in world axes at create time rather than rotating with ``body_a`` (anchors are not rotated; parity with the other 2D joints). The exact rotating-groove and motor live in the pymunk backend. """ #: 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) )