feat(B.4b): WorldPicker.Pick — ray-sphere entity pick
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>
This commit is contained in:
parent
f0b3bd9aa2
commit
221b64186d
2 changed files with 151 additions and 0 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
|
@ -52,4 +54,55 @@ public static class WorldPicker
|
||||||
return (Vector3.Zero, Vector3.Zero);
|
return (Vector3.Zero, Vector3.Zero);
|
||||||
return (nearWorld, Vector3.Normalize(dir));
|
return (nearWorld, Vector3.Normalize(dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
|
||||||
|
/// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
|
||||||
|
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
|
||||||
|
/// statics) are skipped — they have no server-side identity and can't be
|
||||||
|
/// the target of a Use packet. The player's own guid is skipped via
|
||||||
|
/// <paramref name="skipServerGuid"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static uint? Pick(
|
||||||
|
Vector3 origin, Vector3 direction,
|
||||||
|
IEnumerable<WorldEntity> candidates,
|
||||||
|
uint skipServerGuid,
|
||||||
|
float maxDistance = 50f)
|
||||||
|
{
|
||||||
|
const float Radius = 5f;
|
||||||
|
const float Radius2 = Radius * Radius;
|
||||||
|
|
||||||
|
if (direction.LengthSquared() < 1e-10f) return null;
|
||||||
|
|
||||||
|
uint? bestGuid = null;
|
||||||
|
float bestT = float.PositiveInfinity;
|
||||||
|
foreach (var entity in candidates)
|
||||||
|
{
|
||||||
|
if (entity.ServerGuid == 0u) continue;
|
||||||
|
if (entity.ServerGuid == skipServerGuid) continue;
|
||||||
|
|
||||||
|
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
|
||||||
|
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
|
||||||
|
// the ray misses the sphere. Otherwise nearest intersection is
|
||||||
|
// t = -b - sqrt(discriminant).
|
||||||
|
var oc = origin - entity.Position;
|
||||||
|
float b = Vector3.Dot(oc, direction);
|
||||||
|
float c = Vector3.Dot(oc, oc) - Radius2;
|
||||||
|
float d = b * b - c;
|
||||||
|
if (d < 0f) continue;
|
||||||
|
|
||||||
|
float t = -b - MathF.Sqrt(d);
|
||||||
|
if (t < 0f) continue; // ray points away or origin inside
|
||||||
|
if (t >= maxDistance) continue;
|
||||||
|
if (t < bestT)
|
||||||
|
{
|
||||||
|
bestT = t;
|
||||||
|
bestGuid = entity.ServerGuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestGuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Core.World;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Selection;
|
namespace AcDream.Core.Tests.Selection;
|
||||||
|
|
@ -51,4 +52,101 @@ public class WorldPickerTests
|
||||||
|
|
||||||
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue