acdream/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs
Erik 3764867566 fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
WorldPicker.Pick previously had no occlusion test — any entity along
the click ray within maxDistance was a candidate, including ones
behind walls. Adds the CellBspRayOccluder static helper that
Möller-Trumbore-tests the click ray against every polygon in every
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
Both Pick overloads gate candidate selection by that wall-t (legacy
ray-sphere via world-space `t`, screen-rect via camera-space clip.W
depth — matching ScreenProjection.TryProjectSphereToScreenRect's
convention).

PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
caller can iterate without needing the private cache dictionary.
CellPhysics.BSP/PhysicsPolygons/Vertices relaxed from required to
nullable so test fixtures can construct a CellPhysics from Resolved
alone without a real DAT BSP object. GameWindow snapshots the loaded
cell physics on each Pick call and passes the occluder callback.

Closes #86.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:41:56 +02:00

81 lines
2.4 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>(),
};
[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);
}
}