"""Capability gate: the small, dimension-agnostic feature-advertisement enum.
Role
----
The strict :class:`~simvx.core.physics.world.PhysicsWorld` ABC is the **parity
contract**: every backend implements the *same* rigid-body methods with the
*same* behaviour, so a node never branches on which backend it got in order to
make a call. A handful of things, however, genuinely depend on the backend, and
those are advertised rather than promised: a node queries
:meth:`PhysicsWorld.capabilities` and degrades / refuses / branches explicitly if
the resolved backend does not list the :class:`Capability` it needs.
Advertisement runs BOTH ways. Some members only a native backend can offer
(bit-exact determinism, vehicle constraints, soft bodies). Others the builtin
pure-Python solver honours and an optional native backend does not, because the
library it wraps exposes no hook for them. The enum takes no side: it names a
fact about the resolved backend, whichever direction that fact points.
A member takes one of three forms:
1. A whole feature the physics world has no methods for at all (``VEHICLES``,
``SOFT_BODY``).
2. A strengthened guarantee about a method every backend already has
(``DETERMINISTIC``, about :meth:`PhysicsWorld.step`; ``SENSOR_DETECTS_STATIC``,
about which bodies reach the overlap stream).
3. A payload field that carries a value only where the backend can measure it
(``CONTACT_IMPULSE``). This is the one place a payload field is
backend-dependent, and it is advertised precisely so the branch is explicit
rather than a surprise at runtime.
**Every member ships a documented degradation path**, one of the two rules the
seam contract states (see :class:`~simvx.core.physics.world.PhysicsWorld`). A
capability a caller cannot degrade around is a typed docstring rather than a
contract, so each member below says what a game does when it is absent, and the
one member whose absence would otherwise leave a caller with nothing --
``CONTACT_IMPULSE`` -- has its fallback published on the event itself, by
:func:`contact_impulse_estimate`.
This is deliberately a *small* additive enum + one method, NOT an audio-style
Protocol-facet split of the whole interface. The physics world stays a single
strict ABC; the capability set is the only place backends diverge in what they
claim. ``Capability`` is dimension-agnostic (shared by 2D and 3D) and lives in
its own module so both ``world.py`` and ``world2d.py`` import it without a cycle.
``Capability`` is re-exported from ``simvx.core.physics`` because games read it.
:func:`contact_impulse_estimate` is not: it is the helper a *backend author*
calls to fill ``ContactEvent.impulse_estimate``, and a game reads the filled
field rather than recomputing it. Import it from this module by its full path.
"""
from __future__ import annotations
from enum import StrEnum
import numpy as np
[docs]
class Capability(StrEnum):
"""A feature a backend may or may not honour, advertised via ``capabilities()``.
Every backend lists only what it actually honours. A node needing one of
these MUST check ``Capability.X in world.capabilities()`` first; a backend
never silently pretends to support a capability it lacks, and never
substitutes a plausible-looking stand-in for a value it cannot measure.
Every member states what to do where it is absent: that degradation path is
part of the contract, not advice.
Members:
DETERMINISTIC: Cross-platform bit-exact stepping (e.g. a Jolt build
compiled with ``JPH_CROSS_PLATFORM_DETERMINISTIC``). Needed for
lockstep netcode / replays; the builtin backend's float maths is
reproducible on one machine but not bit-identical across platforms.
No backend advertises it today. Degradation path: replicate state
rather than input (send positions, not keystrokes), or record a
replay as a transform stream rather than a seed plus an input log.
A single machine replaying its own recording is reproducible
everywhere; only agreement BETWEEN machines needs this.
VEHICLES: Specialised vehicle constraints (wheeled / tracked raycast
vehicles). Not expressible with the basic joint set. No backend
advertises it today. Degradation path: build the vehicle out of the
joints and forces the seam does have (a raycast per wheel plus
``apply_force`` at the contact is the usual shape), which every
backend runs identically.
SOFT_BODY: Deformable soft-body / cloth simulation. Outside the
rigid-body model entirely. No backend advertises it today.
Degradation path: approximate with a lattice of bodies and spring
joints, or animate the deformation outside physics and keep a rigid
collider for the interaction.
SENSOR_DETECTS_STATIC: A sensor built the way ``Area3D`` / ``Area2D``
build one -- ``BodyMode.STATIC``, ``is_sensor=True`` -- reports
``STATIC`` bodies, not only the ones that can move. All five backends
advertise it, and on the three native ones it is the adapter's doing
rather than the library's: both libraries build colliding pairs from
the MOVING side only, so a static sensor is not even searched, and
each adapter therefore holds its sensors ``KINEMATIC`` (the caller's
``BodyMode`` is unchanged, and so is everything the body does: a
kinematic body takes no gravity, force, impulse or torque, and the two
ways a velocity could reach one are both closed against the caller's
mode -- a write to a body the caller made ``STATIC`` while the library
holds it ``KINEMATIC`` is kept away from the library rather than
integrated, and a velocity taken while the caller had the body
``KINEMATIC`` is cleared inside the library when the mode returns to
``STATIC`` (on the two Jolt adapters the seam keeps its own copy,
which is what the caller reads back; the pymunk adapter clears it on
that flip instead and reports ``0``) -- so the promotion cannot set
the sensor moving; a body its library holds static is untouched by
either guard). That
alone is enough on Chipmunk, which then queries the sensor against
the static index; Jolt needs one thing more,
because it forms a pair only when one of the two is ``DYNAMIC`` -- a
kinematic body paired with a sensor being the single exception -- so
the adapters also set ``mCollideKinematicVsNonDynamic`` on every
sensor, which lifts that rule for the pairs the sensor is in.
Measured on a sensor overlapping one box: every backend reports one
``ENTER`` whatever mode the box is in, on Jolt including a body that
is asleep, and one for another static SENSOR too.
**The one exception, on the two Jolt backends: a sensor whose
collider is a MESH.** Mesh colliders are ``STATIC``-only, on the seam
and in Jolt, so such a sensor cannot be held ``KINEMATIC``, drives no
pair search of its own, and the flag has nothing to act on. Measured,
a mesh sensor straddling one box reports it when it is ``KINEMATIC``
or ``DYNAMIC`` and never when it is ``STATIC``; it is blind to
sleepers for the same reason. Swapping convex geometry onto it lifts
both blindnesses, the adapter rebuilding the body to do it.
Degradation path, for that case and for any backend added later
without this: give the body the sensor must find
``BodyMode.KINEMATIC`` instead (it never moves and never falls, so
the simulation is the same), or poll
:meth:`~simvx.core.physics.world.PhysicsWorld.overlap`, which tests
geometry rather than the pair search and reports ``STATIC`` bodies on
all five backends, at the cost of a query per check rather than an
event.
It is not free. A sensor now runs narrow phase against the static
geometry it overlaps, which nothing else in the world pays for, so
the cost scales with (sensors x static bodies each one covers) and
with nothing else. Measured on Jolt, 64 dynamic crates over a carpet
of 900 static tiles: 0.004 ms/step with no sensors either way, then
0.25 -> 0.95 ms/step with four sensors on the carpet and
2.5 -> 9.3 ms/step with thirty-two. On pymunk, 400 static bodies and
100 dynamic: 0.18 ms/step with no sensors either way, then
0.17 -> 1.07 ms/step with a hundred. Call it a four-to-sixfold step
in a sensor-heavy scene (the exact ratio moves with machine load),
and it is the sensors that are paying it -- a scene with a thousand
trigger volumes over dense static geometry should expect to.
CONTACT_IMPULSE: The contact-event stream carries the solver's applied
normal impulse. Governs
:attr:`~simvx.core.physics.world.ContactEvent.impulse` /
:attr:`~simvx.core.physics.world2d.ContactEvent2D.impulse` and the
node-level ``Contact`` the tree builds from them. A backend that
does not advertise it reports ``None`` there, having no hook into
its solver's applied lambda; ``None`` is a different fact from
``0.0``, which is a measured contact the solver resolved with no
impulse (a separating velocity). The builtin 3D / 2D solvers and
pymunk advertise it; both Jolt backends do not. It governs the
contact-event stream ONLY: a kinematic sweep
(``PhysicsBody2D.move_and_collide``, ``sweep_body``) runs no solver
pass at all, so its
:class:`~simvx.core.physics.world.SweepHit` result has no ``impulse``
field to misread. Degradation path: read ``impulse_estimate``, which
every backend publishes on the same event from mass and the two
bodies' LINEAR velocities (see :func:`contact_impulse_estimate`). It
is the impulse that would arrest the approach, computed by one shared
function over one shared quantity everywhere -- portable where
``impulse`` is a different solver's measurement each time -- and it
is deliberately blind to the solver's work: it ignores restitution,
it ignores spin, and it is ``0.0`` for a pair that is not
approaching, including a body settling onto ground that is itself
falling. Note that it is NOT the event's own ``rel_velocity`` fed
through the formula: that field is the at-point velocity, spin and
all, and computing the estimate from it would put each backend's
choice of contact point into the answer. Impact-keyed hit sounds,
screen shake and damage are what it is for; a running measure of how
hard a pile is pressing down is not.
CONTINUOUS: The backend honours continuous collision detection: the
``continuous`` argument to ``create_body`` and the
:meth:`~simvx.core.physics.world.PhysicsWorld.set_body_continuous`
live setter both change how the body integrates. The builtin 3D / 2D
solvers advertise it (a centre sweep against STATIC geometry, clamped
to the time of impact) and both Jolt backends advertise it
(``EMotionQuality::LinearCast``); pymunk does NOT, because Chipmunk2D
has no CCD of any kind, so it accepts the flag and integrates
discretely. Without it a fast small body can tunnel through a thin
collider, which is a silent wrong answer rather than an error, so a
caller that requires it checks here. Degradation path: keep the
per-step motion below the thin collider's thickness, by raising the
fixed-step rate or by capping the body's speed, and where neither is
possible sweep the motion yourself with
:meth:`~simvx.core.physics.world.PhysicsWorld.sweep_body` and place
the body at the reported time of impact.
SLEEP: The backend puts settled bodies to sleep, so the whole sleep
surface is live:
:meth:`~simvx.core.physics.world.PhysicsWorld.sleeping` can report
True, :meth:`~simvx.core.physics.world.PhysicsWorld.sleep` parks a
body, and the ``can_sleep`` body flag and the ``wake=`` argument on
the live-edit setters change what the simulation does. The builtin
3D / 2D solvers and both Jolt backends advertise it; pymunk does NOT,
because Chipmunk2D disables sleeping unless the space is given a
finite sleep-time threshold, and trying to sleep a body in a space
without one aborts the process at the C level rather than raising.
Degradation path: where it is absent nothing ever sleeps, so every
body behaves exactly as an always-awake one. ``sleeping()`` is
permanently False, ``sleep()`` does nothing, and ``wake=False`` is
free of consequence because there is no sleeper to leave undisturbed
-- correct behaviour rather than a stand-in, but never a saving: a
scene that never settles goes on paying to simulate every body on
every step, however long it has been at rest. A caller that
needs a body parked (to stop polling it, or to freeze a finished
pile) checks here and keeps the body awake instead.
"""
DETERMINISTIC = "deterministic"
VEHICLES = "vehicles"
SOFT_BODY = "soft_body"
CONTACT_IMPULSE = "contact_impulse"
CONTINUOUS = "continuous"
SLEEP = "sleep"
SENSOR_DETECTS_STATIC = "sensor_detects_static"
__all__ = ["Capability", "contact_impulse_estimate", "contact_manifold_payload"]