Source code for simvx.core.physics.world2d

"""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 Contact2D: """Result of a kinematic / character shape-sweep stopping against a body (T2d). 2D sibling of :class:`~simvx.core.physics.world.Contact`: a single "other" body (like a query result); the swept body is implicit (the caller). Attributes: body: Handle of the OTHER body that was hit. point: World-space contact point (``Vec2``). normal: World-space surface normal (``Vec2``, unit), pointing AWAY from the other body toward the moving body (the direction that separates the mover). distance: Distance the mover actually travelled before contact (TOI distance, ``0..|motion|``). """ body: BodyHandle point: Vec2 normal: Vec2 distance: float
[docs] @dataclass(slots=True, frozen=True) class ContactEvent2D: """A node-agnostic 2D body-pair collision event emitted by the seam (T2d). 2D sibling of :class:`~simvx.core.physics.world.ContactEvent`. Keyed by body HANDLES only (the seam never names a node); orientation is fixed ``a -> b``. Reuses :class:`ContactPhase` (no second phase enum). Attributes: a: Handle of the first body of the pair (canonical order). b: Handle of the second body of the pair. phase: :class:`ContactPhase` (``ENTER`` / ``EXIT``). point: World contact point (``Vec2``). Meaningful on ``ENTER``; degenerate (``Vec2(0)``) on ``EXIT``. normal: Unit contact normal oriented ``a -> b`` (``Vec2``). Degenerate (``Vec2(0)``) on ``EXIT``. impulse: Normal impulse magnitude applied to the pair this step. ``0`` on ``EXIT`` and ``0`` on an ``ENTER`` where the solver applied none. rel_velocity: Pre-solve velocity of ``b`` w.r.t. ``a`` at the contact (``Vec2``). Degenerate (``Vec2(0)``) on ``EXIT``. """ a: BodyHandle b: BodyHandle phase: ContactPhase point: Vec2 normal: Vec2 impulse: float rel_velocity: Vec2
[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_contact_events(self) -> list[ContactEvent2D]: """Return and CLEAR this step's buffered enter/exit contact events (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_transforms(self, out: np.ndarray) -> None: """Fill ``out`` with current body transforms, in place. Args: out: Pre-allocated array of shape ``(N, 4)``, dtype ``float32``, C-contiguous, where ``N`` matches the most recent :meth:`register_bodies`. Each row is ``[px, py, cos(theta), sin(theta)]``: position xy followed by the rotation as a unit vector (``cos``/``sin`` of the scalar angle, NOT the bare angle: interpolation lerps the unit vector with no +-pi wraparound). Row ``i`` corresponds to ``handles[i]``. """
[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 set_character_transform(self, handle: CharacterHandle, transform: Any) -> None: """Teleport a character to a new pose (no collision) (T2e)."""
[docs] @abstractmethod def character_transform(self, handle: CharacterHandle) -> tuple[Vec2, float]: """Read a character's current pose as ``(position, rotation)`` (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