"""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 seam (arm 3)
---------------------------
There is no native backend yet (Jolt / pymunk are future work), so the
auto-discovery arm resolves to Builtin **today**. It is nonetheless the single,
real plug point a future backend registers through, mirroring the engine's
miniaudio "installed -> used" model: a native package, on import, calls
:func:`register_backend` (e.g. from its ``__init__`` or a ``simvx.physics.backends``
entry point) to add itself to :data:`_REGISTRY`; once registered it is both
auto-discoverable (arm 3) and addressable by name (arm 2 / arm 1). Until one
exists, the registry holds only ``"builtin"`` and every arm collapses to Builtin.
"""
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"
[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 (with a warning) rather
than failing, so a 3D-only native backend never breaks a 2D scene.
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``.
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).
"""
name: str
world_factory: WorldFactory | None
world_factory_2d: WorldFactory2D | None
native: 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 import of the known optional native backend modules.
Arm 3's "installed -> used" probe (mirrors miniaudio): a native backend
self-registers on import, so importing its module is what makes it
auto-discoverable. The import is guarded -- a missing optional dependency
(the package was never installed) is the silent, expected fallback to Builtin,
not an error. The probe runs once per resolution; re-import is cheap (Python
caches the module) and ``register_backend`` is idempotent. Today the only
native module is the pymunk (Chipmunk2D) 2D backend.
"""
try:
from . import pymunk_backend # noqa: F401 (import side-effect: self-registers)
except ImportError:
pass # pymunk not installed: silently fall through to Builtin
def _auto_discovered_native() -> str | None:
"""Return the name of an auto-discovered installed native backend, or ``None``.
Arm 3 of the precedence. First probe the known optional native modules (a
successful import self-registers the backend), then return the first registered
``native`` entry. When no optional native backend is installed the probe is a
no-op and this returns ``None``, so resolution falls through to Builtin.
"""
_ensure_seeded()
_try_import_native_backends()
for name, entry in _REGISTRY.items():
if entry.native:
return name
return None
[docs]
def resolve_backend_name(explicit: str | None, setting: str | 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.
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 arm (auto, then Builtin) rather than raising: a missing
optional native backend degrades to Builtin, it does not crash the game.
"""
_ensure_seeded()
# A name explicitly requested (arm 1 / arm 2) 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 the auto arm.
if explicit or setting:
_try_import_native_backends()
# Arm 1: explicit node override.
if explicit:
if explicit in _REGISTRY:
return explicit
log.warning("PhysicsRoot(backend=%r) not installed; falling back", explicit)
# Arm 2: project / App setting.
if setting:
if setting in _REGISTRY:
return setting
log.warning("physics_backend=%r not installed; falling back", setting)
# Arm 3: auto-discovered native backend.
auto = _auto_discovered_native()
if auto is not None:
return auto
# Arm 4: Builtin default.
return BUILTIN
def _project_backend_setting() -> str | None:
"""Read the resolved ``physics_backend`` setting from project/user config.
Arm 2's source. 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.
Args:
explicit: A ``PhysicsRoot(backend=...)`` override, or ``None``.
Returns:
A callable ``(gravity: Vec3) -> PhysicsWorld``.
"""
name = resolve_backend_name(explicit, _project_backend_setting())
entry = _REGISTRY[name]
if entry.world_factory is None:
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).
Args:
explicit: A ``PhysicsRoot2D(backend=...)`` override, or ``None``.
Returns:
A callable ``(gravity: Vec2) -> Physics2DWorld``.
"""
name = resolve_backend_name(explicit, _project_backend_setting())
entry = _REGISTRY[name]
if entry.world_factory_2d is None:
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",
]