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:
Erik 2026-05-19 21:47:49 +02:00
parent 86ecdf9ee1
commit 91b29d1a89
3 changed files with 269 additions and 136 deletions

View file

@ -1167,20 +1167,16 @@ public sealed class Transition
// -----------------------------------------------------------------------
/// <summary>
/// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor
/// polygon directly under <paramref name="localFootCenter"/> within
/// <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>).
/// Synthesize the indoor walkable contact plane for the player's current
/// position when the cell BSP returns OK (no wall collision).
///
/// <para>
/// Iterates <see cref="CellPhysics.Resolved"/> physics polygons; selects
/// the one with the most upward-facing normal (Z &gt;= 0.6664 = walkable
/// slope threshold matching retail's WalkableSlopeMin) whose XY projection
/// contains the player's local foot XY. Returns the polygon's plane +
/// vertices in WORLD space for the <c>ValidateWalkable</c> call.
/// Routes through the retail-faithful BSP walkable-finder
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
/// stop-gap; that scan picked the wrong floor whenever two polygons
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
/// </para>
///
/// <para>
@ -1188,10 +1184,17 @@ public sealed class Transition
/// player. The caller falls through to outdoor terrain in that case
/// (defensive backstop — should not normally happen inside a sealed cell).
/// </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>
internal static bool TryFindIndoorWalkablePlane(
internal bool TryFindIndoorWalkablePlane(
CellPhysics cellPhysics,
Vector3 localFootCenter,
float sphereRadius,
out System.Numerics.Plane worldPlane,
out Vector3[] worldVertices,
out uint hitPolyId)
@ -1200,57 +1203,76 @@ public sealed class Transition
worldVertices = System.Array.Empty<Vector3>();
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...)
// and our existing TerrainSurface.WalkableSlopeMin check.
if (poly.Plane.Normal.Z < 0.6664f) continue;
if (poly.Vertices is null || poly.Vertices.Length < 3) continue;
Origin = localFootCenter,
Radius = sphereRadius,
};
// Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule.
if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue;
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
// 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
// to world space.
var worldNormal = Vector3.TransformNormal(poly.Plane.Normal, cellPhysics.WorldTransform);
worldNormal = Vector3.Normalize(worldNormal);
// 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);
ResolvedPolygon? hitPoly = null;
ushort hitId = 0;
Vector3 adjustedCenter;
bool found;
worldVertices = new Vector3[poly.Vertices.Length];
for (int i = 0; i < poly.Vertices.Length; i++)
worldVertices[i] = Vector3.Transform(poly.Vertices[i], cellPhysics.WorldTransform);
hitPolyId = id;
return true;
try
{
found = BSPQuery.FindWalkableSphere(
cellPhysics.BSP.Root,
cellPhysics.Resolved,
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>
/// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting
/// even-odd rule. Works for convex and concave polygons.
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
/// 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>
internal static bool PointInPolygonXY(Vector3 point, Vector3[] vertices)
{
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;
}
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
/// <summary>
/// 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
// branch with the cell's walkable plane set — no fall-through
// to terrain.
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint _))