acdream/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Erik 91b29d1a89 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>
2026-05-19 21:47:49 +02:00

240 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/>.
///
/// 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
/// 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>
public class IndoorWalkablePlaneTests
{
// -----------------------------------------------------------------------
// 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>
/// 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
/// and a BSP leaf that covers all polygons.
/// </summary>
private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
{
var verts = new[]
{
new Vector3(-5f, -5f, floorZ),
new Vector3( 5f, -5f, floorZ),
new Vector3( 5f, 5f, floorZ),
new Vector3(-5f, 5f, floorZ),
};
var normal = new Vector3(0f, 0f, 1f); // straight up
float D = -Vector3.Dot(normal, verts[0]); // = -floorZ
var floorPoly = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(normal, D),
NumPoints = 4,
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
{
BSP = bsp,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = resolved,
};
}
// -----------------------------------------------------------------------
// TryFindIndoorWalkablePlane
// -----------------------------------------------------------------------
[Fact]
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
{
var cell = BuildCellWithFloor(floorZ: 0f);
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(
cell, localFoot, sphereRadius: 0.48f,
out _, out _, out _);
Assert.True(found);
}
[Fact]
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
{
var cell = BuildCellWithFloor(floorZ: 0f);
var transition = new Transition();
var localFoot = new Vector3(0f, 0f, 0.4f);
transition.TryFindIndoorWalkablePlane(
cell, localFoot, sphereRadius: 0.48f,
out var plane, out _, out _);
// The floor's normal must point up (Z close to 1).
Assert.True(plane.Normal.Z > 0.99f,
$"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}");
}
[Fact]
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ()
{
const float floorZ = 2.5f;
var cell = BuildCellWithFloor(floorZ);
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(
cell, localFoot, sphereRadius: 0.48f,
out var plane, out _, out _);
// 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).
Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f,
$"Expected plane.D ≈ {-floorZ}, got {plane.D}");
}
[Fact]
public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
{
var cell = BuildCellWithFloor();
var transition = new Transition();
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
var localFoot = new Vector3(20f, 20f, 0.4f);
bool found = transition.TryFindIndoorWalkablePlane(
cell, localFoot, sphereRadius: 0.48f,
out _, out _, out _);
Assert.False(found);
}
[Fact]
public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse()
{
// CellPhysics without a BSP → BSP?.Root is null → early return false.
var cell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var transition = new Transition();
bool found = transition.TryFindIndoorWalkablePlane(
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
out _, out _, out _);
Assert.False(found);
}
[Fact]
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
{
BSP = bsp,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var transition = new Transition();
bool found = transition.TryFindIndoorWalkablePlane(
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
out _, out _, out _);
Assert.False(found);
}
[Fact]
public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace()
{
// Cell is translated 100 units in X and 200 units in Y.
var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f);
Matrix4x4.Invert(translation, out var inv);
var localVerts = new[]
{
new Vector3(-5f, -5f, 0f),
new Vector3( 5f, -5f, 0f),
new Vector3( 5f, 5f, 0f),
new Vector3(-5f, 5f, 0f),
};
var floorPoly = new ResolvedPolygon
{
Vertices = localVerts,
Plane = new Plane(new Vector3(0f, 0f, 1f), 0f),
NumPoints = 4,
SidesType = CullMode.None,
};
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f);
var cell = new CellPhysics
{
BSP = bsp,
WorldTransform = translation,
InverseWorldTransform = inv,
Resolved = resolved,
};
// 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.4f);
var transition = new Transition();
bool found = transition.TryFindIndoorWalkablePlane(
cell, localFoot, sphereRadius: 0.48f,
out var plane, out var worldVerts, out _);
Assert.True(found);
// World normal should still be (0,0,1).
Assert.True(plane.Normal.Z > 0.99f);
// World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94).
Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f);
Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f);
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
$"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
}
}