collide-mesh 0.1.0

Triangle mesh collider for the collide crate (3D)
Documentation
use collide_capsule::Capsule;
use collide_mesh::{CollisionWorld, TriangleMesh};
use collide_ray::Ray;
use ga3::Vector;

const RADIUS: f32 = 0.4;
const HEIGHT: f32 = 1.8;

fn capsule_at(position: Vector<f32>) -> Capsule<Vector<f32>> {
    Capsule::new(
        RADIUS,
        position + Vector::y(RADIUS),
        position + Vector::y(HEIGHT - RADIUS),
    )
}

fn floor_world() -> CollisionWorld {
    let positions = [
        [-10.0, 0.0, -10.0],
        [10.0, 0.0, -10.0],
        [10.0, 0.0, 10.0],
        [-10.0, 0.0, 10.0],
    ];
    let indices = [0, 2, 1, 0, 3, 2];
    CollisionWorld::new(vec![TriangleMesh::from_vertices(&positions, &indices)])
}

fn wall_world() -> CollisionWorld {
    let positions = [
        [2.0, -1.0, -10.0],
        [2.0, -1.0, 10.0],
        [2.0, 5.0, 10.0],
        [2.0, 5.0, -10.0],
    ];
    let indices = [0, 1, 2, 0, 2, 3];
    CollisionWorld::new(vec![TriangleMesh::from_vertices(&positions, &indices)])
}

#[test]
fn capsule_rests_on_floor() {
    let world = floor_world();
    let result = world.collide_capsule(&capsule_at(Vector::new(0.0, 0.0, 0.0)), 0.0);
    assert!(result.grounded);
    assert!(result.ground_y.abs() < 1e-4);
    assert!((result.ground_normal.y - 1.0).abs() < 1e-4);
}

#[test]
fn capsule_above_floor_is_airborne() {
    let world = floor_world();
    let result = world.collide_capsule(&capsule_at(Vector::new(0.0, 2.0, 0.0)), 0.0);
    assert!(!result.grounded);
}

#[test]
fn falling_capsule_lands_on_floor() {
    let world = floor_world();
    let result = world.collide_capsule(&capsule_at(Vector::new(0.0, -0.1, 0.0)), -5.0);
    assert!(result.grounded);
    assert!(result.ground_y.abs() < 1e-4);
}

#[test]
fn wall_pushes_capsule_out() {
    let world = wall_world();
    let result = world.collide_capsule(&capsule_at(Vector::new(1.8, 0.0, 0.0)), 0.0);
    assert!(!result.grounded);
    assert!(result.push.x < -1e-4);
    assert!(result.push.y.abs() < 1e-4);
}

#[test]
fn gentle_slope_counts_as_ground() {
    let positions = [
        [-10.0, 0.0, -10.0],
        [10.0, 4.0, -10.0],
        [10.0, 4.0, 10.0],
        [-10.0, 0.0, 10.0],
    ];
    let indices = [0, 2, 1, 0, 3, 2];
    let world = CollisionWorld::new(vec![TriangleMesh::from_vertices(&positions, &indices)]);
    let expected_height = 2.0;
    let result = world.collide_capsule(
        &capsule_at(Vector::new(0.0, expected_height - 0.05, 0.0)),
        -1.0,
    );
    assert!(result.grounded);
    assert!((result.ground_y - expected_height).abs() < 0.05);
}

#[test]
fn steep_slope_pushes_instead_of_grounding() {
    let positions = [
        [-10.0, -20.0, -10.0],
        [10.0, 20.0, -10.0],
        [10.0, 20.0, 10.0],
        [-10.0, -20.0, 10.0],
    ];
    let indices = [0, 2, 1, 0, 3, 2];
    let world = CollisionWorld::new(vec![TriangleMesh::from_vertices(&positions, &indices)]);
    let result = world.collide_capsule(&capsule_at(Vector::new(0.0, -0.2, 0.0)), 0.0);
    assert!(!result.grounded);
    assert!(result.push != Vector::new(0.0, 0.0, 0.0));
}

#[test]
fn raycast_hits_floor_from_above() {
    let world = floor_world();
    let ray = Ray::new(Vector::new(0.0, 5.0, 0.0), Vector::y(-1.0));
    let distance = world.raycast(&ray, 100.0);
    assert!(distance.is_some_and(|value| (value - 5.0).abs() < 1e-4));
}

#[test]
fn raycast_misses_outside_range() {
    let world = floor_world();
    let ray = Ray::new(Vector::new(0.0, 5.0, 0.0), Vector::y(-1.0));
    assert!(world.raycast(&ray, 3.0).is_none());
}

#[test]
fn raycast_ignores_backward_geometry() {
    let world = floor_world();
    let ray = Ray::new(Vector::new(0.0, 5.0, 0.0), Vector::y(1.0));
    assert!(world.raycast(&ray, 100.0).is_none());
}

#[test]
fn bvh_path_matches_small_mesh_path() {
    let mut positions = Vec::new();
    let mut indices = Vec::new();
    for row in 0..10 {
        for column in 0..10 {
            let base = positions.len() as u32;
            let x = column as f32 * 2.0 - 10.0;
            let z = row as f32 * 2.0 - 10.0;
            positions.extend_from_slice(&[
                [x, 0.0, z],
                [x + 2.0, 0.0, z],
                [x + 2.0, 0.0, z + 2.0],
                [x, 0.0, z + 2.0],
            ]);
            indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
        }
    }
    let world = CollisionWorld::new(vec![TriangleMesh::from_vertices(&positions, &indices)]);

    for sample in 0..20 {
        let x = sample as f32 - 9.5;
        let result = world.collide_capsule(&capsule_at(Vector::new(x, 0.0, 0.5)), 0.0);
        assert!(result.grounded, "sample {sample} at x {x}");
        assert!(result.ground_y.abs() < 1e-4);
    }
}

#[test]
fn degenerate_triangles_are_skipped() {
    let positions = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
    let indices = [0, 1, 2, 0, 0, 0, 5, 6, 7];
    let mesh = TriangleMesh::from_vertices(&positions, &indices);
    let world = CollisionWorld::new(vec![mesh]);
    let result = world.collide_capsule(&capsule_at(Vector::new(0.0, 0.0, 0.0)), 0.0);
    assert!(!result.grounded);
}