phys_raycast/shapes/
cylinder.rs

1// Copyright (C) 2020-2025 phys-raycast authors. All Rights Reserved.
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
15use phys_geom::math::*;
16use phys_geom::shape::Cylinder;
17
18use crate::{Raycast, RaycastHitResult};
19
20impl Raycast for Cylinder {
21    #[allow(clippy::many_single_char_names)]
22    fn raycast(
23        &self,
24        local_ray: phys_geom::Ray,
25        max_distance: Real,
26        discard_inside_hit: bool,
27    ) -> Option<RaycastHitResult> {
28        let half_height = self.half_height();
29        let radius = self.radius();
30
31        let offset = (-local_ray
32            .origin
33            .coords
34            .dot(&local_ray.direction.into_inner())
35            - (half_height + radius))
36            .max(0.0);
37        let translated_origin = local_ray.origin + local_ray.direction.into_inner() * offset;
38
39        // Test ray and infinite cylinder
40        // 'origin_xz' is ray origin ('o'), 'direction_xz' is ray direction ('d'), on plane y = 0, r
41        // is circle radius
42        let origin_xz = Vec3::new(translated_origin.x, 0.0, translated_origin.z);
43        let direction_xz = Vec3::new(local_ray.direction.x, 0.0, local_ray.direction.z);
44
45        // the circle bottom at y = circle_center_y to test
46        let circle_center_y: Real;
47
48        // test ray with circle at plane y = 0
49        // point on ray is (o + td)
50        // solve t in equation (o + td).dot(o + td) - r * r = 0
51        // which is d.dot(d)*t^2 + 2*o.dot(d)*t + o.dot(o) - r^2 = 0
52        let discr_half_b = origin_xz.dot(&direction_xz);
53        let discr_c = origin_xz.norm_squared() - radius * radius;
54
55        if discr_half_b > 0.0 && discr_c > 0.0 {
56            // c > 0 means ray origin is outside the circle
57            // half_b > 0 means ray is pointing away from the circle
58            return None;
59        }
60
61        let discr_a = direction_xz.norm_squared();
62        if discr_a > 1e-8 {
63            let delta = discr_half_b * discr_half_b - discr_a * discr_c;
64            if delta < 0.0 {
65                return None;
66            }
67            // use the less solution
68            let mut t = (-discr_half_b - delta.sqrt()) / discr_a;
69            let inside = if t < -offset {
70                t = -offset;
71                true
72            } else {
73                false
74            };
75
76            // if hit position y in [-half_height, half_height], it is a hit on side
77            // otherwise need check whether hit circle bottom or top
78            let hit_position = translated_origin + local_ray.direction.into_inner() * t;
79            if hit_position.y > half_height {
80                circle_center_y = half_height;
81            } else if hit_position.y < -half_height {
82                circle_center_y = -half_height;
83            } else {
84                if inside {
85                    if discard_inside_hit {
86                        return None;
87                    }
88                    return Some(RaycastHitResult {
89                        distance: 0.0,
90                        normal: -local_ray.direction,
91                    });
92                }
93                // hit on the side of cylinder
94                let normal =
95                    UnitVec3::new_normalize(Vec3::new(hit_position.x, 0.0, hit_position.z));
96                let distance = t + offset;
97                if distance <= max_distance {
98                    return Some(RaycastHitResult { distance, normal });
99                }
100                return None;
101            }
102        } else {
103            // ray is parallel to the infinite cylinder
104            circle_center_y = if local_ray.direction.y > 0.0 {
105                -half_height
106            } else {
107                half_height
108            }
109        }
110
111        // test ray with circle bottom at plane y = circle_center_y
112        if translated_origin.y.abs() > half_height
113            && translated_origin.y * local_ray.direction.y >= 0.0
114        {
115            // ray origin is outside and direction toward the cylinder circle
116            return None;
117        }
118
119        // ray: translated_origin + t * local_ray.direction
120        // hit plane at y = circle_center_y
121        let t = (circle_center_y - translated_origin.y) / local_ray.direction.y;
122        let hit_position = translated_origin + local_ray.direction.into_inner() * t;
123
124        // check hit position whether in circle
125        if hit_position.x * hit_position.x + hit_position.z * hit_position.z > radius * radius {
126            return None;
127        }
128
129        let distance = t + offset;
130        if distance <= max_distance {
131            Some(RaycastHitResult {
132                distance,
133                normal: UnitVec3::new_normalize(Vec3::new(
134                    0.0,
135                    if local_ray.direction.y < 0.0 {
136                        1.0
137                    } else {
138                        -1.0
139                    },
140                    0.0,
141                )),
142            })
143        } else {
144            None
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use approx::assert_relative_eq;
152
153    use super::*;
154
155    #[test]
156    fn test_raycast_cylinder_side() {
157        let cylinder = Cylinder::new(2.0, 1.0); // half_height=2.0, radius=1.0
158
159        // Direct hit on cylinder side from outside
160        let ray = phys_geom::Ray::new(
161            Point3::new(-3.0, 0.0, 0.0),
162            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
163        );
164
165        let hit = cylinder.raycast(ray, 10.0, false).unwrap();
166        assert_relative_eq!(hit.distance, 2.0);
167        assert_relative_eq!(
168            hit.normal,
169            UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0))
170        );
171    }
172
173    #[test]
174    fn test_raycast_cylinder_top() {
175        let cylinder = Cylinder::new(2.0, 1.0);
176
177        // Hit on top of cylinder
178        let ray = phys_geom::Ray::new(
179            Point3::new(0.0, 4.0, 0.0),
180            UnitVec3::new_normalize(Vec3::new(0.0, -1.0, 0.0)),
181        );
182
183        let hit = cylinder.raycast(ray, 10.0, false).unwrap();
184        assert_relative_eq!(hit.distance, 2.0);
185        assert_relative_eq!(
186            hit.normal,
187            UnitVec3::new_normalize(Vec3::new(0.0, 1.0, 0.0))
188        );
189    }
190
191    #[test]
192    fn test_raycast_cylinder_bottom() {
193        let cylinder = Cylinder::new(2.0, 1.0);
194
195        // Hit on bottom of cylinder
196        let ray = phys_geom::Ray::new(
197            Point3::new(0.0, -4.0, 0.0),
198            UnitVec3::new_normalize(Vec3::new(0.0, 1.0, 0.0)),
199        );
200
201        let hit = cylinder.raycast(ray, 10.0, false).unwrap();
202        assert_relative_eq!(hit.distance, 2.0);
203        assert_relative_eq!(
204            hit.normal,
205            UnitVec3::new_normalize(Vec3::new(0.0, -1.0, 0.0))
206        );
207    }
208
209    #[test]
210    fn test_raycast_cylinder_inside() {
211        let cylinder = Cylinder::new(2.0, 1.0);
212
213        // Ray from inside
214        let ray = phys_geom::Ray::new(
215            Point3::new(0.0, 0.0, 0.0),
216            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
217        );
218
219        let hit = cylinder.raycast(ray, 10.0, false).unwrap();
220        assert_relative_eq!(hit.distance, 0.0);
221        assert_relative_eq!(
222            hit.normal,
223            UnitVec3::new_normalize(Vec3::new(-1.0, 0.0, 0.0))
224        );
225    }
226
227    #[test]
228    fn test_raycast_cylinder_inside_discarded() {
229        let cylinder = Cylinder::new(2.0, 1.0);
230
231        // Ray from inside but discard inside hits
232        let ray = phys_geom::Ray::new(
233            Point3::new(0.0, 0.0, 0.0),
234            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
235        );
236
237        assert_eq!(cylinder.raycast(ray, 10.0, true), None);
238    }
239
240    #[test]
241    fn test_raycast_cylinder_miss() {
242        let cylinder = Cylinder::new(2.0, 1.0);
243
244        // Ray that misses the cylinder
245        let ray = phys_geom::Ray::new(
246            Point3::new(-3.0, 5.0, 0.0),
247            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
248        );
249
250        assert_eq!(cylinder.raycast(ray, 10.0, false), None);
251    }
252
253    #[test]
254    fn test_raycast_cylinder_parallel() {
255        let cylinder = Cylinder::new(2.0, 1.0);
256
257        // Ray parallel to cylinder axis, hitting the top
258        let ray = phys_geom::Ray::new(
259            Point3::new(0.5, 4.0, 0.0),
260            UnitVec3::new_normalize(Vec3::new(0.0, -1.0, 0.0)),
261        );
262
263        let hit = cylinder.raycast(ray, 10.0, false).unwrap();
264        assert_relative_eq!(hit.distance, 2.0);
265        assert_relative_eq!(
266            hit.normal,
267            UnitVec3::new_normalize(Vec3::new(0.0, 1.0, 0.0))
268        );
269    }
270
271    #[test]
272    fn test_raycast_cylinder_max_distance() {
273        let cylinder = Cylinder::new(2.0, 1.0);
274
275        let ray = phys_geom::Ray::new(
276            Point3::new(-5.0, 0.0, 0.0),
277            UnitVec3::new_normalize(Vec3::new(1.0, 0.0, 0.0)),
278        );
279
280        // Max distance too short
281        assert_eq!(cylinder.raycast(ray, 1.0, false), None);
282
283        // Max distance sufficient
284        let hit = cylinder.raycast(ray, 5.0, false).unwrap();
285        assert_relative_eq!(hit.distance, 4.0);
286    }
287}