acdream/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
Erik 5821bdc9ea fix(B.4b): WorldPicker.Pick — handle inside-sphere origin + document normalize contract
Code review flagged two latent correctness bugs in Pick:

1. The single t = -b - sqrt(d) intersection skipped entities whose
   5m bounding sphere contained the ray origin. Realistic at
   point-blank range — if the player stands within ~5m of a door,
   the near-plane sits inside the door's bounding sphere and the
   door becomes unpickable. Standard fix: when t_near < 0 fall
   through to t_far = -b + sqrt(d) (the sphere exit point).

2. The discriminant formula assumes |direction| = 1. BuildRay
   currently normalizes so the assumption holds at the wire, but
   the contract wasn't documented. Added an explicit
   <param name="direction"> note.

New test Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid
covers the inside-sphere case. Suite: 9/9 WorldPicker tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:52:45 +02:00

169 lines
5.1 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);
}
[Fact]
public void Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid()
{
// Player ~3m from a door -> camera near-plane sits INSIDE the door's
// 5m bounding sphere. Naive t_near < 0 guard would skip; correct
// behavior is to fall through to t_far (the sphere exit point).
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -3));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
}