acdream/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs
Erik 78e5758185 feat(physics): Task 2 — true sphere collision primitive (CSphere::intersects_sphere)
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>
2026-06-24 19:08:53 +02:00

218 lines
8.2 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>
/// 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 &lt; 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}");
}
}