#![allow(missing_docs)]
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
#[inline]
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
#[inline]
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
#[inline]
fn normalize(a: [f64; 3]) -> Option<[f64; 3]> {
let l = len(a);
if l < 1e-12 {
None
} else {
Some(scale(a, 1.0 / l))
}
}
#[inline]
fn project_onto_plane(v: [f64; 3], normal: [f64; 3]) -> [f64; 3] {
let d = dot(v, normal);
sub(v, scale(normal, d))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HitKind {
Ground,
Wall,
}
fn classify_hit(normal: [f64; 3], up: [f64; 3], max_slope_deg: f64) -> HitKind {
let cos_threshold = max_slope_deg.to_radians().cos();
if dot(normal, up) > cos_threshold {
HitKind::Ground
} else {
HitKind::Wall
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterShape {
pub radius: f64,
pub half_height: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterConfig {
pub max_slope_deg: f64,
pub step_offset: f64,
pub skin_width: f64,
pub up_axis: [f64; 3],
pub max_iterations: usize,
}
impl Default for CharacterConfig {
fn default() -> Self {
Self {
max_slope_deg: 45.0,
step_offset: 0.3,
skin_width: 1e-3,
up_axis: [0.0, 1.0, 0.0],
max_iterations: 4,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SweepHit {
pub toi: f64,
pub normal: [f64; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterMove {
pub translation: [f64; 3],
pub is_grounded: bool,
pub ground_normal: Option<[f64; 3]>,
pub iteration_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterController {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub is_grounded: bool,
pub ground_normal: Option<[f64; 3]>,
pub shape: CharacterShape,
pub config: CharacterConfig,
}
impl CharacterController {
pub fn new(position: [f64; 3], shape: CharacterShape, config: CharacterConfig) -> Self {
Self {
position,
velocity: [0.0; 3],
is_grounded: false,
ground_normal: None,
shape,
config,
}
}
pub fn move_and_slide<F>(&mut self, desired_delta: [f64; 3], sweep: F) -> CharacterMove
where
F: Fn([f64; 3], [f64; 3]) -> Option<SweepHit>,
{
let initial_pos = self.position;
self.is_grounded = false;
self.ground_normal = None;
let mut remaining = desired_delta;
let mut iteration_count = 0_usize;
for _ in 0..self.config.max_iterations {
let mag = len(remaining);
if mag < self.config.skin_width {
break;
}
iteration_count += 1;
match sweep(self.position, remaining) {
None => {
self.position = add(self.position, remaining);
break;
}
Some(hit) => {
let t = hit.toi.clamp(0.0, 1.0);
let normal = hit.normal;
let skin_ratio = (self.config.skin_width / mag).min(t);
let effective_t = (t - skin_ratio).max(0.0);
self.position = add(self.position, scale(remaining, effective_t));
let kind = classify_hit(normal, self.config.up_axis, self.config.max_slope_deg);
match kind {
HitKind::Ground => {
self.is_grounded = true;
self.ground_normal = Some(normal);
remaining = project_onto_plane(remaining, normal);
}
HitKind::Wall => {
let stepped =
try_step_up(self.position, remaining, &self.config, &sweep);
if let Some(new_pos) = stepped {
self.position = new_pos;
break;
}
remaining = project_onto_plane(remaining, normal);
}
}
}
}
}
let snap_result = ground_snap(self.position, &self.config, &sweep);
if let Some((snapped_pos, snap_normal)) = snap_result {
self.position = snapped_pos;
self.is_grounded = true;
self.ground_normal = Some(snap_normal);
}
let translation = sub(self.position, initial_pos);
CharacterMove {
translation,
is_grounded: self.is_grounded,
ground_normal: self.ground_normal,
iteration_count,
}
}
pub fn jump(&mut self, speed: f64) {
let up = self.config.up_axis;
let v_up = dot(self.velocity, up);
self.velocity = add(self.velocity, scale(up, speed - v_up));
self.is_grounded = false;
self.ground_normal = None;
}
pub fn apply_gravity(&mut self, gravity_accel: f64, dt: f64) {
let down = scale(self.config.up_axis, -1.0);
self.velocity = add(self.velocity, scale(down, gravity_accel * dt));
}
}
fn try_step_up<F>(
pos: [f64; 3],
remaining: [f64; 3],
config: &CharacterConfig,
sweep: &F,
) -> Option<[f64; 3]>
where
F: Fn([f64; 3], [f64; 3]) -> Option<SweepHit>,
{
let up = config.up_axis;
let step = config.step_offset;
let skin = config.skin_width;
let up_delta = scale(up, step);
let up_pos = match sweep(pos, up_delta) {
None => add(pos, up_delta),
Some(hit) => {
let mag = len(up_delta);
let skin_ratio = (skin / mag).min(hit.toi);
let t = (hit.toi - skin_ratio).max(0.0);
if t < 1e-6 {
return None; }
add(pos, scale(up_delta, t))
}
};
let fwd_pos = match sweep(up_pos, remaining) {
None => add(up_pos, remaining),
Some(_) => return None, };
let down_delta = scale(up, -(step + skin));
match sweep(fwd_pos, down_delta) {
None => {
None
}
Some(hit) => {
let mag = len(down_delta);
let skin_ratio = (skin / mag).min(hit.toi);
let t = (hit.toi - skin_ratio).max(0.0);
let snapped = add(fwd_pos, scale(down_delta, t));
if classify_hit(hit.normal, up, config.max_slope_deg) == HitKind::Ground {
Some(snapped)
} else {
None
}
}
}
}
fn ground_snap<F>(
pos: [f64; 3],
config: &CharacterConfig,
sweep: &F,
) -> Option<([f64; 3], [f64; 3])>
where
F: Fn([f64; 3], [f64; 3]) -> Option<SweepHit>,
{
let up = config.up_axis;
let down = scale(up, -1.0);
let down_delta = scale(down, config.step_offset);
let mag = len(down_delta);
let hit = sweep(pos, down_delta)?;
if classify_hit(hit.normal, up, config.max_slope_deg) != HitKind::Ground {
return None;
}
let skin_ratio = (config.skin_width / mag).min(hit.toi);
let t = (hit.toi - skin_ratio).max(0.0);
let snapped_pos = add(pos, scale(down_delta, t));
Some((snapped_pos, hit.normal))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn default_controller() -> CharacterController {
CharacterController::new(
[0.0, 0.0, 0.0],
CharacterShape {
radius: 0.4,
half_height: 0.9,
},
CharacterConfig::default(),
)
}
#[test]
fn test_flat_floor_slide() {
let mut ctrl = default_controller();
let desired = [1.0_f64, -1.0, 0.0];
let call_count = Cell::new(0_usize);
let result = ctrl.move_and_slide(desired, |_origin, _dir| {
let n = call_count.get();
call_count.set(n + 1);
if n == 0 {
Some(SweepHit {
toi: 0.5,
normal: [0.0, 1.0, 0.0],
})
} else {
None
}
});
assert!(result.is_grounded, "Should be grounded after floor hit");
assert!(result.ground_normal.is_some());
let tx = result.translation[0];
let ty = result.translation[1];
assert!(tx > 0.4, "Should have moved forward (tx={tx})");
assert!(ty < 0.0, "Should have moved down to the floor (ty={ty})");
assert!(
ty > -1.1,
"Should not have sunk through the floor (ty={ty})"
);
}
#[test]
fn test_wall_stop() {
let mut ctrl = default_controller();
let desired = [1.0_f64, 0.0, 0.0];
let call_count = Cell::new(0_usize);
let result = ctrl.move_and_slide(desired, |_origin, _dir| {
let n = call_count.get();
call_count.set(n + 1);
if n == 0 {
Some(SweepHit {
toi: 0.1,
normal: [1.0, 0.0, 0.0],
})
} else {
None
}
});
assert!(!result.is_grounded, "Wall hit should not be grounded");
let tx = result.translation[0];
assert!(
tx.abs() < 0.2,
"Wall hit: x translation should be small (tx={tx})"
);
}
#[test]
fn test_slope_climb() {
let mut ctrl = default_controller();
let desired = [1.0_f64, -0.2, 0.0];
let angle = 40.0_f64.to_radians();
let nx = -angle.sin();
let ny = angle.cos();
let slope_normal = [nx, ny, 0.0_f64];
let call_count = Cell::new(0_usize);
let result = ctrl.move_and_slide(desired, |_origin, _dir| {
let n = call_count.get();
call_count.set(n + 1);
if n == 0 {
Some(SweepHit {
toi: 0.5,
normal: slope_normal,
})
} else {
None
}
});
assert!(result.is_grounded, "40° slope should be treated as ground");
assert!(
result.ground_normal.is_some(),
"ground_normal should be set for 40° slope"
);
}
#[test]
fn test_slope_reject() {
let mut ctrl = default_controller();
let desired = [1.0_f64, 0.0, 0.0];
let angle = 60.0_f64.to_radians();
let nx = -angle.sin();
let ny = angle.cos();
let slope_normal = [nx, ny, 0.0_f64];
let call_count = Cell::new(0_usize);
let result = ctrl.move_and_slide(desired, |_origin, _dir| {
let n = call_count.get();
call_count.set(n + 1);
if n == 0 {
Some(SweepHit {
toi: 0.5,
normal: slope_normal,
})
} else {
None
}
});
assert!(
!result.is_grounded,
"60° slope should be treated as wall, not ground"
);
let tx = result.translation[0];
assert!(
tx < 0.8,
"Steep slope: x translation should be reduced (tx={tx})"
);
}
#[test]
fn test_step_up() {
let mut ctrl = default_controller();
ctrl.position = [0.0, 0.0, 0.0];
let desired = [0.5_f64, 0.0, 0.0];
let call_count = Cell::new(0_usize);
let result = ctrl.move_and_slide(desired, |_origin, dir| {
let n = call_count.get();
call_count.set(n + 1);
match n {
0 => {
Some(SweepHit {
toi: 0.1,
normal: [1.0, 0.0, 0.0],
})
}
1 => None, 2 => None, 3 => {
let _ = dir;
Some(SweepHit {
toi: 0.5,
normal: [0.0, 1.0, 0.0],
})
}
_ => None,
}
});
let tx = result.translation[0];
assert!(
tx > 0.1,
"Step-up: controller should have advanced forward (tx={tx})"
);
}
#[test]
fn test_jump() {
let mut ctrl = default_controller();
ctrl.is_grounded = true;
ctrl.velocity = [2.0, 0.0, 0.0];
ctrl.jump(5.0);
assert!(
(ctrl.velocity[1] - 5.0).abs() < 1e-10,
"After jump, velocity.y should be 5.0, got {}",
ctrl.velocity[1]
);
assert!(
(ctrl.velocity[0] - 2.0).abs() < 1e-10,
"Jump should preserve horizontal velocity, got {}",
ctrl.velocity[0]
);
assert!(!ctrl.is_grounded, "Should not be grounded after jump");
assert!(ctrl.ground_normal.is_none());
}
#[test]
fn test_serde_round_trip() {
let original = CharacterController {
position: [1.0, 2.5, -3.0],
velocity: [0.5, 0.0, 1.2],
is_grounded: true,
ground_normal: Some([0.0, 1.0, 0.0]),
shape: CharacterShape {
radius: 0.4,
half_height: 0.9,
},
config: CharacterConfig {
max_slope_deg: 45.0,
step_offset: 0.3,
skin_width: 1e-3,
up_axis: [0.0, 1.0, 0.0],
max_iterations: 4,
},
};
let json = serde_json::to_string(&original).expect("serialize should succeed");
let restored: CharacterController =
serde_json::from_str(&json).expect("deserialize should succeed");
assert!((restored.position[0] - original.position[0]).abs() < 1e-12);
assert!((restored.position[1] - original.position[1]).abs() < 1e-12);
assert!((restored.position[2] - original.position[2]).abs() < 1e-12);
assert!((restored.velocity[0] - original.velocity[0]).abs() < 1e-12);
assert_eq!(restored.is_grounded, original.is_grounded);
assert!(restored.ground_normal.is_some());
assert!((restored.shape.radius - original.shape.radius).abs() < 1e-12);
assert!((restored.config.max_slope_deg - original.config.max_slope_deg).abs() < 1e-12);
assert_eq!(
restored.config.max_iterations,
original.config.max_iterations
);
}
#[test]
fn test_no_hit_path() {
let mut ctrl = default_controller();
let desired = [3.0_f64, 0.0, 0.0];
let result = ctrl.move_and_slide(desired, |_origin, _dir| None);
assert!(
(result.translation[0] - 3.0).abs() < 1e-10,
"No-hit: should move full delta x (tx={})",
result.translation[0]
);
assert!(
result.translation[1].abs() < 1e-10,
"No-hit: y translation should be zero"
);
assert!(
result.translation[2].abs() < 1e-10,
"No-hit: z translation should be zero"
);
assert!(!result.is_grounded, "No-hit: should not be grounded");
}
#[test]
fn test_apply_gravity() {
let mut ctrl = default_controller();
ctrl.velocity = [0.0, 0.0, 0.0];
ctrl.apply_gravity(9.81, 1.0);
assert!(
(ctrl.velocity[1] - (-9.81)).abs() < 1e-10,
"apply_gravity: velocity.y should be -9.81, got {}",
ctrl.velocity[1]
);
}
#[test]
fn test_config_defaults() {
let cfg = CharacterConfig::default();
assert!((cfg.max_slope_deg - 45.0).abs() < 1e-10);
assert!((cfg.step_offset - 0.3).abs() < 1e-10);
assert!((cfg.skin_width - 1e-3).abs() < 1e-15);
assert_eq!(cfg.up_axis, [0.0, 1.0, 0.0]);
assert_eq!(cfg.max_iterations, 4);
}
}