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}