Source code for simvx.core.physics.query2d

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