Source code for simvx.core.physics.backends

"""Backend selection: the single place a physics backend is chosen.

Precedence (most specific wins)
-------------------------------
1. An explicit ``PhysicsRoot(backend=...)`` override on the node.
2. The project / App-level ``physics_backend`` setting (``GeneralConfig``,
   ``.simvx/config.json``).
3. An auto-discovered installed native backend package.
4. The pure-Python :class:`~simvx.core.physics.builtin.BuiltinPhysics` default.

Only the resolution lives here; the *consumers* (``PhysicsRoot`` and the tree's
default world) call :func:`resolve_world_factory` / :func:`resolve_world_factory_2d`
to get a ready-to-call :data:`~simvx.core.physics.root.WorldFactory` for the
chosen backend. This keeps backend choice out of the resolution walk in
``root.py`` (which only finds *which* world a node belongs to, never *which kind*).

Auto-discovery (precedence 3)
-----------------------------
A native backend self-registers on import, mirroring the engine's miniaudio
"installed -> used" model. The probe (:func:`_try_import_native_backends`) unions
two routes: (a) the in-core pymunk (Chipmunk2D) 2D module, imported directly, and
(b) any out-of-tree package advertising a ``simvx.physics.backends`` entry point
(e.g. ``simvx-physics-jolt``), discovered via :mod:`importlib.metadata` and
``register()``-ed. Either route calls :func:`register_backend` to add itself to
:data:`_REGISTRY`; once registered a backend is both auto-discoverable (3) and
addressable by name (2 / 1). When no native backend is installed the
registry holds only ``"builtin"`` and every level collapses to Builtin.

Auto-discovery (3) never wins over an explicit Builtin choice: an explicitly
requested ``"builtin"`` resolves at 1 / 2 before 3 is consulted, so an
installed native backend is opt-in for the default, not a silent replacement.

A backend further controls whether auto-discovery may pick it *at all* via
:attr:`BackendEntry.auto_default`. pymunk follows the "installed -> used" model
(``auto_default=True``) and so wins auto-discovery for 2D; a gameplay-affecting
3D solver like Jolt registers ``auto_default=False``: it stays name-addressable
(1 / 2) but installing it does not change the default 3D backend.

Auto-discovery is PER DIMENSION
-------------------------------
The registry is one flat namespace but a backend may implement only one half of
it, so :func:`_auto_discovered_native` takes the dimension it is choosing for and
never offers a backend that cannot serve it. Without that, a machine with pymunk
installed picks ``"pymunk"`` for a 3D world, finds no 3D factory, and falls back
to Builtin while warning about a choice nothing asked for; and a 3D-only backend
that registered second could never win 3D at all, because the one flat answer is
registration order and pymunk is imported first.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING

from ..math import Vec2, Vec3

if TYPE_CHECKING:
    from .root import WorldFactory, WorldFactory2D

log = logging.getLogger(__name__)

# The canonical name of the always-present pure-Python default backend.
BUILTIN = "builtin"

# Entry-point group out-of-tree native backend packages advertise to self-register.
_BACKEND_ENTRY_POINT_GROUP = "simvx.physics.backends"


[docs] @dataclass(frozen=True, slots=True) class BackendEntry: """A registered physics backend: its name + 3D / 2D world factories. A native backend registers one of these via :func:`register_backend`. A backend may support only one dimension; the missing factory is ``None`` and resolution for that dimension falls through to Builtin rather than failing, so a 3D-only native backend never breaks a 2D scene. The fall-through warns when the backend was explicitly requested (node override or project setting) and logs at debug from auto-discovery, which has no business picking a backend for a dimension it cannot serve. Attributes: name: The token used in ``physics_backend`` config / ``PhysicsRoot(backend=)``. world_factory: Builds a 3D world for a given gravity, or ``None``. world_factory_2d: Builds a 2D world for a given gravity, or ``None``. A missing factory does not fail resolution for that dimension: an explicitly requested backend degrades to Builtin with a warning, and from auto-discovery it degrades silently, since a backend that cannot serve the dimension is not a choice for it. native: ``True`` for an installed native backend (auto-discoverable), ``False`` for the always-present Builtin (never auto-selected over a native one; it is the final fallback). auto_default: When ``True`` (the miniaudio "installed -> used" model, used by pymunk), the backend may win auto-discovery and become the default for every dimension it serves. When ``False`` the backend is still name-addressable (via a node override or the project setting) but never auto-selected: installing it does NOT change the default backend. A gameplay-affecting 3D solver swap (Jolt) sets this ``False`` so it is strictly explicit opt-in. """ name: str world_factory: WorldFactory | None world_factory_2d: WorldFactory2D | None native: bool = True auto_default: bool = True
# The backend registry. Seeded with Builtin (lazily, see ``_ensure_seeded``) so # the dict is never empty. A native backend adds itself via ``register_backend``. _REGISTRY: dict[str, BackendEntry] = {} _seeded = False def _builtin_world_factory(gravity: Vec3) -> object: """Build the Builtin 3D world (imported lazily to avoid import cycles).""" from .builtin import BuiltinPhysics return BuiltinPhysics(gravity=gravity) def _builtin_world_factory_2d(gravity: Vec2) -> object: """Build the Builtin 2D world (imported lazily to avoid import cycles).""" from .builtin.world2d import BuiltinPhysics2D return BuiltinPhysics2D(gravity=gravity) def _ensure_seeded() -> None: """Register the always-present Builtin entry once.""" global _seeded if _seeded: return _REGISTRY[BUILTIN] = BackendEntry( name=BUILTIN, world_factory=_builtin_world_factory, # type: ignore[arg-type] world_factory_2d=_builtin_world_factory_2d, # type: ignore[arg-type] native=False, ) _seeded = True
[docs] def register_backend(entry: BackendEntry) -> None: """Register a physics backend so it is name-addressable and auto-discoverable. The single plug point for a future native backend (Jolt, pymunk): the native package calls this on import to add itself, mirroring miniaudio's "installed -> used" model. Re-registering the same name replaces the entry (idempotent for re-import). ``"builtin"`` is reserved as the fallback name. Args: entry: The :class:`BackendEntry` to register. Raises: ValueError: If ``entry.name`` is ``"builtin"`` (reserved) or empty. """ if not entry.name or entry.name == BUILTIN: raise ValueError(f"backend name must be non-empty and not {BUILTIN!r}") _ensure_seeded() _REGISTRY[entry.name] = entry
def _try_import_native_backends() -> None: """Best-effort registration of every installed native backend (UNION of routes). The auto-discovery "installed -> used" probe (mirrors miniaudio): a native backend self-registers, so running this probe is what makes it auto-discoverable. Two routes are unioned and each is independently guarded -- a missing optional dependency is the silent, expected fallback to Builtin, not an error: (a) **In-core module** -- the pymunk (Chipmunk2D) 2D backend, imported directly for its self-registering side-effect. (b) **Entry points** -- out-of-tree packages (e.g. ``simvx-physics-jolt``) that advertise a ``simvx.physics.backends`` entry point; each is loaded and its ``register()`` called. De-duplication is intrinsic: ``register_backend`` is idempotent (keyed by name), so a backend reachable by both routes registers once. The probe runs once per resolution; re-import is cheap (Python caches the module) and re-registration is a no-op replace. """ # Route (a): in-core pymunk 2D backend (kept; not entry-point based). try: from . import pymunk_backend # noqa: F401 (import side-effect: self-registers) except ImportError: pass # pymunk not installed: silently fall through to other routes / Builtin # Route (b): out-of-tree native backend packages via entry points. from importlib.metadata import entry_points for ep in entry_points(group=_BACKEND_ENTRY_POINT_GROUP): try: ep.load()() # load the register() callable and call it (self-registers) except Exception as exc: # noqa: BLE001 (any load/register failure -- ImportError included -- is non-fatal) # A broken or partially-installed native backend must never crash backend # resolution; it degrades to the next route / Builtin. Logged at debug # so a developer can see why a backend they expected did not appear. log.debug("physics backend entry point %r failed to register: %s", getattr(ep, "name", ep), exc) def _auto_discovered_native(dimension: int | None = None) -> str | None: """Name of an auto-discovered native backend for ``dimension``, or ``None``. Precedence level 3. First probe the known optional native modules (a successful import self-registers the backend), then return a registered ``native`` entry that may win auto-discovery. When no optional native backend is installed the probe is a no-op and this returns ``None``, so resolution falls through to Builtin. ``dimension`` is 2, 3, or ``None`` for the dimension-agnostic answer. A backend that does not serve the requested dimension is not a candidate for it: the registry holds one flat namespace but a backend may implement only one half of it, and picking a 2D-only backend for a 3D world resolves to Builtin with a warning about a choice nothing asked for. The ``None`` arm keeps REGISTRATION ORDER, which is not a stylistic choice: it has public callers whose answer must not change, and a 2D-only native still legitimately wins the dimension-agnostic name. Within a dimension the tie-break is sorted, so the day a second backend serves one the answer is deterministic and announced. """ _ensure_seeded() _try_import_native_backends() candidates = [ name for name, entry in _REGISTRY.items() if entry.native and entry.auto_default and not (dimension == 3 and entry.world_factory is None) and not (dimension == 2 and entry.world_factory_2d is None) ] if not candidates: return None if dimension is None: return candidates[0] if len(candidates) > 1: log.warning( "several auto-default backends serve %dD: %s; taking %r. Name one explicitly to settle it.", dimension, ", ".join(sorted(candidates)), sorted(candidates)[0], ) return sorted(candidates)[0] return candidates[0]
[docs] def resolve_backend_name(explicit: str | None, setting: str | None, *, dimension: int | None = None) -> str: """Resolve the backend NAME by precedence: explicit > setting > auto > builtin. Args: explicit: A ``PhysicsRoot(backend=...)`` override, or ``None`` if unset. setting: The project/App ``physics_backend`` config value, or ``None`` / empty if unset. dimension: 2 or 3 to restrict auto-discovery to backends that serve that dimension, or ``None`` for the dimension-agnostic answer. It affects level 3 only: an explicit name or project setting is honoured whatever it can serve, so a backend named for a dimension it does not implement still reaches the warned fall-through in :func:`resolve_world_factory`. Returns: The resolved backend name (a key of :data:`_REGISTRY`). An explicit / setting value that names an unknown backend logs a warning and falls back to the next level (auto, then Builtin) rather than raising: a missing optional native backend degrades to Builtin, it does not crash the game. """ name, _tier = _resolve_name_tier(explicit, setting, dimension=dimension) return name
def _resolve_name_tier(explicit: str | None, setting: str | None, *, dimension: int | None = None) -> tuple[str, str]: """Resolve the backend name and the precedence tier that won. The precedence walk behind :func:`resolve_backend_name`, kept separate because the factory resolvers need the tier as well as the name: a dimension the chosen backend cannot serve is a designed fall-through to Builtin when auto-discovery made the choice (not news, logged at debug) but an explicit mis-request when a node override or project setting named it (warned). Returns: ``(name, tier)`` where *tier* is ``"explicit"``, ``"setting"``, ``"auto"`` or ``"builtin"``. """ _ensure_seeded() # A name explicitly requested (node override or project setting) may be an optional native backend # whose module has not been imported yet. Probe the known native modules first # so a self-registering backend is name-addressable, mirroring auto-discovery. if explicit or setting: _try_import_native_backends() # 1: explicit node override. if explicit: if explicit in _REGISTRY: return explicit, "explicit" log.warning("PhysicsRoot(backend=%r) not installed; falling back", explicit) # 2: project / App setting. if setting: if setting in _REGISTRY: return setting, "setting" log.warning("physics_backend=%r not installed; falling back", setting) # 3: auto-discovered native backend, restricted to those serving `dimension`. auto = _auto_discovered_native(dimension) if auto is not None: return auto, "auto" # 4: Builtin default. return BUILTIN, "builtin" def _project_backend_setting() -> str | None: """Read the resolved ``physics_backend`` setting from project/user config. The source for precedence level 2. Loads :class:`~simvx.core.config.AppConfig` overlaid with any ``.simvx/config.json`` for the current working directory (the project root at runtime), returning ``general.physics_backend`` or ``None`` if unset. Imported lazily so the physics package has no module-level config dependency. """ from pathlib import Path from ..config import AppConfig cfg = AppConfig() cfg.load_with_project(Path.cwd()) return cfg.general.physics_backend or None
[docs] def resolve_world_factory(explicit: str | None = None) -> WorldFactory: """Return the 3D :data:`WorldFactory` for the resolved backend. Applies the full precedence (explicit > project setting > auto > Builtin) and returns the chosen backend's 3D factory. If the chosen backend has no 3D factory (a 2D-only native backend), falls back to Builtin's 3D factory, warning when the backend was explicitly requested (a node override or project setting naming it) and logging at debug from auto-discovery, where a backend that cannot serve 3D is not a 3D choice at all. Args: explicit: A ``PhysicsRoot(backend=...)`` override, or ``None``. Returns: A callable ``(gravity: Vec3) -> PhysicsWorld``. """ name, tier = _resolve_name_tier(explicit, _project_backend_setting(), dimension=3) entry = _REGISTRY[name] if entry.world_factory is None: if tier == "auto": log.debug("backend %r has no 3D world; using builtin", name) else: log.warning("backend %r has no 3D world; using builtin", name) return _REGISTRY[BUILTIN].world_factory # type: ignore[return-value] return entry.world_factory
[docs] def resolve_world_factory_2d(explicit: str | None = None) -> WorldFactory2D: """Return the 2D :data:`WorldFactory2D` for the resolved backend. 2D sibling of :func:`resolve_world_factory`. Falls back to Builtin's 2D factory when the chosen backend has no 2D factory (a 3D-only native backend), with the same tier-dependent logging: warned for an explicit request, debug from auto-discovery. Args: explicit: A ``PhysicsRoot2D(backend=...)`` override, or ``None``. Returns: A callable ``(gravity: Vec2) -> Physics2DWorld``. """ name, tier = _resolve_name_tier(explicit, _project_backend_setting(), dimension=2) entry = _REGISTRY[name] if entry.world_factory_2d is None: if tier == "auto": log.debug("backend %r has no 2D world; using builtin", name) else: log.warning("backend %r has no 2D world; using builtin", name) return _REGISTRY[BUILTIN].world_factory_2d # type: ignore[return-value] return entry.world_factory_2d
__all__ = [ "BUILTIN", "BackendEntry", "register_backend", "resolve_backend_name", "resolve_world_factory", "resolve_world_factory_2d", ]