"""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]
@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
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_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_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",
]