diff --git a/src/AcDream.Core/Selection/WorldPicker.cs b/src/AcDream.Core/Selection/WorldPicker.cs
index ae3f0cb..a0da64c 100644
--- a/src/AcDream.Core/Selection/WorldPicker.cs
+++ b/src/AcDream.Core/Selection/WorldPicker.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
@@ -52,4 +54,55 @@ public static class WorldPicker
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
+
+ ///
+ /// Ray-sphere intersection against each candidate's
+ /// using a fixed 5m sphere radius. Returns the
+ /// of the closest hit within , or null on miss.
+ ///
+ ///
+ /// Entities with ServerGuid == 0 (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
+ /// .
+ ///
+ public static uint? Pick(
+ Vector3 origin, Vector3 direction,
+ IEnumerable 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;
+ }
}
diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
index 5f55161..20550db 100644
--- a/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
+++ b/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
+using AcDream.Core.World;
using Xunit;
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)");
}
+
+ 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(),
+ };
+
+ [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);
+ }
}