acdream/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.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

86 lines
3 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class CellBspRayOccluderTests
{
// Build a CellPhysics with a single triangular poly at world-Y=10.
// Triangle vertices in local space, world transform = identity.
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
private static CellPhysics MakeWallCell()
{
var verts = new[]
{
new Vector3(-5, 10, 0),
new Vector3( 5, 10, 0),
new Vector3( 0, 10, 5),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
NumPoints = 3,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
[Fact]
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
}
[Fact]
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = -Vector3.UnitY; // travels AWAY from the wall
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
}
[Fact]
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
{
var origin = Vector3.Zero;
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
Assert.True(float.IsPositiveInfinity(t));
}
[Fact]
public void NearestWallT_TwoCells_ReturnsNearer()
{
var nearCell = MakeWallCell(); // wall at Y=10
var farCell = MakeWallCell();
// Move farCell's transform to push it to Y=20.
farCell = new CellPhysics
{
BSP = null,
Resolved = nearCell.Resolved,
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
};
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
}
}