use crate::math::{Fix128, Vec3Fix};
use crate::solver::RigidBody;
#[cfg(not(feature = "std"))]
use alloc::vec;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelConfig {
pub local_position: Vec3Fix,
pub suspension_rest: Fix128,
pub spring_stiffness: Fix128,
pub damping: Fix128,
pub radius: Fix128,
pub max_steer_angle: Fix128,
pub driven: bool,
pub has_brake: bool,
pub progressive_rate: Fix128,
pub bump_damping: Fix128,
pub rebound_damping: Fix128,
pub bump_stop_stiffness: Fix128,
pub bump_stop_threshold: Fix128,
}
impl Default for WheelConfig {
fn default() -> Self {
Self {
local_position: Vec3Fix::ZERO,
suspension_rest: Fix128::from_ratio(3, 10),
spring_stiffness: Fix128::from_int(50000),
damping: Fix128::from_int(4500),
radius: Fix128::from_ratio(3, 10),
max_steer_angle: Fix128::from_ratio(5, 10), driven: false,
has_brake: true,
progressive_rate: Fix128::ZERO,
bump_damping: Fix128::ZERO,
rebound_damping: Fix128::ZERO,
bump_stop_stiffness: Fix128::ZERO,
bump_stop_threshold: Fix128::from_ratio(9, 10), }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EngineConfig {
pub max_torque: Fix128,
pub max_rpm: Fix128,
pub idle_rpm: Fix128,
pub engine_brake: Fix128,
pub num_gears: usize,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
max_torque: Fix128::from_int(300),
max_rpm: Fix128::from_int(7000),
idle_rpm: Fix128::from_int(800),
engine_brake: Fix128::from_ratio(5, 10),
num_gears: 5,
}
}
}
#[derive(Clone, Debug)]
pub struct VehicleConfig {
pub wheels: Vec<WheelConfig>,
pub engine: EngineConfig,
pub gear_ratios: Vec<Fix128>,
pub brake_force: Fix128,
pub aero_drag: Fix128,
pub downforce: Fix128,
pub ground_height: Fix128,
pub anti_roll_stiffness: Fix128,
}
impl Default for VehicleConfig {
fn default() -> Self {
Self {
wheels: vec![
WheelConfig {
local_position: Vec3Fix::from_f32(-0.8, -0.2, 1.2),
driven: false,
max_steer_angle: Fix128::from_ratio(5, 10),
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(0.8, -0.2, 1.2),
driven: false,
max_steer_angle: Fix128::from_ratio(5, 10),
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(-0.8, -0.2, -1.2),
driven: true,
..Default::default()
},
WheelConfig {
local_position: Vec3Fix::from_f32(0.8, -0.2, -1.2),
driven: true,
..Default::default()
},
],
engine: EngineConfig::default(),
gear_ratios: vec![
Fix128::from_ratio(35, 10), Fix128::from_ratio(22, 10), Fix128::from_ratio(15, 10), Fix128::from_ratio(11, 10), Fix128::from_ratio(8, 10), ],
brake_force: Fix128::from_int(10000),
aero_drag: Fix128::from_ratio(4, 10),
downforce: Fix128::from_ratio(1, 10),
ground_height: Fix128::ZERO,
anti_roll_stiffness: Fix128::from_int(5000),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelState {
pub compression: Fix128,
pub suspension_force: Fix128,
pub grounded: bool,
pub contact_point: Vec3Fix,
pub ground_normal: Vec3Fix,
pub spin_angle: Fix128,
pub spin_speed: Fix128,
pub lateral_slip: Fix128,
pub longitudinal_slip: Fix128,
}
impl Default for WheelState {
fn default() -> Self {
Self {
compression: Fix128::ZERO,
suspension_force: Fix128::ZERO,
grounded: false,
contact_point: Vec3Fix::ZERO,
ground_normal: Vec3Fix::UNIT_Y,
spin_angle: Fix128::ZERO,
spin_speed: Fix128::ZERO,
lateral_slip: Fix128::ZERO,
longitudinal_slip: Fix128::ZERO,
}
}
}
pub struct Vehicle {
pub config: VehicleConfig,
pub wheel_states: Vec<WheelState>,
pub current_gear: usize,
pub engine_rpm: Fix128,
pub throttle: Fix128,
pub brake: Fix128,
pub steering: Fix128,
pub speed_kmh: Fix128,
}
impl Vehicle {
#[must_use]
pub fn new(config: VehicleConfig) -> Self {
let n = config.wheels.len();
Self {
wheel_states: vec![WheelState::default(); n],
current_gear: 0,
engine_rpm: config.engine.idle_rpm,
throttle: Fix128::ZERO,
brake: Fix128::ZERO,
steering: Fix128::ZERO,
speed_kmh: Fix128::ZERO,
config,
}
}
#[must_use]
pub fn new_default() -> Self {
Self::new(VehicleConfig::default())
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, chassis: &mut RigidBody, dt: Fix128) {
if chassis.is_static() {
return;
}
let forward = chassis.rotation.rotate_vec(Vec3Fix::UNIT_Z);
let right = chassis.rotation.rotate_vec(Vec3Fix::UNIT_X);
let up = chassis.rotation.rotate_vec(Vec3Fix::UNIT_Y);
self.speed_kmh = chassis.velocity.dot(forward) * Fix128::from_ratio(36, 10);
let gear_ratio = if self.current_gear < self.config.gear_ratios.len() {
self.config.gear_ratios[self.current_gear]
} else {
Fix128::ONE
};
let engine_torque = self.config.engine.max_torque * self.throttle;
let wheel_torque = engine_torque * gear_ratio;
let num_driven = self.config.wheels.iter().filter(|w| w.driven).count() as i64;
let torque_per_wheel = if num_driven > 0 {
wheel_torque / Fix128::from_int(num_driven)
} else {
Fix128::ZERO
};
let mut total_force = Vec3Fix::ZERO;
for i in 0..self.config.wheels.len() {
let wheel_cfg = self.config.wheels[i];
let wheel_world =
chassis.position + chassis.rotation.rotate_vec(wheel_cfg.local_position);
let ray_start = wheel_world;
let max_dist = wheel_cfg.suspension_rest + wheel_cfg.radius;
let ground_y = self.config.ground_height;
let dist_to_ground = ray_start.y - ground_y;
if dist_to_ground < max_dist && dist_to_ground > Fix128::ZERO {
self.wheel_states[i].grounded = true;
self.wheel_states[i].ground_normal = Vec3Fix::UNIT_Y;
self.wheel_states[i].contact_point =
Vec3Fix::new(wheel_world.x, ground_y, wheel_world.z);
let compression =
Fix128::ONE - (dist_to_ground - wheel_cfg.radius) / wheel_cfg.suspension_rest;
let compression = if compression < Fix128::ZERO {
Fix128::ZERO
} else if compression > Fix128::ONE {
Fix128::ONE
} else {
compression
};
let effective_stiffness = wheel_cfg.spring_stiffness
* (Fix128::ONE + wheel_cfg.progressive_rate * compression);
let mut spring_force = effective_stiffness * compression;
if !wheel_cfg.bump_stop_stiffness.is_zero()
&& compression > wheel_cfg.bump_stop_threshold
{
let overshoot = compression - wheel_cfg.bump_stop_threshold;
spring_force = spring_force + wheel_cfg.bump_stop_stiffness * overshoot;
}
let vel_y = chassis.velocity.dot(up);
let damp_coeff = if vel_y < Fix128::ZERO {
if wheel_cfg.bump_damping.is_zero() {
wheel_cfg.damping
} else {
wheel_cfg.bump_damping
}
} else {
if wheel_cfg.rebound_damping.is_zero() {
wheel_cfg.damping
} else {
wheel_cfg.rebound_damping
}
};
let damp_force = damp_coeff * vel_y;
let susp_force = spring_force - damp_force;
self.wheel_states[i].compression = compression;
self.wheel_states[i].suspension_force = susp_force;
total_force = total_force + up * susp_force;
if wheel_cfg.driven {
let drive_force = forward * (torque_per_wheel / wheel_cfg.radius);
total_force = total_force + drive_force;
}
if wheel_cfg.has_brake && self.brake > Fix128::ZERO {
let speed = chassis.velocity.dot(forward);
if speed.abs() > Fix128::from_ratio(1, 10) {
let brake_dir = if speed > Fix128::ZERO {
-forward
} else {
forward
};
let brake_mag = self.config.brake_force * self.brake;
total_force = total_force + brake_dir * brake_mag;
}
}
let lateral_vel = chassis.velocity.dot(right);
let lateral_friction = -right * (lateral_vel * Fix128::from_int(5000));
total_force = total_force + lateral_friction;
if wheel_cfg.max_steer_angle > Fix128::ZERO {
let steer_angle = self.steering * wheel_cfg.max_steer_angle;
let (sin_a, cos_a) = steer_angle.sin_cos();
let steer_force = (right * sin_a + forward * cos_a) - forward;
let speed_factor = chassis.velocity.dot(forward).abs();
total_force =
total_force + steer_force * (speed_factor * Fix128::from_int(1000));
}
} else {
self.wheel_states[i].grounded = false;
self.wheel_states[i].compression = Fix128::ZERO;
}
if self.wheel_states[i].grounded {
let ground_speed = chassis.velocity.dot(forward);
self.wheel_states[i].spin_speed = ground_speed / wheel_cfg.radius;
}
self.wheel_states[i].spin_angle =
self.wheel_states[i].spin_angle + self.wheel_states[i].spin_speed * dt;
}
if !self.config.anti_roll_stiffness.is_zero() && self.config.wheels.len() >= 2 {
let num_pairs = self.config.wheels.len() / 2;
for pair in 0..num_pairs {
let left = pair * 2;
let right = pair * 2 + 1;
let diff =
self.wheel_states[left].compression - self.wheel_states[right].compression;
let anti_roll_force = self.config.anti_roll_stiffness * diff;
if self.wheel_states[left].grounded {
total_force = total_force - up * anti_roll_force;
}
if self.wheel_states[right].grounded {
total_force = total_force + up * anti_roll_force;
}
}
}
let speed_sq = chassis.velocity.length_squared();
if !speed_sq.is_zero() {
let speed = speed_sq.sqrt();
let drag_dir = chassis.velocity / speed;
total_force = total_force - drag_dir * (self.config.aero_drag * speed_sq);
total_force = total_force - up * (self.config.downforce * speed_sq);
}
chassis.apply_impulse(total_force * dt);
let wheel_rpm = self.speed_kmh.abs() * Fix128::from_ratio(100, 36) * gear_ratio;
self.engine_rpm = wheel_rpm.abs();
if self.engine_rpm < self.config.engine.idle_rpm {
self.engine_rpm = self.config.engine.idle_rpm;
}
}
pub fn shift_up(&mut self) {
if self.current_gear + 1 < self.config.gear_ratios.len() {
self.current_gear += 1;
}
}
pub fn shift_down(&mut self) {
if self.current_gear > 0 {
self.current_gear -= 1;
}
}
#[must_use]
pub fn grounded_wheels(&self) -> usize {
self.wheel_states.iter().filter(|w| w.grounded).count()
}
}
impl core::fmt::Debug for Vehicle {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Vehicle")
.field("config", &self.config)
.field(
"wheel_states",
&format_args!("[{} items]", self.wheel_states.len()),
)
.field("current_gear", &self.current_gear)
.field("engine_rpm", &self.engine_rpm)
.field("throttle", &self.throttle)
.field("brake", &self.brake)
.field("steering", &self.steering)
.field("speed_kmh", &self.speed_kmh)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vehicle_creation() {
let vehicle = Vehicle::new_default();
assert_eq!(vehicle.config.wheels.len(), 4);
assert_eq!(vehicle.wheel_states.len(), 4);
}
#[test]
fn test_vehicle_suspension() {
let mut vehicle = Vehicle::new_default();
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
assert!(vehicle.grounded_wheels() > 0, "Wheels should touch ground");
}
#[test]
fn test_vehicle_acceleration() {
let mut vehicle = Vehicle::new_default();
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
vehicle.throttle = Fix128::ONE;
let dt = Fix128::from_ratio(1, 60);
for _ in 0..60 {
vehicle.update(&mut chassis, dt);
}
let speed = vehicle.speed_kmh.to_f32();
assert!(
speed.abs() > 0.01,
"Vehicle should accelerate, speed={speed}"
);
}
#[test]
fn test_gear_shifting() {
let mut vehicle = Vehicle::new_default();
assert_eq!(vehicle.current_gear, 0);
vehicle.shift_up();
assert_eq!(vehicle.current_gear, 1);
vehicle.shift_up();
vehicle.shift_up();
vehicle.shift_up();
vehicle.shift_up(); assert_eq!(vehicle.current_gear, 4);
vehicle.shift_down();
assert_eq!(vehicle.current_gear, 3);
}
#[test]
fn test_progressive_spring() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.progressive_rate = Fix128::ONE; }
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
let grounded: Vec<_> = vehicle.wheel_states.iter().filter(|w| w.grounded).collect();
assert!(!grounded.is_empty(), "Should have grounded wheels");
for ws in &grounded {
assert!(
ws.suspension_force > Fix128::ZERO,
"Suspension force should be positive"
);
}
}
#[test]
fn test_anti_roll_bar() {
let config = VehicleConfig::default();
assert!(
!config.anti_roll_stiffness.is_zero(),
"Default anti-roll stiffness should be non-zero"
);
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle.update(&mut chassis, dt);
assert!(vehicle.grounded_wheels() > 0);
}
#[test]
fn test_asymmetric_damping_defaults() {
let w = WheelConfig::default();
assert!(w.bump_damping.is_zero());
assert!(w.rebound_damping.is_zero());
}
#[test]
fn test_asymmetric_damping_simulation() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_damping = Fix128::from_int(6000); w.rebound_damping = Fix128::from_int(3000); }
let mut vehicle = Vehicle::new(config);
let mut chassis = RigidBody::new(Vec3Fix::from_f32(0.0, 0.5, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
for _ in 0..30 {
vehicle.update(&mut chassis, dt);
}
assert!(vehicle.grounded_wheels() > 0, "Should have grounded wheels");
for ws in &vehicle.wheel_states {
if ws.grounded {
assert!(
ws.suspension_force > Fix128::ZERO,
"Suspension force should be positive with asymmetric damping"
);
}
}
}
#[test]
fn test_bump_stop_defaults() {
let w = WheelConfig::default();
assert!(w.bump_stop_stiffness.is_zero()); let expected = Fix128::from_ratio(9, 10);
assert_eq!(w.bump_stop_threshold, expected); }
#[test]
fn test_bump_stop_activation() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_stop_stiffness = Fix128::from_int(100_000); w.bump_stop_threshold = Fix128::from_ratio(5, 10); }
let mut vehicle_with_stop = Vehicle::new(config.clone());
let mut vehicle_without_stop = Vehicle::new(VehicleConfig::default());
let mut chassis_with =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.35, 0.0), Fix128::from_int(1500));
let mut chassis_without =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.35, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle_with_stop.update(&mut chassis_with, dt);
vehicle_without_stop.update(&mut chassis_without, dt);
let force_with: Fix128 = vehicle_with_stop
.wheel_states
.iter()
.filter(|w| w.grounded)
.map(|w| w.suspension_force)
.fold(Fix128::ZERO, |a, b| a + b);
let force_without: Fix128 = vehicle_without_stop
.wheel_states
.iter()
.filter(|w| w.grounded)
.map(|w| w.suspension_force)
.fold(Fix128::ZERO, |a, b| a + b);
assert!(
force_with >= force_without,
"Bump stop should increase suspension force: with={}, without={}",
force_with.to_f32(),
force_without.to_f32()
);
}
#[test]
fn test_bump_stop_no_effect_low_compression() {
let mut config = VehicleConfig::default();
for w in &mut config.wheels {
w.bump_stop_stiffness = Fix128::from_int(100_000);
w.bump_stop_threshold = Fix128::from_ratio(95, 100); }
let mut vehicle_with = Vehicle::new(config);
let mut vehicle_without = Vehicle::new(VehicleConfig::default());
let mut chassis_with =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.55, 0.0), Fix128::from_int(1500));
let mut chassis_without =
RigidBody::new(Vec3Fix::from_f32(0.0, 0.55, 0.0), Fix128::from_int(1500));
let dt = Fix128::from_ratio(1, 60);
vehicle_with.update(&mut chassis_with, dt);
vehicle_without.update(&mut chassis_without, dt);
for (ws_with, ws_without) in vehicle_with
.wheel_states
.iter()
.zip(vehicle_without.wheel_states.iter())
{
if ws_with.grounded && ws_without.grounded {
let diff = (ws_with.suspension_force - ws_without.suspension_force)
.to_f32()
.abs();
assert!(
diff < 0.01,
"Low compression: bump stop should have no effect, diff={diff}"
);
}
}
}
}