Source code for simvx.core.physics.query

"""Node-level spatial query API for the new physics seam (Stage R2b).

Role
----
The seam (:class:`~simvx.core.physics.world.PhysicsWorld`) is node-agnostic: its
queries return opaque body HANDLES (and seam-level ``RaycastHit`` / ``Contact``
keyed by handle). :class:`PhysicsQuery` 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:`RayHit` / :class:`ShapeHit` results (or plain nodes for overlap).

It is constructed fresh per ``node.physics`` access (cold path; see
``Node.physics``) 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 Vec3
from .shapes import Shape
from .world import BodyHandle, PhysicsWorld

if TYPE_CHECKING:
    from ..node import Node
    from .nodes import PhysicsBody3D

__all__ = ["RayHit", "ShapeHit", "PhysicsQuery"]


[docs] @dataclass(slots=True, frozen=True) class RayHit: """A node-level raycast result (mapped from the seam's ``RaycastHit``). Distinct from the node-agnostic seam ``RaycastHit`` (keyed by a body handle): this 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 (``Vec3``). normal: World-space surface normal at the hit (``Vec3``, unit length). distance: Distance from the ray origin to ``point`` along the ray. """ node: PhysicsBody3D point: Vec3 normal: Vec3 distance: float
[docs] def __bool__(self) -> bool: return True
[docs] @dataclass(slots=True, frozen=True) class ShapeHit: """A node-level shapecast result (mapped from the seam's ``Contact``). Mirrors :class:`RayHit` for a swept-shape query; a miss is ``None``. Attributes: node: The body node the shape swept into. point: World-space contact point (``Vec3``). normal: World-space separating normal pointing back toward the cast origin (``Vec3``, unit length). distance: Time-of-impact distance along the cast direction. """ node: PhysicsBody3D point: Vec3 normal: Vec3 distance: float
[docs] class PhysicsQuery: """Node-facing spatial query wrapper over a :class:`PhysicsWorld`. 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`` 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: PhysicsWorld, 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. Unmapped handles (transient probe bodies, or a body whose node was GC'd out of the weak map) are silently dropped by the callers, so query results are always a subset of live, registered bodies. """ 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: Vec3, direction: Vec3, *, distance: float = math.inf, mask: int = 0xFFFFFFFF, exclude: set[Node] | None = None, ) -> RayHit | 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`` (the nearest hit might be an excluded node) 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 (``Vec3``). direction: Ray direction (``Vec3``); 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:`RayHit`, 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 RayHit(node, seam_hit.point, seam_hit.normal, seam_hit.distance)
[docs] def raycast_all( self, origin: Vec3, direction: Vec3, *, distance: float = math.inf, mask: int = 0xFFFFFFFF, exclude: set[Node] | None = None, ) -> list[RayHit]: """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``. Args: origin: Ray origin (``Vec3``). direction: Ray direction (``Vec3``); need not be normalised. distance: Maximum ray distance (default unbounded). mask: Query layer mask. exclude: Optional set of nodes to ignore. Returns: List of :class:`RayHit`, ascending by distance (empty on miss). """ hits: list[RayHit] = [] 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(RayHit(node, seam_hit.point, seam_hit.normal, seam_hit.distance)) return hits
# -- shapecast ---------------------------------------------------------
[docs] def shapecast( self, shape: Shape, origin: Vec3, direction: Vec3, *, distance: float = math.inf, mask: int = 0xFFFFFFFF, exclude: set[Node] | None = None, ) -> ShapeHit | 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:`Shape` resource, keeping the public API node-level (the user passes geometry, never an opaque handle). A swept shape needs a bounded sweep length, so unlike :meth:`raycast` ``distance`` must be FINITE (the substepped basic-tier cast cannot size an unbounded sweep); pass an explicit distance. Args: shape: A :class:`Shape` resource (``SphereShape3D`` / ``BoxShape3D``). origin: Cast origin (``Vec3``). direction: Cast direction (``Vec3``); need not be normalised. distance: Maximum sweep distance (must be finite). mask: Query layer mask. exclude: Optional set of nodes to ignore. Returns: The earliest-TOI :class:`ShapeHit`, 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 ShapeHit(node, contact.point, contact.normal, contact.distance)
# -- overlap -----------------------------------------------------------
[docs] def overlap( self, shape: Shape, transform: object, *, mask: int = 0xFFFFFFFF, exclude: set[Node] | None = None, ) -> list[PhysicsBody3D]: """Return all bodies a static shape overlaps at ``transform``. Returns plain nodes (not typed hits): an overlap has no single point / normal / distance (design S9). Drops unmapped and excluded handles. Args: shape: A :class:`Shape` resource. transform: World pose to place the shape at (same flexible forms the seam accepts: ``Transform3D`` / ``Vec3`` / ``(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[PhysicsBody3D] = [] 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