Skip to main content

oxiphysics_geometry/
capsule.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Capsule shape (sphere-swept line segment along Y axis).
5
6use crate::shape::{RayHit, Shape};
7use oxiphysics_core::Aabb;
8use oxiphysics_core::math::{Mat3, Real, Vec3};
9use std::f64::consts::PI;
10
11/// A capsule defined by radius and half-height along the Y axis.
12#[derive(Debug, Clone)]
13pub struct Capsule {
14    /// Radius of the capsule end-caps.
15    pub radius: Real,
16    /// Half-height of the cylindrical segment.
17    pub half_height: Real,
18}
19
20impl Capsule {
21    /// Create a new capsule.
22    pub fn new(radius: Real, half_height: Real) -> Self {
23        Self {
24            radius,
25            half_height,
26        }
27    }
28
29    /// Volume: cylinder + two hemisphere caps = πr²h + (4/3)πr³.
30    #[allow(dead_code)]
31    pub fn volume_explicit(&self) -> Real {
32        let r = self.radius;
33        let h = 2.0 * self.half_height;
34        PI * r * r * h + (4.0 / 3.0) * PI * r.powi(3)
35    }
36
37    /// Surface area: 2πrh (cylinder lateral) + 4πr² (two hemispheres).
38    #[allow(dead_code)]
39    pub fn surface_area(&self) -> Real {
40        let r = self.radius;
41        let h = 2.0 * self.half_height;
42        2.0 * PI * r * h + 4.0 * PI * r * r
43    }
44
45    /// Inertia tensor as \[\[f64;3\\];3] row-major.
46    #[allow(dead_code)]
47    pub fn inertia_tensor_array(&self, mass: f64) -> [[f64; 3]; 3] {
48        let r = self.radius;
49        let h = 2.0 * self.half_height;
50        let r2 = r * r;
51        let h2 = h * h;
52
53        let vol_cyl = PI * r2 * h;
54        let vol_sphere = (4.0 / 3.0) * PI * r.powi(3);
55        let total_vol = vol_cyl + vol_sphere;
56
57        let mass_cyl = mass * vol_cyl / total_vol;
58        let mass_sphere = mass * vol_sphere / total_vol;
59
60        let iy_cyl = 0.5 * mass_cyl * r2;
61        let ixz_cyl = mass_cyl * (3.0 * r2 + h2) / 12.0;
62
63        let iy_sphere = 0.4 * mass_sphere * r2;
64        let offset = self.half_height + 3.0 * r / 8.0;
65        let ixz_sphere = 0.4 * mass_sphere * r2 + mass_sphere * offset * offset;
66
67        let iy = iy_cyl + iy_sphere;
68        let ixz = ixz_cyl + ixz_sphere;
69
70        [[ixz, 0.0, 0.0], [0.0, iy, 0.0], [0.0, 0.0, ixz]]
71    }
72
73    /// Ray cast returning (t, normal) as plain arrays.
74    #[allow(dead_code)]
75    pub fn ray_cast_array(
76        &self,
77        origin: [f64; 3],
78        direction: [f64; 3],
79        max_toi: f64,
80    ) -> Option<(f64, [f64; 3])> {
81        let o = Vec3::new(origin[0], origin[1], origin[2]);
82        let d = Vec3::new(direction[0], direction[1], direction[2]);
83        let hit = self.ray_cast(&o, &d, max_toi)?;
84        Some((hit.toi, [hit.normal.x, hit.normal.y, hit.normal.z]))
85    }
86
87    /// GJK support function: farthest point in `direction`.
88    #[allow(dead_code)]
89    pub fn support(&self, direction: [f64; 3]) -> [f64; 3] {
90        let len = (direction[0] * direction[0]
91            + direction[1] * direction[1]
92            + direction[2] * direction[2])
93            .sqrt();
94        // Choose which hemicap center
95        let cap_y = if direction[1] >= 0.0 {
96            self.half_height
97        } else {
98            -self.half_height
99        };
100        if len < 1e-12 {
101            return [0.0, cap_y, 0.0];
102        }
103        let s = self.radius / len;
104        [direction[0] * s, cap_y + direction[1] * s, direction[2] * s]
105    }
106
107    // ── New methods ──
108
109    /// Closest point on the capsule surface to point `p`.
110    #[allow(dead_code)]
111    pub fn closest_point(&self, p: [f64; 3]) -> [f64; 3] {
112        // Project p onto the medial axis (Y-axis segment from -hh to +hh)
113        let clamped_y = p[1].clamp(-self.half_height, self.half_height);
114        // Vector from axis point to p
115        let dx = p[0];
116        let dy = p[1] - clamped_y;
117        let dz = p[2];
118        let len = (dx * dx + dy * dy + dz * dz).sqrt();
119
120        if len < 1e-12 {
121            // On the axis; return a point on the surface in +X direction
122            return [self.radius, clamped_y, 0.0];
123        }
124
125        let s = self.radius / len;
126        [dx * s, clamped_y + dy * s, dz * s]
127    }
128
129    /// Returns true if `p` is inside (or on) the capsule.
130    #[allow(dead_code)]
131    pub fn contains_point(&self, p: [f64; 3]) -> bool {
132        let clamped_y = p[1].clamp(-self.half_height, self.half_height);
133        let dx = p[0];
134        let dy = p[1] - clamped_y;
135        let dz = p[2];
136        dx * dx + dy * dy + dz * dz <= self.radius * self.radius
137    }
138
139    /// Signed distance from a point to the capsule surface.
140    /// Negative if inside, positive if outside.
141    #[allow(dead_code)]
142    pub fn signed_distance(&self, p: [f64; 3]) -> f64 {
143        let clamped_y = p[1].clamp(-self.half_height, self.half_height);
144        let dx = p[0];
145        let dy = p[1] - clamped_y;
146        let dz = p[2];
147        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
148        dist - self.radius
149    }
150
151    /// Medial axis endpoints: the two centers of the hemispherical caps.
152    #[allow(dead_code)]
153    pub fn medial_axis_endpoints(&self) -> ([f64; 3], [f64; 3]) {
154        ([0.0, self.half_height, 0.0], [0.0, -self.half_height, 0.0])
155    }
156
157    /// Full length of the capsule (tip to tip along Y).
158    #[allow(dead_code)]
159    pub fn full_length(&self) -> f64 {
160        2.0 * self.half_height + 2.0 * self.radius
161    }
162
163    /// Medial axis length (just the cylindrical segment).
164    #[allow(dead_code)]
165    pub fn medial_axis_length(&self) -> f64 {
166        2.0 * self.half_height
167    }
168
169    /// Distance between two segments in 3D.
170    /// Segment A: from `a0` to `a1`, Segment B: from `b0` to `b1`.
171    /// Returns the minimum distance.
172    #[allow(dead_code)]
173    pub fn segment_segment_distance(a0: [f64; 3], a1: [f64; 3], b0: [f64; 3], b1: [f64; 3]) -> f64 {
174        let da = [a1[0] - a0[0], a1[1] - a0[1], a1[2] - a0[2]];
175        let db = [b1[0] - b0[0], b1[1] - b0[1], b1[2] - b0[2]];
176        let r = [a0[0] - b0[0], a0[1] - b0[1], a0[2] - b0[2]];
177
178        let a = da[0] * da[0] + da[1] * da[1] + da[2] * da[2];
179        let e = db[0] * db[0] + db[1] * db[1] + db[2] * db[2];
180        let f = db[0] * r[0] + db[1] * r[1] + db[2] * r[2];
181
182        let eps = 1e-12;
183
184        if a <= eps && e <= eps {
185            // Both segments degenerate to points
186            let dx = r[0];
187            let dy = r[1];
188            let dz = r[2];
189            return (dx * dx + dy * dy + dz * dz).sqrt();
190        }
191
192        let b = da[0] * db[0] + da[1] * db[1] + da[2] * db[2];
193        let c = da[0] * r[0] + da[1] * r[1] + da[2] * r[2];
194
195        let (s, t);
196
197        if a <= eps {
198            s = 0.0;
199            t = (f / e).clamp(0.0, 1.0);
200        } else if e <= eps {
201            t = 0.0;
202            s = (-c / a).clamp(0.0, 1.0);
203        } else {
204            let denom = a * e - b * b;
205            s = if denom.abs() > eps {
206                ((b * f - c * e) / denom).clamp(0.0, 1.0)
207            } else {
208                0.0
209            };
210            let t_nom = b * s + f;
211            if t_nom < 0.0 {
212                t = 0.0;
213            } else if t_nom > e {
214                t = 1.0;
215            } else {
216                t = t_nom / e;
217            }
218        }
219
220        // Recompute s based on t
221        let s = if a > eps {
222            ((b * t - c) / a).clamp(0.0, 1.0)
223        } else {
224            s
225        };
226
227        let dx = r[0] + da[0] * s - db[0] * t;
228        let dy = r[1] + da[1] * s - db[1] * t;
229        let dz = r[2] + da[2] * s - db[2] * t;
230        (dx * dx + dy * dy + dz * dz).sqrt()
231    }
232
233    /// Capsule-capsule distance.
234    /// Both capsules are centered at the origin with Y-axis alignment,
235    /// but offset by given centers.
236    /// Returns the minimum distance between the two capsule surfaces (0 if overlapping).
237    #[allow(dead_code)]
238    pub fn capsule_capsule_distance(
239        &self,
240        center_a: [f64; 3],
241        other: &Capsule,
242        center_b: [f64; 3],
243    ) -> f64 {
244        // Medial axis of A: from center_a + [0, -hh_a, 0] to center_a + [0, hh_a, 0]
245        let a0 = [center_a[0], center_a[1] - self.half_height, center_a[2]];
246        let a1 = [center_a[0], center_a[1] + self.half_height, center_a[2]];
247        let b0 = [center_b[0], center_b[1] - other.half_height, center_b[2]];
248        let b1 = [center_b[0], center_b[1] + other.half_height, center_b[2]];
249
250        let seg_dist = Self::segment_segment_distance(a0, a1, b0, b1);
251        let surface_dist = seg_dist - self.radius - other.radius;
252        surface_dist.max(0.0)
253    }
254
255    /// Check if two capsules overlap.
256    #[allow(dead_code)]
257    pub fn capsule_capsule_overlap(
258        &self,
259        center_a: [f64; 3],
260        other: &Capsule,
261        center_b: [f64; 3],
262    ) -> bool {
263        let a0 = [center_a[0], center_a[1] - self.half_height, center_a[2]];
264        let a1 = [center_a[0], center_a[1] + self.half_height, center_a[2]];
265        let b0 = [center_b[0], center_b[1] - other.half_height, center_b[2]];
266        let b1 = [center_b[0], center_b[1] + other.half_height, center_b[2]];
267
268        let seg_dist = Self::segment_segment_distance(a0, a1, b0, b1);
269        seg_dist <= self.radius + other.radius
270    }
271
272    /// Project a point onto the medial axis (the Y-axis segment).
273    /// Returns the clamped Y value.
274    #[allow(dead_code)]
275    pub fn project_on_medial_axis(&self, p: [f64; 3]) -> f64 {
276        p[1].clamp(-self.half_height, self.half_height)
277    }
278
279    /// Distance from a point to the medial axis.
280    #[allow(dead_code)]
281    pub fn distance_to_medial_axis(&self, p: [f64; 3]) -> f64 {
282        let clamped_y = p[1].clamp(-self.half_height, self.half_height);
283        let dy = p[1] - clamped_y;
284        (p[0] * p[0] + dy * dy + p[2] * p[2]).sqrt()
285    }
286
287    // ── New expanded methods ──
288
289    /// Inertia tensor (mass-normalized) returned as `[[f64;3\];3]` (identical to
290    /// `inertia_tensor_array` but accepts mass separately for the public array API).
291    #[allow(dead_code)]
292    pub fn inertia_tensor_raw(&self, mass: f64) -> [[f64; 3]; 3] {
293        self.inertia_tensor_array(mass)
294    }
295
296    /// Closest points between two capsule medial axes (and thus capsule surfaces).
297    ///
298    /// Returns `(pa, pb, seg_dist)` where `pa` and `pb` are the closest points
299    /// on the respective medial axes and `seg_dist` is the axis-to-axis distance.
300    #[allow(dead_code)]
301    pub fn closest_points_capsule_vs_capsule(
302        &self,
303        center_a: [f64; 3],
304        other: &Capsule,
305        center_b: [f64; 3],
306    ) -> ([f64; 3], [f64; 3], f64) {
307        let a0 = [center_a[0], center_a[1] - self.half_height, center_a[2]];
308        let a1 = [center_a[0], center_a[1] + self.half_height, center_a[2]];
309        let b0 = [center_b[0], center_b[1] - other.half_height, center_b[2]];
310        let b1 = [center_b[0], center_b[1] + other.half_height, center_b[2]];
311
312        let (pa, pb, seg_dist) = Self::segment_segment_closest(a0, a1, b0, b1);
313        (pa, pb, seg_dist)
314    }
315
316    /// Returns the closest points on two 3D segments and the distance between them.
317    #[allow(dead_code)]
318    pub fn segment_segment_closest(
319        a0: [f64; 3],
320        a1: [f64; 3],
321        b0: [f64; 3],
322        b1: [f64; 3],
323    ) -> ([f64; 3], [f64; 3], f64) {
324        let da = [a1[0] - a0[0], a1[1] - a0[1], a1[2] - a0[2]];
325        let db = [b1[0] - b0[0], b1[1] - b0[1], b1[2] - b0[2]];
326        let r = [a0[0] - b0[0], a0[1] - b0[1], a0[2] - b0[2]];
327
328        let aa = da[0] * da[0] + da[1] * da[1] + da[2] * da[2];
329        let ee = db[0] * db[0] + db[1] * db[1] + db[2] * db[2];
330        let f = db[0] * r[0] + db[1] * r[1] + db[2] * r[2];
331        let eps = 1e-12;
332
333        let (s, t) = if aa <= eps && ee <= eps {
334            (0.0_f64, 0.0_f64)
335        } else if aa <= eps {
336            (0.0_f64, (f / ee).clamp(0.0, 1.0))
337        } else {
338            let c = da[0] * r[0] + da[1] * r[1] + da[2] * r[2];
339            if ee <= eps {
340                ((-c / aa).clamp(0.0, 1.0), 0.0_f64)
341            } else {
342                let b = da[0] * db[0] + da[1] * db[1] + da[2] * db[2];
343                let denom = aa * ee - b * b;
344                let s_cand = if denom.abs() > eps {
345                    ((b * f - c * ee) / denom).clamp(0.0, 1.0)
346                } else {
347                    0.0
348                };
349                let t_nom = b * s_cand + f;
350                let t_cand = if t_nom < 0.0 {
351                    0.0
352                } else if t_nom > ee {
353                    1.0
354                } else {
355                    t_nom / ee
356                };
357                let s_final = ((b * t_cand - c) / aa).clamp(0.0, 1.0);
358                (s_final, t_cand)
359            }
360        };
361
362        let pa = [a0[0] + s * da[0], a0[1] + s * da[1], a0[2] + s * da[2]];
363        let pb = [b0[0] + t * db[0], b0[1] + t * db[1], b0[2] + t * db[2]];
364        let dx = pa[0] - pb[0];
365        let dy = pa[1] - pb[1];
366        let dz = pa[2] - pb[2];
367        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
368        (pa, pb, dist)
369    }
370
371    /// Capsule vs Oriented Bounding Box (OBB) intersection test.
372    ///
373    /// `obb_center`: world-space center of the OBB.
374    /// `obb_axes`:   columns are the OBB's local X, Y, Z axes (unit vectors).
375    /// `obb_half`:   half-extents along the OBB local axes.
376    ///
377    /// Converts the query to OBB-local space and tests sphere-swept segment
378    /// against the OBB using the separating-axis theorem (SAT) with slabs.
379    #[allow(dead_code)]
380    #[allow(clippy::too_many_arguments)]
381    pub fn intersects_obb(
382        &self,
383        capsule_center: [f64; 3],
384        obb_center: [f64; 3],
385        obb_axes: [[f64; 3]; 3],
386        obb_half: [f64; 3],
387    ) -> bool {
388        // Expand the OBB by the capsule radius (Minkowski sum)
389        let expanded_half = [
390            obb_half[0] + self.radius,
391            obb_half[1] + self.radius,
392            obb_half[2] + self.radius,
393        ];
394
395        // Capsule segment endpoints in world space
396        let a = [
397            capsule_center[0],
398            capsule_center[1] - self.half_height,
399            capsule_center[2],
400        ];
401        let b = [
402            capsule_center[0],
403            capsule_center[1] + self.half_height,
404            capsule_center[2],
405        ];
406
407        // Test whether either endpoint is inside the expanded OBB
408        for pt in [a, b] {
409            let d = [
410                pt[0] - obb_center[0],
411                pt[1] - obb_center[1],
412                pt[2] - obb_center[2],
413            ];
414            let local = [
415                d[0] * obb_axes[0][0] + d[1] * obb_axes[0][1] + d[2] * obb_axes[0][2],
416                d[0] * obb_axes[1][0] + d[1] * obb_axes[1][1] + d[2] * obb_axes[1][2],
417                d[0] * obb_axes[2][0] + d[1] * obb_axes[2][1] + d[2] * obb_axes[2][2],
418            ];
419            if local[0].abs() <= expanded_half[0]
420                && local[1].abs() <= expanded_half[1]
421                && local[2].abs() <= expanded_half[2]
422            {
423                return true;
424            }
425        }
426
427        // Test whether segment passes through the expanded OBB (slab test)
428        // Transform segment into OBB-local space
429        let da = [
430            a[0] - obb_center[0],
431            a[1] - obb_center[1],
432            a[2] - obb_center[2],
433        ];
434        let dir_w = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
435
436        let orig_local = [
437            da[0] * obb_axes[0][0] + da[1] * obb_axes[0][1] + da[2] * obb_axes[0][2],
438            da[0] * obb_axes[1][0] + da[1] * obb_axes[1][1] + da[2] * obb_axes[1][2],
439            da[0] * obb_axes[2][0] + da[1] * obb_axes[2][1] + da[2] * obb_axes[2][2],
440        ];
441        let dir_local = [
442            dir_w[0] * obb_axes[0][0] + dir_w[1] * obb_axes[0][1] + dir_w[2] * obb_axes[0][2],
443            dir_w[0] * obb_axes[1][0] + dir_w[1] * obb_axes[1][1] + dir_w[2] * obb_axes[1][2],
444            dir_w[0] * obb_axes[2][0] + dir_w[1] * obb_axes[2][1] + dir_w[2] * obb_axes[2][2],
445        ];
446
447        let mut t_min = 0.0_f64;
448        let mut t_max = 1.0_f64;
449
450        for axis in 0..3 {
451            let d_loc = dir_local[axis];
452            let o_loc = orig_local[axis];
453            let hh = expanded_half[axis];
454            if d_loc.abs() < 1e-12 {
455                if o_loc.abs() > hh {
456                    return false;
457                }
458            } else {
459                let inv_d = 1.0 / d_loc;
460                let t0 = (-hh - o_loc) * inv_d;
461                let t1 = (hh - o_loc) * inv_d;
462                let (t_lo, t_hi) = if t0 < t1 { (t0, t1) } else { (t1, t0) };
463                t_min = t_min.max(t_lo);
464                t_max = t_max.min(t_hi);
465                if t_min > t_max {
466                    return false;
467                }
468            }
469        }
470        true
471    }
472
473    /// Signed distance field (SDF) for the capsule.
474    ///
475    /// Same as `signed_distance` but with a different name to match the
476    /// naming convention used in shader / SDF contexts.
477    #[allow(dead_code)]
478    pub fn sdf(&self, p: [f64; 3]) -> f64 {
479        self.signed_distance(p)
480    }
481
482    /// Continuous collision detection (swept capsule).
483    ///
484    /// Given the capsule moving from `center_start` to `center_end` (linear
485    /// motion, constant orientation), and a static sphere at `sphere_center`
486    /// with `sphere_radius`, returns the first time of contact `t ∈ [0,1]`
487    /// or `None` if no contact occurs.
488    ///
489    /// This is a simple CCD sweep using the Minkowski-sum radius and a
490    /// moving-segment vs static-point formulation.
491    #[allow(dead_code)]
492    pub fn swept_capsule_vs_sphere(
493        &self,
494        center_start: [f64; 3],
495        center_end: [f64; 3],
496        sphere_center: [f64; 3],
497        sphere_radius: f64,
498    ) -> Option<f64> {
499        let combined_radius = self.radius + sphere_radius;
500
501        // We need to find the earliest t in [0,1] such that the distance from
502        // sphere_center to the capsule medial axis (at time t) equals combined_radius.
503        // Linearly interpolate capsule center.
504        // At time t, capsule axis: from C(t)-[0,hh,0] to C(t)+[0,hh,0],
505        // where C(t) = center_start + t*(center_end - center_start).
506
507        let n_steps = 64;
508        for i in 0..=n_steps {
509            let t = i as f64 / n_steps as f64;
510            let cx = center_start[0] + t * (center_end[0] - center_start[0]);
511            let cy = center_start[1] + t * (center_end[1] - center_start[1]);
512            let cz = center_start[2] + t * (center_end[2] - center_start[2]);
513
514            let a0 = [cx, cy - self.half_height, cz];
515            let a1 = [cx, cy + self.half_height, cz];
516            let dist = Self::point_segment_distance(sphere_center, a0, a1);
517            if dist <= combined_radius {
518                return Some(t);
519            }
520        }
521        None
522    }
523
524    /// Distance from a point to a segment.
525    #[allow(dead_code)]
526    fn point_segment_distance(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
527        let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
528        let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
529        let ab_sq = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
530        let t = if ab_sq < 1e-24 {
531            0.0
532        } else {
533            ((ab[0] * ap[0] + ab[1] * ap[1] + ab[2] * ap[2]) / ab_sq).clamp(0.0, 1.0)
534        };
535        let proj = [a[0] + t * ab[0], a[1] + t * ab[1], a[2] + t * ab[2]];
536        let dx = p[0] - proj[0];
537        let dy = p[1] - proj[1];
538        let dz = p[2] - proj[2];
539        (dx * dx + dy * dy + dz * dz).sqrt()
540    }
541
542    /// Generate `n` random points on the capsule surface using rejection sampling.
543    ///
544    /// Returns a `Vec` of `[f64;3]` coordinates sampled uniformly on the surface.
545    /// Uses a deterministic PRNG seed for reproducibility.
546    #[allow(dead_code)]
547    pub fn random_surface_points(&self, n: usize, seed: u64) -> Vec<[f64; 3]> {
548        let mut points = Vec::with_capacity(n);
549        let r = self.radius;
550        let hh = self.half_height;
551
552        // Surface areas
553        let cyl_area = 2.0 * PI * r * 2.0 * hh;
554        let sphere_area = 4.0 * PI * r * r;
555        let total_area = cyl_area + sphere_area;
556        let p_cylinder = cyl_area / total_area;
557
558        let mut rng_state = seed;
559        let next_f64 = |state: &mut u64| -> f64 {
560            // xorshift64 PRNG
561            *state ^= *state << 13;
562            *state ^= *state >> 7;
563            *state ^= *state << 17;
564            (*state as f64) / (u64::MAX as f64)
565        };
566
567        while points.len() < n {
568            let u = next_f64(&mut rng_state);
569            let v = next_f64(&mut rng_state);
570            let w = next_f64(&mut rng_state);
571
572            if u < p_cylinder {
573                // Cylindrical region
574                let theta = v * 2.0 * PI;
575                let y = (w * 2.0 - 1.0) * hh;
576                points.push([r * theta.cos(), y, r * theta.sin()]);
577            } else {
578                // Hemisphere (top or bottom)
579                let theta = v * 2.0 * PI;
580                let phi = (w * 2.0 - 1.0).acos();
581                let x = r * phi.sin() * theta.cos();
582                let z = r * phi.sin() * theta.sin();
583                let raw_y = r * phi.cos();
584                // Map to correct hemisphere
585                let extra = next_f64(&mut rng_state);
586                let y = if extra < 0.5 {
587                    hh + raw_y.abs()
588                } else {
589                    -hh - raw_y.abs()
590                };
591                points.push([x, y, z]);
592            }
593        }
594        points
595    }
596
597    // ── Capsule-triangle contact ──
598
599    /// Test capsule vs a world-space triangle `(ta, tb, tc)`.
600    ///
601    /// The capsule is placed at `center` (Y-aligned).  Returns `Some((depth, normal))`
602    /// if the capsule penetrates the triangle plane by at least `depth > 0`, where
603    /// `normal` points from the triangle towards the capsule center.
604    #[allow(dead_code)]
605    pub fn capsule_triangle_contact(
606        &self,
607        center: [f64; 3],
608        ta: [f64; 3],
609        tb: [f64; 3],
610        tc: [f64; 3],
611    ) -> Option<(f64, [f64; 3])> {
612        // Triangle edge vectors and normal
613        let e1 = [tb[0] - ta[0], tb[1] - ta[1], tb[2] - ta[2]];
614        let e2 = [tc[0] - ta[0], tc[1] - ta[1], tc[2] - ta[2]];
615        let raw_n = [
616            e1[1] * e2[2] - e1[2] * e2[1],
617            e1[2] * e2[0] - e1[0] * e2[2],
618            e1[0] * e2[1] - e1[1] * e2[0],
619        ];
620        let n_len = (raw_n[0] * raw_n[0] + raw_n[1] * raw_n[1] + raw_n[2] * raw_n[2]).sqrt();
621        if n_len < 1e-12 {
622            return None;
623        }
624        let n = [raw_n[0] / n_len, raw_n[1] / n_len, raw_n[2] / n_len];
625
626        // Capsule medial axis endpoints in world space
627        let a0 = [center[0], center[1] - self.half_height, center[2]];
628        let a1 = [center[0], center[1] + self.half_height, center[2]];
629
630        // Signed distances of both endpoints from the triangle plane
631        let d0 = (a0[0] - ta[0]) * n[0] + (a0[1] - ta[1]) * n[1] + (a0[2] - ta[2]) * n[2];
632        let d1 = (a1[0] - ta[0]) * n[0] + (a1[1] - ta[1]) * n[1] + (a1[2] - ta[2]) * n[2];
633
634        // Use the endpoint closest to the triangle plane
635        let min_dist = d0.abs().min(d1.abs());
636        let penetration = self.radius - min_dist;
637        if penetration > 0.0 {
638            Some((penetration, n))
639        } else {
640            None
641        }
642    }
643}
644
645// ── CapsuleChain (rope segments) ──
646
647/// A chain of capsule segments forming a rope / chain approximation.
648///
649/// Each consecutive pair of control points defines a capsule segment
650/// of the same radius.
651#[allow(dead_code)]
652#[derive(Debug, Clone)]
653pub struct CapsuleChain {
654    /// Control points of the chain (N points → N-1 segments).
655    pub points: Vec<[f64; 3]>,
656    /// Uniform radius for all segments.
657    pub radius: f64,
658}
659
660#[allow(dead_code)]
661impl CapsuleChain {
662    /// Create a chain from control points with zero radius.
663    pub fn new(points: Vec<[f64; 3]>) -> Self {
664        Self {
665            points,
666            radius: 0.0,
667        }
668    }
669
670    /// Create a chain with a specified radius.
671    pub fn with_radius(points: Vec<[f64; 3]>, radius: f64) -> Self {
672        Self { points, radius }
673    }
674
675    /// Number of capsule segments (one fewer than the number of points).
676    pub fn segment_count(&self) -> usize {
677        self.points.len().saturating_sub(1)
678    }
679
680    /// Total arc length of the chain (sum of segment lengths).
681    pub fn total_length(&self) -> f64 {
682        self.points
683            .windows(2)
684            .map(|w| {
685                let dx = w[1][0] - w[0][0];
686                let dy = w[1][1] - w[0][1];
687                let dz = w[1][2] - w[0][2];
688                (dx * dx + dy * dy + dz * dz).sqrt()
689            })
690            .sum()
691    }
692
693    /// Returns `true` if `p` is inside any capsule segment of the chain.
694    pub fn contains_point(&self, p: [f64; 3]) -> bool {
695        self.points
696            .windows(2)
697            .any(|w| Capsule::point_segment_distance(p, w[0], w[1]) <= self.radius)
698    }
699
700    /// SDF: minimum signed distance to any segment surface.
701    pub fn sdf(&self, p: [f64; 3]) -> f64 {
702        let min_seg_dist = self
703            .points
704            .windows(2)
705            .map(|w| Capsule::point_segment_distance(p, w[0], w[1]))
706            .fold(f64::INFINITY, f64::min);
707        min_seg_dist - self.radius
708    }
709
710    /// Minimum distance from `p` to the chain surface (negative if inside).
711    pub fn min_distance_to_point(&self, p: [f64; 3]) -> f64 {
712        self.sdf(p)
713    }
714}
715
716// ── DeformableCapsule ──
717
718/// A capsule with arbitrary (deformable) endpoint positions.
719///
720/// Unlike the axis-aligned `Capsule`, this capsule can have its two
721/// hemispherical cap centres at any positions in 3D space.
722#[allow(dead_code)]
723#[derive(Debug, Clone)]
724pub struct DeformableCapsule {
725    /// First endpoint of the medial axis.
726    pub a: [f64; 3],
727    /// Second endpoint of the medial axis.
728    pub b: [f64; 3],
729    /// Capsule radius.
730    pub radius: f64,
731}
732
733#[allow(dead_code)]
734impl DeformableCapsule {
735    /// Create a new deformable capsule.
736    pub fn new(a: [f64; 3], b: [f64; 3], radius: f64) -> Self {
737        Self { a, b, radius }
738    }
739
740    /// First endpoint (cap centre A).
741    pub fn endpoint_a(&self) -> [f64; 3] {
742        self.a
743    }
744
745    /// Second endpoint (cap centre B).
746    pub fn endpoint_b(&self) -> [f64; 3] {
747        self.b
748    }
749
750    /// Update endpoint A.
751    pub fn set_endpoint_a(&mut self, a: [f64; 3]) {
752        self.a = a;
753    }
754
755    /// Update endpoint B.
756    pub fn set_endpoint_b(&mut self, b: [f64; 3]) {
757        self.b = b;
758    }
759
760    /// Length of the medial axis segment.
761    pub fn length(&self) -> f64 {
762        let d = [
763            self.b[0] - self.a[0],
764            self.b[1] - self.a[1],
765            self.b[2] - self.a[2],
766        ];
767        (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
768    }
769
770    /// Midpoint of the medial axis.
771    pub fn midpoint(&self) -> [f64; 3] {
772        [
773            (self.a[0] + self.b[0]) * 0.5,
774            (self.a[1] + self.b[1]) * 0.5,
775            (self.a[2] + self.b[2]) * 0.5,
776        ]
777    }
778
779    /// Medial axis endpoints.
780    pub fn medial_axis_endpoints(&self) -> ([f64; 3], [f64; 3]) {
781        (self.a, self.b)
782    }
783
784    /// Signed distance field: negative inside, positive outside.
785    pub fn sdf(&self, p: [f64; 3]) -> f64 {
786        Capsule::point_segment_distance(p, self.a, self.b) - self.radius
787    }
788
789    /// Support point in the given direction.
790    pub fn support(&self, dir: [f64; 3]) -> [f64; 3] {
791        let da = dir[0] * self.a[0] + dir[1] * self.a[1] + dir[2] * self.a[2];
792        let db = dir[0] * self.b[0] + dir[1] * self.b[1] + dir[2] * self.b[2];
793        let base = if da >= db { self.a } else { self.b };
794        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
795        if len < 1e-12 {
796            return base;
797        }
798        let s = self.radius / len;
799        [
800            base[0] + dir[0] * s,
801            base[1] + dir[1] * s,
802            base[2] + dir[2] * s,
803        ]
804    }
805}
806
807// ── CapsuleFrustum ──
808
809/// A capsule-like shape with different radii at each end (a "swept frustum").
810///
811/// The axis runs along Y from `y=0` (radius `r_bottom`) to `y=height` (radius `r_top`).
812/// The radius varies linearly along the axis.
813#[allow(dead_code)]
814#[derive(Debug, Clone)]
815pub struct CapsuleFrustum {
816    /// Radius at the bottom cap (y=0).
817    pub r_bottom: f64,
818    /// Radius at the top cap (y=height).
819    pub r_top: f64,
820    /// Total height (length of axis segment).
821    pub height: f64,
822}
823
824#[allow(dead_code)]
825impl CapsuleFrustum {
826    /// Create a new capsule frustum.
827    pub fn new(r_bottom: f64, r_top: f64, height: f64) -> Self {
828        Self {
829            r_bottom,
830            r_top,
831            height,
832        }
833    }
834
835    /// Interpolated radius at height `y` (clamped to \[0, height\]).
836    pub fn radius_at_height(&self, y: f64) -> f64 {
837        if self.height < 1e-12 {
838            return (self.r_bottom + self.r_top) * 0.5;
839        }
840        let t = (y / self.height).clamp(0.0, 1.0);
841        self.r_bottom + t * (self.r_top - self.r_bottom)
842    }
843
844    /// Approximate volume (frustum cone + two hemispheres of different sizes).
845    pub fn volume(&self) -> f64 {
846        // Truncated cone: π/3 * h * (r1² + r1*r2 + r2²)
847        let r1 = self.r_bottom;
848        let r2 = self.r_top;
849        let h = self.height;
850        let cone_vol = std::f64::consts::PI / 3.0 * h * (r1 * r1 + r1 * r2 + r2 * r2);
851        let sphere_bot = (2.0 / 3.0) * std::f64::consts::PI * r1 * r1 * r1;
852        let sphere_top = (2.0 / 3.0) * std::f64::consts::PI * r2 * r2 * r2;
853        cone_vol + sphere_bot + sphere_top
854    }
855
856    /// Returns `true` if point `p` is inside the frustum.
857    pub fn contains_point(&self, p: [f64; 3]) -> bool {
858        self.sdf(p) <= 0.0
859    }
860
861    /// Signed distance to the frustum surface (approximate).
862    pub fn sdf(&self, p: [f64; 3]) -> f64 {
863        let y = p[1].clamp(0.0, self.height);
864        let r = self.radius_at_height(y);
865        let dy = p[1] - y;
866        let xz_dist = (p[0] * p[0] + p[2] * p[2]).sqrt();
867        let dist_to_axis = (xz_dist * xz_dist + dy * dy).sqrt();
868        dist_to_axis - r
869    }
870}
871
872// ── CurvedCapsulePath ──
873
874/// A capsule swept along a polyline path.
875///
876/// Each consecutive pair of path points defines a capsule segment.
877/// The shape represents the union of all such capsule segments.
878#[allow(dead_code)]
879#[derive(Debug, Clone)]
880pub struct CurvedCapsulePath {
881    /// Polyline control points.
882    pub path: Vec<[f64; 3]>,
883    /// Uniform radius.
884    pub radius: f64,
885}
886
887#[allow(dead_code)]
888impl CurvedCapsulePath {
889    /// Create a new curved capsule path.
890    pub fn new(path: Vec<[f64; 3]>, radius: f64) -> Self {
891        Self { path, radius }
892    }
893
894    /// Total path length (sum of segment lengths).
895    pub fn path_length(&self) -> f64 {
896        self.path
897            .windows(2)
898            .map(|w| {
899                let d = [w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]];
900                (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
901            })
902            .sum()
903    }
904
905    /// Number of path segments (one fewer than points).
906    pub fn num_segments(&self) -> usize {
907        self.path.len().saturating_sub(1)
908    }
909
910    /// Returns `true` if `p` is inside any capsule segment.
911    pub fn contains_point(&self, p: [f64; 3]) -> bool {
912        self.path
913            .windows(2)
914            .any(|w| Capsule::point_segment_distance(p, w[0], w[1]) <= self.radius)
915    }
916
917    /// Approximate SDF: minimum distance to any segment minus radius.
918    pub fn sdf(&self, p: [f64; 3]) -> f64 {
919        let min_d = self
920            .path
921            .windows(2)
922            .map(|w| Capsule::point_segment_distance(p, w[0], w[1]))
923            .fold(f64::INFINITY, f64::min);
924        min_d - self.radius
925    }
926}
927
928impl Shape for Capsule {
929    fn bounding_box(&self) -> Aabb {
930        let r = self.radius;
931        let h = self.half_height + r;
932        Aabb::new(Vec3::new(-r, -h, -r), Vec3::new(r, h, r))
933    }
934
935    fn support_point(&self, direction: &Vec3) -> Vec3 {
936        let norm = direction.norm();
937        let cap_center = if direction.y >= 0.0 {
938            Vec3::new(0.0, self.half_height, 0.0)
939        } else {
940            Vec3::new(0.0, -self.half_height, 0.0)
941        };
942        if norm < 1e-10 {
943            return cap_center;
944        }
945        cap_center + direction * (self.radius / norm)
946    }
947
948    fn volume(&self) -> Real {
949        let r = self.radius;
950        let h = 2.0 * self.half_height;
951        // Cylinder + sphere
952        PI * r * r * h + (4.0 / 3.0) * PI * r.powi(3)
953    }
954
955    fn center_of_mass(&self) -> Vec3 {
956        Vec3::zeros()
957    }
958
959    fn inertia_tensor(&self, mass: Real) -> Mat3 {
960        let r = self.radius;
961        let h = 2.0 * self.half_height;
962        let r2 = r * r;
963        let h2 = h * h;
964
965        // Approximate as cylinder for the inertia
966        let vol_cyl = PI * r2 * h;
967        let vol_sphere = (4.0 / 3.0) * PI * r.powi(3);
968        let total_vol = vol_cyl + vol_sphere;
969
970        let mass_cyl = mass * vol_cyl / total_vol;
971        let mass_sphere = mass * vol_sphere / total_vol;
972
973        // Cylinder inertia (Y-aligned)
974        let iy_cyl = 0.5 * mass_cyl * r2;
975        let ixz_cyl = mass_cyl * (3.0 * r2 + h2) / 12.0;
976
977        // Hemisphere inertia (approximation using shifted sphere)
978        let iy_sphere = 0.4 * mass_sphere * r2;
979        // Parallel axis theorem for hemispheres offset by half_height + 3r/8
980        let offset = self.half_height + 3.0 * r / 8.0;
981        let ixz_sphere = 0.4 * mass_sphere * r2 + mass_sphere * offset * offset;
982
983        let iy = iy_cyl + iy_sphere;
984        let ixz = ixz_cyl + ixz_sphere;
985
986        Mat3::new(ixz, 0.0, 0.0, 0.0, iy, 0.0, 0.0, 0.0, ixz)
987    }
988
989    fn ray_cast(&self, ray_origin: &Vec3, ray_direction: &Vec3, max_toi: Real) -> Option<RayHit> {
990        // Simplified: test against the two end-cap spheres and the cylinder
991        let mut best: Option<RayHit> = None;
992
993        // Test top hemisphere
994        let top_center = Vec3::new(0.0, self.half_height, 0.0);
995        if let Some(hit) = ray_sphere(ray_origin, ray_direction, &top_center, self.radius)
996            && hit.toi <= max_toi
997            && hit.point.y >= self.half_height
998            && best.as_ref().is_none_or(|b| hit.toi < b.toi)
999        {
1000            best = Some(hit);
1001        }
1002
1003        // Test bottom hemisphere
1004        let bot_center = Vec3::new(0.0, -self.half_height, 0.0);
1005        if let Some(hit) = ray_sphere(ray_origin, ray_direction, &bot_center, self.radius)
1006            && hit.toi <= max_toi
1007            && hit.point.y <= -self.half_height
1008            && best.as_ref().is_none_or(|b| hit.toi < b.toi)
1009        {
1010            best = Some(hit);
1011        }
1012
1013        // Test infinite cylinder (XZ plane)
1014        if let Some(hit) = ray_cylinder_y(ray_origin, ray_direction, self.radius, self.half_height)
1015            && hit.toi <= max_toi
1016            && best.as_ref().is_none_or(|b| hit.toi < b.toi)
1017        {
1018            best = Some(hit);
1019        }
1020
1021        best
1022    }
1023}
1024
1025fn ray_sphere(origin: &Vec3, direction: &Vec3, center: &Vec3, radius: Real) -> Option<RayHit> {
1026    let oc = origin - center;
1027    let a = direction.dot(direction);
1028    let b = 2.0 * oc.dot(direction);
1029    let c = oc.dot(&oc) - radius * radius;
1030    let disc = b * b - 4.0 * a * c;
1031    if disc < 0.0 {
1032        return None;
1033    }
1034    let sqrt_disc = disc.sqrt();
1035    let t1 = (-b - sqrt_disc) / (2.0 * a);
1036    let t2 = (-b + sqrt_disc) / (2.0 * a);
1037    let t = if t1 >= 0.0 { t1 } else { t2 };
1038    if t < 0.0 {
1039        return None;
1040    }
1041    let point = origin + direction * t;
1042    let normal = (point - center).normalize();
1043    Some(RayHit {
1044        point,
1045        normal,
1046        toi: t,
1047    })
1048}
1049
1050fn ray_cylinder_y(
1051    origin: &Vec3,
1052    direction: &Vec3,
1053    radius: Real,
1054    half_height: Real,
1055) -> Option<RayHit> {
1056    // Infinite cylinder along Y axis in XZ plane
1057    let a = direction.x * direction.x + direction.z * direction.z;
1058    let b = 2.0 * (origin.x * direction.x + origin.z * direction.z);
1059    let c = origin.x * origin.x + origin.z * origin.z - radius * radius;
1060    let disc = b * b - 4.0 * a * c;
1061    if disc < 0.0 || a < 1e-12 {
1062        return None;
1063    }
1064    let sqrt_disc = disc.sqrt();
1065    let t1 = (-b - sqrt_disc) / (2.0 * a);
1066    let t2 = (-b + sqrt_disc) / (2.0 * a);
1067
1068    for t in [t1, t2] {
1069        if t < 0.0 {
1070            continue;
1071        }
1072        let point = origin + direction * t;
1073        if point.y.abs() <= half_height {
1074            let normal = Vec3::new(point.x, 0.0, point.z).normalize();
1075            return Some(RayHit {
1076                point,
1077                normal,
1078                toi: t,
1079            });
1080        }
1081    }
1082    None
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088
1089    #[test]
1090    fn test_capsule_volume() {
1091        let c = Capsule::new(1.0, 1.0);
1092        let expected = PI * 1.0 * 2.0 + (4.0 / 3.0) * PI;
1093        assert!((c.volume() - expected).abs() < 1e-10);
1094    }
1095
1096    #[test]
1097    fn test_capsule_volume_explicit() {
1098        let c = Capsule::new(1.0, 1.0);
1099        assert!((c.volume_explicit() - c.volume()).abs() < 1e-10);
1100    }
1101
1102    #[test]
1103    fn test_capsule_surface_area() {
1104        // r=1, half_height=1: 2π*1*2 + 4π = 4π + 4π = 8π
1105        let c = Capsule::new(1.0, 1.0);
1106        let expected = 8.0 * PI;
1107        assert!((c.surface_area() - expected).abs() < 1e-10);
1108    }
1109
1110    #[test]
1111    fn test_capsule_inertia_array() {
1112        let c = Capsule::new(1.0, 1.0);
1113        let it = c.inertia_tensor_array(1.0);
1114        // Diagonal elements should be positive
1115        assert!(it[0][0] > 0.0);
1116        assert!(it[1][1] > 0.0);
1117        assert!(it[2][2] > 0.0);
1118        // Ixx == Izz by symmetry
1119        assert!((it[0][0] - it[2][2]).abs() < 1e-10);
1120        // Off-diagonal zero
1121        assert!(it[0][1].abs() < 1e-10);
1122    }
1123
1124    #[test]
1125    fn test_capsule_raycast() {
1126        let c = Capsule::new(1.0, 2.0);
1127        let origin = Vec3::new(-5.0, 0.0, 0.0);
1128        let dir = Vec3::new(1.0, 0.0, 0.0);
1129        let hit = c.ray_cast(&origin, &dir, 100.0).unwrap();
1130        assert!((hit.toi - 4.0).abs() < 1e-10);
1131    }
1132
1133    #[test]
1134    fn test_capsule_raycast_array() {
1135        let c = Capsule::new(1.0, 2.0);
1136        let (t, _n) = c
1137            .ray_cast_array([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 100.0)
1138            .unwrap();
1139        assert!((t - 4.0).abs() < 1e-10);
1140    }
1141
1142    #[test]
1143    fn test_capsule_support_up() {
1144        let c = Capsule::new(1.0, 2.0);
1145        let sp = c.support([0.0, 1.0, 0.0]);
1146        // Should return top cap: y = half_height + radius = 3
1147        assert!((sp[1] - 3.0).abs() < 1e-10);
1148    }
1149
1150    #[test]
1151    fn test_capsule_support_down() {
1152        let c = Capsule::new(1.0, 2.0);
1153        let sp = c.support([0.0, -1.0, 0.0]);
1154        // Should return bottom: y = -half_height - radius = -3
1155        assert!((sp[1] + 3.0).abs() < 1e-10);
1156    }
1157
1158    #[test]
1159    fn test_capsule_raycast_top_cap() {
1160        let c = Capsule::new(1.0, 2.0);
1161        let origin = Vec3::new(0.0, 10.0, 0.0);
1162        let dir = Vec3::new(0.0, -1.0, 0.0);
1163        let hit = c.ray_cast(&origin, &dir, 100.0).unwrap();
1164        // Top of capsule at y = half_height + radius = 3
1165        assert!(
1166            (hit.toi - 7.0).abs() < 1e-10,
1167            "expected toi=7, got {}",
1168            hit.toi
1169        );
1170    }
1171
1172    // ── New tests ──
1173
1174    #[test]
1175    fn test_capsule_closest_point_on_side() {
1176        let c = Capsule::new(1.0, 2.0);
1177        let cp = c.closest_point([5.0, 0.0, 0.0]);
1178        assert!((cp[0] - 1.0).abs() < 1e-10);
1179        assert!(cp[1].abs() < 1e-10);
1180        assert!(cp[2].abs() < 1e-10);
1181    }
1182
1183    #[test]
1184    fn test_capsule_closest_point_on_top_cap() {
1185        let c = Capsule::new(1.0, 2.0);
1186        let cp = c.closest_point([0.0, 5.0, 0.0]);
1187        // Should be on the top hemisphere: (0, 2+1, 0) = (0, 3, 0)
1188        assert!((cp[1] - 3.0).abs() < 1e-10);
1189        assert!(cp[0].abs() < 1e-10);
1190    }
1191
1192    #[test]
1193    fn test_capsule_closest_point_on_bottom_cap() {
1194        let c = Capsule::new(1.0, 2.0);
1195        let cp = c.closest_point([0.0, -5.0, 0.0]);
1196        assert!((cp[1] + 3.0).abs() < 1e-10);
1197    }
1198
1199    #[test]
1200    fn test_capsule_closest_point_on_axis() {
1201        let c = Capsule::new(1.0, 2.0);
1202        // Point on axis inside capsule
1203        let cp = c.closest_point([0.0, 0.0, 0.0]);
1204        // Should return a point on the surface, in +X direction
1205        assert!((cp[0] - 1.0).abs() < 1e-10);
1206        assert!(cp[1].abs() < 1e-10);
1207    }
1208
1209    #[test]
1210    fn test_capsule_contains_point() {
1211        let c = Capsule::new(1.0, 2.0);
1212        assert!(c.contains_point([0.0, 0.0, 0.0])); // center
1213        assert!(c.contains_point([0.5, 1.0, 0.0])); // inside cylinder
1214        assert!(c.contains_point([0.0, 2.5, 0.0])); // inside top cap
1215        assert!(!c.contains_point([0.0, 3.5, 0.0])); // above capsule
1216        assert!(!c.contains_point([2.0, 0.0, 0.0])); // outside radially
1217    }
1218
1219    #[test]
1220    fn test_capsule_signed_distance() {
1221        let c = Capsule::new(2.0, 1.0);
1222        // Center
1223        assert!((c.signed_distance([0.0, 0.0, 0.0]) + 2.0).abs() < 1e-10);
1224        // On surface
1225        assert!(c.signed_distance([2.0, 0.0, 0.0]).abs() < 1e-10);
1226        // Outside
1227        assert!((c.signed_distance([4.0, 0.0, 0.0]) - 2.0).abs() < 1e-10);
1228    }
1229
1230    #[test]
1231    fn test_capsule_medial_axis_endpoints() {
1232        let c = Capsule::new(1.0, 3.0);
1233        let (top, bot) = c.medial_axis_endpoints();
1234        assert!((top[1] - 3.0).abs() < 1e-10);
1235        assert!((bot[1] + 3.0).abs() < 1e-10);
1236    }
1237
1238    #[test]
1239    fn test_capsule_full_length() {
1240        let c = Capsule::new(1.0, 2.0);
1241        // 2*hh + 2*r = 4 + 2 = 6
1242        assert!((c.full_length() - 6.0).abs() < 1e-10);
1243    }
1244
1245    #[test]
1246    fn test_capsule_medial_axis_length() {
1247        let c = Capsule::new(1.0, 2.0);
1248        assert!((c.medial_axis_length() - 4.0).abs() < 1e-10);
1249    }
1250
1251    #[test]
1252    fn test_segment_segment_distance_parallel() {
1253        // Two parallel segments along Y, offset in X
1254        let d = Capsule::segment_segment_distance(
1255            [0.0, 0.0, 0.0],
1256            [0.0, 1.0, 0.0],
1257            [3.0, 0.0, 0.0],
1258            [3.0, 1.0, 0.0],
1259        );
1260        assert!((d - 3.0).abs() < 1e-10);
1261    }
1262
1263    #[test]
1264    fn test_segment_segment_distance_crossing() {
1265        // Two segments that cross: one along X, one along Z, separated by y
1266        let d = Capsule::segment_segment_distance(
1267            [-1.0, 0.0, 0.0],
1268            [1.0, 0.0, 0.0],
1269            [0.0, 2.0, -1.0],
1270            [0.0, 2.0, 1.0],
1271        );
1272        assert!((d - 2.0).abs() < 1e-10);
1273    }
1274
1275    #[test]
1276    fn test_segment_segment_distance_touching() {
1277        let d = Capsule::segment_segment_distance(
1278            [0.0, 0.0, 0.0],
1279            [1.0, 0.0, 0.0],
1280            [1.0, 0.0, 0.0],
1281            [2.0, 0.0, 0.0],
1282        );
1283        assert!(d.abs() < 1e-10);
1284    }
1285
1286    #[test]
1287    fn test_segment_segment_distance_degenerate_points() {
1288        let d = Capsule::segment_segment_distance(
1289            [0.0, 0.0, 0.0],
1290            [0.0, 0.0, 0.0],
1291            [3.0, 4.0, 0.0],
1292            [3.0, 4.0, 0.0],
1293        );
1294        assert!((d - 5.0).abs() < 1e-10);
1295    }
1296
1297    #[test]
1298    fn test_capsule_capsule_distance_separated() {
1299        let a = Capsule::new(1.0, 1.0);
1300        let b = Capsule::new(1.0, 1.0);
1301        // Place them 5 apart in X
1302        let d = a.capsule_capsule_distance([0.0, 0.0, 0.0], &b, [5.0, 0.0, 0.0]);
1303        // Segment distance = 5, surface distance = 5 - 1 - 1 = 3
1304        assert!((d - 3.0).abs() < 1e-10);
1305    }
1306
1307    #[test]
1308    fn test_capsule_capsule_distance_overlapping() {
1309        let a = Capsule::new(1.0, 1.0);
1310        let b = Capsule::new(1.0, 1.0);
1311        let d = a.capsule_capsule_distance([0.0, 0.0, 0.0], &b, [0.5, 0.0, 0.0]);
1312        assert_eq!(d, 0.0); // overlapping
1313    }
1314
1315    #[test]
1316    fn test_capsule_capsule_overlap() {
1317        let a = Capsule::new(1.0, 1.0);
1318        let b = Capsule::new(1.0, 1.0);
1319        assert!(a.capsule_capsule_overlap([0.0, 0.0, 0.0], &b, [1.0, 0.0, 0.0]));
1320        assert!(!a.capsule_capsule_overlap([0.0, 0.0, 0.0], &b, [5.0, 0.0, 0.0]));
1321    }
1322
1323    #[test]
1324    fn test_capsule_project_on_medial_axis() {
1325        let c = Capsule::new(1.0, 2.0);
1326        assert!((c.project_on_medial_axis([0.0, 5.0, 0.0]) - 2.0).abs() < 1e-10);
1327        assert!((c.project_on_medial_axis([0.0, -5.0, 0.0]) + 2.0).abs() < 1e-10);
1328        assert!((c.project_on_medial_axis([3.0, 1.0, 0.0]) - 1.0).abs() < 1e-10);
1329    }
1330
1331    #[test]
1332    fn test_capsule_distance_to_medial_axis() {
1333        let c = Capsule::new(1.0, 2.0);
1334        assert!(c.distance_to_medial_axis([0.0, 0.0, 0.0]).abs() < 1e-10);
1335        assert!((c.distance_to_medial_axis([3.0, 0.0, 0.0]) - 3.0).abs() < 1e-10);
1336        // Point above cap
1337        assert!((c.distance_to_medial_axis([0.0, 5.0, 0.0]) - 3.0).abs() < 1e-10);
1338    }
1339
1340    #[test]
1341    fn test_capsule_support_diagonal() {
1342        let c = Capsule::new(1.0, 2.0);
1343        let sp = c.support([1.0, 1.0, 0.0]);
1344        // cap_y = 2.0 (positive Y)
1345        let len = (1.0_f64 + 1.0).sqrt();
1346        let expected_x = 1.0 / len;
1347        let expected_y = 2.0 + 1.0 / len;
1348        assert!((sp[0] - expected_x).abs() < 1e-10);
1349        assert!((sp[1] - expected_y).abs() < 1e-10);
1350    }
1351
1352    // ── Expanded tests ──
1353
1354    #[test]
1355    fn test_capsule_closest_points_vs_capsule() {
1356        let a = Capsule::new(1.0, 1.0);
1357        let b = Capsule::new(1.0, 1.0);
1358        // Capsules side by side in X, separated by 4 units
1359        let (pa, pb, seg_dist) =
1360            a.closest_points_capsule_vs_capsule([0.0, 0.0, 0.0], &b, [4.0, 0.0, 0.0]);
1361        assert!(
1362            (seg_dist - 4.0).abs() < 1e-9,
1363            "axis dist should be 4, got {seg_dist}"
1364        );
1365        assert!((pa[0]).abs() < 1e-9);
1366        assert!((pb[0] - 4.0).abs() < 1e-9);
1367    }
1368
1369    #[test]
1370    fn test_segment_segment_closest_crossing() {
1371        // X-segment and Z-segment crossing at origin at different Y levels
1372        let (pa, pb, dist) = Capsule::segment_segment_closest(
1373            [-1.0, 0.0, 0.0],
1374            [1.0, 0.0, 0.0],
1375            [0.0, 3.0, -1.0],
1376            [0.0, 3.0, 1.0],
1377        );
1378        assert!((dist - 3.0).abs() < 1e-9, "dist should be 3, got {dist}");
1379        assert!(pa[0].abs() < 1e-9);
1380        assert!((pb[1] - 3.0).abs() < 1e-9);
1381    }
1382
1383    #[test]
1384    fn test_capsule_intersects_obb_basic() {
1385        let c = Capsule::new(0.5, 1.0);
1386        // OBB centered at origin, axis-aligned, half-extents [2,2,2]
1387        let axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1388        // Capsule centered inside OBB
1389        assert!(c.intersects_obb([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], axes, [2.0, 2.0, 2.0]));
1390        // Capsule far away
1391        assert!(!c.intersects_obb([0.0, 0.0, 0.0], [10.0, 0.0, 0.0], axes, [2.0, 2.0, 2.0]));
1392    }
1393
1394    #[test]
1395    fn test_capsule_sdf_matches_signed_distance() {
1396        let c = Capsule::new(1.0, 2.0);
1397        let p = [3.0, 0.0, 0.0];
1398        assert!((c.sdf(p) - c.signed_distance(p)).abs() < 1e-12);
1399    }
1400
1401    #[test]
1402    fn test_swept_capsule_vs_sphere_contact() {
1403        let c = Capsule::new(1.0, 1.0);
1404        // Capsule starts at x=10, moves to x=0 – sphere at origin with radius 0.5
1405        let result =
1406            c.swept_capsule_vs_sphere([10.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 0.5);
1407        assert!(result.is_some(), "swept capsule should contact sphere");
1408        let t = result.unwrap();
1409        assert!((0.0..=1.0).contains(&t), "t should be in [0,1], got {t}");
1410    }
1411
1412    #[test]
1413    fn test_swept_capsule_vs_sphere_no_contact() {
1414        let c = Capsule::new(0.5, 0.5);
1415        // Capsule moves along X, sphere far away in Z
1416        let result =
1417            c.swept_capsule_vs_sphere([0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [0.0, 0.0, 100.0], 0.5);
1418        assert!(result.is_none(), "should not contact distant sphere");
1419    }
1420
1421    #[test]
1422    fn test_random_surface_points_count() {
1423        let c = Capsule::new(1.0, 2.0);
1424        let pts = c.random_surface_points(50, 42);
1425        assert_eq!(pts.len(), 50);
1426    }
1427
1428    #[test]
1429    fn test_random_surface_points_on_surface() {
1430        let c = Capsule::new(1.0, 2.0);
1431        let pts = c.random_surface_points(100, 7);
1432        for p in &pts {
1433            // Each point should be within radius+eps of the surface
1434            let sdf = c.sdf(*p);
1435            assert!(sdf.abs() < 0.05, "point {:?} has sdf={sdf}", p);
1436        }
1437    }
1438
1439    #[test]
1440    fn test_point_segment_distance_midpoint() {
1441        // Point above midpoint of segment along X
1442        let d = Capsule::point_segment_distance([0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1443        assert!((d - 1.0).abs() < 1e-12);
1444    }
1445
1446    #[test]
1447    fn test_inertia_tensor_raw_matches_array() {
1448        let c = Capsule::new(1.5, 2.5);
1449        let raw = c.inertia_tensor_raw(3.0);
1450        let arr = c.inertia_tensor_array(3.0);
1451        for i in 0..3 {
1452            for j in 0..3 {
1453                assert!((raw[i][j] - arr[i][j]).abs() < 1e-12);
1454            }
1455        }
1456    }
1457
1458    // ── New 30-50 expanded tests ──
1459
1460    #[test]
1461    fn test_capsule_triangle_contact_no_overlap() {
1462        let c = Capsule::new(0.5, 1.0);
1463        // Triangle far away from capsule
1464        let a = [10.0, 0.0, 0.0];
1465        let b = [11.0, 0.0, 0.0];
1466        let tri = [10.0_f64, 0.0_f64, 1.0_f64];
1467        let result = c.capsule_triangle_contact([0.0, 0.0, 0.0], a, b, tri);
1468        assert!(
1469            result.is_none(),
1470            "should be no contact with distant triangle"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_capsule_triangle_contact_overlap() {
1476        let c = Capsule::new(1.0, 1.0);
1477        // Large triangle containing the capsule axis
1478        let a = [-5.0, 0.0, -5.0];
1479        let b = [5.0, 0.0, -5.0];
1480        let tri = [0.0_f64, 0.0_f64, 5.0_f64];
1481        let result = c.capsule_triangle_contact([0.0, 0.5, 0.0], a, b, tri);
1482        assert!(
1483            result.is_some(),
1484            "capsule near triangle should produce contact"
1485        );
1486    }
1487
1488    #[test]
1489    fn test_capsule_chain_empty() {
1490        let links = CapsuleChain::new(vec![]);
1491        assert_eq!(links.segment_count(), 0);
1492    }
1493
1494    #[test]
1495    fn test_capsule_chain_single_point() {
1496        let links = CapsuleChain::new(vec![[0.0, 0.0, 0.0]]);
1497        assert_eq!(links.segment_count(), 0);
1498    }
1499
1500    #[test]
1501    fn test_capsule_chain_two_points() {
1502        let links = CapsuleChain::new(vec![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
1503        assert_eq!(links.segment_count(), 1);
1504    }
1505
1506    #[test]
1507    fn test_capsule_chain_total_length() {
1508        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]];
1509        let chain = CapsuleChain::new(pts);
1510        assert!((chain.total_length() - 2.0).abs() < 1e-10);
1511    }
1512
1513    #[test]
1514    fn test_capsule_chain_contains_point_near_segment() {
1515        let pts = vec![[0.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
1516        let chain = CapsuleChain::with_radius(pts, 1.0);
1517        assert!(chain.contains_point([0.5, 1.0, 0.0]));
1518        assert!(!chain.contains_point([2.0, 1.0, 0.0]));
1519    }
1520
1521    #[test]
1522    fn test_deformable_capsule_update_endpoints() {
1523        let mut dc = DeformableCapsule::new([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.5);
1524        dc.set_endpoint_a([1.0, 0.0, 0.0]);
1525        dc.set_endpoint_b([1.0, 2.0, 0.0]);
1526        assert!((dc.endpoint_a()[0] - 1.0).abs() < 1e-12);
1527        assert!((dc.endpoint_b()[1] - 2.0).abs() < 1e-12);
1528    }
1529
1530    #[test]
1531    fn test_deformable_capsule_length() {
1532        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [0.0, 3.0, 0.0], 0.5);
1533        assert!((dc.length() - 3.0).abs() < 1e-10);
1534    }
1535
1536    #[test]
1537    fn test_deformable_capsule_midpoint() {
1538        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [2.0, 0.0, 0.0], 0.5);
1539        let mid = dc.midpoint();
1540        assert!((mid[0] - 1.0).abs() < 1e-12);
1541    }
1542
1543    #[test]
1544    fn test_capsule_medial_axis_3d_segment() {
1545        let dc = DeformableCapsule::new([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 0.5);
1546        let (a, b) = dc.medial_axis_endpoints();
1547        assert!((a[0] - 1.0).abs() < 1e-12);
1548        assert!((b[2] - 6.0).abs() < 1e-12);
1549    }
1550
1551    #[test]
1552    fn test_deformable_capsule_sdf_inside() {
1553        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [0.0, 2.0, 0.0], 1.0);
1554        // Point on the axis inside the capsule
1555        assert!(dc.sdf([0.0, 1.0, 0.0]) < 0.0);
1556    }
1557
1558    #[test]
1559    fn test_deformable_capsule_sdf_outside() {
1560        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [0.0, 2.0, 0.0], 0.5);
1561        // Point far away
1562        assert!(dc.sdf([5.0, 1.0, 0.0]) > 0.0);
1563    }
1564
1565    #[test]
1566    fn test_capsule_frustum_volume_positive() {
1567        let f = CapsuleFrustum::new(0.5, 1.0, 2.0);
1568        assert!(f.volume() > 0.0);
1569    }
1570
1571    #[test]
1572    fn test_capsule_frustum_contains_center() {
1573        let f = CapsuleFrustum::new(0.5, 1.0, 2.0);
1574        // Center along axis should be inside
1575        assert!(f.contains_point([0.0, 1.0, 0.0]));
1576    }
1577
1578    #[test]
1579    fn test_capsule_frustum_excludes_far_point() {
1580        let f = CapsuleFrustum::new(0.5, 1.0, 2.0);
1581        assert!(!f.contains_point([10.0, 0.0, 0.0]));
1582    }
1583
1584    #[test]
1585    fn test_curved_capsule_path_length() {
1586        // Path with 3 points forming an L
1587        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]];
1588        let cp = CurvedCapsulePath::new(pts, 0.3);
1589        let len = cp.path_length();
1590        assert!((len - 2.0).abs() < 1e-10);
1591    }
1592
1593    #[test]
1594    fn test_curved_capsule_path_contains_axis_point() {
1595        let pts = vec![[0.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
1596        let cp = CurvedCapsulePath::new(pts, 0.5);
1597        assert!(cp.contains_point([0.0, 1.0, 0.0]));
1598    }
1599
1600    #[test]
1601    fn test_curved_capsule_path_excludes_far_point() {
1602        let pts = vec![[0.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
1603        let cp = CurvedCapsulePath::new(pts, 0.5);
1604        assert!(!cp.contains_point([5.0, 0.0, 0.0]));
1605    }
1606
1607    #[test]
1608    fn test_swept_capsule_contact_time_order() {
1609        let c = Capsule::new(0.5, 1.0);
1610        // Capsule moving from far away to the origin
1611        let t = c.swept_capsule_vs_sphere([20.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 0.1);
1612        assert!(t.is_some());
1613        let tv = t.unwrap();
1614        assert!(tv > 0.0 && tv <= 1.0);
1615    }
1616
1617    #[test]
1618    fn test_capsule_capsule_touching_boundary() {
1619        let a = Capsule::new(1.0, 1.0);
1620        let b = Capsule::new(1.0, 1.0);
1621        // Place them exactly touching: axis distance = sum of radii = 2
1622        let d = a.capsule_capsule_distance([0.0, 0.0, 0.0], &b, [2.0, 0.0, 0.0]);
1623        assert!(
1624            d.abs() < 1e-10,
1625            "touching capsules should have 0 distance, got {d}"
1626        );
1627    }
1628
1629    #[test]
1630    fn test_segment_segment_closest_endpoints() {
1631        // Two perpendicular segments not crossing; closest is the tips
1632        let (pa, pb, dist) = Capsule::segment_segment_closest(
1633            [0.0, 0.0, 0.0],
1634            [1.0, 0.0, 0.0],
1635            [2.0, 1.0, 0.0],
1636            [2.0, 2.0, 0.0],
1637        );
1638        assert!(dist > 0.0);
1639        let _ = (pa, pb);
1640    }
1641
1642    #[test]
1643    fn test_capsule_closest_point_on_surface_norm() {
1644        let c = Capsule::new(1.0, 2.0);
1645        let p = [3.0, 1.0, 4.0];
1646        let cp = c.closest_point(p);
1647        // Verify the closest point is on the capsule surface (sdf ~ 0)
1648        let sdf = c.sdf(cp);
1649        assert!(
1650            sdf.abs() < 1e-9,
1651            "closest point sdf should be ~0, got {sdf}"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_capsule_bounding_box_correct() {
1657        let c = Capsule::new(1.0, 2.0);
1658        use crate::shape::Shape;
1659        let bb = c.bounding_box();
1660        // Bounding box should extend r=1 in X/Z, half_height+r=3 in Y
1661        assert!((bb.max.y - 3.0).abs() < 1e-10);
1662        assert!((bb.max.x - 1.0).abs() < 1e-10);
1663    }
1664
1665    #[test]
1666    fn test_capsule_chain_segment_radii() {
1667        let pts = vec![
1668            [0.0, 0.0, 0.0],
1669            [1.0, 0.0, 0.0],
1670            [2.0, 0.0, 0.0],
1671            [2.0, 1.0, 0.0],
1672        ];
1673        let chain = CapsuleChain::with_radius(pts, 0.3);
1674        assert_eq!(chain.segment_count(), 3);
1675    }
1676
1677    #[test]
1678    fn test_deformable_capsule_support_positive_y() {
1679        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [0.0, 4.0, 0.0], 1.0);
1680        let sp = dc.support([0.0, 1.0, 0.0]);
1681        // Should be at top cap: (0, 4+1, 0)
1682        assert!(sp[1] > 4.0, "support in +Y should be above endpoint b");
1683    }
1684
1685    #[test]
1686    fn test_capsule_frustum_sdf_on_axis_top() {
1687        // At the top (y=height), sdf should be ~0 or negative
1688        let f = CapsuleFrustum::new(0.5, 1.0, 3.0);
1689        // center at y=1.5, top cap at y=3 + r_top=1.0 => y=4 is outside
1690        let sdf_inside = f.sdf([0.0, 1.5, 0.0]);
1691        assert!(
1692            sdf_inside < 0.0,
1693            "center should be inside frustum, sdf={sdf_inside}"
1694        );
1695    }
1696
1697    #[test]
1698    fn test_curved_capsule_path_segment_count() {
1699        let pts: Vec<[f64; 3]> = (0..5).map(|i| [i as f64, 0.0, 0.0]).collect();
1700        let cp = CurvedCapsulePath::new(pts, 0.2);
1701        assert_eq!(cp.num_segments(), 4);
1702    }
1703
1704    #[test]
1705    fn test_capsule_chain_sdf_on_axis() {
1706        let pts = vec![[0.0, 0.0, 0.0], [0.0, 3.0, 0.0]];
1707        let chain = CapsuleChain::with_radius(pts, 1.0);
1708        // Point on the axis inside the chain
1709        let sdf = chain.sdf([0.0, 1.5, 0.0]);
1710        assert!(sdf < 0.0, "axis point should be inside chain, sdf={sdf}");
1711    }
1712
1713    #[test]
1714    fn test_capsule_triangle_contact_normal_points_away() {
1715        let c = Capsule::new(1.0, 1.0);
1716        // Triangle at y=-0.3, large and centered
1717        let a = [-10.0, -0.3, -10.0];
1718        let b = [10.0, -0.3, -10.0];
1719        let tri = [0.0_f64, -0.3_f64, 10.0_f64];
1720        if let Some((depth, normal)) = c.capsule_triangle_contact([0.0, 0.0, 0.0], a, b, tri) {
1721            assert!(depth > 0.0, "depth should be positive, got {depth}");
1722            // Normal should be unit length (either +Y or -Y for a horizontal triangle)
1723            let n_len =
1724                (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
1725            assert!(
1726                (n_len - 1.0).abs() < 1e-9,
1727                "normal should be unit length, got {n_len}"
1728            );
1729        }
1730    }
1731
1732    #[test]
1733    fn test_deformable_capsule_arbitrary_orientation() {
1734        // Capsule along X axis
1735        let dc = DeformableCapsule::new([0.0, 0.0, 0.0], [4.0, 0.0, 0.0], 1.0);
1736        assert!(dc.sdf([2.0, 0.0, 0.0]) < 0.0); // on axis
1737        assert!(dc.sdf([2.0, 1.5, 0.0]) > 0.0); // outside radius
1738    }
1739
1740    #[test]
1741    fn test_capsule_chain_min_distance_to_point() {
1742        let pts = vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]];
1743        let chain = CapsuleChain::with_radius(pts, 0.5);
1744        let d = chain.min_distance_to_point([2.0, 3.0, 0.0]);
1745        // Closest axis point is (2,0,0), dist to that is 3, minus radius 0.5 = 2.5
1746        assert!((d - 2.5).abs() < 1e-9, "expected 2.5, got {d}");
1747    }
1748
1749    #[test]
1750    fn test_capsule_frustum_larger_radius_at_top() {
1751        let f = CapsuleFrustum::new(0.5, 2.0, 3.0);
1752        // At the top, radius should be larger
1753        assert!(f.radius_at_height(3.0) > f.radius_at_height(0.0));
1754    }
1755
1756    #[test]
1757    fn test_capsule_frustum_radius_interpolated() {
1758        let f = CapsuleFrustum::new(1.0, 3.0, 4.0);
1759        // At height 2 (midpoint), radius should be midway between 1 and 3
1760        let r_mid = f.radius_at_height(2.0);
1761        assert!(
1762            (r_mid - 2.0).abs() < 1e-9,
1763            "midpoint radius should be 2.0, got {r_mid}"
1764        );
1765    }
1766}