Skip to main content

parry3d/query/ray/
ray_capsule.rs

1use crate::math::{ComplexField, Real, Vector};
2use crate::query::{Ray, RayCast, RayIntersection};
3use crate::shape::{Capsule, FeatureId, Segment};
4
5impl RayCast for Capsule {
6    #[inline]
7    fn cast_local_ray(&self, ray: &Ray, max_time_of_impact: Real, solid: bool) -> Option<Real> {
8        ray_toi_with_capsule(&self.segment, self.radius, ray, solid)
9            .1
10            .filter(|time_of_impact| *time_of_impact <= max_time_of_impact)
11    }
12
13    #[inline]
14    fn cast_local_ray_and_get_normal(
15        &self,
16        ray: &Ray,
17        max_time_of_impact: Real,
18        solid: bool,
19    ) -> Option<RayIntersection> {
20        ray_toi_and_normal_with_capsule(&self.segment, self.radius, ray, solid)
21            .filter(|inter| inter.time_of_impact <= max_time_of_impact)
22    }
23}
24
25/// Computes the time of impact of a ray on a capsule.
26/// Returns true if the ray started inside the capsule and the time of impact.
27///
28/// Adapted from Inigo Quilez (<https://iquilezles.org/articles/intersectors/>).
29/// Adapted to unnormalized ray direction.
30/// Made robust to degenerate cases and ray origin inside the capsule.
31/// Switched to projecting onto the plane with cross products
32/// because they introduce much less error than a difference of dot products.
33/// Discriminants are measured at the ray's closest approach to the axis or cap center,
34/// so a distant ray origin doesn't cancel out the radius.
35fn ray_toi_with_capsule(
36    segment: &Segment,
37    radius: Real,
38    ray: &Ray,
39    solid: bool,
40) -> (bool, Option<Real>) {
41    let ab = segment.b - segment.a;
42    let ao = ray.origin - segment.a;
43
44    let ab_ab = ab.length_squared();
45    let dir_dir = ray.dir.length_squared();
46    let ab_dir = ab.dot(ray.dir);
47    let ab_ao = ab.dot(ao);
48    let radius_squared = radius * radius;
49
50    // do a circle intersection on the plane perpendicular to the capsule's axis.
51    // all these variables are scaled by ab
52    let dir_on_plane = cross(ray.dir, ab);
53    let origin_on_plane = cross(ao, ab);
54    let ray_step = dir_on_plane.length_squared();
55    let radius_on_plane_squared = radius_squared * ab_ab;
56
57    let inside = origin_on_plane.length_squared() <= radius_on_plane_squared
58        && (0.0 < ab_ao || ao.length_squared() <= radius_squared)
59        && (ab_ao < ab_ab || (ray.origin - segment.b).length_squared() <= radius_squared);
60
61    if inside && solid {
62        return (true, Some(0.0));
63    }
64
65    let check_sphere_a = if ray_step == 0.0 {
66        // the ray is parallel to the capsule,
67        // so it can only hit one of the caps
68        (ab_dir > 0.0) ^ inside
69    } else {
70        // cylinder part
71        let Some(t) = closest_approach_root(
72            origin_on_plane,
73            dir_on_plane,
74            ray_step,
75            radius_on_plane_squared,
76            inside,
77        ) else {
78            return (false, None);
79        };
80        let y = ab_ao + t * ab_dir;
81        if 0.0 < y && y < ab_ab && t >= 0.0 {
82            return (inside, Some(t));
83        }
84        y <= 0.0
85    };
86
87    if dir_dir == 0.0 {
88        return (inside, None);
89    }
90
91    // caps
92    let oc = if check_sphere_a {
93        ao
94    } else {
95        ray.origin - segment.b
96    };
97    let t = closest_approach_root(oc, ray.dir, dir_dir, radius_squared, inside);
98    (inside, t.filter(|t| *t >= 0.0))
99}
100
101/// Solves `|origin + t * dir|² = radius²` for the entry (outside) or exit (inside) root.
102///
103/// Returns `None` if the line misses. When inside, rounding can't turn the exit into a miss or a
104/// negative time.
105#[inline]
106fn closest_approach_root(
107    origin: Vector,
108    dir: Vector,
109    dir_dir: Real,
110    radius_squared: Real,
111    inside: bool,
112) -> Option<Real> {
113    let t_closest = -dir.dot(origin) / dir_dir;
114    let closest = origin + dir * t_closest;
115    let h = radius_squared - closest.length_squared();
116    if h < 0.0 && !inside {
117        return None;
118    }
119    let half_chord = <Real as ComplexField>::sqrt(h.max(0.0) / dir_dir);
120    if inside {
121        Some((t_closest + half_chord).max(0.0))
122    } else {
123        Some(t_closest - half_chord)
124    }
125}
126
127#[cfg(feature = "dim3")]
128#[inline]
129fn cross(v: Vector, segment: Vector) -> Vector {
130    v.cross(segment)
131}
132
133/// Returns a vector with zero y, which is complete nonsense
134/// but makes the 2D case work with the same code as the 3D case.
135#[cfg(feature = "dim2")]
136#[inline]
137fn cross(v: Vector, segment: Vector) -> Vector {
138    Vector::new(v.x * segment.y - v.y * segment.x, 0.0)
139}
140
141/// Computes the time of impact and contact normal of a ray on a capsule.
142fn ray_toi_and_normal_with_capsule(
143    segment: &Segment,
144    radius: Real,
145    ray: &Ray,
146    solid: bool,
147) -> Option<RayIntersection> {
148    let (inside, inter) = ray_toi_with_capsule(segment, radius, ray, solid);
149
150    inter.map(|t| {
151        let normal = if solid && inside {
152            Vector::ZERO
153        } else {
154            let p = ray.origin + ray.dir * t;
155            let a_to_p = p - segment.a;
156            let seg = segment.b - segment.a;
157            let seg_squared = seg.length_squared();
158
159            // the projection of the point onto the capsule's axis times the segment's length
160            let proj_times_seg = a_to_p.dot(seg);
161
162            // zero-radius capsules hit on the axis, where the normal is undefined
163            let n = if proj_times_seg <= 0.0 {
164                a_to_p.normalize_or_zero()
165            } else if proj_times_seg >= seg_squared {
166                (p - segment.b).normalize_or_zero()
167            } else {
168                (a_to_p - (proj_times_seg / seg_squared) * seg).normalize_or_zero()
169            };
170            if inside {
171                -n
172            } else {
173                n
174            }
175        };
176        RayIntersection::new(t, normal, FeatureId::Face(0))
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::math::Vector;
184    use crate::query::point::point_query::PointQuery;
185    use oorandom::Rand32;
186
187    #[test]
188    fn exact_cases() {
189        let c = Capsule::new(v2(0.0, 0.5), v2(0.0, 1.5), 0.5);
190        // Hit straight down the axis on the top cap, unnormalized direction.
191        expect_hit(&c, v2(0.0, 5.0), v2(0.0, -0.2), true, 15.0, v2(0.0, 1.0));
192        // Oblique hit on the cylinder.
193        expect_hit(&c, v2(5.0, 1.0), v2(-0.3, 0.0), true, 15.0, v2(1.0, 0.0));
194        // Tangential hit at the tip of the top cap.
195        expect_hit(&c, v2(5.0, 2.0), v2(-1.0, 0.0), true, 5.0, v2(0.0, 1.0));
196        // Hit on the bottom cap from below, parallel to the axis but offset
197        // from it.
198        expect_hit(
199            &c,
200            v2(0.1, -4.0),
201            v2(0.0, 0.2),
202            true,
203            20.0505,
204            v2(0.2, -0.9798),
205        );
206        // Lateral miss.
207        assert!(c
208            .cast_local_ray(&Ray::new(v2(10.0, 5.0), v2(0.0, 0.1)), 50.0, true)
209            .is_none());
210
211        // Outside-origin misses where the ray's line crosses the tube (or a cap
212        // sphere) only BEHIND the origin: the entry root is negative and must
213        // not be reported. The fuzz can't catch these (it only aims rays at
214        // interior points, so its entries are always positive).
215        // Inside the infinite tube past the b-cap, receding. The tube entry is
216        // behind (t = -0.9) with a phantom axis coordinate inside the band.
217        assert!(c
218            .cast_local_ray(&Ray::new(v2(0.4, 1.85), v2(1.0, 1.0)), 50.0, true)
219            .is_none());
220        // On-axis past the b-cap, parallel, receding (phantom t = -2.1).
221        assert!(c
222            .cast_local_ray(&Ray::new(v2(0.0, 2.1), v2(0.0, 1.0)), 50.0, true)
223            .is_none());
224        // Inside the tube past the b-cap, receding at an angle. Phantom axis
225        // coordinate beyond the slab, cap entry behind (t = -0.87).
226        assert!(c
227            .cast_local_ray(&Ray::new(v2(0.4, 1.85), v2(1.0, 0.2)), 50.0, true)
228            .is_none());
229        // Outside everything, receding. The line crosses the tube behind the
230        // origin, phantom axis coordinate in the band (t = -2.5).
231        assert!(c
232            .cast_local_ray(&Ray::new(v2(2.0, 1.0), v2(1.0, 0.0)), 50.0, true)
233            .is_none());
234        // Same, with the phantom axis coordinate exactly on the a-end boundary.
235        assert!(c
236            .cast_local_ray(&Ray::new(v2(2.0, 3.0), v2(1.0, 1.0)), 50.0, true)
237            .is_none());
238        // Inside, solid: contact at the origin, zero normal.
239        expect_hit(&c, v2(0.1, 1.0), v2(0.0, 1.0), true, 0.0, v2(0.0, 0.0));
240        // Inside, hollow: the exit, inward normal.
241        expect_hit(&c, v2(0.0, 1.0), v2(0.0, 1.0), false, 1.0, v2(0.0, -1.0));
242        // Same, toward the a-cap (the other parallel routing branch).
243        expect_hit(&c, v2(0.0, 1.0), v2(0.0, -1.0), false, 1.0, v2(0.0, 1.0));
244        // Inside, hollow: exit through the cylinder's side (the band exit
245        // root, not a cap).
246        expect_hit(&c, v2(0.0, 1.0), v2(1.0, 0.0), false, 0.5, v2(-1.0, 0.0));
247        // Same, oblique.
248        expect_hit(&c, v2(0.1, 0.8), v2(1.0, 0.5), false, 0.4, v2(-1.0, 0.0));
249        // Inside the b-cap sphere past the slab: the b-sphere exit (t = 0.6)
250        // is an intermediate crossing and must be skipped in favor of the
251        // last one (the a-sphere exit at t = 1.6).
252        expect_hit(&c, v2(0.0, 1.6), v2(0.0, -1.0), false, 1.6, v2(0.0, 1.0));
253        // Degenerate zero-length ray, inside / outside.
254        expect_hit(&c, v2(0.1, 1.0), v2(0.0, 0.0), true, 0.0, v2(0.0, 0.0));
255        assert!(c
256            .cast_local_ray(&Ray::new(v2(0.1, 3.0), v2(0.0, 0.0)), 50.0, true)
257            .is_none());
258        // Degenerate capsule (a == b): behaves as a ball of radius 1 at (0, 1).
259        let ball = Capsule::new(v2(0.0, 1.0), v2(0.0, 1.0), 1.0);
260        expect_hit(&ball, v2(0.0, 5.0), v2(0.0, -1.0), true, 3.0, v2(0.0, 1.0));
261        expect_hit(&ball, v2(0.5, 1.0), v2(1.0, 0.0), true, 0.0, v2(0.0, 0.0));
262        // max_toi filtering (the top-cap hit above is at t = 15).
263        assert!(c
264            .cast_local_ray(&Ray::new(v2(0.0, 5.0), v2(0.0, -0.2)), 14.9, true)
265            .is_none());
266        assert!(c
267            .cast_local_ray(&Ray::new(v2(0.0, 5.0), v2(0.0, -0.2)), 15.1, true)
268            .is_some());
269        assert!(c
270            .cast_local_ray_and_get_normal(&Ray::new(v2(0.0, 5.0), v2(0.0, -0.2)), 14.9, true)
271            .is_none());
272    }
273
274    #[test]
275    fn distant_origin_precision() {
276        // A plain quadratic loses the radius against the origin's distance in f32 and reports
277        // hits up to a whole radius off at these scales.
278        let c = Capsule::new(v2(1.0e4, 0.0), v2(1.0e4, 5.0), 1.0);
279        let i = c
280            .cast_local_ray_and_get_normal(&Ray::new(v2(-1.0e4, 2.5), v2(2.0e4, 0.0)), 2.0, true)
281            .unwrap();
282        let hit = -1.0e4 + i.time_of_impact * 2.0e4;
283        assert!((hit - (1.0e4 - 1.0)).abs() < 1.0e-2, "hit at x = {hit}");
284
285        // Oblique hit on the cylinder's side at (-1, 2.5).
286        let c = Capsule::new(v2(0.0, 0.0), v2(0.0, 5.0), 1.0);
287        let dir = v2(1.0e4, 3.0e3);
288        let i = c
289            .cast_local_ray_and_get_normal(&Ray::new(v2(-1.0, 2.5) - dir, dir), 2.0, true)
290            .unwrap();
291        assert!((i.time_of_impact - 1.0).abs() * dir.length() < 1.0e-2);
292        assert!((i.normal - v2(-1.0, 0.0)).length() < 1.0e-3);
293    }
294
295    #[test]
296    fn zero_radius_normal_is_finite() {
297        let c = Capsule::new(v2(0.0, -1.0), v2(0.0, 1.0), 0.0);
298        for dir in [v2(1.0, 0.0), v2(0.0, -1.0)] {
299            let i = c
300                .cast_local_ray_and_get_normal(&Ray::new(v2(0.0, 0.0) - dir * 2.0, dir), 10.0, true)
301                .unwrap();
302            assert!(i.normal.is_finite(), "normal: {:?}", i.normal);
303        }
304    }
305
306    fn v2(x: Real, y: Real) -> Vector {
307        Vector::new(
308            x,
309            y,
310            #[cfg(feature = "dim3")]
311            0.0,
312        )
313    }
314
315    #[track_caller]
316    fn expect_hit(c: &Capsule, o: Vector, d: Vector, solid: bool, et: Real, en: Vector) {
317        let i = c
318            .cast_local_ray_and_get_normal(&Ray::new(o, d), 50.0, solid)
319            .unwrap_or_else(|| panic!("expected hit (o={:?}, d={:?})", o, d));
320        assert!(
321            (i.time_of_impact - et).abs() < 1e-4,
322            "t: got {}, want {}",
323            i.time_of_impact,
324            et
325        );
326        assert!(
327            (i.normal - en).length() < 1e-3,
328            "n: got {:?}, want {:?}",
329            i.normal,
330            en
331        );
332    }
333
334    #[test]
335    fn fuzz_capsule_ray_casts() {
336        let epsilon = 0.002;
337        let mut rng = Rand32::new(42);
338
339        for _ in 0..100_000 {
340            let (a, b) = (rnd_vec(&mut rng, 10.0), rnd_vec(&mut rng, 10.0));
341            let r = 0.5 + 5.0 * rnd(&mut rng);
342            let capsule = Capsule::new(a, b, r);
343
344            // a random point inside the capsule
345            let inside = {
346                let mut w = rnd_vec(&mut rng, r);
347                while w.length_squared() >= r * r {
348                    w = rnd_vec(&mut rng, r);
349                }
350                a + (b - a) * rnd(&mut rng) + w
351            };
352
353            // cast random ray toward the inside point
354            let far_enough = r + a.distance(b);
355            let mut offset = Vector::ZERO;
356            while offset.length_squared() < far_enough * far_enough {
357                offset = rnd_vec(&mut rng, far_enough * 2.0);
358            }
359
360            let o = (a + b) * 0.5 + offset;
361            let d = (inside - o) * (0.1 + 0.9 * rnd(&mut rng));
362            let i = capsule
363                .cast_local_ray_and_get_normal(&Ray::new(o, d), 1000.0, true)
364                .expect("a ray aimed at an interior point must hit");
365
366            let hit = o + d * i.time_of_impact;
367            assert!(
368                capsule.contains_local_point(hit - i.normal * epsilon),
369                "nudging inward along the normal should go inside the capsule"
370            );
371            assert!(
372                !capsule.contains_local_point(hit + i.normal * epsilon),
373                "nudging outward along the normal should go outside the capsule"
374            );
375
376            // A ray from the interior point to the far point (hollow) must
377            // exit the capsule; its normal points inward.
378            let i_in = capsule
379                .cast_local_ray_and_get_normal(&Ray::new(inside, o - inside), 1000.0, false)
380                .expect("a ray from inside toward the outside must exit");
381            let hit_in = inside + (o - inside) * i_in.time_of_impact;
382            assert!(
383                capsule.contains_local_point(hit_in + i_in.normal * epsilon),
384                "nudging along the inward normal should stay inside"
385            );
386            assert!(
387                !capsule.contains_local_point(hit_in - i_in.normal * epsilon),
388                "nudging against the inward normal should go outside"
389            );
390
391            assert!(
392                capsule
393                    .cast_local_ray(&Ray::new(o, -d), 1000.0, true)
394                    .is_none(),
395                "a retreating ray must miss"
396            );
397
398            #[cfg(feature = "dim2")]
399            let tangent = Vector::new(-i.normal.y, i.normal.x);
400            #[cfg(feature = "dim3")]
401            let tangent = {
402                let mut tangent = Vector::ZERO;
403                while tangent.length_squared() < 1e-8 {
404                    tangent = rnd_vec(&mut rng, 1.0).cross(i.normal);
405                }
406                tangent
407            };
408            let origin = hit + i.normal * (epsilon + rnd(&mut rng)) - rnd(&mut rng) * tangent;
409            assert!(
410                capsule
411                    .cast_local_ray(&Ray::new(origin, tangent), 1000.0, true)
412                    .is_none(),
413                "tangent outside the capsule should miss"
414            );
415        }
416    }
417
418    fn rnd(rng: &mut Rand32) -> Real {
419        #[cfg(feature = "f32")]
420        {
421            rng.rand_float()
422        }
423        #[cfg(feature = "f64")]
424        {
425            rng.rand_float() as Real
426        }
427    }
428
429    fn rnd_vec(rng: &mut Rand32, scale: Real) -> Vector {
430        let mut component = || (rnd(rng) - 0.5) * 2.0 * scale;
431        #[cfg(feature = "dim2")]
432        {
433            Vector::new(component(), component())
434        }
435        #[cfg(feature = "dim3")]
436        {
437            Vector::new(component(), component(), component())
438        }
439    }
440}