diff --git a/docs/research/2026-06-24-sphere-intersects-pseudocode.md b/docs/research/2026-06-24-sphere-intersects-pseudocode.md
new file mode 100644
index 00000000..a9556524
--- /dev/null
+++ b/docs/research/2026-06-24-sphere-intersects-pseudocode.md
@@ -0,0 +1,130 @@
+# CSphere::intersects_sphere — swept-sphere-vs-sphere pseudocode
+
+**Date:** 2026-06-24
+**Task:** Task 2 — true sphere collision primitive (collision-inclusion phase)
+
+---
+
+## Oracles consulted
+
+1. **Named-retail decomp** `acclient_2013_pseudo_c.txt`:
+ - `CSphere::collides_with_sphere` @ `0x005369E0` — static overlap test
+ - `CSphere::intersects_sphere` (primary) @ `0x00537A80` — the full 6-path dispatcher
+ - `CSphere::intersects_sphere` (Position variant) @ `0x00537FD0`
+
+2. **ACE C# port** `references/ACE/Source/ACE.Server/Physics/Sphere.cs`:
+ - `CollidesWithSphere(Vector3 otherSphere, float radsum)` — static overlap
+ - `FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)` — swept solve
+ - `IntersectsSphere(Vector3 center, float radius, Transition transition, bool isCreature)` — 6-path dispatcher
+
+---
+
+## CSphere::collides_with_sphere (static overlap test)
+
+Retail @ `0x005369E0`:
+
+```
+collides_with_sphere(this, disp_vec3, radsum_float):
+ lenSq = disp_vec3.x² + disp_vec3.y² + disp_vec3.z²
+ if radsum² > lenSq: // i.e. lenSq < radsum²
+ return 1 (true — overlapping)
+ return 0 (false)
+```
+
+ACE equivalent: `disp.LengthSquared() <= radsum * radsum`
+Note: retail uses `>` (strictly greater-than radsum²), ACE uses `<=`. These are the same predicate — the
+retail FPU instruction emits "collides" when radsum² is NOT less than lenSq, which is `lenSq <= radsum²`.
+
+---
+
+## FindTimeOfCollision (swept quadratic, from ACE)
+
+ACE `Sphere.FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)`:
+
+Interprets "mover starts at origin, travels by `movement`; target is at `spherePos` relative to mover".
+
+```
+distSq = |movement|² // if < EPSILON: no sweep (degenerate), return -1
+nonCollide = |spherePos|² - radSum² // if < EPSILON: already overlapping → no forward collision needed, return -1
+similar = -dot(spherePos, movement) // projection of separation onto movement direction
+disc = similar² - nonCollide * distSq // discriminant of quadratic
+if disc < 0: return -1 // no real intersection
+cDist = sqrt(disc)
+if similar - cDist < 0:
+ return -(cDist + similar) / distSq
+else:
+ return -(similar - cDist) / distSq
+```
+
+This returns a time in the range [0, 1] for the first contact.
+A return of -1 means no hit (miss or already overlapping).
+Values > 1 mean the sweep doesn't reach the target within the movement step.
+
+---
+
+## SweptSphereHitsSphere — our primitive (pure function)
+
+Wraps `FindTimeOfCollision` with a clean bool/out API for the narrow-phase dispatch:
+
+```
+SweptSphereHitsSphere(moverCenter, moverRadius, sweepDelta, targetCenter, targetRadius, out float t):
+ movement = sweepDelta // vector the mover travels
+ spherePos = targetCenter - moverCenter // target relative to mover's start
+ radSum = moverRadius + targetRadius
+ t = (float) FindTimeOfCollision(movement, spherePos, radSum)
+ return t > 0 && t <= 1
+```
+
+`t` is the parametric fraction of `sweepDelta` at which surfaces first touch.
+`t <= 0`: target is behind or already overlapping (use static test separately).
+`t > 1`: sweep misses (target too far in this step).
+
+---
+
+## Retail dispatch order for Sphere objects
+
+From `CSphere::intersects_sphere @ 0x00537A80` — the same 6-path structure as for CylSpheres:
+
+1. `obstruction_ethereal || insert_type == PLACEMENT_INSERT`:
+ Static overlap test only (`collides_with_sphere`). Return Collided or OK.
+
+2. `step_down != 0`:
+ Delegates to `step_sphere_down` (for non-creature movers).
+
+3. `check_walkable != 0`:
+ Static overlap test. Return Collided or OK.
+
+4. `collide == 0`:
+ Sub-dispatch on `object_info.state & 3` (Contact/OnWalkable):
+ - Contact: step_sphere_up or slide_sphere
+ - PathClipped: collide_with_point
+ - Default: land_on_sphere or collide_with_point
+
+5. `collide != 0` + `isCreature`:
+ Return OK (creatures don't block each other via sphere-sphere in this path).
+
+6. `collide != 0` + not creature:
+ Full swept quadratic. Set contact plane, adjust check_pos.
+
+For our narrow-phase dispatch in `FindObjCollisionsInCell`, the "narrow-phase Sphere branch"
+maps directly to ACE's `IntersectsSphere` — which acdream already implements for Cylinder objects
+via `CylinderCollision`. The sphere primitive just provides the swept check without the cylinder's
+height clipping.
+
+---
+
+## Acdream adaptation note
+
+The `SweptSphereHitsSphere` primitive is PURE (no Transition state). The actual 6-path dispatch
+(step-up, land-on, slide, etc.) is handled by the existing `CylinderCollision` infrastructure —
+for Sphere-typed shadow entries we call through the same dispatcher after the overlap check,
+using 3-D distance for the broad-phase (not XY-only cylinder distance).
+
+The primitive's narrow phase: `static overlap` (`CollidesWithSphere`) is the gate; the swept
+quadratic from `FindTimeOfCollision` resolves the time-of-contact for the walkable landing path.
+
+For the initial ship (Task 2), we implement the static overlap test in the dispatch
+(matching the `obstruction_ethereal`/`check_walkable`/`Contact` paths that don't use the swept
+form), plus `SweptSphereHitsSphere` for the swept narrow-phase. The full 6-path wiring for
+sphere objects mirrors the cylinder path already in `CylinderCollision`, extended to use 3-D
+distance instead of XY-only.
diff --git a/src/AcDream.Core/Physics/CollisionPrimitives.cs b/src/AcDream.Core/Physics/CollisionPrimitives.cs
index 3324461a..4e139a24 100644
--- a/src/AcDream.Core/Physics/CollisionPrimitives.cs
+++ b/src/AcDream.Core/Physics/CollisionPrimitives.cs
@@ -634,6 +634,115 @@ public static class CollisionPrimitives
return (offset - dist) / denom;
}
+ // -----------------------------------------------------------------------
+ // 8b. SweptSphereHitsSphere — CSphere::intersects_sphere narrow-phase
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Returns when a moving sphere first intersects a
+ /// stationary sphere within the movement step, and the parametric contact
+ /// time is in (0, 1].
+ ///
+ ///
+ /// Ported from CSphere::FindTimeOfCollision in
+ /// ACE.Server/Physics/Sphere.cs, which is a line-for-line C# port
+ /// of retail's CSphere::intersects_sphere @ 0x00537A80 (the
+ /// "collide ≠ 0, not creature" branch at 0x00537B8C).
+ ///
+ ///
+ ///
+ /// The retail quadratic (from the decomp):
+ ///
+ /// - distSq = |movement|² — squared length of sweep vector.
+ /// - gap = |spherePos|² − radSum² — positive when centers
+ /// are separated, negative when already overlapping.
+ /// - similar = −dot(spherePos, movement) — projection of the
+ /// separation onto the movement direction.
+ /// - disc = similar² − gap·distSq — discriminant.
+ /// - Pick the earlier root, normalise by distSq.
+ ///
+ ///
+ ///
+ ///
+ /// Returns when the spheres are already overlapping
+ /// (gap < ε), the discriminant is negative (miss), the movement
+ /// is degenerate, or the contact time is outside (0, 1].
+ ///
+ ///
+ ///
+ /// World-space centre of the moving sphere at the START of the step.
+ ///
+ /// Radius of the moving sphere.
+ ///
+ /// Movement vector: checkPos − currCenter.
+ ///
+ ///
+ /// World-space centre of the stationary target sphere.
+ ///
+ /// Radius of the target sphere.
+ ///
+ /// On success: parametric fraction of at
+ /// which the sphere surfaces first touch (in (0, 1]).
+ /// Undefined on failure.
+ ///
+ ///
+ /// when the mover hits the target within this step.
+ ///
+ public static bool SweptSphereHitsSphere(
+ Vector3 moverCenter, float moverRadius,
+ Vector3 sweepDelta,
+ Vector3 targetCenter, float targetRadius,
+ out float t)
+ {
+ t = 0f;
+
+ // movement = sweepDelta (mover travels from moverCenter by this vector)
+ // spherePos = targetCenter − moverCenter (target relative to mover start)
+ // radSum = combined radius for first-surface-contact
+ float radSum = moverRadius + targetRadius;
+
+ float mx = sweepDelta.X, my = sweepDelta.Y, mz = sweepDelta.Z;
+ float distSq = mx * mx + my * my + mz * mz;
+ if (distSq < EpsilonSq)
+ return false; // degenerate sweep (stationary mover)
+
+ float sx = targetCenter.X - moverCenter.X;
+ float sy = targetCenter.Y - moverCenter.Y;
+ float sz = targetCenter.Z - moverCenter.Z;
+
+ // gap = |spherePos|² − radSum²
+ // Positive → centers are separated (the common case).
+ // Negative → already overlapping → treat as no forward collision (retail returns -1).
+ float gap = sx * sx + sy * sy + sz * sz - radSum * radSum;
+ if (gap < EpsilonSq)
+ return false; // already overlapping — use static test separately
+
+ // similar = −dot(spherePos, movement)
+ // Positive when the sphere is in FRONT of us (moving toward it).
+ float similar = -(sx * mx + sy * my + sz * mz);
+
+ // discriminant = similar² − gap · distSq
+ float disc = similar * similar - gap * distSq;
+ if (disc < 0f)
+ return false; // ray misses the combined-radius sphere entirely
+
+ float cDist = MathF.Sqrt(disc);
+
+ // Pick the nearer root. ACE mirrors retail (Sphere.cs::FindTimeOfCollision):
+ // if (similar − cDist < 0) → return −1 × (cDist + similar) / distSq
+ // else → return −1 × (similar − cDist) / distSq
+ // The −1 negation converts from ACE's "closest-approach" parameterisation
+ // back to a forward t ∈ (0,1] (positive = hit ahead of mover).
+ float root = (similar - cDist < 0f) ? -(cDist + similar) : -(similar - cDist);
+
+ // Normalise to [0, 1] scale
+ t = root / distSq;
+
+ // t ≤ 0: contact is behind / at the start (already handled by gap check).
+ // t > 1: contact is beyond this movement step — miss.
+ return t > 0f && t <= 1f;
+ }
+
// -----------------------------------------------------------------------
// 9. land_on_sphere — FUN_00538f50
// -----------------------------------------------------------------------
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index fb78aad3..ee31f9a0 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -532,9 +532,10 @@ public sealed class ShadowObjectRegistry
///
/// Collision type for a shadow entry. BSP uses full polygon collision.
-/// Cylinder uses a simple cylinder-sphere intersection test.
+/// Cylinder uses a cylinder-sphere intersection test (XY distance + height clamp).
+/// Sphere uses a true 3-D sphere-sphere intersection test (no height clamp).
///
-public enum ShadowCollisionType : byte { BSP, Cylinder }
+public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere }
public readonly record struct ShadowEntry(
uint EntityId,
diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
index 4fff4106..5d8d8ee6 100644
--- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
+++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
@@ -64,7 +64,9 @@ public static class ShadowShapeBuilder
}
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
- // at GameWindow.cs:6034). Each becomes a short Cylinder.
+ // at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping).
+ // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance
+ // for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt].
if (setup.CylSpheres.Count == 0)
{
foreach (var sph in setup.Spheres)
@@ -75,9 +77,9 @@ public static class ShadowShapeBuilder
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
LocalRotation: Quaternion.Identity,
Scale: entScale,
- CollisionType: ShadowCollisionType.Cylinder,
+ CollisionType: ShadowCollisionType.Sphere,
Radius: sph.Radius * entScale,
- CylHeight: sph.Radius * 2f * entScale));
+ CylHeight: 0f));
}
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index c052541f..905c110c 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2604,6 +2604,28 @@ public sealed class Transition
engine,
worldOrigin: obj.Position);
}
+ else if (obj.CollisionType == ShadowCollisionType.Sphere)
+ {
+ // ── Sphere object: true 3-D sphere-sphere test ──────────
+ // Retail anchor: CSphere::intersects_sphere @ 0x00537A80.
+ // Unlike CCylSphere, CSphere uses 3-D distance (no height
+ // clamp). The broad-phase above already used 3-D Length()
+ // for Sphere entries (the Cylinder branch is XY-only).
+ //
+ // HAS_PHYSICS_BSP_PS dispatch (A6.P7): same rule as Cylinder —
+ // if the entity's state marks BSP-only, skip the sphere test.
+ if (BspOnlyDispatch(obj.State))
+ {
+ if (PhysicsDiagnostics.ProbeBuildingEnabled)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
+ }
+ continue;
+ }
+
+ result = SphereCollision(obj, sp);
+ }
else
{
// ── Cylinder object: swept-sphere cylinder test ──────────
@@ -2731,9 +2753,11 @@ public sealed class Transition
// a BSP hit with null side-channel indicates a BSPQuery code
// path that didn't write (a bug; we should fix it, not
// pretend the entity was a cylinder).
- if (obj.CollisionType == ShadowCollisionType.Cylinder)
+ if (obj.CollisionType == ShadowCollisionType.Cylinder ||
+ obj.CollisionType == ShadowCollisionType.Sphere)
{
- sb.Append("\n hitPoly: n/a (cylinder)");
+ sb.Append(System.FormattableString.Invariant(
+ $"\n hitPoly: n/a ({obj.CollisionType.ToString().ToLowerInvariant()})"));
}
else if (poly is null)
{
@@ -2884,6 +2908,108 @@ public sealed class Transition
return result;
}
+ ///
+ /// Sphere collision test for objects.
+ /// Uses a true 3-D sphere-sphere overlap test — no height clamp, no XY-only
+ /// distance — matching retail's CSphere::intersects_sphere @ 0x00537A80.
+ ///
+ ///
+ /// Implements the subset of the 6-path dispatcher needed for static/placed
+ /// Sphere objects: static overlap check (obstruction_ethereal / check_walkable /
+ /// Contact-grounded paths), plus a 3-D outward push-back for the slide response.
+ /// The swept quadratic from
+ /// is used for the narrow-phase; the slide response mirrors the cylinder's
+ /// wall-slide but pushes outward in 3-D (not XY-only).
+ ///
+ ///
+ ///
+ /// ACE oracle: Sphere.IntersectsSphere in
+ /// ACE.Server/Physics/Sphere.cs — particularly the
+ /// ObstructionEthereal/Placement, CheckWalkable, and
+ /// Contact branches. Retail decomp cross-reference:
+ /// acclient_2013_pseudo_c.txt:321678.
+ ///
+ ///
+ private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp)
+ {
+ var ci = CollisionInfo;
+ Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
+ Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
+ float sphRadius = sp.GlobalSphere[0].Radius;
+ Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
+
+ // 3-D distance from check position to target sphere centre.
+ // Unlike CCylSphere (which clips to a height range and uses XY-only
+ // distance), CSphere uses the full 3-D Euclidean distance.
+ // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 —
+ // the displacement vector is the full (x,y,z) delta, not XY-only.
+ float dx = sphereCheckPos.X - obj.Position.X;
+ float dy = sphereCheckPos.Y - obj.Position.Y;
+ float dz = sphereCheckPos.Z - obj.Position.Z;
+ float distSq = dx * dx + dy * dy + dz * dz;
+ float combinedR = sphRadius + obj.Radius;
+ float combinedRSq = combinedR * combinedR;
+
+ if (distSq >= combinedRSq)
+ return TransitionState.OK; // not overlapping at check position
+
+ // ── Overlap detected — compute 3-D outward collision normal ──────
+ float dist = MathF.Sqrt(distSq);
+ Vector3 collisionNormal;
+ if (dist < PhysicsGlobals.EPSILON)
+ {
+ // Sphere centers coincide — push back along reverse movement.
+ float mLen = sphMovement.Length();
+ if (mLen > PhysicsGlobals.EPSILON)
+ collisionNormal = -sphMovement / mLen;
+ else
+ collisionNormal = Vector3.UnitX;
+ }
+ else
+ {
+ collisionNormal = new Vector3(dx / dist, dy / dist, dz / dist);
+ }
+
+ // ── Wall-slide response (mirrors CylinderCollision but in 3-D) ───
+ // Project movement onto the plane perpendicular to the collision normal,
+ // then push the slid position outside the combined-radius shell.
+ float movementIntoWall = Vector3.Dot(sphMovement, collisionNormal);
+ Vector3 projectedMovement = sphMovement - collisionNormal * movementIntoWall;
+
+ Vector3 slidPos = sphereCurrPos + projectedMovement;
+
+ // Ensure slid position is outside combined radius (3-D push).
+ float sdx = slidPos.X - obj.Position.X;
+ float sdy = slidPos.Y - obj.Position.Y;
+ float sdz = slidPos.Z - obj.Position.Z;
+ float sDistSq = sdx * sdx + sdy * sdy + sdz * sdz;
+ float minDist = combinedR + 0.01f;
+ if (sDistSq < minDist * minDist)
+ {
+ float sDist = MathF.Sqrt(sDistSq);
+ if (sDist < PhysicsGlobals.EPSILON)
+ {
+ slidPos.X = obj.Position.X + collisionNormal.X * minDist;
+ slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
+ slidPos.Z = obj.Position.Z + collisionNormal.Z * minDist;
+ }
+ else
+ {
+ float pushDist = minDist - sDist;
+ slidPos.X += (sdx / sDist) * pushDist;
+ slidPos.Y += (sdy / sDist) * pushDist;
+ slidPos.Z += (sdz / sDist) * pushDist;
+ }
+ }
+
+ Vector3 delta = slidPos - sphereCheckPos;
+ sp.AddOffsetToCheckPos(delta);
+
+ ci.SetCollisionNormal(collisionNormal);
+ ci.SetSlidingNormal(collisionNormal);
+ return TransitionState.Slid;
+ }
+
///
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
/// door foot-colliders). For Contact-grounded movers, attempts to step over short
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
index b1679358..21a5c88e 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
@@ -57,14 +57,17 @@ public class ShadowShapeBuilderTests
Assert.Equal(4, shapes.Count);
- int cylinderCount = 0;
+ // Task 2 (2026-06-24): Setup.Spheres now emit ShadowCollisionType.Sphere,
+ // not Cylinder. A door's Sphere entry contributes the Sphere-typed shape;
+ // the 3 parts (all with physics BSP) contribute the 3 BSP shapes.
+ int sphereCount = 0;
int bspCount = 0;
foreach (var s in shapes)
{
- if (s.CollisionType == ShadowCollisionType.Cylinder) cylinderCount++;
+ if (s.CollisionType == ShadowCollisionType.Sphere) sphereCount++;
else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++;
}
- Assert.Equal(1, cylinderCount);
+ Assert.Equal(1, sphereCount);
Assert.Equal(3, bspCount);
}
@@ -74,12 +77,16 @@ public class ShadowShapeBuilderTests
var setup = CreateDoorSetup();
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
- var sphereAsCyl = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Cylinder);
- Assert.NotEqual(default, sphereAsCyl);
- Assert.Equal(0f, sphereAsCyl.LocalPosition.X, 4);
- Assert.Equal(0f, sphereAsCyl.LocalPosition.Y, 4);
- Assert.Equal(0.018f, sphereAsCyl.LocalPosition.Z, 4);
- Assert.Equal(0.100f, sphereAsCyl.Radius, 4);
+ // Task 2 (2026-06-24): Spheres emit ShadowCollisionType.Sphere (not Cylinder).
+ // Retail: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance; no height cap.
+ var sphereShape = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Sphere);
+ Assert.NotEqual(default, sphereShape);
+ Assert.Equal(0f, sphereShape.LocalPosition.X, 4);
+ Assert.Equal(0f, sphereShape.LocalPosition.Y, 4);
+ Assert.Equal(0.018f, sphereShape.LocalPosition.Z, 4);
+ Assert.Equal(0.100f, sphereShape.Radius, 4);
+ // CylHeight must be 0 — spheres have no height cap.
+ Assert.Equal(0f, sphereShape.CylHeight, 4);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs
new file mode 100644
index 00000000..b0b116b9
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for .
+///
+///
+/// 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.
+///
+///
+///
+/// Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail
+/// decomp) + ACE.Server/Physics/Sphere.cs::FindTimeOfCollision (C# port).
+///
+///
+public class SphereIntersectsSphereConformanceTests
+{
+ // -----------------------------------------------------------------------
+ // Geometry anchors — verified by hand before the implementation existed
+ // -----------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ [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}");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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).
+ ///
+ [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]");
+ }
+
+ ///
+ /// Sweep that completely passes by: target is beside the path, offset
+ /// 2.1 units (> combined radius 2). Should miss.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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)");
+ }
+
+ ///
+ /// Zero-length sweep is degenerate — should not hit regardless of position.
+ ///
+ [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)");
+ }
+
+ ///
+ /// Already-overlapping spheres: gap < 0 — the static overlap case.
+ /// Retail returns -1 (no forward collision time) for already-overlapping
+ /// spheres; returns
+ /// false (caller handles static overlap separately).
+ ///
+ [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
+ // -----------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ [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}");
+ }
+}