diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 9f2be66..cb32399 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -962,6 +962,51 @@ public static class BSPQuery return false; } + /// + /// Issue #90 fix (2026-05-20). Radius-aware sibling of + /// . Returns true if a sphere centered + /// at with radius + /// overlaps the cell volume — i.e., the sphere is fully inside OR + /// straddles any splitting plane. Returns false only when the sphere + /// is FULLY outside (behind some splitting plane by more than its + /// radius). + /// + /// + /// Used by to avoid + /// ping-ponging the CellId back to outdoor on each indoor BSP + /// push-back: when the indoor BSP pushes the foot-sphere back by + /// a few cm, the sphere CENTER may briefly leave the indoor + /// CellBSP volume, but the sphere itself still OVERLAPS the cell. + /// The point-only test would flip CellId to outdoor; this overlap + /// test keeps the player classified as indoor as long as any part + /// of the sphere is still inside the cell. + /// + /// + /// + /// Retail oracle: BSPTREE::sphere_intersects_cell_bsp at + /// acclient_2013_pseudo_c.txt:323267 → + /// BSPNODE::sphere_intersects_cell_bsp at + /// :325546. Retail adds a 0.01f epsilon to the radius before + /// the threshold compare; we match that. + /// + /// + public static bool SphereIntersectsCellBsp(CellBSPNode? node, Vector3 center, float radius) + { + if (node is null) return true; + if (node.Type == BSPNodeType.Leaf) return true; + + float dist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D; + float rad = radius + 0.01f; // retail's epsilon at :325551 + + // Behind splitting plane by more than radius → sphere fully outside. + if (dist < -rad) return false; + + // Otherwise (front, on-plane, or straddling within radius) → follow positive child. + return node.PosNode is not null + ? SphereIntersectsCellBsp(node.PosNode, center, radius) + : true; + } + // ========================================================================= // BSP TREE-LEVEL HELPERS // diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index eab87de..d23a000 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -281,12 +281,26 @@ public sealed class PhysicsEngine if (indoorCell?.CellBSP?.Root is null) return indoorResult; // Can't verify (no CellBSP); trust FindCellList. + // Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in + // for the indoor verification. The previous point-only check caused + // a per-frame ping-pong at the inn doorway: indoor BSP push-back + // moved the sphere CENTER a few cm outside the indoor CellBSP + // volume → point-only check returned false → fell through to outdoor + // → next tick re-promoted to indoor → wall hit → push-back → + // outdoor → repeat. Net visual behavior: "walls walk through" + // because outdoor ticks bypass indoor BSP entirely. With sphere- + // overlap, the player stays classified indoor as long as ANY part + // of the foot sphere still overlaps the indoor cell volume. + // + // Retail oracle: CCellStruct::sphere_intersects_cell at + // acclient_2013_pseudo_c.txt:317666 → + // BSPTREE::sphere_intersects_cell_bsp at :323267. var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform); - if (BSPQuery.PointInsideCellBsp(indoorCell.CellBSP.Root, localCenter)) + if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius)) return indoorResult; - // Fall through to outdoor resolution: player has left the indoor - // portal-connected graph entirely. + // Fall through to outdoor resolution: player has FULLY left the + // indoor portal-connected graph (sphere no longer overlaps). } // Outdoor seed: use terrain grid to compute the prefixed cell id. diff --git a/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs b/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs new file mode 100644 index 0000000..c72fc5b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Tests for — the radius- +/// aware sibling of PointInsideCellBsp. Pins issue #90's fix semantics: +/// a sphere whose center is JUST outside the cell volume but whose +/// radius extends back into the cell should still register as +/// overlapping. +/// +/// Retail oracle: BSPTREE::sphere_intersects_cell_bsp at +/// acclient_2013_pseudo_c.txt:323267 / BSPNODE::sphere_intersects_cell_bsp +/// at :325546. +/// +public class SphereIntersectsCellBspTests +{ + /// + /// One splitting plane at x=0 with positive normal +X (so the + /// "inside" half-space is x ≥ 0). Pos-leaf representing the cell + /// interior. + /// + private static CellBSPNode SinglePlaneTree() + { + var leaf = new CellBSPNode { Type = BSPNodeType.Leaf }; + // Internal nodes don't set Type (default is non-Leaf). The production + // PointInsideCellBsp / SphereIntersectsCellBsp both only branch on + // `Type == Leaf` and otherwise treat the node as internal. + return new CellBSPNode + { + SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), // x ≥ 0 is inside + PosNode = leaf, + }; + } + + [Fact] + public void NullRoot_ReturnsTrue() + { + Assert.True(BSPQuery.SphereIntersectsCellBsp(null, Vector3.Zero, 0.5f)); + } + + [Fact] + public void Leaf_ReturnsTrue() + { + var leaf = new CellBSPNode { Type = BSPNodeType.Leaf }; + Assert.True(BSPQuery.SphereIntersectsCellBsp(leaf, Vector3.Zero, 0.5f)); + } + + [Fact] + public void SphereCenterInsideHalfSpace_ReturnsTrue() + { + var root = SinglePlaneTree(); + // Sphere center at x=0.5 (inside). + Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(0.5f, 0f, 0f), 0.5f)); + } + + [Fact] + public void SphereCenterOnPlane_ReturnsTrue() + { + var root = SinglePlaneTree(); + // Sphere center exactly at x=0 (on the plane). + Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(0f, 0f, 0f), 0.5f)); + } + + [Fact] + public void SphereCenterOutside_ButRadiusReachesIn_ReturnsTrue() + { + // Issue #90's core case. Sphere center at x=-0.3 (outside the + // x≥0 cell), but radius 0.5 → reach to x=+0.2 (inside). The + // sphere STRADDLES the splitting plane — must return true. + // Pre-#90, PointInsideCellBsp would have returned false here, + // causing CellId to flip out → wall ping-pong. + var root = SinglePlaneTree(); + Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-0.3f, 0f, 0f), 0.5f)); + } + + [Fact] + public void SphereFullyOutside_ReturnsFalse() + { + var root = SinglePlaneTree(); + // Sphere center at x=-1.0 with radius 0.5 → reach to x=-0.5. + // Fully behind the splitting plane (with the 0.01 retail epsilon + // accounted for, still fully outside). + Assert.False(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-1.0f, 0f, 0f), 0.5f)); + } + + [Fact] + public void SphereTangentToPlane_ReturnsTrue() + { + // Tangent boundary: sphere center at x=-0.5, radius=0.5 → + // reaches exactly x=0 (touches the plane). Retail's +0.01 epsilon + // applied to the radius means -0.5 > -(0.5+0.01) → returns true. + var root = SinglePlaneTree(); + Assert.True(BSPQuery.SphereIntersectsCellBsp(root, new Vector3(-0.5f, 0f, 0f), 0.5f)); + } + + [Fact] + public void PointInsideCellBsp_PointJustOutside_ReturnsFalse_ProvesRegression() + { + // Confirms the OLD point-only behavior would have returned false + // for the same input that SphereIntersectsCellBsp returns true + // for above. This is the "ping-pong cause" pin. + var root = SinglePlaneTree(); + Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.3f, 0f, 0f))); + } +}