feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:17:48 +02:00
parent e4f3f6bfab
commit cb46d892d5
2 changed files with 112 additions and 0 deletions

View file

@ -0,0 +1,45 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// A portal plane derived from an EnvCell's CellPortal polygon.
/// Used to detect when a player crosses from one cell into another.
/// </summary>
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
{
/// <summary>
/// Construct a PortalPlane from three coplanar vertices (winding order
/// determines the normal direction via cross product).
/// </summary>
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);
}
/// <summary>
/// Returns true when the movement from <paramref name="oldPos"/> to
/// <paramref name="newPos"/> 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.
/// </summary>
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;
}
}

View file

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