Phase B's WorldPicker change added cellOccluder to both Pick overloads, but the integration test suite only covered the legacy ray-sphere overload. The production code path (GameWindow.PickAndStoreSelection) uses the screen-rect overload, and its clip.W depth-conversion math had no direct test. Adds two integration tests mirroring the existing ray-sphere variants: - Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp — entity dead- ahead, wall between, with cellOccluder → null. - Pick_ScreenRect_NoWall_HitsEntity — same scene, null occluder → hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.9 KiB
C#
189 lines
6.9 KiB
C#
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<MeshRef>(),
|
||
};
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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
|
||
// ──────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
}
|