Zum Inhalt

viewer/tests/unit/mesh-bvh.test.mjs

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildBvh, raycast, capsuleSweep } from '../../js/collision/mesh-bvh.js';

// ---------- build ----------

test('buildBvh: empty mesh → null root', () => {
  const bvh = buildBvh(new Float32Array(0), new Uint32Array(0));
  assert.equal(bvh.root, null);
  assert.equal(bvh.triCount, 0);
});

test('buildBvh: single triangle → leaf root with that triangle', () => {
  const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  assert.equal(bvh.triCount, 1);
  assert.ok(bvh.root);
  assert.equal(bvh.root.tris.length, 1);
  assert.equal(bvh.root.tris[0], 0);
});

test('buildBvh: 100 random triangles → internal-node tree', () => {
  const positions = new Float32Array(100 * 9);
  const indices = new Uint32Array(100 * 3);
  for (let t = 0; t < 100; t++) {
    for (let v = 0; v < 9; v++) positions[t * 9 + v] = Math.random();
    indices[t * 3]     = t * 3;
    indices[t * 3 + 1] = t * 3 + 1;
    indices[t * 3 + 2] = t * 3 + 2;
  }
  const bvh = buildBvh(positions, indices);
  assert.equal(bvh.triCount, 100);
  assert.ok(bvh.root.left && bvh.root.right);
});

// ---------- raycast ----------

test('raycast: ray hits a single triangle at expected t', () => {
  const positions = new Float32Array([
    -1, -1, 2,  1, -1, 2,  0, 1, 2,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  const hit = raycast(bvh, [0, 0, 0], [0, 0, 1]);
  assert.ok(hit, 'should hit');
  assert.ok(Math.abs(hit.t - 2) < 1e-4, `t=${hit.t}`);
});

test('raycast: ray misses (parallel) returns null', () => {
  const positions = new Float32Array([
    -1, -1, 2,  1, -1, 2,  0, 1, 2,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  const hit = raycast(bvh, [0, 0, 0], [1, 0, 0]);
  assert.equal(hit, null);
});

test('raycast: ray pointing away from triangle returns null', () => {
  const positions = new Float32Array([
    -1, -1, 2,  1, -1, 2,  0, 1, 2,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  const hit = raycast(bvh, [0, 0, 0], [0, 0, -1]);
  assert.equal(hit, null);
});

// ---------- capsule sweep ----------

test('capsuleSweep: vertical capsule with no nearby tris → no penetration', () => {
  const positions = new Float32Array([
    -10, -10, -10,  10, -10, -10,  0, -10, 10,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  const result = capsuleSweep(bvh, [0, 5, 0], [0, 5, 0], 0.5, 1.5);
  assert.equal(result.hits.length, 0);
});

test('capsuleSweep: capsule approaching a wall is clipped back', () => {
  // Large wall triangle in the X=1 plane.
  const positions = new Float32Array([
    1, -10, -10,  1, -10, 10,  1, 10, 0,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  // Capsule radius 0.4, height 1. Step from x=0 to x=0.8 — the destination
  // capsule penetrates the wall (distance 0.2 < radius 0.4). Sweep should
  // push the destination back to x ≤ 0.6.
  const result = capsuleSweep(bvh, [0, 0, 0], [0.8, 0, 0], 0.4, 1.0);
  assert.ok(result.hits.length > 0, 'should report a wall hit');
  assert.ok(result.endX <= 0.61, `endX=${result.endX} should be ≤ 0.6 (= 1 - radius)`);
});

test('capsuleSweep: pure-tunnel (start and end on opposite sides) is NOT detected', () => {
  // Documented limitation: single-step sweep checks only the destination,
  // so a capsule that fully passes through a thin wall in one step misses
  // the collision. Walking step-size is small enough that this doesn't
  // happen in practice — test guards against accidentally "fixing" it in
  // a way that breaks the simple destination-check semantics.
  const positions = new Float32Array([
    1, -10, -10,  1, -10, 10,  1, 10, 0,
  ]);
  const indices = new Uint32Array([0, 1, 2]);
  const bvh = buildBvh(positions, indices);
  // Step from x=0 to x=2 — capsule starts and ends well clear of the wall
  // at radius 0.4. Distance from destination to wall is 1, radius 0.4 →
  // no penetration reported.
  const result = capsuleSweep(bvh, [0, 0, 0], [2, 0, 0], 0.4, 1.0);
  assert.equal(result.hits.length, 0);
});