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>
497 lines
18 KiB
Rust
497 lines
18 KiB
Rust
// Port of box3d/test/test_height_field.c
|
|
// The C `hf->version == B3_HEIGHT_FIELD_VERSION` blob-metadata assertion has no
|
|
// Rust counterpart (the Vec-based HeightFieldData carries no version field).
|
|
|
|
use makepad_box3d::distance::shape_cast;
|
|
use makepad_box3d::height_field::*;
|
|
use makepad_box3d::math_functions::*;
|
|
use makepad_box3d::simd::{intersect_ray_triangle, load_vec3};
|
|
use makepad_box3d::types::{
|
|
CastOutput, HeightFieldData, HeightFieldDef, RayCastInput, ShapeCastInput, ShapeCastPairInput,
|
|
ShapeProxy, HEIGHT_FIELD_HOLE,
|
|
};
|
|
use makepad_box3d::{ensure, ensure_small};
|
|
|
|
#[test]
|
|
fn height_field_create() {
|
|
let scale = vec3(1.0, 1.0, 1.0);
|
|
let hf = create_grid(4, 4, scale, false);
|
|
|
|
ensure!(hf.row_count == 4);
|
|
ensure!(hf.column_count == 4);
|
|
ensure!(hf.clockwise == false);
|
|
// C: ENSURE( hf->version == B3_HEIGHT_FIELD_VERSION ) — no version field in the port.
|
|
|
|
ensure_small!(hf.aabb.lower_bound.x, f32::EPSILON);
|
|
ensure_small!(hf.aabb.lower_bound.y, f32::EPSILON);
|
|
ensure_small!(hf.aabb.lower_bound.z, f32::EPSILON);
|
|
|
|
ensure_small!(hf.aabb.upper_bound.x - 3.0, f32::EPSILON);
|
|
ensure_small!(hf.aabb.upper_bound.y, f32::EPSILON);
|
|
ensure_small!(hf.aabb.upper_bound.z - 3.0, f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn height_field_triangle_index() {
|
|
// Asymmetric grid (rows != cols) catches off-by-one errors between
|
|
// vertex stride (columnCount) and cell stride (columnCount - 1).
|
|
let row_count = 4;
|
|
let column_count = 5;
|
|
let scale = vec3(1.0, 1.0, 1.0);
|
|
let hf = create_grid(row_count, column_count, scale, false);
|
|
|
|
let triangle_count = 2 * (row_count - 1) * (column_count - 1);
|
|
|
|
for triangle_index in 0..triangle_count {
|
|
let quad_index = triangle_index >> 1;
|
|
let sub = triangle_index & 1;
|
|
let row = quad_index / (column_count - 1);
|
|
let column = quad_index - row * (column_count - 1);
|
|
|
|
let index11 = row * column_count + column;
|
|
let index12 = index11 + 1;
|
|
let index21 = (row + 1) * column_count + column;
|
|
let index22 = index21 + 1;
|
|
|
|
let t = get_height_field_triangle(&hf, triangle_index);
|
|
|
|
if sub == 0 {
|
|
// Triangle 0 (CCW): {11, 21, 12}
|
|
ensure!(t.i1 == index11);
|
|
ensure!(t.i2 == index21);
|
|
ensure!(t.i3 == index12);
|
|
} else {
|
|
// Triangle 1 (CCW): {22, 12, 21}
|
|
ensure!(t.i1 == index22);
|
|
ensure!(t.i2 == index12);
|
|
ensure!(t.i3 == index21);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn height_field_winding() {
|
|
// Build the same flat 3x3 field with CCW and CW winding. The cross-product
|
|
// normal of triangle 0 must flip sign accordingly.
|
|
let heights = [0.0f32; 9];
|
|
let materials = [0u8; 4];
|
|
|
|
let mut def = HeightFieldDef {
|
|
heights: &heights,
|
|
material_indices: &materials,
|
|
scale: vec3(1.0, 1.0, 1.0),
|
|
count_x: 3,
|
|
count_z: 3,
|
|
global_minimum_height: -1.0,
|
|
global_maximum_height: 1.0,
|
|
..Default::default()
|
|
};
|
|
|
|
def.clockwise_winding = false;
|
|
let ccw = create_height_field(&def);
|
|
|
|
def.clockwise_winding = true;
|
|
let cw = create_height_field(&def);
|
|
|
|
let ta = get_height_field_triangle(&ccw, 0);
|
|
let tb = get_height_field_triangle(&cw, 0);
|
|
|
|
let na = normalize(cross(sub(ta.vertices[1], ta.vertices[0]), sub(ta.vertices[2], ta.vertices[0])));
|
|
let nb = normalize(cross(sub(tb.vertices[1], tb.vertices[0]), sub(tb.vertices[2], tb.vertices[0])));
|
|
|
|
ensure_small!(na.x, f32::EPSILON);
|
|
ensure_small!(na.y - 1.0, f32::EPSILON);
|
|
ensure_small!(na.z, f32::EPSILON);
|
|
|
|
ensure_small!(nb.x, f32::EPSILON);
|
|
ensure_small!(nb.y + 1.0, f32::EPSILON);
|
|
ensure_small!(nb.z, f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn ray_cast_flat_field() {
|
|
// Build a flat 4x4 field with a tight quantization range so the recovered
|
|
// surface stays within ~1e-5 of y=0 (create_grid uses -256..256 which
|
|
// blows the 1/UINT16_MAX quantum up to ~4e-3 in y).
|
|
let heights = [0.0f32; 16];
|
|
let materials = [0u8; 9];
|
|
|
|
let def = HeightFieldDef {
|
|
heights: &heights,
|
|
material_indices: &materials,
|
|
scale: vec3(1.0, 1.0, 1.0),
|
|
count_x: 4,
|
|
count_z: 4,
|
|
global_minimum_height: -1.0,
|
|
global_maximum_height: 1.0,
|
|
clockwise_winding: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let hf = create_height_field(&def);
|
|
|
|
// Origin sits clearly inside triangle 0 of cell (1, 1) — off the cell
|
|
// diagonal x+z = 3. The translation overshoots the surface so the hit
|
|
// fraction is strictly less than maxFraction.
|
|
let input = RayCastInput {
|
|
origin: vec3(1.25, 10.0, 1.25),
|
|
translation: vec3(0.0, -20.0, 0.0),
|
|
max_fraction: 1.0,
|
|
};
|
|
|
|
let out = ray_cast_height_field(&hf, &input);
|
|
|
|
ensure!(out.hit == true);
|
|
ensure_small!(out.fraction - 0.5, 1e-5);
|
|
ensure_small!(out.normal.x, 1e-5);
|
|
ensure_small!(out.normal.y - 1.0, 1e-5);
|
|
ensure_small!(out.normal.z, 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn overlap_at_surface() {
|
|
let scale = vec3(1.0, 1.0, 1.0);
|
|
let hf = create_grid(4, 4, scale, false);
|
|
|
|
// Sphere center 1.0 above the surface, radius 0.5 — clear gap.
|
|
let above = [vec3(1.5, 1.0, 1.5)];
|
|
let proxy_above = ShapeProxy { points: &above, radius: 0.5 };
|
|
let hit_above = overlap_height_field(&hf, Transform::IDENTITY, &proxy_above);
|
|
ensure!(hit_above == false);
|
|
|
|
// Sphere centered on the surface — radius pokes through.
|
|
let through = [vec3(1.5, 0.0, 1.5)];
|
|
let proxy_through = ShapeProxy { points: &through, radius: 0.5 };
|
|
let hit_through = overlap_height_field(&hf, Transform::IDENTITY, &proxy_through);
|
|
ensure!(hit_through == true);
|
|
}
|
|
|
|
#[test]
|
|
fn file_roundtrip() {
|
|
let heights = [0.0f32, 0.5, -0.3, 0.1, 0.0, 0.0, 0.0, 0.2, 0.0];
|
|
let materials = [0u8, HEIGHT_FIELD_HOLE, 1, 2];
|
|
|
|
let def = HeightFieldDef {
|
|
heights: &heights,
|
|
material_indices: &materials,
|
|
scale: vec3(1.5, 2.0, 0.75),
|
|
count_x: 3,
|
|
count_z: 3,
|
|
global_minimum_height: -1.0,
|
|
global_maximum_height: 1.0,
|
|
clockwise_winding: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let path = std::env::temp_dir().join("test_height_field_roundtrip.dat");
|
|
let path = path.to_str().unwrap();
|
|
dump_height_data(&def, path);
|
|
|
|
let loaded = load_height_field(path);
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
ensure!(loaded.is_some());
|
|
let loaded = loaded.unwrap();
|
|
ensure!(loaded.row_count == def.count_z);
|
|
ensure!(loaded.column_count == def.count_x);
|
|
ensure!(loaded.clockwise == def.clockwise_winding);
|
|
|
|
ensure_small!(loaded.scale.x - def.scale.x, f32::EPSILON);
|
|
ensure_small!(loaded.scale.y - def.scale.y, f32::EPSILON);
|
|
ensure_small!(loaded.scale.z - def.scale.z, f32::EPSILON);
|
|
ensure_small!(loaded.min_height - def.global_minimum_height, f32::EPSILON);
|
|
ensure_small!(loaded.max_height - def.global_maximum_height, f32::EPSILON);
|
|
|
|
let cell_count = ((def.count_x - 1) * (def.count_z - 1)) as usize;
|
|
for i in 0..cell_count {
|
|
ensure!(loaded.materials[i] == materials[i]);
|
|
}
|
|
|
|
// Recovered heights round-trip within the quantization tolerance.
|
|
let quantum = (def.global_maximum_height - def.global_minimum_height) / u16::MAX as f32;
|
|
for i in 0..(def.count_x * def.count_z) as usize {
|
|
let recovered = loaded.min_height + loaded.height_scale * loaded.heights[i] as f32;
|
|
ensure_small!(recovered - heights[i], 2.0 * quantum);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shape_cast_vertical_straddle() {
|
|
// Regression: a vertical shape cast whose swept volume straddles a cell
|
|
// boundary must test every cell it overlaps. The field is flat at y = 0 with
|
|
// only cell (0,0) solid; the surrounding cells are holes. Each sphere is
|
|
// dropped straight down with its center nudged just past a boundary of the
|
|
// solid cell, so that cell sits on the trailing (-x / -z) side of the sweep.
|
|
// A cull AABB pinned to the leading box corner skips the solid cell entirely
|
|
// and reports a miss.
|
|
let heights = [0.0f32; 9];
|
|
let materials = [0u8, HEIGHT_FIELD_HOLE, HEIGHT_FIELD_HOLE, HEIGHT_FIELD_HOLE];
|
|
|
|
let def = HeightFieldDef {
|
|
heights: &heights,
|
|
material_indices: &materials,
|
|
scale: vec3(1.0, 1.0, 1.0),
|
|
count_x: 3,
|
|
count_z: 3,
|
|
global_minimum_height: -1.0,
|
|
global_maximum_height: 1.0,
|
|
clockwise_winding: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let hf = create_height_field(&def);
|
|
|
|
// Solid cell (0,0) spans x,z in [0,1]. Radius 0.3 with the center 0.05 past a
|
|
// boundary still reaches back into the solid cell.
|
|
let radius = 0.3f32;
|
|
|
|
// Straddle the x = 1 edge: solid cell is on the -x side. Contact lands on the
|
|
// cell edge, sqrt(0.05^2 + cy^2) = radius -> cy = 0.2958040,
|
|
// fraction = (10 - cy) / 20 = 0.4852098.
|
|
{
|
|
let center = [vec3(1.05, 10.0, 0.5)];
|
|
let input = ShapeCastInput {
|
|
proxy: ShapeProxy { points: ¢er, radius },
|
|
translation: vec3(0.0, -20.0, 0.0),
|
|
max_fraction: 1.0,
|
|
can_encroach: false,
|
|
};
|
|
|
|
let out = shape_cast_height_field(&hf, &input);
|
|
ensure!(out.hit == true);
|
|
ensure_small!(out.fraction - 0.4852098, 2e-3);
|
|
}
|
|
|
|
// Straddle the z = 1 edge: solid cell is on the -z side (same geometry).
|
|
{
|
|
let center = [vec3(0.5, 10.0, 1.05)];
|
|
let input = ShapeCastInput {
|
|
proxy: ShapeProxy { points: ¢er, radius },
|
|
translation: vec3(0.0, -20.0, 0.0),
|
|
max_fraction: 1.0,
|
|
can_encroach: false,
|
|
};
|
|
|
|
let out = shape_cast_height_field(&hf, &input);
|
|
ensure!(out.hit == true);
|
|
ensure_small!(out.fraction - 0.4852098, 2e-3);
|
|
}
|
|
|
|
// Straddle the (1,1) corner: solid cell is diagonally trailing. Contact lands
|
|
// on the corner vertex, sqrt(2*0.05^2 + cy^2) = radius -> cy = 0.2915476,
|
|
// fraction = (10 - cy) / 20 = 0.4854226.
|
|
{
|
|
let center = [vec3(1.05, 10.0, 1.05)];
|
|
let input = ShapeCastInput {
|
|
proxy: ShapeProxy { points: ¢er, radius },
|
|
translation: vec3(0.0, -20.0, 0.0),
|
|
max_fraction: 1.0,
|
|
can_encroach: false,
|
|
};
|
|
|
|
let out = shape_cast_height_field(&hf, &input);
|
|
ensure!(out.hit == true);
|
|
ensure_small!(out.fraction - 0.4854226, 2e-3);
|
|
}
|
|
}
|
|
|
|
// Brute-force shape cast: cast the proxy against every (non-hole) triangle and
|
|
// keep the closest hit. This is the ground truth for shape_cast_height_field.
|
|
fn brute_force_shape_cast(hf: &HeightFieldData, input: &ShapeCastInput) -> CastOutput {
|
|
let mut best = CastOutput::default();
|
|
let mut best_fraction = input.max_fraction;
|
|
|
|
let triangle_count = get_height_field_triangle_count(hf);
|
|
for t in 0..triangle_count {
|
|
let cell_index = t >> 1;
|
|
if hf.materials[cell_index as usize] == HEIGHT_FIELD_HOLE {
|
|
continue;
|
|
}
|
|
|
|
let tri = get_height_field_triangle(hf, t);
|
|
|
|
let pair = ShapeCastPairInput {
|
|
proxy_a: ShapeProxy { points: &tri.vertices, radius: 0.0 },
|
|
proxy_b: input.proxy,
|
|
transform: Transform::IDENTITY,
|
|
translation_b: input.translation,
|
|
max_fraction: best_fraction,
|
|
can_encroach: input.can_encroach,
|
|
};
|
|
|
|
let out = shape_cast(&pair);
|
|
if out.hit && out.fraction < best_fraction {
|
|
best_fraction = out.fraction;
|
|
best = out;
|
|
best.triangle_index = t;
|
|
}
|
|
}
|
|
|
|
best
|
|
}
|
|
|
|
#[test]
|
|
fn shape_cast_brute_force() {
|
|
// shape_cast_height_field walks the grid and culls cells; the brute-force cast
|
|
// against every triangle is the ground truth. The grid walk must never miss a
|
|
// closer hit, regardless of cast direction, origin or radius.
|
|
let scale = vec3(2.0, 1.5, 2.0);
|
|
let hf = create_wave(10, 10, scale, 0.1, 0.03333, false);
|
|
|
|
// Documented repro from sample/sample_mesh.cpp "Height Field": a sphere cast
|
|
// that moves only in z (and y). Body at (-9,0,-9), world origin (5.5,4,2.913)
|
|
// -> local (14.5,4,11.913). The grid walk used to terminate one row early
|
|
// because it compared a clamped-sweep fraction against an input-space one.
|
|
{
|
|
let origin = [vec3(14.5, 4.0, 11.913)];
|
|
let input = ShapeCastInput {
|
|
proxy: ShapeProxy { points: &origin, radius: 0.2 },
|
|
translation: vec3(0.0, -8.0, 6.397),
|
|
max_fraction: 1.0,
|
|
can_encroach: false,
|
|
};
|
|
|
|
let grid = shape_cast_height_field(&hf, &input);
|
|
let brute = brute_force_shape_cast(&hf, &input);
|
|
ensure!(brute.hit == true);
|
|
ensure!(grid.hit == brute.hit);
|
|
ensure_small!(grid.fraction - brute.fraction, 2e-3);
|
|
}
|
|
|
|
// Sweep origins across the field with assorted directions and radii.
|
|
let radii = [0.15f32, 0.4, 0.9];
|
|
let deltas = [
|
|
vec3(0.0, -8.0, 0.0), // vertical
|
|
vec3(0.0, -8.0, 6.4), // z only (+ y)
|
|
vec3(5.1, -8.0, 0.0), // x only (+ y)
|
|
vec3(0.0, -8.0, -6.4), // -z
|
|
vec3(-5.1, -8.0, 0.0), // -x
|
|
vec3(6.0, -8.0, 5.0), // diagonal
|
|
vec3(-7.0, -8.0, 4.0), // diagonal, mixed sign
|
|
vec3(9.0, -3.0, -9.0), // shallow diagonal
|
|
];
|
|
|
|
let mut failures = 0;
|
|
for xi in 0..5 {
|
|
for zi in 0..5 {
|
|
// 0.05 nudge keeps the swept box straddling cell boundaries.
|
|
let origin = [vec3(1.0 + 4.0 * xi as f32 + 0.05, 4.0, 1.0 + 4.0 * zi as f32 + 0.05)];
|
|
|
|
for di in 0..deltas.len() {
|
|
for ri in 0..radii.len() {
|
|
let input = ShapeCastInput {
|
|
proxy: ShapeProxy { points: &origin, radius: radii[ri] },
|
|
translation: deltas[di],
|
|
max_fraction: 1.0,
|
|
can_encroach: false,
|
|
};
|
|
|
|
let grid = shape_cast_height_field(&hf, &input);
|
|
let brute = brute_force_shape_cast(&hf, &input);
|
|
|
|
let mut diff = grid.fraction - brute.fraction;
|
|
diff = if diff < 0.0 { -diff } else { diff };
|
|
|
|
if grid.hit != brute.hit || (brute.hit && diff > 2e-3) {
|
|
println!(
|
|
" mismatch: origin=({:.2},{:.2},{:.2}) delta=({:.2},{:.2},{:.2}) r={:.2} grid(hit={},f={:.5}) brute(hit={},f={:.5} tri={})",
|
|
origin[0].x, origin[0].y, origin[0].z,
|
|
deltas[di].x, deltas[di].y, deltas[di].z, radii[ri],
|
|
grid.hit, grid.fraction, brute.hit, brute.fraction, brute.triangle_index
|
|
);
|
|
failures += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ensure!(failures == 0);
|
|
}
|
|
|
|
// Brute-force ray cast: cast the ray against every (non-hole) triangle and keep
|
|
// the closest hit. get_height_field_triangle returns vertices in the same winding
|
|
// order that shape_cast_height_field feeds to intersect_ray_triangle, so this is a
|
|
// pure traversal/culling check — the per-triangle math is identical.
|
|
fn brute_force_ray_cast(hf: &HeightFieldData, input: &RayCastInput) -> CastOutput {
|
|
let mut best = CastOutput::default();
|
|
let mut best_fraction = input.max_fraction;
|
|
|
|
let ray_start = load_vec3(input.origin);
|
|
let ray_delta = load_vec3(input.translation);
|
|
|
|
let triangle_count = get_height_field_triangle_count(hf);
|
|
for t in 0..triangle_count {
|
|
let cell_index = t >> 1;
|
|
if hf.materials[cell_index as usize] == HEIGHT_FIELD_HOLE {
|
|
continue;
|
|
}
|
|
|
|
let tri = get_height_field_triangle(hf, t);
|
|
let v1 = load_vec3(tri.vertices[0]);
|
|
let v2 = load_vec3(tri.vertices[1]);
|
|
let v3 = load_vec3(tri.vertices[2]);
|
|
|
|
// intersect_ray_triangle returns 1.0 on a miss.
|
|
let alpha = intersect_ray_triangle(ray_start, ray_delta, v1, v2, v3);
|
|
if alpha < best_fraction {
|
|
best_fraction = alpha;
|
|
best.hit = true;
|
|
best.fraction = alpha;
|
|
best.triangle_index = t;
|
|
}
|
|
}
|
|
|
|
best
|
|
}
|
|
|
|
#[test]
|
|
fn ray_cast_brute_force() {
|
|
// ray_cast_height_field routes through the same grid walk as the shape cast
|
|
// (radius-zero point proxy), so it is subject to the same early-termination
|
|
// bug. The brute-force cast against every triangle is the ground truth.
|
|
let scale = vec3(2.0, 1.5, 2.0);
|
|
let hf = create_wave(10, 10, scale, 0.1, 0.03333, false);
|
|
|
|
let deltas = [
|
|
vec3(0.0, -8.0, 0.0), // straight down
|
|
vec3(0.0, -8.0, 12.0), // down + z
|
|
vec3(12.0, -8.0, 0.0), // down + x
|
|
vec3(0.0, -8.0, -12.0), // down - z
|
|
vec3(-12.0, -8.0, 0.0), // down - x
|
|
vec3(14.0, -8.0, 11.0), // diagonal
|
|
vec3(-13.0, -8.0, 9.0), // diagonal, mixed sign
|
|
vec3(16.0, -4.0, -15.0), // shallow diagonal
|
|
];
|
|
|
|
let mut failures = 0;
|
|
for xi in 0..5 {
|
|
for zi in 0..5 {
|
|
// 0.05 nudge keeps the ray off cell boundaries.
|
|
let origin = vec3(1.0 + 4.0 * xi as f32 + 0.05, 4.0, 1.0 + 4.0 * zi as f32 + 0.05);
|
|
|
|
for di in 0..deltas.len() {
|
|
let input = RayCastInput { origin, translation: deltas[di], max_fraction: 1.0 };
|
|
|
|
let grid = ray_cast_height_field(&hf, &input);
|
|
let brute = brute_force_ray_cast(&hf, &input);
|
|
|
|
let mut diff = grid.fraction - brute.fraction;
|
|
diff = if diff < 0.0 { -diff } else { diff };
|
|
|
|
if grid.hit != brute.hit || (brute.hit && diff > 1e-4) {
|
|
println!(
|
|
" mismatch: origin=({:.2},{:.2},{:.2}) delta=({:.2},{:.2},{:.2}) grid(hit={},f={:.6} tri={}) brute(hit={},f={:.6} tri={})",
|
|
origin.x, origin.y, origin.z,
|
|
deltas[di].x, deltas[di].y, deltas[di].z,
|
|
grid.hit, grid.fraction, grid.triangle_index,
|
|
brute.hit, brute.fraction, brute.triangle_index
|
|
);
|
|
failures += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ensure!(failures == 0);
|
|
}
|