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>
291 lines
10 KiB
C#
291 lines
10 KiB
C#
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}");
|
||
}
|
||
}
|