From cb46d892d5eb0ea3248ee34c3e277c7f3b147de1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 18:17:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20Phase=20B.3=20=E2=80=94=20PortalP?= =?UTF-8?q?lane=20(plane=20math=20+=20crossing=20detection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundational portal-plane record for cell transition detection. PortalPlane.FromVertices computes a normalised plane from 3 coplanar polygon vertices via cross product + dot product; IsCrossing tests whether a movement vector straddles the plane (strictly negative dot-product product — exact-on-plane position returns false as specified). 4 new unit tests: normal construction, opposite-side crossing, same-side no-crossing, start-on-plane no-crossing. All 269 tests green. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.Core/Physics/PortalPlane.cs | 45 +++++++++++++ .../Physics/PortalPlaneTests.cs | 67 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/AcDream.Core/Physics/PortalPlane.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs 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)); + } +}