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:
parent
79dee342f2
commit
78e5758185
7 changed files with 609 additions and 16 deletions
|
|
@ -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 < 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}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue