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