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}