use std::sync::Arc;
use glam::{DMat3, DVec3};
const ZERO_SMALL: f64 = 1.0e-10;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContactShape {
Point {
position: DVec3,
radius: f64,
},
Line {
start: DVec3,
end: DVec3,
radius: f64,
},
}
impl ContactShape {
pub fn reference_position(&self) -> DVec3 {
match *self {
ContactShape::Point { position, .. } => position,
ContactShape::Line { start, end, .. } => 0.5 * (start + end),
}
}
pub fn radius(&self) -> f64 {
match *self {
ContactShape::Point { radius, .. } | ContactShape::Line { radius, .. } => radius,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContactMaterial {
pub stiffness: f64,
pub damping: f64,
pub mu_static: f64,
pub mu_kinetic: f64,
pub slip_velocity: f64,
}
impl ContactMaterial {
pub fn jeod_spring(stiffness: f64, damping: f64, mu: f64) -> Self {
Self {
stiffness,
damping,
mu_static: mu,
mu_kinetic: mu,
slip_velocity: 0.0,
}
}
fn mu_at_speed(&self, tangential_speed: f64) -> f64 {
if self.mu_static == self.mu_kinetic {
return self.mu_kinetic;
}
if self.slip_velocity <= 0.0 {
return self.mu_kinetic;
}
if tangential_speed < self.slip_velocity {
self.mu_static
} else {
self.mu_kinetic
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContactFacet {
pub shape: ContactShape,
pub material: ContactMaterial,
}
impl ContactFacet {
pub fn point(position: DVec3, radius: f64, material: ContactMaterial) -> Self {
Self {
shape: ContactShape::Point { position, radius },
material,
}
}
pub fn line(start: DVec3, end: DVec3, radius: f64, material: ContactMaterial) -> Self {
Self {
shape: ContactShape::Line { start, end, radius },
material,
}
}
}
pub trait Terrain: std::fmt::Debug + Send + Sync {
fn ground_point_pfix(&self, vehicle_pfix: DVec3) -> (DVec3, DVec3);
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SphericalTerrain {
pub radius: f64,
}
impl SphericalTerrain {
pub fn new(radius: f64) -> Self {
assert!(
radius.is_finite() && radius > 0.0,
"SphericalTerrain::new: radius must be finite and > 0, got {radius}"
);
Self { radius }
}
}
impl Terrain for SphericalTerrain {
fn ground_point_pfix(&self, vehicle_pfix: DVec3) -> (DVec3, DVec3) {
let len_sq = vehicle_pfix.length_squared();
if len_sq < ZERO_SMALL {
return (DVec3::new(self.radius, 0.0, 0.0), DVec3::X);
}
let n = vehicle_pfix / len_sq.sqrt();
(n * self.radius, n)
}
}
#[derive(Debug, Clone)]
pub struct GroundFacet {
pub terrain: Arc<dyn Terrain>,
pub alt_offset: f64,
pub material: ContactMaterial,
pub active: bool,
}
impl GroundFacet {
pub fn new(terrain: Arc<dyn Terrain>, alt_offset: f64, material: ContactMaterial) -> Self {
assert!(
alt_offset.is_finite(),
"GroundFacet::new: alt_offset must be finite, got {alt_offset}"
);
Self {
terrain,
alt_offset,
material,
active: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContactForce {
pub force: DVec3,
pub contact_point_on_a: DVec3,
pub contact_point_on_b: DVec3,
pub penetration_depth: f64,
pub normal: DVec3,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContactGeometry {
pub contact_point_on_a: DVec3,
pub contact_point_on_b: DVec3,
pub normal: DVec3,
pub penetration_depth: f64,
}
pub fn compute_contact_geometry(
facet_a: &ContactFacet,
facet_b: &ContactFacet,
rel_pos_a_wrt_b: DVec3,
) -> Option<ContactGeometry> {
let (a_ref, b_ref) = facet_world_refs(facet_a, facet_b, rel_pos_a_wrt_b);
let (p_a, p_b) = closest_points(facet_a, facet_b, a_ref, b_ref);
let sep = p_a - p_b;
let sep_len = sep.length();
let sum_radii = facet_a.shape.radius() + facet_b.shape.radius();
if sep_len >= sum_radii {
return None;
}
let normal = if sep_len < ZERO_SMALL {
DVec3::X
} else {
sep / sep_len
};
let contact_a_world = p_a - normal * facet_a.shape.radius();
let contact_b_world = p_b + normal * facet_b.shape.radius();
let contact_point_on_a = contact_a_world - a_ref;
let contact_point_on_b = contact_b_world - b_ref;
let penetration_depth = sum_radii - sep_len;
Some(ContactGeometry {
contact_point_on_a,
contact_point_on_b,
normal,
penetration_depth,
})
}
pub fn compute_contact_force(
facet_a: &ContactFacet,
facet_b: &ContactFacet,
rel_pos_a_wrt_b: DVec3,
rel_vel_a_wrt_b: DVec3,
) -> Option<ContactForce> {
let geom = compute_contact_geometry(facet_a, facet_b, rel_pos_a_wrt_b)?;
Some(compute_contact_force_from_geometry(
facet_a,
facet_b,
&geom,
rel_vel_a_wrt_b,
))
}
pub fn compute_contact_force_from_geometry(
facet_a: &ContactFacet,
facet_b: &ContactFacet,
geom: &ContactGeometry,
rel_vel_a_wrt_b: DVec3,
) -> ContactForce {
assert_eq!(
facet_a.material, facet_b.material,
"contact facet materials must match (JEOD pairs a single SpringPairInteraction to a facet pair)",
);
let &ContactGeometry {
contact_point_on_a,
contact_point_on_b,
normal,
penetration_depth,
} = geom;
let penetration_vec = penetration_depth * normal;
let spring_force = if penetration_vec.length() < ZERO_SMALL {
DVec3::ZERO
} else {
facet_a.material.stiffness * penetration_vec
};
let v_normal_mag = rel_vel_a_wrt_b.dot(normal);
let damping_force = -normal * (v_normal_mag * facet_a.material.damping);
let mut total = spring_force + damping_force;
let v_tangent = rel_vel_a_wrt_b - v_normal_mag * normal;
let tangential_speed = v_tangent.length();
let total_rel_speed = rel_vel_a_wrt_b.length();
if tangential_speed > ZERO_SMALL && total_rel_speed > ZERO_SMALL {
let tangent_hat = v_tangent / tangential_speed;
let mu = facet_a.material.mu_at_speed(tangential_speed);
let normal_force_mag = total.length();
let friction_mag = mu * normal_force_mag * (tangential_speed / total_rel_speed);
total -= tangent_hat * friction_mag;
}
ContactForce {
force: total,
contact_point_on_a,
contact_point_on_b,
penetration_depth,
normal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Initialization,
SteadyState,
}
pub fn compute_ground_contact_geometry(
vehicle_facet: &ContactFacet,
ground_facet: &GroundFacet,
vehicle_pos_inertial: DVec3,
t_inertial_body: DMat3,
t_struct_body: DMat3,
t_inertial_pfix: DMat3,
phase: Phase,
) -> Option<ContactGeometry> {
assert!(
ground_facet.active,
"compute_ground_contact_geometry: ground_facet must be active"
);
assert!(
ground_facet.alt_offset.is_finite(),
"compute_ground_contact_geometry: ground_facet.alt_offset must be finite, got {}",
ground_facet.alt_offset
);
let vehicle_pfix = t_inertial_pfix * vehicle_pos_inertial;
let (mut ground_pfix, normal_pfix) = ground_facet.terrain.ground_point_pfix(vehicle_pfix);
ground_pfix += normal_pfix * ground_facet.alt_offset;
let ground_inertial = t_inertial_pfix.transpose() * ground_pfix;
let ground_body = t_inertial_body * ground_inertial;
let facet_pos_body = match phase {
Phase::Initialization => {
DVec3::ZERO
}
Phase::SteadyState => {
t_inertial_body * vehicle_pos_inertial
}
};
let shape_ref_body = t_struct_body * vehicle_facet.shape.reference_position();
let contact_point_body = match vehicle_facet.shape {
ContactShape::Point { position, radius } => {
let center_body = t_struct_body * position;
let dir = ground_body - center_body;
center_body + surface_contact_point_point(dir, radius)
}
ContactShape::Line { start, end, radius } => {
let s_body = t_struct_body * start;
let e_body = t_struct_body * end;
let centerline_closest = closest_point_on_segment(s_body, e_body, ground_body);
surface_contact_point_line(s_body, e_body, radius, ground_body, centerline_closest)
} };
let rel_state = contact_point_body + facet_pos_body;
let subject_mag = rel_state.length();
let ground_mag = ground_body.length();
if subject_mag >= ground_mag {
return None;
}
let penetration_vec = ground_body - rel_state;
let penetration_depth = penetration_vec.length();
let normal = if penetration_depth < ZERO_SMALL {
ground_body.normalize_or(DVec3::X)
} else {
penetration_vec / penetration_depth
};
let contact_point_on_a = contact_point_body - shape_ref_body;
Some(ContactGeometry {
contact_point_on_a,
contact_point_on_b: DVec3::ZERO,
normal,
penetration_depth,
})
}
fn surface_contact_point_point(nvec: DVec3, radius: f64) -> DVec3 {
let n = nvec.normalize_or_zero();
n * radius
}
fn surface_contact_point_line(
start: DVec3,
end: DVec3,
radius: f64,
ground_point: DVec3,
centerline_closest: DVec3,
) -> DVec3 {
let axis = end - start;
let length = axis.length();
let half_length = length * 0.5;
if length < ZERO_SMALL {
let mid = 0.5 * (start + end);
let nvec = ground_point - mid;
return mid + surface_contact_point_point(nvec, radius);
}
let x_hat = axis / length;
let mid = 0.5 * (start + end);
let cp_local_x = (centerline_closest - mid).dot(x_hat);
let y_hat = if x_hat.x.abs() < 0.9 {
x_hat.cross(DVec3::X).normalize()
} else {
x_hat.cross(DVec3::Y).normalize()
};
let z_hat = x_hat.cross(y_hat);
let g = ground_point - mid;
let nvec_local = DVec3::new(g.dot(x_hat), g.dot(y_hat), g.dot(z_hat));
let sign = if cp_local_x < 0.0 { -1.0 } else { 1.0 };
let end_x = sign * half_length;
let n = nvec_local.length();
let (mut cx, mut cy, mut cz) = (cp_local_x, 0.0, 0.0);
if n > ZERO_SMALL {
let v = nvec_local / n; let x = v.x;
let yz_len_sq = v.y * v.y + v.z * v.z;
if yz_len_sq > 1e-20 {
let yz_len = yz_len_sq.sqrt();
let uy = v.y / yz_len;
let uz = v.z / yz_len;
cy = uy * radius;
cz = uz * radius;
let scale = (x * x * (cy * cy + cz * cz) / (v.y * v.y + v.z * v.z)).sqrt();
cx += sign * scale;
}
}
if cx.abs() >= end_x.abs() {
let end = DVec3::new(end_x, 0.0, 0.0);
let dir = (nvec_local - end).normalize_or_zero();
let surf = end + dir * radius;
cx = surf.x;
cy = surf.y;
cz = surf.z;
}
let local = DVec3::new(cx, cy, cz);
mid + local.x * x_hat + local.y * y_hat + local.z * z_hat
}
fn facet_world_refs(
_facet_a: &ContactFacet,
_facet_b: &ContactFacet,
rel_pos_a_wrt_b: DVec3,
) -> (DVec3, DVec3) {
let b_ref = DVec3::ZERO;
let a_ref = rel_pos_a_wrt_b;
(a_ref, b_ref)
}
fn closest_points(
facet_a: &ContactFacet,
facet_b: &ContactFacet,
a_ref: DVec3,
b_ref: DVec3,
) -> (DVec3, DVec3) {
let a_shape_ref = facet_a.shape.reference_position();
let b_shape_ref = facet_b.shape.reference_position();
let a_shift = a_ref - a_shape_ref;
let b_shift = b_ref - b_shape_ref;
match (facet_a.shape, facet_b.shape) {
(ContactShape::Point { position: pa, .. }, ContactShape::Point { position: pb, .. }) => {
(pa + a_shift, pb + b_shift)
}
(ContactShape::Line { start, end, .. }, ContactShape::Point { position: pb, .. }) => {
let p = pb + b_shift;
let s = start + a_shift;
let e = end + a_shift;
(closest_point_on_segment(s, e, p), p)
}
(ContactShape::Point { position: pa, .. }, ContactShape::Line { start, end, .. }) => {
let p = pa + a_shift;
let s = start + b_shift;
let e = end + b_shift;
(p, closest_point_on_segment(s, e, p))
}
(
ContactShape::Line {
start: s1, end: e1, ..
},
ContactShape::Line {
start: s2, end: e2, ..
},
) => {
let p1 = s1 + a_shift;
let p2 = e1 + a_shift;
let p3 = s2 + b_shift;
let p4 = e2 + b_shift;
closest_points_segment_segment(p1, p2, p3, p4)
}
}
}
fn closest_point_on_segment(s: DVec3, e: DVec3, p: DVec3) -> DVec3 {
let d = e - s;
let len_sq = d.length_squared();
if len_sq < ZERO_SMALL {
return s;
}
let t = ((p - s).dot(d) / len_sq).clamp(0.0, 1.0);
s + d * t
}
fn closest_points_segment_segment(p1: DVec3, p2: DVec3, p3: DVec3, p4: DVec3) -> (DVec3, DVec3) {
let eps = ZERO_SMALL;
let p13 = p1 - p3;
let p43 = p4 - p3;
let p21 = p2 - p1;
let d1343 = p13.dot(p43);
let d4321 = p43.dot(p21);
let d1321 = p13.dot(p21);
let d4343 = p43.dot(p43);
let d2121 = p21.dot(p21);
let denom = d2121 * d4343 - d4321 * d4321;
if d4343 < eps && d2121 < eps {
return (p1, p3);
}
if d4343 < eps {
let p31 = p3 - p1;
let d3121 = p31.dot(p21);
let u = (d3121 / d2121).clamp(0.0, 1.0);
return (p1 + p21 * u, p3);
}
if d2121 < eps {
let u = (d1343 / d4343).clamp(0.0, 1.0);
return (p1, p3 + p43 * u);
}
if denom.abs() < eps {
let d13 = p13.length();
let d14 = (p1 - p4).length();
let d23 = (p2 - p3).length();
let d24 = (p2 - p4).length();
let mut best = d13;
let mut res = (p1, p3);
if d14 < best {
best = d14;
res = (p1, p4);
}
if d23 < best {
best = d23;
res = (p2, p3);
}
if d24 < best {
res = (p2, p4);
}
return res;
}
let numer = d1343 * d4321 - d1321 * d4343;
let ma = numer / denom;
let mb = (d1343 + d4321 * ma) / d4343;
let va = if ma <= 0.0 {
p1
} else if ma >= 1.0 {
p2
} else {
p1 + p21 * ma
};
let vb = if mb <= 0.0 {
p3
} else if mb >= 1.0 {
p4
} else {
p3 + p43 * mb
};
(va, vb)
}
#[cfg(test)]
mod tests {
use super::*;
fn steel() -> ContactMaterial {
ContactMaterial::jeod_spring(3502.5, 70.05, 0.05)
}
#[test]
fn point_contact_no_penetration_zero_force() {
let a = ContactFacet::point(DVec3::ZERO, 1.0, steel());
let b = ContactFacet::point(DVec3::ZERO, 1.0, steel());
let rel_pos = DVec3::new(10.0, 0.0, 0.0);
let rel_vel = DVec3::new(-1.0, 0.0, 0.0);
assert!(compute_contact_force(&a, &b, rel_pos, rel_vel).is_none());
}
#[test]
fn point_contact_with_penetration_spring_force() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::ZERO;
let result = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!(
(result.force.x - 200.0).abs() < 1e-9,
"Fx: {}",
result.force.x
);
assert!(result.force.y.abs() < 1e-9);
assert!(result.force.z.abs() < 1e-9);
assert!((result.penetration_depth - 0.2).abs() < 1e-9);
assert!((result.normal.x - 1.0).abs() < 1e-12);
}
#[test]
fn damping_opposes_approach_velocity() {
let mat = ContactMaterial::jeod_spring(1000.0, 50.0, 0.0);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::new(-1.0, 0.0, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!((res.force.x - 250.0).abs() < 1e-9, "Fx: {}", res.force.x);
}
#[test]
fn damping_follows_separation_velocity() {
let mat = ContactMaterial::jeod_spring(1000.0, 50.0, 0.0);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::new(1.0, 0.0, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!((res.force.x - 150.0).abs() < 1e-9, "Fx: {}", res.force.x);
}
#[test]
fn friction_static_below_slip_velocity() {
let mat_static = ContactMaterial {
stiffness: 1000.0,
damping: 0.0,
mu_static: 0.8,
mu_kinetic: 0.2,
slip_velocity: 0.1,
};
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat_static);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat_static);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::new(0.0, 0.05, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!((res.force.y + 160.0).abs() < 1e-9, "Fy: {}", res.force.y);
}
#[test]
fn friction_kinetic_above_slip_velocity() {
let mat = ContactMaterial {
stiffness: 1000.0,
damping: 0.0,
mu_static: 0.8,
mu_kinetic: 0.2,
slip_velocity: 0.1,
};
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::new(0.0, 1.0, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!((res.force.y + 40.0).abs() < 1e-9, "Fy: {}", res.force.y);
}
#[test]
fn friction_zero_at_zero_slip() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.5);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel = DVec3::ZERO;
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!(res.force.y.abs() < 1e-12);
assert!(res.force.z.abs() < 1e-12);
}
#[test]
fn line_contact_perpendicular_lines() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let a = ContactFacet::line(
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(1.0, 0.0, 0.0),
1.0,
mat,
);
let b = ContactFacet::line(
DVec3::new(0.0, -1.0, 0.0),
DVec3::new(0.0, 1.0, 0.0),
1.0,
mat,
);
let rel_pos = DVec3::new(0.0, 0.0, -1.5);
let rel_vel = DVec3::ZERO;
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
assert!(
(res.force.length() - 500.0).abs() < 1e-9,
"|F|: {}",
res.force.length()
);
assert!(res.normal.z < 0.0);
assert!(res.force.z < 0.0, "Force on A pushes away from B: -z");
}
#[test]
fn line_contact_parallel_lines() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let a = ContactFacet::line(
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(1.0, 0.0, 0.0),
1.0,
mat,
);
let b = ContactFacet::line(
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(1.0, 0.0, 0.0),
1.0,
mat,
);
let rel_pos = DVec3::new(0.0, 0.0, -1.5);
let res = compute_contact_force(&a, &b, rel_pos, DVec3::ZERO).expect("in contact");
assert!(
(res.force.length() - 500.0).abs() < 1e-9,
"|F| expected 500, got {}",
res.force.length()
);
}
#[test]
fn line_point_contact_end_of_line() {
let mat = ContactMaterial::jeod_spring(100.0, 0.0, 0.0);
let a = ContactFacet::line(
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(1.0, 0.0, 0.0),
1.0,
mat,
);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(-2.2, 0.0, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, DVec3::ZERO).expect("in contact");
assert!(
(res.force.length() - 80.0).abs() < 1e-9,
"|F|: {}",
res.force.length()
);
assert!(res.force.x < 0.0);
}
#[test]
fn newtons_third_law_sign() {
let mat = ContactMaterial::jeod_spring(500.0, 10.0, 0.1);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.5, 0.2, 0.0);
let rel_vel = DVec3::new(-0.1, 0.05, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, rel_vel).expect("in contact");
let along_pos = res.force.dot(rel_pos.normalize());
assert!(
along_pos > 0.0,
"Contact force on A should push away from B, got component {along_pos}"
);
}
#[test]
fn degenerate_line_segment_falls_back_to_point() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let a = ContactFacet::line(DVec3::ZERO, DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let res = compute_contact_force(&a, &b, rel_pos, DVec3::ZERO).expect("in contact");
assert!((res.force.length() - 200.0).abs() < 1e-9);
}
#[test]
fn material_jeod_spring_single_mu() {
let m = ContactMaterial::jeod_spring(1.0, 2.0, 0.3);
assert_eq!(m.mu_static, 0.3);
assert_eq!(m.mu_kinetic, 0.3);
assert_eq!(m.slip_velocity, 0.0);
assert_eq!(m.mu_at_speed(0.0), 0.3);
assert_eq!(m.mu_at_speed(100.0), 0.3);
}
#[test]
fn oblique_friction_scales_by_tangential_over_total_speed() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.5);
let a = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let b = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let rel_pos = DVec3::new(1.8, 0.0, 0.0);
let rel_vel_pure_tang = DVec3::new(0.0, 1.0, 0.0);
let res_tang =
compute_contact_force(&a, &b, rel_pos, rel_vel_pure_tang).expect("in contact");
assert!(
(res_tang.force.y + 100.0).abs() < 1e-9,
"pure-tangent friction y: {}",
res_tang.force.y
);
let rel_vel_oblique = DVec3::new(-1.0, 1.0, 0.0);
let res_ob = compute_contact_force(&a, &b, rel_pos, rel_vel_oblique).expect("in contact");
let expected_friction_y = -100.0 / 2.0_f64.sqrt();
assert!(
(res_ob.force.y - expected_friction_y).abs() < 1e-9,
"oblique friction y: {} (expected {})",
res_ob.force.y,
expected_friction_y,
);
let unit_tangent_friction_y = -100.0;
assert!(
(res_ob.force.y - unit_tangent_friction_y).abs() > 1.0,
"oblique friction should differ from unit-tangent result ({}) \
by more than 1 N; got {}",
unit_tangent_friction_y,
res_ob.force.y
);
}
#[test]
fn spherical_terrain_ground_point_radial() {
let t = SphericalTerrain::new(6378137.0);
let v = DVec3::new(6378137.0 + 100_000.0, 0.0, 0.0);
let (p, n) = t.ground_point_pfix(v);
assert!((p - DVec3::new(6378137.0, 0.0, 0.0)).length() < 1e-9);
assert!((n - DVec3::X).length() < 1e-12);
let r = 6378137.0 + 1_000_000.0;
let v2 = DVec3::new(r * 0.5_f64.sqrt(), r * 0.5_f64.sqrt(), 0.0);
let (p2, n2) = t.ground_point_pfix(v2);
let r_p2 = p2.length();
assert!((r_p2 - 6378137.0).abs() < 1e-6);
assert!((n2.length() - 1.0).abs() < 1e-12);
}
#[test]
fn point_ground_jeod_algorithm_matches_run_contact_ground_t0() {
let mat = ContactMaterial::jeod_spring(1751.25, 35.025, 0.5);
let vehicle = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let ground = GroundFacet::new(Arc::new(SphericalTerrain::new(6378137.0)), 0.0, mat);
let pos = DVec3::new(6378137.0, 0.0, 0.0);
let geom = compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::Initialization,
)
.expect("Initialization phase reports contact (JEOD CSV t=0)");
assert!(
(geom.penetration_depth - 6378136.0).abs() < 1.0,
"penetration depth ≈ R-1 = 6378136, got {}",
geom.penetration_depth
);
assert!(
geom.normal.x > 0.95,
"normal expected near +x in body frame (radially outward), got {:?}",
geom.normal
);
let runtime = compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::SteadyState,
);
assert!(
runtime.is_none(),
"SteadyState reports no contact for vehicle at planet surface, got {runtime:?}"
);
}
#[test]
fn line_ground_jeod_algorithm_matches_run_contact_ground_t0() {
let mat = ContactMaterial::jeod_spring(1751.25, 35.025, 0.5);
let vehicle = ContactFacet::line(
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(1.0, 0.0, 0.0),
1.0,
mat,
);
let ground = GroundFacet::new(Arc::new(SphericalTerrain::new(6378137.0)), 0.0, mat);
let pos = DVec3::new(6378137.0, 0.0, 0.0);
let geom = compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::Initialization,
)
.expect("Initialization phase reports contact");
assert!(
(geom.penetration_depth - 6378135.0).abs() < 1.0,
"penetration depth ≈ R-2 = 6378135, got {}",
geom.penetration_depth
);
let runtime = compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::SteadyState,
);
assert!(
runtime.is_none(),
"SteadyState reports no contact, got {runtime:?}"
);
}
#[test]
fn ground_steady_state_no_contact_at_altitude() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let vehicle = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let ground = GroundFacet::new(Arc::new(SphericalTerrain::new(6378137.0)), 0.0, mat);
let pos = DVec3::new(6378137.0 + 100_000.0, 0.0, 0.0);
let runtime = compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::SteadyState,
);
assert!(runtime.is_none());
}
#[test]
fn ground_facet_inactive_panics() {
let mat = ContactMaterial::jeod_spring(1000.0, 0.0, 0.0);
let vehicle = ContactFacet::point(DVec3::ZERO, 1.0, mat);
let mut ground = GroundFacet::new(Arc::new(SphericalTerrain::new(6378137.0)), 0.0, mat);
ground.active = false;
let pos = DVec3::new(6378137.0, 0.0, 0.0);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
compute_ground_contact_geometry(
&vehicle,
&ground,
pos,
DMat3::IDENTITY,
DMat3::IDENTITY,
DMat3::IDENTITY,
Phase::Initialization,
)
}));
assert!(result.is_err(), "inactive ground facet should panic");
}
}