Source code for simvx.core.physics.world

"""PhysicsWorld: the interface between the engine and a physics backend.

This module defines ``PhysicsWorld``, the abstract interface every physics
backend (builtin, pymunk, Jolt) implements. It is a **transport** abstraction,
not a semantics one: all backends run the same kind of rigid-body simulation,
so the contract is about *moving body state across the Python<->native boundary
efficiently*, not about defining solver behaviour.

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**. Per-body crossings are reserved for setup/teardown and are never
used on the hot path. See :meth:`PhysicsWorld.register_bodies`,
:meth:`PhysicsWorld.read_transforms`, and :meth:`PhysicsWorld.read_velocities`.

The interface is testable standalone by constructing a concrete backend and
calling :meth:`step` manually; in normal use a ``PhysicsRoot`` node owns the
world and drives it from the fixed-step loop.
"""

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any, cast

import numpy as np

from ..math import Quat, Vec3
from .capability import Capability
from .material import CombineMode, PhysicsMaterial

# Opaque handle aliases. 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 so a backend can
# back it with a Jolt Constraint (not a Body); callers treat it as opaque and
# never inspect it. Joints are node-agnostic and keyed only by the two
# BodyHandles they constrain.
JointHandle = Any

# Default contact clearance for the bare :meth:`PhysicsWorld.move_and_collide`
# primitive, which has no per-body skin knob. A caller with one of its own (the
# collide-and-slide policy in ``physics/slide.py``) passes that instead.
_SWEEP_SKIN = 1e-3

# -- seam defaults: per-body dynamics ---------------------------------------
#
# Damping and gravity scale were UNSPECIFIED before these constants existed, so
# every backend ran whatever the library under it happened to ship and the same
# scene simulated differently on each. Measured: a pushed sphere coasted 3.6 m on
# the builtin solver, which applied no damping at all, and 78 m on Jolt, which
# defaults to 0.05. That is a contract violation rather than a tolerance, because
# it came from a parameter nobody had stated, not from the solver tier.
#
# 0.05 is Jolt's shipped default, and also PhysX's and Unity's angular default.
# The alternatives were measured against each other rather than picked: Box2D
# ships 0 (a body coasts forever, which is what produced the 3.6 m/78 m split in
# the first place) and Godot ships 0.1 as a project setting (visibly syrupy on a
# rolling ball). Taking the recommended backend's own value also means adopting
# these changes nothing about how a Jolt scene behaves; only the pure-Python
# tiers move, and they move TOWARDS it.
DEFAULT_LINEAR_DAMPING = 0.05
DEFAULT_ANGULAR_DAMPING = 0.05
DEFAULT_GRAVITY_SCALE = 1.0

# -- seam defaults: world knobs ---------------------------------------------
#
# Iterations: the impulse-solver iteration count, the one convergence dial all
# three solvers have (builtin's velocity loop, Jolt's ``numVelocitySteps``,
# Chipmunk's ``space.iterations``).
#
# 8, the value the builtin solver was tuned at, rather than the 10 Jolt and
# Chipmunk both ship. This is the one knob where a single number across two
# different solvers does not simply mean the same thing, and it was MEASURED
# rather than assumed: at 10, three-high stacks on the builtin solver are left
# jittering just above the sleep threshold and NONE of nine crates ever settles,
# where at 8 all nine do. Jolt and Chipmunk are indifferent between the two (the
# same nine crates rest within a millimetre of the same heights, all asleep, at
# either count). So the default is the value that costs nothing on the backends
# that do not care and keeps the one that does working; a scene that wants
# tighter convergence raises it per world.
DEFAULT_SOLVER_ITERATIONS = 8

# Position passes: how many times a step drives the RIGID joints (pin, hinge,
# weld, the 2D groove) toward the pose they name, after the velocity loop has
# run. It is the second half of a sequential-impulse solver and it is what a
# loaded chain's sag is made of: the velocity loop cancels the error's rate of
# growth and this drains the error itself, so too few passes leave a chain
# hanging longer than its links.
#
# 3, the value both builtin solvers have always run, so the default changes
# nothing. It is a knob because the right number is a property of the scene
# rather than of the engine: one pin needs none of it and an eight-link rope
# needs more than three (see the measured sag in
# ``docs/core/physics_backends.md``). Contacts are not driven by it -- their
# positional correction is deliberately one pass per step, not one per iteration
# -- so raising it costs only the joints a scene actually has.
DEFAULT_POSITION_ITERATIONS = 3

# Sleep: world-level thresholds with only a boolean (``can_sleep``) on the body,
# which is Jolt's own model -- ``timeBeforeSleep`` and
# ``pointVelocitySleepThreshold`` live in its world-level settings struct and
# ``allowSleeping`` is the only per-body control it exposes. 0.5 s is the value
# Jolt and the builtin solver already agreed on. 0.05 m/s is the builtin value:
# Jolt measures a POINT velocity at the body's extremities (folding in spin) and
# so ships 0.03 for a stricter measure, and no single number makes the two
# measures equal, so the seam keeps the default backend's shipped behaviour and
# moves Jolt by 0.02 m/s on the stricter of the two.
DEFAULT_SLEEP_TIME = 0.5
DEFAULT_SLEEP_VELOCITY = 0.05

# Contact overlap a solver tolerates without pushing it back out, in world units.
# 1 mm is a METRE-scale number, and it is the one the builtin solvers have always
# used; stating it at the seam is what makes the two native lanes agree with them
# rather than each inheriting its own library's number. It DISPLACES both: Jolt
# ships 0.02 and Chipmunk 0.1 (ten centimetres at this scale, which leaves a
# landed crate resting visibly inside the floor), so a resting body sits about
# 19 mm higher on Jolt than it did and up to ten centimetres higher on pymunk.
# Both are measured stable here; see docs/core/physics_backends.md.
#
# A knob rather than a constant because a backend may want its own library's
# tolerance back (Jolt's 0.02, Chipmunk's 0.1), not because the value should track
# the scene's scale. It is a FLOOR on how deep a settled body rests -- a body sits
# exactly this far inside what it stands on, at any world scale -- so on a backend
# that sleeps, lower is strictly better and this default is right in pixels as
# much as in metres.
#
# It does NOT need raising at pixel scale. The text here used to say it did, and
# that raising it bought "sleep and a body that does not visibly sink"; both
# halves are measured false. A settled pile already sleeps at this value, and
# raising it is what makes the body sink: a lone 20-unit crate at pixel scale
# sinks 0.0008 here and 0.0998 at 0.1, and a settled pile drifts at neither.
# It is also measured NOT to be the axis that holds a held-awake stack together.
# See PhysicsWorld.contact_slop.
DEFAULT_CONTACT_SLOP = 0.001


def _knob_float(name: str, value: Any, requirement: str) -> float:
    """Convert a knob write to a finite float, refused in the knob's own words.

    ``float()`` answers a value it cannot take with whatever error the type it was
    handed happens to raise -- ``TypeError`` for ``None`` or a list, an anonymous
    parse message for a string, ``OverflowError`` for an int too large to
    represent -- where a refused knob owes the caller a ``ValueError`` naming
    itself. NaN and the infinities are refused here too, so a caller sees the same
    sentence for every value the knob cannot hold.

    A STRING is refused rather than parsed, even one that would parse:
    :func:`_knob_int` has always refused ``"8"`` (an int is not equal to its own
    spelling), and a seam where one knob reads text and its neighbour does not is
    worse than either rule on its own. Text arriving at a physics knob is a
    caller's mistake, not a unit.
    """
    if isinstance(value, str | bytes):
        raise ValueError(f"{name} must be {requirement}, got {value!r}")
    try:
        number = float(value)
    except (OverflowError, TypeError, ValueError):
        raise ValueError(f"{name} must be {requirement}, got {value!r}") from None
    if not math.isfinite(number):
        raise ValueError(f"{name} must be {requirement}, got {value!r}")
    return number


def _knob_int(name: str, value: Any, requirement: str) -> int:
    """Convert a knob write to a whole number, refused in the knob's own words.

    Refuses what ``int()`` cannot take (as :func:`_knob_float` does) and also what
    it would take by TRUNCATING: ``3.7`` is not a whole number, and silently
    running 3 iterations for it is worse than saying so. A float that happens to
    be whole (``4.0``) is accepted and means 4; anything whose value differs from
    its integer conversion -- a fraction, a string, a non-numeric object -- is not
    a whole number and is refused.
    """
    try:
        count = int(value)
        whole = bool(count == value)
    except (OverflowError, TypeError, ValueError):
        raise ValueError(f"{name} must be {requirement}, got {value!r}") from None
    if not whole:
        raise ValueError(f"{name} must be {requirement}, got {value!r}")
    return count


