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