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