Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance) to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips entities with ServerGuid==0 (atlas/dat-hydrated statics — no server identity) and the caller's skipServerGuid (the player self). Geometric ray-sphere intersection at 5m radius (matches WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid within maxDistance (50m default), or null on miss. 6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid, skip-zero-server-guid, beyond-max-distance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
4.5 KiB
C#
152 lines
4.5 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.World;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Selection;
|
|
|
|
public class WorldPickerTests
|
|
{
|
|
private const float Epsilon = 0.01f;
|
|
|
|
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
|
|
{
|
|
var view = Matrix4x4.Identity;
|
|
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
|
fieldOfView: MathF.PI / 3f,
|
|
aspectRatio: 16f / 9f,
|
|
nearPlaneDistance: 0.1f,
|
|
farPlaneDistance: 100f);
|
|
return (view, proj);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
|
|
{
|
|
var (view, proj) = MakeIdentityCamera();
|
|
const float vpW = 1920f, vpH = 1080f;
|
|
|
|
var (_, direction) = WorldPicker.BuildRay(
|
|
mouseX: vpW / 2f, mouseY: vpH / 2f,
|
|
viewportW: vpW, viewportH: vpH,
|
|
view, proj);
|
|
|
|
// Right-handed perspective + identity view -> camera looks down -Z.
|
|
// Center pixel ray = (0, 0, -1) within float epsilon.
|
|
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
|
|
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
|
|
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
|
|
{
|
|
var (view, proj) = MakeIdentityCamera();
|
|
const float vpW = 1920f, vpH = 1080f;
|
|
|
|
var (_, direction) = WorldPicker.BuildRay(
|
|
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
|
|
viewportW: vpW, viewportH: vpH,
|
|
view, proj);
|
|
|
|
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
|
|
}
|
|
|
|
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
|
{
|
|
Id = serverGuid == 0u ? 1u : serverGuid,
|
|
ServerGuid = serverGuid,
|
|
SourceGfxObjOrSetupId = 0u,
|
|
Position = position,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
};
|
|
|
|
[Fact]
|
|
public void Pick_RayThroughEntity_ReturnsServerGuid()
|
|
{
|
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: -Vector3.UnitZ,
|
|
candidates: new[] { entity },
|
|
skipServerGuid: 0u);
|
|
|
|
Assert.Equal(0xABCDu, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RayMisses_ReturnsNull()
|
|
{
|
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: Vector3.UnitX,
|
|
candidates: new[] { entity },
|
|
skipServerGuid: 0u);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_TwoEntitiesInLine_ReturnsCloser()
|
|
{
|
|
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
|
|
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: -Vector3.UnitZ,
|
|
candidates: new[] { far, near }, // iteration order shouldn't matter
|
|
skipServerGuid: 0u);
|
|
|
|
Assert.Equal(0x1111u, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_SkipsSkipGuid()
|
|
{
|
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: -Vector3.UnitZ,
|
|
candidates: new[] { entity },
|
|
skipServerGuid: 0xABCDu);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_SkipsZeroServerGuid()
|
|
{
|
|
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
|
|
// and aren't valid Use targets — server would reject guid=0.
|
|
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: -Vector3.UnitZ,
|
|
candidates: new[] { entity },
|
|
skipServerGuid: 0xDEADu);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_BeyondMaxDistance_ReturnsNull()
|
|
{
|
|
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
|
|
|
|
var result = WorldPicker.Pick(
|
|
origin: Vector3.Zero,
|
|
direction: -Vector3.UnitZ,
|
|
candidates: new[] { entity },
|
|
skipServerGuid: 0u); // default maxDistance = 50f
|
|
|
|
Assert.Null(result);
|
|
}
|
|
}
|