diff --git a/src/AcDream.Core/Physics/PortalPlane.cs b/src/AcDream.Core/Physics/PortalPlane.cs new file mode 100644 index 0000000..f81611b --- /dev/null +++ b/src/AcDream.Core/Physics/PortalPlane.cs @@ -0,0 +1,45 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// A portal plane derived from an EnvCell's CellPortal polygon. +/// Used to detect when a player crosses from one cell into another. +/// +public readonly record struct PortalPlane( + Vector3 Normal, + float D, + uint TargetCellId, // OtherCellId — the cell on the far side (0xFFFF = outdoor) + uint OwnerCellId, // the EnvCell that owns this portal + ushort Flags) // PortalFlags value +{ + /// + /// Construct a PortalPlane from three coplanar vertices (winding order + /// determines the normal direction via cross product). + /// + public static PortalPlane FromVertices( + Vector3 v0, Vector3 v1, Vector3 v2, + uint targetCellId, uint ownerCellId, ushort flags) + { + var edge1 = v1 - v0; + var edge2 = v2 - v0; + var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2)); + float d = -Vector3.Dot(normal, v0); + return new PortalPlane(normal, d, targetCellId, ownerCellId, flags); + } + + /// + /// Returns true when the movement from to + /// crosses this plane (the two positions are on + /// strictly opposite sides). A position exactly on the plane (distance = 0) + /// does NOT count as a crossing. + /// + public bool IsCrossing(Vector3 oldPos, Vector3 newPos) + { + float oldDist = Vector3.Dot(Normal, oldPos) + D; + float newDist = Vector3.Dot(Normal, newPos) + D; + // Strictly negative product → opposite signs → crossed the plane. + // If either distance is exactly 0 the product is 0, not negative → no crossing. + return oldDist * newDist < 0f; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs b/tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs new file mode 100644 index 0000000..70ef9d1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class PortalPlaneTests +{ + // Helper: build an XY-plane portal (z = 0) from three known vertices. + private static PortalPlane XyPlane() => + PortalPlane.FromVertices( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(0, 1, 0), + targetCellId: 42, + ownerCellId: 7, + flags: 0); + + [Fact] + public void FromVertices_ComputesCorrectNormal() + { + var plane = XyPlane(); + + // Cross((1,0,0)-(0,0,0), (0,1,0)-(0,0,0)) = Cross((1,0,0),(0,1,0)) = (0,0,1) + // The normalised result must be either (0,0,1) or (0,0,-1). + Assert.Equal(0f, plane.Normal.X, precision: 5); + Assert.Equal(0f, plane.Normal.Y, precision: 5); + Assert.Equal(1f, MathF.Abs(plane.Normal.Z), precision: 5); + } + + [Fact] + public void IsCrossing_PositionsOnOppositeSides_ReturnsTrue() + { + var plane = XyPlane(); // normal is (0,0,±1), D = 0 + + // One position above the XY plane, one below. + var above = new Vector3(0, 0, 1f); + var below = new Vector3(0, 0, -1f); + + Assert.True(plane.IsCrossing(above, below)); + Assert.True(plane.IsCrossing(below, above)); + } + + [Fact] + public void IsCrossing_PositionsOnSameSide_ReturnsFalse() + { + var plane = XyPlane(); + + var pos1 = new Vector3(0, 0, 1f); + var pos2 = new Vector3(0, 0, 2f); + + Assert.False(plane.IsCrossing(pos1, pos2)); + } + + [Fact] + public void IsCrossing_StartOnPlane_ReturnsFalse() + { + var plane = XyPlane(); + + // oldPos is exactly on the plane (z = 0) → distance = 0. + // Product (0 * anything) == 0, which is NOT < 0 → no crossing. + var onPlane = new Vector3(0.5f, 0.5f, 0f); + var above = new Vector3(0.5f, 0.5f, 1f); + + Assert.False(plane.IsCrossing(onPlane, above)); + } +}