simvx.core.physics.capability

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.

Capability gate: the small, dimension-agnostic feature-advertisement enum.

Module Contents

Classes

Capability

A feature a backend may or may not honour, advertised via capabilities().

Functions

contact_impulse_estimate

The portable contact impulse: what it would take to arrest the approach.

contact_manifold_payload

The point and rel_velocity one contact ENTER publishes.

Data

API

class simvx.core.physics.capability.Capability[source]

Bases: enum.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.

Initialization

Initialize self. See help(type(self)) for accurate signature.

DETERMINISTIC

‘deterministic’

VEHICLES

‘vehicles’

SOFT_BODY

‘soft_body’

CONTACT_IMPULSE

‘contact_impulse’

CONTINUOUS

‘continuous’

SLEEP

‘sleep’

SENSOR_DETECTS_STATIC

‘sensor_detects_static’

__new__(*values)
__add__()
__contains__()
__delattr__()
__dir__()
__eq__()
__format__()
__ge__()
__getattribute__()
__getitem__()
__getnewargs__()
__getstate__()
__gt__()
__hash__()
__iter__()
__le__()
__len__()
__lt__()
__mod__()
__mul__()
__ne__()
__reduce__()
__reduce_ex__()
__repr__()
__rmod__()
__rmul__()
__setattr__()
__sizeof__()
__str__()
__subclasshook__()
capitalize()
casefold()
center()
count()
encode()
endswith()
expandtabs()
find()
format()
format_map()
index()
isalnum()
isalpha()
isascii()
isdecimal()
isdigit()
isidentifier()
islower()
isnumeric()
isprintable()
isspace()
istitle()
isupper()
join()
ljust()
lower()
lstrip()
partition()
removeprefix()
removesuffix()
replace()
rfind()
rindex()
rjust()
rpartition()
rsplit()
rstrip()
split()
splitlines()
startswith()
strip()
swapcase()
title()
translate()
upper()
zfill()
__deepcopy__(memo)
__copy__()
name()
value()
simvx.core.physics.capability.contact_impulse_estimate(linear_rel_velocity: numpy.ndarray, normal: numpy.ndarray, inv_mass_a: float, inv_mass_b: float) float[source]

The portable contact impulse: what it would take to arrest the approach.

Attr:

Capability.CONTACT_IMPULSE’s degradation path, and the reason that capability is a contract rather than a typed docstring. Every backend fills impulse_estimate on every contact ENTER with this, from the two quantities the seam already carries – the pre-solve linear velocities and the pair’s masses – so a game that keys impact sounds, screen shake or damage off a collision reads a number of the same KIND on a backend that measures its solver’s applied impulse and on one that cannot, instead of reading nothing at all.

linear_rel_velocity is the difference of the two bodies’ LINEAR velocities, b minus a, and NOT the at-point

Attr:

~simvx.core.physics.world.ContactEvent.rel_velocity the same event publishes. That is the whole of what makes the number portable: the at-point velocity depends on where in the contact patch each narrow phase puts its point, which is a per-backend choice with no right answer, so a spinning crate’s estimate computed from it comes out several times apart across backends while the crate itself is doing the same thing everywhere. The linear difference has no such freedom. Spin is not lost by this – it is published, at full fidelity and at the contact point, in rel_velocity itself, which is where a caller who wants a skid or a grind reads it.

normal points a -> b, the seam’s own orientation, so an approaching pair has a negative projection. Inverse masses are 0.0 for anything that cannot be pushed (STATIC, KINEMATIC), which makes the reduced mass the moving body’s own, exactly as an impulse solver would.

Deliberately blind to the solver’s work, which is what makes it portable:

  • It ignores restitution, so a bouncy pair really exchanges up to (1 + e) times this. Combining two materials’ restitution is a per-backend rule with its own rest thresholds, and folding that in would reintroduce the divergence the estimate exists to remove.

  • It is 0.0 for a pair that is not approaching, which includes a body settling onto ground that is itself falling, and a resting stack pressing down. Those contacts carry a real solver impulse where the backend measures one; the estimate reports the impact that is not happening.

  • It ignores spin, for the reason above. What survives is the spread in the linear velocities themselves, which is the spread in how far each backend had let the pair fall before it called them touching: fractions of a percent, pinned by the contract suites’ test_the_estimate_agrees_across_the_backends_on_a_straight_impact.

Every backend feeds it the same quantity, the two Jolt lanes included: their contact listeners read the linear velocities off the LOCKED bodies (JPH_Body_GetLinearVelocity on desktop, Body.GetLinearVelocity in the web shim), which take no lock of their own, where the same read through the body interface deadlocks the step. Measured on a 2 m/s crate landing flat while spinning at 2 rad/s – the case where the arms genuinely place the contact point half a crate apart – the builtin solver and both Jolt lanes agree to well inside _ESTIMATE_TOL, and test_physics_contract_3d.py::test_a_spinning_crate_s_estimate_is_portable_across_every_arm measures it rather than assuming it.

Args: linear_rel_velocity: Pre-solve difference of the two bodies’ linear velocities, b minus a (Vec2 / Vec3). Not the event’s at-point rel_velocity. normal: Unit contact normal oriented a -> b. inv_mass_a: 1 / mass of a, or 0.0 if it cannot be pushed. inv_mass_b: 1 / mass of b, or 0.0 if it cannot be pushed.

Returns: The non-negative impulse magnitude, in N*s. 0.0 for a separating pair, and 0.0 when neither body can be pushed.

simvx.core.physics.capability.contact_manifold_payload(witness: simvx.core.physics.capability.contact_manifold_payload.VecT | None, at_point_rel_velocity: simvx.core.physics.capability.contact_manifold_payload.VecT | None, linear_rel_velocity: simvx.core.physics.capability.contact_manifold_payload.VecT) tuple[simvx.core.physics.capability.contact_manifold_payload.VecT, simvx.core.physics.capability.contact_manifold_payload.VecT][source]

The point and rel_velocity one contact ENTER publishes.

The seam’s single answer to a narrow phase that reports a touching pair and no contact point with it. Every backend that can produce such a manifold routes its payload through here, so “there is no point here” is answered once rather than once per lane.

A manifold with a point publishes it, and the velocity measured AT it. A manifold without one publishes the zero vector – the same degenerate point the seam already publishes on EXIT – and the difference of the two bodies’ LINEAR velocities, which is what the at-point value reduces to for a pair whose spin has nowhere to act. The alternatives were measured against that: reading each body’s point velocity at the world origin is not an approximation of anything (a spinning body reports a surface speed that grows with its distance from the origin), and inventing a point between the two centres would be a third answer to a question the seam has already answered one field above.

Args: witness: The manifold’s world-space contact point, or None where the manifold carries none. at_point_rel_velocity: Velocity of b w.r.t. a at witness, spin included. Unread, and may be None, when witness is None: there was nowhere to measure it. linear_rel_velocity: Difference of the two bodies’ linear velocities, b minus a. The fallback, and always available.

Returns: (point, rel_velocity) for the event, in the caller’s own vector type.

simvx.core.physics.capability.__all__

[‘Capability’, ‘contact_impulse_estimate’, ‘contact_manifold_payload’]