"""Node-level spatial query API for the 2D physics seam (Stage T2f).
Role
----
The 2D sibling of :mod:`~simvx.core.physics.query`. The seam
(:class:`~simvx.core.physics.world2d.Physics2DWorld`) is node-agnostic: its
queries return opaque body HANDLES (and seam-level ``RaycastHit2D`` / ``Contact2D``
keyed by handle). :class:`PhysicsQuery2D` is the thin node-facing wrapper that maps
those handles back to scene nodes (via the tree's ``register_physics_node``
registry) and applies node-level filters (``exclude=`` a set of nodes), returning
typed :class:`RayHit2D` / :class:`ShapeHit2D` results (or plain nodes for overlap).
Constructed fresh per ``node.physics_2d`` access (cold path) bound to
``(world, node_map)`` where ``node_map`` is the per-world ``handle -> Node`` weak
map. The seam never learns about nodes; all handle->node mapping happens here.
"""
from __future__ import annotations
import math
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..math import Vec2
from .shapes2d import Shape2D
from .world2d import BodyHandle, Physics2DWorld
if TYPE_CHECKING:
from ..node import Node
from .nodes2d import PhysicsBody2D
__all__ = ["RayHit2D", "ShapeHit2D", "PhysicsQuery2D"]
[docs]
@dataclass(slots=True, frozen=True)
class RayHit2D:
"""A node-level 2D raycast result (mapped from the seam's ``RaycastHit2D``).
Carries the resolved scene node, so user code never touches handles. Always
truthy, so ``if hit:`` reads naturally; a miss is ``None`` (falsy).
Attributes:
node: The body node the ray hit.
point: World-space contact point (``Vec2``).
normal: World-space surface normal at the hit (``Vec2``, unit length).
distance: Distance from the ray origin to ``point`` along the ray.
"""
node: PhysicsBody2D
point: Vec2
normal: Vec2
distance: float
[docs]
def __bool__(self) -> bool:
return True
[docs]
@dataclass(slots=True, frozen=True)
class ShapeHit2D:
"""A node-level 2D shapecast result (mapped from the seam's ``Contact2D``).
Mirrors :class:`RayHit2D` for a swept-shape query; a miss is ``None``.
Attributes:
node: The body node the shape swept into.
point: World-space contact point (``Vec2``).
normal: World-space separating normal pointing back toward the cast
origin (``Vec2``, unit length).
distance: Time-of-impact distance along the cast direction.
"""
node: PhysicsBody2D
point: Vec2
normal: Vec2
distance: float
[docs]
class PhysicsQuery2D:
"""Node-facing 2D spatial query wrapper over a :class:`Physics2DWorld`.
Bound to one world and that world's ``handle -> Node`` map. Exposes
:meth:`raycast` / :meth:`raycast_all` / :meth:`shapecast` / :meth:`overlap`
with typed results and keyword-only ``mask`` / ``exclude`` / ``distance``
filters. Constructed fresh per ``node.physics_2d`` access; bind it locally if
a hot loop wants to avoid the per-call construction.
``node_map`` may be ``None`` (no bodies registered in this world yet); then
every hit maps to ``None`` and is dropped, so queries against an empty world
return ``None`` / ``[]`` rather than raising.
"""
__slots__ = ("_world", "_node_map")
def __init__(
self,
world: Physics2DWorld,
node_map: weakref.WeakValueDictionary[BodyHandle, Node] | None,
) -> None:
self._world = world
self._node_map = node_map
# -- handle -> node mapping --------------------------------------------
def _to_node(self, handle: BodyHandle) -> Node | None:
"""Map a seam body handle to its scene node, or ``None`` if unmapped."""
if self._node_map is None:
return None
return self._node_map.get(handle)
@staticmethod
def _keep(node: Node | None, exclude: set[Node] | None) -> bool:
"""True if ``node`` resolved and is not excluded."""
return node is not None and (exclude is None or node not in exclude)
# -- raycast -----------------------------------------------------------
[docs]
def raycast(
self,
origin: Vec2,
direction: Vec2,
*,
distance: float = math.inf,
mask: int = 0xFFFFFFFF,
exclude: set[Node] | None = None,
) -> RayHit2D | None:
"""Cast a ray, return the nearest non-excluded hit, or ``None``.
With no ``exclude`` the seam's single-nearest :meth:`raycast` is used
directly. With ``exclude`` it falls back to :meth:`raycast_all` and
returns the first non-excluded hit, so an excluded body never shadows a
farther real one.
Args:
origin: Ray origin (``Vec2``).
direction: Ray direction (``Vec2``); need not be normalised.
distance: Maximum ray distance (default unbounded).
mask: Query layer mask (matched against each body's layer).
exclude: Optional set of nodes to ignore (e.g. ``{self}``).
Returns:
The nearest :class:`RayHit2D`, or ``None`` on miss / all-excluded.
"""
max_dist = float(distance)
if exclude:
for hit in self.raycast_all(origin, direction, distance=max_dist, mask=mask, exclude=exclude):
return hit
return None
seam_hit = self._world.raycast(origin, direction, max_dist, mask=mask)
if seam_hit is None:
return None
node = self._to_node(seam_hit.body)
if node is None:
return None
return RayHit2D(node, seam_hit.point, seam_hit.normal, seam_hit.distance)
[docs]
def raycast_all(
self,
origin: Vec2,
direction: Vec2,
*,
distance: float = math.inf,
mask: int = 0xFFFFFFFF,
exclude: set[Node] | None = None,
) -> list[RayHit2D]:
"""Cast a ray, return ALL non-excluded hits sorted by distance.
Drops hits whose handle is unmapped (transient/destroyed body) or whose
node is in ``exclude``.
Returns:
List of :class:`RayHit2D`, ascending by distance (empty on miss).
"""
hits: list[RayHit2D] = []
for seam_hit in self._world.raycast_all(origin, direction, float(distance), mask=mask):
node = self._to_node(seam_hit.body)
if self._keep(node, exclude):
hits.append(RayHit2D(node, seam_hit.point, seam_hit.normal, seam_hit.distance))
return hits
# -- shapecast ---------------------------------------------------------
[docs]
def shapecast(
self,
shape: Shape2D,
origin: Vec2,
direction: Vec2,
*,
distance: float = math.inf,
mask: int = 0xFFFFFFFF,
exclude: set[Node] | None = None,
) -> ShapeHit2D | None:
"""Sweep a shape resource along a ray, return the earliest-TOI hit.
The wrapper owns building the transient seam shape handle from the
:class:`Shape2D` resource, keeping the public API node-level.
A swept shape needs a bounded sweep length, so unlike :meth:`raycast`
``distance`` must be FINITE; pass an explicit distance.
Returns:
The earliest-TOI :class:`ShapeHit2D`, or ``None`` on miss / excluded /
unmapped.
"""
handle = shape.build(self._world)
contact = self._world.shapecast(handle, origin, direction, float(distance), mask=mask)
if contact is None:
return None
node = self._to_node(contact.body)
if not self._keep(node, exclude):
return None
return ShapeHit2D(node, contact.point, contact.normal, contact.distance)
# -- overlap -----------------------------------------------------------
[docs]
def overlap(
self,
shape: Shape2D,
transform: object,
*,
mask: int = 0xFFFFFFFF,
exclude: set[Node] | None = None,
) -> list[PhysicsBody2D]:
"""Return all bodies a static shape overlaps at ``transform``.
Returns plain nodes (not typed hits): an overlap has no single point /
normal / distance. Drops unmapped and excluded handles.
Args:
shape: A :class:`Shape2D` resource.
transform: World pose to place the shape at (the flexible forms the
seam accepts: ``Transform2D`` / ``Vec2`` / ``(pos, rot)``).
mask: Query layer mask.
exclude: Optional set of nodes to ignore.
Returns:
List of overlapping body nodes (empty if none / all dropped).
"""
handle = shape.build(self._world)
nodes: list[PhysicsBody2D] = []
for body_handle in self._world.overlap(handle, transform, mask=mask):
node = self._to_node(body_handle)
if self._keep(node, exclude):
nodes.append(node)
return nodes