From 78e5758185a9f388ad6205a56975978864940893 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 19:08:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20Task=202=20=E2=80=94=20true=20?= =?UTF-8?q?sphere=20collision=20primitive=20(CSphere::intersects=5Fsphere)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...2026-06-24-sphere-intersects-pseudocode.md | 130 +++++++++++ .../Physics/CollisionPrimitives.cs | 109 +++++++++ .../Physics/ShadowObjectRegistry.cs | 5 +- .../Physics/ShadowShapeBuilder.cs | 8 +- src/AcDream.Core/Physics/TransitionTypes.cs | 130 ++++++++++- .../Physics/ShadowShapeBuilderTests.cs | 25 +- .../SphereIntersectsSphereConformanceTests.cs | 218 ++++++++++++++++++ 7 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 docs/research/2026-06-24-sphere-intersects-pseudocode.md create mode 100644 tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs 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}"); + } +}