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>
This commit is contained in:
Erik 2026-06-24 19:08:53 +02:00
parent 79dee342f2
commit 78e5758185
7 changed files with 609 additions and 16 deletions

View file

@ -0,0 +1,218 @@
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}");
}
}