acdream/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs
Erik 4e308d567a test(picker): Cluster A #86 — screen-rect cell-occlusion tests
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>
2026-05-19 14:56:51 +02:00

189 lines
6.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}