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!(
217 (hit.toi - 0.0).abs() < 1e-12,
218 "already overlapping: TOI should be 0"
219 );
220 }
221
222 #[test]
223 fn sphere_halfspace_falling_onto_ground() {
224 let pos = SVector::from([0.0, 10.0, 0.0]);
225 let vel = SVector::from([0.0, -100.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
227
228 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
229
230 assert!(
233 (hit.toi - 0.095).abs() < 0.01,
234 "TOI = {}, expected ~0.095",
235 hit.toi
236 );
237 assert!(hit.normal[1] > 0.9, "normal should point up");
238 }
239
240 #[test]
241 fn sphere_halfspace_moving_away() {
242 let pos = SVector::from([0.0, 5.0, 0.0]);
243 let vel = SVector::from([0.0, 10.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
245
246 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
247 assert!(hit.is_none(), "moving away from plane should not hit");
248 }
249
250 #[test]
251 fn sphere_halfspace_already_penetrating() {
252 let pos = SVector::from([0.0, 0.3, 0.0]);
253 let vel = SVector::from([0.0, -10.0, 0.0]);
254 let normal = SVector::from([0.0, 1.0, 0.0]);
255
256 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
257 assert!(
258 (hit.toi - 0.0).abs() < 1e-12,
259 "already penetrating: TOI = 0"
260 );
261 }
262
263 #[test]
264 fn sphere_sphere_4d() {
265 let pos_a = SVector::from([0.0, 0.0, 0.0, 0.0]);
266 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]);
268 let vel_b = SVector::zeros();
269
270 let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
271 assert!(
273 (hit.toi - 0.4).abs() < 0.01,
274 "4D TOI = {}, expected ~0.4",
275 hit.toi
276 );
277 }
278
279 #[test]
280 fn sphere_halfspace_outside_dt() {
281 let pos = SVector::from([0.0, 100.0, 0.0]);
282 let vel = SVector::from([0.0, -1.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
284
285 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
287 assert!(hit.is_none(), "impact outside dt should not be reported");
288 }
289
290 #[test]
291 fn ccd_prevents_tunneling_scenario() {
292 let pos = SVector::from([0.0, 5.0, 0.0]);
295 let vel = SVector::from([0.0, -500.0, 0.0]);
296 let normal = SVector::from([0.0, 1.0, 0.0]);
297
298 let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 0.016).unwrap();
299 assert!(hit.toi < 0.016, "CCD should detect hit within dt");
300 assert!(hit.toi > 0.0, "CCD should detect hit after start");
301
302 let corrected_y = 5.0 + (-500.0) * hit.toi;
304 assert!(
305 corrected_y >= 0.4,
306 "corrected position should be above plane, y = {corrected_y}"
307 );
308 }
309}