use nalgebra::SVector;
#[derive(Clone, Debug)]
pub struct CcdHit<const D: usize> {
pub toi: f64,
pub point: SVector<f64, D>,
pub normal: SVector<f64, D>,
}
pub const CCD_SPEED_THRESHOLD: f64 = 5.0;
pub fn sphere_sphere<const D: usize>(
pos_a: &SVector<f64, D>,
vel_a: &SVector<f64, D>,
radius_a: f64,
pos_b: &SVector<f64, D>,
vel_b: &SVector<f64, D>,
radius_b: f64,
dt: f64,
) -> Option<CcdHit<D>> {
let dp = pos_b - pos_a;
let dv = vel_b - vel_a;
let r = radius_a + radius_b;
let a = dv.dot(&dv);
let b = 2.0 * dp.dot(&dv);
let c = dp.dot(&dp) - r * r;
if c <= 0.0 {
let normal = if dp.norm() > 1e-15 {
dp.normalize()
} else {
let mut n = SVector::zeros();
n[0] = 1.0;
n
};
return Some(CcdHit {
toi: 0.0,
point: pos_a + normal * radius_a,
normal,
});
}
if a < 1e-20 {
return None;
}
let discriminant = b * b - 4.0 * a * c;
if discriminant < 0.0 {
return None; }
let sqrt_disc = discriminant.sqrt();
let t1 = (-b - sqrt_disc) / (2.0 * a);
if t1 >= 0.0 && t1 <= dt {
let pos_a_t = pos_a + vel_a * t1;
let pos_b_t = pos_b + vel_b * t1;
let delta = pos_b_t - pos_a_t;
let normal = if delta.norm() > 1e-15 {
delta.normalize()
} else {
let mut n = SVector::zeros();
n[0] = 1.0;
n
};
Some(CcdHit {
toi: t1,
point: pos_a_t + normal * radius_a,
normal,
})
} else {
let t2 = (-b + sqrt_disc) / (2.0 * a);
if t2 >= 0.0 && t2 <= dt {
let pos_a_t = pos_a + vel_a * t2;
let pos_b_t = pos_b + vel_b * t2;
let delta = pos_b_t - pos_a_t;
let normal = if delta.norm() > 1e-15 {
delta.normalize()
} else {
let mut n = SVector::zeros();
n[0] = 1.0;
n
};
Some(CcdHit {
toi: t2,
point: pos_a_t + normal * radius_a,
normal,
})
} else {
None }
}
}
pub fn sphere_halfspace<const D: usize>(
pos: &SVector<f64, D>,
vel: &SVector<f64, D>,
radius: f64,
plane_normal: &SVector<f64, D>,
plane_offset: f64,
dt: f64,
) -> Option<CcdHit<D>> {
let signed_dist = plane_normal.dot(pos) - plane_offset;
if signed_dist <= radius {
let contact = pos - plane_normal * signed_dist;
return Some(CcdHit {
toi: 0.0,
point: contact,
normal: *plane_normal,
});
}
let vel_toward_plane = -plane_normal.dot(vel);
if vel_toward_plane <= 0.0 {
return None; }
let t = (signed_dist - radius) / vel_toward_plane;
if t >= 0.0 && t <= dt {
let pos_t = pos + vel * t;
let contact = pos_t - plane_normal * radius;
Some(CcdHit {
toi: t,
point: contact,
normal: *plane_normal,
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sphere_sphere_head_on() {
let pos_a = SVector::from([0.0, 0.0, 0.0]);
let vel_a = SVector::from([10.0, 0.0, 0.0]);
let pos_b = SVector::from([5.0, 0.0, 0.0]);
let vel_b = SVector::from([-10.0, 0.0, 0.0]);
let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0).unwrap();
assert!(
(hit.toi - 0.2).abs() < 0.01,
"TOI = {}, expected ~0.2",
hit.toi
);
}
#[test]
fn sphere_sphere_no_collision() {
let pos_a = SVector::from([0.0, 0.0, 0.0]);
let vel_a = SVector::from([1.0, 0.0, 0.0]);
let pos_b = SVector::from([0.0, 10.0, 0.0]); let vel_b = SVector::from([0.0, 0.0, 0.0]);
let hit = sphere_sphere(&pos_a, &vel_a, 0.5, &pos_b, &vel_b, 0.5, 1.0);
assert!(hit.is_none(), "spheres moving apart should not collide");
}
#[test]
fn sphere_sphere_already_overlapping() {
let pos_a = SVector::from([0.0, 0.0, 0.0]);
let vel_a = SVector::zeros();
let pos_b = SVector::from([0.5, 0.0, 0.0]);
let vel_b = SVector::zeros();
let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
assert!((hit.toi - 0.0).abs() < 1e-12, "already overlapping: TOI should be 0");
}
#[test]
fn sphere_halfspace_falling_onto_ground() {
let pos = SVector::from([0.0, 10.0, 0.0]);
let vel = SVector::from([0.0, -100.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
assert!(
(hit.toi - 0.095).abs() < 0.01,
"TOI = {}, expected ~0.095",
hit.toi
);
assert!(hit.normal[1] > 0.9, "normal should point up");
}
#[test]
fn sphere_halfspace_moving_away() {
let pos = SVector::from([0.0, 5.0, 0.0]);
let vel = SVector::from([0.0, 10.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
assert!(hit.is_none(), "moving away from plane should not hit");
}
#[test]
fn sphere_halfspace_already_penetrating() {
let pos = SVector::from([0.0, 0.3, 0.0]);
let vel = SVector::from([0.0, -10.0, 0.0]);
let normal = SVector::from([0.0, 1.0, 0.0]);
let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0).unwrap();
assert!((hit.toi - 0.0).abs() < 1e-12, "already penetrating: TOI = 0");
}
#[test]
fn sphere_sphere_4d() {
let pos_a = SVector::from([0.0, 0.0, 0.0, 0.0]);
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]);
let vel_b = SVector::zeros();
let hit = sphere_sphere(&pos_a, &vel_a, 1.0, &pos_b, &vel_b, 1.0, 1.0).unwrap();
assert!(
(hit.toi - 0.4).abs() < 0.01,
"4D TOI = {}, expected ~0.4",
hit.toi
);
}
#[test]
fn sphere_halfspace_outside_dt() {
let pos = SVector::from([0.0, 100.0, 0.0]);
let vel = SVector::from([0.0, -1.0, 0.0]); let normal = SVector::from([0.0, 1.0, 0.0]);
let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 1.0);
assert!(hit.is_none(), "impact outside dt should not be reported");
}
#[test]
fn ccd_prevents_tunneling_scenario() {
let pos = SVector::from([0.0, 5.0, 0.0]);
let vel = SVector::from([0.0, -500.0, 0.0]);
let normal = SVector::from([0.0, 1.0, 0.0]);
let hit = sphere_halfspace(&pos, &vel, 0.5, &normal, 0.0, 0.016).unwrap();
assert!(hit.toi < 0.016, "CCD should detect hit within dt");
assert!(hit.toi > 0.0, "CCD should detect hit after start");
let corrected_y = 5.0 + (-500.0) * hit.toi;
assert!(
corrected_y >= 0.4,
"corrected position should be above plane, y = {corrected_y}"
);
}
}