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); + } }