1use nalgebra::SVector;
19
20#[derive(Clone, Debug)]
22pub struct CcdHit<const D: usize> {
23 pub toi: f64,
26 pub point: SVector<f64, D>,
28 pub normal: SVector<f64, D>,
30}
31
32pub const CCD_SPEED_THRESHOLD: f64 = 5.0;
35
36pub fn sphere_sphere<const D: usize>(
44 pos_a: &SVector<f64, D>,
45 vel_a: &SVector<f64, D>,
46 radius_a: f64,
47 pos_b: &SVector<f64, D>,
48 vel_b: &SVector<f64, D>,
49 radius_b: f64,
50 dt: f64,
51) -> Option<CcdHit<D>> {
52 let dp = pos_b - pos_a;
53 let dv = vel_b - vel_a;
54 let r = radius_a + radius_b;
55
56 let a = dv.dot(&dv);
57 let b = 2.0 * dp.dot(&dv);
58 let c = dp.dot(&dp) - r * r;
59
60 if c <= 0.0 {
62 let normal = if dp.norm() > 1e-15 {
63 dp.normalize()
64 } else {
65 let mut n = SVector::zeros();
66 n[0] = 1.0;
67 n
68 };
69 return Some(CcdHit {
70 toi: 0.0,
71 point: pos_a + normal * radius_a,
72 normal,
73 });
74 }
75
76 if a < 1e-20 {
78 return None;
79 }
80
81 let discriminant = b * b - 4.0 * a * c;
82 if discriminant < 0.0 {
83 return None; }
85
86 let sqrt_disc = discriminant.sqrt();
87 let t1 = (-b - sqrt_disc) / (2.0 * a);
88
89 if t1 >= 0.0 && t1 <= dt {
91 let pos_a_t = pos_a + vel_a * t1;
92 let pos_b_t = pos_b + vel_b * t1;
93 let delta = pos_b_t - pos_a_t;
94 let normal = if delta.norm() > 1e-15 {
95 delta.normalize()
96 } else {
97 let mut n = SVector::zeros();
98 n[0] = 1.0;
99 n
100 };
101 Some(CcdHit {
102 toi: t1,
103 point: pos_a_t + normal * radius_a,
104 normal,
105 })
106 } else {
107 let t2 = (-b + sqrt_disc) / (2.0 * a);
108 if t2 >= 0.0 && t2 <= dt {
109 let pos_a_t = pos_a + vel_a * t2;
110 let pos_b_t = pos_b + vel_b * t2;
111 let delta = pos_b_t - pos_a_t;
112 let normal = if delta.norm() > 1e-15 {
113 delta.normalize()
114 } else {
115 let mut n = SVector::zeros();
116 n[0] = 1.0;
117 n
118 };
119 Some(CcdHit {
120 toi: t2,
121 point: pos_a_t + normal * radius_a,
122 normal,
123 })
124 } else {
125 None }
127 }
128}
129
130pub fn sphere_halfspace<const D: usize>(
135 pos: &SVector<f64, D>,
136 vel: &SVector<f64, D>,
137 radius: f64,
138 plane_normal: &SVector<f64, D>,
139 plane_offset: f64,
140 dt: f64,
141) -> Option<CcdHit<D>> {
142 let signed_dist = plane_normal.dot(pos) - plane_offset;
143
144 if signed_dist <= radius {
146 let contact = pos - plane_normal * signed_dist;
147 return Some(CcdHit {
148 toi: 0.0,
149 point: contact,
150 normal: *plane_normal,
151 });
152 }
153
154 let vel_toward_plane = -plane_normal.dot(vel);
155 if vel_toward_plane <= 0.0 {
156 return None; }
158
159 let t = (signed_dist - radius) / vel_toward_plane;
161
162 if t >= 0.0 && t <= dt {
163 let pos_t = pos + vel * t;
164 let contact = pos_t - plane_normal * radius;
165 Some(CcdHit {
166 toi: t,
167 point: contact,
168 normal: *plane_normal,
169 })
170 } else {
171 None
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn sphere_sphere_head_on() {
181 let pos_a = SVector::from([0.0, 0.0, 0.0]);
182 let vel_a = SVector::from([10.0, 0.0, 0.0]);
183 let pos_b = SVector::from([5.0, 0.0, 0.0]);
184 let vel_b = SVector::from([-10.0, 0.0, 0.0]);
185
186 let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0).unwrap();
187
188 assert!(
191 (hit.toi - 0.2).abs() < 0.01,
192 "TOI = {}, expected ~0.2",
193 hit.toi
194 );
195 }
196
197 #[test]
198 fn sphere_sphere_no_collision() {
199 let pos_a = SVector::from([0.0, 0.0, 0.0]);
200 let vel_a = SVector::from([1.0, 0.0, 0.0]);
201 let pos_b = SVector::from([0.0, 10.0, 0.0]); let vel_b = SVector::from([0.0, 0.0, 0.0]);
203
204 let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0);
205 assert!(hit.is_none(), "spheres moving apart should not collide");
206 }
207
208 #[test]
209 fn sphere_sphere_already_overlapping() {
210 let pos_a = SVector::from([0.0, 0.0, 0.0]);
211 let vel_a = SVector::zeros();
212 let pos_b = SVector::from([0.5, 0.0, 0.0]);
213 let vel_b = SVector::zeros();
214
215 let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
216 assert!((hit.toi - 0.0).abs() < 1e-12, "already overlapping: TOI should be 0");
217 }
218
219 #[test]
220 fn sphere_halfspace_falling_onto_ground() {
221 let pos = SVector::from([0.0, 10.0, 0.0]);
222 let vel = SVector::from([0.0, -100.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
224
225 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
226
227 assert!(
230 (hit.toi - 0.095).abs() < 0.01,
231 "TOI = {}, expected ~0.095",
232 hit.toi
233 );
234 assert!(hit.normal[1] > 0.9, "normal should point up");
235 }
236
237 #[test]
238 fn sphere_halfspace_moving_away() {
239 let pos = SVector::from([0.0, 5.0, 0.0]);
240 let vel = SVector::from([0.0, 10.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
242
243 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
244 assert!(hit.is_none(), "moving away from plane should not hit");
245 }
246
247 #[test]
248 fn sphere_halfspace_already_penetrating() {
249 let pos = SVector::from([0.0, 0.3, 0.0]);
250 let vel = SVector::from([0.0, -10.0, 0.0]);
251 let normal = SVector::from([0.0, 1.0, 0.0]);
252
253 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
254 assert!((hit.toi - 0.0).abs() < 1e-12, "already penetrating: TOI = 0");
255 }
256
257 #[test]
258 fn sphere_sphere_4d() {
259 let pos_a = SVector::from([0.0, 0.0, 0.0, 0.0]);
260 let vel_a = SVector::from([0.0, 0.0, 0.0, 20.0]); let pos_b = SVector::from([0.0, 0.0, 0.0, 10.0]);
262 let vel_b = SVector::zeros();
263
264 let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
265 assert!(
267 (hit.toi - 0.4).abs() < 0.01,
268 "4D TOI = {}, expected ~0.4",
269 hit.toi
270 );
271 }
272
273 #[test]
274 fn sphere_halfspace_outside_dt() {
275 let pos = SVector::from([0.0, 100.0, 0.0]);
276 let vel = SVector::from([0.0, -1.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
278
279 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
281 assert!(hit.is_none(), "impact outside dt should not be reported");
282 }
283
284 #[test]
285 fn ccd_prevents_tunneling_scenario() {
286 let pos = SVector::from([0.0, 5.0, 0.0]);
289 let vel = SVector::from([0.0, -500.0, 0.0]);
290 let normal = SVector::from([0.0, 1.0, 0.0]);
291
292 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 0.016).unwrap();
293 assert!(hit.toi < 0.016, "CCD should detect hit within dt");
294 assert!(hit.toi > 0.0, "CCD should detect hit after start");
295
296 let corrected_y = 5.0 + (-500.0) * hit.toi;
298 assert!(
299 corrected_y >= 0.4,
300 "corrected position should be above plane, y = {corrected_y}"
301 );
302 }
303}