Source code for simvx.core.math.raycast

"""Rays: the ``Ray`` value type and ray-sphere intersection."""

import math
from dataclasses import dataclass

import numpy as np

from .types import Vec3


[docs] @dataclass(frozen=True, slots=True, init=False, eq=False) class Ray: """A half-line in world space: an origin and a unit direction. ``Camera3D.ray_at(screen_pos)`` is the usual way to get one; build one by hand with ``Ray(origin, direction)``, which normalises the direction. Distances along a ray are in world units, the same ``distance`` a physics ``RayHit`` reports, so ``ray.at(hit.distance)`` is the hit point. Example:: ray = camera.ray_at(Input.mouse_position) ground = ray.intersect_plane((0, 0, 0), (0, 1, 0)) if ground is not None: marker.position = ground """ origin: Vec3 direction: Vec3 def __init__(self, origin, direction): d = np.asarray(direction, dtype=np.float32) length = float(np.linalg.norm(d)) if not length > 0.0: raise ValueError(f"Ray direction must be a non-zero vector, got {tuple(d.tolist())}") object.__setattr__(self, "origin", Vec3(origin)) object.__setattr__(self, "direction", Vec3(d / length))
[docs] @classmethod def from_screen(cls, screen_pos, screen_size, view, proj) -> Ray: """The ray through *screen_pos* for a camera given as matrices. For a view drawn by a camera node, ``Camera3D.ray_at`` is the spelling to use; this form is for a view whose size or matrices do not come from a node, such as a viewport panel inside a larger window. Args: screen_pos: Pixel position, ``(0, 0)`` at the top left of the view. screen_size: The view's ``(width, height)`` in the same units. view: Row-major 4x4 view matrix. proj: Row-major 4x4 reverse-Z projection matrix, Vulkan Y-flip included or not (the flip is read from the matrix). """ ndc_x = 2.0 * float(screen_pos[0]) / float(screen_size[0]) - 1.0 ndc_y = 1.0 - 2.0 * float(screen_pos[1]) / float(screen_size[1]) view = np.asarray(view, dtype=np.float32).reshape(4, 4) proj = np.asarray(proj, dtype=np.float32).reshape(4, 4) if proj[1, 1] < 0: ndc_y = -ndc_y # Vulkan Y-flip # Both projections are reverse-Z: the near plane is NDC z = 1 and the far # plane z = 0. Under perspective the choice of near z only slides the origin # along a ray that passes through the eye either way; under orthographic the # rays are parallel, so reading the far value would place every origin a # whole depth range behind the camera and offset every hit distance. inv_vp = np.linalg.inv(proj @ view) near = inv_vp @ np.array([ndc_x, ndc_y, 1.0, 1.0], dtype=np.float32) far = inv_vp @ np.array([ndc_x, ndc_y, 0.0, 1.0], dtype=np.float32) near_pos = near[:3] / near[3] return cls(near_pos, far[:3] / far[3] - near_pos)
[docs] def at(self, distance: float) -> Vec3: """The point *distance* world units along the ray.""" return Vec3(self.origin + self.direction * float(distance))
[docs] def intersect_plane(self, point, normal) -> Vec3 | None: """Where the ray crosses the plane through *point* with *normal*, or ``None``. ``None`` when the ray runs parallel to the plane or the plane is behind the origin. Either side of the plane counts, so a ray from below a floor still meets it. """ n = np.asarray(normal, dtype=np.float32) denom = float(np.dot(self.direction, n)) if abs(denom) < 1e-8: return None t = float(np.dot(np.asarray(point, dtype=np.float32) - self.origin, n)) / denom return self.at(t) if t >= 0.0 else None
[docs] def ray_intersect_sphere(origin, direction, center, radius: float): """Ray-sphere intersection. Returns distance t or None if no hit.""" if not isinstance(origin, Vec3): origin = Vec3(origin) if not isinstance(direction, Vec3): direction = Vec3(direction) if not isinstance(center, Vec3): center = Vec3(center) oc = origin - center b = oc.dot(direction) c = oc.dot(oc) - radius * radius discriminant = b * b - c if discriminant < 0: return None sqrt_d = math.sqrt(discriminant) t = -b - sqrt_d if t < 0: t = -b + sqrt_d return t if t >= 0 else None