acdream/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Erik 7c516edd7b fix(physics): document adjustedCenter discard + restore wall-poly test
Code review feedback on Task 3 commit 91b29d1:

- TryFindIndoorWalkablePlane: comment explaining why FindWalkableSphere's
  adjustedCenter out param is intentionally discarded (ValidateWalkable
  recomputes contact geometry from plane + foot position, consistent
  with the outdoor terrain path).
- IndoorWalkablePlaneTests: new TryFindIndoorWalkablePlane_WallPolyInBsp_ReturnsFalse
  restores integration-level coverage that the renamed NoBsp_ReturnsFalse
  lost. Verifies WalkableAllowance gate rejects a wall polygon in the
  cell BSP. Steep-poly rejection is also covered at the BSPQuery layer
  by FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance.

No behavior change. Build clean; all related tests pass; same 8
pre-existing failures.

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:58:53 +02:00

291 lines
10 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_WallPolyInBsp_ReturnsFalse()
{
// A polygon with a horizontal normal (Z = 0) is a wall, not a floor.
// walkable_hits_sphere rejects it: dp = dot(UnitZ, (0,1,0)) = 0 <= FloorZ.
// Regression coverage for the previous NoWalkablePolys_ReturnsFalse intent
// (the renamed NoBsp_ReturnsFalse only covers the null-BSP early-return).
Vector3[] wallVerts =
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(1f, 0f, 1f),
new Vector3(0f, 0f, 1f),
};
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = wallVerts,
Plane = new Plane(new Vector3(0f, 1f, 0f), 0f), // wall facing +Y
NumPoints = 4,
SidesType = CullMode.None,
},
};
var center = new Vector3(0.5f, 0f, 0.5f);
var bsp = BuildLeafBsp(new ushort[] { 0 }, center, 2f);
var cell = new CellPhysics
{
BSP = bsp,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = resolved,
};
var transition = new Transition();
transition.SpherePath.WalkInterp = 1.0f;
// Foot sphere positioned to overlap the wall's plane (|Y - 0| = 0 < radius 0.48).
bool found = transition.TryFindIndoorWalkablePlane(
cell,
localFootCenter: new Vector3(0.5f, 0f, 0.5f),
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}");
}
}