Skip to content

Commit

Permalink
Merge pull request #29938 from OliBomby/selection-center
Browse files Browse the repository at this point in the history
Use minimum enclosing circle as selection centre for scale and rotate
  • Loading branch information
bdach authored Sep 27, 2024
2 parents 766d2d2 + fa90b43 commit 3359328
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 16 deletions.
29 changes: 29 additions & 0 deletions osu.Game.Benchmarks/BenchmarkGeometryUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. 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);
}
}
9 changes: 4 additions & 5 deletions osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ private void updateState()

private OsuHitObject[]? objectsInRotation;

private Vector2? defaultOrigin;
private Dictionary<OsuHitObject, Vector2>? originalPositions;
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;

Expand All @@ -61,7 +60,7 @@ public override void Begin()
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<IHasPath>().ToDictionary(
obj => obj,
Expand All @@ -73,9 +72,9 @@ public override void Update(float rotation, Vector2? origin = null)
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)
{
Expand Down Expand Up @@ -103,7 +102,7 @@ public override void Commit()
objectsInRotation = null;
originalPositions = null;
originalPathControlPointPositions = null;
defaultOrigin = null;
DefaultOrigin = null;
}

private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
Expand Down
2 changes: 1 addition & 1 deletion osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ public override void Begin()
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)
Expand Down
18 changes: 18 additions & 0 deletions osu.Game.Tests/Utils/GeometryUtilsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,23 @@ public void TestConvexHull(int[] values, int[] expected)

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));
}
}
}
1 change: 1 addition & 0 deletions osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public override void Begin()

targetContainer = getTargetContainer();
initialRotation = targetContainer!.Rotation;
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));

base.Begin();
}
Expand Down
9 changes: 4 additions & 5 deletions osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ private void updateState()

private Drawable[]? objectsInRotation;

private Vector2? defaultOrigin;
private Dictionary<Drawable, float>? originalRotations;
private Dictionary<Drawable, Vector2>? originalPositions;

Expand All @@ -60,7 +59,7 @@ public override void Begin()
objectsInRotation = selectedItems.Cast<Drawable>().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();
}
Expand All @@ -70,7 +69,7 @@ public override void Update(float rotation, Vector2? origin = null)
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)
{
Expand All @@ -79,7 +78,7 @@ public override void Update(float rotation, Vector2? origin = null)
return;
}

var actualOrigin = origin ?? defaultOrigin.Value;
var actualOrigin = origin ?? DefaultOrigin.Value;

foreach (var drawableItem in objectsInRotation)
{
Expand All @@ -100,7 +99,7 @@ public override void Commit()
objectsInRotation = null;
originalPositions = null;
originalRotations = null;
defaultOrigin = null;
DefaultOrigin = null;

base.Commit();
}
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public override void Begin()

objectsInScale = selectedItems.Cast<Drawable>().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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ protected override void OnDrag(DragEvent e)
{
base.OnDrag(e);

if (rotationHandler == null || !rotationHandler.OperationInProgress.Value) return;

rawCumulativeRotation += convertDragEventToAngleOfRotation(e);

applyRotation(shouldSnap: e.ShiftPressed);
Expand Down Expand Up @@ -113,9 +115,11 @@ protected override void OnDragEnd(DragEndEvent e)

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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
public partial class SelectionRotationHandler : Component
{
/// <summary>
/// Whether there is any ongoing scale operation right now.
/// Whether there is any ongoing rotation operation right now.
/// </summary>
public Bindable<bool> OperationInProgress { get; private set; } = new BindableBool();

Expand All @@ -27,6 +27,12 @@ public partial class SelectionRotationHandler : Component
/// </summary>
public Bindable<bool> CanRotateAroundPlayfieldOrigin { get; private set; } = new BindableBool();

/// <summary>
/// Implementation-defined origin point to rotate around when no explicit origin is provided.
/// This field is only assigned during a rotation operation.
/// </summary>
public Vector2? DefaultOrigin { get; protected set; }

/// <summary>
/// Performs a single, instant, atomic rotation operation.
/// </summary>
Expand Down
154 changes: 154 additions & 0 deletions osu.Game/Utils/GeometryUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects.Types;
using osuTK;

Expand Down Expand Up @@ -218,5 +219,158 @@ private static IEnumerable<Vector2> enumerateStartAndEndPositions(IEnumerable<IH
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<Vector2> 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<Vector2> 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

/// <summary>
/// Function to find the minimum enclosing circle for a collection of points.
/// </summary>
/// <returns>A tuple containing the circle centre and radius.</returns>
public static (Vector2, float) MinimumEnclosingCircle(IEnumerable<Vector2> points)
{
// Using Welzl's algorithm to find the minimum enclosing circle
// https://www.geeksforgeeks.org/minimum-enclosing-circle-using-welzls-algorithm/
List<Vector2> p = points.ToList();

var stack = new Stack<(Vector2?, int)>();
var r = new List<Vector2>(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;
}

/// <summary>
/// Function to find the minimum enclosing circle for a collection of hit objects.
/// </summary>
/// <returns>A tuple containing the circle centre and radius.</returns>
public static (Vector2, float) MinimumEnclosingCircle(IEnumerable<IHasPosition> hitObjects) =>
MinimumEnclosingCircle(enumerateStartAndEndPositions(hitObjects));
}
}

0 comments on commit 3359328

Please sign in to comment.