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
|
|
@ -1,18 +1,23 @@
|
|||
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"/> and
|
||||
/// <see cref="Transition.PointInPolygonXY"/>.
|
||||
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/>.
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
{
|
||||
|
|
@ -20,9 +25,27 @@ 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.
|
||||
/// (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)
|
||||
{
|
||||
|
|
@ -44,11 +67,15 @@ public class IndoorWalkablePlaneTests
|
|||
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 = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
||||
Resolved = resolved,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,12 +86,14 @@ public class IndoorWalkablePlaneTests
|
|||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var localFoot = new Vector3(0f, 0f, 0.5f); // centred over the 10×10 square
|
||||
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,
|
||||
out var plane, out var verts, out uint polyId);
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.True(found);
|
||||
}
|
||||
|
|
@ -72,11 +101,13 @@ public class IndoorWalkablePlaneTests
|
|||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var localFoot = new Vector3(0f, 0f, 0.5f);
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var transition = new Transition();
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
|
||||
Transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, out var plane, out _, out _);
|
||||
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,
|
||||
|
|
@ -88,10 +119,13 @@ public class IndoorWalkablePlaneTests
|
|||
{
|
||||
const float floorZ = 2.5f;
|
||||
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(
|
||||
cell, localFoot, out var plane, out _, out _);
|
||||
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).
|
||||
|
|
@ -103,35 +137,32 @@ public class IndoorWalkablePlaneTests
|
|||
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.5f);
|
||||
var localFoot = new Vector3(20f, 20f, 0.4f);
|
||||
|
||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, out _, out _, out _);
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_NoWalkablePolys_ReturnsFalse()
|
||||
public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse()
|
||||
{
|
||||
// A polygon whose normal points sideways (wall) — normal.Z < 0.6664.
|
||||
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,
|
||||
};
|
||||
// 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> { [1] = wallPoly },
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = Transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
|
@ -139,15 +170,20 @@ public class IndoorWalkablePlaneTests
|
|||
[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.5f), out _, out _, out _);
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
|
@ -173,18 +209,24 @@ public class IndoorWalkablePlaneTests
|
|||
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 = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly },
|
||||
Resolved = resolved,
|
||||
};
|
||||
|
||||
// The player's local foot is at (0,0,0.5) in local space.
|
||||
var localFoot = new Vector3(0f, 0f, 0.5f);
|
||||
// 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, out var plane, out var worldVerts, out _);
|
||||
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).
|
||||
|
|
@ -195,46 +237,4 @@ public class IndoorWalkablePlaneTests
|
|||
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
|
||||
$"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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue