acdream/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
Erik 874d267117 feat(physics): PhysicsDataCache + BSP sphere query
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.

- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
  (BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
  CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
  phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
  (FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
  (streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
  internal node recursion, and empty cache behaviour. All 447 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:28:39 +02:00

207 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="BSPQuery.SphereIntersectsPoly"/>.
///
/// Real BSP data requires dat files (integration-test territory), so these
/// tests use manually constructed BSP nodes and polygon/vertex data that
/// match the structure the dat reader would produce.
/// </summary>
public class BSPQueryTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// Build a <see cref="VertexArray"/> with four vertices forming a unit
/// square in the XY-plane (Z = 0), ids 0-3.
/// </summary>
private static VertexArray UnitSquareVertexArray()
{
var va = new VertexArray();
var positions = new[]
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(1f, 1f, 0f),
new Vector3(0f, 1f, 0f),
};
for (ushort i = 0; i < positions.Length; i++)
{
var sv = new SWVertex { Origin = positions[i], Normal = Vector3.UnitZ };
va.Vertices[i] = sv;
}
return va;
}
/// <summary>
/// Build a <see cref="Polygon"/> referencing vertex ids 0-3 in order.
/// </summary>
private static Polygon UnitSquarePolygon() => new Polygon
{
SidesType = DatReaderWriter.Enums.CullMode.None,
VertexIds = new List<short> { 0, 1, 2, 3 },
};
/// <summary>
/// Build a leaf <see cref="PhysicsBSPNode"/> containing one polygon (id 0)
/// with a bounding sphere that covers the unit square.
/// </summary>
private static PhysicsBSPNode LeafNode(Sphere bounds)
{
var node = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = bounds,
};
node.Polygons.Add(0);
return node;
}
// -----------------------------------------------------------------------
// Test 1: null node returns false without throwing
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_NullNode_ReturnsFalse()
{
var polygons = new Dictionary<ushort, Polygon>();
var vertices = new VertexArray();
bool hit = BSPQuery.SphereIntersectsPoly(
null, polygons, vertices,
new Vector3(0.5f, 0.5f, 0.1f), 0.2f,
out _, out _);
Assert.False(hit);
}
// -----------------------------------------------------------------------
// Test 2: sphere far outside the bounding sphere is fast-rejected
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_MissesBoundingSphere_ReturnsFalse()
{
// Leaf node centred at origin with radius 1.
var bounds = new Sphere { Origin = Vector3.Zero, Radius = 1f };
var node = LeafNode(bounds);
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
var vertices = UnitSquareVertexArray();
// Sphere is 100 units away — broad phase must reject.
bool hit = BSPQuery.SphereIntersectsPoly(
node, polygons, vertices,
new Vector3(100f, 100f, 100f), 0.5f,
out _, out _);
Assert.False(hit);
}
// -----------------------------------------------------------------------
// Test 3: sphere resting just above the unit-square floor polygon hits
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_HitsLeafPolygon()
{
// Bounding sphere covers the 1×1 unit-square leaf.
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
var node = LeafNode(bounds);
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
var vertices = UnitSquareVertexArray();
// Sphere centred at (0.5, 0.5, 0.3) with radius 0.5 should touch Z=0 plane.
bool hit = BSPQuery.SphereIntersectsPoly(
node, polygons, vertices,
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
out ushort polyId, out Vector3 normal);
Assert.True(hit);
Assert.Equal(0, polyId);
// Normal should point roughly upward (+Z).
Assert.True(normal.Z > 0.9f, $"Expected Z-up normal, got {normal}");
}
// -----------------------------------------------------------------------
// Test 4: sphere entirely above the polygon (no contact) returns false
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_SphereTooHigh_ReturnsFalse()
{
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
var node = LeafNode(bounds);
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
var vertices = UnitSquareVertexArray();
// Sphere centred 5 units above the floor with radius 0.3 → no contact.
bool hit = BSPQuery.SphereIntersectsPoly(
node, polygons, vertices,
new Vector3(0.5f, 0.5f, 5f), 0.3f,
out _, out _);
Assert.False(hit);
}
// -----------------------------------------------------------------------
// Test 5: internal node — sphere on positive side recurses pos subtree
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_InternalNode_PosSubtreeHit()
{
// Leaf on the positive side (Z > 0 half-space) contains the floor poly.
var leafBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
var leafNode = LeafNode(leafBounds);
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
var vertices = UnitSquareVertexArray();
// Splitting plane: Z = 0, normal = +Z, D = 0.
// Sphere at Z = 0.3 is on the positive side.
var internalBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 5f };
var internalNode = new PhysicsBSPNode
{
Type = BSPNodeType.BPnn, // has PosNode only (BPnn = pos + null-neg)
SplittingPlane = new Plane(Vector3.UnitZ, 0f),
BoundingSphere = internalBounds,
PosNode = leafNode,
NegNode = null,
};
bool hit = BSPQuery.SphereIntersectsPoly(
internalNode, polygons, vertices,
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
out ushort polyId, out _);
Assert.True(hit);
Assert.Equal(0, polyId);
}
// -----------------------------------------------------------------------
// Test 6: PhysicsDataCache — caches GfxObj with physics data
// -----------------------------------------------------------------------
[Fact]
public void PhysicsDataCache_CachesGfxObjWithPhysics()
{
// We can't easily construct a real GfxObj (field-based dat type),
// so this test verifies the SetupPhysics cache path which is more
// easily instantiated.
var cache = new PhysicsDataCache();
Assert.Equal(0, cache.GfxObjCount);
Assert.Equal(0, cache.SetupCount);
// GetGfxObj for an unknown id should return null safely.
Assert.Null(cache.GetGfxObj(0x01000001u));
Assert.Null(cache.GetSetup(0x02000001u));
}
}