Source code for simvx.core.physics.capability

"""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"
[docs] def contact_impulse_estimate( linear_rel_velocity: np.ndarray, normal: np.ndarray, inv_mass_a: float, inv_mass_b: float ) -> float: """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. """ approach = -float(np.dot(linear_rel_velocity, normal)) if approach <= 0.0: return 0.0 total = inv_mass_a + inv_mass_b return approach / total if total > 0.0 else 0.0
[docs] def contact_manifold_payload[VecT: np.ndarray]( witness: VecT | None, at_point_rel_velocity: VecT | None, linear_rel_velocity: VecT ) -> tuple[VecT, VecT]: """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. """ if witness is None: return np.zeros_like(linear_rel_velocity), linear_rel_velocity assert at_point_rel_velocity is not None, "a manifold with a point must carry the velocity measured at it" return witness, at_point_rel_velocity
__all__ = ["Capability", "contact_impulse_estimate", "contact_manifold_payload"]