diff --git a/docs/navigation.md b/docs/navigation.md index ff6b3c37..43e1b7a5 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -188,7 +188,7 @@ Everything is on `host.Automation.Navigation`. A host without navigation answers | `GoTo(objectId, arrivalMeters)` | Walks to an object and ends beside it, facing it, within `arrivalMeters` (above 0, at most 50) at a spot with a line of sight to it. | | `GoTo(position, arrivalMeters)` | Walks to a place and ends on the floor that place stands on. Faces nothing on arrival. A position with no cell, such as one read from a route file, is placed by its map coordinates alone, and an elevation of NaN stands it on the ground there. | | `PreviewPathAsync(objectId, arrivalMeters)` | Plans a path to an object and returns ordered cell-aware positions with elevation. Does not start or replace a walk. | -| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. Does not start or replace a walk. | +| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. A place in a dungeon or building cell stands on the floor under it, so a dungeon map cell's `Center` can be given as it is. Does not start or replace a walk. | | `StandOn(objectId, arrivalMeters)` | Walks onto an object and ends on its top, jumping up where the character can. | | `Follow(playerId, bufferMeters)` | Follows a player until stopped: see [Following](#following). Only players can be followed. | | `StopGoTo()` | Ends the walk under way; `Rejected` when there is none. | @@ -320,7 +320,7 @@ Reports are snapshots, safe to read on any plugin tick. Check that `Sequence` is | Goal | Where the walk ends | |---|---| | An object (`go to`) | Beside it, within the arrival distance of its side, at a spot that can see that side, facing it. An object whose collision is in the grid, such as a life stone or a sign, hides its own middle, so its side is what counts. Failing that, the nearest spot that can see it, up to 10 m away, as `Arrived` with a reason. Failing that, the nearest spot a walk reaches, as `ArrivedWithoutSight`. | -| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. | +| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. A place in a dungeon or building cell is first stood on the floor under it: the cell's own floor nearest at or below it (or up to 0.5 m above it), or where the cell has none there, as in the upper half of a tall room authored as a cell on top of a cell, the nearest floor of any cell below it. Preview and walk place it the same way. | | A player (`follow`) | Never ends by itself: see [Following](#following). | | An object's top (`stand on`) | On the highest floor over the object's own collision that a body stands on, within the arrival distance of that top's middle. A ramp or stair up to a deck is not the deck. An object with nothing on top to stand on, such as a creature or a thin wall, or no way up, ends `NoRoute`. | diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs index e363fd9e..909fcd1d 100644 --- a/src/AcDream.Core/Physics/CellSurface.cs +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -98,6 +98,30 @@ public CellSurface( return null; } + /// + /// The height of the highest level face under a point that is no higher than + /// , or null where the cell has none: the floor a point + /// in the cell stands on, not the ceiling over it. + /// + public float? SampleLevelZAtOrBelow(float worldX, float worldY, float atMostZ) + { + float? highest = null; + foreach (var (a, b, c) in _triangles) + { + Vector3 normal = Vector3.Cross(b - a, c - a); + float length = normal.Length(); + if (length <= 1e-6f || MathF.Abs(normal.Z) / length < PhysicsGlobals.FloorZ) + continue; + if (PointInTriangleXY(worldX, worldY, a, b, c, out float z) + && z <= atMostZ + && (highest is null || z > highest)) + { + highest = z; + } + } + return highest; + } + private static bool PointInTriangleXY( float px, float py, diff --git a/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs b/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs index 3a896f2e..13a64780 100644 --- a/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs @@ -14,7 +14,10 @@ namespace AcDream.Plugin.Abstractions; /// One cell of a floorplan: where it is and which layer it was drawn in. /// /// The full cell id, landblock in the high half. -/// The middle of the cell's geometry, in landblock-local metres. +/// +/// The middle of the cell's geometry, in landblock-local metres. In a tall room it is well +/// above the floor; a walk or preview to it stands it on the floor under it. +/// /// The of the layer the cell is drawn in. public readonly record struct PluginDungeonCell(uint CellId, Vector3 Center, float LayerZ); diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs index 7081801c..b40c19bd 100644 --- a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -475,7 +475,9 @@ Task PreviewPathAsync(uint objectId, float arrivalMeters = /// /// Plans a path to a cell-aware position without moving or taking ownership of /// the character. Call from the thread that raises . - /// An elevation of NaN selects ground at the destination. + /// An elevation of NaN selects ground at the destination. A position in a dungeon or + /// building cell stands on the floor under it, so a + /// may be given as it is. /// Task PreviewPathAsync(PluginNavigationPosition position, float arrivalMeters = 2.5f) => Task.FromResult(new PluginNavigationPlan(PluginNavigationPlanStatus.Unavailable, [], "navigation is unavailable")); @@ -656,7 +658,9 @@ PluginNavigationCommandStatus GoTo(uint objectId, float arrivalMeters) => /// of on the floor that /// position stands on, and waiting while something else needs the character. A position /// on a rock top is arrived at on the rock, or not at all. An elevation of NaN stands the - /// position on the ground there, for map coordinates given without a height. It does not turn the character to face + /// position on the ground there, for map coordinates given without a height. A position in + /// a dungeon or building cell stands on the floor under it, the floor of the cell below + /// where its own cell has none there, as in the upper half of a tall room. It does not turn the character to face /// anything on arrival. A position with no cell, such as one read from a VTank route /// file, is placed by its map coordinates alone. reports the /// walk with no object id. diff --git a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs index 272f222d..e9c800b0 100644 --- a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs +++ b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs @@ -405,13 +405,54 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) return false; } AcDream.Core.Physics.Position here = controller.CellPosition; - position = OnTheGround( + position = OnTheCellFloor( _physics, - controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin), - controller.Position.Z); + cellId, + OnTheGround( + _physics, + controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin), + controller.Position.Z)); return true; } + /// + /// How far above a floor a place may be given and still stand on it, so a place given at + /// its floor's height, a little out, is not dropped to a floor below. + /// + internal const float PlaceAboveFloorMeters = 0.5f; + + /// + /// A place in an interior cell stood on the floor under it: the cell's own floor nearest at + /// or below the place, or where the cell has none there, as in the upper half of a tall room + /// authored as a cell on top of a cell, the nearest floor of any cell in its landblock. A + /// place in a cell is often given by the middle of the cell's bounds, which in a tall room + /// is metres above the floor, higher than a walk can arrive at. An outdoor place, or one with + /// no floor under it, is left as it is. + /// + internal static Vector3 OnTheCellFloor(PhysicsEngine physics, uint cellId, Vector3 point) + { + ArgumentNullException.ThrowIfNull(physics); + if ((cellId & 0xFFFFu) < 0x0100u || (cellId & 0xFFFFu) == 0xFFFFu || float.IsNaN(point.Z) + || !physics.TryGetLandblockCollision((cellId & 0xFFFF0000u) | 0xFFFFu, out _, out IReadOnlyList cells, out _)) + { + return point; + } + float atMost = point.Z + PlaceAboveFloorMeters; + float? own = null; + float? any = null; + foreach (CellSurface cell in cells) + { + if (cell.SampleLevelZAtOrBelow(point.X, point.Y, atMost) is not { } z) + continue; + if (cell.CellId == cellId) + own = own is null ? z : MathF.Max(own.Value, z); + any = any is null ? z : MathF.Max(any.Value, z); + } + return own is { } floor ? point with { Z = floor } + : any is { } below ? point with { Z = below } + : point; + } + /// /// A point whose height is not known stood on the terrain under it, or at /// where no terrain under it is loaded yet, as for a far goal diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs new file mode 100644 index 00000000..c1050edc --- /dev/null +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs @@ -0,0 +1,89 @@ +using System.Numerics; +using AcDream.Core.Navigation; +using AcDream.Core.Physics; +using AcDream.Runtime.Navigation; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Navigation; + +/// +/// Every cell of an installed dungeon routed to by the middle of its bounds, the place a +/// dungeon map gives for it, as it is given and as it stands on the floor under it. +/// +[Trait("Lane", "InstalledDat")] +[Trait("Purpose", "Diagnostic")] +public sealed class DungeonPlaceFloorInstalledDatTests +{ + private readonly ITestOutputHelper _output; + + public DungeonPlaceFloorInstalledDatTests(ITestOutputHelper output) => _output = output; + + /// + /// 0x5B48's wings are joined by more than its cells, so from its largest wing about 265 of + /// its cells route on the floor and few as given; 0x5948 routes nearly every cell. + /// + [Theory] + [InlineData(0x5B48FFFFu, 0x5B48013Cu)] + [InlineData(0x5948FFFFu, 0x59480100u)] + public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint startCell) + { + string? directory = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + Assert.False(string.IsNullOrWhiteSpace(directory), "Set ACDREAM_DAT_DIR to the installed game files."); + uint[] cellIds = [.. Enumerable.Range(0x100, 0x400).Select(offset => (dungeon & 0xFFFF0000u) | (uint)offset)]; + PublishedLandblock world = PublishedLandblock.Load(directory!, dungeon, cellIds); + Assert.True(world.Engine.TryGetLandblockCollision(dungeon, out _, out IReadOnlyList cells, out _)); + Assert.True(NavGeometry.TryMeasureCells(world.Engine, dungeon, out Vector2 minimum, out Vector2 maximum)); + float size = MathF.Ceiling((MathF.Max(maximum.X - minimum.X, maximum.Y - minimum.Y) + 48f) / 16f) * 16f; + float originX = MathF.Floor((((minimum.X + maximum.X) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize; + float originY = MathF.Floor((((minimum.Y + maximum.Y) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize; + NavGrid grid = NavGrid.Build(NavGeometry.CaptureDungeon(world.Engine, dungeon, originX, originY, size)!, world.Body); + + int startNode = grid.FindWalkableNode(world.CellOrigins[startCell], 1f, 1.5f); + Assert.True(startNode >= 0); + Vector3 start = grid.Position(startNode); + float arrival = NavigationWalkController.PlanningRadius(2.5f); + int asGiven = 0; + int onTheFloor = 0; + int tall = 0; + int floorless = 0; + var reasons = new Dictionary(); + var flooredReasons = new Dictionary(); + foreach (CellSurface cell in cells) + { + var low = new Vector3(float.PositiveInfinity); + var high = new Vector3(float.NegativeInfinity); + foreach ((Vector3 a, Vector3 b, Vector3 c) in cell.Triangles) + { + low = Vector3.Min(low, Vector3.Min(a, Vector3.Min(b, c))); + high = Vector3.Max(high, Vector3.Max(a, Vector3.Max(b, c))); + } + if (float.IsInfinity(low.X)) + continue; + Vector3 middle = (low + high) * 0.5f; + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world.Engine, cell.CellId, middle); + if (middle.Z - placed.Z > NavRouter.GoalHeightTolerance) + tall++; + if (cell.SampleLevelZAtOrBelow(middle.X, middle.Y, middle.Z) is null) + floorless++; + NavRoute raw = NavRouter.Find(grid, start, middle, arrival, arriveOnGoalFloor: true); + if (raw.Outcome == NavRouteOutcome.Routed) + asGiven++; + else + reasons[raw.Reason ?? "(none)"] = reasons.GetValueOrDefault(raw.Reason ?? "(none)") + 1; + NavRoute floored = NavRouter.Find(grid, start, placed, arrival, arriveOnGoalFloor: true); + if (floored.Outcome == NavRouteOutcome.Routed) + onTheFloor++; + else + flooredReasons[floored.Reason ?? "(none)"] = flooredReasons.GetValueOrDefault(floored.Reason ?? "(none)") + 1; + } + + _output.WriteLine($"0x{dungeon >> 16:X4}: {cells.Count} cells, {tall} with their middle higher over the floor than a walk arrives, {floorless} with no floor of their own under it"); + _output.WriteLine($" routed as given: {asGiven}; routed on the floor: {onTheFloor}"); + foreach ((string reason, int count) in reasons.OrderByDescending(pair => pair.Value).Take(5)) + _output.WriteLine($" {count} as given: {reason}"); + foreach ((string reason, int count) in flooredReasons.OrderByDescending(pair => pair.Value).Take(5)) + _output.WriteLine($" {count} on the floor: {reason}"); + Assert.True(onTheFloor > asGiven); + } + +} diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs new file mode 100644 index 00000000..7bf61a42 --- /dev/null +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs @@ -0,0 +1,175 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Navigation; +using static AcDream.App.Tests.Navigation.NavigationWalkControllerTests; + +namespace AcDream.App.Tests.Navigation; + +/// +/// A place inside a dungeon cell is often given by the middle of the cell's bounds, which in a +/// tall room is metres above its floor. Such a place stands on the floor under it. +/// +public sealed class DungeonPlaceFloorTests +{ + private const uint LowRoom = 0xA9B40100u; + private const uint TallRoom = 0xA9B40101u; + private const uint StackedLower = 0xA9B40102u; + private const uint StackedUpper = 0xA9B40103u; + private const float Floor = -30f; + + /// The middle of the 5 m room's bounds, 2.5 m over its floor, as a dungeon map gives it. + private static readonly Vector3 TallMiddle = new(30f, 10f, -27.5f); + + /// The middle of the floorless upper cell's bounds, 7.5 m over the floor of the cell below. + private static readonly Vector3 UpperMiddle = new(50f, 10f, -22.5f); + + /// + /// Why the fix is needed: a goal left in mid-air, higher over the floor than a walk can arrive + /// at, has no route at all, though the room is open to walk into. + /// + [Fact] + public async Task APlaceLeftInMidAirOverATallRoomsFloorHasNoRoute() + { + PluginNavigationPlan plan = await Preview(new Goals(), TallRoom, TallMiddle); + + Assert.Equal(PluginNavigationPlanStatus.NoRoute, plan.Status); + } + + [Fact] + public async Task APlaceAtTheMiddleOfATallRoomRoutesToItsFloor() + { + PluginNavigationPlan plan = await Preview(new Goals { Floors = DungeonWithTallRooms() }, TallRoom, TallMiddle); + + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.InRange(plan.Path[^1].Elevation * 240d, Floor - 0.1d, Floor + 0.1d); + } + + [Fact] + public async Task APlaceInAFloorlessUpperCellRoutesToTheFloorOfTheCellBelow() + { + PhysicsEngine world = DungeonWithTallRooms(); + + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world, StackedUpper, UpperMiddle); + PluginNavigationPlan plan = await Preview(new Goals { Floors = world }, StackedUpper, UpperMiddle); + + Assert.Equal(new Vector3(50f, 10f, Floor), placed); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.Equal(StackedLower, plan.Path[^1].CellId); + } + + [Fact] + public async Task APlaceInALowRoomIsUnchanged() + { + PhysicsEngine world = DungeonWithTallRooms(); + var onTheFloor = new Vector3(15f, 12f, Floor); + var middle = new Vector3(15f, 12f, -28.5f); + + PluginNavigationPlan plan = await Preview(new Goals { Floors = world }, LowRoom, middle); + + Assert.Equal(onTheFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, LowRoom, onTheFloor)); + Assert.Equal(onTheFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, LowRoom, middle)); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + } + + [Fact] + public void APlaceGivenALittleUnderItsFloorStaysOnThatFloor() + { + PhysicsEngine world = DungeonWithTallRooms(); + + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, new Vector3(30f, 10f, Floor - 0.2f)); + + Assert.Equal(Floor, placed.Z); + } + + [Theory] + [InlineData(0xA9B40001u)] + [InlineData(0xA9B4FFFFu)] + [InlineData(0u)] + public void AnOutdoorPlaceIsUnchanged(uint cellId) + { + PhysicsEngine world = DungeonWithTallRooms(); + var place = new Vector3(30f, 10f, -27.5f); + + Assert.Equal(place, RuntimeNavigationGoalSource.OnTheCellFloor(world, cellId, place)); + } + + [Fact] + public void APlaceWithNoFloorUnderItIsUnchanged() + { + PhysicsEngine world = DungeonWithTallRooms(); + var outside = new Vector3(100f, 100f, -27.5f); + var belowEveryFloor = new Vector3(30f, 10f, -40f); + + Assert.Equal(outside, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, outside)); + Assert.Equal(belowEveryFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, belowEveryFloor)); + } + + /// The walk to a place stands its goal where the preview does. + [Fact] + public void AWalkToAPlaceAtTheMiddleOfATallRoomArrives() + { + PhysicsEngine world = DungeonWithTallRooms(); + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(world, body, new Goals { Floors = world }, isSealedDungeon: _ => true); + + walk.WalkToPlace(TallRoom, TallMiddle, 2.5f); + NavigationWalkReport report = RunUntilSettled(walk, body); + + Assert.Equal(NavigationWalkState.Arrived, report.State); + Assert.InRange(Vector2.Distance(new Vector2(body.Position.X, body.Position.Y), new Vector2(30f, 10f)), 0f, 2.6f); + } + + private static Task Preview(Goals goals, uint cellId, Vector3 local) + { + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(goals.Floors ?? DungeonWithTallRooms(), body, goals, isSealedDungeon: _ => true); + return walk.PreviewPathAsync(cellId, local, 2.5f); + } + + /// + /// Three rooms in a row 30 m down: a low room 3 m high, a room 5 m high, and a room 10 m + /// high authored as a cell on top of a cell, the upper one with walls and a ceiling but + /// no floor. + /// + internal static PhysicsEngine DungeonWithTallRooms() + { + static CellSurface Cell(uint cellId, float x0, float x1, float? floor, float low, float top, bool roofed = true) + { + var vertices = new Dictionary(); + var polygons = new List>(); + void Add(params Vector3[] corners) + { + var ids = new List(); + foreach (Vector3 corner in corners) + { + ids.Add((short)vertices.Count); + vertices[(ushort)vertices.Count] = corner; + } + polygons.Add(ids); + } + if (floor is { } z) + Add(new(x0, 0f, z), new(x1, 0f, z), new(x1, 20f, z), new(x0, 20f, z)); + if (roofed) + Add(new(x0, 0f, top), new(x0, 20f, top), new(x1, 20f, top), new(x1, 0f, top)); + Add(new(x0, 0f, low), new(x1, 0f, low), new(x1, 0f, top), new(x0, 0f, top)); + Add(new(x0, 20f, low), new(x1, 20f, low), new(x1, 20f, top), new(x0, 20f, top)); + return new CellSurface(cellId, vertices, polygons); + } + + var physics = new PhysicsEngine(); + physics.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(new byte[81], new float[256]), + [ + Cell(LowRoom, 0f, 20f, Floor, Floor, -27f), + Cell(TallRoom, 20f, 40f, Floor, Floor, -25f), + Cell(StackedLower, 40f, 60f, Floor, Floor, -25f, roofed: false), + Cell(StackedUpper, 40f, 60f, null, -25f, -20f), + ], + [], + 0f, + 0f); + return physics; + } +} diff --git a/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs b/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs index edb724e5..f4ec9e4e 100644 --- a/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs +++ b/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs @@ -2222,7 +2222,7 @@ private static PhysicsEngine DungeonWorld(uint firstCell, float corridorEnd = 23 return physics; } - private static NavigationWalkReport RunUntilSettled(NavigationWalkController walk, SimulatedBody body, float seconds = 120f) => + internal static NavigationWalkReport RunUntilSettled(NavigationWalkController walk, SimulatedBody body, float seconds = 120f) => RunUntil( walk, body, @@ -2343,10 +2343,13 @@ public IReadOnlyList FindPortals(Vector3 around, float radius, uin [.. Portals.Select(RuntimeNavigationGoalSource.PortalFootprint)], goal); + /// When set, places in a cell stand on its floor as the client's goal source stands them. + public PhysicsEngine? Floors { get; init; } + /// Places stand where their landblock-local point says, the test world having one landblock at its origin. public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) { - position = local; + position = Floors is { } floors ? RuntimeNavigationGoalSource.OnTheCellFloor(floors, cellId, local) : local; return true; } diff --git a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs index 93e3d4b2..8dcb49e6 100644 --- a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs @@ -43,6 +43,24 @@ public void SampleFloorZ_InsideFlat_ReturnsZ() Assert.Equal(10f, z!.Value, precision: 2); } + [Fact] + public void SampleLevelZAtOrBelow_TakesTheFloorUnderThePointNotTheCeilingOverIt() + { + var vertices = new Dictionary + { + [0] = new(0f, 0f, 0f), [1] = new(10f, 0f, 0f), [2] = new(10f, 10f, 0f), [3] = new(0f, 10f, 0f), + [4] = new(0f, 0f, 5f), [5] = new(0f, 10f, 5f), [6] = new(10f, 10f, 5f), [7] = new(10f, 0f, 5f), + [8] = new(0f, 0f, 0f), [9] = new(10f, 0f, 0f), [10] = new(10f, 0f, 5f), [11] = new(0f, 0f, 5f), + }; + var surface = new CellSurface(0x0100, vertices, + [new() { 0, 1, 2, 3 }, new() { 4, 5, 6, 7 }, new() { 8, 9, 10, 11 }]); + + Assert.Equal(0f, surface.SampleLevelZAtOrBelow(5f, 5f, 2.5f)); + Assert.Equal(5f, surface.SampleLevelZAtOrBelow(5f, 5f, 6f)); + Assert.Null(surface.SampleLevelZAtOrBelow(5f, 5f, -1f)); + Assert.Null(surface.SampleLevelZAtOrBelow(20f, 5f, 2.5f)); + } + [Fact] public void SampleFloorZ_OutsideFloor_ReturnsNull() {