fix(physics): #32 L.2c precipice edge-slide context

Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.

Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-04-30 08:04:37 +02:00
parent 1ec40f2a4f
commit 261322b48e
10 changed files with 559 additions and 60 deletions

View file

@ -377,30 +377,33 @@ public static class BSPQuery
///
/// <para>ACE: Polygon.cs find_crossed_edge.</para>
/// </summary>
private static bool FindCrossedEdge(
ResolvedPolygon poly,
CollisionSphere sphere,
Vector3 up,
ref Vector3 normal)
internal static bool FindCrossedEdge(
Plane polyPlane,
ReadOnlySpan<Vector3> verts,
Vector3 sphereCenter,
Vector3 up,
out Vector3 normal)
{
float angleUp = Vector3.Dot(poly.Plane.Normal, up);
normal = Vector3.Zero;
float angleUp = Vector3.Dot(polyPlane.Normal, up);
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
var center = sphere.Center - up * angle;
float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
var center = sphereCenter - up * angle;
int n = poly.Vertices.Length;
int n = verts.Length;
int prevIdx = n - 1;
for (int i = 0; i < n; i++)
{
var v = poly.Vertices[i];
var lv = poly.Vertices[prevIdx];
var v = verts[i];
var lv = verts[prevIdx];
prevIdx = i;
var edge = v - lv;
var disp = center - lv;
var cross = Vector3.Cross(poly.Plane.Normal, edge);
var cross = Vector3.Cross(polyPlane.Normal, edge);
if (Vector3.Dot(disp, cross) < 0f)
{
@ -412,6 +415,47 @@ public static class BSPQuery
return false;
}
private static bool FindCrossedEdge(
ResolvedPolygon poly,
CollisionSphere sphere,
Vector3 up,
ref Vector3 normal)
{
if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal))
return false;
normal = crossedNormal;
return true;
}
private static Vector3 TransformNormal(Vector3 normal, Quaternion localToWorld)
{
var worldNormal = Vector3.Transform(normal, localToWorld);
return worldNormal.LengthSquared() > PhysicsGlobals.EpsilonSq
? Vector3.Normalize(worldNormal)
: Vector3.UnitZ;
}
private static Vector3[] TransformVertices(
ReadOnlySpan<Vector3> vertices,
Quaternion localToWorld,
float scale,
Vector3 worldOrigin)
{
var result = new Vector3[vertices.Length];
for (int i = 0; i < vertices.Length; i++)
result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin;
return result;
}
private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan<Vector3> worldVertices)
{
float d = worldVertices.Length > 0
? -Vector3.Dot(worldNormal, worldVertices[0])
: 0f;
return new Plane(worldNormal, d);
}
// -------------------------------------------------------------------------
// adjust_to_placement_poly
// ACE: Polygon.cs adjust_to_placement_poly
@ -1037,7 +1081,8 @@ public static class BSPQuery
CollisionSphere checkPos,
Vector3 up,
float scale,
Quaternion localToWorld = default)
Quaternion localToWorld = default,
Vector3 worldOrigin = default)
{
if (localToWorld == default) localToWorld = Quaternion.Identity;
@ -1061,14 +1106,12 @@ public static class BSPQuery
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
path.AddOffsetToCheckPos(offset);
var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
collisions.SetContactPlane(
new Plane(worldNormal, polyHit.Plane.D * scale),
path.CheckCellId, false);
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.WalkableValid = true;
path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
path.WalkableAllowance = PhysicsGlobals.FloorZ;
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
@ -1359,7 +1402,8 @@ public static class BSPQuery
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld = default,
PhysicsEngine? engine = null)
PhysicsEngine? engine = null,
Vector3 worldOrigin = default)
{
if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity
@ -1410,7 +1454,7 @@ public static class BSPQuery
// ----------------------------------------------------------------
if (path.StepDown)
{
return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld);
return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin);
}
// ----------------------------------------------------------------
@ -1433,14 +1477,12 @@ public static class BSPQuery
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
var worldNormal = L2W(hitPoly.Plane.Normal);
collisions.SetContactPlane(
new Plane(worldNormal, hitPoly.Plane.D * scale),
path.CheckCellId, false);
var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.WalkableValid = true;
path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
path.WalkableAllowance = PhysicsGlobals.FloorZ;
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}