all_is_cubes_base/raycast/ray.rs
1use core::f64::consts::TAU;
2
3use euclid::Point3D;
4
5/// Acts as polyfill for float methods
6#[cfg(not(feature = "std"))]
7#[allow(unused_imports)]
8use num_traits::float::Float as _;
9
10use crate::math::{
11 self, Cube, Face7, FreeCoordinate, FreePoint, FreeVector, GridCoordinate, GridVector,
12 LineVertex,
13};
14use crate::raycast::{AxisAlignedRaycaster, Raycaster};
15use crate::resolution::Resolution;
16
17#[cfg(doc)]
18use crate::math::GridAab;
19
20/// A ray; a half-infinite line segment (sometimes used as finite by the length of the
21/// direction vector).
22#[expect(clippy::exhaustive_structs)]
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Ray {
25 /// The sole endpoint of the ray.
26 pub origin: FreePoint,
27
28 /// The direction in which the ray extends infinitely.
29 ///
30 /// The meaning, if any, of the magnitude of this vector depends on context;
31 /// considered as a geometric object it is a parameter.
32 pub direction: FreeVector,
33}
34
35impl Ray {
36 /// Constructs a [`Ray`] from convertible types (e.g. tuples or 3-element arrays).
37 /// Other than the use of [`Into`], this is equivalent to a struct literal.
38 ///
39 /// ```
40 /// # use all_is_cubes_base as all_is_cubes;
41 /// use all_is_cubes::euclid::{point3, vec3};
42 /// use all_is_cubes::raycast::Ray;
43 ///
44 /// assert_eq!(
45 /// Ray::new([1., 2., 3.], [4., 5., 6.]),
46 /// Ray {
47 /// origin: point3(1., 2., 3.),
48 /// direction: vec3(4., 5., 6.),
49 /// }
50 /// );
51 /// ```
52 #[allow(clippy::missing_inline_in_public_items, reason = "is generic already")]
53 pub fn new(origin: impl Into<FreePoint>, direction: impl Into<FreeVector>) -> Self {
54 Self {
55 origin: origin.into(),
56 direction: direction.into(),
57 }
58 }
59
60 /// Prepares a [`Raycaster`] that will iterate over cubes intersected by this ray.
61 #[must_use]
62 #[allow(clippy::missing_inline_in_public_items)]
63 pub fn cast(&self) -> Raycaster {
64 Raycaster::new(self.origin, self.direction)
65 }
66
67 /// Add `offset` to the origin of this ray.
68 #[inline]
69 #[must_use]
70 pub fn translate(self, offset: FreeVector) -> Self {
71 Self {
72 origin: self.origin + offset,
73 ..self
74 }
75 }
76
77 /// Scale the ray's coordinates by the given factor.
78 #[must_use]
79 #[inline]
80 pub fn scale_all(self, scale: FreeCoordinate) -> Self {
81 Self {
82 origin: self.origin * scale,
83 direction: self.direction * scale,
84 }
85 }
86
87 /// Scale the ray's direction vector by the given factor.
88 #[must_use]
89 #[inline]
90 pub fn scale_direction(self, scale: FreeCoordinate) -> Self {
91 Self {
92 origin: self.origin,
93 direction: self.direction * scale,
94 }
95 }
96
97 /// Return `self.origin + self.direction`, the “far end” of the ray.
98 ///
99 /// This only makes sense in contexts which are specifically using the length of the
100 /// direction vector as a distance, or for visualization as a line segment.
101 #[must_use]
102 #[inline]
103 pub fn unit_endpoint(self) -> FreePoint {
104 self.origin + self.direction
105 }
106
107 pub(crate) fn advance(self, t: FreeCoordinate) -> Self {
108 Self {
109 origin: self.origin + self.direction * t,
110 direction: self.direction,
111 }
112 }
113}
114
115impl math::Wireframe for Ray {
116 #[allow(clippy::missing_inline_in_public_items)]
117 fn wireframe_points<E>(&self, output: &mut E)
118 where
119 E: Extend<LineVertex>,
120 {
121 // Draw line
122 let tip = self.unit_endpoint();
123 output.extend([self.origin.into(), tip.into()]);
124
125 // If the length is nonzero, draw arrowhead
126 let length = self.direction.length();
127 if length.partial_cmp(&0.0) != Some(core::cmp::Ordering::Greater) {
128 return;
129 }
130 let norm_dir = self.direction / length;
131
132 // Pick a size of arrowhead
133 let head_length = (length * 0.25).min(0.125);
134 let head_width = head_length * 0.25;
135 let head_base_point = tip - norm_dir * head_length;
136
137 // Pick a set of perpendicular axes
138 let mut perp1 = norm_dir.cross(FreeVector::new(0., 1., 0.));
139 if (perp1.length() - 1.0).abs() > 1e-2 {
140 // handle parallel-to-up vectors
141 perp1 = norm_dir.cross(FreeVector::new(1., 0., 0.));
142 }
143 let perp2 = norm_dir.cross(perp1);
144
145 // Generate a wireframe cone
146 fn ang(step: i32) -> f64 {
147 f64::from(step) * (TAU / 8.0)
148 }
149 for step in 0..8 {
150 let circle_point = head_base_point
151 + perp1 * head_width * ang(step).sin()
152 + perp2 * head_width * ang(step).cos();
153 let adj_circle_point = head_base_point
154 + perp1 * head_width * ang(step + 1).sin()
155 + perp2 * head_width * ang(step + 1).cos();
156 output.extend([
157 circle_point.into(),
158 tip.into(),
159 circle_point.into(),
160 adj_circle_point.into(),
161 ]);
162 }
163 }
164}
165
166/// An axis-aligned, grid-aligned version of [`Ray`].
167#[derive(Clone, Copy, Debug, PartialEq)]
168#[allow(clippy::module_name_repetitions)]
169pub struct AaRay {
170 /// Cube within which the ray starts.
171 pub(in crate::raycast) origin: Cube,
172 /// Axis-aligned direction in which the ray extends.
173 pub(in crate::raycast) direction: Face7,
174 /// Further offset within the `origin` cube.
175 /// Components are always in the range `0.0..1.0`.
176 //---
177 // TODO: switch this to f16 when that is supported — we really don't need more precision,
178 // because the goal is to hit individual voxels, not any finer subdivision than that.
179 // (In fact, fixed-point u8 would really suffice.)
180 pub(in crate::raycast) sub_origin: Point3D<f32, Cube>,
181}
182
183impl AaRay {
184 /// Constructs an [`AaRay`] from its origin cube and direction.
185 ///
186 /// The ray's exact origin is considered to be in the center of the given cube.
187 ///
188 /// # Example
189 ///
190 /// ```
191 /// # use all_is_cubes_base as all_is_cubes;
192 /// use all_is_cubes::math::{Cube, Face7};
193 /// use all_is_cubes::raycast::{AaRay, Ray};
194 ///
195 /// let aa_ray = AaRay::new(Cube::new(1, 2, 3), Face7::PX);
196 ///
197 /// assert_eq!(Ray::from(aa_ray), Ray::new([1.5, 2.5, 3.5], [1., 0., 0.]));
198 /// ```
199 #[inline]
200 #[must_use]
201 pub fn new(origin: Cube, direction: Face7) -> Self {
202 Self {
203 origin,
204 direction,
205 sub_origin: Point3D::splat(0.5),
206 }
207 }
208
209 /// Returns the cube which this ray’s origin point is located in.
210 #[inline]
211 #[must_use]
212 pub fn origin_cube(&self) -> Cube {
213 self.origin
214 }
215
216 /// Scale and translate this ray’s origin to occupy the given cube;
217 /// its prior origin now lies within that cube.
218 ///
219 /// Panics if `self`’s position is outside of the bounds of
220 /// [`GridAab::for_block(resolution)`](GridAab::for_block).
221 ///
222 /// # Example
223 ///
224 /// ```
225 /// # mod all_is_cubes {
226 /// # pub mod block { pub use all_is_cubes_base::resolution::Resolution; }
227 /// # pub use all_is_cubes_base::{math, raycast};
228 /// # }
229 /// use all_is_cubes::block::Resolution;
230 /// use all_is_cubes::math::{Cube, Face7};
231 /// use all_is_cubes::raycast::{AaRay, Ray};
232 ///
233 /// let aa_ray =
234 /// AaRay::new(Cube::new(1, 2, 0), Face7::PX)
235 /// .zoom_out(Cube::new(100, 100, 100), Resolution::R4);
236 ///
237 /// assert_eq!(Ray::from(aa_ray), Ray::new([100.375, 100.625, 100.125], [1., 0., 0.]));
238 /// ```
239 #[inline]
240 #[must_use]
241 #[track_caller]
242 pub fn zoom_out(self, cube: Cube, resolution: Resolution) -> Self {
243 assert!(
244 math::GridAab::for_block(resolution).contains_cube(self.origin),
245 "ray origin {o:?} is out of bounds for within_cube({resolution:?}",
246 o = self.origin
247 );
248
249 let sub_origin = (self.origin.lower_bounds().to_f32() + self.sub_origin.to_vector())
250 / f32::from(resolution);
251
252 Self {
253 origin: cube,
254 direction: self.direction,
255 sub_origin,
256 }
257 }
258
259 /// Create a new ray which crosses a grid of resolution `resolution`
260 /// within the bounds of `cube`, along the same path this ray takes through that cube.
261 ///
262 /// This is not a perfect inverse of [`AaRay::zoom_in()`], because it does not
263 /// preserve the origin’s position along the path of the ray.
264 #[inline]
265 #[must_use]
266 pub fn zoom_in(self, cube: Cube, resolution: Resolution) -> Self {
267 let resolution_g = GridCoordinate::from(resolution);
268 let mut transformed_origin = self
269 .origin
270 .lower_bounds()
271 .zip(cube.lower_bounds(), GridCoordinate::saturating_sub)
272 .map(|coord| coord.saturating_mul(resolution_g))
273 .to_point();
274
275 // The origin coordinates may have saturated, but only along the axis of the ray, because
276 // the others must be within the cube bounds.
277 // Replace that coordinate with one which is just outside the bounds of the cube.
278 if let Some(axis) = self.direction.axis() {
279 // Only if not starting within that cube.
280 if !(0..resolution_g).contains(&transformed_origin[axis]) {
281 transformed_origin[axis] = if self.direction.is_positive() {
282 -1
283 } else {
284 resolution_g
285 };
286 }
287 }
288
289 // Translation vector for the cube of the high-resolution grid that the ray is within
290 // according to `sub_origin`.
291 let sub_cube_containing_sub_origin: GridVector =
292 Cube::containing(self.sub_origin.to_f64() * FreeCoordinate::from(resolution))
293 .unwrap() // can't fail because it's in the range 0..Resolution::MAX
294 .lower_bounds()
295 .to_vector();
296
297 Self {
298 origin: Cube::from(transformed_origin + sub_cube_containing_sub_origin),
299 direction: self.direction,
300 sub_origin: self.sub_origin * f32::from(resolution)
301 - sub_cube_containing_sub_origin.to_f32(),
302 }
303 }
304
305 /// Prepares an [`AxisAlignedRaycaster`] that will iterate over cubes intersected by this ray.
306 #[inline]
307 pub fn cast(self) -> AxisAlignedRaycaster {
308 AxisAlignedRaycaster::new(self)
309 }
310}
311
312impl From<AaRay> for Ray {
313 #[inline]
314 fn from(ray: AaRay) -> Self {
315 Ray {
316 origin: ray.origin.lower_bounds().to_f64() + (ray.sub_origin.to_f64().to_vector()),
317 direction: ray.direction.normal_vector(),
318 }
319 }
320}
321
322impl TryFrom<Ray> for AaRay {
323 // TODO: proper error type
324 type Error = ();
325
326 /// Converts the given arbitrary ray into an axis-aligned ray.
327 ///
328 /// Currently, its length is discarded.
329 ///
330 /// # Errors
331 ///
332 /// Returns an error if:
333 ///
334 /// * The ray’s origin lies outside of the bounds permitted by [`Cube::containing()`].
335 /// * The ray’s direction is not axis-aligned.
336 /// * The ray has NaN components.
337 #[inline]
338 fn try_from(ray: Ray) -> Result<Self, Self::Error> {
339 use core::cmp::Ordering::*;
340
341 // Find which axis is nonzero.
342 // Reject all cases where more than one axis is nonzero, or any axis is NaN.
343 let direction = match <[_; 3]>::from(ray.direction.map(|coord| coord.partial_cmp(&0.0))) {
344 [Some(Less), Some(Equal), Some(Equal)] => Face7::NX,
345 [Some(Equal), Some(Less), Some(Equal)] => Face7::NY,
346 [Some(Equal), Some(Equal), Some(Less)] => Face7::NZ,
347 [Some(Greater), Some(Equal), Some(Equal)] => Face7::PX,
348 [Some(Equal), Some(Greater), Some(Equal)] => Face7::PY,
349 [Some(Equal), Some(Equal), Some(Greater)] => Face7::PZ,
350 [Some(Equal), Some(Equal), Some(Equal)] => Face7::Within,
351 _ => return Err(()),
352 };
353
354 let origin = Cube::containing(ray.origin).ok_or(())?;
355 Ok(AaRay {
356 origin,
357 direction,
358 sub_origin: (ray.origin - origin.lower_bounds().to_vector().to_f64()).to_f32(),
359 })
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn aa_ray_round_trip() {
369 assert_eq!(
370 Ray::from(AaRay::try_from(Ray::new([1., 2., 3.], [0., 0., 0.])).unwrap()),
371 Ray::new([1., 2., 3.], [0., 0., 0.]),
372 );
373 assert_eq!(
374 Ray::from(AaRay::try_from(Ray::new([1., 2., 3.], [4., 0., 0.])).unwrap()),
375 Ray::new([1., 2., 3.], [1., 0., 0.]), // note length got reset
376 );
377 }
378}