fix(physics): remove per-frame indoor walkable-plane synthesis

The indoor branch of FindEnvCollisions called Transition.TryFindIndoorWalkablePlane
every frame to re-synthesize the ContactPlane after BSP returned OK.
The synthesis routed through BSPQuery.FindWalkableSphere ->
walkable_hits_sphere, which correctly rejects tangent contact via
|dist| > radius - epsilon. For a grounded player standing on or
brushing a floor, the foot sphere is tangent: 99.87% MISS rate per
the 2026-05-20 [cp-write] probe (3150 MISS / 3154 calls). Each MISS
fell through to outdoor terrain backstop, writing a ContactPlane
that's below the indoor floor by ~0.02m (the render Z-bump),
marking the player airborne and triggering the falling-animation
stuck symptom user-reported on 2nd-floor walks.

Fix: delete the synthesis + outdoor-fallthrough from the indoor OK
path. ContactPlane is retained from the prior tick's seed
(PhysicsEngine.ResolveWithTransition:583, init_contact_plane
equivalent) or refreshed by BSP Path 3 (step_sphere_down) / Path 4
(land-on-surface) during the same tick. Matches retail's
BSPTREE::find_collisions OK path (acclient_2013_pseudo_c.txt:323938).

Also deletes:
- Transition.TryFindIndoorWalkablePlane (~104 lines incl. doc-comment)
- INDOOR_WALKABLE_PROBE_DISTANCE constant
- [indoor-walkable] probe log line
- IndoorWalkablePlaneTests.cs (8 tests, the helper's coverage)
- TransitionTypesTests.cs (1 test, also tested the helper)

Net: -491 lines. BSPQuery.FindWalkableSphere + its 5 unit tests
retained as the underlying retail-faithful walkable-finder API
(reachable for spawn-placement / teleport-verification / future
debug needs; its doc-comment is updated to reflect the change).

Closes Bug A in the indoor ContactPlane retention phase.
Spec: docs/superpowers/specs/2026-05-20-indoor-walkable-synthesis-removal-design.md.
Plan: docs/superpowers/plans/2026-05-20-indoor-walkable-synthesis-removal.md.
Predecessor: de8ffde (Bug B, BSP world-origin fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-20 09:11:04 +02:00
parent 686f27f227
commit 9f874f4650
4 changed files with 23 additions and 573 deletions

View file

@ -1,291 +0,0 @@
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}");
}
}

View file

@ -1,111 +0,0 @@
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
public class TransitionTypesTests
{
[Fact]
public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
{
// Build a CellPhysics with two horizontal walkable polygons at
// local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
// Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon
// (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly
// to be returned. Sentinel WalkableAllowance value must be preserved
// across the call.
var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
var transition = new Transition();
const float sentinelAllowance = 0.42f;
transition.SpherePath.WalkableAllowance = sentinelAllowance;
transition.SpherePath.WalkInterp = 1.0f;
bool found = transition.TryFindIndoorWalkablePlane(
cellPhysics,
localFootCenter: new Vector3(0.5f, 0.5f, 0.4f),
sphereRadius: 0.48f,
out var worldPlane,
out var worldVertices,
out var hitPolyId);
Assert.True(found);
// Lower polygon's local plane Normal.Z = 1.0; identity world transform
// means world Normal.Z is also 1.0.
Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
// World vertices match the lower polygon (Z=0 in world space, identity transform).
Assert.Equal(4, worldVertices.Length);
Assert.Equal(0f, worldVertices[0].Z, precision: 3);
// hitPolyId is the dictionary key — lower polygon was inserted as key 0.
Assert.Equal(0u, hitPolyId);
// WalkableAllowance must be restored to the sentinel.
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
}
/// <summary>
/// Build a minimal CellPhysics with two horizontal walkable polygons at
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
/// </summary>
private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
{
Vector3[] lowerVerts =
{
new Vector3(0f, 0f, lowerZ),
new Vector3(1f, 0f, lowerZ),
new Vector3(1f, 1f, lowerZ),
new Vector3(0f, 1f, lowerZ),
};
Vector3[] upperVerts =
{
new Vector3(0f, 0f, upperZ),
new Vector3(1f, 0f, upperZ),
new Vector3(1f, 1f, upperZ),
new Vector3(0f, 1f, upperZ),
};
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = lowerVerts,
Plane = new Plane(Vector3.UnitZ, -lowerZ),
NumPoints = 4,
SidesType = CullMode.None,
},
[1] = new ResolvedPolygon
{
Vertices = upperVerts,
Plane = new Plane(Vector3.UnitZ, -upperZ),
NumPoints = 4,
SidesType = CullMode.None,
},
};
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = center, Radius = radius },
};
root.Polygons.Add(0);
root.Polygons.Add(1);
var bsp = new PhysicsBSPTree { Root = root };
return new CellPhysics
{
BSP = bsp,
Resolved = resolved,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
}