fix(physics): route indoor walkable-plane synthesis through retail BSP walker
TryFindIndoorWalkablePlane (Phase 2 commit eb0f772) used a linear
first-match XY scan of cellPhysics.Resolved with no Z-proximity test.
For any cell with two walkable polys overlapping in XY at different Z
(cellars, 2nd floors, balconies, stairs spanning floors), it returned
whichever polygon came first in dictionary order — typically the upper
floor when descending, causing the player to be reported below the
synthesized plane → ValidateWalkable fails → falling-stuck. Symptoms
reported by user 2026-05-19: cannot descend into cellar; cannot walk
on 2nd floor; "invisible obstacles at certain spots" (suspected
cascade from wrong-Z ContactPlane misrouting the resolver state).
Fix: route through BSPQuery.FindWalkableSphere (added previous commit),
which wraps the existing retail-faithful FindWalkableInternal
(BSPNODE::find_walkable + BSPLEAF::find_walkable port). Adds a
sphereRadius parameter to TryFindIndoorWalkablePlane so the foot
sphere is built with the actual entity radius rather than a guess.
WalkableAllowance is save/restored via try/finally so the slope
threshold used by walkable_hits_sphere doesn't leak back to the
resolver. Method becomes an instance method (was static) to access
this.SpherePath.
Deletes the now-dead PointInPolygonXY helper.
Updates IndoorWalkablePlaneTests.cs: all TryFindIndoorWalkablePlane
test fixtures now include a PhysicsBSPTree leaf node (required by
the new routing path), calls pass sphereRadius, and the PointInPolygonXY
tests are removed (method deleted). Adds TransitionTypesTests.cs with
an integration test covering two-overlapping-floors selection AND
WalkableAllowance preservation.
Closes (pending visual verification): ISSUES #83.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
86ecdf9ee1
commit
91b29d1a89
3 changed files with 269 additions and 136 deletions
|
|
@ -1167,20 +1167,16 @@ public sealed class Transition
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor
|
/// Synthesize the indoor walkable contact plane for the player's current
|
||||||
/// polygon directly under <paramref name="localFootCenter"/> within
|
/// position when the cell BSP returns OK (no wall collision).
|
||||||
/// <paramref name="cellPhysics"/>. Used when the indoor cell-BSP query
|
|
||||||
/// returns OK (no wall collision) — we need to provide a walkable contact
|
|
||||||
/// plane from the cell's geometry instead of falling through to outdoor
|
|
||||||
/// terrain (which is below the cell floor due to the +0.02f Z-bump
|
|
||||||
/// applied at <c>GameWindow.BuildInteriorEntitiesForStreaming</c>).
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Iterates <see cref="CellPhysics.Resolved"/> physics polygons; selects
|
/// Routes through the retail-faithful BSP walkable-finder
|
||||||
/// the one with the most upward-facing normal (Z >= 0.6664 = walkable
|
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
|
||||||
/// slope threshold matching retail's WalkableSlopeMin) whose XY projection
|
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
|
||||||
/// contains the player's local foot XY. Returns the polygon's plane +
|
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
|
||||||
/// vertices in WORLD space for the <c>ValidateWalkable</c> call.
|
/// stop-gap; that scan picked the wrong floor whenever two polygons
|
||||||
|
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -1188,10 +1184,17 @@ public sealed class Transition
|
||||||
/// player. The caller falls through to outdoor terrain in that case
|
/// player. The caller falls through to outdoor terrain in that case
|
||||||
/// (defensive backstop — should not normally happen inside a sealed cell).
|
/// (defensive backstop — should not normally happen inside a sealed cell).
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
|
||||||
|
/// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
|
||||||
|
/// CPolygon::adjust_sphere_to_plane (:322032).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool TryFindIndoorWalkablePlane(
|
internal bool TryFindIndoorWalkablePlane(
|
||||||
CellPhysics cellPhysics,
|
CellPhysics cellPhysics,
|
||||||
Vector3 localFootCenter,
|
Vector3 localFootCenter,
|
||||||
|
float sphereRadius,
|
||||||
out System.Numerics.Plane worldPlane,
|
out System.Numerics.Plane worldPlane,
|
||||||
out Vector3[] worldVertices,
|
out Vector3[] worldVertices,
|
||||||
out uint hitPolyId)
|
out uint hitPolyId)
|
||||||
|
|
@ -1200,57 +1203,76 @@ public sealed class Transition
|
||||||
worldVertices = System.Array.Empty<Vector3>();
|
worldVertices = System.Array.Empty<Vector3>();
|
||||||
hitPolyId = 0;
|
hitPolyId = 0;
|
||||||
|
|
||||||
foreach (var (id, poly) in cellPhysics.Resolved)
|
if (cellPhysics.BSP?.Root is null) return false;
|
||||||
|
|
||||||
|
// Build foot sphere in cell-local space. Caller passes localFootCenter
|
||||||
|
// already transformed into cell-local space and the resolver's
|
||||||
|
// foot-sphere radius.
|
||||||
|
var localSphere = new DatReaderWriter.Types.Sphere
|
||||||
{
|
{
|
||||||
// Walkable slope threshold matches retail WalkableSlopeMin (0.6664...)
|
Origin = localFootCenter,
|
||||||
// and our existing TerrainSurface.WalkableSlopeMin check.
|
Radius = sphereRadius,
|
||||||
if (poly.Plane.Normal.Z < 0.6664f) continue;
|
};
|
||||||
if (poly.Vertices is null || poly.Vertices.Length < 3) continue;
|
|
||||||
|
|
||||||
// Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule.
|
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
|
||||||
if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue;
|
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
|
||||||
|
// "standing here, find my floor" we want the walkability slope
|
||||||
|
// threshold FloorZ. The outer resolver may have set it to LandingZ
|
||||||
|
// (airborne→ground transition) or another value; we must not leak our
|
||||||
|
// change back to the resolver. try/finally so an exception inside
|
||||||
|
// FindWalkableSphere doesn't leak the modified state.
|
||||||
|
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
|
||||||
|
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||||
|
|
||||||
// Found a floor poly under the player. Transform plane + vertices
|
ResolvedPolygon? hitPoly = null;
|
||||||
// to world space.
|
ushort hitId = 0;
|
||||||
var worldNormal = Vector3.TransformNormal(poly.Plane.Normal, cellPhysics.WorldTransform);
|
Vector3 adjustedCenter;
|
||||||
worldNormal = Vector3.Normalize(worldNormal);
|
bool found;
|
||||||
// Take vertex 0, transform to world, recompute D so the plane
|
|
||||||
// equation normal·p + D = 0 holds at the world-space vertex.
|
|
||||||
var worldV0 = Vector3.Transform(poly.Vertices[0], cellPhysics.WorldTransform);
|
|
||||||
float worldD = -Vector3.Dot(worldNormal, worldV0);
|
|
||||||
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
|
|
||||||
|
|
||||||
worldVertices = new Vector3[poly.Vertices.Length];
|
try
|
||||||
for (int i = 0; i < poly.Vertices.Length; i++)
|
{
|
||||||
worldVertices[i] = Vector3.Transform(poly.Vertices[i], cellPhysics.WorldTransform);
|
found = BSPQuery.FindWalkableSphere(
|
||||||
|
cellPhysics.BSP.Root,
|
||||||
hitPolyId = id;
|
cellPhysics.Resolved,
|
||||||
return true;
|
this,
|
||||||
|
localSphere,
|
||||||
|
INDOOR_WALKABLE_PROBE_DISTANCE,
|
||||||
|
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
|
||||||
|
out hitPoly,
|
||||||
|
out hitId,
|
||||||
|
out adjustedCenter);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (!found || hitPoly is null) return false;
|
||||||
|
|
||||||
|
// Transform hit polygon's plane + vertices to world space. Math is
|
||||||
|
// unchanged from the previous TryFindIndoorWalkablePlane implementation.
|
||||||
|
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
|
||||||
|
worldNormal = Vector3.Normalize(worldNormal);
|
||||||
|
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
|
||||||
|
float worldD = -Vector3.Dot(worldNormal, worldV0);
|
||||||
|
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
|
||||||
|
|
||||||
|
worldVertices = new Vector3[hitPoly.Vertices.Length];
|
||||||
|
for (int i = 0; i < hitPoly.Vertices.Length; i++)
|
||||||
|
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
|
||||||
|
|
||||||
|
hitPolyId = hitId;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting
|
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
|
||||||
/// even-odd rule. Works for convex and concave polygons.
|
/// when scanning for the indoor walkable contact plane. 50 cm.
|
||||||
|
/// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
|
||||||
|
/// step riser; smaller than a full cell height so we don't reach through
|
||||||
|
/// a thin floor into the cell above/below.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool PointInPolygonXY(Vector3 point, Vector3[] vertices)
|
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
|
||||||
{
|
|
||||||
bool inside = false;
|
|
||||||
int n = vertices.Length;
|
|
||||||
for (int i = 0, j = n - 1; i < n; j = i++)
|
|
||||||
{
|
|
||||||
var vi = vertices[i];
|
|
||||||
var vj = vertices[j];
|
|
||||||
if (((vi.Y > point.Y) != (vj.Y > point.Y)) &&
|
|
||||||
(point.X < (vj.X - vi.X) * (point.Y - vi.Y) / (vj.Y - vi.Y) + vi.X))
|
|
||||||
{
|
|
||||||
inside = !inside;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
||||||
|
|
@ -1355,7 +1377,7 @@ public sealed class Transition
|
||||||
// Retail: CEnvCell::find_env_collisions returns from the cell
|
// Retail: CEnvCell::find_env_collisions returns from the cell
|
||||||
// branch with the cell's walkable plane set — no fall-through
|
// branch with the cell's walkable plane set — no fall-through
|
||||||
// to terrain.
|
// to terrain.
|
||||||
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
|
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
|
||||||
out var indoorPlane,
|
out var indoorPlane,
|
||||||
out var indoorVertices,
|
out var indoorVertices,
|
||||||
out uint _))
|
out uint _))
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Physics;
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/> and
|
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/>.
|
||||||
/// <see cref="Transition.PointInPolygonXY"/>.
|
|
||||||
///
|
///
|
||||||
/// Indoor walking Phase 2 follow-up (2026-05-19): these helpers synthesize
|
/// Indoor walking Phase 2 follow-up (2026-05-19): the helper synthesizes
|
||||||
/// a walkable contact plane from cell floor polys so the resolver does not
|
/// a walkable contact plane from cell floor polys so the resolver does not
|
||||||
/// fall through to outdoor terrain when the player is standing indoors.
|
/// fall through to outdoor terrain when the player is standing indoors.
|
||||||
|
///
|
||||||
|
/// Task 3 (2026-05-19): refactored to route through BSPQuery.FindWalkableSphere.
|
||||||
|
/// Fixtures now include a PhysicsBSPTree with a Leaf node listing all polygon ids,
|
||||||
|
/// and calls pass sphereRadius explicitly. PointInPolygonXY tests removed since
|
||||||
|
/// that helper was deleted (it was the dead linear-scan body).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class IndoorWalkablePlaneTests
|
public class IndoorWalkablePlaneTests
|
||||||
{
|
{
|
||||||
|
|
@ -20,9 +25,27 @@ public class IndoorWalkablePlaneTests
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a BSP Leaf node that lists the given polygon ids, with a bounding
|
||||||
|
/// sphere large enough to always contain the test geometry.
|
||||||
|
/// </summary>
|
||||||
|
private static PhysicsBSPTree BuildLeafBsp(IEnumerable<ushort> polyIds,
|
||||||
|
Vector3 center, float radius)
|
||||||
|
{
|
||||||
|
var node = new PhysicsBSPNode
|
||||||
|
{
|
||||||
|
Type = BSPNodeType.Leaf,
|
||||||
|
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||||
|
};
|
||||||
|
foreach (var id in polyIds)
|
||||||
|
node.Polygons.Add(id);
|
||||||
|
return new PhysicsBSPTree { Root = node };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a CellPhysics with a single upward-facing floor polygon
|
/// Builds a CellPhysics with a single upward-facing floor polygon
|
||||||
/// (a 10×10 square in the XY plane at local Z=0), plus identity transforms.
|
/// (a 10×10 square in the XY plane at local Z=0), plus identity transforms
|
||||||
|
/// and a BSP leaf that covers all polygons.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
|
private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
|
||||||
{
|
{
|
||||||
|
|
@ -44,11 +67,15 @@ public class IndoorWalkablePlaneTests
|
||||||
SidesType = CullMode.None,
|
SidesType = CullMode.None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||||
|
var bsp = BuildLeafBsp(new ushort[] { 0 }, new Vector3(0f, 0f, floorZ), 10f);
|
||||||
|
|
||||||
return new CellPhysics
|
return new CellPhysics
|
||||||
{
|
{
|
||||||
|
BSP = bsp,
|
||||||
WorldTransform = Matrix4x4.Identity,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
Resolved = resolved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,12 +86,14 @@ public class IndoorWalkablePlaneTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
||||||
{
|
{
|
||||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||||
var localFoot = new Vector3(0f, 0f, 0.5f); // centred over the 10×10 square
|
var transition = new Transition();
|
||||||
|
// Foot sphere centre at Z=0.4, radius=0.48 → overlaps floor at Z=0.
|
||||||
|
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||||
|
|
||||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
cell, localFoot,
|
cell, localFoot, sphereRadius: 0.48f,
|
||||||
out var plane, out var verts, out uint polyId);
|
out _, out _, out _);
|
||||||
|
|
||||||
Assert.True(found);
|
Assert.True(found);
|
||||||
}
|
}
|
||||||
|
|
@ -72,11 +101,13 @@ public class IndoorWalkablePlaneTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
||||||
{
|
{
|
||||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||||
var localFoot = new Vector3(0f, 0f, 0.5f);
|
var transition = new Transition();
|
||||||
|
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||||
|
|
||||||
Transition.TryFindIndoorWalkablePlane(
|
transition.TryFindIndoorWalkablePlane(
|
||||||
cell, localFoot, out var plane, out _, out _);
|
cell, localFoot, sphereRadius: 0.48f,
|
||||||
|
out var plane, out _, out _);
|
||||||
|
|
||||||
// The floor's normal must point up (Z close to 1).
|
// The floor's normal must point up (Z close to 1).
|
||||||
Assert.True(plane.Normal.Z > 0.99f,
|
Assert.True(plane.Normal.Z > 0.99f,
|
||||||
|
|
@ -88,10 +119,13 @@ public class IndoorWalkablePlaneTests
|
||||||
{
|
{
|
||||||
const float floorZ = 2.5f;
|
const float floorZ = 2.5f;
|
||||||
var cell = BuildCellWithFloor(floorZ);
|
var cell = BuildCellWithFloor(floorZ);
|
||||||
var localFoot = new Vector3(0f, 0f, floorZ + 0.5f);
|
var transition = new Transition();
|
||||||
|
// Foot sphere overlaps floor: centre at floorZ + 0.4, radius=0.48 → dist=0.4 < 0.48.
|
||||||
|
var localFoot = new Vector3(0f, 0f, floorZ + 0.4f);
|
||||||
|
|
||||||
Transition.TryFindIndoorWalkablePlane(
|
transition.TryFindIndoorWalkablePlane(
|
||||||
cell, localFoot, out var plane, out _, out _);
|
cell, localFoot, sphereRadius: 0.48f,
|
||||||
|
out var plane, out _, out _);
|
||||||
|
|
||||||
// With identity transform and an upward normal, plane.D = -floorZ.
|
// With identity transform and an upward normal, plane.D = -floorZ.
|
||||||
// The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1).
|
// The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1).
|
||||||
|
|
@ -103,35 +137,32 @@ public class IndoorWalkablePlaneTests
|
||||||
public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
|
public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
|
||||||
{
|
{
|
||||||
var cell = BuildCellWithFloor();
|
var cell = BuildCellWithFloor();
|
||||||
|
var transition = new Transition();
|
||||||
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
|
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
|
||||||
var localFoot = new Vector3(20f, 20f, 0.5f);
|
var localFoot = new Vector3(20f, 20f, 0.4f);
|
||||||
|
|
||||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
cell, localFoot, out _, out _, out _);
|
cell, localFoot, sphereRadius: 0.48f,
|
||||||
|
out _, out _, out _);
|
||||||
|
|
||||||
Assert.False(found);
|
Assert.False(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryFindIndoorWalkablePlane_NoWalkablePolys_ReturnsFalse()
|
public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// A polygon whose normal points sideways (wall) — normal.Z < 0.6664.
|
// CellPhysics without a BSP → BSP?.Root is null → early return false.
|
||||||
var wallPoly = new ResolvedPolygon
|
|
||||||
{
|
|
||||||
Vertices = new[] { Vector3.Zero, Vector3.UnitY, Vector3.UnitZ },
|
|
||||||
Plane = new Plane(new Vector3(1f, 0f, 0f), 0f), // normal.Z = 0
|
|
||||||
NumPoints = 3,
|
|
||||||
SidesType = CullMode.None,
|
|
||||||
};
|
|
||||||
var cell = new CellPhysics
|
var cell = new CellPhysics
|
||||||
{
|
{
|
||||||
WorldTransform = Matrix4x4.Identity,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
Resolved = new Dictionary<ushort, ResolvedPolygon> { [1] = wallPoly },
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||||
};
|
};
|
||||||
|
var transition = new Transition();
|
||||||
|
|
||||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
|
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||||
|
out _, out _, out _);
|
||||||
|
|
||||||
Assert.False(found);
|
Assert.False(found);
|
||||||
}
|
}
|
||||||
|
|
@ -139,15 +170,20 @@ public class IndoorWalkablePlaneTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
|
public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
|
||||||
{
|
{
|
||||||
|
// BSP leaf exists but references no polygons → FindWalkableSphere returns false.
|
||||||
|
var bsp = BuildLeafBsp(System.Array.Empty<ushort>(), Vector3.Zero, 10f);
|
||||||
var cell = new CellPhysics
|
var cell = new CellPhysics
|
||||||
{
|
{
|
||||||
|
BSP = bsp,
|
||||||
WorldTransform = Matrix4x4.Identity,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||||
};
|
};
|
||||||
|
var transition = new Transition();
|
||||||
|
|
||||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
|
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||||
|
out _, out _, out _);
|
||||||
|
|
||||||
Assert.False(found);
|
Assert.False(found);
|
||||||
}
|
}
|
||||||
|
|
@ -173,18 +209,24 @@ public class IndoorWalkablePlaneTests
|
||||||
NumPoints = 4,
|
NumPoints = 4,
|
||||||
SidesType = CullMode.None,
|
SidesType = CullMode.None,
|
||||||
};
|
};
|
||||||
|
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||||
|
var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f);
|
||||||
|
|
||||||
var cell = new CellPhysics
|
var cell = new CellPhysics
|
||||||
{
|
{
|
||||||
|
BSP = bsp,
|
||||||
WorldTransform = translation,
|
WorldTransform = translation,
|
||||||
InverseWorldTransform = inv,
|
InverseWorldTransform = inv,
|
||||||
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
Resolved = resolved,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The player's local foot is at (0,0,0.5) in local space.
|
// The player's local foot sphere centre at (0,0,0.4) overlaps the floor at Z=0.
|
||||||
var localFoot = new Vector3(0f, 0f, 0.5f);
|
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||||
|
var transition = new Transition();
|
||||||
|
|
||||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
cell, localFoot, out var plane, out var worldVerts, out _);
|
cell, localFoot, sphereRadius: 0.48f,
|
||||||
|
out var plane, out var worldVerts, out _);
|
||||||
|
|
||||||
Assert.True(found);
|
Assert.True(found);
|
||||||
// World normal should still be (0,0,1).
|
// World normal should still be (0,0,1).
|
||||||
|
|
@ -195,46 +237,4 @@ public class IndoorWalkablePlaneTests
|
||||||
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
|
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
|
||||||
$"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
|
$"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// PointInPolygonXY
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData( 0f, 0f, true)] // centre
|
|
||||||
[InlineData( 4f, 4f, true)] // near corner, inside
|
|
||||||
[InlineData( 5f, 5f, false)] // on the corner — outside by convention
|
|
||||||
[InlineData(10f, 0f, false)] // clearly outside
|
|
||||||
[InlineData(-4f, -4f, true)] // near opposite corner, inside
|
|
||||||
public void PointInPolygonXY_UnitSquare(float px, float py, bool expected)
|
|
||||||
{
|
|
||||||
var square = new[]
|
|
||||||
{
|
|
||||||
new Vector3(-5f, -5f, 0f),
|
|
||||||
new Vector3( 5f, -5f, 0f),
|
|
||||||
new Vector3( 5f, 5f, 0f),
|
|
||||||
new Vector3(-5f, 5f, 0f),
|
|
||||||
};
|
|
||||||
bool result = Transition.PointInPolygonXY(new Vector3(px, py, 99f), square);
|
|
||||||
Assert.Equal(expected, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PointInPolygonXY_IgnoresZ()
|
|
||||||
{
|
|
||||||
// Same XY, different Z — should still be inside.
|
|
||||||
var square = new[]
|
|
||||||
{
|
|
||||||
new Vector3(-5f, -5f, 0f),
|
|
||||||
new Vector3( 5f, -5f, 0f),
|
|
||||||
new Vector3( 5f, 5f, 0f),
|
|
||||||
new Vector3(-5f, 5f, 0f),
|
|
||||||
};
|
|
||||||
// Point has the same XY as the inside case but a very different Z.
|
|
||||||
bool atLowZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, -1000f), square);
|
|
||||||
bool atHighZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, 1000f), square);
|
|
||||||
|
|
||||||
Assert.True(atLowZ);
|
|
||||||
Assert.True(atHighZ);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Plane = System.Numerics.Plane;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
public class TransitionTypesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
|
||||||
|
{
|
||||||
|
// Build a CellPhysics with two horizontal walkable polygons at
|
||||||
|
// local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
|
||||||
|
// Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon
|
||||||
|
// (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly
|
||||||
|
// to be returned. Sentinel WalkableAllowance value must be preserved
|
||||||
|
// across the call.
|
||||||
|
|
||||||
|
var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
|
||||||
|
|
||||||
|
var transition = new Transition();
|
||||||
|
const float sentinelAllowance = 0.42f;
|
||||||
|
transition.SpherePath.WalkableAllowance = sentinelAllowance;
|
||||||
|
transition.SpherePath.WalkInterp = 1.0f;
|
||||||
|
|
||||||
|
bool found = transition.TryFindIndoorWalkablePlane(
|
||||||
|
cellPhysics,
|
||||||
|
localFootCenter: new Vector3(0.5f, 0.5f, 0.4f),
|
||||||
|
sphereRadius: 0.48f,
|
||||||
|
out var worldPlane,
|
||||||
|
out var worldVertices,
|
||||||
|
out var hitPolyId);
|
||||||
|
|
||||||
|
Assert.True(found);
|
||||||
|
// Lower polygon's local plane Normal.Z = 1.0; identity world transform
|
||||||
|
// means world Normal.Z is also 1.0.
|
||||||
|
Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
|
||||||
|
// World vertices match the lower polygon (Z=0 in world space, identity transform).
|
||||||
|
Assert.Equal(4, worldVertices.Length);
|
||||||
|
Assert.Equal(0f, worldVertices[0].Z, precision: 3);
|
||||||
|
// hitPolyId is the dictionary key — lower polygon was inserted as key 0.
|
||||||
|
Assert.Equal(0u, hitPolyId);
|
||||||
|
// WalkableAllowance must be restored to the sentinel.
|
||||||
|
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a minimal CellPhysics with two horizontal walkable polygons at
|
||||||
|
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
|
||||||
|
/// </summary>
|
||||||
|
private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
|
||||||
|
{
|
||||||
|
Vector3[] lowerVerts =
|
||||||
|
{
|
||||||
|
new Vector3(0f, 0f, lowerZ),
|
||||||
|
new Vector3(1f, 0f, lowerZ),
|
||||||
|
new Vector3(1f, 1f, lowerZ),
|
||||||
|
new Vector3(0f, 1f, lowerZ),
|
||||||
|
};
|
||||||
|
Vector3[] upperVerts =
|
||||||
|
{
|
||||||
|
new Vector3(0f, 0f, upperZ),
|
||||||
|
new Vector3(1f, 0f, upperZ),
|
||||||
|
new Vector3(1f, 1f, upperZ),
|
||||||
|
new Vector3(0f, 1f, upperZ),
|
||||||
|
};
|
||||||
|
|
||||||
|
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||||
|
{
|
||||||
|
[0] = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = lowerVerts,
|
||||||
|
Plane = new Plane(Vector3.UnitZ, -lowerZ),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
},
|
||||||
|
[1] = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = upperVerts,
|
||||||
|
Plane = new Plane(Vector3.UnitZ, -upperZ),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
|
||||||
|
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
|
||||||
|
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
|
||||||
|
|
||||||
|
var root = new PhysicsBSPNode
|
||||||
|
{
|
||||||
|
Type = BSPNodeType.Leaf,
|
||||||
|
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||||
|
};
|
||||||
|
root.Polygons.Add(0);
|
||||||
|
root.Polygons.Add(1);
|
||||||
|
|
||||||
|
var bsp = new PhysicsBSPTree { Root = root };
|
||||||
|
|
||||||
|
return new CellPhysics
|
||||||
|
{
|
||||||
|
BSP = bsp,
|
||||||
|
Resolved = resolved,
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue