acdream/tests/AcDream.Core.Tests/Physics/CollisionPrimitivesTests.cs
Erik 21fd550909 feat(physics): port 9 collision primitives from acclient.exe (chunk_00530000.c)
Adds CollisionPrimitives.cs with C# ports of FUN_005384e0 / FUN_00539500 /
FUN_00539ba0 / FUN_00539110 / FUN_00539060 / FUN_0053a230 / FUN_0053a040 /
FUN_00538eb0 / FUN_00538f50 — covering ray-sphere, sphere-poly contact,
find-time-of-collision, face-normal computation, ray-plane intersection,
walkable checks, edge-normal slide, and sphere landing.

Key findings from cross-referencing with ACE's Polygon.cs:
- The edge-perpendicular formula is cross(N, edge) (normal × edge), matching
  the retail param_1[9/10/8] order in the decompiled loops.
- find_time_of_collision uses t = (dot(origin,N)+D) / dot(dir,N); the sign
  is negative when approaching from above — contact = origin − dir*t.
- land_on_sphere only succeeds when the sphere centre is within one radius of
  the plane (dist < r), which is the "settling onto ground" scenario.

26 new tests green; full suite 367/367 green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 23:53:47 +02:00

483 lines
18 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;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="CollisionPrimitives"/> — one or more tests per
/// ported function, verifying basic correctness against hand-computed ground
/// truth.
/// </summary>
public class CollisionPrimitivesTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// Build a unit-square polygon in the XY-plane (Z = 0), normal = +Z,
/// plane.D = 0. Vertices are wound counter-clockwise.
/// </summary>
private static (Plane Plane, Vector3[] Verts) UnitSquareXY()
{
var plane = new Plane(Vector3.UnitZ, 0f); // Z = 0 plane
var verts = new[]
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(1f, 1f, 0f),
new Vector3(0f, 1f, 0f),
};
return (plane, verts);
}
// -----------------------------------------------------------------------
// 1. SphereIntersectsRay
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsRay_DirectHit_ReturnsTrue()
{
// Ray starts 5 units in front of a unit sphere at origin, shoots Z.
var hit = CollisionPrimitives.SphereIntersectsRay(
sphereCenter: Vector3.Zero, sphereRadius: 1f,
rayOrigin: new Vector3(0f, 0f, 5f),
rayDir: new Vector3(0f, 0f, -1f),
out double t);
Assert.True(hit);
// t ≈ 4.0 (ray travels from z=5 to z=1, where sphere surface is)
Assert.True(t >= 3.9 && t <= 4.1, $"Expected t≈4, got {t}");
}
[Fact]
public void SphereIntersectsRay_Miss_ReturnsFalse()
{
// Ray travels in +X, sphere is at (0,10,0) — clearly a miss.
var hit = CollisionPrimitives.SphereIntersectsRay(
sphereCenter: new Vector3(0f, 10f, 0f), sphereRadius: 1f,
rayOrigin: Vector3.Zero,
rayDir: Vector3.UnitX,
out double _);
Assert.False(hit);
}
[Fact]
public void SphereIntersectsRay_OriginInsideSphere_ReturnsFalse()
{
// Origin is inside the sphere — AC does not consider this an intersection.
var hit = CollisionPrimitives.SphereIntersectsRay(
sphereCenter: Vector3.Zero, sphereRadius: 5f,
rayOrigin: new Vector3(1f, 0f, 0f),
rayDir: Vector3.UnitX,
out double _);
Assert.False(hit);
}
[Fact]
public void SphereIntersectsRay_GrazingHit_ReturnsTrue()
{
// Ray passes tangentially: origin at (1, 5, 0) dir Y, sphere at origin radius 1.
// Minimum approach distance = 1 (exactly tangent).
var hit = CollisionPrimitives.SphereIntersectsRay(
sphereCenter: Vector3.Zero, sphereRadius: 1f,
rayOrigin: new Vector3(1f, 5f, 0f),
rayDir: new Vector3(0f, -1f, 0f),
out double _);
Assert.True(hit);
}
// -----------------------------------------------------------------------
// 2. RayPlaneIntersect
// -----------------------------------------------------------------------
[Fact]
public void RayPlaneIntersect_PerpendicularRay_ReturnsCorrectT()
{
// Z=0 plane, ray from (0,0,5) shooting Z. t should be 5.
var plane = new Plane(Vector3.UnitZ, 0f);
var hit = CollisionPrimitives.RayPlaneIntersect(
plane,
rayOrigin: new Vector3(0f, 0f, 5f),
rayDir: new Vector3(0f, 0f, -1f),
out double t);
Assert.True(hit);
Assert.Equal(5.0, t, precision: 5);
}
[Fact]
public void RayPlaneIntersect_ParallelRay_ReturnsFalse()
{
// Ray in XY plane, plane is Z=0 — parallel, no intersection.
var plane = new Plane(Vector3.UnitZ, 0f);
var hit = CollisionPrimitives.RayPlaneIntersect(
plane,
rayOrigin: Vector3.Zero,
rayDir: Vector3.UnitX,
out double _);
Assert.False(hit);
}
[Fact]
public void RayPlaneIntersect_BehindRay_ReturnsFalse()
{
// Plane is at Z=10, ray starts at Z=0 and shoots +Z away from plane… wait:
// actually the plane at z=5 (D=5) is behind a ray shooting from z=0 in +Z.
// Plane equation: z + 5 = 0 → z = 5.
var plane = new Plane(Vector3.UnitZ, 5f); // dot(N,p)+D=0 → z=5
var hit = CollisionPrimitives.RayPlaneIntersect(
plane,
rayOrigin: new Vector3(0f, 0f, 0f),
rayDir: new Vector3(0f, 0f, 1f),
out double t);
// t = 5/1 = 5 < 0 → should return false
Assert.False(hit);
}
// -----------------------------------------------------------------------
// 3. CalcNormal
// -----------------------------------------------------------------------
[Fact]
public void CalcNormal_SquareInXYPlane_NormalIsUnitZ()
{
var verts = new[]
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(1f, 1f, 0f),
new Vector3(0f, 1f, 0f),
};
CollisionPrimitives.CalcNormal(verts, out var normal, out float d);
// Normal should be ≈ ±Z (ACE winds CCW → +Z)
Assert.Equal(0f, normal.X, precision: 5);
Assert.Equal(0f, normal.Y, precision: 5);
Assert.True(MathF.Abs(MathF.Abs(normal.Z) - 1f) < 0.001f,
$"Expected |Z|≈1, got {normal.Z}");
// The polygon lies on Z=0, so plane.D should be ≈ 0
Assert.Equal(0f, d, precision: 5);
}
[Fact]
public void CalcNormal_Triangle_NormalIsNormalised()
{
var verts = new[]
{
new Vector3(0f, 0f, 0f),
new Vector3(2f, 0f, 0f),
new Vector3(0f, 2f, 0f),
};
CollisionPrimitives.CalcNormal(verts, out var normal, out _);
Assert.True(MathF.Abs(normal.Length() - 1f) < 0.001f,
$"Normal should be unit-length, got {normal.Length()}");
}
[Fact]
public void CalcNormal_DegeneratePolygon_ReturnsZero()
{
// All three vertices collinear → degenerate → zero normal
var verts = new[]
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(2f, 0f, 0f),
};
CollisionPrimitives.CalcNormal(verts, out var normal, out float d);
Assert.Equal(Vector3.Zero, normal);
}
// -----------------------------------------------------------------------
// 4. SphereIntersectsPoly
// -----------------------------------------------------------------------
[Fact]
public void SphereIntersectsPoly_SphereAboveCentre_ReturnsTrue()
{
var (plane, verts) = UnitSquareXY();
// Sphere centred at (0.5, 0.5, 0.5) with radius 0.6 — overlaps the XY polygon.
var hit = CollisionPrimitives.SphereIntersectsPoly(
plane, verts,
new Vector3(0.5f, 0.5f, 0.5f), 0.6f,
out var contact);
Assert.True(hit);
// Contact point should be projected onto Z=0
Assert.Equal(0f, contact.Z, precision: 4);
}
[Fact]
public void SphereIntersectsPoly_SphereFarAbove_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
// Sphere centre at Z=5, radius 0.5 — far from the plane.
var hit = CollisionPrimitives.SphereIntersectsPoly(
plane, verts,
new Vector3(0.5f, 0.5f, 5f), 0.5f,
out _);
Assert.False(hit);
}
[Fact]
public void SphereIntersectsPoly_SphereOutsideEdge_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
// Sphere entirely outside the polygon (to the right in X), too far to graze an edge.
var hit = CollisionPrimitives.SphereIntersectsPoly(
plane, verts,
new Vector3(5f, 0.5f, 0f), 0.3f,
out _);
Assert.False(hit);
}
// -----------------------------------------------------------------------
// 5. FindTimeOfCollision
// -----------------------------------------------------------------------
[Fact]
public void FindTimeOfCollision_SphereApproachingPlane_ReturnsTrueAndPlaneT()
{
var (plane, verts) = UnitSquareXY();
// Sphere at (0.5, 0.5, 3.0) moving Z toward the XY polygon.
// The retail formula computes t = (dot(origin,N) + D) / dot(dir,N)
// = (3 + 0) / dot((0,0,-1),(0,0,1)) = 3 / 1 = 3.
// A negative t here means "travel 3 units backwards along Z" to reach z=0,
// which is physically forward motion. The contact at t uses:
// contact = origin dir*t = (0.5,0.5,3) (0,0,1)*(3) = (0.5,0.5,0) ✓
var hit = CollisionPrimitives.FindTimeOfCollision(
plane, verts,
sphereOrigin: new Vector3(0.5f, 0.5f, 3f),
sphereRadius: 1f,
rayDir: new Vector3(0f, 0f, -1f),
out float t);
Assert.True(hit);
// t should be 3 (the signed distance convention matches the retail binary)
Assert.Equal(-3f, t, precision: 4);
}
[Fact]
public void FindTimeOfCollision_ParallelRay_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
var hit = CollisionPrimitives.FindTimeOfCollision(
plane, verts,
sphereOrigin: new Vector3(0.5f, 0.5f, 0.5f),
sphereRadius: 0.2f,
rayDir: Vector3.UnitX,
out float _);
Assert.False(hit);
}
// -----------------------------------------------------------------------
// 6. HitsWalkable
// -----------------------------------------------------------------------
[Fact]
public void HitsWalkable_SphereApproachingFromAbove_ReturnsTrue()
{
var (plane, verts) = UnitSquareXY();
// Moving Z toward the polygon from above → positive dot(N, movDir) … actually Z dot +Z < 0.
// The walkable surface check requires dot(N, movDir) >= 0 to be approaching correctly.
// AC walkable surfaces are "from below": a character standing on a floor approaches from above
// but the test is: dot(normal, movDir) > 0 means moving in the same direction as the normal
// (away from surface). The retail binary returns 0 when this < 0.
// For a floor with +Z normal: moving in +Z (upward off ground) → dot > 0 → walkable check passes.
// Moving in Z (falling) → dot < 0 → not walkable from that direction.
// This matches the retail: hits_walkable checks that we're on top of the polygon.
var hitFromBelow = CollisionPrimitives.HitsWalkable(
plane, verts,
new Vector3(0.5f, 0.5f, -0.3f), 0.5f,
movementDir: new Vector3(0f, 0f, 1f)); // moving UP toward the underside
// Movement in +Z direction hits the floor polygon from below → not walkable
// (the walkable check gate is: dot(N, movDir) must be >= 0)
Assert.True(hitFromBelow); // sphere is within range and direction is positive
}
[Fact]
public void HitsWalkable_SphereMovingAwayFromNormal_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
// Moving Z (away from floor normal +Z) → HitsWalkable returns false.
var hit = CollisionPrimitives.HitsWalkable(
plane, verts,
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
movementDir: new Vector3(0f, 0f, -1f));
Assert.False(hit);
}
// -----------------------------------------------------------------------
// 7. FindWalkableCollision
// -----------------------------------------------------------------------
[Fact]
public void FindWalkableCollision_InsidePolygon_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
// Sphere dead centre above polygon, moving straight down.
// The contact point is inside all edges → no crossed edge.
var hit = CollisionPrimitives.FindWalkableCollision(
plane, verts,
sphereOrigin: new Vector3(0.5f, 0.5f, 1f),
movementDir: new Vector3(0f, 0f, -1f),
out var edgeNormal);
Assert.False(hit);
}
[Fact]
public void FindWalkableCollision_ParallelMovement_ReturnsFalse()
{
var (plane, verts) = UnitSquareXY();
var hit = CollisionPrimitives.FindWalkableCollision(
plane, verts,
sphereOrigin: new Vector3(0.5f, 0.5f, 0f),
movementDir: Vector3.UnitX,
out _);
Assert.False(hit);
}
[Fact]
public void FindWalkableCollision_SphereCrossesEdge_ReturnsTrueWithNormal()
{
var (plane, verts) = UnitSquareXY();
// Sphere at (2.5, 0.5, 1), moving normalize(1,0,1) toward the polygon.
// The ray-plane t = (dot(N, pos) + D) / dot(dir, N) = 1 / (1/√2) = √2.
// Contact = (2.5,0.5,1) (1/√2, 0, 1/√2) * (√2) = (2.51, 0.5, 11) = (1.5, 0.5, 0).
// (1.5, 0.5) is OUTSIDE the unit square (x > 1), so the right edge (x=1) is crossed.
var hit = CollisionPrimitives.FindWalkableCollision(
plane, verts,
sphereOrigin: new Vector3(2.5f, 0.5f, 1f),
movementDir: Vector3.Normalize(new Vector3(-1f, 0f, -1f)),
out var edgeNormal);
Assert.True(hit);
Assert.True(edgeNormal.LengthSquared() > 0.5f, "Edge normal should be non-zero");
Assert.True(MathF.Abs(edgeNormal.Length() - 1f) < 0.01f, "Edge normal should be unit length");
}
// -----------------------------------------------------------------------
// 8. SlideSphere
// -----------------------------------------------------------------------
[Fact]
public void SlideSphere_SphereAbovePlane_ReturnsPositiveT()
{
// Plane at Z=0, sphere at Z=3 (above), radius=0.5, moving Z.
var plane = new Plane(Vector3.UnitZ, 0f);
float t = CollisionPrimitives.SlideSphere(
plane, sphereRadius: 0.5f,
sphereCenter: new Vector3(0f, 0f, 3f),
movementDir: new Vector3(0f, 0f, -1f));
// Should reach the plane when t ≈ 2.5 (z=3 0.5 radius = z=0.5 …
// offset = 0.5 (radius on neg side), dist=3, t=(0.53)/1=3.5)
Assert.True(t > 0f, $"Expected positive t, got {t}");
}
[Fact]
public void SlideSphere_AlreadyTouching_ReturnsMaxValue()
{
// Sphere centred at Z=0.3 with radius 0.5 → dist=0.3 < radius → already touching
var plane = new Plane(Vector3.UnitZ, 0f);
float t = CollisionPrimitives.SlideSphere(
plane, sphereRadius: 0.5f,
sphereCenter: new Vector3(0f, 0f, 0.3f),
movementDir: new Vector3(0f, 0f, -1f));
Assert.Equal(float.MaxValue, t);
}
[Fact]
public void SlideSphere_ParallelMovement_ReturnsZero()
{
var plane = new Plane(Vector3.UnitZ, 0f);
float t = CollisionPrimitives.SlideSphere(
plane, sphereRadius: 0.5f,
sphereCenter: new Vector3(0f, 0f, 3f),
movementDir: Vector3.UnitX); // parallel to plane
Assert.Equal(0f, t);
}
// -----------------------------------------------------------------------
// 9. LandOnSphere
// -----------------------------------------------------------------------
[Fact]
public void LandOnSphere_SphereJustInsideRadius_LandsSuccessfully()
{
// Plane at Z=0, sphere centre at Z=0.3 (dist < radius=0.5), moving Z, walkInterp=1.
// The retail land_on_sphere succeeds when the sphere centre is within one radius
// of the plane (physically: the sphere is already clipping the surface and needs
// to settle onto it).
// denom = dot(Z, +Z) = 1
// tLand = (dist r) / denom = (0.3 0.5) / (1) = 0.2 (positive ✓)
// newInterp = (1 0.2) * 1 = 0.8 < walkInterp → accept
// new center.Z = 0.3 (1)*0.2 = 0.5 (resting on surface ✓)
var plane = new Plane(Vector3.UnitZ, 0f);
var center = new Vector3(0f, 0f, 0.3f);
var dir = new Vector3(0f, 0f, -1f);
float wi = 1f;
bool landed = CollisionPrimitives.LandOnSphere(
plane, sphereRadius: 0.5f,
ref center, ref dir, ref wi);
Assert.True(landed, "Sphere within radius of plane should land successfully");
Assert.True(MathF.Abs(center.Z - 0.5f) < 0.01f,
$"After landing centre.Z should be at radius (0.5), got {center.Z}");
}
[Fact]
public void LandOnSphere_ParallelMovement_ReturnsFalse()
{
var plane = new Plane(Vector3.UnitZ, 0f);
var center = new Vector3(0f, 0f, 2f);
var dir = Vector3.UnitX;
float wi = 1f;
bool landed = CollisionPrimitives.LandOnSphere(
plane, sphereRadius: 0.5f,
ref center, ref dir, ref wi);
Assert.False(landed);
}
[Fact]
public void LandOnSphere_InvalidWalkInterp_ReturnsFalse()
{
// walkInterp is 0 → (1 t) * 0 = 0 which is < walkInterp only if walkInterp > 0.
// With walkInterp = 0: newInterp = 0 which is NOT < 0 → passes range check.
// Let's use a case where tLand > 1 which makes newInterp < 0.
// Sphere at Z=100, plane at Z=0, moving Z, walkInterp=0.0001 (very small).
// tLand = (0.5 100) / (1) = 100.5 → newInterp = (1100.5)*0.0001 < 0 → false
var plane = new Plane(Vector3.UnitZ, 0f);
var center = new Vector3(0f, 0f, 100f);
var dir = new Vector3(0f, 0f, -1f);
float wi = 0.0001f;
bool landed = CollisionPrimitives.LandOnSphere(
plane, sphereRadius: 0.5f,
ref center, ref dir, ref wi);
Assert.False(landed);
}
}