From 91b29d1a89632b6c380d2f5d5348ea703af6effd Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 21:47:49 +0200 Subject: [PATCH] fix(physics): route indoor walkable-plane synthesis through retail BSP walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.Core/Physics/TransitionTypes.cs | 132 ++++++++------ .../Physics/IndoorWalkablePlaneTests.cs | 162 +++++++++--------- .../Physics/TransitionTypesTests.cs | 111 ++++++++++++ 3 files changed, 269 insertions(+), 136 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 4a3b8e8..7364ec4 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1167,20 +1167,16 @@ public sealed class Transition // ----------------------------------------------------------------------- /// - /// 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). + /// Synthesize the indoor walkable contact plane for the player's current + /// position when the cell BSP returns OK (no wall collision). /// /// - /// 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. + /// Routes through the retail-faithful BSP walkable-finder + /// () — 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). /// /// /// @@ -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). /// + /// + /// + /// 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). + /// /// - 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(); 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; } /// - /// 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 + /// 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. /// - 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; /// /// 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 _)) diff --git a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs index 1a455c6..466c5d0 100644 --- a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs +++ b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs @@ -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; /// -/// Unit tests for and -/// . +/// Unit tests for . /// -/// 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). /// public class IndoorWalkablePlaneTests { @@ -20,9 +25,27 @@ public class IndoorWalkablePlaneTests // Helpers // ----------------------------------------------------------------------- + /// + /// Build a BSP Leaf node that lists the given polygon ids, with a bounding + /// sphere large enough to always contain the test geometry. + /// + private static PhysicsBSPTree BuildLeafBsp(IEnumerable 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 }; + } + /// /// 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. /// private static CellPhysics BuildCellWithFloor(float floorZ = 0f) { @@ -44,11 +67,15 @@ public class IndoorWalkablePlaneTests SidesType = CullMode.None, }; + var resolved = new Dictionary { [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 { [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 { [1] = wallPoly }, + Resolved = new Dictionary(), }; + 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(), Vector3.Zero, 10f); var cell = new CellPhysics { + BSP = bsp, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), }; + 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 { [0] = floorPoly }; + var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f); + var cell = new CellPhysics { + BSP = bsp, WorldTransform = translation, InverseWorldTransform = inv, - Resolved = new Dictionary { [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); - } } diff --git a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs new file mode 100644 index 0000000..e4dd879 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs @@ -0,0 +1,111 @@ +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); + } + + /// + /// Build a minimal CellPhysics with two horizontal walkable polygons at + /// local Z=lowerZ and Z=upperZ. Identity world transform so world == local. + /// + 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 + { + [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, + }; + } +}