diff --git a/osu.Game.Benchmarks/BenchmarkGeometryUtils.cs b/osu.Game.Benchmarks/BenchmarkGeometryUtils.cs new file mode 100644 index 0000000000..2ab4d3369a --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkGeometryUtils.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using osu.Framework.Utils; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkGeometryUtils : BenchmarkTest + { + [Params(100, 1000, 2000, 4000, 8000, 10000)] + public int N; + + private Vector2[] points = null!; + + public override void SetUp() + { + points = new Vector2[N]; + + for (int i = 0; i < points.Length; ++i) + points[i] = new Vector2(RNG.Next(512), RNG.Next(384)); + } + + [Benchmark] + public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs index 62a39d3702..44d1543ae4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs @@ -47,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit private OsuHitObject[]? objectsInRotation; - private Vector2? defaultOrigin; private Dictionary? originalPositions; private Dictionary? originalPathControlPointPositions; @@ -61,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit changeHandler?.BeginChange(); objectsInRotation = selectedMovableObjects.ToArray(); - defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre; + DefaultOrigin = GeometryUtils.MinimumEnclosingCircle(objectsInRotation).Item1; originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position); originalPathControlPointPositions = objectsInRotation.OfType().ToDictionary( obj => obj, @@ -73,9 +72,9 @@ namespace osu.Game.Rulesets.Osu.Edit if (!OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!"); - Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null); + Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && DefaultOrigin != null); - Vector2 actualOrigin = origin ?? defaultOrigin.Value; + Vector2 actualOrigin = origin ?? DefaultOrigin.Value; foreach (var ho in objectsInRotation) { @@ -103,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Edit objectsInRotation = null; originalPositions = null; originalPathControlPointPositions = null; - defaultOrigin = null; + DefaultOrigin = null; } private IEnumerable selectedMovableObjects => selectedItems.Cast() diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index ea16946dcb..fc85865dd2 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -84,10 +84,10 @@ namespace osu.Game.Rulesets.Osu.Edit OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider ? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) : GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); - defaultOrigin = OriginalSurroundingQuad.Value.Centre; originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2 ? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position)) : GeometryUtils.GetConvexHull(objectsInScale.Keys); + defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; } public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) diff --git a/osu.Game.Tests/Utils/GeometryUtilsTest.cs b/osu.Game.Tests/Utils/GeometryUtilsTest.cs index ded4656ac1..f73175bb5b 100644 --- a/osu.Game.Tests/Utils/GeometryUtilsTest.cs +++ b/osu.Game.Tests/Utils/GeometryUtilsTest.cs @@ -29,5 +29,23 @@ namespace osu.Game.Tests.Utils Assert.That(hull, Is.EquivalentTo(expectedPoints)); } + + [TestCase(new int[] { }, 0, 0, 0)] + [TestCase(new[] { 0, 0 }, 0, 0, 0)] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, 1, 0, 1)] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, 1, 0, 1)] + [TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, 3, 4.5f, 5.5901699f)] + public void TestMinimumEnclosingCircle(int[] values, float x, float y, float r) + { + var points = new Vector2[values.Length / 2]; + for (int i = 0; i < values.Length; i += 2) + points[i / 2] = new Vector2(values[i], values[i + 1]); + + (var centre, float radius) = GeometryUtils.MinimumEnclosingCircle(points); + + Assert.That(centre.X, Is.EqualTo(x).Within(0.0001)); + Assert.That(centre.Y, Is.EqualTo(y).Within(0.0001)); + Assert.That(radius, Is.EqualTo(r).Within(0.0001)); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index 30f397f518..2bf07d8e27 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -84,6 +84,7 @@ namespace osu.Game.Tests.Visual.Editing targetContainer = getTargetContainer(); initialRotation = targetContainer!.Rotation; + DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero)); base.Begin(); } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs index 36b38543d1..9fd28a1cad 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs @@ -46,7 +46,6 @@ namespace osu.Game.Overlays.SkinEditor private Drawable[]? objectsInRotation; - private Vector2? defaultOrigin; private Dictionary? originalRotations; private Dictionary? originalPositions; @@ -60,7 +59,7 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = selectedItems.Cast().ToArray(); originalRotations = objectsInRotation.ToDictionary(d => d, d => d.Rotation); originalPositions = objectsInRotation.ToDictionary(d => d, d => d.ToScreenSpace(d.OriginPosition)); - defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; + DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; base.Begin(); } @@ -70,7 +69,7 @@ namespace osu.Game.Overlays.SkinEditor if (objectsInRotation == null) throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!"); - Debug.Assert(originalRotations != null && originalPositions != null && defaultOrigin != null); + Debug.Assert(originalRotations != null && originalPositions != null && DefaultOrigin != null); if (objectsInRotation.Length == 1 && origin == null) { @@ -79,7 +78,7 @@ namespace osu.Game.Overlays.SkinEditor return; } - var actualOrigin = origin ?? defaultOrigin.Value; + var actualOrigin = origin ?? DefaultOrigin.Value; foreach (var drawableItem in objectsInRotation) { @@ -100,7 +99,7 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = null; originalPositions = null; originalRotations = null; - defaultOrigin = null; + DefaultOrigin = null; base.Commit(); } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs index 977aaade99..6915769212 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs @@ -67,7 +67,7 @@ namespace osu.Game.Overlays.SkinEditor objectsInScale = selectedItems.Cast().ToDictionary(d => d, d => new OriginalDrawableState(d)); OriginalSurroundingQuad = ToLocalSpace(GeometryUtils.GetSurroundingQuad(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray()))); - defaultOrigin = OriginalSurroundingQuad.Value.Centre; + defaultOrigin = ToLocalSpace(GeometryUtils.MinimumEnclosingCircle(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray())).Item1); isFlippedX = false; isFlippedY = false; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index c62e0e0d41..03d600bfa2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -77,6 +77,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.OnDrag(e); + if (rotationHandler == null || !rotationHandler.OperationInProgress.Value) return; + rawCumulativeRotation += convertDragEventToAngleOfRotation(e); applyRotation(shouldSnap: e.ShiftPressed); @@ -113,9 +115,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private float convertDragEventToAngleOfRotation(DragEvent e) { - // Adjust coordinate system to the center of SelectionBox - float startAngle = MathF.Atan2(e.LastMousePosition.Y - selectionBox.DrawHeight / 2, e.LastMousePosition.X - selectionBox.DrawWidth / 2); - float endAngle = MathF.Atan2(e.MousePosition.Y - selectionBox.DrawHeight / 2, e.MousePosition.X - selectionBox.DrawWidth / 2); + // Adjust coordinate system to the center of the selection + Vector2 center = selectionBox.ToLocalSpace(rotationHandler!.ToScreenSpace(rotationHandler!.DefaultOrigin!.Value)); + + float startAngle = MathF.Atan2(e.LastMousePosition.Y - center.Y, e.LastMousePosition.X - center.X); + float endAngle = MathF.Atan2(e.MousePosition.Y - center.Y, e.MousePosition.X - center.X); return (endAngle - startAngle) * 180 / MathF.PI; } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs index 532daaf7fa..af3b3d6489 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs @@ -13,7 +13,7 @@ namespace osu.Game.Screens.Edit.Compose.Components public partial class SelectionRotationHandler : Component { /// - /// Whether there is any ongoing scale operation right now. + /// Whether there is any ongoing rotation operation right now. /// public Bindable OperationInProgress { get; private set; } = new BindableBool(); @@ -27,6 +27,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public Bindable CanRotateAroundPlayfieldOrigin { get; private set; } = new BindableBool(); + /// + /// Implementation-defined origin point to rotate around when no explicit origin is provided. + /// This field is only assigned during a rotation operation. + /// + public Vector2? DefaultOrigin { get; protected set; } + /// /// Performs a single, instant, atomic rotation operation. /// diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index 8572ac6609..eac86a9c02 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -218,5 +219,158 @@ namespace osu.Game.Utils return new[] { h.Position }; }); + + #region Welzl helpers + + // Function to check whether a point lies inside or on the boundaries of the circle + private static bool isInside((Vector2 Centre, float Radius) c, Vector2 p) + { + return Precision.AlmostBigger(c.Radius, Vector2.Distance(c.Centre, p)); + } + + // Function to return a unique circle that intersects three points + private static (Vector2, float) circleFrom(Vector2 a, Vector2 b, Vector2 c) + { + if (Precision.AlmostEquals(0, (b.Y - a.Y) * (c.X - a.X) - (b.X - a.X) * (c.Y - a.Y))) + return circleFrom(a, b); + + // See: https://en.wikipedia.org/wiki/Circumcircle#Cartesian_coordinates + float d = 2 * (a.X * (b - c).Y + b.X * (c - a).Y + c.X * (a - b).Y); + float aSq = a.LengthSquared; + float bSq = b.LengthSquared; + float cSq = c.LengthSquared; + + var centre = new Vector2( + aSq * (b - c).Y + bSq * (c - a).Y + cSq * (a - b).Y, + aSq * (c - b).X + bSq * (a - c).X + cSq * (b - a).X) / d; + + return (centre, Vector2.Distance(a, centre)); + } + + // Function to return the smallest circle that intersects 2 points + private static (Vector2, float) circleFrom(Vector2 a, Vector2 b) + { + var centre = (a + b) / 2.0f; + return (centre, Vector2.Distance(a, b) / 2.0f); + } + + // Function to check whether a circle encloses the given points + private static bool isValidCircle((Vector2, float) c, List points) + { + // Iterating through all the points to check whether the points lie inside the circle or not + foreach (Vector2 p in points) + { + if (!isInside(c, p)) return false; + } + + return true; + } + + // Function to return the minimum enclosing circle for N <= 3 + private static (Vector2, float) minCircleTrivial(List points) + { + if (points.Count > 3) + throw new ArgumentException("Number of points must be at most 3", nameof(points)); + + switch (points.Count) + { + case 0: + return (new Vector2(0, 0), 0); + + case 1: + return (points[0], 0); + + case 2: + return circleFrom(points[0], points[1]); + } + + // To check if MEC can be determined by 2 points only + for (int i = 0; i < 3; i++) + { + for (int j = i + 1; j < 3; j++) + { + var c = circleFrom(points[i], points[j]); + + if (isValidCircle(c, points)) + return c; + } + } + + return circleFrom(points[0], points[1], points[2]); + } + + #endregion + + /// + /// Function to find the minimum enclosing circle for a collection of points. + /// + /// A tuple containing the circle centre and radius. + public static (Vector2, float) MinimumEnclosingCircle(IEnumerable points) + { + // Using Welzl's algorithm to find the minimum enclosing circle + // https://www.geeksforgeeks.org/minimum-enclosing-circle-using-welzls-algorithm/ + List p = points.ToList(); + + var stack = new Stack<(Vector2?, int)>(); + var r = new List(3); + (Vector2, float) d = (Vector2.Zero, 0); + + stack.Push((null, p.Count)); + + while (stack.Count > 0) + { + // `n` represents the number of points in P that are not yet processed. + // `point` represents the point that was randomly picked to process. + (Vector2? point, int n) = stack.Pop(); + + if (!point.HasValue) + { + // Base case when all points processed or |R| = 3 + if (n == 0 || r.Count == 3) + { + d = minCircleTrivial(r); + continue; + } + + // Pick a random point randomly + int idx = RNG.Next(n); + point = p[idx]; + + // Put the picked point at the end of P since it's more efficient than + // deleting from the middle of the list + (p[idx], p[n - 1]) = (p[n - 1], p[idx]); + + // Schedule processing of p after we get the MEC circle d from the set of points P - {p} + stack.Push((point, n)); + // Get the MEC circle d from the set of points P - {p} + stack.Push((null, n - 1)); + } + else + { + // If d contains p, return d + if (isInside(d, point.Value)) + continue; + + // Remove points from R that were added in a deeper recursion + // |R| = |P| - |stack| - n + int removeCount = r.Count - (p.Count - stack.Count - n); + r.RemoveRange(r.Count - removeCount, removeCount); + + // Otherwise, must be on the boundary of the MEC + r.Add(point.Value); + // Return the MEC for P - {p} and R U {p} + stack.Push((null, n - 1)); + } + } + + return d; + } + + /// + /// Function to find the minimum enclosing circle for a collection of hit objects. + /// + /// A tuple containing the circle centre and radius. + public static (Vector2, float) MinimumEnclosingCircle(IEnumerable hitObjects) => + MinimumEnclosingCircle(enumerateStartAndEndPositions(hitObjects)); } }