#: Axis names, per dimension, for the gravity refusal message.
_GRAVITY_AXES = {2: "(x, y)", 3: "(x, y, z)"}


[docs] def normalise_gravity(value: Any, components: int) -> tuple[float, ...]: """Convert a gravity write to exactly ``components`` finite floats. The vector twin of :func:`_knob_float`, shared by every backend's ``gravity`` setter so all of them refuse the same values in the same words. Boxing the write straight into a ``Vec2`` / ``Vec3`` does not: those constructors are written for game code and are deliberately forgiving, so a 3-sequence handed to a 2D world loses its last component, a bare number broadcasts across the axes, and a NaN sails through and poisons every body in the world on the next step. A knob is not the place for any of that. Args: value: The caller's write: any sequence of exactly ``components`` real numbers (a ``Vec2`` / ``Vec3``, a tuple, a list, a numpy row). components: How many axes this world's gravity has, 2 or 3. Returns: The axes as plain floats, in order, for the caller to box. Raises: ValueError: If the write is not a sequence, is the wrong length, or holds anything that is not a finite number. Strings are refused rather than read character by character. """ axes = _GRAVITY_AXES[components] if isinstance(value, str | bytes) or not hasattr(value, "__iter__"): raise ValueError(f"gravity must be {components} finite numbers {axes}, got {value!r}") parts = tuple(value) if len(parts) != components: raise ValueError(f"gravity must be {components} finite numbers {axes}, got {value!r}") out = [] for part in parts: if isinstance(part, str | bytes): raise ValueError(f"gravity must be {components} finite numbers {axes}, got {value!r}") try: number = float(part) except (OverflowError, TypeError, ValueError): raise ValueError(f"gravity must be {components} finite numbers {axes}, got {value!r}") from None if not math.isfinite(number): raise ValueError(f"gravity must be {components} finite numbers {axes}, got {value!r}") out.append(number) return tuple(out)
[docs] def normalise_damping(value: float, what: str) -> float: """Validate a damping coefficient (finite, ``>= 0``) and return it as a float. Damping is a per-second rate applied to the velocity a body ALREADY carries, once per step, before that step's acceleration is added: ``v = v * max(0, 1 - damping * dt) + a * dt``. ``0`` coasts forever, ``1`` sheds roughly 63% of the speed per second. There is no upper bound -- a value above ``1/dt`` simply stops the body dead in one step, which is a legitimate way to ask for that. Damping the carried velocity rather than the sum is load-bearing at rest: the acceleration a resting body is given is exactly what its contact is about to cancel, so damping that too leaves a residue the solver cannot remove and a settled stack jitters on it. The two orders are identical whenever nothing is accelerating the body, which is the case damping exists for. The pure-Python tiers run exactly this. Both native lanes damp at the stated rate and differ only in where their own integrator applies it, in a way that is bounded rather than cumulative: - **Jolt** damps AFTER adding the step's acceleration, so a body under sustained acceleration ends up a relative ``damping * dt`` slower -- 0.08% at the default rate and 60 Hz, 3.3% at a rate of ``2``. It vanishes the moment nothing is accelerating the body. - **pymunk** integrates position BEFORE damping the velocity, so a body coasting from a push travels one step's worth of the speed it has shed further than the formula above: a fixed ``damping * dt`` fraction of the coast, 0.083% at the default rate and 0.83% at ``0.5``. The metres grow with the coast; the fraction does not. (That lane also runs Chipmunk's space-wide exponential ``damping`` for a body on the seam default and the exact formula for one that deviates; the two forms differ by ~3e-7 per step at 60 Hz.) """ v = float(value) if not v >= 0.0: # NaN fails this too raise ValueError(f"{what} must be >= 0 and finite, got {value!r}") if v == float("inf"): raise ValueError(f"{what} must be finite, got {value!r}") return v
[docs] def normalise_gravity_scale(value: float) -> float: """Validate a per-body gravity multiplier (finite) and return it as a float. Any usable value is legal, including ``0`` (a body that ignores gravity, the usual way to float a pickup) and negatives (a body that falls upward, the usual way to make a balloon). NaN and the infinities are refused, and so is any magnitude at or beyond ``1e30``: a multiplier that large is not a gravity setting a scene meant, it overflows to infinity the moment it is integrated against a float32 velocity, and the bound is what lets one comparison reject NaN and the infinities too. """ v = float(value) if not -1e30 < v < 1e30: # rejects NaN and both infinities raise ValueError(f"gravity_scale must be finite, got {value!r}") return v
# Shape kinds whose geometry cannot represent a non-uniform scale, keyed by the # ``create_*`` factory that builds them. A sphere and a capsule are defined by a # single radius, and a cylinder by a radius plus an independent height, so each # constrains the axes it derives that radius from. Every other kind (box, hull, # mesh, and their 2D counterparts) scales componentwise. Held here, not per # backend, so all five agree on which scales are expressible. _UNIFORM_SCALE_KINDS = frozenset({"sphere", "capsule"}) _UNIFORM_XZ_SCALE_KINDS = frozenset({"cylinder"}) # The bytes of an unscaled float32 (3,) scale, for the identity test below. _UNIT_SCALE_BYTES = np.ones(3, dtype=np.float32).tobytes()
[docs] def is_unit_scale(scale: np.ndarray) -> bool: """True when ``scale`` is exactly ``(1, 1, 1)`` and so changes no geometry. A byte comparison, so a backend can skip materialising a scaled shape for the overwhelmingly common unscaled body without a float epsilon deciding it. """ return scale.tobytes() == _UNIT_SCALE_BYTES
[docs] def body_scale_unchanged(scale: object, stored: np.ndarray) -> bool: """True when ``scale`` is byte-identical to the scale a body already carries. The pose write is the seam's hottest per-body crossing, and the node layer states the node's scale on every one of them, so a scale that has not moved since the last write is the overwhelmingly common case. Deciding it by a byte compare against what the body already holds is some twenty times cheaper than validating the value again, and validating it again would be redundant: a scale is validated when it is first accepted, and the geometry it was accepted for is re-validated independently whenever the collider is swapped. Conservative by construction: anything that is not a float32 array of exactly the stored bytes answers False and takes the full validating path, so a caller passing a tuple or a float64 array loses only the shortcut, never correctness. Dimension-agnostic -- ``stored`` carries the dimension, so the 2D seam uses this function too. """ return isinstance(scale, np.ndarray) and scale.dtype == stored.dtype and scale.tobytes() == stored.tobytes()
[docs] def normalise_body_scale(scale: object, kind: str) -> np.ndarray: """Validate a body scale against a shape kind and return it as float32 ``(3,)``. Scale lives on the BODY, not on the shape resource, because one resource is shared by every body that uses that geometry. Each backend applies it to its own instance of the shape, so this is where the rule that decides which scales a given geometry can express is stated once for all of them. Non-uniform scale is REJECTED rather than approximated where the geometry cannot carry it: a sphere and a capsule have one radius, so squashing one on a single axis has no representation, and quietly substituting the largest or the mean would give a collider that does not match what is on screen -- which is the defect scale exists to fix. A box, a convex hull and a triangle mesh scale componentwise; a cylinder scales freely along Y and uniformly across X/Z. Negative components MIRROR: they flip a point cloud through the origin and are irrelevant to the analytic kinds, whose parameters are magnitudes. Uniformity is therefore judged on magnitudes, so a sprite-style ``(-1, 1, 1)`` flip is a uniform scale. Args: scale: A ``Vec3`` or any 3-sequence. kind: The backend's own name for the geometry (``"sphere"``, ``"box"``, ``"capsule"``, ``"cylinder"``, ...). Unknown kinds scale componentwise. Returns: The scale as a float32 ``(3,)`` array. Always a fresh array, never a view of the caller's: backends keep what this returns as the body's own scale and compare the next write against it, so sharing a buffer with a caller that mutates its ``Vec3`` in place would leave a body whose recorded scale had changed and whose collider had not. Raises: ValueError: If any 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(3) 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)}") mag = np.abs(s) if kind in _UNIFORM_SCALE_KINDS: if not (float(mag[0]) == float(mag[1]) == float(mag[2])): 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 box or " f"convex-hull collider, which scale per axis." ) elif kind in _UNIFORM_XZ_SCALE_KINDS and float(mag[0]) != float(mag[2]): raise ValueError( f"a {kind} collider has one radius across X/Z and cannot be scaled non-uniformly there; got " f"{tuple(float(c) for c in s)}. Y scales freely; use a box or convex-hull collider for the rest." ) return s
[docs] class BodyMode(Enum): """Motion mode of a body, mirroring the modes every serious solver exposes. - ``STATIC``: immovable collider. Never integrated; infinite mass. - ``DYNAMIC``: force-simulated; responds to gravity, impulses, contacts. - ``KINEMATIC``: code-moved; pushes dynamic bodies, immune to forces. """ STATIC = "static" DYNAMIC = "dynamic" KINEMATIC = "kinematic"
[docs] @dataclass(slots=True, frozen=True) class RaycastHit: """Result of a successful raycast against the world. Attributes: body: Handle of the body the ray hit. point: World-space contact point (``Vec3``). normal: World-space surface normal at the hit (``Vec3``, unit length). distance: Distance from the ray origin to ``point`` along the ray. """ body: BodyHandle point: Vec3 normal: Vec3 distance: float
[docs] @dataclass(slots=True, frozen=True) class SweepHit: """Result of a shape sweep stopping against a body. Mirrors :class:`RaycastHit` exactly (a single "other" body, like a query result): the swept body is implicit (the caller). Maps cleanly onto Jolt (``body`` <- hit ``BodyID``, ``normal`` <- contact normal, ``distance`` <- ``fraction * |motion|``). Attributes: body: Handle of the OTHER body that was hit. point: World-space contact point (``Vec3``). normal: World-space surface normal (``Vec3``, unit), pointing AWAY from the other body toward the moving body, i.e. the direction that separates the mover. A sweep that begins already in contact still reports the surface's separating normal, never the cast axis, so a caller can classify what it is touching. distance: A distance along ``motion`` at which the mover's shape is guaranteed NOT to penetrate the blocker. A backend with a true time-of-impact sweep reports ``max(0.0, toi - skin)``; a backend that substeps reports a bound refined by bisection to ``|motion| / (substeps * 2**8)`` and ignores ``skin``, because its own quantum is already larger than any sane skin. It is therefore an under-estimate of the true touch distance whose error is bounded by the backend's sweep granularity, and it may be exactly ``0.0`` for a sweep that begins in contact. Callers advance to exactly ``distance`` and subtract nothing further. """ body: BodyHandle point: Vec3 normal: Vec3 distance: float
[docs] class ContactPhase(Enum): """Edge phase of a body-pair contact, diffed by the broadphase each step. Only the two transitions are reported (no per-frame "stay"): a pair fires ``ENTER`` the step it begins overlapping and ``EXIT`` the step it stops. """ ENTER = "enter" EXIT = "exit"
[docs] @dataclass(slots=True, frozen=True) class ContactEvent: """A node-agnostic body-pair collision event emitted by a physics world. Keyed by body HANDLES only: the world never names a node. The tree maps ``a`` / ``b`` back to nodes and fires the node-level ``collided`` / ``separated`` Signals. Both static-dynamic and dynamic-dynamic pairs are reported, and both bodies are notified (the tree reorients per side). Orientation convention is fixed here as ``a -> b`` (mirroring the internal narrow-phase ``_Contact.normal``); the tree negates ``normal`` / ``rel_velocity`` for the ``b`` side so each body sees the separating direction pointing toward itself. A contact event is emitted only for pairs with at least one DYNAMIC participant. Kinematic-vs-kinematic and kinematic-vs-static pairs cannot be pushed apart, produce no solver work, and produce no ``ContactEvent`` on any backend. Sensor overlap is the separate one-directional stream (see :class:`OverlapEvent`) and is unaffected. 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 (``Vec3``). Meaningful on ``ENTER``; degenerate (``Vec3(0)``) on ``EXIT`` (no live manifold). The same degenerate value, for the same reason, on the rare ``ENTER`` whose manifold carries no point at all: a narrow phase that reports a touching pair and nowhere it touches has no world point to publish, and every backend answers that one way (:func:`~simvx.core.physics.capability.contact_manifold_payload`). normal: Unit contact normal oriented ``a -> b`` (``Vec3``). Degenerate (``Vec3(0)``) on ``EXIT``. impulse: Normal impulse magnitude the solver applied to the pair this step. ``0.0`` on ``EXIT``, and ``0.0`` on an ``ENTER`` the solver resolved with no impulse (e.g. separating velocity). ``None`` iff the backend does not advertise :attr:`Capability.CONTACT_IMPULSE`: it has no hook into its solver's applied lambda, so it reports no number rather than a plausible-looking guess. ``None`` and ``0.0`` are different facts and both stay expressible. impulse_estimate: The portable stand-in for ``impulse``: the impulse it would take to arrest the approach, computed the same way on every backend from the pair's masses and the difference of the two bodies' LINEAR velocities (:func:`~simvx.core.physics.capability.contact_impulse_estimate`). Always a number, never ``None``, on every backend and whether or not :attr:`~simvx.core.physics.capability.Capability.CONTACT_IMPULSE` is advertised. ``0.0`` on ``EXIT``, and ``0.0`` for a pair that is not approaching. It is not a measurement: it ignores restitution and the solver's work entirely, which is what makes one formula serve every backend. Note that it is NOT this event's own ``rel_velocity`` put through that formula: the at-point value below carries each backend's choice of contact point, and a spinning body's estimate computed from it would not be portable. Read ``impulse`` where it is measured and this everywhere else. rel_velocity: Pre-solve velocity of ``b`` w.r.t. ``a`` AT THE CONTACT POINT (``Vec3``), spin included: each body's ``v + omega x r`` for the offset from its centre to :attr:`point`, differenced. A wheel spinning against the ground reports the speed of its tread, which its centre is not moving at. That meaning is contract on every backend, because the engine owns the error-prone half: the difference of the two bodies' LINEAR velocities is one line of caller code, while deriving the at-point value needs the contact point and both angular velocities. Where neither body spins it reduces to that linear difference, and so does the value published where :attr:`point` is degenerate for want of a manifold point: there is nowhere to measure at, so the linear difference is what every backend publishes rather than a spin read at some substituted place. Degenerate (``Vec3(0)``) on ``EXIT``. """ a: BodyHandle b: BodyHandle phase: ContactPhase point: Vec3 normal: Vec3 impulse: float | None impulse_estimate: float rel_velocity: Vec3
[docs] @dataclass(slots=True, frozen=True) class OverlapEvent: """A node-agnostic sensor-overlap event emitted by a physics world. A SECOND, independent edge-diffed stream, parallel to :class:`ContactEvent` but never mixed with it: a sensor pair produces NO collision response and NO manifold, so there is no point / normal / impulse / rel_velocity to carry. Keyed by body HANDLES only (node-agnostic, like :class:`ContactEvent`), but DIRECTED ``sensor -> other`` rather than a canonical unordered pair: the detection is one-directional (the observing sensor decides via its mask), so a sensor-vs-sensor overlap can fire on one side without the other. Measured with two overlapping sensors built as an ``Area`` builds one: both sides report on all five backends, pymunk included since its sensors are held ``KINEMATIC`` (a pair of ``STATIC`` shapes, which is what they used to be, Chipmunk never forms at all). The tree maps both handles to nodes and routes ``body_entered`` vs ``area_entered`` by the OTHER node's type. 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``); reused, no second phase enum. """ sensor: BodyHandle other: BodyHandle phase: ContactPhase
[docs] class PhysicsWorld(ABC): """Abstract backend interface: one isolated simulation world. A ``PhysicsWorld`` 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 (``BuiltinPhysics``, later ``JoltPhysics``) implement every method. What this promises across backends ---------------------------------- This ABC and its 2D sibling :class:`~simvx.core.physics.world2d.Physics2DWorld` are held to one contract, stated here for both. - **Identical contract behaviour.** The same call has the same effect and the same observable consequences on every backend: a body created ``DYNAMIC`` falls, a filter that fails in one direction rejects the pair, a mutator wakes what the body it names was holding up, a pair reports exactly one ``ENTER`` and one ``EXIT``, a sensor reports the bodies that overlap it. A node never branches on which backend it got in order to make a call. Where a backend cannot deliver one of those clauses the shortfall is written down rather than left silent, and sensing has the two the seam knows of. One is queryable: a ``STATIC`` body is reported only where :attr:`~simvx.core.physics.capability.Capability.SENSOR_DETECTS_STATIC` is advertised, which is every backend today (measured, one sensor over one box: one ``ENTER`` on all five, whatever mode the box is in) with the one exception that member states, a Jolt sensor whose collider is a mesh. The other is part of the event's own definition: a sensor overlapping ANOTHER sensor is reported per observer, each side firing only where its own mask admits the other -- an asymmetric pair fires on one side alone -- as :class:`OverlapEvent` describes. - **Numbers only within a tolerance, and the tolerance is documented.** Two solvers do not agree on where a crate rests or which step it settles on. Numeric equality between the pure-Python solvers and Jolt is explicitly NOT promised, and code that needs a number to be reproducible needs it from one backend. ``docs/core/physics_backends.md`` records the differences that are big enough to design around. - **Backend-dependent features and payload fields are queryable.** Anything that genuinely differs is a :class:`~simvx.core.physics.capability.Capability` the caller can ask :meth:`capabilities` about, never a silent substitute and never a plausible-looking stand-in for a value the backend cannot measure. Two rules follow, and they bind new work on this seam as much as they describe it: 1. **No gameplay-visible discrete decision may be derived from a quantity whose precision is backend-dependent.** Continuous quantities may differ by a tolerance; a yes/no the player can see must not turn on that tolerance. The character step-up probe is the worked example: deciding "can I step here" from sweep precision alone let a character climb a loose sphere on an exact time-of-impact backend and not on a substepped one, so the decision is made on the walkable-slope test of the surface it lands on, which every backend agrees about. The one place the rule is not yet fully honoured is recorded where it lives, on the step-up helper behind :func:`~simvx.core.physics.slide.move_and_slide`: a rounded character mounting a ledge and ratcheting up a sphere are the same manoeuvre, and separating them needs a floor check the seam cannot express. 2. **Every Capability ships a documented degradation path.** A capability a caller cannot degrade around is a typed docstring rather than a contract. Each member of :class:`~simvx.core.physics.capability.Capability` says what a game does where it is absent, and ``CONTACT_IMPULSE`` -- the one whose absence would otherwise leave a caller with nothing -- publishes its fallback on the event itself as :attr:`ContactEvent.impulse_estimate`. 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). Shape contract (immutable values, owned by the resource that asked) ------------------------------------------------------------------- A shape handle names an IMMUTABLE VALUE, and every ``create_*`` factory mints a FRESH one. The factories do not memoise on the geometry: the world has no way of knowing when a caller has finished with a handle, so a world-level cache keyed on geometry would be a table that only ever grows -- one entry per distinct size a collider was ever animated through, for the life of the world. Sharing and lifetime belong one level up, to the ``Shape`` resource (:class:`~simvx.core.physics.shapes.Shape`), which builds its handle once per world and releases it when the resource is collected. One resource used by a thousand bodies is one backend record; a collider rebuilt every frame holds one record at a time. A caller working against this interface directly owns what it creates and releases it with :meth:`destroy_shape`. Two rules follow, and both are part of this interface, not an implementation detail of any backend: 1. **There is no mutation API, and there must never be one.** A hypothetical ``set_shape_radius`` would silently resize every body built on the handle. Any edit-the-geometry surface must BUILD A NEW HANDLE from the new parameters and hand it to :meth:`set_body_shape`, which is exactly what changing a collider does today. 2. **Destruction is about the handle, never about a body.** A handle can be shared by many bodies, so "destroy the shape this body uses" is meaningless as an instruction to the simulation: :meth:`destroy_shape` releases the world's reference and leaves every body's geometry alone. See its docstring. Per-body SCALE is separate from the shape and 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. Not every geometry can express every scale (a sphere has one radius), and the ones that cannot raise rather than approximate -- see :func:`normalise_body_scale`. """ def __init__(self, *, gravity: Vec3) -> None: """Initialise the world. Args: gravity: World gravity acceleration vector (``Vec3``), metres/s^2. """ self._gravity: Vec3 = Vec3(*normalise_gravity(gravity, 3)) 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 ------------------------------------------------------ # # The world's own knobs are plain properties rather than ``set_world_*`` # methods, matching ``gravity``, which has always been one. They are also # deliberately NOT part of the wake partition: none of them names a body, so # none of them can take support away from one. @property def gravity(self) -> Vec3: """World gravity acceleration vector (``Vec3``), metres/s^2. Scaled per body by the ``gravity_scale`` :meth:`create_body` takes, so a balloon or a pickup opts out of this without the world changing. """ return self._gravity
[docs] @gravity.setter def gravity(self, value: Vec3) -> None: self._gravity = Vec3(*normalise_gravity(value, 3))
@property def solver_iterations(self) -> int: """Impulse-solver iterations per :meth:`step` (``>= 1``). The convergence / cost dial: more iterations means a tighter stack and a stiffer joint chain for proportionally more time. Every tier has this one dial and honours it (the builtin velocity loop, Jolt's velocity steps, Chipmunk's ``space.iterations``). Its partner for the joints alone is :attr:`position_iterations`. Defaults to :data:`DEFAULT_SOLVER_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``). The second half of a sequential-impulse solve, and the one a jointed assembly notices: the velocity loop stops a joint's error growing, and these passes drain the error the loop leaves behind. Raising it tightens a loaded chain for the cost of that many more passes over the joints the scene has, and costs a scene with no joints nothing at all. Contacts are not driven by it -- their positional correction is one pass per step by design -- so this is a joint dial, not a stacking dial. Honoured by the two builtin solvers, which is where the seam's own position pass lives. The three native lanes store the value and read it back without running it: Chipmunk has no position solver at all, and Jolt's own position-step count, though it sits in the very settings struct the seam does push, is carried by neither lane's entry point (``simvx_jolt_set_world_settings`` and the shim's ``setWorldSettings``), so a Jolt world runs the library's default whatever this says. See ``docs/core/physics_backends.md``. Defaults to :data:`DEFAULT_POSITION_ITERATIONS`, so a world that never touches it behaves exactly as it always has. """ 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``). World-level, with only the ``can_sleep`` boolean on the body, because that is the model the recommended backend has: Jolt keeps ``timeBeforeSleep`` and ``pointVelocitySleepThreshold`` in its world settings struct and exposes ``allowSleeping`` per body and nothing else. A per-body threshold would have to be emulated everywhere, for a knob whose real use ("this one must never sleep") ``can_sleep`` already serves. Defaults to :data:`DEFAULT_SLEEP_TIME`. """ 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``). One threshold, not one per axis of motion: the pure-Python tiers test it against linear speed and against angular speed alike (rad/s against the same number), and Jolt against the point velocity at the body's extremities, which folds spin into the same measure. ``0`` means a body must be exactly still to sleep. Defaults to :data:`DEFAULT_SLEEP_VELOCITY`. """ 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``). The deadband every impulse solver keeps so that a resting contact is not fought over every step: penetration up to this depth is left alone, and only the excess is pushed out. **So it is a floor on how deep a settled body rests, and lower is strictly better on any backend that sleeps.** A body at rest sits exactly ``contact_slop`` inside what it is standing on, at any world scale: ``examples/features/physics/body_knobs.py --test`` prints crates resting at ``0.4990`` against a geometric ``0.5`` at the ``0.001`` default and at ``0.4030`` at ``0.1``, and the pile sleeps 4/4 at the default with a slop change waking none of it. It is a knob because a backend may want its own library's tolerance back (``world.contact_slop = 0.02`` for Jolt), not because the value should track the scene's scale. **Raising it at pixel scale is not the recommendation it used to be here, and the text that said so was wrong in both halves:** raising it does not buy sleep, because a settled pile already sleeps at the default, and it does not stop a body sinking, it is the thing that makes it sink. Measured on the builtin 2D solver at pixel scale, a lone 20-unit crate sinks 0.0008 / 0.0198 / 0.0998 / 0.4998 / 1.9998 at slop 0.001 / 0.02 / 0.1 / 0.5 / 2.0, and a settled four-high pile shows no post-settle movement at any of them. What raising it will NOT do is rescue a game-scale stack that is held awake. Measured on the built-in 2D solver, a four-high stack of 20-unit boxes at ``gravity`` 980 with ``can_sleep=False``, over 900 and 2000 steps and at drop gaps of 0, 0.02 and 1.0: the worst rest-height error is within 1% of the same number at ``0.001``, ``0.02`` and ``0.05`` in every one of those twelve scenes, and the stack that collapses collapses at all three. That residual belongs to the once-per-step position pass, not to this deadband; the same pile allowed to sleep settles correctly at any of the three, because the drain that runs as an island parks is what reaches the stacked pose. Defaults to :data:`DEFAULT_CONTACT_SLOP`. """ return self._contact_slop
[docs] @contact_slop.setter def contact_slop(self, value: float) -> None: slop = _knob_float("contact_slop", value, ">= 0 and finite") if slop < 0.0: raise ValueError(f"contact_slop must be >= 0 and finite, got {value!r}") self._contact_slop = slop self._on_world_settings_changed()
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. A pure-Python backend reads the attributes when it steps and overrides nothing; one wrapping a native world (Jolt's settings struct, Chipmunk's space) overrides this to restate them. Not abstract: a backend that needs no push should not have to write an empty method to say so. """ # -- capability gate ----------------------------------------------------
[docs] def capabilities(self) -> frozenset[Capability]: """Return the set of :class:`Capability` features this backend honours. The rigid-body surface is the parity contract every backend implements with the same methods and the same behaviour; this method is the ONE place backends advertise what genuinely depends on the backend: a whole feature this interface has no methods for (vehicles, soft bodies), a strengthened guarantee about a method every backend has (cross-platform determinism, about :meth:`step`), or a payload field that carries a value only where the backend can measure it (the contact-event impulse). A node checks ``Capability.X in world.capabilities()`` and branches, degrades or refuses explicitly. The default (this base implementation) 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: the builtin solver advertises what it measures, and an optional native backend omits what the library it wraps gives it no hook for. Every member carries a documented degradation path, so a caller that finds one absent has somewhere to go rather than a missing feature: read the member's own docstring for what to do instead. """ return frozenset()
# -- shapes (opaque handles) -------------------------------------------
[docs] @abstractmethod def create_sphere(self, radius: float) -> ShapeHandle: """Create a sphere collision shape and return an opaque handle. Args: radius: Sphere radius, world units (> 0). Returns: An opaque shape handle for use with :meth:`create_body`. """
[docs] @abstractmethod def create_box(self, half_extents: Vec3) -> ShapeHandle: """Create an axis-aligned box collision shape (centred at the origin). Args: half_extents: Half-sizes along x/y/z (``Vec3``, all > 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 hemispherical 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 sphere of ``radius``. Returns: An opaque shape handle for use with :meth:`create_body`. """
[docs] @abstractmethod def create_cylinder(self, radius: float, height: float) -> ShapeHandle: """Create a Y-axis cylinder collision shape and return an opaque handle. Args: radius: Cylinder radius, world units (> 0). height: Total extent along Y with flat caps at ``+-height / 2`` (> 0). Returns: An opaque shape handle for use with :meth:`create_body`. """
[docs] @abstractmethod def create_convex_hull(self, points: np.ndarray) -> ShapeHandle: """Create a convex-hull collision shape from a point cloud. Args: points: ``(N, 3)`` float32 array of >= 4 finite points. The backend computes its own internal hull representation from the cloud. Orientation is supported via the body transform like other shapes, EXCEPT the basic ``BuiltinPhysics`` backend, which IGNORES hull rotation (the cloud is treated in world axes offset by the body position); the Jolt backend rotates properly. Returns: An opaque shape handle for use with :meth:`create_body`. The basic backend's penetration depth/normal for a hull is an EPA-lite approximation (GJK overlap is exact); see ``builtin/world.py``. """
[docs] @abstractmethod def create_mesh(self, vertices: np.ndarray, indices: np.ndarray) -> ShapeHandle: """Create a STATIC triangle-mesh collision shape (level geometry). Args: vertices: ``(N, 3)`` float32 vertex positions. indices: ``(3 * T,)`` int64 flat triangle-list indices (three per triangle), each in ``[0, N)``. Returns: An opaque shape handle for use with :meth:`create_body`. A mesh shape is a **STATIC-ONLY** collider: placing it on a non-STATIC body is an error (rejected at :meth:`create_body` and :meth:`set_body_mode`). It carries no inertia / mass and cannot be used as a moving query shape (:meth:`shapecast` / :meth:`overlap` reject a mesh probe). """
[docs] @abstractmethod def destroy_shape(self, shape: ShapeHandle) -> None: """Release the world's reference to a shape handle. Drops ``shape`` from the backend's shape table. A backend that owns a native shape object frees it here; a pure-Python backend simply forgets the record. This is what a :class:`~simvx.core.physics.shapes.Shape` resource calls for each handle it owns when it is collected, and what a caller working against this interface directly calls for the handles it created. **Bodies are not touched.** One handle is routinely shared by many bodies (see the shape contract on :class:`PhysicsWorld`), so destroying it cannot mean "take the collider away from whoever is using it", and a resource going out of scope must never yank the geometry out from under a live body. Every body created with this shape keeps its geometry and keeps simulating exactly as before. Destroying a shape that is still in use neither raises nor detaches nor changes any body, on every backend. "Keeps simulating" is the FULL surface, not just the next :meth:`step`: a body whose shape handle has been released stays editable through every ``set_body_*`` setter and remains sweepable through :meth:`sweep_body`, which is what keeps a character controller working. A backend must therefore reach a body's geometry through the record that body owns and never by looking the caller's handle back up in its shape table. An unknown handle is a silent no-op, the same silent-drop contract :meth:`remove_joint` uses, so destroying twice or destroying after :meth:`clear` is safe in any teardown order. The HANDLE, however, 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 rather than a way to resurrect the geometry. ``KeyError`` on every backend and every one of those four calls, matching what a bogus BODY handle already raises, so one ``except KeyError`` covers the lot. 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: Vec3 | 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. Args: shape: An opaque shape handle from :meth:`create_sphere`, :meth:`create_box`, :meth:`create_capsule`, :meth:`create_cylinder`, :meth:`create_convex_hull`, or :meth:`create_mesh`. A mesh shape on a non-STATIC body is an error (mesh colliders are STATIC-only), rejected here and in :meth:`set_body_mode`. body_type: One of :class:`BodyMode`. transform: Initial world transform: a ``Transform3D``, a bare position (identity orientation), or a ``(position, orientation)`` pair whose orientation is a ``Quat``. The pair form states a complete pose, so pass ``Quat()`` for identity rather than leaving the orientation unset. mass: Body mass in kg, ``> 0`` (a non-positive or NaN mass raises ``ValueError``, whatever the mode). It is 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 bodies integrate as infinite-mass regardless of the value. scale: Per-body scale of ``shape`` (``Vec3``); ``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. The backend applies it to its own instance of the geometry, and it composes with the pose: 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. Not every geometry can express every scale, and the ones that cannot RAISE rather than approximate (see :func:`normalise_body_scale`). 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, which is what a body that must react the instant something reaches it wants: a trigger platform, a player-driven prop, or anything a game polls the velocity of. Editable afterwards 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:`normalise_damping`, which also records where Jolt's integrator differs under sustained acceleration). Defaults to :data:`DEFAULT_LINEAR_DAMPING`. This is a body knob rather than a world one because it stands in for the drag of a specific object's shape and material, which is why a feather and a cannonball want different values in the same air. Editable afterwards through :meth:`set_body_damping`. angular_damping: The same rate for spin, applied the same way to the angular velocity. Defaults to :data:`DEFAULT_ANGULAR_DAMPING`. Independent of ``linear_damping``: a wheel that must keep rolling but stop sliding wants them different. gravity_scale: Multiplier on the world's gravity for this body alone. ``1`` (the default) falls normally, ``0`` ignores gravity entirely (a floating pickup, a hovering drone), and a negative value falls upward (a balloon). Multiplies :attr:`gravity`, so a world with no gravity has none whatever this says. Editable afterwards through :meth:`set_body_gravity_scale`. collision_layer: 32-bit layer membership of this body (which layers it lives on). Stored verbatim; defaults to layer 1. collision_mask: 32-bit mask of layers this body scans for collisions. Defaults to all (``0xFFFFFFFF``) so the bare API collides every pair. A pair (a, b) collides iff ``(a.mask & b.layer) and (b.mask & a.layer)``: both bodies must opt in to the other (the Box2D / Rapier convention). is_sensor: When True, this body is a SENSOR (trigger). It is created / destroyed / teleported exactly like a normal body and participates in the broadphase, but is EXCLUDED from collision resolution (it skips the solver, applies no impulse, never appears in the contact-event stream, and never blocks a shape sweep) and instead generates a SEPARATE overlap-event stream (see :meth:`drain_overlap_events`) using the ONE-DIRECTIONAL filter ``sensor.mask & other.layer`` (the observer decides; the other body's mask is irrelevant), never the AND body-body rule. A sensor is an ordinary body with a flag, not a separate kind of handle. material: The body's SURFACE: friction, restitution and the two combine modes, as one :class:`PhysicsMaterial` resource. ``None`` (the default) uses :data:`~simvx.core.physics.material.DEFAULT_PHYSICS_MATERIAL`, which is ``mu = 0.5`` and no bounce. A surface is a shared property of many bodies -- ice, rubber, wood -- so it is one resource passed by reference rather than four loose numbers repeated per body; the world reads its values and keeps none of it, so the same instance may be handed to any number of bodies and to any number of worlds. The resource is FROZEN, so it cannot be edited afterwards at all: give a live body a different surface through :meth:`set_body_material`. continuous: When True, this body uses continuous collision detection: each step its centre displacement is swept against STATIC geometry and clamped to the time-of-impact so a fast small body cannot tunnel through thin static colliders. Defaults False (discrete). Basic-tier honesty: a CENTRE ray / shapecast sweep vs STATIC bodies only, no rotational sweep and no dynamic-vs-dynamic CCD; the Jolt backend honours the flag faithfully via ``EMotionQuality::LinearCast``. 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. After destruction the handle is invalid. 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`). Args: handle: A handle previously returned by :meth:`create_body`. """
[docs] @abstractmethod def set_body_transform( self, handle: BodyHandle, transform: Any, *, scale: Vec3 | 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 same forms :meth:`create_body` accepts. A ``(position, orientation)`` pair states a complete pose: pass ``Quat()`` for identity. scale: New per-body scale of the body's shape (``Vec3``), or ``None`` (the default) to leave the scale it already has alone. ``None`` rather than ``(1, 1, 1)`` is load-bearing: a character controller re-writes its body's pose every step and knows nothing about scale, so a default that reset it would silently unscale every such body once per step. Re-stating the scale a body already has changes no geometry. 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. Raises: ValueError: If the body's geometry cannot represent ``scale`` (see :func:`normalise_body_scale`). """
[docs] @abstractmethod def set_body_velocity( self, handle: BodyHandle, linear: Vec3, angular: Vec3 | None = None, ) -> None: """Set a body's linear and angular velocity directly. A body the caller made ``STATIC`` is never set in motion by this call, on any 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 every backend, and it becomes the body's live velocity if the body later leaves ``STATIC``. The native adapters hold the value themselves rather than giving it to their library: they hold a STATIC sensor KINEMATIC (see :attr:`~simvx.core.physics.capability.Capability.SENSOR_DETECTS_STATIC`), which the library WOULD integrate, and Jolt keeps no velocity on a static body at all. What such a velocity means beyond the readback is the backend's: the builtin solver reads it as a surface velocity in the friction solve, which is how a conveyor is expressed, and Jolt does not. Args: handle: Body handle. linear: Linear velocity (``Vec3``), world units/s. angular: Angular velocity (``Vec3``), radians/s about each axis. ``None`` (default) means zero angular velocity. """
[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 the body between STATIC / KINEMATIC / DYNAMIC, updating its effective (inverse) mass: STATIC and KINEMATIC are infinite-mass (inv_mass 0), DYNAMIC restores the mass the body was created with. Flips are lossless, so freezing a body to STATIC and waking it later gives back exactly its original mass. Maps onto Jolt's Body::SetMotionType; the builtin backend flips body_type + inverse_mass. 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``. """
# -- live edits to what create_body was given --------------------------- # # Everything create_body takes beyond the pose is editable afterwards through # one of the five methods below, so a body never has to be destroyed and # rebuilt to change a value. That matters because rebuilding mints a NEW # handle: the joints referencing the old one are purged with it, and the # caller's handle->object bookkeeping goes stale. Each of these keeps the # handle, the pose, the velocity, the mode and the sleep-independent identity # of the body; each WAKES a sleeping body by default, because a value the # solver reads has changed and a sleeper would otherwise keep the old # behaviour until something else disturbed it, and 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 inertia from its current shape. Velocity is preserved, not momentum: the body keeps moving at the speed it had, and only its response to future impulses, forces and contacts changes. The new mass is RETAINED exactly like the create-time one, so it survives a later :meth:`set_body_mode` flip; setting it on a STATIC or KINEMATIC body is legal and takes effect the moment 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: a pillar made a thousand times heavier while a crate sleeps on it must not leave that crate reading the old response. 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 pair-acceptance rule reads both sides of both bodies (``(a.mask & b.layer) and (b.mask & a.layer)``), so changing one alone is never the whole answer; a caller changing one passes the current value of the other. The new filter is honoured from the NEXT :meth:`step` on, and the event streams follow it: a pair that was touching and no longer matches reports an EXIT rather than vanishing silently, so a listener is never left believing a pair is still in contact, and one that newly matches reports an ENTER like any other new contact. An edit that changes no verdict reports nothing at all: re-stating the filter a pair already matched must not re-announce it. 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: a caller swapping a body from wood to ice hands over the ice, not four numbers. Honoured from the next :meth:`step`; contacts already being solved this step keep the coefficients they were solved with. The world reads the resource's values and does not retain it. That is deliberate, and safe: a :class:`PhysicsMaterial` is frozen, so there is no later edit for the world to have missed -- a material shared by fifty bodies would otherwise have no way to tell fifty backend records that one of its fields moved. A body's surface changes only by passing a different one here. 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. Both at once for the reason :meth:`set_body_filter` takes both halves of the filter: they are one description of how a body sheds motion, and a caller changing one passes the current value of the other. Honoured from the next :meth:`step`. Raising damping on a body already in flight slows it from there rather than retroactively. 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. Honoured from the next :meth:`step`, and it WAKES the body: gravity is applied by integration, which skips a sleeper, so a body parked in mid-air at ``scale = 0`` would otherwise stay there when gravity was switched back on for it. Args: handle: Body handle. scale: New multiplier on the world's gravity for this body. Any finite value, including ``0`` and negatives. 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 (the library it wraps has no CCD at all) accepts the call and does nothing, exactly as it already ignores the ``continuous`` argument to :meth:`create_body`; the capability is how a caller finds that out rather than by backend name. 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, its pose, its velocity, its mode, its mass, its filter, its sensor flag, its material and its CCD flag; only the geometry changes. The inertia is recomputed from the NEW shape at the body's retained mass, so a body does not silently keep the rotational response of the shape it no longer has. A swap is a teleport of geometry, not a move: the new shape can overlap neighbours the old one cleared. Nothing is resolved at swap time, so an overlap introduced this way is pushed apart by the ordinary contact solve over the following steps, and a body swapped inside static geometry is pushed out of it the same way. The body wakes, so that recovery starts on the next step rather than whenever something else happens to disturb it. The event streams describe the geometry, not the swap: a pair the new shape still touches is NOT announced again (it never stopped touching), a pair it no longer reaches reports an EXIT, and one it newly reaches reports an ENTER. Replacing a collider with an identical one is therefore 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 triangle mesh is a STATIC-only collider). """
[docs] @abstractmethod def body_velocity(self, handle: BodyHandle) -> tuple[Vec3, Vec3]: """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; this is the accessor used by ``PhysicsBody3D.velocity`` / ``.spin`` for a single synchronous read-back (e.g. ``self.velocity += dv``). Args: handle: Body handle. Returns: ``(linear, angular)`` velocity (``Vec3``, ``Vec3``); angular in radians/s. Returns zero velocities for an infinite-mass body that was never moved. """
[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. Args: handle: Body handle. 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 sleeping body stays a full collider and still reads back its (frozen) transform / velocity through the bulk readers. 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`) wakes the body and its neighbours, and each takes ``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` and :meth:`apply_torque` take no such flag: waking is the point of them. """
[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. Use it when a game knows something the solver cannot see (a script is about to read the body's velocity, a scripted force is coming next frame). 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: 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 (a completed level section, a body a cutscene has taken over) 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, so a body that must stay simulated cannot be put to sleep by hand either. 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, so the effect is immediate rather than starting at the next settle; 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: Vec3, *, at: Vec3 | None = None, angular: Vec3 | None = None, ) -> None: """Apply an instantaneous velocity change to a body NOW. Unlike :meth:`apply_force` this takes effect immediately (it mutates velocity, not an accumulator) and is NOT cleared by :meth:`step`. Inert on non-DYNAMIC bodies (inverse mass 0). Args: handle: Body handle. impulse: Linear impulse (``Vec3``), N*s. Adds ``impulse * inv_mass`` to the linear velocity. at: Optional world-space application point. When given, the offset ``r = at - position`` contributes an angular impulse ``cross(r, impulse)`` (basic tier scales it by ``inv_mass`` as a stand-in for the inverse inertia tensor, which the basic backend does not model). ``None`` applies the impulse purely through the centre of mass (no torque). angular: Optional explicit angular impulse (``Vec3``), for ``spin_up``. Adds ``angular * inv_mass`` (basic-tier inverse inertia stand-in) to the angular velocity, independent of ``at``. """
[docs] @abstractmethod def apply_force(self, handle: BodyHandle, force: Vec3, *, at: Vec3 | None = None) -> None: """Accumulate a continuous force, applied during the NEXT :meth:`step`. The force is integrated as acceleration (``force * inv_mass``) before position integration, then **auto-cleared** at the end of the step. To sustain a force the caller must re-add it every fixed step; a single call affects exactly one step. Inert on non-DYNAMIC. Args: handle: Body handle. force: Linear force (``Vec3``), N. at: Optional world-space application point. When given, the offset ``r = at - position`` adds a torque ``cross(r, force)`` to the torque accumulator. ``None`` applies the force through the COM. """
[docs] @abstractmethod def apply_torque(self, handle: BodyHandle, torque: Vec3) -> None: """Accumulate a continuous torque, applied during the NEXT :meth:`step`. Auto-cleared after the step like :meth:`apply_force`: re-add each fixed step to sustain it. Inert on non-DYNAMIC. Basic tier applies it as ``torque * inv_mass`` (inverse inertia stand-in). Args: handle: Body handle. torque: Torque (``Vec3``), N*m. """
# -- joints / constraints (sequential-impulse basic tier) ----
[docs] @abstractmethod def create_fixed_joint(self, a: BodyHandle, b: BodyHandle) -> JointHandle: """Weld two bodies: lock their full relative transform. Captures the CURRENT relative pose of ``b`` in ``a``'s frame at create time (relative position AND relative orientation) and holds it in that frame: the two bodies thereafter move as one rigid assembly, and the whole assembly swings round when ``a`` turns. Built-in backend caveat: it has NO inertia tensor, so the angular lock uses ``inverse_mass`` as the inverse-inertia scalar; a long thin body or an off-centre weld will rotate too easily. Convergence is a few sequential-impulse iterations, so a long weld chain sags slightly. Use the Jolt backend for precise articulated mechanisms. Args: a: First body handle (the reference frame). b: Second body handle (welded into ``a``'s frame). Returns: An opaque :data:`JointHandle`, valid until :meth:`remove_joint` (or until either body is destroyed, which silently drops the joint). """
[docs] @abstractmethod def create_pin_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec3) -> JointHandle: """Pin two bodies at a single world-space point (ball / point-to-point). Constrains the two bodies so the world point ``anchor`` stays coincident on both (they cannot separate there) while leaving all three rotational DOF free. 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 orientation every solver pass -- so a pin on a spinning body orbits with it, which is what the shape of the joint promises. Basic-tier honesty on the built-in solvers: angular cross-coupling uses ``inverse_mass`` as the inverse-inertia scalar (there is no inertia tensor), and convergence is a few iterations, so a long chain sags slightly. The anchoring itself is exact; the narrowphase is the part that still treats most colliders as unrotated. Args: a: First body handle. b: Second body handle. anchor: World-space pivot point shared by both bodies (``Vec3``). Returns: An opaque :data:`JointHandle`. """
[docs] @abstractmethod def create_hinge_joint(self, a: BodyHandle, b: BodyHandle, anchor: Vec3, axis: Vec3) -> JointHandle: """Hinge two bodies: pin at ``anchor`` + one free rotational DOF about ``axis``. A point constraint at ``anchor`` (like :meth:`create_pin_joint`) PLUS an angular constraint that locks the two off-axis rotational DOF, leaving free rotation only about ``axis``. The axis is given in world space, normalised, and captured into each body's own frame at create, so it turns with the bodies: a door on a post that is itself turning keeps swinging about the post. There are no motors and no angular limits. Built-in backend caveat: the same ``inverse_mass`` inverse-inertia-scalar stand-in as :meth:`create_pin_joint`, and only a few solver iterations. Args: a: First body handle. b: Second body handle. anchor: World-space hinge pivot point (``Vec3``). axis: World-space hinge axis (``Vec3``, normalised at create). Returns: An opaque :data:`JointHandle`. """
[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, not rigid). A soft constraint that pulls the two body centres of mass toward ``rest_length`` apart with spring constant ``stiffness`` (N/m) and damping ``damping`` (N*s/m). Unlike the rigid joints it is intentionally compliant: it applies a soft velocity impulse with a ``k*x`` bias and a ``c*v`` damping term, and is NEVER position-corrected. Basic-tier honesty: the builtin backend uses the two COMs, NOT per-body anchors (Pin / Hinge use anchors, Spring uses centres for simplicity). The explicit soft-impulse form can oscillate or overshoot when ``stiffness`` is large relative to the fixed ``dt``; a stiff spring needs a smaller ``dt`` or the Jolt backend. Nothing is silently clamped. Args: a: First body handle. b: Second body handle. rest_length: Target centre-to-centre separation (world units, >= 0). stiffness: Spring constant k (N/m). damping: Damping coefficient c (N*s/m). Returns: An opaque :data:`JointHandle`. """
[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): this is the SAME silent-drop contract :meth:`destroy_body` already uses for touching / overlap pairs, not an error-swallowing shim. A joint whose body was freed is the expected case, so removing it twice (once by the body-purge, once by the joint node's own teardown) must be safe in either teardown order. Args: handle: A handle previously returned by a ``create_*_joint`` call. """
# -- introspection ------------------------------------------------------
[docs] @property @abstractmethod def body_count(self) -> int: """Number of bodies currently in the world. Read-only. Used by ``SceneTree.physics_tick`` to skip stepping empty worlds for zero overhead, mirroring ``PhysicsServer.body_count``. """
[docs] @abstractmethod def clear(self) -> None: """Remove every body and joint, emptying the world. A level-teardown / restart-the-scene primitive that returns the world to an empty state (``body_count == 0``) WITHOUT discarding the world object, its configured :attr:`gravity`, or its backend. Per-step edge-diff buffers (contacts / overlaps) and any warm-start cache are reset so the next step starts from a clean broadphase. Cached shape handles stay valid (shapes are reusable resources), and handle counters keep advancing so a freed handle is never re-issued to a new body. After :meth:`clear`, callers using the bulk readers must re-:meth:`register_bodies`. """
# -- stepping -----------------------------------------------------------
[docs] @abstractmethod def step(self, dt: float) -> None: """Advance the whole world once by a fixed timestep. This integrates every body, resolves collisions, and updates internal state for the entire world as a unit. It is designed to be driven by a fixed-step accumulator (``SceneTree.physics_tick``). Args: dt: Fixed timestep in seconds. Callers must pass a constant value. """
# -- collision events (broadphase-diffed edges) ------------------------
[docs] @abstractmethod def drain_contact_events(self) -> list[ContactEvent]: """Return and CLEAR this step's buffered enter/exit contact events. Edge-only and broadphase-driven: the backend diffs the touching-pair set each :meth:`step` and buffers a :class:`ContactEvent` for every pair that began (``ENTER``) or stopped (``EXIT``) overlapping. Returns ``[]`` when nothing changed. Node-agnostic: events are keyed by body handles only; the tree maps handles to nodes and fires the Signals. Both static-dynamic AND dynamic-dynamic pairs are reported, filtered by the same layer/mask rule the simulation uses. """
[docs] @abstractmethod def drain_overlap_events(self) -> list[OverlapEvent]: """Return and CLEAR this step's buffered sensor-overlap enter/exit events. Edge-only, broadphase-driven, DIRECTED (keyed ``sensor -> other``), filtered by the one-directional sensor rule (``sensor.mask & other.layer``), node-agnostic. Distinct from :meth:`drain_contact_events`: a sensor pair produces NO collision response and NO manifold, so the event carries only the two handles plus the :class:`ContactPhase`. Returns ``[]`` when nothing changed. """
# -- bulk transfer (the keystone) --------------------------------------
[docs] @abstractmethod def register_bodies(self, handles: list[BodyHandle]) -> None: """Fix the body->row order used by the bulk readers. Establishes the mapping from each body handle to a row index. After this call, :meth:`read_transforms` / :meth:`read_velocities` fill row ``i`` with the state of ``handles[i]``. Call again whenever membership or desired ordering changes. Args: handles: Ordered list of body handles. ``len(handles)`` is the row count ``N`` expected by the bulk readers. """
[docs] @abstractmethod def read_transforms(self, out: np.ndarray) -> None: """Fill ``out`` with current body transforms, in place. Bulk hot-path read. The backend writes into the caller-owned buffer and allocates nothing. Args: out: Pre-allocated array of shape ``(N, 7)``, dtype ``float32``, C-contiguous, where ``N`` matches the most recent :meth:`register_bodies`. Each row is ``[px, py, pz, qx, qy, qz, qw]``: position xyz followed by an orientation quaternion in **xyzw** order (scalar-last). Row ``i`` corresponds to ``handles[i]``. """
[docs] @abstractmethod def read_velocities(self, out: np.ndarray) -> None: """Fill ``out`` with current body velocities, in place. Bulk hot-path read. The backend writes into the caller-owned buffer and allocates nothing. Args: out: Pre-allocated array of shape ``(N, 6)``, dtype ``float32``, C-contiguous, where ``N`` matches the most recent :meth:`register_bodies`. Each row is ``[lx, ly, lz, ax, ay, az]``: linear velocity xyz followed by angular velocity xyz (radians/s). Row ``i`` corresponds to ``handles[i]``. """
# -- queries (minimal) --------------------------------------------------
[docs] @abstractmethod def raycast( self, origin: Vec3, direction: Vec3, max_dist: float, *, mask: int = 0xFFFFFFFF, ) -> RaycastHit | None: """Cast a ray and return the nearest hit, or ``None``. Args: origin: Ray origin in world space (``Vec3``). direction: Ray direction (``Vec3``); need not be normalised. max_dist: Maximum distance along ``direction`` to test. mask: Query layer mask. Only bodies whose ``collision_layer & mask`` is non-zero are considered. Defaults to all layers. This is the single query-mask convention (one query mask vs each body's layer), distinct from the bidirectional body-pair rule used by the simulation. Returns: A :class:`RaycastHit` for the closest body intersected within ``max_dist`` whose layer matches ``mask``, or ``None`` if the ray hits nothing. """
[docs] @abstractmethod def raycast_all( self, origin: Vec3, direction: Vec3, max_dist: float, *, mask: int = 0xFFFFFFFF, ) -> list[RaycastHit]: """Cast a ray and return EVERY hit within ``max_dist``, sorted by distance. Like :meth:`raycast` but collects all intersected bodies (whose ``collision_layer & mask`` is set) instead of only the nearest, returned ascending by :attr:`RaycastHit.distance`. Empty list on no hit. Backs ``PhysicsQuery.raycast_all`` and the ``exclude=`` filter path of ``raycast`` (which must skip excluded nearer hits). Args: origin: Ray origin in world space (``Vec3``). direction: Ray direction (``Vec3``); need not be normalised. max_dist: Maximum distance along ``direction`` to test. mask: Query layer mask (single query-mask vs body-layer convention). Returns: All :class:`RaycastHit`\\ s within ``max_dist``, sorted ascending by distance (empty if the ray hits nothing). """
[docs] @abstractmethod def shapecast( self, shape: ShapeHandle, origin: Vec3, direction: Vec3, max_dist: float, *, mask: int = 0xFFFFFFFF, ) -> SweepHit | None: """Sweep a shape along a ray and return the earliest-TOI contact, or ``None``. Sweeps ``shape`` from ``origin`` along ``direction`` (need not be normalised) up to ``max_dist`` against world bodies, returning the earliest time-of-impact contact among bodies whose ``collision_layer & mask`` is set, else ``None``. Reuses :class:`SweepHit`: the swept shape is the implicit caller, ``body`` is the hit body, ``normal`` is the separating normal pointing back toward the cast origin, and ``distance`` is the TOI distance along ``direction``. Basic-tier honesty: on the builtin backend this is a substepped sweep, not a true continuous cast, so fast casts vs very thin colliders can tunnel and box orientation is ignored (AABB), matching :meth:`sweep_body`. Args: shape: An opaque shape handle from :meth:`create_sphere`, :meth:`create_box`, :meth:`create_capsule`, or :meth:`create_cylinder`. origin: Cast origin in world space (``Vec3``). direction: Cast direction (``Vec3``); need not be normalised. max_dist: Maximum sweep distance along ``direction``. mask: Query layer mask (single query-mask vs body-layer convention). Returns: The earliest-TOI :class:`SweepHit`, or ``None`` if nothing was hit. """
[docs] @abstractmethod def overlap(self, shape: ShapeHandle, transform: Any, *, mask: int = 0xFFFFFFFF) -> list[BodyHandle]: """Return all bodies a static shape overlaps at ``transform``. Places ``shape`` at ``transform`` (same flexible forms as :meth:`create_body`) and returns the handles of every body it overlaps whose ``collision_layer & mask`` is set, sorted by handle for determinism. Basic-tier honesty: AABB-ish narrowphase, box orientation ignored, like :meth:`sweep_body`. Every body in the table is visible here, including a KINEMATIC character body. Args: shape: An opaque shape handle from :meth:`create_sphere`, :meth:`create_box`, :meth:`create_capsule`, or :meth:`create_cylinder`. transform: World pose to place the shape at (same flexible forms as :meth:`create_body`). mask: Query layer mask (single query-mask vs body-layer convention). Returns: Sorted list of overlapping body handles (empty if none). """
# -- kinematic sweep ----------------------------------------------------
[docs] @abstractmethod def sweep_body( self, handle: BodyHandle, motion: Vec3, *, from_transform: tuple[Vec3, Quat] | None = None, skin: float = 0.0, ) -> SweepHit | None: """Cast a body's shape along ``motion`` and report the first blocker. The one non-mutating sweep primitive: it does NOT move ``handle`` and does not mutate any other body, so a caller can run a whole collide-and-slide loop against it while tracking the pose itself. A body blocks the cast only when ALL of the following hold: it is not the mover itself; it is not a sensor (a sensor is excluded from collision resolution, so it never blocks a sweep); the pair passes the canonical AND rule ``(mover.mask & other.layer) and (other.mask & mover.layer)``; in 2D the one-way filter does not reject it; and the contact OPPOSES the cast direction (``dot(motion_dir, separating_normal) < -1e-4``), so a surface the mover already rests on does not halt a tangential cast. A contact reported at distance zero must still carry a true surface normal, never the cast axis: a caller classifies floor / wall / ceiling from that normal, and an exact-sweep backend whose narrowphase degenerates at zero separation has to adjudicate the case rather than pass the axis on. Args: handle: Body handle of the mover. motion: World-space displacement to sweep along (``Vec3``); the sweep length is ``|motion|``. from_transform: Optional ``(position, orientation)`` pose to cast from instead of the body's own stored pose. A complete pose, not a bare position, so a caller tracking a pose in Python never needs a second read-back to recover the orientation. skin: Contact clearance to leave at the blocker, in world units. A backend with an exact time-of-impact sweep subtracts it; a substepped backend ignores it (see :attr:`SweepHit.distance`). Returns: The nearest blocking :class:`SweepHit`, or ``None`` when the path is clear. """
[docs] def move_and_collide(self, handle: BodyHandle, motion: Vec3) -> SweepHit | None: """Move a kinematic body by ``motion``, stopping at the first contact. Concrete composition of :meth:`sweep_body` and :meth:`set_body_transform`, identical on every backend: sweep, then advance to exactly ``SweepHit.distance`` along ``motion`` (the full ``motion`` when the path is clear). Does NOT slide and does NOT integrate gravity: one sweep, stop at the first blocker. The collide-and-slide policy for a character body lives in ``simvx.core.physics.slide``, not here. Args: handle: A body created with :attr:`BodyMode.KINEMATIC`. motion: World-space displacement (``Vec3``), already ``velocity * dt`` (this call takes a displacement, so ``dt`` lives in the caller). Returns: A :class:`SweepHit` (other body, world point, separating normal, guaranteed-clear distance) if the sweep was blocked, else ``None`` after moving the full ``motion``. """ # asarray, not Vec3(*motion): a Vec3 is already a float32 (3,) 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(Vec3, np.asarray(motion, dtype=np.float32)) dist = math.sqrt(float(m[0] * m[0] + m[1] * m[1] + m[2] * m[2])) 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
# -- body read-back -----------------------------------------------------
[docs] @abstractmethod def body_transform(self, handle: BodyHandle) -> tuple[Vec3, Quat]: """Read a body's current pose as ``(position, orientation)``. The cold per-body pose read, parallel to :meth:`body_velocity`: returns plain ``Vec3`` / ``Quat`` so callers get a clean synchronous read-back after a user-driven :meth:`move_and_collide` without the bulk path. Args: handle: Body handle. Returns: ``(position, orientation)``. """
# -- 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 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, 7)``, ``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, 7): raise ValueError(f"read_transforms out must be ({n}, 7), 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, 6)``, ``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, 6): raise ValueError(f"read_velocities out must be ({n}, 6), 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", "Capability", "CombineMode", "PhysicsMaterial", "DEFAULT_LINEAR_DAMPING", "DEFAULT_ANGULAR_DAMPING", "DEFAULT_GRAVITY_SCALE", "DEFAULT_SOLVER_ITERATIONS", "DEFAULT_POSITION_ITERATIONS", "DEFAULT_SLEEP_TIME", "DEFAULT_SLEEP_VELOCITY", "DEFAULT_CONTACT_SLOP", "normalise_damping", "normalise_gravity", "normalise_gravity_scale", "RaycastHit", "SweepHit", "ContactPhase", "ContactEvent", "OverlapEvent", "PhysicsWorld", "normalise_body_scale", "is_unit_scale", "body_scale_unchanged", "BodyHandle", "ShapeHandle", "JointHandle", ]