using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.World; using DatReaderWriter.Enums; using Xunit; namespace AcDream.Core.Tests.Selection; public class WorldPickerCellOcclusionTests { private static CellPhysics MakeWallAtY10() { // A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space // because WorldTransform = Identity). The occluder triangulates it as a fan: // tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10. var verts = new[] { new Vector3(-5, 10, -5), new Vector3( 5, 10, -5), new Vector3( 5, 10, 5), new Vector3(-5, 10, 5), }; var poly = new ResolvedPolygon { Vertices = verts, Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f), NumPoints = 4, SidesType = CullMode.None, }; return new CellPhysics { BSP = null, Resolved = new() { [0] = poly }, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, }; } private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new() { Id = guid, ServerGuid = guid, SourceGfxObjOrSetupId = 0, Position = pos, Rotation = Quaternion.Identity, 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() { var wall = MakeWallAtY10(); var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10 var result = WorldPicker.Pick( origin: Vector3.Zero, direction: Vector3.UnitY, candidates: new[] { entity }, skipServerGuid: 0u, cellOccluder: (origin, direction) => CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall })); Assert.Null(result); } [Fact] public void Pick_RaySphere_NoWall_HitsEntity() { var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); var result = WorldPicker.Pick( origin: Vector3.Zero, direction: Vector3.UnitY, candidates: new[] { entity }, skipServerGuid: 0u, cellOccluder: null); // null occluder = no occlusion Assert.Equal(0xABCDu, result); } }