"""Physics2DWorld: the 2D backend transport interface.
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 interface (rather than the 3D one constrained to a plane) is
deliberate: 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 physics world 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`.
Every method is declared ``@abstractmethod`` here, so a backend has a complete
contract to implement and a partial backend fails loudly rather than silently
returning nothing.
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from ..math import Vec2
# Reuse the dimension-agnostic enums / resource from the 3D modules rather than
# duplicating them. BodyMode + ContactPhase live on the 3D interface; CombineMode
# + PhysicsMaterial live on the shared material module.
from .capability import Capability
from .material import CombineMode, PhysicsMaterial
from .world import (
_SWEEP_SKIN,
DEFAULT_ANGULAR_DAMPING,
DEFAULT_CONTACT_SLOP,
DEFAULT_GRAVITY_SCALE,
DEFAULT_LINEAR_DAMPING,
DEFAULT_POSITION_ITERATIONS,
DEFAULT_SLEEP_TIME,
DEFAULT_SLEEP_VELOCITY,
DEFAULT_SOLVER_ITERATIONS,
BodyMode,
ContactPhase,
_knob_float,
_knob_int,
body_scale_unchanged,
normalise_gravity,
)
# Opaque handle aliases (parity with the 3D world). Backends choose the concrete
# representation; callers treat these as opaque tokens and never inspect them.
BodyHandle = int
ShapeHandle = Any
# A joint / constraint token. DISTINCT namespace from BodyHandle; 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)
# 2D shape kinds defined by a single radius, which therefore cannot represent a
# non-uniform scale. The 2D twin of the 3D table in ``world.py``; a segment
# carries a thickness radius, so it joins them.
_UNIFORM_SCALE_KINDS_2D = frozenset({"circle", "capsule", "segment"})
# The bytes of an unscaled float32 (2,) scale, for the identity test below.
_UNIT_SCALE_2D_BYTES = np.ones(2, dtype=np.float32).tobytes()
[docs]
def is_unit_scale_2d(scale: np.ndarray) -> bool:
"""True when ``scale`` is exactly ``(1, 1)`` and so changes no geometry."""
return scale.tobytes() == _UNIT_SCALE_2D_BYTES
[docs]
def normalise_body_scale_2d(scale: object, kind: str) -> np.ndarray:
"""Validate a 2D body scale against a shape kind, as float32 ``(2,)``.
The 2D twin of :func:`~simvx.core.physics.world.normalise_body_scale`, with
the same rule: geometry defined by a single radius (a circle, a capsule, a
thick segment) cannot express a non-uniform scale and raises rather than
silently substituting a component, while a rectangle, a convex polygon and a
segment soup scale componentwise. Negative components mirror, and uniformity
is judged on magnitudes, so the ``scale.x = -1`` sprite flip is uniform.
Args:
scale: A ``Vec2`` or any 2-sequence.
kind: The backend's own name for the geometry (``"circle"``, ``"box"``,
``"capsule"``, ``"segment"``, ...). Unknown kinds scale componentwise.
Returns:
The scale as a float32 ``(2,)`` array, always fresh and never a view of
the caller's, for the reason the 3D twin gives.
Raises:
ValueError: If either component is zero or non-finite, or if the geometry
cannot represent the requested non-uniform scale.
"""
s = np.array(scale, dtype=np.float32).reshape(2)
if not bool(np.all(np.isfinite(s))) or bool(np.any(s == 0.0)):
raise ValueError(f"body scale components must be non-zero and finite, got {tuple(float(c) for c in s)}")
if kind in _UNIFORM_SCALE_KINDS_2D and abs(float(s[0])) != abs(float(s[1])):
raise ValueError(
f"a {kind} collider has one radius and cannot be scaled non-uniformly; got "
f"{tuple(float(c) for c in s)}. Scale the node uniformly, or give it a rectangle or "
f"convex-polygon collider, which scale per axis."
)
return s
[docs]
@dataclass(slots=True, frozen=True)
class RaycastHit2D:
"""Result of a successful 2D raycast against the world.
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 SweepHit2D:
"""Result of a shape sweep stopping against a body.
2D sibling of :class:`~simvx.core.physics.world.SweepHit`: 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). A sweep that begins already in contact still reports the
surface's separating normal, never the cast axis.
distance: A distance along ``motion`` at which the mover's shape is
guaranteed NOT to penetrate the blocker. An exact time-of-impact
backend reports ``max(0.0, toi - skin)``; a substepped backend reports
a bound refined by bisection to ``|motion| / (substeps * 2**8)`` and
ignores ``skin``. It is therefore an under-estimate bounded by the
backend's sweep granularity, and may be exactly ``0.0`` for a sweep
that begins in contact. Callers advance to exactly ``distance`` and
subtract nothing further.
"""
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 a physics world.
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]
class Physics2DWorld(ABC):
"""Abstract 2D backend interface: 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.
What this promises across backends
----------------------------------
The same contract the 3D seam states, and it is stated there once for both
dimensions: see :class:`~simvx.core.physics.world.PhysicsWorld` for the three
promises (identical contract behaviour, numbers only within a documented
tolerance, backend-dependent features and payload fields queryable as a
:class:`~simvx.core.physics.capability.Capability`) and the two rules that
follow from them. The seam is two disjoint ABCs because the payload types
differ, not because the promise does.
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 checked
by subclasses (see ``_check_transforms_out`` / ``_check_velocities_out``, which
raise ``ValueError``: the buffer is caller input, so the check is not an assert).
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).
Shape contract (immutable values, owned by the resource that asked)
-------------------------------------------------------------------
Identical to the 3D one stated on
:class:`~simvx.core.physics.world.PhysicsWorld`, restated here so a 2D backend
author never has to read the 3D interface: a shape handle names an IMMUTABLE
VALUE and every ``create_*`` factory mints a FRESH one. The factories do not memoise
on the geometry, because the world cannot know when a caller has
finished with a handle and a geometry-keyed cache would only ever grow.
Sharing and lifetime belong to the ``Shape2D`` resource
(:class:`~simvx.core.physics.shapes2d.Shape2D`), which builds its handle once
per world and releases it when the resource is collected. A caller working
against this interface directly owns what it creates and releases it with
:meth:`destroy_shape`.
1. **There is no mutation API, and there must never be one.** A hypothetical
``set_shape_radius`` would silently resize every body sharing the handle.
Any future edit-the-geometry surface must BUILD A NEW HANDLE and hand it to
:meth:`set_body_shape`, which is what changing a collider does today.
2. **Destruction is about the handle, never about a body.**
:meth:`destroy_shape` releases the world's reference and leaves every
body's geometry alone.
Per-body SCALE belongs to the body, precisely because a shape is shared:
:meth:`create_body` and :meth:`set_body_transform` take a ``scale`` and the
backend applies it to its own instance of the geometry. Geometries that cannot
express a non-uniform scale raise rather than approximate (see
:func:`~simvx.core.physics.world2d.normalise_body_scale_2d`).
"""
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(*normalise_gravity(gravity, 2))
self._solver_iterations: int = DEFAULT_SOLVER_ITERATIONS
self._position_iterations: int = DEFAULT_POSITION_ITERATIONS
self._sleep_time_threshold: float = DEFAULT_SLEEP_TIME
self._sleep_velocity_threshold: float = DEFAULT_SLEEP_VELOCITY
self._contact_slop: float = DEFAULT_CONTACT_SLOP
# -- configuration ------------------------------------------------------
#
# Plain properties rather than ``set_world_*`` methods, matching ``gravity``,
# and deliberately outside the wake partition: none of them names a body, so
# none of them can take support away from one. Same four knobs, same
# defaults and same meanings as the 3D seam.
@property
def gravity(self) -> Vec2:
"""World gravity acceleration vector (``Vec2``), metres/s^2 (Y-up).
Scaled per body by the ``gravity_scale`` :meth:`create_body` takes.
"""
return self._gravity
[docs]
@gravity.setter
def gravity(self, value: Vec2) -> None:
self._gravity = Vec2(*normalise_gravity(value, 2))
@property
def solver_iterations(self) -> int:
"""Impulse-solver iterations per :meth:`step` (``>= 1``).
See :attr:`~simvx.core.physics.world.PhysicsWorld.solver_iterations`; the
2D tiers honour it through the builtin velocity loop and Chipmunk's
``space.iterations``.
"""
return self._solver_iterations
[docs]
@solver_iterations.setter
def solver_iterations(self, value: int) -> None:
count = _knob_int("solver_iterations", value, "a finite whole number >= 1")
if count < 1:
raise ValueError(f"solver_iterations must be >= 1, got {value!r}")
self._solver_iterations = count
self._on_world_settings_changed()
@property
def position_iterations(self) -> int:
"""Rigid-joint position passes per :meth:`step` (``>= 1``).
See :attr:`~simvx.core.physics.world.PhysicsWorld.position_iterations`.
The builtin 2D solver runs this many Baumgarte passes over its pins,
hinges, welds and grooves; Chipmunk has no position solver, so the pymunk
lane keeps the value and reads it back without running it.
"""
return self._position_iterations
[docs]
@position_iterations.setter
def position_iterations(self, value: int) -> None:
count = _knob_int("position_iterations", value, "a finite whole number >= 1")
if count < 1:
raise ValueError(f"position_iterations must be >= 1, got {value!r}")
self._position_iterations = count
self._on_world_settings_changed()
@property
def sleep_time_threshold(self) -> float:
"""Seconds of continuous sub-threshold motion before a body sleeps (``> 0``).
See :attr:`~simvx.core.physics.world.PhysicsWorld.sleep_time_threshold`.
"""
return self._sleep_time_threshold
[docs]
@sleep_time_threshold.setter
def sleep_time_threshold(self, value: float) -> None:
seconds = _knob_float("sleep_time_threshold", value, "> 0 and finite")
if seconds <= 0.0:
raise ValueError(f"sleep_time_threshold must be > 0 and finite, got {value!r}")
self._sleep_time_threshold = seconds
self._on_world_settings_changed()
@property
def sleep_velocity_threshold(self) -> float:
"""Speed below which a body counts as at rest, m/s (``>= 0``).
See :attr:`~simvx.core.physics.world.PhysicsWorld.sleep_velocity_threshold`.
"""
return self._sleep_velocity_threshold
[docs]
@sleep_velocity_threshold.setter
def sleep_velocity_threshold(self, value: float) -> None:
speed = _knob_float("sleep_velocity_threshold", value, ">= 0 and finite")
if speed < 0.0:
raise ValueError(f"sleep_velocity_threshold must be >= 0 and finite, got {value!r}")
self._sleep_velocity_threshold = speed
self._on_world_settings_changed()
@property
def contact_slop(self) -> float:
"""Contact overlap tolerated without correction, world units (``>= 0``).
See :attr:`~simvx.core.physics.world.PhysicsWorld.contact_slop`. A
pixel-scale 2D game is the case this knob most exists for: at gravity 980
and 50-unit sprites, raise it to roughly ``0.1``-``0.5``.
"""
return self._contact_slop
def _on_world_settings_changed(self) -> None: # noqa: B027 - deliberately optional, not abstract
"""Push the world knobs into the backend, for backends that need pushing.
See :meth:`~simvx.core.physics.world.PhysicsWorld._on_world_settings_changed`.
"""
# -- capability gate ----------------------------------------------------
[docs]
def capabilities(self) -> frozenset[Capability]:
"""Return the set of :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: a whole
feature this interface has no methods for, a strengthened guarantee about a
method every backend has, or a payload field that carries a value only
where the backend can measure it. The default is the empty set, so a
partially implemented backend claims nothing by accident; concrete
backends override to list exactly what they honour, in either direction.
"""
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.
"""
[docs]
@abstractmethod
def destroy_shape(self, shape: ShapeHandle) -> None:
"""Release the world's reference to a shape handle.
2D sibling of
:meth:`~simvx.core.physics.world.PhysicsWorld.destroy_shape`, with the
identical contract. Drops ``shape`` from the backend's shape table: a
backend that owns a native shape object frees it here, a pure-Python one
simply forgets the record. This is what a
:class:`~simvx.core.physics.shapes2d.Shape2D` resource calls for each
handle it owns when it is collected, and the escape hatch for a caller
working against this interface directly.
**Bodies are not touched.** One handle is routinely shared by many
bodies, so destroying it cannot mean "take the collider away from
whoever is using it": every body created with this shape keeps its
geometry and keeps simulating. It neither raises nor detaches. As in 3D,
"keeps simulating" covers the full surface: the body stays editable
through every ``set_body_*`` setter and sweepable through
:meth:`sweep_body`, so a backend must reach a body's geometry through the
record that body owns rather than by re-indexing its shape table.
An unknown handle is a silent no-op, so destroying twice or destroying
after :meth:`clear` is safe in any teardown order. The HANDLE is invalid
afterwards: passing it to :meth:`create_body`, :meth:`set_body_shape`,
:meth:`shapecast` or :meth:`overlap` raises ``KeyError``, and is a caller
error. ``KeyError`` on every backend and every one of those four calls,
matching what a bogus BODY handle already raises.
Args:
shape: A handle previously returned by one of the ``create_*`` shape
factories.
"""
# -- bodies -------------------------------------------------------------
[docs]
@abstractmethod
def create_body(
self,
shape: ShapeHandle,
body_type: BodyMode,
transform: Any,
*,
mass: float = 1.0,
scale: Vec2 | None = None,
can_sleep: bool = True,
linear_damping: float = DEFAULT_LINEAR_DAMPING,
angular_damping: float = DEFAULT_ANGULAR_DAMPING,
gravity_scale: float = DEFAULT_GRAVITY_SCALE,
collision_layer: int = 1,
collision_mask: int = 0xFFFFFFFF,
is_sensor: bool = False,
material: PhysicsMaterial | None = None,
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, in the form every ``transform``
argument on this interface accepts: a ``Transform2D`` (``.position`` +
``.rotation``), a bare ``Vec2`` / sequence (position only, zero
rotation), or a ``(position, rotation)`` pair where rotation is
any real scalar in radians. ``numpy`` floats are real scalars
here, since the engine's own maths is float32; the pair must be a
tuple, because a 2-element list or ``Vec2`` is a bare position.
mass: Body mass in kg, ``> 0`` (a non-positive or NaN mass raises
``ValueError``, whatever the mode). Consulted while the body is
DYNAMIC and RETAINED across :meth:`set_body_mode`, so a body
created STATIC or KINEMATIC carries this mass into a later
DYNAMIC flip. STATIC and KINEMATIC integrate as infinite-mass
regardless of the value.
scale: Per-body scale of ``shape`` (``Vec2``); ``None`` (the default)
means unscaled. Scale belongs to the body rather than the shape
because a shape handle is shared, so one collider resource serves
bodies at any number of sizes: a body at ``scale=4`` collides as
the four-times-larger collider, which is what makes a scaled
node's collider match what is drawn. Geometry that cannot express
a non-uniform scale RAISES rather than approximating (see
:func:`normalise_body_scale_2d`); negative components mirror.
Editable afterwards through :meth:`set_body_transform`.
can_sleep: Whether this body is ALLOWED to fall asleep once it
settles (see :meth:`sleeping`). Defaults True; False keeps it
permanently simulated. Editable through
:meth:`set_body_can_sleep`.
linear_damping: Per-second rate at which the body sheds linear speed
with nothing touching it, applied as
``v = v * max(0, 1 - linear_damping * dt) + a * dt`` (see
:func:`~simvx.core.physics.world.normalise_damping`, which also
records where Jolt's integrator differs under sustained
acceleration). Defaults to
:data:`~simvx.core.physics.world.DEFAULT_LINEAR_DAMPING`.
Editable through :meth:`set_body_damping`.
angular_damping: The same rate for spin. Defaults to
:data:`~simvx.core.physics.world.DEFAULT_ANGULAR_DAMPING`, and is
independent of ``linear_damping``.
gravity_scale: Multiplier on the world's gravity for this body alone.
``1`` (the default) falls normally, ``0`` ignores gravity, and a
negative value falls upward. Editable through
:meth:`set_body_gravity_scale`.
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, never blocks a shape
sweep, feeds the separate overlap stream (one-directional
``sensor.mask & other.layer``).
material: The body's SURFACE (friction, restitution, combine modes) as
one :class:`PhysicsMaterial` resource, shareable across bodies.
``None`` (the default) uses
:data:`~simvx.core.physics.material.DEFAULT_PHYSICS_MATERIAL`.
The resource is frozen; a live body's surface changes by passing a
different one to :meth:`set_body_material`.
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.
An unknown handle is a silent no-op, the same contract
:meth:`destroy_shape` follows, so destroying twice or destroying after
:meth:`clear` is safe in any teardown order.
Destruction ENDS every contact and sensor overlap the body was in, so the
event streams report one ``EXIT`` per open edge rather than dropping it: a
listener is never left believing a pair is still in contact (the same rule
:meth:`set_body_filter` follows). The events surface in the first
:meth:`drain_contact_events` / :meth:`drain_overlap_events` after the next
:meth:`step`, and never twice. Their payload is the ordinary degenerate
``EXIT`` payload, and the destroyed handle they carry is an identity to
match against a caller's own bookkeeping, never an argument for a further
world call.
Destruction also WAKES every body that was in contact with this one, so
whatever it was holding up falls (see :meth:`sleeping`).
"""
[docs]
@abstractmethod
def set_body_transform(
self, handle: BodyHandle, transform: Any, *, scale: Vec2 | None = None, wake: bool = True
) -> None:
"""Teleport a body to a new world transform, optionally rescaling it.
Wakes the body, and a pose that really moves it also wakes the bodies it
was in contact with, so a platform driven out from under a sleeping crate
drops the crate (see :meth:`sleeping`). Re-writing the pose a body already
holds moves nothing and wakes nothing.
Args:
handle: Body handle.
transform: New world transform, in the forms this interface accepts.
scale: New per-body scale of the body's shape (``Vec2``), or ``None``
(the default) to leave the scale it already has alone. ``None``
rather than ``(1, 1)`` is load-bearing: a character controller
re-writes its body's pose every step and knows nothing about
scale. Rescaling is a geometry change, so it wakes the body's
neighbours exactly as :meth:`set_body_shape` does.
wake: Whether this write may disturb sleepers (see :meth:`sleeping`).
``False`` re-poses the body without waking it or anything it was
holding up: streaming a level in, or repositioning a pooled
object, must not re-activate a settled pile.
"""
[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.
A body the caller made ``STATIC`` is never set in motion by this call, on
either backend: an immovable body stays where it was put, and the way to
move one is :meth:`set_body_transform`. The write is still remembered and
read back, on both backends. The pymunk adapter holds a STATIC sensor
KINEMATIC (see
:attr:`~simvx.core.physics.capability.Capability.SENSOR_DETECTS_STATIC`),
which Chipmunk WOULD integrate, so for that one case the value is kept by
the adapter instead of being given to the library, and becomes the live
velocity if the body later leaves ``STATIC``. A static body's velocity is
also read as a surface velocity by the friction solve on both backends,
which is how a conveyor belt is expressed.
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, *, wake: bool = True) -> 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
restores the mass the body was created with and recomputes the per-shape
moment from it. Flips are lossless, so freezing a body to STATIC and
waking it later gives back exactly its original mass and moment.
A flip to DYNAMIC wakes the bodies this one was in contact with: it can no
longer hold anything up, so what rested on it must fall (see
:meth:`sleeping`). Freezing a body to STATIC takes nothing away and leaves
a sleeping neighbour asleep, and re-asserting the mode a body already has
does nothing at all.
``wake`` governs both halves of that, and the two directions of the flip
are not symmetric:
- LEAVING DYNAMIC ends the body's own sleep whatever ``wake`` says, because
an immovable body is never asleep (see :meth:`sleeping`). That restores
the invariant rather than waking anything, so it is not suppressible.
- ENTERING DYNAMIC with ``wake=False`` hands the body to the simulation
PARKED: DYNAMIC and asleep, its velocity zeroed, costing nothing until
something disturbs it (:meth:`wake`, a pose write, an impulse, or an
awake body arriving at it). That is what streaming a section in wants,
its geometry landing settled rather than paying to fall into place. The
default hands the body over awake, moving from the next step.
- A body whose ``can_sleep`` is False has no parked state to be handed to,
so it is freed AWAKE whichever way ``wake`` points: forbidding sleep
forbids it by this route too.
- Entering KINEMATIC parks nothing. A KINEMATIC body never reports as
asleep, and it moves only when it is written to, which is itself a
disturbance. So it stays in the pair and overlap search whichever way
``wake`` points: a platform streamed in and flipped to KINEMATIC where
it stands still fires the triggers it is standing in.
Parking needs a backend that advertises :attr:`Capability.SLEEP`. Without
it nothing can be held out of the simulation, so a body freed with
``wake=False`` is simulated from the very next step.
Args:
handle: Body handle.
mode: The new :class:`BodyMode`.
wake: Whether this write may disturb sleepers (see :meth:`sleeping`),
and whether a body entering DYNAMIC is handed over awake.
Raises:
ValueError: If the stored mass is not ``> 0`` and ``mode`` is
DYNAMIC, or if the body's shape kind forbids ``mode`` (a concave
polygon is STATIC-only).
"""
# -- live edits to what create_body was given ---------------------------
#
# The 2D mirror of the 3D live-edit set: everything create_body takes beyond
# the pose is editable afterwards, so a body never has to be destroyed and
# rebuilt (which would mint a new handle and purge the joints referencing the
# old one). Each keeps the handle, pose, velocity and mode, and by default
# WAKES a sleeping body so the new value takes effect on the next step; each
# takes ``wake=False`` to suppress that (see :meth:`sleeping`).
[docs]
@abstractmethod
def set_body_mass(self, handle: BodyHandle, mass: float, *, wake: bool = True) -> None:
"""Set a live body's mass, recomputing its moment from its current shape.
Velocity is preserved, not momentum. The new mass is RETAINED like the
create-time one, so it survives a later :meth:`set_body_mode` flip and
setting it on a STATIC or KINEMATIC body takes effect when that body
becomes DYNAMIC.
A re-massed body is also a support whose behaviour has changed under
whatever is resting on it, so the wake reaches its neighbours too.
Args:
handle: Body handle.
mass: New mass in kg, ``> 0``.
wake: Whether this write may disturb sleepers (see :meth:`sleeping`).
Raises:
ValueError: If ``mass`` is not ``> 0`` (NaN included).
"""
[docs]
@abstractmethod
def set_body_filter(
self, handle: BodyHandle, collision_layer: int, collision_mask: int, *, wake: bool = True
) -> None:
"""Set a live body's 32-bit layer membership and collision mask together.
One method for the pair because the acceptance rule reads both sides of
both bodies. Honoured from the next :meth:`step`, and the event streams
follow it: a pair that was touching and no longer matches reports an EXIT
rather than vanishing silently, one that newly matches reports an ENTER,
and an edit that changes no verdict reports nothing at all.
Args:
handle: Body handle.
collision_layer: New 32-bit layer membership.
collision_mask: New 32-bit mask of layers this body scans.
wake: Whether this write may disturb sleepers (see :meth:`sleeping`).
"""
[docs]
@abstractmethod
def set_body_material(self, handle: BodyHandle, material: PhysicsMaterial | None) -> None:
"""Replace a live body's surface material.
The whole surface at once, because that is what a material IS. See
:meth:`~simvx.core.physics.world.PhysicsWorld.set_body_material`: the
world reads the resource's values and does not retain it, which is safe
because the frozen resource has no later edit to miss.
Args:
handle: Body handle.
material: The new surface, or ``None`` for
:data:`~simvx.core.physics.material.DEFAULT_PHYSICS_MATERIAL`.
"""
[docs]
@abstractmethod
def set_body_damping(self, handle: BodyHandle, linear: float, angular: float) -> None:
"""Set a live body's linear and angular damping together.
See :meth:`~simvx.core.physics.world.PhysicsWorld.set_body_damping`.
Args:
handle: Body handle.
linear: Per-second linear damping rate (``>= 0``, finite).
angular: Per-second angular damping rate (``>= 0``, finite).
Raises:
ValueError: If either rate is negative, NaN or infinite.
"""
[docs]
@abstractmethod
def set_body_gravity_scale(self, handle: BodyHandle, scale: float) -> None:
"""Set a live body's gravity multiplier.
Wakes the body, for the reason
:meth:`~simvx.core.physics.world.PhysicsWorld.set_body_gravity_scale`
gives: gravity is applied by integration, which skips a sleeper.
Args:
handle: Body handle.
scale: New multiplier on the world's gravity for this body.
Raises:
ValueError: If ``scale`` is NaN or infinite.
"""
[docs]
@abstractmethod
def set_body_continuous(self, handle: BodyHandle, enabled: bool) -> None:
"""Turn continuous collision detection on or off for a live body.
Honoured only where the backend advertises
:attr:`~simvx.core.physics.capability.Capability.CONTINUOUS`; a backend
that does not accepts the call and does nothing, exactly as it already
ignores the ``continuous`` argument to :meth:`create_body`.
Args:
handle: Body handle.
enabled: True for continuous (swept) integration, False for discrete.
"""
[docs]
@abstractmethod
def set_body_shape(self, handle: BodyHandle, shape: ShapeHandle, *, wake: bool = True) -> None:
"""Swap a live body's collision shape, keeping everything else.
The body keeps its handle, pose, velocity, mode, mass, filter, sensor
flag, material, CCD flag and one-way configuration; only the geometry
changes, with the moment of inertia recomputed from the new shape at the
retained mass. An overlap the new shape introduces is not resolved at swap
time: the ordinary contact solve pushes it apart over the following steps,
which is why the body wakes.
The event streams describe the geometry, not the swap: a pair the new shape
still touches is NOT announced again, a pair it no longer reaches reports an
EXIT, and one it newly reaches reports an ENTER, so replacing a collider
with an identical one is silent.
The swap also vacates whatever volume the old geometry held, so the wake
reaches the body's neighbours as well: shrinking a platform under a
sleeping crate drops the crate rather than leaving it on a ledge that is
no longer there.
Args:
handle: Body handle.
shape: An opaque shape handle from one of the ``create_*`` factories.
wake: Whether this write may disturb sleepers (see :meth:`sleeping`).
Raises:
ValueError: If the new shape's kind forbids the body's current mode (a
concave polygon is a STATIC-only collider).
"""
[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 body_mass(self, handle: BodyHandle) -> float:
"""Read a body's EFFECTIVE mass in kg, the one an impulse divides by.
The counterpart of :meth:`set_body_mass`, and deliberately not a
read-back of what was set: a STATIC or KINEMATIC body is infinite-mass
whatever mass it was created with, so it answers ``math.inf`` and an
impulse applied to it moves nothing. Flipping the same body to DYNAMIC
restores its retained mass and this returns that.
Returns:
Mass in kg for a DYNAMIC body, ``math.inf`` for an immovable one.
"""
[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, and a body flipped off DYNAMIC stops reporting as
asleep at once, whatever ``wake=`` the flip was given -- an immovable body
has no motion to resume, so clearing the flag restores this invariant
rather than waking anything.
A sleeper is woken by anything the solver would otherwise let it miss:
a contact, an impulse or force, a write to its own pose, velocity, mode or
any of the live-edit setters, AND a change to a body it was in contact
with that takes support away. Destroying a body, teleporting it, changing
its geometry or its mass, or flipping it to DYNAMIC therefore wakes
whatever was resting on it; without that a crate whose support has gone
hangs in mid-air for the life of the world, because the ordinary
wake-on-contact needs a contact that still exists. Writes that change
nothing (re-posing a parked platform where it already is, re-asserting the
mode a body already has) wake nothing, so a game may drive both every
frame.
Sleep and wake are ISLAND-ATOMIC. A pile parks as a unit: no body commits
until every DYNAMIC body it touches, and everything those touch in turn,
has come to rest as well. A disturbance that wakes any member of a
sleeping island wakes all of it, so nothing a change took support from is
left asleep however deep the pile: pull the bottom crate out of a settled
stack and the whole stack falls, not just the crate that was on it. What a
pile rests ON is in nobody's island (STATIC and KINEMATIC bodies end the
walk), so two piles sharing a floor sleep and wake independently.
Asleep is FROZEN. A sleeping body's pose is the pose it fell asleep at,
every step it stays asleep, so anything that reads a settled scene -- a
save, a placement check, a golden image -- reads the same numbers until
something wakes it.
That is an invariant of this interface rather than a courtesy each backend
remembers: **every** mutator that can take support away
(:meth:`set_body_transform`, :meth:`set_body_mode`, :meth:`set_body_mass`,
:meth:`set_body_filter`, :meth:`set_body_shape`, :meth:`set_one_way`)
wakes the body and its neighbours. All but :meth:`set_one_way` take
``wake=False`` to suppress it for the cases
where a write is bookkeeping rather than a disturbance: streaming a level
in, respawning a pooled object, or an editor writing a value the player
cannot feel. Suppression is opt-IN because getting it wrong the other way
strands a body in mid-air, which no later step can recover.
:meth:`apply_impulse`, :meth:`apply_force`, :meth:`apply_torque` and
:meth:`set_one_way` have no such flag, because changing what the solver
does with the body is the whole point of each of them; a
:meth:`set_one_way` that changes nothing is not a disturbance in the
first place, and wakes nothing.
"""
[docs]
@abstractmethod
def wake(self, handle: BodyHandle) -> None:
"""Wake a sleeping body, whatever its sleep timer had reached.
Reaches only the body it names: the island rule belongs to a change that
takes support away, and this call takes nothing away. A body that is
already awake, and one that is STATIC or KINEMATIC and so was never
asleep, are both a no-op.
Args:
handle: Body handle.
"""
[docs]
@abstractmethod
def sleep(self, handle: BodyHandle) -> None:
"""Put a body to sleep now, without waiting for it to settle.
Freezes the body where it is: skipped by integration and the contact
velocity solve until something wakes it, while staying a full collider that
still reads back its (frozen) pose. Use it to park a pile a game knows is
finished rather than paying for it to come to rest first.
A no-op on a body that is STATIC or KINEMATIC (never awake), and on one
whose ``can_sleep`` is False: forbidding sleep means forbidding it.
Args:
handle: Body handle.
"""
[docs]
@abstractmethod
def set_body_can_sleep(self, handle: BodyHandle, enabled: bool) -> None:
"""Allow or forbid this body ever falling asleep.
The create-time ``can_sleep`` argument, live. Forbidding it wakes the body
if it was asleep; allowing it again lets the body settle from the current
step, with no credit for time already spent at rest.
A body that may not sleep is simulated every step for the life of the
world. That is the point of it, and it is also the cost: it will show up in
a profile as a body that never leaves the integrate and solve sets, which
is correct rather than a defect.
Args:
handle: Body handle.
enabled: True to allow sleeping (the default), False to forbid it.
"""
# -- forces -------------------------------------------------------
[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.
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`.
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`.
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 ----------------------------------------
[docs]
@abstractmethod
def create_fixed_joint(self, a: BodyHandle, b: BodyHandle) -> JointHandle:
"""Weld two bodies: lock their full relative transform.
The current offset and relative angle are captured in ``a``'s frame at
create and held there, so the two move as one rigid assembly and the
whole assembly swings round when ``a`` turns.
"""
[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.
The two bodies cannot separate at ``anchor`` but turn freely about it, so
chaining pins builds a rope and pinning to a STATIC body builds a
pendulum. The anchor is captured at create as a point in EACH body's own
frame and turned back into world axes by that body's current angle every
solver pass, so a pin on a spinning body orbits with it.
Built-in backend caveat: convergence is a few sequential-impulse
iterations, so a loaded chain sags and a hard yank stretches a link
before it is pulled straight; the pymunk backend hangs the same chain on
its rest length. See ``docs/core/physics_backends.md`` for the measured
sag.
"""
[docs]
@abstractmethod
def create_hinge_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec2) -> JointHandle:
"""Hinge two bodies at ``anchor``.
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).
``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).
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. Both
stay in the frames they were given in, so the rail turns with ``a``.
**What every backend promises is the anchor on the rail**: ``anchor_b``,
placed by ``b``'s own current rotation, stays on the segment ``a``'s
current rotation puts there, however either body is turning. That is the
whole of the cross-backend contract, and the 2D contract suite measures
it on both implementations.
What is NOT promised is the slider's own rotation. A groove leaves it
free, so what the slider ends up pointing at is whatever each solver's
residual torque about the anchor made of it, and the two backends part
company: on the suite's own scenario -- a carrier turning at 1 rad/s
under a slider spun at 3 rad/s about an anchor a quarter of a unit off
its centre -- they are 0.33 rad apart after two seconds and 3.14 rad
apart after ten, with both anchors still on the rail. A scene that needs
the slider held at an angle welds or pins it rather than reading a
heading off the groove.
Motion along the groove is free and no backend drives it: a slider is
pushed by forces on ``b``. The two backends stop it at the endpoints with
different firmness, measured in ``docs/core/physics_backends.md``.
"""
[docs]
@abstractmethod
def remove_joint(self, handle: JointHandle) -> None:
"""Remove a constraint; the handle is invalid afterwards.
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 and joint, emptying the world.
2D sibling of :meth:`~simvx.core.physics.world.PhysicsWorld.clear`. 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) ------------------
[docs]
@abstractmethod
def drain_overlap_events(self) -> list[OverlapEvent2D]:
"""Return and CLEAR this step's buffered sensor-overlap events."""
# -- one-way platforms -------------------------------------------
[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).
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.
Turning one-way ON takes support away from whatever was resting on the
side the platform stops resolving, so it wakes the body's neighbours as
well as the body, exactly as :meth:`set_body_filter` does (see
:meth:`sleeping`). A sleeping stack on a platform that becomes
pass-through therefore falls, instead of hanging above geometry that no
longer holds it. Any REAL change wakes the same set -- turning one-way
off or editing the normal adds support rather than takes it, but the
bodies affected still need a step awake to settle against the new rule.
A write that changes neither the flag nor the normal wakes nothing, so a
game may drive this every frame.
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) -------------------------------------------
[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``."""
[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."""
[docs]
@abstractmethod
def shapecast(
self, shape: ShapeHandle, origin: Vec2, direction: Vec2, max_dist: float, *, mask: int = 0xFFFFFFFF
) -> SweepHit2D | None:
"""Sweep a shape along a ray, return the earliest-TOI contact."""
[docs]
@abstractmethod
def overlap(self, shape: ShapeHandle, transform: Any, *, mask: int = 0xFFFFFFFF) -> list[BodyHandle]:
"""Return all bodies a static shape overlaps at ``transform``.
Every body in the table is visible here, including a KINEMATIC character
body.
"""
# -- kinematic sweep ---------------------------------------------
[docs]
@abstractmethod
def sweep_body(
self,
handle: BodyHandle,
motion: Vec2,
*,
from_transform: tuple[Vec2, float] | None = None,
skin: float = 0.0,
) -> SweepHit2D | None:
"""Cast a body's shape along ``motion`` and report the first blocker.
2D sibling of :meth:`~simvx.core.physics.world.PhysicsWorld.sweep_body`,
with the same non-mutating contract and the same blocking predicate: not
the mover, not a sensor, the canonical AND layer/mask rule, the 2D one-way
filter, and an opposing contact normal
(``dot(motion_dir, separating_normal) < -1e-4``). A contact reported at
distance zero must still carry a true surface normal, never the cast axis.
Args:
handle: Body handle of the mover.
motion: World-space displacement to sweep along (``Vec2``).
from_transform: Optional ``(position, rotation)`` pose to cast from
instead of the body's own stored pose; rotation is a scalar in
radians.
skin: Contact clearance to leave at the blocker, in world units;
ignored by a substepped backend (see :attr:`SweepHit2D.distance`).
Returns:
The nearest blocking :class:`SweepHit2D`, or ``None`` when clear.
"""
[docs]
def move_and_collide(self, handle: BodyHandle, motion: Vec2) -> SweepHit2D | None:
"""Move a kinematic body by ``motion``, stop at the first contact.
Concrete composition of :meth:`sweep_body` and :meth:`set_body_transform`,
identical on every backend: sweep, then advance to exactly
``SweepHit2D.distance`` along ``motion`` (the full ``motion`` when clear).
The collide-and-slide policy for a character body lives in
``simvx.core.physics.slide``, not here.
"""
# asarray, not Vec2(*motion): a Vec2 is already a float32 (2,) array, so
# this is a no-op on the common path and re-boxing would cost an allocation
# per call for a value the sweep only ever indexes.
m = cast(Vec2, np.asarray(motion, dtype=np.float32))
dist = math.hypot(float(m[0]), float(m[1]))
if dist < 1e-9:
return None
pose = self.body_transform(handle)
hit = self.sweep_body(handle, m, from_transform=pose, skin=_SWEEP_SKIN)
reach = dist if hit is None else min(dist, max(0.0, float(hit.distance)))
pos, rot = pose
self.set_body_transform(handle, (pos + m * (reach / dist), rot))
return hit
# -- characters are bodies ---------------------------------------------
# A character controller is a BodyMode.KINEMATIC body created with
# create_body, stored in the same body table, carrying the same
# collision_layer / collision_mask, and returning the same BodyHandle. It is
# visible to raycast, raycast_all, shapecast, overlap, register_bodies,
# read_transforms, body_count, the contact-event stream and the
# sensor-overlap stream, exactly like any other kinematic body. There is no
# character handle namespace, no character table, and no character-specific
# create / destroy / transform call: this interface has no character surface
# at all. The only thing that distinguishes a character is the movement policy,
# which is the free function simvx.core.physics.slide.move_and_slide_2d
# written against body_transform / set_body_transform / sweep_body.
# -- subclass helpers (contract enforcement) ---------------------------
@staticmethod
def _check_transforms_out(out: np.ndarray, n: int) -> None:
"""Reject an ``out`` buffer that breaks 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. The buffer is caller input, so a bad one
raises ``ValueError`` rather than asserting: ``assert`` is compiled out
by ``python -O``, and the write would then silently reinterpret or
truncate the caller's memory.
"""
if out.shape != (n, 4):
raise ValueError(f"read_transforms out must be ({n}, 4), got {out.shape}")
if out.dtype != np.float32:
raise ValueError(f"read_transforms out must be float32, got {out.dtype}")
if not out.flags["C_CONTIGUOUS"]:
raise ValueError("read_transforms out must be C-contiguous")
@staticmethod
def _check_velocities_out(out: np.ndarray, n: int) -> None:
"""Reject an ``out`` buffer that breaks 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. The buffer is caller input, so a bad one
raises ``ValueError`` rather than asserting, for the reason given on
:meth:`_check_transforms_out`.
"""
if out.shape != (n, 3):
raise ValueError(f"read_velocities out must be ({n}, 3), got {out.shape}")
if out.dtype != np.float32:
raise ValueError(f"read_velocities out must be float32, got {out.dtype}")
if not out.flags["C_CONTIGUOUS"]:
raise ValueError("read_velocities out must be C-contiguous")
__all__ = [
"BodyMode",
"DEFAULT_ANGULAR_DAMPING",
"DEFAULT_GRAVITY_SCALE",
"DEFAULT_LINEAR_DAMPING",
"Capability",
"CombineMode",
"PhysicsMaterial",
"ContactPhase",
"RaycastHit2D",
"SweepHit2D",
"ContactEvent2D",
"OverlapEvent2D",
"Physics2DWorld",
"normalise_body_scale_2d",
"is_unit_scale_2d",
"body_scale_unchanged",
"BodyHandle",
"ShapeHandle",
"JointHandle",
]