diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs index af80446..91151db 100644 --- a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs +++ b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs @@ -47,6 +47,114 @@ public class WorldPickerCellOcclusionTests MeshRefs = System.Array.Empty(), }; + /// + /// Builds a quad wall at Z=-10 in front of the camera (identity view, + /// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 — + /// large enough to cover the center-pixel ray. An entity at Z=-20 sits + /// behind it. + /// + /// Wall normal direction doesn't affect Möller-Trumbore (the occluder + /// is two-sided), but the Plane is stored for completeness. For a plane + /// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0 + /// → D = 10. + /// + private static CellPhysics MakeWallAtZNeg10() + { + var verts = new[] + { + new Vector3(-5, -5, -10), + new Vector3( 5, -5, -10), + new Vector3( 5, 5, -10), + new Vector3(-5, 5, -10), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f), + NumPoints = 4, + SidesType = CullMode.None, + }; + return new CellPhysics + { + BSP = null, + Resolved = new() { [0] = poly }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + } + + // ────────────────────────────────────────────── + // Screen-rect overload + cell-BSP occlusion + // ────────────────────────────────────────────── + + /// + /// Production path exercised by GameWindow.PickAndStoreSelection. + /// Camera at origin looking down -Z (identity view). Entity at Z=-20 + /// projects to the center of the viewport. A wall at Z=-10 sits between + /// camera and entity; with cellOccluder wired up the entity must be + /// occluded → null result. + /// + /// This test specifically covers the clip.W depth-conversion math in + /// WorldPicker.Pick's screen-rect overload (issue #86). + /// + [Fact] + public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp() + { + // Use the same camera convention as WorldPickerRectOverloadTests.StdCam(): + // identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300). + var view = Matrix4x4.Identity; + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f); + var viewport = new Vector2(800f, 600f); + + var wall = MakeWallAtZNeg10(); + var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20)); + + // Entity is dead-ahead: center of viewport. + var result = WorldPicker.Pick( + mouseX: 400f, mouseY: 300f, + view, proj, viewport, + candidates: new[] { entity }, + skipServerGuid: 0u, + sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f), + inflatePixels: 8f, + cellOccluder: (origin, direction) => + CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall })); + + Assert.Null(result); + } + + /// + /// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp, + /// but with a null cellOccluder. Verifies that the no-occluder path still + /// resolves the entity to a hit (the new parameter is a pure no-op when null). + /// + [Fact] + public void Pick_ScreenRect_NoWall_HitsEntity() + { + var view = Matrix4x4.Identity; + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f); + var viewport = new Vector2(800f, 600f); + + var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20)); + + var result = WorldPicker.Pick( + mouseX: 400f, mouseY: 300f, + view, proj, viewport, + candidates: new[] { entity }, + skipServerGuid: 0u, + sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f), + inflatePixels: 8f, + cellOccluder: null); + + Assert.Equal(0xABCDu, result); + } + + // ────────────────────────────────────────────── + // Ray-sphere overload (legacy path) + // ────────────────────────────────────────────── + [Fact] public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp() {