using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Unit tests for — one or more tests per /// ported function, verifying basic correctness against hand-computed ground /// truth. /// public class CollisionPrimitivesTests { // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- /// /// Build a unit-square polygon in the XY-plane (Z = 0), normal = +Z, /// plane.D = 0. Vertices are wound counter-clockwise. /// 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.5−1, 0.5, 1−1) = (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.5−3)/−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 = (1−100.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); } }