makepad/libs/box3d/tests/test_mover.rs
Admin 848e715c6c box3d: pure Rust port of Box3D (erincatto/box3d @ 29bf523)
Full engine port in libs/box3d: math, geometry, GJK/TOI, hull builder,
dynamic tree, manifolds, constraint graph, solver (serial, scalar SIMD
path), all 8 joint types, sensors, mover, world API. 147 ported C unit
tests green in debug and release. See libs/box3d/README.md for the
upstream revision and sync notes, PORTING.md for conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00

207 lines
7.8 KiB
Rust

// Port of box3d/test/test_mover.c
use makepad_box3d::capsule::collide_mover_and_capsule;
use makepad_box3d::hull::{collide_mover_and_hull, make_box_hull};
use makepad_box3d::math_functions::*;
use makepad_box3d::mover::solve_planes;
use makepad_box3d::sphere::collide_mover_and_sphere;
use makepad_box3d::types::*;
use makepad_box3d::{ensure, ensure_small};
#[test]
fn parallel_planes() {
let mut planes = [CollisionPlane::default(); 3];
planes[0].plane.normal = vec3(0.0, 0.0, 1.0);
planes[0].plane.offset = 0.5;
planes[0].push_limit = f32::MAX;
planes[1].plane.normal = vec3(0.0, 0.0, 1.0);
planes[1].plane.offset = 1.0;
planes[1].push_limit = f32::MAX;
let target = vec3(0.0, 0.0, 0.0);
let result = solve_planes(target, &mut planes[..2]);
ensure!(result.iteration_count == 2);
ensure_small!(result.delta.z - 1.0, 0.0055);
}
#[test]
fn game_planes() {
// This scenario takes many iterations because the target is deep into the plane.
let mut planes = [CollisionPlane::default(); 3];
planes[0].plane.normal = vec3(0.0, -0.23941046, 0.970918416);
planes[0].plane.offset = 0.390724182;
planes[0].push_limit = f32::MAX;
planes[1].plane.normal = vec3(0.0, 0.0, 1.0);
planes[1].plane.offset = 1.49998093;
planes[1].push_limit = f32::MAX;
let mut target = vec3(-2.5390625, 0.0, -73.6880798);
planes[0].plane.offset -= dot(planes[0].plane.normal, target);
planes[1].plane.offset -= dot(planes[1].plane.normal, target);
target = Vec3::ZERO;
let result = solve_planes(target, &mut planes[..2]);
ensure!(result.iteration_count == 20);
}
// ---------------------------------------------------------------------------
// Mover-collide overlap handling
//
// collide_mover_and_sphere / capsule / hull must never emit a plane with a
// degenerate (zero) normal, even when the mover deeply penetrates the shape.
// On deep overlap the GJK path returns a {0,0,0} normal; these tests guard the
// fix that replaces it with an analytic (sphere/capsule) or dropped (hull) result.
// ---------------------------------------------------------------------------
#[test]
fn mover_sphere_separated() {
let shape = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
let mover = Capsule { center1: vec3(4.0, 3.0, 0.0), center2: vec3(6.0, 3.0, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_sphere(&mut result, &shape, &mover);
ensure!(count == 0);
}
#[test]
fn mover_sphere_touching() {
let shape = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
// Mover core segment runs along X at y = 0.6, leaving it 0.1 inside the
// 0.7 combined radius.
let mover = Capsule { center1: vec3(-1.0, 0.6, 0.0), center2: vec3(1.0, 0.6, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_sphere(&mut result, &shape, &mover);
ensure!(count == 1);
ensure!(is_normalized(result.plane.normal));
// Push-out points from the sphere straight up toward the mover.
ensure!(result.plane.normal.y > 0.99);
ensure_small!(result.plane.offset - 0.1, 1e-5);
}
#[test]
fn mover_sphere_deep_overlap() {
let shape = Sphere { center: vec3(0.0, 0.0, 0.0), radius: 0.5 };
// Mover axis runs straight through the sphere center: the bug case where
// GJK reports a zero normal.
let mover = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_sphere(&mut result, &shape, &mover);
ensure!(count == 1);
// The normal must still be a valid unit vector.
ensure!(is_normalized(result.plane.normal));
// The fallback axis is perpendicular to the mover axis (X).
ensure_small!(result.plane.normal.x, 1e-5);
// Deepest possible penetration: the full combined radius.
ensure_small!(result.plane.offset - 0.7, 1e-5);
}
#[test]
fn mover_capsule_separated() {
let shape = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.3 };
let mover = Capsule { center1: vec3(-1.0, 5.0, 0.0), center2: vec3(1.0, 5.0, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_capsule(&mut result, &shape, &mover);
ensure!(count == 0);
}
#[test]
fn mover_capsule_touching() {
let shape = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.3 };
// Parallel mover 0.4 above, leaving it 0.1 inside the 0.5 combined radius.
let mover = Capsule { center1: vec3(-1.0, 0.4, 0.0), center2: vec3(1.0, 0.4, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_capsule(&mut result, &shape, &mover);
ensure!(count == 1);
ensure!(is_normalized(result.plane.normal));
ensure!(result.plane.normal.y > 0.99);
ensure_small!(result.plane.offset - 0.1, 1e-5);
}
#[test]
fn mover_capsule_deep_overlap() {
// Shape capsule along X, mover capsule along Z; their core segments cross
// exactly at the origin, so GJK reports a zero normal.
let shape = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.3 };
let mover = Capsule { center1: vec3(0.0, 0.0, -1.0), center2: vec3(0.0, 0.0, 1.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_capsule(&mut result, &shape, &mover);
ensure!(count == 1);
ensure!(is_normalized(result.plane.normal));
// The separating axis of two crossing segments is perpendicular to both.
ensure_small!(result.plane.normal.x, 1e-5);
ensure_small!(result.plane.normal.z, 1e-5);
ensure_small!(result.plane.offset - 0.5, 1e-5);
}
#[test]
fn mover_capsule_parallel_overlap() {
// Mover core segment coincides with the shape core segment: the cross-product
// axis degenerates, so a perpendicular of the mover axis is used instead.
let shape = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.3 };
let mover = Capsule { center1: vec3(-1.0, 0.0, 0.0), center2: vec3(1.0, 0.0, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_capsule(&mut result, &shape, &mover);
ensure!(count == 1);
ensure!(is_normalized(result.plane.normal));
// The fallback axis is perpendicular to the mover axis (X).
ensure_small!(result.plane.normal.x, 1e-5);
ensure_small!(result.plane.offset - 0.5, 1e-5);
}
#[test]
fn mover_hull_separated() {
let box_hull = make_box_hull(0.5, 0.5, 0.5);
let mover = Capsule { center1: vec3(-0.3, 5.0, 0.0), center2: vec3(0.3, 5.0, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_hull(&mut result, &box_hull, &mover);
ensure!(count == 0);
}
#[test]
fn mover_hull_touching() {
let box_hull = make_box_hull(0.5, 0.5, 0.5);
// Mover core segment above the +Y face; the 0.2 radius reaches 0.1 into it.
let mover = Capsule { center1: vec3(-0.3, 0.6, 0.0), center2: vec3(0.3, 0.6, 0.0), radius: 0.2 };
let mut result = PlaneResult::default();
let count = collide_mover_and_hull(&mut result, &box_hull, &mover);
ensure!(count == 1);
ensure!(is_normalized(result.plane.normal));
ensure!(result.plane.normal.y > 0.99);
ensure_small!(result.plane.offset - 0.1, 1e-4);
}
#[test]
fn mover_hull_deep_overlap() {
let box_hull = make_box_hull(0.5, 0.5, 0.5);
// Mover core segment lies entirely inside the box, so GJK reports overlap.
let mover = Capsule { center1: vec3(-0.2, 0.0, 0.0), center2: vec3(0.2, 0.0, 0.0), radius: 0.1 };
let mut result = PlaneResult::default();
let count = collide_mover_and_hull(&mut result, &box_hull, &mover);
// The overlap guard drops the plane rather than emit a zero normal.
// todo replace with SAT once collide_mover_and_hull resolves overlaps.
ensure!(count == 0);
}