From eb0f772f0f4f3c9b376c4ca5bbc5cb52647f3a58 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 19:13:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20Phase=202=20=E2=80=94=20synthes?= =?UTF-8?q?ize=20indoor=20walkable=20plane=20from=20cell=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the indoor cell-BSP query returns OK (no wall collision), the player is standing on a floor poly inside the cell. Previously the code fell through to outdoor terrain (SampleTerrainWalkable + ValidateWalkable), which used the OUTDOOR terrain plane — below the indoor floor due to the +0.02f Z-bump applied for render z-fight prevention. ValidateWalkable saw the player 0.5m above the outdoor plane → marked them as airborne → walkable=False → falling animation, never recovers. Adds TryFindIndoorWalkablePlane (internal static for testability): scans the cell's resolved physics polys for a walkable floor poly (normal.Z >= 0.6664, walkable-slope threshold matching retail) under the player's XY, transforms its plane + vertices to world space via WorldTransform, and calls ValidateWalkable with the indoor plane. Adds PointInPolygonXY (ray-casting even-odd rule, ignores Z). Both are wired just after the BSP OK branch in FindEnvCollisions; outdoor terrain remains a defensive backstop if no floor poly is found under the player indoors (rare). Matches retail's CEnvCell::find_env_collisions behavior: no fall-through to terrain when the cell BSP successfully completes a query. Evidence: launch-phase2-verify5.log captured 12,141 walkable=False events during an indoor session where the player never managed to walk back outdoor through a door — they got stuck against the indoor wall and the resolver never re-established a walkable contact plane. Adds 13 unit tests in IndoorWalkablePlaneTests.cs covering: - player over floor poly (returns true, plane normal up, plane at correct Z) - player outside poly XY (returns false) - no walkable polys (returns false) - empty Resolved dict (returns false) - cell with world translation (plane + vertices in world space) - PointInPolygonXY cases (centre, near corner, on boundary, outside, Z ignored) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 119 +++++++++ .../Physics/IndoorWalkablePlaneTests.cs | 240 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 1deed49..4a3b8e8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1166,6 +1166,92 @@ public sealed class Transition // Environment collision — outdoor terrain // ----------------------------------------------------------------------- + /// + /// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor + /// polygon directly under within + /// . 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 GameWindow.BuildInteriorEntitiesForStreaming). + /// + /// + /// Iterates physics polygons; selects + /// the one with the most upward-facing normal (Z >= 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 ValidateWalkable call. + /// + /// + /// + /// Returns false if no walkable floor poly is found under the + /// player. The caller falls through to outdoor terrain in that case + /// (defensive backstop — should not normally happen inside a sealed cell). + /// + /// + internal static bool TryFindIndoorWalkablePlane( + CellPhysics cellPhysics, + Vector3 localFootCenter, + out System.Numerics.Plane worldPlane, + out Vector3[] worldVertices, + out uint hitPolyId) + { + worldPlane = default; + worldVertices = System.Array.Empty(); + hitPolyId = 0; + + foreach (var (id, poly) in cellPhysics.Resolved) + { + // 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; + + // Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule. + if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue; + + // 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); + + 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; + } + + return false; + } + + /// + /// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting + /// even-odd rule. Works for convex and concave polygons. + /// + 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; + } + /// /// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic. /// Indoor BSP collision is deferred to Task 6c. @@ -1255,6 +1341,39 @@ public sealed class Transition ci.CollidedWithEnvironment = true; return cellState; } + + // ── Synthesize indoor walkable contact plane ────────────── + // Indoor walking Phase 2 follow-up (2026-05-19). When the BSP + // returns OK (no wall collision), the player is standing on a + // floor poly inside the cell. We must NOT fall through to + // outdoor terrain (SampleTerrainWalkable) — the outdoor terrain + // Z is below the indoor floor due to the +0.02f Z-bump applied + // for render z-fight prevention. ValidateWalkable would then see + // the player 0.5m above the outdoor plane → marks them as + // airborne → walkable=False → falling animation, never recovers. + // + // 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, + out var indoorPlane, + out var indoorVertices, + out uint _)) + { + return ValidateWalkable( + footCenter, + sphereRadius, + indoorPlane, + isWater: false, + waterDepth: 0f, + cellId: sp.CheckCellId, + walkableVertices: indoorVertices); + } + // If no walkable floor was found under the player indoors + // (rare — cell with only walls/ceiling), fall through to + // outdoor terrain as a defensive backstop. Indoor walking + // will report walkable=False until the player moves over a + // cell with a proper floor poly. } } diff --git a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs new file mode 100644 index 0000000..1a455c6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for and +/// . +/// +/// Indoor walking Phase 2 follow-up (2026-05-19): these helpers synthesize +/// a walkable contact plane from cell floor polys so the resolver does not +/// fall through to outdoor terrain when the player is standing indoors. +/// +public class IndoorWalkablePlaneTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// 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. + /// + 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, + }; + + return new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floorPoly }, + }; + } + + // ----------------------------------------------------------------------- + // TryFindIndoorWalkablePlane + // ----------------------------------------------------------------------- + + [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 + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, + out var plane, out var verts, out uint polyId); + + Assert.True(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp() + { + var cell = BuildCellWithFloor(floorZ: 0f); + var localFoot = new Vector3(0f, 0f, 0.5f); + + Transition.TryFindIndoorWalkablePlane( + cell, localFoot, 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 localFoot = new Vector3(0f, 0f, floorZ + 0.5f); + + Transition.TryFindIndoorWalkablePlane( + cell, localFoot, 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(); + // XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes). + var localFoot = new Vector3(20f, 20f, 0.5f); + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_NoWalkablePolys_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, + }; + var cell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [1] = wallPoly }, + }; + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse() + { + var cell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.5f), 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 cell = new CellPhysics + { + WorldTransform = translation, + InverseWorldTransform = inv, + Resolved = new Dictionary { [0] = floorPoly }, + }; + + // The player's local foot is at (0,0,0.5) in local space. + var localFoot = new Vector3(0f, 0f, 0.5f); + + bool found = Transition.TryFindIndoorWalkablePlane( + cell, localFoot, 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}"); + } + + // ----------------------------------------------------------------------- + // 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); + } +}