phys-raycast 2.0.0

Ray casting functionality for 3D physics shapes
Documentation
// Copyright (C) 2020-2025 phys-raycast authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use phys_geom::math::{Real, *};
use phys_geom::shape::Sphere;

use crate::{Raycast, RaycastHitResult};

impl Raycast for Sphere {
    fn raycast(
        &self,
        local_ray: phys_geom::Ray,
        max_distance: Real,
        discard_inside_hit: bool,
    ) -> Option<RaycastHitResult> {
        let radius = self.radius();
        let center_to_origin = local_ray.origin - Point3::origin();

        // Move ray origin closer to the sphere, to avoid numerical problems.
        let offset = (-center_to_origin.dot(&local_ray.direction.into_inner()) - radius).max(0.0);
        let center_to_translated_origin =
            center_to_origin + local_ray.direction.into_inner() * offset;
        let discr_half_b = center_to_translated_origin.dot(&local_ray.direction.into_inner());
        let discr_c = center_to_translated_origin.norm_squared() - radius * radius;

        // Ray is outside and pointing away
        if discr_half_b > 0.0 && discr_c > 0.0 {
            return None;
        }

        let discr = discr_half_b * discr_half_b - discr_c;
        if discr < 0.0 {
            None
        } else {
            let mut distance = -discr_half_b - discr.sqrt();

            // If the origin is inside the sphere.
            if distance < -offset {
                if discard_inside_hit {
                    return None;
                }
                Some(RaycastHitResult {
                    distance: 0.0,
                    normal: -local_ray.direction,
                })
            } else {
                distance += offset;
                if distance <= max_distance {
                    let hit_point = center_to_origin + local_ray.direction.into_inner() * distance;
                    Some(RaycastHitResult {
                        distance,
                        normal: UnitVec3::new_normalize(hit_point),
                    })
                } else {
                    None
                }
            }
        }
    }
}

#[cfg(test)]
mod raycast_sphere_tests {

    use super::*;

    #[test]
    fn test_raycast() {
        let sphere = Sphere::new(1.0);

        // Direct hit from outside
        {
            let ray = phys_geom::Ray::new(
                Point3::new(-2.0, 0.0, 0.0),
                UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
            );
            assert_eq!(
                sphere.raycast(ray, 5.0, false),
                Some(RaycastHitResult {
                    distance: 1.0,
                    normal: UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0)),
                })
            );

            // Max distance is too short
            assert_eq!(
                sphere.raycast(ray, 1.0, false),
                Some(RaycastHitResult {
                    distance: 1.0,
                    normal: UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0)),
                })
            );

            assert_eq!(sphere.raycast(ray, 0.5, false), None);
        }

        // Ray misses
        {
            let ray = phys_geom::Ray::new(
                Point3::new(-2.0, 2.0, 0.0),
                UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
            );
            assert_eq!(sphere.raycast(ray, 10.0, false), None);
        }

        // Ray starts inside sphere
        {
            let ray = phys_geom::Ray::new(
                Point3::new(0.0, 0.0, 0.0),
                UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
            );
            assert_eq!(
                sphere.raycast(ray, 5.0, false),
                Some(RaycastHitResult {
                    distance: 0.0,
                    normal: UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0)),
                })
            );
        }

        // Ray starts inside but discard inside hits
        {
            let ray = phys_geom::Ray::new(
                Point3::new(0.0, 0.0, 0.0),
                UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
            );
            assert_eq!(sphere.raycast(ray, 5.0, true), None);
        }
    }

    #[test]
    fn test_raycast_inner() {
        let sphere = Sphere::new(1.0);

        let ray = phys_geom::Ray::new(
            Point3::new(0.5, 0.0, 0.0),
            UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0)),
        );
        assert_eq!(
            sphere.raycast(ray, 5.0, false),
            Some(RaycastHitResult {
                distance: 0.0,
                normal: UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
            })
        );

        assert_eq!(sphere.raycast(ray, 5.0, true), None);
    }

    #[test]
    fn test_raycast_from_far_origin() {
        let sphere = Sphere::new(1.0);

        let ray = phys_geom::Ray::new(
            Point3::new(-100001.0, 0.0, 0.0),
            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
        );
        assert_eq!(
            sphere.raycast(ray, 100002.0, false),
            Some(RaycastHitResult {
                distance: 100000.0,
                normal: UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0)),
            })
        );
    }
}