diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs index ef3824b5b0..2ffde0d3a3 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs @@ -3,8 +3,10 @@ #nullable disable +using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -72,48 +74,119 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void moveMouse(Vector2 pos) => AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos))); } + [TestFixture] public class TestSliderNearLinearScaling { + private readonly Random rng = new Random(1337); + [Test] public void TestScalingSliderFlat() { - Slider sliderPerfect = new Slider - { - Position = new Vector2(300), - Path = new SliderPath( - [ - new PathControlPoint(new Vector2(0), PathType.PERFECT_CURVE), - new PathControlPoint(new Vector2(50, 25)), - new PathControlPoint(new Vector2(25, 100)), - ]) - }; + SliderPath sliderPathPerfect = new SliderPath( + [ + new PathControlPoint(new Vector2(0), PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(50, 25)), + new PathControlPoint(new Vector2(25, 100)), + ]); - Slider sliderBezier = new Slider - { - Position = new Vector2(300), - Path = new SliderPath( - [ - new PathControlPoint(new Vector2(0), PathType.BEZIER), - new PathControlPoint(new Vector2(50, 25)), - new PathControlPoint(new Vector2(25, 100)), - ]) - }; + SliderPath sliderPathBezier = new SliderPath( + [ + new PathControlPoint(new Vector2(0), PathType.BEZIER), + new PathControlPoint(new Vector2(50, 25)), + new PathControlPoint(new Vector2(25, 100)), + ]); - scaleSlider(sliderPerfect, new Vector2(0.000001f, 1)); - scaleSlider(sliderBezier, new Vector2(0.000001f, 1)); + scaleSlider(sliderPathPerfect, new Vector2(0.000001f, 1)); + scaleSlider(sliderPathBezier, new Vector2(0.000001f, 1)); for (int i = 0; i < 100; i++) { - Assert.True(Precision.AlmostEquals(sliderPerfect.Path.PositionAt(i / 100.0f), sliderBezier.Path.PositionAt(i / 100.0f))); + Assert.True(Precision.AlmostEquals(sliderPathPerfect.PositionAt(i / 100.0f), sliderPathBezier.PositionAt(i / 100.0f))); } } - private void scaleSlider(Slider slider, Vector2 scale) + [Test] + public void TestPerfectCurveMatchesTheoretical() { - for (int i = 0; i < slider.Path.ControlPoints.Count; i++) + for (int i = 0; i < 20000; i++) { - slider.Path.ControlPoints[i].Position *= scale; + //Only test points that are in the screen's bounds + float p1X = 640.0f * (float)rng.NextDouble(); + float p2X = 640.0f * (float)rng.NextDouble(); + + float p1Y = 480.0f * (float)rng.NextDouble(); + float p2Y = 480.0f * (float)rng.NextDouble(); + SliderPath sliderPathPerfect = new SliderPath( + [ + new PathControlPoint(new Vector2(0, 0), PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(p1X, p1Y)), + new PathControlPoint(new Vector2(p2X, p2Y)), + ]); + + assertMatchesPerfectCircle(sliderPathPerfect); + + scaleSlider(sliderPathPerfect, new Vector2(0.00001f, 1)); + + assertMatchesPerfectCircle(sliderPathPerfect); + } + } + + private void assertMatchesPerfectCircle(SliderPath path) + { + if (path.ControlPoints.Count != 3) + return; + + //Replication of PathApproximator.CircularArcToPiecewiseLinear + CircularArcProperties circularArcProperties = new CircularArcProperties(path.ControlPoints.Select(x => x.Position).ToArray()); + + if (!circularArcProperties.IsValid) + return; + + //Addresses cases where circularArcProperties.ThetaRange>0.5 + //Occurs in code in PathControlPointVisualiser.ensureValidPathType + RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(path.ControlPoints.Select(x => x.Position).ToArray()); + if (boundingBox.Width >= 640 || boundingBox.Height >= 480) + return; + + int subpoints = (2f * circularArcProperties.Radius <= 0.1f) ? 2 : Math.Max(2, (int)Math.Ceiling(circularArcProperties.ThetaRange / (2.0 * Math.Acos(1f - 0.1f / circularArcProperties.Radius)))); + + //ignore cases where subpoints is int.MaxValue, result will be garbage + //as well, having this many subpoints will cause an out of memory error, so can't happen during normal useage + if (subpoints == int.MaxValue) + return; + + for (int i = 0; i < Math.Min(subpoints, 100); i++) + { + float progress = (float)rng.NextDouble(); + + //To avoid errors from interpolating points, ensure we check only positions that would be subpoints. + progress = (float)Math.Ceiling(progress * (subpoints - 1)) / (subpoints - 1); + + //Special case - if few subpoints, ensure checking every single one rather than randomly + if (subpoints < 100) + progress = i / (float)(subpoints - 1); + + //edge points cause issue with interpolation, so ignore the last two points and first + if (progress == 0.0f || progress >= (subpoints - 2) / (float)(subpoints - 1)) + continue; + + double theta = circularArcProperties.ThetaStart + circularArcProperties.Direction * progress * circularArcProperties.ThetaRange; + Vector2 vector = new Vector2((float)Math.Cos(theta), (float)Math.Sin(theta)) * circularArcProperties.Radius; + + Assert.True(Precision.AlmostEquals(circularArcProperties.Centre + vector, path.PositionAt(progress), 0.01f), + "A perfect circle with points " + string.Join(", ", path.ControlPoints.Select(x => x.Position)) + " and radius" + circularArcProperties.Radius + "from SliderPath does not almost equal a theoretical perfect circle with " + subpoints + " subpoints" + + ": " + (circularArcProperties.Centre + vector) + " - " + path.PositionAt(progress) + + " = " + (circularArcProperties.Centre + vector - path.PositionAt(progress)) + ); + } + } + + private void scaleSlider(SliderPath path, Vector2 scale) + { + for (int i = 0; i < path.ControlPoints.Count; i++) + { + path.ControlPoints[i].Position *= scale; } } }