Source code for simvx.core.physics.root

"""PhysicsRoot: branch-isolating physics node + node->world resolution.

Role
----
A ``PhysicsRoot`` node carves its sub-branch of the scene tree into a separate,
isolated :class:`~simvx.core.physics.world.PhysicsWorld`. Bodies resolve their
world by walking up to the nearest ``PhysicsRoot`` ancestor; if there is none,
they fall back to a default world (one per scene tree). Nested roots: the
innermost wins. This mirrors the scoping pattern the codebase already uses and
is the future unit of cross-world parallelism.

The factory used to build a world for a root (and for the default) is pluggable
so a backend (``BuiltinPhysics`` now, ``JoltPhysics`` later) can be selected
without changing the resolution logic. Which backend a root builds is decided by
:mod:`simvx.core.physics.backends` (precedence: explicit ``PhysicsRoot(backend=)``
> project ``physics_backend`` setting > auto-discovered native > Builtin); a
direct ``factory=`` argument is the test escape hatch that bypasses precedence.

Stage 2 status
--------------
The new seam is now wired into ``SceneTree.physics_tick``. World ownership is
SceneTree-owned: the default world (for bodies with no ``PhysicsRoot``
ancestor) lives on the tree as ``tree.physics_world`` (lazy, tree-scoped,
survives ``change_scene`` like the event bus), and each ``PhysicsRoot``
registers its own isolated world with the tree on enter and unregisters +
drops it on exit, so no worlds leak on teardown.

The sole provisional remnant is the **treeless fallback**: a single
module-level default world used only when :func:`resolve_world` is given a node
with no ``SceneTree`` (standalone unit tests that call ``step()`` manually).
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

from ..math import Vec2, Vec3
from ..node import Node
from .world import PhysicsWorld

if TYPE_CHECKING:
    from .world2d import Physics2DWorld

# Factory signature: build a fresh world for a given gravity. Kept pluggable so
# the backend is selected here, not in resolution. Defaults to BuiltinPhysics.
WorldFactory = Callable[[Vec3], PhysicsWorld]

# 2D factory signature (Vec2 gravity, Physics2DWorld result). Separate alias so
# the 2D backend is selected here, not in resolution. Defaults to BuiltinPhysics2D.
WorldFactory2D = Callable[[Vec2], "Physics2DWorld"]

# Default gravity for a PhysicsRoot's isolated world. Earth-like, -Y up.
_DEFAULT_GRAVITY = Vec3(0.0, -9.81, 0.0)

# Default gravity for a PhysicsRoot2D's isolated 2D world. Y-up, -Y down.
_DEFAULT_GRAVITY_2D = Vec2(0.0, -9.81)

# Treeless fallback (the sole provisional remnant of Stage 1): a single
# module-level default world returned by resolve_world for a node that has no
# SceneTree, so standalone unit tests calling step() manually still resolve to
# a stable shared world. Not owned by any tree; lives for the process.
_module_default_world: PhysicsWorld | None = None


[docs] def resolve_world(node: Node) -> PhysicsWorld: """Resolve the :class:`PhysicsWorld` a node's bodies belong to. Walks up from ``node`` (inclusive) to the nearest :class:`PhysicsRoot` ancestor and returns that root's world. If no ``PhysicsRoot`` is found, returns the tree's default world (``node.tree.physics_world``), or, for a treeless node, the module-level fallback world. Args: node: The node whose world to resolve. Returns: The resolved :class:`PhysicsWorld` (innermost root wins; tree default otherwise; treeless fallback when the node is not in a tree). """ current: Node | None = node while current is not None: if isinstance(current, PhysicsRoot): return current.world current = current.parent tree = node.tree if tree is None: # Treeless node (standalone unit tests calling step() manually): fall # back to a single module-level default world so resolution still works # without a SceneTree. global _module_default_world if _module_default_world is None: from .backends import resolve_world_factory _module_default_world = resolve_world_factory()(_DEFAULT_GRAVITY) return _module_default_world return tree.physics_world
# Treeless 2D fallback (parity with ``_module_default_world``): a single # module-level default 2D world returned by resolve_world_2d for a node with no # SceneTree, so standalone 2D unit tests calling step() manually still resolve. _module_default_world_2d: Physics2DWorld | None = None
[docs] def resolve_world_2d(node: Node) -> Physics2DWorld: """Resolve the :class:`Physics2DWorld` a node's 2D bodies belong to. The 2D sibling of :func:`resolve_world`. Walks up from ``node`` (inclusive) to the nearest :class:`PhysicsRoot2D` ancestor and returns that root's 2D world. If none is found, returns the tree's default 2D world (``node.tree.physics_world_2d``), or, for a treeless node, the module-level 2D fallback world. Args: node: The node whose 2D world to resolve. Returns: The resolved :class:`Physics2DWorld` (innermost ``PhysicsRoot2D`` wins; tree 2D default otherwise; treeless 2D fallback when not in a tree). """ current: Node | None = node while current is not None: if isinstance(current, PhysicsRoot2D): return current.world_2d current = current.parent tree = node.tree if tree is None: global _module_default_world_2d if _module_default_world_2d is None: from .backends import resolve_world_factory_2d _module_default_world_2d = resolve_world_factory_2d()(_DEFAULT_GRAVITY_2D) return _module_default_world_2d return tree.physics_world_2d
[docs] class PhysicsRoot(Node): """A node that owns an isolated :class:`PhysicsWorld` for its sub-branch. Bodies under a ``PhysicsRoot`` (with no nearer ``PhysicsRoot``) simulate in this node's world, fully isolated from the rest of the tree: independent gravity and stepping. Resolve a node's world with :func:`resolve_world`. The world is created lazily on first access via the configured ``factory`` (defaults to ``BuiltinPhysics``), so constructing the node is cheap and the backend choice stays in one place. Stage 2: this root registers its world with the SceneTree on enter so the fixed-step loop (``SceneTree.physics_tick``) advances it, and unregisters + drops it on exit so nothing leaks across scene swaps or teardown. """ def __init__( self, name: str = "", *, gravity: Vec3 = _DEFAULT_GRAVITY, backend: str | None = None, factory: WorldFactory | None = None, **kwargs: object, ) -> None: """Initialise the physics root. Args: name: Node name (defaults to the class name). gravity: Gravity for this root's isolated world. backend: Explicit backend-name override (arm 1 of the selection precedence: explicit > project ``physics_backend`` setting > auto-discovered native > Builtin). ``None`` (default) defers to the setting / auto / Builtin arms. An unknown name degrades to the next arm with a warning (a missing optional native backend does not crash the game). factory: Direct backend-factory escape hatch. When given it bypasses the ``backend`` precedence entirely and is used verbatim (used by tests to inject a stub world). Defaults to ``None`` -> resolve via ``backend`` precedence. **kwargs: Forwarded to :class:`~simvx.core.node.Node`. """ super().__init__(name, **kwargs) self._gravity: Vec3 = Vec3(*gravity) self._backend: str | None = backend self._factory: WorldFactory | None = factory self._world: PhysicsWorld | None = None
[docs] @property def world(self) -> PhysicsWorld: """This root's isolated world, created lazily on first access. Built from the explicit ``factory`` if one was given, else from the backend resolved by precedence (explicit ``backend`` > project setting > auto-discovered native > Builtin). """ if self._world is None: from .backends import resolve_world_factory factory = self._factory or resolve_world_factory(self._backend) self._world = factory(self._gravity) return self._world
[docs] def on_enter_tree(self) -> None: """Register this root's isolated world with the tree so it gets stepped.""" tree = self.tree if tree is not None: tree.register_physics_world(self.world)
[docs] def on_exit_tree(self) -> None: """Unregister + drop this root's world (no leaked worlds on teardown). Uses ``self._world`` (not the creating ``.world`` property) so exit never builds a world a never-used root never had. Re-entering the tree rebuilds a fresh world lazily and re-registers it; the registry guard makes the re-register idempotent. """ tree = self.tree if tree is not None and self._world is not None: tree.unregister_physics_world(self._world) self._world = None
[docs] class PhysicsRoot2D(PhysicsRoot): """A node that owns an isolated :class:`Physics2DWorld` for its sub-branch. The 2D sibling of :class:`PhysicsRoot` (recommended over a ``dims`` flag, per the design: it matches the taxonomy split and the resolution walk is a plain ``isinstance`` test). 2D bodies under a ``PhysicsRoot2D`` (with no nearer ``PhysicsRoot2D``) simulate in this node's 2D world, isolated from the tree's default 2D world. Resolve a node's 2D world with :func:`resolve_world_2d`. It deliberately does NOT reuse the 3D base's ``_world`` slot / ``world`` property (those build a 3D ``BuiltinPhysics``): a ``PhysicsRoot2D`` owns its own 2D world via :attr:`world_2d`, built lazily by the 2D ``factory`` (defaults to ``BuiltinPhysics2D``), and registers THAT world with the tree. """ def __init__( self, name: str = "", *, gravity: Vec2 = _DEFAULT_GRAVITY_2D, backend: str | None = None, factory: WorldFactory2D | None = None, **kwargs: object, ) -> None: """Initialise the 2D physics root. Args: name: Node name (defaults to the class name). gravity: Gravity (``Vec2``) for this root's isolated 2D world. backend: Explicit backend-name override (arm 1 of the selection precedence; see :class:`PhysicsRoot`). ``None`` defers to the project setting / auto / Builtin arms. factory: Direct 2D backend-factory escape hatch; when given it bypasses the ``backend`` precedence and is used verbatim (test injection). Defaults to ``None`` -> resolve via ``backend`` precedence. **kwargs: Forwarded to :class:`~simvx.core.node.Node`. """ # Bypass the 3D base __init__ (it would build a Vec3 gravity + 3D factory # we never use); initialise Node directly and keep only 2D state. The base # ``world`` property / ``_world`` slot stays unused for a 2D root. Node.__init__(self, name, **kwargs) self._gravity_2d: Vec2 = Vec2(*gravity) self._backend_2d: str | None = backend self._factory_2d: WorldFactory2D | None = factory self._world_2d_obj: Physics2DWorld | None = None
[docs] @property def world_2d(self) -> Physics2DWorld: """This root's isolated 2D world, created lazily on first access. Built from the explicit ``factory`` if one was given, else from the backend resolved by precedence (explicit ``backend`` > project setting > auto-discovered native > Builtin). """ if self._world_2d_obj is None: from .backends import resolve_world_factory_2d factory = self._factory_2d or resolve_world_factory_2d(self._backend_2d) self._world_2d_obj = factory(self._gravity_2d) return self._world_2d_obj
[docs] def on_enter_tree(self) -> None: """Register this root's isolated 2D world with the tree so it gets stepped.""" tree = self.tree if tree is not None: tree.register_physics_world(self.world_2d)
[docs] def on_exit_tree(self) -> None: """Unregister + drop this root's 2D world (no leaked worlds on teardown). Uses ``self._world_2d_obj`` (not the creating ``.world_2d`` property) so exit never builds a world a never-used root never had. Re-entering rebuilds a fresh world lazily and re-registers it (the registry guard is idempotent). """ tree = self.tree if tree is not None and self._world_2d_obj is not None: tree.unregister_physics_world(self._world_2d_obj) self._world_2d_obj = None
__all__ = [ "PhysicsRoot", "PhysicsRoot2D", "resolve_world", "resolve_world_2d", "WorldFactory", "WorldFactory2D", ]