"""Physics2DWorld: the 2D backend transport seam (Stage T2 additive scaffolding).
Role
----
This module defines ``Physics2DWorld``, the abstract interface every 2D physics
backend (``BuiltinPhysics2D`` now, an optional ``PymunkPhysics2D`` later)
implements. It is the 2D sibling of :class:`~simvx.core.physics.world.PhysicsWorld`
and serves the same purpose: a **transport** abstraction that moves rigid-body
state across the Python boundary efficiently, not a definition of solver
behaviour.
A SEPARATE seam (not the 3D one constrained to a plane) is deliberate (design
``physics_2d_design.md``): the bulk contract width differs (``(N,4)`` transforms /
``(N,3)`` velocities here vs ``(N,7)`` / ``(N,6)`` in 3D), the optional native
backend differs (pymunk / Chipmunk2D vs Jolt), and rotation is a scalar radian
angle rather than a quaternion. Dimension-agnostic pieces (:class:`BodyMode`,
:class:`CombineMode`, :class:`ContactPhase`, :class:`PhysicsMaterial`) are REUSED
by import from the 3D modules, never duplicated.
World convention (state it loudly)
----------------------------------
**Y-up, identical to the 3D world.** Gravity defaults to ``Vec2(0, -9.81)``: down
is ``-Y``. A Y-down game (renders +Y downward on screen) simply sets
``gravity=Vec2(0, 9.81)``; the seam itself stays neutral and never assumes a
screen orientation. Rotation is a scalar angle in **radians**, positive
counter-clockwise (standard math convention).
The load-bearing part of the contract is the **bulk-array transfer**: per-frame
body state is exchanged as a single numpy buffer (one transfer per world per
frame), in a fixed body->row order, by filling a caller-preallocated array
**in place**. The transform row is ``(N, 4) = [px, py, cos(theta), sin(theta)]``
(``cos``/``sin`` rather than the bare angle so interpolation lerps the unit
vector with no +-pi wraparound, matching ``Transform2D`` and the pymunk angle
convention); the velocity row is ``(N, 3) = [lx, ly, omega]`` (linear x/y plus
scalar angular velocity in radians/s). See :meth:`register_bodies`,
:meth:`read_transforms`, and :meth:`read_velocities`.
Stage T2a status
----------------
This module defines the FULL ABC surface so subclasses have a complete contract.
``BuiltinPhysics2D`` implements the T2a subset (gravity, shapes, bodies,
integrator, bulk readers); the collision-dependent methods (queries, events,
joints, forces, character, one-way) are declared ``@abstractmethod`` here and
filled by later sub-stages (T2b-T2e), with honest ``NotImplementedError`` stubs
in the backend until then.
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import numpy as np
from ..math import Vec2
# Reuse the dimension-agnostic enums / resource from the 3D modules (design:
# reuse, never duplicate). BodyMode + ContactPhase live on the 3D seam; CombineMode
# + PhysicsMaterial live on the shared material module.
from .capability import Capability
from .material import CombineMode, PhysicsMaterial
from .world import BodyMode, ContactPhase
# Opaque handle aliases (parity with the 3D seam). Backends choose the concrete
# representation; callers treat these as opaque tokens and never inspect them.
BodyHandle = int
ShapeHandle = Any
# A character controller token. DISTINCT type from BodyHandle so a backend can
# back it with a pymunk-style kinematic controller (not a rigid Body); callers
# treat it as opaque. Character handles live in their own namespace and never
# appear in register_bodies / read_transforms.
CharacterHandle = Any
# A joint / constraint token (T2c). DISTINCT namespace from BodyHandle /
# CharacterHandle; callers treat it as opaque. Joints are node-agnostic and keyed
# only by the two BodyHandles they constrain.
JointHandle = Any
# Default world up (Y-up). A module-level singleton so it is not constructed in
# argument defaults (Vec2 is a mutable ndarray subclass); callers never mutate it.
_DEFAULT_UP_2D = Vec2(0.0, 1.0)
[docs]
@dataclass(slots=True, frozen=True)
class RaycastHit2D:
"""Result of a successful 2D raycast against the world (T2d).
Attributes:
body: Handle of the body the ray hit.
point: World-space contact point (``Vec2``).
normal: World-space surface normal at the hit (``Vec2``, unit length).
distance: Distance from the ray origin to ``point`` along the ray.
"""
body: BodyHandle
point: Vec2
normal: Vec2
distance: float
[docs]
@dataclass(slots=True, frozen=True)
class OverlapEvent2D:
"""A node-agnostic 2D sensor-overlap event emitted by the seam (T2d).
2D sibling of :class:`~simvx.core.physics.world.OverlapEvent`: a SECOND,
independent edge-diffed stream. DIRECTED ``sensor -> other`` (the observing
sensor decides via its mask). Reuses :class:`ContactPhase`.
Attributes:
sensor: Handle of the detecting sensor body (the observer).
other: Handle of the detected body (a normal body OR another sensor).
phase: :class:`ContactPhase` (``ENTER`` / ``EXIT``).
"""
sensor: BodyHandle
other: BodyHandle
phase: ContactPhase
[docs]
@dataclass(slots=True, frozen=True)
class CharacterMoveResult2D:
"""Returned struct of a 2D character collide-and-slide move (T2e).
2D sibling of :class:`~simvx.core.physics.world.CharacterMoveResult`.
Floor/wall/ceiling are computed seam-side from contact normals vs ``up`` (and
the character's ``slope_limit``) so every backend exposes identical semantics.
Attributes:
velocity: Post-slide velocity (deflected along contact normals); the
caller writes this back as its new velocity (``Vec2``).
on_floor: True if a contact this move was classified as floor.
on_wall: True if a contact this move was classified as wall.
on_ceiling: True if a contact this move was classified as ceiling.
floor_normal: Normal of the floor contact this move (unit ``Vec2``), or
``+up`` if there was no floor contact.
"""
velocity: Vec2
on_floor: bool
on_wall: bool
on_ceiling: bool
floor_normal: Vec2
[docs]
class Physics2DWorld(ABC):
"""Abstract 2D backend seam: one isolated simulation world.
A ``Physics2DWorld`` owns a set of bodies, advances them as a unit at a fixed
timestep via :meth:`step`, and exchanges per-frame state in bulk. Concrete
backends (``BuiltinPhysics2D``, later ``PymunkPhysics2D``) implement every
method.
Bulk-array contract (the keystone)
----------------------------------
1. Call :meth:`register_bodies` once (or whenever membership changes) to fix
the body->row order used by the bulk readers.
2. Each frame, after :meth:`step`, call :meth:`read_transforms` and/or
:meth:`read_velocities`, passing a caller-preallocated, C-contiguous
``float32`` numpy array of the documented shape. The backend fills it
**in place**; it must not allocate or return a new array on the hot path.
The array shapes/dtypes/contiguity are part of the contract and MUST be
asserted by subclasses (see ``_check_transforms_out`` / ``_check_velocities_out``).
The transform row is ``(N, 4) = [px, py, cos(theta), sin(theta)]`` and the
velocity row is ``(N, 3) = [lx, ly, omega]`` (see the module docstring for the
Y-up / radians convention).
"""
def __init__(self, *, gravity: Vec2) -> None:
"""Initialise the world.
Args:
gravity: World gravity acceleration vector (``Vec2``), metres/s^2.
Y-up: ``Vec2(0, -9.81)`` is "down".
"""
self._gravity: Vec2 = Vec2(gravity)
# -- configuration ------------------------------------------------------
@property
def gravity(self) -> Vec2:
"""World gravity acceleration vector (``Vec2``), metres/s^2 (Y-up)."""
return self._gravity
[docs]
@gravity.setter
def gravity(self, value: Vec2) -> None:
self._gravity = Vec2(value)
# -- capability gate ----------------------------------------------------
[docs]
def capabilities(self) -> frozenset[Capability]:
"""Return the set of Tier-3 :class:`Capability` features this 2D backend honours.
2D sibling of :meth:`~simvx.core.physics.world.PhysicsWorld.capabilities`,
sharing the same dimension-agnostic :class:`Capability` enum. The default
is the empty set (a backend advertises nothing it cannot honour); native
backends override to list only what they actually support.
"""
return frozenset()
# -- shapes (opaque handles) -------------------------------------------
[docs]
@abstractmethod
def create_circle(self, radius: float) -> ShapeHandle:
"""Create a circle collision shape and return an opaque handle.
Args:
radius: Circle radius, world units (> 0).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_box(self, half_extents: Vec2) -> ShapeHandle:
"""Create an axis-aligned (body-local) box shape, centred at the origin.
Args:
half_extents: Half-sizes along x/y (``Vec2``, both > 0).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_capsule(self, radius: float, height: float) -> ShapeHandle:
"""Create a Y-axis capsule collision shape and return an opaque handle.
Args:
radius: Capsule radius, world units (> 0).
height: Total extent along Y including the two semicircular caps
(> 0). The central segment half-length is
``max(0, height / 2 - radius)``; when ``height <= 2 * radius`` the
segment collapses to a point and the capsule behaves as a circle.
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_segment(self, a: Vec2, b: Vec2, radius: float = 0.0) -> ShapeHandle:
"""Create a line-segment collision shape (2D-only).
A thick line from ``a`` to ``b`` (a "beam"), the 2D analogue with no 3D
equivalent. Useful for thin static walls / floors and one-way platforms.
Args:
a: Segment start, body-local (``Vec2``).
b: Segment end, body-local (``Vec2``).
radius: Segment thickness radius (>= 0); 0 is an infinitely thin line.
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_convex_polygon(self, points: np.ndarray) -> ShapeHandle:
"""Create a convex polygon collision shape from CCW points.
Args:
points: ``(N, 2)`` float32 array of >= 3 points in counter-clockwise
winding, defining a convex polygon (body-local).
Returns:
An opaque shape handle for use with :meth:`create_body`.
"""
[docs]
@abstractmethod
def create_concave_polygon(self, segments: np.ndarray) -> ShapeHandle:
"""Create a STATIC edge-soup collision shape (2D analogue of a mesh).
Args:
segments: ``(N, 2, 2)`` float32 array of N line segments (each an
``[start, end]`` pair of ``Vec2`` points), body-local. The 2D
analogue of a static triangle mesh: STATIC-ONLY level geometry.
Returns:
An opaque shape handle for use with :meth:`create_body`. A concave
polygon is a **STATIC-ONLY** collider: placing it on a non-STATIC
body is an error.
"""
# -- bodies -------------------------------------------------------------
[docs]
@abstractmethod
def create_body(
self,
shape: ShapeHandle,
body_type: BodyMode,
transform: Any,
*,
mass: float = 1.0,
collision_layer: int = 1,
collision_mask: int = 0xFFFFFFFF,
is_sensor: bool = False,
friction: float = 0.5,
restitution: float = 0.0,
friction_combine: CombineMode = CombineMode.AVERAGE,
restitution_combine: CombineMode = CombineMode.AVERAGE,
continuous: bool = False,
) -> BodyHandle:
"""Create a body in the world and return its handle.
Mirrors :meth:`~simvx.core.physics.world.PhysicsWorld.create_body` (same
layer/mask, sensor, material, and ``continuous`` semantics), with 2D
transforms (position ``Vec2`` + scalar rotation).
Args:
shape: An opaque shape handle from one of the ``create_*`` shape
factories. A concave polygon on a non-STATIC body is an error.
body_type: One of :class:`BodyMode`.
transform: Initial world transform. Accepted as a ``Transform2D``
(``.position`` + ``.rotation``), a bare ``Vec2`` / sequence
(position only, zero rotation), or a ``(position, rotation)``
pair where rotation is a scalar in radians.
mass: Body mass in kg, used only for ``DYNAMIC`` bodies. Ignored for
``STATIC`` / ``KINEMATIC`` (treated as infinite).
collision_layer: 32-bit layer membership; defaults to layer 1.
collision_mask: 32-bit mask of layers this body scans; defaults all.
is_sensor: When True, this body is a SENSOR (trigger): broadphase only,
excluded from collision resolution, feeds the separate overlap
stream (one-directional ``sensor.mask & other.layer``).
friction: Coulomb friction coefficient ``mu`` (``>= 0``), default 0.5.
restitution: Bounciness in ``[0, 1]`` (default 0.0, no bounce).
friction_combine: Per-contact friction combine mode (default AVERAGE).
restitution_combine: Per-contact restitution combine mode (default
AVERAGE), independent of ``friction_combine``.
continuous: When True, use continuous collision detection (centre
sweep vs STATIC, TOI clamp; basic-tier honesty, full CCD deferred
to pymunk). Defaults False (discrete).
Returns:
An opaque body handle, stable until :meth:`destroy_body`.
"""
[docs]
@abstractmethod
def destroy_body(self, handle: BodyHandle) -> None:
"""Remove a body from the world; the handle is invalid afterwards.
Callers that use the bulk readers must re-call :meth:`register_bodies` to
re-establish row order.
"""
[docs]
@abstractmethod
def set_body_transform(self, handle: BodyHandle, transform: Any) -> None:
"""Teleport a body to a new world transform (position + scalar rotation)."""
[docs]
@abstractmethod
def set_body_velocity(self, handle: BodyHandle, linear: Vec2, angular: float = 0.0) -> None:
"""Set a body's linear and (scalar) angular velocity directly.
Args:
handle: Body handle.
linear: Linear velocity (``Vec2``), world units/s.
angular: Angular velocity (scalar float), radians/s (CCW positive).
Defaults to ``0.0``.
"""
[docs]
@abstractmethod
def set_body_mode(self, handle: BodyHandle, mode: BodyMode) -> None:
"""Change a live body's motion mode in place (no destroy/recreate).
Flips STATIC / KINEMATIC / DYNAMIC, updating effective (inverse) mass and
moment of inertia: STATIC / KINEMATIC are infinite (inverse 0), DYNAMIC
uses the body's stored mass and per-shape moment.
"""
[docs]
@abstractmethod
def body_velocity(self, handle: BodyHandle) -> tuple[Vec2, float]:
"""Read a body's current ``(linear, angular)`` velocity, per-body.
Cold per-body read parallel to :meth:`body_transform`; the bulk
:meth:`read_velocities` stays the hot scatter path.
Returns:
``(linear, angular)`` velocity (``Vec2``, scalar float in radians/s).
Returns zero velocities for an infinite-mass body that was never moved.
"""
[docs]
@abstractmethod
def body_transform(self, handle: BodyHandle) -> tuple[Vec2, float]:
"""Read a body's current pose as ``(position, rotation)``.
Cold per-body read parallel to :meth:`body_velocity`.
Returns:
``(position, rotation)`` (``Vec2``, scalar float radians).
"""
[docs]
@abstractmethod
def sleeping(self, handle: BodyHandle) -> bool:
"""True if the body is asleep (skipped by integrate + solve until woken).
STATIC / KINEMATIC bodies are never 'asleep' (they were never awake):
returns False for them.
"""
# -- forces (T2c) -------------------------------------------------------
[docs]
@abstractmethod
def apply_impulse(self, handle: BodyHandle, impulse: Vec2, *, at: Vec2 | None = None, angular: float = 0.0) -> None:
"""Apply an instantaneous velocity change to a body NOW (T2c).
Args:
handle: Body handle.
impulse: Linear impulse (``Vec2``), N*s.
at: Optional world-space application point; the offset
``r = at - position`` contributes a scalar angular impulse via the
2D cross product ``cross(r, impulse)`` scaled by the inverse
moment of inertia.
angular: Optional explicit scalar angular impulse (radians-equivalent),
applied via the inverse moment of inertia.
"""
[docs]
@abstractmethod
def apply_force(self, handle: BodyHandle, force: Vec2, *, at: Vec2 | None = None) -> None:
"""Accumulate a continuous force, applied during the NEXT :meth:`step` (T2c).
Auto-cleared at the end of the step (re-add each fixed step to sustain).
Inert on non-DYNAMIC. ``at`` adds a scalar torque ``cross(r, force)``.
"""
[docs]
@abstractmethod
def apply_torque(self, handle: BodyHandle, torque: float) -> None:
"""Accumulate a continuous scalar torque for the NEXT :meth:`step` (T2c).
Auto-cleared after the step (re-add each fixed step to sustain). Inert on
non-DYNAMIC. Applied via the inverse moment of inertia.
"""
# -- joints / constraints (T2c) ----------------------------------------
[docs]
@abstractmethod
def create_fixed_joint(self, a: BodyHandle, b: BodyHandle) -> JointHandle:
"""Weld two bodies: lock their full relative transform (T2c)."""
[docs]
@abstractmethod
def create_pin_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec2) -> JointHandle:
"""Pin two bodies at a single world-space point, rotation free (T2c)."""
[docs]
@abstractmethod
def create_hinge_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec2) -> JointHandle:
"""Hinge two bodies at ``anchor`` (T2c).
2D rotation is 1-DOF, so a 2D hinge has no ``axis`` argument: it is a pin
at ``anchor`` (this tier). Motors and angular limits are a follow-on.
"""
[docs]
@abstractmethod
def create_spring_joint(
self, a: BodyHandle, b: BodyHandle, rest_length: float, stiffness: float, damping: float
) -> JointHandle:
"""Soft distance-spring between the two body centres (compliant) (T2c).
``rest_length < 0`` auto-captures the current centre distance as the rest
length (the common "spring at its natural length on creation" case).
"""
[docs]
@abstractmethod
def create_groove_joint(
self, a: BodyHandle, b: BodyHandle, groove_a: Vec2, groove_b: Vec2, anchor_b: Vec2
) -> JointHandle:
"""Constrain ``b``'s anchor to slide along a groove on ``a`` (2D-only) (T2g).
The pymunk-native slider-on-a-line constraint with no 3D equivalent. The
groove is the segment ``[groove_a, groove_b]`` in ``a``'s frame; ``b``'s
``anchor_b`` (in ``b``'s frame) is constrained to lie on that line.
"""
[docs]
@abstractmethod
def remove_joint(self, handle: JointHandle) -> None:
"""Remove a constraint; the handle is invalid afterwards (T2c).
A no-op if ``handle`` is unknown (already removed, or silently dropped
because one of its bodies was destroyed).
"""
# -- introspection ------------------------------------------------------
[docs]
@property
@abstractmethod
def body_count(self) -> int:
"""Number of bodies currently in the world (skip-empty-world fast path)."""
[docs]
@abstractmethod
def clear(self) -> None:
"""Remove every body, character, and joint, emptying the world.
2D sibling of :meth:`~simvx.core.physics.world.PhysicsWorld.clear`: the seam
equivalent of the old global ``PhysicsServer.reset()``. Returns the world to
an empty state (``body_count == 0``) WITHOUT discarding the world, its
:attr:`gravity`, or its backend; per-step edge-diff buffers and warm-start
cache are reset; cached shape handles stay valid; handle counters keep
advancing. After :meth:`clear`, re-:meth:`register_bodies` before the bulk
readers.
"""
# -- stepping -----------------------------------------------------------
[docs]
@abstractmethod
def step(self, dt: float) -> None:
"""Advance the whole world once by a fixed timestep ``dt`` (seconds)."""
# -- collision events (broadphase-diffed edges) (T2d) ------------------
[docs]
@abstractmethod
def drain_overlap_events(self) -> list[OverlapEvent2D]:
"""Return and CLEAR this step's buffered sensor-overlap events (T2d)."""
# -- one-way platforms (T2e) -------------------------------------------
[docs]
@abstractmethod
def set_one_way(self, handle: BodyHandle, enabled: bool, normal: Vec2 = _DEFAULT_UP_2D) -> None:
"""Mark a body as a one-way platform (2D-only) (T2e).
When enabled, the body only collides with bodies approaching from the
``+normal`` side (landing on it); bodies passing up through it (moving
along ``+normal``) are not blocked.
Args:
handle: Body handle.
enabled: Whether one-way filtering is active.
normal: World-space "solid side" normal (``Vec2``, unit); the side a
lander must approach from. Defaults to ``+Y`` (a floor).
"""
# -- bulk transfer (the keystone) --------------------------------------
[docs]
@abstractmethod
def register_bodies(self, handles: list[BodyHandle]) -> None:
"""Fix the body->row order used by the bulk readers.
After this call, :meth:`read_transforms` / :meth:`read_velocities` fill
row ``i`` with the state of ``handles[i]``. ``len(handles)`` is ``N``.
"""
[docs]
@abstractmethod
def read_velocities(self, out: np.ndarray) -> None:
"""Fill ``out`` with current body velocities, in place.
Args:
out: Pre-allocated array of shape ``(N, 3)``, dtype ``float32``,
C-contiguous, where ``N`` matches the most recent
:meth:`register_bodies`. Each row is ``[lx, ly, omega]``: linear
velocity xy followed by scalar angular velocity (radians/s, CCW
positive). Row ``i`` corresponds to ``handles[i]``.
"""
# -- queries (minimal) (T2d) -------------------------------------------
[docs]
@abstractmethod
def raycast(self, origin: Vec2, direction: Vec2, max_dist: float, *, mask: int = 0xFFFFFFFF) -> RaycastHit2D | None:
"""Cast a ray and return the nearest hit, or ``None`` (T2d)."""
[docs]
@abstractmethod
def raycast_all(
self, origin: Vec2, direction: Vec2, max_dist: float, *, mask: int = 0xFFFFFFFF
) -> list[RaycastHit2D]:
"""Cast a ray and return EVERY hit within ``max_dist``, sorted (T2d)."""
[docs]
@abstractmethod
def shapecast(
self, shape: ShapeHandle, origin: Vec2, direction: Vec2, max_dist: float, *, mask: int = 0xFFFFFFFF
) -> Contact2D | None:
"""Sweep a shape along a ray, return the earliest-TOI contact (T2d)."""
[docs]
@abstractmethod
def overlap(self, shape: ShapeHandle, transform: Any, *, mask: int = 0xFFFFFFFF) -> list[BodyHandle]:
"""Return all bodies a static shape overlaps at ``transform`` (T2d)."""
# -- kinematic sweep (T2d) ---------------------------------------------
[docs]
@abstractmethod
def move_and_collide(self, handle: BodyHandle, motion: Vec2) -> Contact2D | None:
"""Move a kinematic body by ``motion``, stop at first contact (T2d)."""
# -- character controller (T2e) ----------------------------------------
[docs]
@abstractmethod
def create_character(
self,
shape: ShapeHandle,
transform: Any,
*,
up: Vec2 = _DEFAULT_UP_2D,
slope_limit: float = math.radians(45.0),
step_height: float = 0.0,
skin_width: float = 0.001,
collision_layer: int = 1,
collision_mask: int = 0xFFFFFFFF,
) -> CharacterHandle:
"""Create a 2D character controller bound to this world (T2e)."""
[docs]
@abstractmethod
def destroy_character(self, handle: CharacterHandle) -> None:
"""Remove a character controller from the world (T2e)."""
[docs]
@abstractmethod
def character_move_and_slide(
self, handle: CharacterHandle, velocity: Vec2, dt: float, *, up: Vec2, max_slides: int = 4
) -> CharacterMoveResult2D:
"""Collide-and-slide a 2D character by ``velocity * dt`` (T2e)."""
# -- subclass helpers (contract enforcement) ---------------------------
@staticmethod
def _check_transforms_out(out: np.ndarray, n: int) -> None:
"""Assert ``out`` meets the :meth:`read_transforms` contract.
Subclasses call this at the top of ``read_transforms`` so the bulk
contract (shape ``(N, 4)``, ``float32``, C-contiguous) is enforced
uniformly across backends.
"""
assert out.shape == (n, 4), f"read_transforms out must be ({n}, 4), got {out.shape}"
assert out.dtype == np.float32, f"read_transforms out must be float32, got {out.dtype}"
assert out.flags["C_CONTIGUOUS"], "read_transforms out must be C-contiguous"
@staticmethod
def _check_velocities_out(out: np.ndarray, n: int) -> None:
"""Assert ``out`` meets the :meth:`read_velocities` contract.
Subclasses call this at the top of ``read_velocities`` so the bulk
contract (shape ``(N, 3)``, ``float32``, C-contiguous) is enforced
uniformly across backends.
"""
assert out.shape == (n, 3), f"read_velocities out must be ({n}, 3), got {out.shape}"
assert out.dtype == np.float32, f"read_velocities out must be float32, got {out.dtype}"
assert out.flags["C_CONTIGUOUS"], "read_velocities out must be C-contiguous"
__all__ = [
"BodyMode",
"Capability",
"CombineMode",
"PhysicsMaterial",
"ContactPhase",
"RaycastHit2D",
"Contact2D",
"ContactEvent2D",
"OverlapEvent2D",
"CharacterMoveResult2D",
"Physics2DWorld",
"BodyHandle",
"ShapeHandle",
"CharacterHandle",
"JointHandle",
]
# PhysicsMaterial is re-exported for the 2D node layer (T2f) to carry on bodies,
# parity with the 3D system; referenced here to keep the dependency explicit.
_ = PhysicsMaterial