Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r), which is geometrically wrong: a cylinder has flat caps; a sphere does not. This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow entries are tested as spheres — 3-D distance, no height clamping. Changes: - ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2). The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path (anyCyl=false → included), which is correct. - ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere (CylHeight=0) for Setup.Spheres instead of a short Cylinder. - CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention confirmed against the decomp: retail negates the root to produce a forward t ∈ (0,1]. - TransitionTypes.cs: added Sphere narrow-phase branch between BSP and Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap (not XY-only). Added SphereCollision() method implementing the 3-D wall-slide response. Updated diagnostic logging at :2734 to cover Sphere. - Updated ShadowShapeBuilderTests for new Sphere type assertion. - New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored cases (head-on, tangent, perpendicular-miss, lateral-near-miss, sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping, vertical-sweep). Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail); ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed). Build: 0 errors, 10 warnings (pre-existing). Tests: 1576 pass / 0 fail / 2 skip (1578 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.2 KiB
C#
218 lines
8.2 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// Conformance tests for <see cref="CollisionPrimitives.SweptSphereHitsSphere"/>.
|
||
///
|
||
/// <para>
|
||
/// All anchor cases are geometrically verifiable independently of the
|
||
/// implementation — they are derived from first-principles geometry, not
|
||
/// from the method under test, to avoid a circular test.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail oracle: <c>CSphere::intersects_sphere @ 0x00537A80</c> (named-retail
|
||
/// decomp) + <c>ACE.Server/Physics/Sphere.cs::FindTimeOfCollision</c> (C# port).
|
||
/// </para>
|
||
/// </summary>
|
||
public class SphereIntersectsSphereConformanceTests
|
||
{
|
||
// -----------------------------------------------------------------------
|
||
// Geometry anchors — verified by hand before the implementation existed
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Two unit spheres (r=1 each) 5 units apart on the X axis.
|
||
/// Mover at origin, target at (5, 0, 0).
|
||
/// Sweep: move 5 units in +X.
|
||
/// Combined radius = 2.
|
||
/// Expected first contact at x = 3 from start = t = 3/5 = 0.6.
|
||
/// </summary>
|
||
[Fact]
|
||
public void HeadOn_HitsAtExpectedTime()
|
||
{
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(5f, 0f, 0f),
|
||
targetCenter: new Vector3(5f, 0f, 0f),
|
||
targetRadius: 1f,
|
||
out float t);
|
||
|
||
Assert.True(hit, "Head-on sweep should hit");
|
||
// t = 3/5 = 0.6 — surface contact when mover centre is at x=3,
|
||
// target centre at x=5, gap = 2 = combined radius. Within ±1e-4.
|
||
Assert.True(MathF.Abs(t - 0.6f) < 1e-4f,
|
||
$"Expected t≈0.6, got {t:G6}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Two unit spheres. Mover sweeps purely in +Y, target is offset 3 units
|
||
/// in +X. The sweep never reaches within combined radius (2) of the target.
|
||
/// </summary>
|
||
[Fact]
|
||
public void PerpendicularSweep_TooFar_Misses()
|
||
{
|
||
// Mover at origin, target at (3, 0, 0). Sweep in +Y by 10 units.
|
||
// Closest approach = 3 units (> combined radius 2).
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(0f, 10f, 0f),
|
||
targetCenter: new Vector3(3f, 0f, 0f),
|
||
targetRadius: 1f,
|
||
out float _);
|
||
|
||
Assert.False(hit, "Perpendicular sweep at distance 3 > combinedR 2 should miss");
|
||
}
|
||
|
||
/// <summary>
|
||
/// A sweep that grazes the target sphere (closest approach = exactly
|
||
/// combined radius). Geometrically this is a tangent hit and should
|
||
/// return true with t in (0, 1].
|
||
/// Mover at origin, sweep in +Y by 6. Target at (2, 3, 0).
|
||
/// Combined radius = 2 (r1=r2=1). Closest approach = 2 (tangent).
|
||
/// </summary>
|
||
[Fact]
|
||
public void TangentSweep_Hits()
|
||
{
|
||
// Mover sweeps from (0,0,0) to (0,6,0).
|
||
// Target at (2, 3, 0). At t=0.5 mover centre is at (0,3,0).
|
||
// Distance at closest = 2, exactly combinedR → tangent touch.
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(0f, 6f, 0f),
|
||
targetCenter: new Vector3(2f, 3f, 0f),
|
||
targetRadius: 1f,
|
||
out float t);
|
||
|
||
Assert.True(hit, "Tangent sweep (distance = combinedR) should register as a hit");
|
||
Assert.True(t > 0f && t <= 1f, $"t={t:G6} should be in (0,1]");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sweep that completely passes by: target is beside the path, offset
|
||
/// 2.1 units (> combined radius 2). Should miss.
|
||
/// </summary>
|
||
[Fact]
|
||
public void OffAxisSweep_JustOutside_Misses()
|
||
{
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(0f, 6f, 0f),
|
||
targetCenter: new Vector3(2.1f, 3f, 0f),
|
||
targetRadius: 1f,
|
||
out float _);
|
||
|
||
Assert.False(hit, "Lateral offset 2.1 > combinedR 2 — should miss");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sweep away from the target — degenerate "wrong direction".
|
||
/// Mover at (0,0,0) sweeps in −X while target is at (5,0,0).
|
||
/// The sweep is directly away; no forward contact.
|
||
/// </summary>
|
||
[Fact]
|
||
public void SweepAwayFromTarget_Misses()
|
||
{
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(-5f, 0f, 0f),
|
||
targetCenter: new Vector3(5f, 0f, 0f),
|
||
targetRadius: 1f,
|
||
out float _);
|
||
|
||
Assert.False(hit, "Sweep directly away from target should not hit");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sweep within the step but the target is too far for the step to reach.
|
||
/// Target is 10 units away, sweep is only 3 units — t would be >1.
|
||
/// </summary>
|
||
[Fact]
|
||
public void TargetBeyondStep_Misses()
|
||
{
|
||
// combinedR = 2; target centre 10 away; contact at t = (10-2)/3 ≈ 2.67 > 1.
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(3f, 0f, 0f),
|
||
targetCenter: new Vector3(10f, 0f, 0f),
|
||
targetRadius: 1f,
|
||
out float _);
|
||
|
||
Assert.False(hit, "Target 10 away with only a 3-unit sweep should miss (t>1)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Zero-length sweep is degenerate — should not hit regardless of position.
|
||
/// </summary>
|
||
[Fact]
|
||
public void DegenerateSweep_Misses()
|
||
{
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: Vector3.Zero,
|
||
targetCenter: new Vector3(0.5f, 0f, 0f),
|
||
targetRadius: 0.1f,
|
||
out float _);
|
||
|
||
Assert.False(hit, "Zero-length sweep should return false (degenerate)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Already-overlapping spheres: gap < 0 — the static overlap case.
|
||
/// Retail returns -1 (no forward collision time) for already-overlapping
|
||
/// spheres; <see cref="CollisionPrimitives.SweptSphereHitsSphere"/> returns
|
||
/// false (caller handles static overlap separately).
|
||
/// </summary>
|
||
[Fact]
|
||
public void AlreadyOverlapping_ReturnsFalse()
|
||
{
|
||
// Centres 0.5 apart, combined radius 2 — deeply overlapping.
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: Vector3.Zero,
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(1f, 0f, 0f),
|
||
targetCenter: new Vector3(0.5f, 0f, 0f),
|
||
targetRadius: 1f,
|
||
out float _);
|
||
|
||
Assert.False(hit,
|
||
"Already-overlapping spheres: retail FindTimeOfCollision returns -1 (no forward t); SweptSphereHitsSphere should return false");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 3-D geometry — sphere primitive must use full 3-D distance
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Pure Z-axis sweep: verifies the primitive uses 3-D distance (not XY-only).
|
||
/// A purely vertical sweep toward a sphere directly below should hit.
|
||
/// </summary>
|
||
[Fact]
|
||
public void VerticalSweep_HitsTargetBelow()
|
||
{
|
||
// Mover at (0,0,5), sweeps down -Z by 5 to (0,0,0).
|
||
// Target at (0,0,0) with radius 1. Combined radius = 2.
|
||
// First contact when mover centre Z = 2 → t = (5-2)/5 = 0.6.
|
||
bool hit = CollisionPrimitives.SweptSphereHitsSphere(
|
||
moverCenter: new Vector3(0f, 0f, 5f),
|
||
moverRadius: 1f,
|
||
sweepDelta: new Vector3(0f, 0f, -5f),
|
||
targetCenter: Vector3.Zero,
|
||
targetRadius: 1f,
|
||
out float t);
|
||
|
||
Assert.True(hit, "Vertical sweep toward sphere below should hit");
|
||
Assert.True(MathF.Abs(t - 0.6f) < 1e-4f, $"Expected t≈0.6, got {t:G6}");
|
||
}
|
||
}
|