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