using OpenNest.Engine.BestFit; using OpenNest.Geometry; namespace OpenNest.Tests.Geometry; public class SlideContactTests { public static IEnumerable LinePaths() { foreach (var path in new[] { "axis", "offset", "edges", "vector", "entities", "cpu-lines", "cpu-entities" }) foreach (var reverse in new[] { false, true }) yield return new object[] { path, reverse }; } [Theory] [MemberData(nameof(LinePaths))] public void TouchingRectangles_LeaveOrSlideButCannotEnter(string path, bool reverse) { var stationary = Rect(0, 0, 2, 2); var moving = Rect(2, 0, 2, 2); if (reverse) { Reverse(stationary); Reverse(moving); } Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left)); Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Right)); Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Up)); Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Down)); } [Theory] [MemberData(nameof(LinePaths))] public void SlidingContact_StillStopsAtLaterHookOnSameObstacle(string path, bool reverse) { var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)); var moving = Rect(2, 0, 1, 1); if (reverse) { Reverse(stationary); Reverse(moving); } Assert.Equal(3, Distance(path, moving, stationary, PushDirection.Up), 9); } [Theory] [MemberData(nameof(LinePaths))] public void HoleContact_LeavingWallStillStopsAtOppositeWall(string path, bool reverse) { var stationary = Rect(0, 0, 10, 10); stationary.AddRange(Rect(2, 2, 6, 6)); // depth, not winding, defines the hole var moving = Rect(2, 3, 1, 1); if (reverse) { Reverse(stationary); Reverse(moving); } Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left)); Assert.Equal(5, Distance(path, moving, stationary, PushDirection.Right), 9); Assert.Equal(4, Distance(path, moving, stationary, PushDirection.Up), 9); } [Theory] [InlineData(false, 0)] [InlineData(true, 0)] [InlineData(false, 0.37)] [InlineData(true, 0.37)] public void RotatedHook_StopsAtFirstBlockingContact(bool cpu, double angle) { var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)).Cast().ToList(); var moving = Rect(2, 0, 1, 1).Cast().ToList(); foreach (var entity in stationary.Concat(moving)) { entity.Rotate(angle); entity.Offset(17, -23); } Assert.Equal(3, EntityDistance(cpu, moving, stationary, new Vector(0, 1).Rotate(angle)), 8); } [Theory] [InlineData(false)] [InlineData(true)] public void Circles_TangentEscapeAndEnteringContact(bool cpu) { var moving = new List { new Circle(2, 0, 1) }; var stationary = new List { new Circle(0, 0, 1) }; Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(-1, 0))); Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(0, 1))); } [Theory] [InlineData(false)] [InlineData(true)] public void CircleInsideHole_TangentBlocksButDepartureFindsFarSide(bool cpu) { var moving = new List { new Circle(3, 0, 1) }; var stationary = new List { new Circle(0, 0, 6), new Circle(0, 0, 4) }; Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(0, 1))); Assert.Equal(6, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9); } [Theory] [InlineData(false)] [InlineData(true)] public void PositiveGrazingContact_DoesNotHideLaterCircle(bool cpu) { var moving = new List { new Circle(0, 0, 1) }; var stationary = new List { new Circle(4, 2, 1), new Circle(10, 0, 1) }; Assert.Equal(8, EntityDistance(cpu, moving, stationary, new Vector(1, 0)), 9); } [Fact] public void ReusedEdgeArrays_KeepTopologyAfterPreviousQuerySortedThem() { var moving = Rect(2, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray(); var stationary = Rect(0, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray(); Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Left)); Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Right)); Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Up)); } [Theory] [InlineData(false)] [InlineData(true)] public void FullCircleArcSeam_AllowsSeparation(bool cpu) { var moving = new List { new Arc(0, 0, 1, 0, 2 * System.Math.PI) }; var stationary = new List { new Circle(2, 0, 1) }; Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(-1, 0))); } [Theory] [InlineData(false)] [InlineData(true)] public void ConcaveCorner_DoesNotBlockSlidingAlongItsStraightSide(bool cpu) { var moving = Rect(3, -1, 2, 1).Cast().ToList(); var stationary = new List { new Arc(0, 0, 5, System.Math.PI / 2, 0, true), new Line(5, 0, 10, 0), new Arc(0, 0, 10, 0, System.Math.PI / 2), new Line(0, 10, 0, 5), }; Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); } [Theory] [InlineData(false)] [InlineData(true)] public void ThinRing_ArcSeamDoesNotChangeHoleClassification(bool cpu) { var a = System.Math.PI / 72; var moving = new List { new Circle(8.995, 0, 1) }; var stationary = new List { new Circle(0, 0, 10), new Arc(0, 0, 9.995, a, a + System.Math.PI), new Arc(0, 0, 9.995, a + System.Math.PI, a + 2 * System.Math.PI), }; Assert.Equal(17.99, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9); } [Theory] [InlineData(false)] [InlineData(true)] public void CircleAgainstInclinedWall_UsesInteriorCurveContact(bool cpu) { var moving = new List { new Circle(0, 0, 1) }; var stationary = Loop((-10, 0), (10, 10), (10, 12), (-10, 2)).Cast().ToList(); Assert.Equal(5 - System.Math.Sqrt(1.25), EntityDistance(cpu, moving, stationary, new Vector(0, 1)), 9); } [Fact] public void GpuAdapter_ArbitraryDirectionUsesExactCpuFallback() { using var axesOnly = new RejectSlideComputer(); var distance = new GpuDistanceComputer(axesOnly).ComputeDistances(Rect(10, 10, 2, 2), Rect(0, 0, 2, 2), new[] { new SlideOffset(0, 0, 0.6, 0.8) })[0]; Assert.Equal(8 / 0.6, distance, 9); } private sealed class RejectSlideComputer : ISlideComputer { public double[] ComputeBatch(double[] s, int sc, double[] m, int mc, double[] o, int oc, PushDirection d) => throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU"); public double[] ComputeBatchMultiDir(double[] s, int sc, double[] m, int mc, double[] o, int oc, int[] d) => throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU"); public void Dispose() { } } [Fact] public void OpenBoundaries_RemainConservative() { var moving = new List { new Line(2, 0, 2, 2) }; var stationary = new List { new Line(2, 0, 2, 2) }; Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, stationary, PushDirection.Right)); } private static double EntityDistance(bool cpu, List moving, List stationary, Vector direction) => cpu ? new CpuDistanceComputer().ComputeDistances(stationary, moving, new[] { new SlideOffset(0, 0, direction.X, direction.Y) })[0] : SpatialQuery.DirectionalDistance(moving, stationary, direction); private static double Distance(string path, List moving, List stationary, PushDirection direction) { var unit = SpatialQuery.DirectionToOffset(direction, 1); // A nonzero template origin catches mixed local/world contact coordinates. var origin = new Vector(13, -7); var local = moving.Select(l => new Line(l.StartPoint - origin, l.EndPoint - origin)).ToList(); return path switch { "axis" => SpatialQuery.DirectionalDistance(moving, stationary, direction), "offset" => SpatialQuery.DirectionalDistance(local, origin.X, origin.Y, stationary, direction), "edges" => SpatialQuery.DirectionalDistance(local.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), origin, stationary.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), Vector.Zero, direction), "vector" => SpatialQuery.DirectionalDistance(moving, stationary, unit), "entities" => SpatialQuery.DirectionalDistance(moving.Cast().ToList(), stationary.Cast().ToList(), unit), "cpu-lines" => new CpuDistanceComputer().ComputeDistances(stationary, local, new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0], "cpu-entities" => new CpuDistanceComputer().ComputeDistances(stationary.Cast().ToList(), local.Cast().ToList(), new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0], _ => throw new ArgumentOutOfRangeException(nameof(path)), }; } private static void Reverse(List lines) { lines.Reverse(); foreach (var line in lines) line.Reverse(); } private static List Rect(double x, double y, double w, double h) => Loop((x, y), (x + w, y), (x + w, y + h), (x, y + h)); private static List Loop(params (double X, double Y)[] points) => points.Select((p, i) => new Line(p.X, p.Y, points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList(); }