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));
+ }
+}