Skip to main content

khora_core/math/
ray.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Rays and the intersection tests that go with them.
16//!
17//! One shared implementation for every caster in the engine: physics queries,
18//! editor picking, and gizmo manipulation all measure against the same
19//! primitives rather than each rolling their own. [`Ray`] lived in
20//! [`crate::physics`] for a while — it is re-exported from there, since a ray is
21//! geometry and only *some* of its users are physics.
22//!
23//! Every test returns a **signed distance along the ray** (its `t` parameter),
24//! never a point, because the caller almost always wants to compare hits before
25//! it wants a position. Recover the position with [`Ray::at`].
26
27use bincode::{Decode, Encode};
28use serde::{Deserialize, Serialize};
29
30use super::geometry::Aabb;
31use super::Vec3;
32
33/// Denominators below this mean the ray is parallel to what it is being tested
34/// against, so the intersection is undefined rather than merely far away.
35const PARALLEL_EPSILON: f32 = 1e-6;
36
37/// A half-line: an origin and a direction.
38///
39/// `direction` is expected to be normalised. [`Ray::new`] guarantees it; the
40/// struct literal — kept public because a ray is plain data — does not.
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Encode, Decode)]
42pub struct Ray {
43    /// Origin point.
44    pub origin: Vec3,
45    /// Direction vector (should be normalized).
46    pub direction: Vec3,
47}
48
49impl Ray {
50    /// Creates a ray, normalising `direction`.
51    #[inline]
52    pub fn new(origin: Vec3, direction: Vec3) -> Self {
53        Self {
54            origin,
55            direction: direction.normalize(),
56        }
57    }
58
59    /// The point `distance` along the ray.
60    #[inline]
61    pub fn at(&self, distance: f32) -> Vec3 {
62        self.origin + self.direction * distance
63    }
64
65    /// The component-wise reciprocal of the direction, as the slab test wants
66    /// it.
67    ///
68    /// A zero component yields an infinity, which [`Aabb::intersect_ray`]
69    /// handles correctly: a ray parallel to a slab simply never leaves it.
70    #[inline]
71    pub fn inv_direction(&self) -> Vec3 {
72        Vec3::new(
73            1.0 / self.direction.x,
74            1.0 / self.direction.y,
75            1.0 / self.direction.z,
76        )
77    }
78
79    /// Distance to the near face of `aabb`, or `None` if the ray misses it.
80    ///
81    /// Negative when the origin is already inside or past the box. Hoist
82    /// [`Ray::inv_direction`] and call [`Aabb::intersect_ray`] directly when
83    /// testing one ray against many boxes.
84    #[inline]
85    pub fn intersect_aabb(&self, aabb: &Aabb) -> Option<f32> {
86        aabb.intersect_ray(self.origin, self.inv_direction())
87    }
88
89    /// Distance to the plane through `point` with normal `normal`, or `None`
90    /// when the ray is parallel to it or the plane lies behind the origin.
91    ///
92    /// `normal` need not be normalised — it only appears in a ratio.
93    #[inline]
94    pub fn intersect_plane(&self, point: Vec3, normal: Vec3) -> Option<f32> {
95        let denominator = self.direction.dot(normal);
96        if denominator.abs() < PARALLEL_EPSILON {
97            return None;
98        }
99        let distance = (point - self.origin).dot(normal) / denominator;
100        (distance >= 0.0).then_some(distance)
101    }
102
103    /// Perpendicular distance from `point` to the ray.
104    ///
105    /// Measured from the *half*-line: a point behind the origin is measured
106    /// from the origin itself, not from the line the ray extends backwards
107    /// along.
108    #[inline]
109    pub fn distance_to_point(&self, point: Vec3) -> f32 {
110        let along = (point - self.origin).dot(self.direction).max(0.0);
111        (point - self.at(along)).length()
112    }
113
114    /// How far along the line `origin + t * direction` its closest approach to
115    /// this ray sits, or `None` if the two are parallel.
116    ///
117    /// The classic closest-approach of two skew lines. `direction` must be
118    /// normalised. Note this parameterises the *other* line, not the ray —
119    /// which is what a caller dragging along an axis wants to know.
120    pub fn closest_param_on_line(&self, origin: Vec3, direction: Vec3) -> Option<f32> {
121        let between = origin - self.origin;
122        let alignment = direction.dot(self.direction);
123        // Both directions are unit, so the general `a*c - b*b` reduces to this.
124        let denominator = 1.0 - alignment * alignment;
125        if denominator.abs() < PARALLEL_EPSILON {
126            return None;
127        }
128        Some((alignment * self.direction.dot(between) - direction.dot(between)) / denominator)
129    }
130
131    /// The point on segment `a..b` closest to this ray.
132    ///
133    /// Falls back to the midpoint when the segment is parallel to the ray or
134    /// degenerate — every point on it is then equally close, and a caller
135    /// hit-testing a handle wants an answer rather than an absence.
136    pub fn closest_point_on_segment(&self, a: Vec3, b: Vec3) -> Vec3 {
137        let span = b - a;
138        let length = span.length();
139        if length < PARALLEL_EPSILON {
140            return a;
141        }
142        let direction = span / length;
143        match self.closest_param_on_line(a, direction) {
144            Some(t) => a + direction * t.clamp(0.0, length),
145            None => a + span * 0.5,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// A ray down -Z from +Z, the canonical "looking at the origin" case.
155    fn toward_origin() -> Ray {
156        Ray::new(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0))
157    }
158
159    #[test]
160    fn new_normalizes_the_direction() {
161        let ray = Ray::new(Vec3::ZERO, Vec3::new(0.0, 3.0, 0.0));
162        assert!((ray.direction - Vec3::new(0.0, 1.0, 0.0)).length() < 1e-6);
163    }
164
165    #[test]
166    fn at_walks_along_the_direction() {
167        assert!((toward_origin().at(5.0) - Vec3::ZERO).length() < 1e-6);
168    }
169
170    /// A zero direction component must give an infinite reciprocal, not a NaN —
171    /// the slab test relies on the sign of that infinity.
172    #[test]
173    fn inv_direction_handles_axis_parallel_rays() {
174        let inv = toward_origin().inv_direction();
175        assert!(inv.x.is_infinite() && inv.y.is_infinite());
176        assert!((inv.z + 1.0).abs() < 1e-6);
177    }
178
179    #[test]
180    fn intersect_aabb_reports_the_near_face() {
181        let box_at_origin = Aabb::from_half_extents(Vec3::new(1.0, 1.0, 1.0));
182        let hit = toward_origin()
183            .intersect_aabb(&box_at_origin)
184            .expect("the ray runs straight through the box");
185        assert!((hit - 4.0).abs() < 1e-4, "got {hit}");
186    }
187
188    #[test]
189    fn intersect_aabb_misses_what_it_misses() {
190        let elsewhere =
191            Aabb::from_min_max(Vec3::new(10.0, 10.0, 10.0), Vec3::new(11.0, 11.0, 11.0));
192        assert_eq!(toward_origin().intersect_aabb(&elsewhere), None);
193    }
194
195    #[test]
196    fn intersect_plane_finds_the_crossing() {
197        let hit = toward_origin()
198            .intersect_plane(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0))
199            .expect("the ray crosses the XY plane");
200        assert!((hit - 5.0).abs() < 1e-4, "got {hit}");
201    }
202
203    /// A plane the ray runs along has no single crossing, and must report that
204    /// rather than an arbitrary one.
205    #[test]
206    fn intersect_plane_rejects_a_parallel_plane() {
207        assert_eq!(
208            toward_origin().intersect_plane(Vec3::ZERO, Vec3::new(0.0, 1.0, 0.0)),
209            None
210        );
211    }
212
213    /// A plane behind the origin is not something the ray reaches — returning a
214    /// negative distance would let a caller select what is behind the camera.
215    #[test]
216    fn intersect_plane_ignores_what_is_behind() {
217        assert_eq!(
218            toward_origin().intersect_plane(Vec3::new(0.0, 0.0, 9.0), Vec3::new(0.0, 0.0, 1.0)),
219            None
220        );
221    }
222
223    #[test]
224    fn distance_to_point_measures_perpendicular() {
225        let distance = toward_origin().distance_to_point(Vec3::new(2.0, 0.0, 0.0));
226        assert!((distance - 2.0).abs() < 1e-4, "got {distance}");
227    }
228
229    /// Behind the origin the distance is measured from the origin, so a point
230    /// on the backward extension of the line is *not* reported as a hit.
231    #[test]
232    fn distance_to_point_clamps_behind_the_origin() {
233        let distance = toward_origin().distance_to_point(Vec3::new(0.0, 0.0, 9.0));
234        assert!((distance - 4.0).abs() < 1e-4, "got {distance}");
235    }
236
237    /// The closest approach to the X axis of a ray aimed 2 units along it sits
238    /// at t = 2 on that axis.
239    #[test]
240    fn closest_param_on_line_parameterises_the_other_line() {
241        let ray = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
242        let t = ray
243            .closest_param_on_line(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0))
244            .expect("the ray is perpendicular to the X axis");
245        assert!((t - 2.0).abs() < 1e-4, "got {t}");
246    }
247
248    #[test]
249    fn closest_param_on_line_rejects_a_parallel_line() {
250        let along_x = Ray::new(Vec3::new(0.0, 1.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
251        assert_eq!(
252            along_x.closest_param_on_line(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)),
253            None
254        );
255    }
256
257    /// The segment is finite: an approach beyond its end clamps to that end
258    /// instead of running off along the line it lies on.
259    #[test]
260    fn closest_point_on_segment_clamps_to_the_ends() {
261        let ray = Ray::new(Vec3::new(9.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
262        let point = ray.closest_point_on_segment(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0));
263        assert!(
264            (point - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-4,
265            "got {point:?}"
266        );
267    }
268
269    /// A degenerate segment has one point, and asking for the closest one must
270    /// return it rather than dividing by its zero length.
271    #[test]
272    fn closest_point_on_segment_survives_a_zero_length_segment() {
273        let point = toward_origin().closest_point_on_segment(Vec3::ONE, Vec3::ONE);
274        assert_eq!(point, Vec3::ONE);
275    }
276}