feat(selection): port retail polygon picking and vivid marker
Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks. Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp. Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0f82a08f0a
commit
146a963aeb
26 changed files with 1302 additions and 1340 deletions
|
|
@ -1,86 +0,0 @@
|
|||
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}");
|
||||
}
|
||||
}
|
||||
173
tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
Normal file
173
tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class RetailWorldPickerTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRayStartsAtCameraViewpointRatherThanNearPlane()
|
||||
{
|
||||
var eye = new Vector3(10f, 20f, 30f);
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(eye, eye - Vector3.UnitZ, Vector3.UnitY);
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
|
||||
|
||||
var ray = WorldPicker.BuildRay(400f, 300f, 800f, 600f, view, projection);
|
||||
|
||||
Assert.True(Vector3.Distance(eye, ray.Origin) < 0.0001f);
|
||||
Assert.True(Vector3.Distance(-Vector3.UnitZ, ray.Direction) < 0.0001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolygonHitGloballyBeatsNearerSphereFallback()
|
||||
{
|
||||
var sphereOnly = Part(1u, z: -3f, polygons: []);
|
||||
var polygon = Part(2u, z: -8f, polygons: [SquareAtLocalZ(0f)]);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [sphereOnly, polygon]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(2u, hit.Value.ServerGuid);
|
||||
Assert.True(hit.Value.PolygonHit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereIsUsedOnlyWhenNoVisiblePolygonHits()
|
||||
{
|
||||
var part = Part(0xABCDu, z: -5f, polygons: []);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [part]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(0xABCDu, hit.Value.ServerGuid);
|
||||
Assert.False(hit.Value.PolygonHit);
|
||||
Assert.Equal(3d, hit.Value.Distance, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopsAtFirstHitPolygonInDatOrderForEachPart()
|
||||
{
|
||||
var datOrdered = Part(
|
||||
10u,
|
||||
z: 0f,
|
||||
polygons: [SquareAtLocalZ(-10f), SquareAtLocalZ(-5f)],
|
||||
sphereCenter: new Vector3(0f, 0f, -7.5f),
|
||||
sphereRadius: 5f);
|
||||
var competitor = Part(20u, z: -7f, polygons: [SquareAtLocalZ(0f)]);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [datOrdered, competitor]);
|
||||
|
||||
// Scanning all polygons would incorrectly give guid 10 at 5 m. Retail
|
||||
// accepts its first DAT-order hit at 10 m, so guid 20 wins at 7 m.
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(20u, hit.Value.ServerGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleSidedPolygonRejectsItsBackFace()
|
||||
{
|
||||
var front = new RetailSelectionPart(
|
||||
1u,
|
||||
0,
|
||||
Matrix4x4.Identity,
|
||||
new RetailSelectionMesh(
|
||||
new Vector3(0f, 0f, -5f), 2f,
|
||||
[SquareAtLocalZ(-5f, singleSided: true)]));
|
||||
|
||||
RetailSelectionHit? backHit = RetailWorldPicker.Pick(
|
||||
new Vector3(0f, 0f, -10f), Vector3.UnitZ, [front]);
|
||||
|
||||
Assert.NotNull(backHit); // drawing sphere remains the retail fallback
|
||||
Assert.False(backHit.Value.PolygonHit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InverseScaledPartPreservesWorldDistance()
|
||||
{
|
||||
var transform = Matrix4x4.CreateScale(2f)
|
||||
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
|
||||
var part = new RetailSelectionPart(
|
||||
3u,
|
||||
0,
|
||||
transform,
|
||||
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [part]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.True(hit.Value.PolygonHit);
|
||||
Assert.Equal(10d, hit.Value.Distance, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipGuidAndNonServerPartsNeverCompete()
|
||||
{
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero,
|
||||
-Vector3.UnitZ,
|
||||
[Part(0u, -2f, [SquareAtLocalZ(0f)]), Part(7u, -4f, [SquareAtLocalZ(0f)])],
|
||||
skipServerGuid: 7u);
|
||||
|
||||
Assert.Null(hit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawingSphereRejectsRayWhichBeginsInsideLikeRetail()
|
||||
{
|
||||
bool hit = RetailWorldPicker.TryIntersectSphere(
|
||||
Vector3.Zero, Vector3.UnitX, Vector3.Zero, 2f, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvexEdgeTestRejectsAConcaveNotchRatherThanUsingEvenOddFill()
|
||||
{
|
||||
var concave = new RetailSelectionPolygon(
|
||||
[
|
||||
new(-2f, -2f, -5f),
|
||||
new( 2f, -2f, -5f),
|
||||
new( 2f, 2f, -5f),
|
||||
new( 0f, 0f, -5f),
|
||||
new(-2f, 2f, -5f),
|
||||
],
|
||||
SingleSided: false);
|
||||
|
||||
bool hit = RetailWorldPicker.TryIntersectPolygon(
|
||||
new Vector3(0f, 1f, 0f), -Vector3.UnitZ, concave, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
private static RetailSelectionPart Part(
|
||||
uint guid,
|
||||
float z,
|
||||
IReadOnlyList<RetailSelectionPolygon> polygons,
|
||||
Vector3? sphereCenter = null,
|
||||
float sphereRadius = 2f)
|
||||
=> new(
|
||||
guid,
|
||||
0,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, z),
|
||||
new RetailSelectionMesh(
|
||||
sphereCenter ?? Vector3.Zero,
|
||||
sphereRadius,
|
||||
polygons));
|
||||
|
||||
private static RetailSelectionPolygon SquareAtLocalZ(
|
||||
float z,
|
||||
bool singleSided = false)
|
||||
=> new(
|
||||
[
|
||||
new Vector3(-1f, -1f, z),
|
||||
new Vector3( 1f, -1f, z),
|
||||
new Vector3( 1f, 1f, z),
|
||||
new Vector3(-1f, 1f, z),
|
||||
],
|
||||
singleSided);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class WorldPickerRectOverloadTests
|
||||
{
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
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_RectHitTest_ReturnsHitWhenMouseInsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 50f, mouseY: 50f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
|
||||
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { far, near } /* deliberately reversed */,
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_NullResolverSkipsCandidates()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e1 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e1, e2 },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => x.ServerGuid == 0x10001u
|
||||
? ((Vector3, float)?)null
|
||||
: ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_RespectsSkipServerGuid()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
|
||||
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { player, npc },
|
||||
skipServerGuid: 0x5000000Au,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_InflateExpandsClickableArea()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
|
||||
uint? withoutInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(withoutInflate);
|
||||
|
||||
uint? withInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 250f);
|
||||
Assert.Equal(0x10001u, withInflate);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue